feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
+456
-231
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,11 @@
|
||||
|
||||
[](https://pkg.go.dev/b612.me/astro)
|
||||
|
||||
自用多年的天文算法库,用于个人天文历法爱好、科普演示和轻量研究。
|
||||
自用多年的天文算法库,用于个人天文历法爱好。
|
||||
|
||||
>📚 本项目主要用于天文算法学习与验证,计算结果满足业余爱好级别需求。
|
||||
|
||||
基于《天文算法》(Astronomical Algorithms)一书实现,提供历法转换、太阳/月亮/行星位置、日月食、月掩、升落、中天、月相、恒星、坐标变换、物理星历、研究公式和通用小天体轨道传播等功能。太阳和行星部分使用内置 VSOP87 解析项,月球部分使用内置 ELP2000/82 解析级数,不依赖外部 JPL 星历文件。
|
||||
基于《天文算法》(Astronomical Algorithms)一书实现,覆盖范围见下方[功能概览](#功能概览)。太阳和行星部分使用内置 VSOP87 解析项,月球部分使用内置 ELP/MPP02(DE405 拟合)解析级数,不依赖外部 JPL 星历文件。
|
||||
|
||||
没有特殊标注时,本程序所提供的坐标均为瞬时天球坐标;角度单位默认是度,视直径/视半径单位是角秒,距离单位按函数名使用 AU 或 km。
|
||||
|
||||
@@ -42,7 +42,7 @@ go get b612.me/astro
|
||||
|
||||
## 功能概览
|
||||
|
||||
- 📅 **历法转换**:公历与农历互转(公元前721年-公元3000年或更久)、节气时刻
|
||||
- 📅 **历法转换**:公历与农历互转(公元前721年-公元3000年)、节气时刻
|
||||
- 🌞 **太阳计算**:天球位置、日出日落、日地距离、真太阳时、视高度角、视差角、日面物理参数(`P/B0/L0`)、视直径等
|
||||
- 🌙 **月亮计算**:天球位置、月出月落、地月距离、月相、朔望时间、视直径、亮边位置角、视差角、地心/站心天平动、近远地点、交点、最大赤纬等
|
||||
- 🪶 **轻量链路**:`lite/sun` 与 `lite/moon` 提供面向手表、前端、小程序和其它资源受限环境的轻量近似太阳/月亮算法,覆盖天球位置、升落和月相
|
||||
@@ -50,7 +50,7 @@ go get b612.me/astro
|
||||
- 🌘 **月掩**:按指定赤经赤纬搜索恒星月掩,按有限圆盘计算行星月掩,支持指定地点接触时刻、全球掩带、几何掩甚点和 SVG
|
||||
- 🗺️ **地理输出**:日食、月食和月掩结果可编码为带时间数据与可选时间标记的 GeoJSON;全球 SVG 使用无行政边界海岸线,并支持等经纬和南北极投影
|
||||
- 🪐 **行星计算**:七大行星天球位置、升落时间、合冲留、大距、水星/金星地心凌日等特殊天象时间、升交点/降交点、视直径/视半径、相位、视差角、节点、视星等与物理星历
|
||||
- ⭐ **恒星计算**:指定天球坐标所属星座;同时包含9100颗恒星数据库,可计算升降时间、视差角和视高度角,获取指定日期的恒星坐标信息
|
||||
- ⭐ **恒星计算**:指定天球坐标所属星座;同时内置 9100 颗恒星数据库,可计算升降时间、视差角和视高度角,获取指定日期的恒星坐标信息
|
||||
- 🧭 **坐标工具**:黄道/赤道/地平坐标转换、站心坐标、恒星时、岁差、章动、角距离、大气折射、大气质量、视差角、银道坐标
|
||||
- 🔭 **研究公式**:黑体辐射、会合周期、星等距离换算、望远镜极限星等、恒星半径/温度/光度换算、大气质量模型
|
||||
- ☄️ **通用轨道**:给定小行星、彗星或假想天体轨道根数,计算日心/地心位置和站心视位置,并提供距日/距地距离、日距角、相位角、照明比例、H-G 视星等和轻量视双星位置角/角距计算
|
||||
@@ -67,7 +67,7 @@ go get b612.me/astro
|
||||
| `lite/sun` / `lite/moon` | 轻量太阳/月亮近似链路,面向分钟级升落、轻量天球位置和月相计算 |
|
||||
| `eclipse` / `eclipse/svg` | 全局/局地日月食、日食中心线与偏食足迹、局地可见性筛选、局地示意图与全球见食图 SVG |
|
||||
| `moon/svg` | 指定地点恒星/行星月掩视圆图,以及带掩带、中心线和时间标记的全球投影 SVG |
|
||||
| `geojson` | 将日食、月食和月掩的既有地理结果编码为 RFC 7946 GeoJSON,投影与样式由应用负责 |
|
||||
| `geojson` | 将日食、月食和月掩的既有地理结果编码为 RFC 7946 GeoJSON |
|
||||
| `mercury` / `venus` | 水星、金星位置、升落、合日、留、大距、地心凌日、相位、视差角、视星等、视直径、节点和物理星历 |
|
||||
| `mars` / `jupiter` / `saturn` / `uranus` / `neptune` | 外行星位置、升落、合冲、留、方照、相位、视差角、视星等、视直径、节点和物理星历 |
|
||||
| `earth` | 地球轨道偏心率、近日点、远日点 |
|
||||
@@ -76,23 +76,18 @@ go get b612.me/astro
|
||||
| `orbit` | 通用日心二体圆锥曲线轨道传播,支持椭圆、近抛物、抛物和双曲轨道;另含相位/测光辅助和轻量视双星计算 |
|
||||
| `sundial` | 真/平太阳时换算、太阳时角、平太阳时/区时时角、平面日晷几何、时间线/赤纬曲线采样、赤道/水平/垂直日晷特例 |
|
||||
|
||||
很多接口额外提供 `...N` 截断版本:
|
||||
一些接口额外提供 `...N` 截断版本:
|
||||
|
||||
- `n < 0`:使用本仓库当前内置的全部解析项
|
||||
- `n >= 0`:截断解析项,适合性能对比、粗算或算法研究
|
||||
|
||||
这里的“全部解析项”指package中已经内置的表项,不等同于外部发行版 VSOP/ELP 长表的全部原始数据。
|
||||
|
||||
大气质量接口的补充:
|
||||
|
||||
- `coord.Airmass...` 面向观测场景,既可以直接传入视高度角,也可以从真高度角先做折射修正再计算
|
||||
- `formula.Airmass...` 只提供纯公式本身,不负责折射修正,适合已经在别处拿到视高度角或天顶距时直接调用
|
||||
|
||||
## 适用范围与精度
|
||||
|
||||
### 太阳与行星
|
||||
|
||||
太阳和行星使用内置 VSOP87 解析项,当前表项覆盖 **J2000 前后约 4000 年**。精度量级如下:
|
||||
太阳和行星使用内置 VSOP87 解析项,当前表项覆盖 **J2000 前后约 4000 年**。下表列出相对完整 VSOP87 的截断误差量级:
|
||||
|
||||
| 目标 | 黄经/黄纬 | 距离 |
|
||||
| --- | --- | --- |
|
||||
@@ -104,20 +99,20 @@ go get b612.me/astro
|
||||
| 天王星 | 约 `1"` | 约 `20 × 10^-6 AU` |
|
||||
| 海王星 | 约 `1"` | 约 `40 × 10^-6 AU` |
|
||||
|
||||
这类精度适合常规天文历法、观测辅助、科普展示和个人研究。如果需要航天导航、掩星预报或严格动力学积分,应使用 JPL DE 等专业星历。
|
||||
这类精度适合常规天文历法、观测辅助、科普展示和个人研究;航天导航、精确掩星预报和严格动力学积分不在该范围内,这类用途通常需要 JPL DE 等专业星历。
|
||||
|
||||
### 月球
|
||||
|
||||
月球使用内置的 ELP/MPP02 DE405 解析级数(截断版,保留主要周期项),库体积轻,不需要外部星历文件。它适合农历定朔、月相、升落、月食、业余月掩预报和常规位置计算;若需要极高精度月球测距、长期物理天平动或专业掩星,请以 JPL 星历或专门月球星历为准。
|
||||
月球使用内置的 ELP/MPP02 DE405 解析级数(截断版,保留主要周期项),库体积轻,不需要外部星历文件。它适合农历定朔、月相、升落、月食、业余月掩预报和常规位置计算;极高精度月球测距、长期物理天平动和专业掩星超出该范围,这类用途以 JPL 星历或专门月球星历为准。
|
||||
|
||||
### Lite 轻量链路
|
||||
|
||||
`lite/sun` 和 `lite/moon` 是独立于 `sun` / `moon` 的近似实现。不依赖 VSOP87 或 ELP2000/82,适合 CPU / 内存受限环境。
|
||||
`lite/sun` 和 `lite/moon` 是独立于 `sun` / `moon` 的近似实现。不依赖 VSOP87 或 ELP/MPP02,适合 CPU / 内存受限环境。
|
||||
|
||||
- `lite/sun`:简化太阳真黄经 / 视黄经公式 + 轻量赤道坐标转换
|
||||
- `lite/moon`:Schlyter 风格月球近似(约 15 个摄动项)+ 轻量站心修正
|
||||
- 升落搜索:固定步长扫描 + 二分,不走主链的高精度章动迭代
|
||||
- 计算链路零堆分配(0 allocs/op),月球位置约 1µs,比主链快 20–60 倍
|
||||
- 计算链路零堆分配(0 allocs/op);相对主链,位置与月相等纯求值接口约快 `8.3–27.3x`,升落接口约 `1.0–3.7x`
|
||||
|
||||
能力边界:
|
||||
|
||||
@@ -126,62 +121,63 @@ go get b612.me/astro
|
||||
| `lite/sun` | 简化太阳真/视黄经 + 轻量赤道坐标转换 | `30` 分钟步长扫描 + 二分 | 日出日落、太阳高度角、表盘/前端周期刷新 |
|
||||
| `lite/moon` | Schlyter / vFPS 月球近似 + 轻量站心修正 | `15` 分钟步长扫描 + 二分 | 月出月落、月相、月龄、轻量月球观测辅助 |
|
||||
|
||||
与主链 `sun` / `moon` 的误差(2026 全年,8 个站点;升落每 7 或 15 天取样,月相月龄每 6 小时):
|
||||
与 `sun` / `moon` package的误差(2026 全年,8 个站点;升落每 7 或 15 天取样,月相月龄每 6 小时):
|
||||
|
||||
| 能力 | 平均绝对误差 | P95 | 最大绝对误差 | 备注 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `lite/sun` 日出 | `0.02 min` | `0.04 min` | `0.31 min` | 样本中无事件存在性分歧 |
|
||||
| `lite/sun` 日落 | `0.02 min` | `0.06 min` | `0.35 min` | `2` 个高纬样本在跨午夜“归属哪一天”上有语义差异 |
|
||||
| `lite/moon` 月出 | `0.28 min` | `0.57 min` | `1.44 min` | 样本中无事件存在性分歧 |
|
||||
| `lite/moon` 月落 | `0.36 min` | `0.86 min` | `1.24 min` | `1` 个高纬样本在“当天是否有月落”上与主链判断不同 |
|
||||
| `lite/moon` `Phase()` | `0.00089` | `0.00185` | `0.00243` | 和 `moon.Phase` 对比 |
|
||||
| 能力 | 平均绝对误差 | P95 | 最大绝对误差 | 备注 |
|
||||
| --- | --- | --- | --- |-----------------------------------|
|
||||
| `lite/sun` 日出 | `0.02 min` | `0.04 min` | `0.31 min` | 样本中无事件存在性分歧 |
|
||||
| `lite/sun` 日落 | `0.02 min` | `0.06 min` | `0.35 min` | `2` 个高纬样本在跨午夜日期归属上有语义差异 |
|
||||
| `lite/moon` 月出 | `0.28 min` | `0.57 min` | `1.44 min` | 样本中无事件存在性分歧 |
|
||||
| `lite/moon` 月落 | `0.36 min` | `0.86 min` | `1.24 min` | `1` 个高纬样本在“当天是否有月落”上与主链判断不同 |
|
||||
| `lite/moon` `Phase()` | `0.00089` | `0.00185` | `0.00243` | 与 `moon.Phase` 对比的结果 |
|
||||
| `lite/moon` `PhaseAge()` | `0.003 d` | `0.010 d` | `0.014 d` | 约平均 4.3 分钟、P95 14.4 分钟、最大 20.2 分钟 |
|
||||
| `lite/moon` 地心黄经 | `2.41'` | `6.82'` | `9.91'` | 相对主链月球位置 |
|
||||
| `lite/moon` 地心黄纬 | `0.87'` | `1.83'` | `2.92'` | 相对主链月球位置 |
|
||||
| `lite/moon` 地心黄经 | `2.41'` | `6.82'` | `9.91'` | 相对主链月球位置 |
|
||||
| `lite/moon` 地心黄纬 | `0.87'` | `1.83'` | `2.92'` | 相对主链月球位置 |
|
||||
|
||||
本地 `Go testing.Benchmark` 参考值(绝对值因机器而异,相对趋势稳定):
|
||||
`Go testing.Benchmark` 参考值(单机实测,仅供参考;绝对值因机器而异):
|
||||
|
||||
口径为 2026-01-01 20:00 CST、上海(`121.4737°E, 31.2304°N`)、`height=0`、`aero=true`,表中取 3 次中位数;每项先预热一次,懒加载缓存与首次分配不计入稳态单次开销。
|
||||
|
||||
| 接口 | 主链 | `lite` | 加速倍数 | 主链分配 | `lite` 分配 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `Sun ApparentRaDec` | `13.392 µs/op` | `231.0 ns/op` | `57.97x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` |
|
||||
| `Sun Altitude` | `16.405 µs/op` | `681.5 ns/op` | `24.09x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` |
|
||||
| `Sun RiseTime` | `202.994 µs/op` | `18.823 µs/op` | `10.78x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` |
|
||||
| `Moon ApparentRaDec` | `65.273 µs/op` | `1.035 µs/op` | `63.06x` | `297202 B/op, 70 allocs/op` | `0 B/op, 0 allocs/op` |
|
||||
| `Moon Phase` | `40.264 µs/op` | `940.6 ns/op` | `42.83x` | `178321 B/op, 42 allocs/op` | `0 B/op, 0 allocs/op` |
|
||||
| `Moon Altitude` | `44.883 µs/op` | `2.275 µs/op` | `19.73x` | `178321 B/op, 42 allocs/op` | `0 B/op, 0 allocs/op` |
|
||||
| `Moon RiseTime` | `659.886 µs/op` | `77.600 µs/op` | `8.50x` | `2377613 B/op, 560 allocs/op` | `0 B/op, 0 allocs/op` |
|
||||
| `Sun ApparentRaDec` | `5.888 µs/op` | `215.6 ns/op` | `27.3x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` |
|
||||
| `Sun Altitude` | `5.955 µs/op` | `625.9 ns/op` | `9.5x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` |
|
||||
| `Sun RiseTime` | `95.847 µs/op` | `25.648 µs/op` | `3.7x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` |
|
||||
| `Moon ApparentRaDec` | `16.520 µs/op` | `1.006 µs/op` | `16.4x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` |
|
||||
| `Moon Phase` | `15.139 µs/op` | `917.7 ns/op` | `16.5x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` |
|
||||
| `Moon Altitude` | `9.533 µs/op` | `1.150 µs/op` | `8.3x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` |
|
||||
| `Moon RiseTime` | `120.545 µs/op` | `118.037 µs/op` | `1.0x` | `0 B/op, 0 allocs/op` | `0 B/op, 0 allocs/op` |
|
||||
|
||||
需要日月食、物理天平动或高纬边界判定时,仍用主链 `sun` / `moon`。
|
||||
主链与 `lite` 的差距随场景变化:位置、月相等纯求值接口约 `8.3–27.3x`;升落接口两边都要做时间搜索,差距缩小到 `1.0–3.7x`(`Moon RiseTime` 已接近持平)。加速倍数来自同一台机器上的对照,受机器影响小于绝对值。
|
||||
|
||||
日月食、物理天平动或高纬边界判定使用主链 `sun` / `moon`。
|
||||
|
||||
### 精度校验参考
|
||||
|
||||
下面这些函数曾与 JPL Horizons、NASA GSFC 等资料对照,可作为使用时判断结果量级的参考:
|
||||
|
||||
- 太阳/行星/月亮视直径:与外部基线最大差异从 `0.000002"` 到 `0.194598"` 不等,月亮因视差和距离变化更敏感
|
||||
- 太阳/行星/月亮视直径:各天体与外部基线的最大差异从 `0.000002"` 到 `0.194598"` 不等,月亮因视差和距离变化更敏感
|
||||
- 太阳物理星历 `P/B0/L0`:最大差异约 `0.003349° / 0.003986° / 0.047394°`
|
||||
- 行星升/中天/落:已用 JPL Horizons 电视事件(TVH, Time-Varying Hourly)做对比校验;该基线按 1 分钟步长生成,当前结果与 Horizons 事件时间在分钟级上对齐
|
||||
- 月出/月落:`aero=true` 按动态标准折射和实时月球视半径计算上缘过地平线。7 个地点、14 个海平面事件相对 JPL Horizons DE441 的平均/最大差异约 `0.30s / 0.75s`
|
||||
- 月出/月落的其他口径:相对固定 `-0.8333°` 的 MET Norway(Skyfield 1.53 + DE440s)约 `38.77s / 76.22s`;相对未公开地平线口径的 IMCCE Miriade 平均约 `2m13.46s`,`61°N` 低仰角样本最大约 `6m41.82s`
|
||||
- 月掩恒星全球路径:`2025-06-05` 月掩进贤增九(HR 4799)样例相对项目记录的参考值,掩始/掩甚/掩终差异约 `+2.30s / -1.37s / -3.12s`;掩甚经纬度差异约 `+0.0122° / +0.0105°`,掩带宽为 `3582.4 km`,与参考 `3571.9 km` 相差约 `10.5 km`
|
||||
- 地球近日点/远日点:时刻最大差异约 `1m28.84s`,距离最大差异约 `0.000000039837 AU`
|
||||
- 月球主链位置:当前算法为 ELP/MPP02 DE405 解析级数截断版;在 `-2000` 年四个 JPL/Horizons `JDTT` 样本上,相对 JPL/Horizons 的最大差异约为黄经 `219.6"`、黄纬 `25.8"`、距离 `34.3 km`
|
||||
- 月球近地点/远地点:时刻最大差异约 `15m53.45s`,距离最大差异约 `39.758 km`
|
||||
- 月球最大赤纬:时刻最大差异约 `2.43s`,赤纬最大差异约 `0.00006431°`
|
||||
|
||||
站心时角修复后的影响边界:月出/月落、指定地点月掩、月球站心坐标,以及地方月食结果中的食甚月高和可见性判断使用修复后的 UT 时角链。日食和月食的全球接触时刻、食甚、食分等主体结果来自各自的地心/贝塞尔几何,不经过该站心转换,因此现有 NASA 精度数据无需随之改写。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 历法转换与节气
|
||||
|
||||
本 package 支持公历与中国传统农历日期之间的相互转换,并提供节气信息。支持年份范围为公元前721年至公元3000年(部分现代算法可更久)。
|
||||
本 package 支持公历与中国传统农历日期之间的相互转换,并提供节气信息。支持年份范围为公元前721年至公元3000年(公元前104年为历法表切换点)。
|
||||
农历本质上是阴阳合历(Lunisolar Calendar),但为兼顾大众习惯与代码简洁性,相关函数命名采用 `Lunar` 而非更学术的 `Lunisolar`。
|
||||
|
||||
#### 历法说明
|
||||
|
||||
- **默认路由**:按年份自动选择,先秦段使用春秋/古六历重建,`-220..-104` 使用秦汉颛顼历,`-103..1912` 使用历表,`1913` 年后使用现代算法。
|
||||
- **显式古历**:如果需要指定某一古历系统,请使用 `SolarToLunarWithCalendar` / `LunarToSolarWithCalendar` 这类 API。
|
||||
- **数据来源**:古历部分主要参考《寿星天文历》;使用 [ytliu0教授的网站数据](https://ytliu0.github.io/ChineseCalendar/index_simp.html)做验证;现代段依据GB/T 33661-2017编排,通过 VSOP87、ELP定气定朔 。
|
||||
- **数据来源**:古历部分主要参考《寿星天文历》;使用 [ytliu0教授的网站数据](https://ytliu0.github.io/ChineseCalendar/index_simp.html)做验证校验;现代段依据GB/T 33661-2017编排,通过 VSOP87、ELP定气定朔 。
|
||||
- **节气**:`JieQi` 返回现代天文计算的节气时刻;`CalendricalJieQi` 返回历法相符节气日期。
|
||||
|
||||
---
|
||||
@@ -207,7 +203,7 @@ go get b612.me/astro
|
||||
|
||||
本 package 主要面向中国历法,因此定气和定朔的计算默认采用北京时间(UTC+8)。对于使用其他时区的地区,若直接套用中国农历的编排规则,可能会产生日期偏差。
|
||||
|
||||
为方便探索与研究,本 package 提供了底层方法 `Solar` 和 `Lunar`,它们支持在**自定义时区**下,按照**现行中国农历算法(GB/T 33661-2017)**进行公历与农历的相互转换。
|
||||
为方便探索与研究,本 package 提供了底层方法 `Solar` 和 `Lunar`,它们支持在**自定义时区**下,按照**现行中国农历算法(GB/T 33661-2017)** 进行公历与农历的相互转换。
|
||||
如果只需北京时间下的标准转换,请直接使用封装好的 `SolarToLunar` 和 `LunarToSolar` 方法。
|
||||
|
||||
**示例**:农历规则要求冬至必须落在农历十一月。以1984年冬至为例,计算可得:
|
||||
@@ -232,7 +228,7 @@ fmt.Println(calendar.Solar(1985, 1, 1, false, 7.0))
|
||||
|
||||
##### 5. Go 语言特别注意
|
||||
|
||||
⚠️ **Go 标准库 `time.Time` 在历法处理上与本程序存在差异:**
|
||||
⚠️ Go 标准库 `time.Time` 在历法处理上与本程序存在差异:
|
||||
|
||||
- Go 语言在1582年10月15日之前使用逆推格里高利历,而非儒略历。若不使用 `Add` 方法,一般可正常使用。
|
||||
- 因此,**在1582年10月15日之前,`time.Time.Weekday()` 返回结果与本程序计算结果不一致**。
|
||||
@@ -246,9 +242,44 @@ fmt.Println(calendar.Solar(1985, 1, 1, false, 7.0))
|
||||
weekday := int(calendar.Date2JDE(date)+1.5) % 7
|
||||
// 0表示星期日,1表示星期一,……,6表示星期六
|
||||
```
|
||||
若在1582年之前使用 time.Time 的 Add 或 AddDate 方法,请注意其在某些年份可能不准确。
|
||||
在 1582 年之前使用 `time.Time` 的 `Add` 或 `AddDate` 会经过逆推格里高利历,跨过儒略历独有的闰日时与儒略历相差一天。
|
||||
例如:700年儒略历为闰年,而 Go 使用的逆推格里高利历中700年不是闰年。
|
||||
|
||||
##### 6. 儒略历独有的闰日(如 700-02-29)
|
||||
|
||||
1582 年以前"能被 100 整除但不能被 400 整除"的年份(如 100、700、1500 年)在儒略历中有 2 月 29 日,
|
||||
而 Go 的`time.Time`使用逆推格里高利历,没有这一天(`time.Date(700, 2, 29, ...)` 会被规范化成 700-03-01)。本库承认 700-02-29 这一天存在,对应的约束如下:
|
||||
|
||||
- `Time.JulianOnly()`:该农历日是否只存在于儒略历(对应 JSON 字段 `julianOnly`);
|
||||
- `Time.JDE()`:该日精确的儒略日;儒略历闰日比 `Solar()` 早一天,其余情况两者一致(对应 JSON 字段 `jde`)。
|
||||
- `Time.Solar()` / `LunarTime.SolarDate`:库内标准输出,对于700-02-29Go标准库表示不出来的日期,固定返回为**后一天**(700-02-29 的后一天是 700-03-01),与 `basic.JDE2DateByZone` 的约定一致;
|
||||
|
||||
```go
|
||||
julian, _ := calendar.SolarToLunarByYMD(700, 2, 29)
|
||||
fmt.Println(julian.Solar().Format("2006-01-02"), julian.JulianOnly(), julian.JDE(), julian.Lunar().MonthDay())
|
||||
// 0700-03-01 true 1.9767915e+06 二月初五
|
||||
```
|
||||
|
||||
> 涉及这类日期时,不丢闰日的入口有两类:整型年月日入口 `SolarToLunarByYMD` / `LunarToSolarByYMD`,以及直接调用
|
||||
> `basic.JDECalc(700, 2, 29)` 得到精确儒略日 `1976791.5`;先构造 `time.Time` 的那一步就会丢掉闰日。
|
||||
|
||||
##### 7. 同一农历日的多个公历候选
|
||||
|
||||
改历双纪年(王莽 9–23 年、魏明帝 237–240 年、武则天 689–700 年、唐肃宗 761–762 年)与太初改历交接
|
||||
(公元前 104 年)会让同一个农历日对应两个合法公历日。`Solar()` 仍是库内默认选择,`SolarCandidates()`
|
||||
返回全部候选、首个恒等于 `Solar()`:
|
||||
|
||||
```go
|
||||
res, _ := calendar.LunarToSolarByYMD(700, 11, 1, false)
|
||||
fmt.Println(res.Solar().Format("2006-01-02"))
|
||||
fmt.Println(len(res.SolarCandidates()))
|
||||
// 0700-12-15
|
||||
// 2
|
||||
```
|
||||
|
||||
非改历年份的农历日只有一个候选,`SolarCandidates()` 返回仅含 `Solar()` 的slice;儒略历独有的闰日也只返回
|
||||
标准输出,因为它唯一合法的那一天无法表示成 `time.Time`,精确日期见 `JDE()`。
|
||||
|
||||
#### 历法转换
|
||||
|
||||
##### 公历转农历
|
||||
@@ -272,14 +303,13 @@ weekday := int(calendar.Date2JDE(date)+1.5) % 7
|
||||
3. `年份+月+日`:如 **`"二零二五年正月初一"`**(闰月前加"闰",适用于现代日期)
|
||||
4. `年份+月+干支日`:如 **`"二零二五年正月戊戌日"`**
|
||||
5. `阿拉伯数字+月+日`:可以将中文数字替换为阿拉伯数字,如 **`"2025年1月1日"`**,代表`二零二五年正月初一`
|
||||
6. **注意历史场景**:历史上月份名称可能与现代不同(如武则天时期“正月”与“一月”代表不同月份),请使用汉字数字确保准确性
|
||||
6. 历史场景:历史上月份名称可能与现代不同(如武则天时期“正月”与“一月”代表不同月份),这类场景下月份名称按汉字数字解释
|
||||
|
||||
> ⚠️ **特别提醒**:
|
||||
> 农历年份与公历年份并非完全重合。例如:公历2025年1月28日(除夕)对应农历2024年腊月二十九,应传入 `"二零二四年腊月廿九"`。
|
||||
> ⚠️ 农历年份与公历年份并非完全重合。例如:公历2025年1月28日(除夕)对应农历2024年腊月二十九,对应的字符串是 `"二零二四年腊月廿九"`。
|
||||
|
||||
###### 方式二:传入数字参数
|
||||
- **参数**:年份 (`int`)、月份 (`int`)、日期 (`int`)、是否闰月 (`bool`)
|
||||
- **特点**:简单直接,适用于现代农历日期转换
|
||||
- **语义**:按农历年、月、日与闰月标志定位日期,适用于现代农历日期转换
|
||||
|
||||
##### 代码示例
|
||||
|
||||
@@ -336,6 +366,9 @@ func main() {
|
||||
"ganzhiYear": "己未",
|
||||
"ganzhiMonth": "丙子",
|
||||
"ganzhiDay": "辛未",
|
||||
"calendarSystem": "",
|
||||
"calendarName": "",
|
||||
"jde": 1808717.8389814815,
|
||||
"dynasty": "魏",
|
||||
"emperor": "魏明帝",
|
||||
"nianhao": "景初",
|
||||
@@ -355,7 +388,10 @@ func main() {
|
||||
"ganzhiYear": "己未",
|
||||
"ganzhiMonth": "丙子",
|
||||
"ganzhiDay": "辛未",
|
||||
"dynasty": "",
|
||||
"calendarSystem": "",
|
||||
"calendarName": "",
|
||||
"jde": 1808717.8389814815,
|
||||
"dynasty": "蜀",
|
||||
"emperor": "蜀后主",
|
||||
"nianhao": "延熙",
|
||||
"yearOfNianhao": 2,
|
||||
@@ -374,6 +410,9 @@ func main() {
|
||||
"ganzhiYear": "己未",
|
||||
"ganzhiMonth": "丙子",
|
||||
"ganzhiDay": "辛未",
|
||||
"calendarSystem": "",
|
||||
"calendarName": "",
|
||||
"jde": 1808717.8389814815,
|
||||
"dynasty": "吴",
|
||||
"emperor": "吴大帝",
|
||||
"nianhao": "赤乌",
|
||||
@@ -450,17 +489,15 @@ fmt.Printf("%d-%02d-%02d %v\n", date.Year(), int(date.Month()), date.Day(), err)
|
||||
|
||||
- `Altitude`:高度角,地平线为 `0°`,天顶为 `+90°`
|
||||
- `Zenith`:天顶距,天顶为 `0°`,地平线为 `90°`
|
||||
- **注意:旧版本中 `Zenith` 曾错误返回高度角;当前版本已修正,升级时请重点检查调用方**
|
||||
- `Zenith` 与 `Altitude` 互补,两者相加为 `90°`
|
||||
|
||||
#### 日出日落/月出月落
|
||||
|
||||
> ⚠️ **重要说明**:
|
||||
> 月球升降时间计算基于当天日期,升降时间点之间不一定具有连续性。
|
||||
> ⚠️ 月球升降时间按当天日期计算,升降时间点之间不一定具有连续性。
|
||||
>
|
||||
> **可能出现的情况**:
|
||||
> - 月亮可能在凌晨1点落下,中午12点再次升起,此时升起时间会晚于降落时间;要获取此场景晚上的月落时间,需要传入次日日期进行计算
|
||||
> 例如月亮可能在凌晨1点落下、中午12点再次升起,此时升起时间会晚于降落时间;这一场景晚上的月落时间对应次日日期。
|
||||
>
|
||||
> **如需获取完整升降周期,需要自行通过判断升起时间是否在降落时间之后来确定后续的正确时间点**
|
||||
> 完整的升降周期由升起时间与降落时间的先后关系决定:判断升起时间是否在降落时间之后,即可确定后续的正确时间点。
|
||||
|
||||
```go
|
||||
package main
|
||||
@@ -509,8 +546,8 @@ func main() {
|
||||
2020-01-01 17:45:09.188657999 +0800 CST <nil>
|
||||
2020-01-01 18:12:33.624035418 +0800 CST <nil>
|
||||
2020-01-01 11:52:49.860912859 +0800 CST <nil>
|
||||
2020-01-01 17:38:02.510787248 +0800 CST
|
||||
2020-01-01 23:26:49.313593804 +0800 CST <nil>
|
||||
2020-01-01 17:36:48.811488747 +0800 CST
|
||||
2020-01-01 23:26:49.313553571 +0800 CST <nil>
|
||||
|
||||
|
||||
```
|
||||
@@ -571,9 +608,9 @@ func main() {
|
||||
人马座
|
||||
方位角: 120.19477090015224 高度角: 2.4014437419430097 天顶距: 87.59855625805699
|
||||
0.983292937163176
|
||||
赤经: 23h17m53.15s 赤纬: -10°19′18.57″
|
||||
赤经: 23h18m56.24s 赤纬: -10°20′54.42″
|
||||
宝瓶座
|
||||
方位角: 67.84050700509859 高度角: -45.13425530765482 天顶距: 135.13425530765483
|
||||
方位角: 67.63889332004852 高度角: -45.34916937173283 天顶距: 135.34916937173284
|
||||
404238.6096080479
|
||||
```
|
||||
|
||||
@@ -649,8 +686,8 @@ north=2026-01-02T08:10:49Z dec=28.266373
|
||||
south=2026-01-16T05:15:14Z dec=-28.304184
|
||||
libration lon=-1.278902 lat=-6.531444 pa=-9.967050
|
||||
bright limb=267.364849
|
||||
topo libration lon=-2.010562 lat=-5.912181 pa=-10.184664
|
||||
topo bright limb=266.045494
|
||||
topo libration lon=-1.736754 lat=-5.780730 pa=-10.072846
|
||||
topo bright limb=266.038258
|
||||
```
|
||||
|
||||
如果只关心某一时刻地球轨道偏心率,也可以直接调用:
|
||||
@@ -675,7 +712,7 @@ fmt.Println(moon.AscendingNode(nodeDate), moon.DescendingNode(nodeDate))
|
||||
以上面 `nodeDate := 2026-01-01 00:00:00 UTC` 的示例来说,输出结果是:
|
||||
|
||||
```text
|
||||
340.9570862454423 160.95708624544227
|
||||
340.95708624505863 160.9570862450587
|
||||
```
|
||||
|
||||
#### 月相
|
||||
@@ -711,7 +748,7 @@ func main() {
|
||||
输出结果:
|
||||
|
||||
```
|
||||
0.300041309608744 // 月面约有 30% 被太阳照亮
|
||||
0.30004130960877884 // 月面约有 30% 被太阳照亮
|
||||
上峨眉月 // 当前月相描述
|
||||
2020-01-25 05:41:58.271192908 +0800 CST // 下一次朔月
|
||||
2020-01-03 12:45:23.229190707 +0800 CST // 下一次上弦
|
||||
@@ -778,21 +815,28 @@ func main() {
|
||||
|
||||
`SolarEclipsePartialFootprintsInfo` 还给出影锥与地球的全球接触:`P1/P4` 是半影外切,`P2/P3` 是半影内切;`U1/U4` 是本影或反本影外切,`U2/U3` 是内切。某次日食不存在的接触保持 `time.Time` 零值。`CentralBeginOnEarth` / `CentralEndOnEarth` 仍表示影轴进入和离开地球,不等同于 `U1/U4`。
|
||||
|
||||
需要结构化的瞬时中心影轮廓时,可在 `SolarEclipsePartialFootprintOptions` 中设置 `CentralShadowStep`;结果写入 `CentralShadowFootprints`。零值关闭该额外计算,SVG 入口则默认按 10 分钟采样。
|
||||
需要结构化的瞬时中心影轮廓时,可在 `SolarEclipsePartialFootprintOptions` 中设置 `CentralShadowStep`;结果写入 `CentralShadowFootprints`。零值关闭该额外计算;SVG 入口同样只在正值时采样(小于一分钟按一分钟),零值或负值都不画。
|
||||
|
||||
需要在数据层直接取等时线时,可在同一个 `SolarEclipsePartialFootprintOptions` 中设置 `GreatestTimeValues` 或 `GreatestTimeStep`。`GreatestTimeValues []time.Time` 是**食甚时刻取值**,按绝对时刻使用(其 `Location` 不参与换算),最多保留 64 条:重复的时刻取值与偏食可见窗口之外的时刻取值会被跳过,其余按时间先后排序,超出时保留最早的 64 条;没有可用支路的时刻取值不会出现在结果里。它为空时改用 `GreatestTimeStep` 按间隔生成,间隔只在为正值时生效,且对齐到 UTC 整刻度;要按展示时区对齐,请自行生成时刻后传给 `GreatestTimeValues`。
|
||||
|
||||
结果写入 `SolarEclipsePartialFootprintsInfo.GreatestTimeContours`:`JDE` 是对应的力学时儒略日,`Time` 是该时刻取值在输入时区下的时刻(显式传入的时刻取值原样回显,按步长生成时由 `JDE` 换算并抹到毫秒,避免往返把整分截断成前一分钟),`Segments` 是该时刻的等时线支路。等时线只出现在日月盘面确有重叠且太阳在几何地平以上(不含蒙气差与半径修正)的地方,两端止于地平线或偏食可见域边界;纬度 ±88° 以上不再延拓,同一时刻可能有多条互不相连的支路。不请求时既有输出完全不变。
|
||||
|
||||
日食结果 `SolarEclipseInfo`、`LocalSolarEclipseInfo`,以及 `SolarEclipsePath` / `SolarEclipsePartialFootprintsInfo` 里的 `Eclipse` 字段还会附带沙罗序列信息:
|
||||
|
||||
- `HasSaros`:是否成功匹配到沙罗序列
|
||||
- `Saros.Series`:NASA 沙罗系列号
|
||||
- `Saros.Series`:`Verified=true` 时为 NASA 沙罗系列号,否则为推算的暂定系列号
|
||||
- `Saros.Member`:这次日食在该系列中的第几个成员,从 `1` 开始
|
||||
- `Saros.Count`:该沙罗系列的总成员数
|
||||
- `Saros.Verified`:是否已与内置权威目录锚点核验;扩展表或范围外推算结果为 `false`
|
||||
|
||||
说明:
|
||||
|
||||
- 沙罗周期约为 `6585.321` 天,也就是 `223` 个朔望月,常写作约 `18 年 11 天 8 小时`。经过一个沙罗周期后,太阳、地球、月球的相对几何关系接近重复,因此会出现性质相近的一次日食。
|
||||
- 沙罗周期约为 `6585.321` 天,也就是 `223` 个朔望月、约 `18 年 11 天 8 小时`;系列成员按此周期排列。
|
||||
- 沙罗系列是一组按沙罗周期连续排列的日食事件;`Series` 标识该组,`Member` / `Count` 表示当前事件在该组中的序号和总数。
|
||||
- 沙罗序列属于整场日食事件,不随观测地点改变,所以全局日食、站心日食、中心路径和偏食足迹中的对应值应当一致。
|
||||
- 例如 `2024-04-08` 北美日全食属于 `Solar Saros 139` 的第 `30/71` 个成员。
|
||||
- 内置 NASA 锚点优先使用正式编号;天文年份 `-3000` 至 `+6000` 年内(含首尾年,`0` 年为公元前 1 年)未被锚点覆盖的事件使用预计算扩展表,范围外才实时演算外推。预计算与实时推算结果的 `Verified` 都是 `false`,不应视为实际已发布编号。
|
||||
- 扩展编号沿用 NASA 的 [Saros/Inex 编号关系](https://eclipse.gsfc.nasa.gov/SEsaros/SEperiodicity.html),成员按 Split-K 模型计算,计数覆盖完整系列,不在预计算年份边界截断。`3288-11-15` 的推算结果为系列 `202`、第 `1/71` 个成员。
|
||||
- 例如 `2024-04-08` 北美日全食属于 `日食沙罗序列139` 的第 `30/71` 个成员。
|
||||
|
||||
##### 与 NASA 资料的时间对照
|
||||
|
||||
@@ -813,11 +857,11 @@ func main() {
|
||||
|
||||
- 全局日食资料通常给到秒,适合直接做秒级对照。
|
||||
- 很多站心日食页面的初亏、复圆和本地食甚只公开到整分钟,因此这类资料只按分钟级核对,公开资料舍入造成的残差不按秒级误差解读。
|
||||
- 下面的 2009 洋山和 2012 厦门示例主要展示接口调用和 SVG 输出;如果要把某个具体观测点的接触时刻用于正式发布,建议再拿该点的 NASA/IMCCE local circumstances 做逐项核对。
|
||||
- 下面的 2009 洋山和 2012 厦门示例只展示接口调用与 SVG 输出,未承诺地方接触时刻的发布级精度;需要逐项核对时可与 NASA/IMCCE local circumstances 比对。
|
||||
|
||||
##### 2009 年长江大日食:长江口洋山附近
|
||||
|
||||
2009-07-22 是国内常说的“长江大日食”。下面示例选用上海东南方长江口洋山附近的观测点,接近中心线,食甚时日月中心非常接近,全食持续约 5 分 57 秒。
|
||||
2009-07-22 “长江大日食”。下面示例选用上海东南方长江口洋山附近的观测点,接近中心线,全食持续约 5 分 57 秒。
|
||||
|
||||
```go
|
||||
package main
|
||||
@@ -863,15 +907,15 @@ func main() {
|
||||
|
||||
```text
|
||||
true total // 洋山站点当天命中日食,食型为日全食
|
||||
true {136 37 71} // Solar Saros 136,第 37/71 个成员
|
||||
2009-07-22 08:23:54.85276848 +0800 CST // 初亏
|
||||
2009-07-22 09:37:22.978325486 +0800 CST // 全食开始
|
||||
2009-07-22 09:40:20.771768689 +0800 CST // 食甚
|
||||
2009-07-22 09:43:19.611152708 +0800 CST // 全食结束
|
||||
2009-07-22 11:03:13.974365293 +0800 CST // 复圆
|
||||
5m56.632827222s // 全食持续时间
|
||||
true {136 37 71 true} // Solar Saros 136,第 37/71 个成员,已核验
|
||||
2009-07-22 08:23:54.852366149 +0800 CST // 初亏
|
||||
2009-07-22 09:37:22.978486418 +0800 CST // 全食开始
|
||||
2009-07-22 09:40:20.771366357 +0800 CST // 食甚
|
||||
2009-07-22 09:43:19.610750377 +0800 CST // 全食结束
|
||||
2009-07-22 11:03:13.974526226 +0800 CST // 复圆
|
||||
5m56.632263959s // 全食持续时间
|
||||
magnitude=1.076997 obscuration=1.000000 altitude=57.292 // 食分、遮掩比例、食甚太阳高度
|
||||
greatest lon=144.1177 lat=24.2193 width=258.3km center=268 // 全局食甚点经纬度、食带宽度、中心线采样点数
|
||||
greatest lon=144.1177 lat=24.2193 width=258.3km center=289 // 全局食甚点经纬度、食带宽度、中心线采样点数
|
||||
```
|
||||
|
||||
##### 2012 年日环食:厦门示例
|
||||
@@ -909,13 +953,13 @@ func main() {
|
||||
|
||||
```text
|
||||
true annular // 厦门站点当天命中日食,食型为日环食
|
||||
true {128 58 73} // Solar Saros 128,第 58/73 个成员
|
||||
2012-05-21 05:08:12.683185637 +0800 CST // 初亏
|
||||
2012-05-21 06:08:15.570583641 +0800 CST // 环食开始
|
||||
2012-05-21 06:10:25.164288282 +0800 CST // 食甚
|
||||
2012-05-21 06:12:34.763746261 +0800 CST // 环食结束
|
||||
2012-05-21 07:20:55.029697716 +0800 CST // 复圆
|
||||
4m19.19316262s // 环食持续时间
|
||||
true {128 58 73 true} // Solar Saros 128,第 58/73 个成员,已核验
|
||||
2012-05-21 05:08:12.683024704 +0800 CST // 初亏
|
||||
2012-05-21 06:08:15.570422708 +0800 CST // 环食开始
|
||||
2012-05-21 06:10:25.156724452 +0800 CST // 食甚
|
||||
2012-05-21 06:12:34.764188826 +0800 CST // 环食结束
|
||||
2012-05-21 07:20:55.029536783 +0800 CST // 复圆
|
||||
4m19.193766118s // 环食持续时间
|
||||
magnitude=0.933290 obscuration=0.872480 altitude=9.567 // 食分、遮掩比例、食甚太阳高度
|
||||
```
|
||||
|
||||
@@ -1007,8 +1051,8 @@ func main() {
|
||||
true 13460 // 洋山日全食 SVG 生成成功,长度 13460 字节
|
||||
true 13377 // 厦门日环食 SVG 生成成功,长度 13377 字节
|
||||
true total // 北京站点当天命中日食,食型为日全食
|
||||
true {145 23 77} // Solar Saros 145,第 23/77 个成员
|
||||
1m33.329527975s // 北京市区近似坐标下的全食持续时间
|
||||
true {145 23 77 true} // Solar Saros 145,第 23/77 个成员,已核验
|
||||
1m33.329527974s // 北京市区近似坐标下的全食持续时间
|
||||
true 13424 // 北京日全食 SVG 生成成功,长度 13424 字节
|
||||
```
|
||||
|
||||
@@ -1025,7 +1069,7 @@ true 13424 // 北京日全食 SVG 生成成功,长度 13424 字节
|
||||
本库的月食判断与搜索能力统一放在 `eclipse` 包,返回结果会保持传入 `time.Time` 的时区。
|
||||
常用接口:
|
||||
|
||||
- `LunarEclipseOnDate`:判断某个当地日期是否与整场月食重叠
|
||||
- `LunarEclipseOnDate`:判断某个当地日期是否有月食
|
||||
- `LastLunarEclipse` / `NextLunarEclipse` / `ClosestLunarEclipse`:搜索全局月食
|
||||
- `LocalLunarEclipseOnDate`:判断某地当天是否能看到可见月食
|
||||
- `LastLocalLunarEclipse` / `NextLocalLunarEclipse` / `ClosestLocalLunarEclipse`:搜索某地可见月食
|
||||
@@ -1043,23 +1087,25 @@ true 13424 // 北京日全食 SVG 生成成功,长度 13424 字节
|
||||
|
||||
其中 `Saros` 的含义与日食部分相同:
|
||||
|
||||
- `Saros.Series`:NASA 月食沙罗系列号
|
||||
- `Saros.Series`:`Verified=true` 时为 NASA 月食沙罗系列号,否则为推算的暂定系列号
|
||||
- `Saros.Member`:这次月食在该系列中的第几个成员,从 `1` 开始
|
||||
- `Saros.Count`:该沙罗系列的总成员数
|
||||
- `Saros.Verified`:是否已与内置权威目录锚点核验;扩展表或范围外推算结果为 `false`
|
||||
|
||||
例如 `2028-12-31 / 2029-01-01` 这次跨年月全食属于 `Lunar Saros 125` 的第 `49/72` 个成员。
|
||||
月食同样优先使用 NASA 锚点,天文年份 `-3000` 至 `+6000` 年内查扩展表,范围外才实时演算。推算成员按 Danjon 与 Chauvenet 检出的事件并集计数,因此不随调用的月食模型或观测地点改变;极浅成员可能与 NASA 目录不同,`Verified` 保持 `false`。
|
||||
例如 `2028-12-31 / 2029-01-01` 这次跨年月全食属于 `月食沙罗序列125` 的第 `49/72` 个成员。
|
||||
|
||||
当前同时保留两套地影放大口径:
|
||||
|
||||
- **Danjon(默认,推荐)**:只对月球水平视差项乘 `1.01`,再与太阳视半径、太阳视差组合求影半径。NASA GSFC 当前月食目录与图页采用的也是这一路线,本库默认的 `LunarEclipseOnDate`、`LastLunarEclipse`、`NextLunarEclipse`、`ClosestLunarEclipse` 都使用它。
|
||||
- **Danjon(默认)**:只对月球水平视差项乘 `1.01`,再与太阳视半径、太阳视差组合求影半径。NASA GSFC 当前月食目录与图页采用的也是这一路线,本库默认的 `LunarEclipseOnDate`、`LastLunarEclipse`、`NextLunarEclipse`、`ClosestLunarEclipse` 都使用它。
|
||||
- **Chauvenet(兼容口径)**:先取 `0.99834 × 地球赤道半径`,再把整组影半径统一乘 `51/50`。这与传统旧历表口径更接近,适合做兼容性回归和旧结果对照。
|
||||
|
||||
两者的直接差异通常表现为:
|
||||
|
||||
- `Chauvenet` 给出的半影和本影都更大,半影食分通常比 `Danjon` 多约 `0.025`,本影食分通常多约 `0.005`
|
||||
- 对边界月食而言,`Chauvenet` 更容易把结果推向“更深”的食型
|
||||
- 若目的是与 NASA 目录、现代星历软件或当前主流月食资料对照,优先使用默认的 `Danjon`
|
||||
- 若目的是兼容既有历史基线,可显式调用 `Chauvenet`
|
||||
- 与 NASA 目录、现代星历软件或当前主流月食资料对照时,对应的是默认的 `Danjon`
|
||||
- 兼容既有历史基线时,对应的是显式调用的 `Chauvenet`
|
||||
|
||||
##### 代码示例
|
||||
|
||||
@@ -1105,16 +1151,16 @@ func main() {
|
||||
|
||||
```text
|
||||
total
|
||||
true {125 49 72}
|
||||
true {125 49 72 true}
|
||||
2028-12-31 16:52:05.566135346 +0000 UTC
|
||||
2.273989043382249 1.2461142882946992
|
||||
2.2739890433790566 1.2461142882915068
|
||||
2028-12-31 14:03:54.219463169 +0000 UTC
|
||||
2028-12-31 15:07:42.115980684 +0000 UTC
|
||||
2028-12-31 16:16:27.24464178 +0000 UTC
|
||||
2028-12-31 17:27:46.214954853 +0000 UTC
|
||||
2028-12-31 18:36:32.251235246 +0000 UTC
|
||||
2028-12-31 19:40:11.52023971 +0000 UTC
|
||||
2.2996033397593934 1.2511710895700923
|
||||
2.2996033397562012 1.2511710895669002
|
||||
true
|
||||
total
|
||||
2029-01-01 00:52:05.566135346 +0800 CST
|
||||
@@ -1158,6 +1204,8 @@ total
|
||||
|
||||
##### 月食 SVG
|
||||
|
||||
`LunarEclipseSVG`、`LunarEclipseDetailedSVG` 与 `LunarEclipseMapSVG` 的默认模型与后缀入口口径见下文[全球见食图 SVG](#全球见食图-svg)。
|
||||
|
||||
默认月食 SVG 头部会自动带上沙罗序列;如果需要自定义更多文字,可以通过 `LunarEclipseSVGOptions` 覆写:
|
||||
|
||||
- `Title`:主标题
|
||||
@@ -1301,13 +1349,28 @@ func main() {
|
||||
|
||||
```text
|
||||
进贤增九 total
|
||||
2025-06-05 19:14:01.095 CST 2025-06-05 20:02:06.357 CST 2025-06-05 20:50:10.740 CST
|
||||
2025-06-05 19:14:01.062 CST 2025-06-05 20:02:06.296 CST 2025-06-05 20:50:10.697 CST
|
||||
altitude=75.561 visible=true
|
||||
2025-06-05 17:45:28.498 CST 2025-06-05 20:02:06.332 CST 2025-06-05 22:18:49.977 CST
|
||||
greatest=121.566021 6.807046 width=3582.4km center=108
|
||||
2025-06-05 17:45:28.475 CST 2025-06-05 20:02:06.300 CST 2025-06-05 22:18:49.945 CST
|
||||
greatest=121.566140 6.807079 width=3582.4km center=108
|
||||
```
|
||||
|
||||
`OccultationSearchOptions` 的零值使用默认搜索步长和安全余量;`MaxEvents > 0` 限制返回数量。`OccultationPathOptions.Step` 控制基础时间采样,`TargetSpacingKM` 按地面距离自适应加密中心线;过密请求超出确定性预算时返回 `ErrOccultationPathSamplingLimit`。
|
||||
`OccultationSearchOptions` 的零值使用默认搜索步长和安全余量;`MaxEvents > 0` 限制返回数量。`OccultationPathOptions.Step` 控制基础时间采样,`TargetSpacingKM` 按地面距离自适应加密中心线;过密请求超出确定性预算时返回 `ErrOccultationPathSamplingLimit`。`RiseSetStep` 独立控制初掩、掩甚、终掩分别发生在月升/月落时的六类阶段线,零值使用 5 分钟;`DisableRiseSet` 可跳过这些阶段线。`DisableFootprints` 跳过体积较大的密集瞬时可见区要素,改用稀疏支撑样本合并成紧凑掩带;中心线、边界和六类升落阶段线仍保留,适合普通 GeoJSON 地图(首次渲染需合并一次,重复渲染走缓存)。`GreatestLimitSeparationKM` 是掩甚处南北限的地面间距,掩星图与详细版的"掩带宽"用它标注,与 `Greatest.WidthKM` 口径不同、不可互换。`IncludeFootprintTimeline` 可在紧凑掩带之外保留按 `FootprintTimelineStep` 采样的瞬时足迹,供时间轴选择当前时刻的可见区域。
|
||||
|
||||
`OccultationPathOptions.Algorithm` 控制恒星和行星全球路径的星历分支:零值或 `moon.OccultationPathAlgorithmOptimized` 默认使用经抽检的 30 分钟节点矢量插值,保留现有站心方程、连续包络和升落曲线;`moon.OccultationPathAlgorithmExact` 保留原有分支,候选可使用插值,最终求解仍使用全项星历。优化分支在抽检不合格时回退到原分支,超出插值时间窗时使用精确星历。抽检不是全时段严格误差证明;两个分支的几何目标相同,但不保证采样点或 GeoJSON 字节完全相同。此选项不影响仅查询事件、指定站点接触或独立单时刻月影接口,也不影响日月食。
|
||||
|
||||
两个分支的全球起止、掩甚标记和中心线宽度均保留全项星历计算。绘图时应传入完整返回路径,包括可见性轮廓;丢弃该轮廓会调用历史瞬时足迹回退逻辑,其边界不能替代完整解析可见集。
|
||||
|
||||
路径中的 `BandContours` 是静态掩带的接触包络,`VisibilityContours` 是月亮处于地平线以上时的可见时间包络;两者与 `Footprints` 的瞬时采样分别承担静态边界、可见性边界和时间轴细节,不应互相替代。
|
||||
|
||||
`OccultationPathOptions.GreatestTimeValues` / `GreatestTimeStep` 请求**掩甚时刻等时线**。与日食不同,`GreatestTimeValues []float64` 给的是力学时儒略日,最多保留 64 条(先去掉重复的时刻取值,按时间先后排序,超出时保留最早的 64 条),掩可见窗口之外或没有可用支路的时刻取值不会出现在结果里;它为空时改用 `GreatestTimeStep`,同样只在为正值时生效,且对齐到 UTC 整刻度。结果写入 `StarOccultationPath.GreatestTimeContours`(行星路径是同名字段),元素类型 `OccultationGreatestTimeContour` 的 `JDE`、`Time`、`Segments` 与日食同义:`Time` 在按步长生成时是原始对齐时刻,显式给出的时刻取值则由 `JDE` 换算并抹到毫秒,两者都落在 UTC 时区,而支路点的时刻仍按路径时区;日食公共层的 `Time` 则直接落在输入时区。边界口径同样一致:只出现在目标盘面与月面确有重叠且月亮在几何地平以上(不含蒙气差与半径修正)的地方,两端止于地平线或掩可见域边界,纬度 ±88° 以上不再延拓,同一时刻可能有多条互不相连的支路;不请求时既有输出不变。
|
||||
|
||||
```go
|
||||
options := moon.OccultationPathOptions{
|
||||
Algorithm: moon.OccultationPathAlgorithmExact, // 显式选择原分支;省略时使用优化分支
|
||||
DisableFootprints: true,
|
||||
}
|
||||
```
|
||||
|
||||
#### 行星月掩
|
||||
|
||||
@@ -1346,14 +1409,14 @@ func main() {
|
||||
|
||||
```text
|
||||
Saturn total true
|
||||
2025-02-01 11:29:09.692 CST
|
||||
2025-02-01 11:29:40.042 CST
|
||||
2025-02-01 12:00:48.729 CST
|
||||
2025-02-01 12:32:46.388 CST
|
||||
2025-02-01 12:33:18.285 CST
|
||||
2025-02-01 11:29:09.710 CST
|
||||
2025-02-01 11:29:40.069 CST
|
||||
2025-02-01 12:00:48.747 CST
|
||||
2025-02-01 12:32:46.415 CST
|
||||
2025-02-01 12:33:18.312 CST
|
||||
```
|
||||
|
||||
`FindPlanetOccultationPaths` 的全球结果同时包含任意圆盘重叠的部分掩区域和整颗行星被遮住的全掩区域。`HasTotalBand` 表示是否存在全掩带,`GreatestTotalWidthKM` 是掩甚处全掩带宽;中心线、边界和瞬时足迹都带采样时刻。
|
||||
`FindPlanetOccultationPaths` 的全球结果同时包含任意圆盘重叠的部分掩区域和整颗行星被遮住的全掩区域。`HasTotalBand` 表示是否存在全掩带,`GreatestTotalWidthKM` 是掩甚处全掩带宽;中心线、边界和启用时的瞬时足迹都带采样时刻。
|
||||
|
||||
#### 月掩 SVG
|
||||
|
||||
@@ -1372,33 +1435,19 @@ localSVGs, err := moonsvg.FindLocalStarOccultationSVGs(
|
||||
moonsvg.LocalStarOccultationSVGOptions{Width: 920, Height: 700, Location: cst},
|
||||
)
|
||||
fmt.Println(err, len(localSVGs))
|
||||
|
||||
globalSVGs, err := moonsvg.FindStarOccultationSVGs(
|
||||
start, end, target,
|
||||
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
|
||||
moonsvg.StarOccultationSVGOptions{
|
||||
Width: 1200, Height: 800, Location: cst,
|
||||
TimeLabelStep: 30 * time.Minute,
|
||||
},
|
||||
)
|
||||
fmt.Println(err, len(globalSVGs))
|
||||
```
|
||||
|
||||
本地图按指定观测者的站心几何绘制。全球图使用 Natural Earth `1:50m` 海岸线,不含行政边界;默认每 30 分钟在中心线上标记 `HH:MM`,高纬事件可自动切换极区投影。
|
||||
|
||||
下面两张图沿用前文 `2025-06-05` 月掩进贤增九(HR 4799)的样例。局地图的观测点为 `121.56601°E, 6.80706°N`,靠近全球几何掩甚点;图中的掩始、掩甚和掩终是该地点实际看到的站心接触时刻,并同时给出月面方向、白道、月高、方位和地平可见性。
|
||||
本地图按指定观测者的站心几何绘制,下图沿用前文 `2025-06-05` 月掩进贤增九(HR 4799)的样例。局地图的观测点为 `121.56601°E, 6.80706°N`,靠近全球几何掩甚点;图中的掩始、掩甚和掩终是该地点实际看到的站心接触时刻,并同时给出月面方向、白道、月高、方位和地平可见性。
|
||||
|
||||

|
||||
|
||||
全球图展示同一事件的掩带边界、可见/几何中心线、全球阶段点以及每 30 分钟的中心线时间标记。全球掩始和掩终表示月影首次接触和最后离开地球,并不是上述指定地点的接触时刻。
|
||||
|
||||

|
||||
|
||||
### 天象地图与 GeoJSON
|
||||
### 天象图与 GeoJSON
|
||||
|
||||
#### 全球见食图 SVG
|
||||
|
||||
`eclipse/svg` 可直接生成日食和月食全球图。日食图绘制完整偏食可见区、全食/环食中心带、中心线、全球阶段信息和中心线时间标记;同时显示食甚时的晨昏圈与日下点、影轴进出地球点、`P1-P4/U1-U4` 接触点、定时半影轮廓和本影/反本影轮廓。月食图绘制 P1/P4 可见半球、月出/月落过渡区和整场可见区。
|
||||
`eclipse/svg` 可直接生成日食和月食全球图。日食图绘制完整偏食可见区、全食/环食中心带、中心线、全球阶段信息和中心线时间标记;同时显示初亏/食甚/复圆的日升日落线、太阳直射点、影轴进出地球点、`P1-P4/U1-U4` 接触点,以及默认关闭、按需打开的定时半影轮廓和本影/反本影轮廓。月食图绘制 P1/P4 可见半球、月出/月落过渡区和整场可见区。
|
||||
|
||||
`LunarEclipseDetailedSVG` 把上述两类月食图合成详细版式的一页:居中摘要(食甚、半影/本影食分、伽马、半影/本影半径、月距、沙罗序列)、左右两侧的日月地心坐标块、穿影示意图、历时 / 弧分比例尺 / 接触时刻三栏,以及下方的世界可见性底图与图例。地影几何由 `basic.LunarEclipseShadowGeometryAt` 给出,其中 **Gamma 用地球赤道半径、半影/本影半径用度**,换成地球半径要乘以月球处的地球视差。
|
||||
|
||||
```go
|
||||
package main
|
||||
@@ -1417,9 +1466,7 @@ func main() {
|
||||
time.Date(2009, 7, 22, 12, 0, 0, 0, cst),
|
||||
eclipsesvg.SolarEclipseMapSVGOptions{
|
||||
Width: 1200, Height: 800, Location: cst,
|
||||
TimeLabelStep: 30 * time.Minute,
|
||||
PenumbralOutlineStep: 60 * time.Minute,
|
||||
CentralShadowStep: 10 * time.Minute,
|
||||
TimeLabelStep: 30 * time.Minute,
|
||||
},
|
||||
)
|
||||
if ok {
|
||||
@@ -1433,21 +1480,56 @@ func main() {
|
||||
if ok {
|
||||
_ = os.WriteFile("doc/lunar-eclipse-2029-01-01-global.svg", []byte(lunar), 0o644)
|
||||
}
|
||||
|
||||
detailed, ok := eclipsesvg.LunarEclipseDetailedSVG(
|
||||
time.Date(2029, 1, 1, 0, 0, 0, 0, cst),
|
||||
eclipsesvg.LunarEclipseDetailedSVGOptions{Location: cst},
|
||||
)
|
||||
if ok {
|
||||
_ = os.WriteFile("doc/lunar-eclipse-2029-01-01-detailed.svg", []byte(detailed), 0o644)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
日食图右侧事件表按时间列出可用的 `P1-P4/U1-U4`、影轴进出地球和食甚时刻,空间允许时同时显示接触点经纬度;摘要还包含沙罗序列、食带宽、食甚点太阳高度/方位和中心食持续时间。图中的橙色虚线是带 `HH:MM` 标记的瞬时半影边界,灰色虚线是食甚时晨昏圈,棕色实线是本影或反本影瞬时轮廓。
|
||||
图上的橙色长虚线是初亏/食甚/复圆分别发生在日出和日落时的六类阶段线;**瞬时半影与本影轮廓默认不画**,需要时用正的 `PenumbralOutlineStep` / `CentralShadowStep` 打开。紫色短虚线是 `MagnitudeValues` 指定的地方最大食分等值线(默认 0.2/0.4/0.6/0.8),蓝色实线是食甚时刻等时线。
|
||||
|
||||
`PenumbralOutlineStep` 与 `CentralShadowStep` 的零值分别使用 60 分钟和 10 分钟,负值关闭对应轮廓;显式设置仍可使用 30 分钟等其他间隔。`TimeLabelStep` 的零值为 30 分钟,负值关闭中心线时刻标记。
|
||||
**食甚时刻等时线**(蓝色实线)默认不画:同一条线上的地点在同一时刻看到食甚,需要时用正的 `GreatestTimeStep` 打开,NASA 全球图的间隔是 30 分钟。这里的 `GreatestTimeStep` 属于 SVG 层,按**展示时区**(`Location`)对齐整刻度,与数据层的 UTC 对齐不同;`eclipse/svg` 不提供显式时刻取值入口,需要别的对齐刻度时请直接调用数据层并把时刻传给 `GreatestTimeValues`。
|
||||
|
||||
```go
|
||||
solar, _ := eclipsesvg.SolarEclipseMapSVG(date, eclipsesvg.SolarEclipseMapSVGOptions{
|
||||
Width: 1200, Height: 800, Location: cst,
|
||||
GreatestTimeStep: 30 * time.Minute,
|
||||
})
|
||||
```
|
||||
|
||||
它不是在经纬度网格上逐点求食甚再描等值线,而是固定时刻后求解 `∂(日月中心角距²)/∂t = 0` 的零集,再沿曲线延拓,因此成本正比于曲线长度而不是可见域面积。等时线只画在日月盘面确有重叠且太阳在几何地平以上(不含蒙气差与半径修正)的地方,每条支路止于地平线或偏食可见域边界;纬度 ±88° 以上不再延拓,同一时刻可能有多条互不相连的支路。
|
||||
|
||||
`TimeLabelStep` 的零值为 30 分钟,负值关闭中心线时刻标记。`GreatestTimeStep` 的零值与负值都不画食甚时刻等时线(与核心层、`moon/svg` 一样必须显式请求),正值按展示时区对齐、小于一分钟时按一分钟处理,单次最多生成 64 条;30 分钟是 NASA 全球图的推荐间隔。`MagnitudeValues` 为 nil 时使用 0.2/0.4/0.6/0.8,显式空切片关闭,非空切片按给定电平绘制。
|
||||
|
||||
`SolarEclipseMapSVGOptions.EventsTitle` 覆盖“全球阶段”数据块的标题(该块给出食甚经纬度与地球范围的中心食始/终),为空时使用本地化默认标题;`MapTitle` 与 `Title` 分别覆盖地图分区标题与主标题。
|
||||
|
||||
日食图的画布下限是 **800×560**:宽度小于 800 或高度小于 560 时按文档回落到 960×640,更窄的横版画布上地图框会与右栏数据网格水平重叠、面板行距压到 1 px 以下。`PartialStep` 小于两分钟时按两分钟处理:偏食区填充是瞬时足迹的并集,成本随采样数成倍增长,而并集必须由一整条自洽的扫描序列生成,更密的请求不改变产物(1 秒步长实测 36.8 s / 951 MB,夹取后为 1.1 s / 30 MB,与默认请求逐字节相同)。月食详细版式按 `Height` 推导版面:640×420 与 800×600 容不下示意图与底图的下限而返回 `false`,1000×1414 与 1414×1000 正常出图。
|
||||
|
||||
`eclipse/svg` 的三个无后缀月食入口(`LunarEclipseSVG`、`LunarEclipseDetailedSVG`、`LunarEclipseMapSVG`)使用同一个默认模型:以 Danjon 为主,极浅半影按核心默认口径回退 Chauvenet,与 `LunarEclipseOnDate` 一致;带 `Danjon` / `Chauvenet` 后缀的入口强制指定模型。
|
||||
|
||||
可降级的图层用 `data-source` 标注实际几何来源,取值词表见 `eclipse/svg` 包注释:`partial-band-union`、`sampled-footprint-sweep`、`partial-band-contours`、`rise-set-phase-lines`、`magnitude-contours`、`greatest-time-isochrones`、`besselian-critical-envelope`、`paired-limit-chords`、`sampled-open-sweep`、`central-path-limits`、`penumbral-outlines`、`central-shadow-outlines`、`p1-p4-visibility-regions`、`p1-p4-horizon-boundaries`。`PenumbralOutlineStep` 与 `CentralShadowStep` **默认关闭**(零值或负值都不画瞬时半影/本影轮廓,它们会把地球盖住,NASA 全球图也没有这两族),正值给出采样间隔,小于一分钟时按一分钟。
|
||||
|
||||
日食和月掩的自动投影会在适合时选择北极或南极图;月食默认使用等经纬投影。投影仅影响 SVG 表达,不改变底层 WGS84 地理结果。
|
||||
|
||||
日月食通过 `EclipseMapProjectionEquirectangular`、`EclipseMapProjectionNorthPolar`、`EclipseMapProjectionSouthPolar` 强制投影;月掩使用对应的 `MapProjection...` 常量。
|
||||
日月食通过 `EclipseMapProjectionEquirectangular`、`EclipseMapProjectionNorthPolar`、`EclipseMapProjectionSouthPolar` 强制投影;月掩使用对应的 `MapProjection...` 常量。`EclipseMapProjectionOrthographic` 给出 NASA 版式的**正射球面图**:视点取食甚点,只画朝向视点的半个地球,投影边界就是可见半球的大圆。
|
||||
|
||||
球面图不需要新增数据,也不需要第三方投影库:陆地由内置的等经纬底图在运行时反解回经纬度再正射投影,视界裁剪在地理坐标上按大圆求交、并沿视界弧补齐被切断的环;经纬网按球面采样后同样裁剪。
|
||||
|
||||
正射投影同时切换成 **NASA 摆法**的版式:球面居中放大,比例尺排在球面正下方,阶段信息改为三栏面板(半影接触 / 食甚点地方情况 / 本影接触),图例与页脚依次向下;其他投影保持原有版式。代价是 `1000×1414` 画布下整幅图约 1.0 s(等经纬图约 0.85 s),一个同尺寸的球面图 SVG 约 530 KB;耗时为单机实测参考值,绝对值因机器而异。
|
||||
|
||||
下面的全球图沿用前文局地 SVG 的事件日期。2009 长江大日食、2012 厦门日环食和 2035 北京日全食使用等经纬投影:
|
||||
|
||||

|
||||
|
||||
同一场日食的正射球面版式(`EclipseMapProjectionOrthographic`):
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
@@ -1460,6 +1542,58 @@ func main() {
|
||||
|
||||

|
||||
|
||||
月食还有把上面两类图合二为一的详细版式:居中摘要(食甚、半影/本影食分、伽马、半影/本影半径、月距、沙罗序列)、左右两侧的日月地心坐标块、穿影示意图、历时 / 弧分比例尺 / 接触时刻三栏,以及下方的世界可见性底图与图例,一页 `1000x1414`:
|
||||
|
||||

|
||||
|
||||
#### 月掩详细版式 SVG
|
||||
|
||||
`moon/svg` 的详细版式把整场月掩合成一页 `1000x1414`:居中摘要、日月与目标天体的地心/站心数据块、一张**正射球面**的全球掩带图(南北限、可见/几何中心线、掩甚点、初掩/掩甚/终掩阶段点与 30 分钟时间标记),以及页脚说明。球面视点取事件中心,只画朝向视点的半球,这一版式固定用正射球面,不接受其它投影;只需要单独的全球掩带地图时用前文“月掩 SVG”的 `StarOccultationPathSVG` / `FindStarOccultationSVGs`。页内数据分为月亮地心坐标、目标天体、掩带路径点、接触时刻、历表与常数、天平动六块;横版画布把数据块排在地图右侧两栏三行,竖版把数据块排在球面下方三栏两行。下图是 `2025-06-05` 月掩 HR 4799:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/moon"
|
||||
moonsvg "b612.me/astro/moon/svg"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cst := time.FixedZone("CST", 8*3600)
|
||||
star := moon.StarCoordinate{
|
||||
ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444,
|
||||
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: moon.CoordinateFrameJ2000,
|
||||
ProperMotionRACosDecMasPerYear: -28, ProperMotionDecMasPerYear: -18,
|
||||
}
|
||||
paths, err := moon.FindStarOccultationPaths(
|
||||
time.Date(2025, 6, 5, 0, 0, 0, 0, cst),
|
||||
time.Date(2025, 6, 6, 0, 0, 0, 0, cst),
|
||||
star,
|
||||
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
|
||||
)
|
||||
if err == nil && len(paths) > 0 {
|
||||
detailed, renderErr := moonsvg.StarOccultationDetailedSVG(
|
||||
paths[0], star,
|
||||
moonsvg.OccultationDetailedSVGOptions{Width: 1000, Height: 1414, Location: cst},
|
||||
)
|
||||
if renderErr == nil {
|
||||
_ = os.WriteFile("doc/lunar-occultation-hr4799-2025-06-05-detailed.svg", []byte(detailed), 0o644)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
页内的球面掩带图使用 Natural Earth `1:50m` 海岸线,不含行政边界。在 `moon.OccultationPathOptions` 上设置 `GreatestTimeStep` 会额外请求**掩甚时刻等时线**,含义与日食图上的蓝色等时线相同:固定时刻后求角距导数的零集并沿曲线延拓。它同样是可选项,不设置时输出不变。掩星全球可见窗口通常只有数小时(本页 HR 4799 样例为 4 小时 33 分),常用间隔比日食更密,为 15–30 分钟量级,间隔越大掩带上的等时线越少;点源恒星按日月中心角距定食甚,有限盘面行星按外接触度量,分别与库内 `StarOccultationInfo.Greatest`、`PlanetOccultationInfo.Greatest` 同口径。`moon/svg` 自己不提供等时线开关,只绘制路径结果里已有的 `GreatestTimeContours`,因此请求必须在计算路径时通过 `OccultationPathOptions` 提出,线的位置也由核心口径决定(`GreatestTimeStep` 对齐 UTC 整刻度);按展示时区对齐的入口,是把时刻换算成力学时儒略日后传给 `GreatestTimeValues`。全球掩始和掩终表示月影首次接触和最后离开地球,与指定地点的接触时刻无关。
|
||||
|
||||
- 画布与错误:详细版最小 `480x320`,并按画布推导版式,地图与数据块放不下时返回 `ErrInvalidOccultationDetailedSVGOptions`(`800x600`、`1000x1414`、`1414x1000` 均可出图,`640x420`、`900x400` 会被拒绝);单独的全球掩带地图最小 `640x480`,更小的画布返回 `ErrInvalidStarOccultationSVGOptions`。
|
||||
|
||||
图上标注的“掩带宽”是掩甚处南北限的地面间距 `GreatestLimitSeparationKM`(本例约 `3666.6 km`),与中心线横向宽度 `Greatest.WidthKM`(约 `3582.4 km`)口径不同、不可互换。掩甚时刻等时线要在路径层显式请求 `OccultationPathOptions.GreatestTimeStep`;使用 `DisableFootprints` 的紧凑掩带首次渲染会合并一次,之后同一路径走缓存。详细版式与固定地点图见前文“月掩 SVG”。
|
||||
|
||||
#### GeoJSON
|
||||
|
||||
`geojson` 接收已经计算好的日食、月食或月掩结果,返回 `[]byte`。这段字节是完整的 UTF-8 RFC 7946 `FeatureCollection` JSON,不是图片,也不是压缩数据,可以直接写入 `.geojson`、交给 `encoding/json`,或发送给前端地图组件。
|
||||
@@ -1516,6 +1650,20 @@ func main() {
|
||||
|
||||
坐标统一为 WGS84 经度、纬度,跨反经线的线和面会拆分。带时路径的 `times` 属性与各段坐标逐点对齐;`WithTimeMarkers` 另加 `role=time-marker` 的 Point Feature,本地化 `label` 用于显示,`time` 始终是 UTC RFC 3339。
|
||||
|
||||
单时刻原语(拖动时间轴、"停下即精确")与站心搜索跨度:
|
||||
|
||||
- `eclipse.NewSolarEclipseShadowSolver(eclipse.SolarEclipseShadowSolverOptions{...})` 返回可复用句柄;`ShadowAt(time.Time)`(按 UTC 解释)或 `ShadowAtJDE(jdeTT)`(TT 语义)取该时刻的**全球本影足迹**,`StationStateAt` / `StationStateAtJDE` 取该时刻、该站点的**站心日月几何**(食分、遮蔽率、站心角距、日月视半径、太阳高度/方位、是否处于全食/环食)。两者都只算这一件事,不产生可见带、食分线、升落边界、南北界或中心线;本影不在地球上时返回空/零值而不是错误。
|
||||
- `geojson.MarshalSolarEclipseShadowInstant(instant)` 只输出该时刻的阴影区域,以及被地平线切断时的物理边界;属性含 `time`、`source_boundary_closed`、`geometry_role`、`closure`、`delta_t_seconds`、`model`、`interp_signature`。本影用 `central-shadow-footprint` + `central-shadow-boundary`;把 `Kind` 设为 `SolarEclipseShadowPenumbra` 则输出半影(偏食区),角色为 `partial-footprint` + `partial-footprint-boundary`,默认参数与整包偏食采样一致(96 点 + 200 km 加密),因此同一时刻的结果与采样逐点一致(约 1e-12 度差)。没有阴影时返回空 FeatureCollection。
|
||||
- 采样的 `partial-footprint` 也带 `source_boundary_closed`、`geometry_role`、`closure` 与 `interp_signature`;被地平线切断的序列端点补到地平圈擦地点,未补齐时该处的端点偏差为 `36–41 km` 量级。掩带的填充提示仍沿用旧封口,避免端点外扩改变极区面归属。
|
||||
- 实测成本(原生构建,单机参考值,绝对值因机器而异):单时刻足迹(96 点)约 **64 µs**,站心瞬时约 **20 µs**;公开句柄构造只是夹取选项(≈0),首次查询时按最近朔月构造内部状态约 39 µs、锚点查询约 130 µs,随后按事件缓存;批处理约 75 µs/时刻。
|
||||
- ΔT:`DeltaTSeconds` 显式指定时只作用于该句柄,且只改变地球自转相位——同一 TT 的几何不变,地面足迹沿经度平移 `0.4651·|ΔΔT|·cos(纬度)` 千米(见 `basic.DeltaTGroundShiftKM`);`<=0` 时使用进程级模型。两种情况下结果都回传实际使用的 ΔT。库不附带 ΔT 不确定度模型,请用该函数把外部的 ΔT 标准差换算成几何不确定度。
|
||||
- 插值:整包的 `central-shadow-footprint` 与该时刻的单时刻导出都带 `interp_signature`(形如 `umbra-closed-seg1-pt97`,由物理边界的顶点数/分段数/闭合标志与绕极标志给出),**相同**的相邻时刻才适合按顶点插值;`closed` 翻转、段数变化(换日线拆分)、顶点数变化、空↔非空(U1/U4 附近)时必须改取精确几何。实测 2 分钟步长下中段质心移动 78–232 km,端点附近可达约 520 km。
|
||||
- 批量:`ShadowBetween(start, end, step)` / `StationStatesBetween(...)` 按时间轴对齐返回整段,没有阴影的时刻是空条目。
|
||||
- 掩星的单时刻足迹(`moon.StarOccultationFootprintAt` / `moon.PlanetOccultationFootprintsAt` → `geojson.MarshalStarOccultationFootprint` / `MarshalPlanetOccultationFootprints`)同样带 `delta_t_seconds`、`source_boundary_closed`、`geometry_role`、`interp_signature`;被月球地平切断时 `closure` 的 `kind` 是 `target-horizon`、`body` 是 `moon`,参照 `sublunar` 月下点而不是日下点。掩星子系统沿用进程级 ΔT,只回传实际用值,不提供显式覆盖。
|
||||
- 站心搜索:`SearchLocalCentralSolarEclipse(date, lon, lat, height, eclipse.SolarEclipseLocalSearchOptions{Kind, MaxYears, Backward, Geometric, Model})` 返回 `(info, status)`,`status.Exhausted` 把"跨度内确实没有"与"找到了"分开;`MaxYears<=0` 使用与旧入口等价的默认跨度(6000 次候选步进 ≈ 992 年,因为候选会跳过非食季)。`SolarEclipseCandidates(start, end, options)` 只回时刻表(食甚时刻、食型、中心食类型、食分、伽马、可选沙罗序列),不含任何几何。
|
||||
|
||||
日食 GeoJSON 的中心影相关 role 是稳定契约:`role=central-shadow-footprint` 要么缺省、要么是 `Polygon`/`MultiPolygon`,永不出现线类型;被地平线切断时它仍输出该时刻地面本影(或反本影)覆盖的完整区域——物理边界延伸到两个地平擦地点,再由两擦地点之间的地平弧闭合,此时 `source_boundary_closed=false`,并由 `closure`(`kind`、`time`、`subsolar`)声明那段人工弧。只含物理边界曲线的折线另由 `role=central-shadow-boundary` 输出,调用方描它、填上面那个面即可,不会描出假的地平线边界。足迹收缩到零(U1/U4)时整条缺省,也不会退化成线。`source_boundary_closed=true` 表示边界由本影自身闭合,环上没有任何人工段。
|
||||
|
||||
`TimeMarkerOptions.Step` 的零值为 30 分钟,正值至少 1 分钟,每次导出最多 1440 个标记。GeoJSON 不携带底图、国家边界、样式或投影;Web Mercator、极区图、瓦片选择和政治边界由应用自行决定。
|
||||
|
||||
### 行星
|
||||
@@ -1569,18 +1717,18 @@ func main() {
|
||||
输出结果:
|
||||
|
||||
```
|
||||
2019-11-11 23:21:42.048057317 +0800 CST // 水星上次下合
|
||||
2021-03-26 14:57:43.01215589 +0800 CST // 金星下次上合
|
||||
2019-11-01 04:31:38.999851942 +0800 CST // 水星上次由顺行转逆行的留
|
||||
2020-06-25 02:07:41.549940705 +0800 CST // 金星下次由逆行转顺行的留
|
||||
2019-10-20 11:50:28.734245896 +0800 CST // 水星上次东大距
|
||||
2020-08-13 07:59:17.123789191 +0800 CST // 金星下次西大距
|
||||
2020-01-01 10:02:34.172194004 +0800 CST <nil> // 西安当天金星升起时刻;无错误
|
||||
2020-01-01 20:25:37.363712489 +0800 CST <nil> // 西安当天金星落下时刻;无错误
|
||||
2019-11-11 23:21:41.971051096 +0800 CST // 水星上次下合
|
||||
2021-03-26 14:57:42.052354216 +0800 CST // 金星下次上合
|
||||
2019-11-01 04:31:49.749019145 +0800 CST // 水星上次由顺行转逆行的留
|
||||
2020-06-25 02:07:41.599749326 +0800 CST // 金星下次由逆行转顺行的留
|
||||
2019-10-20 12:01:37.740152478 +0800 CST // 水星上次东大距
|
||||
2020-08-13 08:14:46.304587125 +0800 CST // 金星下次西大距
|
||||
2020-01-01 10:02:34.172435402 +0800 CST <nil> // 西安当天金星升起时刻;无错误
|
||||
2020-01-01 20:25:37.36411482 +0800 CST <nil> // 西安当天金星落下时刻;无错误
|
||||
-4 // 金星视星等
|
||||
49.98145049145023 // 金星相位角,单位度
|
||||
0.8215177914415865 // 金星被照亮比例
|
||||
255.63802093000768 // 金星亮面中心位置角,单位度
|
||||
255.63802053541346 // 金星亮面中心位置角,单位度
|
||||
1.2778819631550336 // 金地距离,单位 AU
|
||||
0.7262651056423838 // 金日距离,单位 AU
|
||||
```
|
||||
@@ -1650,21 +1798,21 @@ func main() {
|
||||
|
||||
```text
|
||||
true // 找到一次有效的地心水星凌日
|
||||
2019-11-11 12:35:31.617325544 +0000 UTC // 一触:水星外切进入太阳圆面
|
||||
2019-11-11 12:37:13.078211545 +0000 UTC // 二触:水星完全进入太阳圆面
|
||||
2019-11-11 15:19:48.410291075 +0000 UTC // 凌甚:水星中心最接近太阳中心
|
||||
2019-11-11 18:02:29.2267102 +0000 UTC // 三触:水星开始离开太阳圆面
|
||||
2019-11-11 18:04:10.687676668 +0000 UTC // 四触:水星外切离开太阳圆面
|
||||
2019-11-11 12:35:31.567597389 +0000 UTC // 一触:水星外切进入太阳圆面
|
||||
2019-11-11 12:37:12.817581295 +0000 UTC // 二触:水星完全进入太阳圆面
|
||||
2019-11-11 15:19:48.36056292 +0000 UTC // 凌甚:水星中心最接近太阳中心
|
||||
2019-11-11 18:02:29.176982045 +0000 UTC // 三触:水星开始离开太阳圆面
|
||||
2019-11-11 18:04:10.637948513 +0000 UTC // 四触:水星外切离开太阳圆面
|
||||
5h28m39.070351124s // 一触到四触的地心凌日持续时间
|
||||
75.92506897631685 // 凌甚时水星中心与太阳中心的最小角距离,单位角秒
|
||||
968.8881520858397 // 凌甚时太阳视半径,单位角秒
|
||||
4.978442860728242 // 凌甚时水星视半径,单位角秒
|
||||
75.92400059923187 // 凌甚时水星中心与太阳中心的最小角距离,单位角秒
|
||||
968.8881519533047 // 凌甚时太阳视半径,单位角秒
|
||||
4.978442871670873 // 凌甚时水星视半径,单位角秒
|
||||
true // 找到一次有效的地心金星凌日
|
||||
2012-06-05 22:09:47.514281272 +0000 UTC // 一触:金星外切进入太阳圆面
|
||||
2012-06-05 22:27:35.701768398 +0000 UTC // 二触:金星完全进入太阳圆面
|
||||
2012-06-06 01:29:35.408823788 +0000 UTC // 凌甚:金星中心最接近太阳中心
|
||||
2012-06-06 04:31:34.90493685 +0000 UTC // 三触:金星开始离开太阳圆面
|
||||
2012-06-06 04:49:23.303366303 +0000 UTC // 四触:金星外切离开太阳圆面
|
||||
2012-06-05 22:09:47.466886639 +0000 UTC // 一触:金星外切进入太阳圆面
|
||||
2012-06-05 22:27:35.865356326 +0000 UTC // 二触:金星完全进入太阳圆面
|
||||
2012-06-06 01:29:35.572371482 +0000 UTC // 凌甚:金星中心最接近太阳中心
|
||||
2012-06-06 04:31:35.068444311 +0000 UTC // 三触:金星开始离开太阳圆面
|
||||
2012-06-06 04:49:23.25597167 +0000 UTC // 四触:金星外切离开太阳圆面
|
||||
6h39m35.789085031s // 一触到四触的地心凌日持续时间
|
||||
```
|
||||
|
||||
@@ -1727,15 +1875,15 @@ func main() {
|
||||
输出结果:
|
||||
|
||||
```
|
||||
2020-10-14 07:25:50.262777507 +0800 CST // 火星下次冲日
|
||||
2021-01-29 09:39:33.565426468 +0800 CST // 木星下次合日
|
||||
2019-04-30 10:27:41.606289446 +0800 CST // 土星上次由顺行转逆行的留
|
||||
saturn B=23.577026 Bp=23.266930 P=6.629811 dU=1.171016 major=34.133852 minor=13.652911 // 土星环 B、B'、P、dU、长轴、短轴
|
||||
2020-01-11 15:23:07.378419935 +0800 CST // 天王星下次由逆行转顺行的留
|
||||
2019-12-08 17:00:15.328663587 +0800 CST // 海王星上次东方照
|
||||
2020-06-07 03:10:59.356176853 +0800 CST // 火星下次西方照
|
||||
2020-01-01 04:41:29.622089266 +0800 CST <nil> // 西安当天火星升起时刻;无错误
|
||||
2020-01-01 14:55:32.963870465 +0800 CST <nil> // 西安当天火星落下时刻;无错误
|
||||
2020-10-14 07:25:50.441412627 +0800 CST // 火星下次冲日
|
||||
2021-01-29 09:39:33.697994649 +0800 CST // 木星下次合日
|
||||
2019-04-30 10:28:00.187439918 +0800 CST // 土星上次由顺行转逆行的留
|
||||
saturn B=23.577025 Bp=23.266930 P=6.629811 dU=1.171016 major=34.133852 minor=13.652911 // 土星环 B、B'、P、dU、长轴、短轴
|
||||
2020-01-11 15:23:23.360308706 +0800 CST // 天王星下次由逆行转顺行的留
|
||||
2019-12-08 17:00:15.517960488 +0800 CST // 海王星上次东方照
|
||||
2020-06-07 03:11:00.026179254 +0800 CST // 火星下次西方照
|
||||
2020-01-01 04:41:29.621566236 +0800 CST <nil> // 西安当天火星升起时刻;无错误
|
||||
2020-01-01 14:55:32.963508367 +0800 CST <nil> // 西安当天火星落下时刻;无错误
|
||||
1.57 // 火星视星等
|
||||
2.1844284956325937 // 地火距离,单位 AU
|
||||
1.5897860004265403 // 日火距离,单位 AU
|
||||
@@ -1789,7 +1937,7 @@ func main() {
|
||||
|
||||
```text
|
||||
jupiter DS=54.342153 DE=1.436485 CMI=292.712909 CMII=276.309048 CMIII=147.241811 // 木星子日/子地赤纬,System I/II/III 中央经线,单位度
|
||||
saturn B=-0.608046 Bp=-2.675677 P=4.480276 major=42.709920 minor=0.453246 // 土星环 B、B'、短轴位置角、外缘长短轴,角度单位度,长短轴单位角秒
|
||||
saturn B=-0.608048 Bp=-2.675677 P=4.480276 major=42.709920 minor=0.453248 // 土星环 B、B'、短轴位置角、外缘长短轴,角度单位度,长短轴单位角秒
|
||||
```
|
||||
|
||||
只需要中央经线时,可以单独调用 `CentralMeridians`:
|
||||
@@ -1819,12 +1967,12 @@ fmt.Printf("uranus systemIII lon=%.6f lat=%.6f P=%.6f\n", ura3.SubEarthLongitude
|
||||
- `LastGalileanPhenomenonEvent` / `NextGalileanPhenomenonEvent` / `ClosestGalileanPhenomenonEvent`:搜索整场现象区间
|
||||
- `LastGalileanPhenomenonContactEvent` / `NextGalileanPhenomenonContactEvent` / `ClosestGalileanPhenomenonContactEvent`:搜索 IMCCE 风格的 D/F 接触事件
|
||||
|
||||
这里有两个容易混淆的口径,以木卫一凌日为例:
|
||||
两个口径的区别如下,以木卫一凌日为例:
|
||||
|
||||
- `GalileanPhenomenonEvent` 把卫星看作一个点,判断“卫星圆心是否进入/离开木星圆面”。它返回整段凌日的起止区间,适合快速搜索现象和程序内部状态判断。
|
||||
- `GalileanPhenomenonContactEvent` 把卫星自身的有限圆盘考虑进去,区分初亏到复圆的完整接触过程。它返回消失阶段(D)和再现阶段(R)各自的接触起止与模型中心穿越时刻,适合和 IMCCE 年表中的 `TR.D/TR.F/OC.D/OC.F/EC.D/EC.F/SH.D/SH.F` 逐项对照。
|
||||
|
||||
两个口径的差异在持续时间上最多约 7 分钟——这是模型定义不同造成的,不表示精度问题。用于观测预报或和公开年表逐项核对时,优先使用 `GalileanPhenomenonContactEvent`。
|
||||
两个口径的差异在持续时间上最多约 7 分钟,差异来自模型定义不同。用于观测预报或与公开年表逐项核对时,取 `GalileanPhenomenonContactEvent`。
|
||||
|
||||
##### 代码示例
|
||||
|
||||
@@ -1875,15 +2023,15 @@ func main() {
|
||||
输出结果:
|
||||
|
||||
```text
|
||||
io x=-0.658543 y=-0.035608 front=true // 木卫一相对木星中心的 X/Y 偏移,单位木星半径;位于木星盘面前方
|
||||
europa ra=110.769323 dec=22.335800 // 木卫二视赤经、视赤纬,单位度
|
||||
io x=-0.675026 y=-0.032798 front=true // 木卫一相对木星中心的 X/Y 偏移,单位木星半径;位于木星盘面前方
|
||||
europa ra=110.769133 dec=22.335828 // 木卫二视赤经、视赤纬,单位度
|
||||
io transit=true occultation=false eclipse=false shadow=true // 木卫一正在凌日,且影子正在凌日
|
||||
europa transit=false occultation=false eclipse=false shadow=false // 木卫二此刻无凌日、掩蔽、木星食或影凌
|
||||
event valid=true sat=1 type=transit // 下一次有效事件为木卫一凌日
|
||||
2026-01-16 16:32:47.785289883 +0000 UTC // 木卫一凌日开始
|
||||
2026-01-16 17:40:43.882995843 +0000 UTC // 木卫一凌日中点
|
||||
2026-01-16 18:48:40.519664883 +0000 UTC // 木卫一凌日结束
|
||||
2h15m52.734375s // 木卫一凌日持续时间
|
||||
2026-01-16 16:32:47.552742362 +0000 UTC // 木卫一凌日开始
|
||||
2026-01-16 17:40:44.189371168 +0000 UTC // 木卫一凌日中点
|
||||
2026-01-16 18:48:40.287077128 +0000 UTC // 木卫一凌日结束
|
||||
2h15m52.734334766s // 木卫一凌日持续时间
|
||||
contact valid=true sat=2 type=occultation // 下一次有效接触事件为木卫二掩蔽
|
||||
2026-01-17 01:00:34.99533087 +0000 UTC // 木卫二掩蔽消失阶段开始
|
||||
2026-01-17 01:02:31.714070141 +0000 UTC // 木卫二掩蔽消失阶段模型中心穿越
|
||||
@@ -1903,14 +2051,14 @@ contact valid=true sat=2 type=occultation // 下一次有效接触事件为木
|
||||
|
||||
当前测试结果可概括为:
|
||||
|
||||
- `Satellites` 相对木星中心的位置,对 JPL Horizons 的样例最大偏差约为 `X=0.252"`、`Y=0.108"`。
|
||||
- `Satellites` 相对木星中心的位置,对 JPL Horizons 的样例最大偏差约为 `X=0.054"`、`Y=0.048"`。
|
||||
- `SatellitePhenomena` 的影凌影心偏移,对 JPL Horizons 的样例最大偏差约为 `X=0.051"`、`Y=0.016"`,现象布尔标志在样例中一致。
|
||||
- `GalileanPhenomenonContactEvent` 对 IMCCE 2026 年表,当前样例中接触起止时刻最大偏差约 `72 s`,接触持续时间最大偏差约 `17 s`。
|
||||
- `GalileanPhenomenonEvent` 不是 IMCCE 的 D/F 接触口径;如果拿它去直接对 IMCCE 起止时刻,当前样例会出现最多约 `7` 分钟的差异。这是事件定义不同,不应当按“时间精度差”解读。
|
||||
- `GalileanPhenomenonContactEvent` 对 IMCCE 2026 年表(8 个样例、D1/D2/F1/F2 四个接触逐值对拍):接触时刻最大偏差约 `79 s`,接触持续时间最大偏差约 `17 s`,回归测试按 `120 s` / `25 s` 上限钉住。
|
||||
- `GalileanPhenomenonEvent` 不是 IMCCE 的 D/F 接触口径;与 IMCCE 起止时刻直接对照时,当前样例的最大差异约 `7` 分钟,来源是事件定义不同。
|
||||
|
||||
### 恒星
|
||||
|
||||
1. 本程序自带9100颗恒星的数据库,能够自动计算自行
|
||||
1. 本程序自带 9100 颗恒星的数据库(BSC / HR 编号 `1–9110`,视星等 `-1.46`~`7.96`),能够自动计算自行
|
||||
|
||||
```go
|
||||
package main
|
||||
@@ -1953,11 +2101,11 @@ func main() {
|
||||
```
|
||||
|
||||
```
|
||||
2019-12-31 19:22:56.176710426 +0800 CST // 天狼星升起时刻
|
||||
2020-01-01 05:30:39.834894239 +0800 CST // 天狼星落下时刻
|
||||
2019-12-31 19:22:56.144202053 +0800 CST // 天狼星升起时刻
|
||||
2020-01-01 05:30:39.802506566 +0800 CST // 天狼星落下时刻
|
||||
大犬座 // 天狼星所在星座
|
||||
5h58m5.71s // 织女一在公元 13600 年的赤经
|
||||
84°19′26.13″ // 织女一在公元 13600 年的赤纬
|
||||
6h3m46.61s // 织女一在公元 13600 年的赤经
|
||||
84°18′27.15″ // 织女一在公元 13600 年的赤纬
|
||||
天狼 Sirius -1.46 // 最亮恒星表第一项:中文名、英文常用名、视星等
|
||||
```
|
||||
|
||||
@@ -2010,13 +2158,13 @@ func main() {
|
||||
```text
|
||||
143.72223158223719 19.53512536790277
|
||||
43.46959597099446 -17.686623571613737 107.68662357161374
|
||||
143.99353431082105 18.7404068044953
|
||||
144.2551242046188 18.790254631841993
|
||||
manual az=281.869347 alt=24.489608 zen=65.510392 ha=73.866900
|
||||
gal lon=0.000047 lat=-0.000079
|
||||
apparent alt=10.093429
|
||||
apparent alt=10.093428
|
||||
```
|
||||
|
||||
`coord` 里的研究型接口不会自动代入当前日期的黄赤交角或恒星时,适合做“不同自转轴倾角”“手工指定时角”这类推演。常规观测计算仍建议使用 `EclipticToEquatorial`、`EquatorialToHorizontal` 等带 `time.Time` 的接口。
|
||||
`coord` 里的研究型接口不会自动代入当前日期的黄赤交角或恒星时,适合做“不同自转轴倾角”“手工指定时角”这类推演。常规计算可用 `EclipticToEquatorial`、`EquatorialToHorizontal` 等带 `time.Time` 的接口。
|
||||
|
||||
观测辅助方面,`coord` 还提供了两类高频小工具:
|
||||
|
||||
@@ -2180,7 +2328,7 @@ fmt.Printf("alt=%.6f az=%.6f rise=%s\n", alt, az, rise.Format(time.RFC3339))
|
||||
|
||||
这些观测接口基于站心视坐标计算,适合直接拿去做小行星、彗星或自定义二体目标的升落和指向辅助。
|
||||
|
||||
另外,`orbit` 里还带了一个非常轻量的视双星求解器,直接按《天文算法》第 55 章的经典表观轨道公式输出位置角和角距:
|
||||
`orbit` 里还带了一个视双星求解器,直接按《天文算法》第 55 章的经典表观轨道公式输出位置角和角距:
|
||||
|
||||
```go
|
||||
gammaVir := orbit.VisualBinaryElements{
|
||||
@@ -2238,9 +2386,7 @@ func main() {
|
||||
- `HorizontalHourLineAngle`:给定纬度和时角,计算水平日晷相对午线的时线角
|
||||
- `HorizontalHourLineAngleAt`:直接用时刻和经纬度求当前时线角
|
||||
|
||||
注意:
|
||||
|
||||
- `MeanSolarTimePoint` / `MeanSolarTimeLine` 中的 `date` 应位于目标地点的地方平太阳时区;最直接的来源就是 `MeanSolarTime(...)` 的返回值。
|
||||
- `MeanSolarTimePoint` / `MeanSolarTimeLine` 的 `date` 位于目标地点的地方平太阳时区,通常是 `MeanSolarTime(...)` 的返回值。
|
||||
- `ZoneTimePoint` / `ZoneTimeLine` 会忽略传入 `date` 的原有时分秒,只使用它的年月日与时区,再把钟面时间替换成参数 `zoneTimeHours`。
|
||||
|
||||
## 已实现
|
||||
@@ -2252,8 +2398,8 @@ func main() {
|
||||
- ✅ 地球偏心率、日地距离、近日点、远日点
|
||||
- ✅ 真平恒星时、星座计算、常用坐标转换、大气折射、大气质量、视差角、银道坐标
|
||||
- ✅ 七大行星坐标、距日距地距离、特殊天象、水星/金星地心凌日、物理星历、视直径、相位、视差角与节点
|
||||
- ✅ 公农历转换(公元前104年-公元3000年)
|
||||
- ✅ 9100+恒星数据库
|
||||
- ✅ 公农历转换(公元前721年至公元3000年)
|
||||
- ✅ 9100 颗恒星数据库
|
||||
- ✅ 通用小天体轨道传播、H-G 视星等、视双星位置角/角距
|
||||
- ✅ 黑体辐射、会合周期、星等、望远镜、大气质量等研究公式
|
||||
- ✅ 真太阳时、平面日晷几何、水平日晷时线角
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Package astro
|
||||
// Package astro 进程级 ΔT 模型的读取、替换与恢复默认:DeltaT、SetDeltaT、DefaultDeltaT。
|
||||
// Package astro exposes the process-wide DeltaT model: read the current function, install a custom one, or restore the built-in default.
|
||||
package astro
|
||||
|
||||
import "b612.me/astro/basic"
|
||||
|
||||
+172
-33
@@ -1,7 +1,9 @@
|
||||
// 根包回归:testdata 的位级基线与 n 截断契约(n<0 用全项,n>=0 保留约 n 个主项)。
|
||||
package astro_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
@@ -52,6 +54,122 @@ func loadBaselineSamples(t *testing.T) []baselineSample {
|
||||
return samples
|
||||
}
|
||||
|
||||
// truncationTermCounts 是截断检验抽取的 n 值。
|
||||
var truncationTermCounts = []int{2, 4, 8, 16}
|
||||
|
||||
// angularDifference 两个角度量的最小夹角,单位度。
|
||||
func angularDifference(a, b float64) float64 {
|
||||
d := math.Mod(a-b, 360)
|
||||
if d > 180 {
|
||||
d -= 360
|
||||
}
|
||||
if d < -180 {
|
||||
d += 360
|
||||
}
|
||||
return math.Abs(d)
|
||||
}
|
||||
|
||||
// truncationCase 是一个截断探针:err 给出第 sample 个采样点在截断项数 n 下相对全项 n<0 的偏差。
|
||||
type truncationCase struct {
|
||||
name string
|
||||
tol []float64
|
||||
err func(sample, n int) float64
|
||||
}
|
||||
|
||||
// scalarTruncation 构造标量探针;jd 按采样索引取值,angular 为真时偏差取角度最小夹角。
|
||||
func scalarTruncation(name string, jd func(index int) float64, f func(float64, int) float64, angular bool, tol []float64) truncationCase {
|
||||
return truncationCase{
|
||||
name: name,
|
||||
tol: tol,
|
||||
err: func(sample, n int) float64 {
|
||||
got := f(jd(sample), n)
|
||||
full := f(jd(sample), -1)
|
||||
if angular {
|
||||
return angularDifference(got, full)
|
||||
}
|
||||
return math.Abs(got - full)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// pairTruncation 构造经纬成对返回的探针,取两个分量偏差的较大者。
|
||||
func pairTruncation(name string, jd func(index int) float64, f func(float64, int) (float64, float64), tol []float64) truncationCase {
|
||||
return truncationCase{
|
||||
name: name,
|
||||
tol: tol,
|
||||
err: func(sample, n int) float64 {
|
||||
gotA, gotB := f(jd(sample), n)
|
||||
fullA, fullB := f(jd(sample), -1)
|
||||
return math.Max(angularDifference(gotA, fullA), angularDifference(gotB, fullB))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// dateTruncation 构造以 time.Time 为入口的探针,偏差按角度最小夹角计。
|
||||
func dateTruncation(name string, date func(index int) time.Time, f func(time.Time, int) float64, tol []float64) truncationCase {
|
||||
return truncationCase{
|
||||
name: name,
|
||||
tol: tol,
|
||||
err: func(sample, n int) float64 {
|
||||
return angularDifference(f(date(sample), n), f(date(sample), -1))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// baselineDates 解析基线样本的 UTC 时刻。
|
||||
func baselineDates(t *testing.T, samples []baselineSample) []time.Time {
|
||||
t.Helper()
|
||||
|
||||
dates := make([]time.Time, len(samples))
|
||||
for i, sample := range samples {
|
||||
parsed, err := time.Parse(time.RFC3339Nano, sample.UTC)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dates[i] = parsed
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
// assertTruncationConverges 逐采样点检验误差上限,并要求最坏误差随 n 不增、多数采样点严格下降。
|
||||
// 单点截断误差不是嵌套部分和,允许同量级抖动,因此“不增”约束的是采样集上的误差包络。
|
||||
func assertTruncationConverges(t *testing.T, cases []truncationCase, samples int) {
|
||||
t.Helper()
|
||||
|
||||
for _, tc := range cases {
|
||||
envelope := make([]float64, len(truncationTermCounts))
|
||||
strict := 0
|
||||
for sample := 0; sample < samples; sample++ {
|
||||
first, last := 0.0, 0.0
|
||||
for i, n := range truncationTermCounts {
|
||||
e := tc.err(sample, n)
|
||||
if e > tc.tol[i] {
|
||||
t.Fatalf("%s: sample %d truncation error %.6g at n=%d exceeds %.6g", tc.name, sample, e, n, tc.tol[i])
|
||||
}
|
||||
if e > envelope[i] {
|
||||
envelope[i] = e
|
||||
}
|
||||
if i == 0 {
|
||||
first = e
|
||||
}
|
||||
last = e
|
||||
}
|
||||
if last < first {
|
||||
strict++
|
||||
}
|
||||
}
|
||||
for i := 1; i < len(envelope); i++ {
|
||||
if envelope[i] > envelope[i-1] {
|
||||
t.Fatalf("%s: worst truncation error grows from %.6g at n=%d to %.6g at n=%d",
|
||||
tc.name, envelope[i-1], truncationTermCounts[i-1], envelope[i], truncationTermCounts[i])
|
||||
}
|
||||
}
|
||||
if 2*strict < samples {
|
||||
t.Fatalf("%s: truncation error shrank with n at only %d of %d samples", tc.name, strict, samples)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetMoonBaselineRegression(t *testing.T) {
|
||||
samples := loadBaselineSamples(t)
|
||||
for _, sample := range samples {
|
||||
@@ -60,64 +178,85 @@ func TestPlanetMoonBaselineRegression(t *testing.T) {
|
||||
if math.Float64bits(gotLon) != body.LonBits {
|
||||
t.Fatalf("%s lon regression at %s", body.Name, sample.UTC)
|
||||
}
|
||||
gotLonN := planet.WherePlanetN(body.XT, 0, sample.TTJD, -1)
|
||||
if math.Float64bits(gotLonN) != body.LonBits {
|
||||
t.Fatalf("%s lon full-n regression at %s", body.Name, sample.UTC)
|
||||
}
|
||||
|
||||
gotLat := planet.WherePlanet(body.XT, 1, sample.TTJD)
|
||||
if math.Float64bits(gotLat) != body.LatBits {
|
||||
t.Fatalf("%s lat regression at %s", body.Name, sample.UTC)
|
||||
}
|
||||
gotLatN := planet.WherePlanetN(body.XT, 1, sample.TTJD, -1)
|
||||
if math.Float64bits(gotLatN) != body.LatBits {
|
||||
t.Fatalf("%s lat full-n regression at %s", body.Name, sample.UTC)
|
||||
}
|
||||
|
||||
gotRad := planet.WherePlanet(body.XT, 2, sample.TTJD)
|
||||
if math.Float64bits(gotRad) != body.RadBits {
|
||||
t.Fatalf("%s rad regression at %s", body.Name, sample.UTC)
|
||||
}
|
||||
gotRadN := planet.WherePlanetN(body.XT, 2, sample.TTJD, -1)
|
||||
if math.Float64bits(gotRadN) != body.RadBits {
|
||||
t.Fatalf("%s rad full-n regression at %s", body.Name, sample.UTC)
|
||||
}
|
||||
}
|
||||
|
||||
if math.Float64bits(basic.HMoonTrueLo(sample.TTJD)) != sample.Moon.LonBits {
|
||||
t.Fatalf("moon lon regression at %s", sample.UTC)
|
||||
}
|
||||
if math.Float64bits(basic.HMoonTrueLoN(sample.TTJD, -1)) != sample.Moon.LonBits {
|
||||
t.Fatalf("moon lon full-n regression at %s", sample.UTC)
|
||||
}
|
||||
if math.Float64bits(basic.HMoonTrueBo(sample.TTJD)) != sample.Moon.LatBits {
|
||||
t.Fatalf("moon lat regression at %s", sample.UTC)
|
||||
}
|
||||
if math.Float64bits(basic.HMoonTrueBoN(sample.TTJD, -1)) != sample.Moon.LatBits {
|
||||
t.Fatalf("moon lat full-n regression at %s", sample.UTC)
|
||||
}
|
||||
if math.Float64bits(basic.HMoonAway(sample.TTJD)) != sample.Moon.DisBits {
|
||||
t.Fatalf("moon distance regression at %s", sample.UTC)
|
||||
}
|
||||
if math.Float64bits(basic.HMoonAwayN(sample.TTJD, -1)) != sample.Moon.DisBits {
|
||||
t.Fatalf("moon distance full-n regression at %s", sample.UTC)
|
||||
}
|
||||
}
|
||||
|
||||
// planetTruncationName 与 planetTruncationTolerance 的下标同 WherePlanetN 的 xt。
|
||||
var planetTruncationName = []string{"earth", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune"}
|
||||
|
||||
// planetTruncationTolerance 是 zn=黄经/黄纬(度)、日心距(AU)两种量纲的上限,取实测最坏值的 5 倍以上。
|
||||
var planetTruncationTolerance = [][]float64{
|
||||
{20, 2, 0.5, 0.1},
|
||||
{5, 0.5, 0.05, 0.01},
|
||||
{0.3, 0.1, 0.05, 0.02},
|
||||
}
|
||||
|
||||
func planetMoonTruncationCases(samples []baselineSample) []truncationCase {
|
||||
jdOf := func(index int) float64 { return samples[index].TTJD }
|
||||
|
||||
wherePlanet := func(xt, zn int) truncationCase {
|
||||
return scalarTruncation(
|
||||
fmt.Sprintf("planet.WherePlanetN(%s, zn=%d)", planetTruncationName[xt], zn),
|
||||
jdOf,
|
||||
func(jd float64, n int) float64 { return planet.WherePlanetN(xt, zn, jd, n) },
|
||||
zn != 2,
|
||||
planetTruncationTolerance[zn],
|
||||
)
|
||||
}
|
||||
|
||||
cases := make([]truncationCase, 0, 27)
|
||||
for xt := 0; xt <= 7; xt++ {
|
||||
for zn := 0; zn <= 2; zn++ {
|
||||
cases = append(cases, wherePlanet(xt, zn))
|
||||
}
|
||||
}
|
||||
return append(cases,
|
||||
scalarTruncation("basic.HMoonTrueLoN", jdOf, basic.HMoonTrueLoN, true, []float64{5, 2.5, 1, 0.25}),
|
||||
scalarTruncation("basic.HMoonTrueBoN", jdOf, basic.HMoonTrueBoN, true, []float64{3, 0.75, 0.15, 0.06}),
|
||||
scalarTruncation("basic.HMoonAwayN", jdOf, basic.HMoonAwayN, false, []float64{40000, 7000, 2500, 400}),
|
||||
)
|
||||
}
|
||||
|
||||
func TestPublicTruncationFullMatchesDefault(t *testing.T) {
|
||||
date := time.Date(2026, 1, 2, 3, 4, 5, 123456789, time.UTC)
|
||||
func TestPlanetMoonTruncationConvergesToFullSeries(t *testing.T) {
|
||||
samples := loadBaselineSamples(t)
|
||||
assertTruncationConverges(t, planetMoonTruncationCases(samples), len(samples))
|
||||
}
|
||||
|
||||
if math.Float64bits(sun.TrueLo(date)) != math.Float64bits(sun.TrueLoN(date, -1)) {
|
||||
t.Fatal("sun.TrueLoN(-1) should match default")
|
||||
}
|
||||
if math.Float64bits(sun.TrueBo(date)) != math.Float64bits(sun.TrueBoN(date, -1)) {
|
||||
t.Fatal("sun.TrueBoN(-1) should match default")
|
||||
}
|
||||
if math.Float64bits(moon.TrueLo(date)) != math.Float64bits(moon.TrueLoN(date, -1)) {
|
||||
t.Fatal("moon.TrueLoN(-1) should match default")
|
||||
}
|
||||
if math.Float64bits(moon.TrueBo(date)) != math.Float64bits(moon.TrueBoN(date, -1)) {
|
||||
t.Fatal("moon.TrueBoN(-1) should match default")
|
||||
func publicSunMoonTruncationCases(t *testing.T, samples []baselineSample) []truncationCase {
|
||||
t.Helper()
|
||||
|
||||
dates := baselineDates(t, samples)
|
||||
dateOf := func(index int) time.Time { return dates[index] }
|
||||
return []truncationCase{
|
||||
dateTruncation("sun.TrueLoN", dateOf, sun.TrueLoN, []float64{0.15, 0.05, 0.02, 0.006}),
|
||||
dateTruncation("sun.TrueBoN", dateOf, sun.TrueBoN, []float64{1e-3, 5e-4, 3e-4, 1.5e-4}),
|
||||
dateTruncation("moon.TrueLoN", dateOf, moon.TrueLoN, []float64{5, 2.5, 1, 0.25}),
|
||||
dateTruncation("moon.TrueBoN", dateOf, moon.TrueBoN, []float64{3, 0.75, 0.15, 0.06}),
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicSunMoonTruncationConvergesToFullSeries(t *testing.T) {
|
||||
samples := loadBaselineSamples(t)
|
||||
assertTruncationConverges(t, publicSunMoonTruncationCases(t, samples), len(samples))
|
||||
}
|
||||
|
||||
+13
-1
@@ -145,14 +145,26 @@ func moonApsisSeedTT(k float64) float64 {
|
||||
}
|
||||
|
||||
func refineDistanceExtremum(seed float64, cfg apsisSearchConfig, distanceFn func(float64) float64) (float64, float64) {
|
||||
if !finite(seed) || !finite(cfg.bracketHalfWidth) || !finite(cfg.sampleStep) || cfg.sampleStep <= 0 {
|
||||
return seed, math.NaN()
|
||||
}
|
||||
best := seed
|
||||
bestDistance := distanceFn(seed)
|
||||
for sample := seed - cfg.bracketHalfWidth; sample <= seed+cfg.bracketHalfWidth+1e-12; sample += cfg.sampleStep {
|
||||
// seed 幅度很大时 sample += sampleStep 可能小于一个 ULP(浮点间隔)而永不推进,
|
||||
// 因此显式检测步长是否真的改变了采样点,避免无界循环。
|
||||
// At a large |seed| the step can fall below one ULP, so `sample += step` would never
|
||||
// advance; detect a step that does not move the sample and stop the sweep instead.
|
||||
for sample, end := seed-cfg.bracketHalfWidth, seed+cfg.bracketHalfWidth+1e-12; sample <= end; {
|
||||
dist := distanceFn(sample)
|
||||
if distanceBetter(dist, bestDistance, cfg.maximize) {
|
||||
best = sample
|
||||
bestDistance = dist
|
||||
}
|
||||
next := sample + cfg.sampleStep
|
||||
if next == sample {
|
||||
break
|
||||
}
|
||||
sample = next
|
||||
}
|
||||
|
||||
left, right, ok := apsisDerivativeBracket(best, seed, cfg, distanceFn)
|
||||
|
||||
+14
-5
@@ -10,7 +10,8 @@ import (
|
||||
* 坐标变换,黄道转赤道
|
||||
*/
|
||||
func LoToRa(jde, lo, bo float64) float64 {
|
||||
ra := math.Atan2(Sin(lo)*Cos(TrueObliquity(jde))-Tan(bo)*Sin(TrueObliquity(jde)), Cos(lo))
|
||||
eps := TrueObliquity(jde)
|
||||
ra := math.Atan2(Sin(lo)*Cos(eps)-Tan(bo)*Sin(eps), Cos(lo))
|
||||
ra = ra * 180 / math.Pi
|
||||
if ra < 0 {
|
||||
ra += 360
|
||||
@@ -19,13 +20,17 @@ func LoToRa(jde, lo, bo float64) float64 {
|
||||
}
|
||||
|
||||
func BoToDec(jde, lo, bo float64) float64 {
|
||||
dec := ArcSin(Sin(bo)*Cos(TrueObliquity(jde)) + Cos(bo)*Sin(TrueObliquity(jde))*Sin(lo))
|
||||
eps := TrueObliquity(jde)
|
||||
dec := ArcSin(Sin(bo)*Cos(eps) + Cos(bo)*Sin(eps)*Sin(lo))
|
||||
return dec
|
||||
}
|
||||
|
||||
func LoBoToRaDec(jde, lo, bo float64) (float64, float64) {
|
||||
dec := ArcSin(Sin(bo)*Cos(TrueObliquity(jde)) + Cos(bo)*Sin(TrueObliquity(jde))*Sin(lo))
|
||||
ra := math.Atan2(Sin(lo)*Cos(TrueObliquity(jde))-Tan(bo)*Sin(TrueObliquity(jde)), Cos(lo))
|
||||
eps := TrueObliquity(jde)
|
||||
sinEps := Sin(eps)
|
||||
cosEps := Cos(eps)
|
||||
dec := ArcSin(Sin(bo)*cosEps + Cos(bo)*sinEps*Sin(lo))
|
||||
ra := math.Atan2(Sin(lo)*cosEps-Tan(bo)*sinEps, Cos(lo))
|
||||
ra = ra * 180 / math.Pi
|
||||
if ra < 0 {
|
||||
ra += 360
|
||||
@@ -81,10 +86,14 @@ func psini(lat, h float64) float64 {
|
||||
}
|
||||
|
||||
func TopocentricRaDec(ra, dec, lat, lon, jd, au, h float64) (float64, float64) {
|
||||
return topocentricRaDecWithSidereal(ra, dec, lat, lon, ApparentSiderealTime(jd)*15, au, h)
|
||||
}
|
||||
|
||||
func topocentricRaDecWithSidereal(ra, dec, lat, lon, siderealDegrees, au, h float64) (float64, float64) {
|
||||
sinpi := Sin(0.0024427777777) / au
|
||||
pcosi := pcosi(lat, h)
|
||||
psini := psini(lat, h)
|
||||
tH := Limit360(ApparentSiderealTime(jd)*15 + lon - ra)
|
||||
tH := Limit360(siderealDegrees + lon - ra)
|
||||
nra := math.Atan2(-pcosi*sinpi*Sin(tH), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
|
||||
|
||||
ndec := math.Atan2((Sin(dec)-psini*sinpi)*Cos(nra), (Cos(dec)-pcosi*sinpi*Cos(tH))) * 180 / math.Pi
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
. "b612.me/astro/tools"
|
||||
)
|
||||
|
||||
func TestEclipticToEquatorialCachedObliquityMatchesRepeatedEvaluation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
jde float64
|
||||
lo float64
|
||||
bo float64
|
||||
}{
|
||||
{jde: 2451545.0, lo: 0, bo: 0},
|
||||
{jde: 2460832.0, lo: 193.25, bo: -5.75},
|
||||
{jde: 2378496.5, lo: 359.999, bo: 87.5},
|
||||
} {
|
||||
legacyDec := ArcSin(
|
||||
Sin(test.bo)*Cos(TrueObliquity(test.jde)) +
|
||||
Cos(test.bo)*Sin(TrueObliquity(test.jde))*Sin(test.lo),
|
||||
)
|
||||
legacyRA := math.Atan2(
|
||||
Sin(test.lo)*Cos(TrueObliquity(test.jde))-Tan(test.bo)*Sin(TrueObliquity(test.jde)),
|
||||
Cos(test.lo),
|
||||
) * 180 / math.Pi
|
||||
if legacyRA < 0 {
|
||||
legacyRA += 360
|
||||
}
|
||||
|
||||
ra, dec := LoBoToRaDec(test.jde, test.lo, test.bo)
|
||||
if ra != legacyRA || dec != legacyDec {
|
||||
t.Fatalf("LoBoToRaDec(%v, %v, %v) = %.15g %.15g, want %.15g %.15g",
|
||||
test.jde, test.lo, test.bo, ra, dec, legacyRA, legacyDec)
|
||||
}
|
||||
if got := LoToRa(test.jde, test.lo, test.bo); got != legacyRA {
|
||||
t.Fatalf("LoToRa(%v, %v, %v) = %.15g, want %.15g", test.jde, test.lo, test.bo, got, legacyRA)
|
||||
}
|
||||
if got := BoToDec(test.jde, test.lo, test.bo); got != legacyDec {
|
||||
t.Fatalf("BoToDec(%v, %v, %v) = %.15g, want %.15g", test.jde, test.lo, test.bo, got, legacyDec)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件核实太阳中天(floor 正午锚点)与月亮中天(floor+0.5 午夜锚点)两套锚点:
|
||||
// 调用方按各自约定补偿后,两者都必须落在同一个本地民用日,并接近当日本地日的真实中天。
|
||||
//
|
||||
// 框架约定(由各自调用方固定下来):
|
||||
// - SunHeight/HMoonHeight 的 jd 是本地民用时框架,本地民用日从 Date2JDE(本地 0 时) 起算;
|
||||
// - basic.CulminationTime 的返回值是本地民用时框架,sun/sun.go 减 tz/24 得到 UT;
|
||||
// - basic.MoonCulminationTime 的返回值就是本地民用时框架,moon/moon.go 按 byZone=true 使用。
|
||||
|
||||
type culminationAnchorSite struct {
|
||||
name string
|
||||
lon float64
|
||||
lat float64
|
||||
tz float64
|
||||
}
|
||||
|
||||
var culminationAnchorSites = []culminationAnchorSite{
|
||||
{"beijing", 116.4074, 39.9042, 8},
|
||||
{"utc", 0, 0, 0},
|
||||
{"newyork", -74, 40.7, -5},
|
||||
{"adelaide", 138.6, -34.9, 9.5},
|
||||
{"chatham", -176.5, -43.9, 12.75},
|
||||
{"kiritimati", -157.4, 1.9, 14},
|
||||
}
|
||||
|
||||
func culminationAnchorLocalMidnight(site culminationAnchorSite, timestamp time.Time) (time.Time, float64) {
|
||||
location := time.FixedZone("anchor", int(site.tz*3600))
|
||||
local := time.Date(timestamp.Year(), timestamp.Month(), timestamp.Day(), 0, 0, 0, 0, location)
|
||||
return local, Date2JDE(local)
|
||||
}
|
||||
|
||||
func TestSunCulminationAnchorKeepsLocalDay(t *testing.T) {
|
||||
timestamp := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for _, site := range culminationAnchorSites {
|
||||
local, midnightJD := culminationAnchorLocalMidnight(site, timestamp)
|
||||
location := local.Location()
|
||||
// sun/sun.go 的补偿:本地 0 时 JD 再加半天,使 floor 落在同一本地日;随后减 tz/24 得 UT。
|
||||
got := JDE2DateByZone(CulminationTime(midnightJD+0.5, site.lon, site.tz)-site.tz/24, location, false)
|
||||
truthJD, truthAltitude := 0.0, -999.0
|
||||
for minute := 0; minute <= 24*60; minute++ {
|
||||
jd := midnightJD + float64(minute)/1440.0
|
||||
if altitude := SunHeight(jd, site.lon, site.lat, site.tz); altitude > truthAltitude {
|
||||
truthAltitude, truthJD = altitude, jd
|
||||
}
|
||||
}
|
||||
truth := JDE2DateByZone(truthJD-site.tz/24, location, false)
|
||||
if got.Day() != local.Day() || got.Month() != local.Month() {
|
||||
t.Fatalf("%s: sun culmination = %s, want local day %s", site.name, got.Format("2006-01-02 15:04"), local.Format("2006-01-02"))
|
||||
}
|
||||
if deviation := math.Abs(got.Sub(truth).Minutes()); deviation > 1.5 {
|
||||
t.Fatalf("%s: sun culmination deviates %.2f min from the daily altitude maximum", site.name, deviation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoonCulminationAnchorKeepsLocalDay(t *testing.T) {
|
||||
timestamp := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for _, site := range culminationAnchorSites {
|
||||
local, midnightJD := culminationAnchorLocalMidnight(site, timestamp)
|
||||
location := local.Location()
|
||||
got := JDE2DateByZone(MoonCulminationTime(midnightJD, site.lon, site.lat, site.tz), location, true)
|
||||
truthJD, truthAltitude := 0.0, -999.0
|
||||
for minute := 0; minute <= 24*60; minute++ {
|
||||
jd := midnightJD + float64(minute)/1440.0
|
||||
if altitude := HMoonHeight(jd, site.lon, site.lat, site.tz); altitude > truthAltitude {
|
||||
truthAltitude, truthJD = altitude, jd
|
||||
}
|
||||
}
|
||||
truth := JDE2DateByZone(truthJD, location, true)
|
||||
if got.Day() != local.Day() || got.Month() != local.Month() {
|
||||
t.Fatalf("%s: moon culmination = %s, want local day %s", site.name, got.Format("2006-01-02 15:04"), local.Format("2006-01-02"))
|
||||
}
|
||||
if deviation := math.Abs(got.Sub(truth).Minutes()); deviation > 6.0 {
|
||||
t.Fatalf("%s: moon culmination deviates %.2f min from the daily altitude maximum", site.name, deviation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSunCulminationAnchorDiffersFromMidnightAnchor(t *testing.T) {
|
||||
site := culminationAnchorSites[0]
|
||||
_, midnightJD := culminationAnchorLocalMidnight(site, time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
// 不补半天时 floor 落在相邻的 UT 日:这正是调用方必须补偿 +0.5 的原因。
|
||||
uncompensated := CulminationTime(midnightJD, site.lon, site.tz) - site.tz/24
|
||||
compensated := CulminationTime(midnightJD+0.5, site.lon, site.tz) - site.tz/24
|
||||
if math.Abs(uncompensated-compensated) < 0.4 {
|
||||
t.Fatalf("expected the uncompensated anchor to land on an adjacent day: %.6f vs %.6f", uncompensated, compensated)
|
||||
}
|
||||
}
|
||||
+95
-7
@@ -1,26 +1,60 @@
|
||||
package basic
|
||||
|
||||
import "math"
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var defDeltaTFn = DefaultDeltaTv2
|
||||
var deltaTFnMu sync.RWMutex
|
||||
|
||||
// deltaTGeneration 随每次 ΔT 覆盖递增,供依赖 ΔT 的只读记忆表判断自身是否过期。
|
||||
// 起始为 1,使零值缓存条目(世代 0)天然视为未命中。
|
||||
// deltaTGeneration increments on every ΔT override so ΔT-dependent memo tables can detect
|
||||
// staleness. It starts at 1 so a zero-valued cache entry (generation 0) is never a hit.
|
||||
var deltaTGeneration uint64 = 1
|
||||
|
||||
func DeltaT(date float64, isJDE bool) float64 {
|
||||
return defDeltaTFn(date, isJDE)
|
||||
deltaTFnMu.RLock()
|
||||
fn := defDeltaTFn
|
||||
deltaTFnMu.RUnlock()
|
||||
return fn(date, isJDE)
|
||||
}
|
||||
|
||||
func SetDeltaTFn(fn func(float64, bool) float64) {
|
||||
if fn != nil {
|
||||
deltaTFnMu.Lock()
|
||||
defDeltaTFn = fn
|
||||
deltaTGeneration++
|
||||
deltaTFnMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// deltaTGenerationValue 返回当前 ΔT 世代,用于让只读记忆表在 ΔT 改变后整体失效。
|
||||
// deltaTGenerationValue returns the current ΔT generation so memo tables can be invalidated.
|
||||
func deltaTGenerationValue() uint64 {
|
||||
deltaTFnMu.RLock()
|
||||
value := deltaTGeneration
|
||||
deltaTFnMu.RUnlock()
|
||||
return value
|
||||
}
|
||||
|
||||
func GetDeltaTFn() func(float64, bool) float64 {
|
||||
return defDeltaTFn
|
||||
deltaTFnMu.RLock()
|
||||
fn := defDeltaTFn
|
||||
deltaTFnMu.RUnlock()
|
||||
return fn
|
||||
}
|
||||
|
||||
func DefaultDeltaTv2(date float64, isJd bool) float64 { //传入年或儒略日,传出为秒
|
||||
if math.IsNaN(date) || math.IsInf(date, 0) {
|
||||
return math.NaN()
|
||||
}
|
||||
if !isJd {
|
||||
date = JDECalc(int(date), int((date-math.Floor(date))*12)+1, (date-math.Floor(date))*365.25+1)
|
||||
year := math.Floor(date)
|
||||
start := JDECalc(int(year), 1, 1)
|
||||
end := JDECalc(int(year)+1, 1, 1)
|
||||
date = start + (date-year)*(end-start)
|
||||
}
|
||||
return DeltaTv2(date)
|
||||
}
|
||||
@@ -30,6 +64,9 @@ func DefaultDeltaTv2(date float64, isJd bool) float64 { //传入年或儒略日
|
||||
// 2010年后的系数已修改以包含2019年后的数据
|
||||
// 返回Delta T,单位为秒
|
||||
func DeltaTSplineY(y float64) float64 {
|
||||
if math.IsNaN(y) || math.IsInf(y, 0) {
|
||||
return math.NaN()
|
||||
}
|
||||
// 积分lod(平均太阳日偏离86400秒的偏差)方程:
|
||||
// 来自 http://astro.ukho.gov.uk/nao/lvm/:
|
||||
// lod = 1.72 t − 3.5 sin(2*pi*(t+0.75)/14) 单位ms/day,其中 t = (y - 1825)/100
|
||||
@@ -76,6 +113,9 @@ func DeltaTSplineY(y float64) float64 {
|
||||
}
|
||||
|
||||
func DeltaTv2(jd float64) float64 {
|
||||
if math.IsNaN(jd) || math.IsInf(jd, 0) {
|
||||
return math.NaN()
|
||||
}
|
||||
if jd > 2461041.5 || jd < 2441317.5 {
|
||||
var y float64
|
||||
if jd >= 2299160.5 {
|
||||
@@ -97,7 +137,7 @@ func DeltaTv2(jd float64) float64 {
|
||||
n := len(jdLeaps)
|
||||
deltaTSeconds := 42.184
|
||||
for i := 0; i < n; i++ {
|
||||
if jd > jdLeaps[i] {
|
||||
if jd >= jdLeaps[i] {
|
||||
deltaTSeconds += float64(n - i - 1)
|
||||
break
|
||||
}
|
||||
@@ -105,11 +145,59 @@ func DeltaTv2(jd float64) float64 {
|
||||
return deltaTSeconds
|
||||
}
|
||||
|
||||
// DeltaTSecondsAt 返回某个 TT 时刻实际使用的 ΔT(秒):overrideSeconds 是有限值时直接采用
|
||||
// (含 0,可显式要求 ΔT=0),为 NaN/±Inf 时改用进程级模型。模型按 UT 键控,因此这里先解
|
||||
// TT−ΔT(UT) 再求值,不把 TT 直接当作 UT 送进模型(差约 2e-6 s)。
|
||||
// DeltaTSecondsAt returns the ΔT in seconds used at one TT instant: a finite override wins
|
||||
// (including 0, which requests ΔT = 0 explicitly), while NaN or ±Inf selects the process-wide
|
||||
// model. The model is keyed by UT, so the equation TT - ΔT(UT) is solved instead of feeding TT.
|
||||
func DeltaTSecondsAt(jdeTT, overrideSeconds float64) float64 {
|
||||
if !math.IsNaN(overrideSeconds) && !math.IsInf(overrideSeconds, 0) {
|
||||
return overrideSeconds
|
||||
}
|
||||
return deltaTModelSecondsAtTT(jdeTT)
|
||||
}
|
||||
|
||||
func deltaTModelSecondsAtTT(jdeTT float64) float64 {
|
||||
ut := jdeTT - DeltaT(jdeTT, true)/86400.0
|
||||
for iteration := 0; iteration < 4; iteration++ {
|
||||
next := jdeTT - DeltaT(ut, true)/86400.0
|
||||
if next == ut {
|
||||
break
|
||||
}
|
||||
ut = next
|
||||
}
|
||||
return DeltaT(ut, true)
|
||||
}
|
||||
|
||||
// DeltaTGroundShiftKM 把 ΔT 误差换算为站点相对影子的地面横移距离(千米)。
|
||||
// 地球赤道自转线速度 465.1 m/s,因此 ΔT 相差 Δ 秒时,地面点相对影子横移
|
||||
// 0.4651·|Δ|·cos(纬度) 千米;±400 年跨度上 ΔT 外推差几百到几千秒,足以挪动本影
|
||||
// 边界数百千米,调用方可用本函数把外部给出的 ΔT 不确定度换算成几何不确定度。
|
||||
// DeltaTGroundShiftKM converts a ΔT error into the ground displacement of a station
|
||||
// relative to the shadow, in kilometres: 0.4651 * |ΔT| * cos(latitude).
|
||||
func DeltaTGroundShiftKM(deltaTSeconds, latitudeDeg float64) float64 {
|
||||
if math.IsNaN(deltaTSeconds) || math.IsInf(deltaTSeconds, 0) ||
|
||||
math.IsNaN(latitudeDeg) || math.IsInf(latitudeDeg, 0) {
|
||||
return math.NaN()
|
||||
}
|
||||
return solarEclipseEarthEquatorialRotationKMPerSecond * math.Abs(deltaTSeconds) * math.Cos(latitudeDeg*rad)
|
||||
}
|
||||
|
||||
func TD2UT(jde float64, utToTD bool) float64 { // true 世界时转力学时CC,false 力学时转世界时VV
|
||||
deltaTSeconds := DeltaT(jde, true)
|
||||
if utToTD {
|
||||
return jde + deltaTSeconds/3600/24
|
||||
} else {
|
||||
return jde - deltaTSeconds/3600/24
|
||||
}
|
||||
// Delta T is evaluated at UT in the forward conversion. Solve the same
|
||||
// equation in reverse so distant-epoch contact times survive a round trip.
|
||||
ut := jde - deltaTSeconds/3600/24
|
||||
for iteration := 0; iteration < 4; iteration++ {
|
||||
next := jde - DeltaT(ut, true)/3600/24
|
||||
if next == ut {
|
||||
break
|
||||
}
|
||||
ut = next
|
||||
}
|
||||
return ut
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultDeltaTFractionalYear(t *testing.T) {
|
||||
for _, year := range []float64{-700.5, -0.5, 0, 1000.5, 1582.75, 1800.5, 2000.5, 2026.5} {
|
||||
whole := math.Floor(year)
|
||||
start := JDECalc(int(whole), 1, 1)
|
||||
end := JDECalc(int(whole)+1, 1, 1)
|
||||
want := DefaultDeltaTv2(start+(year-whole)*(end-start), true)
|
||||
got := DefaultDeltaTv2(year, false)
|
||||
if math.IsNaN(got) || math.Abs(got-want) > 1e-10 {
|
||||
t.Errorf("year=%v DeltaT=%v, want %v", year, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultDeltaTInvalidInput(t *testing.T) {
|
||||
for _, value := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} {
|
||||
for _, isJD := range []bool{false, true} {
|
||||
if got := DefaultDeltaTv2(value, isJD); !math.IsNaN(got) {
|
||||
t.Errorf("DeltaT(%v, %v)=%v, want NaN", value, isJD, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeltaTLeapSecondBoundary(t *testing.T) {
|
||||
boundary := 2457754.5
|
||||
if got := DeltaTv2(boundary); got != 69.184 {
|
||||
t.Fatalf("DeltaT at leap-second boundary=%v, want 69.184", got)
|
||||
}
|
||||
if got := DeltaTSplineY(math.NaN()); !math.IsNaN(got) {
|
||||
t.Fatalf("DeltaTSplineY(NaN)=%v, want NaN", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTD2UTRoundTrip(t *testing.T) {
|
||||
for _, year := range []int{-2000, -720, 0, 26, 1426, 2025, 2027, 3627, 4026, 5000} {
|
||||
ut := JDECalc(year, 9, 20.123456)
|
||||
tt := TD2UT(ut, true)
|
||||
if got := TD2UT(tt, false); math.Abs(got-ut) > math.Nextafter(ut, math.Inf(1))-ut {
|
||||
t.Errorf("year=%d UT round trip differs by %.9f seconds", year, (got-ut)*86400)
|
||||
}
|
||||
if got := TD2UT(TD2UT(tt, false), true); got != tt {
|
||||
t.Errorf("year=%d TT round trip differs by %.9f seconds", year, (got-tt)*86400)
|
||||
}
|
||||
}
|
||||
for _, seconds := range []float64{-70, -1, -0.1, 0, 0.1, 1, 70} {
|
||||
ut := 2457754.5 + seconds/86400
|
||||
if got := TD2UT(TD2UT(ut, true), false); got != ut {
|
||||
t.Errorf("leap second offset=%g round trip differs by %.9f seconds", seconds, (got-ut)*86400)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTD2UTCustomDeltaT(t *testing.T) {
|
||||
original := GetDeltaTFn()
|
||||
t.Cleanup(func() { SetDeltaTFn(original) })
|
||||
for _, slope := range []float64{0, 0.01} {
|
||||
SetDeltaTFn(func(jd float64, isJD bool) float64 {
|
||||
if !isJD {
|
||||
t.Fatal("conversion must request Delta T at a Julian day")
|
||||
}
|
||||
return 10000 + slope*(jd-2451545)
|
||||
})
|
||||
ut := 3000000.123456
|
||||
if got := TD2UT(TD2UT(ut, true), false); got != ut {
|
||||
t.Errorf("custom slope=%g round trip differs by %.9f seconds", slope, (got-ut)*86400)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeltaTSecondsAt:有限值(含 0)是显式覆盖,NaN/±Inf 才回退进程级模型;模型按 UT 求值。
|
||||
func TestDeltaTSecondsAtOverrideContract(t *testing.T) {
|
||||
jd := 2460310.5
|
||||
if got := DeltaTSecondsAt(jd, 0); got != 0 {
|
||||
t.Fatalf("explicit zero override = %v, want 0", got)
|
||||
}
|
||||
if got := DeltaTSecondsAt(jd, 12.5); got != 12.5 {
|
||||
t.Fatalf("explicit override = %v, want 12.5", got)
|
||||
}
|
||||
model := DeltaTSecondsAt(jd, math.NaN())
|
||||
if !finite(model) || model <= 0 {
|
||||
t.Fatalf("model fallback = %v, want a positive finite value", model)
|
||||
}
|
||||
if got := DeltaTSecondsAt(jd, math.Inf(1)); got != model {
|
||||
t.Fatalf("+Inf override = %v, want the model %v", got, model)
|
||||
}
|
||||
if got := deltaTModelSecondsAtTT(jd); got != model {
|
||||
t.Fatalf("deltaTModelSecondsAtTT = %v, want %v", got, model)
|
||||
}
|
||||
// 模型按 UT 键控:与"把 TT 直接当 UT"的朴素求值有微小差别。
|
||||
naive := DeltaT(jd, true)
|
||||
if diff := math.Abs(naive - model); diff > 1e-3 {
|
||||
t.Fatalf("UT-keyed model differs from naive TT evaluation by %v s", diff)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -36,7 +36,7 @@ func HeightDegreeByLat(height, lat float64) float64 {
|
||||
return math.Acos((radius)/(radius+height)) * 180 / math.Pi
|
||||
}
|
||||
|
||||
// GeocentricRadius 地心直径与纬度的关系
|
||||
// GeocentricRadius 给定纬度处的地心半径,单位米 / geocentric radius at the given latitude, in metres.
|
||||
func GeocentricRadius(lat float64) float64 {
|
||||
a := (EARTH_EQUATORIAL_RADIUS * EARTH_EQUATORIAL_RADIUS * Cos(lat))
|
||||
a *= a
|
||||
|
||||
+24
-26
@@ -5,14 +5,28 @@ import "math"
|
||||
const (
|
||||
exactEventTolerance = 2.0 / 86400.0
|
||||
exactQueryTTToleranceUT = 0.1 / 86400.0
|
||||
// stationQueryToleranceUT 站(留)事件的「同刻」容差 / same-instant tolerance for station events.
|
||||
//
|
||||
// 站的求解本身有数值不确定度(火星 ~0.2 s、金星 ~2 s、外行星改精修步长后 <0.05 s)。
|
||||
// 若与普通事件一样取 0.1 s,查询落在站前几十毫秒时会被判成「站仍在未来」,
|
||||
// 于是跳到上一个会合周期。取 0.5 s 可覆盖除金星外的全部不确定度。
|
||||
// 上限约束:必须明显小于 1 s,否则会破坏
|
||||
// TestInnerPlanetNextEventAdvancesPastReturnedEvent 的「查询=事件+1 秒必须前进」语义。
|
||||
stationQueryToleranceUT = 0.5 / 86400.0
|
||||
)
|
||||
|
||||
func sameEventJD(a, b float64) bool {
|
||||
return math.Abs(a-b) <= exactEventTolerance
|
||||
}
|
||||
|
||||
func sameEventUTQueryTT(eventUT, queryTT float64) bool {
|
||||
return math.Abs(eventUTQueryTTDelta(eventUT, queryTT)) <= exactQueryTTToleranceUT
|
||||
// stationUTQueryBeforeOrEqual / stationUTQueryAfterOrEqual 站事件专用侧向判定,
|
||||
// 容差比普通事件宽(见 stationQueryToleranceUT)。
|
||||
func stationUTQueryBeforeOrEqual(eventUT, queryTT float64) bool {
|
||||
return eventUTQueryTTDelta(eventUT, queryTT) <= stationQueryToleranceUT
|
||||
}
|
||||
|
||||
func stationUTQueryAfterOrEqual(eventUT, queryTT float64) bool {
|
||||
return eventUTQueryTTDelta(eventUT, queryTT) >= -stationQueryToleranceUT
|
||||
}
|
||||
|
||||
func closestEventUTToQueryTT(queryTT, best float64, candidates ...float64) float64 {
|
||||
@@ -30,50 +44,34 @@ func closestEventUTToQueryTT(queryTT, best float64, candidates ...float64) float
|
||||
type phaseEventSearchFunc func(jde, degree float64, next uint8) float64
|
||||
type simpleEventSearchFunc func(jde float64) float64
|
||||
|
||||
// inclusive* 的唯一作用是把「查询几乎正好落在事件上」判成包含:
|
||||
// 反向搜索给出的邻接事件落在同刻容差内时优先返回它,否则返回本方向的事件(可能是 NaN)。
|
||||
func inclusiveLastPhaseEvent(jde, degree float64, fn phaseEventSearchFunc) float64 {
|
||||
last := fn(jde, degree, 0)
|
||||
next := fn(jde, degree, 1)
|
||||
if eventUTQueryBeforeOrEqual(next, jde) && eventUTQueryAfterOrEqual(next, jde) {
|
||||
if next := fn(jde, degree, 1); eventUTQueryBeforeOrEqual(next, jde) && eventUTQueryAfterOrEqual(next, jde) {
|
||||
return next
|
||||
}
|
||||
if eventUTQueryBeforeOrEqual(last, jde) {
|
||||
return last
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func inclusiveNextPhaseEvent(jde, degree float64, fn phaseEventSearchFunc) float64 {
|
||||
last := fn(jde, degree, 0)
|
||||
if eventUTQueryBeforeOrEqual(last, jde) && eventUTQueryAfterOrEqual(last, jde) {
|
||||
if last := fn(jde, degree, 0); eventUTQueryBeforeOrEqual(last, jde) && eventUTQueryAfterOrEqual(last, jde) {
|
||||
return last
|
||||
}
|
||||
next := fn(jde, degree, 1)
|
||||
if eventUTQueryAfterOrEqual(next, jde) {
|
||||
return next
|
||||
}
|
||||
return next
|
||||
return fn(jde, degree, 1)
|
||||
}
|
||||
|
||||
func inclusiveLastSimpleEvent(jde float64, lastFn, nextFn simpleEventSearchFunc) float64 {
|
||||
last := lastFn(jde)
|
||||
next := nextFn(jde)
|
||||
if eventUTQueryBeforeOrEqual(next, jde) && eventUTQueryAfterOrEqual(next, jde) {
|
||||
if next := nextFn(jde); eventUTQueryBeforeOrEqual(next, jde) && eventUTQueryAfterOrEqual(next, jde) {
|
||||
return next
|
||||
}
|
||||
if eventUTQueryBeforeOrEqual(last, jde) {
|
||||
return last
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func inclusiveNextSimpleEvent(jde float64, lastFn, nextFn simpleEventSearchFunc) float64 {
|
||||
last := lastFn(jde)
|
||||
if eventUTQueryBeforeOrEqual(last, jde) && eventUTQueryAfterOrEqual(last, jde) {
|
||||
if last := lastFn(jde); eventUTQueryBeforeOrEqual(last, jde) && eventUTQueryAfterOrEqual(last, jde) {
|
||||
return last
|
||||
}
|
||||
next := nextFn(jde)
|
||||
if eventUTQueryAfterOrEqual(next, jde) {
|
||||
return next
|
||||
}
|
||||
return next
|
||||
return nextFn(jde)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ const (
|
||||
eventNewtonMaxIterations = 24
|
||||
eventDirectionalSearchIterations = 128
|
||||
eventRiseSetScanStep = 1.0 / 1440
|
||||
// stationDerivativeStepDay 「留」精修用的中心差分步长(天)。
|
||||
// 0.5 秒级的步长会被浮点相消噪声支配(实测外行星留误差可达 42 s);
|
||||
// 0.01 天(14.4 分钟)实测误差 <0.05 s,且求值次数不变。
|
||||
stationDerivativeStepDay = 0.01
|
||||
)
|
||||
|
||||
func isFiniteFloat(value float64) bool {
|
||||
@@ -121,6 +125,56 @@ func eventDirectionalBracketRefine(leftJD, rightJD, leftValue, rightValue float6
|
||||
return (leftJD + rightJD) / 2
|
||||
}
|
||||
|
||||
// eventBracketSecantRoot 在已知异号的括号内用割线法(带中点兜底)求根。
|
||||
//
|
||||
// 与 eventDirectionalBracketRefine(固定 48 次二分)相比,割线法通常 5~8 次求值即可达到
|
||||
// 亚秒精度,适合「括号由廉价截断级数给出、抛光必须用全项级数」的两段式搜索。
|
||||
// 括号每一步都收缩,因此不会跑到括号外;括号端点同号或出现非有限值时返回 false。
|
||||
func eventBracketSecantRoot(leftJD, rightJD, leftValue, rightValue, tolerance float64,
|
||||
fn func(float64) float64) (float64, bool) {
|
||||
if leftValue == 0 {
|
||||
return leftJD, true
|
||||
}
|
||||
if rightValue == 0 {
|
||||
return rightJD, true
|
||||
}
|
||||
if !isFiniteFloat(leftValue) || !isFiniteFloat(rightValue) || leftValue*rightValue > 0 {
|
||||
return math.NaN(), false
|
||||
}
|
||||
if !isFiniteFloat(tolerance) || tolerance <= 0 {
|
||||
tolerance = 0.5 / 86400.0
|
||||
}
|
||||
bestJD := (leftJD + rightJD) / 2
|
||||
for i := 0; i < 48; i++ {
|
||||
candidateJD := (leftJD + rightJD) / 2
|
||||
if rightValue != leftValue {
|
||||
secantJD := rightJD - rightValue*(rightJD-leftJD)/(rightValue-leftValue)
|
||||
if secantJD > leftJD && secantJD < rightJD {
|
||||
candidateJD = secantJD
|
||||
}
|
||||
}
|
||||
candidateValue := fn(candidateJD)
|
||||
if !isFiniteFloat(candidateValue) {
|
||||
return math.NaN(), false
|
||||
}
|
||||
bestJD = candidateJD
|
||||
if candidateValue == 0 || math.Abs(rightJD-leftJD) <= tolerance {
|
||||
return candidateJD, true
|
||||
}
|
||||
if (leftValue < 0) == (candidateValue < 0) {
|
||||
leftJD, leftValue = candidateJD, candidateValue
|
||||
continue
|
||||
}
|
||||
rightJD, rightValue = candidateJD, candidateValue
|
||||
}
|
||||
if math.Abs(rightJD-leftJD) <= tolerance {
|
||||
return bestJD, true
|
||||
}
|
||||
// 48 次迭代后括号仍宽于容差(例如容差低于该儒略日的 ULP):调用方把 ok 当作
|
||||
// “已抛光到容差”,这里必须报 false,不能返回一个精度未达标的时刻。
|
||||
return bestJD, false
|
||||
}
|
||||
|
||||
func eventFixedScanRefine(seed, halfWindow, step float64, fn func(float64) float64) float64 {
|
||||
start := seed - halfWindow
|
||||
bestJD := start
|
||||
|
||||
@@ -23,3 +23,19 @@ func TestEventZeroRefineFallsBackToFixedScan(t *testing.T) {
|
||||
t.Fatalf("got %.15f want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 48 次迭代用尽后括号仍宽于容差时不能谎报"已抛光":调用方把 ok 当作精度保证。
|
||||
func TestEventBracketSecantRootRejectsUnpolishedRoot(t *testing.T) {
|
||||
linear := func(x float64) float64 { return x - 0.5 }
|
||||
if _, ok := eventBracketSecantRoot(0, 1, -0.5, 0.5, 1e-9, linear); !ok {
|
||||
t.Fatalf("normal tolerance should return ok=true")
|
||||
}
|
||||
// 无理根(sqrt(2))永远取不到精确零,容差又低于该量级的 ULP:必须报 false。
|
||||
irrational := func(x float64) float64 { return x*x - 2 }
|
||||
if _, ok := eventBracketSecantRoot(0, 2, -2, 2, 1e-25, irrational); ok {
|
||||
t.Fatalf("unreachable tolerance must not report a polished root")
|
||||
}
|
||||
if _, ok := eventBracketSecantRoot(0, 2, -2, 2, 1e-12, irrational); !ok {
|
||||
t.Fatalf("reachable tolerance should still report ok=true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 等时线(食甚/掩甚时刻等值线)的共享常量与工具。
|
||||
// 两个等时线实现(日食、月掩)用同一套容差与时刻取值生成规则,避免口径漂移。
|
||||
|
||||
const (
|
||||
// greatestTimeContourCoverToleranceKM 是"该点是否已被已绘等时线覆盖"的距离门限。
|
||||
// 判据必须用点到折线的距离:同一条曲线被两个种子各画一次时该距离约为 0,而折线弦高在
|
||||
// 1.5° 步长下最大几 km,所以 10 km 既不会漏判重复,也远小于不同支路的间距。
|
||||
greatestTimeContourCoverToleranceKM = 10.0
|
||||
// greatestTimeContourCorrectionBacktracking 是牛顿投影跳出定义域时按二分回退的次数。
|
||||
greatestTimeContourCorrectionBacktracking = 6
|
||||
// greatestTimeContourKMPerDegree 是地面大圆每度的近似长度,用于局部平面投影。
|
||||
greatestTimeContourKMPerDegree = 111.195
|
||||
// greatestTimeContourMaxLevels 是单个事件允许的等时线条数上限;超出部分按时间截断,
|
||||
// 避免极长事件请求出上万条曲线。
|
||||
greatestTimeContourMaxLevels = 64
|
||||
// greatestTimeContourResidualTolerance 是残差零点判定的收敛容差。
|
||||
greatestTimeContourResidualTolerance = 1e-9
|
||||
// greatestTimeContourBisectionIterations 是二分求根的迭代上限。
|
||||
greatestTimeContourBisectionIterations = 64
|
||||
)
|
||||
|
||||
// greatestTimeContourDifference 优先用中心差分,一侧越界时退化为单侧差分。
|
||||
func greatestTimeContourDifference(center, positive, negative, step float64, positiveOK, negativeOK bool) (float64, bool) {
|
||||
switch {
|
||||
case positiveOK && negativeOK:
|
||||
return (positive - negative) / (2 * step), true
|
||||
case positiveOK:
|
||||
return (positive - center) / step, true
|
||||
case negativeOK:
|
||||
return (center - negative) / step, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// greatestTimeContourBisect 在 [left,right] 上二分求残差零点;residual 在定义域外返回 NaN。
|
||||
func greatestTimeContourBisect(
|
||||
residual func(longitude, latitude float64) float64,
|
||||
left, right, latitude, leftValue float64,
|
||||
) (float64, bool) {
|
||||
for iteration := 0; iteration < greatestTimeContourBisectionIterations; iteration++ {
|
||||
middle := (left + right) / 2
|
||||
middleValue := residual(middle, latitude)
|
||||
if !finite(middleValue) {
|
||||
return 0, false
|
||||
}
|
||||
if math.Abs(middleValue) <= greatestTimeContourResidualTolerance || right-left <= 1e-9 {
|
||||
return middle, true
|
||||
}
|
||||
if leftValue*middleValue <= 0 {
|
||||
right = middle
|
||||
} else {
|
||||
left, leftValue = middle, middleValue
|
||||
}
|
||||
}
|
||||
return (left + right) / 2, true
|
||||
}
|
||||
|
||||
// greatestTimeContourTickTolerance 是对齐网格在窗口边界上的容差,远小于任何合法步长。
|
||||
const greatestTimeContourTickTolerance = time.Millisecond
|
||||
|
||||
// greatestTimeContourLevel 是一条等时线对应的时刻取值:TT 儒略日与它对应的时刻必须成对传递,
|
||||
// 前者用于求根,后者用于标注(按步长生成时它是原始对齐时刻,避免 JDE 往返把整分截断成前一分钟)。
|
||||
type greatestTimeContourLevel struct {
|
||||
tt float64
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// greatestTimeContourAlignedLevels 生成覆盖 [startTT,endTT] 且对齐到 step 整刻度的时刻取值,
|
||||
// 最多 maxLevels 条;起点或终点恰好落在整刻度上时该刻度只生成一次。
|
||||
func greatestTimeContourAlignedLevels(startTT, endTT float64, step time.Duration, maxLevels int) []greatestTimeContourLevel {
|
||||
if step <= 0 || maxLevels <= 0 || endTT <= startTT {
|
||||
return nil
|
||||
}
|
||||
start := greatestTimeContourTTToUTC(startTT)
|
||||
first := start.Truncate(step)
|
||||
// TT 儒略日往返有约 40 µs(1 ULP)误差,恰好落在整刻度上的窗口边界会算到刻度外一点点,容差按 1 ms 给。
|
||||
if first.Add(greatestTimeContourTickTolerance).Before(start) {
|
||||
first = first.Add(step)
|
||||
}
|
||||
end := greatestTimeContourTTToUTC(endTT)
|
||||
levels := make([]greatestTimeContourLevel, 0, maxLevels)
|
||||
for current := first; !current.Add(-greatestTimeContourTickTolerance).After(end) && len(levels) < maxLevels; current = current.Add(step) {
|
||||
levels = append(levels, greatestTimeContourLevel{tt: greatestTimeContourUTCToTT(current), at: current})
|
||||
}
|
||||
return levels
|
||||
}
|
||||
|
||||
// greatestTimeContourTTToUTC 把 TT 儒略日换成对应的 UTC 时刻。
|
||||
func greatestTimeContourTTToUTC(tt float64) time.Time {
|
||||
return JDE2DateByZone(TD2UT(tt, false), time.UTC, false)
|
||||
}
|
||||
|
||||
// greatestTimeContourUTCToTT 把 UTC 时刻换成对应的 TT 儒略日。
|
||||
func greatestTimeContourUTCToTT(value time.Time) float64 {
|
||||
return TD2UT(Date2JDE(value.UTC()), true)
|
||||
}
|
||||
|
||||
// greatestTimeContourPointSegmentKM 返回点到折线段的距离,单位 km。
|
||||
// 段长只有 1–2 度,用局部等距圆柱近似即可,误差远小于覆盖容差。
|
||||
func greatestTimeContourPointSegmentKM(longitude, latitude, startLongitude, startLatitude, endLongitude, endLatitude float64) float64 {
|
||||
cosine := math.Cos(latitude * rad)
|
||||
toX := func(value float64) float64 {
|
||||
return normalizeLongitude(value-startLongitude) * rad * cosine * greatestTimeContourKMPerDegree
|
||||
}
|
||||
toY := func(value float64) float64 { return (value - startLatitude) * greatestTimeContourKMPerDegree }
|
||||
pointX, pointY := toX(longitude), toY(latitude)
|
||||
endX, endY := toX(endLongitude), toY(endLatitude)
|
||||
lengthSquared := endX*endX + endY*endY
|
||||
projection := 0.0
|
||||
if lengthSquared > 0 {
|
||||
projection = (pointX*endX + pointY*endY) / lengthSquared
|
||||
if projection < 0 {
|
||||
projection = 0
|
||||
} else if projection > 1 {
|
||||
projection = 1
|
||||
}
|
||||
}
|
||||
return math.Hypot(pointX-projection*endX, pointY-projection*endY)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 对齐网格是导出行为(等时线按整刻度取值)的契约,取值本身写成字面量以免与实际生成函数互相自证。
|
||||
func TestGreatestTimeContourAlignedLevelsGridContracts(t *testing.T) {
|
||||
tt := func(hour, minute int) float64 {
|
||||
return TD2UT(Date2JDE(time.Date(2024, 1, 1, hour, minute, 0, 0, time.UTC)), true)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
start, end float64
|
||||
step time.Duration
|
||||
maxLevels int
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "起点在刻度之间时从下一个整刻度开始", start: tt(0, 7), end: tt(2, 7),
|
||||
step: 30 * time.Minute, maxLevels: 64,
|
||||
want: []string{"2024-01-01T00:30:00Z", "2024-01-01T01:00:00Z", "2024-01-01T01:30:00Z", "2024-01-01T02:00:00Z"},
|
||||
},
|
||||
{
|
||||
name: "起点恰在刻度上时不重复生成该刻度", start: tt(0, 30), end: tt(2, 0),
|
||||
step: 30 * time.Minute, maxLevels: 64,
|
||||
want: []string{"2024-01-01T00:30:00Z", "2024-01-01T01:00:00Z", "2024-01-01T01:30:00Z", "2024-01-01T02:00:00Z"},
|
||||
},
|
||||
{
|
||||
name: "终点恰在刻度上时包含终点", start: tt(0, 7), end: tt(1, 30),
|
||||
step: 30 * time.Minute, maxLevels: 64,
|
||||
want: []string{"2024-01-01T00:30:00Z", "2024-01-01T01:00:00Z", "2024-01-01T01:30:00Z"},
|
||||
},
|
||||
{
|
||||
name: "上限截断", start: tt(0, 7), end: tt(5, 7),
|
||||
step: 30 * time.Minute, maxLevels: 3,
|
||||
want: []string{"2024-01-01T00:30:00Z", "2024-01-01T01:00:00Z", "2024-01-01T01:30:00Z"},
|
||||
},
|
||||
{
|
||||
name: "窗口内没有整刻度时为空", start: tt(0, 1), end: tt(0, 20),
|
||||
step: time.Hour, maxLevels: 64,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "非正步长或倒置窗口为空", start: tt(0, 7), end: tt(2, 7),
|
||||
step: 0, maxLevels: 64, want: nil,
|
||||
},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
levels := greatestTimeContourAlignedLevels(testCase.start, testCase.end, testCase.step, testCase.maxLevels)
|
||||
if len(levels) != len(testCase.want) {
|
||||
t.Fatalf("%s: %d levels, want %d", testCase.name, len(levels), len(testCase.want))
|
||||
}
|
||||
for index, level := range levels {
|
||||
got := level.at.UTC().Format(time.RFC3339)
|
||||
if got != testCase.want[index] {
|
||||
t.Fatalf("%s: level %d at %s, want %s", testCase.name, index, got, testCase.want[index])
|
||||
}
|
||||
// 标注时刻与求根时刻必须成对:TT 往返 1 ULP 内一致。
|
||||
if roundTrip := greatestTimeContourUTCToTT(level.at); roundTrip != level.tt {
|
||||
t.Fatalf("%s: level %d tt %.9f != %.9f", testCase.name, index, level.tt, roundTrip)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,8 @@ func maximizeInWindow(start, end, coarseStep float64, coarseFn, exactFn func(flo
|
||||
if right-left <= innerEventMaximizeEpsilon {
|
||||
return guess
|
||||
}
|
||||
// 注意:这里必须全部用全项函数迭代。粗阶段(截断函数)虽然更便宜,但截断峰位与全项峰位
|
||||
// 相差分钟量级,粗段省下的求值次数会被精段为达到同等精度而多花掉,因此不做两段式拆分。
|
||||
for i := 0; i < 20; i++ {
|
||||
third := (right - left) / 3.0
|
||||
leftThird := left + third
|
||||
|
||||
@@ -93,8 +93,20 @@ func parseInnerBaselineTime(t *testing.T, value string) time.Time {
|
||||
|
||||
func innerBaselineTolerance(event innerBaselineEvent) time.Duration {
|
||||
switch event.Kind {
|
||||
case "IC", "SC", "P2R", "R2P":
|
||||
return 2 * time.Minute
|
||||
case "IC", "SC":
|
||||
// 2197 年附近双方 ΔT 外推已差约 2 分钟(校正了 EarthPI 的 0.00046·T² 项之后,
|
||||
// 三个 2197 样本从 −119.5 s 移到 −121…−126 s),因此与留点同样留 3 分钟余量;
|
||||
// 真实的搜索错误是小时/天级,靠这个容差仍能抓住。
|
||||
// Around 2197 the two ΔT extrapolations already differ by about two minutes (after
|
||||
// correcting the EarthPI 0.00046*T^2 term, three 2197 samples moved from -119.5 s to
|
||||
// -121..-126 s), so conjunctions get the same 3-minute margin as the stations.
|
||||
// Real search errors are hours or days, so this still catches them.
|
||||
return 3 * time.Minute
|
||||
case "P2R", "R2P":
|
||||
// 留是 RA 的极值点:星历模型中 ~1e-6 度/天的变化率差异就会被放大成分钟级时刻差,
|
||||
// 且 JPL 给出的 UT 时刻还叠加了各自的 ΔT 外推(2197 年附近两者已差 ~2 分钟)。
|
||||
// 这里留 3 分钟余量,仍远小于任何真实的搜索错误(小时/天级)。
|
||||
return 3 * time.Minute
|
||||
case "GEE", "GEW":
|
||||
return 90 * time.Minute
|
||||
default:
|
||||
|
||||
+62
-10
@@ -172,6 +172,9 @@ func LastJupiterWesternQuadrature(jde float64) float64 {
|
||||
}
|
||||
|
||||
func jupiterRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool) float64 {
|
||||
if !isFiniteFloat(oppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
oppositionTT := TD2UT(oppositionJD, true)
|
||||
startTT := oppositionTT
|
||||
endTT := oppositionTT
|
||||
@@ -183,49 +186,98 @@ func jupiterRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposit
|
||||
endTT = TD2UT(westernQuadratureUT, true)
|
||||
}
|
||||
bestJD := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 {
|
||||
return jupiterRADerivativeN(jd, 1.0/86400.0, jupiterEventSearchN)
|
||||
return jupiterRADerivativeN(jd, stationDerivativeStepDay, jupiterEventSearchN)
|
||||
}, func(jd float64) float64 {
|
||||
return jupiterRADerivative(jd, 0.5/86400.0)
|
||||
return jupiterRADerivative(jd, stationDerivativeStepDay)
|
||||
})
|
||||
return TD2UT(bestJD, false)
|
||||
}
|
||||
|
||||
func NextJupiterRetrogradeToPrograde(jde float64) float64 {
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := jupiterConjunctionFull(jde, 180, 0)
|
||||
date := jupiterRetrogradeAroundOpposition(lastOppositionJD, false)
|
||||
if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) {
|
||||
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
nextOppositionJD := jupiterConjunctionFull(jde, 180, 1)
|
||||
return jupiterRetrogradeAroundOpposition(nextOppositionJD, false)
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = jupiterRetrogradeAroundOpposition(nextOppositionJD, false)
|
||||
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func LastJupiterRetrogradeToPrograde(jde float64) float64 {
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := jupiterConjunctionFull(jde, 180, 0)
|
||||
date := jupiterRetrogradeAroundOpposition(lastOppositionJD, false)
|
||||
if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) {
|
||||
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(lastOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
previousOppositionJD := jupiterConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0)
|
||||
return jupiterRetrogradeAroundOpposition(previousOppositionJD, false)
|
||||
if !isFiniteFloat(previousOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = jupiterRetrogradeAroundOpposition(previousOppositionJD, false)
|
||||
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func NextJupiterProgradeToRetrograde(jde float64) float64 {
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
nextOppositionJD := jupiterConjunctionFull(jde, 180, 1)
|
||||
date := jupiterRetrogradeAroundOpposition(nextOppositionJD, true)
|
||||
if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) {
|
||||
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
followingOppositionJD := jupiterConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1)
|
||||
return jupiterRetrogradeAroundOpposition(followingOppositionJD, true)
|
||||
if !isFiniteFloat(followingOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = jupiterRetrogradeAroundOpposition(followingOppositionJD, true)
|
||||
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func LastJupiterProgradeToRetrograde(jde float64) float64 {
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
nextOppositionJD := jupiterConjunctionFull(jde, 180, 1)
|
||||
date := jupiterRetrogradeAroundOpposition(nextOppositionJD, true)
|
||||
if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) {
|
||||
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := jupiterConjunctionFull(jde, 180, 0)
|
||||
return jupiterRetrogradeAroundOpposition(lastOppositionJD, true)
|
||||
if !isFiniteFloat(lastOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = jupiterRetrogradeAroundOpposition(lastOppositionJD, true)
|
||||
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
@@ -336,25 +336,6 @@ func refineJupiterGalileanShadowContactPair(
|
||||
}, true
|
||||
}
|
||||
|
||||
func refineJupiterGalileanNegativeMetricWindow(
|
||||
seedJD float64,
|
||||
metric func(jd float64) float64,
|
||||
) (float64, float64, bool) {
|
||||
activeJD, activeValue, ok := findJupiterGalileanNegativeMetricSeed(seedJD, metric)
|
||||
if !ok {
|
||||
return math.NaN(), math.NaN(), false
|
||||
}
|
||||
start, ok := refineJupiterGalileanNegativeWindowRoot(activeJD, activeValue, -1, metric)
|
||||
if !ok {
|
||||
return math.NaN(), math.NaN(), false
|
||||
}
|
||||
end, ok := refineJupiterGalileanNegativeWindowRoot(activeJD, activeValue, 1, metric)
|
||||
if !ok {
|
||||
return math.NaN(), math.NaN(), false
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
func findJupiterGalileanNegativeMetricSeed(
|
||||
seedJD float64,
|
||||
metric func(jd float64) float64,
|
||||
@@ -593,18 +574,6 @@ func jupiterPenumbraScale(distanceBehindAU, sunDistanceAU float64) float64 {
|
||||
return 1 + distanceBehindAU*(solarRadiusAU+jupiterRadiusAU)/(sunDistanceAU*jupiterRadiusAU)
|
||||
}
|
||||
|
||||
func jupiterGalileanUmbraRadiusJupiterRadii(satellite int, pathLengthAU, sunDistanceAU float64) float64 {
|
||||
if pathLengthAU <= 0 || sunDistanceAU <= 0 {
|
||||
return math.NaN()
|
||||
}
|
||||
satelliteRadiusAU := jupiterGalileanSatelliteRadiusJupiterRadii(satellite) * jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM
|
||||
umbraRadiusAU := satelliteRadiusAU - pathLengthAU*(solarRadiusAU-satelliteRadiusAU)/sunDistanceAU
|
||||
if umbraRadiusAU <= 0 {
|
||||
return math.NaN()
|
||||
}
|
||||
return umbraRadiusAU / (jupiterGalileanEquatorialRadiusKM / astronomicalUnitKM)
|
||||
}
|
||||
|
||||
func jupiterGalileanPenumbraRadiusJupiterRadii(satellite int, pathLengthAU, sunDistanceAU float64) float64 {
|
||||
if pathLengthAU <= 0 || sunDistanceAU <= 0 {
|
||||
return math.NaN()
|
||||
|
||||
@@ -39,8 +39,10 @@ type JupiterGalileanPhenomenonEvent struct {
|
||||
}
|
||||
|
||||
type jupiterGalileanMetricSample struct {
|
||||
active bool
|
||||
metric float64
|
||||
active bool
|
||||
metric float64
|
||||
// signed 是该现象相对木星视面中心线的有符号偏移(木星半径),每次过零对应一次现象;未定义时为 NaN。
|
||||
signed float64
|
||||
phenomenon JupiterGalileanPhenomenon
|
||||
}
|
||||
|
||||
@@ -55,18 +57,24 @@ type jupiterGalileanShadowPoint struct {
|
||||
}
|
||||
|
||||
// LastJupiterGalileanPhenomenonEvent 上一次伽利略卫星现象 / previous Galilean-satellite event.
|
||||
//
|
||||
// jd 与返回时刻都是 UTC/UT 儒略日;需要瞬时状态时先用 TD2UT(jd, true) 换成 TT。
|
||||
func LastJupiterGalileanPhenomenonEvent(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonEvent {
|
||||
event, _ := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, -1, true)
|
||||
return event
|
||||
}
|
||||
|
||||
// NextJupiterGalileanPhenomenonEvent 下一次伽利略卫星现象 / next Galilean-satellite event.
|
||||
//
|
||||
// jd 与返回时刻都是 UTC/UT 儒略日;需要瞬时状态时先用 TD2UT(jd, true) 换成 TT。
|
||||
func NextJupiterGalileanPhenomenonEvent(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonEvent {
|
||||
event, _ := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, 1, false)
|
||||
return event
|
||||
}
|
||||
|
||||
// ClosestJupiterGalileanPhenomenonEvent 最近一次伽利略卫星现象 / closest Galilean-satellite event.
|
||||
//
|
||||
// jd 与返回时刻都是 UTC/UT 儒略日;需要瞬时状态时先用 TD2UT(jd, true) 换成 TT。
|
||||
func ClosestJupiterGalileanPhenomenonEvent(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonEvent {
|
||||
last, hasLast := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, -1, true)
|
||||
next, hasNext := searchJupiterGalileanPhenomenonEvent(jd, satellite, phenomenonType, 1, false)
|
||||
@@ -121,14 +129,11 @@ func searchJupiterGalileanPhenomenonEvent(
|
||||
for i := 2; i <= maxSteps; i++ {
|
||||
nextTime := jd + sign*float64(i)*stepDays
|
||||
nextSample := jupiterGalileanPhenomenonMetricAt(nextTime, satellite, phenomenonType)
|
||||
if isFinite(midSample.metric) &&
|
||||
midSample.metric <= prevSample.metric &&
|
||||
midSample.metric <= nextSample.metric {
|
||||
candidate := refineJupiterGalileanMetricMinimum(prevTime, nextTime, satellite, phenomenonType)
|
||||
event := findJupiterGalileanPhenomenonEventAround(candidate, satellite, phenomenonType)
|
||||
if event.Valid && jupiterGalileanEventMatchesDirection(event.Greatest, jd, direction, includeCurrent) {
|
||||
return event, true
|
||||
}
|
||||
if event, ok := jupiterGalileanIntervalCandidate(
|
||||
prevTime, nextTime, prevSample, midSample, nextSample,
|
||||
jd, satellite, phenomenonType, direction, includeCurrent,
|
||||
); ok {
|
||||
return event, true
|
||||
}
|
||||
prevTime, prevSample = midTime, midSample
|
||||
midTime, midSample = nextTime, nextSample
|
||||
@@ -137,6 +142,132 @@ func searchJupiterGalileanPhenomenonEvent(
|
||||
return invalidJupiterGalileanPhenomenonEvent(), false
|
||||
}
|
||||
|
||||
// jupiterGalileanIntervalCandidate 在粗扫区间内确认现象:度量局部极小,或有符号偏移过零。
|
||||
func jupiterGalileanIntervalCandidate(
|
||||
prevTime, nextTime float64,
|
||||
prevSample, midSample, nextSample jupiterGalileanMetricSample,
|
||||
jd float64,
|
||||
satellite int,
|
||||
phenomenonType JupiterGalileanPhenomenonType,
|
||||
direction int,
|
||||
includeCurrent bool,
|
||||
) (JupiterGalileanPhenomenonEvent, bool) {
|
||||
var best JupiterGalileanPhenomenonEvent
|
||||
found := false
|
||||
consider := func(event JupiterGalileanPhenomenonEvent) {
|
||||
if !event.Valid ||
|
||||
!jupiterGalileanEventMatchesDirection(event.Greatest, jd, direction, includeCurrent) {
|
||||
return
|
||||
}
|
||||
if !found ||
|
||||
(direction > 0 && event.Greatest < best.Greatest) ||
|
||||
(direction < 0 && event.Greatest > best.Greatest) {
|
||||
best, found = event, true
|
||||
}
|
||||
}
|
||||
if isFinite(midSample.metric) &&
|
||||
midSample.metric <= prevSample.metric &&
|
||||
midSample.metric <= nextSample.metric {
|
||||
consider(findJupiterGalileanPhenomenonEventAround(
|
||||
refineJupiterGalileanMetricMinimum(prevTime, nextTime, satellite, phenomenonType),
|
||||
satellite, phenomenonType,
|
||||
))
|
||||
}
|
||||
if jupiterGalileanSignedCrosses(prevSample.signed, nextSample.signed) &&
|
||||
jupiterGalileanNearDisk(prevSample, midSample, nextSample) {
|
||||
crossing := refineJupiterGalileanSignedCrossing(prevTime, nextTime, satellite, phenomenonType)
|
||||
// 过零点只说明"靠近木星",只在它已贴近视面时按事件尺度细扫一次。
|
||||
if sample := jupiterGalileanPhenomenonMetricAt(crossing, satellite, phenomenonType); isFinite(sample.metric) &&
|
||||
sample.metric <= jupiterGalileanCrossingProbeMetric {
|
||||
if event, ok := jupiterGalileanFineScanAround(crossing, satellite, phenomenonType); ok {
|
||||
consider(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
return best, found
|
||||
}
|
||||
|
||||
const (
|
||||
// jupiterGalileanCrossingProbeMetric 是细扫门限(椭球度量,1 为视面边缘)。
|
||||
jupiterGalileanCrossingProbeMetric = 2.5
|
||||
// jupiterGalileanCrossingProbeDays 是过零点两侧的细扫半宽。
|
||||
jupiterGalileanCrossingProbeDays = 15.0 / 1440.0
|
||||
// jupiterGalileanCrossingProbeStepDays 是细扫步长(30 秒),短于最短的擦边事件。
|
||||
jupiterGalileanCrossingProbeStepDays = 0.5 / 1440.0
|
||||
)
|
||||
|
||||
// jupiterGalileanNearDisk 报告区间内是否已有贴近木星视面的采样点。
|
||||
func jupiterGalileanNearDisk(prevSample, midSample, nextSample jupiterGalileanMetricSample) bool {
|
||||
for _, metric := range [3]float64{prevSample.metric, midSample.metric, nextSample.metric} {
|
||||
if isFinite(metric) && metric <= jupiterGalileanCrossingProbeMetric {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// jupiterGalileanFineScanAround 在过零点邻域按事件尺度取样,命中后交给事件求解。
|
||||
func jupiterGalileanFineScanAround(
|
||||
jd float64,
|
||||
satellite int,
|
||||
phenomenonType JupiterGalileanPhenomenonType,
|
||||
) (JupiterGalileanPhenomenonEvent, bool) {
|
||||
// 从过零点向两侧交替外扩,通常几步内就能落进事件窗口。
|
||||
steps := int(math.Round(jupiterGalileanCrossingProbeDays / jupiterGalileanCrossingProbeStepDays))
|
||||
for step := 0; step <= steps; step++ {
|
||||
for _, sign := range [2]float64{1, -1} {
|
||||
if step == 0 && sign < 0 {
|
||||
continue
|
||||
}
|
||||
candidate := jd + sign*float64(step)*jupiterGalileanCrossingProbeStepDays
|
||||
if !jupiterGalileanPhenomenonMetricAt(candidate, satellite, phenomenonType).active {
|
||||
continue
|
||||
}
|
||||
if event := findJupiterGalileanPhenomenonEventAround(candidate, satellite, phenomenonType); event.Valid {
|
||||
return event, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return JupiterGalileanPhenomenonEvent{}, false
|
||||
}
|
||||
|
||||
// jupiterGalileanSignedCrosses 判断两次采样的有符号偏移是否变号(端点为 0 也算)。
|
||||
func jupiterGalileanSignedCrosses(first, second float64) bool {
|
||||
if !isFinite(first) || !isFinite(second) {
|
||||
return false
|
||||
}
|
||||
if first == 0 || second == 0 {
|
||||
return true
|
||||
}
|
||||
return (first < 0) != (second < 0)
|
||||
}
|
||||
|
||||
// refineJupiterGalileanSignedCrossing 用二分把过零时刻收敛到事件容差内。
|
||||
func refineJupiterGalileanSignedCrossing(
|
||||
left, right float64,
|
||||
satellite int,
|
||||
phenomenonType JupiterGalileanPhenomenonType,
|
||||
) float64 {
|
||||
leftValue := jupiterGalileanPhenomenonMetricAt(left, satellite, phenomenonType).signed
|
||||
rightValue := jupiterGalileanPhenomenonMetricAt(right, satellite, phenomenonType).signed
|
||||
if !isFinite(leftValue) || !isFinite(rightValue) || (leftValue < 0) == (rightValue < 0) {
|
||||
return (left + right) / 2
|
||||
}
|
||||
for i := 0; i < 80 && right-left > jupiterGalileanEventEpsilonDays; i++ {
|
||||
middle := (left + right) / 2
|
||||
value := jupiterGalileanPhenomenonMetricAt(middle, satellite, phenomenonType).signed
|
||||
if !isFinite(value) {
|
||||
return middle
|
||||
}
|
||||
if (value < 0) == (leftValue < 0) {
|
||||
left, leftValue = middle, value
|
||||
} else {
|
||||
right, rightValue = middle, value
|
||||
}
|
||||
}
|
||||
return (left + right) / 2
|
||||
}
|
||||
|
||||
func findJupiterGalileanPhenomenonEventAround(jd float64, satellite int, phenomenonType JupiterGalileanPhenomenonType) JupiterGalileanPhenomenonEvent {
|
||||
sample := jupiterGalileanPhenomenonMetricAt(jd, satellite, phenomenonType)
|
||||
if !sample.active {
|
||||
@@ -292,6 +423,7 @@ func jupiterGalileanPhenomenonMetricAt(
|
||||
if !isFinite(jd) || satellite < 1 || satellite > 4 || !isValidJupiterGalileanPhenomenonType(phenomenonType) {
|
||||
return jupiterGalileanMetricSample{
|
||||
metric: math.Inf(1),
|
||||
signed: math.NaN(),
|
||||
phenomenon: invalidJupiterGalileanPhenomenon(),
|
||||
}
|
||||
}
|
||||
@@ -301,6 +433,7 @@ func jupiterGalileanPhenomenonMetricAt(
|
||||
if context.jupiterDistance == 0 {
|
||||
return jupiterGalileanMetricSample{
|
||||
metric: math.Inf(1),
|
||||
signed: math.NaN(),
|
||||
phenomenon: invalidJupiterGalileanPhenomenon(),
|
||||
}
|
||||
}
|
||||
@@ -360,27 +493,41 @@ func jupiterGalileanPhenomenonMetricAt(
|
||||
if !observation.InFrontOfJupiter {
|
||||
metric += 4
|
||||
}
|
||||
return jupiterGalileanMetricSample{active: phenomenon.Transit, metric: metric, phenomenon: phenomenon}
|
||||
return jupiterGalileanMetricSample{
|
||||
active: phenomenon.Transit, metric: metric, signed: xEarth, phenomenon: phenomenon,
|
||||
}
|
||||
case JupiterGalileanOccultation:
|
||||
metric := earthMetric
|
||||
if observation.InFrontOfJupiter {
|
||||
metric += 4
|
||||
}
|
||||
return jupiterGalileanMetricSample{active: phenomenon.Occultation, metric: metric, phenomenon: phenomenon}
|
||||
return jupiterGalileanMetricSample{
|
||||
active: phenomenon.Occultation, metric: metric, signed: xEarth, phenomenon: phenomenon,
|
||||
}
|
||||
case JupiterGalileanEclipse:
|
||||
metric := sunMetric
|
||||
if zSunAU <= 0 {
|
||||
metric += 4
|
||||
}
|
||||
return jupiterGalileanMetricSample{active: phenomenon.Eclipse, metric: metric, phenomenon: phenomenon}
|
||||
return jupiterGalileanMetricSample{
|
||||
active: phenomenon.Eclipse, metric: metric, signed: xSun, phenomenon: phenomenon,
|
||||
}
|
||||
case JupiterGalileanShadowTransit:
|
||||
metric := shadowMetric
|
||||
if shadowPoint.hasIntersection && !shadowPoint.visible {
|
||||
metric += 4
|
||||
}
|
||||
return jupiterGalileanMetricSample{active: phenomenon.ShadowTransit, metric: metric, phenomenon: phenomenon}
|
||||
signed := math.NaN()
|
||||
if shadowPoint.hasIntersection {
|
||||
signed = shadowPoint.xJupiterRadii
|
||||
}
|
||||
return jupiterGalileanMetricSample{
|
||||
active: phenomenon.ShadowTransit, metric: metric, signed: signed, phenomenon: phenomenon,
|
||||
}
|
||||
default:
|
||||
return jupiterGalileanMetricSample{metric: math.Inf(1), phenomenon: invalidJupiterGalileanPhenomenon()}
|
||||
return jupiterGalileanMetricSample{
|
||||
metric: math.Inf(1), signed: math.NaN(), phenomenon: invalidJupiterGalileanPhenomenon(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,3 +143,39 @@ func mustParseRFC3339Nano(t *testing.T, value string) time.Time {
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func TestJupiterGalileanSearchFindsShortGrazingTransit(t *testing.T) {
|
||||
// 2463144 附近 Callisto 有一次擦边凌日:Start 2463143.9993 / Greatest 2463144.0015 /
|
||||
// End 2463144.0038,全场只有 6.4 分钟,整段落在 2 小时粗采样之间。旧实现看不到度量
|
||||
// 凹陷,会把 16.85 天后的下一次凌日当成 "next"(Valid 仍为 true,属静默跳事件)。
|
||||
// Callisto has a grazing transit near 2463144: Start 2463143.9993, Greatest
|
||||
// 2463144.0015, End 2463144.0038 — only 6.4 minutes, entirely between two two-hour
|
||||
// coarse samples. The old search saw no metric dip and reported the next transit 16.85
|
||||
// days later as "next", still with Valid=true (a silent skip).
|
||||
closest := ClosestJupiterGalileanPhenomenonEvent(2463144.0, 4, JupiterGalileanTransit)
|
||||
if !closest.Valid {
|
||||
t.Fatal("no Callisto transit found at 2463144")
|
||||
}
|
||||
if duration := closest.End - closest.Start; duration > 0.01 {
|
||||
t.Fatalf("fixture changed: transit duration %.4f d, want the 6.4-minute grazing event", duration)
|
||||
}
|
||||
|
||||
next := NextJupiterGalileanPhenomenonEvent(2463143.9646, 4, JupiterGalileanTransit)
|
||||
if !next.Valid {
|
||||
t.Fatal("NextJupiterGalileanPhenomenonEvent returned nothing")
|
||||
}
|
||||
if offset := next.Greatest - 2463143.9646; offset > 0.1 {
|
||||
t.Fatalf("next transit is %.3f d away, want the 53-minute one (the search skipped it)", offset)
|
||||
}
|
||||
if math.Abs(next.Greatest-closest.Greatest) > 1e-5 {
|
||||
t.Fatalf("next greatest %.9f, want the same event as closest %.9f", next.Greatest, closest.Greatest)
|
||||
}
|
||||
|
||||
last := LastJupiterGalileanPhenomenonEvent(2463144.05, 4, JupiterGalileanTransit)
|
||||
if !last.Valid {
|
||||
t.Fatal("LastJupiterGalileanPhenomenonEvent returned nothing")
|
||||
}
|
||||
if math.Abs(last.Greatest-closest.Greatest) > 1e-5 {
|
||||
t.Fatalf("last greatest %.9f, want the same event as closest %.9f", last.Greatest, closest.Greatest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ type jupiterGalileanL1Term struct {
|
||||
// JupiterGalileanState 木星伽利略卫星原始状态 / raw Galilean-satellite state.
|
||||
//
|
||||
// 输入 jd 使用 TT/TDB 对应的儒略日;返回值为 IMCCE L1 理论的木心 J2000 平赤道直角坐标与速度,单位 AU / AU/day。
|
||||
// The input jd is a TT/TDB Julian day. Returned coordinates are Jovicentric J2000 mean-equatorial position and velocity from the IMCCE L1 theory, in AU and AU/day.
|
||||
// The input jd is a TT/TDB Julian day (convert a UT query with TD2UT(jd, true)). Returned coordinates are Jovicentric J2000 mean-equatorial position and velocity from the IMCCE L1 theory, in AU and AU/day.
|
||||
type JupiterGalileanState struct {
|
||||
X float64
|
||||
Y float64
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package basic
|
||||
|
||||
import "math"
|
||||
|
||||
// localEphemerisVectorNode is a pair of Cartesian ephemeris samples at one
|
||||
// TT. Cartesian interpolation avoids right-ascension wraparound at 0/360.
|
||||
type localEphemerisVectorNode struct {
|
||||
tt float64
|
||||
first, next [3]float64
|
||||
}
|
||||
|
||||
const (
|
||||
solarLocalEphemerisNodeCount = 13
|
||||
solarLocalEphemerisStepDays = 1.0 / 24.0
|
||||
occultationLocalEphemerisNodeCount = 49
|
||||
occultationLocalEphemerisStepDays = 2.0 / 24.0
|
||||
)
|
||||
|
||||
func interpolateLocalEphemerisVectors(
|
||||
nodes []localEphemerisVectorNode,
|
||||
tt float64,
|
||||
) ([3]float64, [3]float64, bool) {
|
||||
const interpolationPoints = 6
|
||||
if len(nodes) < interpolationPoints || !finite(tt) {
|
||||
return [3]float64{}, [3]float64{}, false
|
||||
}
|
||||
step := nodes[1].tt - nodes[0].tt
|
||||
if !finite(step) || step <= 0 {
|
||||
return [3]float64{}, [3]float64{}, false
|
||||
}
|
||||
u := (tt - nodes[0].tt) / step
|
||||
if u < 0 || u > float64(len(nodes)-1) {
|
||||
return [3]float64{}, [3]float64{}, false
|
||||
}
|
||||
start := int(math.Floor(u)) - 2
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if start > len(nodes)-interpolationPoints {
|
||||
start = len(nodes) - interpolationPoints
|
||||
}
|
||||
var weights [interpolationPoints]float64
|
||||
for point := 0; point < interpolationPoints; point++ {
|
||||
x := float64(start + point)
|
||||
weight := 1.0
|
||||
for other := 0; other < interpolationPoints; other++ {
|
||||
if other == point {
|
||||
continue
|
||||
}
|
||||
xOther := float64(start + other)
|
||||
weight *= (u - xOther) / (x - xOther)
|
||||
}
|
||||
weights[point] = weight
|
||||
}
|
||||
var first, next [3]float64
|
||||
for coordinate := 0; coordinate < 3; coordinate++ {
|
||||
for point := 0; point < interpolationPoints; point++ {
|
||||
first[coordinate] += weights[point] * nodes[start+point].first[coordinate]
|
||||
next[coordinate] += weights[point] * nodes[start+point].next[coordinate]
|
||||
}
|
||||
}
|
||||
return first, next, finiteVector3(first) && finiteVector3(next)
|
||||
}
|
||||
|
||||
func finiteVector3(value [3]float64) bool {
|
||||
return finite(value[0]) && finite(value[1]) && finite(value[2])
|
||||
}
|
||||
|
||||
func occultationPathVectorRaDec(vector occultationPathVector) (float64, float64, float64, bool) {
|
||||
distance := occultationPathNorm(vector)
|
||||
if !finite(distance) || distance <= 0 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
ra := math.Atan2(vector.y, vector.x) / rad
|
||||
dec := math.Asin(math.Max(-1, math.Min(1, vector.z/distance))) / rad
|
||||
return ra, dec, distance, finite(ra) && finite(dec)
|
||||
}
|
||||
|
||||
type solarEclipseLocalEphemeris struct {
|
||||
nodes []localEphemerisVectorNode
|
||||
}
|
||||
|
||||
func newSolarEclipseLocalEphemeris(center float64) *solarEclipseLocalEphemeris {
|
||||
half := solarLocalEphemerisNodeCount / 2
|
||||
nodes := make([]localEphemerisVectorNode, solarLocalEphemerisNodeCount)
|
||||
for index := range nodes {
|
||||
tt := center + float64(index-half)*solarLocalEphemerisStepDays
|
||||
sun, moon := solarEclipseSunMoonEquatorial(tt)
|
||||
nodes[index] = localEphemerisVectorNode{
|
||||
tt: tt,
|
||||
first: solarEclipseLLRToXYZ(sun[0], sun[1], sun[2]),
|
||||
next: solarEclipseLLRToXYZ(moon[0], moon[1], moon[2]),
|
||||
}
|
||||
}
|
||||
return &solarEclipseLocalEphemeris{nodes: nodes}
|
||||
}
|
||||
|
||||
func (ephemeris *solarEclipseLocalEphemeris) equatorialAt(tt float64) ([3]float64, [3]float64, bool) {
|
||||
var empty [3]float64
|
||||
if ephemeris == nil {
|
||||
return empty, empty, false
|
||||
}
|
||||
sunXYZ, moonXYZ, ok := interpolateLocalEphemerisVectors(ephemeris.nodes, tt)
|
||||
if !ok {
|
||||
return empty, empty, false
|
||||
}
|
||||
return solarEclipseXYZToLLR(sunXYZ[0], sunXYZ[1], sunXYZ[2]),
|
||||
solarEclipseXYZToLLR(moonXYZ[0], moonXYZ[1], moonXYZ[2]), true
|
||||
}
|
||||
|
||||
type starOccultationLocalEphemeris struct {
|
||||
star StarCoordinate
|
||||
nodes []localEphemerisVectorNode
|
||||
dense bool
|
||||
}
|
||||
|
||||
func newStarOccultationLocalEphemeris(center float64, star StarCoordinate) *starOccultationLocalEphemeris {
|
||||
half := occultationLocalEphemerisNodeCount / 2
|
||||
nodes := make([]localEphemerisVectorNode, occultationLocalEphemerisNodeCount)
|
||||
for index := range nodes {
|
||||
tt := center + float64(index-half)*occultationLocalEphemerisStepDays
|
||||
state := starOccultationEphemerisStateAt(tt, star)
|
||||
moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM)
|
||||
targetDistance := state.starDistanceKM
|
||||
if targetDistance <= 0 {
|
||||
// 恒星距离未知时只需方向:按单位球方向装配,几何只用归一化后的矢量。
|
||||
targetDistance = 1
|
||||
}
|
||||
target := occultationPathRaDecVector(state.starRA, state.starDec, targetDistance)
|
||||
nodes[index] = localEphemerisVectorNode{
|
||||
tt: tt,
|
||||
first: [3]float64{moon.x, moon.y, moon.z},
|
||||
next: [3]float64{target.x, target.y, target.z},
|
||||
}
|
||||
}
|
||||
return &starOccultationLocalEphemeris{star: star, nodes: nodes}
|
||||
}
|
||||
|
||||
func (ephemeris *starOccultationLocalEphemeris) stateAt(tt float64) (starOccultationEphemerisState, bool) {
|
||||
moonXYZ, targetXYZ, ok := ephemeris.vectorsAt(tt)
|
||||
if !ok {
|
||||
return starOccultationEphemerisState{}, false
|
||||
}
|
||||
moonRA, moonDec, moonDistance, moonOK := occultationPathVectorRaDec(occultationPathVector{
|
||||
x: moonXYZ[0], y: moonXYZ[1], z: moonXYZ[2],
|
||||
})
|
||||
targetRA, targetDec, _, targetOK := occultationPathVectorRaDec(occultationPathVector{
|
||||
x: targetXYZ[0], y: targetXYZ[1], z: targetXYZ[2],
|
||||
})
|
||||
if !moonOK || !targetOK {
|
||||
return starOccultationEphemerisState{}, false
|
||||
}
|
||||
return starOccultationEphemerisState{
|
||||
moonRA: moonRA, moonDec: moonDec, moonDistanceKM: moonDistance,
|
||||
starRA: targetRA, starDec: targetDec, starDistanceKM: ephemeris.starDistanceKM(),
|
||||
valid: true,
|
||||
}, true
|
||||
}
|
||||
|
||||
func (ephemeris *starOccultationLocalEphemeris) vectorsAt(tt float64) ([3]float64, [3]float64, bool) {
|
||||
if ephemeris == nil {
|
||||
return [3]float64{}, [3]float64{}, false
|
||||
}
|
||||
if ephemeris.dense {
|
||||
return interpolateDenseOccultationVectors(ephemeris.nodes, tt)
|
||||
}
|
||||
return interpolateLocalEphemerisVectors(ephemeris.nodes, tt)
|
||||
}
|
||||
|
||||
func (ephemeris *starOccultationLocalEphemeris) starDistanceKM() float64 {
|
||||
if ephemeris.star.ParallaxMas <= 0 {
|
||||
return 0
|
||||
}
|
||||
return 206264806.247 / ephemeris.star.ParallaxMas * occultationPathAstronomicalUnitKM
|
||||
}
|
||||
|
||||
type planetOccultationLocalEphemeris struct {
|
||||
nodes []localEphemerisVectorNode
|
||||
dense bool
|
||||
}
|
||||
|
||||
func newPlanetOccultationLocalEphemeris(center float64, config planetOccultationConfig) *planetOccultationLocalEphemeris {
|
||||
half := occultationLocalEphemerisNodeCount / 2
|
||||
nodes := make([]localEphemerisVectorNode, occultationLocalEphemerisNodeCount)
|
||||
for index := range nodes {
|
||||
tt := center + float64(index-half)*occultationLocalEphemerisStepDays
|
||||
state := planetOccultationEphemerisStateAt(tt, config)
|
||||
moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM)
|
||||
target := occultationPathRaDecVector(state.planetRA, state.planetDec, state.planetDistanceKM)
|
||||
nodes[index] = localEphemerisVectorNode{
|
||||
tt: tt,
|
||||
first: [3]float64{moon.x, moon.y, moon.z},
|
||||
next: [3]float64{target.x, target.y, target.z},
|
||||
}
|
||||
}
|
||||
return &planetOccultationLocalEphemeris{nodes: nodes}
|
||||
}
|
||||
|
||||
func (ephemeris *planetOccultationLocalEphemeris) stateAt(tt float64) (planetOccultationEphemerisState, bool) {
|
||||
moonXYZ, targetXYZ, ok := ephemeris.vectorsAt(tt)
|
||||
if !ok {
|
||||
return planetOccultationEphemerisState{}, false
|
||||
}
|
||||
moonRA, moonDec, moonDistance, moonOK := occultationPathVectorRaDec(occultationPathVector{
|
||||
x: moonXYZ[0], y: moonXYZ[1], z: moonXYZ[2],
|
||||
})
|
||||
targetRA, targetDec, planetDistance, targetOK := occultationPathVectorRaDec(occultationPathVector{
|
||||
x: targetXYZ[0], y: targetXYZ[1], z: targetXYZ[2],
|
||||
})
|
||||
if !moonOK || !targetOK {
|
||||
return planetOccultationEphemerisState{}, false
|
||||
}
|
||||
return planetOccultationEphemerisState{
|
||||
moonRA: moonRA, moonDec: moonDec, moonDistanceKM: moonDistance,
|
||||
planetRA: targetRA, planetDec: targetDec, planetDistanceKM: planetDistance,
|
||||
valid: true,
|
||||
}, true
|
||||
}
|
||||
|
||||
func (ephemeris *planetOccultationLocalEphemeris) vectorsAt(tt float64) ([3]float64, [3]float64, bool) {
|
||||
if ephemeris == nil {
|
||||
return [3]float64{}, [3]float64{}, false
|
||||
}
|
||||
if ephemeris.dense {
|
||||
return interpolateDenseOccultationVectors(ephemeris.nodes, tt)
|
||||
}
|
||||
return interpolateLocalEphemerisVectors(ephemeris.nodes, tt)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSolarEclipseLocalEphemerisBoundedError(t *testing.T) {
|
||||
events := []struct {
|
||||
name string
|
||||
seed float64
|
||||
}{
|
||||
{"2010-01-15", JDECalc(2010, 1, 15)},
|
||||
{"2014-04-29", JDECalc(2014, 4, 29)},
|
||||
{"2023-04-20", JDECalc(2023, 4, 20)},
|
||||
{"2031-05-21", JDECalc(2031, 5, 21)},
|
||||
{"2309-06-09", JDECalc(2309, 6, 9)},
|
||||
}
|
||||
for _, event := range events {
|
||||
t.Run(event.name, func(t *testing.T) {
|
||||
center := CalcMoonSHByJDE(event.seed, 0)
|
||||
ephemeris := newSolarEclipseLocalEphemeris(center)
|
||||
for offset := -4.5; offset <= 4.5; offset += 0.25 {
|
||||
jd := center + offset/24
|
||||
approxSun, approxMoon, ok := ephemeris.equatorialAt(jd)
|
||||
if !ok {
|
||||
t.Fatalf("interpolator rejected in-window time %.3fh", offset)
|
||||
}
|
||||
exactSun, exactMoon := solarEclipseSunMoonEquatorial(jd)
|
||||
for name, pair := range map[string][2][3]float64{
|
||||
"sun": {solarEclipseLLRToXYZ(approxSun[0], approxSun[1], approxSun[2]), solarEclipseLLRToXYZ(exactSun[0], exactSun[1], exactSun[2])},
|
||||
"moon": {solarEclipseLLRToXYZ(approxMoon[0], approxMoon[1], approxMoon[2]), solarEclipseLLRToXYZ(exactMoon[0], exactMoon[1], exactMoon[2])},
|
||||
} {
|
||||
if errorKM := vectorDifferenceKM(pair[0], pair[1]); errorKM > 1 {
|
||||
t.Fatalf("%s interpolation error at %.3fh = %.6f km", name, offset, errorKM)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, _, ok := ephemeris.equatorialAt(center + 6.1/24); ok {
|
||||
t.Fatal("interpolator should reject times outside its bounded window")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationLocalEphemerisBoundedError(t *testing.T) {
|
||||
star := hr4799OccultationCoordinateForTest()
|
||||
starTT := occultationTimeToTT(time.Date(2025, 6, 5, 20, 0, 0, 0, time.UTC))
|
||||
starEphemeris := newStarOccultationLocalEphemeris(starTT, star)
|
||||
for offset := -4.5; offset <= 4.5; offset += 0.25 {
|
||||
jd := starTT + offset/24
|
||||
got, ok := starEphemeris.stateAt(jd)
|
||||
if !ok {
|
||||
t.Fatalf("star interpolator rejected in-window time %.3fh", offset)
|
||||
}
|
||||
want := starOccultationEphemerisStateAt(jd, star)
|
||||
if difference := angularSeparationDegrees(got.moonRA, got.moonDec, want.moonRA, want.moonDec); difference > 1e-5 {
|
||||
t.Fatalf("moon direction interpolation error at %.3fh = %.9f deg", offset, difference)
|
||||
}
|
||||
if difference := angularSeparationDegrees(got.starRA, got.starDec, want.starRA, want.starDec); difference > 1e-5 {
|
||||
t.Fatalf("star direction interpolation error at %.3fh = %.9f deg", offset, difference)
|
||||
}
|
||||
}
|
||||
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn occultation config is unavailable")
|
||||
}
|
||||
planetTT := occultationTimeToTT(time.Date(2024, 8, 21, 2, 40, 0, 0, time.UTC))
|
||||
planetEphemeris := newPlanetOccultationLocalEphemeris(planetTT, config)
|
||||
for offset := -4.5; offset <= 4.5; offset += 0.25 {
|
||||
jd := planetTT + offset/24
|
||||
got, ok := planetEphemeris.stateAt(jd)
|
||||
if !ok {
|
||||
t.Fatalf("planet interpolator rejected in-window time %.3fh", offset)
|
||||
}
|
||||
want := planetOccultationEphemerisStateAt(jd, config)
|
||||
if difference := angularSeparationDegrees(got.moonRA, got.moonDec, want.moonRA, want.moonDec); difference > 1e-5 {
|
||||
t.Fatalf("moon direction interpolation error at %.3fh = %.9f deg", offset, difference)
|
||||
}
|
||||
if difference := angularSeparationDegrees(got.planetRA, got.planetDec, want.planetRA, want.planetDec); difference > 1e-5 {
|
||||
t.Fatalf("planet direction interpolation error at %.3fh = %.9f deg", offset, difference)
|
||||
}
|
||||
if math.Abs(got.planetDistanceKM-want.planetDistanceKM) > 100 {
|
||||
t.Fatalf("planet distance interpolation error at %.3fh = %.6f km", offset, math.Abs(got.planetDistanceKM-want.planetDistanceKM))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationLocalEphemerisFullWindowBoundedError(t *testing.T) {
|
||||
star := hr4799OccultationCoordinateForTest()
|
||||
starTT := occultationTimeToTT(time.Date(2025, 6, 5, 20, 0, 0, 0, time.UTC))
|
||||
starEphemeris := newStarOccultationLocalEphemeris(starTT, star)
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn occultation config is unavailable")
|
||||
}
|
||||
planetTT := occultationTimeToTT(time.Date(2024, 8, 21, 2, 40, 0, 0, time.UTC))
|
||||
planetEphemeris := newPlanetOccultationLocalEphemeris(planetTT, config)
|
||||
for _, offset := range []float64{-47.5, -40, -24, 24, 40, 47.5} {
|
||||
starJD := starTT + offset/24
|
||||
gotStar, starOK := starEphemeris.stateAt(starJD)
|
||||
wantStar := starOccultationEphemerisStateAt(starJD, star)
|
||||
if !starOK {
|
||||
t.Fatalf("star interpolator rejected in-window time %.3fh", offset)
|
||||
}
|
||||
if difference := angularSeparationDegrees(gotStar.moonRA, gotStar.moonDec, wantStar.moonRA, wantStar.moonDec); difference > 1e-5 {
|
||||
t.Fatalf("star-event moon direction error at %.3fh = %.9f deg", offset, difference)
|
||||
}
|
||||
if difference := angularSeparationDegrees(gotStar.starRA, gotStar.starDec, wantStar.starRA, wantStar.starDec); difference > 1e-5 {
|
||||
t.Fatalf("star direction error at %.3fh = %.9f deg", offset, difference)
|
||||
}
|
||||
|
||||
planetJD := planetTT + offset/24
|
||||
gotPlanet, planetOK := planetEphemeris.stateAt(planetJD)
|
||||
wantPlanet := planetOccultationEphemerisStateAt(planetJD, config)
|
||||
if !planetOK {
|
||||
t.Fatalf("planet interpolator rejected in-window time %.3fh", offset)
|
||||
}
|
||||
if difference := angularSeparationDegrees(gotPlanet.moonRA, gotPlanet.moonDec, wantPlanet.moonRA, wantPlanet.moonDec); difference > 1e-5 {
|
||||
t.Fatalf("planet-event moon direction error at %.3fh = %.9f deg", offset, difference)
|
||||
}
|
||||
if difference := angularSeparationDegrees(gotPlanet.planetRA, gotPlanet.planetDec, wantPlanet.planetRA, wantPlanet.planetDec); difference > 1e-5 {
|
||||
t.Fatalf("planet direction error at %.3fh = %.9f deg", offset, difference)
|
||||
}
|
||||
if difference := math.Abs(gotPlanet.planetDistanceKM - wantPlanet.planetDistanceKM); difference > 100 {
|
||||
t.Fatalf("planet distance error at %.3fh = %.6f km", offset, difference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func vectorDifferenceKM(a, b [3]float64) float64 {
|
||||
return math.Sqrt((a[0]-b[0])*(a[0]-b[0]) + (a[1]-b[1])*(a[1]-b[1]) + (a[2]-b[2])*(a[2]-b[2]))
|
||||
}
|
||||
+56
-9
@@ -22,6 +22,9 @@ const (
|
||||
// 输入 seedJDE 只需要落在目标望月附近,允许相差数天。
|
||||
type LunarEclipseResult struct {
|
||||
Type LunarEclipseType
|
||||
// ShadowModel 是本次使用的影半径模型,决定影半径与食分的具体数值。
|
||||
// ShadowModel is the shadow-radius model used, which fixes the shadow radii and magnitudes.
|
||||
ShadowModel LunarEclipseShadowModel
|
||||
|
||||
// Maximum 是食甚时刻;即使最终没有月食,也会返回该次望月附近
|
||||
// “月面中心最接近地影中心”的几何极值时刻。
|
||||
@@ -39,6 +42,10 @@ type LunarEclipseResult struct {
|
||||
// PenumbralStart / PenumbralEnd: 半影食始 / 半影食终
|
||||
// PartialStart / PartialEnd: 初亏 / 复圆
|
||||
// TotalStart / TotalEnd: 食既 / 生光
|
||||
//
|
||||
// 该阶段不发生时为 NaN(0 是 −4713-11-24 的真实时刻);判断阶段是否存在一律用 Has*。
|
||||
// A contact is NaN when that phase does not occur (JD 0 is the real instant −4713-11-24);
|
||||
// decide by the Has* flags, never by comparing a contact against zero.
|
||||
PenumbralStart float64
|
||||
PenumbralEnd float64
|
||||
PartialStart float64
|
||||
@@ -60,6 +67,17 @@ type lunarShadowState struct {
|
||||
penumbraRadiusRad float64
|
||||
}
|
||||
|
||||
// LunarEclipseShadowModel 标识月食用的是哪套影半径模型。
|
||||
// LunarEclipseShadowModel identifies which shadow-radius model produced a result.
|
||||
type LunarEclipseShadowModel int
|
||||
|
||||
const (
|
||||
// LunarEclipseShadowModelDanjon 是 Danjon 影半径模型。
|
||||
LunarEclipseShadowModelDanjon LunarEclipseShadowModel = iota
|
||||
// LunarEclipseShadowModelChauvenet 是 Chauvenet 影半径模型。
|
||||
LunarEclipseShadowModelChauvenet
|
||||
)
|
||||
|
||||
type lunarEclipseShadowModel int
|
||||
|
||||
const (
|
||||
@@ -117,11 +135,23 @@ func lunarEclipse(seedJDE float64, shadowModel lunarEclipseShadowModel) LunarEcl
|
||||
fullMoonJDE := CalcMoonSHByJDE(seedJDE, 1)
|
||||
maximumJDE, state, dxdt, dydt, minimumDistance := refineLunarEclipseMaximum(fullMoonJDE, shadowModel)
|
||||
|
||||
// 未发生的阶段保持 NaN:Has* 是权威判据,时刻字段不能拿 0 当哨兵。
|
||||
model := LunarEclipseShadowModelDanjon
|
||||
if shadowModel == lunarEclipseShadowChauvenet {
|
||||
model = LunarEclipseShadowModelChauvenet
|
||||
}
|
||||
result := LunarEclipseResult{
|
||||
Type: LunarEclipseNone,
|
||||
ShadowModel: model,
|
||||
Maximum: maximumJDE,
|
||||
MinimumDistance: minimumDistance,
|
||||
PenumbralMagnitude: (state.moonRadiusRad + state.penumbraRadiusRad - minimumDistance) / (2 * state.moonRadiusRad),
|
||||
PenumbralStart: math.NaN(),
|
||||
PenumbralEnd: math.NaN(),
|
||||
PartialStart: math.NaN(),
|
||||
PartialEnd: math.NaN(),
|
||||
TotalStart: math.NaN(),
|
||||
TotalEnd: math.NaN(),
|
||||
}
|
||||
rawUmbralMagnitude := (state.moonRadiusRad + state.umbraRadiusRad - minimumDistance) / (2 * state.moonRadiusRad)
|
||||
|
||||
@@ -223,7 +253,7 @@ func refineLunarEclipseContact(
|
||||
) float64 {
|
||||
firstGuess, ok := solveLineCircleContact(maximumState, dxdt, dydt, boundaryRadius, afterMaximum)
|
||||
if !ok {
|
||||
return 0
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
contactState := computeLunarShadowState(firstGuess, shadowModel)
|
||||
@@ -272,11 +302,14 @@ func solveLineCircleContact(
|
||||
return state.jde + delta, true
|
||||
}
|
||||
|
||||
// computeLunarShadowState 计算某一力学时刻下,月心相对地影中心的二维几何状态。
|
||||
//
|
||||
// 所有内部角量统一使用弧度。影半径模型允许在 Danjon 与 Chauvenet 之间切换,
|
||||
// 其余月心轨迹与几何求交框架保持一致。
|
||||
func computeLunarShadowState(jde float64, shadowModel lunarEclipseShadowModel) lunarShadowState {
|
||||
// lunarEclipsePlaneState 只需要月心相对地影中心的二维坐标(穿影图采样点用,跳过半径所需的距离项)。
|
||||
type lunarEclipsePlaneState struct {
|
||||
jde float64
|
||||
x float64
|
||||
y float64
|
||||
}
|
||||
|
||||
func lunarEclipsePlaneStateAt(jde float64) lunarEclipsePlaneState {
|
||||
julianCentury := (jde - 2451545.0) / 36525.0
|
||||
|
||||
sunLongitude := HSunTrueLo(jde)*rad + sunLongitudeAberrationRad(julianCentury)
|
||||
@@ -284,6 +317,20 @@ func computeLunarShadowState(jde float64, shadowModel lunarEclipseShadowModel) l
|
||||
moonLongitude := HMoonTrueLo(jde)*rad + lunarLongitudeAberration
|
||||
moonLatitude := HMoonTrueBo(jde)*rad + moonLatitudeAberrationRad(julianCentury)
|
||||
|
||||
return lunarEclipsePlaneState{
|
||||
jde: jde,
|
||||
x: normalizeRadians(moonLongitude+math.Pi-sunLongitude) * math.Cos((moonLatitude-sunLatitude)/2),
|
||||
y: moonLatitude + sunLatitude,
|
||||
}
|
||||
}
|
||||
|
||||
// computeLunarShadowState 计算某一力学时刻下,月心相对地影中心的二维几何状态。
|
||||
//
|
||||
// 所有内部角量统一使用弧度。影半径模型允许在 Danjon 与 Chauvenet 之间切换,
|
||||
// 其余月心轨迹与几何求交框架保持一致。
|
||||
func computeLunarShadowState(jde float64, shadowModel lunarEclipseShadowModel) lunarShadowState {
|
||||
plane := lunarEclipsePlaneStateAt(jde)
|
||||
|
||||
moonDistanceKM := HMoonAway(jde)
|
||||
sunDistanceAU := EarthAway(jde)
|
||||
|
||||
@@ -299,9 +346,9 @@ func computeLunarShadowState(jde float64, shadowModel lunarEclipseShadowModel) l
|
||||
)
|
||||
|
||||
return lunarShadowState{
|
||||
jde: jde,
|
||||
x: normalizeRadians(moonLongitude+math.Pi-sunLongitude) * math.Cos((moonLatitude-sunLatitude)/2),
|
||||
y: moonLatitude + sunLatitude,
|
||||
jde: plane.jde,
|
||||
x: plane.x,
|
||||
y: plane.y,
|
||||
moonRadiusRad: moonRadiusArcsec / lunarArcsecPerRadian,
|
||||
umbraRadiusRad: umbraRadiusArcsec / lunarArcsecPerRadian,
|
||||
penumbraRadiusRad: penumbraRadiusArcsec / lunarArcsecPerRadian,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 未发生阶段的接触时刻必须是 NaN:JD 0 是 −4713-11-24 的真实时刻,不能当“无此阶段”的哨兵。
|
||||
|
||||
func TestLunarEclipseAbsentPhasesAreNaN(t *testing.T) {
|
||||
eclipse := LunarEclipse(2458860.0)
|
||||
if eclipse.Type != LunarEclipsePenumbral {
|
||||
t.Fatalf("type = %s, want %s", eclipse.Type, LunarEclipsePenumbral)
|
||||
}
|
||||
if !eclipse.HasPenumbral || eclipse.HasPartial || eclipse.HasTotal {
|
||||
t.Fatalf("unexpected flags: %+v", eclipse)
|
||||
}
|
||||
for name, value := range map[string]float64{
|
||||
"PartialStart": eclipse.PartialStart,
|
||||
"PartialEnd": eclipse.PartialEnd,
|
||||
"TotalStart": eclipse.TotalStart,
|
||||
"TotalEnd": eclipse.TotalEnd,
|
||||
} {
|
||||
if !math.IsNaN(value) {
|
||||
t.Fatalf("%s = %.12f, want NaN", name, value)
|
||||
}
|
||||
}
|
||||
for name, value := range map[string]float64{
|
||||
"PenumbralStart": eclipse.PenumbralStart,
|
||||
"PenumbralEnd": eclipse.PenumbralEnd,
|
||||
"Maximum": eclipse.Maximum,
|
||||
} {
|
||||
if !isFiniteFloat(value) {
|
||||
t.Fatalf("%s = %v, want a finite contact", name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLunarEclipseWithoutEclipseHasNoContacts(t *testing.T) {
|
||||
// 2025-01-13 的望月没有月食(半影食分 < 0)。
|
||||
eclipse := LunarEclipse(JDECalc(2025, 1, 13))
|
||||
if eclipse.Type != LunarEclipseNone {
|
||||
t.Fatalf("type = %s, want %s", eclipse.Type, LunarEclipseNone)
|
||||
}
|
||||
if eclipse.HasPenumbral || eclipse.HasPartial || eclipse.HasTotal {
|
||||
t.Fatalf("unexpected flags: %+v", eclipse)
|
||||
}
|
||||
for name, value := range map[string]float64{
|
||||
"PenumbralStart": eclipse.PenumbralStart,
|
||||
"PenumbralEnd": eclipse.PenumbralEnd,
|
||||
"PartialStart": eclipse.PartialStart,
|
||||
"PartialEnd": eclipse.PartialEnd,
|
||||
"TotalStart": eclipse.TotalStart,
|
||||
"TotalEnd": eclipse.TotalEnd,
|
||||
} {
|
||||
if !math.IsNaN(value) {
|
||||
t.Fatalf("%s = %.12f, want NaN", name, value)
|
||||
}
|
||||
}
|
||||
if !isFiniteFloat(eclipse.Maximum) {
|
||||
t.Fatalf("Maximum = %v, want the geometric extremum", eclipse.Maximum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLunarEclipseTotalKeepsAllContactsFinite(t *testing.T) {
|
||||
eclipse := LunarEclipse(JDECalc(2025, 3, 14))
|
||||
if eclipse.Type != LunarEclipseTotal || !eclipse.HasPenumbral || !eclipse.HasPartial || !eclipse.HasTotal {
|
||||
t.Fatalf("unexpected result: %+v", eclipse)
|
||||
}
|
||||
contacts := []float64{
|
||||
eclipse.PenumbralStart, eclipse.PartialStart, eclipse.TotalStart,
|
||||
eclipse.Maximum,
|
||||
eclipse.TotalEnd, eclipse.PartialEnd, eclipse.PenumbralEnd,
|
||||
}
|
||||
for i, value := range contacts {
|
||||
if !isFiniteFloat(value) {
|
||||
t.Fatalf("contact %d = %v, want finite", i, value)
|
||||
}
|
||||
if i > 0 && value <= contacts[i-1] {
|
||||
t.Fatalf("contacts not increasing at %d: %v", i, contacts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLunarEclipseDiagramSkipsAbsentPhases(t *testing.T) {
|
||||
diagram := LunarEclipseDiagram(2458860.0, LunarEclipseDiagramOptions{})
|
||||
if len(diagram.Points) == 0 {
|
||||
t.Fatalf("penumbral eclipse diagram should still have path points")
|
||||
}
|
||||
for _, point := range diagram.Points {
|
||||
if !isFiniteFloat(point.JDE) || !isFiniteFloat(point.X) || !isFiniteFloat(point.Y) {
|
||||
t.Fatalf("non-finite diagram point: %+v", point)
|
||||
}
|
||||
}
|
||||
if empty := LunarEclipseDiagram(JDECalc(2025, 1, 13), LunarEclipseDiagramOptions{}); len(empty.Points) != 0 {
|
||||
t.Fatalf("eclipse-free diagram should have no points, got %d", len(empty.Points))
|
||||
}
|
||||
}
|
||||
@@ -111,11 +111,11 @@ func lunarEclipseDiagram(
|
||||
result.StepDays = stepDays
|
||||
result.Points = make([]LunarEclipseDiagramPoint, 0, len(times))
|
||||
for _, item := range times {
|
||||
state := computeLunarShadowState(item.jde, shadowModel)
|
||||
plane := lunarEclipsePlaneStateAt(item.jde)
|
||||
result.Points = append(result.Points, LunarEclipseDiagramPoint{
|
||||
JDE: item.jde,
|
||||
X: state.x / maximumState.moonRadiusRad,
|
||||
Y: state.y / maximumState.moonRadiusRad,
|
||||
X: plane.x / maximumState.moonRadiusRad,
|
||||
Y: plane.y / maximumState.moonRadiusRad,
|
||||
Label: lunarEclipseDiagramPrimaryLabel(item.labels),
|
||||
Labels: append([]string(nil), item.labels...),
|
||||
})
|
||||
@@ -136,7 +136,7 @@ func normalizeLunarEclipseDiagramOptions(options LunarEclipseDiagramOptions) Lun
|
||||
func lunarEclipseDiagramTimes(eclipse LunarEclipseResult, stepDays float64) ([]lunarEclipseDiagramTime, float64) {
|
||||
startJDE := eclipse.PenumbralStart
|
||||
endJDE := eclipse.PenumbralEnd
|
||||
if startJDE == 0 || endJDE == 0 || endJDE <= startJDE {
|
||||
if !isFiniteFloat(startJDE) || !isFiniteFloat(endJDE) || endJDE <= startJDE {
|
||||
return nil, stepDays
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ func uniqueLunarEclipseDiagramTimes(times []lunarEclipseDiagramTime) []lunarEcli
|
||||
|
||||
unique := times[:0]
|
||||
for _, item := range times {
|
||||
if item.jde == 0 {
|
||||
if !isFiniteFloat(item.jde) {
|
||||
continue
|
||||
}
|
||||
if len(unique) == 0 || math.Abs(item.jde-unique[len(unique)-1].jde) > lunarEclipseDiagramDuplicateDays {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package basic
|
||||
|
||||
import "math"
|
||||
|
||||
// LunarEclipseShadowGeometry 是食甚时刻的地影几何。
|
||||
// 注意单位口径:Gamma 用地球赤道半径,而两个影半径用度——后者是 NASA 月食图上 P./U. Radius 的口径,
|
||||
// 换成地球赤道半径要乘以月球处的地球视差(弧度)。
|
||||
// LunarEclipseShadowGeometry is the terrestrial-shadow geometry at maximum eclipse. Gamma is in Earth
|
||||
// equatorial radii while the two shadow radii are in degrees, matching the P./U. Radius convention of
|
||||
// NASA lunar-eclipse charts; multiply a radius by the Earth's parallax at the Moon to get Earth radii.
|
||||
type LunarEclipseShadowGeometry struct {
|
||||
// Gamma 是月心到地影轴的最小距离,单位地球赤道半径。
|
||||
Gamma float64
|
||||
// PenumbralRadiusDegrees 与 UmbralRadiusDegrees 是半影、本影在地影轴垂直面上的角半径,单位度。
|
||||
PenumbralRadiusDegrees float64
|
||||
UmbralRadiusDegrees float64
|
||||
// MoonDistanceEarthRadii 是食甚时的地心月距。
|
||||
MoonDistanceEarthRadii float64
|
||||
// AxisDegrees 是食甚时月心到地影轴的角距,即 NASA 月食图上那列 Axis。
|
||||
// 它与 Gamma 是同一个量的两种刻度:Gamma 除以月球处的地球视差就是它。
|
||||
// AxisDegrees is the angular distance from the Moon's centre to the shadow axis at greatest eclipse,
|
||||
// the column NASA lunar-eclipse charts print as Axis. It is the same quantity as Gamma on a different
|
||||
// scale: divide Gamma by the Earth's parallax at the Moon.
|
||||
AxisDegrees float64
|
||||
}
|
||||
|
||||
// LunarEclipseShadowGeometryAt 用 Danjon 影半径模型计算食甚时刻的地影几何。
|
||||
// LunarEclipseShadowGeometryAt computes the shadow geometry at maximum eclipse with the Danjon shadow model.
|
||||
func LunarEclipseShadowGeometryAt(maximumJDE float64) LunarEclipseShadowGeometry {
|
||||
return lunarEclipseShadowGeometryAt(maximumJDE, lunarEclipseShadowDanjon)
|
||||
}
|
||||
|
||||
// LunarEclipseShadowGeometryChauvenetAt 用 Chauvenet 影半径模型计算食甚时刻的地影几何。
|
||||
// LunarEclipseShadowGeometryChauvenetAt computes the shadow geometry with the Chauvenet shadow model.
|
||||
func LunarEclipseShadowGeometryChauvenetAt(maximumJDE float64) LunarEclipseShadowGeometry {
|
||||
return lunarEclipseShadowGeometryAt(maximumJDE, lunarEclipseShadowChauvenet)
|
||||
}
|
||||
|
||||
// LunarEclipseShadowGeometryAtModel 按结果里记录的影半径模型取地影几何。
|
||||
// LunarEclipseShadowGeometryAtModel picks the shadow geometry by the model recorded in a result.
|
||||
func LunarEclipseShadowGeometryAtModel(maximumJDE float64, model LunarEclipseShadowModel) LunarEclipseShadowGeometry {
|
||||
if model == LunarEclipseShadowModelChauvenet {
|
||||
return LunarEclipseShadowGeometryChauvenetAt(maximumJDE)
|
||||
}
|
||||
return LunarEclipseShadowGeometryAt(maximumJDE)
|
||||
}
|
||||
|
||||
func lunarEclipseShadowGeometryAt(maximumJDE float64, shadowModel lunarEclipseShadowModel) LunarEclipseShadowGeometry {
|
||||
state := computeLunarShadowState(maximumJDE, shadowModel)
|
||||
moonDistanceKM := HMoonAway(maximumJDE)
|
||||
// 影半径是以地心为顶点的角量,除以月球处的地球视差就换成地球赤道半径。
|
||||
earthParallax := lunarEarthEquatorialRadiusKM / moonDistanceKM
|
||||
_, _, _, _, minimumDistance := refineLunarEclipseMaximum(maximumJDE, shadowModel)
|
||||
if earthParallax <= 0 {
|
||||
return LunarEclipseShadowGeometry{}
|
||||
}
|
||||
return LunarEclipseShadowGeometry{
|
||||
Gamma: minimumDistance / earthParallax,
|
||||
PenumbralRadiusDegrees: state.penumbraRadiusRad * 180 / math.Pi,
|
||||
UmbralRadiusDegrees: state.umbraRadiusRad * 180 / math.Pi,
|
||||
MoonDistanceEarthRadii: moonDistanceKM / lunarEarthEquatorialRadiusKM,
|
||||
// 平面 x 向东(黄经差)、y 向北(黄纬和),方位角自北向东量。
|
||||
AxisDegrees: minimumDistance * 180 / math.Pi,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Axis 与 Gamma 是同一个量的两种刻度:Gamma × 月球处的地球视差 = Axis。
|
||||
func TestLunarEclipseShadowGeometryAxisMatchesGamma(t *testing.T) {
|
||||
for _, date := range []time.Time{
|
||||
time.Date(2025, time.March, 14, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2028, time.July, 6, 0, 0, 0, 0, time.UTC),
|
||||
} {
|
||||
result := LunarEclipse(TD2UT(Date2JDE(date), true))
|
||||
geometry := LunarEclipseShadowGeometryAt(result.Maximum)
|
||||
// 影几何内部用的是小角近似的地球视差(R⊕/月距),这里必须用同一形式,否则二级差会露出来。
|
||||
earthParallaxDegrees := (1 / geometry.MoonDistanceEarthRadii) * 180 / math.Pi
|
||||
if delta := geometry.Gamma*earthParallaxDegrees - geometry.AxisDegrees; math.Abs(delta) > 1e-9 {
|
||||
t.Fatalf("%s: Gamma×parallax - Axis = %g", date.Format("2006-01-02"), delta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 食分公式反解出的月心到影轴距离必须等于 Axis:这条把影几何与食分求解器对起来。
|
||||
// 食分 = (影半径 + 月视半径 − Axis) / (2 × 月视半径)。
|
||||
func TestLunarEclipseMagnitudeInvertsToAxis(t *testing.T) {
|
||||
for _, date := range []time.Time{
|
||||
time.Date(2025, time.March, 14, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2028, time.July, 6, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2020, time.November, 30, 0, 0, 0, 0, time.UTC),
|
||||
} {
|
||||
result := LunarEclipse(TD2UT(Date2JDE(date), true))
|
||||
geometry := LunarEclipseShadowGeometryAt(result.Maximum)
|
||||
moonSemidiameter := MoonSemidiameter(result.Maximum) / 3600
|
||||
fromUmbral := geometry.UmbralRadiusDegrees + moonSemidiameter -
|
||||
2*moonSemidiameter*result.Magnitude
|
||||
fromPenumbral := geometry.PenumbralRadiusDegrees + moonSemidiameter -
|
||||
2*moonSemidiameter*result.PenumbralMagnitude
|
||||
t.Logf("%s %s: Axis=%.4f 本影反推=%.4f 半影反推=%.4f",
|
||||
date.Format("2006-01-02"), result.Type, geometry.AxisDegrees, fromUmbral, fromPenumbral)
|
||||
if math.Abs(fromUmbral-geometry.AxisDegrees) > 0.002 {
|
||||
t.Fatalf("%s: umbral magnitude implies axis %.4f, geometry gives %.4f",
|
||||
date.Format("2006-01-02"), fromUmbral, geometry.AxisDegrees)
|
||||
}
|
||||
if math.Abs(fromPenumbral-geometry.AxisDegrees) > 0.002 {
|
||||
t.Fatalf("%s: penumbral magnitude implies axis %.4f, geometry gives %.4f",
|
||||
date.Format("2006-01-02"), fromPenumbral, geometry.AxisDegrees)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,12 +133,12 @@ func TestLunarEclipseChauvenetAgainstLegacyBaseline(t *testing.T) {
|
||||
t.Fatalf("Magnitude mismatch: got %.12f want %.12f", result.Magnitude, tc.expectedMag)
|
||||
}
|
||||
|
||||
assertCloseJD(t, "PenumbralStart", result.PenumbralStart, tc.expectedPenumbralStart, timeTolerance)
|
||||
assertCloseJD(t, "PenumbralEnd", result.PenumbralEnd, tc.expectedPenumbralEnd, timeTolerance)
|
||||
assertCloseJD(t, "PartialStart", result.PartialStart, tc.expectedPartialStart, timeTolerance)
|
||||
assertCloseJD(t, "PartialEnd", result.PartialEnd, tc.expectedPartialEnd, timeTolerance)
|
||||
assertCloseJD(t, "TotalStart", result.TotalStart, tc.expectedTotalStart, timeTolerance)
|
||||
assertCloseJD(t, "TotalEnd", result.TotalEnd, tc.expectedTotalEnd, timeTolerance)
|
||||
assertContactJD(t, "PenumbralStart", result.PenumbralStart, tc.expectedPenumbralStart, timeTolerance)
|
||||
assertContactJD(t, "PenumbralEnd", result.PenumbralEnd, tc.expectedPenumbralEnd, timeTolerance)
|
||||
assertContactJD(t, "PartialStart", result.PartialStart, tc.expectedPartialStart, timeTolerance)
|
||||
assertContactJD(t, "PartialEnd", result.PartialEnd, tc.expectedPartialEnd, timeTolerance)
|
||||
assertContactJD(t, "TotalStart", result.TotalStart, tc.expectedTotalStart, timeTolerance)
|
||||
assertContactJD(t, "TotalEnd", result.TotalEnd, tc.expectedTotalEnd, timeTolerance)
|
||||
|
||||
if result.HasTotal && !(result.TotalStart < result.Maximum && result.Maximum < result.TotalEnd) {
|
||||
t.Fatalf("total contact order invalid: start=%.12f max=%.12f end=%.12f", result.TotalStart, result.Maximum, result.TotalEnd)
|
||||
@@ -284,8 +284,17 @@ func TestLunarEclipseNoEvent(t *testing.T) {
|
||||
if result.HasPenumbral || result.HasPartial || result.HasTotal {
|
||||
t.Fatalf("unexpected contacts: %+v", result)
|
||||
}
|
||||
if result.PenumbralStart != 0 || result.PenumbralEnd != 0 || result.PartialStart != 0 || result.PartialEnd != 0 || result.TotalStart != 0 || result.TotalEnd != 0 {
|
||||
t.Fatalf("expected no contact times, got %+v", result)
|
||||
for name, value := range map[string]float64{
|
||||
"PenumbralStart": result.PenumbralStart,
|
||||
"PenumbralEnd": result.PenumbralEnd,
|
||||
"PartialStart": result.PartialStart,
|
||||
"PartialEnd": result.PartialEnd,
|
||||
"TotalStart": result.TotalStart,
|
||||
"TotalEnd": result.TotalEnd,
|
||||
} {
|
||||
if !math.IsNaN(value) {
|
||||
t.Fatalf("%s = %.12f, want NaN for a non-eclipse: %+v", name, value, result)
|
||||
}
|
||||
}
|
||||
if result.Magnitude != 0 || result.PenumbralMagnitude != 0 {
|
||||
t.Fatalf("expected zero magnitudes for non-eclipse, got %+v", result)
|
||||
@@ -306,3 +315,15 @@ func assertCloseJD(t *testing.T, name string, got, want, tolerance float64) {
|
||||
t.Fatalf("%s mismatch: got %.12f want %.12f", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// assertContactJD 未发生阶段的期望值是 0(基准表写法),此时实际值必须是 NaN 而不是 JD 0。
|
||||
func assertContactJD(t *testing.T, name string, got, want, tolerance float64) {
|
||||
t.Helper()
|
||||
if want == 0 {
|
||||
if !math.IsNaN(got) {
|
||||
t.Fatalf("%s = %.12f, want NaN for an absent phase", name, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
assertCloseJD(t, name, got, want, tolerance)
|
||||
}
|
||||
|
||||
+60
-8
@@ -175,6 +175,9 @@ func LastMarsWesternQuadrature(jde float64) float64 {
|
||||
}
|
||||
|
||||
func marsRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool) float64 {
|
||||
if !isFiniteFloat(oppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
oppositionTT := TD2UT(oppositionJD, true)
|
||||
startTT := oppositionTT
|
||||
endTT := oppositionTT
|
||||
@@ -194,41 +197,90 @@ func marsRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition
|
||||
}
|
||||
|
||||
func NextMarsRetrogradeToPrograde(jde float64) float64 {
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
|
||||
date := marsRetrogradeAroundOpposition(lastOppositionJD, false)
|
||||
if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) {
|
||||
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
|
||||
return marsRetrogradeAroundOpposition(nextOppositionJD, false)
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = marsRetrogradeAroundOpposition(nextOppositionJD, false)
|
||||
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func LastMarsRetrogradeToPrograde(jde float64) float64 {
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
|
||||
date := marsRetrogradeAroundOpposition(lastOppositionJD, false)
|
||||
if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) {
|
||||
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(lastOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
previousOppositionJD := marsConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0)
|
||||
return marsRetrogradeAroundOpposition(previousOppositionJD, false)
|
||||
if !isFiniteFloat(previousOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = marsRetrogradeAroundOpposition(previousOppositionJD, false)
|
||||
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func NextMarsProgradeToRetrograde(jde float64) float64 {
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
|
||||
date := marsRetrogradeAroundOpposition(nextOppositionJD, true)
|
||||
if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) {
|
||||
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
followingOppositionJD := marsConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1)
|
||||
return marsRetrogradeAroundOpposition(followingOppositionJD, true)
|
||||
if !isFiniteFloat(followingOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = marsRetrogradeAroundOpposition(followingOppositionJD, true)
|
||||
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func LastMarsProgradeToRetrograde(jde float64) float64 {
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
nextOppositionJD := marsConjunctionFull(jde, 180, 1)
|
||||
date := marsRetrogradeAroundOpposition(nextOppositionJD, true)
|
||||
if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) {
|
||||
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := marsConjunctionFull(jde, 180, 0)
|
||||
return marsRetrogradeAroundOpposition(lastOppositionJD, true)
|
||||
if !isFiniteFloat(lastOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = marsRetrogradeAroundOpposition(lastOppositionJD, true)
|
||||
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 这些基准是"性能不回退"的度量口径:Nutation2000B 有界记忆表、升落上下文 stateAt 的分配、
|
||||
// 以及两条真实调用链(月球升落、掩星升落回归)。改动这些热路径后请对比 benchmark 数字。
|
||||
// These benchmarks are the regression yardstick for the hot paths changed here: the bounded
|
||||
// Nutation2000B memo, the allocation behaviour of the rise/set context stateAt, and two real call
|
||||
// chains (moon rise/set and the occultation rise/set regression case). Compare before/after numbers
|
||||
// whenever those paths change.
|
||||
func BenchmarkNutation2000B(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = Nutation2000B(2460310.5 + float64(i%97)*0.37)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkTrueObliquity(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = TrueObliquity(2460310.5 + float64(i%97)*0.37)
|
||||
}
|
||||
}
|
||||
|
||||
var benchMoonRise, benchMoonSet float64
|
||||
|
||||
func BenchmarkMoonRiseSetChain(b *testing.B) {
|
||||
jd := JDECalc(2023, 6, 21)
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
benchMoonRise, _ = GetMoonRiseTime(jd, 116.4074, 39.9042, 8, 1, 0)
|
||||
benchMoonSet, _ = GetMoonSetTime(jd, 116.4074, 39.9042, 8, 1, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Nutation2000B 有界记忆表:重复瞬时必须命中且数值逐位不变。
|
||||
func TestNutationMemoHitsRepeatedInstants(t *testing.T) {
|
||||
resetNutationMemo()
|
||||
const jd = 2460310.5
|
||||
firstPsi, firstEps := Nutation2000B(jd)
|
||||
secondPsi, secondEps := Nutation2000B(jd)
|
||||
if firstPsi != secondPsi || firstEps != secondEps {
|
||||
t.Fatalf("memo changed the value: (%v,%v) vs (%v,%v)", firstPsi, firstEps, secondPsi, secondEps)
|
||||
}
|
||||
hits, misses := nutationMemoStats()
|
||||
if hits == 0 || misses == 0 {
|
||||
t.Fatalf("expected one miss and at least one hit, got hits=%d misses=%d", hits, misses)
|
||||
}
|
||||
// 与未走记忆表的实现逐位一致。
|
||||
directPsi, directEps := nutation2000BCompute(jd)
|
||||
if firstPsi != directPsi || firstEps != directEps {
|
||||
t.Fatalf("memoized value differs from direct computation")
|
||||
}
|
||||
}
|
||||
+249
-181
@@ -17,6 +17,20 @@ const (
|
||||
mercuryStationCoarseStepDay = 2.0
|
||||
mercuryStationHalfWindowDay = 2.0
|
||||
mercuryStationMotionTolerance = 1e-3
|
||||
// mercuryStationAnchorAttempts 类型化「留」在锚点不满足侧向不变量时,最多推进/回退几个会合周期。
|
||||
mercuryStationAnchorAttempts = 4
|
||||
// mercuryConjunctionSameInstantDegrees 合搜索「同刻」快速路径的视黄经差触发阈值(度)。
|
||||
// 0.05 度约合 30 分钟的时间跨度,远大于截断级数在根附近的误差,
|
||||
// 从而保证「查询落在合附近」时一定走精确解 + 侧向判定,而不是被方向扫描跨过去。
|
||||
mercuryConjunctionSameInstantDegrees = 5.0e-2
|
||||
// mercuryConjunctionScanStepDay / mercuryConjunctionScanMaxSteps 方向性括号扫描参数:
|
||||
// 8 天一步 × 80 步 = 640 天,覆盖一个水星会合周期(115.88 天)且有大量余量。
|
||||
mercuryConjunctionScanStepDay = 8.0
|
||||
mercuryConjunctionScanMaxSteps = 80
|
||||
// mercuryConjunctionPolishToleranceDay 全项抛光的时间容差(0.01 秒):
|
||||
// 割线法从 8 天括号收敛到该量级只需多 1~2 次求值,但把合时刻精度保持在毫秒级
|
||||
// (与旧实现牛顿法的 1e-5 天口径一致)。
|
||||
mercuryConjunctionPolishToleranceDay = 0.01 / 86400.0
|
||||
)
|
||||
|
||||
type mercuryConjunctionLBR struct {
|
||||
@@ -132,15 +146,25 @@ func mercuryConjunctionExactTT(seed float64, inferior bool) float64 {
|
||||
return estimateJD
|
||||
}
|
||||
|
||||
// mercuryConjunction 在 jde 的指定方向上求最近一次水星合(内合/外合)。
|
||||
//
|
||||
// 旧实现用「启发式跳 + 2 天一步走」:跳过头落到合之后时,|Δ| 会持续变大,
|
||||
// 于是走满一个会合周期、跳过一次合(例如 2008-01-22 查到 2008-06-07 而不是 2008-02-06),
|
||||
// 并且下游的类型化「留」会因此返回错事件、甚至让 Last 返回未来。
|
||||
// 新实现改成方向性括号扫描:截断级数逐段找异号区间(顺序扫描 ⇒ 一定取最近的一个合),
|
||||
// 再用全项级数在括号内抛光;扫满 640 天仍无括号时有界返回 NaN。
|
||||
func mercuryConjunction(jde float64, next uint8) float64 {
|
||||
//0=last 1=next
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
if math.Abs(mercuryConjunctionExactDelta(jde)) <= 30.0/86400.0 {
|
||||
if math.Abs(mercuryConjunctionExactDelta(jde)) <= mercuryConjunctionSameInstantDegrees {
|
||||
best := math.NaN()
|
||||
consider := func(inferior bool) {
|
||||
eventUT := TD2UT(mercuryConjunctionExactTT(jde, inferior), false)
|
||||
if !isFiniteFloat(eventUT) {
|
||||
return
|
||||
}
|
||||
if next == 0 && !eventUTQueryBeforeOrEqual(eventUT, jde) {
|
||||
return
|
||||
}
|
||||
@@ -157,54 +181,87 @@ func mercuryConjunction(jde float64, next uint8) float64 {
|
||||
return best
|
||||
}
|
||||
}
|
||||
currentDelta := mercuryConjunctionExactDelta(jde)
|
||||
// pos 大于0:远离太阳 小于0:靠近太阳
|
||||
distanceTrend := math.Abs(mercuryConjunctionExactDelta(jde+1/86400.0)) - math.Abs(currentDelta)
|
||||
if distanceTrend >= 0 && next == 1 && currentDelta > 0 {
|
||||
jde += MERCURY_S_PERIOD/8.0 + 2
|
||||
|
||||
direction := 1.0
|
||||
if next == 0 {
|
||||
direction = -1
|
||||
}
|
||||
if distanceTrend >= 0 && next == 1 && currentDelta < 0 {
|
||||
jde += MERCURY_S_PERIOD/6.0 + 2
|
||||
leftJD := jde
|
||||
leftValue := mercuryConjunctionDeltaN(leftJD, mercuryEventSearchN)
|
||||
if !isFiniteFloat(leftValue) {
|
||||
return math.NaN()
|
||||
}
|
||||
if distanceTrend <= 0 && next == 0 && currentDelta < 0 {
|
||||
jde -= MERCURY_S_PERIOD/8.0 + 2
|
||||
}
|
||||
if distanceTrend <= 0 && next == 0 && currentDelta > 0 {
|
||||
jde -= MERCURY_S_PERIOD/6.0 + 2
|
||||
}
|
||||
found := false
|
||||
for i := 0; i < eventDirectionalSearchIterations; i++ {
|
||||
currentDelta := mercuryConjunctionExactDelta(jde)
|
||||
nextDelta := mercuryConjunctionExactDelta(jde + 1/86400.0)
|
||||
if !isFiniteFloat(currentDelta) || !isFiniteFloat(nextDelta) {
|
||||
for i := 0; i < mercuryConjunctionScanMaxSteps; i++ {
|
||||
rightJD := jde + direction*mercuryConjunctionScanStepDay*float64(i+1)
|
||||
rightValue := mercuryConjunctionDeltaN(rightJD, mercuryEventSearchN)
|
||||
if !isFiniteFloat(rightValue) {
|
||||
return math.NaN()
|
||||
}
|
||||
distanceTrend := math.Abs(nextDelta) - math.Abs(currentDelta)
|
||||
if math.Abs(currentDelta) > 12 || (distanceTrend > 0 && next == 1) || (distanceTrend < 0 && next == 0) {
|
||||
if next == 1 {
|
||||
jde += 2
|
||||
} else {
|
||||
jde -= 2
|
||||
if leftValue == 0 || rightValue == 0 || leftValue*rightValue < 0 {
|
||||
return mercuryConjunctionPolish(jde, leftJD, rightJD, direction)
|
||||
}
|
||||
leftJD, leftValue = rightJD, rightValue
|
||||
}
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
// mercuryConjunctionDeltaN 截断级数下的水星-太阳视黄经差(度,[-180,180]),用于方向性括号扫描。
|
||||
func mercuryConjunctionDeltaN(jd float64, n int) float64 {
|
||||
return mercuryConjunctionAngleDelta(MercuryApparentLoN(jd, n) - HSunApparentLoN(jd, n))
|
||||
}
|
||||
|
||||
// mercuryConjunctionPolish 用全项级数在截断级数给出的括号内抛光。
|
||||
// 截断误差可能让括号两端在全项函数上同号(罕见),此时沿扫描方向再扩一两个扫描步;
|
||||
// 若仍未被确认(典型情形:查询几乎正好落在合上,截断级数在根两侧的符号与全项不一致),
|
||||
// 退回全项级数的方向扫描,保证有界且不返回 NaN。
|
||||
func mercuryConjunctionPolish(jde, leftJD, rightJD, direction float64) float64 {
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
leftValue := mercuryConjunctionExactDelta(leftJD)
|
||||
rightValue := mercuryConjunctionExactDelta(rightJD)
|
||||
if !isFiniteFloat(leftValue) || !isFiniteFloat(rightValue) {
|
||||
return math.NaN()
|
||||
}
|
||||
if leftValue == 0 || rightValue == 0 || leftValue*rightValue < 0 {
|
||||
root, ok := eventBracketSecantRoot(leftJD, rightJD, leftValue, rightValue,
|
||||
mercuryConjunctionPolishToleranceDay, mercuryConjunctionExactDelta)
|
||||
if !ok {
|
||||
return math.NaN()
|
||||
}
|
||||
return TD2UT(root, false)
|
||||
}
|
||||
if direction > 0 {
|
||||
rightJD += mercuryConjunctionScanStepDay
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
return math.NaN()
|
||||
leftJD -= mercuryConjunctionScanStepDay
|
||||
}
|
||||
return mercuryConjunctionFullDirectionalScan(jde, direction)
|
||||
}
|
||||
|
||||
inferior := mercuryConjunctionExactTT(jde, true)
|
||||
superior := mercuryConjunctionExactTT(jde, false)
|
||||
if !isFiniteFloat(inferior) || !isFiniteFloat(superior) {
|
||||
// mercuryConjunctionFullDirectionalScan 全项级数的方向扫描(截断括号未被确认时的兜底)。
|
||||
func mercuryConjunctionFullDirectionalScan(jde, direction float64) float64 {
|
||||
leftJD := jde
|
||||
leftValue := mercuryConjunctionExactDelta(leftJD)
|
||||
if !isFiniteFloat(leftValue) {
|
||||
return math.NaN()
|
||||
}
|
||||
best := inferior
|
||||
if math.Abs(superior-jde) < math.Abs(inferior-jde) {
|
||||
best = superior
|
||||
for i := 0; i < mercuryConjunctionScanMaxSteps; i++ {
|
||||
rightJD := jde + direction*mercuryConjunctionScanStepDay*float64(i+1)
|
||||
rightValue := mercuryConjunctionExactDelta(rightJD)
|
||||
if !isFiniteFloat(rightValue) {
|
||||
return math.NaN()
|
||||
}
|
||||
if leftValue == 0 || rightValue == 0 || leftValue*rightValue < 0 {
|
||||
root, ok := eventBracketSecantRoot(leftJD, rightJD, leftValue, rightValue,
|
||||
mercuryConjunctionPolishToleranceDay, mercuryConjunctionExactDelta)
|
||||
if !ok {
|
||||
return math.NaN()
|
||||
}
|
||||
return TD2UT(root, false)
|
||||
}
|
||||
leftJD, leftValue = rightJD, rightValue
|
||||
}
|
||||
return TD2UT(best, false)
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
func LastMercuryConjunction(jde float64) float64 {
|
||||
@@ -293,8 +350,13 @@ func mercuryStationBetween(startTT, endTT float64) bool {
|
||||
if endTT-startTT <= 0 {
|
||||
return false
|
||||
}
|
||||
// 跨度超过一个回留窗口时截断扫描已不可靠:返回 false 不再阻断快速路径。
|
||||
// 此时候选事件由 mercuryConjunction 的完整方向扫描保证,快速路径无需再复核。
|
||||
// A span beyond the station window makes the truncated scan unreliable; report false so the
|
||||
// fast path is not blocked. The typed candidate is already guaranteed by mercuryConjunction's
|
||||
// exhaustive directional scan.
|
||||
if endTT-startTT > mercuryStationWindowDays {
|
||||
return true
|
||||
return false
|
||||
}
|
||||
// 截断扫描足以判断单候选快速路径是否安全 / A truncated scan is enough to decide whether the one-candidate fast path is safe.
|
||||
left := startTT
|
||||
@@ -324,114 +386,161 @@ func mercuryRetrogradeToProgradeAroundInferior(inferiorUT float64) float64 {
|
||||
return mercuryStationInWindow(inferiorTT, inferiorTT+mercuryStationWindowDays)
|
||||
}
|
||||
|
||||
// 类型化「留」统一结构:以下合为锚点求候选,再用侧向不变量(Next 不得早于查询、
|
||||
// Last 不得晚于查询)校验;不满足则把锚点推进/回退一个会合周期重试,最多
|
||||
// mercuryStationAnchorAttempts 次,最终仍有界失败为 NaN。
|
||||
// 这保证 Last* 永远不会返回未来、Next* 永远不会返回过去的事件。
|
||||
func NextMercuryProgradeToRetrograde(jde float64) float64 {
|
||||
inferior := NextMercuryInferiorConjunction(jde)
|
||||
date := mercuryProgradeToRetrogradeAroundInferior(inferior)
|
||||
if eventUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
followingInferior := NextMercuryInferiorConjunction(eventUTNextQueryTT(inferior))
|
||||
return mercuryProgradeToRetrogradeAroundInferior(followingInferior)
|
||||
inferior := NextMercuryInferiorConjunction(jde)
|
||||
for i := 0; i < mercuryStationAnchorAttempts && isFiniteFloat(inferior); i++ {
|
||||
date := mercuryProgradeToRetrogradeAroundInferior(inferior)
|
||||
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
inferior = NextMercuryInferiorConjunction(eventUTNextQueryTT(inferior))
|
||||
}
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
func NextMercuryRetrogradeToPrograde(jde float64) float64 {
|
||||
inferior := LastMercuryInferiorConjunction(jde)
|
||||
date := mercuryRetrogradeToProgradeAroundInferior(inferior)
|
||||
if eventUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
nextInferior := NextMercuryInferiorConjunction(eventUTNextQueryTT(inferior))
|
||||
return mercuryRetrogradeToProgradeAroundInferior(nextInferior)
|
||||
inferior := LastMercuryInferiorConjunction(jde)
|
||||
for i := 0; i < mercuryStationAnchorAttempts && isFiniteFloat(inferior); i++ {
|
||||
date := mercuryRetrogradeToProgradeAroundInferior(inferior)
|
||||
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
inferior = NextMercuryInferiorConjunction(eventUTNextQueryTT(inferior))
|
||||
}
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
func LastMercuryProgradeToRetrograde(jde float64) float64 {
|
||||
inferior := NextMercuryInferiorConjunction(jde)
|
||||
date := mercuryProgradeToRetrogradeAroundInferior(inferior)
|
||||
if eventUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
previousInferior := LastMercuryInferiorConjunction(eventUTLastQueryTT(inferior))
|
||||
return mercuryProgradeToRetrogradeAroundInferior(previousInferior)
|
||||
inferior := NextMercuryInferiorConjunction(jde)
|
||||
for i := 0; i < mercuryStationAnchorAttempts && isFiniteFloat(inferior); i++ {
|
||||
date := mercuryProgradeToRetrogradeAroundInferior(inferior)
|
||||
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
inferior = LastMercuryInferiorConjunction(eventUTLastQueryTT(inferior))
|
||||
}
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
func LastMercuryRetrogradeToPrograde(jde float64) float64 {
|
||||
inferior := LastMercuryInferiorConjunction(jde)
|
||||
date := mercuryRetrogradeToProgradeAroundInferior(inferior)
|
||||
if eventUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
previousInferior := LastMercuryInferiorConjunction(eventUTLastQueryTT(inferior))
|
||||
return mercuryRetrogradeToProgradeAroundInferior(previousInferior)
|
||||
inferior := LastMercuryInferiorConjunction(jde)
|
||||
for i := 0; i < mercuryStationAnchorAttempts && isFiniteFloat(inferior); i++ {
|
||||
date := mercuryRetrogradeToProgradeAroundInferior(inferior)
|
||||
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
inferior = LastMercuryInferiorConjunction(eventUTLastQueryTT(inferior))
|
||||
}
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
// earliestFiniteEventUT / latestFiniteEventUT 在候选中取最早/最晚的有限值(全部非有限时返回 NaN)。
|
||||
func earliestFiniteEventUT(candidates ...float64) float64 {
|
||||
best := math.NaN()
|
||||
for _, candidate := range candidates {
|
||||
if !isFiniteFloat(candidate) {
|
||||
continue
|
||||
}
|
||||
if math.IsNaN(best) || candidate < best {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func latestFiniteEventUT(candidates ...float64) float64 {
|
||||
best := math.NaN()
|
||||
for _, candidate := range candidates {
|
||||
if !isFiniteFloat(candidate) {
|
||||
continue
|
||||
}
|
||||
if math.IsNaN(best) || candidate > best {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func nextMercuryRetrogradeFromTyped(jde float64) float64 {
|
||||
p2r := NextMercuryProgradeToRetrograde(jde)
|
||||
r2p := NextMercuryRetrogradeToPrograde(jde)
|
||||
if p2r < r2p {
|
||||
return p2r
|
||||
}
|
||||
return r2p
|
||||
return earliestFiniteEventUT(NextMercuryProgradeToRetrograde(jde), NextMercuryRetrogradeToPrograde(jde))
|
||||
}
|
||||
|
||||
func NextMercuryRetrograde(jde float64) float64 {
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
motion := mercuryRADerivative(jde, mercuryStationDerivativeStepDay)
|
||||
if motion > mercuryStationMotionTolerance {
|
||||
p2r := NextMercuryProgradeToRetrograde(jde)
|
||||
if !mercuryStationBetween(jde, TD2UT(p2r, true)) {
|
||||
if isFiniteFloat(p2r) && !mercuryStationBetween(jde, TD2UT(p2r, true)) {
|
||||
return p2r
|
||||
}
|
||||
r2p := NextMercuryRetrogradeToPrograde(jde)
|
||||
if p2r < r2p {
|
||||
return p2r
|
||||
best := earliestFiniteEventUT(p2r, NextMercuryRetrogradeToPrograde(jde))
|
||||
if !stationUTQueryAfterOrEqual(best, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return r2p
|
||||
return best
|
||||
}
|
||||
if motion < -mercuryStationMotionTolerance {
|
||||
r2p := NextMercuryRetrogradeToPrograde(jde)
|
||||
if !mercuryStationBetween(jde, TD2UT(r2p, true)) {
|
||||
if isFiniteFloat(r2p) && !mercuryStationBetween(jde, TD2UT(r2p, true)) {
|
||||
return r2p
|
||||
}
|
||||
p2r := NextMercuryProgradeToRetrograde(jde)
|
||||
if p2r < r2p {
|
||||
return p2r
|
||||
best := earliestFiniteEventUT(NextMercuryProgradeToRetrograde(jde), r2p)
|
||||
if !stationUTQueryAfterOrEqual(best, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return r2p
|
||||
return best
|
||||
}
|
||||
return nextMercuryRetrogradeFromTyped(jde)
|
||||
}
|
||||
|
||||
func lastMercuryRetrogradeFromTyped(jde float64) float64 {
|
||||
p2r := LastMercuryProgradeToRetrograde(jde)
|
||||
r2p := LastMercuryRetrogradeToPrograde(jde)
|
||||
if p2r > r2p {
|
||||
return p2r
|
||||
}
|
||||
return r2p
|
||||
return latestFiniteEventUT(LastMercuryProgradeToRetrograde(jde), LastMercuryRetrogradeToPrograde(jde))
|
||||
}
|
||||
|
||||
func LastMercuryRetrograde(jde float64) float64 {
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
motion := mercuryRADerivative(jde, mercuryStationDerivativeStepDay)
|
||||
if motion > mercuryStationMotionTolerance {
|
||||
r2p := LastMercuryRetrogradeToPrograde(jde)
|
||||
if !mercuryStationBetween(TD2UT(r2p, true), jde) {
|
||||
if isFiniteFloat(r2p) && !mercuryStationBetween(TD2UT(r2p, true), jde) {
|
||||
return r2p
|
||||
}
|
||||
p2r := LastMercuryProgradeToRetrograde(jde)
|
||||
if p2r > r2p {
|
||||
return p2r
|
||||
best := latestFiniteEventUT(LastMercuryProgradeToRetrograde(jde), r2p)
|
||||
if !stationUTQueryBeforeOrEqual(best, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return r2p
|
||||
return best
|
||||
}
|
||||
if motion < -mercuryStationMotionTolerance {
|
||||
p2r := LastMercuryProgradeToRetrograde(jde)
|
||||
if !mercuryStationBetween(TD2UT(p2r, true), jde) {
|
||||
if isFiniteFloat(p2r) && !mercuryStationBetween(TD2UT(p2r, true), jde) {
|
||||
return p2r
|
||||
}
|
||||
r2p := LastMercuryRetrogradeToPrograde(jde)
|
||||
if p2r > r2p {
|
||||
return p2r
|
||||
best := latestFiniteEventUT(p2r, LastMercuryRetrogradeToPrograde(jde))
|
||||
if !stationUTQueryBeforeOrEqual(best, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return r2p
|
||||
return best
|
||||
}
|
||||
return lastMercuryRetrogradeFromTyped(jde)
|
||||
}
|
||||
@@ -458,20 +567,15 @@ func mercurySunElongationN(jde float64, n int) float64 {
|
||||
return StarAngularSeparation(lo1, bo1, lo2, bo2)
|
||||
}
|
||||
|
||||
func mercuryTrueElongationN(jde float64, n int) float64 {
|
||||
earth := mercuryHelioN(-1, jde, n)
|
||||
planetPos := mercuryHelioN(1, jde, n)
|
||||
geo := mercuryGeocentric(planetPos, earth)
|
||||
return StarAngularSeparation(geo.lo, geo.bo, HSunTrueLoN(jde, n), HSunTrueBoN(jde, n))
|
||||
}
|
||||
|
||||
// mercuryGreatestElongationInWindow 求窗口内大距:目标是公开 MercurySunElongation 的极大(视距角),
|
||||
// 而不是忽略光行差/视位置修正的真距角,否则返回的时刻不是调用方能量到的那个极值。
|
||||
// 窗口两端是世界时,目标函数收力学时,因此逐次换算。
|
||||
func mercuryGreatestElongationInWindow(start, end float64) float64 {
|
||||
best := maximizeInWindow(start, end, 2.0, func(jd float64) float64 {
|
||||
return mercuryTrueElongationN(jd, mercuryEventSearchN)
|
||||
}, func(jd float64) float64 {
|
||||
return mercuryTrueElongationN(jd, -1)
|
||||
return maximizeInWindow(start, end, 2.0, func(utJD float64) float64 {
|
||||
return mercurySunElongationN(TD2UT(utJD, true), mercuryEventSearchN)
|
||||
}, func(utJD float64) float64 {
|
||||
return MercurySunElongation(TD2UT(utJD, true))
|
||||
})
|
||||
return TD2UT(best, false)
|
||||
}
|
||||
|
||||
func mercuryEastElongationWindowEndingAt(inferior float64) (float64, float64) {
|
||||
@@ -572,93 +676,57 @@ func lastMercuryGreatestElongationTyped(jde float64, east bool) float64 {
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
func mercuryGreatestElongation(jde float64) float64 {
|
||||
// mercuryElongationWindowAt 返回包含 jde 的该侧大距窗口;查询落在合的 4 秒内边距里时两侧都不包含。
|
||||
func mercuryElongationWindowAt(jde float64, east bool) (float64, float64, bool) {
|
||||
if east {
|
||||
start, end := mercuryEastElongationWindowEndingAt(NextMercuryInferiorConjunction(jde))
|
||||
return start, end, eventUTQueryBeforeOrEqual(start, jde) && eventUTQueryAfterOrEqual(end, jde)
|
||||
}
|
||||
start, end := mercuryWestElongationWindowEndingAt(NextMercurySuperiorConjunction(jde))
|
||||
return start, end, eventUTQueryBeforeOrEqual(start, jde) && eventUTQueryAfterOrEqual(end, jde)
|
||||
}
|
||||
|
||||
// 无东西侧参数的 Next/Last 先只看查询所在窗口那一侧:该侧大距未过就是答案,已过则另一侧紧接着的
|
||||
// 下一个才是答案,因此只有在查询贴住合(两侧窗口都不含)时才退化为两侧都算。赤经差在合附近会提前
|
||||
// 变号,不能用它定窗口。
|
||||
func NextMercuryGreatestElongation(jde float64) float64 {
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
solarRADelta := func(jde float64) float64 {
|
||||
sub := Limit360(MercuryApparentRa(jde) - SunApparentRa(jde))
|
||||
if sub > 180 {
|
||||
sub -= 360
|
||||
}
|
||||
if sub < -180 {
|
||||
sub += 360
|
||||
}
|
||||
return sub
|
||||
}
|
||||
elongationRate := func(jde float64, delta float64) float64 {
|
||||
sub := MercurySunElongation(jde+delta) - MercurySunElongation(jde-delta)
|
||||
if sub > 180 {
|
||||
sub -= 360
|
||||
}
|
||||
if sub < -180 {
|
||||
sub += 360
|
||||
}
|
||||
return sub / (2 * delta)
|
||||
}
|
||||
lastConjunction := LastMercuryConjunctionStrict(jde)
|
||||
nextConjunction := NextMercuryConjunctionStrict(jde)
|
||||
currentRADelta := solarRADelta(jde)
|
||||
if currentRADelta > 0 {
|
||||
jde = lastConjunction + ((nextConjunction - lastConjunction) / 5.0 * 2.0)
|
||||
} else {
|
||||
jde = lastConjunction + ((nextConjunction - lastConjunction) / 6.0)
|
||||
}
|
||||
found := false
|
||||
for i := 0; i < eventDirectionalSearchIterations; i++ {
|
||||
currentRate := elongationRate(jde, 1.0/86400.0)
|
||||
if !isFiniteFloat(currentRate) {
|
||||
return math.NaN()
|
||||
}
|
||||
if math.Abs(currentRate) > 0.4 {
|
||||
jde += 2
|
||||
for _, east := range [2]bool{true, false} {
|
||||
start, end, ok := mercuryElongationWindowAt(jde, east)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if date := mercuryGreatestElongationInWindow(start, end); isFiniteFloat(date) && eventUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if date := nextMercuryGreatestElongationTyped(jde, !east); isFiniteFloat(date) && eventUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
return math.NaN()
|
||||
}
|
||||
estimateJD := jde
|
||||
var ok bool
|
||||
estimateJD, ok = eventNewtonRefine(estimateJD, 30.0/86400.0, func(prevJD float64) float64 {
|
||||
rateValue := elongationRate(prevJD, 2.0/86400.0)
|
||||
rateSlope := (elongationRate(prevJD+15.0/86400.0, 2.0/86400.0) - elongationRate(prevJD-15.0/86400.0, 2.0/86400.0)) / (30.0 / 86400.0)
|
||||
return rateValue / rateSlope
|
||||
})
|
||||
if !ok {
|
||||
return math.NaN()
|
||||
}
|
||||
bestJD := eventZeroRefine(estimateJD, 15.0/86400.0, 0.5/86400.0, func(jd float64) float64 {
|
||||
return elongationRate(jd, 0.5/86400.0)
|
||||
})
|
||||
//fmt.Println((bestJD - lastConjunction) / (nextConjunction - lastConjunction))
|
||||
return TD2UT(bestJD, false)
|
||||
}
|
||||
|
||||
func NextMercuryGreatestElongation(jde float64) float64 {
|
||||
east := NextMercuryGreatestElongationEast(jde)
|
||||
west := NextMercuryGreatestElongationWest(jde)
|
||||
if sameEventJD(east, west) {
|
||||
return east
|
||||
}
|
||||
if east < west {
|
||||
return east
|
||||
}
|
||||
return west
|
||||
return earliestFiniteEventUT(nextMercuryGreatestElongationTyped(jde, true), nextMercuryGreatestElongationTyped(jde, false))
|
||||
}
|
||||
|
||||
func LastMercuryGreatestElongation(jde float64) float64 {
|
||||
east := LastMercuryGreatestElongationEast(jde)
|
||||
west := LastMercuryGreatestElongationWest(jde)
|
||||
if sameEventJD(east, west) {
|
||||
return east
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
if east > west {
|
||||
return east
|
||||
for _, east := range [2]bool{true, false} {
|
||||
start, end, ok := mercuryElongationWindowAt(jde, east)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if date := mercuryGreatestElongationInWindow(start, end); isFiniteFloat(date) && eventUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if date := lastMercuryGreatestElongationTyped(jde, !east); isFiniteFloat(date) && eventUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
break
|
||||
}
|
||||
return west
|
||||
return latestFiniteEventUT(lastMercuryGreatestElongationTyped(jde, true), lastMercuryGreatestElongationTyped(jde, false))
|
||||
}
|
||||
|
||||
func LastMercuryInferiorConjunctionInclusive(jde float64) float64 {
|
||||
|
||||
+1
-6
@@ -72,12 +72,7 @@ func MoonTrueRaDec(jd float64) (float64, float64) {
|
||||
return LoBoToRaDec(jd, MoonApparentLo(jd), MoonTrueBo(jd))
|
||||
}
|
||||
|
||||
/*
|
||||
*
|
||||
|
||||
*
|
||||
传入世界时
|
||||
*/
|
||||
// MoonApparentRa 站心视赤经;jd 为当地时儒略日,tz 为时区小时数 / topocentric apparent right ascension; jd is local civil time and tz is the zone offset in hours.
|
||||
func MoonApparentRa(jd, lon, lat float64, tz int) float64 {
|
||||
jde := TD2UT(jd, true)
|
||||
utcJD := jde - float64(tz)/24.000
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package basic
|
||||
|
||||
import "math"
|
||||
|
||||
// MoonHorizon 返回 UT 儒略日下海平面几何月球中心地平圈(月球恰好在地平线上的观测者轨迹,
|
||||
// 即月下点周围的地平圈)的 [经度, 纬度] 顶点,单位为度,不重复首点。视差与椭球口径同
|
||||
// HMoonHeight,不含折射;与 HMoonHeight(经, 纬, ..., 0) 配合时该圈上的点高度角为 0。
|
||||
// samples<=0 取 360,其余夹到 [12, 1440]。
|
||||
// MoonHorizon returns sea-level geometric Moon-centre horizon vertices in degrees for a UT Julian
|
||||
// day: the locus of observers that see the Moon exactly on the horizon. Parallax and the observer
|
||||
// ellipsoid match HMoonHeight; refraction is excluded.
|
||||
func MoonHorizon(jdUT float64, samples int) [][2]float64 {
|
||||
if !finite(jdUT) {
|
||||
return nil
|
||||
}
|
||||
if samples <= 0 {
|
||||
samples = 360
|
||||
}
|
||||
if samples < 12 {
|
||||
samples = 12
|
||||
} else if samples > 1440 {
|
||||
samples = 1440
|
||||
}
|
||||
tt := TD2UT(jdUT, true)
|
||||
ra, dec := HMoonTrueRaDec(tt)
|
||||
distanceAU := HMoonAway(tt) / angularDiameterAstronomicalUnitKM
|
||||
parallax := math.Sin(0.0024427777777*rad) / distanceAU
|
||||
longitude := (ra - ApparentSiderealTime(jdUT)*15) * rad
|
||||
latitude := dec * rad
|
||||
if !finite(parallax) || parallax <= 0 || parallax >= 1 || !finite(longitude) || !finite(latitude) {
|
||||
return nil
|
||||
}
|
||||
center := [3]float64{math.Cos(latitude) * math.Cos(longitude), math.Cos(latitude) * math.Sin(longitude), math.Sin(latitude)}
|
||||
north := [3]float64{-math.Sin(latitude) * math.Cos(longitude), -math.Sin(latitude) * math.Sin(longitude), math.Cos(latitude)}
|
||||
east := [3]float64{-math.Sin(longitude), math.Cos(longitude), 0}
|
||||
points := make([][2]float64, samples)
|
||||
for index := range points {
|
||||
bearing := 2 * math.Pi * float64(index) / float64(samples)
|
||||
radius := math.Acos(parallax)
|
||||
var point [3]float64
|
||||
for iteration := 0; iteration < 8; iteration++ {
|
||||
for axis := range point {
|
||||
point[axis] = center[axis]*math.Cos(radius) +
|
||||
(north[axis]*math.Cos(bearing)+east[axis]*math.Sin(bearing))*math.Sin(radius)
|
||||
}
|
||||
lat := math.Asin(math.Max(-1, math.Min(1, point[2]))) / rad
|
||||
// The topocentric direction is horizontal when its dot product
|
||||
// with the geodetic zenith vanishes: cos(radius)=observer/range.
|
||||
next := math.Acos(parallax * (pcosi(lat, 0)*math.Cos(lat*rad) + psini(lat, 0)*math.Sin(lat*rad)))
|
||||
if math.Abs(next-radius) < 1e-14 {
|
||||
break
|
||||
}
|
||||
radius = next
|
||||
}
|
||||
points[index] = [2]float64{math.Atan2(point[1], point[0]) / rad, math.Asin(math.Max(-1, math.Min(1, point[2]))) / rad}
|
||||
}
|
||||
return points
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMoonHorizonMatchesTopocentricAltitude(t *testing.T) {
|
||||
for _, jd := range []float64{JDECalc(2026, 3, 3), JDECalc(2025, 9, 7), JDECalc(2024, 12, 15)} {
|
||||
points := MoonHorizon(jd, 360)
|
||||
if len(points) != 360 {
|
||||
t.Fatalf("horizon points=%d", len(points))
|
||||
}
|
||||
for _, point := range points {
|
||||
if altitude := HMoonHeight(jd, point[0], point[1], 0); math.Abs(altitude) > 1e-9 {
|
||||
t.Fatalf("JD=%v point=%v altitude=%g, want zero", jd, point, altitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
if MoonHorizon(math.NaN(), 360) != nil {
|
||||
t.Fatal("invalid JD accepted")
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,79 @@ func moonMaximumDeclinationEvent(k int, coeffs moonMaxDeclinationCoefficients, c
|
||||
}
|
||||
|
||||
func moonMaximumDeclinationSearch(jd float64, coeffs moonMaxDeclinationCoefficients, direction int, includeCurrent bool) DeclinationEvent {
|
||||
cfg := apsisSearchConfig{
|
||||
if !finite(jd) {
|
||||
// 非有限查询算不出周期序号(centerK 会把 NaN/±Inf 压成 MinInt64,种子外推到 1e40 后步长小于一个 ULP)。
|
||||
return DeclinationEvent{}
|
||||
}
|
||||
cfg := moonMaximumDeclinationSearchConfig(coeffs)
|
||||
centerK := moonMaximumDeclinationOffset(jd, coeffs)
|
||||
step := 1
|
||||
if direction < 0 {
|
||||
step = -1
|
||||
}
|
||||
event := moonMaximumDeclinationEvent(centerK, coeffs, cfg)
|
||||
// 事件时刻随周期序号单调递增:该方向上最近的候选就是 centerK 沿该方向第一个满足方向不变量
|
||||
// 的周期;命中后只需再回退检查更早的周期是否同样满足,可达范围仍旧是 ±3 个周期。
|
||||
if !moonMaximumDeclinationMatchesDirection(event.JDE-jd, direction, includeCurrent) {
|
||||
for offset := 1; offset <= moonMaxDeclinationSearchSpan; offset++ {
|
||||
event = moonMaximumDeclinationEvent(centerK+step*offset, coeffs, cfg)
|
||||
if moonMaximumDeclinationMatchesDirection(event.JDE-jd, direction, includeCurrent) {
|
||||
return event
|
||||
}
|
||||
}
|
||||
return DeclinationEvent{}
|
||||
}
|
||||
for offset := 1; offset <= moonMaxDeclinationSearchSpan; offset++ {
|
||||
previous := moonMaximumDeclinationEvent(centerK-step*offset, coeffs, cfg)
|
||||
if !moonMaximumDeclinationMatchesDirection(previous.JDE-jd, direction, includeCurrent) {
|
||||
break
|
||||
}
|
||||
event = previous
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
func moonClosestMaximumDeclination(jd float64, coeffs moonMaxDeclinationCoefficients) DeclinationEvent {
|
||||
if !finite(jd) {
|
||||
return DeclinationEvent{}
|
||||
}
|
||||
cfg := moonMaximumDeclinationSearchConfig(coeffs)
|
||||
centerK := moonMaximumDeclinationOffset(jd, coeffs)
|
||||
center := moonMaximumDeclinationEvent(centerK, coeffs, cfg)
|
||||
var last, next DeclinationEvent
|
||||
if center.JDE <= jd {
|
||||
last = center
|
||||
next = moonMaximumDeclinationAround(jd, coeffs, cfg, centerK, 1, false)
|
||||
} else {
|
||||
next = center
|
||||
last = moonMaximumDeclinationAround(jd, coeffs, cfg, centerK, -1, true)
|
||||
}
|
||||
lastDistance := math.Abs(jd - last.JDE)
|
||||
nextDistance := math.Abs(next.JDE - jd)
|
||||
if lastDistance <= nextDistance {
|
||||
return last
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// moonMaximumDeclinationAround 从 centerK 沿 step 方向找第一个满足方向不变量的周期;±3 个周期内没有就返回零事件。
|
||||
func moonMaximumDeclinationAround(jd float64, coeffs moonMaxDeclinationCoefficients, cfg apsisSearchConfig,
|
||||
centerK, step int, includeCurrent bool) DeclinationEvent {
|
||||
direction := 1
|
||||
if step < 0 {
|
||||
direction = -1
|
||||
}
|
||||
for offset := 1; offset <= moonMaxDeclinationSearchSpan; offset++ {
|
||||
event := moonMaximumDeclinationEvent(centerK+step*offset, coeffs, cfg)
|
||||
if moonMaximumDeclinationMatchesDirection(event.JDE-jd, direction, includeCurrent) {
|
||||
return event
|
||||
}
|
||||
}
|
||||
return DeclinationEvent{}
|
||||
}
|
||||
|
||||
func moonMaximumDeclinationSearchConfig(coeffs moonMaxDeclinationCoefficients) apsisSearchConfig {
|
||||
return apsisSearchConfig{
|
||||
bracketHalfWidth: moonApsisBracketHalfWidth,
|
||||
sampleStep: moonApsisSampleStep,
|
||||
derivativeStep: moonApsisDerivativeStep,
|
||||
@@ -175,37 +247,11 @@ func moonMaximumDeclinationSearch(jd float64, coeffs moonMaxDeclinationCoefficie
|
||||
maxIterations: moonApsisMaxIterations,
|
||||
maximize: coeffs.sign > 0,
|
||||
}
|
||||
targetTT := TD2UT(jd, true)
|
||||
centerK := int(math.Round((targetTT - coeffs.JDE0) / moonMaxDeclinationMeanMonthDays))
|
||||
|
||||
found := false
|
||||
bestDistance := math.Inf(1)
|
||||
var best DeclinationEvent
|
||||
for offset := -moonMaxDeclinationSearchSpan; offset <= moonMaxDeclinationSearchSpan; offset++ {
|
||||
event := moonMaximumDeclinationEvent(centerK+offset, coeffs, cfg)
|
||||
delta := event.JDE - jd
|
||||
if !moonMaximumDeclinationMatchesDirection(delta, direction, includeCurrent) {
|
||||
continue
|
||||
}
|
||||
distance := math.Abs(delta)
|
||||
if !found || distance < bestDistance || (distance == bestDistance && moonMaximumDeclinationEarlier(event, best)) {
|
||||
best = event
|
||||
bestDistance = distance
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func moonClosestMaximumDeclination(jd float64, coeffs moonMaxDeclinationCoefficients) DeclinationEvent {
|
||||
last := moonMaximumDeclinationSearch(jd, coeffs, -1, true)
|
||||
next := moonMaximumDeclinationSearch(jd, coeffs, 1, false)
|
||||
lastDistance := math.Abs(jd - last.JDE)
|
||||
nextDistance := math.Abs(next.JDE - jd)
|
||||
if lastDistance <= nextDistance {
|
||||
return last
|
||||
}
|
||||
return next
|
||||
// moonMaximumDeclinationOffset 查询时刻落在哪个平均周期(种子多项式的中心序号)。
|
||||
func moonMaximumDeclinationOffset(jd float64, coeffs moonMaxDeclinationCoefficients) int {
|
||||
return int(math.Round((TD2UT(jd, true) - coeffs.JDE0) / moonMaxDeclinationMeanMonthDays))
|
||||
}
|
||||
|
||||
func moonMaximumDeclinationMatchesDirection(delta float64, direction int, includeCurrent bool) bool {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件用改动前的“七个候选全精修”实现做差分对照与 A/B 基准。
|
||||
|
||||
func moonMaximumDeclinationReferenceSearch(jd float64, coeffs moonMaxDeclinationCoefficients, direction int, includeCurrent bool) DeclinationEvent {
|
||||
if !finite(jd) {
|
||||
return DeclinationEvent{}
|
||||
}
|
||||
cfg := moonMaximumDeclinationSearchConfig(coeffs)
|
||||
centerK := moonMaximumDeclinationOffset(jd, coeffs)
|
||||
found := false
|
||||
bestDistance := math.Inf(1)
|
||||
var best DeclinationEvent
|
||||
for offset := -moonMaxDeclinationSearchSpan; offset <= moonMaxDeclinationSearchSpan; offset++ {
|
||||
event := moonMaximumDeclinationEvent(centerK+offset, coeffs, cfg)
|
||||
delta := event.JDE - jd
|
||||
if !moonMaximumDeclinationMatchesDirection(delta, direction, includeCurrent) {
|
||||
continue
|
||||
}
|
||||
distance := math.Abs(delta)
|
||||
if !found || distance < bestDistance || (distance == bestDistance && moonMaximumDeclinationEarlier(event, best)) {
|
||||
best = event
|
||||
bestDistance = distance
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func moonMaximumDeclinationReferenceClosest(jd float64, coeffs moonMaxDeclinationCoefficients) DeclinationEvent {
|
||||
if !finite(jd) {
|
||||
return DeclinationEvent{}
|
||||
}
|
||||
last := moonMaximumDeclinationReferenceSearch(jd, coeffs, -1, true)
|
||||
next := moonMaximumDeclinationReferenceSearch(jd, coeffs, 1, false)
|
||||
lastDistance := math.Abs(jd - last.JDE)
|
||||
nextDistance := math.Abs(next.JDE - jd)
|
||||
if lastDistance <= nextDistance {
|
||||
return last
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func TestMoonMaximumDeclinationPruningMatchesFullSearch(t *testing.T) {
|
||||
for _, coeffs := range []moonMaxDeclinationCoefficients{moonMaxDeclinationNorthCoefficients, moonMaxDeclinationSouthCoefficients} {
|
||||
for jd := JDECalc(2024, 1, 1); jd <= JDECalc(2025, 6, 1); jd += 2.5 {
|
||||
for _, direction := range []struct {
|
||||
name string
|
||||
dir int
|
||||
includeCurrent bool
|
||||
fn func(float64) DeclinationEvent
|
||||
}{
|
||||
{"Last", -1, true, func(v float64) DeclinationEvent {
|
||||
return moonMaximumDeclinationSearch(v, coeffs, -1, true)
|
||||
}},
|
||||
{"Next", 1, false, func(v float64) DeclinationEvent {
|
||||
return moonMaximumDeclinationSearch(v, coeffs, 1, false)
|
||||
}},
|
||||
} {
|
||||
want := moonMaximumDeclinationReferenceSearch(jd, coeffs, direction.dir, direction.includeCurrent)
|
||||
got := direction.fn(jd)
|
||||
if got != want {
|
||||
t.Fatalf("k=%v %s(%v) = %+v, want %+v", coeffs.sign, direction.name, jd, got, want)
|
||||
}
|
||||
}
|
||||
wantClosest := moonMaximumDeclinationReferenceClosest(jd, coeffs)
|
||||
if got := moonClosestMaximumDeclination(jd, coeffs); got != wantClosest {
|
||||
t.Fatalf("k=%v Closest(%v) = %+v, want %+v", coeffs.sign, jd, got, wantClosest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMoonMaximumDeclinationFullSearchReference(b *testing.B) {
|
||||
jd := JDECalc(2025, 3, 1)
|
||||
b.Run("Next", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = moonMaximumDeclinationReferenceSearch(jd, moonMaxDeclinationNorthCoefficients, 1, false)
|
||||
}
|
||||
})
|
||||
b.Run("Closest", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = moonMaximumDeclinationReferenceClosest(jd, moonMaxDeclinationNorthCoefficients)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -124,6 +124,8 @@ func moonRiseSetResidual(jd, longitude, latitude, timeZone, zenithShift, height
|
||||
return residual
|
||||
}
|
||||
|
||||
// moonRiseSetOnCivilDay 在民用日内求升/落时刻;找不到过零时的错误口径与 rise_set.go 的 ErrNeverRise/ErrNeverSet 一致,
|
||||
// fallbackErr 是调用方用中天/下中天残差预判的同一几何结论,命中时优先于扫描结果。
|
||||
func moonRiseSetOnCivilDay(candidate, slope, civilDayStart, longitude, latitude, originalTimeZone,
|
||||
localTimeZone, zenithShift, height float64, isRise bool, fallbackErr error) (float64, error) {
|
||||
if eventRiseSetCandidateValid(candidate, civilDayStart, slope, isRise) {
|
||||
@@ -231,6 +233,7 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig
|
||||
currentResidual := moonRiseSetResidual(estimatedTime, longitude, latitude, timeZone, zenithShift, height, -1)
|
||||
if !(currentResidual < -10 && math.Abs(latitude) < 60) {
|
||||
if currentResidual > 0 {
|
||||
// 下中天仍在地平线上:当日无落下(也无可升起),口径见 moonRiseSetOnCivilDay。
|
||||
return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude,
|
||||
originalTimeZone, timeZone, zenithShift, height, true, ErrNeverSet)
|
||||
}
|
||||
@@ -241,6 +244,7 @@ func GetMoonRiseTime(julianDay, longitude, latitude, timeZone, zenithShift, heig
|
||||
}
|
||||
checkTime += (360 - checkAngle) * 4.0 / 60.0 / 24.0
|
||||
if moonRiseSetResidual(checkTime, longitude, latitude, timeZone, zenithShift, height, -1) < 0 {
|
||||
// 上中天仍在地平线下:当日无升起。
|
||||
return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude,
|
||||
originalTimeZone, timeZone, zenithShift, height, true, ErrNeverRise)
|
||||
}
|
||||
@@ -314,6 +318,7 @@ func GetMoonSetTime(julianDay, longitude, latitude, timeZone, zenithShift, heigh
|
||||
currentResidual := moonRiseSetResidual(estimatedTime, longitude, latitude, timeZone, zenithShift, height, -1)
|
||||
if !(currentResidual > 10 && math.Abs(latitude) < 60) {
|
||||
if currentResidual < 0 {
|
||||
// 上中天仍在地平线下:当日无升起,也就无落下。
|
||||
return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude,
|
||||
originalTimeZone, timeZone, zenithShift, height, false, ErrNeverRise)
|
||||
}
|
||||
@@ -321,6 +326,7 @@ func GetMoonSetTime(julianDay, longitude, latitude, timeZone, zenithShift, heigh
|
||||
angleSubtraction := 180 - MoonTimeAngle(checkTime, longitude, latitude, timeZone)
|
||||
checkTime += angleSubtraction * 4.0 / 60.0 / 24.0
|
||||
if moonRiseSetResidual(checkTime, longitude, latitude, timeZone, zenithShift, height, -1) > 0 {
|
||||
// 下中天仍在地平线上:当日无落下。
|
||||
return moonRiseSetOnCivilDay(math.NaN(), math.NaN(), civilDayStart, longitude, latitude,
|
||||
originalTimeZone, timeZone, zenithShift, height, false, ErrNeverSet)
|
||||
}
|
||||
|
||||
+43
-11
@@ -54,8 +54,24 @@ func MoonCalcNew(coordIndex int, jd float64) float64 {
|
||||
}
|
||||
|
||||
func MoonCalcNewN(coordIndex int, jd float64, n int) float64 {
|
||||
rad := 180.0 * 3600.0 / math.Pi
|
||||
t := (jd - 2451545.0) / 36525.0
|
||||
return moonCalcNewN(coordIndex, jd, n)
|
||||
}
|
||||
|
||||
// moonPhaseCompensationThreshold 是启用线性相位补偿的最小 |t|(世纪数)倒数尺度:
|
||||
// 更小的项本身已低于纳角秒级相位舍入,无需补偿。
|
||||
const moonPhaseCompensationThreshold = 1e6
|
||||
|
||||
func moonCalcNewN(coordIndex int, jd float64, n int) float64 {
|
||||
arcsecPerRadian := 180.0 * 3600.0 / math.Pi
|
||||
// Preserve the low parts of both the epoch subtraction and century division.
|
||||
days := jd - 2451545.0
|
||||
origin := days - jd
|
||||
daysLow := (jd - (days - origin)) - (2451545 + origin)
|
||||
t := days / 36525.0
|
||||
tLow := (math.FMA(-t, 36525, days) + daysLow) / 36525
|
||||
// Small angular terms have sub-nanoarcsecond phase roundoff already.
|
||||
// Compensate only the larger terms; distance needs no angular precision.
|
||||
phaseThreshold := moonPhaseCompensationThreshold / math.Abs(t)
|
||||
ob := moonCir[coordIndex]
|
||||
var v float64
|
||||
var tn float64 = 1
|
||||
@@ -65,8 +81,11 @@ func MoonCalcNewN(coordIndex int, jd float64, n int) float64 {
|
||||
t5 := t4 * t
|
||||
tx := t - 10
|
||||
if coordIndex == 0 {
|
||||
v += (3.81034409 + 8399.684730072*t - 3.319e-05*t2 + 3.11e-08*t3 - 2.033e-10*t4) * rad //月球平黄经(弧度)
|
||||
v += 5028.792262*t + 1.1124406*t2 + 0.00007699*t3 - 0.000023479*t4 - 0.0000000178*t5 //岁差(角秒)
|
||||
// 黄经线性项必须始终走补偿版本:MoonCalcNew 与 HMoonTrueLo 是同一个物理量的两个入口,
|
||||
// 只在其中一个入口补偿会让两者差约 1e-9 度。
|
||||
longitude := moonReducedLinearPhase(3.81034409, 8399.684730072, t, tLow)
|
||||
v += (longitude - 3.319e-05*t2 + 3.11e-08*t3 - 2.033e-10*t4) * arcsecPerRadian
|
||||
v += 5028.792262*t + 1.1124406*t2 + 0.00007699*t3 - 0.000023479*t4 - 0.0000000178*t5 //岁差(角秒)
|
||||
if tx > 0 {
|
||||
v += -0.866 + 1.43*tx + 0.054*tx*tx //对公元3000年至公元5000年的拟合,最大误差小于10角秒
|
||||
}
|
||||
@@ -80,6 +99,7 @@ func MoonCalcNewN(coordIndex int, jd float64, n int) float64 {
|
||||
}
|
||||
for i := 0; i < len(ob); i++ {
|
||||
F := ob[i]
|
||||
seriesPhaseThreshold := phaseThreshold / math.Abs(tn)
|
||||
N := math.Floor(float64(nScalars*len(F))/float64(len(ob[0])) + 0.5)
|
||||
if i != 0 {
|
||||
N += 6
|
||||
@@ -89,23 +109,37 @@ func MoonCalcNewN(coordIndex int, jd float64, n int) float64 {
|
||||
}
|
||||
var c float64 = 0
|
||||
for j := 0; float64(j) < N; j += 6 {
|
||||
c += F[j] * math.Cos(F[j+1]+t*F[j+2]+t2*F[j+3]+t3*F[j+4]+t4*F[j+5])
|
||||
phase := F[j+1] + t*F[j+2]
|
||||
if coordIndex != 2 && math.Abs(F[j]*F[j+2]) > seriesPhaseThreshold {
|
||||
phase = moonReducedLinearPhase(F[j+1], F[j+2], t, tLow)
|
||||
}
|
||||
c += F[j] * math.Cos(phase+t2*F[j+3]+t3*F[j+4]+t4*F[j+5])
|
||||
}
|
||||
v += c * tn
|
||||
tn *= t
|
||||
}
|
||||
if coordIndex != 2 {
|
||||
v /= rad
|
||||
v /= arcsecPerRadian
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Keep the small phase increment out of the rounded secular product. The
|
||||
// low part of 2*pi also prevents a range-reduction jump at full revolutions.
|
||||
func moonReducedLinearPhase(offset, rate, t, tLow float64) float64 {
|
||||
const twoPiLow = 2.4492935982947064e-16
|
||||
product := rate * t
|
||||
roundoff := math.FMA(rate, t, -product) + rate*tLow
|
||||
turns := math.Round(product / (2 * math.Pi))
|
||||
return math.FMA(-turns, 2*math.Pi, product) + (roundoff - turns*twoPiLow) + offset
|
||||
}
|
||||
|
||||
func HMoonTrueLo(jd float64) float64 { //计算月亮
|
||||
return HMoonTrueLoN(jd, -1)
|
||||
}
|
||||
|
||||
func HMoonTrueLoN(jd float64, n int) float64 { //计算月亮
|
||||
v := MoonCalcNewN(0, jd, n) * 180 / math.Pi
|
||||
v := moonCalcNewN(0, jd, n) * 180 / math.Pi
|
||||
return Limit360(v)
|
||||
}
|
||||
|
||||
@@ -234,10 +268,8 @@ func HMoonTrueRaN(jd float64, n int) float64 {
|
||||
}
|
||||
|
||||
/*
|
||||
*
|
||||
*
|
||||
传入世界时
|
||||
*/
|
||||
* HMoonApparentRaDec 本地民用时下的月球视赤道坐标;jd 是本地民用时(内部按 jd-tz/24 换算 UT),不是世界时
|
||||
*/
|
||||
func HMoonApparentRaDec(jd, lon, lat, tz float64) (float64, float64) {
|
||||
return HMoonApparentRaDecN(jd, lon, lat, tz, -1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMoonSeriesNumericalPrecision(t *testing.T) {
|
||||
// The existing binary64 coefficients evaluated at 70 decimal digits.
|
||||
// These check arithmetic precision, not the lunar theory's physical error.
|
||||
for _, sample := range []struct{ jd, longitude, latitude, distance, velocity float64 }{
|
||||
{990647.125, 48.68531043735905108, -3.208940888099671943, 365328.8143785083007, 14.53542662716284001},
|
||||
{1730647.9333686847, 346.3227699608293767, -2.151500042672265917, 368450.7410464923284, 14.30553896629878483},
|
||||
{2451545.0, 223.3189003731153751, 5.170885995272171723, 402448.7431750887777, 12.02142669184144213},
|
||||
{2460770.123456789, 99.20516643712787636, 5.156920731462777075, 378303.2056716878878, 13.57356557811076973},
|
||||
{2817000.99999, 256.0031142427467021, 3.438015761341575242, 402219.0739759512533, 11.99285384469081927},
|
||||
{3191874.3956256355, 178.1121054545695063, 1.37109140818496243, 379219.9689425243825, 13.41568168416747773},
|
||||
{3547272.75, 167.6849402044916958, 5.088592207858965847, 374590.3971822900352, 13.85120063917007945},
|
||||
} {
|
||||
longitude := HMoonTrueLo(sample.jd)
|
||||
latitude := HMoonTrueBo(sample.jd)
|
||||
distance := HMoonAway(sample.jd)
|
||||
if error := math.Abs(math.Remainder(longitude-sample.longitude, 360)); error > 2e-11 {
|
||||
t.Errorf("jd=%.12f longitude error=%g degrees", sample.jd, error)
|
||||
}
|
||||
if error := math.Abs(latitude - sample.latitude); error > 2e-11 {
|
||||
t.Errorf("jd=%.12f latitude error=%g degrees", sample.jd, error)
|
||||
}
|
||||
if error := math.Abs(distance - sample.distance); error > 1e-5 {
|
||||
t.Errorf("jd=%.12f distance error=%g km", sample.jd, error)
|
||||
}
|
||||
const step = 5.0 / 86400
|
||||
before, after := sample.jd-step, sample.jd+step
|
||||
velocity := math.Remainder(HMoonTrueLo(after)-HMoonTrueLo(before), 360) / (after - before)
|
||||
if error := math.Abs(velocity - sample.velocity); error > 2e-7 {
|
||||
t.Errorf("jd=%.12f velocity error=%g degrees/day", sample.jd, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationContactDerivativeNumericalStability(t *testing.T) {
|
||||
config, _ := planetOccultationConfigFor(OccultationMercury)
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
const tt = 1730647.933368684724
|
||||
const longitude = 90.88806942770216
|
||||
const latitude = 29.603106445560446
|
||||
evaluations := newOccultationRiseSetEvaluationCache(cache.riseSetContextAt)
|
||||
derivative := func(jd float64) float64 {
|
||||
return evaluations.evaluation(jd).contactDerivative(longitude, latitude)
|
||||
}
|
||||
center := derivative(tt)
|
||||
const trendStep = 0.5 / 86400
|
||||
trend := (derivative(tt+trendStep) - derivative(tt-trendStep)) / (2 * trendStep)
|
||||
ulp := math.Nextafter(tt, math.Inf(1)) - tt
|
||||
for i := -16; i <= 16; i++ {
|
||||
offset := float64(i) * ulp
|
||||
jitter := derivative(tt+offset) - center - trend*offset
|
||||
if math.Abs(jitter) > occultationRiseSetJunctionDerivativeTolerance {
|
||||
t.Fatalf("offset=%g seconds: derivative jitter=%g degrees/day", offset*86400, jitter)
|
||||
}
|
||||
}
|
||||
cache.preparePathEphemeris(tt, OccultationPathAlgorithmOptimized)
|
||||
if cache.local == nil || !cache.local.dense {
|
||||
t.Fatal("smooth exact states must support the checked dense ephemeris")
|
||||
}
|
||||
}
|
||||
|
||||
// MoonCalcNew 与 HMoonTrueLo 是同一个物理量的两个入口,必须给出同一个值(此前只有一个入口
|
||||
// 走线性相位补偿,两者差约 1e-9 度)。
|
||||
func TestMoonCalcNewMatchesHMoonTrueLo(t *testing.T) {
|
||||
for _, jd := range []float64{2451545.0, 2460310.5, 2415020.5, 2299160.5, 2500000.5} {
|
||||
direct := math.Mod(MoonCalcNew(0, jd)*180/math.Pi, 360)
|
||||
if direct < 0 {
|
||||
direct += 360
|
||||
}
|
||||
high := HMoonTrueLo(jd)
|
||||
if diff := math.Abs(direct - high); diff > 1e-12 {
|
||||
t.Errorf("jd %.1f: MoonCalcNew=%0.15f HMoonTrueLo=%0.15f diff=%.3g deg", jd, direct, high, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件钉住月出/月落「缺失事件」的口径:错误名描述缺失的那个现象,不描述被问的事件。
|
||||
|
||||
func moonRiseSetErrorMatches(got, want error) bool {
|
||||
if want == nil {
|
||||
return got == nil
|
||||
}
|
||||
return errors.Is(got, want)
|
||||
}
|
||||
|
||||
func moonRiseSetDailyGeometry(jd, lon, lat, tz float64) (aboveAll, belowAll bool) {
|
||||
dayStart := math.Floor(jd) + 0.5
|
||||
aboveAll, belowAll = true, true
|
||||
previous := moonRiseSetResidual(dayStart, lon, lat, tz, 1, 0, -1)
|
||||
for i := 1; i <= 288; i++ {
|
||||
current := moonRiseSetResidual(dayStart+float64(i)/288.0, lon, lat, tz, 1, 0, -1)
|
||||
if previous <= 0 || current <= 0 {
|
||||
aboveAll = false
|
||||
}
|
||||
if previous >= 0 || current >= 0 {
|
||||
belowAll = false
|
||||
}
|
||||
previous = current
|
||||
}
|
||||
return aboveAll, belowAll
|
||||
}
|
||||
|
||||
func TestMoonRiseSetMissingEventConvention(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
jd float64
|
||||
lon, lat, tz float64
|
||||
riseErr, setErr error
|
||||
}{
|
||||
{"极昼:全天在地平线上", JDECalc(2023, 6, 21), 0, 85, 0, ErrNeverSet, ErrNeverSet},
|
||||
{"极夜:全天在地平线下", JDECalc(2023, 12, 22), 0, -85, 0, ErrNeverRise, ErrNeverRise},
|
||||
{"当日无升起但别日有", JDECalc(2024, 2, 29), 0, 60, 0, ErrNotOnThisDate, nil},
|
||||
{"正常日两侧都有", JDECalc(2025, 6, 5), 116.4074, 39.9042, 8, nil, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := GetMoonRiseTime(tc.jd, tc.lon, tc.lat, tc.tz, 1, 0); !moonRiseSetErrorMatches(err, tc.riseErr) {
|
||||
t.Fatalf("GetMoonRiseTime error = %v, want %v", err, tc.riseErr)
|
||||
}
|
||||
if _, err := GetMoonSetTime(tc.jd, tc.lon, tc.lat, tc.tz, 1, 0); !moonRiseSetErrorMatches(err, tc.setErr) {
|
||||
t.Fatalf("GetMoonSetTime error = %v, want %v", err, tc.setErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoonRiseSetMissingEventMatchesDailyGeometry(t *testing.T) {
|
||||
dates := []float64{
|
||||
JDECalc(2023, 6, 21), JDECalc(2023, 12, 22), JDECalc(2024, 2, 29),
|
||||
JDECalc(2025, 6, 21), JDECalc(2025, 12, 22), JDECalc(2026, 3, 3),
|
||||
}
|
||||
latitudes := []float64{-89, -85, -75, -66, -60, 60, 66, 75, 85, 89}
|
||||
for _, jd := range dates {
|
||||
for _, lat := range latitudes {
|
||||
aboveAll, belowAll := moonRiseSetDailyGeometry(jd, 0, lat, 0)
|
||||
if !aboveAll && !belowAll {
|
||||
continue
|
||||
}
|
||||
_, riseErr := GetMoonRiseTime(jd, 0, lat, 0, 1, 0)
|
||||
_, setErr := GetMoonSetTime(jd, 0, lat, 0, 1, 0)
|
||||
want := ErrNeverSet
|
||||
if belowAll {
|
||||
want = ErrNeverRise
|
||||
}
|
||||
if !moonRiseSetErrorMatches(riseErr, want) {
|
||||
t.Fatalf("lat %v jd %.1f aboveAll=%v belowAll=%v: moonrise error = %v, want %v",
|
||||
lat, jd, aboveAll, belowAll, riseErr, want)
|
||||
}
|
||||
if !moonRiseSetErrorMatches(setErr, want) {
|
||||
t.Fatalf("lat %v jd %.1f aboveAll=%v belowAll=%v: moonset error = %v, want %v",
|
||||
lat, jd, aboveAll, belowAll, setErr, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// movingDiskEventEngine contains the time-domain part shared by solar
|
||||
// eclipses and lunar occultations. Geometry remains in the caller: a solar
|
||||
// adapter evaluates the Bessel projection, while an occultation adapter
|
||||
// evaluates the Earth-vector frame. Keeping that boundary explicit is what
|
||||
// lets the solar implementation remain the numerical baseline.
|
||||
type movingDiskEventEngine struct {
|
||||
maxSampleCount int
|
||||
searchSpanDays float64
|
||||
rangeStepDays float64
|
||||
rootToleranceDays float64
|
||||
greatestSpanDays float64
|
||||
greatestToleranceDays float64
|
||||
reserveAnchorSlot bool
|
||||
uniformOverflow bool
|
||||
indexedSampleTimes bool
|
||||
}
|
||||
|
||||
// movingDiskContactState is the dimensionless circular-disk model shared by
|
||||
// solar eclipse and lunar occultation adapters. The radii and separation may
|
||||
// be radians, degrees, or arcseconds, but all four values must use the same
|
||||
// unit. Solar eclipses provide distinct outer and inner occulting radii;
|
||||
// finite-planet occultations normally use the same lunar radius for both.
|
||||
type movingDiskContactState struct {
|
||||
separation float64
|
||||
occultingOuterRadius float64
|
||||
occultingInnerRadius float64
|
||||
targetRadius float64
|
||||
valid bool
|
||||
}
|
||||
|
||||
// movingDiskEventCacheMaximumEntries is shared by event-local caches so a
|
||||
// dense path cannot grow without bound in native or TinyGo/WASM execution.
|
||||
const movingDiskEventCacheMaximumEntries = 2048
|
||||
const movingDiskContactCacheMaximumEntries = movingDiskEventCacheMaximumEntries
|
||||
|
||||
// movingDiskContactCache stores the disk state for one event evaluation. The
|
||||
// valid bit is part of the entry so failed ephemeris evaluations are cached as
|
||||
// well; otherwise a pair of external/internal roots can repeat the same
|
||||
// invalid star or planet calculation indefinitely. Entries are keyed by the
|
||||
// exact TT bits because root refinement intentionally revisits exact endpoints.
|
||||
type movingDiskContactCache struct {
|
||||
entries map[uint64]movingDiskContactCacheEntry
|
||||
maxEntries int
|
||||
}
|
||||
|
||||
type movingDiskContactCacheEntry struct {
|
||||
state movingDiskContactState
|
||||
ok bool
|
||||
}
|
||||
|
||||
func newMovingDiskContactCache() *movingDiskContactCache {
|
||||
return &movingDiskContactCache{
|
||||
entries: make(map[uint64]movingDiskContactCacheEntry),
|
||||
maxEntries: movingDiskContactCacheMaximumEntries,
|
||||
}
|
||||
}
|
||||
|
||||
func (cache *movingDiskContactCache) lookup(tt float64) (movingDiskContactState, bool, bool) {
|
||||
if cache == nil || cache.entries == nil {
|
||||
return movingDiskContactState{}, false, false
|
||||
}
|
||||
entry, ok := cache.entries[math.Float64bits(tt)]
|
||||
if !ok {
|
||||
return movingDiskContactState{}, false, false
|
||||
}
|
||||
return entry.state, entry.ok, true
|
||||
}
|
||||
|
||||
func (cache *movingDiskContactCache) store(tt float64, state movingDiskContactState, ok bool) {
|
||||
if cache == nil {
|
||||
return
|
||||
}
|
||||
if cache.entries == nil {
|
||||
cache.entries = make(map[uint64]movingDiskContactCacheEntry)
|
||||
}
|
||||
limit := cache.maxEntries
|
||||
if limit <= 0 {
|
||||
limit = movingDiskContactCacheMaximumEntries
|
||||
}
|
||||
key := math.Float64bits(tt)
|
||||
if _, exists := cache.entries[key]; !exists && len(cache.entries) >= limit {
|
||||
// Contact roots are local to one event and are naturally clustered in
|
||||
// time. Clearing the bounded table is cheaper and more predictable
|
||||
// than maintaining an eviction list in TinyGo/WASM.
|
||||
for key := range cache.entries {
|
||||
delete(cache.entries, key)
|
||||
}
|
||||
}
|
||||
cache.entries[key] = movingDiskContactCacheEntry{state: state, ok: ok}
|
||||
}
|
||||
|
||||
// movingDiskContactEvaluator adapts an event-specific geometry calculation to
|
||||
// the shared contact root engine. Its callback must be pure for a given TT;
|
||||
// all observer/configuration values belong to the evaluator closure.
|
||||
type movingDiskContactEvaluator struct {
|
||||
evaluate func(float64) (movingDiskContactState, bool)
|
||||
cache *movingDiskContactCache
|
||||
}
|
||||
|
||||
func newMovingDiskContactEvaluator(
|
||||
evaluate func(float64) (movingDiskContactState, bool),
|
||||
) *movingDiskContactEvaluator {
|
||||
return &movingDiskContactEvaluator{
|
||||
evaluate: evaluate,
|
||||
cache: newMovingDiskContactCache(),
|
||||
}
|
||||
}
|
||||
|
||||
func (evaluator *movingDiskContactEvaluator) stateAt(tt float64) (movingDiskContactState, bool) {
|
||||
if evaluator == nil || evaluator.evaluate == nil || !finiteMovingDiskValue(tt) {
|
||||
return movingDiskContactState{}, false
|
||||
}
|
||||
if state, ok, hit := evaluator.cache.lookup(tt); hit {
|
||||
return state, ok
|
||||
}
|
||||
state, ok := evaluator.evaluate(tt)
|
||||
if !ok {
|
||||
state = movingDiskContactState{}
|
||||
}
|
||||
evaluator.cache.store(tt, state, ok)
|
||||
return state, ok
|
||||
}
|
||||
|
||||
// prime inserts a state already computed by the caller. Greatest-point
|
||||
// evaluation often precedes both external and internal contact roots; priming
|
||||
// avoids evaluating that same TT a second time while preserving the callback
|
||||
// as the source of truth for all other samples.
|
||||
func (evaluator *movingDiskContactEvaluator) prime(
|
||||
tt float64,
|
||||
state movingDiskContactState,
|
||||
ok bool,
|
||||
) {
|
||||
if evaluator == nil || !finiteMovingDiskValue(tt) {
|
||||
return
|
||||
}
|
||||
evaluator.cache.store(tt, state, ok)
|
||||
}
|
||||
|
||||
func (evaluator *movingDiskContactEvaluator) gap(tt float64, internal bool) (float64, bool) {
|
||||
state, ok := evaluator.stateAt(tt)
|
||||
if !ok || !state.valid {
|
||||
return 0, false
|
||||
}
|
||||
if internal {
|
||||
return state.internalContactGap(), true
|
||||
}
|
||||
return state.externalContactGap(), true
|
||||
}
|
||||
|
||||
func (state movingDiskContactState) externalContactGap() float64 {
|
||||
if !state.valid {
|
||||
return math.NaN()
|
||||
}
|
||||
return state.separation - state.occultingOuterRadius - state.targetRadius
|
||||
}
|
||||
|
||||
func (state movingDiskContactState) internalContactGap() float64 {
|
||||
if !state.valid {
|
||||
return math.NaN()
|
||||
}
|
||||
return state.separation - math.Abs(state.occultingInnerRadius-state.targetRadius)
|
||||
}
|
||||
|
||||
func movingDiskContactStateValid(separation, outerRadius, innerRadius, targetRadius float64) bool {
|
||||
return finiteMovingDiskValue(separation) && separation >= 0 &&
|
||||
finiteMovingDiskValue(outerRadius) && outerRadius > 0 &&
|
||||
finiteMovingDiskValue(innerRadius) && innerRadius > 0 &&
|
||||
finiteMovingDiskValue(targetRadius) && targetRadius >= 0
|
||||
}
|
||||
|
||||
func finiteMovingDiskValue(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
}
|
||||
|
||||
func (engine movingDiskEventEngine) sampleTimes(
|
||||
start, end, greatest, requestedStep float64,
|
||||
) ([]float64, float64) {
|
||||
if end < start {
|
||||
start, end = end, start
|
||||
}
|
||||
maximum := engine.maxSampleCount
|
||||
if maximum < 3 {
|
||||
maximum = 3
|
||||
}
|
||||
duration := end - start
|
||||
if duration <= 0 {
|
||||
return []float64{start}, requestedStep
|
||||
}
|
||||
step := requestedStep
|
||||
if step <= 0 || math.IsNaN(step) || math.IsInf(step, 0) {
|
||||
step = duration
|
||||
}
|
||||
baseSampleCount := int(math.Ceil(duration/step)) + 1
|
||||
if engine.reserveAnchorSlot {
|
||||
baseSampleCount++
|
||||
}
|
||||
if baseSampleCount > maximum && engine.uniformOverflow {
|
||||
interiorCount := maximum - 3
|
||||
if interiorCount < 0 {
|
||||
interiorCount = 0
|
||||
}
|
||||
bounded := make([]float64, 0, maximum)
|
||||
bounded = append(bounded, start, greatest, end)
|
||||
for index := 1; index <= interiorCount; index++ {
|
||||
bounded = append(bounded, start+duration*float64(index)/float64(interiorCount+1))
|
||||
}
|
||||
sort.Float64s(bounded)
|
||||
return movingDiskUniqueTimes(bounded), step
|
||||
}
|
||||
if baseSampleCount > maximum {
|
||||
step = duration / float64(maximum-1)
|
||||
}
|
||||
|
||||
times := []float64{start, greatest, end}
|
||||
if engine.indexedSampleTimes {
|
||||
for index := 1; ; index++ {
|
||||
current := start + float64(index)*step
|
||||
if current >= end {
|
||||
break
|
||||
}
|
||||
times = append(times, current)
|
||||
}
|
||||
} else {
|
||||
for current := start + step; current < end; current += step {
|
||||
times = append(times, current)
|
||||
}
|
||||
}
|
||||
sort.Float64s(times)
|
||||
return movingDiskUniqueTimes(times), step
|
||||
}
|
||||
|
||||
func (engine movingDiskEventEngine) window(
|
||||
seed, start, end float64,
|
||||
candidateAt, exactAt func(float64) bool,
|
||||
) (float64, float64, bool) {
|
||||
if exactAt == nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
left := start
|
||||
right := end
|
||||
if engine.searchSpanDays > 0 {
|
||||
left = math.Max(left, seed-engine.searchSpanDays)
|
||||
right = math.Min(right, seed+engine.searchSpanDays)
|
||||
}
|
||||
if right <= left {
|
||||
return 0, 0, false
|
||||
}
|
||||
if candidateAt == nil {
|
||||
candidateAt = exactAt
|
||||
}
|
||||
step := engine.rangeStepDays
|
||||
if step <= 0 || math.IsNaN(step) || math.IsInf(step, 0) {
|
||||
step = right - left
|
||||
}
|
||||
|
||||
first := math.NaN()
|
||||
previous := left
|
||||
if candidateAt(previous) && exactAt(previous) {
|
||||
first = previous
|
||||
}
|
||||
if math.IsNaN(first) {
|
||||
for current := left + step; current <= right; current += step {
|
||||
current = math.Min(current, right)
|
||||
if candidateAt(current) {
|
||||
first = engine.refineTransition(previous, current, exactAt, false)
|
||||
break
|
||||
}
|
||||
previous = current
|
||||
}
|
||||
}
|
||||
if math.IsNaN(first) {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
last := first
|
||||
previous = first
|
||||
for current := first + step; current <= right; current += step {
|
||||
current = math.Min(current, right)
|
||||
if !candidateAt(current) {
|
||||
last = engine.refineTransition(previous, current, exactAt, true)
|
||||
return first, last, true
|
||||
}
|
||||
last = current
|
||||
previous = current
|
||||
}
|
||||
return first, right, true
|
||||
}
|
||||
|
||||
func (engine movingDiskEventEngine) refineTransition(
|
||||
left, right float64,
|
||||
predicate func(float64) bool,
|
||||
trueToFalse bool,
|
||||
) float64 {
|
||||
leftOK := predicate(left)
|
||||
tolerance := engine.rootToleranceDays
|
||||
if tolerance <= 0 || math.IsNaN(tolerance) || math.IsInf(tolerance, 0) {
|
||||
tolerance = 1e-10
|
||||
}
|
||||
for iteration := 0; iteration < 48 && math.Abs(right-left) > tolerance; iteration++ {
|
||||
middle := (left + right) / 2
|
||||
middleOK := predicate(middle)
|
||||
if trueToFalse {
|
||||
if middleOK {
|
||||
left = middle
|
||||
} else {
|
||||
right = middle
|
||||
}
|
||||
continue
|
||||
}
|
||||
if middleOK {
|
||||
right = middle
|
||||
} else {
|
||||
left = middle
|
||||
}
|
||||
}
|
||||
if trueToFalse {
|
||||
return left
|
||||
}
|
||||
if leftOK {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
// greatest returns the minimum impact value in the event-local interval.
|
||||
// The callback returns (impact, valid); invalid states are treated as +Inf.
|
||||
func (engine movingDiskEventEngine) greatest(
|
||||
seed, start, end float64,
|
||||
impactAt func(float64) (float64, bool),
|
||||
iterations int,
|
||||
) float64 {
|
||||
if impactAt == nil {
|
||||
return seed
|
||||
}
|
||||
left := start
|
||||
right := end
|
||||
span := engine.greatestSpanDays
|
||||
if span > 0 {
|
||||
left = math.Max(left, seed-span)
|
||||
right = math.Min(right, seed+span)
|
||||
}
|
||||
if right <= left {
|
||||
return seed
|
||||
}
|
||||
if iterations <= 0 {
|
||||
iterations = 56
|
||||
}
|
||||
const goldenRatio = 0.6180339887498949
|
||||
x1 := right - goldenRatio*(right-left)
|
||||
x2 := left + goldenRatio*(right-left)
|
||||
f1 := movingDiskImpact(impactAt, x1)
|
||||
f2 := movingDiskImpact(impactAt, x2)
|
||||
for iteration := 0; iteration < iterations &&
|
||||
(engine.greatestToleranceDays <= 0 || right-left > engine.greatestToleranceDays); iteration++ {
|
||||
if f1 > f2 {
|
||||
left = x1
|
||||
x1, f1 = x2, f2
|
||||
x2 = left + goldenRatio*(right-left)
|
||||
f2 = movingDiskImpact(impactAt, x2)
|
||||
continue
|
||||
}
|
||||
right = x2
|
||||
x2, f2 = x1, f1
|
||||
x1 = right - goldenRatio*(right-left)
|
||||
f1 = movingDiskImpact(impactAt, x1)
|
||||
}
|
||||
return (left + right) / 2
|
||||
}
|
||||
|
||||
// contactRoot finds one external or internal disk-contact root by walking
|
||||
// away from greatest and then bisecting the first valid sign change. The
|
||||
// callback may reject an ephemeris sample; rejected samples are skipped while
|
||||
// searching, but an invalid value inside a confirmed bisection bracket aborts
|
||||
// that root rather than inventing a crossing.
|
||||
func (engine movingDiskEventEngine) contactRoot(
|
||||
greatest, direction, stepDays, spanDays, tolerance float64,
|
||||
metric func(float64) (float64, bool),
|
||||
iterations int,
|
||||
) (float64, bool) {
|
||||
if metric == nil || (direction != -1 && direction != 1) ||
|
||||
stepDays <= 0 || spanDays <= 0 || tolerance <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
nearTT := greatest
|
||||
nearValue, nearOK := metric(nearTT)
|
||||
if !nearOK || !finiteMovingDiskValue(nearValue) || nearValue > 0 {
|
||||
return 0, false
|
||||
}
|
||||
maxSteps := int(math.Ceil(spanDays / stepDays))
|
||||
if maxSteps < 1 {
|
||||
maxSteps = 1
|
||||
}
|
||||
for index := 1; index <= maxSteps; index++ {
|
||||
farTT := greatest + direction*float64(index)*stepDays
|
||||
farValue, farOK := metric(farTT)
|
||||
if !farOK || !finiteMovingDiskValue(farValue) {
|
||||
continue
|
||||
}
|
||||
if farValue < 0 {
|
||||
nearTT, nearValue = farTT, farValue
|
||||
continue
|
||||
}
|
||||
return movingDiskContactBracketRoot(
|
||||
nearTT, farTT, nearValue, farValue, metric, tolerance, iterations,
|
||||
)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func movingDiskContactBracketRoot(
|
||||
left, right, leftValue, rightValue float64,
|
||||
metric func(float64) (float64, bool),
|
||||
tolerance float64,
|
||||
iterations int,
|
||||
) (float64, bool) {
|
||||
if left > right {
|
||||
left, right = right, left
|
||||
leftValue, rightValue = rightValue, leftValue
|
||||
}
|
||||
if !finiteMovingDiskValue(leftValue) || !finiteMovingDiskValue(rightValue) ||
|
||||
leftValue*rightValue > 0 {
|
||||
return 0, false
|
||||
}
|
||||
if leftValue == 0 {
|
||||
return left, true
|
||||
}
|
||||
if rightValue == 0 {
|
||||
return right, true
|
||||
}
|
||||
if iterations <= 0 {
|
||||
iterations = 64
|
||||
}
|
||||
for index := 0; index < iterations && right-left > tolerance; index++ {
|
||||
middle := (left + right) / 2
|
||||
middleValue, ok := metric(middle)
|
||||
if !ok || !finiteMovingDiskValue(middleValue) {
|
||||
return 0, false
|
||||
}
|
||||
if leftValue*middleValue <= 0 {
|
||||
right, rightValue = middle, middleValue
|
||||
} else {
|
||||
left, leftValue = middle, middleValue
|
||||
}
|
||||
}
|
||||
return (left + right) / 2, true
|
||||
}
|
||||
|
||||
func movingDiskImpact(impactAt func(float64) (float64, bool), tt float64) float64 {
|
||||
value, ok := impactAt(tt)
|
||||
if !ok || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return math.Inf(1)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func movingDiskUniqueTimes(times []float64) []float64 {
|
||||
if len(times) < 2 {
|
||||
return times
|
||||
}
|
||||
unique := times[:1]
|
||||
for _, current := range times[1:] {
|
||||
if math.Abs(current-unique[len(unique)-1]) <= 1e-10 {
|
||||
continue
|
||||
}
|
||||
unique = append(unique, current)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
func solarEclipseMovingDiskEngine() movingDiskEventEngine {
|
||||
return movingDiskEventEngine{
|
||||
maxSampleCount: solarEclipsePathMaxSampleCount,
|
||||
rootToleranceDays: solarEclipseShadowContactToleranceDays,
|
||||
greatestToleranceDays: localSolarEclipseGreatestTolerance,
|
||||
}
|
||||
}
|
||||
|
||||
func occultationMovingDiskEngine() movingDiskEventEngine {
|
||||
return movingDiskEventEngine{
|
||||
maxSampleCount: occultationPathMaxSampleCount,
|
||||
searchSpanDays: occultationPathSearchSpanDays,
|
||||
rangeStepDays: occultationPathRangeStepDays,
|
||||
rootToleranceDays: occultationPathRootToleranceDays,
|
||||
greatestSpanDays: 0.75,
|
||||
reserveAnchorSlot: true,
|
||||
uniformOverflow: true,
|
||||
indexedSampleTimes: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMovingDiskEventEngineSampleTimesKeepAnchorsAndBudget(t *testing.T) {
|
||||
engine := movingDiskEventEngine{maxSampleCount: 7, reserveAnchorSlot: true, uniformOverflow: true}
|
||||
times, step := engine.sampleTimes(0, 1, 0.37, 0.01)
|
||||
if len(times) != 7 {
|
||||
t.Fatalf("sample count = %d, want 7", len(times))
|
||||
}
|
||||
if times[0] != 0 || times[len(times)-1] != 1 {
|
||||
t.Fatalf("sample endpoints = %.6f, %.6f, want 0 and 1", times[0], times[len(times)-1])
|
||||
}
|
||||
foundGreatest := false
|
||||
for index, value := range times {
|
||||
if index > 0 && value <= times[index-1] {
|
||||
t.Fatalf("sample times are not strictly increasing: %v", times)
|
||||
}
|
||||
if math.Abs(value-0.37) <= 1e-12 {
|
||||
foundGreatest = true
|
||||
}
|
||||
}
|
||||
if !foundGreatest {
|
||||
t.Fatalf("sample times omitted greatest: %v", times)
|
||||
}
|
||||
if step <= 0 {
|
||||
t.Fatalf("effective step = %v, want positive", step)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingDiskEventEngineWindowUsesCandidateAndExactPredicates(t *testing.T) {
|
||||
engine := movingDiskEventEngine{
|
||||
searchSpanDays: 1,
|
||||
rangeStepDays: 0.1,
|
||||
rootToleranceDays: 1e-9,
|
||||
}
|
||||
candidateCalls, exactCalls := 0, 0
|
||||
candidate := func(tt float64) bool {
|
||||
candidateCalls++
|
||||
return tt >= -0.35 && tt <= 0.42
|
||||
}
|
||||
exact := func(tt float64) bool {
|
||||
exactCalls++
|
||||
return tt >= -0.3 && tt <= 0.4
|
||||
}
|
||||
start, end, ok := engine.window(0, -1, 1, candidate, exact)
|
||||
if !ok {
|
||||
t.Fatal("window returned not found")
|
||||
}
|
||||
if math.Abs(start+0.3) > 2e-8 || math.Abs(end-0.4) > 2e-8 {
|
||||
t.Fatalf("window = %.12f..%.12f, want -0.3..0.4", start, end)
|
||||
}
|
||||
if candidateCalls == 0 || exactCalls == 0 {
|
||||
t.Fatalf("candidate/exact calls = %d/%d, want both", candidateCalls, exactCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingDiskEventEngineGreatestFindsMinimumImpact(t *testing.T) {
|
||||
engine := movingDiskEventEngine{greatestSpanDays: 0.75}
|
||||
got := engine.greatest(0.1, -1, 1, func(tt float64) (float64, bool) {
|
||||
return (tt - 0.23) * (tt - 0.23), true
|
||||
}, 56)
|
||||
if math.Abs(got-0.23) > 1e-8 {
|
||||
t.Fatalf("greatest = %.12f, want 0.23", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingDiskContactStateUsesSolarOuterAndInnerRadii(t *testing.T) {
|
||||
state := movingDiskContactState{
|
||||
separation: 1.0,
|
||||
occultingOuterRadius: 0.6,
|
||||
occultingInnerRadius: 0.4,
|
||||
targetRadius: 0.5,
|
||||
valid: true,
|
||||
}
|
||||
if got := state.externalContactGap(); math.Abs(got+0.1) > 1e-12 {
|
||||
t.Fatalf("external gap = %.12f, want -0.1", got)
|
||||
}
|
||||
if got := state.internalContactGap(); math.Abs(got-0.9) > 1e-12 {
|
||||
t.Fatalf("internal gap = %.12f, want 0.9", got)
|
||||
}
|
||||
if !movingDiskContactStateValid(1, 0.6, 0.4, 0.5) {
|
||||
t.Fatal("finite disk state was rejected")
|
||||
}
|
||||
if movingDiskContactStateValid(math.NaN(), 0.6, 0.4, 0.5) {
|
||||
t.Fatal("NaN disk state was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingDiskEventEngineContactRootWalksBothDirections(t *testing.T) {
|
||||
engine := movingDiskEventEngine{}
|
||||
metric := func(tt float64) (float64, bool) {
|
||||
return (tt + 0.3) * (tt - 0.4), true
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
direction float64
|
||||
want float64
|
||||
}{
|
||||
{name: "forward", direction: 1, want: 0.4},
|
||||
{name: "backward", direction: -1, want: -0.3},
|
||||
} {
|
||||
got, ok := engine.contactRoot(0, test.direction, 0.05, 1, 1e-9, metric, 64)
|
||||
if !ok || math.Abs(got-test.want) > 2e-8 {
|
||||
t.Errorf("%s contact root = %.12f, ok=%v, want %.3f", test.name, got, ok, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingDiskContactEvaluatorCachesValidAndInvalidStates(t *testing.T) {
|
||||
calls := 0
|
||||
evaluator := newMovingDiskContactEvaluator(func(tt float64) (movingDiskContactState, bool) {
|
||||
calls++
|
||||
if tt < 0 {
|
||||
return movingDiskContactState{}, false
|
||||
}
|
||||
state := movingDiskContactState{
|
||||
separation: 1,
|
||||
occultingOuterRadius: 0.6,
|
||||
occultingInnerRadius: 0.6,
|
||||
targetRadius: 0.2,
|
||||
valid: true,
|
||||
}
|
||||
return state, true
|
||||
})
|
||||
if _, ok := evaluator.gap(0.25, false); !ok {
|
||||
t.Fatal("first valid evaluation failed")
|
||||
}
|
||||
if _, ok := evaluator.gap(0.25, true); !ok {
|
||||
t.Fatal("cached valid evaluation failed")
|
||||
}
|
||||
if _, ok := evaluator.gap(-0.25, false); ok {
|
||||
t.Fatal("invalid evaluation was accepted")
|
||||
}
|
||||
if _, ok := evaluator.gap(-0.25, true); ok {
|
||||
t.Fatal("cached invalid evaluation was accepted")
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("evaluation calls = %d, want 2 after valid/invalid cache hits", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingDiskContactEvaluatorPrimeAvoidsCallback(t *testing.T) {
|
||||
calls := 0
|
||||
evaluator := newMovingDiskContactEvaluator(func(tt float64) (movingDiskContactState, bool) {
|
||||
calls++
|
||||
return movingDiskContactState{}, false
|
||||
})
|
||||
state := movingDiskContactState{
|
||||
separation: 1,
|
||||
occultingOuterRadius: 0.6,
|
||||
occultingInnerRadius: 0.6,
|
||||
targetRadius: 0.2,
|
||||
valid: true,
|
||||
}
|
||||
evaluator.prime(0.5, state, true)
|
||||
value, ok := evaluator.gap(0.5, false)
|
||||
if !ok || math.Abs(value-0.2) > 1e-12 {
|
||||
t.Fatalf("primed external gap = %.12f, ok=%v", value, ok)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("callback calls = %d, want 0 for primed TT", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingDiskContactCacheClearsAtBoundedCapacity(t *testing.T) {
|
||||
cache := &movingDiskContactCache{maxEntries: 2}
|
||||
state := movingDiskContactState{valid: true}
|
||||
cache.store(1, state, true)
|
||||
cache.store(2, state, true)
|
||||
cache.store(3, state, true)
|
||||
if len(cache.entries) != 1 {
|
||||
t.Fatalf("cache size = %d, want 1 after bounded clear", len(cache.entries))
|
||||
}
|
||||
if _, _, hit := cache.lookup(1); hit {
|
||||
t.Fatal("old entry survived bounded cache clear")
|
||||
}
|
||||
if _, _, hit := cache.lookup(3); !hit {
|
||||
t.Fatal("new entry missing after bounded cache clear")
|
||||
}
|
||||
}
|
||||
+62
-10
@@ -172,6 +172,9 @@ func LastNeptuneWesternQuadrature(jde float64) float64 {
|
||||
}
|
||||
|
||||
func neptuneRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool) float64 {
|
||||
if !isFiniteFloat(oppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
oppositionTT := TD2UT(oppositionJD, true)
|
||||
startTT := oppositionTT
|
||||
endTT := oppositionTT
|
||||
@@ -183,49 +186,98 @@ func neptuneRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposit
|
||||
endTT = TD2UT(westernQuadratureUT, true)
|
||||
}
|
||||
bestJD := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 {
|
||||
return neptuneRADerivativeN(jd, 1.0/86400.0, neptuneEventSearchN)
|
||||
return neptuneRADerivativeN(jd, stationDerivativeStepDay, neptuneEventSearchN)
|
||||
}, func(jd float64) float64 {
|
||||
return neptuneRADerivative(jd, 0.5/86400.0)
|
||||
return neptuneRADerivative(jd, stationDerivativeStepDay)
|
||||
})
|
||||
return TD2UT(bestJD, false)
|
||||
}
|
||||
|
||||
func NextNeptuneRetrogradeToPrograde(jde float64) float64 {
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := neptuneConjunctionFull(jde, 180, 0)
|
||||
date := neptuneRetrogradeAroundOpposition(lastOppositionJD, false)
|
||||
if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) {
|
||||
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
nextOppositionJD := neptuneConjunctionFull(jde, 180, 1)
|
||||
return neptuneRetrogradeAroundOpposition(nextOppositionJD, false)
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = neptuneRetrogradeAroundOpposition(nextOppositionJD, false)
|
||||
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func LastNeptuneRetrogradeToPrograde(jde float64) float64 {
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := neptuneConjunctionFull(jde, 180, 0)
|
||||
date := neptuneRetrogradeAroundOpposition(lastOppositionJD, false)
|
||||
if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) {
|
||||
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(lastOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
previousOppositionJD := neptuneConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0)
|
||||
return neptuneRetrogradeAroundOpposition(previousOppositionJD, false)
|
||||
if !isFiniteFloat(previousOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = neptuneRetrogradeAroundOpposition(previousOppositionJD, false)
|
||||
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func NextNeptuneProgradeToRetrograde(jde float64) float64 {
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
nextOppositionJD := neptuneConjunctionFull(jde, 180, 1)
|
||||
date := neptuneRetrogradeAroundOpposition(nextOppositionJD, true)
|
||||
if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) {
|
||||
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
followingOppositionJD := neptuneConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1)
|
||||
return neptuneRetrogradeAroundOpposition(followingOppositionJD, true)
|
||||
if !isFiniteFloat(followingOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = neptuneRetrogradeAroundOpposition(followingOppositionJD, true)
|
||||
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func LastNeptuneProgradeToRetrograde(jde float64) float64 {
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
nextOppositionJD := neptuneConjunctionFull(jde, 180, 1)
|
||||
date := neptuneRetrogradeAroundOpposition(nextOppositionJD, true)
|
||||
if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) {
|
||||
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := neptuneConjunctionFull(jde, 180, 0)
|
||||
return neptuneRetrogradeAroundOpposition(lastOppositionJD, true)
|
||||
if !isFiniteFloat(lastOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = neptuneRetrogradeAroundOpposition(lastOppositionJD, true)
|
||||
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
+10
-2
@@ -209,9 +209,17 @@ func Nutation1980(jd float64) (float64, float64) {
|
||||
return dpsi * deg, deps * deg
|
||||
}
|
||||
|
||||
// Nutation2000B 计算 IAU 2000B 章动模型
|
||||
// 返回交角章动 (de) 和黄经章动 (dp),单位为度
|
||||
// Nutation2000B IAU 2000B 章动,返回 (黄经章动, 交角章动),单位度 / nutation in longitude and obliquity in degrees.
|
||||
func Nutation2000B(jd float64) (float64, float64) {
|
||||
if dpsi, deps, ok := nutationMemoLoad(jd); ok {
|
||||
return dpsi, deps
|
||||
}
|
||||
dpsi, deps := nutation2000BCompute(jd)
|
||||
nutationMemoStore(jd, dpsi, deps)
|
||||
return dpsi, deps
|
||||
}
|
||||
|
||||
func nutation2000BCompute(jd float64) (float64, float64) {
|
||||
// 常量定义
|
||||
as2r := 4.848136811095359935899141e-6 // 角秒到弧度的转换因子
|
||||
twopi := 6.283185307179586476925287 // 2π
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Nutation2000B 是 jd 的纯函数,但升落、星历与掩星路径会对同一批瞬时反复求值:一条计算链里
|
||||
// 地球自转、状态上下文与几何装配各自求一次同一个瞬时。这里用有界直接映射表记住结果:
|
||||
// 槽位固定、无分配、RWMutex 保证 c-shared 宿主多线程安全。章动只依赖传入的 jd,与 ΔT 世代无关。
|
||||
// Nutation2000B is a pure function of jd, yet the rise/set, ephemeris and occultation chains evaluate
|
||||
// it repeatedly for the same instants from independent code paths. This bounded direct-mapped memo
|
||||
// removes that redundancy with fixed slots, no allocation and an RWMutex for the c-shared host.
|
||||
// Nutation depends only on its argument, so no ΔT generation stamp is needed.
|
||||
const nutationMemoBits = 13
|
||||
|
||||
const nutationMemoSize = 1 << nutationMemoBits
|
||||
|
||||
type nutationMemoEntry struct {
|
||||
// key 是 math.Float64bits(jd)+1,0 表示空槽(避免 jd=0 与空槽同码)。
|
||||
key uint64
|
||||
dpsi, deps float64
|
||||
}
|
||||
|
||||
var (
|
||||
nutationMemoMu sync.RWMutex
|
||||
nutationMemoTable [nutationMemoSize]nutationMemoEntry
|
||||
nutationMemoHits uint64
|
||||
nutationMemoMiss uint64
|
||||
)
|
||||
|
||||
func nutationMemoIndex(jd float64) uint64 {
|
||||
bits := math.Float64bits(jd)
|
||||
return (bits ^ (bits >> 31)) & (nutationMemoSize - 1)
|
||||
}
|
||||
|
||||
func nutationMemoLoad(jd float64) (float64, float64, bool) {
|
||||
key := math.Float64bits(jd) + 1
|
||||
entry := &nutationMemoTable[nutationMemoIndex(jd)]
|
||||
nutationMemoMu.RLock()
|
||||
entryKey, dpsi, deps := entry.key, entry.dpsi, entry.deps
|
||||
nutationMemoMu.RUnlock()
|
||||
if key != 0 && entryKey == key {
|
||||
atomic.AddUint64(&nutationMemoHits, 1)
|
||||
return dpsi, deps, true
|
||||
}
|
||||
atomic.AddUint64(&nutationMemoMiss, 1)
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func nutationMemoStore(jd, dpsi, deps float64) {
|
||||
key := math.Float64bits(jd) + 1
|
||||
if key == 0 {
|
||||
return
|
||||
}
|
||||
nutationMemoMu.Lock()
|
||||
nutationMemoTable[nutationMemoIndex(jd)] = nutationMemoEntry{key: key, dpsi: dpsi, deps: deps}
|
||||
nutationMemoMu.Unlock()
|
||||
}
|
||||
|
||||
// nutationMemoStats 返回命中/未命中计数,供测试守护命中率。
|
||||
func nutationMemoStats() (uint64, uint64) {
|
||||
return atomic.LoadUint64(&nutationMemoHits), atomic.LoadUint64(&nutationMemoMiss)
|
||||
}
|
||||
|
||||
func resetNutationMemo() {
|
||||
nutationMemoMu.Lock()
|
||||
for i := range nutationMemoTable {
|
||||
nutationMemoTable[i] = nutationMemoEntry{}
|
||||
}
|
||||
nutationMemoMu.Unlock()
|
||||
atomic.StoreUint64(&nutationMemoHits, 0)
|
||||
atomic.StoreUint64(&nutationMemoMiss, 0)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 记忆表必须逐位精确:命中返回值与重新展开 77 项级数完全一致,且相邻 1 ULP 的输入互不干扰。
|
||||
// The memo must be bit-exact: a hit returns exactly what the 77-term series would recompute, and
|
||||
// inputs one ULP apart keep independent entries.
|
||||
func TestNutationMemoIsBitExact(t *testing.T) {
|
||||
values := []float64{
|
||||
2451545.0, 2460310.5, 0, -1000000.5, -4713.5, 5373484.5,
|
||||
math.NaN(), math.Inf(1), math.Inf(-1), math.SmallestNonzeroFloat64,
|
||||
}
|
||||
for index := 0; index < 5000; index++ {
|
||||
values = append(values, 1000000.0+float64(index)*0.37)
|
||||
}
|
||||
for _, jd := range values {
|
||||
memoPsi, memoEps := Nutation2000B(jd)
|
||||
directPsi, directEps := nutation2000BCompute(jd)
|
||||
if math.Float64bits(memoPsi) != math.Float64bits(directPsi) ||
|
||||
math.Float64bits(memoEps) != math.Float64bits(directEps) {
|
||||
t.Fatalf("jd=%v: memo (%v,%v) != direct (%v,%v)", jd, memoPsi, memoEps, directPsi, directEps)
|
||||
}
|
||||
againPsi, againEps := Nutation2000B(jd)
|
||||
if math.Float64bits(againPsi) != math.Float64bits(memoPsi) ||
|
||||
math.Float64bits(againEps) != math.Float64bits(memoEps) {
|
||||
t.Fatalf("jd=%v: repeated call changed the value", jd)
|
||||
}
|
||||
// 相邻 1 ULP 的输入必须各自独立(不能被同一槽位合并)。
|
||||
neighbour := math.Nextafter(jd, math.Inf(1))
|
||||
if math.IsNaN(jd) || math.IsInf(jd, 0) {
|
||||
continue
|
||||
}
|
||||
nearPsi, _ := Nutation2000B(neighbour)
|
||||
nearDirect, _ := nutation2000BCompute(neighbour)
|
||||
if math.Float64bits(nearPsi) != math.Float64bits(nearDirect) {
|
||||
t.Fatalf("jd=%v neighbour: memo (%v) != direct (%v)", jd, nearPsi, nearDirect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 章动只依赖传入的 jd:ΔT 覆盖不改变它,因此记忆表不需要世代戳(与视恒星时不同)。
|
||||
func TestNutationMemoIgnoresDeltaTGeneration(t *testing.T) {
|
||||
original := GetDeltaTFn()
|
||||
defer SetDeltaTFn(original)
|
||||
jd := 2460310.5
|
||||
beforePsi, beforeEps := Nutation2000B(jd)
|
||||
SetDeltaTFn(func(date float64, isJd bool) float64 { return 6000 })
|
||||
afterPsi, afterEps := Nutation2000B(jd)
|
||||
if math.Float64bits(beforePsi) != math.Float64bits(afterPsi) ||
|
||||
math.Float64bits(beforeEps) != math.Float64bits(afterEps) {
|
||||
t.Fatalf("nutation changed with the ΔT generation: (%v,%v) vs (%v,%v)", beforePsi, beforeEps, afterPsi, afterEps)
|
||||
}
|
||||
}
|
||||
+210
-27
@@ -162,9 +162,19 @@ func moonTopocentricSemidiameterN(tt float64, observer Observer, n int) float64
|
||||
return angularSemidiameterArcsec(moonEquatorialRadiusKM, distanceKM)
|
||||
}
|
||||
|
||||
// topocentricDistanceKM 使用与 TopocentricRaDec 相同的 WGS-84 风格站点因子计算观测者到目标的距离 /
|
||||
// The target is supplied in apparent equatorial coordinates, and the sidereal angle uses UTC/UT like TopocentricRaDec.
|
||||
// topocentricDistanceKM 使用与 TopocentricRaDec 相同的 WGS-84 风格站点因子计算观测者到目标的距离。
|
||||
// 目标采用视赤道坐标,恒星时与 TopocentricRaDec 一样基于 UTC/UT。
|
||||
// topocentricDistanceKM uses the same WGS-84-style site factors as TopocentricRaDec.
|
||||
// The target uses apparent equatorial coordinates, and the sidereal angle is based on UTC/UT.
|
||||
func topocentricDistanceKM(ra, dec, distanceKM float64, observer Observer, ut float64) float64 {
|
||||
return topocentricDistanceKMWithSidereal(ra, dec, distanceKM, observer, ApparentSiderealTime(ut)*15)
|
||||
}
|
||||
|
||||
func topocentricDistanceKMWithSidereal(
|
||||
ra, dec, distanceKM float64,
|
||||
observer Observer,
|
||||
siderealDegrees float64,
|
||||
) float64 {
|
||||
const earthEquatorialRadius = 6378.14
|
||||
const astronomicalUnitKM = angularDiameterAstronomicalUnitKM
|
||||
|
||||
@@ -179,7 +189,7 @@ func topocentricDistanceKM(ra, dec, distanceKM float64, observer Observer, ut fl
|
||||
distanceAU * math.Cos(decRad) * math.Sin(raRad),
|
||||
distanceAU * math.Sin(decRad),
|
||||
}
|
||||
theta := (ApparentSiderealTime(ut)*15 + observer.Longitude) * math.Pi / 180
|
||||
theta := (siderealDegrees + observer.Longitude) * math.Pi / 180
|
||||
observerAU := earthEquatorialRadius / astronomicalUnitKM
|
||||
observerVector := [3]float64{
|
||||
observerAU * pcosi(observer.Latitude, observer.Height) * math.Cos(theta),
|
||||
@@ -309,21 +319,68 @@ type OccultationSearchOptions struct {
|
||||
MaxEvents int
|
||||
}
|
||||
|
||||
// OccultationPathAlgorithm 选择全球月掩路径的星历求解分支。
|
||||
// OccultationPathAlgorithm selects the ephemeris branch for global occultation paths.
|
||||
type OccultationPathAlgorithm string
|
||||
|
||||
const (
|
||||
// OccultationPathAlgorithmOptimized 使用经抽检的密集星历插值,保留站心方程与连续包络。
|
||||
// OccultationPathAlgorithmOptimized uses checked dense ephemeris interpolation with the same station equations and continuous envelopes.
|
||||
OccultationPathAlgorithmOptimized OccultationPathAlgorithm = "optimized"
|
||||
// OccultationPathAlgorithmExact 保留原分支:插值预测候选,最终求解使用全项星历。
|
||||
// OccultationPathAlgorithmExact retains the original branch: interpolated candidates and full-term ephemerides for final solving.
|
||||
OccultationPathAlgorithmExact OccultationPathAlgorithm = "exact"
|
||||
)
|
||||
|
||||
// OccultationPathOptions 控制全球月掩路径采样。
|
||||
//
|
||||
// Step 为路径采样的基础时间步长,正值至少为 1 秒。TargetSpacingKM 要求相邻中心线点超过目标地面距离时进行自适应加密。
|
||||
// 正的 TargetSpacingKM 至少为 1 km;超过中心线或有限盘面路径工作量预算时返回 ErrOccultationPathSamplingLimit,不会静默降低请求分辨率。行星瞬时足迹使用结果中说明的独立有界采样策略。
|
||||
// 正的 TargetSpacingKM 至少为 1 km;超过中心线或有限盘面路径工作量预算时返回 ErrOccultationPathSamplingLimit,不会静默降低请求分辨率。恒星和行星瞬时足迹使用 1 分钟目标步长和独立样本上限。
|
||||
// OccultationPathOptions controls global occultation-path sampling.
|
||||
// Step is the base time step used for path samples; positive values must be at least one second. TargetSpacingKM requests adaptive refinement when adjacent center-line points exceed the requested ground distance.
|
||||
// Positive TargetSpacingKM values must be at least 1 km. Requests that exceed the center-line or aggregate finite-disk work budgets return ErrOccultationPathSamplingLimit instead of silently reducing resolution. Planetary instantaneous footprints have a separate bounded sampling policy documented on the result.
|
||||
// Positive TargetSpacingKM values must be at least 1 km. Requests that exceed the center-line or aggregate finite-disk work budgets return ErrOccultationPathSamplingLimit instead of silently reducing resolution. Stellar and planetary instantaneous footprints use a one-minute target step and a separate sample cap.
|
||||
type OccultationPathOptions struct {
|
||||
// Algorithm 的零值等同 optimized;exact 可选择原有精确分支。此选项不影响独立的单时刻月影与事件查询接口。
|
||||
// Algorithm defaults to optimized; exact selects the original branch. Independent instant-footprint and event-search APIs are unaffected.
|
||||
Algorithm OccultationPathAlgorithm
|
||||
Step time.Duration
|
||||
TargetSpacingKM float64
|
||||
// RiseSetStep 独立控制六类升落阶段线的采样步长;零值使用 5 分钟。
|
||||
// RiseSetStep controls horizon-curve sampling independently from the path step; zero uses five minutes.
|
||||
RiseSetStep time.Duration
|
||||
// DisableRiseSet 在不需要时跳过六类初掩、掩甚、终掩月升/月落线。
|
||||
// DisableRiseSet skips the six local phase/horizon curves when they are not needed.
|
||||
DisableRiseSet bool
|
||||
// DisableFootprints 跳过密集的独立瞬时可见区,改用稀疏支撑样本构造紧凑掩带;中心线、边界和升落阶段线仍保留。
|
||||
// DisableFootprints skips dense standalone instantaneous visible regions and uses sparse support samples to construct compact bands; the center line, limits, and rise/set curves remain available.
|
||||
DisableFootprints bool
|
||||
// IncludeFootprintTimeline 在保留紧凑静态掩带的同时,增加独立采样的瞬时足迹时间线。
|
||||
// IncludeFootprintTimeline adds independently sampled instantaneous footprints while retaining the compact static band.
|
||||
IncludeFootprintTimeline bool
|
||||
// FootprintTimelineStep 控制瞬时足迹时间线的采样间隔;启用时零值使用 5 分钟。
|
||||
// FootprintTimelineStep controls the instantaneous timeline interval; zero uses five minutes when the timeline is enabled.
|
||||
FootprintTimelineStep time.Duration
|
||||
// GreatestTimeValues 是要计算的地方掩甚时刻等值线的时刻取值(力学时儒略日,最多 64 条,超出按时间截断);空值时改用 GreatestTimeStep。
|
||||
// 只有确实存在该时刻掩甚轨迹的取值才会出现在结果里,所以返回条数可能少于请求条数。
|
||||
// 每条等时线用固定时刻的残差零集延拓,成本正比于曲线长度而不是可见域面积。
|
||||
// GreatestTimeValues requests local greatest-occultation time isolines as TT Julian ephemeris days;
|
||||
// when empty, GreatestTimeStep is used instead. Each isochrone is continued along the zero set of a
|
||||
// fixed-instant residual so the cost scales with curve length rather than with the visible area.
|
||||
GreatestTimeValues []float64
|
||||
// GreatestTimeStep 是等时线间隔;仅在 GreatestTimeValues 为空时生效,非正值不计算等时线。
|
||||
// GreatestTimeStep is the isochrone interval; it applies only when GreatestTimeValues is empty,
|
||||
// and non-positive values disable the isolines.
|
||||
GreatestTimeStep time.Duration
|
||||
}
|
||||
|
||||
// Validate 检查全球路径采样选项。
|
||||
// Validate checks global path sampling options.
|
||||
func (o OccultationPathOptions) Validate() error {
|
||||
switch o.Algorithm {
|
||||
case "", OccultationPathAlgorithmOptimized, OccultationPathAlgorithmExact:
|
||||
default:
|
||||
return fmt.Errorf("%w: unsupported path algorithm %q", ErrInvalidOccultationInput, o.Algorithm)
|
||||
}
|
||||
if o.Step < 0 {
|
||||
return fmt.Errorf("%w: path step cannot be negative", ErrInvalidOccultationInput)
|
||||
}
|
||||
@@ -336,6 +393,18 @@ func (o OccultationPathOptions) Validate() error {
|
||||
if o.TargetSpacingKM > 0 && o.TargetSpacingKM < occultationPathMinimumTargetSpacingKM {
|
||||
return fmt.Errorf("%w: path target spacing must be zero or at least %.0f km", ErrInvalidOccultationInput, occultationPathMinimumTargetSpacingKM)
|
||||
}
|
||||
if o.RiseSetStep < 0 {
|
||||
return fmt.Errorf("%w: rise/set step cannot be negative", ErrInvalidOccultationInput)
|
||||
}
|
||||
if o.RiseSetStep > 0 && o.RiseSetStep < occultationPathMinimumStep {
|
||||
return fmt.Errorf("%w: rise/set step must be zero or at least %s", ErrInvalidOccultationInput, occultationPathMinimumStep)
|
||||
}
|
||||
if o.FootprintTimelineStep < 0 {
|
||||
return fmt.Errorf("%w: footprint step cannot be negative", ErrInvalidOccultationInput)
|
||||
}
|
||||
if o.FootprintTimelineStep > 0 && o.FootprintTimelineStep < occultationPathMinimumStep {
|
||||
return fmt.Errorf("%w: footprint step must be zero or at least %s", ErrInvalidOccultationInput, occultationPathMinimumStep)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -412,23 +481,44 @@ type PlanetOccultationInfo struct {
|
||||
}
|
||||
|
||||
// OccultationPathPoint 是全球月掩路径上的一个地理采样点。
|
||||
// Start 和 End 描述月缘外接触掩带;WidthKM 是垂直地面轨迹方向的切平面宽度,仅对中心线采样点有意义。
|
||||
// Start 和 End 描述月缘外接触掩带。
|
||||
// WidthKM 是中心线采样处的地面横向宽度:接触锥可见弧上地面横向偏移的极差;投影折叠的退化事件改用同刻两条横切母线与椭球交点间的弦长。
|
||||
// LimitSeparationKM 只对南北限采样点(含全掩限)非零,是同一时刻对侧限线的地面间距;它与 WidthKM 构造不同,不要互相换算。
|
||||
// 基础采样直接求解,自适应插入点使用宽度插值并进行五米采样误差检查。
|
||||
// OccultationPathPoint is a geographic sample of a global lunar-occultation path.
|
||||
// Start and End describe the outer lunar-limb footprint. WidthKM is the local tangent-plane width perpendicular to the ground track and is meaningful only on center-line samples.
|
||||
// Start and End describe the outer lunar-limb footprint.
|
||||
// WidthKM is the ground cross-track width at a center-line sample: the spread of ground cross-track offsets over the visible contact-cone arc, falling back to the same-instant chord between the two cross-track generators when the projection folds.
|
||||
// LimitSeparationKM is non-zero only on northern/southern limit samples (total limits included) and is the same-instant ground distance to the opposite limit; it is constructed differently from WidthKM and must not be converted into it.
|
||||
// Base samples are solved directly; adaptive samples use width interpolation and five-meter error checks.
|
||||
type OccultationPathPoint struct {
|
||||
Time time.Time
|
||||
Longitude float64
|
||||
Latitude float64
|
||||
MoonAltitude float64
|
||||
WidthKM float64
|
||||
Time time.Time
|
||||
Longitude float64
|
||||
Latitude float64
|
||||
MoonAltitude float64
|
||||
WidthKM float64
|
||||
LimitSeparationKM float64
|
||||
}
|
||||
|
||||
// OccultationGreatestTimeContour 是一个固定地方掩甚时刻的等值线支路集合。
|
||||
// OccultationGreatestTimeContour contains the continuous branches of one fixed local greatest-occultation time.
|
||||
type OccultationGreatestTimeContour struct {
|
||||
// JDE 是该等值线表示的力学时儒略日,也就是各支路上地方掩甚发生的时刻。
|
||||
// JDE is the TT Julian ephemeris day represented by this contour, the local greatest-occultation instant along every branch.
|
||||
JDE float64
|
||||
// Time 是 JDE 对应的时刻;按步长请求时它是原始对齐时刻,避免 JDE 往返把整分取值截断成前一分钟。
|
||||
// Time is the instant matching JDE; for step-derived levels it is the original aligned instant,
|
||||
// so a JDE round trip cannot truncate a whole-minute level into the previous minute.
|
||||
Time time.Time
|
||||
// Segments 是该时刻的连续等时线支路;一条支路两端止于月平线(几何地平,无蒙气差修正)或掩可见域边界,
|
||||
// 纬度 ±88° 以上不再延拓,同一时刻可能有多条不相连的支路。
|
||||
// Segments are continuous isochrone branches; each branch ends at the lunar horizon or the occultation-visibility boundary.
|
||||
Segments [][]OccultationPathPoint
|
||||
}
|
||||
|
||||
// StarOccultationPath 包含点光源恒星月掩的全球掩带。
|
||||
// 中心线是月心与恒星对齐的轨迹;NorthernLimit 和 SouthernLimit 是中心线两侧采样的月缘外边界。
|
||||
// 中心线是月心与恒星对齐的轨迹;NorthernLimit 和 SouthernLimit 是月缘外接触锥的切点轨迹(掩星在该线上恰好退化为擦边),不是中心线的等距横向平移。
|
||||
// StarOccultationPath contains the global footprint of a point-source stellar occultation.
|
||||
// The center line is the locus where the lunar center aligns with the star; NorthernLimit and SouthernLimit are the two outer lunar-limb boundaries sampled beside that line.
|
||||
// The center line is the locus where the lunar center aligns with the star; NorthernLimit and SouthernLimit are the tangency tracks of the outer-contact cone, where the occultation degenerates to a graze, so they are not a constant offset of the center line.
|
||||
type StarOccultationPath struct {
|
||||
TargetID string
|
||||
|
||||
@@ -442,20 +532,77 @@ type StarOccultationPath struct {
|
||||
CenterLine []OccultationPathPoint
|
||||
NorthernLimit []OccultationPathPoint
|
||||
SouthernLimit []OccultationPathPoint
|
||||
// GreatestLimitSeparationKM 是掩甚处南北限的地面间距(取与掩甚时刻最近的限线采样对),与 Greatest.WidthKM 口径不同,不要互相换算。
|
||||
// GreatestLimitSeparationKM is the ground separation of the two limits at greatest, taken from the limit sample pair nearest to the greatest instant; it uses a different construction from Greatest.WidthKM and must not be converted into it.
|
||||
GreatestLimitSeparationKM float64
|
||||
// BandContours 是构造静态可见掩带的连续接触包络;点光源恒星等价于月缘外接触边界。
|
||||
// BandContours are the continuous contact envelopes used to construct the static visible band; for point-source stars they are the lunar-limb outer-contact boundaries.
|
||||
BandContours [][]OccultationPathPoint
|
||||
// VisibilityContours 是掩星有效期间月球可见性的连续时间外包络。
|
||||
// VisibilityContours are the continuous temporal envelopes of lunar visibility while the occultation is active.
|
||||
VisibilityContours [][]OccultationPathPoint
|
||||
// Footprints 是时刻采样的可见点源掩区,其扫掠构成全球掩带;采样口径与行星外接触足迹一致。
|
||||
// Footprints are sampled visible point-source regions whose sweep forms the global band, using the same sampling contract as planetary outer-contact footprints.
|
||||
Footprints []OccultationFootprint
|
||||
// BandFootprints 是关闭密集瞬时足迹时用于构造紧凑掩带的稀疏可见区样本;展示层应合并它们,不应逐个输出。
|
||||
// BandFootprints are sparse visible-region samples used to construct a compact band when dense instantaneous footprints are disabled. Renderers should merge rather than emit them individually.
|
||||
BandFootprints []OccultationFootprint
|
||||
// RiseSetCurves 是初掩、掩甚和终掩分别发生在月升或月落时的六类边界。
|
||||
// RiseSetCurves are the six boundaries where local start, greatest, or end occurs at moonrise or moonset.
|
||||
RiseSetCurves []OccultationRiseSetCurve
|
||||
// GreatestTimeContours 是按掩甚时刻采样的等时线。
|
||||
// GreatestTimeContours are sampled local greatest-occultation time isolines.
|
||||
GreatestTimeContours []OccultationGreatestTimeContour
|
||||
|
||||
Step time.Duration
|
||||
TargetSpacingKM float64
|
||||
}
|
||||
|
||||
// PlanetOccultationFootprint 是一个时刻的可见接触足迹。
|
||||
// Polygons 包含接触锥圆弧;当锥面与椭球的交线在朝月半球开放时,沿月球地平线闭合。
|
||||
// PlanetOccultationFootprint is one instantaneous visible contact footprint.
|
||||
// Polygons contain contact-cone arcs closed along the lunar horizon when the cone/ellipsoid intersection is open on the Moon-facing hemisphere.
|
||||
type PlanetOccultationFootprint struct {
|
||||
Time time.Time
|
||||
// OccultationFootprint 是一个时刻的可见接触足迹。
|
||||
// Polygons 包含供独立时刻绘制的闭合可见区;Boundaries 保留未经地平线封口的接触锥弧,供稀疏静态样本连续扫掠。
|
||||
// Closed 表示 Boundaries 本身是否为闭合交线。
|
||||
// OccultationFootprint is one instantaneous visible contact footprint.
|
||||
// Polygons contain closed visible regions for rendering one instant. Boundaries retain contact-cone arcs before horizon closure for continuously sweeping sparse static samples.
|
||||
// Closed reports whether Boundaries themselves form a closed intersection.
|
||||
type OccultationFootprint struct {
|
||||
// Time 是该瞬时足迹对应的事件时刻。
|
||||
// Time is the event time represented by this instantaneous footprint.
|
||||
Time time.Time
|
||||
// Polygons 是供独立时刻绘制的闭合可见区域。
|
||||
// Polygons are closed visible regions for rendering one instant.
|
||||
Polygons [][]OccultationPathPoint
|
||||
// InteriorPolygons 是为单时刻绘制保留的闭合可见修复面。静态掩带构建器会将其与连续扫掠的接触边界弧分开,避免数值中心修复扩大长时间地理掩带。
|
||||
// InteriorPolygons are closed visible repair faces retained for one-instant
|
||||
// rendering. Static band builders keep them separate from the continuously
|
||||
// swept contact-boundary arcs so a numerical center repair cannot enlarge a
|
||||
// long-lived geographic band.
|
||||
InteriorPolygons [][]OccultationPathPoint
|
||||
// Boundaries 是尚未经过地平线封口的接触边界弧。
|
||||
// Boundaries are contact-boundary arcs before horizon closure.
|
||||
Boundaries [][]OccultationPathPoint
|
||||
// Closed 表示 Boundaries 是否构成闭合交线。
|
||||
// Closed reports whether Boundaries form a closed intersection.
|
||||
Closed bool
|
||||
}
|
||||
|
||||
// OccultationRiseSetCurve 是一种局部月掩阶段与月升/月落同时发生的边界。
|
||||
// OccultationRiseSetCurve is one boundary where a local occultation phase coincides with moonrise or moonset.
|
||||
type OccultationRiseSetCurve struct {
|
||||
// Phase 是与月升或月落同时发生的局部月掩阶段。
|
||||
// Phase is the local occultation phase coinciding with moonrise or moonset.
|
||||
Phase RiseSetPhase
|
||||
// Direction 标识月球正在升起还是落下。
|
||||
// Direction identifies whether the Moon is rising or setting.
|
||||
Direction RiseSetDirection
|
||||
// Segments 是反经线和支路跳变安全分段后的边界采样。
|
||||
// Segments are boundary samples split safely at the antimeridian and branch changes.
|
||||
Segments [][]OccultationPathPoint
|
||||
}
|
||||
|
||||
// PlanetOccultationFootprint 保留有限盘面行星月掩足迹的兼容名称。
|
||||
// PlanetOccultationFootprint retains the compatibility name for finite-disk planetary footprints.
|
||||
type PlanetOccultationFootprint = OccultationFootprint
|
||||
|
||||
// PlanetOccultationPath 包含有限盘面行星月掩的全球掩带。
|
||||
// NorthernLimit 和 SouthernLimit 是行星盘面任意部分被覆盖的外接触边界。
|
||||
// HasTotalBand 为 true 时,NorthernTotalLimit 和 SouthernTotalLimit 是行星圆盘完全被月球覆盖的内接触边界;
|
||||
@@ -474,13 +621,32 @@ type PlanetOccultationPath struct {
|
||||
// Complete is true when Start and End are the global outer contacts.
|
||||
Complete bool
|
||||
|
||||
CenterLine []OccultationPathPoint
|
||||
// CenterLine 是影轴与椭球交点的轨迹;非中心事件退化为最接近影轴的椭球点。
|
||||
// CenterLine is the track of the shadow-axis/ellipsoid intersection, degenerating to the ellipsoid point nearest the axis for non-central events.
|
||||
CenterLine []OccultationPathPoint
|
||||
// NorthernLimit 和 SouthernLimit 是外接触锥的切点轨迹:掩星在该线上恰好退化为擦边,不是中心线的等距横向平移。
|
||||
// NorthernLimit and SouthernLimit are the tangency tracks of the outer-contact cone, where the occultation degenerates to a graze, so they are not a constant-width offset of the center line.
|
||||
NorthernLimit []OccultationPathPoint
|
||||
SouthernLimit []OccultationPathPoint
|
||||
// PartialFootprints 是时刻采样的可见外接触区域,其扫掠构成全球偏掩区域;为限制输出和运行时间,采样可能比 Step 更粗,
|
||||
// GreatestLimitSeparationKM 是掩甚处南北限的地面间距(取与掩甚时刻最近的限线采样对),与 Greatest.WidthKM 口径不同,不要互相换算。
|
||||
// GreatestLimitSeparationKM is the ground separation of the two limits at greatest, taken from the limit sample pair nearest to the greatest instant; it uses a different construction from Greatest.WidthKM and must not be converted into it.
|
||||
GreatestLimitSeparationKM float64
|
||||
// PartialBandContours 是偏掩静态带的连续外接触包络;任意目标盘面重叠时取零。
|
||||
// PartialBandContours are the continuous outer-contact envelopes for the static partial band where any target-disk overlap begins.
|
||||
PartialBandContours [][]OccultationPathPoint
|
||||
// PartialVisibilityContours 是月球可见性在偏掩阶段内的连续时间外包络。
|
||||
// PartialVisibilityContours are the continuous temporal envelopes of lunar visibility while partial occultation is active.
|
||||
PartialVisibilityContours [][]OccultationPathPoint
|
||||
// PartialFootprints 是时刻采样的可见外接触区域,其扫掠构成全球偏掩区域;足迹以 1 分钟为目标步长,超出独立样本上限时会变粗,
|
||||
// 每个足迹携带实际采样时刻。
|
||||
// PartialFootprints are instantaneous visible outer-contact regions whose sweep forms the global partial-occultation area. To bound output and runtime, sampling may be coarser than Step; each footprint carries its actual sample time.
|
||||
// PartialFootprints are instantaneous visible outer-contact regions whose sweep forms the global partial-occultation area. Footprints target one-minute intervals and become coarser when their independent sample cap is reached; each footprint carries its actual sample time.
|
||||
PartialFootprints []PlanetOccultationFootprint
|
||||
// PartialBandFootprints 是关闭密集瞬时足迹时用于构造紧凑偏掩带的稀疏可见区样本。
|
||||
// PartialBandFootprints are sparse visible-region samples used to construct the compact partial band when dense instantaneous footprints are disabled.
|
||||
PartialBandFootprints []PlanetOccultationFootprint
|
||||
// RiseSetCurves 是初掩、掩甚和终掩分别发生在月升或月落时的六类边界。
|
||||
// RiseSetCurves are the six boundaries where local start, greatest, or end occurs at moonrise or moonset.
|
||||
RiseSetCurves []OccultationRiseSetCurve
|
||||
|
||||
HasTotalBand bool
|
||||
// TotalStart 和 TotalEnd 是全球内接触的起止点。
|
||||
@@ -491,14 +657,31 @@ type PlanetOccultationPath struct {
|
||||
// TotalComplete is true when TotalStart and TotalEnd are not clipped by the internal search span.
|
||||
TotalComplete bool
|
||||
|
||||
// NorthernTotalLimit 和 SouthernTotalLimit 是内接触(全掩)锥的切点轨迹,同刻间距同样记录在 LimitSeparationKM 上。
|
||||
// NorthernTotalLimit and SouthernTotalLimit are the tangency tracks of the inner-contact cone; their same-instant separation is recorded in LimitSeparationKM as well.
|
||||
NorthernTotalLimit []OccultationPathPoint
|
||||
SouthernTotalLimit []OccultationPathPoint
|
||||
// TotalFootprints 是时刻采样的可见内接触区域,其扫掠构成全球全掩区域;采样使用与 PartialFootprints 相同的有界策略。
|
||||
// TotalFootprints are instantaneous visible inner-contact regions whose sweep forms the global full-coverage area. Their sampling uses the same bounded policy as PartialFootprints.
|
||||
// TotalBandContours 是全掩静态带的连续内接触包络;仅在目标圆盘完全被月球覆盖时取零。
|
||||
// TotalBandContours are the continuous inner-contact envelopes for the static total band where the complete target disk is covered by the Moon.
|
||||
TotalBandContours [][]OccultationPathPoint
|
||||
// TotalVisibilityContours 是全掩阶段内月球可见性的连续时间外包络。
|
||||
// TotalVisibilityContours are the temporal envelopes of lunar visibility while the total-occultation contact condition is active.
|
||||
TotalVisibilityContours [][]OccultationPathPoint
|
||||
// TotalFootprints 是时刻采样的可见内接触区域,其扫掠构成全球全掩区域;采样使用与 PartialFootprints 相同的 1 分钟目标步长和样本上限。
|
||||
// TotalFootprints are instantaneous visible inner-contact regions whose sweep forms the global full-coverage area. Their sampling uses the same one-minute target step and sample cap as PartialFootprints.
|
||||
TotalFootprints []PlanetOccultationFootprint
|
||||
// GreatestTotalWidthKM 是全球掩甚时的全掩带宽度。
|
||||
// GreatestTotalWidthKM is the full-coverage band width at global greatest.
|
||||
// TotalBandFootprints 是关闭密集瞬时足迹时用于构造紧凑全掩带的稀疏可见区样本。
|
||||
// TotalBandFootprints are sparse visible-region samples used to construct the compact total band when dense instantaneous footprints are disabled.
|
||||
TotalBandFootprints []PlanetOccultationFootprint
|
||||
// TotalRiseSetCurves 是全掩带使用的内接触月升/月落边界;其相位与 RiseSetCurves 相同,但 contact metric 采用内接触而非外接触。
|
||||
// TotalRiseSetCurves are the inner-contact moonrise/moonset boundaries used by the total band; they share the same phase labels as RiseSetCurves but evaluate the inner-contact metric instead of the outer-contact metric.
|
||||
TotalRiseSetCurves []OccultationRiseSetCurve
|
||||
// GreatestTotalWidthKM 是全球掩甚时的全掩带宽度,与 Greatest.WidthKM 同口径而作用于内接触锥,且恒小于它。
|
||||
// GreatestTotalWidthKM is the full-coverage band width at global greatest, using the same construction as Greatest.WidthKM on the inner-contact cone and always below it.
|
||||
GreatestTotalWidthKM float64
|
||||
// GreatestTimeContours 是按掩甚时刻采样的等时线,判据用外接触锥,与偏掩可见域一致。
|
||||
// GreatestTimeContours are sampled local greatest-occultation time isolines, gated by the outer-contact cone so they match the partial-occultation visibility area.
|
||||
GreatestTimeContours []OccultationGreatestTimeContour
|
||||
|
||||
Step time.Duration
|
||||
TargetSpacingKM float64
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOccultationBoundarySamplingNearTemporalFolds(t *testing.T) {
|
||||
for _, algorithm := range []OccultationPathAlgorithm{OccultationPathAlgorithmExact, OccultationPathAlgorithmOptimized} {
|
||||
t.Run(string(algorithm), func(t *testing.T) {
|
||||
day := time.Date(3627, 9, 20, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := FindPlanetOccultationPaths(day.Add(-12*time.Hour), day.Add(36*time.Hour), OccultationJupiter,
|
||||
OccultationPathOptions{Algorithm: algorithm, Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, RiseSetStep: time.Minute})
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("paths=%d err=%v", len(paths), err)
|
||||
}
|
||||
for _, contours := range [][][]OccultationPathPoint{paths[0].PartialBandContours, paths[0].TotalBandContours} {
|
||||
for ci, contour := range contours {
|
||||
for i := 1; i+1 < len(contour); i++ {
|
||||
a, b, c := contour[i-1], contour[i], contour[i+1]
|
||||
first, second := occultationPathDistanceKM(a, b), occultationPathDistanceKM(b, c)
|
||||
if first > 0.05 && second > 0.05 && first+second > 2 && occultationRiseSetTurnAngleDegrees(a, b, c) < 30 {
|
||||
t.Errorf("contour %d point %d reverses between %.3f/%.3f km edges at %.9f,%.9f", ci, i, first, second, b.Longitude, b.Latitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationPhaseJunctionSamplingResolvesCurvature(t *testing.T) {
|
||||
day := time.Date(1227, 5, 19, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := FindPlanetOccultationPaths(day.Add(-12*time.Hour), day.Add(36*time.Hour), OccultationVenus,
|
||||
OccultationPathOptions{Algorithm: OccultationPathAlgorithmExact, Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, RiseSetStep: time.Minute})
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("paths=%d err=%v", len(paths), err)
|
||||
}
|
||||
config, _ := planetOccultationConfigFor(OccultationVenus)
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
checked := 0
|
||||
for ci, curve := range paths[0].RiseSetCurves {
|
||||
if curve.Phase == RiseSetPhaseGreatest {
|
||||
continue
|
||||
}
|
||||
for _, segment := range curve.Segments {
|
||||
if len(segment) < 2 {
|
||||
continue
|
||||
}
|
||||
for _, atStart := range []bool{true, false} {
|
||||
endpoint := occultationRiseSetSegmentEndpoint(segment, atStart)
|
||||
if !occultationRiseSetEndpointSharesPhaseJunction(paths[0].RiseSetCurves, ci, endpoint) {
|
||||
continue
|
||||
}
|
||||
index := len(segment) - 2
|
||||
if atStart {
|
||||
index = 0
|
||||
}
|
||||
a, b := segment[index], segment[index+1]
|
||||
middle, ok := occultationRiseSetPhaseMidpoint(a, b, curve.Phase, curve.Direction, time.UTC, cache.riseSetCache)
|
||||
if !ok || occultationPathDistanceKM(a, b) < 0.05 {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
deviation := planetOccultationPointSegmentDistanceKM(middle, a, b)
|
||||
if math.IsNaN(deviation) || deviation > 0.05 {
|
||||
t.Errorf("%s/%s endpoint chord misses the physical arc by %.6f km", curve.Phase, curve.Direction, deviation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked < 2 {
|
||||
t.Fatalf("checked only %d junction chords", checked)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestOccultationPathFrameBoundaryCacheMatchesUncachedSearch 固定 frame 级边界记忆化的语义:
|
||||
// 记忆化只是复用同一 frame 上纯函数的计算结果,因此切点、可见 θ 区间与按中心角的 θ 区间
|
||||
// 都必须与直接调用未缓存实现逐位一致,重复调用也必须稳定。
|
||||
// TestOccultationPathFrameBoundaryCacheMatchesUncachedSearch pins the frame-level boundary memo
|
||||
// semantics: memoization only reuses pure results for the same frame, so the tangency, the
|
||||
// visible theta intervals and the per-center-angle interval must stay bit-identical to the
|
||||
// uncached implementations, and repeated calls must be stable.
|
||||
func TestOccultationPathFrameBoundaryCacheMatchesUncachedSearch(t *testing.T) {
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn occultation config is unavailable")
|
||||
}
|
||||
tt := occultationTimeToTT(time.Date(2024, time.August, 21, 2, 41, 36, 0, time.UTC))
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
|
||||
cachedFrame, cachedOK := cache.outerFrameAt(tt)
|
||||
directFrame, directOK := planetOccultationPathFrameAt(tt, config)
|
||||
if cachedOK != directOK || !cachedOK {
|
||||
t.Fatalf("frame availability cached=%v direct=%v, want both true", cachedOK, directOK)
|
||||
}
|
||||
if !occultationPathFrameGeometryEqual(cachedFrame, directFrame) {
|
||||
t.Fatal("cached frame geometry differs from a direct frame")
|
||||
}
|
||||
if cachedFrame.boundary == nil {
|
||||
t.Fatal("cached frame is missing its boundary memo")
|
||||
}
|
||||
|
||||
wantPoint, wantTheta, wantOK := occultationPathBoundaryTangentUncached(directFrame)
|
||||
gotPoint, gotTheta, gotOK := occultationPathBoundaryTangent(cachedFrame)
|
||||
if gotOK != wantOK || gotPoint != wantPoint || gotTheta != wantTheta {
|
||||
t.Fatalf("memoized tangency = (%.15g, %.15g, %v), want (%.15g, %.15g, %v)",
|
||||
gotPoint, gotTheta, gotOK, wantPoint, wantTheta, wantOK)
|
||||
}
|
||||
againPoint, againTheta, againOK := occultationPathBoundaryTangent(cachedFrame)
|
||||
if againOK != gotOK || againPoint != gotPoint || againTheta != gotTheta {
|
||||
t.Fatal("repeated tangency query is not stable")
|
||||
}
|
||||
|
||||
wantIntervals := occultationPathBoundaryThetaIntervalsUncached(directFrame)
|
||||
gotIntervals := occultationPathBoundaryThetaIntervals(cachedFrame)
|
||||
if len(gotIntervals) != len(wantIntervals) {
|
||||
t.Fatalf("memoized theta intervals = %v, want %v", gotIntervals, wantIntervals)
|
||||
}
|
||||
for index := range gotIntervals {
|
||||
if gotIntervals[index] != wantIntervals[index] {
|
||||
t.Fatalf("memoized theta interval %d = %v, want %v", index, gotIntervals[index], wantIntervals[index])
|
||||
}
|
||||
}
|
||||
if againIntervals := occultationPathBoundaryThetaIntervals(cachedFrame); len(againIntervals) != len(gotIntervals) {
|
||||
t.Fatal("repeated theta-interval query is not stable")
|
||||
}
|
||||
|
||||
cachedLeft, cachedRight, cachedIntervalOK := occultationPathBoundaryThetaInterval(cachedFrame, wantTheta)
|
||||
directLeft, directRight, directIntervalOK := occultationPathBoundaryThetaInterval(directFrame, wantTheta)
|
||||
if cachedIntervalOK != directIntervalOK || cachedLeft != directLeft || cachedRight != directRight {
|
||||
t.Fatalf("memoized interval = (%.15g, %.15g, %v), want (%.15g, %.15g, %v)",
|
||||
cachedLeft, cachedRight, cachedIntervalOK, directLeft, directRight, directIntervalOK)
|
||||
}
|
||||
repeatLeft, repeatRight, repeatOK := occultationPathBoundaryThetaInterval(cachedFrame, wantTheta)
|
||||
if repeatOK != cachedIntervalOK || repeatLeft != cachedLeft || repeatRight != cachedRight {
|
||||
t.Fatal("repeated per-angle interval query is not stable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOccultationPathZeroFrameComputesBoundaryWithoutCache 保证未挂记忆化的零值 frame 仍然
|
||||
// 直接计算,不会被 nil 缓存跳过。
|
||||
// TestOccultationPathZeroFrameComputesBoundaryWithoutCache keeps a frame without a memo working:
|
||||
// a nil boundary cache must fall back to direct computation, not skip it.
|
||||
func TestOccultationPathZeroFrameComputesBoundaryWithoutCache(t *testing.T) {
|
||||
frame := occultationPathFrame{
|
||||
moon: occultationPathVector{x: 384000},
|
||||
axis: occultationPathVector{x: -1},
|
||||
first: occultationPathVector{y: 1},
|
||||
second: occultationPathVector{z: 1},
|
||||
moonRadius: 0.0045,
|
||||
targetRadius: 1e-5,
|
||||
}
|
||||
if frame.boundary != nil {
|
||||
t.Fatal("fixture frame unexpectedly carries a boundary memo")
|
||||
}
|
||||
point, theta, ok := occultationPathBoundaryTangent(frame)
|
||||
wantPoint, wantTheta, wantOK := occultationPathBoundaryTangentUncached(frame)
|
||||
if ok != wantOK || point != wantPoint || theta != wantTheta {
|
||||
t.Fatalf("uncached tangency = (%.15g, %.15g, %v), want (%.15g, %.15g, %v)",
|
||||
point, theta, ok, wantPoint, wantTheta, wantOK)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StarOccultationInstant 包含指定时刻的点源恒星月掩可见足迹;若接触锥在该时刻未到达可见地球,Footprint 为 nil。
|
||||
// StarOccultationInstant contains the visible point-source footprint at one requested instant; Footprint is nil when no part of the contact cone reaches the visible Earth.
|
||||
type StarOccultationInstant struct {
|
||||
Time time.Time
|
||||
TargetID string
|
||||
// DeltaTSeconds 本次实际使用的 ΔT / ΔT actually used.
|
||||
DeltaTSeconds float64
|
||||
// SublunarLongitude 与 SublunarLatitude 是该时刻月下点,用于声明地平闭合弧 / sublunar point for the horizon closure.
|
||||
SublunarLongitude float64
|
||||
SublunarLatitude float64
|
||||
Footprint *OccultationFootprint
|
||||
}
|
||||
|
||||
// PlanetOccultationInstant 包含指定时刻的行星外接触和内接触可见足迹;相应接触锥未到达可见地球时,Partial 或 Total 为 nil。
|
||||
// PlanetOccultationInstant contains the visible outer- and inner-contact footprints at one requested instant; Partial or Total is nil when that contact cone does not reach the visible Earth.
|
||||
type PlanetOccultationInstant struct {
|
||||
Time time.Time
|
||||
Planet OccultationPlanet
|
||||
TargetID string
|
||||
// DeltaTSeconds 本次实际使用的 ΔT / ΔT actually used.
|
||||
DeltaTSeconds float64
|
||||
// SublunarLongitude 与 SublunarLatitude 是该时刻月下点,用于声明地平闭合弧 / sublunar point for the horizon closure.
|
||||
SublunarLongitude float64
|
||||
SublunarLatitude float64
|
||||
Partial *PlanetOccultationFootprint
|
||||
Total *PlanetOccultationFootprint
|
||||
}
|
||||
|
||||
// StarOccultationFootprintAt 返回指定时刻的精确点源恒星月掩可见足迹;它采用与路径时间线相同的分辨率,并对完整接触弧执行站心校正。
|
||||
// StarOccultationFootprintAt returns the exact visible lunar-occultation footprint of a point-source star at one instant, using timeline resolution and station-centred correction of the complete contact arc.
|
||||
func StarOccultationFootprintAt(at time.Time, star StarCoordinate) (StarOccultationInstant, error) {
|
||||
result := StarOccultationInstant{Time: at, TargetID: star.ID}
|
||||
if at.IsZero() {
|
||||
return result, fmt.Errorf("%w: time is required", ErrInvalidOccultationInput)
|
||||
}
|
||||
if err := star.Validate(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
cache := newStarOccultationEventCache(star)
|
||||
tt := occultationTimeToTT(at)
|
||||
result.DeltaTSeconds = DeltaT(tt, true)
|
||||
result.SublunarLongitude, result.SublunarLatitude = occultationSublunarPoint(tt, cache.frameAt)
|
||||
result.Footprint = occultationInstantFootprint(
|
||||
at, cache.frameAt, cache.riseSetContextAt, false,
|
||||
)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlanetOccultationFootprintsAt 返回指定时刻有限行星盘面的精确外接触和内接触月掩可见足迹;它采用路径时间线分辨率,并对每条完整接触弧执行站心校正。
|
||||
// PlanetOccultationFootprintsAt returns the exact visible outer- and inner-contact lunar-occultation footprints of a finite planetary disk at one instant, using timeline resolution and station-centred correction of each complete contact arc.
|
||||
func PlanetOccultationFootprintsAt(at time.Time, planet OccultationPlanet) (PlanetOccultationInstant, error) {
|
||||
result := PlanetOccultationInstant{Time: at, Planet: planet, TargetID: planet.String()}
|
||||
if at.IsZero() {
|
||||
return result, fmt.Errorf("%w: time is required", ErrInvalidOccultationInput)
|
||||
}
|
||||
if err := planet.Validate(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
config, _ := planetOccultationConfigFor(planet)
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
tt := occultationTimeToTT(at)
|
||||
result.DeltaTSeconds = DeltaT(tt, true)
|
||||
result.SublunarLongitude, result.SublunarLatitude = occultationSublunarPoint(tt, cache.outerFrameAt)
|
||||
result.Partial = occultationInstantFootprint(
|
||||
at, cache.outerFrameAt, cache.riseSetContextAt, false,
|
||||
)
|
||||
result.Total = occultationInstantFootprint(
|
||||
at, cache.totalFrameAt, cache.riseSetContextAt, true,
|
||||
)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func occultationSublunarPoint(tt float64, frameAt occultationPathFrameFunc) (float64, float64) {
|
||||
frame, ok := frameAt(tt)
|
||||
if !ok {
|
||||
return math.NaN(), math.NaN()
|
||||
}
|
||||
distance := math.Sqrt(frame.moon.x*frame.moon.x + frame.moon.y*frame.moon.y + frame.moon.z*frame.moon.z)
|
||||
if distance == 0 {
|
||||
return math.NaN(), math.NaN()
|
||||
}
|
||||
rightAscension := math.Atan2(frame.moon.y, frame.moon.x) * 180 / math.Pi
|
||||
declination := math.Asin(math.Max(-1, math.Min(1, frame.moon.z/distance))) * 180 / math.Pi
|
||||
longitude := rightAscension - ApparentSiderealTime(TD2UT(tt, false))*15
|
||||
for longitude > 180 {
|
||||
longitude -= 360
|
||||
}
|
||||
for longitude < -180 {
|
||||
longitude += 360
|
||||
}
|
||||
return longitude, declination
|
||||
}
|
||||
|
||||
func occultationInstantFootprint(
|
||||
at time.Time,
|
||||
frameAt occultationPathFrameFunc,
|
||||
contextAt occultationRiseSetContextFunc,
|
||||
total bool,
|
||||
) *OccultationFootprint {
|
||||
tt := occultationTimeToTT(at)
|
||||
footprint, ok := planetOccultationFootprintAtWithResolution(
|
||||
tt, frameAt, at.Location(),
|
||||
planetOccultationTimelineBoundaryPoints,
|
||||
planetOccultationTimelineHorizonPoints,
|
||||
planetOccultationTimelineTargetSpacingKM,
|
||||
)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// A one-element correction deliberately treats this instant as both ends of
|
||||
// the sequence, so every contact-arc sample receives the exact station solve.
|
||||
corrected := occultationStationCorrectFootprintEdges(
|
||||
[]PlanetOccultationFootprint{footprint}, frameAt, contextAt, total, at.Location(),
|
||||
)
|
||||
if len(corrected) != 1 {
|
||||
return nil
|
||||
}
|
||||
footprint = corrected[0]
|
||||
normalizeOccultationInstantTime(&footprint, at)
|
||||
return &footprint
|
||||
}
|
||||
|
||||
func normalizeOccultationInstantTime(footprint *OccultationFootprint, at time.Time) {
|
||||
if footprint == nil {
|
||||
return
|
||||
}
|
||||
footprint.Time = at
|
||||
for _, polygons := range [][][]OccultationPathPoint{
|
||||
footprint.Polygons,
|
||||
footprint.InteriorPolygons,
|
||||
footprint.Boundaries,
|
||||
} {
|
||||
for polygonIndex := range polygons {
|
||||
for pointIndex := range polygons[polygonIndex] {
|
||||
polygons[polygonIndex][pointIndex].Time = at
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPlanetOccultationFootprintsAtReturnsExactRequestedInstant(t *testing.T) {
|
||||
start := time.Date(2025, time.January, 5, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
|
||||
events, err := FindBestPlanetOccultations(
|
||||
start, start.Add(24*time.Hour), OccultationSaturn, OccultationSearchOptions{MaxEvents: 1},
|
||||
)
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("FindBestPlanetOccultations events=%d err=%v, want one", len(events), err)
|
||||
}
|
||||
at := events[0].Greatest.Add(123 * time.Nanosecond)
|
||||
instant, err := PlanetOccultationFootprintsAt(at, OccultationSaturn)
|
||||
if err != nil {
|
||||
t.Fatalf("PlanetOccultationFootprintsAt: %v", err)
|
||||
}
|
||||
if instant.Partial == nil || instant.Total == nil {
|
||||
t.Fatalf("instant footprints partial=%v total=%v, want both", instant.Partial != nil, instant.Total != nil)
|
||||
}
|
||||
for name, footprint := range map[string]*OccultationFootprint{
|
||||
"partial": instant.Partial,
|
||||
"total": instant.Total,
|
||||
} {
|
||||
if !footprint.Time.Equal(at) {
|
||||
t.Fatalf("%s footprint time=%v, want %v", name, footprint.Time, at)
|
||||
}
|
||||
if len(footprint.Polygons) == 0 || len(footprint.Boundaries) == 0 {
|
||||
t.Fatalf("%s footprint has no visible geometry", name)
|
||||
}
|
||||
for _, boundary := range footprint.Boundaries {
|
||||
for _, point := range boundary {
|
||||
if !point.Time.Equal(at) {
|
||||
t.Fatalf("%s boundary time=%v, want %v", name, point.Time, at)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationFootprintAtReturnsEmptyOutsideEvent(t *testing.T) {
|
||||
star := StarCoordinate{
|
||||
ID: "Antares", RA: 247.35166667, Dec: -26.43194444,
|
||||
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
|
||||
Frame: CoordinateFrameJ2000,
|
||||
}
|
||||
instant, err := StarOccultationFootprintAt(
|
||||
time.Date(2024, time.March, 1, 17, 0, 0, 0, time.UTC), star,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("StarOccultationFootprintAt: %v", err)
|
||||
}
|
||||
if instant.Footprint != nil {
|
||||
t.Fatal("outside-event stellar footprint is not nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationFootprintAtValidatesInputs(t *testing.T) {
|
||||
if _, err := PlanetOccultationFootprintsAt(time.Time{}, OccultationSaturn); !errors.Is(err, ErrInvalidOccultationInput) {
|
||||
t.Fatalf("zero time error=%v, want ErrInvalidOccultationInput", err)
|
||||
}
|
||||
if _, err := PlanetOccultationFootprintsAt(time.Now(), OccultationPlanet("earth")); !errors.Is(err, ErrInvalidOccultationInput) {
|
||||
t.Fatalf("invalid planet error=%v, want ErrInvalidOccultationInput", err)
|
||||
}
|
||||
if _, err := StarOccultationFootprintAt(time.Now(), StarCoordinate{}); !errors.Is(err, ErrInvalidOccultationInput) {
|
||||
t.Fatalf("invalid star error=%v, want ErrInvalidOccultationInput", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
occultationGreatestTimeContourSeedLatitudeStepDegrees = 5.0
|
||||
occultationGreatestTimeContourSeedLongitudeStepDegrees = 5.0
|
||||
occultationGreatestTimeContourSeedLatitudeLimitDegrees = 85.0
|
||||
occultationGreatestTimeContourArcStepDegrees = 1.5
|
||||
occultationGreatestTimeContourMinArcStepDegrees = 0.01
|
||||
occultationGreatestTimeContourMaxArcSteps = 4000
|
||||
occultationGreatestTimeContourCorrectionIterations = 12
|
||||
occultationGreatestTimeContourGradientStepDegrees = 1e-4
|
||||
occultationGreatestTimeContourLatitudeLimitDegrees = 88.0
|
||||
)
|
||||
|
||||
// occultationGreatestTimeArc 固定一个掩甚时刻后的等时线求根器。
|
||||
// 时刻固定后判据的时间导数只随经纬度变化,其零集就是该时刻的掩甚等值线:
|
||||
// 一个约束、两个未知量,所以结果是曲线而不是区域,延拓成本正比于曲线长度。
|
||||
// useContactMetric 选择判据口径:点源恒星用月面中心角距(与库内 StarOccultationInfo.Greatest 同口径),
|
||||
// 有限盘面行星用外接触度量(与库内 PlanetOccultationInfo.Greatest 同口径),两者相差数秒。
|
||||
type occultationGreatestTimeArc struct {
|
||||
evaluation occultationRiseSetEvaluation
|
||||
location *time.Location
|
||||
useContactMetric bool
|
||||
}
|
||||
|
||||
func (arc occultationGreatestTimeArc) metric(state occultationRiseSetState) float64 {
|
||||
if arc.useContactMetric {
|
||||
return state.contactMetric
|
||||
}
|
||||
return state.separationSquared
|
||||
}
|
||||
|
||||
// sample 返回残差与中心状态;月面在地平下、未发生接触、非极小点或数值无效时 ok 为 false。
|
||||
func (arc occultationGreatestTimeArc) sample(longitude, latitude float64) (float64, occultationRiseSetState, bool) {
|
||||
var state occultationRiseSetState
|
||||
if latitude <= -90 || latitude >= 90 {
|
||||
return 0, state, false
|
||||
}
|
||||
before := arc.evaluation.before.stateAt(longitude, latitude)
|
||||
state = arc.evaluation.center.stateAt(longitude, latitude)
|
||||
after := arc.evaluation.after.stateAt(longitude, latitude)
|
||||
if !state.valid || state.moonAltitude <= 0 {
|
||||
return 0, state, false
|
||||
}
|
||||
// 角距极小值处处存在,等时线必须再要求目标盘面真的与月面接触,否则会在无掩可见的区域画出曲线。
|
||||
if state.contactMetric > 1e-7 {
|
||||
return 0, state, false
|
||||
}
|
||||
stepSquared := occultationRiseSetDerivativeStepDays * occultationRiseSetDerivativeStepDays
|
||||
// 判据在此取极小值才是掩甚;二阶导非正说明该时刻不是本地的极大掩。
|
||||
center := arc.metric(state)
|
||||
if (arc.metric(after)-2*center+arc.metric(before))/stepSquared <= 0 {
|
||||
return 0, state, false
|
||||
}
|
||||
value := (arc.metric(after) - arc.metric(before)) / (2 * occultationRiseSetDerivativeStepDays)
|
||||
if !finite(value) {
|
||||
return 0, state, false
|
||||
}
|
||||
return value, state, true
|
||||
}
|
||||
|
||||
func (arc occultationGreatestTimeArc) residual(longitude, latitude float64) float64 {
|
||||
value, _, ok := arc.sample(longitude, latitude)
|
||||
if !ok {
|
||||
return math.NaN()
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (arc occultationGreatestTimeArc) point(longitude, latitude float64, state occultationRiseSetState) OccultationPathPoint {
|
||||
return OccultationPathPoint{
|
||||
Time: occultationTTToLocation(arc.evaluation.tt, arc.location),
|
||||
Longitude: normalizeLongitude(longitude),
|
||||
Latitude: latitude,
|
||||
MoonAltitude: state.moonAltitude,
|
||||
}
|
||||
}
|
||||
|
||||
// metricGradient 返回 g 对地面东向、北向角度的偏导;东向角度 = 经度差 × cos(纬度)。
|
||||
func (arc occultationGreatestTimeArc) metricGradient(longitude, latitude float64) (float64, float64, bool) {
|
||||
step := occultationGreatestTimeContourGradientStepDegrees
|
||||
value, _, ok := arc.sample(longitude, latitude)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
cosine := math.Cos(latitude * rad)
|
||||
if cosine < 1e-6 {
|
||||
return 0, 0, false
|
||||
}
|
||||
eastValue, _, eastOK := arc.sample(longitude+step, latitude)
|
||||
westValue, _, westOK := arc.sample(longitude-step, latitude)
|
||||
northValue, _, northOK := arc.sample(longitude, latitude+step)
|
||||
southValue, _, southOK := arc.sample(longitude, latitude-step)
|
||||
longitudeDerivative, ok := greatestTimeContourDifference(value, eastValue, westValue, step, eastOK, westOK)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
latitudeDerivative, ok := greatestTimeContourDifference(value, northValue, southValue, step, northOK, southOK)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
return longitudeDerivative / cosine, latitudeDerivative, true
|
||||
}
|
||||
|
||||
// correct 把预测点沿残差梯度投影回零集;失败说明该方向已离开等时线定义域。
|
||||
func (arc occultationGreatestTimeArc) correct(longitude, latitude float64) (float64, float64, occultationRiseSetState, bool) {
|
||||
var state occultationRiseSetState
|
||||
for iteration := 0; iteration < occultationGreatestTimeContourCorrectionIterations; iteration++ {
|
||||
value, current, ok := arc.sample(longitude, latitude)
|
||||
if !ok {
|
||||
return 0, 0, state, false
|
||||
}
|
||||
state = current
|
||||
if math.Abs(value) <= greatestTimeContourResidualTolerance {
|
||||
return longitude, latitude, state, true
|
||||
}
|
||||
east, north, ok := arc.metricGradient(longitude, latitude)
|
||||
if !ok {
|
||||
return 0, 0, state, false
|
||||
}
|
||||
denominator := east*east + north*north
|
||||
cosine := math.Cos(latitude * rad)
|
||||
if denominator < 1e-18 || cosine < 1e-6 {
|
||||
return 0, 0, state, false
|
||||
}
|
||||
// 完整牛顿步可能一步跨出可见域(掩带很窄,限界附近的种子尤其容易);逐步二分回退,
|
||||
// 只要还有一步落在域内就继续投影。
|
||||
scale, advanced := 1.0, false
|
||||
for attempt := 0; attempt < greatestTimeContourCorrectionBacktracking; attempt++ {
|
||||
nextLongitude := longitude - scale*value*east/denominator/cosine
|
||||
nextLatitude := latitude - scale*value*north/denominator
|
||||
scale /= 2
|
||||
if nextLatitude <= -90 || nextLatitude >= 90 {
|
||||
continue
|
||||
}
|
||||
if _, _, ok := arc.sample(nextLongitude, nextLatitude); !ok {
|
||||
continue
|
||||
}
|
||||
longitude, latitude = nextLongitude, nextLatitude
|
||||
advanced = true
|
||||
break
|
||||
}
|
||||
if !advanced {
|
||||
return 0, 0, state, false
|
||||
}
|
||||
}
|
||||
value, current, ok := arc.sample(longitude, latitude)
|
||||
if !ok || math.Abs(value) > 1e-6 {
|
||||
return 0, 0, state, false
|
||||
}
|
||||
return longitude, latitude, current, true
|
||||
}
|
||||
|
||||
// traceOccultationGreatestTimeArc 从种子沿一个方向按弧长延拓,预测点落到定义域外时步长减半。
|
||||
func traceOccultationGreatestTimeArc(
|
||||
evaluation occultationRiseSetEvaluation,
|
||||
location *time.Location,
|
||||
useContactMetric bool,
|
||||
longitude, latitude, direction float64,
|
||||
) []OccultationPathPoint {
|
||||
arc := occultationGreatestTimeArc{evaluation: evaluation, location: location, useContactMetric: useContactMetric}
|
||||
_, state, ok := arc.sample(longitude, latitude)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
points := []OccultationPathPoint{arc.point(longitude, latitude, state)}
|
||||
step := occultationGreatestTimeContourArcStepDegrees
|
||||
previousEast, previousNorth := 0.0, 0.0
|
||||
for count := 0; count < occultationGreatestTimeContourMaxArcSteps; count++ {
|
||||
east, north, ok := arc.metricGradient(longitude, latitude)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
norm := math.Hypot(east, north)
|
||||
cosine := math.Cos(latitude * rad)
|
||||
if norm < 1e-12 || cosine < 1e-6 {
|
||||
break
|
||||
}
|
||||
tangentEast, tangentNorth := -north/norm, east/norm
|
||||
if previousEast != 0 || previousNorth != 0 {
|
||||
if tangentEast*previousEast+tangentNorth*previousNorth < 0 {
|
||||
tangentEast, tangentNorth = -tangentEast, -tangentNorth
|
||||
}
|
||||
}
|
||||
nextLongitude, nextLatitude, nextState, ok := arc.correct(
|
||||
longitude+direction*step*tangentEast/cosine,
|
||||
latitude+direction*step*tangentNorth,
|
||||
)
|
||||
if !ok {
|
||||
step /= 2
|
||||
if step < occultationGreatestTimeContourMinArcStepDegrees {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if math.Abs(nextLatitude) > occultationGreatestTimeContourLatitudeLimitDegrees {
|
||||
break
|
||||
}
|
||||
next := arc.point(nextLongitude, nextLatitude, nextState)
|
||||
distance := occultationPathDistanceKM(points[len(points)-1], next)
|
||||
// 校正回到原点说明该方向已经走到支路端点,继续只会原地打转。
|
||||
if distance < 1e-9 || distance > 4*step*greatestTimeContourKMPerDegree {
|
||||
break
|
||||
}
|
||||
points = append(points, next)
|
||||
longitude, latitude = nextLongitude, nextLatitude
|
||||
previousEast, previousNorth = tangentEast, tangentNorth
|
||||
step = math.Min(occultationGreatestTimeContourArcStepDegrees, step*1.5)
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
// occultationGreatestTimeContourSeriesSeed 在一条序列上按时间插值取种子。
|
||||
// 掩带是单条窄带,宽度常小于粗扫步长,二维经纬度扫描会整条漏掉,所以优先用中心线:
|
||||
// 中心线各点的时刻就是该点的本地掩甚,按时间插值得到的点已经贴着等时线。
|
||||
// 限界只在中心线的时间范围够不到该时刻时提供候选,且必须再经定义域校验(限界点的时刻是
|
||||
// 擦边时刻,不等于本地掩甚,直接当种子可能落在可见域外)。
|
||||
func occultationGreatestTimeContourSeriesSeed(points []OccultationPathPoint, levelTT float64) (float64, float64, bool) {
|
||||
for index := 1; index < len(points); index++ {
|
||||
before := occultationTimeToTT(points[index-1].Time)
|
||||
after := occultationTimeToTT(points[index].Time)
|
||||
if levelTT < before || levelTT > after || after <= before {
|
||||
continue
|
||||
}
|
||||
fraction := (levelTT - before) / (after - before)
|
||||
delta := math.Remainder(points[index].Longitude-points[index-1].Longitude, 360)
|
||||
return points[index-1].Longitude + delta*fraction,
|
||||
points[index-1].Latitude + (points[index].Latitude-points[index-1].Latitude)*fraction, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// occultationGreatestTimeContourSeeds 按序列顺序(中心线优先)给出该时刻的全部候选种子。
|
||||
func occultationGreatestTimeContourSeeds(series [][]OccultationPathPoint, levelTT float64) [][2]float64 {
|
||||
seeds := make([][2]float64, 0, len(series))
|
||||
for _, points := range series {
|
||||
if longitude, latitude, ok := occultationGreatestTimeContourSeriesSeed(points, levelTT); ok {
|
||||
seeds = append(seeds, [2]float64{longitude, latitude})
|
||||
}
|
||||
}
|
||||
return seeds
|
||||
}
|
||||
|
||||
// occultationGreatestTimeContourScanSeeds 是没有可用序列种子时的兜底粗扫。
|
||||
func occultationGreatestTimeContourScanSeeds(arc occultationGreatestTimeArc) []OccultationPathPoint {
|
||||
seeds := make([]OccultationPathPoint, 0, 16)
|
||||
latitudeStep := occultationGreatestTimeContourSeedLatitudeStepDegrees
|
||||
longitudeStep := occultationGreatestTimeContourSeedLongitudeStepDegrees
|
||||
limit := occultationGreatestTimeContourSeedLatitudeLimitDegrees
|
||||
for latitude := -limit; latitude <= limit; latitude += latitudeStep {
|
||||
previousLongitude := -180.0
|
||||
previousValue := arc.residual(previousLongitude, latitude)
|
||||
for longitude := previousLongitude + longitudeStep; longitude <= 180; longitude += longitudeStep {
|
||||
value := arc.residual(longitude, latitude)
|
||||
if finite(previousValue) && finite(value) && previousValue*value <= 0 {
|
||||
root, ok := greatestTimeContourBisect(arc.residual, previousLongitude, longitude, latitude, previousValue)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, state, sampled := arc.sample(root, latitude); sampled {
|
||||
seeds = append(seeds, arc.point(root, latitude, state))
|
||||
}
|
||||
}
|
||||
previousLongitude, previousValue = longitude, value
|
||||
}
|
||||
}
|
||||
return seeds
|
||||
}
|
||||
|
||||
func occultationGreatestTimeContourCovered(segments [][]OccultationPathPoint, point OccultationPathPoint) bool {
|
||||
for _, segment := range segments {
|
||||
for index := 1; index < len(segment); index++ {
|
||||
if greatestTimeContourPointSegmentKM(
|
||||
point.Longitude, point.Latitude,
|
||||
segment[index-1].Longitude, segment[index-1].Latitude,
|
||||
segment[index].Longitude, segment[index].Latitude,
|
||||
) <= greatestTimeContourCoverToleranceKM {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// occultationGreatestTimeContourSegmentCovered 判断整条支路是否已落在已绘曲线上(同一曲线被先后延拓两次时后一条可能更长)。
|
||||
func occultationGreatestTimeContourSegmentCovered(segments [][]OccultationPathPoint, segment []OccultationPathPoint) bool {
|
||||
for _, point := range segment {
|
||||
if !occultationGreatestTimeContourCovered(segments, point) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// occultationGreatestTimeContourPruneCovered 丢弃已被新支路整条覆盖的旧支路。
|
||||
func occultationGreatestTimeContourPruneCovered(segments [][]OccultationPathPoint, added []OccultationPathPoint) [][]OccultationPathPoint {
|
||||
kept := segments[:0]
|
||||
for _, segment := range segments {
|
||||
if occultationGreatestTimeContourSegmentCovered([][]OccultationPathPoint{added}, segment) {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, segment)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// occultationGreatestTimeContourSegments 汇总一个时刻取值上的全部等时线支路。
|
||||
func occultationGreatestTimeContourSegments(
|
||||
evaluation occultationRiseSetEvaluation,
|
||||
location *time.Location,
|
||||
useContactMetric bool,
|
||||
series [][]OccultationPathPoint,
|
||||
) [][]OccultationPathPoint {
|
||||
arc := occultationGreatestTimeArc{evaluation: evaluation, location: location, useContactMetric: useContactMetric}
|
||||
var seeds []OccultationPathPoint
|
||||
for _, candidate := range occultationGreatestTimeContourSeeds(series, evaluation.tt) {
|
||||
// 候选点可能落在可见域外(限界种子或该时刻已无接触),先校验再投影。
|
||||
if _, _, ok := arc.sample(candidate[0], candidate[1]); !ok {
|
||||
continue
|
||||
}
|
||||
correctedLongitude, correctedLatitude, state, corrected := arc.correct(candidate[0], candidate[1])
|
||||
if corrected {
|
||||
seeds = append(seeds, arc.point(correctedLongitude, correctedLatitude, state))
|
||||
}
|
||||
}
|
||||
// 每个时刻取值只保留能延拓出支路的种子;同一条曲线上的重复种子会被覆盖判据丢弃。
|
||||
if len(seeds) == 0 {
|
||||
seeds = occultationGreatestTimeContourScanSeeds(arc)
|
||||
}
|
||||
segments := make([][]OccultationPathPoint, 0, 2)
|
||||
for _, seed := range seeds {
|
||||
if occultationGreatestTimeContourCovered(segments, seed) {
|
||||
continue
|
||||
}
|
||||
forward := traceOccultationGreatestTimeArc(evaluation, location, useContactMetric, seed.Longitude, seed.Latitude, 1)
|
||||
backward := traceOccultationGreatestTimeArc(evaluation, location, useContactMetric, seed.Longitude, seed.Latitude, -1)
|
||||
segment := make([]OccultationPathPoint, 0, len(forward)+len(backward))
|
||||
for index := len(backward) - 1; index >= 1; index-- {
|
||||
segment = append(segment, backward[index])
|
||||
}
|
||||
segment = append(segment, forward...)
|
||||
if len(segment) < 2 {
|
||||
continue
|
||||
}
|
||||
// 先按整条支路去重:种子检查只能拦住"较短者先画"的情况,反序时需要在这里收口。
|
||||
if occultationGreatestTimeContourSegmentCovered(segments, segment) {
|
||||
continue
|
||||
}
|
||||
segments = occultationGreatestTimeContourPruneCovered(segments, segment)
|
||||
segments = append(segments, segment)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
// occultationGreatestTimeLevels 取请求的掩甚时刻取值:显式取值优先(按时间截断到上限),
|
||||
// 否则按步长对齐到 UTC 整刻度并覆盖可见窗口。掩星全球可见窗口通常只有数小时,间隔应比日食更密
|
||||
// (15–30 分钟量级),否则整条掩带上只有寥寥几条线。
|
||||
func occultationGreatestTimeLevels(options OccultationPathOptions, startTT, endTT float64) []greatestTimeContourLevel {
|
||||
if len(options.GreatestTimeValues) > 0 {
|
||||
values := options.GreatestTimeValues
|
||||
if len(values) > greatestTimeContourMaxLevels {
|
||||
values = values[:greatestTimeContourMaxLevels]
|
||||
}
|
||||
levels := make([]greatestTimeContourLevel, 0, len(values))
|
||||
for _, value := range values {
|
||||
levels = append(levels, greatestTimeContourLevel{
|
||||
tt: value,
|
||||
// 显式取值只有 TT 儒略日;抹掉亚毫秒噪声,避免整分被格式化成前一分钟。
|
||||
at: greatestTimeContourTTToUTC(value).Round(time.Millisecond),
|
||||
})
|
||||
}
|
||||
return levels
|
||||
}
|
||||
return greatestTimeContourAlignedLevels(startTT, endTT, options.GreatestTimeStep, greatestTimeContourMaxLevels)
|
||||
}
|
||||
|
||||
// occultationGreatestTimeContours 计算请求时刻取值的地方掩甚时刻等值线。
|
||||
func occultationGreatestTimeContours(
|
||||
levels []greatestTimeContourLevel,
|
||||
startTT, endTT float64,
|
||||
cache *occultationRiseSetEvaluationCache,
|
||||
series [][]OccultationPathPoint,
|
||||
useContactMetric bool,
|
||||
location *time.Location,
|
||||
) []OccultationGreatestTimeContour {
|
||||
if len(levels) == 0 || cache == nil || startTT == 0 || endTT == 0 || endTT <= startTT {
|
||||
return nil
|
||||
}
|
||||
contours := make([]OccultationGreatestTimeContour, 0, len(levels))
|
||||
for _, level := range levels {
|
||||
if !finite(level.tt) || level.tt < startTT || level.tt > endTT {
|
||||
continue
|
||||
}
|
||||
segments := occultationGreatestTimeContourSegments(cache.evaluation(level.tt), location, useContactMetric, series)
|
||||
if len(segments) == 0 {
|
||||
continue
|
||||
}
|
||||
contours = append(contours, OccultationGreatestTimeContour{JDE: level.tt, Time: level.at, Segments: segments})
|
||||
}
|
||||
return contours
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func occultationIsochroneTestStar() StarCoordinate {
|
||||
return StarCoordinate{
|
||||
ID: "Regulus",
|
||||
RA: 152.09292,
|
||||
Dec: 11.96719,
|
||||
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
|
||||
Frame: CoordinateFrameJ2000,
|
||||
}
|
||||
}
|
||||
|
||||
// 等时线必须与站心掩甚定义自洽:支路上任意点由站心事件搜索独立求出的掩甚时刻等于该支路电平。
|
||||
func TestOccultationGreatestTimeContoursMatchLocalGreatest(t *testing.T) {
|
||||
star := occultationIsochroneTestStar()
|
||||
start := time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC)
|
||||
options := OccultationPathOptions{
|
||||
Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 15 * time.Minute,
|
||||
}
|
||||
paths, err := FindStarOccultationPaths(start, end, star, options)
|
||||
if err != nil || len(paths) == 0 {
|
||||
t.Fatalf("no path with isochrones: %v", err)
|
||||
}
|
||||
contours := paths[0].GreatestTimeContours
|
||||
if len(contours) < 4 {
|
||||
t.Fatalf("expected a full isochrone fan, got %d contours", len(contours))
|
||||
}
|
||||
for _, contour := range contours {
|
||||
if contour.Time.Second() != 0 || contour.Time.Nanosecond() != 0 || contour.Time.Minute()%15 != 0 {
|
||||
t.Fatalf("contour time %s is not aligned to the requested step",
|
||||
contour.Time.Format("15:04:05.000"))
|
||||
}
|
||||
}
|
||||
|
||||
verified := 0
|
||||
worst := 0.0
|
||||
for _, contour := range contours {
|
||||
branchPoints := 0
|
||||
for _, segment := range contour.Segments {
|
||||
if len(segment) < 2 {
|
||||
t.Fatalf("contour %.6f has a degenerate branch", contour.JDE)
|
||||
}
|
||||
branchPoints += len(segment)
|
||||
}
|
||||
// 弧长步长上限决定了单条等时线的点数上界;点数爆表意味着延拓没有真正前进。
|
||||
if branchPoints > 4000 {
|
||||
t.Fatalf("contour %.6f has %d points, continuation is not advancing", contour.JDE, branchPoints)
|
||||
}
|
||||
level := occultationTTToLocation(contour.JDE, time.UTC)
|
||||
for _, segment := range contour.Segments {
|
||||
for index := 0; index < len(segment) && verified < 80; index += 1 + len(segment)/8 {
|
||||
point := segment[index]
|
||||
infos, searchErr := FindStarOccultations(
|
||||
level.Add(-2*time.Hour), level.Add(2*time.Hour), star,
|
||||
point.Longitude, point.Latitude, 0, OccultationSearchOptions{},
|
||||
)
|
||||
if searchErr != nil || len(infos) == 0 {
|
||||
continue
|
||||
}
|
||||
delta := math.Abs(infos[0].Greatest.Sub(point.Time).Seconds())
|
||||
if delta > worst {
|
||||
worst = delta
|
||||
}
|
||||
verified++
|
||||
}
|
||||
}
|
||||
}
|
||||
if verified < 10 {
|
||||
t.Fatalf("verified only %d points", verified)
|
||||
}
|
||||
t.Logf("%d contours, %d points verified, worst %.2f s", len(contours), verified, worst)
|
||||
if worst > 1 {
|
||||
t.Fatalf("isochrone disagrees with local greatest occultation by %.2f s", worst)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationGreatestTimeContoursDisabledByDefault(t *testing.T) {
|
||||
star := occultationIsochroneTestStar()
|
||||
start := time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := FindStarOccultationPaths(start, end, star, OccultationPathOptions{
|
||||
Step: 2 * time.Minute, DisableFootprints: true,
|
||||
})
|
||||
if err != nil || len(paths) == 0 {
|
||||
t.Fatalf("no occultation path: %v", err)
|
||||
}
|
||||
if len(paths[0].GreatestTimeContours) != 0 {
|
||||
t.Fatalf("contours computed without a request: %d", len(paths[0].GreatestTimeContours))
|
||||
}
|
||||
}
|
||||
|
||||
// 行星是有限盘面,等时线按外接触锥门控,必须与站心行星掩甚一致。
|
||||
func TestPlanetOccultationGreatestTimeContoursMatchLocalGreatest(t *testing.T) {
|
||||
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2025, time.February, 2, 0, 0, 0, 0, time.UTC)
|
||||
options := OccultationPathOptions{
|
||||
Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 30 * time.Minute,
|
||||
}
|
||||
paths, err := FindPlanetOccultationPaths(start, end, OccultationSaturn, options)
|
||||
if err != nil || len(paths) == 0 {
|
||||
t.Fatalf("no planetary path with isochrones: %v", err)
|
||||
}
|
||||
contours := paths[0].GreatestTimeContours
|
||||
if len(contours) == 0 {
|
||||
t.Fatal("no greatest-time contours")
|
||||
}
|
||||
verified := 0
|
||||
worst := 0.0
|
||||
for _, contour := range contours {
|
||||
level := occultationTTToLocation(contour.JDE, time.UTC)
|
||||
for _, segment := range contour.Segments {
|
||||
if len(segment) < 2 {
|
||||
t.Fatalf("contour %.6f has a degenerate branch", contour.JDE)
|
||||
}
|
||||
for index := 0; index < len(segment) && verified < 60; index += 1 + len(segment)/8 {
|
||||
point := segment[index]
|
||||
infos, searchErr := FindPlanetOccultations(
|
||||
level.Add(-3*time.Hour), level.Add(3*time.Hour), OccultationSaturn,
|
||||
point.Longitude, point.Latitude, 0, OccultationSearchOptions{},
|
||||
)
|
||||
if searchErr != nil || len(infos) == 0 {
|
||||
continue
|
||||
}
|
||||
delta := math.Abs(infos[0].Greatest.Sub(point.Time).Seconds())
|
||||
if delta > worst {
|
||||
worst = delta
|
||||
}
|
||||
verified++
|
||||
}
|
||||
}
|
||||
}
|
||||
if verified < 10 {
|
||||
t.Fatalf("verified only %d points", verified)
|
||||
}
|
||||
t.Logf("%d contours, %d points verified, worst %.2f s", len(contours), verified, worst)
|
||||
if worst > 1 {
|
||||
t.Fatalf("isochrone disagrees with local greatest occultation by %.2f s", worst)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一时刻取值的等时线必须是单条连通曲线:曾因"覆盖判据用点到顶点距离"而被重复延拓
|
||||
// (行星掩星每个取值 3 条重叠支路)。
|
||||
func TestOccultationGreatestTimeContoursHaveSingleBranchPerLevel(t *testing.T) {
|
||||
star := occultationIsochroneTestStar()
|
||||
starPaths, err := FindStarOccultationPaths(
|
||||
time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC), star,
|
||||
OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 15 * time.Minute},
|
||||
)
|
||||
if err != nil || len(starPaths) == 0 {
|
||||
t.Fatalf("star: %v", err)
|
||||
}
|
||||
planetPaths, err := FindPlanetOccultationPaths(
|
||||
time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2025, time.February, 2, 0, 0, 0, 0, time.UTC), OccultationSaturn,
|
||||
OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 30 * time.Minute},
|
||||
)
|
||||
if err != nil || len(planetPaths) == 0 {
|
||||
t.Fatalf("planet: %v", err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
contours []OccultationGreatestTimeContour
|
||||
}{
|
||||
{"star", starPaths[0].GreatestTimeContours},
|
||||
{"planet", planetPaths[0].GreatestTimeContours},
|
||||
} {
|
||||
if len(tc.contours) == 0 {
|
||||
t.Fatalf("%s: no contours", tc.name)
|
||||
}
|
||||
for _, contour := range tc.contours {
|
||||
if len(contour.Segments) != 1 {
|
||||
t.Fatalf("%s contour %s has %d branches, want a single connected curve",
|
||||
tc.name, contour.Time.Format("15:04"), len(contour.Segments))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 显式时刻取值:按 TT 儒略日给出,回显时刻取整到毫秒,窗口外取值被丢弃,且同样受上限保护。
|
||||
func TestOccultationGreatestTimeContoursAcceptExplicitValues(t *testing.T) {
|
||||
star := occultationIsochroneTestStar()
|
||||
start := time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC)
|
||||
base := OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 15 * time.Minute}
|
||||
stepPaths, err := FindStarOccultationPaths(start, end, star, base)
|
||||
if err != nil || len(stepPaths) == 0 || len(stepPaths[0].GreatestTimeContours) == 0 {
|
||||
t.Fatalf("step request failed: %v", err)
|
||||
}
|
||||
stepContours := stepPaths[0].GreatestTimeContours
|
||||
// 用步长路径得到的取值当显式取值,外加 8 个窗口外取值,检验回显与过滤。
|
||||
values := make([]float64, 0, len(stepContours)+8)
|
||||
for _, contour := range stepContours {
|
||||
values = append(values, contour.JDE)
|
||||
}
|
||||
for index := 0; index < 8; index++ {
|
||||
values = append(values, occultationTimeToTT(start.Add(-time.Duration(index+1)*time.Hour)))
|
||||
}
|
||||
explicitPaths, err := FindStarOccultationPaths(start, end, star, OccultationPathOptions{
|
||||
Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeValues: values,
|
||||
})
|
||||
if err != nil || len(explicitPaths) == 0 {
|
||||
t.Fatalf("explicit request failed: %v", err)
|
||||
}
|
||||
contours := explicitPaths[0].GreatestTimeContours
|
||||
if len(contours) != len(stepContours) {
|
||||
t.Fatalf("explicit values produced %d contours, step request produced %d", len(contours), len(stepContours))
|
||||
}
|
||||
for index, contour := range contours {
|
||||
if contour.JDE != stepContours[index].JDE {
|
||||
t.Fatalf("contour %d JDE %.9f does not match the requested value %.9f", index, contour.JDE, stepContours[index].JDE)
|
||||
}
|
||||
if contour.Time.Nanosecond() != 0 {
|
||||
t.Fatalf("contour time %s is not rounded to the millisecond", contour.Time.Format("15:04:05.000000000"))
|
||||
}
|
||||
}
|
||||
|
||||
// 上限:72 个互不相同、都落在窗口内的取值,返回条数不得超过共享上限。
|
||||
dense := make([]float64, 0, greatestTimeContourMaxLevels+8)
|
||||
for index := 0; index < greatestTimeContourMaxLevels+8; index++ {
|
||||
dense = append(dense, stepContours[0].JDE+float64(index)*1e-6)
|
||||
}
|
||||
cappedPaths, err := FindStarOccultationPaths(start, end, star, OccultationPathOptions{
|
||||
Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeValues: dense,
|
||||
})
|
||||
if err != nil || len(cappedPaths) == 0 {
|
||||
t.Fatalf("capped request failed: %v", err)
|
||||
}
|
||||
if count := len(cappedPaths[0].GreatestTimeContours); count > greatestTimeContourMaxLevels {
|
||||
t.Fatalf("level cap not applied: %d contours", count)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkOccultationGreatestTimeContours(b *testing.B) {
|
||||
star := occultationIsochroneTestStar()
|
||||
start := time.Date(2025, time.August, 22, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2025, time.August, 24, 0, 0, 0, 0, time.UTC)
|
||||
options := OccultationPathOptions{
|
||||
Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 15 * time.Minute,
|
||||
}
|
||||
for index := 0; index < b.N; index++ {
|
||||
FindStarOccultationPaths(start, end, star, options)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 掩带两条横向口径的契约:WidthKM 是中心线采样处的地面横向宽度,南北限是外接触锥的擦边切点轨迹,
|
||||
// 限线间距由 LimitSeparationKM 单独给出,两者不相等也不可互相换算。
|
||||
|
||||
const (
|
||||
occultationLimitTangencyToleranceArcsec = 5e-3
|
||||
occultationLimitEndpointToleranceArcsec = 0.5
|
||||
occultationLimitIndependentTolerance = 1e-4
|
||||
occultationLimitExactTolerance = 1e-9
|
||||
occultationLimitAnchorToleranceKM = 0.5
|
||||
occultationLimitRatioTolerance = 0.002
|
||||
)
|
||||
|
||||
type occultationLimitFixture struct {
|
||||
name string
|
||||
planet OccultationPlanet
|
||||
start time.Time
|
||||
widthKM float64
|
||||
limitSeparationKM float64
|
||||
totalWidthKM float64
|
||||
limitRatio float64
|
||||
centerLineSamples int
|
||||
}
|
||||
|
||||
func occultationLimitFixtures() []occultationLimitFixture {
|
||||
return []occultationLimitFixture{
|
||||
{
|
||||
name: "Jupiter 1962-10-10", planet: OccultationJupiter,
|
||||
start: time.Date(1962, time.October, 10, 0, 0, 0, 0, time.UTC),
|
||||
widthKM: 5319.263900,
|
||||
limitSeparationKM: 3593.197135,
|
||||
totalWidthKM: 3385.147026,
|
||||
limitRatio: 0.675506,
|
||||
centerLineSamples: 12,
|
||||
},
|
||||
{
|
||||
name: "Mars 2025-07-29", planet: OccultationMars,
|
||||
start: time.Date(2025, time.July, 29, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)),
|
||||
widthKM: 2190.685431,
|
||||
limitSeparationKM: 1819.273229,
|
||||
totalWidthKM: 1788.503498,
|
||||
limitRatio: 0.830458,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func occultationLimitOptions() OccultationPathOptions {
|
||||
return OccultationPathOptions{
|
||||
Step: 10 * time.Minute, TargetSpacingKM: 900,
|
||||
DisableFootprints: true, DisableRiseSet: true,
|
||||
}
|
||||
}
|
||||
|
||||
func occultationLimitPath(t *testing.T, fixture occultationLimitFixture) PlanetOccultationPath {
|
||||
t.Helper()
|
||||
paths, err := FindPlanetOccultationPaths(
|
||||
fixture.start, fixture.start.Add(24*time.Hour), fixture.planet, occultationLimitOptions(),
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("%s: paths=%d err=%v, want one", fixture.name, len(paths), err)
|
||||
}
|
||||
return paths[0]
|
||||
}
|
||||
|
||||
func occultationLimitExactFrame(t *testing.T, planet OccultationPlanet, centerTT float64) occultationPathFrameFunc {
|
||||
t.Helper()
|
||||
config, ok := planetOccultationConfigFor(planet)
|
||||
if !ok {
|
||||
t.Fatalf("%v config is unavailable", planet)
|
||||
}
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
cache.preparePathEphemeris(centerTT, OccultationPathAlgorithmExact)
|
||||
return cache.outerFrameAt
|
||||
}
|
||||
|
||||
func occultationLimitExternalContactGap(t *testing.T, planet OccultationPlanet, point OccultationPathPoint) (float64, bool) {
|
||||
t.Helper()
|
||||
config, ok := planetOccultationConfigFor(planet)
|
||||
if !ok {
|
||||
t.Fatalf("%v config is unavailable", planet)
|
||||
}
|
||||
observer := Observer{Longitude: point.Longitude, Latitude: point.Latitude}
|
||||
state := planetOccultationStateAt(centerTimeTT(point.Time), config, &observer, -1)
|
||||
if !state.valid {
|
||||
return 0, false
|
||||
}
|
||||
return state.externalContactMetric, true
|
||||
}
|
||||
|
||||
// occultationLimitIndependentGroundWidth 按定义独立重算地面横向宽度:自建接触弧网格并自算地面横向方向。
|
||||
func occultationLimitIndependentGroundWidth(tt float64, frameAt occultationPathFrameFunc) (float64, bool) {
|
||||
frame, ok := frameAt(tt)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
before, beforeOK := frameAt(tt - occultationPathVelocityStepDays)
|
||||
after, afterOK := frameAt(tt + occultationPathVelocityStepDays)
|
||||
if !beforeOK || !afterOK {
|
||||
return 0, false
|
||||
}
|
||||
center, centerOK := occultationPathTrackReference(frame)
|
||||
beforeCenter, beforeCenterOK := occultationPathTrackReference(before)
|
||||
afterCenter, afterCenterOK := occultationPathTrackReference(after)
|
||||
if !centerOK || !beforeCenterOK || !afterCenterOK {
|
||||
return 0, false
|
||||
}
|
||||
rotation := occultationPathEarthRotationAt(tt)
|
||||
centerFixed := occultationPathEarthFixedVectorWithRotation(center, rotation)
|
||||
beforeFixed := occultationPathEarthFixedVector(tt-occultationPathVelocityStepDays, beforeCenter)
|
||||
afterFixed := occultationPathEarthFixedVector(tt+occultationPathVelocityStepDays, afterCenter)
|
||||
polar := occultationPathEarthPolarRatio * occultationPathEarthPolarRatio
|
||||
normal := occultationPathUnit(occultationPathVector{x: centerFixed.x, y: centerFixed.y, z: centerFixed.z / polar})
|
||||
track := occultationPathSub(afterFixed, beforeFixed)
|
||||
track = occultationPathSub(track, occultationPathScale(normal, occultationPathDot(track, normal)))
|
||||
if occultationPathNorm(track) <= 1e-12 {
|
||||
return 0, false
|
||||
}
|
||||
cross := occultationPathUnit(occultationPathCross(normal, occultationPathUnit(track)))
|
||||
offset := func(vector occultationPathVector) float64 {
|
||||
fixed := occultationPathEarthFixedVectorWithRotation(vector, rotation)
|
||||
return occultationPathDot(occultationPathSub(fixed, centerFixed), cross)
|
||||
}
|
||||
minimum, maximum := math.Inf(1), math.Inf(-1)
|
||||
consider := func(vector occultationPathVector) {
|
||||
value := offset(vector)
|
||||
minimum = math.Min(minimum, value)
|
||||
maximum = math.Max(maximum, value)
|
||||
}
|
||||
const probeSamples = 4096
|
||||
for _, interval := range occultationPathBoundaryThetaIntervals(frame) {
|
||||
for index := 0; index <= probeSamples; index++ {
|
||||
theta := interval.left + (interval.right-interval.left)*float64(index)/probeSamples
|
||||
if vector, _, pointOK := occultationPathBoundaryVector(frame, theta); pointOK {
|
||||
consider(vector)
|
||||
}
|
||||
}
|
||||
}
|
||||
for index := 0; index < occultationPathBoundaryScanPoints; index++ {
|
||||
if vector, _, pointOK := occultationPathBoundaryVector(frame, 2*math.Pi*float64(index)/float64(occultationPathBoundaryScanPoints)); pointOK {
|
||||
consider(vector)
|
||||
}
|
||||
}
|
||||
if !finite(minimum) || !finite(maximum) {
|
||||
return 0, false
|
||||
}
|
||||
return maximum - minimum, true
|
||||
}
|
||||
|
||||
func TestPlanetOccultationLimitTracksAreGrazingTangencies(t *testing.T) {
|
||||
for _, fixture := range occultationLimitFixtures() {
|
||||
path := occultationLimitPath(t, fixture)
|
||||
if len(path.NorthernLimit) != len(path.SouthernLimit) || len(path.NorthernLimit) < 32 {
|
||||
t.Fatalf("%s: limit sample counts=%d/%d, want equal and at least 32",
|
||||
fixture.name, len(path.NorthernLimit), len(path.SouthernLimit))
|
||||
}
|
||||
checked := 0
|
||||
for _, track := range [][]OccultationPathPoint{path.NorthernLimit, path.SouthernLimit} {
|
||||
for index, point := range track {
|
||||
gap, ok := occultationLimitExternalContactGap(t, fixture.planet, point)
|
||||
if !ok {
|
||||
t.Fatalf("%s: limit point %d at %v has no valid contact state", fixture.name, index, point.Time)
|
||||
}
|
||||
// 首末采样是南北限的公共端点(构造上重合),残差略大,单独放宽。
|
||||
if index == 0 || index == len(track)-1 {
|
||||
if math.Abs(gap) > occultationLimitEndpointToleranceArcsec {
|
||||
t.Fatalf("%s: closure endpoint %d external contact gap=%.6f arcsec, want at most %.6f",
|
||||
fixture.name, index, gap, occultationLimitEndpointToleranceArcsec)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if index >= 3 && index < len(track)-3 {
|
||||
checked++
|
||||
}
|
||||
if math.Abs(gap) > occultationLimitTangencyToleranceArcsec {
|
||||
t.Fatalf("%s: limit point %d at %v external contact gap=%.6f arcsec, want at most %.6f",
|
||||
fixture.name, index, point.Time, gap, occultationLimitTangencyToleranceArcsec)
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked < 100 {
|
||||
t.Fatalf("%s: checked %d interior limit points, want at least 100", fixture.name, checked)
|
||||
}
|
||||
if separation := occultationPathDistanceKM(path.NorthernLimit[0], path.SouthernLimit[0]); separation != 0 {
|
||||
t.Fatalf("%s: start closure separation=%.6f km, want the shared endpoint", fixture.name, separation)
|
||||
}
|
||||
last := len(path.NorthernLimit) - 1
|
||||
if separation := occultationPathDistanceKM(path.NorthernLimit[last], path.SouthernLimit[last]); separation != 0 {
|
||||
t.Fatalf("%s: end closure separation=%.6f km, want the shared endpoint", fixture.name, separation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationWidthKMIsGroundCrossTrackWidth(t *testing.T) {
|
||||
for _, fixture := range occultationLimitFixtures() {
|
||||
path := occultationLimitPath(t, fixture)
|
||||
if len(path.CenterLine) != fixture.centerLineSamples {
|
||||
t.Fatalf("%s: center line samples=%d, want %d", fixture.name, len(path.CenterLine), fixture.centerLineSamples)
|
||||
}
|
||||
frameAt := occultationLimitExactFrame(t, fixture.planet, centerTimeTT(path.Greatest.Time))
|
||||
for _, point := range append([]OccultationPathPoint{path.Greatest}, path.CenterLine...) {
|
||||
tt := centerTimeTT(point.Time)
|
||||
_, _, exact, ok := occultationPathLimitsAndWidthForFrame(tt, frameAt)
|
||||
if !ok || exact <= 0 {
|
||||
t.Fatalf("%s: width at %v is unavailable", fixture.name, point.Time)
|
||||
}
|
||||
if difference := math.Abs(point.WidthKM - exact); difference > occultationLimitExactTolerance*exact {
|
||||
t.Fatalf("%s: width at %v field=%.9f recomputed=%.9f, want within %.3e relative",
|
||||
fixture.name, point.Time, point.WidthKM, exact, occultationLimitExactTolerance)
|
||||
}
|
||||
independent, ok := occultationLimitIndependentGroundWidth(tt, frameAt)
|
||||
if !ok || independent <= 0 {
|
||||
t.Fatalf("%s: independent ground width at %v is unavailable", fixture.name, point.Time)
|
||||
}
|
||||
if difference := math.Abs(point.WidthKM - independent); difference > occultationLimitIndependentTolerance*independent {
|
||||
t.Fatalf("%s: width at %v field=%.9f independent ground cross-track width=%.9f, want within %.3e relative",
|
||||
fixture.name, point.Time, point.WidthKM, independent, occultationLimitIndependentTolerance)
|
||||
}
|
||||
}
|
||||
for _, point := range path.CenterLine {
|
||||
if point.LimitSeparationKM != 0 {
|
||||
t.Fatalf("%s: center-line point at %v carries limit separation %.6f, want zero outside limit tracks",
|
||||
fixture.name, point.Time, point.LimitSeparationKM)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationLimitSeparationMatchesExportedLimits(t *testing.T) {
|
||||
for _, fixture := range occultationLimitFixtures() {
|
||||
path := occultationLimitPath(t, fixture)
|
||||
nearestIndex, nearestDelta := 0, math.Inf(1)
|
||||
for index := range path.NorthernLimit {
|
||||
if !path.NorthernLimit[index].Time.Equal(path.SouthernLimit[index].Time) {
|
||||
t.Fatalf("%s: limit sample %d times differ between tracks", fixture.name, index)
|
||||
}
|
||||
separation := occultationPathDistanceKM(path.NorthernLimit[index], path.SouthernLimit[index])
|
||||
if path.NorthernLimit[index].LimitSeparationKM != separation ||
|
||||
path.SouthernLimit[index].LimitSeparationKM != separation {
|
||||
t.Fatalf("%s: limit sample %d separations=%.9f/%.9f, want the exported pair distance %.9f",
|
||||
fixture.name, index, path.NorthernLimit[index].LimitSeparationKM,
|
||||
path.SouthernLimit[index].LimitSeparationKM, separation)
|
||||
}
|
||||
if delta := math.Abs(centerTimeTT(path.NorthernLimit[index].Time) - centerTimeTT(path.Greatest.Time)); delta < nearestDelta {
|
||||
nearestIndex, nearestDelta = index, delta
|
||||
}
|
||||
}
|
||||
nearest := occultationPathDistanceKM(path.NorthernLimit[nearestIndex], path.SouthernLimit[nearestIndex])
|
||||
if path.GreatestLimitSeparationKM != nearest {
|
||||
t.Fatalf("%s: greatest limit separation=%.9f, want the nearest limit sample pair %.9f at %v",
|
||||
fixture.name, path.GreatestLimitSeparationKM, nearest, path.NorthernLimit[nearestIndex].Time)
|
||||
}
|
||||
for index := range path.NorthernTotalLimit {
|
||||
separation := occultationPathDistanceKM(path.NorthernTotalLimit[index], path.SouthernTotalLimit[index])
|
||||
if path.NorthernTotalLimit[index].LimitSeparationKM != separation {
|
||||
t.Fatalf("%s: total limit sample %d separation=%.9f, want %.9f",
|
||||
fixture.name, index, path.NorthernTotalLimit[index].LimitSeparationKM, separation)
|
||||
}
|
||||
}
|
||||
for _, anchor := range []struct {
|
||||
name string
|
||||
got float64
|
||||
want float64
|
||||
}{
|
||||
{name: "greatest width", got: path.Greatest.WidthKM, want: fixture.widthKM},
|
||||
{name: "greatest limit separation", got: path.GreatestLimitSeparationKM, want: fixture.limitSeparationKM},
|
||||
{name: "greatest total width", got: path.GreatestTotalWidthKM, want: fixture.totalWidthKM},
|
||||
} {
|
||||
if difference := math.Abs(anchor.got - anchor.want); difference > occultationLimitAnchorToleranceKM {
|
||||
t.Fatalf("%s: %s=%.6f km, want %.6f km within %.3f km",
|
||||
fixture.name, anchor.name, anchor.got, anchor.want, occultationLimitAnchorToleranceKM)
|
||||
}
|
||||
}
|
||||
ratio := path.GreatestLimitSeparationKM / path.Greatest.WidthKM
|
||||
if math.Abs(ratio-fixture.limitRatio) > occultationLimitRatioTolerance {
|
||||
t.Fatalf("%s: limit separation/width=%.6f, want %.6f within %.4f",
|
||||
fixture.name, ratio, fixture.limitRatio, occultationLimitRatioTolerance)
|
||||
}
|
||||
if path.GreatestTotalWidthKM >= path.Greatest.WidthKM {
|
||||
t.Fatalf("%s: total width=%.6f must stay below the outer width=%.6f",
|
||||
fixture.name, path.GreatestTotalWidthKM, path.Greatest.WidthKM)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationLimitSeparationMatchesExportedLimits(t *testing.T) {
|
||||
start := time.Date(2025, time.June, 5, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := FindStarOccultationPaths(
|
||||
start, start.Add(24*time.Hour), hr4799OccultationCoordinateForTest(),
|
||||
OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
||||
DisableFootprints: true, DisableRiseSet: true,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
path := paths[0]
|
||||
nearestIndex, nearestDelta := 0, math.Inf(1)
|
||||
for index := range path.NorthernLimit {
|
||||
separation := occultationPathDistanceKM(path.NorthernLimit[index], path.SouthernLimit[index])
|
||||
if path.NorthernLimit[index].LimitSeparationKM != separation {
|
||||
t.Fatalf("limit sample %d separation=%.9f, want %.9f",
|
||||
index, path.NorthernLimit[index].LimitSeparationKM, separation)
|
||||
}
|
||||
if delta := math.Abs(centerTimeTT(path.NorthernLimit[index].Time) - centerTimeTT(path.Greatest.Time)); delta < nearestDelta {
|
||||
nearestIndex, nearestDelta = index, delta
|
||||
}
|
||||
}
|
||||
nearest := occultationPathDistanceKM(path.NorthernLimit[nearestIndex], path.SouthernLimit[nearestIndex])
|
||||
if path.GreatestLimitSeparationKM != nearest {
|
||||
t.Fatalf("greatest limit separation=%.9f, want %.9f", path.GreatestLimitSeparationKM, nearest)
|
||||
}
|
||||
if math.Abs(path.Greatest.WidthKM-3582.363599) > occultationLimitAnchorToleranceKM ||
|
||||
math.Abs(path.GreatestLimitSeparationKM-3666.576690) > occultationLimitAnchorToleranceKM {
|
||||
t.Fatalf("greatest width=%.6f limit separation=%.6f, want 3582.363599 and 3666.576690 within %.3f km",
|
||||
path.Greatest.WidthKM, path.GreatestLimitSeparationKM, occultationLimitAnchorToleranceKM)
|
||||
}
|
||||
ratio := path.GreatestLimitSeparationKM / path.Greatest.WidthKM
|
||||
if math.Abs(ratio-1.023508) > occultationLimitRatioTolerance {
|
||||
t.Fatalf("greatest limit separation/width=%.6f, want 1.023508 within %.4f", ratio, occultationLimitRatioTolerance)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOccultationStationCacheRecyclingPreservesContactModel(t *testing.T) {
|
||||
context := newOccultationRiseSetContext(2451545, 10, 5, 384000, 10.5, 5.25, 1e9, 60000)
|
||||
uncached := context
|
||||
uncached.states = nil
|
||||
for pass := 0; pass < 2; pass++ {
|
||||
for index := 0; index < 4*occultationRiseSetStateCacheMaximumEntries; index++ {
|
||||
lon, lat := float64(index*7-110), float64(index-40)
|
||||
for _, total := range []bool{false, true} {
|
||||
actual, reference := context, uncached
|
||||
if total {
|
||||
actual, reference = actual.withInternalContact(), reference.withInternalContact()
|
||||
}
|
||||
if got, want := actual.stateAt(lon, lat), reference.stateAt(lon, lat); got != want {
|
||||
t.Fatalf("cached station differs: got=%+v want=%+v", got, want)
|
||||
}
|
||||
}
|
||||
if len(context.states) > occultationRiseSetStateCacheMaximumEntries {
|
||||
t.Fatal("station cache exceeded bound")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationEnvelopePredictionRequiresExactCorrection(t *testing.T) {
|
||||
start := time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)
|
||||
events, err := FindBestPlanetOccultations(start, start.Add(48*time.Hour), OccultationSaturn, OccultationSearchOptions{})
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("events=%d err=%v", len(events), err)
|
||||
}
|
||||
tt := occultationTimeToTT(events[0].Greatest)
|
||||
config, _ := planetOccultationConfigFor(OccultationSaturn)
|
||||
event := newPlanetOccultationEventCache(config)
|
||||
event.prepareLocalEphemeris(tt)
|
||||
north, _, width, ok := occultationPathLimitsAndWidthForFrame(tt, event.outerFrameAt)
|
||||
if !ok {
|
||||
t.Fatal("missing limit seed")
|
||||
}
|
||||
seed := occultationPathPointFromVector(tt, north, width, time.UTC)
|
||||
sample, ok := occultationStationCorrectEnvelopePoint(tt, seed, event.riseSetContextAt, false, time.UTC)
|
||||
if !ok {
|
||||
t.Fatal("missing exact envelope seed")
|
||||
}
|
||||
model := occultationStationEnvelopeModel{kind: occultationStationContactEnvelope}
|
||||
exact := newOccultationRiseSetEvaluationCache(event.riseSetContextAt)
|
||||
state, ok := occultationStationEnvelopeArcStateAt(sample.point, tt, exact, model)
|
||||
if !ok {
|
||||
t.Fatal("missing envelope tangent")
|
||||
}
|
||||
predictor := state.coordinates
|
||||
for index := range predictor {
|
||||
predictor[index] += 0.2 * state.tangent[index]
|
||||
}
|
||||
want, _, ok := occultationStationCorrectEnvelopeArc(predictor, state.tangent, tt, exact, model, width, time.UTC)
|
||||
if !ok {
|
||||
t.Fatal("exact correction failed")
|
||||
}
|
||||
for _, invalid := range []bool{false, true} {
|
||||
candidateCalls := 0
|
||||
cache := newOccultationRiseSetEvaluationCacheWithCandidate(event.riseSetContextAt, func(at float64) occultationRiseSetContext {
|
||||
candidateCalls++
|
||||
if invalid {
|
||||
return occultationRiseSetContext{}
|
||||
}
|
||||
return event.candidateRiseSetContextAt(at)
|
||||
})
|
||||
got, _, ok := occultationStationCorrectEnvelopeArc(predictor, state.tangent, tt, cache, model, width, time.UTC)
|
||||
if !ok || candidateCalls == 0 {
|
||||
t.Fatalf("candidate correction ok=%v calls=%d invalid=%v", ok, candidateCalls, invalid)
|
||||
}
|
||||
if distance := occultationPathDistanceKM(want.point, got.point); distance > 0.005 {
|
||||
t.Fatalf("candidate displaced exact root by %v km", distance)
|
||||
}
|
||||
evaluation := exact.evaluation(centerTimeTT(got.point.Time))
|
||||
residual, ok := model.residual(evaluation, got.point.Longitude, got.point.Latitude)
|
||||
if !ok || math.Abs(residual[0]) > model.valueTolerance() || math.Abs(residual[1]) > model.derivativeTolerance() {
|
||||
t.Fatalf("accepted inexact root %v", residual)
|
||||
}
|
||||
}
|
||||
}
|
||||
+789
-245
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
package basic
|
||||
|
||||
import "math"
|
||||
|
||||
const (
|
||||
occultationDenseEphemerisNodeCount = 193
|
||||
occultationDenseEphemerisStepDays = 30.0 / 1440.0
|
||||
occultationDenseEphemerisTolerance = 5e-11
|
||||
)
|
||||
|
||||
// Width extrema near a limb can change branches under tiny frame differences.
|
||||
// Keep this metadata on the full-term model without moving the traced points.
|
||||
func correctOccultationCenterWidths(points []OccultationPathPoint, frameAt occultationPathFrameFunc) {
|
||||
for index := range points {
|
||||
_, _, width, ok := occultationPathLimitsAndWidthForFrame(centerTimeTT(points[index].Time), frameAt)
|
||||
points[index].WidthKM = 0
|
||||
if ok {
|
||||
points[index].WidthKM = width
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cache *starOccultationEventCache) preparePathEphemeris(center float64, algorithm OccultationPathAlgorithm) {
|
||||
if algorithm != OccultationPathAlgorithmExact {
|
||||
nodes := newDenseOccultationEphemerisNodes(center, func(tt float64) ([3]float64, [3]float64) {
|
||||
state := starOccultationEphemerisStateAt(tt, cache.star)
|
||||
moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM)
|
||||
distance := state.starDistanceKM
|
||||
if distance <= 0 {
|
||||
distance = 1
|
||||
}
|
||||
target := occultationPathRaDecVector(state.starRA, state.starDec, distance)
|
||||
return [3]float64{moon.x, moon.y, moon.z}, [3]float64{target.x, target.y, target.z}
|
||||
})
|
||||
if len(nodes) > 0 {
|
||||
cache.local = &starOccultationLocalEphemeris{star: cache.star, nodes: nodes, dense: true}
|
||||
}
|
||||
}
|
||||
cache.prepareLocalEphemeris(center)
|
||||
}
|
||||
|
||||
func (cache *planetOccultationEventCache) preparePathEphemeris(center float64, algorithm OccultationPathAlgorithm) {
|
||||
if algorithm != OccultationPathAlgorithmExact {
|
||||
nodes := newDenseOccultationEphemerisNodes(center, func(tt float64) ([3]float64, [3]float64) {
|
||||
state := planetOccultationEphemerisStateAt(tt, cache.config)
|
||||
moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM)
|
||||
target := occultationPathRaDecVector(state.planetRA, state.planetDec, state.planetDistanceKM)
|
||||
return [3]float64{moon.x, moon.y, moon.z}, [3]float64{target.x, target.y, target.z}
|
||||
})
|
||||
if len(nodes) > 0 {
|
||||
cache.local = &planetOccultationLocalEphemeris{nodes: nodes, dense: true}
|
||||
}
|
||||
}
|
||||
cache.prepareLocalEphemeris(center)
|
||||
}
|
||||
|
||||
// Midpoint checks reject inaccurate or invalid tables before any path state
|
||||
// is cached. They are sampled safeguards, not a rigorous global error bound.
|
||||
func newDenseOccultationEphemerisNodes(
|
||||
center float64,
|
||||
sample func(float64) ([3]float64, [3]float64),
|
||||
) []localEphemerisVectorNode {
|
||||
if !finite(center) {
|
||||
return nil
|
||||
}
|
||||
nodes := make([]localEphemerisVectorNode, occultationDenseEphemerisNodeCount)
|
||||
for index := range nodes {
|
||||
tt := center + float64(index-len(nodes)/2)*occultationDenseEphemerisStepDays
|
||||
first, next := sample(tt)
|
||||
if !finiteVector3(first) || !finiteVector3(next) {
|
||||
return nil
|
||||
}
|
||||
nodes[index] = localEphemerisVectorNode{tt: tt, first: first, next: next}
|
||||
}
|
||||
for index := 1; index < len(nodes); index++ {
|
||||
tt := (nodes[index-1].tt + nodes[index].tt) / 2
|
||||
first, next, ok := interpolateDenseOccultationVectors(nodes, tt)
|
||||
exactFirst, exactNext := sample(tt)
|
||||
if !ok || !denseOccultationVectorAccurate(first, exactFirst) || !denseOccultationVectorAccurate(next, exactNext) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
func denseOccultationVectorAccurate(approximate, exact [3]float64) bool {
|
||||
if !finiteVector3(approximate) || !finiteVector3(exact) {
|
||||
return false
|
||||
}
|
||||
norm, difference := 0.0, 0.0
|
||||
for index := range exact {
|
||||
norm += exact[index] * exact[index]
|
||||
delta := approximate[index] - exact[index]
|
||||
difference += delta * delta
|
||||
}
|
||||
return norm > 0 && finite(norm) && difference <= norm*occultationDenseEphemerisTolerance*occultationDenseEphemerisTolerance
|
||||
}
|
||||
|
||||
func interpolateDenseOccultationVectors(
|
||||
nodes []localEphemerisVectorNode,
|
||||
tt float64,
|
||||
) ([3]float64, [3]float64, bool) {
|
||||
const points = 6
|
||||
if len(nodes) < points || !finite(tt) || tt < nodes[0].tt || tt > nodes[len(nodes)-1].tt {
|
||||
return [3]float64{}, [3]float64{}, false
|
||||
}
|
||||
step := nodes[1].tt - nodes[0].tt
|
||||
if !finite(step) || step <= 0 {
|
||||
return [3]float64{}, [3]float64{}, false
|
||||
}
|
||||
start := int(math.Floor((tt-nodes[0].tt)/step)) - 2
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if start > len(nodes)-points {
|
||||
start = len(nodes) - points
|
||||
}
|
||||
var first, next [3]float64
|
||||
for point := 0; point < points; point++ {
|
||||
weight := 1.0
|
||||
// Rounded Julian days are not exactly uniformly spaced. Preserve the
|
||||
// actual node times, leaving the legacy/solar interpolator unchanged.
|
||||
for other := 0; other < points; other++ {
|
||||
if other != point {
|
||||
weight *= (tt - nodes[start+other].tt) / (nodes[start+point].tt - nodes[start+other].tt)
|
||||
}
|
||||
}
|
||||
for coordinate := 0; coordinate < 3; coordinate++ {
|
||||
first[coordinate] += weight * nodes[start+point].first[coordinate]
|
||||
next[coordinate] += weight * nodes[start+point].next[coordinate]
|
||||
}
|
||||
}
|
||||
return first, next, finiteVector3(first) && finiteVector3(next)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOccultationPathAlgorithmOptions(t *testing.T) {
|
||||
for _, algorithm := range []OccultationPathAlgorithm{"", OccultationPathAlgorithmOptimized, OccultationPathAlgorithmExact} {
|
||||
options := OccultationPathOptions{Algorithm: algorithm}
|
||||
if err := options.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := algorithm
|
||||
if want == "" {
|
||||
want = OccultationPathAlgorithmOptimized
|
||||
}
|
||||
if got := normalizeOccultationPathOptions(options).Algorithm; got != want {
|
||||
t.Fatalf("algorithm=%q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
if err := (OccultationPathOptions{Algorithm: "unknown"}).Validate(); !errors.Is(err, ErrInvalidOccultationInput) {
|
||||
t.Fatalf("invalid algorithm: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationPathEphemerisBranches(t *testing.T) {
|
||||
center := occultationTimeToTT(time.Date(2025, 1, 5, 12, 0, 0, 0, time.UTC))
|
||||
config, _ := planetOccultationConfigFor(OccultationSaturn)
|
||||
star := hr4799OccultationCoordinateForTest()
|
||||
for _, algorithm := range []OccultationPathAlgorithm{OccultationPathAlgorithmExact, OccultationPathAlgorithmOptimized} {
|
||||
t.Run(string(algorithm), func(t *testing.T) {
|
||||
planetCache := newPlanetOccultationEventCache(config)
|
||||
starCache := newStarOccultationEventCache(star)
|
||||
planetCache.preparePathEphemeris(center, algorithm)
|
||||
starCache.preparePathEphemeris(center, algorithm)
|
||||
dense := algorithm == OccultationPathAlgorithmOptimized
|
||||
if planetCache.local.dense != dense || starCache.local.dense != dense {
|
||||
t.Fatalf("unexpected selected tables: planet=%v star=%v", planetCache.local.dense, starCache.local.dense)
|
||||
}
|
||||
if !dense {
|
||||
if !reflect.DeepEqual(planetCache.local, newPlanetOccultationLocalEphemeris(center, config)) ||
|
||||
!reflect.DeepEqual(starCache.local, newStarOccultationLocalEphemeris(center, star)) {
|
||||
t.Fatal("exact branch changed the legacy predictor")
|
||||
}
|
||||
}
|
||||
for _, tt := range []float64{center + .03123, center + 3} {
|
||||
planetState := planetCache.stateAt(tt)
|
||||
starState := starCache.stateAt(tt)
|
||||
if dense && tt < center+2 {
|
||||
wantPlanet, _ := planetCache.local.stateAt(tt)
|
||||
wantStar, _ := starCache.local.stateAt(tt)
|
||||
if planetState != wantPlanet || starState != wantStar {
|
||||
t.Fatal("optimized cache did not use its dense table")
|
||||
}
|
||||
} else if planetState != planetOccultationEphemerisStateAt(tt, config) || starState != starOccultationEphemerisStateAt(tt, star) {
|
||||
t.Fatal("exact branch or out-of-window fallback changed")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// The independent instant API constructs these caches without a path table.
|
||||
if newPlanetOccultationEventCache(config).local != nil || newStarOccultationEventCache(star).local != nil {
|
||||
t.Fatal("instant caches must remain exact")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDenseOccultationTableRejectsInvalidAndUnresolvedData(t *testing.T) {
|
||||
center := 2451545.0
|
||||
polynomial := func(tt float64) ([3]float64, [3]float64) {
|
||||
u := tt - center
|
||||
return [3]float64{1 + u*u, 2 + u, 3}, [3]float64{7, 8 + u*u*u, 9}
|
||||
}
|
||||
if nodes := newDenseOccultationEphemerisNodes(center, polynomial); len(nodes) != occultationDenseEphemerisNodeCount {
|
||||
t.Fatal("smooth table rejected")
|
||||
}
|
||||
for _, sample := range []func(float64) ([3]float64, [3]float64){
|
||||
func(float64) ([3]float64, [3]float64) { return [3]float64{}, [3]float64{} },
|
||||
func(float64) ([3]float64, [3]float64) { return [3]float64{math.NaN()}, [3]float64{1} },
|
||||
func(tt float64) ([3]float64, [3]float64) {
|
||||
return [3]float64{2 + math.Sin((tt-center)*800), 1, 1}, [3]float64{1, 1, 1}
|
||||
},
|
||||
} {
|
||||
if nodes := newDenseOccultationEphemerisNodes(center, sample); nodes != nil {
|
||||
t.Fatal("invalid or under-resolved table accepted")
|
||||
}
|
||||
}
|
||||
if nodes := newDenseOccultationEphemerisNodes(math.NaN(), polynomial); nodes != nil {
|
||||
t.Fatal("invalid center accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDenseOccultationPlanetVectorsAgainstFullEphemerides(t *testing.T) {
|
||||
center := occultationTimeToTT(time.Date(2025, 1, 5, 12, 0, 0, 0, time.UTC))
|
||||
for _, planet := range []OccultationPlanet{OccultationMercury, OccultationVenus, OccultationMars, OccultationJupiter, OccultationSaturn, OccultationUranus, OccultationNeptune} {
|
||||
t.Run(planet.String(), func(t *testing.T) {
|
||||
config, _ := planetOccultationConfigFor(planet)
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
cache.preparePathEphemeris(center, OccultationPathAlgorithmOptimized)
|
||||
if !cache.local.dense {
|
||||
t.Fatal("reference event did not accept a dense table")
|
||||
}
|
||||
// Probe off-node, off-midpoint times including both window ends.
|
||||
for _, offset := range []float64{-1.999, -1.9731, -.314159, .03712, 1.9713, 1.999} {
|
||||
tt := center + offset
|
||||
moon, target, ok := cache.local.vectorsAt(tt)
|
||||
exact := planetOccultationEphemerisStateAt(tt, config)
|
||||
em := occultationPathRaDecVector(exact.moonRA, exact.moonDec, exact.moonDistanceKM)
|
||||
et := occultationPathRaDecVector(exact.planetRA, exact.planetDec, exact.planetDistanceKM)
|
||||
if !ok || !denseOccultationVectorAccurate(moon, [3]float64{em.x, em.y, em.z}) ||
|
||||
!denseOccultationVectorAccurate(target, [3]float64{et.x, et.y, et.z}) {
|
||||
t.Fatalf("interpolation exceeds the vector tolerance at offset %g", offset)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDenseOccultationInterpolationUsesActualNodeTimes(t *testing.T) {
|
||||
nodes := make([]localEphemerisVectorNode, 9)
|
||||
for i := range nodes {
|
||||
tt := 2451545 + float64(i)*occultationDenseEphemerisStepDays
|
||||
u := tt - 2451545
|
||||
nodes[i] = localEphemerisVectorNode{tt: tt, first: [3]float64{1, u, u * u}, next: [3]float64{3, u * u * u, 7}}
|
||||
}
|
||||
for _, tt := range []float64{nodes[0].tt, nodes[8].tt, nodes[4].tt + .00321} {
|
||||
first, next, ok := interpolateDenseOccultationVectors(nodes, tt)
|
||||
u := tt - 2451545
|
||||
if !ok || math.Abs(first[1]-u) > 1e-14 || math.Abs(first[2]-u*u) > 1e-14 || math.Abs(next[1]-u*u*u) > 1e-14 {
|
||||
t.Fatalf("interpolation at %.10f: %v %v %v", tt, first, next, ok)
|
||||
}
|
||||
}
|
||||
for _, tt := range []float64{math.NaN(), nodes[0].tt - .1, nodes[8].tt + .1} {
|
||||
if _, _, ok := interpolateDenseOccultationVectors(nodes, tt); ok {
|
||||
t.Fatal("out-of-window interpolation accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationPathDefaultMatchesOptimized(t *testing.T) {
|
||||
start := time.Date(2025, 7, 29, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*3600))
|
||||
options := OccultationPathOptions{Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute, DisableFootprints: true}
|
||||
want, err := FindPlanetOccultationPaths(start, start.Add(24*time.Hour), OccultationMars, options)
|
||||
if err != nil || len(want) != 1 {
|
||||
t.Fatalf("default paths=%d err=%v", len(want), err)
|
||||
}
|
||||
options.Algorithm = OccultationPathAlgorithmOptimized
|
||||
got, err := FindPlanetOccultationPaths(start, start.Add(24*time.Hour), OccultationMars, options)
|
||||
if err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("explicit optimized differs from default: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 稠密星历表必须被接受并覆盖完整的路径搜索窗口,否则路径样本会静默退回精确星历。
|
||||
func TestDenseOccultationTableCoversPathSearchWindow(t *testing.T) {
|
||||
center := occultationTimeToTT(time.Date(2025, time.January, 5, 12, 0, 0, 0, time.UTC))
|
||||
const toleranceDays = 1e-9
|
||||
for _, planet := range []OccultationPlanet{
|
||||
OccultationMercury, OccultationVenus, OccultationMars, OccultationJupiter,
|
||||
OccultationSaturn, OccultationUranus, OccultationNeptune,
|
||||
} {
|
||||
config, ok := planetOccultationConfigFor(planet)
|
||||
if !ok {
|
||||
t.Fatalf("%s occultation config is unavailable", planet)
|
||||
}
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
cache.preparePathEphemeris(center, OccultationPathAlgorithmOptimized)
|
||||
if !cache.local.dense {
|
||||
t.Fatalf("%s dense path table was rejected", planet)
|
||||
}
|
||||
nodes := cache.local.nodes
|
||||
if len(nodes) < 2 {
|
||||
t.Fatalf("%s dense path table has %d nodes", planet, len(nodes))
|
||||
}
|
||||
if first := center - occultationPathSearchSpanDays; nodes[0].tt > first+toleranceDays {
|
||||
t.Fatalf("%s dense table starts at %.9f, want at most %.9f", planet, nodes[0].tt, first)
|
||||
}
|
||||
if last := center + occultationPathSearchSpanDays; nodes[len(nodes)-1].tt < last-toleranceDays {
|
||||
t.Fatalf("%s dense table ends at %.9f, want at least %.9f", planet, nodes[len(nodes)-1].tt, last)
|
||||
}
|
||||
// 节点时刻是 JD 浮点相加的结果,相邻差只近似等于步长(约 1e-10 天)。
|
||||
if step := nodes[1].tt - nodes[0].tt; step < occultationDenseEphemerisStepDays-1e-8 ||
|
||||
step > occultationDenseEphemerisStepDays+1e-8 {
|
||||
t.Fatalf("%s dense table step=%g, want %g", planet, step, occultationDenseEphemerisStepDays)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkOccultationPlanetPathsMars20250729(b *testing.B) {
|
||||
zone := time.FixedZone("UTC+8", 8*3600)
|
||||
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
|
||||
options := OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
|
||||
DisableFootprints: true, IncludeFootprintTimeline: true, FootprintTimelineStep: 5 * time.Minute,
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for index := 0; index < b.N; index++ {
|
||||
paths, err := FindPlanetOccultationPaths(start, start.Add(24*time.Hour), OccultationMars, options)
|
||||
if err != nil || len(paths) != 1 {
|
||||
b.Fatalf("paths=%d err=%v", len(paths), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkOccultationPlanetPathsSaturnDefault(b *testing.B) {
|
||||
start := time.Date(2024, time.August, 21, 0, 0, 0, 0, time.UTC)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for index := 0; index < b.N; index++ {
|
||||
paths, err := FindPlanetOccultationPaths(start, start.Add(24*time.Hour), OccultationSaturn, OccultationPathOptions{})
|
||||
if err != nil || len(paths) != 1 {
|
||||
b.Fatalf("paths=%d err=%v", len(paths), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkOccultationPathLimitsAndWidth(b *testing.B) {
|
||||
config, _ := planetOccultationConfigFor(OccultationSaturn)
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
tt := occultationTimeToTT(time.Date(2025, time.January, 5, 4, 0, 0, 0, time.UTC))
|
||||
cache.preparePathEphemeris(tt, OccultationPathAlgorithmOptimized)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for index := 0; index < b.N; index++ {
|
||||
_, _, _, _ = occultationPathLimitsAndWidthForFrame(tt, cache.outerFrameAt)
|
||||
}
|
||||
}
|
||||
+71
-73
@@ -19,6 +19,7 @@ const (
|
||||
|
||||
type planetOccultationConfig struct {
|
||||
planet OccultationPlanet
|
||||
planetIndex int
|
||||
equatorialRadiusKM float64
|
||||
apparentRaDecN func(float64, int) (float64, float64)
|
||||
earthDistanceN func(float64, int) float64
|
||||
@@ -41,6 +42,16 @@ type planetOccultationState struct {
|
||||
valid bool
|
||||
}
|
||||
|
||||
func (state planetOccultationState) movingDiskContactState() movingDiskContactState {
|
||||
return movingDiskContactState{
|
||||
separation: state.separationArcsec,
|
||||
occultingOuterRadius: state.moonSemidiameter,
|
||||
occultingInnerRadius: state.moonSemidiameter,
|
||||
targetRadius: state.planetSemidiameter,
|
||||
valid: state.valid,
|
||||
}
|
||||
}
|
||||
|
||||
// FindPlanetOccultations 搜索固定观测点的有限盘面行星月掩。
|
||||
// 经度东为正、纬度北为正,单位为度;高度为平均海平面以上米数。目标位置、视差和视半径会在每次候选、掩甚和接触计算时重新计算。
|
||||
// FindPlanetOccultations searches one finite-disk planet at a fixed observing site.
|
||||
@@ -137,36 +148,43 @@ func planetOccultationConfigFor(planet OccultationPlanet) (planetOccultationConf
|
||||
config := planetOccultationConfig{planet: planet}
|
||||
switch planet {
|
||||
case OccultationMercury:
|
||||
config.planetIndex = 1
|
||||
config.equatorialRadiusKM = mercuryEquatorialRadiusKM
|
||||
config.apparentRaDecN = MercuryApparentRaDecN
|
||||
config.earthDistanceN = EarthMercuryAwayN
|
||||
config.semidiameterN = MercurySemidiameterN
|
||||
case OccultationVenus:
|
||||
config.planetIndex = 2
|
||||
config.equatorialRadiusKM = venusEquatorialRadiusKM
|
||||
config.apparentRaDecN = VenusApparentRaDecN
|
||||
config.earthDistanceN = EarthVenusAwayN
|
||||
config.semidiameterN = VenusSemidiameterN
|
||||
case OccultationMars:
|
||||
config.planetIndex = 3
|
||||
config.equatorialRadiusKM = marsEquatorialRadiusKM
|
||||
config.apparentRaDecN = MarsApparentRaDecN
|
||||
config.earthDistanceN = EarthMarsAwayN
|
||||
config.semidiameterN = MarsSemidiameterN
|
||||
case OccultationJupiter:
|
||||
config.planetIndex = 4
|
||||
config.equatorialRadiusKM = jupiterEquatorialRadiusKM
|
||||
config.apparentRaDecN = JupiterApparentRaDecN
|
||||
config.earthDistanceN = EarthJupiterAwayN
|
||||
config.semidiameterN = JupiterSemidiameterN
|
||||
case OccultationSaturn:
|
||||
config.planetIndex = 5
|
||||
config.equatorialRadiusKM = saturnEquatorialRadiusKM
|
||||
config.apparentRaDecN = SaturnApparentRaDecN
|
||||
config.earthDistanceN = EarthSaturnAwayN
|
||||
config.semidiameterN = SaturnSemidiameterN
|
||||
case OccultationUranus:
|
||||
config.planetIndex = 6
|
||||
config.equatorialRadiusKM = uranusEquatorialRadiusKM
|
||||
config.apparentRaDecN = UranusApparentRaDecN
|
||||
config.earthDistanceN = EarthUranusAwayN
|
||||
config.semidiameterN = UranusSemidiameterN
|
||||
case OccultationNeptune:
|
||||
config.planetIndex = 7
|
||||
config.equatorialRadiusKM = neptuneEquatorialRadiusKM
|
||||
config.apparentRaDecN = NeptuneApparentRaDecN
|
||||
config.earthDistanceN = EarthNeptuneAwayN
|
||||
@@ -262,7 +280,16 @@ func planetOccultationBestObserver(seedTT, startTT, endTT float64, config planet
|
||||
}
|
||||
point, pointOK := occultationPathCenterPointForFrame(greatestTT, frameAt, time.UTC)
|
||||
if !pointOK {
|
||||
point, pointOK = occultationPathBoundaryPointForFrame(greatestTT, frameAt, time.UTC)
|
||||
// 非中心事件的月影轴不与地球椭球相交,最佳站心应取离影轴最近的椭球点;
|
||||
// 外接触切点位于掩带边缘,会把掩甚站心推出掩食之外,只作最后回退。
|
||||
// For a non-central event the shadow axis misses the ellipsoid, so the
|
||||
// best station is the ellipsoid point nearest to that axis. The outer
|
||||
// contact tangent sits on the band edge and moves the greatest station
|
||||
// outside the occultation, so it remains the last resort only.
|
||||
point, pointOK = occultationPathTrackPointForFrame(greatestTT, frameAt, time.UTC)
|
||||
if !pointOK {
|
||||
point, pointOK = occultationPathBoundaryPointForFrame(greatestTT, frameAt, time.UTC)
|
||||
}
|
||||
}
|
||||
if !pointOK {
|
||||
return 0, Observer{}, 0, false
|
||||
@@ -312,8 +339,10 @@ func planetOccultationInfoAtGreatest(
|
||||
return info, true
|
||||
}
|
||||
|
||||
externalImmersionTT, externalImmersionOK := planetOccultationContact(greatestTT, -1, false, config, observer)
|
||||
externalEmersionTT, externalEmersionOK := planetOccultationContact(greatestTT, 1, false, config, observer)
|
||||
contactEvaluator := newPlanetOccultationContactEvaluator(config, observer)
|
||||
contactEvaluator.prime(greatestTT, state.movingDiskContactState(), state.valid)
|
||||
externalImmersionTT, externalImmersionOK := planetOccultationContactWithEvaluator(greatestTT, -1, false, contactEvaluator)
|
||||
externalEmersionTT, externalEmersionOK := planetOccultationContactWithEvaluator(greatestTT, 1, false, contactEvaluator)
|
||||
if !externalImmersionOK || !externalEmersionOK || externalEmersionTT <= externalImmersionTT {
|
||||
return PlanetOccultationInfo{}, false
|
||||
}
|
||||
@@ -321,8 +350,8 @@ func planetOccultationInfoAtGreatest(
|
||||
info.ExternalEmersion = occultationTTToLocation(externalEmersionTT, location)
|
||||
|
||||
if state.internalContactMetric < -planetOccultationGrazingToleranceArcsec {
|
||||
internalImmersionTT, internalImmersionOK := planetOccultationContact(greatestTT, -1, true, config, observer)
|
||||
internalEmersionTT, internalEmersionOK := planetOccultationContact(greatestTT, 1, true, config, observer)
|
||||
internalImmersionTT, internalImmersionOK := planetOccultationContactWithEvaluator(greatestTT, -1, true, contactEvaluator)
|
||||
internalEmersionTT, internalEmersionOK := planetOccultationContactWithEvaluator(greatestTT, 1, true, contactEvaluator)
|
||||
if !internalImmersionOK || !internalEmersionOK ||
|
||||
internalImmersionTT <= externalImmersionTT || internalEmersionTT >= externalEmersionTT ||
|
||||
internalImmersionTT >= greatestTT || internalEmersionTT <= greatestTT {
|
||||
@@ -370,14 +399,23 @@ func planetOccultationStateAt(tt float64, config planetOccultationConfig, observ
|
||||
return planetOccultationState{}
|
||||
}
|
||||
separation := angularSeparationDegrees(position.moonRA, position.moonDec, position.planetRA, position.planetDec) * 3600
|
||||
contactState := movingDiskContactState{
|
||||
separation: separation,
|
||||
occultingOuterRadius: moonRadius,
|
||||
occultingInnerRadius: moonRadius,
|
||||
targetRadius: planetRadius,
|
||||
valid: movingDiskContactStateValid(
|
||||
separation, moonRadius, moonRadius, planetRadius,
|
||||
),
|
||||
}
|
||||
return planetOccultationState{
|
||||
position: position,
|
||||
separationArcsec: separation,
|
||||
moonSemidiameter: moonRadius,
|
||||
planetSemidiameter: planetRadius,
|
||||
externalContactMetric: separation - (moonRadius + planetRadius),
|
||||
internalContactMetric: separation - (moonRadius - planetRadius),
|
||||
valid: finite(separation),
|
||||
externalContactMetric: contactState.externalContactGap(),
|
||||
internalContactMetric: contactState.internalContactGap(),
|
||||
valid: contactState.valid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,14 +440,6 @@ func planetOccultationExternalContactMetric(tt float64, config planetOccultation
|
||||
return state.externalContactMetric
|
||||
}
|
||||
|
||||
func planetMoonSeparationArcsec(tt float64, config planetOccultationConfig, observer *Observer, n int) float64 {
|
||||
position := planetMoonPositionAt(tt, config, observer, n)
|
||||
if !position.valid {
|
||||
return math.Inf(1)
|
||||
}
|
||||
return angularSeparationDegrees(position.moonRA, position.moonDec, position.planetRA, position.planetDec) * 3600
|
||||
}
|
||||
|
||||
func planetOccultationLatitudePass(tt float64, config planetOccultationConfig, observer *Observer, safetyMarginArcsec float64) bool {
|
||||
state := planetOccultationStateAt(tt, config, observer, -1)
|
||||
if !state.valid {
|
||||
@@ -421,73 +451,41 @@ func planetOccultationLatitudePass(tt float64, config planetOccultationConfig, o
|
||||
return math.Abs(moonLatitude-planetLatitude)*3600 <= limit
|
||||
}
|
||||
|
||||
func planetOccultationContact(
|
||||
func newPlanetOccultationContactEvaluator(
|
||||
config planetOccultationConfig,
|
||||
observer Observer,
|
||||
) *movingDiskContactEvaluator {
|
||||
return newMovingDiskContactEvaluator(func(tt float64) (movingDiskContactState, bool) {
|
||||
state := planetOccultationStateAt(tt, config, &observer, -1)
|
||||
return state.movingDiskContactState(), state.valid
|
||||
})
|
||||
}
|
||||
|
||||
func planetOccultationContactWithEvaluator(
|
||||
greatestTT float64,
|
||||
direction int,
|
||||
internal bool,
|
||||
config planetOccultationConfig,
|
||||
observer Observer,
|
||||
evaluator *movingDiskContactEvaluator,
|
||||
) (float64, bool) {
|
||||
if direction != -1 && direction != 1 {
|
||||
return math.NaN(), false
|
||||
}
|
||||
metric := func(tt float64) float64 {
|
||||
state := planetOccultationStateAt(tt, config, &observer, -1)
|
||||
if !state.valid {
|
||||
return math.NaN()
|
||||
}
|
||||
if internal {
|
||||
return state.internalContactMetric
|
||||
}
|
||||
return state.externalContactMetric
|
||||
metric := func(tt float64) (float64, bool) {
|
||||
return evaluator.gap(tt, internal)
|
||||
}
|
||||
nearTT := greatestTT
|
||||
nearValue := metric(nearTT)
|
||||
if !finite(nearValue) || nearValue > 0 {
|
||||
root, ok := occultationMovingDiskEngine().contactRoot(
|
||||
greatestTT,
|
||||
float64(direction),
|
||||
planetOccultationContactStepDays,
|
||||
planetOccultationContactSpanDays,
|
||||
planetOccultationRootToleranceDays,
|
||||
metric,
|
||||
64,
|
||||
)
|
||||
if !ok {
|
||||
return math.NaN(), false
|
||||
}
|
||||
maxSteps := int(math.Ceil(planetOccultationContactSpanDays / planetOccultationContactStepDays))
|
||||
if maxSteps > planetOccultationMaxContactSteps {
|
||||
maxSteps = planetOccultationMaxContactSteps
|
||||
}
|
||||
for i := 1; i <= maxSteps; i++ {
|
||||
farTT := greatestTT + float64(direction*i)*planetOccultationContactStepDays
|
||||
farValue := metric(farTT)
|
||||
if !finite(farValue) {
|
||||
continue
|
||||
}
|
||||
if farValue >= 0 {
|
||||
return planetOccultationRoot(nearTT, farTT, nearValue, farValue, metric)
|
||||
}
|
||||
nearTT, nearValue = farTT, farValue
|
||||
}
|
||||
return math.NaN(), false
|
||||
}
|
||||
|
||||
func planetOccultationRoot(
|
||||
leftTT, rightTT, leftValue, rightValue float64,
|
||||
metric func(float64) float64,
|
||||
) (float64, bool) {
|
||||
if leftTT > rightTT {
|
||||
leftTT, rightTT = rightTT, leftTT
|
||||
leftValue, rightValue = rightValue, leftValue
|
||||
}
|
||||
if !finite(leftValue) || !finite(rightValue) || leftValue*rightValue > 0 {
|
||||
return math.NaN(), false
|
||||
}
|
||||
for i := 0; i < 64 && math.Abs(rightTT-leftTT) > planetOccultationRootToleranceDays; i++ {
|
||||
midTT := (leftTT + rightTT) / 2
|
||||
midValue := metric(midTT)
|
||||
if !finite(midValue) {
|
||||
return math.NaN(), false
|
||||
}
|
||||
if leftValue*midValue <= 0 {
|
||||
rightTT, rightValue = midTT, midValue
|
||||
} else {
|
||||
leftTT, leftValue = midTT, midValue
|
||||
}
|
||||
}
|
||||
return (leftTT + rightTT) / 2, true
|
||||
return root, true
|
||||
}
|
||||
|
||||
func planetOccultationCoarseStepDays(options OccultationSearchOptions) float64 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,40 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPlanetOccultationHorizonArcStaysBetweenContacts(t *testing.T) {
|
||||
config, _ := planetOccultationConfigFor(OccultationSaturn)
|
||||
tt := occultationTimeToTT(time.Date(2025, 1, 5, 17, 0, 0, 0, time.UTC))
|
||||
frame, ok := planetOccultationPathFrameAt(tt, config)
|
||||
if !ok {
|
||||
t.Fatal("missing frame")
|
||||
}
|
||||
fine := planetOccultationHorizonCircle(tt, frame, time.UTC, 3600)
|
||||
for _, tc := range []struct {
|
||||
from, to int
|
||||
want []int
|
||||
}{
|
||||
{20, 180, []int{100, 180}},
|
||||
{180, 20, []int{100, 20}},
|
||||
{3590, 130, []int{0, 100, 130}},
|
||||
{130, 3590, []int{100, 0, 3590}},
|
||||
{1790, 1930, []int{1800, 1900, 1930}},
|
||||
{1930, 1790, []int{1900, 1800, 1790}},
|
||||
{20, 30, []int{25, 30}},
|
||||
{30, 20, []int{25, 20}},
|
||||
} {
|
||||
arc := planetOccultationHorizonArc(tt, frame, []OccultationPathPoint{fine[tc.to], fine[tc.from]}, time.UTC, 36)
|
||||
if len(arc) != len(tc.want) {
|
||||
t.Errorf("arc %d -> %d has %d points, want %d", tc.from, tc.to, len(arc), len(tc.want))
|
||||
continue
|
||||
}
|
||||
for i, index := range tc.want {
|
||||
if distance := occultationPathDistanceKM(arc[i], fine[index]); distance > 1e-4 {
|
||||
t.Errorf("arc %d -> %d point %d misses circle sample %d by %.6f km", tc.from, tc.to, i, index, distance)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationSaturnFootprintsContainCenterLine(t *testing.T) {
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
@@ -52,6 +86,42 @@ func TestPlanetOccultationSaturnFootprintsContainCenterLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationMars20250729GreatestIsInsideTotalFootprint(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
|
||||
paths, err := FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), OccultationMars,
|
||||
OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
||||
RiseSetStep: time.Minute, DisableFootprints: true,
|
||||
IncludeFootprintTimeline: true, FootprintTimelineStep: 5 * time.Minute,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
path := paths[0]
|
||||
if !path.HasTotalBand || len(path.TotalBandFootprints) == 0 {
|
||||
t.Fatalf("Mars path has no total band support: hasTotal=%v footprints=%d",
|
||||
path.HasTotalBand, len(path.TotalBandFootprints))
|
||||
}
|
||||
greatest := path.Greatest
|
||||
foundGreatestFootprint := false
|
||||
for _, footprint := range path.TotalBandFootprints {
|
||||
if !footprint.Time.Equal(greatest.Time) {
|
||||
continue
|
||||
}
|
||||
foundGreatestFootprint = true
|
||||
if !planetOccultationFootprintContains(footprint, greatest.Longitude, greatest.Latitude) {
|
||||
t.Fatalf("greatest point %.6f, %.6f is outside total footprint at %v",
|
||||
greatest.Longitude, greatest.Latitude, greatest.Time)
|
||||
}
|
||||
}
|
||||
if !foundGreatestFootprint {
|
||||
t.Fatalf("total compact support omitted greatest time %v", greatest.Time)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationPathRejectsExcessiveAggregateSampling(t *testing.T) {
|
||||
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
|
||||
_, err := FindPlanetOccultationPaths(
|
||||
@@ -82,6 +152,27 @@ func TestPlanetOccultationFootprintsHaveIndependentSampleBudget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationHorizonCircleUsesTopocentricLunarHorizon(t *testing.T) {
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn occultation config is unavailable")
|
||||
}
|
||||
tt := occultationTimeToTT(time.Date(2024, time.July, 24, 16, 30, 0, 0, time.UTC))
|
||||
frame, ok := planetOccultationTotalPathFrameAt(tt, config)
|
||||
if !ok {
|
||||
t.Fatal("Saturn total-occultation frame is unavailable")
|
||||
}
|
||||
points := planetOccultationHorizonCircle(tt, frame, time.UTC, 360)
|
||||
if len(points) != 360 {
|
||||
t.Fatalf("horizon point count = %d, want 360", len(points))
|
||||
}
|
||||
for index, point := range points {
|
||||
if altitude := math.Abs(point.MoonAltitude); altitude > 5e-5 {
|
||||
t.Fatalf("horizon point %d lunar altitude = %.12f degrees, want zero", index, point.MoonAltitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func planetOccultationFootprintContains(
|
||||
footprint PlanetOccultationFootprint,
|
||||
longitude, latitude float64,
|
||||
@@ -114,3 +205,128 @@ func planetOccultationFootprintContains(
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 导出的瞬时足迹必须停在月球地平线切口上:边界、闭合多边形与修复面都不得含地平线以下顶点。
|
||||
func TestPlanetOccultationFootprintsClipAtLunarHorizon(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
saturnStart := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone)
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
options OccultationPathOptions
|
||||
}{
|
||||
{
|
||||
name: "DenseFootprints",
|
||||
options: OccultationPathOptions{
|
||||
Algorithm: OccultationPathAlgorithmExact, Step: 20 * time.Minute,
|
||||
TargetSpacingKM: 900, RiseSetStep: time.Minute,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TimelineFootprints",
|
||||
options: OccultationPathOptions{
|
||||
Algorithm: OccultationPathAlgorithmExact, Step: 20 * time.Minute,
|
||||
TargetSpacingKM: 900, DisableFootprints: true, IncludeFootprintTimeline: true,
|
||||
FootprintTimelineStep: 5 * time.Minute, RiseSetStep: time.Minute,
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
paths, err := FindPlanetOccultationPaths(
|
||||
saturnStart, saturnStart.Add(24*time.Hour), OccultationSaturn, testCase.options,
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
path := paths[0]
|
||||
below, checked := 0, 0
|
||||
for _, footprints := range [][]PlanetOccultationFootprint{path.PartialFootprints, path.TotalFootprints} {
|
||||
belowPart, checkedPart := planetOccultationFootprintHorizonViolations(footprints)
|
||||
below += belowPart
|
||||
checked += checkedPart
|
||||
}
|
||||
if checked < 1000 {
|
||||
t.Fatalf("checked only %d footprint vertices", checked)
|
||||
}
|
||||
if below != 0 {
|
||||
t.Fatalf("%d of %d footprint vertices lie below the lunar horizon", below, checked)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func planetOccultationFootprintHorizonViolations(
|
||||
footprints []PlanetOccultationFootprint,
|
||||
) (below, checked int) {
|
||||
for _, footprint := range footprints {
|
||||
for _, rings := range [][][]OccultationPathPoint{
|
||||
footprint.Boundaries, footprint.Polygons, footprint.InteriorPolygons,
|
||||
} {
|
||||
for _, ring := range rings {
|
||||
for _, point := range ring {
|
||||
checked++
|
||||
if point.MoonAltitude < 0 {
|
||||
below++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return below, checked
|
||||
}
|
||||
|
||||
func BenchmarkOccultationPlanetFootprints(b *testing.B) {
|
||||
b.Run("Saturn20240821Default", func(b *testing.B) {
|
||||
benchmarkPlanetOccultationFootprints(
|
||||
b, OccultationSaturn, time.Date(2024, time.August, 21, 0, 0, 0, 0, time.UTC),
|
||||
OccultationPathOptions{},
|
||||
)
|
||||
})
|
||||
b.Run("Mars20250729Compact", func(b *testing.B) {
|
||||
benchmarkPlanetOccultationFootprints(
|
||||
b, OccultationMars, time.Date(2025, time.July, 29, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)),
|
||||
OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
|
||||
DisableFootprints: true, IncludeFootprintTimeline: true, FootprintTimelineStep: 5 * time.Minute,
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// benchmarkPlanetOccultationFootprints 只度量足迹装配:窗口与帧函数取自一次真实路径求解,避免混入路径与升落成本。
|
||||
func benchmarkPlanetOccultationFootprints(
|
||||
b *testing.B,
|
||||
planet OccultationPlanet,
|
||||
start time.Time,
|
||||
options OccultationPathOptions,
|
||||
) {
|
||||
paths, err := FindPlanetOccultationPaths(start, start.Add(24*time.Hour), planet, options)
|
||||
if err != nil || len(paths) != 1 {
|
||||
b.Fatalf("paths=%d err=%v", len(paths), err)
|
||||
}
|
||||
config, ok := planetOccultationConfigFor(planet)
|
||||
if !ok {
|
||||
b.Fatal("missing occultation config")
|
||||
}
|
||||
path := paths[0]
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
greatestTT := occultationTimeToTT(path.Greatest.Time)
|
||||
cache.preparePathEphemeris(greatestTT, options.Algorithm)
|
||||
startTT, endTT := occultationTimeToTT(path.Start.Time), occultationTimeToTT(path.End.Time)
|
||||
location := start.Location()
|
||||
frameAt := cache.outerFrameAt
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for index := 0; index < b.N; index++ {
|
||||
if options.DisableFootprints {
|
||||
band := planetOccultationBandFootprints(startTT, endTT, greatestTT, frameAt, location, nil)
|
||||
timeline := planetOccultationTimelineFootprints(startTT, endTT, greatestTT, frameAt, options, location)
|
||||
if len(band) == 0 || len(timeline) == 0 {
|
||||
b.Fatalf("band=%d timeline=%d", len(band), len(timeline))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if footprints := planetOccultationFootprints(startTT, endTT, greatestTT, frameAt, options, location); len(footprints) == 0 {
|
||||
b.Fatal("no footprints")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+944
-150
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,352 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPlanetOccultationCombinedPositionMatchesSeparateEphemerides(t *testing.T) {
|
||||
tt := occultationTimeToTT(time.Date(2025, time.February, 1, 4, 0, 48, 0, time.UTC))
|
||||
for _, planet := range []OccultationPlanet{
|
||||
OccultationMercury,
|
||||
OccultationVenus,
|
||||
OccultationMars,
|
||||
OccultationJupiter,
|
||||
OccultationSaturn,
|
||||
OccultationUranus,
|
||||
OccultationNeptune,
|
||||
} {
|
||||
config, ok := planetOccultationConfigFor(planet)
|
||||
if !ok {
|
||||
t.Fatalf("%s occultation config is unavailable", planet)
|
||||
}
|
||||
wantRA, wantDec := config.apparentRaDecN(tt, -1)
|
||||
wantDistance := config.earthDistanceN(tt, -1)
|
||||
gotRA, gotDec, distance := planetOccultationApparentPositionAndDistanceN(tt, config, -1)
|
||||
if gotRA != wantRA || gotDec != wantDec || distance != wantDistance {
|
||||
t.Fatalf("%s combined position = %.15g %.15g %.15g, want %.15g %.15g %.15g",
|
||||
planet, gotRA, gotDec, distance, wantRA, wantDec, wantDistance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationEventCacheReusesStateAndContactFrames(t *testing.T) {
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn occultation config is unavailable")
|
||||
}
|
||||
tt := occultationTimeToTT(time.Date(2024, time.August, 21, 2, 41, 36, 0, time.UTC))
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
|
||||
wantOuter, wantOuterOK := planetOccultationPathFrameAt(tt, config)
|
||||
wantTotal, wantTotalOK := planetOccultationTotalPathFrameAt(tt, config)
|
||||
for iteration := 0; iteration < 2; iteration++ {
|
||||
gotOuter, gotOuterOK := cache.outerFrameAt(tt)
|
||||
gotTotal, gotTotalOK := cache.totalFrameAt(tt)
|
||||
_ = cache.riseSetContextAt(tt)
|
||||
if gotOuterOK != wantOuterOK || !occultationPathFrameGeometryEqual(gotOuter, wantOuter) {
|
||||
t.Fatalf("cached outer frame differs on iteration %d", iteration)
|
||||
}
|
||||
if gotTotalOK != wantTotalOK || !occultationPathFrameGeometryEqual(gotTotal, wantTotal) {
|
||||
t.Fatalf("cached total frame differs on iteration %d", iteration)
|
||||
}
|
||||
}
|
||||
if len(cache.states) != 1 || len(cache.outerFrames) != 1 || len(cache.totalFrames) != 1 {
|
||||
t.Fatalf("cache sizes = states:%d outer:%d total:%d, want one entry each",
|
||||
len(cache.states), len(cache.outerFrames), len(cache.totalFrames))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationPathFramesReuseMoonDistanceForAngularRadius(t *testing.T) {
|
||||
tt := occultationTimeToTT(time.Date(2025, time.February, 1, 4, 0, 48, 0, time.UTC))
|
||||
want := MoonSemidiameter(tt) * math.Pi / (180 * 3600)
|
||||
starFrame, ok := starOccultationPathFrameAt(tt, StarCoordinate{
|
||||
RA: 0, Dec: 0, Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("stellar occultation frame is unavailable")
|
||||
}
|
||||
if difference := math.Abs(starFrame.moonRadius - want); difference > 1e-15 {
|
||||
t.Fatalf("stellar cached lunar radius differs by %.15g radians", difference)
|
||||
}
|
||||
|
||||
config, _ := planetOccultationConfigFor(OccultationSaturn)
|
||||
planetFrame, ok := planetOccultationPathFrameAt(tt, config)
|
||||
if !ok {
|
||||
t.Fatal("planet occultation frame is unavailable")
|
||||
}
|
||||
if difference := math.Abs(planetFrame.moonRadius - want); difference > 1e-15 {
|
||||
t.Fatalf("planet cached lunar radius differs by %.15g radians", difference)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationCanDisableInstantaneousFootprints(t *testing.T) {
|
||||
start := time.Date(2024, time.August, 21, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), OccultationSaturn,
|
||||
OccultationPathOptions{Step: 10 * time.Minute, DisableFootprints: true},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
path := paths[0]
|
||||
if len(path.PartialFootprints) != 0 || len(path.TotalFootprints) != 0 {
|
||||
t.Fatalf("disabled footprint counts partial=%d total=%d, want zero", len(path.PartialFootprints), len(path.TotalFootprints))
|
||||
}
|
||||
if len(path.PartialBandFootprints) == 0 || len(path.TotalBandFootprints) == 0 {
|
||||
t.Fatalf("compact band support counts partial=%d total=%d, want both nonzero", len(path.PartialBandFootprints), len(path.TotalBandFootprints))
|
||||
}
|
||||
if len(path.RiseSetCurves) != 6 {
|
||||
t.Fatalf("rise/set curve count=%d, want six", len(path.RiseSetCurves))
|
||||
}
|
||||
if len(path.TotalRiseSetCurves) != 6 {
|
||||
t.Fatalf("total rise/set curve count=%d, want six", len(path.TotalRiseSetCurves))
|
||||
}
|
||||
if len(path.CenterLine) == 0 || len(path.NorthernLimit) == 0 || len(path.SouthernLimit) == 0 {
|
||||
t.Fatal("disabled footprints removed the center line or outer limits")
|
||||
}
|
||||
if !path.HasTotalBand || len(path.NorthernTotalLimit) == 0 || len(path.SouthernTotalLimit) == 0 {
|
||||
t.Fatal("disabled footprints removed the total-occultation band")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationCompactBandCanIncludeLowFrequencyTimeline(t *testing.T) {
|
||||
start := time.Date(2025, time.January, 5, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
|
||||
paths, err := FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), OccultationSaturn,
|
||||
OccultationPathOptions{
|
||||
Step: 20 * time.Minute, DisableFootprints: true,
|
||||
IncludeFootprintTimeline: true, FootprintTimelineStep: 5 * time.Minute,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
path := paths[0]
|
||||
if len(path.PartialBandFootprints) == 0 || len(path.PartialFootprints) == 0 {
|
||||
t.Fatalf("partial compact/timeline counts=%d/%d, want both", len(path.PartialBandFootprints), len(path.PartialFootprints))
|
||||
}
|
||||
if len(path.TotalBandFootprints) == 0 || len(path.TotalFootprints) == 0 {
|
||||
t.Fatalf("total compact/timeline counts=%d/%d, want both", len(path.TotalBandFootprints), len(path.TotalFootprints))
|
||||
}
|
||||
if len(path.PartialFootprints) > 50 || len(path.TotalFootprints) > 50 {
|
||||
t.Fatalf("five-minute timeline is unexpectedly dense: partial=%d total=%d", len(path.PartialFootprints), len(path.TotalFootprints))
|
||||
}
|
||||
timelinePoints := 0
|
||||
for _, footprints := range [][]PlanetOccultationFootprint{path.PartialFootprints, path.TotalFootprints} {
|
||||
for _, footprint := range footprints {
|
||||
for polygonIndex, polygon := range footprint.Polygons {
|
||||
if len(polygon) < 4 || occultationPathDistanceKM(polygon[0], polygon[len(polygon)-1]) > 0.001 {
|
||||
t.Fatalf("timeline footprint at %v polygon %d is not closed", footprint.Time, polygonIndex)
|
||||
}
|
||||
timelinePoints += len(polygon)
|
||||
for pointIndex := 1; pointIndex < len(polygon); pointIndex++ {
|
||||
if distance := occultationPathDistanceKM(polygon[pointIndex-1], polygon[pointIndex]); distance > 301 {
|
||||
t.Fatalf("timeline footprint at %v polygon %d edge %d spans %.1f km, want at most 301 km",
|
||||
footprint.Time, polygonIndex, pointIndex-1, distance)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if timelinePoints > 15000 {
|
||||
t.Fatalf("timeline contains %d polygon points, want at most 15000", timelinePoints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationCompactBandUsesIndependentTimeline(t *testing.T) {
|
||||
start := time.Date(2025, time.June, 5, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := FindStarOccultationPaths(
|
||||
start, start.Add(24*time.Hour), hr4799OccultationCoordinateForTest(),
|
||||
OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
||||
DisableFootprints: true, IncludeFootprintTimeline: true,
|
||||
FootprintTimelineStep: 5 * time.Minute,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
path := paths[0]
|
||||
if len(path.BandFootprints) == 0 || len(path.Footprints) == 0 {
|
||||
t.Fatalf("compact/timeline counts=%d/%d, want both", len(path.BandFootprints), len(path.Footprints))
|
||||
}
|
||||
for _, footprint := range path.Footprints {
|
||||
for polygonIndex, polygon := range footprint.Polygons {
|
||||
if len(polygon) < 4 || occultationPathDistanceKM(polygon[0], polygon[len(polygon)-1]) > 0.001 {
|
||||
t.Fatalf("timeline footprint at %v polygon %d is not closed", footprint.Time, polygonIndex)
|
||||
}
|
||||
for pointIndex := 1; pointIndex < len(polygon); pointIndex++ {
|
||||
if distance := occultationPathDistanceKM(polygon[pointIndex-1], polygon[pointIndex]); distance > 301 {
|
||||
t.Fatalf("timeline footprint at %v polygon %d edge %d spans %.1f km, want at most 301 km",
|
||||
footprint.Time, polygonIndex, pointIndex-1, distance)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationPathPointBudgetIsBounded(t *testing.T) {
|
||||
options := normalizeOccultationPathOptions(OccultationPathOptions{})
|
||||
got := occultationPathEstimatedPointCount(0, 0.2, 0, 0, false, 0, 0, false, 0.1, options)
|
||||
if got <= 0 || got > occultationPathMaxOutputPointCount {
|
||||
t.Fatalf("default occultation point estimate=%d, want within positive budget", got)
|
||||
}
|
||||
dense := normalizeOccultationPathOptions(OccultationPathOptions{Step: time.Second})
|
||||
got = occultationPathEstimatedPointCount(0, 2, 0, 2, true, 0, 2, true, 1, dense)
|
||||
if got <= occultationPathFootprintPointBudget || got > occultationPathMaxOutputPointCount {
|
||||
t.Fatalf("dense occultation point estimate=%d, want footprint-aware value within budget %d",
|
||||
got, occultationPathMaxOutputPointCount)
|
||||
}
|
||||
if overflow := occultationPathAccumulatePointEstimate(occultationPathMaxOutputPointCount-10, 20); overflow <= occultationPathMaxOutputPointCount {
|
||||
t.Fatalf("overflow estimate=%d, want sentinel above %d", overflow, occultationPathMaxOutputPointCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultation19621010TotalBandIsNarrowerThanOuterBand(t *testing.T) {
|
||||
start := time.Date(1962, time.October, 10, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), OccultationJupiter,
|
||||
OccultationPathOptions{Step: 10 * time.Minute, DisableFootprints: true, DisableRiseSet: true},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
path := paths[0]
|
||||
if !path.HasTotalBand || path.GreatestTotalWidthKM <= 0 || path.GreatestTotalWidthKM >= path.Greatest.WidthKM {
|
||||
t.Fatalf("widths outer=%.3f total=%.3f hasTotal=%v, want a narrower positive total band",
|
||||
path.Greatest.WidthKM, path.GreatestTotalWidthKM, path.HasTotalBand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultation20240725CompactBandRefinesContactsAndBoundaryPairing(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone)
|
||||
paths, err := FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), OccultationSaturn,
|
||||
OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
||||
DisableFootprints: true,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
path := paths[0]
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
footprints []PlanetOccultationFootprint
|
||||
start, end time.Time
|
||||
}{
|
||||
{name: "partial", footprints: path.PartialBandFootprints, start: path.Start.Time, end: path.End.Time},
|
||||
{name: "total", footprints: path.TotalBandFootprints, start: path.TotalStart.Time, end: path.TotalEnd.Time},
|
||||
} {
|
||||
if len(test.footprints) < 2 {
|
||||
t.Fatalf("%s compact support count=%d, want at least two", test.name, len(test.footprints))
|
||||
}
|
||||
if gap := test.footprints[0].Time.Sub(test.start); gap > 30*time.Second {
|
||||
t.Errorf("%s compact support starts %s after contact, want at most 30s", test.name, gap)
|
||||
}
|
||||
if gap := test.end.Sub(test.footprints[len(test.footprints)-1].Time); gap > 30*time.Second {
|
||||
t.Errorf("%s compact support ends %s before contact, want at most 30s", test.name, gap)
|
||||
}
|
||||
for footprintIndex, footprint := range test.footprints {
|
||||
if footprintIndex > 0 {
|
||||
previous := test.footprints[footprintIndex-1]
|
||||
if previous.Closed && footprint.Closed && footprint.Time.Sub(previous.Time) <= time.Second {
|
||||
t.Errorf("%s compact support retains duplicate closed footprints at %s and %s",
|
||||
test.name, previous.Time, footprint.Time)
|
||||
}
|
||||
}
|
||||
for polygonIndex, polygon := range footprint.Polygons {
|
||||
for pointIndex := 1; pointIndex < len(polygon); pointIndex++ {
|
||||
if distance := occultationPathDistanceKM(polygon[pointIndex-1], polygon[pointIndex]); distance > 150 {
|
||||
t.Errorf("%s compact support[%d].polygon[%d] edge %d spans %.1f km, want at most 150 km",
|
||||
test.name, footprintIndex, polygonIndex, pointIndex-1, distance)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
first, second []OccultationPathPoint
|
||||
}{
|
||||
{name: "partial", first: path.NorthernLimit, second: path.SouthernLimit},
|
||||
{name: "total", first: path.NorthernTotalLimit, second: path.SouthernTotalLimit},
|
||||
} {
|
||||
for index := 1; index < len(test.first); index++ {
|
||||
direct := math.Max(
|
||||
occultationPathDistanceKM(test.first[index-1], test.first[index]),
|
||||
occultationPathDistanceKM(test.second[index-1], test.second[index]),
|
||||
)
|
||||
swapped := math.Max(
|
||||
occultationPathDistanceKM(test.first[index-1], test.second[index]),
|
||||
occultationPathDistanceKM(test.second[index-1], test.first[index]),
|
||||
)
|
||||
if direct > 2000 && swapped < 750 {
|
||||
t.Errorf("%s boundary sample %d keeps a %.1f km direct pairing although the swapped pairing is %.1f km",
|
||||
test.name, index, direct, swapped)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultation20250105CompactBandRefinesVisibilityTransitions(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
start := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone)
|
||||
paths, err := FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), OccultationSaturn,
|
||||
OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
||||
DisableRiseSet: true, DisableFootprints: true,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
footprints []PlanetOccultationFootprint
|
||||
}{
|
||||
{name: "partial", footprints: paths[0].PartialBandFootprints},
|
||||
{name: "total", footprints: paths[0].TotalBandFootprints},
|
||||
} {
|
||||
transitions := 0
|
||||
for index := 1; index < len(test.footprints); index++ {
|
||||
previous, current := test.footprints[index-1], test.footprints[index]
|
||||
if previous.Closed == current.Closed {
|
||||
continue
|
||||
}
|
||||
transitions++
|
||||
if gap := current.Time.Sub(previous.Time); gap > 150*time.Millisecond {
|
||||
t.Errorf("%s visibility transition %d spans %s, want at most 150ms", test.name, transitions, gap)
|
||||
}
|
||||
open := previous
|
||||
if open.Closed {
|
||||
open = current
|
||||
}
|
||||
if len(open.Boundaries) != 1 || len(open.Boundaries[0]) < 2 {
|
||||
t.Fatalf("%s visibility transition %d has no open boundary", test.name, transitions)
|
||||
}
|
||||
// Boundaries contain the visible contact arc, not a closed ring. Its
|
||||
// endpoints may remain far apart at the geocentric open/closed
|
||||
// transition because station parallax changes the topology. The physical
|
||||
// contract is that both endpoints lie on the lunar horizon and the
|
||||
// separately exported polygon closes them with the horizon arc.
|
||||
boundary := open.Boundaries[0]
|
||||
for _, endpoint := range []OccultationPathPoint{boundary[0], boundary[len(boundary)-1]} {
|
||||
if math.Abs(endpoint.MoonAltitude) > 1e-5 {
|
||||
t.Errorf("%s visibility transition %d endpoint altitude=%g deg, want horizon root",
|
||||
test.name, transitions, endpoint.MoonAltitude)
|
||||
}
|
||||
}
|
||||
if len(open.Polygons) == 0 || len(open.Polygons[0]) < len(boundary)+2 {
|
||||
t.Errorf("%s visibility transition %d has no horizon-closed polygon", test.name, transitions)
|
||||
}
|
||||
}
|
||||
if transitions != 2 {
|
||||
t.Errorf("%s visibility transition count=%d, want 2", test.name, transitions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationFiniteDiskExpandsOuterAndContractsTotalPath(t *testing.T) {
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
@@ -340,3 +686,40 @@ func assertOccultationPathCommonSamplesEqual(t *testing.T, name string, fine, co
|
||||
t.Fatalf("%s compared only %d common samples, want at least 10", name, matched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultation20250114MarsBandContourTimesIncrease(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
start := time.Date(2025, time.January, 14, 0, 0, 0, 0, zone)
|
||||
paths, err := FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), OccultationMars,
|
||||
OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
||||
DisableFootprints: true, IncludeFootprintTimeline: true,
|
||||
FootprintTimelineStep: 5 * time.Minute, RiseSetStep: time.Minute,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
for _, band := range []struct {
|
||||
name string
|
||||
contours [][]OccultationPathPoint
|
||||
}{
|
||||
{name: "partial", contours: paths[0].PartialBandContours},
|
||||
{name: "total", contours: paths[0].TotalBandContours},
|
||||
} {
|
||||
for contourIndex, contour := range band.contours {
|
||||
for pointIndex := 1; pointIndex < len(contour); pointIndex++ {
|
||||
previous, current := contour[pointIndex-1], contour[pointIndex]
|
||||
if !current.Time.After(previous.Time) {
|
||||
t.Fatalf(
|
||||
"%s contour %d times do not increase at %d: %s then %s (delta=%s, distance=%.6f km)",
|
||||
band.name, contourIndex, pointIndex,
|
||||
previous.Time.Format(time.RFC3339Nano), current.Time.Format(time.RFC3339Nano),
|
||||
current.Time.Sub(previous.Time), occultationPathDistanceKM(previous, current),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMars20250729FiniteContactContoursRespectWebMercatorSpacing(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
|
||||
paths, err := FindPlanetOccultationPaths(start, start.Add(24*time.Hour), OccultationMars, OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
|
||||
DisableFootprints: true, IncludeFootprintTimeline: true, FootprintTimelineStep: 5 * time.Minute,
|
||||
})
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
contours [][]OccultationPathPoint
|
||||
}{
|
||||
{name: "partial", contours: paths[0].PartialBandContours},
|
||||
{name: "total", contours: paths[0].TotalBandContours},
|
||||
} {
|
||||
for contourIndex, contour := range item.contours {
|
||||
for pointIndex := 1; pointIndex < len(contour); pointIndex++ {
|
||||
spacing := webMercatorGeoPointSpacingKM(contour[pointIndex-1], contour[pointIndex])
|
||||
if spacing > 45 {
|
||||
t.Fatalf("%s contour %d edge %d has %.1f km Web Mercator spacing, want <=45 km", item.name, contourIndex, pointIndex, spacing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webMercatorGeoPointSpacingKM(first, second OccultationPathPoint) float64 {
|
||||
const maxLatitude = 85.05112878
|
||||
const radiusKM = 6378.1366
|
||||
clamp := func(value float64) float64 { return math.Max(-maxLatitude, math.Min(maxLatitude, value)) }
|
||||
longitude := math.Remainder(second.Longitude-first.Longitude, 360) * math.Pi / 180
|
||||
firstLatitude := clamp(first.Latitude) * math.Pi / 180
|
||||
secondLatitude := clamp(second.Latitude) * math.Pi / 180
|
||||
firstY := math.Log(math.Tan(math.Pi/4 + firstLatitude/2))
|
||||
secondY := math.Log(math.Tan(math.Pi/4 + secondLatitude/2))
|
||||
return radiusKM * math.Hypot(longitude, secondY-firstY)
|
||||
}
|
||||
@@ -0,0 +1,931 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
occultationRiseSetBoundaryPoints = 180
|
||||
occultationRiseSetDerivativeStepDays = 5.0 / 86400.0
|
||||
// A polar phase fold can create or remove a root branch inside one coarse
|
||||
// rise/set interval. Refine only those topology-changing intervals rather
|
||||
// than lowering the global sampling step for every event.
|
||||
occultationRiseSetAdaptiveMaximumPasses = 1
|
||||
occultationRiseSetAdaptiveMinimumStepDays = 5.0 / 86400.0
|
||||
occultationRiseSetAdaptiveMaximumIntervals = 64
|
||||
occultationRiseSetFoldRootToleranceDays = 1e-10
|
||||
occultationRiseSetJunctionDerivativeTolerance = 1e-6
|
||||
// Beyond this point, recomputing the small vector state costs less than growing every per-TT map.
|
||||
occultationRiseSetStateCacheMaximumEntries = 8
|
||||
// 48 h 窗口按 1 分钟采样需要 2880 个时刻、每个时刻三个上下文(中心/前/后),
|
||||
// 4096 个槽位会在事件中途反复整表清空并重算精确星历。
|
||||
// A 48 h window sampled every minute needs 2880 instants with three contexts each
|
||||
// (center/before/after); 4096 slots cleared the whole table repeatedly mid-event and
|
||||
// recomputed the exact ephemerides it had just dropped.
|
||||
occultationRiseSetEvaluationCacheMaximumEntries = 16384
|
||||
occultationRiseSetTimeEpsilonDays = 1e-8
|
||||
occultationRiseSetTargetSpacingKM = 100.0
|
||||
occultationTopocentricEarthRadiusKM = 6378.14
|
||||
occultationTopocentricEarthPolarRadiusKM = 6356.755
|
||||
)
|
||||
|
||||
// 站心赤经赤纬的历史公式使用固定地平视差常数;保留该尺度可使向量算法与既有结果数值等价。
|
||||
// The legacy topocentric RA/Dec formula uses a fixed horizontal-parallax constant; retaining that scale keeps the vector implementation numerically equivalent.
|
||||
var occultationLegacyParallaxRadiusKM = math.Sin(0.0024427777777*rad) * occultationPathAstronomicalUnitKM
|
||||
|
||||
type occultationRiseSetBody struct {
|
||||
direction occultationPathVector
|
||||
positionKM occultationPathVector
|
||||
distanceKM float64
|
||||
}
|
||||
|
||||
type occultationRiseSetContext struct {
|
||||
tt float64
|
||||
siderealDegrees float64
|
||||
moonRA float64
|
||||
moonDec float64
|
||||
moon occultationRiseSetBody
|
||||
target occultationRiseSetBody
|
||||
targetRadiusKM float64
|
||||
internalContact bool
|
||||
valid bool
|
||||
states map[occultationRiseSetStateKey]occultationRiseSetState
|
||||
}
|
||||
|
||||
type occultationRiseSetStateKey struct {
|
||||
longitude uint64
|
||||
latitude uint64
|
||||
}
|
||||
|
||||
type occultationRiseSetState struct {
|
||||
// contactMetric is selected for the context that reads the cached state.
|
||||
// The two underlying metrics are retained so external and internal contact
|
||||
// contexts can share the expensive topocentric geometry without sharing the
|
||||
// wrong contact equation.
|
||||
contactMetric float64
|
||||
externalContactMetric float64
|
||||
internalContactMetric float64
|
||||
separationSquared float64
|
||||
moonAltitude float64
|
||||
valid bool
|
||||
}
|
||||
|
||||
type occultationRiseSetEvaluation struct {
|
||||
tt float64
|
||||
center occultationRiseSetContext
|
||||
before occultationRiseSetContext
|
||||
after occultationRiseSetContext
|
||||
}
|
||||
|
||||
type occultationRiseSetCurveKey struct {
|
||||
phase RiseSetPhase
|
||||
direction RiseSetDirection
|
||||
}
|
||||
|
||||
type occultationRiseSetTrack struct {
|
||||
segments [][]OccultationPathPoint
|
||||
}
|
||||
|
||||
type occultationRiseSetContextFunc func(float64) occultationRiseSetContext
|
||||
|
||||
// occultationRiseSetEvaluationCache keeps ephemeris contexts and their
|
||||
// center/before/after derivative bundle at the event level. Root refinements
|
||||
// revisit the same TT values many times, especially while joining branches.
|
||||
type occultationRiseSetEvaluationCache struct {
|
||||
contextAt occultationRiseSetContextFunc
|
||||
candidateContextAt occultationRiseSetContextFunc
|
||||
contexts map[uint64]occultationRiseSetContext
|
||||
candidateContexts map[uint64]occultationRiseSetContext
|
||||
evaluations map[uint64]occultationRiseSetEvaluation
|
||||
candidateEvaluations map[uint64]occultationRiseSetEvaluation
|
||||
}
|
||||
|
||||
func newOccultationRiseSetEvaluationCache(contextAt occultationRiseSetContextFunc) *occultationRiseSetEvaluationCache {
|
||||
return newOccultationRiseSetEvaluationCacheWithCandidate(contextAt, nil)
|
||||
}
|
||||
|
||||
func newOccultationRiseSetEvaluationCacheWithCandidate(
|
||||
contextAt, candidateContextAt occultationRiseSetContextFunc,
|
||||
) *occultationRiseSetEvaluationCache {
|
||||
return &occultationRiseSetEvaluationCache{
|
||||
contextAt: contextAt,
|
||||
candidateContextAt: candidateContextAt,
|
||||
contexts: make(map[uint64]occultationRiseSetContext),
|
||||
candidateContexts: make(map[uint64]occultationRiseSetContext),
|
||||
evaluations: make(map[uint64]occultationRiseSetEvaluation),
|
||||
candidateEvaluations: make(map[uint64]occultationRiseSetEvaluation),
|
||||
}
|
||||
}
|
||||
|
||||
func (cache *occultationRiseSetEvaluationCache) context(tt float64) occultationRiseSetContext {
|
||||
key := math.Float64bits(tt)
|
||||
if context, ok := cache.contexts[key]; ok {
|
||||
return context
|
||||
}
|
||||
context := cache.contextAt(tt)
|
||||
if len(cache.contexts) >= occultationRiseSetEvaluationCacheMaximumEntries {
|
||||
clearOccultationRiseSetContexts(cache.contexts)
|
||||
clearOccultationRiseSetEvaluations(cache.evaluations)
|
||||
}
|
||||
cache.contexts[key] = context
|
||||
return context
|
||||
}
|
||||
|
||||
func (cache *occultationRiseSetEvaluationCache) candidateContext(tt float64) occultationRiseSetContext {
|
||||
if cache.candidateContextAt == nil {
|
||||
return cache.context(tt)
|
||||
}
|
||||
key := math.Float64bits(tt)
|
||||
if context, ok := cache.candidateContexts[key]; ok {
|
||||
return context
|
||||
}
|
||||
context := cache.candidateContextAt(tt)
|
||||
if len(cache.candidateContexts) >= occultationRiseSetEvaluationCacheMaximumEntries {
|
||||
clearOccultationRiseSetContexts(cache.candidateContexts)
|
||||
clearOccultationRiseSetEvaluations(cache.candidateEvaluations)
|
||||
}
|
||||
cache.candidateContexts[key] = context
|
||||
return context
|
||||
}
|
||||
|
||||
func (cache *occultationRiseSetEvaluationCache) evaluation(tt float64) occultationRiseSetEvaluation {
|
||||
return cache.evaluationAt(tt, false)
|
||||
}
|
||||
|
||||
func (cache *occultationRiseSetEvaluationCache) candidateEvaluation(tt float64) occultationRiseSetEvaluation {
|
||||
return cache.evaluationAt(tt, true)
|
||||
}
|
||||
|
||||
func (cache *occultationRiseSetEvaluationCache) candidateOnly() *occultationRiseSetEvaluationCache {
|
||||
if cache == nil || cache.candidateContextAt == nil {
|
||||
return cache
|
||||
}
|
||||
return &occultationRiseSetEvaluationCache{
|
||||
contextAt: cache.candidateContextAt,
|
||||
contexts: cache.candidateContexts,
|
||||
evaluations: cache.candidateEvaluations,
|
||||
}
|
||||
}
|
||||
|
||||
func (cache *occultationRiseSetEvaluationCache) evaluationAt(tt float64, candidate bool) occultationRiseSetEvaluation {
|
||||
key := math.Float64bits(tt)
|
||||
evaluations := cache.evaluations
|
||||
context := cache.context
|
||||
if candidate && cache.candidateContextAt != nil {
|
||||
evaluations = cache.candidateEvaluations
|
||||
context = cache.candidateContext
|
||||
}
|
||||
if evaluation, ok := evaluations[key]; ok {
|
||||
return evaluation
|
||||
}
|
||||
evaluation := occultationRiseSetEvaluation{
|
||||
tt: tt,
|
||||
center: context(tt),
|
||||
before: context(tt - occultationRiseSetDerivativeStepDays),
|
||||
after: context(tt + occultationRiseSetDerivativeStepDays),
|
||||
}
|
||||
if len(evaluations) >= occultationRiseSetEvaluationCacheMaximumEntries {
|
||||
clearOccultationRiseSetEvaluations(evaluations)
|
||||
if candidate {
|
||||
clearOccultationRiseSetContexts(cache.candidateContexts)
|
||||
} else {
|
||||
clearOccultationRiseSetContexts(cache.contexts)
|
||||
}
|
||||
}
|
||||
evaluations[key] = evaluation
|
||||
return evaluation
|
||||
}
|
||||
|
||||
func clearOccultationRiseSetContexts(values map[uint64]occultationRiseSetContext) {
|
||||
for key := range values {
|
||||
delete(values, key)
|
||||
}
|
||||
}
|
||||
|
||||
func clearOccultationRiseSetEvaluations(values map[uint64]occultationRiseSetEvaluation) {
|
||||
for key := range values {
|
||||
delete(values, key)
|
||||
}
|
||||
}
|
||||
|
||||
func newOccultationRiseSetContext(
|
||||
tt, moonRA, moonDec, moonDistanceKM,
|
||||
targetRA, targetDec, targetDistanceKM, targetRadiusKM float64,
|
||||
) occultationRiseSetContext {
|
||||
moon := newOccultationRiseSetBody(moonRA, moonDec, moonDistanceKM)
|
||||
target := newOccultationRiseSetBody(targetRA, targetDec, targetDistanceKM)
|
||||
return occultationRiseSetContext{
|
||||
tt: tt,
|
||||
siderealDegrees: ApparentSiderealTime(TD2UT(tt, false)) * 15,
|
||||
moonRA: moonRA,
|
||||
moonDec: moonDec,
|
||||
moon: moon,
|
||||
target: target,
|
||||
targetRadiusKM: targetRadiusKM,
|
||||
valid: finite(moonRA) && finite(moonDec) && finite(moonDistanceKM) && moonDistanceKM > 0 &&
|
||||
finite(targetRA) && finite(targetDec) && finite(targetDistanceKM) && targetDistanceKM >= 0 &&
|
||||
finite(targetRadiusKM) && targetRadiusKM >= 0,
|
||||
states: make(map[occultationRiseSetStateKey]occultationRiseSetState),
|
||||
}
|
||||
}
|
||||
|
||||
func (context occultationRiseSetContext) withInternalContact() occultationRiseSetContext {
|
||||
context.internalContact = true
|
||||
return context
|
||||
}
|
||||
|
||||
func newOccultationRiseSetContextFromVectors(
|
||||
tt float64,
|
||||
moonXYZ, targetXYZ [3]float64,
|
||||
targetAtFiniteDistance bool,
|
||||
targetRadiusKM float64,
|
||||
) occultationRiseSetContext {
|
||||
moon, moonRA, moonDec, moonOK := occultationRiseSetBodyFromVector(moonXYZ, true)
|
||||
target, _, _, targetOK := occultationRiseSetBodyFromVector(targetXYZ, targetAtFiniteDistance)
|
||||
return occultationRiseSetContext{
|
||||
tt: tt,
|
||||
siderealDegrees: ApparentSiderealTime(TD2UT(tt, false)) * 15,
|
||||
moonRA: moonRA,
|
||||
moonDec: moonDec,
|
||||
moon: moon,
|
||||
target: target,
|
||||
targetRadiusKM: targetRadiusKM,
|
||||
valid: moonOK && targetOK && finite(targetRadiusKM) && targetRadiusKM >= 0,
|
||||
states: make(map[occultationRiseSetStateKey]occultationRiseSetState),
|
||||
}
|
||||
}
|
||||
|
||||
func occultationRiseSetBodyFromVector(
|
||||
xyz [3]float64,
|
||||
atFiniteDistance bool,
|
||||
) (occultationRiseSetBody, float64, float64, bool) {
|
||||
vector := occultationPathVector{x: xyz[0], y: xyz[1], z: xyz[2]}
|
||||
distanceKM := occultationPathNorm(vector)
|
||||
if !finite(distanceKM) || distanceKM <= 0 {
|
||||
return occultationRiseSetBody{}, 0, 0, false
|
||||
}
|
||||
direction := occultationPathScale(vector, 1/distanceKM)
|
||||
ra := normalizeRA(math.Atan2(direction.y, direction.x) / rad)
|
||||
dec := math.Asin(math.Max(-1, math.Min(1, direction.z))) / rad
|
||||
body := occultationRiseSetBody{direction: direction}
|
||||
if atFiniteDistance {
|
||||
body.positionKM = vector
|
||||
body.distanceKM = distanceKM
|
||||
}
|
||||
return body, ra, dec, finite(ra) && finite(dec)
|
||||
}
|
||||
|
||||
func newOccultationRiseSetBody(ra, dec, distanceKM float64) occultationRiseSetBody {
|
||||
direction := occultationPathRaDecVector(ra, dec, 1)
|
||||
body := occultationRiseSetBody{direction: direction, distanceKM: distanceKM}
|
||||
if distanceKM > 0 {
|
||||
body.positionKM = occultationPathScale(direction, distanceKM)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (context occultationRiseSetContext) stateAt(longitude, latitude float64) occultationRiseSetState {
|
||||
key := occultationRiseSetStateKey{math.Float64bits(longitude), math.Float64bits(latitude)}
|
||||
if state, ok := context.states[key]; ok {
|
||||
return state.withContactMetric(context.internalContact)
|
||||
}
|
||||
if !context.valid {
|
||||
return context.storeState(key, occultationRiseSetState{})
|
||||
}
|
||||
observerParallaxKM, observerDistanceKM, zenith := occultationRiseSetObserverVectors(
|
||||
context.siderealDegrees, longitude, latitude,
|
||||
)
|
||||
moonDirection := occultationRiseSetTopocentricDirection(context.moon, observerParallaxKM)
|
||||
targetDirection := occultationRiseSetTopocentricDirection(context.target, observerParallaxKM)
|
||||
moonDistanceKM := occultationRiseSetTopocentricDistance(context.moon, observerDistanceKM)
|
||||
if !finite(moonDistanceKM) || moonDistanceKM <= moonEquatorialRadiusKM {
|
||||
return context.storeState(key, occultationRiseSetState{})
|
||||
}
|
||||
moonRadius := angularSemidiameterArcsec(moonEquatorialRadiusKM, moonDistanceKM) / 3600
|
||||
targetRadius := 0.0
|
||||
if context.targetRadiusKM > 0 {
|
||||
targetDistanceKM := occultationRiseSetTopocentricDistance(context.target, observerDistanceKM)
|
||||
if !finite(targetDistanceKM) || targetDistanceKM <= context.targetRadiusKM {
|
||||
return context.storeState(key, occultationRiseSetState{})
|
||||
}
|
||||
targetRadius = angularSemidiameterArcsec(context.targetRadiusKM, targetDistanceKM) / 3600
|
||||
}
|
||||
cosSeparation := math.Max(-1, math.Min(1, occultationPathDot(moonDirection, targetDirection)))
|
||||
separationRad := math.Acos(cosSeparation)
|
||||
separation := separationRad / rad
|
||||
moonAltitude := math.Asin(math.Max(-1, math.Min(1, occultationPathDot(moonDirection, zenith)))) / rad
|
||||
contactState := movingDiskContactState{
|
||||
separation: separation,
|
||||
occultingOuterRadius: moonRadius,
|
||||
occultingInnerRadius: moonRadius,
|
||||
targetRadius: targetRadius,
|
||||
valid: movingDiskContactStateValid(
|
||||
separation, moonRadius, moonRadius, targetRadius,
|
||||
),
|
||||
}
|
||||
externalContactMetric := contactState.externalContactGap()
|
||||
internalContactMetric := contactState.internalContactGap()
|
||||
return context.storeState(key, occultationRiseSetState{
|
||||
externalContactMetric: externalContactMetric,
|
||||
internalContactMetric: internalContactMetric,
|
||||
separationSquared: 2 - 2*cosSeparation,
|
||||
moonAltitude: moonAltitude,
|
||||
valid: contactState.valid && finite(moonAltitude),
|
||||
})
|
||||
}
|
||||
|
||||
// storeState 记住一个站点状态;表满时整表清空后复用,避免为每个解分配新 map。
|
||||
func (context occultationRiseSetContext) storeState(
|
||||
key occultationRiseSetStateKey,
|
||||
state occultationRiseSetState,
|
||||
) occultationRiseSetState {
|
||||
if context.states != nil {
|
||||
// Newton steps reuse a few nearby stations, not the full history.
|
||||
// Keep the small map allocation and recycle it as the solve moves.
|
||||
if len(context.states) >= occultationRiseSetStateCacheMaximumEntries {
|
||||
for cachedKey := range context.states {
|
||||
delete(context.states, cachedKey)
|
||||
}
|
||||
}
|
||||
context.states[key] = state
|
||||
}
|
||||
return state.withContactMetric(context.internalContact)
|
||||
}
|
||||
|
||||
func (state occultationRiseSetState) withContactMetric(internal bool) occultationRiseSetState {
|
||||
if internal {
|
||||
state.contactMetric = state.internalContactMetric
|
||||
} else {
|
||||
state.contactMetric = state.externalContactMetric
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func (context occultationRiseSetContext) moonHorizonResidual(longitude, latitude float64) (float64, bool) {
|
||||
if !context.valid || context.moon.distanceKM <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
latitudeRad := latitude * rad
|
||||
theta := (context.siderealDegrees + longitude) * rad
|
||||
sinLatitude, cosLatitude := math.Sincos(latitudeRad)
|
||||
sinTheta, cosTheta := math.Sincos(theta)
|
||||
moonDotZenith := cosLatitude*(context.moon.positionKM.x*cosTheta+context.moon.positionKM.y*sinTheta) +
|
||||
context.moon.positionKM.z*sinLatitude
|
||||
polarRatio := occultationTopocentricEarthPolarRadiusKM / occultationTopocentricEarthRadiusKM
|
||||
shapeDotZenith := math.Sqrt(cosLatitude*cosLatitude + polarRatio*polarRatio*sinLatitude*sinLatitude)
|
||||
residual := moonDotZenith - occultationLegacyParallaxRadiusKM*shapeDotZenith
|
||||
return residual, finite(residual)
|
||||
}
|
||||
|
||||
func occultationRiseSetObserverVectors(
|
||||
siderealDegrees, longitude, latitude float64,
|
||||
) (occultationPathVector, occultationPathVector, occultationPathVector) {
|
||||
latitudeRad := latitude * rad
|
||||
theta := (siderealDegrees + longitude) * rad
|
||||
sinLatitude, cosLatitude := math.Sincos(latitudeRad)
|
||||
sinTheta, cosTheta := math.Sincos(theta)
|
||||
polarRatio := occultationTopocentricEarthPolarRadiusKM / occultationTopocentricEarthRadiusKM
|
||||
u := math.Atan(polarRatio * math.Tan(latitudeRad))
|
||||
sinU, cosU := math.Sincos(u)
|
||||
shape := occultationPathVector{
|
||||
x: cosU * cosTheta,
|
||||
y: cosU * sinTheta,
|
||||
z: polarRatio * sinU,
|
||||
}
|
||||
zenith := occultationPathVector{x: cosLatitude * cosTheta, y: cosLatitude * sinTheta, z: sinLatitude}
|
||||
return occultationPathScale(shape, occultationLegacyParallaxRadiusKM),
|
||||
occultationPathScale(shape, occultationTopocentricEarthRadiusKM), zenith
|
||||
}
|
||||
|
||||
func occultationRiseSetTopocentricDirection(
|
||||
body occultationRiseSetBody,
|
||||
observerKM occultationPathVector,
|
||||
) occultationPathVector {
|
||||
if body.distanceKM <= 0 {
|
||||
return body.direction
|
||||
}
|
||||
return occultationPathUnit(occultationPathSub(body.positionKM, observerKM))
|
||||
}
|
||||
|
||||
func occultationRiseSetTopocentricDistance(
|
||||
body occultationRiseSetBody,
|
||||
observerKM occultationPathVector,
|
||||
) float64 {
|
||||
if body.distanceKM <= 0 {
|
||||
return math.Inf(1)
|
||||
}
|
||||
return occultationPathNorm(occultationPathSub(body.positionKM, observerKM))
|
||||
}
|
||||
|
||||
func occultationRiseSetCurves(
|
||||
startTT, endTT, greatestTT float64,
|
||||
options OccultationPathOptions,
|
||||
location *time.Location,
|
||||
contextAt occultationRiseSetContextFunc,
|
||||
) []OccultationRiseSetCurve {
|
||||
cache := newOccultationRiseSetEvaluationCache(contextAt)
|
||||
return occultationRiseSetCurvesWithCache(startTT, endTT, greatestTT, options, location, cache)
|
||||
}
|
||||
|
||||
func occultationRiseSetCurvesWithCache(
|
||||
startTT, endTT, greatestTT float64,
|
||||
options OccultationPathOptions,
|
||||
location *time.Location,
|
||||
cache *occultationRiseSetEvaluationCache,
|
||||
) []OccultationRiseSetCurve {
|
||||
curves, _, _ := occultationRiseSetCurvesWithRecoveryReport(
|
||||
startTT, endTT, greatestTT, options, location, cache,
|
||||
)
|
||||
return curves
|
||||
}
|
||||
|
||||
// occultationRiseSetCurvesWithRecoveryReport 额外返回折点补根重建前的相位图和重建候选,
|
||||
// 便于调用方核验“窗口内部未成对端点”判据;正常路径只使用第一个返回值。
|
||||
// occultationRiseSetCurvesWithRecoveryReport also returns the phase graph before fold
|
||||
// recovery and the rebuilt candidate, so callers can verify the interior-endpoint test;
|
||||
// the normal path uses the first result only.
|
||||
func occultationRiseSetCurvesWithRecoveryReport(
|
||||
startTT, endTT, greatestTT float64,
|
||||
options OccultationPathOptions,
|
||||
location *time.Location,
|
||||
cache *occultationRiseSetEvaluationCache,
|
||||
) ([]OccultationRiseSetCurve, []OccultationRiseSetCurve, []OccultationRiseSetCurve) {
|
||||
if options.DisableRiseSet || startTT == 0 || endTT == 0 || endTT <= startTT {
|
||||
return nil, nil, nil
|
||||
}
|
||||
if cache == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
step := options.RiseSetStep
|
||||
if step <= 0 {
|
||||
step = 5 * time.Minute
|
||||
}
|
||||
stepDays := float64(step) / float64(24*time.Hour)
|
||||
times := occultationPathSampleTimes(startTT, endTT, greatestTT, stepDays)
|
||||
keys := []occultationRiseSetCurveKey{
|
||||
{RiseSetPhaseStart, RiseSetDirectionRise},
|
||||
{RiseSetPhaseStart, RiseSetDirectionSet},
|
||||
{RiseSetPhaseGreatest, RiseSetDirectionRise},
|
||||
{RiseSetPhaseGreatest, RiseSetDirectionSet},
|
||||
{RiseSetPhaseEnd, RiseSetDirectionRise},
|
||||
{RiseSetPhaseEnd, RiseSetDirectionSet},
|
||||
}
|
||||
// The ordinary grid is intentionally retained for performance. Near a
|
||||
// grazing phase junction a root branch may exist for less than one grid
|
||||
// interval, so evaluate only intervals whose root topology changes and
|
||||
// bisect those locally. This recovers the missing branch without doubling
|
||||
// the cost of every rise/set curve.
|
||||
samples := make([]occultationRiseSetSampledPoints, 0, len(times))
|
||||
evaluate := func(tt float64, foldRecovery bool) occultationRiseSetSampledPoints {
|
||||
return occultationRiseSetSampledPoints{
|
||||
tt: tt,
|
||||
points: cache.candidateEvaluation(tt).pointsAt(
|
||||
occultationRiseSetBoundaryPoints, location, foldRecovery,
|
||||
),
|
||||
}
|
||||
}
|
||||
pointSource := cache.candidateEvaluation(startTT).center.targetRadiusKM <= 0
|
||||
for _, tt := range times {
|
||||
samples = append(samples, evaluate(tt, false))
|
||||
}
|
||||
buildCurves := func(samples []occultationRiseSetSampledPoints) []OccultationRiseSetCurve {
|
||||
tracks := make(map[occultationRiseSetCurveKey][]*occultationRiseSetTrack, len(keys))
|
||||
curves := make([]OccultationRiseSetCurve, 0, len(keys))
|
||||
for _, sample := range samples {
|
||||
for _, key := range keys {
|
||||
if sample.adaptive && key.phase != RiseSetPhaseGreatest {
|
||||
continue
|
||||
}
|
||||
tracks[key] = appendOccultationRiseSetSamples(tracks[key], sample.points[key], stepDays)
|
||||
}
|
||||
}
|
||||
for _, key := range keys {
|
||||
segments := make([][]OccultationPathPoint, 0, len(tracks[key]))
|
||||
for _, track := range tracks[key] {
|
||||
for _, segment := range track.segments {
|
||||
if len(segment) >= 1 {
|
||||
segments = append(segments, segment)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(segments) > 0 {
|
||||
curves = append(curves, OccultationRiseSetCurve{
|
||||
Phase: key.phase, Direction: key.direction, Segments: segments,
|
||||
})
|
||||
}
|
||||
}
|
||||
completeOccultationRiseSetCurveEndpoints(curves, stepDays, location, cache)
|
||||
return curves
|
||||
}
|
||||
for pass := 0; pointSource && pass < occultationRiseSetAdaptiveMaximumPasses; pass++ {
|
||||
if len(samples) < 2 {
|
||||
break
|
||||
}
|
||||
intervals := make([]int, 0, occultationRiseSetAdaptiveMaximumIntervals)
|
||||
for index := 0; index+1 < len(samples) && len(intervals) < occultationRiseSetAdaptiveMaximumIntervals; index++ {
|
||||
left, right := samples[index], samples[index+1]
|
||||
if right.tt <= left.tt || right.tt-left.tt <= occultationRiseSetAdaptiveMinimumStepDays {
|
||||
continue
|
||||
}
|
||||
if !occultationRiseSetSamplesChangeTopology(left.points, right.points, keys) {
|
||||
continue
|
||||
}
|
||||
// Refine only when the interval hides an additional branch at its
|
||||
// interior. A simple 0->1 appearance is already represented by the
|
||||
// endpoint sample; refining every disappearance interval can alter
|
||||
// branch assignment for polar events whose midpoint has no root.
|
||||
midpoint := evaluate((left.tt+right.tt)/2, false)
|
||||
hiddenBranch := false
|
||||
for _, key := range keys {
|
||||
if len(midpoint.points[key]) > len(left.points[key]) &&
|
||||
len(midpoint.points[key]) > len(right.points[key]) {
|
||||
hiddenBranch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hiddenBranch {
|
||||
intervals = append(intervals, index)
|
||||
}
|
||||
}
|
||||
if len(intervals) == 0 {
|
||||
break
|
||||
}
|
||||
refined := make([]occultationRiseSetSampledPoints, 0, len(samples)+len(intervals))
|
||||
intervalSet := make(map[int]bool, len(intervals))
|
||||
for _, index := range intervals {
|
||||
intervalSet[index] = true
|
||||
}
|
||||
for index, sample := range samples {
|
||||
refined = append(refined, sample)
|
||||
if !intervalSet[index] || index+1 >= len(samples) {
|
||||
continue
|
||||
}
|
||||
midpoint := evaluate((sample.tt+samples[index+1].tt)/2, false)
|
||||
midpoint.adaptive = true
|
||||
refined = append(refined, midpoint)
|
||||
}
|
||||
samples = refined
|
||||
}
|
||||
firstTT, lastTT := times[0], times[len(times)-1]
|
||||
curves := buildCurves(samples)
|
||||
baseCurves := curves
|
||||
recoveredCurves := occultationRiseSetFoldRecoveryCandidate(samples, buildCurves, evaluate, curves, firstTT, lastTT)
|
||||
if recoveredCurves != nil &&
|
||||
occultationRiseSetUnclosedEndpointCount(recoveredCurves, true, firstTT, lastTT) <
|
||||
occultationRiseSetUnclosedEndpointCount(baseCurves, true, firstTT, lastTT) {
|
||||
return recoveredCurves, baseCurves, recoveredCurves
|
||||
}
|
||||
return baseCurves, baseCurves, recoveredCurves
|
||||
}
|
||||
|
||||
// occultationRiseSetFoldRecoveryCandidate 在窗口内部仍有未成对端点时用折点补根重建相位图。
|
||||
// 没有窗口内部未成对端点时直接返回 nil:查询窗口边界上的裁剪端点没有任何折点可以配对,
|
||||
// 为它们重跑整条流水线只会被丢弃。
|
||||
// occultationRiseSetFoldRecoveryCandidate rebuilds the phase graph with fold roots while
|
||||
// interior endpoints are still unpaired, and returns nil when none is: an endpoint clipped
|
||||
// by the query window has no fold to pair with, so rebuilding for it is discarded work.
|
||||
func occultationRiseSetFoldRecoveryCandidate(
|
||||
samples []occultationRiseSetSampledPoints,
|
||||
buildCurves func([]occultationRiseSetSampledPoints) []OccultationRiseSetCurve,
|
||||
evaluate func(float64, bool) occultationRiseSetSampledPoints,
|
||||
base []OccultationRiseSetCurve,
|
||||
firstTT, lastTT float64,
|
||||
) []OccultationRiseSetCurve {
|
||||
if occultationRiseSetUnclosedEndpointCount(base, true, firstTT, lastTT) == 0 {
|
||||
return nil
|
||||
}
|
||||
// A polar tangent fold dropped by the sign scan leaves the two branches of a
|
||||
// phase unjoined. Events that already close keep their existing sampling and
|
||||
// endpoints byte-identical.
|
||||
recovered := make([]occultationRiseSetSampledPoints, len(samples))
|
||||
for index, sample := range samples {
|
||||
recovered[index] = evaluate(sample.tt, true)
|
||||
recovered[index].adaptive = sample.adaptive
|
||||
}
|
||||
return buildCurves(recovered)
|
||||
}
|
||||
|
||||
type occultationRiseSetSampledPoints struct {
|
||||
tt float64
|
||||
adaptive bool
|
||||
points map[occultationRiseSetCurveKey][]OccultationPathPoint
|
||||
}
|
||||
|
||||
func occultationRiseSetSamplesChangeTopology(
|
||||
left, right map[occultationRiseSetCurveKey][]OccultationPathPoint,
|
||||
keys []occultationRiseSetCurveKey,
|
||||
) bool {
|
||||
for _, key := range keys {
|
||||
leftCount := len(left[key])
|
||||
rightCount := len(right[key])
|
||||
if leftCount != rightCount {
|
||||
return leftCount > 0 || rightCount > 0
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (evaluation occultationRiseSetEvaluation) pointsAt(
|
||||
boundaryPoints int,
|
||||
location *time.Location,
|
||||
foldRecovery bool,
|
||||
) map[occultationRiseSetCurveKey][]OccultationPathPoint {
|
||||
result := make(map[occultationRiseSetCurveKey][]OccultationPathPoint, 6)
|
||||
if !evaluation.center.valid || !evaluation.before.valid || !evaluation.after.valid {
|
||||
return result
|
||||
}
|
||||
gst := ApparentSiderealTime(TD2UT(evaluation.tt, false)) * 15
|
||||
centerLongitude := normalizeLongitude(evaluation.center.moonRA - gst)
|
||||
centerLatitude := evaluation.center.moonDec
|
||||
appendRoots := func(greatest bool) {
|
||||
// Keep the historical geographic refinement for ordinary latitudes; it
|
||||
// is both cheaper and preserves the established endpoint network. A
|
||||
// polar root needs the station-corrected horizon parameterization because
|
||||
// its geographic Jacobian becomes singular and can switch siblings.
|
||||
valueAt := func(angle float64) (float64, bool) {
|
||||
longitude, latitude := riseSetHorizonPoint(centerLongitude, centerLatitude, angle)
|
||||
state := evaluation.center.stateAt(longitude, latitude)
|
||||
if !state.valid {
|
||||
return 0, false
|
||||
}
|
||||
if greatest {
|
||||
return evaluation.separationDerivative(longitude, latitude), true
|
||||
}
|
||||
return state.contactMetric, true
|
||||
}
|
||||
foldTolerance := 0.0
|
||||
if foldRecovery && !greatest {
|
||||
foldTolerance = riseSetFoldRootResidualToleranceDeg
|
||||
}
|
||||
roots := riseSetCyclicRootsWithFoldTolerance(boundaryPoints, foldTolerance, valueAt)
|
||||
polar := false
|
||||
for _, angle := range roots {
|
||||
_, latitude := riseSetHorizonPoint(centerLongitude, centerLatitude, angle)
|
||||
if math.Abs(latitude) >= 70 {
|
||||
polar = true
|
||||
break
|
||||
}
|
||||
}
|
||||
var stationHorizonRoots []float64
|
||||
if polar {
|
||||
stationValueAt := func(angle float64) (float64, bool) {
|
||||
longitude, latitude, ok := occultationRiseSetHorizonPointFromContext(evaluation.center, angle)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
state := evaluation.center.stateAt(longitude, latitude)
|
||||
if !state.valid {
|
||||
return 0, false
|
||||
}
|
||||
if greatest {
|
||||
return evaluation.separationDerivative(longitude, latitude), true
|
||||
}
|
||||
return state.contactMetric, true
|
||||
}
|
||||
stationHorizonRoots = riseSetCyclicRootsWithFoldTolerance(boundaryPoints, foldTolerance, stationValueAt)
|
||||
}
|
||||
for _, angle := range roots {
|
||||
var longitude, latitude float64
|
||||
var ok bool
|
||||
longitude, latitude = riseSetHorizonPoint(centerLongitude, centerLatitude, angle)
|
||||
if math.Abs(latitude) >= 70 {
|
||||
stationAngle, found := occultationRiseSetClosestHorizonAngle(angle, stationHorizonRoots)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
// `angle` is the continuation parameter on the station-corrected
|
||||
// horizon. Do not refine this root in longitude/latitude: near a
|
||||
// polar fold that 2-D Newton system can converge to its sibling.
|
||||
longitude, latitude, ok = occultationRiseSetHorizonPointFromContext(evaluation.center, stationAngle)
|
||||
} else {
|
||||
longitude, latitude, ok = riseSetRefineGeographicRoot(
|
||||
longitude, latitude,
|
||||
func(lon, lat float64) (float64, float64, bool) {
|
||||
state := evaluation.center.stateAt(lon, lat)
|
||||
if !state.valid {
|
||||
return 0, 0, false
|
||||
}
|
||||
first := state.contactMetric
|
||||
if greatest {
|
||||
first = evaluation.separationDerivative(lon, lat)
|
||||
}
|
||||
return first, state.moonAltitude, finite(first)
|
||||
},
|
||||
)
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
point, key, valid := evaluation.classify(longitude, latitude, greatest, location)
|
||||
if !valid || occultationRiseSetPointExists(result[key], point) {
|
||||
continue
|
||||
}
|
||||
result[key] = append(result[key], point)
|
||||
}
|
||||
}
|
||||
appendRoots(false)
|
||||
appendRoots(true)
|
||||
return result
|
||||
}
|
||||
|
||||
func occultationRiseSetClosestHorizonAngle(target float64, candidates []float64) (float64, bool) {
|
||||
closest := 0.0
|
||||
distance := math.Inf(1)
|
||||
for _, candidate := range candidates {
|
||||
current := riseSetAngularDistance(target, candidate)
|
||||
if current < distance {
|
||||
closest, distance = candidate, current
|
||||
}
|
||||
}
|
||||
return closest, finite(distance) && distance <= math.Pi/6
|
||||
}
|
||||
|
||||
func (evaluation occultationRiseSetEvaluation) classify(
|
||||
longitude, latitude float64,
|
||||
greatest bool,
|
||||
location *time.Location,
|
||||
) (OccultationPathPoint, occultationRiseSetCurveKey, bool) {
|
||||
state := evaluation.center.stateAt(longitude, latitude)
|
||||
altitudeDerivative := evaluation.moonAltitudeDerivative(longitude, latitude)
|
||||
if !state.valid || !finite(altitudeDerivative) || math.Abs(altitudeDerivative) < 1e-8 {
|
||||
return OccultationPathPoint{}, occultationRiseSetCurveKey{}, false
|
||||
}
|
||||
direction := RiseSetDirectionSet
|
||||
if altitudeDerivative > 0 {
|
||||
direction = RiseSetDirectionRise
|
||||
}
|
||||
phase := RiseSetPhaseGreatest
|
||||
if greatest {
|
||||
if state.contactMetric > 1e-7 || evaluation.separationSecondDerivative(longitude, latitude) <= 0 {
|
||||
return OccultationPathPoint{}, occultationRiseSetCurveKey{}, false
|
||||
}
|
||||
} else {
|
||||
contactDerivative := evaluation.contactDerivative(longitude, latitude)
|
||||
if !finite(contactDerivative) || math.Abs(contactDerivative) < 1e-8 {
|
||||
return OccultationPathPoint{}, occultationRiseSetCurveKey{}, false
|
||||
}
|
||||
phase = RiseSetPhaseEnd
|
||||
if contactDerivative < 0 {
|
||||
phase = RiseSetPhaseStart
|
||||
}
|
||||
}
|
||||
return OccultationPathPoint{
|
||||
Time: occultationTTToLocation(evaluation.tt, location),
|
||||
Longitude: longitude, Latitude: latitude, MoonAltitude: state.moonAltitude,
|
||||
}, occultationRiseSetCurveKey{phase: phase, direction: direction}, true
|
||||
}
|
||||
|
||||
func (evaluation occultationRiseSetEvaluation) contactDerivative(longitude, latitude float64) float64 {
|
||||
before := evaluation.before.stateAt(longitude, latitude)
|
||||
after := evaluation.after.stateAt(longitude, latitude)
|
||||
if !before.valid || !after.valid {
|
||||
return math.NaN()
|
||||
}
|
||||
return (after.contactMetric - before.contactMetric) / (2 * occultationRiseSetDerivativeStepDays)
|
||||
}
|
||||
|
||||
func (evaluation occultationRiseSetEvaluation) contactSecondDerivative(longitude, latitude float64) float64 {
|
||||
before := evaluation.before.stateAt(longitude, latitude)
|
||||
center := evaluation.center.stateAt(longitude, latitude)
|
||||
after := evaluation.after.stateAt(longitude, latitude)
|
||||
if !before.valid || !center.valid || !after.valid {
|
||||
return math.NaN()
|
||||
}
|
||||
stepSquared := occultationRiseSetDerivativeStepDays * occultationRiseSetDerivativeStepDays
|
||||
return (after.contactMetric - 2*center.contactMetric + before.contactMetric) / stepSquared
|
||||
}
|
||||
|
||||
func (evaluation occultationRiseSetEvaluation) separationDerivative(longitude, latitude float64) float64 {
|
||||
before := evaluation.before.stateAt(longitude, latitude)
|
||||
after := evaluation.after.stateAt(longitude, latitude)
|
||||
if !before.valid || !after.valid {
|
||||
return math.NaN()
|
||||
}
|
||||
return (after.separationSquared - before.separationSquared) / (2 * occultationRiseSetDerivativeStepDays)
|
||||
}
|
||||
|
||||
func (evaluation occultationRiseSetEvaluation) separationSecondDerivative(longitude, latitude float64) float64 {
|
||||
before := evaluation.before.stateAt(longitude, latitude)
|
||||
center := evaluation.center.stateAt(longitude, latitude)
|
||||
after := evaluation.after.stateAt(longitude, latitude)
|
||||
if !before.valid || !center.valid || !after.valid {
|
||||
return math.NaN()
|
||||
}
|
||||
stepSquared := occultationRiseSetDerivativeStepDays * occultationRiseSetDerivativeStepDays
|
||||
return (after.separationSquared - 2*center.separationSquared + before.separationSquared) / stepSquared
|
||||
}
|
||||
|
||||
func (evaluation occultationRiseSetEvaluation) moonAltitudeDerivative(longitude, latitude float64) float64 {
|
||||
before := evaluation.before.stateAt(longitude, latitude)
|
||||
after := evaluation.after.stateAt(longitude, latitude)
|
||||
if !before.valid || !after.valid {
|
||||
return math.NaN()
|
||||
}
|
||||
return (after.moonAltitude - before.moonAltitude) / (2 * occultationRiseSetDerivativeStepDays)
|
||||
}
|
||||
|
||||
func (evaluation occultationRiseSetEvaluation) moonAltitudeSecondDerivative(longitude, latitude float64) float64 {
|
||||
before := evaluation.before.stateAt(longitude, latitude)
|
||||
center := evaluation.center.stateAt(longitude, latitude)
|
||||
after := evaluation.after.stateAt(longitude, latitude)
|
||||
if !before.valid || !center.valid || !after.valid {
|
||||
return math.NaN()
|
||||
}
|
||||
stepSquared := occultationRiseSetDerivativeStepDays * occultationRiseSetDerivativeStepDays
|
||||
return (after.moonAltitude - 2*center.moonAltitude + before.moonAltitude) / stepSquared
|
||||
}
|
||||
|
||||
func occultationRiseSetPointExists(points []OccultationPathPoint, candidate OccultationPathPoint) bool {
|
||||
for _, point := range points {
|
||||
if occultationPathDistanceKM(point, candidate) < 0.01 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// occultationRiseSetBranchChanged is stricter than the coarse solar-path
|
||||
// splitter for short occultation segments. A polar rise/set root can move
|
||||
// hundreds of kilometres in nearly the same timestamp when two unrelated
|
||||
// horizon branches are paired. Distances up to 500 km remain valid for the
|
||||
// sampled fold attachments; a larger sub-second jump is rejected as a branch
|
||||
// change before adaptive refinement can reconnect it.
|
||||
func occultationRiseSetBranchChanged(distanceKM, deltaDays float64) bool {
|
||||
if !finite(distanceKM) || !finite(deltaDays) {
|
||||
return true
|
||||
}
|
||||
if distanceKM <= 500 {
|
||||
return false
|
||||
}
|
||||
seconds := deltaDays * 86400
|
||||
// Equal-time endpoints can only be joined when they are already spatially
|
||||
// close. A larger jump in the sub-second interval is a different horizon
|
||||
// root, not a fast-moving physical branch.
|
||||
if seconds <= 0 {
|
||||
return true
|
||||
}
|
||||
return seconds < 1 && distanceKM/seconds > 10
|
||||
}
|
||||
|
||||
// occultationRiseSetWindowEdgeToleranceDays 判定端点是否落在查询窗口边界上。
|
||||
// 窗口边界样本经过民用时往返的误差在微秒量级,远小于任何采样步长。
|
||||
const occultationRiseSetWindowEdgeToleranceDays = 1e-6
|
||||
|
||||
// occultationRiseSetUnclosedEndpointCount 统计未被其他相位曲线端点共享的端点数量,
|
||||
// 这是“相位图是否闭合”的判据。interiorOnly 时跳过窗口裁剪端点:它们没有任何折点
|
||||
// 可以配对,只反映查询窗口而不是几何缺陷。
|
||||
// occultationRiseSetUnclosedEndpointCount counts the endpoints that no other phase
|
||||
// curve endpoint shares, the closure test for the phase graph. With interiorOnly,
|
||||
// endpoints clipped by the query window are skipped: no fold can pair them, so they
|
||||
// describe the window rather than the geometry.
|
||||
func occultationRiseSetUnclosedEndpointCount(
|
||||
curves []OccultationRiseSetCurve,
|
||||
interiorOnly bool,
|
||||
firstTT, lastTT float64,
|
||||
) int {
|
||||
unclosed := 0
|
||||
for ci, curve := range curves {
|
||||
if curve.Phase == RiseSetPhaseGreatest {
|
||||
continue
|
||||
}
|
||||
for si, segment := range curve.Segments {
|
||||
if len(segment) < 2 {
|
||||
continue
|
||||
}
|
||||
for _, endpoint := range []OccultationPathPoint{segment[0], segment[len(segment)-1]} {
|
||||
if interiorOnly && occultationRiseSetEndpointClipped(endpoint, firstTT, lastTT) {
|
||||
continue
|
||||
}
|
||||
shared := false
|
||||
for oi, other := range curves {
|
||||
if other.Phase == RiseSetPhaseGreatest {
|
||||
continue
|
||||
}
|
||||
for os, points := range other.Segments {
|
||||
if (ci == oi && si == os) || len(points) < 2 {
|
||||
continue
|
||||
}
|
||||
for _, point := range []OccultationPathPoint{points[0], points[len(points)-1]} {
|
||||
dt := point.Time.Sub(endpoint.Time)
|
||||
if dt >= -time.Second && dt <= time.Second &&
|
||||
occultationPathDistanceKM(endpoint, point) < 0.01 {
|
||||
shared = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !shared {
|
||||
unclosed++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return unclosed
|
||||
}
|
||||
|
||||
func occultationRiseSetEndpointClipped(endpoint OccultationPathPoint, firstTT, lastTT float64) bool {
|
||||
tt := occultationTimeToTT(endpoint.Time)
|
||||
return tt <= firstTT+occultationRiseSetWindowEdgeToleranceDays ||
|
||||
tt >= lastTT-occultationRiseSetWindowEdgeToleranceDays
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPlanetOccultationContactPhaseJunctionsClose(t *testing.T) {
|
||||
for _, sample := range []struct {
|
||||
year, month, day int
|
||||
planet OccultationPlanet
|
||||
}{
|
||||
{2027, 4, 15, OccultationJupiter},
|
||||
{2027, 1, 8, OccultationMercury},
|
||||
// 2025-01-05 月掩海王星在环极折点处残差与零相切,符号扫描漏根会让
|
||||
// 「终掩在月升/月落」分支断开,这里固定该回归。
|
||||
// The 2025-01-05 Neptune occultation tangents the zero residual at its
|
||||
// polar fold; a sign-only scan drops that root and breaks the end phase
|
||||
// branches apart, so pin the regression here.
|
||||
{2025, 1, 5, OccultationNeptune},
|
||||
} {
|
||||
t.Run(string(sample.planet), func(t *testing.T) {
|
||||
day := time.Date(sample.year, time.Month(sample.month), sample.day, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := FindPlanetOccultationPaths(day.Add(-12*time.Hour), day.Add(36*time.Hour), sample.planet,
|
||||
OccultationPathOptions{Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, RiseSetStep: time.Minute})
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("paths=%d err=%v", len(paths), err)
|
||||
}
|
||||
for _, curves := range [][]OccultationRiseSetCurve{paths[0].RiseSetCurves, paths[0].TotalRiseSetCurves} {
|
||||
for ci, curve := range curves {
|
||||
if curve.Phase == RiseSetPhaseGreatest {
|
||||
continue
|
||||
}
|
||||
for si, segment := range curve.Segments {
|
||||
if len(segment) < 2 {
|
||||
continue
|
||||
}
|
||||
for _, endpoint := range []OccultationPathPoint{segment[0], segment[len(segment)-1]} {
|
||||
shared := false
|
||||
for oi, other := range curves {
|
||||
if other.Phase == RiseSetPhaseGreatest {
|
||||
continue
|
||||
}
|
||||
for os, points := range other.Segments {
|
||||
if ci == oi && si == os || len(points) < 2 {
|
||||
continue
|
||||
}
|
||||
for _, point := range []OccultationPathPoint{points[0], points[len(points)-1]} {
|
||||
dt := point.Time.Sub(endpoint.Time)
|
||||
if dt >= -time.Second && dt <= time.Second && occultationPathDistanceKM(endpoint, point) < 0.01 {
|
||||
shared = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !shared {
|
||||
t.Errorf("unclosed %s/%s endpoint lon=%.9f lat=%.9f at=%s", curve.Phase, curve.Direction, endpoint.Longitude, endpoint.Latitude, endpoint.Time)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
func refineOccultationRiseSetFold(
|
||||
first, second OccultationPathPoint,
|
||||
atStart bool,
|
||||
greatest bool,
|
||||
stepDays float64,
|
||||
location *time.Location,
|
||||
cache *occultationRiseSetEvaluationCache,
|
||||
) (OccultationPathPoint, bool) {
|
||||
newton := func() (OccultationPathPoint, bool) {
|
||||
seedTT := (occultationTimeToTT(first.Time) + occultationTimeToTT(second.Time)) / 2
|
||||
firstAngle := occultationRiseSetHorizonAngle(seedTT, first.Longitude, first.Latitude, cache.context)
|
||||
secondAngle := occultationRiseSetHorizonAngle(seedTT, second.Longitude, second.Latitude, cache.context)
|
||||
coordinates := [2]float64{riseSetNormalizeRadians(firstAngle + math.Remainder(secondAngle-firstAngle, 2*math.Pi)/2), 0}
|
||||
const angleStep = 1e-4
|
||||
for iteration := 0; iteration < 32; iteration++ {
|
||||
residual, ok := occultationRiseSetFoldHorizonResidual(seedTT, coordinates, greatest, cache)
|
||||
if !ok {
|
||||
return OccultationPathPoint{}, false
|
||||
}
|
||||
if math.Abs(residual[0]) <= 1e-10 && math.Abs(residual[1]) <= 1e-9 {
|
||||
break
|
||||
}
|
||||
plusAngle, plusAngleOK := occultationRiseSetFoldHorizonResidual(seedTT, [2]float64{coordinates[0] + angleStep, coordinates[1]}, greatest, cache)
|
||||
minusAngle, minusAngleOK := occultationRiseSetFoldHorizonResidual(seedTT, [2]float64{coordinates[0] - angleStep, coordinates[1]}, greatest, cache)
|
||||
timeStep := 1.0 / 60.0
|
||||
plusTime, plusTimeOK := occultationRiseSetFoldHorizonResidual(seedTT, [2]float64{coordinates[0], coordinates[1] + timeStep}, greatest, cache)
|
||||
minusTime, minusTimeOK := occultationRiseSetFoldHorizonResidual(seedTT, [2]float64{coordinates[0], coordinates[1] - timeStep}, greatest, cache)
|
||||
if !plusAngleOK || !minusAngleOK || !plusTimeOK || !minusTimeOK {
|
||||
return OccultationPathPoint{}, false
|
||||
}
|
||||
matrix := [2][2]float64{
|
||||
{(plusAngle[0] - minusAngle[0]) / (2 * angleStep), (plusTime[0] - minusTime[0]) / (2 * timeStep)},
|
||||
{(plusAngle[1] - minusAngle[1]) / (2 * angleStep), (plusTime[1] - minusTime[1]) / (2 * timeStep)},
|
||||
}
|
||||
determinant := matrix[0][0]*matrix[1][1] - matrix[0][1]*matrix[1][0]
|
||||
if !finite(determinant) || math.Abs(determinant) < 1e-18 {
|
||||
return OccultationPathPoint{}, false
|
||||
}
|
||||
delta := [2]float64{
|
||||
(-residual[0]*matrix[1][1] + matrix[0][1]*residual[1]) / determinant,
|
||||
(-matrix[0][0]*residual[1] + residual[0]*matrix[1][0]) / determinant,
|
||||
}
|
||||
if math.Abs(delta[0]) > 0.25 {
|
||||
delta[0] = math.Copysign(0.25, delta[0])
|
||||
}
|
||||
if math.Abs(delta[1]) > 5 {
|
||||
delta[1] = math.Copysign(5, delta[1])
|
||||
}
|
||||
coordinates[0] = riseSetNormalizeRadians(coordinates[0] + delta[0])
|
||||
coordinates[1] += delta[1]
|
||||
}
|
||||
jd := seedTT + coordinates[1]/1440
|
||||
longitude, latitude, ok := occultationRiseSetHorizonPoint(jd, coordinates[0], cache.context)
|
||||
if !ok {
|
||||
return OccultationPathPoint{}, false
|
||||
}
|
||||
return refineOccultationRiseSetFoldPoint(jd, longitude, latitude, greatest, location, cache)
|
||||
}
|
||||
if point, ok := newton(); ok {
|
||||
return point, true
|
||||
}
|
||||
return refineOccultationRiseSetFoldBracketed(
|
||||
first, second, atStart, greatest, stepDays, location, cache,
|
||||
)
|
||||
}
|
||||
|
||||
func refineOccultationRiseSetFoldBracketed(
|
||||
first, second OccultationPathPoint,
|
||||
atStart bool,
|
||||
greatest bool,
|
||||
stepDays float64,
|
||||
location *time.Location,
|
||||
cache *occultationRiseSetEvaluationCache,
|
||||
) (OccultationPathPoint, bool) {
|
||||
sampleTT := (occultationTimeToTT(first.Time) + occultationTimeToTT(second.Time)) / 2
|
||||
firstAngle := occultationRiseSetHorizonAngle(sampleTT, first.Longitude, first.Latitude, cache.context)
|
||||
secondAngle := occultationRiseSetHorizonAngle(sampleTT, second.Longitude, second.Latitude, cache.context)
|
||||
sampleAngle := riseSetNormalizeRadians(firstAngle + math.Remainder(secondAngle-firstAngle, 2*math.Pi)/2)
|
||||
sampleAngle, sampleValue, ok := occultationRiseSetFoldExtremumAt(sampleTT, sampleAngle, greatest, cache)
|
||||
if !ok {
|
||||
return OccultationPathPoint{}, false
|
||||
}
|
||||
direction := 1.0
|
||||
if atStart {
|
||||
direction = -1
|
||||
}
|
||||
var otherTT, otherAngle, otherValue float64
|
||||
bracketed := false
|
||||
for step := 1; step <= 4; step++ {
|
||||
otherTT = sampleTT + direction*float64(step)*stepDays
|
||||
otherAngle, otherValue, ok = occultationRiseSetFoldExtremumAt(otherTT, sampleAngle, greatest, cache)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if sampleValue == 0 || otherValue == 0 || sampleValue*otherValue < 0 {
|
||||
bracketed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !bracketed {
|
||||
return OccultationPathPoint{}, false
|
||||
}
|
||||
|
||||
leftTT, leftAngle, leftValue := sampleTT, sampleAngle, sampleValue
|
||||
rightTT, rightAngle, rightValue := otherTT, otherAngle, otherValue
|
||||
for iteration := 0; iteration < 64 && math.Abs(rightTT-leftTT) > occultationRiseSetFoldRootToleranceDays; iteration++ {
|
||||
middleTT := (leftTT + rightTT) / 2
|
||||
middleSeed := riseSetNormalizeRadians(leftAngle + math.Remainder(rightAngle-leftAngle, 2*math.Pi)/2)
|
||||
middleAngle, middleValue, middleOK := occultationRiseSetFoldExtremumAt(middleTT, middleSeed, greatest, cache)
|
||||
if !middleOK {
|
||||
return OccultationPathPoint{}, false
|
||||
}
|
||||
if leftValue == 0 || leftValue*middleValue <= 0 {
|
||||
rightTT, rightAngle, rightValue = middleTT, middleAngle, middleValue
|
||||
} else {
|
||||
leftTT, leftAngle, leftValue = middleTT, middleAngle, middleValue
|
||||
}
|
||||
}
|
||||
_ = rightValue
|
||||
rootTT := (leftTT + rightTT) / 2
|
||||
rootSeed := riseSetNormalizeRadians(leftAngle + math.Remainder(rightAngle-leftAngle, 2*math.Pi)/2)
|
||||
rootAngle, rootValue, ok := occultationRiseSetFoldExtremumAt(rootTT, rootSeed, greatest, cache)
|
||||
if !ok {
|
||||
return OccultationPathPoint{}, false
|
||||
}
|
||||
longitude, latitude, ok := occultationRiseSetHorizonPoint(rootTT, rootAngle, cache.context)
|
||||
if !ok {
|
||||
return OccultationPathPoint{}, false
|
||||
}
|
||||
if point, refined := refineOccultationRiseSetFoldPoint(rootTT, longitude, latitude, greatest, location, cache); refined {
|
||||
return point, true
|
||||
}
|
||||
state := cache.context(rootTT).stateAt(longitude, latitude)
|
||||
if !state.valid || math.Abs(rootValue) > 1e-7 || math.Abs(state.moonAltitude) > 1e-8 {
|
||||
return OccultationPathPoint{}, false
|
||||
}
|
||||
return OccultationPathPoint{
|
||||
Time: occultationTTToLocation(rootTT, location), Longitude: longitude,
|
||||
Latitude: latitude, MoonAltitude: state.moonAltitude,
|
||||
}, true
|
||||
}
|
||||
|
||||
func occultationRiseSetFoldExtremumAt(
|
||||
tt, angle float64,
|
||||
greatest bool,
|
||||
cache *occultationRiseSetEvaluationCache,
|
||||
) (float64, float64, bool) {
|
||||
const angleStep = 1e-4
|
||||
angle = riseSetNormalizeRadians(angle)
|
||||
for iteration := 0; iteration < 24; iteration++ {
|
||||
residual, ok := occultationRiseSetFoldHorizonResidual(tt, [2]float64{angle, 0}, greatest, cache)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
if math.Abs(residual[1]) <= 1e-10 {
|
||||
return angle, residual[0], true
|
||||
}
|
||||
before, beforeOK := occultationRiseSetFoldHorizonResidual(tt, [2]float64{angle - angleStep, 0}, greatest, cache)
|
||||
after, afterOK := occultationRiseSetFoldHorizonResidual(tt, [2]float64{angle + angleStep, 0}, greatest, cache)
|
||||
if !beforeOK || !afterOK {
|
||||
return 0, 0, false
|
||||
}
|
||||
secondDerivative := (after[1] - before[1]) / (2 * angleStep)
|
||||
if !finite(secondDerivative) || math.Abs(secondDerivative) < 1e-16 {
|
||||
return 0, 0, false
|
||||
}
|
||||
delta := -residual[1] / secondDerivative
|
||||
if math.Abs(delta) > 0.25 {
|
||||
delta = math.Copysign(0.25, delta)
|
||||
}
|
||||
angle = riseSetNormalizeRadians(angle + delta)
|
||||
}
|
||||
residual, ok := occultationRiseSetFoldHorizonResidual(tt, [2]float64{angle, 0}, greatest, cache)
|
||||
return angle, residual[0], ok && finite(residual[0]) && finite(residual[1]) && math.Abs(residual[1]) <= 1e-7
|
||||
}
|
||||
|
||||
func occultationRiseSetHorizonCenter(context occultationRiseSetContext) (float64, float64) {
|
||||
return normalizeLongitude(context.moonRA - context.siderealDegrees), context.moonDec
|
||||
}
|
||||
|
||||
func occultationRiseSetHorizonPoint(
|
||||
tt, angle float64,
|
||||
contextAt occultationRiseSetContextFunc,
|
||||
) (float64, float64, bool) {
|
||||
return occultationRiseSetHorizonPointFromContext(contextAt(tt), angle)
|
||||
}
|
||||
|
||||
func occultationRiseSetHorizonPointFromContext(
|
||||
context occultationRiseSetContext,
|
||||
angle float64,
|
||||
) (float64, float64, bool) {
|
||||
if !context.valid {
|
||||
return 0, 0, false
|
||||
}
|
||||
center, first, second, basisOK := occultationRiseSetHorizonBasis(context)
|
||||
if !basisOK {
|
||||
return 0, 0, false
|
||||
}
|
||||
tangent := occultationRiseSetCircleTangent(first, second, angle)
|
||||
pointAt := func(radius float64) (float64, float64, float64, bool) {
|
||||
longitude, latitude := occultationRiseSetCirclePointFromTangent(center, tangent, radius)
|
||||
residual, ok := context.moonHorizonResidual(longitude, latitude)
|
||||
return longitude, latitude, residual, ok
|
||||
}
|
||||
left, right := 80*rad, 100*rad
|
||||
_, _, leftValue, leftOK := pointAt(left)
|
||||
_, _, rightValue, rightOK := pointAt(right)
|
||||
if !leftOK || !rightOK || leftValue*rightValue > 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
weightedLeft, weightedRight := leftValue, rightValue
|
||||
lastSide := 0
|
||||
for iteration := 0; iteration < 12; iteration++ {
|
||||
denominator := weightedRight - weightedLeft
|
||||
middle := (left + right) / 2
|
||||
if finite(denominator) && math.Abs(denominator) > 1e-18 {
|
||||
candidate := (left*weightedRight - right*weightedLeft) / denominator
|
||||
if candidate > left && candidate < right {
|
||||
middle = candidate
|
||||
}
|
||||
}
|
||||
longitude, latitude, middleValue, middleOK := pointAt(middle)
|
||||
if !middleOK {
|
||||
return 0, 0, false
|
||||
}
|
||||
if math.Abs(middleValue) <= 1e-6 {
|
||||
return longitude, latitude, true
|
||||
}
|
||||
if leftValue*middleValue <= 0 {
|
||||
right, rightValue, weightedRight = middle, middleValue, middleValue
|
||||
if lastSide < 0 {
|
||||
weightedLeft *= 0.5
|
||||
} else {
|
||||
weightedLeft = leftValue
|
||||
}
|
||||
lastSide = -1
|
||||
} else {
|
||||
left, leftValue, weightedLeft = middle, middleValue, middleValue
|
||||
if lastSide > 0 {
|
||||
weightedRight *= 0.5
|
||||
} else {
|
||||
weightedRight = rightValue
|
||||
}
|
||||
lastSide = 1
|
||||
}
|
||||
}
|
||||
for iteration := 0; iteration < 32; iteration++ {
|
||||
middle := (left + right) / 2
|
||||
_, _, middleValue, middleOK := pointAt(middle)
|
||||
if !middleOK {
|
||||
return 0, 0, false
|
||||
}
|
||||
if leftValue*middleValue <= 0 {
|
||||
right, rightValue = middle, middleValue
|
||||
} else {
|
||||
left, leftValue = middle, middleValue
|
||||
}
|
||||
}
|
||||
longitude, latitude, _, ok := pointAt((left + right) / 2)
|
||||
return longitude, latitude, ok
|
||||
}
|
||||
|
||||
func occultationRiseSetHorizonBasis(context occultationRiseSetContext) ([3]float64, [3]float64, [3]float64, bool) {
|
||||
centerLongitude, centerLatitude := occultationRiseSetHorizonCenter(context)
|
||||
longitude := centerLongitude * rad
|
||||
latitude := centerLatitude * rad
|
||||
center := [3]float64{
|
||||
math.Cos(latitude) * math.Cos(longitude),
|
||||
math.Cos(latitude) * math.Sin(longitude),
|
||||
math.Sin(latitude),
|
||||
}
|
||||
reference := [3]float64{0, 0, 1}
|
||||
if math.Abs(center[2]) > 0.9 {
|
||||
reference = [3]float64{1, 0, 0}
|
||||
}
|
||||
first := riseSetUnitVector(riseSetCross(reference, center))
|
||||
second := riseSetUnitVector(riseSetCross(center, first))
|
||||
return center, first, second, finite(center[0]) && finite(first[0]) && finite(second[0])
|
||||
}
|
||||
|
||||
func occultationRiseSetCirclePointFromBasis(center, first, second [3]float64, angle, radius float64) (float64, float64) {
|
||||
return occultationRiseSetCirclePointFromTangent(
|
||||
center, occultationRiseSetCircleTangent(first, second, angle), radius,
|
||||
)
|
||||
}
|
||||
|
||||
func occultationRiseSetCircleTangent(first, second [3]float64, angle float64) [3]float64 {
|
||||
sinAngle, cosAngle := math.Sincos(angle)
|
||||
return [3]float64{
|
||||
first[0]*cosAngle + second[0]*sinAngle,
|
||||
first[1]*cosAngle + second[1]*sinAngle,
|
||||
first[2]*cosAngle + second[2]*sinAngle,
|
||||
}
|
||||
}
|
||||
|
||||
func occultationRiseSetCirclePointFromTangent(center, tangent [3]float64, radius float64) (float64, float64) {
|
||||
sinRadius, cosRadius := math.Sincos(radius)
|
||||
point := [3]float64{
|
||||
center[0]*cosRadius + tangent[0]*sinRadius,
|
||||
center[1]*cosRadius + tangent[1]*sinRadius,
|
||||
center[2]*cosRadius + tangent[2]*sinRadius,
|
||||
}
|
||||
return normalizeLongitude(math.Atan2(point[1], point[0]) / rad),
|
||||
math.Asin(math.Max(-1, math.Min(1, point[2]))) / rad
|
||||
}
|
||||
|
||||
func occultationRiseSetHorizonAngle(tt, longitude, latitude float64, contextAt occultationRiseSetContextFunc) float64 {
|
||||
_, first, second, ok := occultationRiseSetHorizonBasis(contextAt(tt))
|
||||
if !ok {
|
||||
return math.NaN()
|
||||
}
|
||||
pointLon, pointLat := longitude*rad, latitude*rad
|
||||
point := [3]float64{math.Cos(pointLat) * math.Cos(pointLon), math.Cos(pointLat) * math.Sin(pointLon), math.Sin(pointLat)}
|
||||
return riseSetNormalizeRadians(math.Atan2(point[0]*second[0]+point[1]*second[1]+point[2]*second[2], point[0]*first[0]+point[1]*first[1]+point[2]*first[2]))
|
||||
}
|
||||
|
||||
func occultationRiseSetFoldHorizonResidual(seedTT float64, coordinates [2]float64, greatest bool, cache *occultationRiseSetEvaluationCache) ([2]float64, bool) {
|
||||
jd := seedTT + coordinates[1]/1440
|
||||
evaluation := cache.evaluation(jd)
|
||||
valueAt := func(angle float64) (float64, bool) {
|
||||
longitude, latitude, ok := occultationRiseSetHorizonPoint(jd, angle, cache.context)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return occultationRiseSetPhaseResidual(evaluation, longitude, latitude, greatest)
|
||||
}
|
||||
center, centerOK := valueAt(coordinates[0])
|
||||
before, beforeOK := valueAt(coordinates[0] - 1e-4)
|
||||
after, afterOK := valueAt(coordinates[0] + 1e-4)
|
||||
return [2]float64{center, (after - before) / (2e-4)}, centerOK && beforeOK && afterOK && finite(center) && finite(before) && finite(after)
|
||||
}
|
||||
|
||||
func refineOccultationRiseSetFoldPoint(
|
||||
tt, longitude, latitude float64,
|
||||
greatest bool,
|
||||
location *time.Location,
|
||||
cache *occultationRiseSetEvaluationCache,
|
||||
) (OccultationPathPoint, bool) {
|
||||
coordinates := [3]float64{longitude, latitude, tt}
|
||||
for iteration := 0; iteration < 32; iteration++ {
|
||||
evaluation := cache.evaluation(coordinates[2])
|
||||
residual, ok := occultationRiseSetFoldResidualAt(evaluation, coordinates[0], coordinates[1], greatest)
|
||||
if !ok {
|
||||
return OccultationPathPoint{}, false
|
||||
}
|
||||
if math.Abs(residual[0]) <= 1e-10 && math.Abs(residual[1]) <= 1e-10 && math.Abs(residual[2]) <= 1e-10 {
|
||||
break
|
||||
}
|
||||
steps := [3]float64{1e-3, 1e-3, 1.0 / 86400.0}
|
||||
matrix := [3][3]float64{}
|
||||
for column := range steps {
|
||||
plus, minus := coordinates, coordinates
|
||||
plus[column] += steps[column]
|
||||
minus[column] -= steps[column]
|
||||
plusEval := cache.evaluation(plus[2])
|
||||
minusEval := cache.evaluation(minus[2])
|
||||
plusResidual, plusOK := occultationRiseSetFoldResidualAt(plusEval, plus[0], plus[1], greatest)
|
||||
minusResidual, minusOK := occultationRiseSetFoldResidualAt(minusEval, minus[0], minus[1], greatest)
|
||||
if !plusOK || !minusOK {
|
||||
return OccultationPathPoint{}, false
|
||||
}
|
||||
for row := range matrix {
|
||||
matrix[row][column] = (plusResidual[row] - minusResidual[row]) / (2 * steps[column])
|
||||
}
|
||||
}
|
||||
delta, ok := solveSolarEclipse3x3(matrix, [3]float64{-residual[0], -residual[1], -residual[2]})
|
||||
if !ok {
|
||||
return OccultationPathPoint{}, false
|
||||
}
|
||||
geographicScale := math.Max(math.Abs(delta[0]), math.Abs(delta[1]))
|
||||
if geographicScale > 2 {
|
||||
delta[0] *= 2 / geographicScale
|
||||
delta[1] *= 2 / geographicScale
|
||||
}
|
||||
if math.Abs(delta[2]) > 2.0/1440 {
|
||||
delta[2] = math.Copysign(2.0/1440, delta[2])
|
||||
}
|
||||
for index := range coordinates {
|
||||
coordinates[index] += delta[index]
|
||||
}
|
||||
coordinates[0] = normalizeLongitude(coordinates[0])
|
||||
if coordinates[1] <= -89.999999 || coordinates[1] >= 89.999999 {
|
||||
return OccultationPathPoint{}, false
|
||||
}
|
||||
}
|
||||
evaluation := cache.evaluation(coordinates[2])
|
||||
residual, ok := occultationRiseSetFoldResidualAt(evaluation, coordinates[0], coordinates[1], greatest)
|
||||
if !ok || math.Abs(residual[0]) > 1e-7 || math.Abs(residual[1]) > 1e-8 || math.Abs(residual[2]) > 1e-7 {
|
||||
return OccultationPathPoint{}, false
|
||||
}
|
||||
state := evaluation.center.stateAt(coordinates[0], coordinates[1])
|
||||
return OccultationPathPoint{Time: occultationTTToLocation(coordinates[2], location), Longitude: coordinates[0], Latitude: coordinates[1], MoonAltitude: state.moonAltitude}, true
|
||||
}
|
||||
|
||||
func occultationRiseSetFoldResidualAt(evaluation occultationRiseSetEvaluation, longitude, latitude float64, greatest bool) ([3]float64, bool) {
|
||||
valueAt := func(lon, lat float64) ([2]float64, bool) {
|
||||
first, ok := occultationRiseSetPhaseResidual(evaluation, lon, lat, greatest)
|
||||
state := evaluation.center.stateAt(lon, lat)
|
||||
return [2]float64{first, state.moonAltitude}, ok && state.valid
|
||||
}
|
||||
center, centerOK := valueAt(longitude, latitude)
|
||||
lonPlus, lonPlusOK := valueAt(longitude+1e-3, latitude)
|
||||
lonMinus, lonMinusOK := valueAt(longitude-1e-3, latitude)
|
||||
latPlus, latPlusOK := valueAt(longitude, latitude+1e-3)
|
||||
latMinus, latMinusOK := valueAt(longitude, latitude-1e-3)
|
||||
if !centerOK || !lonPlusOK || !lonMinusOK || !latPlusOK || !latMinusOK {
|
||||
return [3]float64{}, false
|
||||
}
|
||||
firstLon := (lonPlus[0] - lonMinus[0]) / 2e-3
|
||||
firstLat := (latPlus[0] - latMinus[0]) / 2e-3
|
||||
altitudeLon := (lonPlus[1] - lonMinus[1]) / 2e-3
|
||||
altitudeLat := (latPlus[1] - latMinus[1]) / 2e-3
|
||||
residual := [3]float64{center[0], center[1], firstLon*altitudeLat - firstLat*altitudeLon}
|
||||
return residual, finite(residual[2])
|
||||
}
|
||||
|
||||
func refineOccultationRiseSetCurveSpacing(curve *OccultationRiseSetCurve, location *time.Location, cache *occultationRiseSetEvaluationCache) {
|
||||
if curve == nil {
|
||||
return
|
||||
}
|
||||
for index, segment := range curve.Segments {
|
||||
if len(segment) < 2 {
|
||||
continue
|
||||
}
|
||||
refined := make([]OccultationPathPoint, 1, len(segment))
|
||||
refined[0] = segment[0]
|
||||
for pointIndex := 1; pointIndex < len(segment); pointIndex++ {
|
||||
refined = appendRefinedOccultationRiseSetSegment(refined, segment[pointIndex-1], segment[pointIndex], curve.Phase, curve.Direction, location, cache, 0)
|
||||
}
|
||||
curve.Segments[index] = refined
|
||||
}
|
||||
}
|
||||
|
||||
func appendRefinedOccultationRiseSetSegment(
|
||||
points []OccultationPathPoint,
|
||||
start, end OccultationPathPoint,
|
||||
phase RiseSetPhase,
|
||||
direction RiseSetDirection,
|
||||
location *time.Location,
|
||||
cache *occultationRiseSetEvaluationCache,
|
||||
depth int,
|
||||
) []OccultationPathPoint {
|
||||
if occultationPathDistanceKM(start, end) <= occultationRiseSetProjectedTargetSpacingKM(start, end) || depth >= 12 {
|
||||
return append(points, end)
|
||||
}
|
||||
middle, ok := occultationRiseSetPhaseMidpoint(
|
||||
start, end, phase, direction, location, cache,
|
||||
)
|
||||
if !ok {
|
||||
return append(points, end)
|
||||
}
|
||||
points = appendRefinedOccultationRiseSetSegment(points, start, middle, phase, direction, location, cache, depth+1)
|
||||
return appendRefinedOccultationRiseSetSegment(points, middle, end, phase, direction, location, cache, depth+1)
|
||||
}
|
||||
|
||||
func occultationRiseSetPhaseMidpoint(
|
||||
start, end OccultationPathPoint,
|
||||
phase RiseSetPhase,
|
||||
direction RiseSetDirection,
|
||||
location *time.Location,
|
||||
cache *occultationRiseSetEvaluationCache,
|
||||
) (OccultationPathPoint, bool) {
|
||||
startTT, endTT := occultationTimeToTT(start.Time), occultationTimeToTT(end.Time)
|
||||
tt := (startTT + endTT) / 2
|
||||
startAngle := occultationRiseSetHorizonAngle(tt, start.Longitude, start.Latitude, cache.context)
|
||||
endAngle := occultationRiseSetHorizonAngle(tt, end.Longitude, end.Latitude, cache.context)
|
||||
seedAngle := riseSetNormalizeRadians(startAngle + math.Remainder(endAngle-startAngle, 2*math.Pi)/2)
|
||||
if middle, ok := occultationRiseSetPhasePointOnHorizon(
|
||||
tt, seedAngle, phase, direction, location, cache,
|
||||
); ok && occultationRiseSetRefinementPointIsContinuous(start, middle, end) {
|
||||
return middle, true
|
||||
}
|
||||
longitude := normalizeLongitude(start.Longitude + math.Remainder(end.Longitude-start.Longitude, 360)/2)
|
||||
latitude := (start.Latitude + end.Latitude) / 2
|
||||
evaluation := cache.evaluation(tt)
|
||||
longitude, latitude, ok := riseSetRefineGeographicRoot(longitude, latitude, func(lon, lat float64) (float64, float64, bool) {
|
||||
first, valid := occultationRiseSetPhaseResidual(evaluation, lon, lat, phase == RiseSetPhaseGreatest)
|
||||
state := evaluation.center.stateAt(lon, lat)
|
||||
return first, state.moonAltitude, valid && state.valid
|
||||
})
|
||||
if !ok {
|
||||
return OccultationPathPoint{}, false
|
||||
}
|
||||
middle := OccultationPathPoint{Time: occultationTTToLocation(tt, location), Longitude: longitude, Latitude: latitude, MoonAltitude: evaluation.center.stateAt(longitude, latitude).moonAltitude}
|
||||
state := evaluation.center.stateAt(longitude, latitude)
|
||||
keyPoint, key, valid := evaluation.classify(longitude, latitude, phase == RiseSetPhaseGreatest, location)
|
||||
if valid {
|
||||
middle = keyPoint
|
||||
}
|
||||
if !valid || key.phase != phase || key.direction != direction || !state.valid ||
|
||||
!occultationRiseSetRefinementPointIsContinuous(start, middle, end) {
|
||||
return OccultationPathPoint{}, false
|
||||
}
|
||||
return middle, true
|
||||
}
|
||||
|
||||
// refineOccultationRiseSetPhaseJunctionApproaches samples the last two edges
|
||||
// approaching a shared start/greatest/end junction at the exact implicit
|
||||
// F=0,H=0 solution. A phase curve can turn rapidly there even when the final
|
||||
// endpoint is only a few kilometres away; ordinary distance-only sampling
|
||||
// otherwise renders that physical bend as one visible corner.
|
||||
func refineOccultationRiseSetPhaseJunctionApproaches(
|
||||
curves []OccultationRiseSetCurve,
|
||||
location *time.Location,
|
||||
cache *occultationRiseSetEvaluationCache,
|
||||
) {
|
||||
for curveIndex := range curves {
|
||||
curve := &curves[curveIndex]
|
||||
for segmentIndex, segment := range curve.Segments {
|
||||
if len(segment) < 2 {
|
||||
continue
|
||||
}
|
||||
for _, atStart := range []bool{true, false} {
|
||||
endpoint := occultationRiseSetSegmentEndpoint(segment, atStart)
|
||||
if !occultationRiseSetEndpointSharesPhaseJunction(curves, curveIndex, endpoint) {
|
||||
continue
|
||||
}
|
||||
segment = refineOccultationRiseSetJunctionSegment(
|
||||
segment, atStart, curve.Phase, curve.Direction, location, cache,
|
||||
)
|
||||
}
|
||||
curve.Segments[segmentIndex] = segment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func occultationRiseSetEndpointSharesPhaseJunction(
|
||||
curves []OccultationRiseSetCurve,
|
||||
curveIndex int,
|
||||
endpoint OccultationPathPoint,
|
||||
) bool {
|
||||
for otherIndex, curve := range curves {
|
||||
if otherIndex == curveIndex || curve.Phase == curves[curveIndex].Phase ||
|
||||
curve.Direction != curves[curveIndex].Direction {
|
||||
continue
|
||||
}
|
||||
for _, segment := range curve.Segments {
|
||||
if len(segment) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, other := range []OccultationPathPoint{segment[0], segment[len(segment)-1]} {
|
||||
if math.Abs(occultationTimeToTT(endpoint.Time)-occultationTimeToTT(other.Time))*86400 <= 1 &&
|
||||
occultationPathDistanceKM(endpoint, other) <= 0.01 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func refineOccultationRiseSetJunctionSegment(
|
||||
segment []OccultationPathPoint,
|
||||
atStart bool,
|
||||
phase RiseSetPhase,
|
||||
direction RiseSetDirection,
|
||||
location *time.Location,
|
||||
cache *occultationRiseSetEvaluationCache,
|
||||
) []OccultationPathPoint {
|
||||
if len(segment) < 3 {
|
||||
return segment
|
||||
}
|
||||
firstEdge, lastEdge := 0, len(segment)-1
|
||||
if atStart {
|
||||
if lastEdge > 2 {
|
||||
lastEdge = 2
|
||||
}
|
||||
} else {
|
||||
firstEdge = lastEdge - 2
|
||||
if firstEdge < 0 {
|
||||
firstEdge = 0
|
||||
}
|
||||
}
|
||||
result := make([]OccultationPathPoint, 1, len(segment)+16)
|
||||
result[0] = segment[0]
|
||||
for edgeIndex := 0; edgeIndex < len(segment)-1; edgeIndex++ {
|
||||
if edgeIndex >= firstEdge && edgeIndex < lastEdge {
|
||||
result = appendRefinedOccultationRiseSetJunction(
|
||||
result, segment[edgeIndex], segment[edgeIndex+1], phase, direction,
|
||||
location, cache, 0,
|
||||
)
|
||||
continue
|
||||
}
|
||||
result = append(result, segment[edgeIndex+1])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func occultationRiseSetTurnAngleDegrees(
|
||||
first, middle, last OccultationPathPoint,
|
||||
) float64 {
|
||||
longitudeScale := math.Cos(middle.Latitude * rad)
|
||||
firstX := math.Remainder(first.Longitude-middle.Longitude, 360) * longitudeScale
|
||||
firstY := first.Latitude - middle.Latitude
|
||||
lastX := math.Remainder(last.Longitude-middle.Longitude, 360) * longitudeScale
|
||||
lastY := last.Latitude - middle.Latitude
|
||||
firstLength := math.Hypot(firstX, firstY)
|
||||
lastLength := math.Hypot(lastX, lastY)
|
||||
if firstLength <= 1e-12 || lastLength <= 1e-12 {
|
||||
return 180
|
||||
}
|
||||
cosine := (firstX*lastX + firstY*lastY) / (firstLength * lastLength)
|
||||
return math.Acos(math.Max(-1, math.Min(1, cosine))) / rad
|
||||
}
|
||||
|
||||
func appendRefinedOccultationRiseSetJunction(
|
||||
points []OccultationPathPoint,
|
||||
start, end OccultationPathPoint,
|
||||
phase RiseSetPhase,
|
||||
direction RiseSetDirection,
|
||||
location *time.Location,
|
||||
cache *occultationRiseSetEvaluationCache,
|
||||
depth int,
|
||||
) []OccultationPathPoint {
|
||||
if occultationPathDistanceKM(start, end) <= 0.05 || depth >= 12 {
|
||||
return append(points, end)
|
||||
}
|
||||
middle, ok := occultationRiseSetPhaseMidpoint(start, end, phase, direction, location, cache)
|
||||
if !ok {
|
||||
return append(points, end)
|
||||
}
|
||||
// A short chord can still miss the rapidly turning physical arc at the
|
||||
// contact-envelope junction. Refine its measured sagitta, not just length.
|
||||
if planetOccultationPointSegmentDistanceKM(middle, start, end) <= 0.025 {
|
||||
return append(points, end)
|
||||
}
|
||||
points = appendRefinedOccultationRiseSetJunction(
|
||||
points, start, middle, phase, direction, location, cache, depth+1,
|
||||
)
|
||||
return appendRefinedOccultationRiseSetJunction(
|
||||
points, middle, end, phase, direction, location, cache, depth+1,
|
||||
)
|
||||
}
|
||||
|
||||
func occultationRiseSetProjectedTargetSpacingKM(start, end OccultationPathPoint) float64 {
|
||||
latitude := math.Max(math.Abs(start.Latitude), math.Abs(end.Latitude))
|
||||
projectionScale := math.Cos(math.Min(latitude, 85) * rad)
|
||||
return occultationRiseSetTargetSpacingKM * math.Max(0.1, projectionScale)
|
||||
}
|
||||
|
||||
func occultationRiseSetRefinementPointIsContinuous(
|
||||
start, middle, end OccultationPathPoint,
|
||||
) bool {
|
||||
span := occultationPathDistanceKM(start, end)
|
||||
if !finite(span) {
|
||||
return false
|
||||
}
|
||||
// Newton can converge to the sibling root when the horizon contour folds.
|
||||
// A valid midpoint must remain in the local spatial neighbourhood of both
|
||||
// endpoints; the generous floor covers coarse samples while rejecting a
|
||||
// cross-polar branch jump.
|
||||
tolerance := math.Max(500, 2.5*span)
|
||||
return occultationPathDistanceKM(start, middle) <= tolerance &&
|
||||
occultationPathDistanceKM(middle, end) <= tolerance
|
||||
}
|
||||
|
||||
func occultationRiseSetPhasePointOnHorizon(
|
||||
tt, angle float64,
|
||||
phase RiseSetPhase,
|
||||
direction RiseSetDirection,
|
||||
location *time.Location,
|
||||
cache *occultationRiseSetEvaluationCache,
|
||||
) (OccultationPathPoint, bool) {
|
||||
point, evaluation, ok := occultationRiseSetRawPhasePointOnHorizon(
|
||||
tt, angle, phase == RiseSetPhaseGreatest, location, cache,
|
||||
)
|
||||
if !ok {
|
||||
return OccultationPathPoint{}, false
|
||||
}
|
||||
classified, key, valid := evaluation.classify(
|
||||
point.Longitude, point.Latitude, phase == RiseSetPhaseGreatest, location,
|
||||
)
|
||||
if !valid || key.phase != phase || key.direction != direction {
|
||||
return OccultationPathPoint{}, false
|
||||
}
|
||||
return classified, true
|
||||
}
|
||||
|
||||
func occultationRiseSetRawPhasePointOnHorizon(
|
||||
tt, angle float64,
|
||||
greatest bool,
|
||||
location *time.Location,
|
||||
cache *occultationRiseSetEvaluationCache,
|
||||
) (OccultationPathPoint, occultationRiseSetEvaluation, bool) {
|
||||
evaluation := cache.evaluation(tt)
|
||||
valueAt := func(candidateAngle float64) (float64, float64, float64, bool) {
|
||||
longitude, latitude, horizonOK := occultationRiseSetHorizonPoint(tt, candidateAngle, cache.context)
|
||||
if !horizonOK {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
value, valueOK := occultationRiseSetPhaseResidual(evaluation, longitude, latitude, greatest)
|
||||
return value, longitude, latitude, valueOK && finite(value)
|
||||
}
|
||||
const angleStep = 1e-4
|
||||
angle = riseSetNormalizeRadians(angle)
|
||||
for iteration := 0; iteration < 24; iteration++ {
|
||||
value, _, _, ok := valueAt(angle)
|
||||
if !ok {
|
||||
return OccultationPathPoint{}, occultationRiseSetEvaluation{}, false
|
||||
}
|
||||
if math.Abs(value) <= 1e-10 {
|
||||
break
|
||||
}
|
||||
before, _, _, beforeOK := valueAt(angle - angleStep)
|
||||
after, _, _, afterOK := valueAt(angle + angleStep)
|
||||
if !beforeOK || !afterOK {
|
||||
return OccultationPathPoint{}, occultationRiseSetEvaluation{}, false
|
||||
}
|
||||
derivative := (after - before) / (2 * angleStep)
|
||||
if !finite(derivative) || math.Abs(derivative) < 1e-16 {
|
||||
return OccultationPathPoint{}, occultationRiseSetEvaluation{}, false
|
||||
}
|
||||
delta := -value / derivative
|
||||
if math.Abs(delta) > 0.25 {
|
||||
delta = math.Copysign(0.25, delta)
|
||||
}
|
||||
angle = riseSetNormalizeRadians(angle + delta)
|
||||
}
|
||||
value, longitude, latitude, ok := valueAt(angle)
|
||||
if !ok || math.Abs(value) > 1e-7 {
|
||||
return OccultationPathPoint{}, occultationRiseSetEvaluation{}, false
|
||||
}
|
||||
state := evaluation.center.stateAt(longitude, latitude)
|
||||
if !state.valid {
|
||||
return OccultationPathPoint{}, occultationRiseSetEvaluation{}, false
|
||||
}
|
||||
return OccultationPathPoint{
|
||||
Time: occultationTTToLocation(tt, location), Longitude: longitude,
|
||||
Latitude: latitude, MoonAltitude: state.moonAltitude,
|
||||
}, evaluation, true
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 折点补根的判据只能统计查询窗口内部的未成对端点:窗口边界上的裁剪端点没有任何折点
|
||||
// 可以配对,把它们计入会让“未成对端点数严格下降”永远不成立。
|
||||
func TestOccultationRiseSetFoldRecoveryCountsInteriorEndpoints(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*3600)
|
||||
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
|
||||
options := OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
|
||||
DisableFootprints: true,
|
||||
}
|
||||
paths, err := FindPlanetOccultationPaths(start, start.Add(24*time.Hour), OccultationMars, options)
|
||||
if err != nil || len(paths) != 1 || !paths[0].HasTotalBand {
|
||||
t.Fatalf("paths=%d err=%v, want one total-band event", len(paths), err)
|
||||
}
|
||||
path := paths[0]
|
||||
config, _ := planetOccultationConfigFor(OccultationMars)
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
greatestTT := occultationTimeToTT(path.Greatest.Time)
|
||||
cache.preparePathEphemeris(greatestTT, OccultationPathAlgorithmOptimized)
|
||||
firstTT := occultationTimeToTT(path.TotalStart.Time)
|
||||
lastTT := occultationTimeToTT(path.TotalEnd.Time)
|
||||
curves, base, recovered := occultationRiseSetCurvesWithRecoveryReport(
|
||||
firstTT, lastTT, greatestTT, options, start.Location(), cache.totalRiseSetCache,
|
||||
)
|
||||
if recovered == nil {
|
||||
t.Fatal("fold recovery was never attempted on a graph with interior unpaired endpoints")
|
||||
}
|
||||
baseInterior := occultationRiseSetUnclosedEndpointCount(base, true, firstTT, lastTT)
|
||||
recoveredInterior := occultationRiseSetUnclosedEndpointCount(recovered, true, firstTT, lastTT)
|
||||
if baseInterior == 0 || recoveredInterior >= baseInterior {
|
||||
t.Fatalf("interior unpaired endpoints base=%d recovered=%d, want a strict decrease",
|
||||
baseInterior, recoveredInterior)
|
||||
}
|
||||
// 全局计数被窗口裁剪端点抬平:按它判定会丢弃这次重建。
|
||||
baseGlobal := occultationRiseSetUnclosedEndpointCount(base, false, firstTT, lastTT)
|
||||
recoveredGlobal := occultationRiseSetUnclosedEndpointCount(recovered, false, firstTT, lastTT)
|
||||
if recoveredGlobal < baseGlobal {
|
||||
t.Fatalf("global unpaired endpoints base=%d recovered=%d, sample no longer shows a window-truncated endpoint",
|
||||
baseGlobal, recoveredGlobal)
|
||||
}
|
||||
if !reflect.DeepEqual(curves, recovered) {
|
||||
t.Fatal("interior-endpoint test did not select the rebuilt phase graph")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationRiseSetWindowClippedEndpointIgnoredByInteriorCount(t *testing.T) {
|
||||
firstTT := 2460000.0
|
||||
lastTT := firstTT + 0.25
|
||||
point := func(tt float64) OccultationPathPoint {
|
||||
return OccultationPathPoint{Time: occultationTTToLocation(tt, time.UTC)}
|
||||
}
|
||||
curves := []OccultationRiseSetCurve{
|
||||
{
|
||||
Phase: RiseSetPhaseStart, Direction: RiseSetDirectionRise,
|
||||
Segments: [][]OccultationPathPoint{
|
||||
{point(lastTT), point(lastTT + 0.01)},
|
||||
{point(firstTT + 0.02), point(firstTT + 0.03)},
|
||||
},
|
||||
},
|
||||
}
|
||||
if got := occultationRiseSetUnclosedEndpointCount(curves, false, firstTT, lastTT); got != 4 {
|
||||
t.Fatalf("global unpaired endpoints=%d, want 4", got)
|
||||
}
|
||||
if got := occultationRiseSetUnclosedEndpointCount(curves, true, firstTT, lastTT); got != 2 {
|
||||
t.Fatalf("interior unpaired endpoints=%d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// stateAt 的分配预算:同一上下文内换站点只复用已分配的站点表,不再额外分配。
|
||||
func BenchmarkOccultationRiseSetStateAt(b *testing.B) {
|
||||
context := newOccultationRiseSetContext(
|
||||
occultationTimeToTT(time.Date(2025, time.July, 29, 12, 0, 0, 0, time.UTC)),
|
||||
100, 10, 384400, 200, -15, 149597870.7*2, 3389.5,
|
||||
)
|
||||
coordinates := make([][2]float64, 0, 64)
|
||||
for index := 0; index < 64; index++ {
|
||||
coordinates = append(coordinates, [2]float64{float64(index) * 1.7, float64(index%30) - 15})
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for index := 0; index < b.N; index++ {
|
||||
for _, coordinate := range coordinates {
|
||||
_ = context.stateAt(coordinate[0], coordinate[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOccultationRiseSetNewRootKeepsExistingBranch(t *testing.T) {
|
||||
start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for _, offset := range []float64{0, 160, -200} {
|
||||
point := func(lon float64, at time.Time) OccultationPathPoint {
|
||||
return OccultationPathPoint{Time: at, Longitude: normalizeLongitude(lon + offset), Latitude: 58}
|
||||
}
|
||||
tracks := []*occultationRiseSetTrack{{segments: [][]OccultationPathPoint{{point(20, start)}}}}
|
||||
tracks = appendOccultationRiseSetSamples(tracks, []OccultationPathPoint{
|
||||
point(14, start.Add(time.Minute)), point(19, start.Add(time.Minute)),
|
||||
}, 1.0/1440)
|
||||
if len(tracks) != 2 || len(tracks[0].segments[0]) != 2 || len(tracks[1].segments[0]) != 1 {
|
||||
t.Fatalf("offset=%g: new root did not get its own track", offset)
|
||||
}
|
||||
if got := tracks[0].segments[0][1].Longitude; got != normalizeLongitude(19+offset) {
|
||||
t.Errorf("offset=%g: existing track jumped to longitude %g", offset, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOccultationRiseSetVectorStateMatchesLegacyTopocentricState(t *testing.T) {
|
||||
planetConfig, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn occultation config is unavailable")
|
||||
}
|
||||
planetTT := occultationTimeToTT(time.Date(2024, time.August, 21, 2, 41, 36, 0, time.UTC))
|
||||
planetState := planetOccultationEphemerisStateAt(planetTT, planetConfig)
|
||||
|
||||
star := StarCoordinate{
|
||||
ID: "parallax-test", RA: 247.3516666666667, Dec: -26.431944444444444,
|
||||
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000,
|
||||
ProperMotionRACosDecMasPerYear: -10, ProperMotionDecMasPerYear: -20, ParallaxMas: 24,
|
||||
}
|
||||
starTT := occultationTimeToTT(time.Date(2026, time.February, 11, 12, 0, 0, 0, time.UTC))
|
||||
starState := starOccultationEphemerisStateAt(starTT, star)
|
||||
|
||||
contexts := []struct {
|
||||
name string
|
||||
tt float64
|
||||
moonRA, moonDec, moonDistanceKM float64
|
||||
targetRA, targetDec, targetDistanceKM float64
|
||||
targetRadiusKM float64
|
||||
}{
|
||||
{
|
||||
name: "planet", tt: planetTT,
|
||||
moonRA: planetState.moonRA, moonDec: planetState.moonDec, moonDistanceKM: planetState.moonDistanceKM,
|
||||
targetRA: planetState.planetRA, targetDec: planetState.planetDec, targetDistanceKM: planetState.planetDistanceKM,
|
||||
targetRadiusKM: planetConfig.equatorialRadiusKM,
|
||||
},
|
||||
{
|
||||
name: "finite-distance-star", tt: starTT,
|
||||
moonRA: starState.moonRA, moonDec: starState.moonDec, moonDistanceKM: starState.moonDistanceKM,
|
||||
targetRA: starState.starRA, targetDec: starState.starDec, targetDistanceKM: starState.starDistanceKM,
|
||||
},
|
||||
{
|
||||
name: "infinite-distance-star", tt: starTT,
|
||||
moonRA: starState.moonRA, moonDec: starState.moonDec, moonDistanceKM: starState.moonDistanceKM,
|
||||
targetRA: starState.starRA, targetDec: starState.starDec,
|
||||
},
|
||||
}
|
||||
locations := []Observer{
|
||||
{Longitude: 0, Latitude: 0},
|
||||
{Longitude: 115.4, Latitude: 32.9},
|
||||
{Longitude: -73.9857, Latitude: 40.7484},
|
||||
{Longitude: 179.9, Latitude: 80},
|
||||
{Longitude: -120, Latitude: -70},
|
||||
}
|
||||
|
||||
for _, test := range contexts {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
context := newOccultationRiseSetContext(
|
||||
test.tt, test.moonRA, test.moonDec, test.moonDistanceKM,
|
||||
test.targetRA, test.targetDec, test.targetDistanceKM, test.targetRadiusKM,
|
||||
)
|
||||
for _, observer := range locations {
|
||||
got := context.stateAt(observer.Longitude, observer.Latitude)
|
||||
want := legacyOccultationRiseSetStateAt(
|
||||
test.tt, test.moonRA, test.moonDec, test.moonDistanceKM,
|
||||
test.targetRA, test.targetDec, test.targetDistanceKM, test.targetRadiusKM,
|
||||
observer.Longitude, observer.Latitude,
|
||||
)
|
||||
if got.valid != want.valid {
|
||||
t.Fatalf("observer %.4f %.4f valid=%t, want %t", observer.Longitude, observer.Latitude, got.valid, want.valid)
|
||||
}
|
||||
if !got.valid {
|
||||
continue
|
||||
}
|
||||
assertOccultationRiseSetStateClose(t, "contact metric", got.contactMetric, want.contactMetric, 2e-10)
|
||||
assertOccultationRiseSetStateClose(t, "separation squared", got.separationSquared, want.separationSquared, 2e-13)
|
||||
assertOccultationRiseSetStateClose(t, "moon altitude", got.moonAltitude, want.moonAltitude, 2e-10)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationRiseSetContextFromVectorsMatchesRADecContext(t *testing.T) {
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn occultation config is unavailable")
|
||||
}
|
||||
tt := occultationTimeToTT(time.Date(2025, time.January, 5, 17, 0, 0, 0, time.UTC))
|
||||
state := planetOccultationEphemerisStateAt(tt, config)
|
||||
moon := occultationPathRaDecVector(state.moonRA, state.moonDec, state.moonDistanceKM)
|
||||
target := occultationPathRaDecVector(state.planetRA, state.planetDec, state.planetDistanceKM)
|
||||
vectorContext := newOccultationRiseSetContextFromVectors(
|
||||
tt,
|
||||
[3]float64{moon.x, moon.y, moon.z},
|
||||
[3]float64{target.x, target.y, target.z},
|
||||
true,
|
||||
config.equatorialRadiusKM,
|
||||
)
|
||||
raDecContext := newOccultationRiseSetContext(
|
||||
tt, state.moonRA, state.moonDec, state.moonDistanceKM,
|
||||
state.planetRA, state.planetDec, state.planetDistanceKM, config.equatorialRadiusKM,
|
||||
)
|
||||
for _, observer := range []Observer{
|
||||
{Longitude: 0, Latitude: 0},
|
||||
{Longitude: 115.4, Latitude: 32.9},
|
||||
{Longitude: -45, Latitude: 82},
|
||||
{Longitude: 170, Latitude: -70},
|
||||
} {
|
||||
got := vectorContext.stateAt(observer.Longitude, observer.Latitude)
|
||||
want := raDecContext.stateAt(observer.Longitude, observer.Latitude)
|
||||
if got.valid != want.valid {
|
||||
t.Fatalf("observer %.3f %.3f valid=%t, want %t", observer.Longitude, observer.Latitude, got.valid, want.valid)
|
||||
}
|
||||
if !got.valid {
|
||||
continue
|
||||
}
|
||||
assertOccultationRiseSetStateClose(t, "contact metric", got.contactMetric, want.contactMetric, 1e-12)
|
||||
assertOccultationRiseSetStateClose(t, "separation squared", got.separationSquared, want.separationSquared, 1e-15)
|
||||
assertOccultationRiseSetStateClose(t, "moon altitude", got.moonAltitude, want.moonAltitude, 1e-12)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationRiseSetMoonHorizonResidualMatchesVectorAltitude(t *testing.T) {
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn occultation config is unavailable")
|
||||
}
|
||||
tt := occultationTimeToTT(time.Date(2025, time.January, 5, 17, 0, 0, 0, time.UTC))
|
||||
state := planetOccultationEphemerisStateAt(tt, config)
|
||||
context := newOccultationRiseSetContext(
|
||||
tt, state.moonRA, state.moonDec, state.moonDistanceKM,
|
||||
state.planetRA, state.planetDec, state.planetDistanceKM, config.equatorialRadiusKM,
|
||||
)
|
||||
for _, observer := range []Observer{
|
||||
{Longitude: 0, Latitude: 0},
|
||||
{Longitude: 115.4, Latitude: 32.9},
|
||||
{Longitude: -45, Latitude: 82},
|
||||
{Longitude: 170, Latitude: -70},
|
||||
} {
|
||||
got, valid := context.moonHorizonResidual(observer.Longitude, observer.Latitude)
|
||||
observerParallax, _, zenith := occultationRiseSetObserverVectors(
|
||||
context.siderealDegrees, observer.Longitude, observer.Latitude,
|
||||
)
|
||||
topocentricMoon := occultationPathSub(context.moon.positionKM, observerParallax)
|
||||
want := occultationPathDot(topocentricMoon, zenith)
|
||||
if !valid || math.Abs(got-want) > 1e-8 {
|
||||
t.Fatalf("observer %.3f %.3f horizon residual=%.15g valid=%t, want %.15g",
|
||||
observer.Longitude, observer.Latitude, got, valid, want)
|
||||
}
|
||||
altitude := context.stateAt(observer.Longitude, observer.Latitude).moonAltitude * rad
|
||||
if difference := math.Abs(got/occultationPathNorm(topocentricMoon) - math.Sin(altitude)); difference > 1e-14 {
|
||||
t.Fatalf("observer %.3f %.3f normalized horizon residual differs by %.3g",
|
||||
observer.Longitude, observer.Latitude, difference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationRiseSetEvaluationCacheSeparatesCandidateAndExactContexts(t *testing.T) {
|
||||
var exactCalls, candidateCalls int
|
||||
contextAt := func(tt float64) occultationRiseSetContext {
|
||||
return newOccultationRiseSetContext(tt, 10, 5, 384000, 10.5, 5.25, 1e9, 0)
|
||||
}
|
||||
cache := newOccultationRiseSetEvaluationCacheWithCandidate(
|
||||
func(tt float64) occultationRiseSetContext {
|
||||
exactCalls++
|
||||
return contextAt(tt)
|
||||
},
|
||||
func(tt float64) occultationRiseSetContext {
|
||||
candidateCalls++
|
||||
return contextAt(tt)
|
||||
},
|
||||
)
|
||||
tt := occultationTimeToTT(time.Date(2025, time.January, 5, 17, 0, 0, 0, time.UTC))
|
||||
cache.candidateEvaluation(tt)
|
||||
cache.candidateEvaluation(tt)
|
||||
if exactCalls != 0 || candidateCalls != 3 {
|
||||
t.Fatalf("candidate evaluation calls exact=%d candidate=%d, want 0/3", exactCalls, candidateCalls)
|
||||
}
|
||||
cache.evaluation(tt)
|
||||
cache.evaluation(tt)
|
||||
if exactCalls != 3 || candidateCalls != 3 {
|
||||
t.Fatalf("exact evaluation calls exact=%d candidate=%d, want 3/3", exactCalls, candidateCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func legacyOccultationRiseSetStateAt(
|
||||
tt, moonRA, moonDec, moonDistanceKM,
|
||||
targetRA, targetDec, targetDistanceKM, targetRadiusKM,
|
||||
longitude, latitude float64,
|
||||
) occultationRiseSetState {
|
||||
siderealDegrees := ApparentSiderealTime(TD2UT(tt, false)) * 15
|
||||
observer := Observer{Longitude: longitude, Latitude: latitude}
|
||||
moonTopocentricRA, moonTopocentricDec := topocentricRaDecWithSidereal(
|
||||
moonRA, moonDec, latitude, longitude, siderealDegrees,
|
||||
moonDistanceKM/occultationPathAstronomicalUnitKM, 0,
|
||||
)
|
||||
moonTopocentricRA = normalizeRA(moonTopocentricRA)
|
||||
targetTopocentricRA, targetTopocentricDec := targetRA, targetDec
|
||||
if targetDistanceKM > 0 {
|
||||
targetTopocentricRA, targetTopocentricDec = topocentricRaDecWithSidereal(
|
||||
targetRA, targetDec, latitude, longitude, siderealDegrees,
|
||||
targetDistanceKM/occultationPathAstronomicalUnitKM, 0,
|
||||
)
|
||||
targetTopocentricRA = normalizeRA(targetTopocentricRA)
|
||||
}
|
||||
moonTopocentricDistanceKM := topocentricDistanceKMWithSidereal(
|
||||
moonRA, moonDec, moonDistanceKM, observer, siderealDegrees,
|
||||
)
|
||||
if !finite(moonTopocentricDistanceKM) || moonTopocentricDistanceKM <= moonEquatorialRadiusKM {
|
||||
return occultationRiseSetState{}
|
||||
}
|
||||
moonRadius := angularSemidiameterArcsec(moonEquatorialRadiusKM, moonTopocentricDistanceKM) / 3600
|
||||
targetRadius := 0.0
|
||||
if targetRadiusKM > 0 {
|
||||
targetTopocentricDistanceKM := topocentricDistanceKMWithSidereal(
|
||||
targetRA, targetDec, targetDistanceKM, observer, siderealDegrees,
|
||||
)
|
||||
if !finite(targetTopocentricDistanceKM) || targetTopocentricDistanceKM <= targetRadiusKM {
|
||||
return occultationRiseSetState{}
|
||||
}
|
||||
targetRadius = angularSemidiameterArcsec(targetRadiusKM, targetTopocentricDistanceKM) / 3600
|
||||
}
|
||||
separation := angularSeparationDegrees(
|
||||
moonTopocentricRA, moonTopocentricDec, targetTopocentricRA, targetTopocentricDec,
|
||||
)
|
||||
separationRad := separation * rad
|
||||
moonAltitude := occultationAltitudeWithSidereal(
|
||||
siderealDegrees, observer, moonTopocentricRA, moonTopocentricDec,
|
||||
)
|
||||
return occultationRiseSetState{
|
||||
contactMetric: separation - moonRadius - targetRadius,
|
||||
separationSquared: 2 - 2*math.Cos(separationRad),
|
||||
moonAltitude: moonAltitude,
|
||||
valid: finite(separation) && finite(moonRadius) && finite(targetRadius) && finite(moonAltitude),
|
||||
}
|
||||
}
|
||||
|
||||
func assertOccultationRiseSetStateClose(t *testing.T, name string, got, want, tolerance float64) {
|
||||
t.Helper()
|
||||
if difference := math.Abs(got - want); difference > tolerance {
|
||||
t.Fatalf("%s=%.15g, want %.15g (difference %.3g, tolerance %.3g)", name, got, want, difference, tolerance)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOccultationPathsSkipOutsideWindowBeforeSampling(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*3600)
|
||||
options := OccultationPathOptions{
|
||||
Step: time.Second, TargetSpacingKM: 900, RiseSetStep: time.Minute, DisableFootprints: true,
|
||||
}
|
||||
start := time.Date(2025, 1, 6, 0, 0, 0, 0, zone)
|
||||
planets, err := FindPlanetOccultationPaths(start, start.Add(24*time.Hour), OccultationSaturn, options)
|
||||
if err != nil || len(planets) != 0 {
|
||||
t.Fatalf("outside-window planet paths=%d, err=%v", len(planets), err)
|
||||
}
|
||||
start = time.Date(2024, 3, 4, 0, 0, 0, 0, zone)
|
||||
star := StarCoordinate{ID: "Antares", RA: 247.35166667, Dec: -26.43194444,
|
||||
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000}
|
||||
stars, err := FindStarOccultationPaths(start, start.Add(24*time.Hour), star, options)
|
||||
if err != nil || len(stars) != 0 {
|
||||
t.Fatalf("outside-window star paths=%d, err=%v", len(stars), err)
|
||||
}
|
||||
}
|
||||
+16
-58
@@ -240,40 +240,12 @@ func starOccultationLatitudeEnvelopePass(startTT, endTT float64, star StarCoordi
|
||||
return minimumLatitude <= limit && maximumLatitude >= -limit
|
||||
}
|
||||
|
||||
func starOccultationGeocentricLongitudeCandidate(startTT, endTT, step float64, star StarCoordinate) float64 {
|
||||
bestTT := math.NaN()
|
||||
bestDelta := math.Inf(1)
|
||||
for tt := startTT; tt <= endTT; tt += step {
|
||||
delta := math.Abs(signedAngleDifference(HMoonTrueLoN(tt, 8), starOccultationGeocentricStarLongitude(tt, star)))
|
||||
if delta < bestDelta {
|
||||
bestDelta = delta
|
||||
bestTT = tt
|
||||
}
|
||||
}
|
||||
if endTT > startTT {
|
||||
delta := math.Abs(signedAngleDifference(HMoonTrueLoN(endTT, 8), starOccultationGeocentricStarLongitude(endTT, star)))
|
||||
if delta < bestDelta {
|
||||
bestTT = endTT
|
||||
}
|
||||
}
|
||||
return bestTT
|
||||
}
|
||||
|
||||
func starOccultationGeocentricStarLongitude(tt float64, star StarCoordinate) float64 {
|
||||
ra, dec := starApparentRaDecGeocentric(tt, star)
|
||||
longitude, _ := RaDecToLoBo(tt, ra, dec)
|
||||
return longitude
|
||||
}
|
||||
|
||||
func starOccultationMinimizeGeocentricSeparation(seed, startTT, endTT float64, star StarCoordinate) float64 {
|
||||
halfWindow := 0.75
|
||||
left := math.Max(startTT, seed-halfWindow)
|
||||
right := math.Min(endTT, seed+halfWindow)
|
||||
return starOccultationMinimizeValue(left, right, func(tt float64) float64 {
|
||||
return starOccultationGeocentricSeparationArcsec(tt, star)
|
||||
})
|
||||
}
|
||||
|
||||
func starOccultationMinimizeValue(left, right float64, value func(float64) float64) float64 {
|
||||
if right <= left {
|
||||
return left
|
||||
@@ -321,7 +293,15 @@ func starOccultationBestObserver(seedTT, startTT, endTT float64, star StarCoordi
|
||||
frameAt := func(tt float64) (occultationPathFrame, bool) {
|
||||
return starOccultationPathFrameAt(tt, star)
|
||||
}
|
||||
point, pointOK = occultationPathBoundaryPointForFrame(greatestTT, frameAt, time.UTC)
|
||||
// 同行星最佳事件搜索:非中心路径的最佳站心取离影轴最近的椭球点,
|
||||
// 外接触切点位于掩带边缘,只作最后回退。
|
||||
// Same as the planetary best-event search: for a non-central path the
|
||||
// best station is the ellipsoid point nearest to the shadow axis, while
|
||||
// the outer contact tangent on the band edge stays the last resort.
|
||||
point, pointOK = occultationPathTrackPointForFrame(greatestTT, frameAt, time.UTC)
|
||||
if !pointOK {
|
||||
point, pointOK = occultationPathBoundaryPointForFrame(greatestTT, frameAt, time.UTC)
|
||||
}
|
||||
}
|
||||
if !pointOK {
|
||||
return 0, Observer{}, 0, false
|
||||
@@ -531,25 +511,6 @@ func starAnnualParallaxEcliptic(tt, longitude, latitude, parallaxMas float64) (f
|
||||
return normalizeRA(longitude), latitude
|
||||
}
|
||||
|
||||
func starOccultationLongitudeCandidate(startTT, endTT, step float64, star StarCoordinate, observer Observer) float64 {
|
||||
bestTT := math.NaN()
|
||||
bestDelta := math.Inf(1)
|
||||
for tt := startTT; tt <= endTT; tt += step {
|
||||
delta := starOccultationLongitudeDistance(tt, star, observer)
|
||||
if delta < bestDelta {
|
||||
bestDelta = delta
|
||||
bestTT = tt
|
||||
}
|
||||
}
|
||||
if endTT > startTT {
|
||||
delta := starOccultationLongitudeDistance(endTT, star, observer)
|
||||
if delta < bestDelta {
|
||||
bestTT = endTT
|
||||
}
|
||||
}
|
||||
return bestTT
|
||||
}
|
||||
|
||||
func starOccultationLongitudeDistance(tt float64, star StarCoordinate, observer Observer) float64 {
|
||||
moonLongitude := HMoonTrueLoN(tt, 8)
|
||||
starRA, starDec := starApparentRaDec(tt, star, observer)
|
||||
@@ -557,15 +518,6 @@ func starOccultationLongitudeDistance(tt float64, star StarCoordinate, observer
|
||||
return math.Abs(signedAngleDifference(moonLongitude, starLongitude))
|
||||
}
|
||||
|
||||
func starOccultationMinimizeSeparation(seed, startTT, endTT float64, star StarCoordinate, observer Observer) float64 {
|
||||
halfWindow := 0.75
|
||||
left := math.Max(startTT, seed-halfWindow)
|
||||
right := math.Min(endTT, seed+halfWindow)
|
||||
return starOccultationMinimizeValue(left, right, func(tt float64) float64 {
|
||||
return starMoonSeparationArcsec(tt, star, observer)
|
||||
})
|
||||
}
|
||||
|
||||
func starMoonSeparationArcsec(tt float64, star StarCoordinate, observer Observer) float64 {
|
||||
position := starMoonPositionAt(tt, star, observer)
|
||||
if !position.valid {
|
||||
@@ -675,7 +627,13 @@ func occultationPositionAngle(moonRA, moonDec, starRA, starDec float64) float64
|
||||
}
|
||||
|
||||
func occultationAltitude(tt float64, observer Observer, ra, dec float64) float64 {
|
||||
hourAngle := signedAngleDifference(ApparentSiderealTime(TD2UT(tt, false))*15+observer.Longitude, ra) * math.Pi / 180
|
||||
return occultationAltitudeWithSidereal(
|
||||
ApparentSiderealTime(TD2UT(tt, false))*15, observer, ra, dec,
|
||||
)
|
||||
}
|
||||
|
||||
func occultationAltitudeWithSidereal(siderealDegrees float64, observer Observer, ra, dec float64) float64 {
|
||||
hourAngle := signedAngleDifference(siderealDegrees+observer.Longitude, ra) * math.Pi / 180
|
||||
lat := observer.Latitude * math.Pi / 180
|
||||
declination := dec * math.Pi / 180
|
||||
sinAltitude := math.Sin(lat)*math.Sin(declination) + math.Cos(lat)*math.Cos(declination)*math.Cos(hourAngle)
|
||||
|
||||
@@ -161,6 +161,46 @@ func TestRefinedStarOccultationCenterLineRespectsWidthTolerance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolarStarOccultationFootprintsContainCenterLine(t *testing.T) {
|
||||
star := StarCoordinate{
|
||||
ID: "Antares", RA: 247.3516666666667, Dec: -26.431944444444444,
|
||||
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000,
|
||||
ProperMotionRACosDecMasPerYear: -10, ProperMotionDecMasPerYear: -20, ParallaxMas: 24,
|
||||
}
|
||||
start := time.Date(2026, time.February, 11, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := FindStarOccultationPaths(
|
||||
start, start.Add(24*time.Hour), star,
|
||||
OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
if len(paths[0].Footprints) == 0 {
|
||||
t.Fatal("polar stellar path has no instantaneous footprints")
|
||||
}
|
||||
checked := 0
|
||||
for _, center := range paths[0].CenterLine {
|
||||
if center.MoonAltitude <= 0 {
|
||||
continue
|
||||
}
|
||||
contained := false
|
||||
for _, footprint := range paths[0].Footprints {
|
||||
if planetOccultationFootprintContains(footprint, center.Longitude, center.Latitude) {
|
||||
contained = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !contained {
|
||||
t.Fatalf("footprint sweep does not contain visible center line at %v, %.4f, %.4f",
|
||||
center.Time, center.Longitude, center.Latitude)
|
||||
}
|
||||
checked++
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("no visible center-line sample was checked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefineOccultationPathWidthsBoundsSmoothInterpolationError(t *testing.T) {
|
||||
start := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
startTT := occultationTimeToTT(start)
|
||||
@@ -183,6 +223,57 @@ func TestRefineOccultationPathWidthsBoundsSmoothInterpolationError(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationPathCachedEarthRotationMatchesDirectGeometry(t *testing.T) {
|
||||
tt := occultationTimeToTT(time.Date(2025, time.June, 5, 12, 2, 6, 0, time.UTC))
|
||||
vector := occultationPathVector{x: 4123.5, y: -2789.25, z: 3950.75}
|
||||
angle := ApparentSiderealTime(TD2UT(tt, false)) * 15 * math.Pi / 180
|
||||
want := occultationPathVector{
|
||||
x: math.Cos(angle)*vector.x + math.Sin(angle)*vector.y,
|
||||
y: -math.Sin(angle)*vector.x + math.Cos(angle)*vector.y,
|
||||
z: vector.z,
|
||||
}
|
||||
got := occultationPathEarthFixedVectorWithRotation(vector, occultationPathEarthRotationAt(tt))
|
||||
if difference := occultationPathNorm(occultationPathSub(got, want)); difference > 1e-12 {
|
||||
t.Fatalf("cached Earth rotation differs by %.15g km", difference)
|
||||
}
|
||||
|
||||
_, geodeticLatitude := occultationPathGeodetic(tt, vector)
|
||||
if difference := math.Abs(occultationPathGeodeticLatitude(vector) - geodeticLatitude); difference > 1e-12 {
|
||||
t.Fatalf("cached geodetic latitude differs by %.15g degrees", difference)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationPathCachedMoonAndSiderealMatchDirectPoint(t *testing.T) {
|
||||
star := hr4799OccultationCoordinateForTest()
|
||||
tt := occultationTimeToTT(time.Date(2025, time.June, 5, 12, 2, 6, 0, time.UTC))
|
||||
frame, ok := starOccultationPathFrameAt(tt, star)
|
||||
if !ok {
|
||||
t.Fatal("stellar occultation frame is unavailable")
|
||||
}
|
||||
vector, _, ok := occultationEarthLineIntersection(frame.moon, frame.axis)
|
||||
if !ok {
|
||||
t.Fatal("stellar occultation center point is unavailable")
|
||||
}
|
||||
|
||||
direct := occultationPathPointFromVector(tt, vector, 1234.5, time.UTC)
|
||||
cached := occultationPathPointFromVectorWithMoonSidereal(
|
||||
tt, vector, 1234.5, frame.moon, ApparentSiderealTime(TD2UT(tt, false))*15, time.UTC,
|
||||
)
|
||||
if !cached.Time.Equal(direct.Time) {
|
||||
t.Fatalf("cached point time = %v, want %v", cached.Time, direct.Time)
|
||||
}
|
||||
for name, difference := range map[string]float64{
|
||||
"longitude": math.Abs(cached.Longitude - direct.Longitude),
|
||||
"latitude": math.Abs(cached.Latitude - direct.Latitude),
|
||||
"moon altitude": math.Abs(cached.MoonAltitude - direct.MoonAltitude),
|
||||
"width": math.Abs(cached.WidthKM - direct.WidthKM),
|
||||
} {
|
||||
if difference > 1e-10 {
|
||||
t.Fatalf("cached point %s differs by %.15g degrees or km", name, difference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hr4799OccultationCoordinateForTest() StarCoordinate {
|
||||
return StarCoordinate{
|
||||
ID: "HR 4799",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,450 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOccultationStationEnvelopeEqualTimeBranches(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
times []int
|
||||
segments int
|
||||
}{
|
||||
{"leading plateau", []int{0, 0, 0, 1, 2}, 1},
|
||||
{"interior plateau", []int{0, 1, 1, 1, 2}, 2},
|
||||
{"trailing plateau", []int{0, 1, 1, 1}, 1},
|
||||
{"entire plateau", []int{0, 0, 0}, 0},
|
||||
{"reverse branches", []int{2, 1, 1, 1, 0}, 2},
|
||||
{"ordinary fold", []int{0, 1, 2, 1, 0}, 2},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
points := make([]OccultationPathPoint, len(test.times))
|
||||
for index, seconds := range test.times {
|
||||
points[index] = OccultationPathPoint{Time: time.Unix(int64(seconds), 0), Longitude: float64(index)}
|
||||
}
|
||||
segments := occultationStationSplitEnvelopeAtTimeFolds(points)
|
||||
if len(segments) != test.segments {
|
||||
t.Fatalf("segments=%d, want %d", len(segments), test.segments)
|
||||
}
|
||||
for _, segment := range segments {
|
||||
for index := 1; index < len(segment); index++ {
|
||||
if !segment[index].Time.After(segment[index-1].Time) {
|
||||
t.Fatalf("non-increasing segment: %+v", segment)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every edge with elapsed time must survive the split unchanged.
|
||||
for index := 1; index < len(points); index++ {
|
||||
first, second := points[index-1], points[index]
|
||||
if first.Time.Equal(second.Time) {
|
||||
continue
|
||||
}
|
||||
if first.Time.After(second.Time) {
|
||||
first, second = second, first
|
||||
}
|
||||
found := false
|
||||
for _, segment := range segments {
|
||||
for offset := 1; offset < len(segment); offset++ {
|
||||
found = found || segment[offset-1] == first && segment[offset] == second
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("lost monotone edge %d", index)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationStationOracleRefinesPlanetContours(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
cases := []struct {
|
||||
name string
|
||||
start time.Time
|
||||
planet OccultationPlanet
|
||||
}{
|
||||
{name: "Mars-20250729", start: time.Date(2025, time.July, 29, 0, 0, 0, 0, zone), planet: OccultationMars},
|
||||
{name: "Saturn-20240725", start: time.Date(2024, time.July, 25, 0, 0, 0, 0, zone), planet: OccultationSaturn},
|
||||
}
|
||||
for _, test := range cases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
paths, err := FindPlanetOccultationPaths(test.start, test.start.Add(24*time.Hour), test.planet, OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, DisableRiseSet: true,
|
||||
})
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
config, ok := planetOccultationConfigFor(test.planet)
|
||||
if !ok {
|
||||
t.Fatalf("%s config unavailable", test.planet)
|
||||
}
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
cache.prepareLocalEphemeris(occultationTimeToTT(paths[0].Greatest.Time))
|
||||
checked := 0
|
||||
maxOffset := 0.0
|
||||
maxSeedResidual := 0.0
|
||||
unsolved := 0
|
||||
for _, contourSet := range []struct {
|
||||
name string
|
||||
contours [][]OccultationPathPoint
|
||||
frameAt occultationPathFrameFunc
|
||||
total bool
|
||||
}{
|
||||
{name: "partial", contours: occultationContactBandContoursWithAdditionalTimes(
|
||||
paths[0].Start, paths[0].End, centerTimeTT(paths[0].Start.Time), centerTimeTT(paths[0].End.Time),
|
||||
centerTimeTT(paths[0].Greatest.Time), cache.outerFrameAt,
|
||||
OccultationPathOptions{Step: 20 * time.Minute, DisableRiseSet: true}, time.UTC, nil,
|
||||
), frameAt: cache.outerFrameAt},
|
||||
{name: "total", contours: occultationContactBandContoursWithAdditionalTimes(
|
||||
paths[0].TotalStart, paths[0].TotalEnd, centerTimeTT(paths[0].TotalStart.Time), centerTimeTT(paths[0].TotalEnd.Time),
|
||||
centerTimeTT(paths[0].Greatest.Time), cache.totalFrameAt,
|
||||
OccultationPathOptions{Step: 20 * time.Minute, DisableRiseSet: true}, time.UTC, nil,
|
||||
), frameAt: cache.totalFrameAt, total: true},
|
||||
} {
|
||||
for _, contour := range contourSet.contours {
|
||||
if len(contour) == 0 {
|
||||
continue
|
||||
}
|
||||
stride := int(math.Max(1, math.Ceil(float64(len(contour))/12)))
|
||||
for index := stride; index+stride < len(contour); index += stride {
|
||||
seed := contour[index]
|
||||
sample, solved := occultationStationCorrectBoundaryPoint(
|
||||
centerTimeTT(seed.Time), seed, contourSet.frameAt, cache.riseSetContextAt,
|
||||
contourSet.total, time.UTC,
|
||||
)
|
||||
if !solved || !sample.valid {
|
||||
unsolved++
|
||||
continue
|
||||
}
|
||||
if math.Abs(sample.contactResidualDeg) > 1e-5 {
|
||||
t.Fatalf("%s contour sample %d contact residual=%.9g arcsec", contourSet.name, index, sample.contactResidualDeg)
|
||||
}
|
||||
maxOffset = math.Max(maxOffset, math.Abs(sample.offsetKM))
|
||||
maxSeedResidual = math.Max(maxSeedResidual, math.Abs(sample.seedResidualDeg))
|
||||
checked++
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked < 8 || unsolved > checked {
|
||||
t.Fatalf("checked %d station contour samples, unsolved=%d", checked, unsolved)
|
||||
}
|
||||
t.Logf("station oracle samples=%d unsolved=%d max offset=%.1f km max geocentric residual=%.6f arcsec", checked, unsolved, maxOffset, maxSeedResidual)
|
||||
if maxOffset > occultationStationOracleMaximumOffsetKM {
|
||||
t.Fatalf("max station correction offset=%.1f km exceeds oracle bound", maxOffset)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationStationCorrectedOpenFootprintEndpointsStayOnHorizon(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
|
||||
paths, err := FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), OccultationMars,
|
||||
OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
||||
RiseSetStep: time.Minute, DisableFootprints: true,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
config, _ := planetOccultationConfigFor(OccultationMars)
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
cache.preparePathEphemeris(occultationTimeToTT(paths[0].Greatest.Time), OccultationPathAlgorithmOptimized)
|
||||
horizonEndpoints, coneEndpoints := 0, 0
|
||||
for _, band := range []struct {
|
||||
name string
|
||||
frameAt occultationPathFrameFunc
|
||||
footprints []PlanetOccultationFootprint
|
||||
}{
|
||||
{name: "partial", frameAt: cache.outerFrameAt, footprints: paths[0].PartialBandFootprints},
|
||||
{name: "total", frameAt: cache.totalFrameAt, footprints: paths[0].TotalBandFootprints},
|
||||
} {
|
||||
for footprintIndex, footprint := range band.footprints {
|
||||
if footprint.Closed {
|
||||
continue
|
||||
}
|
||||
geocentric, geocentricOK := planetOccultationFootprintAtWithResolution(
|
||||
occultationTimeToTT(footprint.Time), band.frameAt, zone,
|
||||
planetOccultationBandBoundaryPoints, planetOccultationBandHorizonPoints,
|
||||
planetOccultationBandTargetSpacingKM,
|
||||
)
|
||||
if !geocentricOK {
|
||||
t.Fatalf("%s footprint %d has no geocentric support at %v", band.name, footprintIndex, footprint.Time)
|
||||
}
|
||||
for boundaryIndex, boundary := range footprint.Boundaries {
|
||||
if len(boundary) < 2 {
|
||||
continue
|
||||
}
|
||||
for _, pointIndex := range []int{0, len(boundary) - 1} {
|
||||
point := boundary[pointIndex]
|
||||
if math.Abs(point.MoonAltitude) <= 1e-5 {
|
||||
horizonEndpoints++
|
||||
continue
|
||||
}
|
||||
// 接触锥离开椭球处的尖点端点在地平线以上,地平线求解无法也
|
||||
// 不应移动它;此时端点必须与同一时刻的地心边界端点重合。
|
||||
if !planetOccultationFootprintHasGeocentricConeEndpoint(geocentric, point) {
|
||||
t.Fatalf(
|
||||
"%s footprint %d boundary %d endpoint %d MoonAltitude=%.9f deg is neither on the horizon nor a geocentric cone endpoint",
|
||||
band.name, footprintIndex, boundaryIndex, pointIndex, point.MoonAltitude,
|
||||
)
|
||||
}
|
||||
coneEndpoints++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if horizonEndpoints < 20 {
|
||||
t.Fatalf("checked only %d horizon endpoints", horizonEndpoints)
|
||||
}
|
||||
if coneEndpoints == 0 {
|
||||
t.Fatal("no cone-cusp endpoint observed; the cone-edge branch is untested")
|
||||
}
|
||||
}
|
||||
|
||||
func planetOccultationFootprintHasGeocentricConeEndpoint(
|
||||
footprint PlanetOccultationFootprint,
|
||||
point OccultationPathPoint,
|
||||
) bool {
|
||||
for _, boundary := range footprint.Boundaries {
|
||||
if len(boundary) < 2 {
|
||||
continue
|
||||
}
|
||||
for _, candidate := range []OccultationPathPoint{boundary[0], boundary[len(boundary)-1]} {
|
||||
// 锥体尖点端点在月球地平线以上,因此用“离开地平线”筛选地心对应点。
|
||||
if math.Abs(candidate.MoonAltitude) <= 1e-5 {
|
||||
continue
|
||||
}
|
||||
if occultationPathDistanceKM(candidate, point) <= 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestOccultationStationCorrectedContoursStayOnTemporalEnvelope(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
|
||||
paths, err := FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), OccultationMars,
|
||||
OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
||||
RiseSetStep: time.Minute, DisableFootprints: true,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
config, ok := planetOccultationConfigFor(OccultationMars)
|
||||
if !ok {
|
||||
t.Fatal("Mars config unavailable")
|
||||
}
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
cache.prepareLocalEphemeris(occultationTimeToTT(paths[0].Greatest.Time))
|
||||
|
||||
checked := 0
|
||||
failed := 0
|
||||
firstFailure := ""
|
||||
for _, band := range []struct {
|
||||
name string
|
||||
contours [][]OccultationPathPoint
|
||||
total bool
|
||||
}{
|
||||
{name: "partial", contours: paths[0].PartialBandContours},
|
||||
{name: "total", contours: paths[0].TotalBandContours, total: true},
|
||||
} {
|
||||
for contourIndex, contour := range band.contours {
|
||||
for pointIndex, point := range contour {
|
||||
tt := centerTimeTT(point.Time)
|
||||
contextAt := cache.riseSetContextAt
|
||||
if band.total {
|
||||
contextAt = cache.totalRiseSetContextAt
|
||||
}
|
||||
evaluation := occultationRiseSetEvaluation{
|
||||
tt: tt,
|
||||
center: contextAt(tt),
|
||||
before: contextAt(tt - occultationRiseSetDerivativeStepDays),
|
||||
after: contextAt(tt + occultationRiseSetDerivativeStepDays),
|
||||
}
|
||||
state := evaluation.center.stateAt(point.Longitude, point.Latitude)
|
||||
derivative := evaluation.contactDerivative(point.Longitude, point.Latitude)
|
||||
if !state.valid || !finite(derivative) {
|
||||
t.Fatalf("%s contour %d point %d has invalid station state", band.name, contourIndex, pointIndex)
|
||||
}
|
||||
if math.Abs(state.contactMetric) > 1e-5 || math.Abs(derivative) > occultationRiseSetJunctionDerivativeTolerance {
|
||||
failed++
|
||||
if firstFailure == "" {
|
||||
firstFailure = fmt.Sprintf(
|
||||
"%s contour %d point %d contact=%.9g arcsec derivative=%.9g arcsec/day",
|
||||
band.name, contourIndex, pointIndex, state.contactMetric, derivative,
|
||||
)
|
||||
}
|
||||
}
|
||||
checked++
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked < 100 {
|
||||
t.Fatalf("checked only %d station envelope points", checked)
|
||||
}
|
||||
if failed > 0 {
|
||||
t.Fatalf("%d/%d points miss the temporal envelope; first: %s", failed, checked, firstFailure)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationStationCorrectedContoursCloseOnVisiblePhaseJunctions(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
|
||||
paths, err := FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), OccultationMars,
|
||||
OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
||||
RiseSetStep: time.Minute, DisableFootprints: true,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
|
||||
for _, band := range []struct {
|
||||
name string
|
||||
contours [][]OccultationPathPoint
|
||||
curves []OccultationRiseSetCurve
|
||||
}{
|
||||
{name: "partial", contours: paths[0].PartialBandContours, curves: paths[0].RiseSetCurves},
|
||||
{name: "total", contours: paths[0].TotalBandContours, curves: paths[0].TotalRiseSetCurves},
|
||||
} {
|
||||
phasePoints := make([]OccultationPathPoint, 0)
|
||||
for _, curve := range band.curves {
|
||||
if curve.Phase != RiseSetPhaseStart && curve.Phase != RiseSetPhaseEnd {
|
||||
continue
|
||||
}
|
||||
for _, segment := range curve.Segments {
|
||||
phasePoints = append(phasePoints, segment...)
|
||||
}
|
||||
}
|
||||
if len(band.contours) == 0 || len(phasePoints) == 0 {
|
||||
t.Fatalf("%s has contours=%d phase points=%d, want visible envelope and phase boundary", band.name, len(band.contours), len(phasePoints))
|
||||
}
|
||||
for contourIndex, contour := range band.contours {
|
||||
if len(contour) < 2 {
|
||||
t.Fatalf("%s contour %d has %d points", band.name, contourIndex, len(contour))
|
||||
}
|
||||
for pointIndex, point := range contour {
|
||||
if point.MoonAltitude < -1e-7 {
|
||||
t.Fatalf("%s contour %d point %d is below the lunar horizon: altitude=%.9g", band.name, contourIndex, pointIndex, point.MoonAltitude)
|
||||
}
|
||||
if pointIndex > 0 && !point.Time.After(contour[pointIndex-1].Time) {
|
||||
t.Fatalf("%s contour %d times are not strictly increasing at point %d", band.name, contourIndex, pointIndex)
|
||||
}
|
||||
}
|
||||
for _, endpoint := range []OccultationPathPoint{contour[0], contour[len(contour)-1]} {
|
||||
if math.Abs(endpoint.MoonAltitude) <= 1e-6 {
|
||||
nearestKM := math.Inf(1)
|
||||
for _, phasePoint := range phasePoints {
|
||||
nearestKM = math.Min(nearestKM, occultationPathDistanceKM(endpoint, phasePoint))
|
||||
}
|
||||
if nearestKM > 0.1 {
|
||||
t.Fatalf("%s contour %d horizon endpoint is %.3f km from the start/end phase line, want <=0.1 km", band.name, contourIndex, nearestKM)
|
||||
}
|
||||
continue
|
||||
}
|
||||
shared := false
|
||||
for otherIndex, other := range band.contours {
|
||||
if otherIndex == contourIndex || len(other) < 2 {
|
||||
continue
|
||||
}
|
||||
for _, otherEndpoint := range []OccultationPathPoint{other[0], other[len(other)-1]} {
|
||||
if math.Abs(endpoint.Time.Sub(otherEndpoint.Time).Seconds()) <= 0.01 &&
|
||||
occultationPathDistanceKM(endpoint, otherEndpoint) <= 0.001 {
|
||||
shared = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !shared {
|
||||
t.Fatalf("%s contour %d non-horizon endpoint is not a shared temporal fold", band.name, contourIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationStationVisibilityContoursStayOnActiveTemporalMaximum(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
start := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone)
|
||||
paths, err := FindPlanetOccultationPaths(
|
||||
start, start.Add(24*time.Hour), OccultationSaturn,
|
||||
OccultationPathOptions{
|
||||
Step: 20 * time.Minute, TargetSpacingKM: 900,
|
||||
RiseSetStep: time.Minute, DisableFootprints: true,
|
||||
},
|
||||
)
|
||||
if err != nil || len(paths) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
|
||||
}
|
||||
config, ok := planetOccultationConfigFor(OccultationSaturn)
|
||||
if !ok {
|
||||
t.Fatal("Saturn config unavailable")
|
||||
}
|
||||
cache := newPlanetOccultationEventCache(config)
|
||||
cache.prepareLocalEphemeris(occultationTimeToTT(paths[0].Greatest.Time))
|
||||
for _, band := range []struct {
|
||||
name string
|
||||
contours [][]OccultationPathPoint
|
||||
total bool
|
||||
}{
|
||||
{name: "partial", contours: paths[0].PartialVisibilityContours},
|
||||
{name: "total", contours: paths[0].TotalVisibilityContours, total: true},
|
||||
} {
|
||||
if len(band.contours) == 0 {
|
||||
t.Fatalf("%s has no lunar-visibility temporal contour", band.name)
|
||||
}
|
||||
for contourIndex, contour := range band.contours {
|
||||
if len(contour) < 3 {
|
||||
t.Fatalf("%s contour %d has %d points", band.name, contourIndex, len(contour))
|
||||
}
|
||||
for pointIndex, point := range contour {
|
||||
tt := centerTimeTT(point.Time)
|
||||
context := cache.riseSetContextAt
|
||||
if band.total {
|
||||
context = func(value float64) occultationRiseSetContext {
|
||||
return cache.riseSetContextAt(value).withInternalContact()
|
||||
}
|
||||
}
|
||||
evaluation := occultationRiseSetEvaluation{
|
||||
tt: tt, center: context(tt),
|
||||
before: context(tt - occultationRiseSetDerivativeStepDays),
|
||||
after: context(tt + occultationRiseSetDerivativeStepDays),
|
||||
}
|
||||
state := evaluation.center.stateAt(point.Longitude, point.Latitude)
|
||||
altitudeDerivative := evaluation.moonAltitudeDerivative(point.Longitude, point.Latitude)
|
||||
altitudeSecondDerivative := evaluation.moonAltitudeSecondDerivative(point.Longitude, point.Latitude)
|
||||
if !state.valid || math.Abs(state.moonAltitude) > 1e-5 ||
|
||||
math.Abs(altitudeDerivative) > occultationRiseSetJunctionDerivativeTolerance ||
|
||||
altitudeSecondDerivative >= 0 || state.contactMetric > 1e-5 {
|
||||
t.Fatalf(
|
||||
"%s contour %d point %d residuals H=%.9g Ht=%.9g Htt=%.9g F=%.9g",
|
||||
band.name, contourIndex, pointIndex, state.moonAltitude,
|
||||
altitudeDerivative, altitudeSecondDerivative, state.contactMetric,
|
||||
)
|
||||
}
|
||||
}
|
||||
for _, endpoint := range []OccultationPathPoint{contour[0], contour[len(contour)-1]} {
|
||||
context := cache.riseSetContextAt(centerTimeTT(endpoint.Time))
|
||||
if band.total {
|
||||
context = context.withInternalContact()
|
||||
}
|
||||
state := context.stateAt(endpoint.Longitude, endpoint.Latitude)
|
||||
if !state.valid || math.Abs(state.contactMetric) > 1e-5 {
|
||||
t.Fatalf("%s contour %d endpoint contact residual=%.9g", band.name, contourIndex, state.contactMetric)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// occultationStationDensifyContours applies the map's projection metric after
|
||||
// station correction. The correction can move adjacent geocentric samples by
|
||||
// tens of kilometres, so a contour that was dense before correction can still
|
||||
// contain a long Web Mercator chord. A spatial arclength plane selects the
|
||||
// midpoint branch even where time reverses and a fixed-time solve is singular.
|
||||
// The refined curve is split again at time folds for the public time contract.
|
||||
func occultationStationDensifyContours(
|
||||
contours [][]OccultationPathPoint,
|
||||
cache *occultationRiseSetEvaluationCache,
|
||||
location *time.Location,
|
||||
) [][]OccultationPathPoint {
|
||||
if len(contours) == 0 || cache == nil {
|
||||
return contours
|
||||
}
|
||||
const (
|
||||
targetSpacingKM = 30.0
|
||||
maxDepth = 10
|
||||
)
|
||||
result := make([][]OccultationPathPoint, 0, len(contours))
|
||||
for _, contour := range contours {
|
||||
if len(contour) < 2 {
|
||||
continue
|
||||
}
|
||||
var refine func(OccultationPathPoint, OccultationPathPoint, int, *[]OccultationPathPoint)
|
||||
refine = func(start, end OccultationPathPoint, depth int, output *[]OccultationPathPoint) {
|
||||
if depth >= maxDepth || occultationStationProjectedSpacingKM(start, end) <= targetSpacingKM {
|
||||
*output = append(*output, end)
|
||||
return
|
||||
}
|
||||
startTT, endTT := centerTimeTT(start.Time), centerTimeTT(end.Time)
|
||||
chord := [3]float64{math.Remainder(end.Longitude-start.Longitude, 360), end.Latitude - start.Latitude,
|
||||
(endTT - startTT) * occultationStationEnvelopeTimeScale}
|
||||
length := math.Sqrt(dotSolarEclipse3(chord, chord))
|
||||
if length <= 1e-12 {
|
||||
*output = append(*output, end)
|
||||
return
|
||||
}
|
||||
predictor := [3]float64{start.Longitude + chord[0]/2, start.Latitude + chord[1]/2, chord[2] / 2}
|
||||
for index := range chord {
|
||||
chord[index] /= length
|
||||
}
|
||||
midpoint, _, ok := occultationStationCorrectEnvelopeArc(predictor, chord, startTT, cache,
|
||||
occultationStationEnvelopeModel{kind: occultationStationContactEnvelope}, (start.WidthKM+end.WidthKM)/2, location)
|
||||
if ok && occultationPathDistanceKM(start, midpoint.point) < occultationPathDistanceKM(start, end) &&
|
||||
occultationPathDistanceKM(midpoint.point, end) < occultationPathDistanceKM(start, end) {
|
||||
refine(start, midpoint.point, depth+1, output)
|
||||
refine(midpoint.point, end, depth+1, output)
|
||||
return
|
||||
}
|
||||
// Leave a failed local solve untouched. Retrying with an unrelated
|
||||
// branch would be less accurate than retaining the source sample.
|
||||
*output = append(*output, end)
|
||||
}
|
||||
refined := []OccultationPathPoint{contour[0]}
|
||||
for index := 1; index < len(contour); index++ {
|
||||
refine(refined[len(refined)-1], contour[index], 0, &refined)
|
||||
}
|
||||
result = append(result, occultationStationSplitEnvelopeAtTimeFolds(refined)...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func occultationStationProjectedSpacingKM(first, second OccultationPathPoint) float64 {
|
||||
const maxLatitude = 85.05112878
|
||||
firstLatitude := math.Max(-maxLatitude, math.Min(maxLatitude, first.Latitude)) * rad
|
||||
secondLatitude := math.Max(-maxLatitude, math.Min(maxLatitude, second.Latitude)) * rad
|
||||
longitudeDelta := math.Remainder(second.Longitude-first.Longitude, 360) * rad
|
||||
firstY := math.Log(math.Tan(math.Pi/4 + firstLatitude/2))
|
||||
secondY := math.Log(math.Tan(math.Pi/4 + secondLatitude/2))
|
||||
return 6378.1366 * math.Hypot(longitudeDelta, secondY-firstY)
|
||||
}
|
||||
@@ -50,6 +50,20 @@ func OrbitHourAngle(jde, observerLon, observerLat, timezone, observerHeight floa
|
||||
return hourAngle
|
||||
}
|
||||
|
||||
// OrbitHourAngleWithTopocentric 返回站心视时角及同一状态的站心视赤经、视赤纬 / hour angle with its topocentric state.
|
||||
//
|
||||
// jde 沿用 OrbitHourAngle 的当地时口径:函数内部先减 timezone/24 得世界时,再按 UT→TT 换算。
|
||||
// jde follows the local-time convention of OrbitHourAngle: timezone/24 is subtracted before the UT→TT conversion.
|
||||
func OrbitHourAngleWithTopocentric(jde, observerLon, observerLat, timezone, observerHeight float64, elements OrbitElements) (ra, dec, hourAngle float64) {
|
||||
ra, dec, _ = orbitTopocentricObservation(jde, observerLon, observerLat, observerHeight, timezone, elements)
|
||||
st := Limit360(ApparentSiderealTime(jde-timezone/24.0)*15 + observerLon)
|
||||
hourAngle = st - ra
|
||||
if hourAngle < 0 {
|
||||
hourAngle += 360
|
||||
}
|
||||
return ra, dec, hourAngle
|
||||
}
|
||||
|
||||
// OrbitCulminationTime 返回轨道目标的中天时刻,输入输出均沿用本仓库现有观测函数的 JD 语义。
|
||||
func OrbitCulminationTime(jde, observerLon, observerLat, timezone, observerHeight float64, elements OrbitElements) float64 {
|
||||
if !isFiniteFloat(jde) || !isFiniteFloat(observerLon) || !isFiniteFloat(observerLat) || !isFiniteFloat(timezone) || !isFiniteFloat(observerHeight) {
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSolarEclipseSarosFamilyRemainsFiniteAcrossFiveCenturies(t *testing.T) {
|
||||
base := JDECalc(2024, 4, 8)
|
||||
const sarosDays = 6585.321314
|
||||
for familyIndex := -28; familyIndex <= 28; familyIndex++ {
|
||||
seed := base + float64(familyIndex)*sarosDays
|
||||
result := SolarEclipse(seed)
|
||||
for name, value := range map[string]float64{
|
||||
"greatest": result.GreatestEclipse,
|
||||
"gamma": result.Gamma,
|
||||
"magnitude": result.Magnitude,
|
||||
"longitude": result.GreatestLongitude,
|
||||
"latitude": result.GreatestLatitude,
|
||||
} {
|
||||
if !finite(value) {
|
||||
t.Fatalf("saros family index %d %s=%v", familyIndex, name, value)
|
||||
}
|
||||
}
|
||||
if result.HasPartial && !(result.PartialBeginOnEarth <= result.GreatestEclipse &&
|
||||
result.GreatestEclipse <= result.PartialEndOnEarth) {
|
||||
t.Fatalf("saros family index %d partial window does not contain greatest: %+v", familyIndex, result)
|
||||
}
|
||||
if result.HasCentral && !(result.CentralBeginOnEarth <= result.GreatestEclipse &&
|
||||
result.GreatestEclipse <= result.CentralEndOnEarth) {
|
||||
t.Fatalf("saros family index %d central window does not contain greatest: %+v", familyIndex, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarEclipseRepresentativePathSeriesAreOrderedAndFinite(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
seed float64
|
||||
}{
|
||||
{name: "2009-07-22", seed: JDECalc(2009, 7, 22)},
|
||||
{name: "2010-01-15", seed: JDECalc(2010, 1, 15)},
|
||||
{name: "2014-04-29-non-central", seed: JDECalc(2014, 4, 29)},
|
||||
{name: "2023-04-20", seed: JDECalc(2023, 4, 20)},
|
||||
{name: "2043-10-03", seed: JDECalc(2043, 10, 3)},
|
||||
}
|
||||
for _, test := range cases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result := SolarEclipsePartialFootprints(test.seed, SolarEclipsePartialFootprintOptions{
|
||||
StepDays: 20.0 / 1440.0, BoundaryPoints: 24,
|
||||
CentralShadowStepDays: 20.0 / 1440.0,
|
||||
DisableRiseSetCurves: true,
|
||||
})
|
||||
if !result.Eclipse.HasPartial {
|
||||
t.Fatalf("expected partial eclipse, got %+v", result.Eclipse)
|
||||
}
|
||||
if test.name == "2014-04-29-non-central" &&
|
||||
(result.Eclipse.Centrality != SolarEclipseNonCentral || len(result.CentralBandSegments) == 0) {
|
||||
t.Fatalf("non-central eclipse lost centrality envelope: centrality=%s segments=%d",
|
||||
result.Eclipse.Centrality, len(result.CentralBandSegments))
|
||||
}
|
||||
assertSolarEclipseFootprintSeriesFinite(t, result.Footprints)
|
||||
assertSolarEclipseFootprintSeriesFinite(t, result.CentralShadowFootprints)
|
||||
assertSolarEclipseFootprintSeriesFinite(t, result.CentralBandFootprints)
|
||||
for index := 1; index < len(result.CentralBandSegments); index++ {
|
||||
if len(result.CentralBandSegments[index]) == 0 {
|
||||
t.Fatalf("central band segment %d is empty", index)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSolarEclipseFootprintSeriesFinite(t *testing.T, footprints []SolarEclipsePartialFootprint) {
|
||||
t.Helper()
|
||||
for index, footprint := range footprints {
|
||||
if !finite(footprint.JDE) {
|
||||
t.Fatalf("footprint %d has invalid JDE=%v", index, footprint.JDE)
|
||||
}
|
||||
for boundaryIndex, boundary := range footprint.Boundaries {
|
||||
if len(boundary) < 2 {
|
||||
t.Fatalf("footprint %d boundary %d has %d points", index, boundaryIndex, len(boundary))
|
||||
}
|
||||
for pointIndex, point := range boundary {
|
||||
if !finite(point.JDE) || !finite(point.Longitude) || !finite(point.Latitude) || !finite(point.SunAltitude) {
|
||||
t.Fatalf("footprint %d boundary %d point %d is invalid: %+v", index, boundaryIndex, pointIndex, point)
|
||||
}
|
||||
}
|
||||
}
|
||||
if footprint.Closed {
|
||||
totalPoints := 0
|
||||
for _, boundary := range footprint.Boundaries {
|
||||
totalPoints += len(boundary)
|
||||
}
|
||||
if totalPoints < 3 {
|
||||
t.Fatalf("closed footprint %d has only %d points", index, totalPoints)
|
||||
}
|
||||
if len(footprint.Boundaries) == 1 {
|
||||
boundary := footprint.Boundaries[0]
|
||||
if solarEclipsePathDistanceKM(boundary[0], boundary[len(boundary)-1]) > 5 {
|
||||
t.Fatalf("closed footprint %d has %.3f km endpoint gap", index,
|
||||
solarEclipsePathDistanceKM(boundary[0], boundary[len(boundary)-1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationFiniteDiskStatesRemainValidAcrossFiveCenturies(t *testing.T) {
|
||||
base := occultationTimeToTT(time.Date(2025, time.January, 5, 0, 0, 0, 0, time.UTC))
|
||||
for _, yearOffset := range []float64{-500, -250, 0, 250, 500} {
|
||||
tt := base + yearOffset*365.2425
|
||||
for _, planet := range []OccultationPlanet{
|
||||
OccultationMercury, OccultationVenus, OccultationMars,
|
||||
OccultationJupiter, OccultationSaturn, OccultationUranus, OccultationNeptune,
|
||||
} {
|
||||
config, ok := planetOccultationConfigFor(planet)
|
||||
if !ok {
|
||||
t.Fatalf("%s configuration unavailable", planet)
|
||||
}
|
||||
state := planetOccultationStateAt(tt, config, nil, -1)
|
||||
if !state.valid {
|
||||
t.Fatalf("year offset %.0f %s state is invalid: %+v", yearOffset, planet, state)
|
||||
}
|
||||
if !finite(state.externalContactMetric) || !finite(state.internalContactMetric) {
|
||||
t.Fatalf("year offset %.0f %s contact metrics are not finite: %+v", yearOffset, planet, state)
|
||||
}
|
||||
if state.internalContactMetric < state.externalContactMetric {
|
||||
t.Fatalf("year offset %.0f %s inner gap %.9f is below outer gap %.9f",
|
||||
yearOffset, planet, state.internalContactMetric, state.externalContactMetric)
|
||||
}
|
||||
}
|
||||
star := starOccultationEphemerisStateAt(tt, hr4799OccultationCoordinateForTest())
|
||||
if !star.valid || !finite(star.moonDistanceKM) || !finite(star.starRA) || !finite(star.starDec) {
|
||||
t.Fatalf("year offset %.0f star state is invalid: %+v", yearOffset, star)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOccultationRepresentativePathSeriesAreOrderedAndFinite(t *testing.T) {
|
||||
zone := time.FixedZone("UTC+8", 8*60*60)
|
||||
planetPaths, err := FindPlanetOccultationPaths(
|
||||
time.Date(2025, time.January, 5, 0, 0, 0, 0, zone),
|
||||
time.Date(2025, time.January, 6, 0, 0, 0, 0, zone),
|
||||
OccultationSaturn,
|
||||
OccultationPathOptions{Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, DisableRiseSet: true},
|
||||
)
|
||||
if err != nil || len(planetPaths) != 1 {
|
||||
t.Fatalf("planet paths=%d err=%v, want one", len(planetPaths), err)
|
||||
}
|
||||
assertOccultationPointSeriesFinite(t, planetPaths[0].CenterLine)
|
||||
assertOccultationPointSeriesFinite(t, planetPaths[0].NorthernLimit)
|
||||
assertOccultationPointSeriesFinite(t, planetPaths[0].SouthernLimit)
|
||||
if planetPaths[0].HasTotalBand {
|
||||
assertOccultationPointSeriesFinite(t, planetPaths[0].NorthernTotalLimit)
|
||||
assertOccultationPointSeriesFinite(t, planetPaths[0].SouthernTotalLimit)
|
||||
}
|
||||
|
||||
starPaths, err := FindStarOccultationPaths(
|
||||
time.Date(2025, time.June, 5, 0, 0, 0, 0, zone),
|
||||
time.Date(2025, time.June, 6, 0, 0, 0, 0, zone),
|
||||
hr4799OccultationCoordinateForTest(),
|
||||
OccultationPathOptions{Step: 20 * time.Minute, TargetSpacingKM: 900, DisableFootprints: true, DisableRiseSet: true},
|
||||
)
|
||||
if err != nil || len(starPaths) != 1 {
|
||||
t.Fatalf("star paths=%d err=%v, want one", len(starPaths), err)
|
||||
}
|
||||
assertOccultationPointSeriesFinite(t, starPaths[0].CenterLine)
|
||||
assertOccultationPointSeriesFinite(t, starPaths[0].NorthernLimit)
|
||||
assertOccultationPointSeriesFinite(t, starPaths[0].SouthernLimit)
|
||||
}
|
||||
|
||||
func assertOccultationPointSeriesFinite(t *testing.T, points []OccultationPathPoint) {
|
||||
t.Helper()
|
||||
if len(points) == 0 {
|
||||
t.Fatal("path series is empty")
|
||||
}
|
||||
for index, point := range points {
|
||||
if !finite(point.Longitude) || !finite(point.Latitude) || !finite(point.WidthKM) {
|
||||
t.Fatalf("point %d is not finite: %+v", index, point)
|
||||
}
|
||||
if index > 0 && !point.Time.After(points[index-1].Time) {
|
||||
t.Fatalf("path times are not strictly increasing at %d: %v then %v", index, points[index-1].Time, point.Time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarEclipseRepresentativePathPointsDoNotContainNaN(t *testing.T) {
|
||||
path := SolarEclipseCentralPath(JDECalc(2010, 1, 15), SolarEclipsePathOptions{StepDays: 20.0 / 1440.0})
|
||||
for index, point := range append(append(append([]SolarEclipsePathPoint{}, path.CenterLine...), path.NorthernLimit...), path.SouthernLimit...) {
|
||||
if math.IsNaN(point.Longitude) || math.IsNaN(point.Latitude) || math.IsNaN(point.JDE) {
|
||||
t.Fatalf("path point %d contains NaN: %+v", index, point)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package basic
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestSolarEclipseSarosPathSeriesRemainFiniteWithinBudget exercises actual
|
||||
// footprint and central-band generation at several points spanning roughly
|
||||
// five centuries. The P0 state test catches bad ephemeris values; this test
|
||||
// also walks the sampled path output and therefore covers the topology input
|
||||
// used by GeoJSON and SVG consumers.
|
||||
func TestSolarEclipseSarosPathSeriesRemainFiniteWithinBudget(t *testing.T) {
|
||||
const sarosDays = 6585.321314
|
||||
seed := JDECalc(2024, 4, 8)
|
||||
for _, familyIndex := range []int{-28, -21, -14, -7, 0, 7, 14, 21, 28} {
|
||||
familyIndex := familyIndex
|
||||
t.Run("saros-"+formatSignedRegressionIndex(familyIndex), func(t *testing.T) {
|
||||
result := SolarEclipsePartialFootprints(seed+float64(familyIndex)*sarosDays,
|
||||
SolarEclipsePartialFootprintOptions{
|
||||
StepDays: 20.0 / 1440.0,
|
||||
BoundaryPoints: 72,
|
||||
CentralShadowStepDays: 5.0 / 1440.0,
|
||||
DisableRiseSetCurves: true,
|
||||
})
|
||||
if !result.Eclipse.HasPartial || len(result.Footprints) == 0 {
|
||||
t.Fatalf("missing partial path: type=%s footprints=%d", result.Eclipse.Type, len(result.Footprints))
|
||||
}
|
||||
assertSolarEclipseP2Footprints(t, "partial", result.Footprints)
|
||||
assertSolarEclipseP2Footprints(t, "central-shadow", result.CentralShadowFootprints)
|
||||
assertSolarEclipseP2Footprints(t, "central-band", result.CentralBandFootprints)
|
||||
if result.Eclipse.Centrality == SolarEclipseNonCentral && result.Eclipse.Type != SolarEclipsePartial &&
|
||||
len(result.CentralBandFootprints) == 0 {
|
||||
t.Fatal("non-central annular/total event has no central-band samples")
|
||||
}
|
||||
if result.Eclipse.HasCentral {
|
||||
path := SolarEclipseCentralPath(seed+float64(familyIndex)*sarosDays,
|
||||
SolarEclipsePathOptions{StepDays: 20.0 / 1440.0, TargetSpacingKM: 500})
|
||||
if len(path.CenterLine) < 2 || len(path.NorthernLimit) < 2 || len(path.SouthernLimit) < 2 {
|
||||
t.Fatalf("central path is incomplete: center=%d north=%d south=%d",
|
||||
len(path.CenterLine), len(path.NorthernLimit), len(path.SouthernLimit))
|
||||
}
|
||||
assertSolarEclipseP2PointSeries(t, "center-line", path.CenterLine)
|
||||
assertSolarEclipseP2PointSeries(t, "north-limit", path.NorthernLimit)
|
||||
assertSolarEclipseP2PointSeries(t, "south-limit", path.SouthernLimit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarEclipseHighResolutionSamplingHonorsPointBudget(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
shadowStep float64
|
||||
}{
|
||||
{name: "partial-only"},
|
||||
{name: "partial-and-central-shadow", shadowStep: 1.0 / 86400.0},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result := SolarEclipsePartialFootprints(JDECalc(2024, 4, 8), SolarEclipsePartialFootprintOptions{
|
||||
StepDays: 1.0 / 86400.0,
|
||||
BoundaryPoints: solarEclipsePartialFootprintMaxBoundaryPoints,
|
||||
CentralShadowStepDays: test.shadowStep,
|
||||
DisableRiseSetCurves: true,
|
||||
})
|
||||
if len(result.Footprints) == 0 {
|
||||
t.Fatal("high-resolution request returned no partial footprints")
|
||||
}
|
||||
if result.BoundaryPoints < solarEclipsePartialFootprintMinBoundaryPoints ||
|
||||
result.BoundaryPoints > solarEclipsePartialFootprintMaxBoundaryPoints {
|
||||
t.Fatalf("effective boundary points=%d outside [%d,%d]", result.BoundaryPoints,
|
||||
solarEclipsePartialFootprintMinBoundaryPoints, solarEclipsePartialFootprintMaxBoundaryPoints)
|
||||
}
|
||||
totalPoints := 0
|
||||
for _, series := range [][]SolarEclipsePartialFootprint{result.Footprints, result.CentralShadowFootprints, result.CentralBandFootprints} {
|
||||
for _, footprint := range series {
|
||||
for _, boundary := range footprint.Boundaries {
|
||||
totalPoints += len(boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
if totalPoints > solarEclipsePartialFootprintMaxPointCount {
|
||||
t.Fatalf("high-resolution output points=%d, want <=%d", totalPoints, solarEclipsePartialFootprintMaxPointCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSolarEclipseP2Footprints(t *testing.T, name string, footprints []SolarEclipsePartialFootprint) {
|
||||
t.Helper()
|
||||
for footprintIndex, footprint := range footprints {
|
||||
if !finite(footprint.JDE) || len(footprint.Boundaries) == 0 {
|
||||
t.Fatalf("%s footprint %d is incomplete: %+v", name, footprintIndex, footprint)
|
||||
}
|
||||
for boundaryIndex, boundary := range footprint.Boundaries {
|
||||
if len(boundary) < 2 {
|
||||
t.Fatalf("%s footprint %d boundary %d has %d points", name, footprintIndex, boundaryIndex, len(boundary))
|
||||
}
|
||||
for pointIndex, point := range boundary {
|
||||
if !finite(point.JDE) || !finite(point.Longitude) || !finite(point.Latitude) ||
|
||||
!finite(point.SunAltitude) || point.Longitude < -180 || point.Longitude > 180 ||
|
||||
point.Latitude < -90 || point.Latitude > 90 {
|
||||
t.Fatalf("%s footprint %d boundary %d point %d is invalid: %+v",
|
||||
name, footprintIndex, boundaryIndex, pointIndex, point)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertSolarEclipseP2PointSeries(t *testing.T, name string, points []SolarEclipsePathPoint) {
|
||||
t.Helper()
|
||||
for index, point := range points {
|
||||
if !finite(point.JDE) || !finite(point.Longitude) || !finite(point.Latitude) ||
|
||||
!finite(point.SunAltitude) || !finite(point.WidthKM) || point.Longitude < -180 ||
|
||||
point.Longitude > 180 || point.Latitude < -90 || point.Latitude > 90 {
|
||||
t.Fatalf("%s point %d is invalid: %+v", name, index, point)
|
||||
}
|
||||
if index > 0 && point.JDE <= points[index-1].JDE {
|
||||
t.Fatalf("%s times are not strictly increasing at %d", name, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatSignedRegressionIndex(value int) string {
|
||||
if value >= 0 {
|
||||
return "+" + formatRegressionIndexMagnitude(value)
|
||||
}
|
||||
return "-" + formatRegressionIndexMagnitude(-value)
|
||||
}
|
||||
|
||||
func formatRegressionIndexMagnitude(value int) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
return string([]byte{'0' + byte(value/10), '0' + byte(value%10)})
|
||||
}
|
||||
@@ -60,15 +60,25 @@ func planetGeocentricPositionWithEarthN(planetIndex int, planetJD float64, ex, e
|
||||
}
|
||||
|
||||
func planetApparentGeocentricPositionN(planetIndex int, jd float64, n int) (planetGeocentricPosition, float64) {
|
||||
geo, tau, _ := planetApparentGeocentricPositionAndDistanceN(planetIndex, jd, n)
|
||||
return geo, tau
|
||||
}
|
||||
|
||||
func planetApparentGeocentricPositionAndDistanceN(
|
||||
planetIndex int,
|
||||
jd float64,
|
||||
n int,
|
||||
) (planetGeocentricPosition, float64, float64) {
|
||||
ex, ey, ez := earthHeliocentricXYZN(jd, n)
|
||||
geoNow := planetGeocentricPositionWithEarthN(planetIndex, jd, ex, ey, ez, n)
|
||||
tau := 0.0057755183 * math.Sqrt(geoNow.x*geoNow.x+geoNow.y*geoNow.y+geoNow.z*geoNow.z)
|
||||
distance := math.Sqrt(geoNow.x*geoNow.x + geoNow.y*geoNow.y + geoNow.z*geoNow.z)
|
||||
tau := 0.0057755183 * distance
|
||||
geo := planetGeocentricPositionWithEarthN(planetIndex, jd-tau, ex, ey, ez, n)
|
||||
baseLo := geo.lo
|
||||
baseBo := geo.bo
|
||||
geo.lo = Limit360(baseLo + GXCLo(baseLo, baseBo, jd)/3600.0 + Nutation2000Bi(jd))
|
||||
geo.bo = baseBo + GXCBo(baseLo, baseBo, jd)/3600.0
|
||||
return geo, tau
|
||||
return geo, tau, distance
|
||||
}
|
||||
|
||||
func planetTrueGeocentricPositionN(planetIndex int, jd float64, n int) (planetGeocentricPosition, float64) {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 大距口径契约:返回时刻必须是公开“视距角”函数(MercurySunElongation/VenusSunElongation)的极大,
|
||||
// 而不是忽略光行差与视位置修正的真距角极大。
|
||||
|
||||
type elongationObjectiveCase struct {
|
||||
name string
|
||||
elongate func(float64) float64
|
||||
next func(float64) float64
|
||||
last func(float64) float64
|
||||
}
|
||||
|
||||
var elongationObjectiveCases = []elongationObjectiveCase{
|
||||
{"Mercury", MercurySunElongation, NextMercuryGreatestElongation, LastMercuryGreatestElongation},
|
||||
{"Venus", VenusSunElongation, NextVenusGreatestElongation, LastVenusGreatestElongation},
|
||||
}
|
||||
|
||||
func elongationObjectiveSeeds() []float64 {
|
||||
seeds := make([]float64, 0, 24)
|
||||
for year := 2024; year <= 2027; year++ {
|
||||
for month := 1; month <= 12; month += 2 {
|
||||
seeds = append(seeds, JDECalc(year, month, 1))
|
||||
}
|
||||
}
|
||||
return seeds
|
||||
}
|
||||
|
||||
func elongationObjectiveTruth(elongate func(float64) float64, eventUT float64) float64 {
|
||||
left, right := eventUT-30.0/1440.0, eventUT+30.0/1440.0
|
||||
for i := 0; i < 200; i++ {
|
||||
third := (right - left) / 3
|
||||
if elongate(TD2UT(left+third, true)) <= elongate(TD2UT(right-third, true)) {
|
||||
left += third
|
||||
continue
|
||||
}
|
||||
right -= third
|
||||
}
|
||||
return (left + right) / 2
|
||||
}
|
||||
|
||||
func TestGreatestElongationMatchesPublicElongationExtremum(t *testing.T) {
|
||||
const toleranceMinutes = 2.0
|
||||
for _, tc := range elongationObjectiveCases {
|
||||
for _, seed := range elongationObjectiveSeeds() {
|
||||
for _, direction := range []struct {
|
||||
name string
|
||||
fn func(float64) float64
|
||||
}{{"next", tc.next}, {"last", tc.last}} {
|
||||
eventUT := direction.fn(seed)
|
||||
if math.IsNaN(eventUT) {
|
||||
t.Fatalf("%s %s at %.1f returned NaN", tc.name, direction.name, seed)
|
||||
}
|
||||
truth := elongationObjectiveTruth(tc.elongate, eventUT)
|
||||
if deviation := math.Abs(eventUT-truth) * 1440; deviation > toleranceMinutes {
|
||||
t.Fatalf("%s %s at %.1f = %.9f deviates %.2f min from the public elongation maximum",
|
||||
tc.name, direction.name, seed, eventUT, deviation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 金星大距的侧向判定必须与水星同口径(0.1 s 事件容差),不能沿用站事件的 0.5 s。
|
||||
//
|
||||
// 这些入口的查询参数是力学时,返回时刻是世界时:
|
||||
// 查询“事件后 0.3 秒”要把世界时事件先换成力学时再加偏移。
|
||||
func TestGreatestElongationLateralToleranceIsEventTolerance(t *testing.T) {
|
||||
const offsetSeconds = 0.3
|
||||
cases := []struct {
|
||||
name string
|
||||
next func(float64) float64
|
||||
last func(float64) float64
|
||||
}{
|
||||
{"MercuryEast", NextMercuryGreatestElongationEast, LastMercuryGreatestElongationEast},
|
||||
{"MercuryWest", NextMercuryGreatestElongationWest, LastMercuryGreatestElongationWest},
|
||||
{"VenusEast", NextVenusGreatestElongationEast, LastVenusGreatestElongationEast},
|
||||
{"VenusWest", NextVenusGreatestElongationWest, LastVenusGreatestElongationWest},
|
||||
}
|
||||
seeds := []float64{JDECalc(2024, 3, 1), JDECalc(2025, 7, 1), JDECalc(2026, 11, 1)}
|
||||
tolerance := exactQueryTTToleranceUT
|
||||
for _, tc := range cases {
|
||||
for _, seed := range seeds {
|
||||
event := tc.next(seed)
|
||||
if math.IsNaN(event) {
|
||||
t.Fatalf("%s at %.1f returned NaN", tc.name, seed)
|
||||
}
|
||||
eventTT := TD2UT(event, true)
|
||||
afterUT := event + offsetSeconds/86400.0
|
||||
if got := tc.next(eventTT + offsetSeconds/86400.0); got < afterUT-tolerance {
|
||||
t.Fatalf("%s: Next at event+%.1f s = %.9f returned an event %.3f s before the query",
|
||||
tc.name, offsetSeconds, got, (afterUT-got)*86400)
|
||||
}
|
||||
beforeUT := event - offsetSeconds/86400.0
|
||||
if got := tc.last(eventTT - offsetSeconds/86400.0); got > beforeUT+tolerance {
|
||||
t.Fatalf("%s: Last at event-%.1f s = %.9f returned an event %.3f s after the query",
|
||||
tc.name, offsetSeconds, got, (got-beforeUT)*86400)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 无东西侧参数的大距只算查询所在窗口那一侧;这里用「两侧都算再取极值」作差分对照,输出必须逐位一致。
|
||||
func TestGreatestElongationAnySideKeepsBothSideExtremum(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
next func(float64) float64
|
||||
last func(float64) float64
|
||||
eastNext, westNext func(float64) float64
|
||||
eastLast, westLast func(float64) float64
|
||||
}{
|
||||
{"Mercury", NextMercuryGreatestElongation, LastMercuryGreatestElongation,
|
||||
NextMercuryGreatestElongationEast, NextMercuryGreatestElongationWest,
|
||||
LastMercuryGreatestElongationEast, LastMercuryGreatestElongationWest},
|
||||
{"Venus", NextVenusGreatestElongation, LastVenusGreatestElongation,
|
||||
NextVenusGreatestElongationEast, NextVenusGreatestElongationWest,
|
||||
LastVenusGreatestElongationEast, LastVenusGreatestElongationWest},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
for jd := JDECalc(2024, 1, 1); jd <= JDECalc(2027, 1, 1); jd += 14 {
|
||||
east, west := tc.eastNext(jd), tc.westNext(jd)
|
||||
want := east
|
||||
if !sameEventJD(east, west) {
|
||||
want = earliestFiniteEventUT(east, west)
|
||||
}
|
||||
if got := tc.next(jd); got != want {
|
||||
t.Fatalf("%s Next(%.1f) = %.12f, want %.12f (both-side reference)", tc.name, jd, got, want)
|
||||
}
|
||||
east, west = tc.eastLast(jd), tc.westLast(jd)
|
||||
want = east
|
||||
if !sameEventJD(east, west) {
|
||||
want = latestFiniteEventUT(east, west)
|
||||
}
|
||||
if got := tc.last(jd); got != want {
|
||||
t.Fatalf("%s Last(%.1f) = %.12f, want %.12f (both-side reference)", tc.name, jd, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventAggregationKeepsFiniteCandidate(t *testing.T) {
|
||||
const jd = 2460310.5
|
||||
if got := earliestFiniteEventUT(math.NaN(), jd); got != jd {
|
||||
t.Fatalf("earliestFiniteEventUT(NaN, jd) = %v, want %v", got, jd)
|
||||
}
|
||||
if got := latestFiniteEventUT(jd, math.NaN()); got != jd {
|
||||
t.Fatalf("latestFiniteEventUT(jd, NaN) = %v, want %v", got, jd)
|
||||
}
|
||||
if got := earliestFiniteEventUT(math.NaN(), math.Inf(1)); !math.IsNaN(got) {
|
||||
t.Fatalf("earliestFiniteEventUT(NaN, +Inf) = %v, want NaN", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本切片(行星/月球事件层)的性能守护基准;改动热路径时必须给出改前→改后。
|
||||
|
||||
func BenchmarkPlanetEventGreatestElongationAnySide(b *testing.B) {
|
||||
jd := JDECalc(2025, 3, 1)
|
||||
b.Run("Mercury", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = NextMercuryGreatestElongation(jd)
|
||||
}
|
||||
})
|
||||
b.Run("Venus", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = NextVenusGreatestElongation(jd)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkPlanetEventOuterStationNonFiniteQuery(b *testing.B) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
fn func(float64) float64
|
||||
}{
|
||||
{"Mars", NextMarsProgradeToRetrograde},
|
||||
{"Jupiter", NextJupiterProgradeToRetrograde},
|
||||
{"Saturn", NextSaturnProgradeToRetrograde},
|
||||
{"Uranus", NextUranusProgradeToRetrograde},
|
||||
{"Neptune", NextNeptuneProgradeToRetrograde},
|
||||
} {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = tc.fn(math.NaN())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPlanetEventMoonMaximumDeclination(b *testing.B) {
|
||||
jd := JDECalc(2025, 3, 1)
|
||||
b.Run("Next", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = NextMoonMaximumNorthDeclination(jd)
|
||||
}
|
||||
})
|
||||
b.Run("Closest", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = ClosestMoonMaximumNorthDeclination(jd)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkPlanetEventLunarEclipse(b *testing.B) {
|
||||
jd := 2458860.0
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = LunarEclipse(jd)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPlanetEventJupiterGalileanCallisto(b *testing.B) {
|
||||
b.Run("NextTransit", func(b *testing.B) {
|
||||
jd := 2463143.9646
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = NextJupiterGalileanPhenomenonEvent(jd, 4, JupiterGalileanTransit)
|
||||
}
|
||||
})
|
||||
b.Run("ClosestOccultation", func(b *testing.B) {
|
||||
jd := 2463400.0
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = ClosestJupiterGalileanPhenomenonEvent(jd, 4, JupiterGalileanOccultation)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkPlanetEventJupiterSixEventsSameInstant(b *testing.B) {
|
||||
jd := JDECalc(2025, 3, 1)
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = NextJupiterConjunction(jd)
|
||||
_ = NextJupiterOpposition(jd)
|
||||
_ = NextJupiterEasternQuadrature(jd)
|
||||
_ = NextJupiterWesternQuadrature(jd)
|
||||
_ = NextJupiterProgradeToRetrograde(jd)
|
||||
_ = NextJupiterRetrogradeToPrograde(jd)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPlanetEventLunarEclipseDiagram(b *testing.B) {
|
||||
jd := 2458860.0
|
||||
options := LunarEclipseDiagramOptions{StepDays: 1.0 / 86400.0}
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = LunarEclipseDiagram(jd, options)
|
||||
}
|
||||
}
|
||||
|
||||
// 差分对照基准:改动前“两侧都算再取极值”的聚合方式。
|
||||
func BenchmarkPlanetEventGreatestElongationBothSidesReference(b *testing.B) {
|
||||
mercuryJD, venusJD := JDECalc(2025, 3, 1), JDECalc(2025, 3, 1)
|
||||
b.Run("Mercury", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = earliestFiniteEventUT(NextMercuryGreatestElongationEast(mercuryJD), NextMercuryGreatestElongationWest(mercuryJD))
|
||||
}
|
||||
})
|
||||
b.Run("Venus", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = earliestFiniteEventUT(NextVenusGreatestElongationEast(venusJD), NextVenusGreatestElongationWest(venusJD))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -91,3 +91,140 @@ func TestSharedObservationRejectsNonFiniteQuery(t *testing.T) {
|
||||
t.Fatalf("StarRiseTime error = %v, want ErrInvalidObservationInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoonMaximumDeclinationEventsRejectNonFiniteQuery(t *testing.T) {
|
||||
nonFinite := []struct {
|
||||
name string
|
||||
jd float64
|
||||
}{
|
||||
{"NaN", math.NaN()},
|
||||
{"+Inf", math.Inf(1)},
|
||||
{"-Inf", math.Inf(-1)},
|
||||
}
|
||||
events := []struct {
|
||||
name string
|
||||
fn func(float64) DeclinationEvent
|
||||
}{
|
||||
{"LastMoonMaximumNorthDeclination", LastMoonMaximumNorthDeclination},
|
||||
{"NextMoonMaximumNorthDeclination", NextMoonMaximumNorthDeclination},
|
||||
{"ClosestMoonMaximumNorthDeclination", ClosestMoonMaximumNorthDeclination},
|
||||
{"LastMoonMaximumSouthDeclination", LastMoonMaximumSouthDeclination},
|
||||
{"NextMoonMaximumSouthDeclination", NextMoonMaximumSouthDeclination},
|
||||
{"ClosestMoonMaximumSouthDeclination", ClosestMoonMaximumSouthDeclination},
|
||||
}
|
||||
for _, event := range events {
|
||||
for _, query := range nonFinite {
|
||||
// A non-finite query used to seed an out-of-range cycle index and spin in the
|
||||
// sampling sweep forever; it must return the zero event instead.
|
||||
got := event.fn(query.jd)
|
||||
if got.JDE != 0 || got.Declination != 0 {
|
||||
t.Fatalf("%s(%s) = %+v, want zero event", event.name, query.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The finite path must keep working.
|
||||
const jd = 2460310.5
|
||||
north := NextMoonMaximumNorthDeclination(jd)
|
||||
if !(north.JDE > jd) || north.Declination == 0 {
|
||||
t.Fatalf("NextMoonMaximumNorthDeclination(%v) = %+v, want a later event", jd, north)
|
||||
}
|
||||
south := LastMoonMaximumSouthDeclination(jd)
|
||||
if !(south.JDE <= jd) || south.Declination == 0 {
|
||||
t.Fatalf("LastMoonMaximumSouthDeclination(%v) = %+v, want an earlier event", jd, south)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOuterPlanetStationEventsRejectNonFiniteQuery(t *testing.T) {
|
||||
nonFinite := []struct {
|
||||
name string
|
||||
jd float64
|
||||
}{
|
||||
{"NaN", math.NaN()},
|
||||
{"+Inf", math.Inf(1)},
|
||||
{"-Inf", math.Inf(-1)},
|
||||
}
|
||||
events := []struct {
|
||||
name string
|
||||
fn func(float64) float64
|
||||
}{
|
||||
{"Mercury", NextMercuryProgradeToRetrograde},
|
||||
{"Mercury retrograde", NextMercuryRetrograde},
|
||||
{"Venus", NextVenusProgradeToRetrograde},
|
||||
{"Venus retrograde", NextVenusRetrograde},
|
||||
{"Mars", NextMarsProgradeToRetrograde},
|
||||
{"Jupiter", NextJupiterProgradeToRetrograde},
|
||||
{"Saturn", NextSaturnProgradeToRetrograde},
|
||||
{"Uranus", NextUranusProgradeToRetrograde},
|
||||
{"Neptune", NextNeptuneProgradeToRetrograde},
|
||||
{"Mars opposition", NextMarsOpposition},
|
||||
{"Jupiter opposition", NextJupiterOpposition},
|
||||
{"Saturn opposition", NextSaturnOpposition},
|
||||
{"Uranus opposition", NextUranusOpposition},
|
||||
{"Neptune opposition", NextNeptuneOpposition},
|
||||
{"Mars eastern quadrature", NextMarsEasternQuadrature},
|
||||
{"Jupiter eastern quadrature", NextJupiterEasternQuadrature},
|
||||
{"Saturn eastern quadrature", NextSaturnEasternQuadrature},
|
||||
{"Uranus eastern quadrature", NextUranusEasternQuadrature},
|
||||
{"Neptune eastern quadrature", NextNeptuneEasternQuadrature},
|
||||
}
|
||||
for _, event := range events {
|
||||
for _, query := range nonFinite {
|
||||
if got := event.fn(query.jd); !math.IsNaN(got) {
|
||||
t.Fatalf("%s(%s) = %v, want NaN", event.name, query.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOuterPlanetLastStationEventsRejectNonFiniteQuery(t *testing.T) {
|
||||
events := []struct {
|
||||
name string
|
||||
fn func(float64) float64
|
||||
}{
|
||||
{"LastMarsProgradeToRetrograde", LastMarsProgradeToRetrograde},
|
||||
{"LastMarsRetrogradeToPrograde", LastMarsRetrogradeToPrograde},
|
||||
{"LastJupiterProgradeToRetrograde", LastJupiterProgradeToRetrograde},
|
||||
{"LastJupiterRetrogradeToPrograde", LastJupiterRetrogradeToPrograde},
|
||||
{"LastSaturnProgradeToRetrograde", LastSaturnProgradeToRetrograde},
|
||||
{"LastSaturnRetrogradeToPrograde", LastSaturnRetrogradeToPrograde},
|
||||
{"LastUranusProgradeToRetrograde", LastUranusProgradeToRetrograde},
|
||||
{"LastUranusRetrogradeToPrograde", LastUranusRetrogradeToPrograde},
|
||||
{"LastNeptuneProgradeToRetrograde", LastNeptuneProgradeToRetrograde},
|
||||
{"LastNeptuneRetrogradeToPrograde", LastNeptuneRetrogradeToPrograde},
|
||||
}
|
||||
for _, event := range events {
|
||||
if got := event.fn(math.NaN()); !math.IsNaN(got) {
|
||||
t.Fatalf("%s(NaN) = %v, want NaN", event.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查询落在事件之后 1 秒:首候选必须被侧向不变量拒绝,第二候选也必须通过同一不变量。
|
||||
func TestOuterPlanetStationSecondCandidateKeepsSideInvariant(t *testing.T) {
|
||||
const offsetSeconds = 1.0
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func(float64) float64
|
||||
}{
|
||||
{"Mars", NextMarsProgradeToRetrograde},
|
||||
{"Jupiter", NextJupiterProgradeToRetrograde},
|
||||
{"Saturn", NextSaturnProgradeToRetrograde},
|
||||
{"Uranus", NextUranusProgradeToRetrograde},
|
||||
{"Neptune", NextNeptuneProgradeToRetrograde},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
event := tc.fn(JDECalc(2025, 1, 1))
|
||||
if math.IsNaN(event) {
|
||||
t.Fatalf("%s: no station event found", tc.name)
|
||||
}
|
||||
query := event + offsetSeconds/86400.0
|
||||
got := tc.fn(TD2UT(query, true))
|
||||
if math.IsNaN(got) {
|
||||
t.Fatalf("%s: second candidate rejected as NaN at %s", tc.name, JDE2Date(query))
|
||||
}
|
||||
if got < query-stationQueryToleranceUT {
|
||||
t.Fatalf("%s: returned %.9f before the query %.9f", tc.name, got, query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "b612.me/astro/tools"
|
||||
)
|
||||
|
||||
// 本文件是「行星相位事件(留/合/冲/方照)」的顺序不变量回归。
|
||||
//
|
||||
// 背景:类型化「留」的结果必须满足
|
||||
// Next*(q) >= q - tol、Last*(q) <= q + tol、返回的是真值事件、且不得跳过更近的同类型事件。
|
||||
// 2026-09 的 review 发现水星类型化「留」会跳过一个下合,导致 Last* 返回未来
|
||||
// (例如查询 TT 2008-01-22 时 LastMercuryProgradeToRetrograde 返回 2008-01-28)。
|
||||
// 真值来自对导出星历的独立二分扫描,不使用被测的 Next*/Last*,因此能独立复现该类缺陷。
|
||||
|
||||
const phaseInvariantToleranceDay = 0.6 / 86400.0 // 站事件同刻容差 0.5 s + 余量
|
||||
|
||||
type phaseTruthStation struct {
|
||||
jd float64
|
||||
p2r bool
|
||||
}
|
||||
|
||||
func phaseRate(ra func(float64) float64, jd float64) float64 {
|
||||
sub := ra(jd+0.01) - ra(jd-0.01)
|
||||
if sub > 180 {
|
||||
sub -= 360
|
||||
}
|
||||
if sub < -180 {
|
||||
sub += 360
|
||||
}
|
||||
return sub / 0.02
|
||||
}
|
||||
|
||||
func phaseTruthStations(ra func(float64) float64, jd0, jd1, step float64) []phaseTruthStation {
|
||||
rate := func(jd float64) float64 { return phaseRate(ra, jd) }
|
||||
var out []phaseTruthStation
|
||||
prevJD, prev := jd0, rate(jd0)
|
||||
for jd := jd0 + step; jd <= jd1; jd += step {
|
||||
cur := rate(jd)
|
||||
if prev*cur < 0 {
|
||||
left, right := prevJD, jd
|
||||
for i := 0; i < 100; i++ {
|
||||
middle := (left + right) / 2
|
||||
if middle == left || middle == right {
|
||||
break
|
||||
}
|
||||
if (prev < 0) == (rate(middle) < 0) {
|
||||
left = middle
|
||||
continue
|
||||
}
|
||||
right = middle
|
||||
}
|
||||
out = append(out, phaseTruthStation{jd: (left + right) / 2, p2r: prev > 0})
|
||||
}
|
||||
prevJD, prev = jd, cur
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type phaseStationCase struct {
|
||||
name string
|
||||
ra func(float64) float64
|
||||
nextP2R func(float64) float64
|
||||
lastP2R func(float64) float64
|
||||
nextR2P func(float64) float64
|
||||
lastR2P func(float64) float64
|
||||
step float64
|
||||
// radius 真值扫描半径(天):必须覆盖「查询在站后几秒 ⇒ 答案是下一个同名站」的距离。
|
||||
radius float64
|
||||
// queryRadius 参与构造查询的站距中心的最大距离(天)。
|
||||
queryRadius float64
|
||||
}
|
||||
|
||||
func phaseStationCases() []phaseStationCase {
|
||||
return []phaseStationCase{
|
||||
{"Mercury", MercuryApparentRa, NextMercuryProgradeToRetrograde, LastMercuryProgradeToRetrograde,
|
||||
NextMercuryRetrogradeToPrograde, LastMercuryRetrogradeToPrograde, 0.25, 700, 450},
|
||||
{"Mars", MarsApparentRa, NextMarsProgradeToRetrograde, LastMarsProgradeToRetrograde,
|
||||
NextMarsRetrogradeToPrograde, LastMarsRetrogradeToPrograde, 0.5, 1100, 250},
|
||||
{"Venus", VenusApparentRa, NextVenusProgradeToRetrograde, LastVenusProgradeToRetrograde,
|
||||
NextVenusRetrogradeToPrograde, LastVenusRetrogradeToPrograde, 0.5, 900, 250},
|
||||
{"Jupiter", JupiterApparentRa, NextJupiterProgradeToRetrograde, LastJupiterProgradeToRetrograde,
|
||||
NextJupiterRetrogradeToPrograde, LastJupiterRetrogradeToPrograde, 1, 700, 250},
|
||||
}
|
||||
}
|
||||
|
||||
func phaseEpochTT(year int, month time.Month, day int) float64 {
|
||||
return TD2UT(Date2JDE(time.Date(year, month, day, 0, 0, 0, 0, time.UTC)), true)
|
||||
}
|
||||
|
||||
// TestPlanetStationOrderInvariant 在固定的历史失败时点附近逐点检查顺序不变量。
|
||||
func TestPlanetStationOrderInvariant(t *testing.T) {
|
||||
epochs := map[string][]time.Time{
|
||||
"Mercury": {
|
||||
time.Date(2008, 1, 22, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2006, 2, 23, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2007, 2, 6, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2007, 11, 8, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2003, 9, 27, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(-209, 1, 14, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(-208, 12, 11, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
"Mars": {time.Date(2003, 7, 30, 0, 0, 0, 0, time.UTC)},
|
||||
"Venus": {time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC)},
|
||||
"Jupiter": {time.Date(2003, 7, 30, 0, 0, 0, 0, time.UTC)},
|
||||
}
|
||||
offsets := []float64{0, 1, -1, 30, -30, 300, -300, 3600, -3600, 86400, -86400, 10 * 86400, -10 * 86400, 30 * 86400, -30 * 86400}
|
||||
|
||||
for _, tc := range phaseStationCases() {
|
||||
for _, epoch := range epochs[tc.name] {
|
||||
center := phaseEpochTT(epoch.Year(), epoch.Month(), epoch.Day())
|
||||
stations := phaseTruthStations(tc.ra, center-tc.radius, center+tc.radius, tc.step)
|
||||
if len(stations) == 0 {
|
||||
t.Fatalf("%s: no truth stations near %s", tc.name, epoch.Format("2006-01-02"))
|
||||
}
|
||||
funcs := []struct {
|
||||
label string
|
||||
fn func(float64) float64
|
||||
next bool
|
||||
p2r bool
|
||||
}{
|
||||
{"NextP2R", tc.nextP2R, true, true},
|
||||
{"LastP2R", tc.lastP2R, false, true},
|
||||
{"NextR2P", tc.nextR2P, true, false},
|
||||
{"LastR2P", tc.lastR2P, false, false},
|
||||
}
|
||||
for _, f := range funcs {
|
||||
var queries []float64
|
||||
for _, st := range stations {
|
||||
if st.p2r != f.p2r || math.Abs(st.jd-center) > tc.queryRadius {
|
||||
continue
|
||||
}
|
||||
for _, off := range offsets {
|
||||
queries = append(queries, st.jd+off/86400.0)
|
||||
}
|
||||
}
|
||||
for _, q := range queries {
|
||||
gotUT := f.fn(q)
|
||||
if math.IsNaN(gotUT) {
|
||||
t.Fatalf("%s %s at %s returned NaN", tc.name, f.label,
|
||||
JDE2DateByZone(TD2UT(q, false), time.UTC, false).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
got := TD2UT(gotUT, true)
|
||||
// 1) 顺序不变量
|
||||
if f.next && got < q-phaseInvariantToleranceDay {
|
||||
t.Fatalf("%s %s at %s returned past event %s", tc.name, f.label,
|
||||
JDE2DateByZone(TD2UT(q, false), time.UTC, false).Format("2006-01-02 15:04:05"),
|
||||
JDE2DateByZone(gotUT, time.UTC, false).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
if !f.next && got > q+phaseInvariantToleranceDay {
|
||||
t.Fatalf("%s %s at %s returned future event %s", tc.name, f.label,
|
||||
JDE2DateByZone(TD2UT(q, false), time.UTC, false).Format("2006-01-02 15:04:05"),
|
||||
JDE2DateByZone(gotUT, time.UTC, false).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
// 2) 必须是真值事件
|
||||
nearest, nearestDev := math.NaN(), math.Inf(1)
|
||||
for _, st := range stations {
|
||||
if st.p2r != f.p2r {
|
||||
continue
|
||||
}
|
||||
if dev := math.Abs(st.jd - got); dev < nearestDev {
|
||||
nearest, nearestDev = st.jd, dev
|
||||
}
|
||||
}
|
||||
if nearestDev > 60.0/86400.0 {
|
||||
t.Fatalf("%s %s at %s returned non-event %.6f (nearest truth %.3f d away)", tc.name, f.label,
|
||||
JDE2DateByZone(TD2UT(q, false), time.UTC, false).Format("2006-01-02 15:04:05"), got, nearestDev)
|
||||
}
|
||||
_ = nearest
|
||||
// 3) 不得跳过更近的同类型事件
|
||||
for _, st := range stations {
|
||||
if st.p2r != f.p2r {
|
||||
continue
|
||||
}
|
||||
if f.next && st.jd > q+phaseInvariantToleranceDay && st.jd < got-60.0/86400.0 {
|
||||
t.Fatalf("%s %s at %s skipped %s", tc.name, f.label,
|
||||
JDE2DateByZone(TD2UT(q, false), time.UTC, false).Format("2006-01-02 15:04:05"),
|
||||
JDE2DateByZone(TD2UT(st.jd, false), time.UTC, false).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
if !f.next && st.jd < q-phaseInvariantToleranceDay && st.jd > got+60.0/86400.0 {
|
||||
t.Fatalf("%s %s at %s skipped %s", tc.name, f.label,
|
||||
JDE2DateByZone(TD2UT(q, false), time.UTC, false).Format("2006-01-02 15:04:05"),
|
||||
JDE2DateByZone(TD2UT(st.jd, false), time.UTC, false).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMercuryConjunctionNeverSkips 检查水星「合」搜索不会跨过更近的合
|
||||
// (历史缺陷:启发式跳 + 2 天走法会走满一个会合周期,跳过一次下合)。
|
||||
func TestMercuryConjunctionNeverSkips(t *testing.T) {
|
||||
delta := func(jd float64) float64 {
|
||||
sub := Limit360(MercuryApparentLo(jd) - HSunApparentLo(jd))
|
||||
if sub > 180 {
|
||||
sub -= 360
|
||||
}
|
||||
if sub < -180 {
|
||||
sub += 360
|
||||
}
|
||||
return sub
|
||||
}
|
||||
// 真值:局部细扫
|
||||
truth := func(jd0, jd1, step float64) []float64 {
|
||||
var out []float64
|
||||
prevJD, prev := jd0, delta(jd0)
|
||||
for jd := jd0 + step; jd <= jd1; jd += step {
|
||||
cur := delta(jd)
|
||||
if prev*cur < 0 {
|
||||
left, right := prevJD, jd
|
||||
for i := 0; i < 100; i++ {
|
||||
middle := (left + right) / 2
|
||||
if middle == left || middle == right {
|
||||
break
|
||||
}
|
||||
if (prev < 0) == (delta(middle) < 0) {
|
||||
left = middle
|
||||
continue
|
||||
}
|
||||
right = middle
|
||||
}
|
||||
out = append(out, (left+right)/2)
|
||||
}
|
||||
prevJD, prev = jd, cur
|
||||
}
|
||||
return out
|
||||
}
|
||||
for _, epoch := range []time.Time{
|
||||
time.Date(2008, 1, 22, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2006, 2, 23, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2007, 2, 6, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(-209, 1, 14, 0, 0, 0, 0, time.UTC),
|
||||
} {
|
||||
center := phaseEpochTT(epoch.Year(), epoch.Month(), epoch.Day())
|
||||
truthEvents := truth(center-400, center+400, 0.25)
|
||||
if len(truthEvents) < 5 {
|
||||
t.Fatalf("truth scan too sparse near %s: %d", epoch.Format("2006-01-02"), len(truthEvents))
|
||||
}
|
||||
for _, q := range []float64{center, center + 0.5, center + 12, center - 12, center + 60, center - 60} {
|
||||
for _, next := range []uint8{0, 1} {
|
||||
got := TD2UT(mercuryConjunction(q, next), true)
|
||||
if math.IsNaN(got) {
|
||||
t.Fatalf("mercuryConjunction(%.6f, %d) = NaN", q, next)
|
||||
}
|
||||
want := math.NaN()
|
||||
if next == 1 {
|
||||
for _, e := range truthEvents {
|
||||
if e >= q-0.1/86400.0 {
|
||||
want = e
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for i := len(truthEvents) - 1; i >= 0; i-- {
|
||||
if truthEvents[i] <= q+0.1/86400.0 {
|
||||
want = truthEvents[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if math.IsNaN(want) {
|
||||
continue
|
||||
}
|
||||
if math.Abs(got-want) > 60.0/86400.0 {
|
||||
t.Fatalf("mercuryConjunction(%.6f, %d) = %.6f want %.6f (%.3f d off) near %s",
|
||||
q, next, got, want, got-want, epoch.Format("2006-01-02"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGreatestElongationNoSkip 检查大距事件不会跳过相邻的极大。
|
||||
//
|
||||
// 口径说明:库内 Next*GreatestElongation 用的是「真距角」(mercuryTrueElongationN,
|
||||
// 不含光行差/视位置),公开的 MercurySunElongation 是「视距角」,两者极大时刻相差约 10 分钟。
|
||||
// 因此这里用视距角的极值作为参照,只做「不跳事件 / 顺序 / 是极值附近」的判定,
|
||||
// 匹配容差取 30 分钟,足以覆盖口径差又远小于任何真实跳事件(数十天)。
|
||||
func TestGreatestElongationNoSkip(t *testing.T) {
|
||||
type elongCase struct {
|
||||
name string
|
||||
elongate func(float64) float64
|
||||
next func(float64) float64
|
||||
last func(float64) float64
|
||||
}
|
||||
cases := []elongCase{
|
||||
{"Mercury", MercurySunElongation, NextMercuryGreatestElongation, LastMercuryGreatestElongation},
|
||||
{"Venus", VenusSunElongation, NextVenusGreatestElongation, LastVenusGreatestElongation},
|
||||
}
|
||||
jd0 := phaseEpochTT(2024, 1, 1)
|
||||
jd1 := phaseEpochTT(2029, 1, 1)
|
||||
for _, tc := range cases {
|
||||
// 真值:视距角的局部极大
|
||||
var maxima []float64
|
||||
prev, cur := tc.elongate(jd0), tc.elongate(jd0+0.5)
|
||||
for jd := jd0 + 1.0; jd <= jd1; jd += 0.5 {
|
||||
next := tc.elongate(jd)
|
||||
if cur >= prev && cur >= next && (cur > prev || cur > next) {
|
||||
left, right := jd-1.0, jd
|
||||
for i := 0; i < 60; i++ {
|
||||
third := (right - left) / 3
|
||||
if tc.elongate(left+third) <= tc.elongate(right-third) {
|
||||
left += third
|
||||
continue
|
||||
}
|
||||
right -= third
|
||||
}
|
||||
maxima = append(maxima, (left+right)/2)
|
||||
}
|
||||
prev, cur = cur, next
|
||||
}
|
||||
if len(maxima) < 5 {
|
||||
t.Fatalf("%s: too few elongation maxima (%d)", tc.name, len(maxima))
|
||||
}
|
||||
const matchTolerance = 30.0 / 1440.0
|
||||
for i, maximum := range maxima {
|
||||
// 查询落在极大前一天:Next 必须命中该极大(不得跳过)
|
||||
next := TD2UT(tc.next(maximum-1), true)
|
||||
if math.IsNaN(next) {
|
||||
t.Fatalf("%s: Next at %s returned NaN", tc.name, tmpPhaseDate(maximum-1))
|
||||
}
|
||||
if dev := math.Abs(next - maximum); dev > matchTolerance {
|
||||
t.Fatalf("%s: Next at %s = %s, expected the maximum %s (%.2f min off)",
|
||||
tc.name, tmpPhaseDate(maximum-1), tmpPhaseDate(next), tmpPhaseDate(maximum), dev*1440)
|
||||
}
|
||||
if next < maximum-1-phaseInvariantToleranceDay {
|
||||
t.Fatalf("%s: Next returned an event before the query", tc.name)
|
||||
}
|
||||
// 查询落在极大后一天:Last 必须命中该极大,Next 必须命中下一个极大
|
||||
last := TD2UT(tc.last(maximum+1), true)
|
||||
if dev := math.Abs(last - maximum); dev > matchTolerance {
|
||||
t.Fatalf("%s: Last at %s = %s, expected the previous maximum %s (%.2f min off)",
|
||||
tc.name, tmpPhaseDate(maximum+1), tmpPhaseDate(last), tmpPhaseDate(maximum), dev*1440)
|
||||
}
|
||||
if last > maximum+1+phaseInvariantToleranceDay {
|
||||
t.Fatalf("%s: Last returned an event after the query", tc.name)
|
||||
}
|
||||
if i+1 < len(maxima) {
|
||||
following := TD2UT(tc.next(maximum+1), true)
|
||||
if dev := math.Abs(following - maxima[i+1]); dev > matchTolerance {
|
||||
t.Fatalf("%s: Next at %s = %s, expected the following maximum %s (%.2f min off)",
|
||||
tc.name, tmpPhaseDate(maximum+1), tmpPhaseDate(following), tmpPhaseDate(maxima[i+1]), dev*1440)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tmpPhaseDate(jd float64) string {
|
||||
return JDE2DateByZone(TD2UT(jd, false), time.UTC, false).Format("2006-01-02 15:04")
|
||||
}
|
||||
@@ -43,6 +43,10 @@ func planetApparentDecManualN(planetIndex int, jd float64, n int) float64 {
|
||||
|
||||
func planetApparentRaDecManualN(planetIndex int, jd float64, n int) (float64, float64) {
|
||||
lo, bo := planetApparentLoBoN(planetIndex, jd, n)
|
||||
return planetApparentRaDecFromLoBo(jd, lo, bo)
|
||||
}
|
||||
|
||||
func planetApparentRaDecFromLoBo(jd, lo, bo float64) (float64, float64) {
|
||||
eps := TrueObliquity(jd)
|
||||
ra := math.Atan2((Sin(lo)*Cos(eps) - Tan(bo)*Sin(eps)), Cos(lo))
|
||||
ra = ra * 180 / math.Pi
|
||||
|
||||
@@ -8,6 +8,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNeverRise/ErrNeverSet 是几何口径:天体全天在地平线以下报 ErrNeverRise(无升起),
|
||||
// 全天在地平线以上报 ErrNeverSet(无落下)。名字描述“缺失的那个现象”,不是“被问的事件”;
|
||||
// 太阳、恒星、月球三条链路都按此约定,GetMoonRiseTime 因此在极昼返回 ErrNeverSet。
|
||||
ErrNeverRise = errors.New("rise event does not occur on this date")
|
||||
ErrNeverSet = errors.New("set event does not occur on this date")
|
||||
ErrNotOnThisDate = errors.New("rise/set event occurs on adjacent date")
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// RiseSetPhase 标识升落边界对应的局部事件阶段。
|
||||
// RiseSetPhase identifies the local event phase represented by a horizon curve.
|
||||
type RiseSetPhase string
|
||||
|
||||
// 升落边界上局部事件的三个阶段取值 / the three local event phase values on a rise/set boundary.
|
||||
const (
|
||||
RiseSetPhaseStart RiseSetPhase = "start"
|
||||
RiseSetPhaseGreatest RiseSetPhase = "greatest"
|
||||
RiseSetPhaseEnd RiseSetPhase = "end"
|
||||
)
|
||||
|
||||
// RiseSetDirection 标识边界上的目标天体正在升起还是落下。
|
||||
// RiseSetDirection identifies whether the occulted body is rising or setting.
|
||||
type RiseSetDirection string
|
||||
|
||||
// 边界上目标天体正在升起或落下 / whether the body is rising or setting along the boundary.
|
||||
const (
|
||||
RiseSetDirectionRise RiseSetDirection = "rise"
|
||||
RiseSetDirectionSet RiseSetDirection = "set"
|
||||
)
|
||||
|
||||
type riseSetCyclicValueFunc func(float64) (float64, bool)
|
||||
|
||||
// 折点处升落残差与零相切而不变号,纯符号扫描会整圈找不到根;极区相位曲线正是在
|
||||
// 这里断开。补根由调用方只在“分支内部空洞”上启用,因此不会改变分支端点语义。
|
||||
// A rise/set fold makes the residual touch zero without changing sign, so a pure
|
||||
// sign scan can return no root at all and a polar phase curve breaks apart there.
|
||||
// The caller enables fold recovery only for interior branch holes, so branch
|
||||
// endpoints keep their existing semantics.
|
||||
const riseSetFoldRootResidualToleranceDeg = 5e-4
|
||||
|
||||
func riseSetCyclicRoots(samples int, valueAt riseSetCyclicValueFunc) []float64 {
|
||||
return riseSetSignChangeRoots(samples, valueAt)
|
||||
}
|
||||
|
||||
// riseSetCyclicRootsWithFoldTolerance 在符号扫描为空时补出相切折点根。
|
||||
// foldTolerance 非正时与历史符号扫描完全一致;正值为折点根的残差极小值上限。
|
||||
// riseSetCyclicRootsWithFoldTolerance falls back to tangent fold roots when the
|
||||
// sign scan stays empty. A non-positive foldTolerance reproduces the historical
|
||||
// sign scan exactly; a positive value bounds the residual minimum accepted as a
|
||||
// fold root.
|
||||
func riseSetCyclicRootsWithFoldTolerance(samples int, foldTolerance float64, valueAt riseSetCyclicValueFunc) []float64 {
|
||||
roots := riseSetSignChangeRoots(samples, valueAt)
|
||||
if len(roots) > 0 || foldTolerance <= 0 {
|
||||
return roots
|
||||
}
|
||||
return riseSetFoldRoots(samples, foldTolerance, valueAt)
|
||||
}
|
||||
|
||||
// riseSetFoldRoots 取相邻采样 |残差| 的严格极小值为候选,由黄金分割核对区间极小值
|
||||
// 是否进入折点容差。
|
||||
// riseSetFoldRoots takes adjacent |residual| samples forming a strict local minimum
|
||||
// as candidates and lets the golden-section minimum decide whether the fold
|
||||
// tolerance is met.
|
||||
func riseSetFoldRoots(samples int, foldTolerance float64, valueAt riseSetCyclicValueFunc) []float64 {
|
||||
if samples < 12 {
|
||||
samples = 12
|
||||
}
|
||||
step := 2 * math.Pi / float64(samples)
|
||||
values := make([]float64, samples)
|
||||
valid := make([]bool, samples)
|
||||
for index := range values {
|
||||
values[index], valid[index] = valueAt(step * float64(index))
|
||||
valid[index] = valid[index] && finite(values[index])
|
||||
}
|
||||
roots := make([]float64, 0, 2)
|
||||
for index := range values {
|
||||
next := (index + 1) % samples
|
||||
previous := (index - 1 + samples) % samples
|
||||
following := (next + 1) % samples
|
||||
if !valid[index] || !valid[next] || !valid[previous] || !valid[following] {
|
||||
continue
|
||||
}
|
||||
leftValue, rightValue := math.Abs(values[index]), math.Abs(values[next])
|
||||
if math.Abs(values[previous]) <= leftValue || math.Abs(values[following]) <= rightValue {
|
||||
continue
|
||||
}
|
||||
if angle, ok := riseSetFoldRoot(step*float64(index), step*float64(next), foldTolerance, valueAt); ok {
|
||||
roots = append(roots, riseSetNormalizeRadians(angle))
|
||||
}
|
||||
}
|
||||
sort.Float64s(roots)
|
||||
return roots
|
||||
}
|
||||
|
||||
func riseSetSignChangeRoots(samples int, valueAt riseSetCyclicValueFunc) []float64 {
|
||||
if samples < 12 {
|
||||
samples = 12
|
||||
}
|
||||
step := 2 * math.Pi / float64(samples)
|
||||
values := make([]float64, samples)
|
||||
valid := make([]bool, samples)
|
||||
for index := range values {
|
||||
values[index], valid[index] = valueAt(step * float64(index))
|
||||
valid[index] = valid[index] && finite(values[index])
|
||||
}
|
||||
|
||||
roots := make([]float64, 0, 4)
|
||||
for index := range values {
|
||||
next := (index + 1) % samples
|
||||
if !valid[index] || !valid[next] {
|
||||
continue
|
||||
}
|
||||
left := step * float64(index)
|
||||
right := step * float64(index+1)
|
||||
leftValue, rightValue := values[index], values[next]
|
||||
if leftValue == 0 {
|
||||
roots = append(roots, riseSetNormalizeRadians(left))
|
||||
continue
|
||||
}
|
||||
if leftValue*rightValue > 0 {
|
||||
continue
|
||||
}
|
||||
for iteration := 0; iteration < 48 && right-left > 1e-11; iteration++ {
|
||||
middle := (left + right) / 2
|
||||
middleValue, ok := valueAt(riseSetNormalizeRadians(middle))
|
||||
if !ok || !finite(middleValue) {
|
||||
break
|
||||
}
|
||||
if leftValue*middleValue <= 0 {
|
||||
right, rightValue = middle, middleValue
|
||||
} else {
|
||||
left, leftValue = middle, middleValue
|
||||
}
|
||||
}
|
||||
roots = append(roots, riseSetNormalizeRadians((left+right)/2))
|
||||
}
|
||||
sort.Float64s(roots)
|
||||
unique := roots[:0]
|
||||
for _, root := range roots {
|
||||
if len(unique) == 0 || riseSetAngularDistance(root, unique[len(unique)-1]) > 1e-7 {
|
||||
unique = append(unique, root)
|
||||
}
|
||||
}
|
||||
if len(unique) > 1 && riseSetAngularDistance(unique[0], unique[len(unique)-1]) <= 1e-7 {
|
||||
unique = unique[:len(unique)-1]
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
// riseSetFoldRoot 用黄金分割在区间内最小化 |残差|,极小值进入容差时返回折点根。
|
||||
// riseSetFoldRoot minimizes |residual| inside the interval by golden section and
|
||||
// returns the fold root when the minimum stays inside the tolerance.
|
||||
func riseSetFoldRoot(left, right, tolerance float64, valueAt riseSetCyclicValueFunc) (float64, bool) {
|
||||
const goldenRatio = 0.6180339887498949
|
||||
valueAtAbs := func(angle float64) (float64, bool) {
|
||||
value, ok := valueAt(riseSetNormalizeRadians(angle))
|
||||
if !ok || !finite(value) {
|
||||
return 0, false
|
||||
}
|
||||
return math.Abs(value), true
|
||||
}
|
||||
x1 := right - goldenRatio*(right-left)
|
||||
x2 := left + goldenRatio*(right-left)
|
||||
f1, ok1 := valueAtAbs(x1)
|
||||
f2, ok2 := valueAtAbs(x2)
|
||||
if !ok1 || !ok2 {
|
||||
return 0, false
|
||||
}
|
||||
for iteration := 0; iteration < 48 && right-left > 1e-9; iteration++ {
|
||||
if f1 > f2 {
|
||||
left, x1, f1 = x1, x2, f2
|
||||
x2 = left + goldenRatio*(right-left)
|
||||
if f2, ok2 = valueAtAbs(x2); !ok2 {
|
||||
return 0, false
|
||||
}
|
||||
continue
|
||||
}
|
||||
right, x2, f2 = x2, x1, f1
|
||||
x1 = right - goldenRatio*(right-left)
|
||||
if f1, ok1 = valueAtAbs(x1); !ok1 {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
angle, minimum := (left+right)/2, math.Min(f1, f2)
|
||||
if minimum > tolerance {
|
||||
return 0, false
|
||||
}
|
||||
return angle, true
|
||||
}
|
||||
|
||||
type riseSetGeographicResidualFunc func(longitude, latitude float64) (float64, float64, bool)
|
||||
|
||||
func riseSetRefineGeographicRoot(
|
||||
longitude, latitude float64,
|
||||
residualAt riseSetGeographicResidualFunc,
|
||||
) (float64, float64, bool) {
|
||||
const finiteDifferenceDegrees = 1e-4
|
||||
for iteration := 0; iteration < 16; iteration++ {
|
||||
first, second, ok := residualAt(longitude, latitude)
|
||||
if !ok || !finite(first) || !finite(second) {
|
||||
return 0, 0, false
|
||||
}
|
||||
if math.Abs(first) <= 1e-11 && math.Abs(second) <= 1e-11 {
|
||||
return normalizeLongitude(longitude), latitude, true
|
||||
}
|
||||
firstLon, secondLon, lonOK := residualAt(longitude+finiteDifferenceDegrees, latitude)
|
||||
firstLat, secondLat, latOK := residualAt(longitude, latitude+finiteDifferenceDegrees)
|
||||
if !lonOK || !latOK {
|
||||
return 0, 0, false
|
||||
}
|
||||
a := (firstLon - first) / finiteDifferenceDegrees
|
||||
b := (firstLat - first) / finiteDifferenceDegrees
|
||||
c := (secondLon - second) / finiteDifferenceDegrees
|
||||
d := (secondLat - second) / finiteDifferenceDegrees
|
||||
determinant := a*d - b*c
|
||||
if !finite(determinant) || math.Abs(determinant) < 1e-18 {
|
||||
return 0, 0, false
|
||||
}
|
||||
deltaLongitude := (-first*d + b*second) / determinant
|
||||
deltaLatitude := (c*first - a*second) / determinant
|
||||
scale := math.Max(math.Abs(deltaLongitude), math.Abs(deltaLatitude))
|
||||
if scale > 5 {
|
||||
deltaLongitude *= 5 / scale
|
||||
deltaLatitude *= 5 / scale
|
||||
}
|
||||
longitude = normalizeLongitude(longitude + deltaLongitude)
|
||||
latitude += deltaLatitude
|
||||
if latitude <= -89.999999 || latitude >= 89.999999 || !finite(latitude) {
|
||||
return 0, 0, false
|
||||
}
|
||||
}
|
||||
first, second, ok := residualAt(longitude, latitude)
|
||||
return normalizeLongitude(longitude), latitude,
|
||||
ok && finite(first) && finite(second) && math.Abs(first) <= 1e-8 && math.Abs(second) <= 1e-8
|
||||
}
|
||||
|
||||
func riseSetHorizonPoint(centerLongitude, centerLatitude, angle float64) (float64, float64) {
|
||||
longitude := centerLongitude * math.Pi / 180
|
||||
latitude := centerLatitude * math.Pi / 180
|
||||
center := [3]float64{
|
||||
math.Cos(latitude) * math.Cos(longitude),
|
||||
math.Cos(latitude) * math.Sin(longitude),
|
||||
math.Sin(latitude),
|
||||
}
|
||||
reference := [3]float64{0, 0, 1}
|
||||
if math.Abs(center[2]) > 0.9 {
|
||||
reference = [3]float64{1, 0, 0}
|
||||
}
|
||||
first := riseSetUnitVector(riseSetCross(reference, center))
|
||||
second := riseSetUnitVector(riseSetCross(center, first))
|
||||
point := [3]float64{
|
||||
first[0]*math.Cos(angle) + second[0]*math.Sin(angle),
|
||||
first[1]*math.Cos(angle) + second[1]*math.Sin(angle),
|
||||
first[2]*math.Cos(angle) + second[2]*math.Sin(angle),
|
||||
}
|
||||
return normalizeLongitude(math.Atan2(point[1], point[0]) * 180 / math.Pi),
|
||||
math.Asin(math.Max(-1, math.Min(1, point[2]))) * 180 / math.Pi
|
||||
}
|
||||
|
||||
func riseSetCross(first, second [3]float64) [3]float64 {
|
||||
return [3]float64{
|
||||
first[1]*second[2] - first[2]*second[1],
|
||||
first[2]*second[0] - first[0]*second[2],
|
||||
first[0]*second[1] - first[1]*second[0],
|
||||
}
|
||||
}
|
||||
|
||||
func riseSetUnitVector(value [3]float64) [3]float64 {
|
||||
norm := math.Sqrt(value[0]*value[0] + value[1]*value[1] + value[2]*value[2])
|
||||
return [3]float64{value[0] / norm, value[1] / norm, value[2] / norm}
|
||||
}
|
||||
|
||||
func riseSetNormalizeRadians(value float64) float64 {
|
||||
value = math.Mod(value, 2*math.Pi)
|
||||
if value < 0 {
|
||||
value += 2 * math.Pi
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func riseSetAngularDistance(first, second float64) float64 {
|
||||
return math.Abs(math.Remainder(first-second, 2*math.Pi))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+62
-10
@@ -172,6 +172,9 @@ func LastSaturnWesternQuadrature(jde float64) float64 {
|
||||
}
|
||||
|
||||
func saturnRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOpposition bool) float64 {
|
||||
if !isFiniteFloat(oppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
oppositionTT := TD2UT(oppositionJD, true)
|
||||
startTT := oppositionTT
|
||||
endTT := oppositionTT
|
||||
@@ -183,49 +186,98 @@ func saturnRetrogradeAroundOpposition(oppositionJD float64, searchBeforeOppositi
|
||||
endTT = TD2UT(westernQuadratureUT, true)
|
||||
}
|
||||
bestJD := zeroEventInWindow(startTT, endTT, 2.0, 2.0, 30.0/86400.0, func(jd float64) float64 {
|
||||
return saturnRADerivativeN(jd, 1.0/86400.0, saturnEventSearchN)
|
||||
return saturnRADerivativeN(jd, stationDerivativeStepDay, saturnEventSearchN)
|
||||
}, func(jd float64) float64 {
|
||||
return saturnRADerivative(jd, 0.5/86400.0)
|
||||
return saturnRADerivative(jd, stationDerivativeStepDay)
|
||||
})
|
||||
return TD2UT(bestJD, false)
|
||||
}
|
||||
|
||||
func NextSaturnRetrogradeToPrograde(jde float64) float64 {
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := saturnConjunctionFull(jde, 180, 0)
|
||||
date := saturnRetrogradeAroundOpposition(lastOppositionJD, false)
|
||||
if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) {
|
||||
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
nextOppositionJD := saturnConjunctionFull(jde, 180, 1)
|
||||
return saturnRetrogradeAroundOpposition(nextOppositionJD, false)
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = saturnRetrogradeAroundOpposition(nextOppositionJD, false)
|
||||
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func LastSaturnRetrogradeToPrograde(jde float64) float64 {
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := saturnConjunctionFull(jde, 180, 0)
|
||||
date := saturnRetrogradeAroundOpposition(lastOppositionJD, false)
|
||||
if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) {
|
||||
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(lastOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
previousOppositionJD := saturnConjunctionFull(eventUTLastQueryTT(lastOppositionJD), 180, 0)
|
||||
return saturnRetrogradeAroundOpposition(previousOppositionJD, false)
|
||||
if !isFiniteFloat(previousOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = saturnRetrogradeAroundOpposition(previousOppositionJD, false)
|
||||
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func NextSaturnProgradeToRetrograde(jde float64) float64 {
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
nextOppositionJD := saturnConjunctionFull(jde, 180, 1)
|
||||
date := saturnRetrogradeAroundOpposition(nextOppositionJD, true)
|
||||
if sameEventUTQueryTT(date, jde) || eventUTQueryAfterOrEqual(date, jde) {
|
||||
if isFiniteFloat(date) && stationUTQueryAfterOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
followingOppositionJD := saturnConjunctionFull(eventUTNextQueryTT(nextOppositionJD), 180, 1)
|
||||
return saturnRetrogradeAroundOpposition(followingOppositionJD, true)
|
||||
if !isFiniteFloat(followingOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = saturnRetrogradeAroundOpposition(followingOppositionJD, true)
|
||||
if !isFiniteFloat(date) || !stationUTQueryAfterOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func LastSaturnProgradeToRetrograde(jde float64) float64 {
|
||||
if !isFiniteFloat(jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
nextOppositionJD := saturnConjunctionFull(jde, 180, 1)
|
||||
date := saturnRetrogradeAroundOpposition(nextOppositionJD, true)
|
||||
if sameEventUTQueryTT(date, jde) || eventUTQueryBeforeOrEqual(date, jde) {
|
||||
if isFiniteFloat(date) && stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return date
|
||||
}
|
||||
if !isFiniteFloat(nextOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
lastOppositionJD := saturnConjunctionFull(jde, 180, 0)
|
||||
return saturnRetrogradeAroundOpposition(lastOppositionJD, true)
|
||||
if !isFiniteFloat(lastOppositionJD) {
|
||||
return math.NaN()
|
||||
}
|
||||
date = saturnRetrogradeAroundOpposition(lastOppositionJD, true)
|
||||
if !isFiniteFloat(date) || !stationUTQueryBeforeOrEqual(date, jde) {
|
||||
return math.NaN()
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// 视恒星时是 UT 的纯函数,但月掩全球路径会在同一条计算链里反复向它求值:
|
||||
// 地球自转、升落上下文、测地投影各自按自己的调用点重算同一个瞬时。实测单场 Saturn
|
||||
// 2025-01-05 的请求里,191,517 次求值只对应 51,308 个不同的儒略日(重复距离中位数只有
|
||||
// 2 次调用),而每次求值都要完整算一遍 77 项 IAU2000B 章动。
|
||||
//
|
||||
// 这里用一张有界直接映射表把结果记下来:无分配、容量固定(4096 槽 × 24 字节),
|
||||
// 用 RWMutex 保证 C 共享库被宿主多线程调用时安全。表项记录写入时的 ΔT 世代,
|
||||
// 因此 astro.SetDeltaT 覆盖之后旧条目自然失效,不会返回陈旧恒星时。
|
||||
//
|
||||
// Apparent sidereal time is a pure function of UT, yet one occultation path query evaluates
|
||||
// it many times for the same instant from independent code paths (Earth rotation, rise/set
|
||||
// contexts, geodetic projection). A single Saturn 2025-01-05 request performed 191,517
|
||||
// evaluations for only 51,308 distinct Julian days, and each evaluation ran the full
|
||||
// 77-term IAU2000B nutation. This bounded direct-mapped memo removes that redundancy without
|
||||
// allocating: a fixed 4096-slot table guarded by an RWMutex, with the ΔT generation stored in
|
||||
// each entry so an astro.SetDeltaT override invalidates stale values instead of replaying them.
|
||||
// 4096 槽对单场月掩的 5 万余个不同儒略日而言明显偏小(重复距离中位数只有 2 次调用),
|
||||
// 这里扩到 16384 槽(16384×24 B = 384 KB,BSS 静态数组,不参与初始化)。
|
||||
const siderealMemoBits = 14
|
||||
|
||||
const siderealMemoSize = 1 << siderealMemoBits
|
||||
|
||||
type siderealMemoEntry struct {
|
||||
key uint64
|
||||
value float64
|
||||
generation uint64
|
||||
}
|
||||
|
||||
var (
|
||||
siderealMemoMu sync.RWMutex
|
||||
siderealMemoTable [siderealMemoSize]siderealMemoEntry
|
||||
siderealMemoHits uint64
|
||||
siderealMemoMisses uint64
|
||||
siderealMemoStores uint64
|
||||
)
|
||||
|
||||
// siderealMemoIndex 用高低位混合避免相邻儒略日落在相邻槽位而互相驱逐。
|
||||
// siderealMemoIndex mixes high and low bits so adjacent Julian days do not evict each other.
|
||||
func siderealMemoIndex(jd float64) uint64 {
|
||||
bits := math.Float64bits(jd)
|
||||
return (bits ^ (bits >> 29)) & (siderealMemoSize - 1)
|
||||
}
|
||||
|
||||
// siderealMemoLoad 返回缓存命中值;ΔT 世代不匹配时按未命中处理。
|
||||
// siderealMemoLoad returns a cached value; a generation mismatch counts as a miss.
|
||||
func siderealMemoLoad(jd float64) (float64, bool) {
|
||||
generation := deltaTGenerationValue()
|
||||
entry := &siderealMemoTable[siderealMemoIndex(jd)]
|
||||
siderealMemoMu.RLock()
|
||||
key, value, entryGeneration := entry.key, entry.value, entry.generation
|
||||
siderealMemoMu.RUnlock()
|
||||
if entryGeneration == generation && key == math.Float64bits(jd) {
|
||||
atomic.AddUint64(&siderealMemoHits, 1)
|
||||
return value, true
|
||||
}
|
||||
atomic.AddUint64(&siderealMemoMisses, 1)
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// siderealMemoStore 只在 ΔT 世代未变时写入:世代必须在**求值前**采样(见 siderealMemoGeneration),
|
||||
// 否则求值期间发生的 SetDeltaTFn 会把旧 ΔT 的结果打上新世代并长期回放。
|
||||
// siderealMemoStore writes only while the ΔT generation is unchanged; the generation must be sampled
|
||||
// before the evaluation, otherwise a SetDeltaTFn during the computation would stamp the old value
|
||||
// with the new generation and replay it.
|
||||
func siderealMemoStore(jd, value float64, generation uint64) {
|
||||
if deltaTGenerationValue() != generation {
|
||||
return
|
||||
}
|
||||
index := siderealMemoIndex(jd)
|
||||
siderealMemoMu.Lock()
|
||||
siderealMemoTable[index] = siderealMemoEntry{
|
||||
key: math.Float64bits(jd),
|
||||
value: value,
|
||||
generation: generation,
|
||||
}
|
||||
siderealMemoMu.Unlock()
|
||||
atomic.AddUint64(&siderealMemoStores, 1)
|
||||
}
|
||||
|
||||
// siderealMemoGeneration 在求值前采样 ΔT 世代,供 siderealMemoStore 校验。
|
||||
func siderealMemoGeneration() uint64 {
|
||||
return deltaTGenerationValue()
|
||||
}
|
||||
|
||||
// siderealMemoStats 返回命中/未命中/写入计数,供测试守护记忆表确实生效。
|
||||
func siderealMemoStats() (hits, misses, stores uint64) {
|
||||
return atomic.LoadUint64(&siderealMemoHits), atomic.LoadUint64(&siderealMemoMisses),
|
||||
atomic.LoadUint64(&siderealMemoStores)
|
||||
}
|
||||
|
||||
func resetSiderealMemo() {
|
||||
siderealMemoMu.Lock()
|
||||
for i := range siderealMemoTable {
|
||||
siderealMemoTable[i] = siderealMemoEntry{}
|
||||
}
|
||||
siderealMemoMu.Unlock()
|
||||
atomic.StoreUint64(&siderealMemoHits, 0)
|
||||
atomic.StoreUint64(&siderealMemoMisses, 0)
|
||||
atomic.StoreUint64(&siderealMemoStores, 0)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// directApparentSiderealTime2006 是记忆化之前的原始表达式,作为逐位对照。
|
||||
// directApparentSiderealTime2006 is the pre-memo expression, kept as a bit-exact reference.
|
||||
func directApparentSiderealTime2006(jd float64) float64 {
|
||||
return MeanSiderealTime2006(jd) + Nutation2000Bi(jd)*math.Cos(TrueObliquity(jd)*math.Pi/180)/15
|
||||
}
|
||||
|
||||
// directApparentSiderealTime1982 是 1982 模型的原始表达式,同样作为逐位对照。
|
||||
// directApparentSiderealTime1982 is the pre-refactor 1982 expression, kept as a bit-exact reference.
|
||||
func directApparentSiderealTime1982(jd float64) float64 {
|
||||
return MeanSiderealTime1982(jd) + Nutation2000Bi(jd)*math.Cos(TrueObliquity(jd)*math.Pi/180)/15
|
||||
}
|
||||
|
||||
func siderealMemoTestTimes() []float64 {
|
||||
var times []float64
|
||||
for _, year := range []int{-720, -100, 0, 1000, 1582, 1900, 2025, 2026, 3000, 5000} {
|
||||
times = append(times, JDECalc(year, 3, 7.25), JDECalc(year, 9, 20.5), JDECalc(year, 12, 31.75))
|
||||
}
|
||||
return times
|
||||
}
|
||||
|
||||
func TestApparentSiderealTimeMemoMatchesDirectSeries(t *testing.T) {
|
||||
times := siderealMemoTestTimes()
|
||||
firstPass := make([]float64, len(times))
|
||||
for index, jd := range times {
|
||||
got := ApparentSiderealTime2006(jd)
|
||||
if want := directApparentSiderealTime2006(jd); got != want {
|
||||
t.Fatalf("jd=%.6f sidereal=%v, want %v", jd, got, want)
|
||||
}
|
||||
if got1982, want1982 := ApparentSiderealTime1982(jd), directApparentSiderealTime1982(jd); got1982 != want1982 {
|
||||
t.Fatalf("jd=%.6f sidereal1982=%v, want %v", jd, got1982, want1982)
|
||||
}
|
||||
firstPass[index] = got
|
||||
}
|
||||
// 逆序再查一遍:命中记忆表也必须与首次计算逐位一致。
|
||||
// Query again in reverse: memo hits must stay bit-identical to the first evaluation.
|
||||
for index := len(times) - 1; index >= 0; index-- {
|
||||
if got := ApparentSiderealTime2006(times[index]); got != firstPass[index] {
|
||||
t.Errorf("jd=%.6f memoized sidereal=%v, want %v", times[index], got, firstPass[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApparentSiderealTimeMemoInvalidatedByDeltaTOverride(t *testing.T) {
|
||||
original := GetDeltaTFn()
|
||||
t.Cleanup(func() { SetDeltaTFn(original) })
|
||||
jd := JDECalc(2025, 1, 5.5)
|
||||
|
||||
baseline := ApparentSiderealTime2006(jd)
|
||||
if repeat := ApparentSiderealTime2006(jd); repeat != baseline {
|
||||
t.Fatalf("memoized sidereal=%v, want stable %v", repeat, baseline)
|
||||
}
|
||||
|
||||
SetDeltaTFn(func(date float64, isJDE bool) float64 { return 6000 })
|
||||
shifted := ApparentSiderealTime2006(jd)
|
||||
if shifted == baseline {
|
||||
t.Fatalf("ΔT override replayed the memoized value %v", shifted)
|
||||
}
|
||||
if want := directApparentSiderealTime2006(jd); shifted != want {
|
||||
t.Errorf("sidereal after ΔT override=%v, want %v", shifted, want)
|
||||
}
|
||||
|
||||
SetDeltaTFn(DefaultDeltaTv2)
|
||||
if restored := ApparentSiderealTime2006(jd); restored != baseline {
|
||||
t.Errorf("sidereal after restoring ΔT=%v, want %v", restored, baseline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApparentSiderealTimeMemoIsRaceFree(t *testing.T) {
|
||||
base := JDECalc(2025, 1, 5.5)
|
||||
var wait sync.WaitGroup
|
||||
for worker := 0; worker < 4; worker++ {
|
||||
wait.Add(1)
|
||||
go func(offset int) {
|
||||
defer wait.Done()
|
||||
for step := 0; step < 256; step++ {
|
||||
jd := base + float64(step)*1e-4 + float64(offset)*1e-7
|
||||
if got := ApparentSiderealTime2006(jd); got != directApparentSiderealTime2006(jd) {
|
||||
t.Errorf("jd=%.9f sidereal=%v, want %v", jd, got, directApparentSiderealTime2006(jd))
|
||||
return
|
||||
}
|
||||
}
|
||||
}(worker)
|
||||
}
|
||||
wait.Wait()
|
||||
}
|
||||
|
||||
// 记忆表只在 ΔT 世代未变时写入:世代必须在求值前采样,否则求值期间发生的 SetDeltaTFn
|
||||
// 会把旧 ΔT 的结果打上新世代并长期回放。
|
||||
func TestSiderealMemoRejectsStaleGenerationWrite(t *testing.T) {
|
||||
original := GetDeltaTFn()
|
||||
defer SetDeltaTFn(original)
|
||||
resetSiderealMemo()
|
||||
jd := 2460310.5
|
||||
generation := siderealMemoGeneration()
|
||||
SetDeltaTFn(func(date float64, isJd bool) float64 { return 200 })
|
||||
siderealMemoStore(jd, 12345.0, generation)
|
||||
if _, ok := siderealMemoLoad(jd); ok {
|
||||
t.Fatalf("stale-generation write was accepted")
|
||||
}
|
||||
// 世代未变时正常写入并命中。
|
||||
current := siderealMemoGeneration()
|
||||
siderealMemoStore(jd, 12345.0, current)
|
||||
value, ok := siderealMemoLoad(jd)
|
||||
if !ok || value != 12345.0 {
|
||||
t.Fatalf("current-generation write missed: value=%v ok=%v", value, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一批瞬时重复求值必须命中记忆表(否则扩表/世代逻辑等于没生效)。
|
||||
func TestSiderealMemoHitsRepeatedInstants(t *testing.T) {
|
||||
resetSiderealMemo()
|
||||
const distinct = 8192
|
||||
for offset := 0; offset < distinct; offset++ {
|
||||
_ = ApparentSiderealTime2006(2460310.5 + float64(offset)*0.25)
|
||||
}
|
||||
hitsBefore, missesBefore, _ := siderealMemoStats()
|
||||
if missesBefore < distinct || hitsBefore != 0 {
|
||||
t.Fatalf("first pass should miss every distinct instant: hits=%d misses=%d", hitsBefore, missesBefore)
|
||||
}
|
||||
for offset := 0; offset < distinct; offset++ {
|
||||
_ = ApparentSiderealTime2006(2460310.5 + float64(offset)*0.25)
|
||||
}
|
||||
hits, misses, _ := siderealMemoStats()
|
||||
if hits-hitsBefore < distinct*9/10 {
|
||||
t.Fatalf("second pass hit rate too low: hits=%d misses=%d (want >= 90%% of %d)",
|
||||
hits-hitsBefore, misses-missesBefore, distinct)
|
||||
}
|
||||
}
|
||||
+352
-28
@@ -12,7 +12,7 @@ const (
|
||||
SolarEclipseModelNASABulletinSplitK SolarEclipseRadiusModel = "nasa_bulletin_split_k"
|
||||
)
|
||||
|
||||
// SolarEclipseType 表示整场日食的全局食型。
|
||||
// SolarEclipseType 整场日食的全局食型。
|
||||
type SolarEclipseType string
|
||||
|
||||
const (
|
||||
@@ -63,6 +63,11 @@ type SolarEclipseResult struct {
|
||||
Magnitude float64
|
||||
// Gamma 是月影轴到地心的有符号最小距离,单位为地球赤道半径。
|
||||
Gamma float64
|
||||
// CentralDurationDays 是食甚点的中心食持续时间,单位为日;没有中心食时为 0。
|
||||
// 这是日食目录(如 NASA「Central Dur.」)采用的口径:食甚点的中心食时长。
|
||||
// CentralDurationDays is the central-phase duration at the greatest eclipse,
|
||||
// in days, and 0 when the event has no central phase.
|
||||
CentralDurationDays float64
|
||||
// PathWidthKM 是食甚点处中心食带宽度。非中心食时为 0。
|
||||
PathWidthKM float64
|
||||
|
||||
@@ -100,11 +105,37 @@ type solarEclipseSolver struct {
|
||||
model SolarEclipseRadiusModel
|
||||
params solarEclipseModelParameters
|
||||
|
||||
localStateContextCache map[uint64]localSolarEclipseStateContext
|
||||
localEphemeris *solarEclipseLocalEphemeris
|
||||
// deltaTSeconds 是调用方显式给出的 ΔT(秒);NaN 表示未覆盖,用进程级模型。
|
||||
// 只影响地球自转相位(轴的 gst),不改变任何 TT 时刻。
|
||||
deltaTSeconds float64
|
||||
besselGeometryCache map[uint64]solarEclipseBesselGeometryCacheEntry
|
||||
besselCandidateCache map[uint64]solarEclipseBesselGeometryCacheEntry
|
||||
exactCentralContact bool
|
||||
|
||||
meanSunMoonDistance float64
|
||||
penumbraConeTangent float64
|
||||
umbraConeTangent float64
|
||||
}
|
||||
|
||||
const solarEclipseBesselGeometryCacheMaximumEntries = movingDiskEventCacheMaximumEntries
|
||||
|
||||
// solarEclipseBesselGeometryCacheEntry keeps exact and candidate geometry in
|
||||
// separate maps. Candidate geometry is interpolated and is only suitable for
|
||||
// coarse scans; mixing it with exact geometry would silently reduce contact
|
||||
// and topology accuracy.
|
||||
type solarEclipseBesselGeometryCacheEntry struct {
|
||||
// generation 记录写入时的 ΔT 世代:轴里的 gst 由 ΔT 决定,ΔT 覆盖后条目必须失效。
|
||||
// generation is the ΔT generation at write time: the axis carries a ΔT-dependent
|
||||
// gst, so overriding ΔT has to invalidate the entry.
|
||||
generation uint64
|
||||
moon [3]float64
|
||||
axis solarEclipseAxis
|
||||
sun [3]float64
|
||||
valid bool
|
||||
}
|
||||
|
||||
type solarEclipseFeature struct {
|
||||
greatestEclipseJDE float64
|
||||
greatestLongitude float64
|
||||
@@ -132,19 +163,31 @@ type solarEclipseLineIntersection struct {
|
||||
|
||||
const (
|
||||
solarEclipseEarthEquatorialRadiusKM = 6378.1366
|
||||
solarEclipseEarthPolarRatio = 0.99664719
|
||||
solarEclipseEarthPolarRatioSquared = solarEclipseEarthPolarRatio * solarEclipseEarthPolarRatio
|
||||
solarEclipseAstronomicalUnitKM = 1.49597870691e8
|
||||
// 赤道自转线速度,用于把 ΔT 误差换算成地面横移(见 DeltaTGroundShiftKM)。
|
||||
// Equatorial rotation speed, used to convert a ΔT error into ground displacement.
|
||||
solarEclipseEarthEquatorialRotationKMPerSecond = 0.4651
|
||||
solarEclipseEarthPolarRatio = 0.99664719
|
||||
solarEclipseEarthPolarRatioSquared = solarEclipseEarthPolarRatio * solarEclipseEarthPolarRatio
|
||||
solarEclipseAstronomicalUnitKM = 1.49597870691e8
|
||||
|
||||
// IAU Single-K 对所有接触统一使用 0.2725076;
|
||||
// NASA bulletin Split-K 对半影仍使用 0.2725076,对本影/反本影使用 0.2722810。
|
||||
solarEclipseSolarRadiusRatio = 109.1222
|
||||
solarEclipsePenumbralK = 0.2725076
|
||||
solarEclipseUmbralK = 0.2722810
|
||||
// SolarEclipsePenumbralK 与 SolarEclipseUmbralK 是月面半径与地球赤道半径之比,
|
||||
// 即 NASA 星历表里的 k1(半影)与 k2(本影/反本影);IAU Single-K 两者都用 k1。
|
||||
// SolarEclipsePenumbralK and SolarEclipseUmbralK are the lunar-to-terrestrial radius ratios
|
||||
// published as k1 (penumbra) and k2 (umbra/antumbra); IAU Single-K uses k1 for both.
|
||||
SolarEclipsePenumbralK = solarEclipsePenumbralK
|
||||
SolarEclipseUmbralK = solarEclipseUmbralK
|
||||
|
||||
solarEclipseNodeCount = 7
|
||||
solarEclipseNodeStepDays = 0.04
|
||||
solarEclipseMoonLonAberrRad = -3.4e-6
|
||||
solarEclipseNodeCount = 7
|
||||
solarEclipseNodeStepDays = 0.04
|
||||
solarEclipseMoonLonAberrRad = -3.4e-6
|
||||
solarEclipseAxisContactInitialStepDays = 1.0 / 86400.0
|
||||
solarEclipseAxisContactMaximumStepDays = 30.0 / 1440.0
|
||||
solarEclipseAxisContactToleranceDays = 1e-9
|
||||
|
||||
// 这两个系数沿用经典贝塞尔近似中的极区有效半径经验值。
|
||||
solarEclipseNonCentralLimit = 0.9972
|
||||
@@ -169,8 +212,21 @@ func SolarEclipseNASABulletinSplitK(seedJDE float64) SolarEclipseResult {
|
||||
}
|
||||
|
||||
func solarEclipse(seedJDE float64, model SolarEclipseRadiusModel) SolarEclipseResult {
|
||||
return solarEclipseWithDeltaT(seedJDE, model, 0)
|
||||
}
|
||||
|
||||
func solarEclipseWithDeltaT(
|
||||
seedJDE float64,
|
||||
model SolarEclipseRadiusModel,
|
||||
deltaTSeconds float64,
|
||||
) SolarEclipseResult {
|
||||
newMoonJDE := CalcMoonSHByJDE(seedJDE, 0)
|
||||
solver := newSolarEclipseSolver(newMoonJDE, model)
|
||||
solver := newSolarEclipseSolver(newMoonJDE, model).withDeltaTSeconds(deltaTSeconds)
|
||||
return solver.eclipseResult()
|
||||
}
|
||||
|
||||
func (solver solarEclipseSolver) eclipseResult() SolarEclipseResult {
|
||||
model := solver.model
|
||||
feature := solver.feature()
|
||||
|
||||
result := SolarEclipseResult{
|
||||
@@ -213,6 +269,7 @@ func solarEclipse(seedJDE float64, model SolarEclipseRadiusModel) SolarEclipseRe
|
||||
result.HasCentral = true
|
||||
result.CentralBeginOnEarth = feature.centralBeginJDE
|
||||
result.CentralEndOnEarth = feature.centralEndJDE
|
||||
result.CentralDurationDays = solver.greatestCentralDuration(result)
|
||||
}
|
||||
|
||||
switch result.Type {
|
||||
@@ -229,6 +286,18 @@ func solarEclipse(seedJDE float64, model SolarEclipseRadiusModel) SolarEclipseRe
|
||||
return result
|
||||
}
|
||||
|
||||
// greatestCentralDuration 在食甚点解一次站心中心食并返回中心相时长(日);没有中心相时为 0。
|
||||
// greatestCentralDuration solves the local eclipse at the greatest eclipse point and
|
||||
// returns its central-phase duration in days.
|
||||
func (solver solarEclipseSolver) greatestCentralDuration(result SolarEclipseResult) float64 {
|
||||
if !result.HasCentral || result.GreatestEclipse <= 0 {
|
||||
return 0
|
||||
}
|
||||
return solver.centralPhaseDurationDaysAt(
|
||||
result.GreatestEclipse, result.GreatestLongitude, result.GreatestLatitude,
|
||||
)
|
||||
}
|
||||
|
||||
func newSolarEclipseSolver(newMoonJDE float64, model SolarEclipseRadiusModel) solarEclipseSolver {
|
||||
params := solarEclipseModelParameters{
|
||||
penumbralK: solarEclipsePenumbralK,
|
||||
@@ -246,22 +315,67 @@ func newSolarEclipseSolver(newMoonJDE float64, model SolarEclipseRadiusModel) so
|
||||
meanSunMoonDistance := ((firstSun[2] + lastSun[2]) - (firstMoon[2] + lastMoon[2])) / 2 / solarEclipseEarthEquatorialRadiusKM
|
||||
|
||||
return solarEclipseSolver{
|
||||
newMoonJDE: newMoonJDE,
|
||||
model: model,
|
||||
params: params,
|
||||
meanSunMoonDistance: meanSunMoonDistance,
|
||||
penumbraConeTangent: (solarEclipseSolarRadiusRatio + params.penumbralK) / meanSunMoonDistance,
|
||||
umbraConeTangent: (solarEclipseSolarRadiusRatio - params.umbralK) / meanSunMoonDistance,
|
||||
newMoonJDE: newMoonJDE,
|
||||
model: model,
|
||||
params: params,
|
||||
deltaTSeconds: math.NaN(),
|
||||
localStateContextCache: make(map[uint64]localSolarEclipseStateContext),
|
||||
besselGeometryCache: make(map[uint64]solarEclipseBesselGeometryCacheEntry),
|
||||
besselCandidateCache: make(map[uint64]solarEclipseBesselGeometryCacheEntry),
|
||||
meanSunMoonDistance: meanSunMoonDistance,
|
||||
penumbraConeTangent: (solarEclipseSolarRadiusRatio + params.penumbralK) / meanSunMoonDistance,
|
||||
umbraConeTangent: (solarEclipseSolarRadiusRatio - params.umbralK) / meanSunMoonDistance,
|
||||
}
|
||||
}
|
||||
|
||||
// withDeltaTSeconds 固定本求解器使用的 ΔT(秒),非正值表示回到进程级模型。覆盖会改变
|
||||
// 轴里的 gst,因此所有按精确 float 位键控的几何缓存必须同时作废。
|
||||
func (solver solarEclipseSolver) withDeltaTSeconds(deltaTSeconds float64) solarEclipseSolver {
|
||||
if deltaTSeconds <= 0 || math.IsNaN(deltaTSeconds) || math.IsInf(deltaTSeconds, 0) {
|
||||
deltaTSeconds = math.NaN()
|
||||
}
|
||||
solver.deltaTSeconds = deltaTSeconds
|
||||
solver.besselGeometryCache = make(map[uint64]solarEclipseBesselGeometryCacheEntry)
|
||||
solver.besselCandidateCache = make(map[uint64]solarEclipseBesselGeometryCacheEntry)
|
||||
solver.localStateContextCache = make(map[uint64]localSolarEclipseStateContext)
|
||||
return solver
|
||||
}
|
||||
|
||||
// effectiveDeltaTSeconds 返回本求解器在某 TT 时刻实际使用的 ΔT(秒)。
|
||||
func (solver solarEclipseSolver) effectiveDeltaTSeconds(jd float64) float64 {
|
||||
if math.IsNaN(solver.deltaTSeconds) {
|
||||
return DeltaT(jd, true)
|
||||
}
|
||||
return solver.deltaTSeconds
|
||||
}
|
||||
|
||||
// siderealTimeAt 返回某 TT 时刻的视恒星时(弧度),ΔT 覆盖时同样生效。
|
||||
func (solver solarEclipseSolver) siderealTimeAt(jd float64) float64 {
|
||||
utJDE := TD2UT(jd, false)
|
||||
if !math.IsNaN(solver.deltaTSeconds) {
|
||||
utJDE = jd - solver.deltaTSeconds/86400
|
||||
}
|
||||
return ApparentSiderealTime(utJDE) * 15 * rad
|
||||
}
|
||||
|
||||
// withLocalEphemeris prepares the immutable event-local interpolator used by
|
||||
// coarse candidate scans. The exact ephemeris remains the fallback outside its
|
||||
// bounded window and is used by all contact and topology refinements.
|
||||
func (solver solarEclipseSolver) withLocalEphemeris() solarEclipseSolver {
|
||||
if solver.localEphemeris == nil {
|
||||
solver.localEphemeris = newSolarEclipseLocalEphemeris(solver.newMoonJDE)
|
||||
}
|
||||
return solver
|
||||
}
|
||||
|
||||
func (solver solarEclipseSolver) feature() solarEclipseFeature {
|
||||
const finiteDifferenceStep = 0.04
|
||||
candidateSolver := solver.withLocalEphemeris()
|
||||
|
||||
jd := solver.newMoonJDE
|
||||
before := solver.besselMoonAt(jd - finiteDifferenceStep)
|
||||
center := solver.besselMoonAt(jd)
|
||||
after := solver.besselMoonAt(jd + finiteDifferenceStep)
|
||||
before := candidateSolver.besselMoonCandidateAt(jd - finiteDifferenceStep)
|
||||
center := candidateSolver.besselMoonCandidateAt(jd)
|
||||
after := candidateSolver.besselMoonCandidateAt(jd + finiteDifferenceStep)
|
||||
|
||||
vx := (after[0] - before[0]) / (2 * finiteDifferenceStep)
|
||||
vy := (after[1] - before[1]) / (2 * finiteDifferenceStep)
|
||||
@@ -271,9 +385,15 @@ func (solver solarEclipseSolver) feature() solarEclipseFeature {
|
||||
|
||||
t0 := -(center[0]*vx + center[1]*vy) / speedSquared
|
||||
greatestEclipseJDE := jd + t0
|
||||
xc := center[0] + vx*t0
|
||||
yc := center[1] + vy*t0
|
||||
zc := center[2] + vz*t0 - 1.37*t0*t0
|
||||
// The three-node velocity fit locates greatest eclipse accurately, but its
|
||||
// linearly extrapolated coordinates can miss the true Bessel position by
|
||||
// tens of kilometres in a grazing non-central event. Re-evaluate the
|
||||
// ephemeris at the solved time before deriving surface coordinates and
|
||||
// shadow radii so markers and path geometry use the same state.
|
||||
greatestMoon := solver.besselMoonAt(greatestEclipseJDE)
|
||||
xc := greatestMoon[0]
|
||||
yc := greatestMoon[1]
|
||||
zc := greatestMoon[2]
|
||||
gamma := (vx*center[1] - vy*center[0]) / speed
|
||||
minimumDistance := math.Abs(gamma)
|
||||
axis := solver.besselAxisAt(greatestEclipseJDE)
|
||||
@@ -379,14 +499,120 @@ func (solver solarEclipseSolver) feature() solarEclipseFeature {
|
||||
_, _, feature.partialEndJDE, _ = solver.quickContactAt(partialEndParam+jd, vx, vy, true)
|
||||
}
|
||||
|
||||
if typeCode != "N" && typeCode != "P" {
|
||||
if axisIntersection.valid && typeCode != "N" && typeCode != "P" {
|
||||
_, _, feature.centralBeginJDE, _ = solver.quickContactAt(centralStartParam+jd, vx, vy, false)
|
||||
_, _, feature.centralEndJDE, _ = solver.quickContactAt(centralEndParam+jd, vx, vy, false)
|
||||
if refined, ok := solver.centralAxisContactJDE(feature.centralBeginJDE, greatestEclipseJDE, -1); ok {
|
||||
feature.centralBeginJDE = refined
|
||||
}
|
||||
if refined, ok := solver.centralAxisContactJDE(feature.centralEndJDE, greatestEclipseJDE, 1); ok {
|
||||
feature.centralEndJDE = refined
|
||||
}
|
||||
}
|
||||
|
||||
return feature
|
||||
}
|
||||
|
||||
func (solver solarEclipseSolver) centralAxisContactJDE(
|
||||
approximateJDE, greatestJDE, direction float64,
|
||||
) (float64, bool) {
|
||||
if !finite(approximateJDE) || !finite(greatestJDE) || direction == 0 {
|
||||
return 0, false
|
||||
}
|
||||
insideJDE, insideResidual := approximateJDE, solver.centralAxisEarthDiscriminant(approximateJDE)
|
||||
if !finite(insideResidual) || insideResidual < 0 {
|
||||
insideJDE = greatestJDE
|
||||
insideResidual = solver.centralAxisEarthDiscriminant(insideJDE)
|
||||
if !finite(insideResidual) || insideResidual < 0 {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
outsideJDE, outsideResidual := 0.0, 0.0
|
||||
foundOutside := false
|
||||
for step := solarEclipseAxisContactInitialStepDays; step <= solarEclipseAxisContactMaximumStepDays; step *= 2 {
|
||||
candidateJDE := approximateJDE + direction*step
|
||||
candidateResidual := solver.centralAxisEarthDiscriminant(candidateJDE)
|
||||
if !finite(candidateResidual) {
|
||||
continue
|
||||
}
|
||||
if candidateResidual <= 0 {
|
||||
outsideJDE, outsideResidual = candidateJDE, candidateResidual
|
||||
foundOutside = true
|
||||
break
|
||||
}
|
||||
insideJDE, insideResidual = candidateJDE, candidateResidual
|
||||
}
|
||||
if !foundOutside {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
for iteration := 0; iteration < 24 && math.Abs(outsideJDE-insideJDE) > solarEclipseAxisContactToleranceDays; iteration++ {
|
||||
candidateJDE := (insideJDE + outsideJDE) / 2
|
||||
denominator := insideResidual - outsideResidual
|
||||
if denominator != 0 {
|
||||
fraction := insideResidual / denominator
|
||||
if fraction > 0.1 && fraction < 0.9 {
|
||||
candidateJDE = insideJDE + fraction*(outsideJDE-insideJDE)
|
||||
}
|
||||
}
|
||||
candidateResidual := solver.centralAxisEarthDiscriminant(candidateJDE)
|
||||
if !finite(candidateResidual) {
|
||||
return 0, false
|
||||
}
|
||||
if candidateResidual >= 0 {
|
||||
insideJDE, insideResidual = candidateJDE, candidateResidual
|
||||
} else {
|
||||
outsideJDE, outsideResidual = candidateJDE, candidateResidual
|
||||
}
|
||||
}
|
||||
return (insideJDE + outsideJDE) / 2, true
|
||||
}
|
||||
|
||||
func (solver solarEclipseSolver) centralAxisEarthDiscriminant(jd float64) float64 {
|
||||
moon, axis, _ := solver.besselGeometryAt(jd)
|
||||
return solarEclipseLineEllipsoidDiscriminant(
|
||||
moon[0], moon[1], 2,
|
||||
moon[0], moon[1], 0,
|
||||
solarEclipseEarthPolarRatio, 1, axis,
|
||||
)
|
||||
}
|
||||
|
||||
func (solver solarEclipseSolver) centralAxisContactPointAt(jd float64) (SolarEclipsePathPoint, bool) {
|
||||
moon, axis, _ := solver.besselGeometryAt(jd)
|
||||
cosTilt, sinTilt := math.Cos(axis.tilt), math.Sin(axis.tilt)
|
||||
x1 := moon[0]
|
||||
y1 := cosTilt*moon[1] - 2*sinTilt
|
||||
z1 := sinTilt*moon[1] + 2*cosTilt
|
||||
x2 := moon[0]
|
||||
y2 := cosTilt * moon[1]
|
||||
z2 := sinTilt * moon[1]
|
||||
dx, dy, dz := x2-x1, y2-y1, z2-z1
|
||||
polarRatioSquared := solarEclipseEarthPolarRatioSquared
|
||||
a := dx*dx + dy*dy + dz*dz/polarRatioSquared
|
||||
if !finite(a) || a <= 0 {
|
||||
return SolarEclipsePathPoint{}, false
|
||||
}
|
||||
b := x1*dx + y1*dy + z1*dz/polarRatioSquared
|
||||
t := -b / a
|
||||
intersection := solarEclipseLineIntersection{
|
||||
valid: true,
|
||||
x: x1 + dx*t,
|
||||
y: y1 + dy*t,
|
||||
z: z1 + dz*t,
|
||||
}
|
||||
longitude, latitude := solarEclipseIntersectionGeodetic(intersection, axis)
|
||||
if !finite(longitude) || !finite(latitude) {
|
||||
return SolarEclipsePathPoint{}, false
|
||||
}
|
||||
return SolarEclipsePathPoint{
|
||||
JDE: jd,
|
||||
Longitude: longitude,
|
||||
Latitude: latitude,
|
||||
SunAltitude: solarEclipseSunAltitudeAtGreatest(jd, longitude, latitude, axis.gst) / rad,
|
||||
}, true
|
||||
}
|
||||
|
||||
func (solver solarEclipseSolver) quickContactAt(jd, dx, dy float64, penumbral bool) (float64, float64, float64, bool) {
|
||||
moon := solver.besselMoonAt(jd)
|
||||
radii := solver.shadowRadiiAt(moon[2])
|
||||
@@ -431,11 +657,28 @@ func (solver solarEclipseSolver) shadowRadiiAt(moonBesselZ float64) solarEclipse
|
||||
|
||||
func (solver solarEclipseSolver) besselAxisAt(jd float64) solarEclipseAxis {
|
||||
sun, moon := solarEclipseSunMoonEquatorial(jd)
|
||||
return solarEclipseBesselAxisFromEquatorialWithDeltaT(
|
||||
jd, sun, moon, solver.effectiveDeltaTSeconds(jd),
|
||||
)
|
||||
}
|
||||
|
||||
func solarEclipseBesselAxisFromEquatorial(jd float64, sun, moon [3]float64) solarEclipseAxis {
|
||||
return solarEclipseBesselAxisFromEquatorialWithDeltaT(jd, sun, moon, DeltaT(jd, true))
|
||||
}
|
||||
|
||||
// solarEclipseBesselAxisFromEquatorialWithDeltaT 用显式 ΔT 构造贝塞尔轴:TT 时刻保持
|
||||
// 不变,ΔT 只决定地球自转相位(恒星时),因此同一 TT 在不同 ΔT 下得到的地面足迹会
|
||||
// 沿经度平移,这正是"ΔT 只影响自转、不影响几何时刻"的实现点。
|
||||
// solarEclipseBesselAxisFromEquatorialWithDeltaT builds the Besselian axis with an
|
||||
// explicit ΔT: the TT instant is untouched and ΔT only sets Earth rotation.
|
||||
func solarEclipseBesselAxisFromEquatorialWithDeltaT(
|
||||
jd float64, sun, moon [3]float64, deltaTSeconds float64,
|
||||
) solarEclipseAxis {
|
||||
sunXYZ := solarEclipseLLRToXYZ(sun[0], sun[1], sun[2])
|
||||
moonXYZ := solarEclipseLLRToXYZ(moon[0], moon[1], moon[2])
|
||||
axis := solarEclipseXYZToLLR(sunXYZ[0]-moonXYZ[0], sunXYZ[1]-moonXYZ[1], sunXYZ[2]-moonXYZ[2])
|
||||
|
||||
utJDE := TD2UT(jd, false)
|
||||
utJDE := jd - deltaTSeconds/86400
|
||||
return solarEclipseAxis{
|
||||
rightAscension: solarEclipseNormalizeRadians(math.Pi/2 + axis[0]),
|
||||
tilt: math.Pi/2 - axis[1],
|
||||
@@ -444,9 +687,83 @@ func (solver solarEclipseSolver) besselAxisAt(jd float64) solarEclipseAxis {
|
||||
}
|
||||
|
||||
func (solver solarEclipseSolver) besselMoonAt(jd float64) [3]float64 {
|
||||
_, moon := solarEclipseSunMoonEquatorial(jd)
|
||||
axis := solver.besselAxisAt(jd)
|
||||
moon, _, _ := solver.besselGeometryAt(jd)
|
||||
return moon
|
||||
}
|
||||
|
||||
func (solver solarEclipseSolver) besselMoonCandidateAt(jd float64) [3]float64 {
|
||||
moon, _, _, ok := solver.besselGeometryCandidateAt(jd)
|
||||
if !ok {
|
||||
return solver.besselMoonAt(jd)
|
||||
}
|
||||
return moon
|
||||
}
|
||||
|
||||
func (solver solarEclipseSolver) besselGeometryAt(jd float64) ([3]float64, solarEclipseAxis, [3]float64) {
|
||||
key := math.Float64bits(jd)
|
||||
// 命中要求 ΔT 世代一致:轴里的 gst 依赖 ΔT,SetDeltaTFn 之后旧条目必须视为未命中。
|
||||
// A hit requires the same ΔT generation: the cached axis carries a ΔT-dependent gst,
|
||||
// so entries written before a SetDeltaTFn override must count as misses.
|
||||
if entry, ok := solver.besselGeometryCache[key]; ok && entry.generation == deltaTGenerationValue() {
|
||||
return entry.moon, entry.axis, entry.sun
|
||||
}
|
||||
sun, moon := solarEclipseSunMoonEquatorial(jd)
|
||||
axis := solarEclipseBesselAxisFromEquatorialWithDeltaT(
|
||||
jd, sun, moon, solver.effectiveDeltaTSeconds(jd),
|
||||
)
|
||||
geometry := solarEclipseBesselGeometryCacheEntry{
|
||||
moon: solarEclipseBesselMoonFromEquatorial(moon, axis),
|
||||
axis: axis,
|
||||
sun: sun,
|
||||
valid: true,
|
||||
}
|
||||
storeSolarEclipseBesselGeometry(solver.besselGeometryCache, key, geometry)
|
||||
return geometry.moon, geometry.axis, geometry.sun
|
||||
}
|
||||
|
||||
func (solver solarEclipseSolver) besselGeometryCandidateAt(jd float64) ([3]float64, solarEclipseAxis, [3]float64, bool) {
|
||||
key := math.Float64bits(jd)
|
||||
if entry, ok := solver.besselCandidateCache[key]; ok && entry.generation == deltaTGenerationValue() {
|
||||
return entry.moon, entry.axis, entry.sun, entry.valid
|
||||
}
|
||||
if solver.localEphemeris == nil {
|
||||
return [3]float64{}, solarEclipseAxis{}, [3]float64{}, false
|
||||
}
|
||||
sun, moon, ok := solver.localEphemeris.equatorialAt(jd)
|
||||
if !ok {
|
||||
return [3]float64{}, solarEclipseAxis{}, [3]float64{}, false
|
||||
}
|
||||
axis := solarEclipseBesselAxisFromEquatorialWithDeltaT(
|
||||
jd, sun, moon, solver.effectiveDeltaTSeconds(jd),
|
||||
)
|
||||
geometry := solarEclipseBesselGeometryCacheEntry{
|
||||
moon: solarEclipseBesselMoonFromEquatorial(moon, axis),
|
||||
axis: axis,
|
||||
sun: sun,
|
||||
valid: true,
|
||||
}
|
||||
storeSolarEclipseBesselGeometry(solver.besselCandidateCache, key, geometry)
|
||||
return geometry.moon, geometry.axis, geometry.sun, geometry.valid
|
||||
}
|
||||
|
||||
func storeSolarEclipseBesselGeometry(
|
||||
cache map[uint64]solarEclipseBesselGeometryCacheEntry,
|
||||
key uint64,
|
||||
entry solarEclipseBesselGeometryCacheEntry,
|
||||
) {
|
||||
if cache == nil {
|
||||
return
|
||||
}
|
||||
if _, exists := cache[key]; !exists && len(cache) >= solarEclipseBesselGeometryCacheMaximumEntries {
|
||||
for cachedKey := range cache {
|
||||
delete(cache, cachedKey)
|
||||
}
|
||||
}
|
||||
entry.generation = deltaTGenerationValue()
|
||||
cache[key] = entry
|
||||
}
|
||||
|
||||
func solarEclipseBesselMoonFromEquatorial(moon [3]float64, axis solarEclipseAxis) [3]float64 {
|
||||
rotated := solarEclipseRotateLLR(
|
||||
solarEclipseNormalizeSignedRadians(moon[0]-axis.rightAscension),
|
||||
moon[1],
|
||||
@@ -464,13 +781,16 @@ func (solver solarEclipseSolver) besselMoonAt(jd float64) [3]float64 {
|
||||
|
||||
func solarEclipseSunMoonEquatorial(jd float64) ([3]float64, [3]float64) {
|
||||
julianCentury := (jd - 2451545.0) / 36525.0
|
||||
obliquity := EclipticObliquity(jd, true) * rad
|
||||
nutationLongitude, nutationObliquity := Nutation2000B(jd)
|
||||
obliquity := (Obliquity1980(jd) + nutationObliquity) * rad
|
||||
|
||||
sunLongitude := HSunApparentLo(jd) * rad
|
||||
// Share the full-series distance and nutation for this single TT.
|
||||
sunDistanceAU := EarthAway(jd)
|
||||
sunLongitude := (HSunTrueLoN(jd, -1) + nutationLongitude - 20.49552/sunDistanceAU/3600) * rad
|
||||
sunLatitude := HSunTrueBo(jd) * rad
|
||||
sunDistance := EarthAway(jd) * solarEclipseAstronomicalUnitKM
|
||||
sunDistance := sunDistanceAU * solarEclipseAstronomicalUnitKM
|
||||
|
||||
moonLongitude := solarEclipseNormalizeRadians(HMoonApparentLo(jd)*rad + solarEclipseMoonLonAberrRad)
|
||||
moonLongitude := solarEclipseNormalizeRadians((HMoonTrueLoN(jd, -1)+nutationLongitude)*rad + solarEclipseMoonLonAberrRad)
|
||||
moonLatitude := HMoonTrueBo(jd)*rad + moonLatitudeAberrationRad(julianCentury)
|
||||
moonDistance := HMoonAway(jd)
|
||||
|
||||
@@ -483,6 +803,10 @@ func solarEclipseSunMoonEquatorial(jd float64) ([3]float64, [3]float64) {
|
||||
|
||||
func solarEclipseSunAltitudeAtGreatest(jd, lonDeg, latDeg, gst float64) float64 {
|
||||
sun, _ := solarEclipseSunMoonEquatorial(jd)
|
||||
return solarEclipseSunAltitudeFromEquatorial(sun, lonDeg, latDeg, gst)
|
||||
}
|
||||
|
||||
func solarEclipseSunAltitudeFromEquatorial(sun [3]float64, lonDeg, latDeg, gst float64) float64 {
|
||||
horizon := solarEclipseEquatorialToHorizontal(sun[0], sun[1], sun[2], lonDeg*rad, latDeg*rad, gst)
|
||||
return horizon[1]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"b612.me/astro/internal/geodata"
|
||||
)
|
||||
|
||||
// TestSolarEclipseGrazingClosureRootsAreRecovered pins the events whose
|
||||
// greatest-at-horizon closure arcs the analytic seedings miss: a grazing
|
||||
// closure root can sit outside the sampled horizon branches, and the local
|
||||
// classification then refuses the solved root. Recovering both from the sampled
|
||||
// sweep restores the analytic critical envelope, which covers the visible
|
||||
// annulus better than the sampled union that used to replace it.
|
||||
func TestSolarEclipseGrazingClosureRootsAreRecovered(t *testing.T) {
|
||||
for _, date := range [][3]int{{1136, 6, 1}, {-1480, 12, 27}, {5705, 6, 17}} {
|
||||
seed := JDECalc(date[0], date[1], float64(date[2]))
|
||||
result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{
|
||||
StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0,
|
||||
})
|
||||
if result.Eclipse.Centrality != SolarEclipseCentralTwoLimits {
|
||||
t.Fatalf("%04d-%02d-%02d centrality=%s, want two limits",
|
||||
date[0], date[1], date[2], result.Eclipse.Centrality)
|
||||
}
|
||||
if len(result.CentralBandHorizonClosures) != 2 {
|
||||
t.Fatalf("%04d-%02d-%02d horizon closures=%d, want two",
|
||||
date[0], date[1], date[2], len(result.CentralBandHorizonClosures))
|
||||
}
|
||||
if result.CentralBandSampled {
|
||||
t.Fatalf("%04d-%02d-%02d fell back to the sampled footprint union",
|
||||
date[0], date[1], date[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolarEclipseGrazingEventsWithoutClosuresStaySampled pins the other half
|
||||
// of the criterion: a two-limit grazing event whose caps are not bounded by the
|
||||
// greatest-at-horizon condition has no closure arc at all, and its band must
|
||||
// stay a valid closed reconstruction instead of silently disappearing. The
|
||||
// reasons are measured, not assumed: 4862-09-28 ends 40 km inside the horizon
|
||||
// (+0.36 degrees at the cap, so the umbral rim bounds it) and 1552-07-21 has its
|
||||
// boundary running along the horizon (+0.004 then -0.000 degrees), which makes
|
||||
// the arc degenerate.
|
||||
func TestSolarEclipseGrazingEventsWithoutClosuresStaySampled(t *testing.T) {
|
||||
for _, date := range [][3]int{{4862, 9, 28}, {1552, 7, 21}} {
|
||||
seed := JDECalc(date[0], date[1], float64(date[2]))
|
||||
result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{
|
||||
StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0,
|
||||
})
|
||||
if result.Eclipse.Centrality != SolarEclipseCentralTwoLimits {
|
||||
t.Fatalf("%04d-%02d-%02d centrality=%s, want two limits",
|
||||
date[0], date[1], date[2], result.Eclipse.Centrality)
|
||||
}
|
||||
if len(result.CentralBandHorizonClosures) != 0 {
|
||||
t.Fatalf("%04d-%02d-%02d closures=%d, expected none",
|
||||
date[0], date[1], date[2], len(result.CentralBandHorizonClosures))
|
||||
}
|
||||
if len(result.CentralBandSegments) == 0 {
|
||||
t.Fatalf("%04d-%02d-%02d exported no central band", date[0], date[1], date[2])
|
||||
}
|
||||
ring := result.CentralBandSegments[0]
|
||||
if len(ring) < 4 || solarEclipsePathDistanceKM(ring[0], ring[len(ring)-1]) > 0.01 {
|
||||
t.Fatalf("%04d-%02d-%02d central band is not closed", date[0], date[1], date[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolarEclipse11360601GrazingAnnularBandContainsSweep pins the grazing polar
|
||||
// annular event whose shadow axis runs almost parallel to the surface. Its
|
||||
// instantaneous antumbral footprint is a long spindle: the rim is cut by the
|
||||
// horizon over the contact intervals and fully closed over the middle of the
|
||||
// path. The analytic envelope has no horizon roots there, and the open-arc
|
||||
// sweep drops the closed middle samples, so the band used to be exported as a
|
||||
// chordal ribbon hundreds of kilometres smaller than the umbra it describes.
|
||||
func TestSolarEclipse11360601GrazingAnnularBandContainsSweep(t *testing.T) {
|
||||
result := SolarEclipsePartialFootprints(
|
||||
JDECalc(1136, 6, 1),
|
||||
SolarEclipsePartialFootprintOptions{
|
||||
StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0,
|
||||
},
|
||||
)
|
||||
if result.Eclipse.Type != SolarEclipseAnnular ||
|
||||
result.Eclipse.Centrality != SolarEclipseCentralTwoLimits {
|
||||
t.Fatalf("type=%s centrality=%s, want a two-limit annular eclipse",
|
||||
result.Eclipse.Type, result.Eclipse.Centrality)
|
||||
}
|
||||
// The band must be the analytic critical envelope, which needs both
|
||||
// greatest-at-horizon closure arcs. Recovering the second root from the
|
||||
// sampled sweep is what restored them; without it the event silently falls
|
||||
// back to a sampled union that leaves 0.3% of the visible annulus uncovered.
|
||||
if len(result.CentralBandHorizonClosures) != 2 {
|
||||
t.Fatalf("horizon closures=%d, want two", len(result.CentralBandHorizonClosures))
|
||||
}
|
||||
if result.CentralBandSampled {
|
||||
t.Fatal("central band fell back to the sampled footprint union")
|
||||
}
|
||||
if len(result.CentralBandSegments) != 1 {
|
||||
t.Fatalf("central-band segments=%d, want one closed band", len(result.CentralBandSegments))
|
||||
}
|
||||
ring := result.CentralBandSegments[0]
|
||||
if len(ring) < 4 || solarEclipsePathDistanceKM(ring[0], ring[len(ring)-1]) > 0.01 {
|
||||
t.Fatal("central-band envelope is not closed")
|
||||
}
|
||||
if !solarEclipseBandContainsFootprintsWithinKM(
|
||||
result.CentralBandSegments, result.CentralBandFootprints,
|
||||
solarEclipseCentralBandUnionContainmentToleranceKM,
|
||||
) {
|
||||
t.Fatal("central band does not contain the sampled umbral footprints")
|
||||
}
|
||||
// The spindle reaches the poleward tip far above the center-line interval
|
||||
// the old ribbon covered; a point on that tip must stay inside the band.
|
||||
for _, probe := range []SolarEclipsePathPoint{
|
||||
{Longitude: 120, Latitude: 73},
|
||||
{Longitude: 125, Latitude: 71},
|
||||
} {
|
||||
matrix := [][]geodata.GeoPoint{geodataRingFromPath(ring)}
|
||||
point := []geodata.GeoPoint{{Longitude: probe.Longitude, Latitude: probe.Latitude}}
|
||||
if !geodata.SphericalPolygonsContainPoints(matrix, point)[0] {
|
||||
t.Fatalf("central band misses (%g,%g)", probe.Longitude, probe.Latitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func geodataRingFromPath(points []SolarEclipsePathPoint) []geodata.GeoPoint {
|
||||
ring := make([]geodata.GeoPoint, len(points))
|
||||
for index, point := range points {
|
||||
ring[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||||
}
|
||||
return ring
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package basic
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSolarEclipse15001121ShallowTotalBandCloses(t *testing.T) {
|
||||
result := SolarEclipsePartialFootprints(
|
||||
JDECalc(1500, 11, 21),
|
||||
SolarEclipsePartialFootprintOptions{StepDays: 2.0 / 1440.0, BoundaryPoints: 96},
|
||||
)
|
||||
if result.Eclipse.Type != SolarEclipseTotal || result.Eclipse.Centrality != SolarEclipseCentralTwoLimits {
|
||||
t.Fatalf("type=%s centrality=%s, want two-limit total eclipse", result.Eclipse.Type, result.Eclipse.Centrality)
|
||||
}
|
||||
if len(result.CentralBandSegments) != 1 {
|
||||
t.Logf("footprints=%d closures=%d", len(result.CentralBandFootprints), len(result.CentralBandHorizonClosures))
|
||||
t.Fatalf("central-band segments=%d, want one closed envelope", len(result.CentralBandSegments))
|
||||
}
|
||||
if len(result.CentralBandHorizonClosures) != 2 {
|
||||
t.Fatalf("horizon closures=%d, want two", len(result.CentralBandHorizonClosures))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"b612.me/astro/internal/geodata"
|
||||
)
|
||||
|
||||
// TestSolarEclipse18741010GrazingAnnularBandContainsSweep pins the other end of
|
||||
// the grazing annular family: a one-limit path whose analytic sweep returns a
|
||||
// band that still terminates 48 km inside its own sampled umbra. The band must
|
||||
// be rebuilt from those samples instead of being exported as the short ribbon.
|
||||
func TestSolarEclipse18741010GrazingAnnularBandContainsSweep(t *testing.T) {
|
||||
result := SolarEclipsePartialFootprints(
|
||||
JDECalc(1874, 10, 10),
|
||||
SolarEclipsePartialFootprintOptions{
|
||||
StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0,
|
||||
},
|
||||
)
|
||||
if result.Eclipse.Type != SolarEclipseAnnular ||
|
||||
result.Eclipse.Centrality != SolarEclipseCentralOneLimit {
|
||||
t.Fatalf("type=%s centrality=%s, want a one-limit annular eclipse",
|
||||
result.Eclipse.Type, result.Eclipse.Centrality)
|
||||
}
|
||||
if len(result.CentralBandSegments) != 1 {
|
||||
t.Fatalf("central-band segments=%d, want one closed band", len(result.CentralBandSegments))
|
||||
}
|
||||
ring := result.CentralBandSegments[0]
|
||||
if len(ring) < 4 || solarEclipsePathDistanceKM(ring[0], ring[len(ring)-1]) > 0.01 {
|
||||
t.Fatal("central-band envelope is not closed")
|
||||
}
|
||||
if !solarEclipseBandContainsFootprintsWithinKM(
|
||||
result.CentralBandSegments, result.CentralBandFootprints,
|
||||
solarEclipseCentralBandUnionContainmentToleranceKM,
|
||||
) {
|
||||
t.Fatal("central band does not contain the sampled umbral footprints")
|
||||
}
|
||||
// A station with two minutes of annular phase, 14 degrees above the horizon,
|
||||
// used to fall outside the exported band by more than 400 km.
|
||||
matrix := [][]geodata.GeoPoint{geodataRingFromPath(ring)}
|
||||
point := []geodata.GeoPoint{{Longitude: 60.5, Latitude: 57.5}}
|
||||
if !geodata.SphericalPolygonsContainPoints(matrix, point)[0] {
|
||||
t.Fatal("central band misses the visible annular region at (60.5,57.5)")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user