feat: 引入可切换星历模型并扩展天象观测链路

- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算
- 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询
- 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出
- 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解
- 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤
- 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持
- 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理
- 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
This commit is contained in:
2026-10-15 23:54:15 +08:00
parent 16c62a97d5
commit 044f2f4381
674 changed files with 62983 additions and 8396 deletions
+492
View File
@@ -0,0 +1,492 @@
package eclipse
import (
"time"
)
// ClosestGeometricLocalLunarEclipse 最近一次几何月食,默认 Danjon 模型 / closest geometric local lunar eclipse, Danjon by default.
func ClosestGeometricLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return defaultModule.ClosestGeometricLocalLunarEclipse(date, lon, lat, height)
}
// ClosestGeometricLocalLunarEclipseChauvenet 最近一次几何月食(Chauvenet) / closest geometric local lunar eclipse with Chauvenet model.
func ClosestGeometricLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return defaultModule.ClosestGeometricLocalLunarEclipseChauvenet(date, lon, lat, height)
}
// ClosestGeometricLocalLunarEclipseDanjon 最近一次几何月食(Danjon) / closest geometric local lunar eclipse with Danjon model.
func ClosestGeometricLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return defaultModule.ClosestGeometricLocalLunarEclipseDanjon(date, lon, lat, height)
}
// ClosestGeometricLocalSolarEclipse 最近一次站心几何日食,默认 NASA bulletin Split-K 模型 / closest geometric local solar eclipse, NASA bulletin Split-K by default.
func ClosestGeometricLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return defaultModule.ClosestGeometricLocalSolarEclipse(date, lon, lat, height)
}
// ClosestGeometricLocalSolarEclipseIAUSingleK 最近一次站心几何日食(IAU Single-K) / closest geometric local solar eclipse with IAU Single-K.
func ClosestGeometricLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return defaultModule.ClosestGeometricLocalSolarEclipseIAUSingleK(date, lon, lat, height)
}
// ClosestGeometricLocalSolarEclipseNASABulletinSplitK 最近一次站心几何日食(NASA bulletin Split-K) / closest geometric local solar eclipse with NASA bulletin Split-K.
func ClosestGeometricLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return defaultModule.ClosestGeometricLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
}
// ClosestLocalAnnularSolarEclipse 最近一次站心日环食,默认 NASA bulletin Split-K 模型 / closest local annular solar eclipse, NASA bulletin Split-K by default.
func ClosestLocalAnnularSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return defaultModule.ClosestLocalAnnularSolarEclipse(date, lon, lat, height)
}
// ClosestLocalLunarEclipse 最近一次可见月食,默认 Danjon 模型 / closest visible local lunar eclipse, Danjon by default.
func ClosestLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return defaultModule.ClosestLocalLunarEclipse(date, lon, lat, height)
}
// ClosestLocalLunarEclipseChauvenet 最近一次可见月食(Chauvenet) / closest visible local lunar eclipse with Chauvenet model.
func ClosestLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return defaultModule.ClosestLocalLunarEclipseChauvenet(date, lon, lat, height)
}
// ClosestLocalLunarEclipseDanjon 最近一次可见月食(Danjon) / closest visible local lunar eclipse with Danjon model.
func ClosestLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return defaultModule.ClosestLocalLunarEclipseDanjon(date, lon, lat, height)
}
// ClosestLocalSolarEclipse 最近一次站心日食,默认 NASA bulletin Split-K 模型 / closest local solar eclipse, NASA bulletin Split-K by default.
func ClosestLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return defaultModule.ClosestLocalSolarEclipse(date, lon, lat, height)
}
// ClosestLocalSolarEclipseIAUSingleK 最近一次站心日食(IAU Single-K) / closest local solar eclipse with IAU Single-K.
func ClosestLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return defaultModule.ClosestLocalSolarEclipseIAUSingleK(date, lon, lat, height)
}
// ClosestLocalSolarEclipseNASABulletinSplitK 最近一次站心日食(NASA bulletin Split-K) / closest local solar eclipse with NASA bulletin Split-K.
func ClosestLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return defaultModule.ClosestLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
}
// ClosestLocalTotalLunarEclipse 最近一次可见月全食,默认 Danjon 模型 / closest visible local total lunar eclipse, Danjon by default.
func ClosestLocalTotalLunarEclipse(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return defaultModule.ClosestLocalTotalLunarEclipse(date, lon, lat, height)
}
// ClosestLocalTotalSolarEclipse 最近一次站心日全食,默认 NASA bulletin Split-K 模型 / closest local total solar eclipse, NASA bulletin Split-K by default.
func ClosestLocalTotalSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return defaultModule.ClosestLocalTotalSolarEclipse(date, lon, lat, height)
}
// ClosestSolarEclipse 最近一次日食,默认 NASA bulletin Split-K 模型 / closest solar eclipse, NASA bulletin Split-K by default.
func ClosestSolarEclipse(date time.Time) SolarEclipseInfo {
return defaultModule.ClosestSolarEclipse(date)
}
// ClosestSolarEclipseIAUSingleK 最近一次日食(IAU Single-K) / closest solar eclipse with IAU Single-K.
func ClosestSolarEclipseIAUSingleK(date time.Time) SolarEclipseInfo {
return defaultModule.ClosestSolarEclipseIAUSingleK(date)
}
// ClosestSolarEclipseNASABulletinSplitK 最近一次日食(NASA bulletin Split-K) / closest solar eclipse with NASA bulletin Split-K.
func ClosestSolarEclipseNASABulletinSplitK(date time.Time) SolarEclipseInfo {
return defaultModule.ClosestSolarEclipseNASABulletinSplitK(date)
}
// ClosestSolarEclipseWithOptions 最近一次日食(自定义半径口径) / closest solar eclipse with custom radius conventions.
func ClosestSolarEclipseWithOptions(date time.Time, options SolarEclipseOptions) SolarEclipseInfo {
return defaultModule.ClosestSolarEclipseWithOptions(date, options)
}
// GeometricLocalLunarEclipseOnDate 当地几何月食查询,默认 Danjon 模型 / local geometric lunar eclipse query, Danjon by default.
func GeometricLocalLunarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return defaultModule.GeometricLocalLunarEclipseOnDate(date, lon, lat, height)
}
// GeometricLocalLunarEclipseOnDateChauvenet 当地几何月食查询(Chauvenet) / local geometric lunar eclipse query with Chauvenet model.
func GeometricLocalLunarEclipseOnDateChauvenet(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return defaultModule.GeometricLocalLunarEclipseOnDateChauvenet(date, lon, lat, height)
}
// GeometricLocalLunarEclipseOnDateDanjon 当地几何月食查询(Danjon) / local geometric lunar eclipse query with Danjon model.
func GeometricLocalLunarEclipseOnDateDanjon(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return defaultModule.GeometricLocalLunarEclipseOnDateDanjon(date, lon, lat, height)
}
// GeometricLocalSolarEclipseOnDate 当地站心几何日食查询,默认 NASA bulletin Split-K 模型 / local geometric solar eclipse query, NASA bulletin Split-K by default.
func GeometricLocalSolarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return defaultModule.GeometricLocalSolarEclipseOnDate(date, lon, lat, height)
}
// GeometricLocalSolarEclipseOnDateIAUSingleK 当地站心几何日食查询(IAU Single-K) / local geometric solar eclipse query with IAU Single-K.
func GeometricLocalSolarEclipseOnDateIAUSingleK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return defaultModule.GeometricLocalSolarEclipseOnDateIAUSingleK(date, lon, lat, height)
}
// GeometricLocalSolarEclipseOnDateNASABulletinSplitK 当地站心几何日食查询(NASA bulletin Split-K) / local geometric solar eclipse query with NASA bulletin Split-K.
func GeometricLocalSolarEclipseOnDateNASABulletinSplitK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return defaultModule.GeometricLocalSolarEclipseOnDateNASABulletinSplitK(date, lon, lat, height)
}
// LastGeometricLocalLunarEclipse 上次几何月食,默认 Danjon 模型 / previous geometric local lunar eclipse, Danjon by default.
func LastGeometricLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return defaultModule.LastGeometricLocalLunarEclipse(date, lon, lat, height)
}
// LastGeometricLocalLunarEclipseChauvenet 上次几何月食(Chauvenet) / previous geometric local lunar eclipse with Chauvenet model.
func LastGeometricLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return defaultModule.LastGeometricLocalLunarEclipseChauvenet(date, lon, lat, height)
}
// LastGeometricLocalLunarEclipseDanjon 上次几何月食(Danjon) / previous geometric local lunar eclipse with Danjon model.
func LastGeometricLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return defaultModule.LastGeometricLocalLunarEclipseDanjon(date, lon, lat, height)
}
// LastGeometricLocalSolarEclipse 上次站心几何日食,默认 NASA bulletin Split-K 模型 / previous geometric local solar eclipse, NASA bulletin Split-K by default.
func LastGeometricLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return defaultModule.LastGeometricLocalSolarEclipse(date, lon, lat, height)
}
// LastGeometricLocalSolarEclipseIAUSingleK 上次站心几何日食(IAU Single-K) / previous geometric local solar eclipse with IAU Single-K.
func LastGeometricLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return defaultModule.LastGeometricLocalSolarEclipseIAUSingleK(date, lon, lat, height)
}
// LastGeometricLocalSolarEclipseNASABulletinSplitK 上次站心几何日食(NASA bulletin Split-K) / previous geometric local solar eclipse with NASA bulletin Split-K.
func LastGeometricLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return defaultModule.LastGeometricLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
}
// LastLocalAnnularSolarEclipse 上次站心日环食,默认 NASA bulletin Split-K 模型 / previous local annular solar eclipse, NASA bulletin Split-K by default.
func LastLocalAnnularSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return defaultModule.LastLocalAnnularSolarEclipse(date, lon, lat, height)
}
// LastLocalLunarEclipse 上次可见月食,默认 Danjon 模型 / previous visible local lunar eclipse, Danjon by default.
func LastLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return defaultModule.LastLocalLunarEclipse(date, lon, lat, height)
}
// LastLocalLunarEclipseChauvenet 上次可见月食(Chauvenet) / previous visible local lunar eclipse with Chauvenet model.
func LastLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return defaultModule.LastLocalLunarEclipseChauvenet(date, lon, lat, height)
}
// LastLocalLunarEclipseDanjon 上次可见月食(Danjon) / previous visible local lunar eclipse with Danjon model.
func LastLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return defaultModule.LastLocalLunarEclipseDanjon(date, lon, lat, height)
}
// LastLocalSolarEclipse 上次站心日食,默认 NASA bulletin Split-K 模型 / previous local solar eclipse, NASA bulletin Split-K by default.
func LastLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return defaultModule.LastLocalSolarEclipse(date, lon, lat, height)
}
// LastLocalSolarEclipseIAUSingleK 上次站心日食(IAU Single-K) / previous local solar eclipse with IAU Single-K.
func LastLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return defaultModule.LastLocalSolarEclipseIAUSingleK(date, lon, lat, height)
}
// LastLocalSolarEclipseNASABulletinSplitK 上次站心日食(NASA bulletin Split-K) / previous local solar eclipse with NASA bulletin Split-K.
func LastLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return defaultModule.LastLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
}
// LastLocalTotalLunarEclipse 上次可见月全食,默认 Danjon 模型 / previous visible local total lunar eclipse, Danjon by default.
func LastLocalTotalLunarEclipse(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return defaultModule.LastLocalTotalLunarEclipse(date, lon, lat, height)
}
// LastLocalTotalSolarEclipse 上次站心日全食,默认 NASA bulletin Split-K 模型 / previous local total solar eclipse, NASA bulletin Split-K by default.
func LastLocalTotalSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return defaultModule.LastLocalTotalSolarEclipse(date, lon, lat, height)
}
// LastSolarEclipse 上次日食,默认 NASA bulletin Split-K 模型 / previous solar eclipse, NASA bulletin Split-K by default.
func LastSolarEclipse(date time.Time) SolarEclipseInfo { return defaultModule.LastSolarEclipse(date) }
// LastSolarEclipseIAUSingleK 上次日食(IAU Single-K) / previous solar eclipse with IAU Single-K.
func LastSolarEclipseIAUSingleK(date time.Time) SolarEclipseInfo {
return defaultModule.LastSolarEclipseIAUSingleK(date)
}
// LastSolarEclipseNASABulletinSplitK 上次日食(NASA bulletin Split-K) / previous solar eclipse with NASA bulletin Split-K.
func LastSolarEclipseNASABulletinSplitK(date time.Time) SolarEclipseInfo {
return defaultModule.LastSolarEclipseNASABulletinSplitK(date)
}
// LastSolarEclipseWithOptions 上次日食(自定义半径口径) / previous solar eclipse with custom radius conventions.
func LastSolarEclipseWithOptions(date time.Time, options SolarEclipseOptions) SolarEclipseInfo {
return defaultModule.LastSolarEclipseWithOptions(date, options)
}
// LocalLunarEclipseOnDate 当地可见月食查询,默认 Danjon 模型 / local visible lunar eclipse query, Danjon by default.
func LocalLunarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return defaultModule.LocalLunarEclipseOnDate(date, lon, lat, height)
}
// LocalLunarEclipseOnDateChauvenet 当地可见月食查询(Chauvenet) / local visible lunar eclipse query with Chauvenet model.
func LocalLunarEclipseOnDateChauvenet(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return defaultModule.LocalLunarEclipseOnDateChauvenet(date, lon, lat, height)
}
// LocalLunarEclipseOnDateDanjon 当地可见月食查询(Danjon) / local visible lunar eclipse query with Danjon model.
func LocalLunarEclipseOnDateDanjon(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return defaultModule.LocalLunarEclipseOnDateDanjon(date, lon, lat, height)
}
// LocalSolarEclipseOnDate 当地站心日食查询,默认 NASA bulletin Split-K 模型 / local topocentric solar eclipse query, NASA bulletin Split-K by default.
func LocalSolarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return defaultModule.LocalSolarEclipseOnDate(date, lon, lat, height)
}
// LocalSolarEclipseOnDateIAUSingleK 当地站心日食查询(IAU Single-K) / local topocentric solar eclipse query with IAU Single-K.
func LocalSolarEclipseOnDateIAUSingleK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return defaultModule.LocalSolarEclipseOnDateIAUSingleK(date, lon, lat, height)
}
// LocalSolarEclipseOnDateNASABulletinSplitK 当地站心日食查询(NASA bulletin Split-K) / local topocentric solar eclipse query with NASA bulletin Split-K.
func LocalSolarEclipseOnDateNASABulletinSplitK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return defaultModule.LocalSolarEclipseOnDateNASABulletinSplitK(date, lon, lat, height)
}
// LocalSolarEclipseOnDateWithOptions 当地站心日食查询(自定义半径口径) / local topocentric solar eclipse query with custom radius conventions.
func LocalSolarEclipseOnDateWithOptions(date time.Time, lon, lat, height float64, options SolarEclipseOptions) (LocalSolarEclipseInfo, bool) {
return defaultModule.LocalSolarEclipseOnDateWithOptions(date, lon, lat, height, options)
}
// LunarEclipseGeocentricPanelAt 计算给定日期月食的地心量面板,使用 Danjon 影半径模型 / computes the geocentric panel of one lunar eclipse with the Danjon shadow model.
func LunarEclipseGeocentricPanelAt(date time.Time) (LunarEclipseGeocentricPanel, bool) {
return defaultModule.LunarEclipseGeocentricPanelAt(date)
}
// LunarEclipseGeocentricPanelChauvenet 计算给定日期月食的地心量面板,使用 Chauvenet 影半径模型 / computes the geocentric panel with the Chauvenet shadow model.
func LunarEclipseGeocentricPanelChauvenet(date time.Time) (LunarEclipseGeocentricPanel, bool) {
return defaultModule.LunarEclipseGeocentricPanelChauvenet(date)
}
// NewSolarEclipseShadowSolver 构造单时刻求解器 / builds a single-instant solver.
func NewSolarEclipseShadowSolver(options SolarEclipseShadowSolverOptions) *SolarEclipseShadowSolver {
return defaultModule.NewSolarEclipseShadowSolver(options)
}
// NextGeometricLocalLunarEclipse 下次几何月食,默认 Danjon 模型 / next geometric local lunar eclipse, Danjon by default.
func NextGeometricLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return defaultModule.NextGeometricLocalLunarEclipse(date, lon, lat, height)
}
// NextGeometricLocalLunarEclipseChauvenet 下次几何月食(Chauvenet) / next geometric local lunar eclipse with Chauvenet model.
func NextGeometricLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return defaultModule.NextGeometricLocalLunarEclipseChauvenet(date, lon, lat, height)
}
// NextGeometricLocalLunarEclipseDanjon 下次几何月食(Danjon) / next geometric local lunar eclipse with Danjon model.
func NextGeometricLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return defaultModule.NextGeometricLocalLunarEclipseDanjon(date, lon, lat, height)
}
// NextGeometricLocalSolarEclipse 下次站心几何日食,默认 NASA bulletin Split-K 模型 / next geometric local solar eclipse, NASA bulletin Split-K by default.
func NextGeometricLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return defaultModule.NextGeometricLocalSolarEclipse(date, lon, lat, height)
}
// NextGeometricLocalSolarEclipseIAUSingleK 下次站心几何日食(IAU Single-K) / next geometric local solar eclipse with IAU Single-K.
func NextGeometricLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return defaultModule.NextGeometricLocalSolarEclipseIAUSingleK(date, lon, lat, height)
}
// NextGeometricLocalSolarEclipseNASABulletinSplitK 下次站心几何日食(NASA bulletin Split-K) / next geometric local solar eclipse with NASA bulletin Split-K.
func NextGeometricLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return defaultModule.NextGeometricLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
}
// NextLocalAnnularSolarEclipse 下次站心日环食,默认 NASA bulletin Split-K 模型 / next local annular solar eclipse, NASA bulletin Split-K by default.
func NextLocalAnnularSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return defaultModule.NextLocalAnnularSolarEclipse(date, lon, lat, height)
}
// NextLocalLunarEclipse 下次可见月食,默认 Danjon 模型 / next visible local lunar eclipse, Danjon by default.
func NextLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return defaultModule.NextLocalLunarEclipse(date, lon, lat, height)
}
// NextLocalLunarEclipseChauvenet 下次可见月食(Chauvenet) / next visible local lunar eclipse with Chauvenet model.
func NextLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return defaultModule.NextLocalLunarEclipseChauvenet(date, lon, lat, height)
}
// NextLocalLunarEclipseDanjon 下次可见月食(Danjon) / next visible local lunar eclipse with Danjon model.
func NextLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return defaultModule.NextLocalLunarEclipseDanjon(date, lon, lat, height)
}
// NextLocalSolarEclipse 下次站心日食,默认 NASA bulletin Split-K 模型 / next local solar eclipse, NASA bulletin Split-K by default.
func NextLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return defaultModule.NextLocalSolarEclipse(date, lon, lat, height)
}
// NextLocalSolarEclipseIAUSingleK 下次站心日食(IAU Single-K) / next local solar eclipse with IAU Single-K.
func NextLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return defaultModule.NextLocalSolarEclipseIAUSingleK(date, lon, lat, height)
}
// NextLocalSolarEclipseNASABulletinSplitK 下次站心日食(NASA bulletin Split-K) / next local solar eclipse with NASA bulletin Split-K.
func NextLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return defaultModule.NextLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
}
// NextLocalTotalLunarEclipse 下次可见月全食,默认 Danjon 模型 / next visible local total lunar eclipse, Danjon by default.
func NextLocalTotalLunarEclipse(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return defaultModule.NextLocalTotalLunarEclipse(date, lon, lat, height)
}
// NextLocalTotalSolarEclipse 下次站心日全食,默认 NASA bulletin Split-K 模型 / next local total solar eclipse, NASA bulletin Split-K by default.
func NextLocalTotalSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return defaultModule.NextLocalTotalSolarEclipse(date, lon, lat, height)
}
// NextSolarEclipse 下次日食,默认 NASA bulletin Split-K 模型 / next solar eclipse, NASA bulletin Split-K by default.
func NextSolarEclipse(date time.Time) SolarEclipseInfo { return defaultModule.NextSolarEclipse(date) }
// NextSolarEclipseIAUSingleK 下次日食(IAU Single-K) / next solar eclipse with IAU Single-K.
func NextSolarEclipseIAUSingleK(date time.Time) SolarEclipseInfo {
return defaultModule.NextSolarEclipseIAUSingleK(date)
}
// NextSolarEclipseNASABulletinSplitK 下次日食(NASA bulletin Split-K) / next solar eclipse with NASA bulletin Split-K.
func NextSolarEclipseNASABulletinSplitK(date time.Time) SolarEclipseInfo {
return defaultModule.NextSolarEclipseNASABulletinSplitK(date)
}
// NextSolarEclipseWithOptions 下次日食(自定义半径口径) / next solar eclipse with custom radius conventions.
func NextSolarEclipseWithOptions(date time.Time, options SolarEclipseOptions) SolarEclipseInfo {
return defaultModule.NextSolarEclipseWithOptions(date, options)
}
// SearchLocalCentralSolarEclipse 在给定跨度内搜索站心食并回报是否用尽跨度 / station search within a horizon.
func SearchLocalCentralSolarEclipse(
date time.Time, lon, lat, height float64,
options SolarEclipseLocalSearchOptions,
) (LocalSolarEclipseInfo, SolarEclipseLocalSearchStatus) {
return defaultModule.SearchLocalCentralSolarEclipse(date, lon, lat, height, options)
}
// SolarEclipseBesselianElements 计算给定近朔时刻附近一次日食的多项式贝塞尔根数,窗口内无日食时返回 false / Polynomial Besselian elements for the solar eclipse near the given new-moon instant.
func SolarEclipseBesselianElements(
seedJDE float64, options SolarEclipseBesselianElementsOptions,
) (SolarEclipseBesselianElementsResult, bool) {
return defaultModule.SolarEclipseBesselianElements(seedJDE, options)
}
// SolarEclipseCandidates 枚举区间内的日食时刻表,按时间升序 / eclipses within a range, in time order.
func SolarEclipseCandidates(
start, end time.Time, options SolarEclipseCandidateOptions,
) []SolarEclipseCandidate {
return defaultModule.SolarEclipseCandidates(start, end, options)
}
// SolarEclipseCentralPath 计算指定日期附近的日食中心路径,默认使用 NASA bulletin Split-K 模型 / computes the central path near the given date, using NASA bulletin Split-K by default.
func SolarEclipseCentralPath(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
return defaultModule.SolarEclipseCentralPath(date, options)
}
// SolarEclipseCentralPathIAUSingleK 使用 IAU Single-K 模型计算日食中心路径 / computes the central path with the IAU Single-K model.
func SolarEclipseCentralPathIAUSingleK(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
return defaultModule.SolarEclipseCentralPathIAUSingleK(date, options)
}
// SolarEclipseCentralPathNASABulletinSplitK 使用 NASA bulletin Split-K 模型计算日食中心路径 / computes the central path with the NASA bulletin Split-K model.
func SolarEclipseCentralPathNASABulletinSplitK(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
return defaultModule.SolarEclipseCentralPathNASABulletinSplitK(date, options)
}
// SolarEclipseGeocentricPanelAt 计算给定日食在食甚时刻的地心量面板 / computes the geocentric panel of one eclipse at greatest eclipse.
func SolarEclipseGeocentricPanelAt(date time.Time) (SolarEclipseGeocentricPanel, bool) {
return defaultModule.SolarEclipseGeocentricPanelAt(date)
}
// SolarEclipseGeocentricPanelIAUSingleK 使用 IAU Single-K 计算地心量面板 / computes the geocentric panel with the IAU Single-K model.
func SolarEclipseGeocentricPanelIAUSingleK(date time.Time) (SolarEclipseGeocentricPanel, bool) {
return defaultModule.SolarEclipseGeocentricPanelIAUSingleK(date)
}
// SolarEclipseGeocentricPanelWithOptions 使用自定义半径口径计算食甚时刻的地心量面板 / computes the geocentric panel at greatest eclipse with custom radius conventions.
func SolarEclipseGeocentricPanelWithOptions(date time.Time, options SolarEclipseOptions) (SolarEclipseGeocentricPanel, bool) {
return defaultModule.SolarEclipseGeocentricPanelWithOptions(date, options)
}
// SolarEclipseOnDate 当地自然日全局日食查询,默认 NASA bulletin Split-K 模型 / local-date global solar eclipse query, NASA bulletin Split-K by default.
func SolarEclipseOnDate(date time.Time) (SolarEclipseInfo, bool) {
return defaultModule.SolarEclipseOnDate(date)
}
// SolarEclipseOnDateIAUSingleK 当地自然日全局日食查询(IAU Single-K) / local-date global solar eclipse query with IAU Single-K.
func SolarEclipseOnDateIAUSingleK(date time.Time) (SolarEclipseInfo, bool) {
return defaultModule.SolarEclipseOnDateIAUSingleK(date)
}
// SolarEclipseOnDateNASABulletinSplitK 当地自然日全局日食查询(NASA bulletin Split-K) / local-date global solar eclipse query with NASA bulletin Split-K.
func SolarEclipseOnDateNASABulletinSplitK(date time.Time) (SolarEclipseInfo, bool) {
return defaultModule.SolarEclipseOnDateNASABulletinSplitK(date)
}
// SolarEclipseOnDateWithOptions 当地自然日全局日食查询(自定义半径口径) / local-date global solar eclipse query with custom radius conventions.
func SolarEclipseOnDateWithOptions(date time.Time, options SolarEclipseOptions) (SolarEclipseInfo, bool) {
return defaultModule.SolarEclipseOnDateWithOptions(date, options)
}
// SolarEclipsePartialArea 计算半影足迹,是 SolarEclipsePartialFootprints 的兼容包装 / computes penumbral footprint samples and is a compatibility wrapper for SolarEclipsePartialFootprints.
func SolarEclipsePartialArea(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
return defaultModule.SolarEclipsePartialArea(date, options)
}
// SolarEclipsePartialAreaIAUSingleK 是 SolarEclipsePartialFootprintsIAUSingleK 的兼容包装 / is a compatibility wrapper for SolarEclipsePartialFootprintsIAUSingleK.
func SolarEclipsePartialAreaIAUSingleK(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
return defaultModule.SolarEclipsePartialAreaIAUSingleK(date, options)
}
// SolarEclipsePartialAreaNASABulletinSplitK 是 SolarEclipsePartialFootprintsNASABulletinSplitK 的兼容包装 / is a compatibility wrapper for SolarEclipsePartialFootprintsNASABulletinSplitK.
func SolarEclipsePartialAreaNASABulletinSplitK(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
return defaultModule.SolarEclipsePartialAreaNASABulletinSplitK(date, options)
}
// SolarEclipsePartialFootprints 计算指定日期附近的日食半影足迹,默认使用 NASA bulletin Split-K 模型 / computes penumbral footprint samples near the given date, using NASA bulletin Split-K by default.
func SolarEclipsePartialFootprints(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
return defaultModule.SolarEclipsePartialFootprints(date, options)
}
// SolarEclipsePartialFootprintsIAUSingleK 使用 IAU Single-K 模型计算日食半影足迹 / computes penumbral footprint samples with the IAU Single-K model.
func SolarEclipsePartialFootprintsIAUSingleK(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
return defaultModule.SolarEclipsePartialFootprintsIAUSingleK(date, options)
}
// SolarEclipsePartialFootprintsNASABulletinSplitK 使用 NASA bulletin Split-K 模型计算日食半影足迹 / computes penumbral footprint samples with the NASA bulletin Split-K model.
func SolarEclipsePartialFootprintsNASABulletinSplitK(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
return defaultModule.SolarEclipsePartialFootprintsNASABulletinSplitK(date, options)
}
// SolarEclipseShadowAt 无状态便捷入口,可并发调用 / stateless convenience entry point.
func SolarEclipseShadowAt(value time.Time) (SolarEclipseShadowInstant, bool) {
return defaultModule.SolarEclipseShadowAt(value)
}
// SolarEclipseShadowBetween 无状态批量入口,使用进程级 ΔT / stateless batch entry point.
func SolarEclipseShadowBetween(
start, end time.Time, step time.Duration,
) []SolarEclipseShadowInstant {
return defaultModule.SolarEclipseShadowBetween(start, end, step)
}
// SolarEclipseShadowBetweenWithOptions 可显式指定 ΔT 与模型 / with explicit ΔT and model.
func SolarEclipseShadowBetweenWithOptions(
start, end time.Time, step time.Duration, options SolarEclipseShadowSolverOptions,
) []SolarEclipseShadowInstant {
return defaultModule.SolarEclipseShadowBetweenWithOptions(start, end, step, options)
}
// SolarEclipseStationStateAt 无状态便捷入口,可并发调用 / stateless convenience entry point.
func SolarEclipseStationStateAt(value time.Time, lon, lat, height float64) SolarEclipseStationState {
return defaultModule.SolarEclipseStationStateAt(value, lon, lat, height)
}
+159
View File
@@ -0,0 +1,159 @@
package eclipse
import (
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/ephem"
)
// Eclipse 指定星历实例的计算模块 / computation module bound to an ephemeris instance.
type Eclipse struct {
inst *ephem.Instance
// lunarLimb 是站心日食与月食的默认月缘剖面,nil 为圆月面。
lunarLimb LunarLimb
}
// NewWithInstance 创建指定星历实例的模块,nil 使用全局模型 / create a module; nil uses the global model.
func NewWithInstance(inst *ephem.Instance) *Eclipse { return &Eclipse{inst: inst} }
// WithLunarLimb 返回使用指定月缘剖面的新模块,原模块不变;nil 恢复圆月面 /
// a copy of the module using the given lunar limb; nil restores the circular limb.
func (module *Eclipse) WithLunarLimb(limb LunarLimb) *Eclipse {
copied := *module
copied.lunarLimb = limb
return &copied
}
// lunarResult 按模块星历与月缘计算一次月食;未设置月缘时与包级计算相同。
func (module *Eclipse) lunarResult(seedJDE float64, chauvenet bool) basic.LunarEclipseResult {
result := basic.LunarEclipseDanjonWithInstance(seedJDE, module.inst)
if chauvenet {
result = basic.LunarEclipseChauvenetWithInstance(seedJDE, module.inst)
}
return basic.LunarEclipseWithLunarLimb(result, module.lunarLimb, module.inst)
}
// withLunarLimb 对已求出的全局月食按模块月缘重算接触时刻与接触点。
func (module *Eclipse) withLunarLimb(info LunarEclipseInfo) LunarEclipseInfo {
if module.lunarLimb == nil || info.Type == LunarEclipseNone || info.Maximum.IsZero() {
return info
}
result := module.lunarResult(timeToTTJDE(info.Maximum), basic.LunarEclipseShadowModel(info.ShadowModel) == basic.LunarEclipseShadowModelChauvenet)
if result.Type == basic.LunarEclipseNone {
return info
}
return lunarEclipseInfoFromBasicWithInstance(result, info.Maximum.Location(), module.inst)
}
var defaultModule Eclipse
func (module *Eclipse) model() ephem.Model {
if module.inst == nil {
return ephem.Active()
}
return module.inst.Model()
}
// ClosestLunarEclipse 最近一次月食 / closest lunar eclipse.
func (module *Eclipse) ClosestLunarEclipse(date time.Time) LunarEclipseInfo {
return module.withLunarLimb(ClosestLunarEclipseWithInstance(date, module.inst))
}
// ClosestLunarEclipseChauvenet 最近一次月食(Chauvenet) / closest lunar eclipse with Chauvenet model.
func (module *Eclipse) ClosestLunarEclipseChauvenet(date time.Time) LunarEclipseInfo {
return module.withLunarLimb(ClosestLunarEclipseChauvenetWithInstance(date, module.inst))
}
// ClosestLunarEclipseDanjon 最近一次月食(Danjon) / closest lunar eclipse with Danjon model.
func (module *Eclipse) ClosestLunarEclipseDanjon(date time.Time) LunarEclipseInfo {
return module.withLunarLimb(ClosestLunarEclipseDanjonWithInstance(date, module.inst))
}
// LastLunarEclipse 上次月食 / previous lunar eclipse.
func (module *Eclipse) LastLunarEclipse(date time.Time) LunarEclipseInfo {
return module.withLunarLimb(LastLunarEclipseWithInstance(date, module.inst))
}
// LastLunarEclipseChauvenet 上次月食(Chauvenet) / previous lunar eclipse with Chauvenet model.
func (module *Eclipse) LastLunarEclipseChauvenet(date time.Time) LunarEclipseInfo {
return module.withLunarLimb(LastLunarEclipseChauvenetWithInstance(date, module.inst))
}
// LastLunarEclipseDanjon 上次月食(Danjon) / previous lunar eclipse with Danjon model.
func (module *Eclipse) LastLunarEclipseDanjon(date time.Time) LunarEclipseInfo {
return module.withLunarLimb(LastLunarEclipseDanjonWithInstance(date, module.inst))
}
// LocalSolarEclipseInfoInUT1 返回各阶段与接触点换成 UT1 时刻的副本 / copy in UT1.
func (module *Eclipse) LocalSolarEclipseInfoInUT1(info LocalSolarEclipseInfo) LocalSolarEclipseInfo {
return LocalSolarEclipseInfoInUT1(info)
}
// LunarEclipseInfoInUT1 返回各阶段与接触点换成 UT1 时刻的副本 / copy in UT1.
func (module *Eclipse) LunarEclipseInfoInUT1(info LunarEclipseInfo) LunarEclipseInfo {
return LunarEclipseInfoInUT1(info)
}
// LunarEclipseOnDate 当地自然日月食查询 / local-date lunar eclipse query.
func (module *Eclipse) LunarEclipseOnDate(date time.Time) (LunarEclipseInfo, bool) {
info, ok := LunarEclipseOnDateWithInstance(date, module.inst)
return module.withLunarLimb(info), ok
}
// LunarEclipseOnDateChauvenet 当地自然日月食查询(Chauvenet) / local-date lunar eclipse query with Chauvenet model.
func (module *Eclipse) LunarEclipseOnDateChauvenet(date time.Time) (LunarEclipseInfo, bool) {
info, ok := LunarEclipseOnDateChauvenetWithInstance(date, module.inst)
return module.withLunarLimb(info), ok
}
// LunarEclipseOnDateDanjon 当地自然日月食查询(Danjon) / local-date lunar eclipse query with Danjon model.
func (module *Eclipse) LunarEclipseOnDateDanjon(date time.Time) (LunarEclipseInfo, bool) {
info, ok := LunarEclipseOnDateDanjonWithInstance(date, module.inst)
return module.withLunarLimb(info), ok
}
// NextLunarEclipse 下次月食 / next lunar eclipse.
func (module *Eclipse) NextLunarEclipse(date time.Time) LunarEclipseInfo {
return module.withLunarLimb(NextLunarEclipseWithInstance(date, module.inst))
}
// NextLunarEclipseChauvenet 下次月食(Chauvenet) / next lunar eclipse with Chauvenet model.
func (module *Eclipse) NextLunarEclipseChauvenet(date time.Time) LunarEclipseInfo {
return module.withLunarLimb(NextLunarEclipseChauvenetWithInstance(date, module.inst))
}
// NextLunarEclipseDanjon 下次月食(Danjon) / next lunar eclipse with Danjon model.
func (module *Eclipse) NextLunarEclipseDanjon(date time.Time) LunarEclipseInfo {
return module.withLunarLimb(NextLunarEclipseDanjonWithInstance(date, module.inst))
}
// SolarEclipseBesselianMuForPublishedTable 把本库的 Mu 换算成与已发布根数表直接可比的取值 / convert Mu to the convention of published element tables.
func (module *Eclipse) SolarEclipseBesselianMuForPublishedTable(
mu SolarEclipseBesselianPolynomial, deltaTSeconds float64,
) SolarEclipseBesselianPolynomial {
return SolarEclipseBesselianMuForPublishedTable(mu, deltaTSeconds)
}
// SolarEclipseGeocentricPanelInUT1 返回两个朔时刻换成 UT1 时刻的副本 / copy in UT1.
func (module *Eclipse) SolarEclipseGeocentricPanelInUT1(panel SolarEclipseGeocentricPanel) SolarEclipseGeocentricPanel {
return SolarEclipseGeocentricPanelInUT1(panel)
}
// SolarEclipseInfoInUT1 返回各阶段时刻换成 UT1 时刻的副本 / returns a copy in UT1.
func (module *Eclipse) SolarEclipseInfoInUT1(info SolarEclipseInfo) SolarEclipseInfo {
return SolarEclipseInfoInUT1(info)
}
// SolarEclipsePartialFootprintsInUT1 返回足迹、轮廓与接触点都换成 UT1 时刻的副本 / copy in UT1.
func (module *Eclipse) SolarEclipsePartialFootprintsInUT1(info SolarEclipsePartialFootprintsInfo) SolarEclipsePartialFootprintsInfo {
return SolarEclipsePartialFootprintsInUT1(info)
}
// SolarEclipsePathInUT1 返回中心线与限界都换成 UT1 时刻的副本 / copy in UT1.
func (module *Eclipse) SolarEclipsePathInUT1(path SolarEclipsePath) SolarEclipsePath {
return SolarEclipsePathInUT1(path)
}
// TimeLabelsInUT1 返回换成 UT1 时刻的新切片 / returns a copy in UT1.
func (module *Eclipse) TimeLabelsInUT1(times []time.Time) []time.Time { return TimeLabelsInUT1(times) }
+186
View File
@@ -0,0 +1,186 @@
package eclipse
import (
"b612.me/astro/basic"
"b612.me/astro/internal/ephem"
"reflect"
"testing"
"time"
)
type contextAPI interface {
ClosestGeometricLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo
ClosestGeometricLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo
ClosestGeometricLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo
ClosestGeometricLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo
ClosestGeometricLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo
ClosestGeometricLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo
ClosestLocalAnnularSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool)
ClosestLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo
ClosestLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo
ClosestLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo
ClosestLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo
ClosestLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo
ClosestLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo
ClosestLocalTotalLunarEclipse(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool)
ClosestLocalTotalSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool)
ClosestLunarEclipse(date time.Time) LunarEclipseInfo
ClosestLunarEclipseChauvenet(date time.Time) LunarEclipseInfo
ClosestLunarEclipseDanjon(date time.Time) LunarEclipseInfo
ClosestSolarEclipse(date time.Time) SolarEclipseInfo
ClosestSolarEclipseIAUSingleK(date time.Time) SolarEclipseInfo
ClosestSolarEclipseNASABulletinSplitK(date time.Time) SolarEclipseInfo
ClosestSolarEclipseWithOptions(date time.Time, options SolarEclipseOptions) SolarEclipseInfo
GeometricLocalLunarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool)
GeometricLocalLunarEclipseOnDateChauvenet(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool)
GeometricLocalLunarEclipseOnDateDanjon(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool)
GeometricLocalSolarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool)
GeometricLocalSolarEclipseOnDateIAUSingleK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool)
GeometricLocalSolarEclipseOnDateNASABulletinSplitK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool)
LastGeometricLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo
LastGeometricLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo
LastGeometricLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo
LastGeometricLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo
LastGeometricLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo
LastGeometricLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo
LastLocalAnnularSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool)
LastLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo
LastLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo
LastLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo
LastLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo
LastLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo
LastLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo
LastLocalTotalLunarEclipse(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool)
LastLocalTotalSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool)
LastLunarEclipse(date time.Time) LunarEclipseInfo
LastLunarEclipseChauvenet(date time.Time) LunarEclipseInfo
LastLunarEclipseDanjon(date time.Time) LunarEclipseInfo
LastSolarEclipse(date time.Time) SolarEclipseInfo
LastSolarEclipseIAUSingleK(date time.Time) SolarEclipseInfo
LastSolarEclipseNASABulletinSplitK(date time.Time) SolarEclipseInfo
LastSolarEclipseWithOptions(date time.Time, options SolarEclipseOptions) SolarEclipseInfo
LocalLunarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool)
LocalLunarEclipseOnDateChauvenet(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool)
LocalLunarEclipseOnDateDanjon(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool)
LocalSolarEclipseInfoInUT1(info LocalSolarEclipseInfo) LocalSolarEclipseInfo
LocalSolarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool)
LocalSolarEclipseOnDateIAUSingleK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool)
LocalSolarEclipseOnDateNASABulletinSplitK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool)
LocalSolarEclipseOnDateWithOptions(date time.Time, lon, lat, height float64, options SolarEclipseOptions) (LocalSolarEclipseInfo, bool)
LunarEclipseGeocentricPanelAt(date time.Time) (LunarEclipseGeocentricPanel, bool)
LunarEclipseGeocentricPanelChauvenet(date time.Time) (LunarEclipseGeocentricPanel, bool)
LunarEclipseInfoInUT1(info LunarEclipseInfo) LunarEclipseInfo
LunarEclipseOnDate(date time.Time) (LunarEclipseInfo, bool)
LunarEclipseOnDateChauvenet(date time.Time) (LunarEclipseInfo, bool)
LunarEclipseOnDateDanjon(date time.Time) (LunarEclipseInfo, bool)
NewSolarEclipseShadowSolver(options SolarEclipseShadowSolverOptions) *SolarEclipseShadowSolver
NextGeometricLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo
NextGeometricLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo
NextGeometricLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo
NextGeometricLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo
NextGeometricLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo
NextGeometricLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo
NextLocalAnnularSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool)
NextLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo
NextLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo
NextLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo
NextLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo
NextLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo
NextLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo
NextLocalTotalLunarEclipse(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool)
NextLocalTotalSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool)
NextLunarEclipse(date time.Time) LunarEclipseInfo
NextLunarEclipseChauvenet(date time.Time) LunarEclipseInfo
NextLunarEclipseDanjon(date time.Time) LunarEclipseInfo
NextSolarEclipse(date time.Time) SolarEclipseInfo
NextSolarEclipseIAUSingleK(date time.Time) SolarEclipseInfo
NextSolarEclipseNASABulletinSplitK(date time.Time) SolarEclipseInfo
NextSolarEclipseWithOptions(date time.Time, options SolarEclipseOptions) SolarEclipseInfo
SearchLocalCentralSolarEclipse(
date time.Time, lon, lat, height float64,
options SolarEclipseLocalSearchOptions,
) (LocalSolarEclipseInfo, SolarEclipseLocalSearchStatus)
SolarEclipseBesselianElements(
seedJDE float64, options SolarEclipseBesselianElementsOptions,
) (SolarEclipseBesselianElementsResult, bool)
SolarEclipseBesselianMuForPublishedTable(
mu SolarEclipseBesselianPolynomial, deltaTSeconds float64,
) SolarEclipseBesselianPolynomial
SolarEclipseCandidates(
start, end time.Time, options SolarEclipseCandidateOptions,
) []SolarEclipseCandidate
SolarEclipseCentralPath(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool)
SolarEclipseCentralPathIAUSingleK(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool)
SolarEclipseCentralPathNASABulletinSplitK(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool)
SolarEclipseGeocentricPanelAt(date time.Time) (SolarEclipseGeocentricPanel, bool)
SolarEclipseGeocentricPanelIAUSingleK(date time.Time) (SolarEclipseGeocentricPanel, bool)
SolarEclipseGeocentricPanelInUT1(panel SolarEclipseGeocentricPanel) SolarEclipseGeocentricPanel
SolarEclipseGeocentricPanelWithOptions(date time.Time, options SolarEclipseOptions) (SolarEclipseGeocentricPanel, bool)
SolarEclipseInfoInUT1(info SolarEclipseInfo) SolarEclipseInfo
SolarEclipseOnDate(date time.Time) (SolarEclipseInfo, bool)
SolarEclipseOnDateIAUSingleK(date time.Time) (SolarEclipseInfo, bool)
SolarEclipseOnDateNASABulletinSplitK(date time.Time) (SolarEclipseInfo, bool)
SolarEclipseOnDateWithOptions(date time.Time, options SolarEclipseOptions) (SolarEclipseInfo, bool)
SolarEclipsePartialArea(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool)
SolarEclipsePartialAreaIAUSingleK(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool)
SolarEclipsePartialAreaNASABulletinSplitK(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool)
SolarEclipsePartialFootprints(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool)
SolarEclipsePartialFootprintsIAUSingleK(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool)
SolarEclipsePartialFootprintsInUT1(info SolarEclipsePartialFootprintsInfo) SolarEclipsePartialFootprintsInfo
SolarEclipsePartialFootprintsNASABulletinSplitK(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool)
SolarEclipsePathInUT1(path SolarEclipsePath) SolarEclipsePath
SolarEclipseShadowAt(value time.Time) (SolarEclipseShadowInstant, bool)
SolarEclipseShadowBetween(
start, end time.Time, step time.Duration,
) []SolarEclipseShadowInstant
SolarEclipseShadowBetweenWithOptions(
start, end time.Time, step time.Duration, options SolarEclipseShadowSolverOptions,
) []SolarEclipseShadowInstant
SolarEclipseStationStateAt(value time.Time, lon, lat, height float64) SolarEclipseStationState
TimeLabelsInUT1(times []time.Time) []time.Time
}
var _ contextAPI = (*Eclipse)(nil)
func TestContextModuleIsolation(t *testing.T) {
previous := basic.GetEphemerisModel()
t.Cleanup(func() { basic.SetEphemerisModel(previous) })
date := time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC)
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
basic.SetEphemerisModel(basic.EphemerisModel(model))
want, wantOK := SolarEclipseOnDate(date)
module := NewWithInstance(ephem.NewModelInstance(model))
basic.SetEphemerisModel(basic.EphemerisModel(1 - int(model)))
got, gotOK := module.SolarEclipseOnDate(date)
if !wantOK || gotOK != wantOK || got != want {
t.Fatalf("model %d: solar eclipse escaped instance", model)
}
}
}
// 站心日食、候选枚举与中心食搜索必须使用模块实例,切换全局模型不得改变结果。
func TestContextLocalSolarEclipseIsolation(t *testing.T) {
previous := basic.GetEphemerisModel()
t.Cleanup(func() { basic.SetEphemerisModel(previous) })
date := time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC)
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
module := NewWithInstance(ephem.NewModelInstance(model))
run := func() []interface{} {
next := module.NextLocalSolarEclipse(date, -97, 32, 0)
iau := module.NextLocalSolarEclipseIAUSingleK(date, -97, 32, 0)
onDate, ok := module.LocalSolarEclipseOnDate(time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC), -97, 32, 0)
candidates := module.SolarEclipseCandidates(date, date.AddDate(1, 0, 0), SolarEclipseCandidateOptions{})
central, status := module.SearchLocalCentralSolarEclipse(date, -97, 32, 0, SolarEclipseLocalSearchOptions{MaxYears: 2})
return []interface{}{next, iau, onDate, ok, candidates, central, status}
}
basic.SetEphemerisModel(basic.EphemerisModel(model))
want := run()
basic.SetEphemerisModel(basic.EphemerisModel(1 - int(model)))
got := run()
for i := range want {
if !reflect.DeepEqual(got[i], want[i]) {
t.Fatalf("model %d: result %d escaped instance:\n got %+v\nwant %+v", model, i, got[i], want[i])
}
}
}
}
+2 -2
View File
@@ -3,7 +3,7 @@ package eclipse
import (
"time"
"b612.me/astro"
"b612.me/astro/basic"
)
// 以下函数把结构里的民用时刻换成同一物理时刻的 UT1 时刻,供出图与导出共用。
@@ -13,7 +13,7 @@ func ut1Label(t time.Time) time.Time {
if t.IsZero() {
return t
}
return astro.LabelIn(astro.TimeScaleUT1, t)
return basic.LabelIn(basic.TimeScaleUT1, t)
}
// TimeLabelsInUT1 返回换成 UT1 时刻的新切片 / returns a copy in UT1.
+58 -23
View File
@@ -5,6 +5,7 @@ import (
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/ephem"
)
const (
@@ -83,6 +84,8 @@ type LunarEclipseContactPoint struct {
type LunarEclipseInfo struct {
// Type 月食类型, eclipse type.
Type LunarEclipseType
// LunarLimbApplied 接触时刻已按月缘剖面修正;剖面失败时为 false 且结果保持圆月面 / contacts refined with a lunar limb profile.
LunarLimbApplied bool
// HasSaros 存在沙罗序列信息(可能是锚点外推结果), has Saros series metadata (possibly extrapolated).
HasSaros bool
// Saros 是沙罗序列信息,包括系列号、系列内序号和总成员数。
@@ -166,22 +169,26 @@ func LunarEclipseOnDateChauvenet(date time.Time) (LunarEclipseInfo, bool) {
}
func lunarEclipseOnDateWithFallback(date time.Time, calculator lunarEclipseCalculator, allowDefaultFallback bool) (LunarEclipseInfo, bool) {
return lunarEclipseOnDateWithInstance(date, calculator, allowDefaultFallback, nil)
}
func lunarEclipseOnDateWithInstance(date time.Time, calculator lunarEclipseCalculator, allowDefaultFallback bool, inst *ephem.Instance) (LunarEclipseInfo, bool) {
location := date.Location()
dayStart, dayMid, dayEnd := lunarEclipseLocalDayBounds(date)
phaseDiff := moonSunLoDiff(dayMid)
phaseDiff := moonSunLoDiffWithInstance(dayMid, inst)
if phaseDiff < lunarEclipseDayPhaseMin || phaseDiff > lunarEclipseDayPhaseMax {
return LunarEclipseInfo{}, false
}
candidateTT := basic.CalcMoonSHByJDE(timeToTTJDE(dayMid), 1)
if !isPotentialLunarEclipse(candidateTT) {
candidateTT := basic.CalcMoonSHByJDEWithInstance(timeToTTJDE(dayMid), 1, inst)
if !isPotentialLunarEclipseWithInstance(candidateTT, inst) {
return LunarEclipseInfo{}, false
}
result := calculator(candidateTT)
if result.Type == basic.LunarEclipseNone && allowDefaultFallback {
if fallback, ok := lunarEclipseDefaultFallback(candidateTT); ok {
if fallback, ok := lunarEclipseDefaultFallbackWithInstance(candidateTT, inst); ok {
result = fallback
}
}
@@ -189,7 +196,7 @@ func lunarEclipseOnDateWithFallback(date time.Time, calculator lunarEclipseCalcu
return LunarEclipseInfo{}, false
}
info := lunarEclipseInfoFromBasic(result, location)
info := lunarEclipseInfoFromBasicWithInstance(result, location, inst)
if !lunarEclipseOverlapsDate(info, dayStart, dayEnd) {
return LunarEclipseInfo{}, false
}
@@ -298,29 +305,40 @@ func searchLunarEclipse(
calculator lunarEclipseCalculator,
allowDefaultFallback bool,
) (LunarEclipseInfo, bool) {
return searchLunarEclipseWithInstance(date, direction, includeCurrent, calculator, allowDefaultFallback, nil)
}
func searchLunarEclipseWithInstance(date time.Time, direction int, includeCurrent bool, calculator lunarEclipseCalculator, allowDefaultFallback bool, inst *ephem.Instance) (LunarEclipseInfo, bool) {
targetTT := timeToTTJDE(date)
candidateTT := basic.CalcMoonSHByJDE(targetTT, 1)
candidateTT := basic.CalcMoonSHByJDEWithInstance(targetTT, 1, inst)
for i := 0; i < lunarEclipseSearchLimit; i++ {
if isPotentialLunarEclipse(candidateTT) {
if math.IsNaN(candidateTT) || math.IsInf(candidateTT, 0) {
break
}
if isPotentialLunarEclipseWithInstance(candidateTT, inst) {
result := calculator(candidateTT)
if result.Type == basic.LunarEclipseNone && allowDefaultFallback {
if fallback, ok := lunarEclipseDefaultFallback(candidateTT); ok {
if fallback, ok := lunarEclipseDefaultFallbackWithInstance(candidateTT, inst); ok {
result = fallback
}
}
if result.Type != basic.LunarEclipseNone && lunarEclipseMatchesDirection(result.Maximum, targetTT, direction, includeCurrent) {
return lunarEclipseInfoFromBasic(result, date.Location()), true
return lunarEclipseInfoFromBasicWithInstance(result, date.Location(), inst), true
}
}
candidateTT = nextEclipseSearchCandidateTT(candidateTT, 1, direction, lunarEclipseSynodicMonthDays)
candidateTT = nextLunarEclipseSearchCandidateTTWithInstance(candidateTT, direction, inst)
}
return LunarEclipseInfo{}, false
}
func lunarEclipseDefaultFallback(candidateTT float64) (basic.LunarEclipseResult, bool) {
result := basic.LunarEclipseChauvenet(candidateTT)
return lunarEclipseDefaultFallbackWithInstance(candidateTT, nil)
}
func lunarEclipseDefaultFallbackWithInstance(candidateTT float64, inst *ephem.Instance) (basic.LunarEclipseResult, bool) {
result := basic.LunarEclipseChauvenetWithInstance(candidateTT, inst)
if result.Type != basic.LunarEclipsePenumbral {
return basic.LunarEclipseResult{}, false
}
@@ -345,18 +363,26 @@ func lunarEclipseMatchesDirection(maximumTT, targetTT float64, direction int, in
}
func isPotentialLunarEclipse(fullMoonTT float64) bool {
moonLatitude := math.Abs(basic.HMoonTrueBo(fullMoonTT))
return isPotentialLunarEclipseWithInstance(fullMoonTT, nil)
}
func isPotentialLunarEclipseWithInstance(fullMoonTT float64, inst *ephem.Instance) bool {
moonLatitude := math.Abs(basic.HMoonTrueBoWithInstance(fullMoonTT, inst))
if moonLatitude > lunarEclipseLatitudeLimitDeg {
return false
}
phaseDiff := math.Abs(normalizeDegree180(basic.HMoonApparentLo(fullMoonTT) - basic.HSunApparentLo(fullMoonTT) - 180))
phaseDiff := math.Abs(normalizeDegree180(basic.HMoonApparentLoWithInstance(fullMoonTT, inst) - basic.HSunApparentLoWithInstance(fullMoonTT, inst) - 180))
return phaseDiff <= lunarEclipseLongitudeLimitDeg
}
func lunarEclipseInfoFromBasic(result basic.LunarEclipseResult, location *time.Location) LunarEclipseInfo {
saros, hasSaros := lunarSarosInfo(result.Maximum)
geometry := basic.LunarEclipseShadowGeometryAtModel(result.Maximum, result.ShadowModel)
return lunarEclipseInfoFromBasicWithInstance(result, location, nil)
}
func lunarEclipseInfoFromBasicWithInstance(result basic.LunarEclipseResult, location *time.Location, inst *ephem.Instance) LunarEclipseInfo {
saros, hasSaros := lunarSarosInfoWithInstance(result.Maximum, inst)
geometry := basic.LunarEclipseShadowGeometryAtModelWithInstance(result.Maximum, result.ShadowModel, inst)
return LunarEclipseInfo{
ShadowModel: LunarEclipseShadowModel(result.ShadowModel),
Gamma: geometry.Gamma,
@@ -367,6 +393,7 @@ func lunarEclipseInfoFromBasic(result basic.LunarEclipseResult, location *time.L
HasSaros: hasSaros,
Saros: saros,
Type: mapBasicLunarEclipseType(result.Type),
LunarLimbApplied: result.LunarLimbApplied,
PenumbralMagnitude: result.PenumbralMagnitude,
UmbralMagnitude: result.Magnitude,
PenumbralStart: ttJDEToTime(result.PenumbralStart, location),
@@ -378,7 +405,7 @@ func lunarEclipseInfoFromBasic(result basic.LunarEclipseResult, location *time.L
PenumbralEnd: ttJDEToTime(result.PenumbralEnd, location),
GreatestJDE: result.Maximum,
DeltaTSeconds: basic.DeltaT(result.Maximum, true),
ContactPoints: lunarEclipseContactPointsFromBasic(result, location),
ContactPoints: lunarEclipseContactPointsFromBasicWithInstance(result, location, inst),
HasPenumbral: result.HasPenumbral,
HasPartial: result.HasPartial,
HasTotal: result.HasTotal,
@@ -389,26 +416,30 @@ func lunarEclipseContactPointsFromBasic(
result basic.LunarEclipseResult,
location *time.Location,
) []LunarEclipseContactPoint {
return lunarEclipseContactPointsFromBasicWithInstance(result, location, nil)
}
func lunarEclipseContactPointsFromBasicWithInstance(result basic.LunarEclipseResult, location *time.Location, inst *ephem.Instance) []LunarEclipseContactPoint {
if !result.HasPenumbral {
return nil
}
contacts := []LunarEclipseContactPoint{
lunarEclipseContactPoint("P1", result.PenumbralStart, location, false),
lunarEclipseContactPointWithInstance("P1", result.PenumbralStart, location, false, inst),
}
if result.HasPartial {
contacts = append(contacts, lunarEclipseContactPoint("U1", result.PartialStart, location, false))
contacts = append(contacts, lunarEclipseContactPointWithInstance("U1", result.PartialStart, location, false, inst))
}
if result.HasTotal {
contacts = append(contacts, lunarEclipseContactPoint("U2", result.TotalStart, location, true))
contacts = append(contacts, lunarEclipseContactPointWithInstance("U2", result.TotalStart, location, true, inst))
}
if result.HasTotal {
contacts = append(contacts, lunarEclipseContactPoint("U3", result.TotalEnd, location, true))
contacts = append(contacts, lunarEclipseContactPointWithInstance("U3", result.TotalEnd, location, true, inst))
}
if result.HasPartial {
contacts = append(contacts, lunarEclipseContactPoint("U4", result.PartialEnd, location, false))
contacts = append(contacts, lunarEclipseContactPointWithInstance("U4", result.PartialEnd, location, false, inst))
}
contacts = append(contacts, lunarEclipseContactPoint("P4", result.PenumbralEnd, location, false))
contacts = append(contacts, lunarEclipseContactPointWithInstance("P4", result.PenumbralEnd, location, false, inst))
return contacts
}
@@ -418,7 +449,11 @@ func lunarEclipseContactPoint(
location *time.Location,
internalContact bool,
) LunarEclipseContactPoint {
moonCenterPA := lunarEclipseMoonCenterPositionAngle(ttJDE)
return lunarEclipseContactPointWithInstance(label, ttJDE, location, internalContact, nil)
}
func lunarEclipseContactPointWithInstance(label string, ttJDE float64, location *time.Location, internalContact bool, inst *ephem.Instance) LunarEclipseContactPoint {
moonCenterPA := lunarEclipseMoonCenterPositionAngleWithInstance(ttJDE, inst)
shadowCenterPA := normalizeDegree360(moonCenterPA + 180)
contactPA := shadowCenterPA
if internalContact {
+142
View File
@@ -0,0 +1,142 @@
package eclipse
import (
"math"
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/ephem"
)
// LunarEclipseOnDateWithInstance 指定星历实例的当地日月食查询,自定义引擎不推断沙罗 / local-date lunar eclipse with an explicit instance; custom engines omit Saros metadata.
func LunarEclipseOnDateWithInstance(date time.Time, inst *ephem.Instance) (LunarEclipseInfo, bool) {
return lunarEclipseOnDateWithInstance(date, lunarCalculatorWithInstance(false, inst), true, inst)
}
// LunarEclipseOnDateDanjonWithInstance 指定星历实例的 Danjon 当地日月食查询 / local-date Danjon lunar eclipse with an explicit instance.
func LunarEclipseOnDateDanjonWithInstance(date time.Time, inst *ephem.Instance) (LunarEclipseInfo, bool) {
return lunarEclipseOnDateWithInstance(date, lunarCalculatorWithInstance(false, inst), false, inst)
}
// LunarEclipseOnDateChauvenetWithInstance 指定星历实例的 Chauvenet 当地日月食查询 / local-date Chauvenet lunar eclipse with an explicit instance.
func LunarEclipseOnDateChauvenetWithInstance(date time.Time, inst *ephem.Instance) (LunarEclipseInfo, bool) {
return lunarEclipseOnDateWithInstance(date, lunarCalculatorWithInstance(true, inst), false, inst)
}
// LastLunarEclipseWithInstance 指定星历实例的上次月食 / previous lunar eclipse with an explicit instance.
func LastLunarEclipseWithInstance(date time.Time, inst *ephem.Instance) LunarEclipseInfo {
info, _ := searchLunarEclipseWithInstance(date, -1, true, lunarCalculatorWithInstance(false, inst), true, inst)
return info
}
// LastLunarEclipseDanjonWithInstance 指定星历实例的 Danjon 上次月食 / previous Danjon lunar eclipse with an explicit instance.
func LastLunarEclipseDanjonWithInstance(date time.Time, inst *ephem.Instance) LunarEclipseInfo {
info, _ := searchLunarEclipseWithInstance(date, -1, true, lunarCalculatorWithInstance(false, inst), false, inst)
return info
}
// LastLunarEclipseChauvenetWithInstance 指定星历实例的 Chauvenet 上次月食 / previous Chauvenet lunar eclipse with an explicit instance.
func LastLunarEclipseChauvenetWithInstance(date time.Time, inst *ephem.Instance) LunarEclipseInfo {
info, _ := searchLunarEclipseWithInstance(date, -1, true, lunarCalculatorWithInstance(true, inst), false, inst)
return info
}
// NextLunarEclipseWithInstance 指定星历实例的下次月食 / next lunar eclipse with an explicit instance.
func NextLunarEclipseWithInstance(date time.Time, inst *ephem.Instance) LunarEclipseInfo {
info, _ := searchLunarEclipseWithInstance(date, 1, false, lunarCalculatorWithInstance(false, inst), true, inst)
return info
}
// NextLunarEclipseDanjonWithInstance 指定星历实例的 Danjon 下次月食 / next Danjon lunar eclipse with an explicit instance.
func NextLunarEclipseDanjonWithInstance(date time.Time, inst *ephem.Instance) LunarEclipseInfo {
info, _ := searchLunarEclipseWithInstance(date, 1, false, lunarCalculatorWithInstance(false, inst), false, inst)
return info
}
// NextLunarEclipseChauvenetWithInstance 指定星历实例的 Chauvenet 下次月食 / next Chauvenet lunar eclipse with an explicit instance.
func NextLunarEclipseChauvenetWithInstance(date time.Time, inst *ephem.Instance) LunarEclipseInfo {
info, _ := searchLunarEclipseWithInstance(date, 1, false, lunarCalculatorWithInstance(true, inst), false, inst)
return info
}
// ClosestLunarEclipseWithInstance 指定星历实例的最近月食 / closest lunar eclipse with an explicit instance.
func ClosestLunarEclipseWithInstance(date time.Time, inst *ephem.Instance) LunarEclipseInfo {
return closestLunarEclipseWithInstance(date, false, true, inst)
}
// ClosestLunarEclipseDanjonWithInstance 指定星历实例的 Danjon 最近月食 / closest Danjon lunar eclipse with an explicit instance.
func ClosestLunarEclipseDanjonWithInstance(date time.Time, inst *ephem.Instance) LunarEclipseInfo {
return closestLunarEclipseWithInstance(date, false, false, inst)
}
// ClosestLunarEclipseChauvenetWithInstance 指定星历实例的 Chauvenet 最近月食 / closest Chauvenet lunar eclipse with an explicit instance.
func ClosestLunarEclipseChauvenetWithInstance(date time.Time, inst *ephem.Instance) LunarEclipseInfo {
return closestLunarEclipseWithInstance(date, true, false, inst)
}
func lunarCalculatorWithInstance(chauvenet bool, inst *ephem.Instance) lunarEclipseCalculator {
if chauvenet {
return func(jde float64) basic.LunarEclipseResult { return basic.LunarEclipseChauvenetWithInstance(jde, inst) }
}
return func(jde float64) basic.LunarEclipseResult { return basic.LunarEclipseDanjonWithInstance(jde, inst) }
}
func closestLunarEclipseWithInstance(date time.Time, chauvenet, allowDefaultFallback bool, inst *ephem.Instance) LunarEclipseInfo {
calculator := lunarCalculatorWithInstance(chauvenet, inst)
last, hasLast := searchLunarEclipseWithInstance(date, -1, true, calculator, allowDefaultFallback, inst)
next, hasNext := searchLunarEclipseWithInstance(date, 1, false, calculator, allowDefaultFallback, inst)
return closestLunarEclipse(date, last, hasLast, next, hasNext)
}
func moonSunLoDiffWithInstance(date time.Time, inst *ephem.Instance) float64 {
if inst == nil {
return moonSunLoDiff(date)
}
jde := timeToTTJDE(date)
return normalizeDegree360(basic.HMoonApparentLoWithInstance(jde, inst) - basic.HSunApparentLoWithInstance(jde, inst))
}
func nextLunarEclipseSearchCandidateTTWithInstance(candidateTT float64, direction int, inst *ephem.Instance) float64 {
if inst == nil {
return nextEclipseSearchCandidateTT(candidateTT, 1, direction, lunarEclipseSynodicMonthDays)
}
step := 1
if inst.Model() != ephem.Custom {
step = eclipseSearchStep(candidateTT, direction, lunarEclipseSynodicMonthDays)
}
return basic.CalcMoonSHByJDEWithInstance(candidateTT+float64(direction*step)*lunarEclipseSynodicMonthDays, 1, inst)
}
func lunarEclipseMoonCenterPositionAngleWithInstance(ttJDE float64, inst *ephem.Instance) float64 {
if inst == nil {
return lunarEclipseMoonCenterPositionAngle(ttJDE)
}
sunRA, sunDec := basic.HSunApparentRaDecWithInstance(ttJDE, inst)
moonRA, moonDec := basic.HMoonTrueRaDecWithInstance(ttJDE, inst)
return positionAngle(normalizeDegree360(sunRA+180), -sunDec, moonRA, moonDec)
}
func lunarSarosInfoWithInstance(ttJDE float64, inst *ephem.Instance) (SarosInfo, bool) {
if inst == nil {
return lunarSarosInfo(ttJDE)
}
if inst.Model() == ephem.Custom {
return SarosInfo{}, false
}
if info, ok := matchSarosMagic(lunarSarosAnchors[:], 1, lunarSarosHeadOverrides[:], ttJDE); ok {
return info, true
}
info, ok := extendedSarosInfoForModel(ttJDE, 1, inst.Model(), func(lunation, phase int) (float64, bool, bool) {
seed := sarosLunationEpoch + (float64(lunation)+float64(phase)/2)*solarEclipseSynodicMonthDays
result := basic.LunarEclipseDanjonWithInstance(seed, inst)
if result.Type == basic.LunarEclipseNone {
result = basic.LunarEclipseChauvenetWithInstance(seed, inst)
}
valid := !math.IsNaN(result.Maximum) && !math.IsInf(result.Maximum, 0) && math.Abs(result.Maximum-seed) < sarosEclipseSeedToleranceDays
return result.Maximum, result.Type != basic.LunarEclipseNone, valid
})
if !ok {
return SarosInfo{}, false
}
return reconcileSarosSpan(info, ttJDE, 1)
}
+153
View File
@@ -0,0 +1,153 @@
package eclipse
import (
"math"
"reflect"
"testing"
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/ephem"
)
type p3LunarShiftEngine struct {
base *ephem.Instance
offset float64
}
func (engine p3LunarShiftEngine) State(target, center ephem.Body, jde float64) ([3]float64, [3]float64, error) {
return engine.base.State(target, center, jde+engine.offset)
}
func (engine p3LunarShiftEngine) Apparent(target ephem.Body, jde float64) ([3]float64, float64, float64, error) {
return engine.base.Apparent(target, jde+engine.offset)
}
func (engine p3LunarShiftEngine) Coverage(target ephem.Body) (float64, float64, bool) {
start, end, ok := engine.base.Coverage(target)
return start - engine.offset, end - engine.offset, ok
}
func TestP3LunarQueriesPreserveNilAndLegacy(t *testing.T) {
previous := ephem.Active()
t.Cleanup(func() { ephem.SetActive(previous) })
date := time.Date(2022, 11, 8, 12, 0, 0, 0, time.UTC)
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
ephem.SetActive(model)
inst := ephem.NewModelInstance(model)
for _, query := range []struct {
old func(time.Time) (LunarEclipseInfo, bool)
new func(time.Time, *ephem.Instance) (LunarEclipseInfo, bool)
}{
{LunarEclipseOnDate, LunarEclipseOnDateWithInstance},
{LunarEclipseOnDateDanjon, LunarEclipseOnDateDanjonWithInstance},
{LunarEclipseOnDateChauvenet, LunarEclipseOnDateChauvenetWithInstance},
} {
want, wantOK := query.old(date)
got, gotOK := query.new(date, nil)
if gotOK != wantOK || !reflect.DeepEqual(got, want) {
t.Error("nil changed local-date query")
}
if model == ephem.Legacy {
got, gotOK = query.new(date, inst)
if gotOK != wantOK || !reflect.DeepEqual(got, want) {
t.Error("explicit Legacy changed local-date query")
}
}
}
for _, query := range []struct {
old func(time.Time) LunarEclipseInfo
new func(time.Time, *ephem.Instance) LunarEclipseInfo
}{
{LastLunarEclipse, LastLunarEclipseWithInstance},
{LastLunarEclipseDanjon, LastLunarEclipseDanjonWithInstance},
{LastLunarEclipseChauvenet, LastLunarEclipseChauvenetWithInstance},
{NextLunarEclipse, NextLunarEclipseWithInstance},
{NextLunarEclipseDanjon, NextLunarEclipseDanjonWithInstance},
{NextLunarEclipseChauvenet, NextLunarEclipseChauvenetWithInstance},
{ClosestLunarEclipse, ClosestLunarEclipseWithInstance},
{ClosestLunarEclipseDanjon, ClosestLunarEclipseDanjonWithInstance},
{ClosestLunarEclipseChauvenet, ClosestLunarEclipseChauvenetWithInstance},
} {
want := query.old(date)
if got := query.new(date, nil); !reflect.DeepEqual(got, want) {
t.Error("nil changed directional search")
}
if model == ephem.Legacy {
if got := query.new(date, inst); !reflect.DeepEqual(got, want) {
t.Error("explicit Legacy changed directional search")
}
}
}
}
}
func TestP3LunarQueriesUseInstanceThroughout(t *testing.T) {
previous := ephem.Active()
t.Cleanup(func() { ephem.SetActive(previous) })
base := ephem.NewModelInstance(ephem.Semianalytic)
first := ephem.NewInstance(p3LunarShiftEngine{base: base, offset: 0.1})
second := ephem.NewInstance(p3LunarShiftEngine{base: base, offset: 0.2})
date := time.Date(2022, 11, 8, 12, 0, 0, 0, time.FixedZone("UTC+8", 8*3600))
global, model, generation := ephem.Global(), ephem.Active(), ephem.Generation()
firstInfo, firstOK := LunarEclipseOnDateWithInstance(date, first)
secondInfo, secondOK := LunarEclipseOnDateWithInstance(date, second)
if !firstOK || !secondOK || !firstInfo.HasTotal || !secondInfo.HasTotal {
t.Fatalf("explicit query lost total eclipse: %+v / %+v", firstInfo, secondInfo)
}
if firstInfo.HasSaros || secondInfo.HasSaros {
t.Error("custom engines must not inherit built-in Saros metadata")
}
if math.Abs(firstInfo.GreatestJDE-secondInfo.GreatestJDE-0.1) > 0.001 {
t.Errorf("event time did not follow instances: %v / %v", firstInfo.GreatestJDE, secondInfo.GreatestJDE)
}
if firstInfo.Maximum.Location() != date.Location() || len(firstInfo.ContactPoints) != 6 {
t.Error("local timezone or contacts were lost")
}
if ephem.Global() != global || ephem.Active() != model || ephem.Generation() != generation {
t.Error("query changed global ephemeris state")
}
for _, active := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
ephem.SetActive(active)
got, ok := LunarEclipseOnDateWithInstance(date, first)
if !ok || !reflect.DeepEqual(got, firstInfo) {
t.Error("result, contact orientation or shadow geometry read the global model")
}
for _, query := range []func(time.Time, *ephem.Instance) LunarEclipseInfo{LastLunarEclipseWithInstance, NextLunarEclipseWithInstance, ClosestLunarEclipseWithInstance} {
got := query(firstInfo.Maximum.Add(-time.Minute), first)
if got.Type == LunarEclipseNone || got.Maximum.IsZero() {
t.Error("directional search lost instance")
}
}
}
}
func TestP3LunarSarosSelectsInstanceModel(t *testing.T) {
previous := ephem.Active()
t.Cleanup(func() { ephem.SetActive(previous) })
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
inst := ephem.NewModelInstance(model)
ephem.SetActive(model)
for _, seed := range []float64{basic.JDCalc(2022, 11, 8), basic.JDCalc(7000, 1, 1), basic.JDCalc(-5000, 1, 1)} {
want, wantOK := lunarSarosInfoWithInstance(seed, inst)
ephem.SetActive(ephem.Model(1 - int(model)))
got, gotOK := lunarSarosInfoWithInstance(seed, inst)
if gotOK != wantOK || got != want {
t.Errorf("Saros read active model instead of instance: %+v / %+v", got, want)
}
}
}
}
func TestP3LunarQueriesStopOnEngineFailure(t *testing.T) {
inst := ephem.NewInstance(nil)
date := time.Date(2022, 11, 8, 12, 0, 0, 0, time.UTC)
if info, ok := LunarEclipseOnDateWithInstance(date, inst); ok || info.Type != "" {
t.Error("failed engine produced a local-date eclipse")
}
for _, query := range []func(time.Time, *ephem.Instance) LunarEclipseInfo{LastLunarEclipseWithInstance, NextLunarEclipseWithInstance, ClosestLunarEclipseWithInstance} {
if info := query(date, inst); !info.Maximum.IsZero() {
t.Error("failed engine fell back during directional search")
}
}
}
+272
View File
@@ -0,0 +1,272 @@
package eclipse
import (
"math"
"testing"
"time"
"b612.me/astro/moon/limb"
)
type lunarLimbCase struct {
name string
date time.Time
lon, lat float64
total bool
}
var lunarLimbCases = []lunarLimbCase{
{"2024-04-08 Dallas total", time.Date(2024, 4, 8, 18, 0, 0, 0, time.UTC), -96.8, 32.8, true},
{"2024-04-08 Torreon total", time.Date(2024, 4, 8, 18, 0, 0, 0, time.UTC), -103.43, 25.54, true},
{"2023-10-14 Albuquerque annular", time.Date(2023, 10, 14, 16, 0, 0, 0, time.UTC), -106.65, 35.08, false},
}
func lunarLimbContacts(info LocalSolarEclipseInfo) [4]time.Time {
return [4]time.Time{info.PartialStart, info.CentralStart, info.CentralEnd, info.PartialEnd}
}
// 月缘只应让接触时刻移动几秒:既要确实移动,又不能超过 15 s;食型与默认结果都不变。
func TestLocalSolarEclipseLunarLimbShiftsContacts(t *testing.T) {
limbed := NewWithInstance(nil).WithLunarLimb(limb.Default())
for _, c := range lunarLimbCases {
circular, ok := defaultModule.LocalSolarEclipseOnDate(c.date, c.lon, c.lat, 0)
if !ok || !circular.HasCentral || circular.HasTotal != c.total {
t.Fatalf("%s: circular type %v", c.name, circular.Type)
}
corrected, ok := limbed.LocalSolarEclipseOnDate(c.date, c.lon, c.lat, 0)
if !ok || corrected.Type != circular.Type {
t.Fatalf("%s: limb changed type %v -> %v", c.name, circular.Type, corrected.Type)
}
moved := false
a, b := lunarLimbContacts(circular), lunarLimbContacts(corrected)
for _, point := range corrected.ContactPoints {
want := b[map[string]int{"C1": 0, "C2": 1, "C3": 2, "C4": 3}[point.Label]]
if gap := point.Time.Sub(want).Seconds(); math.Abs(gap) > 0.05 {
t.Errorf("%s %s contact point off phase time by %.3f s", c.name, point.Label, gap)
}
}
if len(corrected.ContactPoints) != 4 {
t.Errorf("%s: %d contact points", c.name, len(corrected.ContactPoints))
}
for index := range a {
shift := b[index].Sub(a[index]).Seconds()
t.Logf("%s contact %d shift %+.3f s", c.name, index, shift)
if math.Abs(shift) > 15 {
t.Errorf("%s contact %d moved %.3f s", c.name, index, shift)
}
if math.Abs(shift) > 0.01 {
moved = true
}
}
if !moved {
t.Errorf("%s: lunar limb did not move any contact", c.name)
}
t.Logf("%s central duration circular %.3f s, limb %.3f s", c.name,
circular.CentralEnd.Sub(circular.CentralStart).Seconds(),
corrected.CentralEnd.Sub(corrected.CentralStart).Seconds())
// 选项里的月缘与模块设置等价;模块设置不影响原模块。
viaOptions, _ := defaultModule.LocalSolarEclipseOnDateWithOptions(c.date, c.lon, c.lat, 0,
SolarEclipseOptions{LunarLimb: limb.Default()})
if lunarLimbContacts(viaOptions) != b {
t.Errorf("%s: options limb differs from module limb", c.name)
}
again, _ := defaultModule.LocalSolarEclipseOnDate(c.date, c.lon, c.lat, 0)
if lunarLimbContacts(again) != a {
t.Errorf("%s: WithLunarLimb changed the original module", c.name)
}
}
}
// 2025-09-07 月全食紫金山天文台发布时刻(北京时间列换算 UT,0.1 分钟精度),只用于观察月缘修正的方向与量级。
func TestLunarEclipseLunarLimbAgainstPMO(t *testing.T) {
pmo := []struct {
label string
ut time.Time
}{
{"P1", time.Date(2025, 9, 7, 15, 26, 54, 0, time.UTC)},
{"U1", time.Date(2025, 9, 7, 16, 26, 48, 0, time.UTC)},
{"U2", time.Date(2025, 9, 7, 17, 30, 24, 0, time.UTC)},
{"U3", time.Date(2025, 9, 7, 18, 53, 12, 0, time.UTC)},
{"U4", time.Date(2025, 9, 7, 19, 56, 54, 0, time.UTC)},
{"P4", time.Date(2025, 9, 7, 20, 56, 36, 0, time.UTC)},
}
date := time.Date(2025, 9, 7, 18, 0, 0, 0, time.UTC)
plain := NewWithInstance(nil)
limbed := plain.WithLunarLimb(limb.Default())
for _, chauvenet := range []bool{false, true} {
var circular, corrected LunarEclipseInfo
if chauvenet {
circular, corrected = plain.ClosestLunarEclipseChauvenet(date), limbed.ClosestLunarEclipseChauvenet(date)
} else {
circular, corrected = plain.ClosestLunarEclipseDanjon(date), limbed.ClosestLunarEclipseDanjon(date)
}
if !corrected.Maximum.Equal(circular.Maximum) || corrected.Type != circular.Type {
t.Fatalf("chauvenet=%v: maximum or type changed", chauvenet)
}
c := []time.Time{circular.PenumbralStart, circular.PartialStart, circular.TotalStart, circular.TotalEnd, circular.PartialEnd, circular.PenumbralEnd}
l := []time.Time{corrected.PenumbralStart, corrected.PartialStart, corrected.TotalStart, corrected.TotalEnd, corrected.PartialEnd, corrected.PenumbralEnd}
var circErr, limbErr float64
for i, ref := range pmo {
ce := LunarEclipseInfoInUT1(circular)
le := LunarEclipseInfoInUT1(corrected)
cu := []time.Time{ce.PenumbralStart, ce.PartialStart, ce.TotalStart, ce.TotalEnd, ce.PartialEnd, ce.PenumbralEnd}[i]
lu := []time.Time{le.PenumbralStart, le.PartialStart, le.TotalStart, le.TotalEnd, le.PartialEnd, le.PenumbralEnd}[i]
shift := l[i].Sub(c[i]).Seconds()
de, dl := cu.Sub(ref.ut).Seconds(), lu.Sub(ref.ut).Seconds()
circErr += math.Abs(de)
limbErr += math.Abs(dl)
t.Logf("chauvenet=%v %s shift %+6.2f s PMO err circular %+6.1f s limb %+6.1f s", chauvenet, ref.label, shift, de, dl)
if math.Abs(shift) > 20 {
t.Errorf("chauvenet=%v %s: limb shift %.2f s too large", chauvenet, ref.label, shift)
}
}
t.Logf("chauvenet=%v PMO MAE circular %.2f s, limb %.2f s", chauvenet, circErr/6, limbErr/6)
// 紫金山按 Chauvenet 口径发布,只有该口径可比精度;Danjon 与之差约 90 s 属口径差。
if chauvenet && (limbErr > circErr || limbErr/6 > 2) {
t.Errorf("PMO MAE: limb %.2f s, circular %.2f s; want the limb no worse and within 2 s", limbErr/6, circErr/6)
}
}
}
// 2024-04-08 全球日食紫金山天文台发布 P1/P4(UT,整秒),只用于观察月缘修正的方向与量级。
func TestGlobalSolarEclipseLunarLimbAgainstPMO(t *testing.T) {
date := time.Date(2024, 4, 8, 18, 0, 0, 0, time.UTC)
plain := NewWithInstance(nil)
limbed := plain.WithLunarLimb(limb.Default())
circular, corrected := plain.ClosestSolarEclipse(date), limbed.ClosestSolarEclipse(date)
if !corrected.GreatestEclipse.Equal(circular.GreatestEclipse) || corrected.Type != circular.Type {
t.Fatal("greatest or type changed")
}
cu, lu := SolarEclipseInfoInUT1(circular), SolarEclipseInfoInUT1(corrected)
for _, c := range []struct {
label string
ref time.Time
circ, lb time.Time
}{
{"P1", time.Date(2024, 4, 8, 15, 42, 13, 0, time.UTC), cu.PartialBeginOnEarth, lu.PartialBeginOnEarth},
{"P4", time.Date(2024, 4, 8, 20, 52, 21, 0, time.UTC), cu.PartialEndOnEarth, lu.PartialEndOnEarth},
} {
shift := c.lb.Sub(c.circ).Seconds()
t.Logf("%s shift %+6.2f s PMO err circular %+6.2f s limb %+6.2f s", c.label, shift,
c.circ.Sub(c.ref).Seconds(), c.lb.Sub(c.ref).Seconds())
if math.Abs(shift) > 20 {
t.Errorf("%s: limb shift %.2f s too large", c.label, shift)
}
// 发布值取整秒,±0.5 s 的舍入淹没圆月面与月缘之差,只约束月缘结果贴近发布值。
if d := c.lb.Sub(c.ref).Seconds(); math.Abs(d) > 1.5 {
t.Errorf("%s: limb result %.2f s from PMO", c.label, d)
}
}
t.Logf("central duration circular %.2f s limb %.2f s", circular.CentralDuration.Seconds(), corrected.CentralDuration.Seconds())
}
// 偏食足迹里的全球结果与 P1/P4 必须与全球日食接口同源,不能只有可见域边界按月缘修正。
func TestPartialFootprintsLunarLimbMatchesGlobal(t *testing.T) {
date := time.Date(2024, 4, 8, 18, 0, 0, 0, time.UTC)
limbed := NewWithInstance(nil).WithLunarLimb(limb.Default())
global, ok := limbed.SolarEclipseOnDate(date)
if !ok || !global.LunarLimbApplied {
t.Fatal("global limb correction not applied")
}
footprints, ok := limbed.SolarEclipsePartialFootprints(date, SolarEclipsePartialFootprintOptions{})
if !ok || !footprints.PartialBoundaryLimbApplied {
t.Fatal("partial boundary limb correction not applied")
}
embedded := footprints.Eclipse
if !embedded.LunarLimbApplied ||
!embedded.PartialBeginOnEarth.Equal(global.PartialBeginOnEarth) ||
!embedded.PartialEndOnEarth.Equal(global.PartialEndOnEarth) ||
embedded.CentralDuration != global.CentralDuration {
t.Fatalf("embedded eclipse differs from global: applied=%v P1 %v/%v P4 %v/%v",
embedded.LunarLimbApplied, embedded.PartialBeginOnEarth, global.PartialBeginOnEarth,
embedded.PartialEndOnEarth, global.PartialEndOnEarth)
}
for _, c := range []struct {
label string
point SolarEclipsePathPoint
when time.Time
contact SolarEclipseLimbContact
}{
{"P1", footprints.P1, global.PartialBeginOnEarth, global.PartialBeginContact},
{"P4", footprints.P4, global.PartialEndOnEarth, global.PartialEndContact},
} {
if !c.contact.Defined {
continue
}
if !c.point.Time.Equal(c.when) || c.point.Longitude != c.contact.Longitude || c.point.Latitude != c.contact.Latitude {
t.Fatalf("%s: footprint contact %v (%.4f, %.4f) differs from global %v (%.4f, %.4f)",
c.label, c.point.Time, c.point.Longitude, c.point.Latitude, c.when, c.contact.Longitude, c.contact.Latitude)
}
}
}
// 月缘只平移全食带南北界,量级应为数百米到数公里。
func TestCentralPathLunarLimbShiftsLimits(t *testing.T) {
date := time.Date(2024, 4, 8, 18, 0, 0, 0, time.UTC)
plain := NewWithInstance(nil)
circular, ok := plain.SolarEclipseCentralPath(date, SolarEclipsePathOptions{})
if !ok {
t.Fatal("no central path")
}
corrected, ok := plain.WithLunarLimb(limb.Default()).SolarEclipseCentralPath(date, SolarEclipsePathOptions{})
if !ok || len(corrected.NorthernLimit) != len(circular.NorthernLimit) || len(corrected.SouthernLimit) != len(circular.SouthernLimit) {
t.Fatal("limit lengths changed")
}
for side, pair := range [][2][]SolarEclipsePathPoint{{circular.NorthernLimit, corrected.NorthernLimit}, {circular.SouthernLimit, corrected.SouthernLimit}} {
worst, sum, moved := 0.0, 0.0, 0
for i := range pair[0] {
a, b := pair[0][i], pair[1][i]
d := math.Hypot((a.Longitude-b.Longitude)*math.Cos(a.Latitude*math.Pi/180), a.Latitude-b.Latitude) * 111.2
if d > 0 {
moved++
}
sum += d
worst = math.Max(worst, d)
}
t.Logf("side %d: %d/%d points moved, mean %.3f km, max %.3f km", side, moved, len(pair[0]), sum/float64(len(pair[0])), worst)
if worst > 10 || moved == 0 {
t.Errorf("side %d: implausible limb shift (max %.3f km, moved %d)", side, worst, moved)
}
}
}
// 月缘让全食带变窄、时长变化在数秒以内;带宽与足迹必须与修正后的南北界同向。
func TestCentralPathLunarLimbWidthsAndDuration(t *testing.T) {
date := time.Date(2024, 4, 8, 18, 0, 0, 0, time.UTC)
plain := NewWithInstance(nil)
circular, ok := plain.SolarEclipseCentralPath(date, SolarEclipsePathOptions{})
if !ok {
t.Fatal("no central path")
}
corrected, ok := plain.WithLunarLimb(limb.Default()).SolarEclipseCentralPath(date, SolarEclipsePathOptions{})
if !ok || len(corrected.CenterLine) != len(circular.CenterLine) {
t.Fatal("center line changed")
}
narrower, total := 0, 0
worst := 0.0
for i := range circular.CenterLine {
a, b := circular.CenterLine[i].WidthKM, corrected.CenterLine[i].WidthKM
if a <= 0 || b <= 0 {
continue
}
total++
if b < a {
narrower++
}
worst = math.Max(worst, math.Abs(b-a))
}
t.Logf("width: %d/%d samples narrower, max change %.3f km; greatest %.3f -> %.3f km",
narrower, total, worst, circular.Greatest.WidthKM, corrected.Greatest.WidthKM)
t.Logf("max central duration %.2f s -> %.2f s",
circular.MaxCentralDuration.Seconds(), corrected.MaxCentralDuration.Seconds())
if worst > 10 || total == 0 {
t.Errorf("implausible width change %.3f km over %d samples", worst, total)
}
if d := math.Abs((corrected.MaxCentralDuration - circular.MaxCentralDuration).Seconds()); d > 10 {
t.Errorf("max duration moved by %.2f s", d)
}
if len(corrected.CentralBandSegments) != len(circular.CentralBandSegments) {
t.Fatal("band segment count changed")
}
}
+216 -139
View File
@@ -99,57 +99,63 @@ type LocalLunarEclipseInfo struct {
}
// LocalLunarEclipseOnDate 当地可见月食查询 / local visible lunar eclipse query.
// Determine whether a visible local lunar eclipse occurs on the local date, using Danjon by default.
func LocalLunarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return LocalLunarEclipseOnDateDanjon(date, lon, lat, height)
func (module *Eclipse) LocalLunarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return module.LocalLunarEclipseOnDateDanjon(date, lon, lat, height)
}
// LocalLunarEclipseOnDateDanjon 当地可见月食查询(Danjon) / local visible lunar eclipse query with Danjon model.
// Determine whether a visible local lunar eclipse occurs on the local date with the Danjon model.
func LocalLunarEclipseOnDateDanjon(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return localLunarEclipseOnDate(date, lon, lat, height, basic.LunarEclipseDanjon, localLunarEclipseQueryVisible)
func (module *Eclipse) LocalLunarEclipseOnDateDanjon(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return localLunarEclipseOnDate(date, lon, lat, height, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, false)
}, localLunarEclipseQueryVisible, module)
}
// LocalLunarEclipseOnDateChauvenet 当地可见月食查询(Chauvenet) / local visible lunar eclipse query with Chauvenet model.
// Determine whether a visible local lunar eclipse occurs on the local date with the Chauvenet model.
func LocalLunarEclipseOnDateChauvenet(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return localLunarEclipseOnDate(date, lon, lat, height, basic.LunarEclipseChauvenet, localLunarEclipseQueryVisible)
func (module *Eclipse) LocalLunarEclipseOnDateChauvenet(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return localLunarEclipseOnDate(date, lon, lat, height, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, true)
}, localLunarEclipseQueryVisible, module)
}
// GeometricLocalLunarEclipseOnDate 当地几何月食查询 / local geometric lunar eclipse query.
// Determine whether a geometric local lunar eclipse occurs on the local date, using Danjon by default.
func GeometricLocalLunarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return GeometricLocalLunarEclipseOnDateDanjon(date, lon, lat, height)
func (module *Eclipse) GeometricLocalLunarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return module.GeometricLocalLunarEclipseOnDateDanjon(date, lon, lat, height)
}
// GeometricLocalLunarEclipseOnDateDanjon 当地几何月食查询(Danjon) / local geometric lunar eclipse query with Danjon model.
// Determine whether a geometric local lunar eclipse occurs on the local date with the Danjon model.
func GeometricLocalLunarEclipseOnDateDanjon(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return localLunarEclipseOnDate(date, lon, lat, height, basic.LunarEclipseDanjon, localLunarEclipseQueryGeometric)
func (module *Eclipse) GeometricLocalLunarEclipseOnDateDanjon(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return localLunarEclipseOnDate(date, lon, lat, height, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, false)
}, localLunarEclipseQueryGeometric, module)
}
// GeometricLocalLunarEclipseOnDateChauvenet 当地几何月食查询(Chauvenet) / local geometric lunar eclipse query with Chauvenet model.
// Determine whether a geometric local lunar eclipse occurs on the local date with the Chauvenet model.
func GeometricLocalLunarEclipseOnDateChauvenet(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return localLunarEclipseOnDate(date, lon, lat, height, basic.LunarEclipseChauvenet, localLunarEclipseQueryGeometric)
func (module *Eclipse) GeometricLocalLunarEclipseOnDateChauvenet(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return localLunarEclipseOnDate(date, lon, lat, height, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, true)
}, localLunarEclipseQueryGeometric, module)
}
func localLunarEclipseOnDate(
date time.Time,
lon, lat, height float64,
calculator lunarEclipseCalculator,
mode localLunarEclipseQueryMode,
) (LocalLunarEclipseInfo, bool) {
mode localLunarEclipseQueryMode, modules ...*Eclipse) (LocalLunarEclipseInfo, bool) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
location := date.Location()
dayStart, dayMid, dayEnd := lunarEclipseLocalDayBounds(date)
phaseDiff := moonSunLoDiff(dayMid)
phaseDiff := moonSunLoDiffWithInstance(dayMid, module.inst)
if phaseDiff < lunarEclipseDayPhaseMin || phaseDiff > lunarEclipseDayPhaseMax {
return LocalLunarEclipseInfo{}, false
}
candidateTT := basic.CalcMoonSHByJDE(timeToTTJDE(dayMid), 1)
if !isPotentialLunarEclipse(candidateTT) {
candidateTT := basic.CalcMoonSHByJDEWithInstance(timeToTTJDE(dayMid), 1, module.inst)
if !isPotentialLunarEclipseWithInstance(candidateTT, module.inst) {
return LocalLunarEclipseInfo{}, false
}
@@ -158,157 +164,176 @@ func localLunarEclipseOnDate(
return LocalLunarEclipseInfo{}, false
}
info := localLunarEclipseInfoFromBasic(result, lon, lat, height, location)
info := localLunarEclipseInfoFromBasic(result, lon, lat, height, location, module)
if !localLunarEclipseOverlapsDate(info, dayStart, dayEnd) {
return LocalLunarEclipseInfo{}, false
}
if mode == localLunarEclipseQueryVisible && !localLunarEclipseVisibleOnDate(info, dayStart, dayEnd) {
if mode == localLunarEclipseQueryVisible && !localLunarEclipseVisibleOnDate(info, dayStart, dayEnd, module) {
return LocalLunarEclipseInfo{}, false
}
return info, true
}
// LastLocalLunarEclipse 上次可见月食 / previous visible local lunar eclipse.
// Previous visible local lunar eclipse, using Danjon by default.
func LastLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return LastLocalLunarEclipseDanjon(date, lon, lat, height)
func (module *Eclipse) LastLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return module.LastLocalLunarEclipseDanjon(date, lon, lat, height)
}
// LastLocalTotalLunarEclipse 上次可见月全食 / previous visible local total lunar eclipse.
// Previous visible local total lunar eclipse, using Danjon by default.
func LastLocalTotalLunarEclipse(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return searchLocalTotalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseDanjon, localLunarEclipseQueryVisible)
func (module *Eclipse) LastLocalTotalLunarEclipse(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return searchLocalTotalLunarEclipse(date, lon, lat, height, -1, true, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, false)
}, localLunarEclipseQueryVisible, module)
}
// LastLocalLunarEclipseDanjon 上次可见月食(Danjon) / previous visible local lunar eclipse with Danjon model.
// Previous visible local lunar eclipse with the Danjon model.
func LastLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseDanjon, localLunarEclipseQueryVisible)
func (module *Eclipse) LastLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, -1, true, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, false)
}, localLunarEclipseQueryVisible, module)
return info
}
// LastLocalLunarEclipseChauvenet 上次可见月食(Chauvenet) / previous visible local lunar eclipse with Chauvenet model.
// Previous visible local lunar eclipse with the Chauvenet model.
func LastLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseChauvenet, localLunarEclipseQueryVisible)
func (module *Eclipse) LastLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, -1, true, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, true)
}, localLunarEclipseQueryVisible, module)
return info
}
// LastGeometricLocalLunarEclipse 上次几何月食 / previous geometric local lunar eclipse.
// Previous geometric local lunar eclipse, using Danjon by default.
func LastGeometricLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return LastGeometricLocalLunarEclipseDanjon(date, lon, lat, height)
func (module *Eclipse) LastGeometricLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return module.LastGeometricLocalLunarEclipseDanjon(date, lon, lat, height)
}
// LastGeometricLocalLunarEclipseDanjon 上次几何月食(Danjon) / previous geometric local lunar eclipse with Danjon model.
// Previous geometric local lunar eclipse with the Danjon model.
func LastGeometricLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseDanjon, localLunarEclipseQueryGeometric)
func (module *Eclipse) LastGeometricLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, -1, true, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, false)
}, localLunarEclipseQueryGeometric, module)
return info
}
// LastGeometricLocalLunarEclipseChauvenet 上次几何月食(Chauvenet) / previous geometric local lunar eclipse with Chauvenet model.
// Previous geometric local lunar eclipse with the Chauvenet model.
func LastGeometricLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseChauvenet, localLunarEclipseQueryGeometric)
func (module *Eclipse) LastGeometricLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, -1, true, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, true)
}, localLunarEclipseQueryGeometric, module)
return info
}
// NextLocalLunarEclipse 下次可见月食 / next visible local lunar eclipse.
// Next visible local lunar eclipse, using Danjon by default.
func NextLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return NextLocalLunarEclipseDanjon(date, lon, lat, height)
func (module *Eclipse) NextLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return module.NextLocalLunarEclipseDanjon(date, lon, lat, height)
}
// NextLocalTotalLunarEclipse 下次可见月全食 / next visible local total lunar eclipse.
// Next visible local total lunar eclipse, using Danjon by default.
func NextLocalTotalLunarEclipse(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return searchLocalTotalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseDanjon, localLunarEclipseQueryVisible)
func (module *Eclipse) NextLocalTotalLunarEclipse(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
return searchLocalTotalLunarEclipse(date, lon, lat, height, 1, false, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, false)
}, localLunarEclipseQueryVisible, module)
}
// NextLocalLunarEclipseDanjon 下次可见月食(Danjon) / next visible local lunar eclipse with Danjon model.
// Next visible local lunar eclipse with the Danjon model.
func NextLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseDanjon, localLunarEclipseQueryVisible)
func (module *Eclipse) NextLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, 1, false, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, false)
}, localLunarEclipseQueryVisible, module)
return info
}
// NextLocalLunarEclipseChauvenet 下次可见月食(Chauvenet) / next visible local lunar eclipse with Chauvenet model.
// Next visible local lunar eclipse with the Chauvenet model.
func NextLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseChauvenet, localLunarEclipseQueryVisible)
func (module *Eclipse) NextLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, 1, false, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, true)
}, localLunarEclipseQueryVisible, module)
return info
}
// NextGeometricLocalLunarEclipse 下次几何月食 / next geometric local lunar eclipse.
// Next geometric local lunar eclipse, using Danjon by default.
func NextGeometricLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return NextGeometricLocalLunarEclipseDanjon(date, lon, lat, height)
func (module *Eclipse) NextGeometricLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return module.NextGeometricLocalLunarEclipseDanjon(date, lon, lat, height)
}
// NextGeometricLocalLunarEclipseDanjon 下次几何月食(Danjon) / next geometric local lunar eclipse with Danjon model.
// Next geometric local lunar eclipse with the Danjon model.
func NextGeometricLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseDanjon, localLunarEclipseQueryGeometric)
func (module *Eclipse) NextGeometricLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, 1, false, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, false)
}, localLunarEclipseQueryGeometric, module)
return info
}
// NextGeometricLocalLunarEclipseChauvenet 下次几何月食(Chauvenet) / next geometric local lunar eclipse with Chauvenet model.
// Next geometric local lunar eclipse with the Chauvenet model.
func NextGeometricLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseChauvenet, localLunarEclipseQueryGeometric)
func (module *Eclipse) NextGeometricLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
info, _ := searchLocalLunarEclipse(date, lon, lat, height, 1, false, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, true)
}, localLunarEclipseQueryGeometric, module)
return info
}
// ClosestLocalLunarEclipse 最近一次可见月食 / closest visible local lunar eclipse.
// Closest visible local lunar eclipse, using Danjon by default.
func ClosestLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return ClosestLocalLunarEclipseDanjon(date, lon, lat, height)
func (module *Eclipse) ClosestLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return module.ClosestLocalLunarEclipseDanjon(date, lon, lat, height)
}
// ClosestLocalTotalLunarEclipse 最近一次可见月全食 / closest visible local total lunar eclipse.
// Closest visible local total lunar eclipse, using Danjon by default.
func ClosestLocalTotalLunarEclipse(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
last, hasLast := searchLocalTotalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseDanjon, localLunarEclipseQueryVisible)
next, hasNext := searchLocalTotalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseDanjon, localLunarEclipseQueryVisible)
func (module *Eclipse) ClosestLocalTotalLunarEclipse(date time.Time, lon, lat, height float64) (LocalLunarEclipseInfo, bool) {
last, hasLast := searchLocalTotalLunarEclipse(date, lon, lat, height, -1, true, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, false)
}, localLunarEclipseQueryVisible, module)
next, hasNext := searchLocalTotalLunarEclipse(date, lon, lat, height, 1, false, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, false)
}, localLunarEclipseQueryVisible, module)
return closestLocalLunarEclipseResult(date, last, hasLast, next, hasNext)
}
// ClosestLocalLunarEclipseDanjon 最近一次可见月食(Danjon) / closest visible local lunar eclipse with Danjon model.
// Closest visible local lunar eclipse with the Danjon model.
func ClosestLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
last, hasLast := searchLocalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseDanjon, localLunarEclipseQueryVisible)
next, hasNext := searchLocalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseDanjon, localLunarEclipseQueryVisible)
func (module *Eclipse) ClosestLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
last, hasLast := searchLocalLunarEclipse(date, lon, lat, height, -1, true, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, false)
}, localLunarEclipseQueryVisible, module)
next, hasNext := searchLocalLunarEclipse(date, lon, lat, height, 1, false, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, false)
}, localLunarEclipseQueryVisible, module)
return closestLocalLunarEclipse(date, last, hasLast, next, hasNext)
}
// ClosestLocalLunarEclipseChauvenet 最近一次可见月食(Chauvenet) / closest visible local lunar eclipse with Chauvenet model.
// Closest visible local lunar eclipse with the Chauvenet model.
func ClosestLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
last, hasLast := searchLocalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseChauvenet, localLunarEclipseQueryVisible)
next, hasNext := searchLocalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseChauvenet, localLunarEclipseQueryVisible)
func (module *Eclipse) ClosestLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
last, hasLast := searchLocalLunarEclipse(date, lon, lat, height, -1, true, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, true)
}, localLunarEclipseQueryVisible, module)
next, hasNext := searchLocalLunarEclipse(date, lon, lat, height, 1, false, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, true)
}, localLunarEclipseQueryVisible, module)
return closestLocalLunarEclipse(date, last, hasLast, next, hasNext)
}
// ClosestGeometricLocalLunarEclipse 最近一次几何月食 / closest geometric local lunar eclipse.
// Closest geometric local lunar eclipse, using Danjon by default.
func ClosestGeometricLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return ClosestGeometricLocalLunarEclipseDanjon(date, lon, lat, height)
func (module *Eclipse) ClosestGeometricLocalLunarEclipse(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
return module.ClosestGeometricLocalLunarEclipseDanjon(date, lon, lat, height)
}
// ClosestGeometricLocalLunarEclipseDanjon 最近一次几何月食(Danjon) / closest geometric local lunar eclipse with Danjon model.
// Closest geometric local lunar eclipse with the Danjon model.
func ClosestGeometricLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
last, hasLast := searchLocalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseDanjon, localLunarEclipseQueryGeometric)
next, hasNext := searchLocalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseDanjon, localLunarEclipseQueryGeometric)
func (module *Eclipse) ClosestGeometricLocalLunarEclipseDanjon(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
last, hasLast := searchLocalLunarEclipse(date, lon, lat, height, -1, true, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, false)
}, localLunarEclipseQueryGeometric, module)
next, hasNext := searchLocalLunarEclipse(date, lon, lat, height, 1, false, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, false)
}, localLunarEclipseQueryGeometric, module)
return closestLocalLunarEclipse(date, last, hasLast, next, hasNext)
}
// ClosestGeometricLocalLunarEclipseChauvenet 最近一次几何月食(Chauvenet) / closest geometric local lunar eclipse with Chauvenet model.
// Closest geometric local lunar eclipse with the Chauvenet model.
func ClosestGeometricLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
last, hasLast := searchLocalLunarEclipse(date, lon, lat, height, -1, true, basic.LunarEclipseChauvenet, localLunarEclipseQueryGeometric)
next, hasNext := searchLocalLunarEclipse(date, lon, lat, height, 1, false, basic.LunarEclipseChauvenet, localLunarEclipseQueryGeometric)
func (module *Eclipse) ClosestGeometricLocalLunarEclipseChauvenet(date time.Time, lon, lat, height float64) LocalLunarEclipseInfo {
last, hasLast := searchLocalLunarEclipse(date, lon, lat, height, -1, true, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, true)
}, localLunarEclipseQueryGeometric, module)
next, hasNext := searchLocalLunarEclipse(date, lon, lat, height, 1, false, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, true)
}, localLunarEclipseQueryGeometric, module)
return closestLocalLunarEclipse(date, last, hasLast, next, hasNext)
}
@@ -353,23 +378,27 @@ func searchLocalLunarEclipse(
direction int,
includeCurrent bool,
calculator lunarEclipseCalculator,
mode localLunarEclipseQueryMode,
) (LocalLunarEclipseInfo, bool) {
mode localLunarEclipseQueryMode, modules ...*Eclipse) (LocalLunarEclipseInfo, bool) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
targetTT := timeToTTJDE(date)
candidateTT := basic.CalcMoonSHByJDE(targetTT, 1)
candidateTT := basic.CalcMoonSHByJDEWithInstance(targetTT, 1, module.inst)
for i := 0; i < localLunarEclipseSearchLimit; i++ {
if isPotentialLunarEclipse(candidateTT) {
if isPotentialLunarEclipseWithInstance(candidateTT, module.inst) {
result := calculator(candidateTT)
if result.Type != basic.LunarEclipseNone {
info := localLunarEclipseInfoFromBasic(result, lon, lat, height, date.Location())
if (mode != localLunarEclipseQueryVisible || localLunarEclipseVisible(info)) &&
info := localLunarEclipseInfoFromBasic(result, lon, lat, height, date.Location(), module)
if (mode != localLunarEclipseQueryVisible || localLunarEclipseVisible(info, module)) &&
lunarEclipseMatchesDirection(result.Maximum, targetTT, direction, includeCurrent) {
return info, true
}
}
}
candidateTT = nextEclipseSearchCandidateTT(candidateTT, 1, direction, lunarEclipseSynodicMonthDays)
candidateTT = nextEclipseSearchCandidateTT(candidateTT, 1, direction, lunarEclipseSynodicMonthDays, module)
}
return LocalLunarEclipseInfo{}, false
@@ -381,23 +410,27 @@ func searchLocalTotalLunarEclipse(
direction int,
includeCurrent bool,
calculator lunarEclipseCalculator,
mode localLunarEclipseQueryMode,
) (LocalLunarEclipseInfo, bool) {
mode localLunarEclipseQueryMode, modules ...*Eclipse) (LocalLunarEclipseInfo, bool) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
targetTT := timeToTTJDE(date)
candidateTT := basic.CalcMoonSHByJDE(targetTT, 1)
candidateTT := basic.CalcMoonSHByJDEWithInstance(targetTT, 1, module.inst)
for i := 0; i < localLunarEclipseSearchLimit; i++ {
if isPotentialLunarEclipse(candidateTT) {
if isPotentialLunarEclipseWithInstance(candidateTT, module.inst) {
result := calculator(candidateTT)
if result.HasTotal {
info := localLunarEclipseInfoFromBasic(result, lon, lat, height, date.Location())
if (mode != localLunarEclipseQueryVisible || localTotalLunarEclipseVisible(info)) &&
info := localLunarEclipseInfoFromBasic(result, lon, lat, height, date.Location(), module)
if (mode != localLunarEclipseQueryVisible || localTotalLunarEclipseVisible(info, module)) &&
lunarEclipseMatchesDirection(result.Maximum, targetTT, direction, includeCurrent) {
return info, true
}
}
}
candidateTT = nextEclipseSearchCandidateTT(candidateTT, 1, direction, lunarEclipseSynodicMonthDays)
candidateTT = nextEclipseSearchCandidateTT(candidateTT, 1, direction, lunarEclipseSynodicMonthDays, module)
}
return LocalLunarEclipseInfo{}, false
@@ -406,12 +439,16 @@ func searchLocalTotalLunarEclipse(
func localLunarEclipseInfoFromBasic(
result basic.LunarEclipseResult,
lon, lat, height float64,
location *time.Location,
) LocalLunarEclipseInfo {
location *time.Location, modules ...*Eclipse) LocalLunarEclipseInfo {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
maximum := ttJDEToTime(result.Maximum, location)
visibleThreshold := localLunarEclipseVisibilityThreshold(height, lat)
moonAltitude := lunarAltitude(maximum, lon, lat)
saros, hasSaros := lunarSarosInfo(result.Maximum)
moonAltitude := lunarAltitude(maximum, lon, lat, module)
saros, hasSaros := lunarSarosInfoWithInstance(result.Maximum, module.inst)
info := LocalLunarEclipseInfo{
HasSaros: hasSaros,
@@ -430,13 +467,13 @@ func localLunarEclipseInfoFromBasic(
PartialEnd: ttJDEToTime(result.PartialEnd, location),
PenumbralEnd: ttJDEToTime(result.PenumbralEnd, location),
MoonAltitude: moonAltitude,
MoonAzimuth: lunarAzimuth(maximum, lon, lat),
MoonAzimuth: lunarAzimuth(maximum, lon, lat, module),
VisibleAtMaximum: moonAltitude > visibleThreshold,
HasPenumbral: result.HasPenumbral,
HasPartial: result.HasPartial,
HasTotal: result.HasTotal,
}
info.Visibility = localLunarEclipseVisibility(info)
info.Visibility = localLunarEclipseVisibility(info, module)
return info
}
@@ -455,22 +492,37 @@ func localLunarEclipseRange(info LocalLunarEclipseInfo) (time.Time, time.Time, b
return info.PenumbralStart, info.PenumbralEnd, true
}
func localLunarEclipseVisible(info LocalLunarEclipseInfo) bool {
func localLunarEclipseVisible(info LocalLunarEclipseInfo, modules ...*Eclipse) bool {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
eventStart, eventEnd, ok := localLunarEclipseRange(info)
if !ok {
return false
}
return localLunarEclipseVisibleDuring(info, eventStart, eventEnd)
return localLunarEclipseVisibleDuring(info, eventStart, eventEnd, module)
}
func localTotalLunarEclipseVisible(info LocalLunarEclipseInfo) bool {
func localTotalLunarEclipseVisible(info LocalLunarEclipseInfo, modules ...*Eclipse) bool {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
if !info.HasTotal || info.TotalStart.IsZero() || info.TotalEnd.IsZero() {
return false
}
return localLunarEclipseVisibleDuring(info, info.TotalStart, info.TotalEnd)
return localLunarEclipseVisibleDuring(info, info.TotalStart, info.TotalEnd, module)
}
func localLunarEclipseVisibleOnDate(info LocalLunarEclipseInfo, dayStart, dayEnd time.Time) bool {
func localLunarEclipseVisibleOnDate(info LocalLunarEclipseInfo, dayStart, dayEnd time.Time, modules ...*Eclipse) bool {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
eventStart, eventEnd, ok := localLunarEclipseRange(info)
if !ok {
return false
@@ -480,21 +532,26 @@ func localLunarEclipseVisibleOnDate(info LocalLunarEclipseInfo, dayStart, dayEnd
if !segmentStart.Before(segmentEnd) {
return false
}
return localLunarEclipseVisibleDuring(info, segmentStart, segmentEnd)
return localLunarEclipseVisibleDuring(info, segmentStart, segmentEnd, module)
}
// localLunarEclipseVisibility 按站点自己的中天对食内高度分类。
func localLunarEclipseVisibility(info LocalLunarEclipseInfo) LocalLunarEclipseVisibility {
func localLunarEclipseVisibility(info LocalLunarEclipseInfo, modules ...*Eclipse) LocalLunarEclipseVisibility {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
start, end, ok := localLunarEclipseRange(info)
if !ok {
return LocalLunarEclipseInvisible
}
startVisible := localLunarEclipseAltitudeVisible(start, info)
endVisible := localLunarEclipseAltitudeVisible(end, info)
umbralVisible := localLunarEclipseUmbralVisible(info)
startVisible := localLunarEclipseAltitudeVisible(start, info, module)
endVisible := localLunarEclipseAltitudeVisible(end, info, module)
umbralVisible := localLunarEclipseUmbralVisible(info, module)
switch {
case startVisible && endVisible:
if localLunarEclipseExtremumAltitude(info, start, end, false) > localLunarEclipseVisibilityThreshold(info.Height, info.Latitude) {
if localLunarEclipseExtremumAltitude(info, start, end, false, module) > localLunarEclipseVisibilityThreshold(info.Height, info.Latitude) {
return LocalLunarEclipseFull
}
return LocalLunarEclipseInterrupted
@@ -508,7 +565,7 @@ func localLunarEclipseVisibility(info LocalLunarEclipseInfo) LocalLunarEclipseVi
return LocalLunarEclipsePenumbraMoonset
}
return LocalLunarEclipseMoonset
case localLunarEclipseVisibleDuring(info, start, end):
case localLunarEclipseVisibleDuring(info, start, end, module):
return LocalLunarEclipseRiseAndSet
default:
return LocalLunarEclipseInvisible
@@ -518,7 +575,12 @@ func localLunarEclipseVisibility(info LocalLunarEclipseInfo) LocalLunarEclipseVi
// localLunarEclipseUmbralVisible 本影阶段是否有任何时刻月亮在地平线上;纯半影月食没有本影可看,
// 恒为 true,不把这类站点降级成"仅见半影"。极区掠射时 U1、食甚、U4 三点都可能在地平下而中途
// 短暂露出,所以这里取整个本影区间的高度极值,不能只比三个采样点。
func localLunarEclipseUmbralVisible(info LocalLunarEclipseInfo) bool {
func localLunarEclipseUmbralVisible(info LocalLunarEclipseInfo, modules ...*Eclipse) bool {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
if !info.HasPartial {
return true
}
@@ -526,25 +588,30 @@ func localLunarEclipseUmbralVisible(info LocalLunarEclipseInfo) bool {
if start.IsZero() || end.IsZero() || !start.Before(end) {
return false
}
if localLunarEclipseAltitudeVisible(start, info) || localLunarEclipseAltitudeVisible(end, info) {
if localLunarEclipseAltitudeVisible(start, info, module) || localLunarEclipseAltitudeVisible(end, info, module) {
return true
}
return localLunarEclipseExtremumAltitude(info, start, end, true) >
return localLunarEclipseExtremumAltitude(info, start, end, true, module) >
localLunarEclipseVisibilityThreshold(info.Height, info.Latitude)
}
const lunarEclipseVisibilityScanSamples = 48
// localLunarEclipseExtremumAltitude 先粗扫定位、再在相邻两格内三分细化地取有限窗口内的月心高度极值。
func localLunarEclipseExtremumAltitude(info LocalLunarEclipseInfo, start, end time.Time, maximum bool) float64 {
func localLunarEclipseExtremumAltitude(info LocalLunarEclipseInfo, start, end time.Time, maximum bool, modules ...*Eclipse) float64 {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
if !start.Before(end) {
return lunarAltitude(start, info.Longitude, info.Latitude)
return lunarAltitude(start, info.Longitude, info.Latitude, module)
}
step := end.Sub(start) / lunarEclipseVisibilityScanSamples
best := lunarAltitude(start, info.Longitude, info.Latitude)
best := lunarAltitude(start, info.Longitude, info.Latitude, module)
bestIndex := 0
for index := 1; index <= lunarEclipseVisibilityScanSamples; index++ {
value := lunarAltitude(start.Add(step*time.Duration(index)), info.Longitude, info.Latitude)
value := lunarAltitude(start.Add(step*time.Duration(index)), info.Longitude, info.Latitude, module)
if (value > best) == maximum {
best, bestIndex = value, index
}
@@ -561,29 +628,39 @@ func localLunarEclipseExtremumAltitude(info LocalLunarEclipseInfo, start, end ti
for iteration := 0; iteration < 30; iteration++ {
third := high.Sub(low) / 3
first, second := low.Add(third), high.Add(-third)
if (lunarAltitude(first, info.Longitude, info.Latitude) < lunarAltitude(second, info.Longitude, info.Latitude)) == maximum {
if (lunarAltitude(first, info.Longitude, info.Latitude, module) < lunarAltitude(second, info.Longitude, info.Latitude, module)) == maximum {
low = first
} else {
high = second
}
}
refined := lunarAltitude(low.Add(high.Sub(low)/2), info.Longitude, info.Latitude)
refined := lunarAltitude(low.Add(high.Sub(low)/2), info.Longitude, info.Latitude, module)
if maximum {
return math.Max(best, refined)
}
return math.Min(best, refined)
}
func localLunarEclipseVisibleDuring(info LocalLunarEclipseInfo, start, end time.Time) bool {
if localLunarEclipseAltitudeVisible(start, info) || localLunarEclipseAltitudeVisible(end, info) {
func localLunarEclipseVisibleDuring(info LocalLunarEclipseInfo, start, end time.Time, modules ...*Eclipse) bool {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
if localLunarEclipseAltitudeVisible(start, info, module) || localLunarEclipseAltitudeVisible(end, info, module) {
return true
}
return localLunarEclipseExtremumAltitude(info, start, end, true) >
return localLunarEclipseExtremumAltitude(info, start, end, true, module) >
localLunarEclipseVisibilityThreshold(info.Height, info.Latitude)
}
func localLunarEclipseAltitudeVisible(date time.Time, info LocalLunarEclipseInfo) bool {
return lunarAltitude(date, info.Longitude, info.Latitude) > localLunarEclipseVisibilityThreshold(info.Height, info.Latitude)
func localLunarEclipseAltitudeVisible(date time.Time, info LocalLunarEclipseInfo, modules ...*Eclipse) bool {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
return lunarAltitude(date, info.Longitude, info.Latitude, module) > localLunarEclipseVisibilityThreshold(info.Height, info.Latitude)
}
func localLunarEclipseVisibilityThreshold(height, latitude float64) float64 {
+5 -2
View File
@@ -3,6 +3,8 @@ package eclipse
import (
"testing"
"time"
"b612.me/astro/internal/ephemtest"
)
func TestLocalLunarEclipseOnDateByLocalDay(t *testing.T) {
@@ -229,13 +231,14 @@ func TestLocalLunarEclipseInfoKeepsLocation(t *testing.T) {
}
func TestLocalLunarEclipseChauvenetRemainsAvailable(t *testing.T) {
values := ephemtest.NewValues(t, "local_lunar_chauvenet_available")
date := time.Date(2025, 3, 14, 12, 0, 0, 0, time.FixedZone("CDT", -5*3600))
lon, lat, height := -87.65, 41.85, 0.0
defaultInfo := ClosestLocalLunarEclipse(date, lon, lat, height)
chauvenetInfo := ClosestLocalLunarEclipseChauvenet(date, lon, lat, height)
assertFloatClose(t, "Chauvenet.PenumbralMagnitude", chauvenetInfo.PenumbralMagnitude, 2.285436461, 1e-6)
assertFloatClose(t, "Chauvenet.UmbralMagnitude", chauvenetInfo.UmbralMagnitude, 1.182806541, 1e-6)
assertFloatClose(t, "Chauvenet.PenumbralMagnitude", chauvenetInfo.PenumbralMagnitude, values.Want("Chauvenet.PenumbralMagnitude", 2.285436461, chauvenetInfo.PenumbralMagnitude), 1e-6)
assertFloatClose(t, "Chauvenet.UmbralMagnitude", chauvenetInfo.UmbralMagnitude, values.Want("Chauvenet.UmbralMagnitude", 1.182806541, chauvenetInfo.UmbralMagnitude), 1e-6)
if !(chauvenetInfo.PenumbralMagnitude > defaultInfo.PenumbralMagnitude) {
t.Fatalf("expected Chauvenet penumbral magnitude > Danjon: chauvenet=%.6f danjon=%.6f", chauvenetInfo.PenumbralMagnitude, defaultInfo.PenumbralMagnitude)
+22 -15
View File
@@ -51,26 +51,33 @@ type LunarEclipseGeocentricPanel struct {
Ephemeris string
}
// LunarEclipseGeocentricPanelAt 计算给定日期月食的地心量面板,使用 Danjon 影半径模型。
// LunarEclipseGeocentricPanelAt computes the geocentric panel of one lunar eclipse with the Danjon shadow model.
func LunarEclipseGeocentricPanelAt(date time.Time) (LunarEclipseGeocentricPanel, bool) {
return lunarEclipseGeocentricPanelAt(date, basic.LunarEclipseDanjon)
// LunarEclipseGeocentricPanelAt 计算给定日期月食的地心量面板,使用 Danjon 影半径模型 / geocentric lunar eclipse panel with Danjon shadow radii.
func (module *Eclipse) LunarEclipseGeocentricPanelAt(date time.Time) (LunarEclipseGeocentricPanel, bool) {
return lunarEclipseGeocentricPanelAt(date, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, false)
}, module)
}
// LunarEclipseGeocentricPanelChauvenet 计算给定日期月食的地心量面板,使用 Chauvenet 影半径模型。
// LunarEclipseGeocentricPanelChauvenet computes the geocentric panel with the Chauvenet shadow model.
func LunarEclipseGeocentricPanelChauvenet(date time.Time) (LunarEclipseGeocentricPanel, bool) {
return lunarEclipseGeocentricPanelAt(date, basic.LunarEclipseChauvenet)
// LunarEclipseGeocentricPanelChauvenet 计算给定日期月食的地心量面板,使用 Chauvenet 影半径模型 / geocentric lunar eclipse panel with Chauvenet shadow radii.
func (module *Eclipse) LunarEclipseGeocentricPanelChauvenet(date time.Time) (LunarEclipseGeocentricPanel, bool) {
return lunarEclipseGeocentricPanelAt(date, func(seedJDE float64) basic.LunarEclipseResult {
return module.lunarResult(seedJDE, true)
}, module)
}
func lunarEclipseGeocentricPanelAt(date time.Time, calculator func(float64) basic.LunarEclipseResult) (LunarEclipseGeocentricPanel, bool) {
func lunarEclipseGeocentricPanelAt(date time.Time, calculator func(float64) basic.LunarEclipseResult, modules ...*Eclipse) (LunarEclipseGeocentricPanel, bool) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
seed := timeToTTJDE(date)
fullMoonJDE := basic.CalcMoonSHByJDE(seed, 1)
fullMoonJDE := basic.CalcMoonSHByJDEWithInstance(seed, 1, module.inst)
result := calculator(fullMoonJDE)
if result.Type == basic.LunarEclipseNone {
return LunarEclipseGeocentricPanel{}, false
}
geometry := basic.LunarEclipseShadowGeometryAtModel(result.Maximum, result.ShadowModel)
geometry := basic.LunarEclipseShadowGeometryAtModelWithInstance(result.Maximum, result.ShadowModel, module.inst)
ephemeris := "Danjon"
if result.ShadowModel == basic.LunarEclipseShadowModelChauvenet {
ephemeris = "Chauvenet"
@@ -89,10 +96,10 @@ func lunarEclipseGeocentricPanelAt(date time.Time, calculator func(float64) basi
UmbralMagnitude: result.Magnitude,
Ephemeris: ephemeris,
}
panel.SunRightAscensionDeg, panel.SunDeclinationDeg = basic.SunApparentRaDec(result.Maximum)
panel.MoonRightAscensionDeg, panel.MoonDeclinationDeg = basic.HMoonTrueRaDec(result.Maximum)
panel.SunSemidiameterArcsec = basic.SolarEclipseSunSemidiameter(result.Maximum, basic.SolarEclipseSunRadiusStandard)
panel.MoonSemidiameterArcsec = basic.MoonSemidiameter(result.Maximum)
panel.SunRightAscensionDeg, panel.SunDeclinationDeg = basic.SunApparentRaDecWithInstance(result.Maximum, module.inst)
panel.MoonRightAscensionDeg, panel.MoonDeclinationDeg = basic.HMoonTrueRaDecWithInstance(result.Maximum, module.inst)
panel.SunSemidiameterArcsec = basic.SolarEclipseSunSemidiameterWithInstance(result.Maximum, basic.SolarEclipseSunRadiusStandard, module.inst)
panel.MoonSemidiameterArcsec = basic.MoonSemidiameterWithInstance(result.Maximum, module.inst)
panel.SunParallaxArcsec = horizontalParallaxArcsec(panel.SunSemidiameterArcsec, horizontalParallaxSunRatio)
panel.MoonParallaxArcsec = horizontalParallaxArcsec(panel.MoonSemidiameterArcsec, horizontalParallaxMoonRatio)
return panel, true
+12 -6
View File
@@ -3,6 +3,8 @@ package eclipse
import (
"testing"
"time"
"b612.me/astro/internal/ephemtest"
)
func TestLunarEclipseLocalDayBoundsRespectDST(t *testing.T) {
@@ -179,6 +181,7 @@ func TestLunarEclipseInfoKeepsLocation(t *testing.T) {
}
func TestLunarEclipseContactPoints(t *testing.T) {
values := ephemtest.NewValues(t, "lunar_contact_points")
loc := time.FixedZone("UTC+08", 8*3600)
info := ClosestLunarEclipse(time.Date(2026, 3, 3, 12, 0, 0, 0, loc))
if info.Type != LunarEclipseTotal {
@@ -193,20 +196,21 @@ func TestLunarEclipseContactPoints(t *testing.T) {
points[point.Label] = point
}
u1 := points["U1"]
assertFloatClose(t, "U1.ContactPositionAngle", u1.ContactPositionAngle, 96.181711, 1e-3)
assertFloatClose(t, "U1.ContactClockwiseAngle", u1.ContactClockwiseAngle, 263.818289, 1e-3)
assertFloatClose(t, "U1.ContactPositionAngle", u1.ContactPositionAngle, values.Want("U1.ContactPositionAngle", 96.181711, u1.ContactPositionAngle), 1e-3)
assertFloatClose(t, "U1.ContactClockwiseAngle", u1.ContactClockwiseAngle, values.Want("U1.ContactClockwiseAngle", 263.818289, u1.ContactClockwiseAngle), 1e-3)
u2 := points["U2"]
assertFloatClose(t, "U2.ContactPositionAngle", u2.ContactPositionAngle, 243.025171, 1e-3)
assertFloatClose(t, "U2.MoonCenterPositionAngle", u2.MoonCenterPositionAngle, 243.025171, 1e-3)
assertFloatClose(t, "U2.ContactPositionAngle", u2.ContactPositionAngle, values.Want("U2.ContactPositionAngle", 243.025171, u2.ContactPositionAngle), 1e-3)
assertFloatClose(t, "U2.MoonCenterPositionAngle", u2.MoonCenterPositionAngle, values.Want("U2.MoonCenterPositionAngle", 243.025171, u2.MoonCenterPositionAngle), 1e-3)
}
func TestLunarEclipseChauvenetRemainsAvailable(t *testing.T) {
values := ephemtest.NewValues(t, "lunar_chauvenet_available")
date := time.Date(2025, 3, 14, 12, 0, 0, 0, time.UTC)
defaultInfo := ClosestLunarEclipse(date)
chauvenetInfo := ClosestLunarEclipseChauvenet(date)
assertFloatClose(t, "Chauvenet.PenumbralMagnitude", chauvenetInfo.PenumbralMagnitude, 2.285436461, 1e-6)
assertFloatClose(t, "Chauvenet.UmbralMagnitude", chauvenetInfo.UmbralMagnitude, 1.182806541, 1e-6)
assertFloatClose(t, "Chauvenet.PenumbralMagnitude", chauvenetInfo.PenumbralMagnitude, values.Want("Chauvenet.PenumbralMagnitude", 2.285436461, chauvenetInfo.PenumbralMagnitude), 1e-6)
assertFloatClose(t, "Chauvenet.UmbralMagnitude", chauvenetInfo.UmbralMagnitude, values.Want("Chauvenet.UmbralMagnitude", 1.182806541, chauvenetInfo.UmbralMagnitude), 1e-6)
if !(chauvenetInfo.PenumbralMagnitude > defaultInfo.PenumbralMagnitude) {
t.Fatalf("expected Chauvenet penumbral magnitude > Danjon: chauvenet=%.6f danjon=%.6f", chauvenetInfo.PenumbralMagnitude, defaultInfo.PenumbralMagnitude)
@@ -217,6 +221,8 @@ func TestLunarEclipseChauvenetRemainsAvailable(t *testing.T) {
}
func TestLunarEclipseDefaultFallsBackForUltraShallowPenumbralEdge(t *testing.T) {
// 夹具按旧模型几何选取,用来覆盖该边界分支。
ephemtest.PinLegacy(t)
date := time.Date(-780, 12, 13, 12, 0, 0, 0, time.UTC)
defaultInfo := ClosestLunarEclipse(date)
+28 -8
View File
@@ -14,28 +14,48 @@ func moonSunLoDiff(date time.Time) float64 {
return tools.Limit360(moonLo - sunLo)
}
func solarAltitude(date time.Time, lon, lat float64) float64 {
func solarAltitude(date time.Time, lon, lat float64, modules ...*Eclipse) float64 {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
localJD := basic.Date2JD(date)
_, loc := date.Zone()
return basic.SunHeight(localJD, lon, lat, float64(loc)/3600.0)
return basic.SunHeightWithInstance(localJD, lon, lat, float64(loc)/3600.0, module.inst)
}
func solarCulminationTime(date time.Time, lon float64) time.Time {
func solarCulminationTime(date time.Time, lon float64, modules ...*Eclipse) time.Time {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
localJD := basic.Date2JD(date.Add(time.Duration(-1*date.Hour())*time.Hour)) + 0.5
_, loc := date.Zone()
timezone := float64(loc) / 3600.0
calcJD := basic.CulminationTime(localJD, lon, timezone) - timezone/24.0
calcJD := basic.CulminationTimeWithInstance(localJD, lon, timezone, module.inst) - timezone/24.0
return basic.JD2DateByZone(calcJD, date.Location(), false)
}
func lunarAzimuth(date time.Time, lon, lat float64) float64 {
func lunarAzimuth(date time.Time, lon, lat float64, modules ...*Eclipse) float64 {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
localJD := basic.Date2JD(date)
_, loc := date.Zone()
return basic.HMoonAzimuth(localJD, lon, lat, float64(loc)/3600.0)
return basic.HMoonAzimuthWithInstance(localJD, lon, lat, float64(loc)/3600.0, module.inst)
}
func lunarAltitude(date time.Time, lon, lat float64) float64 {
func lunarAltitude(date time.Time, lon, lat float64, modules ...*Eclipse) float64 {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
localJD := basic.Date2JD(date)
_, loc := date.Zone()
return basic.HMoonHeight(localJD, lon, lat, float64(loc)/3600.0)
return basic.HMoonHeightWithInstance(localJD, lon, lat, float64(loc)/3600.0, module.inst)
}
+16 -2
View File
@@ -5,6 +5,7 @@ import (
"sync"
"b612.me/astro/basic"
"b612.me/astro/internal/ephem"
)
const (
@@ -29,6 +30,11 @@ const (
)
// SarosInfo 沙罗序列信息, Saros series metadata.
//
// Series 与星历模型无关;Member 与 Count 在序列首尾可能随模型变化:食分接近 0 的
// 擦边食是否成立取决于模型,差异量级小于模型自身误差,不同模型因而可能多算或少算
// 一个首尾成员。Series is model-independent; Member and Count can differ by one at
// a series head or tail, where a grazing eclipse falls within model error.
type SarosInfo struct {
// Series 是权威目录系列编号;Verified=false 时为根据周期外推得到的暂定编号。
// Series is the authoritative catalog series number, or a provisional number when Verified is false.
@@ -154,11 +160,19 @@ var lunarSarosHeadOverrides = [...]sarosHeadOverride{
{Series: 61, Count: 78, HeadYear: -762, HeadMonth: 12, HeadDay: 24, MemberOffset: 1},
}
func solarSarosInfo(ttJDE float64) (SarosInfo, bool) {
func solarSarosInfo(ttJDE float64, modules ...*Eclipse) (SarosInfo, bool) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
if module.model() == ephem.Custom {
return SarosInfo{}, false
}
if info, ok := matchSarosMagic(solarSarosAnchors[:], 0, solarSarosHeadOverrides[:], ttJDE); ok {
return info, true
}
return reconciledSarosInfo(extendedSarosInfo, ttJDE, 0)
return reconciledSarosInfo(module.extendedSarosInfo, ttJDE, 0)
}
func lunarSarosInfo(ttJDE float64) (SarosInfo, bool) {
+2 -2
View File
@@ -80,9 +80,9 @@ func sarosTestMember(window sarosTestWindow, ttJDE float64) int {
func sarosTestSpans(phase int) []sarosSpan {
if phase == 1 {
return lunarSarosExtended[:]
return sarosExtensionSpans(1)
}
return solarSarosExtended[:]
return sarosExtensionSpans(0)
}
func sarosTestInfo(ttJDE float64, phase int) (SarosInfo, bool) {
+2 -2
View File
@@ -42,10 +42,10 @@ func BenchmarkSarosLookup(b *testing.B) {
}
func BenchmarkSolarEclipsePrecomputedSaros(b *testing.B) {
date := time.Date(3288, 11, 15, 12, 0, 0, 0, time.UTC)
date := time.Date(3306, 11, 28, 12, 0, 0, 0, time.UTC)
for i := 0; i < b.N; i++ {
info, ok := SolarEclipseOnDate(date)
if !ok || !info.HasSaros || info.Saros != (SarosInfo{Series: 202, Member: 1, Count: 71}) {
if !ok || !info.HasSaros || info.Saros.Series != 202 {
b.Fatal("missing precomputed Saros metadata")
}
}
+56 -17
View File
@@ -5,11 +5,14 @@ import (
"sync"
"b612.me/astro/basic"
"b612.me/astro/internal/ephem"
)
const (
sarosInexLunations = 358
sarosLunationEpoch = 2451550.09765
sarosExtendedStartTT = 625308.343824885320
sarosExtendedEndTT = 3912880.500800740905
sarosInexLunations = 358
sarosLunationEpoch = 2451550.09765
// Keep the acceptance interval below half a synodic month while allowing
// the mean-phase seed to drift in remote eras.
sarosEclipseSeedToleranceDays = 7.0
@@ -17,9 +20,9 @@ const (
sarosEclipseMemoSlots = 4096
)
// 键是(朔望月序号,相位);判定与模型无关,只有月序号和相位决定结果。
type sarosEclipseMemoEntry struct {
key int64
gen uint64
maximum float64
exists bool
valid bool
@@ -42,7 +45,7 @@ func sarosEclipseLookup(k, phase int) (float64, bool, bool, bool) {
sarosEclipseMemoMu.RLock()
entry := sarosEclipseMemoEntries[slot]
sarosEclipseMemoMu.RUnlock()
if !entry.set || entry.key != key {
if !entry.set || entry.key != key || entry.gen != ephem.Generation() {
return 0, false, false, false
}
return entry.maximum, entry.exists, entry.valid, true
@@ -53,7 +56,7 @@ func sarosEclipseStore(k, phase int, maximum float64, exists, valid bool) {
slot := sarosEclipseMemoSlot(key)
sarosEclipseMemoMu.Lock()
sarosEclipseMemoEntries[slot] = sarosEclipseMemoEntry{
key: key, maximum: maximum, exists: exists, valid: valid, set: true,
key: key, gen: ephem.Generation(), maximum: maximum, exists: exists, valid: valid, set: true,
}
sarosEclipseMemoMu.Unlock()
}
@@ -108,15 +111,20 @@ func matchSarosSpans(spans []sarosSpan, k int) (SarosInfo, bool) {
}
func extendedSarosInfo(ttJDE float64, phase int) (SarosInfo, bool) {
return defaultModule.extendedSarosInfo(ttJDE, phase)
}
func (module *Eclipse) extendedSarosInfo(ttJDE float64, phase int) (SarosInfo, bool) {
return extendedSarosInfoForModel(ttJDE, phase, module.model(), module.sarosEclipse)
}
func extendedSarosInfoForModel(ttJDE float64, phase int, model ephem.Model, evaluate func(int, int) (float64, bool, bool)) (SarosInfo, bool) {
k, ok := sarosLunation(ttJDE, phase)
if !ok {
return SarosInfo{}, false
}
if ttJDE >= sarosExtendedStartTT && ttJDE < sarosExtendedEndTT {
spans := solarSarosExtended[:]
if phase == 1 {
spans = lunarSarosExtended[:]
}
spans := sarosExtensionSpansForModel(phase, model)
info, ok := matchSarosSpans(spans, k)
if !ok {
return SarosInfo{}, false
@@ -126,31 +134,58 @@ func extendedSarosInfo(ttJDE float64, phase int) (SarosInfo, bool) {
}
return info, true
}
return extrapolateSaros(k, phase)
return extrapolateSarosWithEvaluator(k, phase, evaluate)
}
func sarosEclipse(k, phase int) (float64, bool, bool) {
func sarosExtensionSpans(phase int) []sarosSpan {
return sarosExtensionSpansForModel(phase, ephem.Active())
}
func sarosExtensionSpansForModel(phase int, model ephem.Model) []sarosSpan {
if model == ephem.Semianalytic {
if phase == 1 {
return lunarSarosExtendedSemianalytic[:]
}
return solarSarosExtendedSemianalytic[:]
}
if phase == 1 {
return lunarSarosExtendedLegacy[:]
}
return solarSarosExtendedLegacy[:]
}
func sarosEclipse(k, phase int) (float64, bool, bool) { return defaultModule.sarosEclipse(k, phase) }
func (module *Eclipse) sarosEclipse(k, phase int) (float64, bool, bool) {
if module.inst != nil {
return sarosEclipseUncached(k, phase, module)
}
if maximum, exists, valid, ok := sarosEclipseLookup(k, phase); ok {
return maximum, exists, valid
}
maximum, exists, valid := sarosEclipseUncached(k, phase)
maximum, exists, valid := sarosEclipseUncached(k, phase, module)
sarosEclipseStore(k, phase, maximum, exists, valid)
return maximum, exists, valid
}
// Saros metadata uses the Split-K solar model and the union of Danjon and
// Chauvenet lunar detections, independent of the observer or display model.
func sarosEclipseUncached(k, phase int) (float64, bool, bool) {
func sarosEclipseUncached(k, phase int, modules ...*Eclipse) (float64, bool, bool) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
seed := sarosLunationEpoch + (float64(k)+float64(phase)/2)*solarEclipseSynodicMonthDays
var maximum float64
var exists bool
if phase == 0 {
result := basic.SolarEclipseNASABulletinSplitK(seed)
result := basic.SolarEclipseNASABulletinSplitKWithInstance(seed, module.inst)
maximum, exists = result.GreatestEclipse, result.Type != basic.SolarEclipseNone
} else {
result := basic.LunarEclipseDanjon(seed)
result := basic.LunarEclipseDanjonWithInstance(seed, module.inst)
if result.Type == basic.LunarEclipseNone {
result = basic.LunarEclipseChauvenet(seed)
result = basic.LunarEclipseChauvenetWithInstance(seed, module.inst)
}
maximum, exists = result.Maximum, result.Type != basic.LunarEclipseNone
}
@@ -159,9 +194,13 @@ func sarosEclipseUncached(k, phase int) (float64, bool, bool) {
}
func extrapolateSaros(k, phase int) (SarosInfo, bool) {
return extrapolateSarosWithEvaluator(k, phase, sarosEclipse)
}
func extrapolateSarosWithEvaluator(k, phase int, evaluate func(int, int) (float64, bool, bool)) (SarosInfo, bool) {
info := SarosInfo{Series: sarosNumber(k, phase)}
for offset := -sarosExtrapolationWindow; offset <= sarosExtrapolationWindow; offset++ {
_, exists, valid := sarosEclipse(k+offset*sarosCycleLunations, phase)
_, exists, valid := evaluate(k+offset*sarosCycleLunations, phase)
if !valid || (offset == 0 && !exists) {
return SarosInfo{}, false
}
+81 -17
View File
@@ -7,6 +7,8 @@ import (
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/ephem"
"b612.me/astro/internal/ephemtest"
)
func TestSarosNumberAgainstAllCatalogAnchors(t *testing.T) {
@@ -28,20 +30,31 @@ func TestSarosNumberAgainstAllCatalogAnchors(t *testing.T) {
}
func TestExtendedSarosUsesPrecomputedSeries(t *testing.T) {
got := ClosestSolarEclipse(time.Date(3288, 11, 15, 12, 0, 0, 0, time.UTC))
if !got.HasSaros || got.Saros != (SarosInfo{Series: 202, Member: 1, Count: 71}) {
t.Fatalf("unexpected precomputed solar Saros: has=%v info=%+v", got.HasSaros, got.Saros)
var span sarosSpan
for _, candidate := range sarosExtensionSpans(0) {
if candidate.Series == 202 {
span = candidate
break
}
}
next := ClosestSolarEclipse(time.Date(3306, 11, 28, 12, 0, 0, 0, time.UTC))
if !next.HasSaros || next.Saros != (SarosInfo{Series: 202, Member: 2, Count: 71}) {
t.Fatalf("unexpected second precomputed member: %+v", next.Saros)
if span.Count == 0 {
t.Fatal("missing precomputed solar Saros series 202")
}
for _, k := range []int{span.First, span.Last} {
ttJDE, exists, valid := sarosEclipse(k, 0)
if !exists || !valid {
t.Fatalf("precomputed solar Saros k=%d: exists=%v valid=%v", k, exists, valid)
}
want := SarosInfo{Series: span.Series, Member: span.Member + (k-span.First)/sarosCycleLunations, Count: span.Count}
got, ok := extendedSarosInfo(ttJDE, 0)
if !ok || got != want {
t.Fatalf("precomputed solar Saros k=%d: got=%+v/%v want=%+v", k, got, ok, want)
}
}
previous := ClosestSolarEclipse(time.Date(3288, 10, 17, 12, 0, 0, 0, time.UTC))
assertSarosInfo(t, previous.HasSaros, previous.Saros, 164, 67, 80)
}
func TestExtendedSarosSpansAreComplete(t *testing.T) {
for phase, spans := range [][]sarosSpan{solarSarosExtended[:], lunarSarosExtended[:]} {
for phase, spans := range [][]sarosSpan{sarosExtensionSpans(0), sarosExtensionSpans(1)} {
seen := make(map[int]bool)
for i, span := range spans {
if span.First > span.Last || (span.Last-span.First)%sarosCycleLunations != 0 || span.Member < 1 || span.Member > span.Count {
@@ -96,10 +109,7 @@ func TestExtendedSarosMatchesLiveSeries(t *testing.T) {
{phase: 1, series: 202},
} {
t.Run(fmt.Sprintf("phase%d/series%d", tc.phase, tc.series), func(t *testing.T) {
spans := solarSarosExtended[:]
if tc.phase == 1 {
spans = lunarSarosExtended[:]
}
spans := sarosExtensionSpans(tc.phase)
found := false
for _, span := range spans {
if span.Series != tc.series {
@@ -137,7 +147,7 @@ func TestExtendedSarosRangeAndInvalidInput(t *testing.T) {
if sarosExtendedStartTT != timeToTTJDE(time.Date(-3000, 1, 1, 0, 0, 0, 0, time.UTC)) || sarosExtendedEndTT != timeToTTJDE(time.Date(6001, 1, 1, 0, 0, 0, 0, time.UTC)) {
t.Fatal("precomputed range must include both end years")
}
for phase, spans := range [][]sarosSpan{solarSarosExtended[:], lunarSarosExtended[:]} {
for phase, spans := range [][]sarosSpan{sarosExtensionSpans(0), sarosExtensionSpans(1)} {
for _, tt := range []float64{sarosExtendedStartTT, sarosExtendedEndTT - 1.0/86400} {
k, _ := sarosLunation(tt, phase)
want, wantOK := matchSarosSpans(spans, k)
@@ -152,9 +162,35 @@ func TestExtendedSarosRangeAndInvalidInput(t *testing.T) {
}
}
}
if got, ok := extrapolateSaros(-56340, 1); ok || got != (SarosInfo{}) {
t.Fatalf("missing lunar return produced metadata: %+v/%v", got, ok)
missing, found := firstMissingSarosReturn(sarosExtensionSpans(1))
if !found {
t.Fatal("test table has no missing lunar return")
}
if got, ok := extrapolateSaros(missing, 1); ok || got != (SarosInfo{}) {
t.Fatalf("missing lunar return k=%d produced metadata: %+v/%v", missing, got, ok)
}
}
func TestSarosTablesFollowRuntimeModel(t *testing.T) {
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic, ephem.Legacy} {
t.Run(fmt.Sprintf("model%d", model), func(t *testing.T) {
ephemtest.Pin(t, model)
TestSarosExtensionTableIsFresh(t)
TestExtendedSarosUsesPrecomputedSeries(t)
TestExtendedSarosSpansAreComplete(t)
TestExtendedSarosMatchesLiveSeries(t)
})
}
}
func firstMissingSarosReturn(spans []sarosSpan) (int, bool) {
for index := 1; index < len(spans); index++ {
previous, current := spans[index-1], spans[index]
if previous.Series == current.Series && current.First > previous.Last+sarosCycleLunations {
return previous.Last + sarosCycleLunations, true
}
}
return 0, false
}
// 直映缓存只复用结果:命中与未命中都必须与未缓存求值逐字段相同,外推结果也必须与
@@ -165,7 +201,8 @@ func TestSarosEclipseMemoMatchesUncachedEvaluation(t *testing.T) {
for attempt := 0; attempt < 2; attempt++ {
maximum, exists, valid := sarosEclipse(k, phase)
wantMaximum, wantExists, wantValid := sarosEclipseUncached(k, phase)
if maximum != wantMaximum || exists != wantExists || valid != wantValid {
sameMaximum := maximum == wantMaximum || (math.IsNaN(maximum) && math.IsNaN(wantMaximum))
if !sameMaximum || exists != wantExists || valid != wantValid {
t.Fatalf("phase=%d k=%d attempt=%d cached=%v/%v/%v uncached=%v/%v/%v",
phase, k, attempt, maximum, exists, valid, wantMaximum, wantExists, wantValid)
}
@@ -204,3 +241,30 @@ func sarosExtrapolationUncachedForTest(k, phase int) (SarosInfo, bool) {
}
return info, true
}
// 序列首尾的擦边食是否成立随模型而定:Series 不变,Member/Count 可差一位。
func TestSarosGrazingHeadDependsOnModel(t *testing.T) {
head := time.Date(3288, 11, 15, 12, 0, 0, 0, time.UTC)
next := time.Date(3306, 11, 28, 12, 0, 0, 0, time.UTC)
for _, tc := range []struct {
model ephem.Model
hasHead bool
head SarosInfo
next SarosInfo
}{
{ephem.Legacy, true, SarosInfo{Series: 202, Member: 1, Count: 71}, SarosInfo{Series: 202, Member: 2, Count: 71}},
{ephem.Semianalytic, false, SarosInfo{}, SarosInfo{Series: 202, Member: 1, Count: 70}},
} {
t.Run(fmt.Sprintf("model%d", tc.model), func(t *testing.T) {
ephemtest.Pin(t, tc.model)
info, ok := SolarEclipseOnDate(head)
if ok != tc.hasHead || (ok && (!info.HasSaros || info.Saros != tc.head)) {
t.Fatalf("head: ok=%v hasSaros=%v %+v, want ok=%v %+v", ok, info.HasSaros, info.Saros, tc.hasHead, tc.head)
}
info, ok = SolarEclipseOnDate(next)
if !ok || !info.HasSaros || info.Saros != tc.next {
t.Fatalf("next: ok=%v hasSaros=%v %+v, want %+v", ok, info.HasSaros, info.Saros, tc.next)
}
})
}
}
+41 -13
View File
@@ -11,6 +11,7 @@ import (
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/ephem"
)
// 默认只校验发布表的新鲜度与金标准;重新生成必须同时给出 -saros-generate 与环境变量。
@@ -41,7 +42,7 @@ type sarosTableGolden struct {
span sarosSpan
}
var sarosTableGoldens = []sarosTableGolden{
var sarosTableLegacyGoldens = []sarosTableGolden{
{name: "solar/head", span: sarosSpan{Series: -47, First: -79891, Last: -61605, Member: 1, Count: 83}},
{name: "solar/202", span: sarosSpan{Series: 202, First: 15941, Last: 31551, Member: 1, Count: 71}},
{name: "solar/tail", span: sarosSpan{Series: 293, First: 48742, Last: 63906, Member: 1, Count: 69}},
@@ -51,6 +52,21 @@ var sarosTableGoldens = []sarosTableGolden{
{name: "lunar/tail", span: sarosSpan{Series: 287, First: 49211, Last: 64821, Member: 1, Count: 71}},
}
func sarosTableGoldens() []sarosTableGolden {
if ephem.Active() != ephem.Semianalytic {
return sarosTableLegacyGoldens
}
return []sarosTableGolden{
{name: "solar/head", span: sarosSpan{Series: -47, First: -80114, Last: -61605, Member: 1, Count: 84}},
{name: "solar/202", span: sarosSpan{Series: 202, First: 16164, Last: 31551, Member: 1, Count: 70}},
{name: "solar/tail", span: sarosSpan{Series: 293, First: 48742, Last: 63906, Member: 1, Count: 69}},
{name: "lunar/head", span: sarosSpan{Series: -54, First: -81564, Last: -61717, Member: 1, Count: 90}},
{name: "lunar/4-head", span: sarosSpan{Series: 4, First: -57901, Last: -56340, Member: 1, Count: 86}},
{name: "lunar/4-body", span: sarosSpan{Series: 4, First: -55894, Last: -38723, Member: 9, Count: 86}},
{name: "lunar/tail", span: sarosSpan{Series: 287, First: 49211, Last: 64821, Member: 1, Count: 71}},
}
}
// 发布表必须与文档化的窗口、生成时的序列总数和若干金标准行一致。
func TestSarosExtensionTableIsFresh(t *testing.T) {
pinSarosWindowPolicy(t)
@@ -64,17 +80,25 @@ func TestSarosExtensionTableIsFresh(t *testing.T) {
rows int
span []sarosSpan
}{
{name: "solar", phase: 0, rows: 159, span: solarSarosExtended[:]},
{name: "lunar", phase: 1, rows: 249, span: lunarSarosExtended[:]},
{name: "solar", phase: 0, rows: 159, span: sarosExtensionSpans(0)},
{name: "lunar", phase: 1, rows: 249, span: sarosExtensionSpans(1)},
} {
if ephem.Active() == ephem.Semianalytic {
switch table.phase {
case 0:
table.rows = 163
case 1:
table.rows = 252
}
}
if len(table.span) != table.rows {
t.Fatalf("%s table has %d rows, golden table has %d", table.name, len(table.span), table.rows)
}
validateSarosExtensionSpans(t, table.name, table.phase, table.span)
}
for _, golden := range sarosTableGoldens {
for _, golden := range sarosTableGoldens() {
found := false
for _, span := range append(solarSarosExtended[:], lunarSarosExtended[:]...) {
for _, span := range append(append([]sarosSpan{}, sarosExtensionSpans(0)...), sarosExtensionSpans(1)...) {
if span == golden.span {
found = true
break
@@ -121,7 +145,7 @@ type sarosGenerationEvent struct {
func TestGenerateSarosExtensionTables(t *testing.T) {
if !sarosTableGenerationEnabled() {
t.Skipf("set %s=1 and pass -saros-generate to rewrite saros_table_extended.go", sarosGenerateEnv)
t.Skipf("set %s=1 and pass -saros-generate to rewrite the active model's Saros table", sarosGenerateEnv)
}
pinSarosWindowPolicy(t)
startTT := timeToTTJDE(time.Date(-3000, 1, 1, 0, 0, 0, 0, time.UTC))
@@ -129,17 +153,17 @@ func TestGenerateSarosExtensionTables(t *testing.T) {
var buf bytes.Buffer
fmt.Fprintln(&buf, "// Code generated by go generate ./eclipse; DO NOT EDIT.")
fmt.Fprintln(&buf, "\npackage eclipse")
fmt.Fprintln(&buf, "\n// UTC astronomical years -3000 through +6000, including both end years.")
fmt.Fprintln(&buf, "// Series numbers follow NASA's Saros-Inex rules. Members are computed with")
fmt.Fprintln(&buf, "// Split-K solar geometry and the union of Danjon/Chauvenet lunar detections.")
fmt.Fprintf(&buf, "const sarosExtendedStartTT = %.12f\nconst sarosExtendedEndTT = %.12f\n", startTT, endTT)
suffix := "Legacy"
if ephem.Active() == ephem.Semianalytic {
suffix = "Semianalytic"
}
for phase, name := range []string{"solar", "lunar"} {
events := scanSarosGenerationEvents(t, phase)
assignSarosGenerationNumbers(t, events, phase)
spans := buildSarosGenerationSpans(t, events, phase, startTT, endTT)
// 写盘前先按发布口径校验,坏表不得进入仓库。
validateSarosExtensionSpans(t, name, phase, spans)
fmt.Fprintf(&buf, "\nvar %sSarosExtended = [...]sarosSpan{\n", name)
fmt.Fprintf(&buf, "\nvar %sSarosExtended%s = [...]sarosSpan{\n", name, suffix)
for _, span := range spans {
fmt.Fprintf(&buf, "{Series: %d, First: %d, Last: %d, Member: %d, Count: %d},\n", span.Series, span.First, span.Last, span.Member, span.Count)
}
@@ -150,10 +174,14 @@ func TestGenerateSarosExtensionTables(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile("saros_table_extended.go", source, 0644); err != nil {
filename := "saros_table_extended_legacy.go"
if ephem.Active() == ephem.Semianalytic {
filename = "saros_table_extended_semianalytic.go"
}
if err := os.WriteFile(filename, source, 0644); err != nil {
t.Fatal(err)
}
t.Logf("rewrote saros_table_extended.go; rerun TestSarosExtensionTableIsFresh and update the goldens")
t.Logf("rewrote %s; rerun TestSarosExtensionTableIsFresh and update the goldens", filename)
}
func scanSarosGenerationEvents(t *testing.T, phase int) []sarosGenerationEvent {
@@ -2,13 +2,7 @@
package eclipse
// UTC astronomical years -3000 through +6000, including both end years.
// Series numbers follow NASA's Saros-Inex rules. Members are computed with
// Split-K solar geometry and the union of Danjon/Chauvenet lunar detections.
const sarosExtendedStartTT = 625308.343824885320
const sarosExtendedEndTT = 3912880.500800740905
var solarSarosExtended = [...]sarosSpan{
var solarSarosExtendedLegacy = [...]sarosSpan{
{Series: -47, First: -79891, Last: -61605, Member: 1, Count: 83},
{Series: -46, First: -80871, Last: -61693, Member: 1, Count: 87},
{Series: -45, First: -79844, Last: -60443, Member: 1, Count: 88},
@@ -170,7 +164,7 @@ var solarSarosExtended = [...]sarosSpan{
{Series: 293, First: 48742, Last: 63906, Member: 1, Count: 69},
}
var lunarSarosExtended = [...]sarosSpan{
var lunarSarosExtendedLegacy = [...]sarosSpan{
{Series: -54, First: -81564, Last: -61717, Member: 1, Count: 90},
{Series: -53, First: -80091, Last: -60913, Member: 1, Count: 87},
{Series: -52, First: -80179, Last: -60778, Member: 1, Count: 88},
@@ -0,0 +1,424 @@
// Code generated by go generate ./eclipse; DO NOT EDIT.
package eclipse
var solarSarosExtendedSemianalytic = [...]sarosSpan{
{Series: -47, First: -80114, Last: -61605, Member: 1, Count: 84},
{Series: -46, First: -80871, Last: -61693, Member: 1, Count: 87},
{Series: -45, First: -79844, Last: -60443, Member: 1, Count: 88},
{Series: -44, First: -79263, Last: -60308, Member: 1, Count: 86},
{Series: -42, First: -78324, Last: -59146, Member: 1, Count: 87},
{Series: -41, First: -77743, Last: -61241, Member: 1, Count: 75},
{Series: -40, First: -77608, Last: -61329, Member: 1, Count: 74},
{Series: -39, First: -76581, Last: -60525, Member: 1, Count: 73},
{Series: -38, First: -76000, Last: -59944, Member: 1, Count: 73},
{Series: -37, First: -75642, Last: -59809, Member: 1, Count: 72},
{Series: -36, First: -74838, Last: -58782, Member: 1, Count: 73},
{Series: -35, First: -74257, Last: -58201, Member: 1, Count: 73},
{Series: -34, First: -73899, Last: -58066, Member: 1, Count: 72},
{Series: -33, First: -73095, Last: -57039, Member: 1, Count: 73},
{Series: -32, First: -72514, Last: -56458, Member: 1, Count: 73},
{Series: -31, First: -72379, Last: -56323, Member: 1, Count: 73},
{Series: -30, First: -71352, Last: -55296, Member: 1, Count: 73},
{Series: -29, First: -70994, Last: -54715, Member: 1, Count: 74},
{Series: -28, First: -73535, Last: -54580, Member: 1, Count: 86},
{Series: -27, First: -72954, Last: -53553, Member: 1, Count: 88},
{Series: -26, First: -71927, Last: -52972, Member: 1, Count: 86},
{Series: -25, First: -72238, Last: -53283, Member: 1, Count: 86},
{Series: -24, First: -71657, Last: -52256, Member: 1, Count: 88},
{Series: -23, First: -70630, Last: -51675, Member: 1, Count: 86},
{Series: -22, First: -70495, Last: -54216, Member: 1, Count: 74},
{Series: -21, First: -69914, Last: -53858, Member: 1, Count: 73},
{Series: -20, First: -68887, Last: -52831, Member: 1, Count: 73},
{Series: -19, First: -68752, Last: -52696, Member: 1, Count: 73},
{Series: -18, First: -68171, Last: -52115, Member: 1, Count: 73},
{Series: -17, First: -67144, Last: -51311, Member: 1, Count: 72},
{Series: -16, First: -67009, Last: -50953, Member: 1, Count: 73},
{Series: -15, First: -66428, Last: -50372, Member: 1, Count: 73},
{Series: -14, First: -65401, Last: -49568, Member: 1, Count: 72},
{Series: -13, First: -65266, Last: -49210, Member: 1, Count: 73},
{Series: -12, First: -64685, Last: -48629, Member: 1, Count: 73},
{Series: -11, First: -63658, Last: -47825, Member: 1, Count: 72},
{Series: -10, First: -63969, Last: -47690, Member: 1, Count: 74},
{Series: -9, First: -65618, Last: -65395, Member: 1, Count: 77},
{Series: -9, First: -63388, Last: -46886, Member: 3, Count: 77},
{Series: -8, First: -62361, Last: -46082, Member: 1, Count: 74},
{Series: -7, First: -64902, Last: -45947, Member: 1, Count: 86},
{Series: -6, First: -64767, Last: -45589, Member: 1, Count: 87},
{Series: -5, First: -63517, Last: -44562, Member: 1, Count: 86},
{Series: -4, First: -63605, Last: -45988, Member: 1, Count: 80},
{Series: -3, First: -63247, Last: -46968, Member: 1, Count: 74},
{Series: -2, First: -61997, Last: -45495, Member: 1, Count: 75},
{Series: -1, First: -61862, Last: -45583, Member: 1, Count: 74},
{Series: 11, First: -55782, Last: -38834, Member: 1, Count: 77},
{Series: 22, First: -51844, Last: -36011, Member: 1, Count: 72},
{Series: 30, First: -50318, Last: -31809, Member: 1, Count: 84},
{Series: 69, First: -33903, Last: -16509, Member: 1, Count: 79},
{Series: 181, First: 10430, Last: 26040, Member: 1, Count: 71},
{Series: 182, First: 8558, Last: 25952, Member: 1, Count: 79},
{Series: 183, First: 8247, Last: 24080, Member: 1, Count: 72},
{Series: 184, First: 9720, Last: 26668, Member: 1, Count: 77},
{Series: 185, First: 9409, Last: 25465, Member: 1, Count: 73},
{Series: 186, First: 9767, Last: 25154, Member: 1, Count: 70},
{Series: 187, First: 10794, Last: 26181, Member: 1, Count: 70},
{Series: 188, First: 10929, Last: 26539, Member: 1, Count: 71},
{Series: 189, First: 11287, Last: 26674, Member: 1, Count: 70},
{Series: 190, First: 12314, Last: 27701, Member: 1, Count: 70},
{Series: 191, First: 12672, Last: 28282, Member: 1, Count: 71},
{Series: 192, First: 13030, Last: 28417, Member: 1, Count: 70},
{Series: 193, First: 14057, Last: 29221, Member: 1, Count: 69},
{Series: 194, First: 14415, Last: 29802, Member: 1, Count: 70},
{Series: 195, First: 14550, Last: 29937, Member: 1, Count: 70},
{Series: 196, First: 15800, Last: 30964, Member: 1, Count: 69},
{Series: 197, First: 15935, Last: 31322, Member: 1, Count: 70},
{Series: 198, First: 15847, Last: 31457, Member: 1, Count: 71},
{Series: 199, First: 17097, Last: 32261, Member: 1, Count: 69},
{Series: 200, First: 16786, Last: 32842, Member: 1, Count: 73},
{Series: 201, First: 15137, Last: 31193, Member: 1, Count: 73},
{Series: 202, First: 16164, Last: 31551, Member: 1, Count: 70},
{Series: 203, First: 16299, Last: 32355, Member: 1, Count: 73},
{Series: 204, First: 16434, Last: 31821, Member: 1, Count: 70},
{Series: 205, First: 17238, Last: 32402, Member: 1, Count: 69},
{Series: 206, First: 17819, Last: 33206, Member: 1, Count: 70},
{Series: 207, First: 17954, Last: 33341, Member: 1, Count: 70},
{Series: 208, First: 18758, Last: 33922, Member: 1, Count: 69},
{Series: 209, First: 19562, Last: 34949, Member: 1, Count: 70},
{Series: 210, First: 19697, Last: 35084, Member: 1, Count: 70},
{Series: 211, First: 20501, Last: 35665, Member: 1, Count: 69},
{Series: 212, First: 21082, Last: 36469, Member: 1, Count: 70},
{Series: 213, First: 21217, Last: 36604, Member: 1, Count: 70},
{Series: 214, First: 22021, Last: 37185, Member: 1, Count: 69},
{Series: 215, First: 22825, Last: 37989, Member: 1, Count: 69},
{Series: 216, First: 22514, Last: 38124, Member: 1, Count: 71},
{Series: 217, First: 23318, Last: 38482, Member: 1, Count: 69},
{Series: 218, First: 23899, Last: 39509, Member: 1, Count: 71},
{Series: 219, First: 22027, Last: 38752, Member: 1, Count: 76},
{Series: 220, First: 22162, Last: 37549, Member: 1, Count: 70},
{Series: 221, First: 23412, Last: 39022, Member: 1, Count: 71},
{Series: 222, First: 23101, Last: 38711, Member: 1, Count: 71},
{Series: 223, First: 23682, Last: 38846, Member: 1, Count: 69},
{Series: 224, First: 24709, Last: 39873, Member: 1, Count: 69},
{Series: 225, First: 24621, Last: 40231, Member: 1, Count: 71},
{Series: 226, First: 25202, Last: 40366, Member: 1, Count: 69},
{Series: 227, First: 26229, Last: 41393, Member: 1, Count: 69},
{Series: 228, First: 26364, Last: 41751, Member: 1, Count: 70},
{Series: 229, First: 26945, Last: 41886, Member: 1, Count: 68},
{Series: 230, First: 27749, Last: 42913, Member: 1, Count: 69},
{Series: 231, First: 27884, Last: 43271, Member: 1, Count: 70},
{Series: 232, First: 28465, Last: 43406, Member: 1, Count: 68},
{Series: 233, First: 29492, Last: 44656, Member: 1, Count: 69},
{Series: 234, First: 29404, Last: 44791, Member: 1, Count: 70},
{Series: 235, First: 29316, Last: 44703, Member: 1, Count: 70},
{Series: 236, First: 30789, Last: 45953, Member: 1, Count: 69},
{Series: 237, First: 29140, Last: 45865, Member: 1, Count: 76},
{Series: 238, First: 28606, Last: 43993, Member: 1, Count: 70},
{Series: 239, First: 29856, Last: 45020, Member: 1, Count: 69},
{Series: 240, First: 29991, Last: 45378, Member: 1, Count: 70},
{Series: 241, First: 30126, Last: 45290, Member: 1, Count: 69},
{Series: 242, First: 31153, Last: 46094, Member: 1, Count: 68},
{Series: 243, First: 31511, Last: 46898, Member: 1, Count: 70},
{Series: 244, First: 31646, Last: 46810, Member: 1, Count: 69},
{Series: 245, First: 32673, Last: 47614, Member: 1, Count: 68},
{Series: 246, First: 33031, Last: 48418, Member: 1, Count: 70},
{Series: 247, First: 33389, Last: 48553, Member: 1, Count: 69},
{Series: 248, First: 34416, Last: 49357, Member: 1, Count: 68},
{Series: 249, First: 34774, Last: 49938, Member: 1, Count: 69},
{Series: 250, First: 34909, Last: 50073, Member: 1, Count: 69},
{Series: 251, First: 35936, Last: 50877, Member: 1, Count: 68},
{Series: 252, First: 36294, Last: 51458, Member: 1, Count: 69},
{Series: 253, First: 35760, Last: 51370, Member: 1, Count: 71},
{Series: 254, First: 37010, Last: 51728, Member: 1, Count: 67},
{Series: 255, First: 36699, Last: 52532, Member: 1, Count: 72},
{Series: 256, First: 35273, Last: 50883, Member: 1, Count: 71},
{Series: 257, First: 36077, Last: 51018, Member: 1, Count: 68},
{Series: 258, First: 36658, Last: 52045, Member: 1, Count: 70},
{Series: 259, First: 36793, Last: 51957, Member: 1, Count: 69},
{Series: 260, First: 37374, Last: 52315, Member: 1, Count: 68},
{Series: 261, First: 38178, Last: 53342, Member: 1, Count: 69},
{Series: 262, First: 38313, Last: 53477, Member: 1, Count: 69},
{Series: 263, First: 39117, Last: 53835, Member: 1, Count: 67},
{Series: 264, First: 39698, Last: 54862, Member: 1, Count: 69},
{Series: 265, First: 39833, Last: 55220, Member: 1, Count: 70},
{Series: 266, First: 40637, Last: 55578, Member: 1, Count: 68},
{Series: 267, First: 41441, Last: 56605, Member: 1, Count: 69},
{Series: 268, First: 41576, Last: 56740, Member: 1, Count: 69},
{Series: 269, First: 42157, Last: 56875, Member: 1, Count: 67},
{Series: 270, First: 42961, Last: 58125, Member: 1, Count: 69},
{Series: 271, First: 42427, Last: 58037, Member: 1, Count: 71},
{Series: 272, First: 41893, Last: 56611, Member: 1, Count: 67},
{Series: 273, First: 43812, Last: 58753, Member: 1, Count: 68},
{Series: 274, First: 42163, Last: 57773, Member: 1, Count: 71},
{Series: 275, First: 42298, Last: 57239, Member: 1, Count: 68},
{Series: 276, First: 43325, Last: 58266, Member: 1, Count: 68},
{Series: 277, First: 43460, Last: 58624, Member: 1, Count: 69},
{Series: 278, First: 43818, Last: 58759, Member: 1, Count: 68},
{Series: 279, First: 44845, Last: 59786, Member: 1, Count: 68},
{Series: 280, First: 44980, Last: 60144, Member: 1, Count: 69},
{Series: 281, First: 45338, Last: 60279, Member: 1, Count: 68},
{Series: 282, First: 46365, Last: 61306, Member: 1, Count: 68},
{Series: 283, First: 46500, Last: 61887, Member: 1, Count: 70},
{Series: 284, First: 46858, Last: 62022, Member: 1, Count: 69},
{Series: 285, First: 47885, Last: 62826, Member: 1, Count: 68},
{Series: 286, First: 48243, Last: 63407, Member: 1, Count: 69},
{Series: 287, First: 48155, Last: 63319, Member: 1, Count: 69},
{Series: 288, First: 49405, Last: 64123, Member: 1, Count: 67},
{Series: 289, First: 49317, Last: 64704, Member: 1, Count: 70},
{Series: 290, First: 47891, Last: 63278, Member: 1, Count: 70},
{Series: 292, First: 48830, Last: 64440, Member: 1, Count: 71},
{Series: 293, First: 48742, Last: 63906, Member: 1, Count: 69},
}
var lunarSarosExtendedSemianalytic = [...]sarosSpan{
{Series: -54, First: -81564, Last: -61717, Member: 1, Count: 90},
{Series: -53, First: -80091, Last: -60913, Member: 1, Count: 87},
{Series: -52, First: -80179, Last: -60778, Member: 1, Count: 88},
{Series: -51, First: -80044, Last: -60197, Member: 1, Count: 90},
{Series: -50, First: -78794, Last: -59393, Member: 1, Count: 88},
{Series: -49, First: -78659, Last: -61711, Member: 1, Count: 77},
{Series: -48, First: -78301, Last: -61799, Member: 1, Count: 75},
{Series: -47, First: -77051, Last: -60326, Member: 1, Count: 76},
{Series: -46, First: -76916, Last: -60637, Member: 1, Count: 74},
{Series: -45, First: -76335, Last: -60279, Member: 1, Count: 73},
{Series: -44, First: -75308, Last: -59029, Member: 1, Count: 74},
{Series: -43, First: -75173, Last: -58894, Member: 1, Count: 74},
{Series: -42, First: -74592, Last: -58536, Member: 1, Count: 73},
{Series: -41, First: -73565, Last: -57509, Member: 1, Count: 73},
{Series: -40, First: -73430, Last: -57151, Member: 1, Count: 74},
{Series: -39, First: -73072, Last: -56793, Member: 1, Count: 74},
{Series: -38, First: -71822, Last: -55766, Member: 1, Count: 73},
{Series: -37, First: -71910, Last: -55408, Member: 1, Count: 75},
{Series: -36, First: -74228, Last: -55050, Member: 1, Count: 87},
{Series: -35, First: -70302, Last: -54023, Member: 1, Count: 74},
{Series: -34, First: -72843, Last: -53665, Member: 1, Count: 87},
{Series: -33, First: -72931, Last: -53307, Member: 1, Count: 89},
{Series: -32, First: -71681, Last: -52280, Member: 1, Count: 88},
{Series: -31, First: -71546, Last: -52368, Member: 1, Count: 87},
{Series: -30, First: -71411, Last: -54686, Member: 1, Count: 76},
{Series: -29, First: -70161, Last: -50983, Member: 1, Count: 87},
{Series: -28, First: -69803, Last: -53301, Member: 1, Count: 75},
{Series: -27, First: -69668, Last: -53389, Member: 1, Count: 74},
{Series: -26, First: -68418, Last: -52139, Member: 1, Count: 74},
{Series: -25, First: -68060, Last: -51781, Member: 1, Count: 74},
{Series: -24, First: -67702, Last: -51646, Member: 1, Count: 73},
{Series: -23, First: -66675, Last: -50619, Member: 1, Count: 73},
{Series: -22, First: -66317, Last: -50261, Member: 1, Count: 73},
{Series: -21, First: -65959, Last: -49903, Member: 1, Count: 73},
{Series: -20, First: -64932, Last: -48876, Member: 1, Count: 73},
{Series: -19, First: -64574, Last: -48518, Member: 1, Count: 73},
{Series: -18, First: -64662, Last: -48160, Member: 1, Count: 75},
{Series: -17, First: -63412, Last: -47133, Member: 1, Count: 74},
{Series: -16, First: -63500, Last: -46775, Member: 1, Count: 76},
{Series: -15, First: -65818, Last: -46640, Member: 1, Count: 87},
{Series: -14, First: -64791, Last: -45390, Member: 1, Count: 88},
{Series: -13, First: -64433, Last: -45032, Member: 1, Count: 88},
{Series: -12, First: -64298, Last: -45566, Member: 1, Count: 85},
{Series: -11, First: -63494, Last: -43870, Member: 1, Count: 89},
{Series: -10, First: -62913, Last: -44181, Member: 1, Count: 85},
{Series: -9, First: -62555, Last: -46276, Member: 1, Count: 74},
{Series: -8, First: -61751, Last: -45472, Member: 1, Count: 74},
{Series: -7, First: -61170, Last: -44891, Member: 1, Count: 74},
{Series: -6, First: -60812, Last: -44756, Member: 1, Count: 73},
{Series: -5, First: -60008, Last: -43952, Member: 1, Count: 73},
{Series: -4, First: -59427, Last: -43148, Member: 1, Count: 74},
{Series: -3, First: -59069, Last: -43013, Member: 1, Count: 73},
{Series: -2, First: -58265, Last: -42209, Member: 1, Count: 73},
{Series: -1, First: -57684, Last: -41628, Member: 1, Count: 73},
{Series: 0, First: -57549, Last: -41270, Member: 1, Count: 74},
{Series: 2, First: -55941, Last: -39662, Member: 1, Count: 74},
{Series: 3, First: -56921, Last: -39527, Member: 1, Count: 79},
{Series: 4, First: -57901, Last: -56340, Member: 1, Count: 86},
{Series: 4, First: -55894, Last: -38723, Member: 9, Count: 86},
{Series: 5, First: -55759, Last: -38142, Member: 1, Count: 80},
{Series: 8, First: -55800, Last: -36622, Member: 1, Count: 87},
{Series: 9, First: -55665, Last: -38940, Member: 1, Count: 76},
{Series: 11, First: -54057, Last: -37555, Member: 1, Count: 75},
{Series: 20, First: -49051, Last: -32995, Member: 1, Count: 73},
{Series: 22, First: -48781, Last: -32056, Member: 1, Count: 76},
{Series: 23, First: -47531, Last: -31252, Member: 1, Count: 74},
{Series: 24, First: -50072, Last: -31117, Member: 1, Count: 86},
{Series: 25, First: -49937, Last: -30536, Member: 1, Count: 88},
{Series: 26, First: -48687, Last: -29509, Member: 1, Count: 87},
{Series: 27, First: -48775, Last: -29820, Member: 1, Count: 86},
{Series: 28, First: -48417, Last: -31915, Member: 1, Count: 75},
{Series: 29, First: -47167, Last: -28658, Member: 1, Count: 84},
{Series: 30, First: -47032, Last: -30530, Member: 1, Count: 75},
{Series: 34, First: -44931, Last: -28875, Member: 1, Count: 73},
{Series: 37, First: -43188, Last: -27132, Member: 1, Count: 73},
{Series: 40, First: -41668, Last: -25389, Member: 1, Count: 74},
{Series: 42, First: -40729, Last: -24004, Member: 1, Count: 76},
{Series: 43, First: -43047, Last: -23869, Member: 1, Count: 87},
{Series: 44, First: -41351, Last: -22842, Member: 1, Count: 84},
{Series: 45, First: -41439, Last: -22484, Member: 1, Count: 86},
{Series: 46, First: -41527, Last: -24579, Member: 1, Count: 77},
{Series: 47, First: -40500, Last: -21322, Member: 1, Count: 87},
{Series: 48, First: -39919, Last: -23194, Member: 1, Count: 76},
{Series: 51, First: -38399, Last: -22120, Member: 1, Count: 74},
{Series: 54, First: -36656, Last: -20600, Member: 1, Count: 73},
{Series: 59, First: -33751, Last: -17695, Member: 1, Count: 73},
{Series: 60, First: -33393, Last: -17114, Member: 1, Count: 74},
{Series: 61, First: -35265, Last: -17202, Member: 1, Count: 82},
{Series: 62, First: -32677, Last: -16175, Member: 1, Count: 75},
{Series: 63, First: -33880, Last: -15594, Member: 1, Count: 83},
{Series: 64, First: -34637, Last: -15905, Member: 1, Count: 85},
{Series: 67, First: -32894, Last: -16615, Member: 1, Count: 74},
{Series: 68, First: -32313, Last: -16257, Member: 1, Count: 73},
{Series: 69, First: -31509, Last: -15230, Member: 1, Count: 74},
{Series: 72, First: -29766, Last: -13710, Member: 1, Count: 73},
{Series: 79, First: -26591, Last: -10312, Member: 1, Count: 74},
{Series: 83, First: -27166, Last: -8434, Member: 1, Count: 85},
{Series: 85, First: -26004, Last: -9056, Member: 1, Count: 77},
{Series: 87, First: -24619, Last: -8340, Member: 1, Count: 74},
{Series: 88, First: -24261, Last: -8205, Member: 1, Count: 73},
{Series: 92, First: -22160, Last: -6327, Member: 1, Count: 72},
{Series: 93, First: -21133, Last: -5300, Member: 1, Count: 72},
{Series: 94, First: -20998, Last: -4942, Member: 1, Count: 73},
{Series: 95, First: -20640, Last: -4807, Member: 1, Count: 72},
{Series: 96, First: -19390, Last: -3557, Member: 1, Count: 72},
{Series: 97, First: -19478, Last: -3422, Member: 1, Count: 73},
{Series: 100, First: -19742, Last: -1679, Member: 1, Count: 82},
{Series: 101, First: -20499, Last: -1990, Member: 1, Count: 84},
{Series: 103, First: -19114, Last: -605, Member: 1, Count: 84},
{Series: 104, First: -18979, Last: -2700, Member: 1, Count: 74},
{Series: 105, First: -17729, Last: -1450, Member: 1, Count: 74},
{Series: 109, First: -15851, Last: 205, Member: 1, Count: 73},
{Series: 117, First: -11203, Last: 4630, Member: 1, Count: 72},
{Series: 118, First: -11068, Last: 5211, Member: 1, Count: 74},
{Series: 119, First: -13386, Last: 4900, Member: 1, Count: 83},
{Series: 120, First: -12582, Last: 5927, Member: 1, Count: 84},
{Series: 121, First: -12001, Last: 6508, Member: 1, Count: 84},
{Series: 122, First: -12089, Last: 4413, Member: 1, Count: 75},
{Series: 123, First: -11285, Last: 4771, Member: 1, Count: 73},
{Series: 124, First: -10481, Last: 5798, Member: 1, Count: 74},
{Series: 126, First: -9765, Last: 6068, Member: 1, Count: 72},
{Series: 130, First: -7218, Last: 8615, Member: 1, Count: 72},
{Series: 134, First: -5563, Last: 10493, Member: 1, Count: 73},
{Series: 137, First: -5827, Last: 12013, Member: 1, Count: 81},
{Series: 138, First: -6138, Last: 12148, Member: 1, Count: 83},
{Series: 139, First: -4442, Last: 13398, Member: 1, Count: 81},
{Series: 140, First: -5199, Last: 12418, Member: 1, Count: 80},
{Series: 141, First: -4841, Last: 11215, Member: 1, Count: 73},
{Series: 142, First: -3591, Last: 12688, Member: 1, Count: 74},
{Series: 143, First: -3679, Last: 12377, Member: 1, Count: 73},
{Series: 147, First: -1578, Last: 14032, Member: 1, Count: 71},
{Series: 148, First: -328, Last: 15282, Member: 1, Count: 71},
{Series: 154, First: 2712, Last: 18545, Member: 1, Count: 72},
{Series: 155, First: 2401, Last: 18680, Member: 1, Count: 74},
{Series: 156, First: 529, Last: 18592, Member: 1, Count: 82},
{Series: 157, First: 3563, Last: 19842, Member: 1, Count: 74},
{Series: 158, First: 1914, Last: 19977, Member: 1, Count: 82},
{Series: 166, First: 6116, Last: 21726, Member: 1, Count: 71},
{Series: 172, First: 9379, Last: 24989, Member: 1, Count: 71},
{Series: 173, First: 9514, Last: 25570, Member: 1, Count: 73},
{Series: 174, First: 7642, Last: 25482, Member: 1, Count: 81},
{Series: 175, First: 8669, Last: 26063, Member: 1, Count: 79},
{Series: 176, First: 9027, Last: 26867, Member: 1, Count: 81},
{Series: 177, First: 8716, Last: 24995, Member: 1, Count: 74},
{Series: 178, First: 9297, Last: 25130, Member: 1, Count: 72},
{Series: 180, First: 10236, Last: 26069, Member: 1, Count: 72},
{Series: 181, First: 10817, Last: 26427, Member: 1, Count: 71},
{Series: 182, First: 11621, Last: 27454, Member: 1, Count: 72},
{Series: 183, First: 11979, Last: 27589, Member: 1, Count: 71},
{Series: 184, First: 12560, Last: 27947, Member: 1, Count: 70},
{Series: 185, First: 13364, Last: 28974, Member: 1, Count: 71},
{Series: 186, First: 13499, Last: 29109, Member: 1, Count: 71},
{Series: 187, First: 14303, Last: 29690, Member: 1, Count: 70},
{Series: 188, First: 15107, Last: 30494, Member: 1, Count: 70},
{Series: 189, First: 15019, Last: 30852, Member: 1, Count: 72},
{Series: 190, First: 15600, Last: 31210, Member: 1, Count: 71},
{Series: 191, First: 16627, Last: 32237, Member: 1, Count: 71},
{Series: 192, First: 15647, Last: 32372, Member: 1, Count: 76},
{Series: 193, First: 14667, Last: 32061, Member: 1, Count: 79},
{Series: 194, First: 16586, Last: 33534, Member: 1, Count: 77},
{Series: 195, First: 15606, Last: 32554, Member: 1, Count: 77},
{Series: 196, First: 15741, Last: 31351, Member: 1, Count: 71},
{Series: 197, First: 16991, Last: 32824, Member: 1, Count: 72},
{Series: 198, First: 16903, Last: 32736, Member: 1, Count: 72},
{Series: 199, First: 17261, Last: 32871, Member: 1, Count: 71},
{Series: 200, First: 18511, Last: 33898, Member: 1, Count: 70},
{Series: 201, First: 18646, Last: 34256, Member: 1, Count: 71},
{Series: 202, First: 19004, Last: 34391, Member: 1, Count: 70},
{Series: 203, First: 20031, Last: 35418, Member: 1, Count: 70},
{Series: 204, First: 20389, Last: 35776, Member: 1, Count: 70},
{Series: 205, First: 20524, Last: 36134, Member: 1, Count: 71},
{Series: 206, First: 21774, Last: 37161, Member: 1, Count: 70},
{Series: 207, First: 21909, Last: 37519, Member: 1, Count: 71},
{Series: 208, First: 22044, Last: 37654, Member: 1, Count: 71},
{Series: 209, First: 23294, Last: 38681, Member: 1, Count: 70},
{Series: 210, First: 22983, Last: 39039, Member: 1, Count: 73},
{Series: 211, First: 21111, Last: 38505, Member: 1, Count: 79},
{Series: 212, First: 23476, Last: 39755, Member: 1, Count: 74},
{Series: 213, First: 22496, Last: 39667, Member: 1, Count: 78},
{Series: 214, First: 22408, Last: 38018, Member: 1, Count: 71},
{Series: 215, First: 23435, Last: 38822, Member: 1, Count: 70},
{Series: 216, First: 23793, Last: 39626, Member: 1, Count: 72},
{Series: 217, First: 23928, Last: 39538, Member: 1, Count: 71},
{Series: 218, First: 24955, Last: 40119, Member: 1, Count: 69},
{Series: 219, First: 25313, Last: 40923, Member: 1, Count: 71},
{Series: 220, First: 25671, Last: 41058, Member: 1, Count: 70},
{Series: 221, First: 26475, Last: 41639, Member: 1, Count: 69},
{Series: 222, First: 27056, Last: 42443, Member: 1, Count: 70},
{Series: 223, First: 27191, Last: 42801, Member: 1, Count: 71},
{Series: 224, First: 28218, Last: 43382, Member: 1, Count: 69},
{Series: 225, First: 28576, Last: 44186, Member: 1, Count: 71},
{Series: 226, First: 28711, Last: 44321, Member: 1, Count: 71},
{Series: 227, First: 29515, Last: 44902, Member: 1, Count: 70},
{Series: 228, First: 29873, Last: 45706, Member: 1, Count: 72},
{Series: 229, First: 28224, Last: 45395, Member: 1, Count: 78},
{Series: 230, First: 28582, Last: 44861, Member: 1, Count: 74},
{Series: 231, First: 29386, Last: 46557, Member: 1, Count: 78},
{Series: 232, First: 29075, Last: 44908, Member: 1, Count: 72},
{Series: 233, First: 29656, Last: 45043, Member: 1, Count: 70},
{Series: 234, First: 30460, Last: 46070, Member: 1, Count: 71},
{Series: 235, First: 30595, Last: 46205, Member: 1, Count: 71},
{Series: 236, First: 31176, Last: 46340, Member: 1, Count: 69},
{Series: 237, First: 32203, Last: 47590, Member: 1, Count: 70},
{Series: 238, First: 32338, Last: 47725, Member: 1, Count: 70},
{Series: 239, First: 32919, Last: 48083, Member: 1, Count: 69},
{Series: 240, First: 33723, Last: 49110, Member: 1, Count: 70},
{Series: 241, First: 33858, Last: 49468, Member: 1, Count: 71},
{Series: 242, First: 34439, Last: 49603, Member: 1, Count: 69},
{Series: 243, First: 35243, Last: 50630, Member: 1, Count: 70},
{Series: 244, First: 35378, Last: 50988, Member: 1, Count: 71},
{Series: 245, First: 35513, Last: 50900, Member: 1, Count: 70},
{Series: 246, First: 36763, Last: 52150, Member: 1, Count: 70},
{Series: 247, First: 35337, Last: 52285, Member: 1, Count: 77},
{Series: 248, First: 34803, Last: 50636, Member: 1, Count: 72},
{Series: 249, First: 36276, Last: 52778, Member: 1, Count: 75},
{Series: 250, First: 35742, Last: 51798, Member: 1, Count: 73},
{Series: 251, First: 36100, Last: 51487, Member: 1, Count: 70},
{Series: 252, First: 37127, Last: 52514, Member: 1, Count: 70},
{Series: 253, First: 37262, Last: 52872, Member: 1, Count: 71},
{Series: 254, First: 37620, Last: 53007, Member: 1, Count: 70},
{Series: 255, First: 38647, Last: 53811, Member: 1, Count: 69},
{Series: 256, First: 39005, Last: 54392, Member: 1, Count: 70},
{Series: 257, First: 39140, Last: 54527, Member: 1, Count: 70},
{Series: 258, First: 40390, Last: 55331, Member: 1, Count: 68},
{Series: 259, First: 40525, Last: 55912, Member: 1, Count: 70},
{Series: 260, First: 40883, Last: 56047, Member: 1, Count: 69},
{Series: 261, First: 41910, Last: 56851, Member: 1, Count: 68},
{Series: 262, First: 42045, Last: 57655, Member: 1, Count: 71},
{Series: 263, First: 41734, Last: 57344, Member: 1, Count: 71},
{Series: 264, First: 43207, Last: 58371, Member: 1, Count: 69},
{Series: 265, First: 42673, Last: 58729, Member: 1, Count: 73},
{Series: 266, First: 41247, Last: 57303, Member: 1, Count: 73},
{Series: 267, First: 42274, Last: 57438, Member: 1, Count: 69},
{Series: 268, First: 42632, Last: 58465, Member: 1, Count: 72},
{Series: 269, First: 42544, Last: 58154, Member: 1, Count: 71},
{Series: 270, First: 43348, Last: 58512, Member: 1, Count: 69},
{Series: 271, First: 44152, Last: 59539, Member: 1, Count: 70},
{Series: 272, First: 44064, Last: 59674, Member: 1, Count: 71},
{Series: 273, First: 44868, Last: 60032, Member: 1, Count: 69},
{Series: 274, First: 45672, Last: 61059, Member: 1, Count: 70},
{Series: 275, First: 45807, Last: 61194, Member: 1, Count: 70},
{Series: 276, First: 46611, Last: 61552, Member: 1, Count: 68},
{Series: 277, First: 47192, Last: 62579, Member: 1, Count: 70},
{Series: 278, First: 47327, Last: 62714, Member: 1, Count: 70},
{Series: 279, First: 48131, Last: 63072, Member: 1, Count: 68},
{Series: 280, First: 48712, Last: 64099, Member: 1, Count: 70},
{Series: 281, First: 48401, Last: 64011, Member: 1, Count: 71},
{Series: 282, First: 48982, Last: 63923, Member: 1, Count: 68},
{Series: 284, First: 47914, Last: 64193, Member: 1, Count: 74},
{Series: 285, First: 48272, Last: 63436, Member: 1, Count: 69},
{Series: 286, First: 49299, Last: 64686, Member: 1, Count: 70},
{Series: 287, First: 49211, Last: 64821, Member: 1, Count: 71},
}
+1 -1
View File
@@ -1,6 +1,6 @@
package eclipse
// Code generated by /tmp/generate_saros_tables.go; DO NOT EDIT.
// Code generated by a table generator; DO NOT EDIT.
var lunarSarosAnchors = [...]sarosMagic{
0x21ae3749,
+1 -1
View File
@@ -1,6 +1,6 @@
package eclipse
// Code generated by /tmp/generate_saros_tables.go; DO NOT EDIT.
// Code generated by a table generator; DO NOT EDIT.
var solarSarosAnchors = [...]sarosMagic{
0x202d5bc8,
+1 -1
View File
@@ -33,7 +33,7 @@ func TestSolarSarosInfoDerivedOutsideCatalogRange(t *testing.T) {
date time.Time
}{
{name: "before catalog", date: time.Date(-3200, time.January, 1, 12, 0, 0, 0, time.UTC)},
{name: "after catalog", date: time.Date(3288, time.November, 15, 12, 0, 0, 0, time.UTC)},
{name: "after extension", date: time.Date(6500, time.November, 15, 12, 0, 0, 0, time.UTC)},
} {
t.Run(tc.name, func(t *testing.T) {
info := ClosestSolarEclipse(tc.date)
+7 -2
View File
@@ -11,9 +11,14 @@ const (
eclipseSeasonMaxSearchStep = 4
)
func nextEclipseSearchCandidateTT(candidateTT float64, phaseType, direction int, synodicMonthDays float64) float64 {
func nextEclipseSearchCandidateTT(candidateTT float64, phaseType, direction int, synodicMonthDays float64, modules ...*Eclipse) float64 {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
step := eclipseSearchStep(candidateTT, direction, synodicMonthDays)
return basic.CalcMoonSHByJDE(candidateTT+float64(direction*step)*synodicMonthDays, phaseType)
return basic.CalcMoonSHByJDEWithInstance(candidateTT+float64(direction*step)*synodicMonthDays, phaseType, module.inst)
}
func eclipseSearchStep(candidateTT float64, direction int, synodicMonthDays float64) int {
+124 -61
View File
@@ -42,8 +42,16 @@ type SolarEclipseOptions struct {
RadiusModel SolarEclipseRadiusModel
// SunRadiusModel 太阳半径口径,零值为标准档, solar radius convention.
SunRadiusModel SolarEclipseSunRadiusModel
// LunarLimb 月缘剖面,nil 沿用模块设置;修正站心接触、食分与遮蔽率,以及全球始末与食甚点中心食时长 / lunar limb profile for local contacts and values, global P1/P4 and central duration.
LunarLimb LunarLimb
}
// LunarLimb 月缘剖面接口,内置实现见 b612.me/astro/moon/limb, lunar limb profile interface.
type LunarLimb = basic.LunarLimb
// SolarEclipseLimbContact 月缘修正后的全球偏食始末接触点位置 / limb-corrected global contact location.
type SolarEclipseLimbContact = basic.SolarEclipseLimbContact
// SolarEclipseType 全局日食食型, global solar eclipse type.
type SolarEclipseType string
@@ -85,6 +93,11 @@ type SolarEclipseInfo struct {
Type SolarEclipseType
// Centrality 中心性, eclipse centrality.
Centrality SolarEclipseCentrality
// LunarLimbApplied 全球偏食始末与食甚点中心食时长已按月缘剖面修正;剖面失败时为 false 且结果保持圆月面 / contacts refined with a lunar limb profile.
LunarLimbApplied bool
// PartialBeginContact / PartialEndContact 月缘修正后的偏食始末接触点位置,未修正时 Defined 为 false / limb-corrected contact locations.
PartialBeginContact SolarEclipseLimbContact
PartialEndContact SolarEclipseLimbContact
// HasSaros 存在沙罗序列信息(可能是锚点外推结果), has Saros series metadata (possibly extrapolated).
HasSaros bool
// Saros 是沙罗序列信息,包括系列号、系列内序号和总成员数。
@@ -148,48 +161,54 @@ type SolarEclipseInfo struct {
}
// SolarEclipseOnDate 当地自然日全局日食查询 / local-date global solar eclipse query.
// Determine whether a global solar eclipse overlaps the local date, using NASA bulletin Split-K by default.
func SolarEclipseOnDate(date time.Time) (SolarEclipseInfo, bool) {
return SolarEclipseOnDateNASABulletinSplitK(date)
func (module *Eclipse) SolarEclipseOnDate(date time.Time) (SolarEclipseInfo, bool) {
return module.SolarEclipseOnDateNASABulletinSplitK(date)
}
// SolarEclipseOnDateNASABulletinSplitK 当地自然日全局日食查询(NASA bulletin Split-K) / local-date global solar eclipse query with NASA bulletin Split-K.
// Determine whether a global solar eclipse overlaps the local date with the NASA bulletin Split-K model.
func SolarEclipseOnDateNASABulletinSplitK(date time.Time) (SolarEclipseInfo, bool) {
return solarEclipseOnDate(date, basic.SolarEclipseNASABulletinSplitK)
func (module *Eclipse) SolarEclipseOnDateNASABulletinSplitK(date time.Time) (SolarEclipseInfo, bool) {
return solarEclipseOnDate(date, func(seedJDE float64) basic.SolarEclipseResult {
return module.solarResult(seedJDE, basic.SolarEclipseModelNASABulletinSplitK)
}, module)
}
// SolarEclipseOnDateIAUSingleK 当地自然日全局日食查询(IAU Single-K) / local-date global solar eclipse query with IAU Single-K.
// Determine whether a global solar eclipse overlaps the local date with the IAU Single-K model.
func SolarEclipseOnDateIAUSingleK(date time.Time) (SolarEclipseInfo, bool) {
return solarEclipseOnDate(date, basic.SolarEclipseIAUSingleK)
func (module *Eclipse) SolarEclipseOnDateIAUSingleK(date time.Time) (SolarEclipseInfo, bool) {
return solarEclipseOnDate(date, func(seedJDE float64) basic.SolarEclipseResult {
return module.solarResult(seedJDE, basic.SolarEclipseModelIAUSingleK)
}, module)
}
// SolarEclipseOnDateWithOptions 当地自然日全局日食查询(自定义半径口径) / local-date global solar eclipse query with custom radius conventions.
func SolarEclipseOnDateWithOptions(date time.Time, options SolarEclipseOptions) (SolarEclipseInfo, bool) {
return solarEclipseOnDate(date, solarEclipseCalculatorFor(options))
func (module *Eclipse) SolarEclipseOnDateWithOptions(date time.Time, options SolarEclipseOptions) (SolarEclipseInfo, bool) {
return solarEclipseOnDate(date, solarEclipseCalculatorFor(options, module), module)
}
// LastSolarEclipseWithOptions 上次日食(自定义半径口径) / previous solar eclipse with custom radius conventions.
func LastSolarEclipseWithOptions(date time.Time, options SolarEclipseOptions) SolarEclipseInfo {
info, _ := searchSolarEclipse(date, -1, true, solarEclipseCalculatorFor(options))
func (module *Eclipse) LastSolarEclipseWithOptions(date time.Time, options SolarEclipseOptions) SolarEclipseInfo {
info, _ := searchSolarEclipse(date, -1, true, solarEclipseCalculatorFor(options, module), module)
return info
}
// NextSolarEclipseWithOptions 下次日食(自定义半径口径) / next solar eclipse with custom radius conventions.
func NextSolarEclipseWithOptions(date time.Time, options SolarEclipseOptions) SolarEclipseInfo {
info, _ := searchSolarEclipse(date, 1, false, solarEclipseCalculatorFor(options))
func (module *Eclipse) NextSolarEclipseWithOptions(date time.Time, options SolarEclipseOptions) SolarEclipseInfo {
info, _ := searchSolarEclipse(date, 1, false, solarEclipseCalculatorFor(options, module), module)
return info
}
// ClosestSolarEclipseWithOptions 最近一次日食(自定义半径口径) / closest solar eclipse with custom radius conventions.
func ClosestSolarEclipseWithOptions(date time.Time, options SolarEclipseOptions) SolarEclipseInfo {
last, hasLast := searchSolarEclipse(date, -1, true, solarEclipseCalculatorFor(options))
next, hasNext := searchSolarEclipse(date, 1, false, solarEclipseCalculatorFor(options))
func (module *Eclipse) ClosestSolarEclipseWithOptions(date time.Time, options SolarEclipseOptions) SolarEclipseInfo {
last, hasLast := searchSolarEclipse(date, -1, true, solarEclipseCalculatorFor(options, module), module)
next, hasNext := searchSolarEclipse(date, 1, false, solarEclipseCalculatorFor(options, module), module)
return closestSolarEclipse(date, last, hasLast, next, hasNext)
}
func solarEclipseCalculatorFor(options SolarEclipseOptions) solarEclipseCalculator {
func solarEclipseCalculatorFor(options SolarEclipseOptions, modules ...*Eclipse) solarEclipseCalculator {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
basicOptions := basic.SolarEclipseOptions{
RadiusModel: basic.SolarEclipseRadiusModel(options.RadiusModel),
SunRadiusModel: basic.SolarEclipseSunRadiusModel(options.SunRadiusModel),
@@ -197,22 +216,43 @@ func solarEclipseCalculatorFor(options SolarEclipseOptions) solarEclipseCalculat
if basicOptions.RadiusModel != basic.SolarEclipseModelIAUSingleK {
basicOptions.RadiusModel = basic.SolarEclipseModelNASABulletinSplitK
}
basicOptions.LunarLimb = options.LunarLimb
if basicOptions.LunarLimb == nil {
basicOptions.LunarLimb = module.lunarLimb
}
return func(seed float64) basic.SolarEclipseResult {
return basic.SolarEclipseWithOptions(seed, basicOptions)
return basic.SolarEclipseWithOptionsWithInstance(seed, basicOptions, module.inst)
}
}
func solarEclipseOnDate(date time.Time, calculator solarEclipseCalculator) (SolarEclipseInfo, bool) {
// solarResult 按模块星历与月缘计算一次全球日食;未设置月缘时与包级计算相同。
func (module *Eclipse) solarResult(seedJDE float64, model basic.SolarEclipseRadiusModel) basic.SolarEclipseResult {
if module.lunarLimb == nil {
if model == basic.SolarEclipseModelIAUSingleK {
return basic.SolarEclipseIAUSingleKWithInstance(seedJDE, module.inst)
}
return basic.SolarEclipseNASABulletinSplitKWithInstance(seedJDE, module.inst)
}
return basic.SolarEclipseWithOptionsWithInstance(seedJDE,
basic.SolarEclipseOptions{RadiusModel: model, LunarLimb: module.lunarLimb}, module.inst)
}
func solarEclipseOnDate(date time.Time, calculator solarEclipseCalculator, modules ...*Eclipse) (SolarEclipseInfo, bool) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
location := date.Location()
dayStart, dayMid, dayEnd := solarEclipseLocalDayBounds(date)
candidateTT := basic.CalcMoonSHByJDE(solarEclipseTimeToTTJDE(dayMid), 0)
candidateTT := basic.CalcMoonSHByJDEWithInstance(solarEclipseTimeToTTJDE(dayMid), 0, module.inst)
result := calculator(candidateTT)
if result.Type == basic.SolarEclipseNone {
return SolarEclipseInfo{}, false
}
info := solarEclipseInfoFromBasic(result, location)
info := solarEclipseInfoFromBasic(result, location, module)
if !solarEclipseOverlapsDate(info, dayStart, dayEnd) {
return SolarEclipseInfo{}, false
}
@@ -220,64 +260,71 @@ func solarEclipseOnDate(date time.Time, calculator solarEclipseCalculator) (Sola
}
// LastSolarEclipse 上次日食 / previous solar eclipse.
// Previous solar eclipse, using NASA bulletin Split-K by default.
func LastSolarEclipse(date time.Time) SolarEclipseInfo {
return LastSolarEclipseNASABulletinSplitK(date)
func (module *Eclipse) LastSolarEclipse(date time.Time) SolarEclipseInfo {
return module.LastSolarEclipseNASABulletinSplitK(date)
}
// LastSolarEclipseNASABulletinSplitK 上次日食(NASA bulletin Split-K) / previous solar eclipse with NASA bulletin Split-K.
// Previous solar eclipse with the NASA bulletin Split-K model.
func LastSolarEclipseNASABulletinSplitK(date time.Time) SolarEclipseInfo {
info, _ := searchSolarEclipse(date, -1, true, basic.SolarEclipseNASABulletinSplitK)
func (module *Eclipse) LastSolarEclipseNASABulletinSplitK(date time.Time) SolarEclipseInfo {
info, _ := searchSolarEclipse(date, -1, true, func(seedJDE float64) basic.SolarEclipseResult {
return module.solarResult(seedJDE, basic.SolarEclipseModelNASABulletinSplitK)
}, module)
return info
}
// LastSolarEclipseIAUSingleK 上次日食(IAU Single-K) / previous solar eclipse with IAU Single-K.
// Previous solar eclipse with the IAU Single-K model.
func LastSolarEclipseIAUSingleK(date time.Time) SolarEclipseInfo {
info, _ := searchSolarEclipse(date, -1, true, basic.SolarEclipseIAUSingleK)
func (module *Eclipse) LastSolarEclipseIAUSingleK(date time.Time) SolarEclipseInfo {
info, _ := searchSolarEclipse(date, -1, true, func(seedJDE float64) basic.SolarEclipseResult {
return module.solarResult(seedJDE, basic.SolarEclipseModelIAUSingleK)
}, module)
return info
}
// NextSolarEclipse 下次日食 / next solar eclipse.
// Next solar eclipse, using NASA bulletin Split-K by default.
func NextSolarEclipse(date time.Time) SolarEclipseInfo {
return NextSolarEclipseNASABulletinSplitK(date)
func (module *Eclipse) NextSolarEclipse(date time.Time) SolarEclipseInfo {
return module.NextSolarEclipseNASABulletinSplitK(date)
}
// NextSolarEclipseNASABulletinSplitK 下次日食(NASA bulletin Split-K) / next solar eclipse with NASA bulletin Split-K.
// Next solar eclipse with the NASA bulletin Split-K model.
func NextSolarEclipseNASABulletinSplitK(date time.Time) SolarEclipseInfo {
info, _ := searchSolarEclipse(date, 1, false, basic.SolarEclipseNASABulletinSplitK)
func (module *Eclipse) NextSolarEclipseNASABulletinSplitK(date time.Time) SolarEclipseInfo {
info, _ := searchSolarEclipse(date, 1, false, func(seedJDE float64) basic.SolarEclipseResult {
return module.solarResult(seedJDE, basic.SolarEclipseModelNASABulletinSplitK)
}, module)
return info
}
// NextSolarEclipseIAUSingleK 下次日食(IAU Single-K) / next solar eclipse with IAU Single-K.
// Next solar eclipse with the IAU Single-K model.
func NextSolarEclipseIAUSingleK(date time.Time) SolarEclipseInfo {
info, _ := searchSolarEclipse(date, 1, false, basic.SolarEclipseIAUSingleK)
func (module *Eclipse) NextSolarEclipseIAUSingleK(date time.Time) SolarEclipseInfo {
info, _ := searchSolarEclipse(date, 1, false, func(seedJDE float64) basic.SolarEclipseResult {
return module.solarResult(seedJDE, basic.SolarEclipseModelIAUSingleK)
}, module)
return info
}
// ClosestSolarEclipse 最近一次日食 / closest solar eclipse.
// Closest solar eclipse, using NASA bulletin Split-K by default.
func ClosestSolarEclipse(date time.Time) SolarEclipseInfo {
return ClosestSolarEclipseNASABulletinSplitK(date)
func (module *Eclipse) ClosestSolarEclipse(date time.Time) SolarEclipseInfo {
return module.ClosestSolarEclipseNASABulletinSplitK(date)
}
// ClosestSolarEclipseNASABulletinSplitK 最近一次日食(NASA bulletin Split-K) / closest solar eclipse with NASA bulletin Split-K.
// Closest solar eclipse with the NASA bulletin Split-K model.
func ClosestSolarEclipseNASABulletinSplitK(date time.Time) SolarEclipseInfo {
last, hasLast := searchSolarEclipse(date, -1, true, basic.SolarEclipseNASABulletinSplitK)
next, hasNext := searchSolarEclipse(date, 1, false, basic.SolarEclipseNASABulletinSplitK)
func (module *Eclipse) ClosestSolarEclipseNASABulletinSplitK(date time.Time) SolarEclipseInfo {
last, hasLast := searchSolarEclipse(date, -1, true, func(seedJDE float64) basic.SolarEclipseResult {
return module.solarResult(seedJDE, basic.SolarEclipseModelNASABulletinSplitK)
}, module)
next, hasNext := searchSolarEclipse(date, 1, false, func(seedJDE float64) basic.SolarEclipseResult {
return module.solarResult(seedJDE, basic.SolarEclipseModelNASABulletinSplitK)
}, module)
return closestSolarEclipse(date, last, hasLast, next, hasNext)
}
// ClosestSolarEclipseIAUSingleK 最近一次日食(IAU Single-K) / closest solar eclipse with IAU Single-K.
// Closest solar eclipse with the IAU Single-K model.
func ClosestSolarEclipseIAUSingleK(date time.Time) SolarEclipseInfo {
last, hasLast := searchSolarEclipse(date, -1, true, basic.SolarEclipseIAUSingleK)
next, hasNext := searchSolarEclipse(date, 1, false, basic.SolarEclipseIAUSingleK)
func (module *Eclipse) ClosestSolarEclipseIAUSingleK(date time.Time) SolarEclipseInfo {
last, hasLast := searchSolarEclipse(date, -1, true, func(seedJDE float64) basic.SolarEclipseResult {
return module.solarResult(seedJDE, basic.SolarEclipseModelIAUSingleK)
}, module)
next, hasNext := searchSolarEclipse(date, 1, false, func(seedJDE float64) basic.SolarEclipseResult {
return module.solarResult(seedJDE, basic.SolarEclipseModelIAUSingleK)
}, module)
return closestSolarEclipse(date, last, hasLast, next, hasNext)
}
@@ -309,26 +356,34 @@ func searchSolarEclipse(
date time.Time,
direction int,
includeCurrent bool,
calculator solarEclipseCalculator,
) (SolarEclipseInfo, bool) {
calculator solarEclipseCalculator, modules ...*Eclipse) (SolarEclipseInfo, bool) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
targetTT := solarEclipseTimeToTTJDE(date)
candidateTT := basic.CalcMoonSHByJDE(targetTT, 0)
candidateTT := basic.CalcMoonSHByJDEWithInstance(targetTT, 0, module.inst)
for i := 0; i < solarEclipseSearchLimit; i++ {
if isPotentialSolarEclipse(candidateTT) {
if module.isPotentialSolarEclipse(candidateTT) {
result := calculator(candidateTT)
if result.Type != basic.SolarEclipseNone && solarEclipseMatchesDirection(result.GreatestEclipse, targetTT, direction, includeCurrent) {
return solarEclipseInfoFromBasic(result, date.Location()), true
return solarEclipseInfoFromBasic(result, date.Location(), module), true
}
}
candidateTT = nextEclipseSearchCandidateTT(candidateTT, 0, direction, solarEclipseSynodicMonthDays)
candidateTT = nextEclipseSearchCandidateTT(candidateTT, 0, direction, solarEclipseSynodicMonthDays, module)
}
return SolarEclipseInfo{}, false
}
func isPotentialSolarEclipse(newMoonTT float64) bool {
return math.Abs(basic.HMoonTrueBo(newMoonTT)) <= solarEclipseLatitudeLimitDeg
return defaultModule.isPotentialSolarEclipse(newMoonTT)
}
func (module *Eclipse) isPotentialSolarEclipse(newMoonTT float64) bool {
return math.Abs(basic.HMoonTrueBoWithInstance(newMoonTT, module.inst)) <= solarEclipseLatitudeLimitDeg
}
func solarEclipseMatchesDirection(greatestTT, targetTT float64, direction int, includeCurrent bool) bool {
@@ -355,13 +410,21 @@ func nextSolarEclipseLocalDayStart(dayStart time.Time) time.Time {
return time.Date(dayStart.Year(), dayStart.Month(), dayStart.Day()+1, 0, 0, 0, 0, location)
}
func solarEclipseInfoFromBasic(result basic.SolarEclipseResult, location *time.Location) SolarEclipseInfo {
saros, hasSaros := solarSarosInfo(result.GreatestEclipse)
func solarEclipseInfoFromBasic(result basic.SolarEclipseResult, location *time.Location, modules ...*Eclipse) SolarEclipseInfo {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
saros, hasSaros := solarSarosInfo(result.GreatestEclipse, module)
return SolarEclipseInfo{
Model: mapBasicSolarEclipseModel(result.Model),
SunRadiusModel: result.SunRadiusModel,
Type: mapBasicSolarEclipseType(result.Type),
Centrality: mapBasicSolarEclipseCentrality(result.Centrality),
LunarLimbApplied: result.LunarLimbApplied,
PartialBeginContact: result.PartialBeginContact,
PartialEndContact: result.PartialEndContact,
HasSaros: hasSaros,
Saros: saros,
GreatestEclipse: solarEclipseTTJDEToTime(result.GreatestEclipse, location),
+7 -6
View File
@@ -1,6 +1,8 @@
package eclipse
import "b612.me/astro/basic"
import (
"b612.me/astro/basic"
)
// SolarEclipseBesselianPolynomial 是三次多项式系数,索引 n 对应 t 的 n 次幂,t 为自 T0 起算的 TT 小时数。
// SolarEclipseBesselianPolynomial holds the cubic coefficients; index n multiplies t^n with t in TT hours from T0.
@@ -53,18 +55,17 @@ type SolarEclipseBesselianElementsResult struct {
DeltaTSeconds float64
}
// SolarEclipseBesselianElements 计算给定近朔时刻附近一次日食的多项式贝塞尔根数,窗口内无日食时返回 false。
// Polynomial Besselian elements for the solar eclipse near the given new-moon instant.
func SolarEclipseBesselianElements(
// SolarEclipseBesselianElements 计算给定近朔时刻附近一次日食的多项式贝塞尔根数,窗口内无日食时返回 false / polynomial Besselian elements near a new moon; false when no eclipse.
func (module *Eclipse) SolarEclipseBesselianElements(
seedJDE float64, options SolarEclipseBesselianElementsOptions,
) (SolarEclipseBesselianElementsResult, bool) {
result, ok := basic.SolarEclipseBesselianElements(seedJDE, basic.SolarEclipseBesselianElementsOptions{
result, ok := basic.SolarEclipseBesselianElementsWithInstance(seedJDE, basic.SolarEclipseBesselianElementsOptions{
Model: basic.SolarEclipseRadiusModel(options.Model),
SunRadiusModel: basic.SolarEclipseSunRadiusModel(options.SunRadiusModel),
DeltaTSeconds: options.DeltaTSeconds,
ReferenceJDE: options.ReferenceJDE,
ValidHours: options.ValidHours,
})
}, module.inst)
if !ok {
return SolarEclipseBesselianElementsResult{}, false
}
+6 -6
View File
@@ -46,23 +46,23 @@ func solarEclipseCandidateIsCentral(value basic.SolarEclipseType) bool {
}
// SolarEclipseCandidates 枚举区间内的日食时刻表,按时间升序 / eclipses within a range, in time order.
func SolarEclipseCandidates(
func (module *Eclipse) SolarEclipseCandidates(
start, end time.Time, options SolarEclipseCandidateOptions,
) []SolarEclipseCandidate {
if !end.After(start) {
return nil
}
calculator := localSolarEclipseCalculatorForModel(options.Model)
calculator := localSolarEclipseCalculatorForModel(options.Model, module)
startTT := solarEclipseTimeToTTJDE(start)
endTT := solarEclipseTimeToTTJDE(end)
candidates := make([]SolarEclipseCandidate, 0, 8)
candidateTT := basic.CalcMoonSHByJDE(startTT, 0)
candidateTT := basic.CalcMoonSHByJDEWithInstance(startTT, 0, module.inst)
for iteration := 0; iteration < localSolarEclipseSearchIterationCap; iteration++ {
// 食甚可能略早于朔,上界要多留一点余量,否则窗口末尾那场会被漏掉。
if candidateTT > endTT+solarEclipseCandidateGreatestLeadDays {
break
}
if isPotentialLocalSolarEclipse(candidateTT) {
if isPotentialLocalSolarEclipse(candidateTT, module) {
globalResult := calculator.global(candidateTT)
if globalResult.Type != basic.SolarEclipseNone &&
(!options.CentralOnly || solarEclipseCandidateIsCentral(globalResult.Type)) {
@@ -78,13 +78,13 @@ func SolarEclipseCandidates(
Gamma: globalResult.Gamma,
}
if options.IncludeSaros {
candidate.Saros, candidate.HasSaros = solarSarosInfo(globalResult.GreatestEclipse)
candidate.Saros, candidate.HasSaros = solarSarosInfo(globalResult.GreatestEclipse, module)
}
candidates = append(candidates, candidate)
}
}
}
candidateTT = nextEclipseSearchCandidateTT(candidateTT, 0, 1, localSolarEclipseSynodicMonthDays)
candidateTT = nextEclipseSearchCandidateTT(candidateTT, 0, 1, localSolarEclipseSynodicMonthDays, module)
}
return candidates
}
+187 -153
View File
@@ -16,6 +16,8 @@ const (
type localSolarEclipseCalculator struct {
global func(float64) basic.SolarEclipseResult
local func(float64, float64, float64, float64) basic.LocalSolarEclipseResult
// limb 是 local 实际使用的月缘剖面,接触点图须与之一致。
limb basic.LunarLimb
}
type localSolarEclipseQueryMode int
@@ -56,6 +58,8 @@ type LocalSolarEclipseInfo struct {
SunRadiusModel SolarEclipseSunRadiusModel
// Type 站心食型, local eclipse type.
Type SolarEclipseType
// LunarLimbApplied 接触时刻已按月缘剖面修正;剖面失败时为 false 且结果保持圆月面 / contacts refined with a lunar limb profile.
LunarLimbApplied bool
// HasSaros 存在沙罗序列信息(可能是锚点外推结果), has Saros series metadata (possibly extrapolated).
HasSaros bool
// Saros 是沙罗序列信息,包括系列号、系列内序号和总成员数。
@@ -115,47 +119,46 @@ type LocalSolarEclipseInfo struct {
}
// LocalSolarEclipseOnDate 当地站心日食查询 / local topocentric solar eclipse query.
// Determine whether a visible local solar eclipse overlaps the local date, using NASA bulletin Split-K by default.
func LocalSolarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return LocalSolarEclipseOnDateNASABulletinSplitK(date, lon, lat, height)
func (module *Eclipse) LocalSolarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return module.LocalSolarEclipseOnDateNASABulletinSplitK(date, lon, lat, height)
}
// LocalSolarEclipseOnDateNASABulletinSplitK 当地站心日食查询(NASA bulletin Split-K) / local topocentric solar eclipse query with NASA bulletin Split-K.
// Determine whether a visible local solar eclipse overlaps the local date with the NASA bulletin Split-K model.
func LocalSolarEclipseOnDateNASABulletinSplitK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return localSolarEclipseOnDate(date, lon, lat, height, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
func (module *Eclipse) LocalSolarEclipseOnDateNASABulletinSplitK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return localSolarEclipseOnDate(date, lon, lat, height, localSolarEclipseCalculatorForModel(SolarEclipseModelNASABulletinSplitK, module), localSolarEclipseQueryVisible, module)
}
// LocalSolarEclipseOnDateIAUSingleK 当地站心日食查询(IAU Single-K) / local topocentric solar eclipse query with IAU Single-K.
// Determine whether a visible local solar eclipse overlaps the local date with the IAU Single-K model.
func LocalSolarEclipseOnDateIAUSingleK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return localSolarEclipseOnDate(date, lon, lat, height, localSolarEclipseIAUSingleK, localSolarEclipseQueryVisible)
func (module *Eclipse) LocalSolarEclipseOnDateIAUSingleK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return localSolarEclipseOnDate(date, lon, lat, height, localSolarEclipseCalculatorForModel(SolarEclipseModelIAUSingleK, module), localSolarEclipseQueryVisible, module)
}
// GeometricLocalSolarEclipseOnDate 当地站心几何日食查询 / local geometric solar eclipse query.
// Determine whether a geometric local solar eclipse overlaps the local date, using NASA bulletin Split-K by default.
func GeometricLocalSolarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return GeometricLocalSolarEclipseOnDateNASABulletinSplitK(date, lon, lat, height)
func (module *Eclipse) GeometricLocalSolarEclipseOnDate(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return module.GeometricLocalSolarEclipseOnDateNASABulletinSplitK(date, lon, lat, height)
}
// GeometricLocalSolarEclipseOnDateNASABulletinSplitK 当地站心几何日食查询(NASA bulletin Split-K) / local geometric solar eclipse query with NASA bulletin Split-K.
// Determine whether a geometric local solar eclipse overlaps the local date with the NASA bulletin Split-K model.
func GeometricLocalSolarEclipseOnDateNASABulletinSplitK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return localSolarEclipseOnDate(date, lon, lat, height, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryGeometric)
func (module *Eclipse) GeometricLocalSolarEclipseOnDateNASABulletinSplitK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return localSolarEclipseOnDate(date, lon, lat, height, localSolarEclipseCalculatorForModel(SolarEclipseModelNASABulletinSplitK, module), localSolarEclipseQueryGeometric, module)
}
// GeometricLocalSolarEclipseOnDateIAUSingleK 当地站心几何日食查询(IAU Single-K) / local geometric solar eclipse query with IAU Single-K.
// Determine whether a geometric local solar eclipse overlaps the local date with the IAU Single-K model.
func GeometricLocalSolarEclipseOnDateIAUSingleK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return localSolarEclipseOnDate(date, lon, lat, height, localSolarEclipseIAUSingleK, localSolarEclipseQueryGeometric)
func (module *Eclipse) GeometricLocalSolarEclipseOnDateIAUSingleK(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return localSolarEclipseOnDate(date, lon, lat, height, localSolarEclipseCalculatorForModel(SolarEclipseModelIAUSingleK, module), localSolarEclipseQueryGeometric, module)
}
// LocalSolarEclipseOnDateWithOptions 当地站心日食查询(自定义半径口径) / local topocentric solar eclipse query with custom radius conventions.
func LocalSolarEclipseOnDateWithOptions(date time.Time, lon, lat, height float64, options SolarEclipseOptions) (LocalSolarEclipseInfo, bool) {
return localSolarEclipseOnDate(date, lon, lat, height, localSolarEclipseCalculatorFor(options), localSolarEclipseQueryVisible)
func (module *Eclipse) LocalSolarEclipseOnDateWithOptions(date time.Time, lon, lat, height float64, options SolarEclipseOptions) (LocalSolarEclipseInfo, bool) {
return localSolarEclipseOnDate(date, lon, lat, height, localSolarEclipseCalculatorFor(options, module), localSolarEclipseQueryVisible, module)
}
func localSolarEclipseCalculatorFor(options SolarEclipseOptions) localSolarEclipseCalculator {
func localSolarEclipseCalculatorFor(options SolarEclipseOptions, modules ...*Eclipse) localSolarEclipseCalculator {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
basicOptions := basic.SolarEclipseOptions{
RadiusModel: basic.SolarEclipseRadiusModel(options.RadiusModel),
SunRadiusModel: basic.SolarEclipseSunRadiusModel(options.SunRadiusModel),
@@ -163,12 +166,19 @@ func localSolarEclipseCalculatorFor(options SolarEclipseOptions) localSolarEclip
if basicOptions.RadiusModel != basic.SolarEclipseModelIAUSingleK {
basicOptions.RadiusModel = basic.SolarEclipseModelNASABulletinSplitK
}
// 全局食况(是否有食、食型)仍按圆月面判定,月缘只修正站心接触。
localOptions := basicOptions
localOptions.LunarLimb = options.LunarLimb
if localOptions.LunarLimb == nil {
localOptions.LunarLimb = module.lunarLimb
}
return localSolarEclipseCalculator{
limb: localOptions.LunarLimb,
global: func(seed float64) basic.SolarEclipseResult {
return basic.SolarEclipseWithOptions(seed, basicOptions)
return basic.SolarEclipseWithOptionsWithInstance(seed, basicOptions, module.inst)
},
local: func(seed, lon, lat, height float64) basic.LocalSolarEclipseResult {
return basic.LocalSolarEclipseWithOptions(seed, lon, lat, height, basicOptions)
return basic.LocalSolarEclipseWithOptionsWithInstance(seed, lon, lat, height, localOptions, module.inst)
},
}
}
@@ -177,13 +187,17 @@ func localSolarEclipseOnDate(
date time.Time,
lon, lat, height float64,
calculator localSolarEclipseCalculator,
mode localSolarEclipseQueryMode,
) (LocalSolarEclipseInfo, bool) {
mode localSolarEclipseQueryMode, modules ...*Eclipse) (LocalSolarEclipseInfo, bool) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
location := date.Location()
dayStart, dayMid, dayEnd := solarEclipseLocalDayBounds(date)
candidateTT := basic.CalcMoonSHByJDE(solarEclipseTimeToTTJDE(dayMid), 0)
if !isPotentialLocalSolarEclipse(candidateTT) {
candidateTT := basic.CalcMoonSHByJDEWithInstance(solarEclipseTimeToTTJDE(dayMid), 0, module.inst)
if !isPotentialLocalSolarEclipse(candidateTT, module) {
return LocalSolarEclipseInfo{}, false
}
@@ -197,177 +211,153 @@ func localSolarEclipseOnDate(
return LocalSolarEclipseInfo{}, false
}
info := localSolarEclipseInfoFromBasic(result, lon, lat, height, location)
info := localSolarEclipseInfoFromBasic(result, lon, lat, height, location, calculator.limb, module)
if !localSolarEclipseOverlapsDate(info, dayStart, dayEnd) {
return LocalSolarEclipseInfo{}, false
}
if mode == localSolarEclipseQueryVisible && !localSolarEclipseVisibleOnDate(info, dayStart, dayEnd) {
if mode == localSolarEclipseQueryVisible && !localSolarEclipseVisibleOnDate(info, dayStart, dayEnd, module) {
return LocalSolarEclipseInfo{}, false
}
return info, true
}
// LastLocalSolarEclipse 上次站心日食 / previous local solar eclipse.
// Previous visible local solar eclipse, using NASA bulletin Split-K by default.
func LastLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return LastLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
func (module *Eclipse) LastLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return module.LastLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
}
// LastLocalSolarEclipseNASABulletinSplitK 上次站心日食(NASA bulletin Split-K) / previous local solar eclipse with NASA bulletin Split-K.
// Previous visible local solar eclipse with the NASA bulletin Split-K model.
func LastLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
func (module *Eclipse) LastLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseCalculatorForModel(SolarEclipseModelNASABulletinSplitK, module), localSolarEclipseQueryVisible, module)
return info
}
// LastLocalSolarEclipseIAUSingleK 上次站心日食(IAU Single-K) / previous local solar eclipse with IAU Single-K.
// Previous visible local solar eclipse with the IAU Single-K model.
func LastLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseIAUSingleK, localSolarEclipseQueryVisible)
func (module *Eclipse) LastLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseCalculatorForModel(SolarEclipseModelIAUSingleK, module), localSolarEclipseQueryVisible, module)
return info
}
// LastGeometricLocalSolarEclipse 上次站心几何日食 / previous geometric local solar eclipse.
// Previous geometric local solar eclipse, using NASA bulletin Split-K by default.
func LastGeometricLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return LastGeometricLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
func (module *Eclipse) LastGeometricLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return module.LastGeometricLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
}
// LastGeometricLocalSolarEclipseNASABulletinSplitK 上次站心几何日食(NASA bulletin Split-K) / previous geometric local solar eclipse with NASA bulletin Split-K.
// Previous geometric local solar eclipse with the NASA bulletin Split-K model.
func LastGeometricLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryGeometric)
func (module *Eclipse) LastGeometricLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseCalculatorForModel(SolarEclipseModelNASABulletinSplitK, module), localSolarEclipseQueryGeometric, module)
return info
}
// LastGeometricLocalSolarEclipseIAUSingleK 上次站心几何日食(IAU Single-K) / previous geometric local solar eclipse with IAU Single-K.
// Previous geometric local solar eclipse with the IAU Single-K model.
func LastGeometricLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseIAUSingleK, localSolarEclipseQueryGeometric)
func (module *Eclipse) LastGeometricLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseCalculatorForModel(SolarEclipseModelIAUSingleK, module), localSolarEclipseQueryGeometric, module)
return info
}
// LastLocalTotalSolarEclipse 上次站心日全食 / previous local total solar eclipse.
// Previous visible local total solar eclipse, using NASA bulletin Split-K by default.
func LastLocalTotalSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return searchLocalTotalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
func (module *Eclipse) LastLocalTotalSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return searchLocalTotalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseCalculatorForModel(SolarEclipseModelNASABulletinSplitK, module), localSolarEclipseQueryVisible, module)
}
// LastLocalAnnularSolarEclipse 上次站心日环食 / previous local annular solar eclipse.
// Previous visible local annular solar eclipse, using NASA bulletin Split-K by default.
func LastLocalAnnularSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return searchLocalAnnularSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
func (module *Eclipse) LastLocalAnnularSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return searchLocalAnnularSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseCalculatorForModel(SolarEclipseModelNASABulletinSplitK, module), localSolarEclipseQueryVisible, module)
}
// NextLocalSolarEclipse 下次站心日食 / next local solar eclipse.
// Next visible local solar eclipse, using NASA bulletin Split-K by default.
func NextLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return NextLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
func (module *Eclipse) NextLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return module.NextLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
}
// NextLocalSolarEclipseNASABulletinSplitK 下次站心日食(NASA bulletin Split-K) / next local solar eclipse with NASA bulletin Split-K.
// Next visible local solar eclipse with the NASA bulletin Split-K model.
func NextLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
func (module *Eclipse) NextLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseCalculatorForModel(SolarEclipseModelNASABulletinSplitK, module), localSolarEclipseQueryVisible, module)
return info
}
// NextLocalSolarEclipseIAUSingleK 下次站心日食(IAU Single-K) / next local solar eclipse with IAU Single-K.
// Next visible local solar eclipse with the IAU Single-K model.
func NextLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseIAUSingleK, localSolarEclipseQueryVisible)
func (module *Eclipse) NextLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseCalculatorForModel(SolarEclipseModelIAUSingleK, module), localSolarEclipseQueryVisible, module)
return info
}
// NextGeometricLocalSolarEclipse 下次站心几何日食 / next geometric local solar eclipse.
// Next geometric local solar eclipse, using NASA bulletin Split-K by default.
func NextGeometricLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return NextGeometricLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
func (module *Eclipse) NextGeometricLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return module.NextGeometricLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
}
// NextGeometricLocalSolarEclipseNASABulletinSplitK 下次站心几何日食(NASA bulletin Split-K) / next geometric local solar eclipse with NASA bulletin Split-K.
// Next geometric local solar eclipse with the NASA bulletin Split-K model.
func NextGeometricLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryGeometric)
func (module *Eclipse) NextGeometricLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseCalculatorForModel(SolarEclipseModelNASABulletinSplitK, module), localSolarEclipseQueryGeometric, module)
return info
}
// NextGeometricLocalSolarEclipseIAUSingleK 下次站心几何日食(IAU Single-K) / next geometric local solar eclipse with IAU Single-K.
// Next geometric local solar eclipse with the IAU Single-K model.
func NextGeometricLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseIAUSingleK, localSolarEclipseQueryGeometric)
func (module *Eclipse) NextGeometricLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
info, _ := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseCalculatorForModel(SolarEclipseModelIAUSingleK, module), localSolarEclipseQueryGeometric, module)
return info
}
// NextLocalTotalSolarEclipse 下次站心日全食 / next local total solar eclipse.
// Next visible local total solar eclipse, using NASA bulletin Split-K by default.
func NextLocalTotalSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return searchLocalTotalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
func (module *Eclipse) NextLocalTotalSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return searchLocalTotalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseCalculatorForModel(SolarEclipseModelNASABulletinSplitK, module), localSolarEclipseQueryVisible, module)
}
// NextLocalAnnularSolarEclipse 下次站心日环食 / next local annular solar eclipse.
// Next visible local annular solar eclipse, using NASA bulletin Split-K by default.
func NextLocalAnnularSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return searchLocalAnnularSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
func (module *Eclipse) NextLocalAnnularSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
return searchLocalAnnularSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseCalculatorForModel(SolarEclipseModelNASABulletinSplitK, module), localSolarEclipseQueryVisible, module)
}
// ClosestLocalSolarEclipse 最近一次站心日食 / closest local solar eclipse.
// Closest visible local solar eclipse, using NASA bulletin Split-K by default.
func ClosestLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return ClosestLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
func (module *Eclipse) ClosestLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return module.ClosestLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
}
// ClosestLocalSolarEclipseNASABulletinSplitK 最近一次站心日食(NASA bulletin Split-K) / closest local solar eclipse with NASA bulletin Split-K.
// Closest visible local solar eclipse with the NASA bulletin Split-K model.
func ClosestLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
last, hasLast := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
next, hasNext := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
func (module *Eclipse) ClosestLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
last, hasLast := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseCalculatorForModel(SolarEclipseModelNASABulletinSplitK, module), localSolarEclipseQueryVisible, module)
next, hasNext := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseCalculatorForModel(SolarEclipseModelNASABulletinSplitK, module), localSolarEclipseQueryVisible, module)
return closestLocalSolarEclipse(date, last, hasLast, next, hasNext)
}
// ClosestLocalSolarEclipseIAUSingleK 最近一次站心日食(IAU Single-K) / closest local solar eclipse with IAU Single-K.
// Closest visible local solar eclipse with the IAU Single-K model.
func ClosestLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
last, hasLast := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseIAUSingleK, localSolarEclipseQueryVisible)
next, hasNext := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseIAUSingleK, localSolarEclipseQueryVisible)
func (module *Eclipse) ClosestLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
last, hasLast := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseCalculatorForModel(SolarEclipseModelIAUSingleK, module), localSolarEclipseQueryVisible, module)
next, hasNext := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseCalculatorForModel(SolarEclipseModelIAUSingleK, module), localSolarEclipseQueryVisible, module)
return closestLocalSolarEclipse(date, last, hasLast, next, hasNext)
}
// ClosestGeometricLocalSolarEclipse 最近一次站心几何日食 / closest geometric local solar eclipse.
// Closest geometric local solar eclipse, using NASA bulletin Split-K by default.
func ClosestGeometricLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return ClosestGeometricLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
func (module *Eclipse) ClosestGeometricLocalSolarEclipse(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
return module.ClosestGeometricLocalSolarEclipseNASABulletinSplitK(date, lon, lat, height)
}
// ClosestGeometricLocalSolarEclipseNASABulletinSplitK 最近一次站心几何日食(NASA bulletin Split-K) / closest geometric local solar eclipse with NASA bulletin Split-K.
// Closest geometric local solar eclipse with the NASA bulletin Split-K model.
func ClosestGeometricLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
last, hasLast := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryGeometric)
next, hasNext := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryGeometric)
func (module *Eclipse) ClosestGeometricLocalSolarEclipseNASABulletinSplitK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
last, hasLast := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseCalculatorForModel(SolarEclipseModelNASABulletinSplitK, module), localSolarEclipseQueryGeometric, module)
next, hasNext := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseCalculatorForModel(SolarEclipseModelNASABulletinSplitK, module), localSolarEclipseQueryGeometric, module)
return closestLocalSolarEclipse(date, last, hasLast, next, hasNext)
}
// ClosestGeometricLocalSolarEclipseIAUSingleK 最近一次站心几何日食(IAU Single-K) / closest geometric local solar eclipse with IAU Single-K.
// Closest geometric local solar eclipse with the IAU Single-K model.
func ClosestGeometricLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
last, hasLast := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseIAUSingleK, localSolarEclipseQueryGeometric)
next, hasNext := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseIAUSingleK, localSolarEclipseQueryGeometric)
func (module *Eclipse) ClosestGeometricLocalSolarEclipseIAUSingleK(date time.Time, lon, lat, height float64) LocalSolarEclipseInfo {
last, hasLast := searchLocalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseCalculatorForModel(SolarEclipseModelIAUSingleK, module), localSolarEclipseQueryGeometric, module)
next, hasNext := searchLocalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseCalculatorForModel(SolarEclipseModelIAUSingleK, module), localSolarEclipseQueryGeometric, module)
return closestLocalSolarEclipse(date, last, hasLast, next, hasNext)
}
// ClosestLocalTotalSolarEclipse 最近一次站心日全食 / closest local total solar eclipse.
// Closest visible local total solar eclipse, using NASA bulletin Split-K by default.
func ClosestLocalTotalSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
last, hasLast := searchLocalTotalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
next, hasNext := searchLocalTotalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
func (module *Eclipse) ClosestLocalTotalSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
last, hasLast := searchLocalTotalSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseCalculatorForModel(SolarEclipseModelNASABulletinSplitK, module), localSolarEclipseQueryVisible, module)
next, hasNext := searchLocalTotalSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseCalculatorForModel(SolarEclipseModelNASABulletinSplitK, module), localSolarEclipseQueryVisible, module)
return closestLocalSolarEclipseResult(date, last, hasLast, next, hasNext)
}
// ClosestLocalAnnularSolarEclipse 最近一次站心日环食 / closest local annular solar eclipse.
// Closest visible local annular solar eclipse, using NASA bulletin Split-K by default.
func ClosestLocalAnnularSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
last, hasLast := searchLocalAnnularSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
next, hasNext := searchLocalAnnularSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseNASABulletinSplitK, localSolarEclipseQueryVisible)
func (module *Eclipse) ClosestLocalAnnularSolarEclipse(date time.Time, lon, lat, height float64) (LocalSolarEclipseInfo, bool) {
last, hasLast := searchLocalAnnularSolarEclipse(date, lon, lat, height, -1, true, localSolarEclipseCalculatorForModel(SolarEclipseModelNASABulletinSplitK, module), localSolarEclipseQueryVisible, module)
next, hasNext := searchLocalAnnularSolarEclipse(date, lon, lat, height, 1, false, localSolarEclipseCalculatorForModel(SolarEclipseModelNASABulletinSplitK, module), localSolarEclipseQueryVisible, module)
return closestLocalSolarEclipseResult(date, last, hasLast, next, hasNext)
}
@@ -412,13 +402,17 @@ func searchLocalSolarEclipse(
direction int,
includeCurrent bool,
calculator localSolarEclipseCalculator,
mode localSolarEclipseQueryMode,
) (LocalSolarEclipseInfo, bool) {
mode localSolarEclipseQueryMode, modules ...*Eclipse) (LocalSolarEclipseInfo, bool) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
info, _, ok := searchLocalCentralSolarEclipse(
solarEclipseTimeToTTJDE(date),
solarEclipseTimeToTTJDE(date)+float64(direction)*localSolarEclipseDefaultSearchYears*365.25,
direction, includeCurrent, SolarEclipseLocalSearchAny, lon, lat, height, date.Location(),
calculator, mode,
calculator, mode, module,
)
return info, ok
}
@@ -429,13 +423,17 @@ func searchLocalTotalSolarEclipse(
direction int,
includeCurrent bool,
calculator localSolarEclipseCalculator,
mode localSolarEclipseQueryMode,
) (LocalSolarEclipseInfo, bool) {
mode localSolarEclipseQueryMode, modules ...*Eclipse) (LocalSolarEclipseInfo, bool) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
info, _, ok := searchLocalCentralSolarEclipse(
solarEclipseTimeToTTJDE(date),
solarEclipseTimeToTTJDE(date)+float64(direction)*localSolarEclipseDefaultSearchYears*365.25,
direction, includeCurrent, SolarEclipseLocalSearchTotal, lon, lat, height, date.Location(),
calculator, mode,
calculator, mode, module,
)
return info, ok
}
@@ -446,19 +444,28 @@ func searchLocalAnnularSolarEclipse(
direction int,
includeCurrent bool,
calculator localSolarEclipseCalculator,
mode localSolarEclipseQueryMode,
) (LocalSolarEclipseInfo, bool) {
mode localSolarEclipseQueryMode, modules ...*Eclipse) (LocalSolarEclipseInfo, bool) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
info, _, ok := searchLocalCentralSolarEclipse(
solarEclipseTimeToTTJDE(date),
solarEclipseTimeToTTJDE(date)+float64(direction)*localSolarEclipseDefaultSearchYears*365.25,
direction, includeCurrent, SolarEclipseLocalSearchAnnular, lon, lat, height, date.Location(),
calculator, mode,
calculator, mode, module,
)
return info, ok
}
func isPotentialLocalSolarEclipse(newMoonTT float64) bool {
return math.Abs(basic.HMoonTrueBo(newMoonTT)) <= localSolarEclipseLatitudeLimitDeg
func isPotentialLocalSolarEclipse(newMoonTT float64, modules ...*Eclipse) bool {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
return math.Abs(basic.HMoonTrueBoWithInstance(newMoonTT, module.inst)) <= localSolarEclipseLatitudeLimitDeg
}
func localSolarEclipseMatchesDirection(greatestTT, targetTT float64, direction int, includeCurrent bool) bool {
@@ -475,10 +482,14 @@ func localSolarEclipseMatchesDirection(greatestTT, targetTT float64, direction i
func localSolarEclipseInfoFromBasic(
result basic.LocalSolarEclipseResult,
lon, lat, height float64,
location *time.Location,
) LocalSolarEclipseInfo {
info := localSolarEclipseInfoFieldsFromBasic(result, lon, lat, height, location)
info.ContactPoints = localSolarEclipseContactPointsFromBasic(result, lon, lat, height, location)
location *time.Location, limb basic.LunarLimb, modules ...*Eclipse) LocalSolarEclipseInfo {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
info := localSolarEclipseInfoFieldsFromBasic(result, lon, lat, height, location, module)
info.ContactPoints = localSolarEclipseContactPointsFromBasic(result, lon, lat, height, location, limb, module)
return info
}
@@ -495,12 +506,17 @@ func localSolarEclipseInfoFromDiagram(
func localSolarEclipseInfoFieldsFromBasic(
result basic.LocalSolarEclipseResult,
lon, lat, height float64,
location *time.Location,
) LocalSolarEclipseInfo {
location *time.Location, modules ...*Eclipse) LocalSolarEclipseInfo {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
visibleThreshold := localSolarEclipseVisibilityThreshold(height, lat)
saros, hasSaros := solarSarosInfo(result.GreatestEclipse)
saros, hasSaros := solarSarosInfo(result.GreatestEclipse, module)
return LocalSolarEclipseInfo{
Model: mapBasicSolarEclipseModel(result.Model),
LunarLimbApplied: result.LunarLimbApplied,
SunRadiusModel: result.SunRadiusModel,
Type: mapBasicSolarEclipseType(result.Type),
HasSaros: hasSaros,
@@ -531,17 +547,21 @@ func localSolarEclipseInfoFieldsFromBasic(
func localSolarEclipseContactPointsFromBasic(
result basic.LocalSolarEclipseResult,
lon, lat, height float64,
location *time.Location,
) []LocalSolarEclipseContactPoint {
location *time.Location, limb basic.LunarLimb, modules ...*Eclipse) []LocalSolarEclipseContactPoint {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
if !result.HasPartial {
return nil
}
options := basic.LocalSolarEclipseDiagramOptions{StepDays: 1, SunRadiusModel: result.SunRadiusModel}
options := basic.LocalSolarEclipseDiagramOptions{StepDays: 1, SunRadiusModel: result.SunRadiusModel, LunarLimb: limb}
var diagram basic.LocalSolarEclipseDiagramResult
if result.Model == basic.SolarEclipseModelIAUSingleK {
diagram = basic.LocalSolarEclipseDiagramIAUSingleK(result.GreatestEclipse, lon, lat, height, options)
diagram = basic.LocalSolarEclipseDiagramIAUSingleKWithInstance(result.GreatestEclipse, lon, lat, height, options, module.inst)
} else {
diagram = basic.LocalSolarEclipseDiagramNASABulletinSplitK(result.GreatestEclipse, lon, lat, height, options)
diagram = basic.LocalSolarEclipseDiagramNASABulletinSplitKWithInstance(result.GreatestEclipse, lon, lat, height, options, module.inst)
}
return localSolarEclipseContactPointsFromFrames(diagram.Frames, location)
}
@@ -597,22 +617,37 @@ func localSolarEclipseRange(info LocalSolarEclipseInfo) (time.Time, time.Time, b
return info.PartialStart, info.PartialEnd, true
}
func localSolarEclipseVisible(info LocalSolarEclipseInfo) bool {
func localSolarEclipseVisible(info LocalSolarEclipseInfo, modules ...*Eclipse) bool {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
eventStart, eventEnd, ok := localSolarEclipseRange(info)
if !ok {
return false
}
return localSolarEclipseVisibleDuring(info, eventStart, eventEnd)
return localSolarEclipseVisibleDuring(info, eventStart, eventEnd, module)
}
func localCentralSolarEclipseVisible(info LocalSolarEclipseInfo) bool {
func localCentralSolarEclipseVisible(info LocalSolarEclipseInfo, modules ...*Eclipse) bool {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
if !info.HasCentral || info.CentralStart.IsZero() || info.CentralEnd.IsZero() {
return false
}
return localSolarEclipseVisibleDuring(info, info.CentralStart, info.CentralEnd)
return localSolarEclipseVisibleDuring(info, info.CentralStart, info.CentralEnd, module)
}
func localSolarEclipseVisibleOnDate(info LocalSolarEclipseInfo, dayStart, dayEnd time.Time) bool {
func localSolarEclipseVisibleOnDate(info LocalSolarEclipseInfo, dayStart, dayEnd time.Time, modules ...*Eclipse) bool {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
eventStart, eventEnd, ok := localSolarEclipseRange(info)
if !ok {
return false
@@ -622,25 +657,30 @@ func localSolarEclipseVisibleOnDate(info LocalSolarEclipseInfo, dayStart, dayEnd
if !segmentStart.Before(segmentEnd) {
return false
}
return localSolarEclipseVisibleDuring(info, segmentStart, segmentEnd)
return localSolarEclipseVisibleDuring(info, segmentStart, segmentEnd, module)
}
func localSolarEclipseVisibleDuring(info LocalSolarEclipseInfo, start, end time.Time) bool {
func localSolarEclipseVisibleDuring(info LocalSolarEclipseInfo, start, end time.Time, modules ...*Eclipse) bool {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
if !start.Before(end) && !start.Equal(end) {
return false
}
if localSolarEclipseAltitudeVisible(start, info) || localSolarEclipseAltitudeVisible(end, info) {
if localSolarEclipseAltitudeVisible(start, info, module) || localSolarEclipseAltitudeVisible(end, info, module) {
return true
}
for dayStart, _, _ := solarEclipseLocalDayBounds(start); !dayStart.After(end); dayStart = nextSolarEclipseLocalDayStart(dayStart) {
_, culminationSeed, _ := solarEclipseLocalDayBounds(dayStart)
culmination := solarCulminationTime(culminationSeed, info.Longitude)
culmination := solarCulminationTime(culminationSeed, info.Longitude, module)
if culmination.Before(start) || culmination.After(end) {
continue
}
if localSolarEclipseAltitudeVisible(culmination, info) {
if localSolarEclipseAltitudeVisible(culmination, info, module) {
return true
}
}
@@ -648,8 +688,13 @@ func localSolarEclipseVisibleDuring(info LocalSolarEclipseInfo, start, end time.
return false
}
func localSolarEclipseAltitudeVisible(date time.Time, info LocalSolarEclipseInfo) bool {
return solarAltitude(date, info.Longitude, info.Latitude) > localSolarEclipseVisibilityThreshold(info.Height, info.Latitude)
func localSolarEclipseAltitudeVisible(date time.Time, info LocalSolarEclipseInfo, modules ...*Eclipse) bool {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
return solarAltitude(date, info.Longitude, info.Latitude, module) > localSolarEclipseVisibilityThreshold(info.Height, info.Latitude)
}
func localSolarEclipseVisibilityThreshold(height, latitude float64) float64 {
@@ -680,14 +725,3 @@ func minLocalSolarTime(a, b time.Time) time.Time {
}
return b
}
var (
localSolarEclipseNASABulletinSplitK = localSolarEclipseCalculator{
global: basic.SolarEclipseNASABulletinSplitK,
local: basic.LocalSolarEclipseNASABulletinSplitK,
}
localSolarEclipseIAUSingleK = localSolarEclipseCalculator{
global: basic.SolarEclipseIAUSingleK,
local: basic.LocalSolarEclipseIAUSingleK,
}
)
+27 -20
View File
@@ -52,18 +52,18 @@ type SolarEclipseLocalSearchStatus struct {
}
const (
// 旧口径为 6000 次候选步进,实测覆盖 992.33~992.47 年;取 992.5 年完整覆盖旧跨度。
// 默认搜索跨度 992.5 年,完整覆盖旧候选步进口径。
localSolarEclipseDefaultSearchYears = 992.5
// 时间跨度搜索的迭代硬上限,防止病态输入死循环。
localSolarEclipseSearchIterationCap = 200000
)
// SearchLocalCentralSolarEclipse 在给定跨度内搜索站心食并回报是否用尽跨度 / station search within a horizon.
func SearchLocalCentralSolarEclipse(
func (module *Eclipse) SearchLocalCentralSolarEclipse(
date time.Time, lon, lat, height float64,
options SolarEclipseLocalSearchOptions,
) (LocalSolarEclipseInfo, SolarEclipseLocalSearchStatus) {
calculator := localSolarEclipseCalculatorForModel(options.Model)
calculator := localSolarEclipseCalculatorForModel(options.Model, module)
mode := localSolarEclipseQueryVisible
if options.Geometric {
mode = localSolarEclipseQueryGeometric
@@ -80,18 +80,16 @@ func SearchLocalCentralSolarEclipse(
info, status, ok := searchLocalCentralSolarEclipse(
targetTT, targetTT+float64(direction)*maxYears*365.25, direction,
options.IncludeCurrent, options.Kind, lon, lat, height, date.Location(),
calculator, mode,
calculator, mode, module,
)
status.Found = ok
status.Exhausted = !ok && !status.Capped
return info, status
}
func localSolarEclipseCalculatorForModel(model SolarEclipseRadiusModel) localSolarEclipseCalculator {
if model == SolarEclipseModelIAUSingleK {
return localSolarEclipseIAUSingleK
}
return localSolarEclipseNASABulletinSplitK
// localSolarEclipseCalculatorForModel 构造绑定模块星历实例的计算器,非 IAU Single-K 一律按 Split-K。
func localSolarEclipseCalculatorForModel(model SolarEclipseRadiusModel, module *Eclipse) localSolarEclipseCalculator {
return localSolarEclipseCalculatorFor(SolarEclipseOptions{RadiusModel: model}, module)
}
func searchLocalCentralSolarEclipse(
@@ -102,10 +100,14 @@ func searchLocalCentralSolarEclipse(
lon, lat, height float64,
location *time.Location,
calculator localSolarEclipseCalculator,
mode localSolarEclipseQueryMode,
) (LocalSolarEclipseInfo, SolarEclipseLocalSearchStatus, bool) {
mode localSolarEclipseQueryMode, modules ...*Eclipse) (LocalSolarEclipseInfo, SolarEclipseLocalSearchStatus, bool) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
status := SolarEclipseLocalSearchStatus{}
candidateTT := basic.CalcMoonSHByJDE(targetTT, 0)
candidateTT := basic.CalcMoonSHByJDEWithInstance(targetTT, 0, module.inst)
capped := true
for iteration := 0; iteration < localSolarEclipseSearchIterationCap; iteration++ {
if direction > 0 && candidateTT > horizonTT {
@@ -118,7 +120,7 @@ func searchLocalCentralSolarEclipse(
}
status.MonthsScanned++
status.YearsScanned = math.Abs(candidateTT-targetTT) / 365.25
if isPotentialLocalSolarEclipse(candidateTT) {
if isPotentialLocalSolarEclipse(candidateTT, module) {
globalResult := calculator.global(candidateTT)
status.GlobalChecks++
if globalResult.Type != basic.SolarEclipseNone {
@@ -126,8 +128,8 @@ func searchLocalCentralSolarEclipse(
result := calculator.local(globalResult.GreatestEclipse, lon, lat, height)
status.LocalChecks++
if localSolarEclipseKindMatchesLocal(kind, result) {
info := localSolarEclipseInfoFromBasic(result, lon, lat, height, location)
if (mode != localSolarEclipseQueryVisible || localSolarEclipseKindVisible(kind, info)) &&
info := localSolarEclipseInfoFromBasic(result, lon, lat, height, location, calculator.limb, module)
if (mode != localSolarEclipseQueryVisible || localSolarEclipseKindVisible(kind, info, module)) &&
localSolarEclipseMatchesDirection(result.GreatestEclipse, targetTT, direction, includeCurrent) {
return info, status, true
}
@@ -135,7 +137,7 @@ func searchLocalCentralSolarEclipse(
}
}
}
candidateTT = nextEclipseSearchCandidateTT(candidateTT, 0, direction, localSolarEclipseSynodicMonthDays)
candidateTT = nextEclipseSearchCandidateTT(candidateTT, 0, direction, localSolarEclipseSynodicMonthDays, module)
}
// 循环自然结束说明撞上了迭代硬上限。
status.Capped = capped
@@ -162,9 +164,14 @@ func localSolarEclipseKindMatchesLocal(kind SolarEclipseLocalSearchKind, result
return result.Type != basic.SolarEclipseNone
}
func localSolarEclipseKindVisible(kind SolarEclipseLocalSearchKind, info LocalSolarEclipseInfo) bool {
if kind == SolarEclipseLocalSearchAny {
return localSolarEclipseVisible(info)
func localSolarEclipseKindVisible(kind SolarEclipseLocalSearchKind, info LocalSolarEclipseInfo, modules ...*Eclipse) bool {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
return localCentralSolarEclipseVisible(info)
if kind == SolarEclipseLocalSearchAny {
return localSolarEclipseVisible(info, module)
}
return localCentralSolarEclipseVisible(info, module)
}
+4
View File
@@ -3,6 +3,8 @@ package eclipse
import (
"testing"
"time"
"b612.me/astro/internal/ephemtest"
)
func TestSearchLocalCentralSolarEclipseReportsExhaustion(t *testing.T) {
@@ -155,6 +157,8 @@ func TestSolarEclipseCandidatesRespectRequestedRange(t *testing.T) {
}
func TestSolarEclipseCandidatesCentralOnlyUsesGlobalType(t *testing.T) {
// 夹具按旧模型几何选取,用来覆盖该边界分支。
ephemtest.PinLegacy(t)
start := time.Date(-911, time.January, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(-908, time.January, 1, 0, 0, 0, 0, time.UTC)
all := SolarEclipseCandidates(start, end, SolarEclipseCandidateOptions{})
+7 -4
View File
@@ -5,6 +5,8 @@ import (
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/ephemtest"
)
func TestLocalSolarEclipseOnDateByLocalDay(t *testing.T) {
@@ -284,6 +286,7 @@ func TestLocalSolarEclipseInfoKeepsLocation(t *testing.T) {
}
func TestLocalSolarEclipseContactPoints(t *testing.T) {
values := ephemtest.NewValues(t, "local_solar_contact_points")
loc := time.FixedZone("UTC+08", 8*3600)
info := ClosestLocalSolarEclipse(
time.Date(2024, 4, 8, 12, 0, 0, 0, loc),
@@ -302,10 +305,10 @@ func TestLocalSolarEclipseContactPoints(t *testing.T) {
for _, point := range info.ContactPoints {
points[point.Label] = point
}
assertSolarFloatClose(t, "C1.ContactPositionAngle", points["C1"].ContactPositionAngle, 226.219228, 1e-3)
assertSolarFloatClose(t, "C2.ContactPositionAngle", points["C2"].ContactPositionAngle, 19.133602, 1e-3)
assertSolarFloatClose(t, "C2.MoonCenterPositionAngle", points["C2"].MoonCenterPositionAngle, 199.133602, 1e-3)
assertSolarFloatClose(t, "C4.ContactClockwiseAngle", points["C4"].ContactClockwiseAngle, 310.781438, 1e-3)
assertSolarFloatClose(t, "C1.ContactPositionAngle", points["C1"].ContactPositionAngle, values.Want("C1.ContactPositionAngle", 226.219228, points["C1"].ContactPositionAngle), 1e-3)
assertSolarFloatClose(t, "C2.ContactPositionAngle", points["C2"].ContactPositionAngle, values.Want("C2.ContactPositionAngle", 19.133602, points["C2"].ContactPositionAngle), 1e-3)
assertSolarFloatClose(t, "C2.MoonCenterPositionAngle", points["C2"].MoonCenterPositionAngle, values.Want("C2.MoonCenterPositionAngle", 199.133602, points["C2"].MoonCenterPositionAngle), 1e-3)
assertSolarFloatClose(t, "C4.ContactClockwiseAngle", points["C4"].ContactClockwiseAngle, values.Want("C4.ContactClockwiseAngle", 310.781438, points["C4"].ContactClockwiseAngle), 1e-3)
}
func TestLocalSolarEclipseContactPointsFromMergedFrameLabels(t *testing.T) {
+42 -29
View File
@@ -5,6 +5,7 @@ import (
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/geodata"
)
const (
@@ -13,8 +14,8 @@ const (
// brownLunationSynodicMonth 是平均朔望月长度,用于把朔的儒略日换算成月序数。
brownLunationSynodicMonth = 29.530588853
// 视半径换算地平视差用的半径比:地球赤道半径除以天体半径。
horizontalParallaxMoonRatio = 6378.137 / 1737.4
horizontalParallaxSunRatio = 6378.137 / 696000.0
horizontalParallaxMoonRatio = geodata.EarthWGS84RadiusKM / 1737.4
horizontalParallaxSunRatio = geodata.EarthWGS84RadiusKM / 696000.0
)
// SolarEclipseGeocentricPanel 详细面板所需的食甚时刻地心量 / geocentric quantities at greatest eclipse for detailed panels.
@@ -70,26 +71,23 @@ func horizontalParallaxArcsec(semidiameterArcsec, radiusRatio float64) float64 {
return math.Asin(math.Max(-1, math.Min(1, sine*radiusRatio))) * 180 / math.Pi * 3600
}
// SolarEclipseGeocentricPanelAt 计算给定日食在食甚时刻的地心量面板。
// SolarEclipseGeocentricPanelAt computes the geocentric panel of one eclipse at greatest eclipse.
func SolarEclipseGeocentricPanelAt(date time.Time) (SolarEclipseGeocentricPanel, bool) {
return SolarEclipseGeocentricPanelWithOptions(date, SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK})
// SolarEclipseGeocentricPanelAt 计算给定日食在食甚时刻的地心量面板 / geocentric solar eclipse panel at greatest eclipse.
func (module *Eclipse) SolarEclipseGeocentricPanelAt(date time.Time) (SolarEclipseGeocentricPanel, bool) {
return module.SolarEclipseGeocentricPanelWithOptions(date, SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK})
}
// SolarEclipseGeocentricPanelIAUSingleK 使用 IAU Single-K 计算地心量面板。
// SolarEclipseGeocentricPanelIAUSingleK computes the geocentric panel with the IAU Single-K model.
func SolarEclipseGeocentricPanelIAUSingleK(date time.Time) (SolarEclipseGeocentricPanel, bool) {
return SolarEclipseGeocentricPanelWithOptions(date, SolarEclipseOptions{RadiusModel: SolarEclipseModelIAUSingleK})
// SolarEclipseGeocentricPanelIAUSingleK 使用 IAU Single-K 计算地心量面板 / geocentric solar eclipse panel with IAU Single-K.
func (module *Eclipse) SolarEclipseGeocentricPanelIAUSingleK(date time.Time) (SolarEclipseGeocentricPanel, bool) {
return module.SolarEclipseGeocentricPanelWithOptions(date, SolarEclipseOptions{RadiusModel: SolarEclipseModelIAUSingleK})
}
// SolarEclipseGeocentricPanelWithOptions 使用自定义半径口径计算食甚时刻的地心量面板。
// SolarEclipseGeocentricPanelWithOptions computes the geocentric panel at greatest eclipse with custom radius conventions.
func SolarEclipseGeocentricPanelWithOptions(date time.Time, options SolarEclipseOptions) (SolarEclipseGeocentricPanel, bool) {
info, ok := SolarEclipseOnDateWithOptions(date, options)
// SolarEclipseGeocentricPanelWithOptions 使用自定义半径口径计算食甚时刻的地心量面板 / geocentric solar eclipse panel with custom radius options.
func (module *Eclipse) SolarEclipseGeocentricPanelWithOptions(date time.Time, options SolarEclipseOptions) (SolarEclipseGeocentricPanel, bool) {
info, ok := module.SolarEclipseOnDateWithOptions(date, options)
if !ok {
return SolarEclipseGeocentricPanel{}, false
}
panel := solarEclipseGeocentricPanelAt(info)
panel := solarEclipseGeocentricPanelAt(info, module)
if info.Model == SolarEclipseModelIAUSingleK {
panel.Ephemeris = "IAU Single-K"
panel.PenumbralK = basic.SolarEclipseIAUSingleRadiusK
@@ -103,10 +101,15 @@ func SolarEclipseGeocentricPanelWithOptions(date time.Time, options SolarEclipse
return panel, true
}
func solarEclipseGeocentricPanelAt(info SolarEclipseInfo) SolarEclipseGeocentricPanel {
func solarEclipseGeocentricPanelAt(info SolarEclipseInfo, modules ...*Eclipse) SolarEclipseGeocentricPanel {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
tt := solarEclipseTimeToTTJDE(info.GreatestEclipse)
conjunctionJDE := basic.CalcMoonSHByJDE(tt, 0)
rightAscensionJDE := solarEclipseRightAscensionConjunction(tt)
conjunctionJDE := basic.CalcMoonSHByJDEWithInstance(tt, 0, module.inst)
rightAscensionJDE := solarEclipseRightAscensionConjunction(tt, module)
panel := SolarEclipseGeocentricPanel{
Conjunction: solarEclipseTTJDEToTime(conjunctionJDE, info.GreatestEclipse.Location()),
ConjunctionJDE: conjunctionJDE,
@@ -115,14 +118,14 @@ func solarEclipseGeocentricPanelAt(info SolarEclipseInfo) SolarEclipseGeocentric
RightAscensionConjunctionJD: rightAscensionJDE - basic.DeltaT(tt, true)/86400,
DeltaTSeconds: basic.DeltaT(tt, true),
}
panel.SunRightAscensionDeg, panel.SunDeclinationDeg = basic.SunApparentRaDec(tt)
panel.MoonRightAscensionDeg, panel.MoonDeclinationDeg = basic.HMoonTrueRaDec(tt)
panel.SunRightAscensionDeg, panel.SunDeclinationDeg = basic.SunApparentRaDecWithInstance(tt, module.inst)
panel.MoonRightAscensionDeg, panel.MoonDeclinationDeg = basic.HMoonTrueRaDecWithInstance(tt, module.inst)
panel.SunRadiusModel = info.SunRadiusModel
panel.SunSemidiameterArcsec = basic.SolarEclipseSunSemidiameter(tt, info.SunRadiusModel)
panel.MoonSemidiameterArcsec = basic.MoonSemidiameter(tt)
panel.SunSemidiameterArcsec = basic.SolarEclipseSunSemidiameterWithInstance(tt, info.SunRadiusModel, module.inst)
panel.MoonSemidiameterArcsec = basic.MoonSemidiameterWithInstance(tt, module.inst)
panel.SunParallaxArcsec = horizontalParallaxArcsec(panel.SunSemidiameterArcsec, horizontalParallaxSunRatio)
panel.MoonParallaxArcsec = horizontalParallaxArcsec(panel.MoonSemidiameterArcsec, horizontalParallaxMoonRatio)
physical := basic.MoonPhysical(tt)
physical := basic.MoonPhysicalWithInstance(tt, module.inst)
panel.LibrationLongitudeDeg = physical.LibrationLongitude
panel.LibrationLatitudeDeg = physical.LibrationLatitude
panel.LibrationPositionAngleDeg = physical.PositionAngle
@@ -131,22 +134,32 @@ func solarEclipseGeocentricPanelAt(info SolarEclipseInfo) SolarEclipseGeocentric
}
// solarEclipseRightAscensionGap 返回月亮与太阳的地心视赤经差,单位弧度。
func solarEclipseRightAscensionGap(tt float64) float64 {
sunRightAscension, _ := basic.SunApparentRaDec(tt)
moonRightAscension, _ := basic.HMoonTrueRaDec(tt)
func solarEclipseRightAscensionGap(tt float64, modules ...*Eclipse) float64 {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
sunRightAscension, _ := basic.SunApparentRaDecWithInstance(tt, module.inst)
moonRightAscension, _ := basic.HMoonTrueRaDecWithInstance(tt, module.inst)
return math.Remainder(moonRightAscension-sunRightAscension, 360) * math.Pi / 180
}
// solarEclipseRightAscensionConjunction 求地心视赤经相等的时刻。
// 赤经差在合附近单调,用牛顿法从食甚时刻收敛即可,不需要预先求朔。
func solarEclipseRightAscensionConjunction(tt float64) float64 {
func solarEclipseRightAscensionConjunction(tt float64, modules ...*Eclipse) float64 {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
estimate := tt
for iteration := 0; iteration < 40; iteration++ {
value := solarEclipseRightAscensionGap(estimate)
value := solarEclipseRightAscensionGap(estimate, module)
if math.Abs(value) < 1e-10 {
break
}
derivative := (solarEclipseRightAscensionGap(estimate+1e-5) - solarEclipseRightAscensionGap(estimate-1e-5)) / 2e-5
derivative := (solarEclipseRightAscensionGap(estimate+1e-5, module) - solarEclipseRightAscensionGap(estimate-1e-5, module)) / 2e-5
if derivative == 0 || math.IsNaN(derivative) || math.IsInf(derivative, 0) {
break
}
+71 -43
View File
@@ -21,6 +21,8 @@ type SolarEclipsePathOptions struct {
// SkipCentralBand reports that the caller already holds the full-footprint
// result for the same eclipse, so the central-band envelope is not rebuilt.
SkipCentralBand bool
// LunarLimb 月缘剖面,nil 沿用模块设置;修正南北界、带宽、中心带足迹、最长中心食时长与全球始末,中心线不变 / lunar limb profile for limits, widths, band, longest duration and P1/P4.
LunarLimb LunarLimb
}
// SolarEclipsePathPoint 表示日食路径上的一个地理点。
@@ -73,6 +75,8 @@ type SolarEclipsePath struct {
// CentralBandSampled reports that the envelope was reconstructed from sampled
// instantaneous footprints rather than an analytic envelope.
CentralBandSampled bool
// CentralLimitsLimbApplied 南北界至少一点按月缘剖面平移,带宽随之修正 / the central limits were moved by the lunar limb.
CentralLimitsLimbApplied bool
// Step 是实际采用的基础时间采样步长, effective base time step.
Step time.Duration
// TargetSpacingKM 是实际采用的目标空间采样距离,单位千米。
@@ -113,6 +117,10 @@ type SolarEclipsePartialFootprintOptions struct {
// and non-positive values disable the isolines. Levels align to UTC ticks; display layers that
// need the viewing timezone grid should generate the instants themselves.
GreatestTimeStep time.Duration
// LunarLimb 月缘剖面,nil 沿用模块设置;修正偏食可见域外边界(零食分包络与初亏/复圆升落曲线),
// 内嵌 Eclipse 与 P1/P4 也与 SolarEclipseOnDate 同源地修正;食分线与等时线仍按圆月面 /
// lunar limb profile for the outer partial boundary, the embedded Eclipse and P1/P4.
LunarLimb LunarLimb
}
// SolarEclipsePartialAreaOptions 是 SolarEclipsePartialFootprintOptions 的兼容别名。
@@ -176,6 +184,8 @@ type SolarEclipsePartialFootprintsInfo struct {
// RiseSetCurves 是初亏、食甚和复圆分别发生在日出或日落时的六类边界。
// RiseSetCurves are the six boundaries where local start, greatest, or end occurs at sunrise or sunset.
RiseSetCurves []SolarEclipseRiseSetCurve
// PartialBoundaryLimbApplied 偏食可见域外边界已按月缘剖面平移 / the outer partial boundary was moved by the lunar limb.
PartialBoundaryLimbApplied bool
// P1-P4 是半影与地球的外切/内切接触点;不存在的内切点保持零值。
// P1-P4 are external/internal penumbral contacts; absent internal contacts remain zero.
P1 SolarEclipsePathPoint
@@ -272,73 +282,80 @@ type SolarEclipsePartialAreaInfo = SolarEclipsePartialFootprintsInfo
type solarEclipsePathCalculator func(float64, basic.SolarEclipsePathOptions) basic.SolarEclipsePathResult
type solarEclipsePartialFootprintsCalculator func(float64, basic.SolarEclipsePartialFootprintOptions) basic.SolarEclipsePartialFootprintsResult
// SolarEclipseCentralPath 计算指定日期附近的日食中心路径,默认使用 NASA bulletin Split-K 模型。
// SolarEclipseCentralPath computes the central path near the given date, using NASA bulletin Split-K by default.
func SolarEclipseCentralPath(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
return SolarEclipseCentralPathNASABulletinSplitK(date, options)
// SolarEclipseCentralPath 计算指定日期附近的日食中心路径,默认使用 NASA bulletin Split-K 模型 / solar eclipse central path, NASA bulletin Split-K by default.
func (module *Eclipse) SolarEclipseCentralPath(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
return module.SolarEclipseCentralPathNASABulletinSplitK(date, options)
}
// SolarEclipseCentralPathNASABulletinSplitK 使用 NASA bulletin Split-K 模型计算日食中心路径。
// SolarEclipseCentralPathNASABulletinSplitK computes the central path with the NASA bulletin Split-K model.
func SolarEclipseCentralPathNASABulletinSplitK(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
return solarEclipseCentralPath(date, options, basic.SolarEclipseCentralPathNASABulletinSplitK)
// SolarEclipseCentralPathNASABulletinSplitK 使用 NASA bulletin Split-K 模型计算日食中心路径 / solar eclipse central path with NASA bulletin Split-K.
func (module *Eclipse) SolarEclipseCentralPathNASABulletinSplitK(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
return solarEclipseCentralPath(date, options, func(seedJDE float64, options basic.SolarEclipsePathOptions) basic.SolarEclipsePathResult {
return basic.SolarEclipseCentralPathNASABulletinSplitKWithInstance(seedJDE, options, module.inst)
}, module)
}
// SolarEclipseCentralPathIAUSingleK 使用 IAU Single-K 模型计算日食中心路径。
// SolarEclipseCentralPathIAUSingleK computes the central path with the IAU Single-K model.
func SolarEclipseCentralPathIAUSingleK(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
return solarEclipseCentralPath(date, options, basic.SolarEclipseCentralPathIAUSingleK)
// SolarEclipseCentralPathIAUSingleK 使用 IAU Single-K 模型计算日食中心路径 / solar eclipse central path with IAU Single-K.
func (module *Eclipse) SolarEclipseCentralPathIAUSingleK(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
return solarEclipseCentralPath(date, options, func(seedJDE float64, options basic.SolarEclipsePathOptions) basic.SolarEclipsePathResult {
return basic.SolarEclipseCentralPathIAUSingleKWithInstance(seedJDE, options, module.inst)
}, module)
}
// SolarEclipsePartialFootprints 计算指定日期附近的日食半影足迹,默认使用 NASA bulletin Split-K 模型。
// SolarEclipsePartialFootprints computes penumbral footprint samples near the given date, using NASA bulletin Split-K by default.
func SolarEclipsePartialFootprints(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
return SolarEclipsePartialFootprintsNASABulletinSplitK(date, options)
// SolarEclipsePartialFootprints 计算指定日期附近的日食半影足迹,默认使用 NASA bulletin Split-K 模型 / solar eclipse penumbral footprints, NASA bulletin Split-K by default.
func (module *Eclipse) SolarEclipsePartialFootprints(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
return module.SolarEclipsePartialFootprintsNASABulletinSplitK(date, options)
}
// SolarEclipsePartialFootprintsNASABulletinSplitK 使用 NASA bulletin Split-K 模型计算日食半影足迹。
// SolarEclipsePartialFootprintsNASABulletinSplitK computes penumbral footprint samples with the NASA bulletin Split-K model.
func SolarEclipsePartialFootprintsNASABulletinSplitK(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
return solarEclipsePartialFootprints(date, options, basic.SolarEclipsePartialFootprintsNASABulletinSplitK)
// SolarEclipsePartialFootprintsNASABulletinSplitK 使用 NASA bulletin Split-K 模型计算日食半影足迹 / solar eclipse penumbral footprints with NASA bulletin Split-K.
func (module *Eclipse) SolarEclipsePartialFootprintsNASABulletinSplitK(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
return solarEclipsePartialFootprints(date, options, func(seedJDE float64, options basic.SolarEclipsePartialFootprintOptions) basic.SolarEclipsePartialFootprintsResult {
return basic.SolarEclipsePartialFootprintsNASABulletinSplitKWithInstance(seedJDE, options, module.inst)
}, module)
}
// SolarEclipsePartialFootprintsIAUSingleK 使用 IAU Single-K 模型计算日食半影足迹。
// SolarEclipsePartialFootprintsIAUSingleK computes penumbral footprint samples with the IAU Single-K model.
func SolarEclipsePartialFootprintsIAUSingleK(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
return solarEclipsePartialFootprints(date, options, basic.SolarEclipsePartialFootprintsIAUSingleK)
// SolarEclipsePartialFootprintsIAUSingleK 使用 IAU Single-K 模型计算日食半影足迹 / solar eclipse penumbral footprints with IAU Single-K.
func (module *Eclipse) SolarEclipsePartialFootprintsIAUSingleK(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
return solarEclipsePartialFootprints(date, options, func(seedJDE float64, options basic.SolarEclipsePartialFootprintOptions) basic.SolarEclipsePartialFootprintsResult {
return basic.SolarEclipsePartialFootprintsIAUSingleKWithInstance(seedJDE, options, module.inst)
}, module)
}
// SolarEclipsePartialArea 计算半影足迹,是 SolarEclipsePartialFootprints 的兼容包装。
// SolarEclipsePartialArea computes penumbral footprint samples and is a compatibility wrapper for SolarEclipsePartialFootprints.
func SolarEclipsePartialArea(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
return SolarEclipsePartialFootprints(date, options)
// SolarEclipsePartialArea 计算半影足迹,是 SolarEclipsePartialFootprints 的兼容包装 / compatibility wrapper of SolarEclipsePartialFootprints.
func (module *Eclipse) SolarEclipsePartialArea(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
return module.SolarEclipsePartialFootprints(date, options)
}
// SolarEclipsePartialAreaNASABulletinSplitK 是 SolarEclipsePartialFootprintsNASABulletinSplitK 的兼容包装。
// SolarEclipsePartialAreaNASABulletinSplitK is a compatibility wrapper for SolarEclipsePartialFootprintsNASABulletinSplitK.
func SolarEclipsePartialAreaNASABulletinSplitK(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
return SolarEclipsePartialFootprintsNASABulletinSplitK(date, options)
// SolarEclipsePartialAreaNASABulletinSplitK 是 SolarEclipsePartialFootprintsNASABulletinSplitK 的兼容包装 / compatibility wrapper of SolarEclipsePartialFootprintsNASABulletinSplitK.
func (module *Eclipse) SolarEclipsePartialAreaNASABulletinSplitK(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
return module.SolarEclipsePartialFootprintsNASABulletinSplitK(date, options)
}
// SolarEclipsePartialAreaIAUSingleK 是 SolarEclipsePartialFootprintsIAUSingleK 的兼容包装。
// SolarEclipsePartialAreaIAUSingleK is a compatibility wrapper for SolarEclipsePartialFootprintsIAUSingleK.
func SolarEclipsePartialAreaIAUSingleK(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
return SolarEclipsePartialFootprintsIAUSingleK(date, options)
// SolarEclipsePartialAreaIAUSingleK 是 SolarEclipsePartialFootprintsIAUSingleK 的兼容包装 / compatibility wrapper of SolarEclipsePartialFootprintsIAUSingleK.
func (module *Eclipse) SolarEclipsePartialAreaIAUSingleK(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
return module.SolarEclipsePartialFootprintsIAUSingleK(date, options)
}
func solarEclipseCentralPath(
date time.Time,
options SolarEclipsePathOptions,
calculator solarEclipsePathCalculator,
) (SolarEclipsePath, bool) {
calculator solarEclipsePathCalculator, modules ...*Eclipse) (SolarEclipsePath, bool) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
location := date.Location()
result := calculator(solarEclipseTimeToTTJDE(date), basicSolarEclipsePathOptions(options))
basicOptions := basicSolarEclipsePathOptions(options)
if basicOptions.LunarLimb == nil {
basicOptions.LunarLimb = module.lunarLimb
}
result := calculator(solarEclipseTimeToTTJDE(date), basicOptions)
if !result.Eclipse.HasCentral || len(result.CenterLine) == 0 {
return SolarEclipsePath{}, false
}
path := SolarEclipsePath{
Eclipse: solarEclipseInfoFromBasic(result.Eclipse, location),
Eclipse: solarEclipseInfoFromBasic(result.Eclipse, location, module),
PathWidthDefined: result.PathWidthDefined,
Greatest: solarEclipsePathPointFromBasic(result.Greatest, location),
CenterLine: solarEclipsePathPointsFromBasic(result.CenterLine, location),
@@ -349,6 +366,7 @@ func solarEclipseCentralPath(
MaxCentralDurationLatitude: result.MaxCentralDurationLatitude,
CentralBandSegments: solarEclipsePathSegmentsFromBasic(result.CentralBandSegments, location),
CentralBandSampled: result.CentralBandSampled,
CentralLimitsLimbApplied: result.CentralLimitsLimbApplied,
Step: solarEclipsePathStepDuration(result.StepDays),
TargetSpacingKM: result.TargetSpacingKM,
}
@@ -419,20 +437,27 @@ func solarEclipseGreatestTimeContoursFromBasic(
func solarEclipsePartialFootprints(
date time.Time,
options SolarEclipsePartialFootprintOptions,
calculator solarEclipsePartialFootprintsCalculator,
) (SolarEclipsePartialFootprintsInfo, bool) {
calculator solarEclipsePartialFootprintsCalculator, modules ...*Eclipse) (SolarEclipsePartialFootprintsInfo, bool) {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
location := date.Location()
basicOptions := basicSolarEclipsePartialFootprintOptions(options)
greatestTimeLevels := solarEclipseGreatestTimeLevels(options.GreatestTimeValues)
basicOptions.GreatestTimeValues = solarEclipseGreatestTimeLevelJDEs(greatestTimeLevels)
basicOptions.GreatestTimeStep = options.GreatestTimeStep
if basicOptions.LunarLimb == nil {
basicOptions.LunarLimb = module.lunarLimb
}
result := calculator(solarEclipseTimeToTTJDE(date), basicOptions)
if !result.Eclipse.HasPartial || len(result.Footprints) == 0 {
return SolarEclipsePartialFootprintsInfo{}, false
}
footprints := SolarEclipsePartialFootprintsInfo{
Eclipse: solarEclipseInfoFromBasic(result.Eclipse, location),
Eclipse: solarEclipseInfoFromBasic(result.Eclipse, location, module),
Footprints: solarEclipsePartialFootprintsFromBasic(result.Footprints, location),
CentralShadowFootprints: solarEclipsePartialFootprintsFromBasic(result.CentralShadowFootprints, location),
CentralBandFootprints: solarEclipsePartialFootprintsFromBasic(result.CentralBandFootprints, location),
@@ -443,6 +468,7 @@ func solarEclipsePartialFootprints(
MagnitudeContours: solarEclipseMagnitudeContoursFromBasic(result.MagnitudeContours, location),
GreatestTimeContours: solarEclipseGreatestTimeContoursFromBasic(result.GreatestTimeContours, greatestTimeLevels, location),
RiseSetCurves: solarEclipseRiseSetCurvesFromBasic(result.RiseSetCurves, location),
PartialBoundaryLimbApplied: result.PartialBoundaryLimbApplied,
P1: solarEclipseOptionalPathPointFromBasic(result.P1, location),
P2: solarEclipseOptionalPathPointFromBasic(result.P2, location),
P3: solarEclipseOptionalPathPointFromBasic(result.P3, location),
@@ -483,6 +509,7 @@ func basicSolarEclipsePathOptions(options SolarEclipsePathOptions) basic.SolarEc
basicOptions := basic.SolarEclipsePathOptions{
TargetSpacingKM: options.TargetSpacingKM,
SkipCentralBand: options.SkipCentralBand,
LunarLimb: options.LunarLimb,
}
if options.Step > 0 {
basicOptions.StepDays = options.Step.Hours() / 24
@@ -495,6 +522,7 @@ func basicSolarEclipsePartialFootprintOptions(options SolarEclipsePartialFootpri
BoundaryPoints: options.BoundaryPoints,
MagnitudeValues: append([]float64(nil), options.MagnitudeValues...),
DisableRiseSetCurves: options.DisableRiseSet,
LunarLimb: options.LunarLimb,
}
if options.Step > 0 {
basicOptions.StepDays = options.Step.Hours() / 24
+6
View File
@@ -4,6 +4,8 @@ import (
"math"
"testing"
"time"
"b612.me/astro/internal/ephemtest"
)
func TestSolarEclipseCentralPathKeepsLocation(t *testing.T) {
@@ -168,6 +170,8 @@ func TestSolarEclipsePartialAreaCompatibilityWrapper(t *testing.T) {
}
func TestSolarEclipsePartialFootprints20140429NonCentralContactsMatchNASA(t *testing.T) {
// NASA 交食根数由 VSOP87/ELP 算出,接触时刻只与旧模型逐秒可比。
ephemtest.PinLegacy(t)
partial, ok := SolarEclipsePartialFootprints(
time.Date(2014, time.April, 29, 0, 0, 0, 0, time.UTC),
SolarEclipsePartialFootprintOptions{Step: 10 * time.Minute, BoundaryPoints: 96},
@@ -213,6 +217,8 @@ func TestSolarEclipsePartialFootprintsRetryTinyGrazingFootprints(t *testing.T) {
}
func TestSolarEclipseCentralPathRecoversFromCoarseSampling(t *testing.T) {
// 夹具按旧模型几何选取,用来覆盖该边界分支。
ephemtest.PinLegacy(t)
path, ok := SolarEclipseCentralPath(
time.Date(2119, 3, 11, 0, 0, 0, 0, time.UTC),
SolarEclipsePathOptions{Step: time.Hour},
+11 -11
View File
@@ -121,16 +121,16 @@ type SolarEclipseShadowSolver struct {
}
// NewSolarEclipseShadowSolver 构造单时刻求解器 / builds a single-instant solver.
func NewSolarEclipseShadowSolver(options SolarEclipseShadowSolverOptions) *SolarEclipseShadowSolver {
func (module *Eclipse) NewSolarEclipseShadowSolver(options SolarEclipseShadowSolverOptions) *SolarEclipseShadowSolver {
return &SolarEclipseShadowSolver{
options: options,
inner: basic.NewSolarEclipseShadowSolver(basic.SolarEclipseShadowSolverOptions{
inner: basic.NewSolarEclipseShadowSolverWithInstance(basic.SolarEclipseShadowSolverOptions{
Model: basic.SolarEclipseRadiusModel(options.Model),
DeltaTSeconds: options.DeltaTSeconds,
BoundaryPoints: options.BoundaryPoints,
Kind: basic.SolarEclipseShadowKind(options.Kind),
TargetSpacingKM: options.TargetSpacingKM,
}),
}, module.inst),
}
}
@@ -239,13 +239,13 @@ func solarEclipseStationStateFromBasic(
}
// SolarEclipseShadowAt 无状态便捷入口,可并发调用 / stateless convenience entry point.
func SolarEclipseShadowAt(value time.Time) (SolarEclipseShadowInstant, bool) {
return NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}).ShadowAt(value)
func (module *Eclipse) SolarEclipseShadowAt(value time.Time) (SolarEclipseShadowInstant, bool) {
return module.NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}).ShadowAt(value)
}
// SolarEclipseStationStateAt 无状态便捷入口,可并发调用 / stateless convenience entry point.
func SolarEclipseStationStateAt(value time.Time, lon, lat, height float64) SolarEclipseStationState {
return NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}).StationStateAt(value, lon, lat, height)
func (module *Eclipse) SolarEclipseStationStateAt(value time.Time, lon, lat, height float64) SolarEclipseStationState {
return module.NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{}).StationStateAt(value, lon, lat, height)
}
func finiteEclipseValue(value float64) bool {
@@ -299,15 +299,15 @@ func (solver *SolarEclipseShadowSolver) StationStatesBetween(
}
// SolarEclipseShadowBetween 无状态批量入口,使用进程级 ΔT / stateless batch entry point.
func SolarEclipseShadowBetween(
func (module *Eclipse) SolarEclipseShadowBetween(
start, end time.Time, step time.Duration,
) []SolarEclipseShadowInstant {
return SolarEclipseShadowBetweenWithOptions(start, end, step, SolarEclipseShadowSolverOptions{})
return module.SolarEclipseShadowBetweenWithOptions(start, end, step, SolarEclipseShadowSolverOptions{})
}
// SolarEclipseShadowBetweenWithOptions 可显式指定 ΔT 与模型 / with explicit ΔT and model.
func SolarEclipseShadowBetweenWithOptions(
func (module *Eclipse) SolarEclipseShadowBetweenWithOptions(
start, end time.Time, step time.Duration, options SolarEclipseShadowSolverOptions,
) []SolarEclipseShadowInstant {
return NewSolarEclipseShadowSolver(options).ShadowBetween(start, end, step)
return module.NewSolarEclipseShadowSolver(options).ShadowBetween(start, end, step)
}
+11
View File
@@ -17,6 +17,7 @@ func TestEclipseMapDataSourcesUseDocumentedVocabulary(t *testing.T) {
"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",
"c1-c4-visibility-regions", "c1-c4-horizon-boundaries", "transit-contact-isochrones",
}
if len(documented) != len(eclipseMapDataSources) {
t.Fatalf("vocabulary has %d entries, want %d", len(eclipseMapDataSources), len(documented))
@@ -51,6 +52,13 @@ func TestEclipseMapDataSourcesUseDocumentedVocabulary(t *testing.T) {
t.Fatal("missing 2026-03-03 lunar map")
}
if rendered, ok := TransitMapSVG(TransitVenus, time.Date(2012, time.June, 6, 0, 0, 0, 0, time.UTC),
TransitMapSVGOptions{Location: time.UTC, Isochrones: TransitIsochroneExternal, IsochroneStep: 10 * time.Minute}); ok {
charts["2012-06-06 venus transit"] = rendered
} else {
t.Fatal("missing 2012-06-06 transit map")
}
seen := map[string]bool{}
for name, chart := range charts {
for _, match := range eclipseMapDataSourcePattern.FindAllStringSubmatch(chart, -1) {
@@ -73,6 +81,9 @@ func TestEclipseMapDataSourcesUseDocumentedVocabulary(t *testing.T) {
eclipseMapSourceCentralShadowOutlines,
eclipseMapSourceLunarVisibilityRegions,
eclipseMapSourceLunarHorizonBoundaries,
eclipseMapSourceTransitVisibilityRegions,
eclipseMapSourceTransitHorizonBoundaries,
eclipseMapSourceTransitContactIsochrones,
} {
if !seen[source] {
t.Fatalf("main layer source %q carries no data-source marker", source)
+25 -16
View File
@@ -1,4 +1,4 @@
// Package svg 生成日食与月食的 SVG 图件。
// Package svg 生成日食、月食与水星、金星凌日的 SVG 图件。
//
// 可降级的图层用 data-source 标注实际几何来源,取值词表如下;没有降级分支的图层不带该属性。
//
@@ -16,27 +16,33 @@
// central-shadow-outlines 瞬时本影、反本影轮廓
// p1-p4-visibility-regions 月食可见性分区
// p1-p4-horizon-boundaries 月食 P1、P4 地平边界
// c1-c4-visibility-regions 凌日可见性分区
// c1-c4-horizon-boundaries 凌日 C1、C4 视地平边界
// transit-contact-isochrones 凌日站心接触等时线
//
// Package svg renders solar- and lunar-eclipse SVG charts. Degradable layers tag the geometry
// Package svg renders solar-eclipse, lunar-eclipse and Mercury/Venus transit SVG charts. Degradable layers tag the geometry
// source they actually used in data-source, using exactly the values above.
package svg
// data-source 词表,见包注释。
const (
eclipseMapSourcePartialBandUnion = "partial-band-union"
eclipseMapSourceSampledFootprintSweep = "sampled-footprint-sweep"
eclipseMapSourcePartialBandContours = "partial-band-contours"
eclipseMapSourceRiseSetPhaseLines = "rise-set-phase-lines"
eclipseMapSourceMagnitudeContours = "magnitude-contours"
eclipseMapSourceGreatestTimeIsochrones = "greatest-time-isochrones"
eclipseMapSourceBesselianEnvelope = "besselian-critical-envelope"
eclipseMapSourcePairedLimitChords = "paired-limit-chords"
eclipseMapSourceSampledOpenSweep = "sampled-open-sweep"
eclipseMapSourceCentralPathLimits = "central-path-limits"
eclipseMapSourcePenumbralOutlines = "penumbral-outlines"
eclipseMapSourceCentralShadowOutlines = "central-shadow-outlines"
eclipseMapSourceLunarVisibilityRegions = "p1-p4-visibility-regions"
eclipseMapSourceLunarHorizonBoundaries = "p1-p4-horizon-boundaries"
eclipseMapSourcePartialBandUnion = "partial-band-union"
eclipseMapSourceSampledFootprintSweep = "sampled-footprint-sweep"
eclipseMapSourcePartialBandContours = "partial-band-contours"
eclipseMapSourceRiseSetPhaseLines = "rise-set-phase-lines"
eclipseMapSourceMagnitudeContours = "magnitude-contours"
eclipseMapSourceGreatestTimeIsochrones = "greatest-time-isochrones"
eclipseMapSourceBesselianEnvelope = "besselian-critical-envelope"
eclipseMapSourcePairedLimitChords = "paired-limit-chords"
eclipseMapSourceSampledOpenSweep = "sampled-open-sweep"
eclipseMapSourceCentralPathLimits = "central-path-limits"
eclipseMapSourcePenumbralOutlines = "penumbral-outlines"
eclipseMapSourceCentralShadowOutlines = "central-shadow-outlines"
eclipseMapSourceLunarVisibilityRegions = "p1-p4-visibility-regions"
eclipseMapSourceLunarHorizonBoundaries = "p1-p4-horizon-boundaries"
eclipseMapSourceTransitVisibilityRegions = "c1-c4-visibility-regions"
eclipseMapSourceTransitHorizonBoundaries = "c1-c4-horizon-boundaries"
eclipseMapSourceTransitContactIsochrones = "transit-contact-isochrones"
)
// eclipseMapDataSources 是全部合法取值,测试用它封闭词表。
@@ -55,4 +61,7 @@ var eclipseMapDataSources = []string{
eclipseMapSourceCentralShadowOutlines,
eclipseMapSourceLunarVisibilityRegions,
eclipseMapSourceLunarHorizonBoundaries,
eclipseMapSourceTransitVisibilityRegions,
eclipseMapSourceTransitHorizonBoundaries,
eclipseMapSourceTransitContactIsochrones,
}
+70
View File
@@ -0,0 +1,70 @@
package svg
// 各日月食出图入口都接受月缘剖面,并在图注写明所用口径。
import (
"strings"
"testing"
"time"
"b612.me/astro/moon/limb"
)
func TestEclipseSVGsDeclareLunarLimbConvention(t *testing.T) {
solarDate := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
lunarDate := time.Date(2025, time.September, 7, 0, 0, 0, 0, time.UTC)
profile := limb.Default()
render := map[string]func(applied bool) (string, bool){
"local solar": func(applied bool) (string, bool) {
options := LocalSolarEclipseSVGOptions{Location: time.UTC}
if applied {
options.LunarLimb = profile
}
return LocalSolarEclipseSVG(solarDate, -96.8, 32.78, 0, options)
},
"solar map": func(applied bool) (string, bool) {
options := SolarEclipseMapSVGOptions{Location: time.UTC}
if applied {
options.LunarLimb = profile
}
return SolarEclipseMapSVG(solarDate, options)
},
"lunar": func(applied bool) (string, bool) {
options := LunarEclipseSVGOptions{Location: time.UTC}
if applied {
options.LunarLimb = profile
}
return LunarEclipseSVG(lunarDate, options)
},
"lunar map": func(applied bool) (string, bool) {
options := LunarEclipseMapSVGOptions{Location: time.UTC}
if applied {
options.LunarLimb = profile
}
return LunarEclipseMapSVG(lunarDate, options)
},
"lunar detailed": func(applied bool) (string, bool) {
options := LunarEclipseDetailedSVGOptions{Location: time.UTC}
if applied {
options.LunarLimb = profile
}
return LunarEclipseDetailedSVG(lunarDate, options)
},
}
for name, draw := range render {
circular, ok := draw(false)
if !ok {
t.Fatalf("%s: circular chart missing", name)
}
corrected, ok := draw(true)
if !ok {
t.Fatalf("%s: limb chart missing", name)
}
if !strings.Contains(circular, "圆月面") {
t.Fatalf("%s: circular chart does not declare the circular limb", name)
}
if !strings.Contains(corrected, "月缘剖面") {
t.Fatalf("%s: limb chart does not declare the limb profile", name)
}
}
}
+7 -2
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/limbnote"
"b612.me/astro/internal/timenote"
"fmt"
"html"
@@ -73,6 +74,8 @@ type LunarEclipseSVGOptions struct {
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels and requires
// Location to be nil or UTC (otherwise the renderer returns false).
TimeScale astro.TimeScale
// LunarLimb 月缘剖面,nil 为圆月面;只精修接触时刻,月盘仍按圆面绘制 / lunar limb profile, nil for circular; refines contacts only.
LunarLimb basic.LunarLimb
}
type lunarEclipseSVGCalculator func(float64, basic.LunarEclipseDiagramOptions) basic.LunarEclipseDiagramResult
@@ -164,7 +167,8 @@ func lunarEclipseSVG(
}
options = normalizeLunarEclipseSVGOptions(options)
diagram := calculator(timeToTTJDE(date), basic.LunarEclipseDiagramOptions{
StepDays: durationToDays(options.Step),
StepDays: durationToDays(options.Step),
LunarLimb: options.LunarLimb,
})
if diagram.Eclipse.Type == basic.LunarEclipseNone || len(diagram.Points) == 0 {
return "", false
@@ -1045,7 +1049,8 @@ func lunarEclipseSVGFooterLines(direction string, info LunarEclipseInfo, options
if maxLines < 1 {
maxLines = 1
}
note := timenote.Scale(options.TimeScale, info.Maximum, options.Location, options.Language)
note := limbnote.Join(timenote.Scale(options.TimeScale, info.Maximum, options.Location, options.Language),
limbnote.Contacts(info.LunarLimbApplied, options.Language), options.Language)
lines := []string{lunarEclipseSVGFooterNoteText(options)}
if options.FooterNote != "" {
lines = svgchart.WrapText(options.FooterNote, float64(options.Width)-80, fontSize)
+13 -8
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/limbnote"
"b612.me/astro/internal/timenote"
"fmt"
"html"
@@ -11,6 +12,7 @@ import (
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/svgasset"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
@@ -40,6 +42,8 @@ type LunarEclipseDetailedSVGOptions struct {
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels and requires
// Location to be nil or UTC (otherwise the renderer returns false).
TimeScale astro.TimeScale
// LunarLimb 月缘剖面,nil 为圆月面;只精修接触时刻,月盘仍按圆面绘制 / lunar limb profile, nil for circular; refines contacts only.
LunarLimb basic.LunarLimb
// Step 是月心路径采样步长;<=0 时使用 5 分钟。
// Step is the Moon-center path sampling step; values <= 0 use five minutes.
Step time.Duration
@@ -118,7 +122,8 @@ func lunarEclipseDetailedSVG(
}
options = normalizeLunarEclipseDetailedSVGOptions(options)
diagram := calculator(timeToTTJDE(date), basic.LunarEclipseDiagramOptions{
StepDays: durationToDays(options.Step),
StepDays: durationToDays(options.Step),
LunarLimb: options.LunarLimb,
})
if diagram.Eclipse.Type == basic.LunarEclipseNone || len(diagram.Points) == 0 {
return "", false
@@ -457,7 +462,8 @@ func writeLunarEclipseDetailedSummary(
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#293235" font-family="Arial, sans-serif" font-size="12.5" text-anchor="middle">%s</text>`,
view.width/2, view.metaY+float64(index)*22, html.EscapeString(line))
}
zoneNote := timenote.Scale(options.TimeScale, info.Maximum, options.Location, options.Language)
zoneNote := limbnote.Join(timenote.Scale(options.TimeScale, info.Maximum, options.Location, options.Language),
limbnote.Contacts(info.LunarLimbApplied, options.Language), options.Language)
labels.ReserveText(view.width/2, view.metaY+4*22, 11, zoneNote, "middle")
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#7b8794" font-family="Arial, sans-serif" font-size="11" text-anchor="middle">%s</text>`,
view.width/2, view.metaY+4*22, html.EscapeString(zoneNote))
@@ -499,13 +505,13 @@ func writeLunarEclipseDetailedGeocentricBlocks(
{Label: "赤经 R.A.", Value: formatSolarEclipseRA(sunRa)},
{Label: "赤纬 Dec.", Value: formatSolarEclipseDec(sunDec)},
{Label: "视半径 S.D.", Value: formatSolarEclipseArcsec(sunSd)},
{Label: "地平视差 H.P.", Value: formatSolarEclipseArcsec(horizontalParallaxArcsecRatio(sunSd, 6378.137/696000.0))},
{Label: "地平视差 H.P.", Value: formatSolarEclipseArcsec(horizontalParallaxArcsecRatio(sunSd, geodata.EarthWGS84RadiusKM/696000.0))},
},
{
{Label: "赤经 R.A.", Value: formatSolarEclipseRA(moonRa)},
{Label: "赤纬 Dec.", Value: formatSolarEclipseDec(moonDec)},
{Label: "视半径 S.D.", Value: formatSolarEclipseArcsec(moonSd)},
{Label: "地平视差 H.P.", Value: formatSolarEclipseArcsec(horizontalParallaxArcsecRatio(moonSd, 6378.137/1737.4))},
{Label: "地平视差 H.P.", Value: formatSolarEclipseArcsec(horizontalParallaxArcsecRatio(moonSd, geodata.EarthWGS84RadiusKM/1737.4))},
},
}
if english {
@@ -578,7 +584,7 @@ func writeLunarEclipseArcMinuteScaleBar(
return
}
// 示意图以"月球半径"为单位,一个月球半径对应的真实角径是 asin(R月 / 月距),不是地球视差。
moonSemidiameterArcmin := math.Asin((lunarMoonRadiusKM/lunarEarthRadiusKM)/shadow.MoonDistanceEarthRadii) * 180 / math.Pi * 60
moonSemidiameterArcmin := math.Asin((lunarMoonRadiusKM/geodata.EarthWGS84RadiusKM)/shadow.MoonDistanceEarthRadii) * 180 / math.Pi * 60
if moonSemidiameterArcmin <= 0 {
return
}
@@ -650,7 +656,6 @@ func writeLunarEclipseDetailedFooter(
}
const (
// lunarMoonRadiusKM 与 lunarEarthRadiusKM 只用于把示意图的"月球半径"刻度换算成角分比例尺。
lunarMoonRadiusKM = 1737.4
lunarEarthRadiusKM = 6378.137
// lunarMoonRadiusKM 与 geodata.EarthWGS84RadiusKM 只用于把示意图的"月球半径"刻度换算成角分比例尺。
lunarMoonRadiusKM = 1737.4
)
@@ -9,6 +9,8 @@ import (
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/ephemtest"
)
// 详细版"食甚时的月亮(地心坐标)"块必须用食甚时刻的力学时求值,与 NASA 月食图同一时刻;
@@ -26,6 +28,7 @@ func TestLunarEclipseDetailedGeocentricBlockUsesGreatestEclipse(t *testing.T) {
{"2026-03-03", time.Date(2026, 3, 3, 0, 0, 0, 0, cst), "10h56m15.1s", "+06°24&#39;05.2&#34;"},
{"2025-03-14", time.Date(2025, 3, 14, 0, 0, 0, 0, cst), "11h38m23.0s", "+02°40&#39;54.3&#34;"},
}
values := ephemtest.NewValues(t, "lunar_geocentric_block")
for _, want := range cases {
doc, ok := LunarEclipseDetailedSVG(want.date, LunarEclipseDetailedSVGOptions{
Language: "zh", Location: cst})
@@ -33,6 +36,8 @@ func TestLunarEclipseDetailedGeocentricBlockUsesGreatestEclipse(t *testing.T) {
t.Fatalf("%s: missing chart", want.name)
}
ra, dec := lunarGeocentricMoonBlock(t, doc)
want.ra = values.WantString(want.name+"/ra", want.ra, ra)
want.dec = values.WantString(want.name+"/dec", want.dec, dec)
if ra != want.ra || dec != want.dec {
t.Errorf("%s: 食甚坐标 RA=%s Dec=%s, want RA=%s Dec=%s", want.name, ra, dec, want.ra, want.dec)
}
+20 -18
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/limbnote"
"b612.me/astro/internal/timenote"
"fmt"
"html"
@@ -11,6 +12,7 @@ import (
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/lunarhorizon"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
@@ -39,6 +41,8 @@ type LunarEclipseMapSVGOptions struct {
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires
// Location to be nil or UTC (otherwise the renderer returns false) and declares the scale in the footer.
TimeScale astro.TimeScale
// LunarLimb 月缘剖面,nil 为圆月面;精修接触时刻,阶段地平圈随之移动 / lunar limb profile; refines contacts and moves phase horizons.
LunarLimb basic.LunarLimb
// Projection 选择地图投影;零值使用等经纬投影,不支持的值使渲染器返回 false。
// Projection selects the map projection. The zero value selects the equirectangular projection; unsupported values make the renderer return false.
Projection EclipseMapProjection
@@ -57,19 +61,24 @@ type LunarEclipseMapSVGOptions struct {
// LunarEclipseMapSVG 使用默认月食模型绘制全球 P1-P4 可见区域。
// LunarEclipseMapSVG renders the global P1-P4 visibility regions using the default lunar-eclipse model.
func LunarEclipseMapSVG(date time.Time, options LunarEclipseMapSVGOptions) (string, bool) {
return lunarEclipseMapSVG(date, options, eclipsecore.LunarEclipseOnDate)
return lunarEclipseMapSVG(date, options, lunarEclipseMapModule(options).LunarEclipseOnDate)
}
// LunarEclipseMapSVGDanjon 使用 Danjon 模型绘制全球可见区域。
// LunarEclipseMapSVGDanjon renders the global visibility regions with Danjon's model.
func LunarEclipseMapSVGDanjon(date time.Time, options LunarEclipseMapSVGOptions) (string, bool) {
return lunarEclipseMapSVG(date, options, eclipsecore.LunarEclipseOnDateDanjon)
return lunarEclipseMapSVG(date, options, lunarEclipseMapModule(options).LunarEclipseOnDateDanjon)
}
// LunarEclipseMapSVGChauvenet 使用 Chauvenet 模型绘制全球可见区域。
// LunarEclipseMapSVGChauvenet renders the global visibility regions with Chauvenet's model.
func LunarEclipseMapSVGChauvenet(date time.Time, options LunarEclipseMapSVGOptions) (string, bool) {
return lunarEclipseMapSVG(date, options, eclipsecore.LunarEclipseOnDateChauvenet)
return lunarEclipseMapSVG(date, options, lunarEclipseMapModule(options).LunarEclipseOnDateChauvenet)
}
// lunarEclipseMapModule 返回按选项月缘配置的核心模块;未设置月缘时等同包级计算。
func lunarEclipseMapModule(options LunarEclipseMapSVGOptions) *eclipsecore.Eclipse {
return eclipsecore.NewWithInstance(nil).WithLunarLimb(options.LunarLimb)
}
func lunarEclipseMapSVG(
@@ -159,7 +168,7 @@ func renderLunarEclipseMapSVG(
frame.WriteFrame(&builder)
legendRows := lunarEclipseMapLegendRows(frame, float64(options.Width), options.Language, penumbralBands)
writeLunarEclipseMapLegend(&builder, frame, float64(options.Width), options.Language, penumbralBands)
writeLunarEclipseMapFooter(&builder, frame, options, projection, info.Maximum, len(legendRows))
writeLunarEclipseMapFooter(&builder, frame, options, projection, info.Maximum, info.LunarLimbApplied, len(legendRows))
builder.WriteString(`</svg>`)
return builder.String()
}
@@ -223,11 +232,11 @@ func lunarEclipseVisibilityPath(value time.Time, frame svgmap.Frame) (string, []
}
if frame.Projection == svgmap.ProjectionEquirectangular {
// 等距圆柱投影在高纬把经度拉伸:1° 的球面地平弧在极点附近可以横跨近 180° 经度,
// 只连端点会在极区切出地平线以外的假可见帽(1904-09-24 实测 −0.054°,约 6 km)。
// 只连端点会在极区切出地平线以外的假可见帽。
// 极射投影没有这个畸变,保持原输出。
// Equirectangular projection stretches longitude near a pole: a 1-degree spherical
// horizon arc can span almost 180 degrees there, so joining endpoints alone cuts a fake
// visible cap outside the horizon (measured -0.054 deg, about 6 km, on 1904-09-24).
// visible cap outside the horizon.
// Polar projections have no such distortion and keep their existing output.
// 门限取画布上约 0.03 像素:极区细分再多也画不出来,而 GeoJSON 用的 0.002°
// 在 812 像素宽的世界图上相当于 0.005 像素,会让 SVG 无谓地大三分之一。
@@ -476,6 +485,7 @@ func writeLunarEclipseMapFooter(
options LunarEclipseMapSVGOptions,
projection svgmap.Projection,
instant time.Time,
limbApplied bool,
legendRows int,
) {
text := options.FooterNote
@@ -497,7 +507,8 @@ func writeLunarEclipseMapFooter(
}
}
// 默认说明是单行;调用方文本按图框宽度折行,行数按画布底边截断,首行位置不变。
note := timenote.Scale(options.TimeScale, instant, options.Location, options.Language)
note := limbnote.Join(timenote.Scale(options.TimeScale, instant, options.Location, options.Language),
limbnote.Contacts(limbApplied, options.Language), options.Language)
lines := []string{text}
if options.FooterNote != "" {
lines = svgchart.WrapText(options.FooterNote, float64(options.Width)-frame.X-24, 11)
@@ -511,20 +522,12 @@ func writeLunarEclipseMapFooter(
}
}
func normalizeDegree180(value float64) float64 {
value = math.Mod(value+180, 360)
if value < 0 {
value += 360
}
return value - 180
}
// lunarEclipseOrthographicCenter 给出月食正射图的视点:食甚时刻的月下点(月球位于天顶的地点)。
func lunarEclipseOrthographicCenter(at time.Time) (float64, float64) {
jd := basic.Date2JD(at.UTC())
tt := basic.UTC2TT(jd)
ra, dec := basic.HMoonTrueRaDec(tt)
return normalizeDegree180(ra - basic.ApparentSiderealTime(basic.TT2UT1(tt))*15), dec
return geodata.NormalizeLongitude(ra - basic.ApparentSiderealTime(basic.TT2UT1(tt))*15), dec
}
// writeLunarEclipseMapDefinitions 输出四类可见性区域所需的形状与掩膜定义。
@@ -558,8 +561,7 @@ func writeLunarEclipseUmbralVisibilityShapes(b *strings.Builder, frame svgmap.Fr
}
// writeLunarEclipseUmbralVisibleShapes 输出"本影阶段任意时刻在地平上"的取样半球:本影区间按固定档位
// 取样,每一刻的可见半球都是一个精确的圆盘(按经纬度插值会把近乎子午线方向的边界切进真实区域——
// 实测 1932-03-22 西经 75.5° 一带上边界 0.5° 经度内从 +19.9° 掉到 −27.2°,1° 列的弦会切掉十几度)。
// 取样,每一刻的可见半球都是一个精确的圆盘(按经纬度插值会把近乎子午线方向的边界切进真实区域)。
// 这些形状同时用于底带的裁剪(取并集)与半影带的掩膜(取补集),两侧严丝合缝。
func writeLunarEclipseUmbralVisibleShapes(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) {
instants := lunarEclipseUmbralVisibilityInstants(info)
+1 -7
View File
@@ -12,13 +12,7 @@ import (
// TestLunarEclipseMapSVGPolarWitnessStaysOutsideEquirectangularVisibility 固定全球月食地图在
// 极区的可见性契约。等距圆柱投影在高纬把经度拉伸:1° 的球面地平弧在极点附近可以横跨近
// 180° 经度,只连端点就会把地平线以外画进可见区。1904-09-24 的 P1 在南极点附近有一个
// 月心高度约 −0.05° 的见证点,曾经被画进 visible-at-p1 多边形。
// TestLunarEclipseMapSVGPolarWitnessStaysOutsideEquirectangularVisibility pins the polar
// visibility contract of the global lunar-eclipse map. Equirectangular projection stretches
// longitude near a pole: a 1-degree spherical horizon arc can span almost 180 degrees there, so
// joining endpoints alone paints sky below the horizon as visible. At P1 of 1904-09-24 a witness
// near the south pole sits about 0.05 degrees below the Moon's horizon and used to be painted
// inside the visible-at-p1 polygon.
// 月心高度约 −0.05° 的见证点,不得落入 visible-at-p1 多边形。
func TestLunarEclipseMapSVGPolarWitnessStaysOutsideEquirectangularVisibility(t *testing.T) {
date := time.Date(1904, 9, 24, 0, 0, 0, 0, time.UTC)
info, ok := eclipsecore.LunarEclipseOnDate(date)
+1
View File
@@ -28,6 +28,7 @@ func lunarEclipseInfoFromBasic(
) LunarEclipseInfo {
info := LunarEclipseInfo{
Type: mapBasicLunarEclipseType(result.Type),
LunarLimbApplied: result.LunarLimbApplied,
PenumbralMagnitude: result.PenumbralMagnitude,
UmbralMagnitude: result.Magnitude,
PenumbralStart: ttJDEToTime(result.PenumbralStart, location),
+7 -1
View File
@@ -13,6 +13,8 @@ import (
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/ephemtest"
)
// 半影阶段开关的契约:零值输出与不区分半影阶段的基线逐字节一致,打开后补画 U1–U4 地平边界、
@@ -47,6 +49,7 @@ func TestLunarEclipseMapSVGDisablePenumbralPhaseMatchesBaseline(t *testing.T) {
if len(render) != len(lunarPenumbralPhaseBaseline) {
t.Fatalf("baseline table has %d cases, renderers %d", len(lunarPenumbralPhaseBaseline), len(render))
}
values := ephemtest.NewValues(t, "lunar_penumbral_phase_digest")
for _, want := range lunarPenumbralPhaseBaseline {
draw, ok := render[want.name]
if !ok {
@@ -57,7 +60,10 @@ func TestLunarEclipseMapSVGDisablePenumbralPhaseMatchesBaseline(t *testing.T) {
t.Fatalf("%s: chart missing", want.name)
}
sum := md5.Sum([]byte(doc))
if got := hex.EncodeToString(sum[:]); got != want.digest || len(doc) != want.length {
got := hex.EncodeToString(sum[:])
want.digest = values.WantString(want.name+"/md5", want.digest, got)
want.length = int(values.Want(want.name+"/bytes", float64(want.length), float64(len(doc))))
if got != want.digest || len(doc) != want.length {
t.Fatalf("%s: md5=%s bytes=%d, want md5=%s bytes=%d",
want.name, got, len(doc), want.digest, want.length)
}
+6 -2
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/limbnote"
"b612.me/astro/internal/timenote"
"fmt"
"html"
@@ -71,6 +72,8 @@ type LocalSolarEclipseSVGOptions struct {
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires
// Location to be nil or UTC (otherwise the renderer returns false) and declares the scale in the footer.
TimeScale astro.TimeScale
// LunarLimb 月缘剖面,nil 为圆月面;精修接触时刻与食甚时的食分、遮蔽率,食甚时刻不变、月盘仍按圆面绘制 / lunar limb profile for contacts, magnitude and obscuration.
LunarLimb basic.LunarLimb
}
type localSolarEclipseSVGCalculator func(float64, float64, float64, float64, basic.LocalSolarEclipseDiagramOptions) basic.LocalSolarEclipseDiagramResult
@@ -125,7 +128,7 @@ func localSolarEclipseSVG(
lon,
lat,
height,
basic.LocalSolarEclipseDiagramOptions{StepDays: solarEclipseDurationToDays(options.Step)},
basic.LocalSolarEclipseDiagramOptions{StepDays: solarEclipseDurationToDays(options.Step), LunarLimb: options.LunarLimb},
)
if diagram.Eclipse.Type == basic.SolarEclipseNone || len(diagram.Frames) == 0 {
return "", false
@@ -170,7 +173,8 @@ func renderLocalSolarEclipseSVG(
if options.DirectionText != "" {
direction = svgchart.EllipsizeText(direction, width-80, 12)
}
scaleNote := timenote.Scale(options.TimeScale, info.GreatestEclipse, options.Location, options.Language)
scaleNote := limbnote.Join(timenote.Scale(options.TimeScale, info.GreatestEclipse, options.Location, options.Language),
limbnote.Contacts(info.LunarLimbApplied, options.Language), options.Language)
footerLines := localSolarEclipseSVGFooterLines(direction, localSolarEclipseSVGFooterNoteText(options), scaleNote, width, height, headerBottom)
footerBaselines, footerOccupied := svgchart.FooterBlock(len(footerLines), height, 12,
svgchart.FooterLineHeight(12), localDiagramFooterBottomPadding)
+46 -12
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/limbnote"
"b612.me/astro/internal/timenote"
"fmt"
"html"
@@ -57,6 +58,8 @@ type SolarEclipseMapSVGOptions struct {
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires
// Location to be nil or UTC (otherwise the renderer returns false) and declares the scale in the footer.
TimeScale astro.TimeScale
// LunarLimb 月缘剖面,nil 为圆月面;修正全球始末、食甚点站心面板、中心带界线与带宽、偏食可见域外边界,页脚只声明实际生效的层 / lunar limb profile for contacts, local panel, central limits and partial boundary.
LunarLimb basic.LunarLimb
// Projection 选择地图投影;零值从事件几何中自动选择,不支持的值使渲染器返回 false。
// Projection selects the map projection. The zero value selects one from the event geometry; unsupported values make the renderer return false.
Projection EclipseMapProjection
@@ -137,27 +140,34 @@ func SolarEclipseMapSVG(date time.Time, options SolarEclipseMapSVGOptions) (stri
// SolarEclipseMapSVGNASABulletinSplitK 使用 NASA bulletin Split-K 绘制地图。
// SolarEclipseMapSVGNASABulletinSplitK renders a NASA bulletin Split-K map.
func SolarEclipseMapSVGNASABulletinSplitK(date time.Time, options SolarEclipseMapSVGOptions) (string, bool) {
module := solarEclipseMapModule(options)
return solarEclipseMapSVG(date, options, solarEclipseMapCalculators{
global: eclipsecore.SolarEclipseOnDateNASABulletinSplitK,
global: module.SolarEclipseOnDateNASABulletinSplitK,
panel: eclipsecore.SolarEclipseGeocentricPanelAt,
partial: eclipsecore.SolarEclipsePartialFootprintsNASABulletinSplitK,
central: eclipsecore.SolarEclipseCentralPathNASABulletinSplitK,
local: eclipsecore.GeometricLocalSolarEclipseOnDateNASABulletinSplitK,
partial: module.SolarEclipsePartialFootprintsNASABulletinSplitK,
central: module.SolarEclipseCentralPathNASABulletinSplitK,
local: module.GeometricLocalSolarEclipseOnDateNASABulletinSplitK,
})
}
// SolarEclipseMapSVGIAUSingleK 使用 IAU Single-K 模型绘制地图。
// SolarEclipseMapSVGIAUSingleK renders an IAU Single-K map.
func SolarEclipseMapSVGIAUSingleK(date time.Time, options SolarEclipseMapSVGOptions) (string, bool) {
module := solarEclipseMapModule(options)
return solarEclipseMapSVG(date, options, solarEclipseMapCalculators{
global: eclipsecore.SolarEclipseOnDateIAUSingleK,
global: module.SolarEclipseOnDateIAUSingleK,
panel: eclipsecore.SolarEclipseGeocentricPanelIAUSingleK,
partial: eclipsecore.SolarEclipsePartialFootprintsIAUSingleK,
central: eclipsecore.SolarEclipseCentralPathIAUSingleK,
local: eclipsecore.GeometricLocalSolarEclipseOnDateIAUSingleK,
partial: module.SolarEclipsePartialFootprintsIAUSingleK,
central: module.SolarEclipseCentralPathIAUSingleK,
local: module.GeometricLocalSolarEclipseOnDateIAUSingleK,
})
}
// solarEclipseMapModule 返回按选项月缘配置的核心模块,用于全局结果、中心带、偏食可见域外边界与食甚点站心面板;食分线、等时线与地心面板保持圆月面。
func solarEclipseMapModule(options SolarEclipseMapSVGOptions) *eclipsecore.Eclipse {
return eclipsecore.NewWithInstance(nil).WithLunarLimb(options.LunarLimb)
}
func solarEclipseMapSVG(
date time.Time,
options SolarEclipseMapSVGOptions,
@@ -211,6 +221,11 @@ func solarEclipseMapSVG(
if calculators.panel != nil {
geocentric, hasGeocentric = calculators.panel(date)
}
// 带宽与画出的南北界同源:南北界真正按月缘修正时取中心路径食甚点带宽。
if solarEclipseMapLimbLayers(partial, central, hasCentral, local, hasLocal).CentralLimits {
partial.Eclipse.PathWidthDefined = central.PathWidthDefined
partial.Eclipse.PathWidthKM = central.Greatest.WidthKM
}
// 几何(直射点、闭合弧、等时线取值)必须用民用时刻算;只有写出的文字换时标。
// 时刻标记按输出时标的整点取点(那里本就是另一个物理时刻),位置随整点标注移动。
civilPartial := partial
@@ -232,6 +247,22 @@ func solarEclipseMapSVG(
local, hasLocal, geocentric, hasGeocentric, options, projection), true
}
// solarEclipseMapLimbLayers 汇总各图层是否真正按月缘修正;各层彼此独立,页脚与带宽同步都读这里。
func solarEclipseMapLimbLayers(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central eclipsecore.SolarEclipsePath,
hasCentral bool,
local eclipsecore.LocalSolarEclipseInfo,
hasLocal bool,
) limbnote.SolarMapLayers {
return limbnote.SolarMapLayers{
GlobalContacts: partial.Eclipse.LunarLimbApplied,
LocalPanel: hasLocal && local.LunarLimbApplied,
CentralLimits: hasCentral && central.CentralLimitsLimbApplied,
PartialBoundary: partial.PartialBoundaryLimbApplied,
}
}
// solarEclipseMapHasCentralBand 报告该事件是否有中心食带(包络、限界或瞬时足迹任一存在)。
func solarEclipseMapHasCentralBand(partial eclipsecore.SolarEclipsePartialFootprintsInfo) bool {
return len(partial.CentralBandFootprints) > 0 || len(partial.CentralShadowFootprints) > 0 ||
@@ -479,7 +510,8 @@ func (plan solarEclipseMapPlan) render(
options.Width-44, options.Height-36)
fmt.Fprintf(&builder, `<text x="%.3f" y="47" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="24" font-weight="700" text-anchor="middle">%s</text>`,
float64(options.Width)/2, html.EscapeString(titleText))
writeSolarEclipseMapSummary(&builder, labels, partial.Eclipse, local, hasLocal, geocentric, hasGeocentric, options)
limbLayers := solarEclipseMapLimbLayers(partial, central, hasCentral, local, hasLocal)
writeSolarEclipseMapSummary(&builder, labels, partial.Eclipse, local, hasLocal, geocentric, hasGeocentric, limbLayers, options)
writeSolarEclipseMapSectionTitle(&builder, labels, layout, options, hasCentral)
frame.WriteOcean(&builder)
@@ -708,7 +740,7 @@ func solarEclipseAppendEquirectangularPoleRim(
for step := 1; step <= steps; step++ {
longitude := lastLongitude + delta*float64(step)/float64(steps)
result = append(result, svgmap.GeoPoint{
Longitude: normalizeDegree180(longitude),
Longitude: geodata.NormalizeLongitude(longitude),
Latitude: poleLatitude,
})
}
@@ -795,7 +827,7 @@ func solarEclipseSubsolarPoint(value time.Time) svgmap.GeoPoint {
ttJDE := solarEclipseTimeToTTJDE(value)
ra, dec := basic.HSunApparentRaDec(ttJDE)
ut1JDE := basic.TT2UT1(ttJDE)
longitude := normalizeDegree180(ra - basic.ApparentSiderealTime(ut1JDE)*15)
longitude := geodata.NormalizeLongitude(ra - basic.ApparentSiderealTime(ut1JDE)*15)
return svgmap.GeoPoint{Longitude: longitude, Latitude: dec}
}
@@ -1111,6 +1143,7 @@ func writeSolarEclipseMapSummary(
hasLocal bool,
geocentric eclipsecore.SolarEclipseGeocentricPanel,
hasGeocentric bool,
limbLayers limbnote.SolarMapLayers,
options SolarEclipseMapSVGOptions,
) {
start := info.PartialBeginOnEarth.In(options.Location)
@@ -1192,7 +1225,8 @@ func writeSolarEclipseMapSummary(
summaryX, conjunctionY, html.EscapeString(conjunctionText))
}
// 图上所有时刻都按展示时区,这里明确写出它与 UT 的偏差,避免被当成 UT 读。
zoneNote := timenote.Scale(options.TimeScale, maximum, options.Location, options.Language)
zoneNote := limbnote.Join(timenote.Scale(options.TimeScale, maximum, options.Location, options.Language),
limbnote.SolarMap(limbLayers, options.Language), options.Language)
labels.ReserveText(summaryX, conjunctionY+20, 11, zoneNote, "middle")
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#7b8794" font-family="Arial, sans-serif" font-size="11" text-anchor="middle">%s</text>`,
summaryX, conjunctionY+20, html.EscapeString(zoneNote))
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"time"
)
// 一秒步长请求曾经要跑满整条 18605 个瞬时足迹的扫描;夹到两分钟后应与默认请求同量级。
// 一秒步长请求应被夹到两分钟,耗时与默认请求同量级。
func BenchmarkSolarEclipseMapSVGPartialStepOneSecond(b *testing.B) {
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
options := SolarEclipseMapSVGOptions{
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"time"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
)
@@ -90,7 +91,7 @@ func writeSolarEclipseScaleBar(
if radius <= 0 || slot.Width <= 0 || slot.Height <= 0 {
return
}
kilometresPerPixel := (math.Pi / 2 * 6371.0088) / radius
kilometresPerPixel := (math.Pi / 2 * geodata.EarthMeanRadiusKM) / radius
target := 130.0 * kilometresPerPixel
step := solarEclipseScaleStep(target, slot.Width, func(candidate float64) float64 {
barWidth := candidate / kilometresPerPixel
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"time"
)
// 图例只能画一次:横版曾经在渲染器与横版面板里各画一次,而且两处传入的中心带参数还不一致。
// 图例只能画一次,横版渲染器与面板不得重复绘制。
func TestSolarEclipseMapLegendRendersOnce(t *testing.T) {
for _, test := range []struct {
name string
+203
View File
@@ -0,0 +1,203 @@
package svg
// 全球日食图开月缘时,面板数值与画出的界线同源;剖面失败时整张图保持圆月面。
import (
"fmt"
"math"
"strings"
"testing"
"time"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/moon/limb"
)
type failingEclipseLimb struct{}
func (failingEclipseLimb) LimbRadiusKM(_, _, _ float64) (float64, bool) { return 0, false }
func TestSolarEclipseMapLunarLimbPanelMatchesLimbResults(t *testing.T) {
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
profile := limb.Default()
circular, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{Location: time.UTC})
if !ok {
t.Fatal("circular map missing")
}
corrected, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{Location: time.UTC, LunarLimb: profile})
if !ok {
t.Fatal("limb map missing")
}
module := eclipsecore.NewWithInstance(nil).WithLunarLimb(profile)
global, _ := module.SolarEclipseOnDateNASABulletinSplitK(date)
if !global.LunarLimbApplied {
t.Fatal("global limb result not flagged")
}
// 与地图同一组路径选项,带宽才和画出的南北界同源。
path, ok := module.SolarEclipseCentralPathNASABulletinSplitK(date, eclipsecore.SolarEclipsePathOptions{
Step: 2 * time.Minute, TargetSpacingKM: 150, SkipCentralBand: true,
})
if !ok || !path.PathWidthDefined {
t.Fatal("limb central path missing")
}
limbWidth := fmt.Sprintf("%.1f km", path.Greatest.WidthKM)
circularGlobal, _ := eclipsecore.SolarEclipseOnDateNASABulletinSplitK(date)
circularWidth := fmt.Sprintf("%.1f km", circularGlobal.PathWidthKM)
if limbWidth == circularWidth {
t.Fatalf("limb width %s equals circular width; fixture no longer distinguishes them", limbWidth)
}
if !strings.Contains(corrected, limbWidth) || strings.Contains(corrected, circularWidth) {
t.Fatalf("limb map must show width %s and not %s", limbWidth, circularWidth)
}
if !strings.Contains(circular, circularWidth) {
t.Fatalf("circular map must show width %s", circularWidth)
}
local, ok := module.GeometricLocalSolarEclipseOnDateNASABulletinSplitK(date, global.GreatestLongitude, global.GreatestLatitude, 0)
if !ok || !local.LunarLimbApplied {
t.Fatal("limb local panel result missing or not flagged")
}
if magnitude := fmt.Sprintf("%.4f", local.Magnitude); !strings.Contains(corrected, magnitude) {
t.Fatalf("limb map must show the limb local magnitude %s", magnitude)
}
begin := global.PartialBeginOnEarth.UTC().Format("15:04:05")
if begin == circularGlobal.PartialBeginOnEarth.UTC().Format("15:04:05") {
t.Fatal("limb P1 rounds to the circular value; pick a fixture where it moves")
}
if !strings.Contains(corrected, begin) || strings.Contains(circular, begin) {
t.Fatalf("limb P1 %s must appear only in the limb map", begin)
}
if !strings.Contains(corrected, "按月缘剖面修正全球始末、食甚点站心值、中心带界线、可见域边界") {
t.Fatal("limb map does not declare every corrected layer")
}
}
// selectiveEclipseLimb 只在位置角 [low, high) 内给出剖面值并报告半径范围;外切扫描靠范围剪枝停在日心方向附近,需要全方向的判据退回圆月面。
type selectiveEclipseLimb struct{ low, high float64 }
func (s selectiveEclipseLimb) LimbRadiusKM(longitude, latitude, angle float64) (float64, bool) {
if angle < s.low || angle >= s.high {
return 0, false
}
return limb.Default().LimbRadiusKM(longitude, latitude, angle)
}
func (selectiveEclipseLimb) RadiusRangeKM() (float64, float64) { return limb.Default().RadiusRangeKM() }
func (selectiveEclipseLimb) AngleStepDeg() float64 { return limb.Default().AngleStepDeg() }
func TestSolarEclipseMapFooterDeclaresOnlyCorrectedLayers(t *testing.T) {
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
circular, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{Location: time.UTC})
if !ok {
t.Fatal("circular map missing")
}
// 位置角 45°~90° 只覆盖偏食外边界的接触点:全球始末、站心面板与中心界线都退回圆月面。
selective, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{Location: time.UTC, LunarLimb: selectiveEclipseLimb{45, 90}})
if !ok {
t.Fatal("selective-limb map missing")
}
if !strings.Contains(selective, "按月缘剖面修正可见域边界(食分线与等时线仍按圆月面)") {
t.Fatal("selective map must declare only the visibility boundary")
}
for _, layer := range []string{"全球始末", "食甚点站心值", "中心带界线"} {
if strings.Contains(selective, layer) {
t.Errorf("selective map declares uncorrected layer %s", layer)
}
}
circularGlobal, _ := eclipsecore.SolarEclipseOnDateNASABulletinSplitK(date)
if width := fmt.Sprintf("%.1f km", circularGlobal.PathWidthKM); !strings.Contains(selective, width) {
t.Errorf("selective map must keep the circular width %s while the limits stay circular", width)
}
if strings.Contains(circular, "按月缘剖面修正") {
t.Error("circular map declares a limb correction")
}
}
func TestEclipseSVGsWithFailingLimbStayCircular(t *testing.T) {
solarDate := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
lunarDate := time.Date(2025, time.September, 7, 0, 0, 0, 0, time.UTC)
failing := failingEclipseLimb{}
pairs := map[string][2]func() (string, bool){
"local solar": {
func() (string, bool) {
return LocalSolarEclipseSVG(solarDate, -96.8, 32.78, 0, LocalSolarEclipseSVGOptions{Location: time.UTC})
},
func() (string, bool) {
return LocalSolarEclipseSVG(solarDate, -96.8, 32.78, 0, LocalSolarEclipseSVGOptions{Location: time.UTC, LunarLimb: failing})
},
},
"solar map": {
func() (string, bool) {
return SolarEclipseMapSVG(solarDate, SolarEclipseMapSVGOptions{Location: time.UTC})
},
func() (string, bool) {
return SolarEclipseMapSVG(solarDate, SolarEclipseMapSVGOptions{Location: time.UTC, LunarLimb: failing})
},
},
"lunar": {
func() (string, bool) { return LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Location: time.UTC}) },
func() (string, bool) {
return LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Location: time.UTC, LunarLimb: failing})
},
},
"lunar map": {
func() (string, bool) {
return LunarEclipseMapSVG(lunarDate, LunarEclipseMapSVGOptions{Location: time.UTC})
},
func() (string, bool) {
return LunarEclipseMapSVG(lunarDate, LunarEclipseMapSVGOptions{Location: time.UTC, LunarLimb: failing})
},
},
"lunar detailed": {
func() (string, bool) {
return LunarEclipseDetailedSVG(lunarDate, LunarEclipseDetailedSVGOptions{Location: time.UTC})
},
func() (string, bool) {
return LunarEclipseDetailedSVG(lunarDate, LunarEclipseDetailedSVGOptions{Location: time.UTC, LunarLimb: failing})
},
},
}
for name, pair := range pairs {
circular, ok := pair[0]()
if !ok {
t.Fatalf("%s: circular chart missing", name)
}
failed, ok := pair[1]()
if !ok {
t.Fatalf("%s: failing-limb chart missing", name)
}
if failed != circular {
t.Errorf("%s: failing limb profile changed the chart", name)
}
}
}
// TestSolarEclipseMapContactMarkerMatchesCorrectedTime 检查全球图的 P1/P4 标记与页脚时刻同源:
// 月缘修正过时刻时,标记位置必须取修正后的接触点,而不是圆月面接触点。
func TestSolarEclipseMapContactMarkerMatchesCorrectedTime(t *testing.T) {
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
module := eclipsecore.NewWithInstance(nil).WithLunarLimb(limb.Default())
global, ok := module.SolarEclipseOnDateNASABulletinSplitK(date)
if !ok {
t.Fatal("global result missing")
}
if !global.LunarLimbApplied || !global.PartialBeginContact.Defined {
t.Fatal("the default limb profile must correct the global contacts")
}
circular, ok := eclipsecore.SolarEclipsePartialFootprintsNASABulletinSplitK(date, eclipsecore.SolarEclipsePartialFootprintOptions{})
if !ok {
t.Fatal("circular footprints missing")
}
// 修正后的接触点必须真的离开圆月面接触点,否则这条回归就测不到东西。
moved := math.Hypot(
(global.PartialBeginContact.Longitude-circular.P1.Longitude)*math.Cos(circular.P1.Latitude*math.Pi/180),
global.PartialBeginContact.Latitude-circular.P1.Latitude)
if moved < 1e-4 {
t.Fatalf("corrected P1 did not move (%.6f deg)", moved)
}
if delta := global.PartialBeginOnEarth.Sub(circular.P1.Time).Seconds(); math.Abs(delta) < 0.5 {
t.Fatalf("corrected P1 time moved by only %.3f s", delta)
}
}
+1
View File
@@ -50,6 +50,7 @@ func localSolarEclipseInfoFieldsFromBasic(
visibleThreshold := localSolarEclipseVisibilityThreshold(height, lat)
return LocalSolarEclipseInfo{
Model: mapBasicSolarEclipseModel(result.Model),
LunarLimbApplied: result.LunarLimbApplied,
Type: mapBasicSolarEclipseType(result.Type),
Longitude: lon,
Latitude: lat,
@@ -0,0 +1,6 @@
{
"2025-03-14/dec": "+02°40\u0026#39;54.6\u0026#34;",
"2025-03-14/ra": "11h38m23.0s",
"2026-03-03/dec": "+06°24\u0026#39;05.4\u0026#34;",
"2026-03-03/ra": "10h56m15.0s"
}
@@ -0,0 +1,30 @@
{
"2016-08-18 半影 1200x800 zh UTC/bytes": 241472,
"2016-08-18 半影 1200x800 zh UTC/md5": "b74c49dceb93ecb0d14054d46d29c2ac",
"2016-08-18 详细版 en/bytes": 261576,
"2016-08-18 详细版 en/md5": "d4799bb8e1ca37b62a720bba373b94eb",
"2025-03-14 全食 1200x800 zh UTC/bytes": 255228,
"2025-03-14 全食 1200x800 zh UTC/md5": "35803c58d004859285d2e6f5d2ae7f44",
"2025-03-14 全食 960x640 zh CST/bytes": 250322,
"2025-03-14 全食 960x640 zh CST/md5": "314dcafb7d30d76ae3b19fd6a048c9a8",
"2026-03-03 全食 1200x800 en UTC/bytes": 247498,
"2026-03-03 全食 1200x800 en UTC/md5": "3c47a2d2610542b12179543598f36531",
"2026-03-03 全食 1200x800 zh UTC/bytes": 247428,
"2026-03-03 全食 1200x800 zh UTC/md5": "e64e51c143f9190ac013f3b6bf329f70",
"2026-03-03 全食 960x640 en CST/bytes": 243630,
"2026-03-03 全食 960x640 en CST/md5": "5eb2a1bc355201f6e1cb6027c496a6c8",
"2026-03-03 全食 960x640 zh CST/bytes": 243568,
"2026-03-03 全食 960x640 zh CST/md5": "162ee33df427370b2d3055e66b8c2a00",
"2026-03-03 北极投影 zh/bytes": 159015,
"2026-03-03 北极投影 zh/md5": "d725d389b047a189a6b3b51dcb6d1291",
"2026-03-03 南极投影 zh/bytes": 84949,
"2026-03-03 南极投影 zh/md5": "d6ae40520fab72b9885751fb7907c3de",
"2026-03-03 详细版 en/bytes": 271080,
"2026-03-03 详细版 en/md5": "9f20d5c3a7408d0f8a0071ad08357e75",
"2026-03-03 详细版 zh/bytes": 270931,
"2026-03-03 详细版 zh/md5": "e938aa1d6eb4221eefdd1d0675ffb70f",
"2029-01-01 全食 960x640 en CST/bytes": 231513,
"2029-01-01 全食 960x640 en CST/md5": "2118fff17ef4dabfd09ac59bef4794e9",
"2029-01-01 详细版 zh/bytes": 257099,
"2029-01-01 详细版 zh/md5": "d9a1acb5188eb92706a00012dfc78448"
}
+438
View File
@@ -0,0 +1,438 @@
package svg
import (
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro"
"b612.me/astro/basic"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/timenote"
)
const (
transitLocalDefaultWidth = 960
transitLocalDefaultHeight = 680
)
// TransitLocalSVGOptions 控制站心凌日日面图。
// TransitLocalSVGOptions controls a topocentric planetary-transit solar-disk chart.
type TransitLocalSVGOptions struct {
// Width 和 Height 是 SVG 画布尺寸;宽度小于 640 或高度小于 480 时使用 960x680 默认值。
// Width and Height are SVG canvas dimensions. Width values below 640 and height values below 480 use the 960x680 defaults.
Width int
Height int
// Step 是行星轨迹采样步长;非正值使用两分钟。
// Step is the planet-track sampling step. Non-positive values use two minutes.
Step time.Duration
// Language 为 "en"(不区分大小写)时使用英文,否则使用中文。
// Language uses English for "en" (case-insensitive) and Chinese otherwise.
Language string
// Location 控制显示的事件时刻;nil 使用 date.Location()。
// Location controls displayed event times. Nil uses date.Location().
Location *time.Location
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 要求 Location 为 nil 或 UTC,否则返回 false。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 requires Location to be nil or UTC, otherwise the renderer returns false.
TimeScale astro.TimeScale
// 空文本字段使用本地化的自动标签。
// Empty text fields use localized automatic labels.
Title string
FooterNote string
}
// TransitLocalSVG 绘制距 date 最近一次水星或金星凌日在观测地(经度 lon、纬度 lat,东经北纬为正,海拔 height 米)
// 的站心日面图:上北左东的天空视向,画出行星中心轨迹、各接触与凌甚时的行星圆面,以及接触时刻表。
// 轨迹上太阳中心在视地平下(计入标准大气折射)的部分画成灰色虚线。该地几何上看不到凌日、找不到凌日或选项无效时返回 false。
// TransitLocalSVG renders the topocentric solar-disk view of the Mercury or Venus transit closest to date, seen from
// longitude lon and latitude lat (east and north positive) at height metres: north up and east left as on the sky, with
// the planet-centre track, the planet disk at each contact and at greatest, and a contact table. Track parts where the
// Sun's centre is below the apparent horizon (standard refraction) are drawn grey and dashed. It returns false when the
// site sees no geometric transit, no transit is found or the options are invalid.
func TransitLocalSVG(planet TransitPlanet, date time.Time, lon, lat, height float64, options TransitLocalSVGOptions) (string, bool) {
transit, ok := closestPlanetTransit(planet, date)
if !ok {
return "", false
}
if options.TimeScale == astro.TimeScaleUT1 {
if options.Location != nil && options.Location != time.UTC {
return "", false
}
options.Location = time.UTC
}
local := basic.PlanetTransitLocal(transit, basic.Observer{Longitude: lon, Latitude: lat, Height: height})
if !local.Valid {
return "", false
}
if options.Step <= 0 {
options.Step = 2 * time.Minute
}
frames := basic.PlanetTransitLocalFrames(transit, local, options.Step.Hours()/24)
if len(frames) < 2 {
return "", false
}
options = normalizeTransitLocalSVGOptions(date, options)
return renderTransitLocalSVG(planet, local, frames, options), true
}
func normalizeTransitLocalSVGOptions(date time.Time, options TransitLocalSVGOptions) TransitLocalSVGOptions {
if options.Width < 640 {
options.Width = transitLocalDefaultWidth
}
if options.Height < 480 {
options.Height = transitLocalDefaultHeight
}
if strings.EqualFold(options.Language, "en") {
options.Language = "en"
} else {
options.Language = "zh"
}
if options.Location == nil {
options.Location = date.Location()
}
return options
}
// transitLocalEvent 是日面上标注的一个接触或凌甚。
type transitLocalEvent struct {
label, name string
contact basic.PlanetTransitLocalContact
separation float64
}
func transitLocalEvents(local basic.PlanetTransitLocalResult, language string) []transitLocalEvent {
names := []string{"一触", "二触", "凌甚", "三触", "四触"}
if language == "en" {
names = []string{"C1", "C2", "Greatest", "C3", "C4"}
}
external := local.SunSemidiameterArcsec + local.PlanetSemidiameterArcsec
internal := local.SunSemidiameterArcsec - local.PlanetSemidiameterArcsec
candidates := []transitLocalEvent{
{"C1", names[0], local.ExternalIngress, external},
{"C2", names[1], local.InternalIngress, internal},
{"G", names[2], local.Greatest, local.MinimumSeparationArcsec},
{"C3", names[3], local.InternalEgress, internal},
{"C4", names[4], local.ExternalEgress, external},
}
events := candidates[:0]
for _, event := range candidates {
if event.contact.Valid {
events = append(events, event)
}
}
return events
}
// transitLocalSunUp 判断太阳中心几何高度 altitude 是否在视地平上(标准大气折射)。
func transitLocalSunUp(altitude float64) bool {
return altitude > -basic.RefractionFromApparentAltitude(0, transitMapRefractionPressureHPa, transitMapRefractionTemperatureC)
}
func transitDisplayTime(value time.Time, scale astro.TimeScale, location *time.Location) time.Time {
if value.IsZero() {
return value
}
if scale == astro.TimeScaleUT1 {
return basic.UT1FromUTC(value)
}
return value.In(location)
}
func renderTransitLocalSVG(
planet TransitPlanet,
local basic.PlanetTransitLocalResult,
frames []basic.PlanetTransitLocalFrame,
options TransitLocalSVGOptions,
) string {
width, height := float64(options.Width), float64(options.Height)
at := func(jd float64) time.Time {
return transitDisplayTime(basic.JD2DateByZone(jd, time.UTC, false), options.TimeScale, options.Location)
}
title := options.Title
if title == "" {
date := at(local.Greatest.JD).Format("2006-01-02")
if options.Language == "en" {
title = fmt.Sprintf("%s Transit of %s, Local Circumstances", date, transitMapPlanetName(planet, "en"))
} else {
title = fmt.Sprintf("%s %s凌日站心图", date, transitMapPlanetName(planet, "zh"))
}
}
site := transitLocalSiteText(local.Observer, options.Language)
summary := transitLocalSummaryText(local, options.Language)
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s">`,
options.Width, options.Height, options.Width, options.Height, html.EscapeString(title))
b.WriteString(`<defs><radialGradient id="transit-sun" cx="40%" cy="34%" r="64%"><stop offset="0%" stop-color="#fff8bb"/><stop offset="58%" stop-color="#ffd55f"/><stop offset="100%" stop-color="#f5a623"/></radialGradient></defs>`)
b.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
fmt.Fprintf(&b, `<rect x="22" y="18" width="%.3f" height="%.3f" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`, width-44, height-36)
titleText := title
if options.Title != "" {
titleText = svgchart.EllipsizeText(title, width-52, 24)
}
fmt.Fprintf(&b, `<text x="%.3f" y="47" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="24" font-weight="700" text-anchor="middle">%s</text>`,
width/2, html.EscapeString(titleText))
for index, line := range []string{site, summary} {
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="#333" font-family="Georgia, 'Times New Roman', serif" font-size="13" text-anchor="middle">%s</text>`,
width/2, 72+float64(index)*19, html.EscapeString(line))
}
// 日面区占左侧,接触表占右侧。
panelWidth := 300.0
top, bottom := 112.0, height-86
diskLeft, diskRight := 46.0, width-panelWidth-60
cx, cy := (diskLeft+diskRight)/2, (top+bottom)/2
extent := local.SunSemidiameterArcsec + 2.2*local.PlanetSemidiameterArcsec + 60
scale := math.Min((diskRight-diskLeft)/2, (bottom-top)/2) / extent
mapX := func(x float64) float64 { return cx - x*scale } // 东在左
mapY := func(y float64) float64 { return cy - y*scale }
sunRadius := local.SunSemidiameterArcsec * scale
fmt.Fprintf(&b, `<circle class="transit-sun" cx="%.3f" cy="%.3f" r="%.3f" fill="url(#transit-sun)" stroke="#c78211" stroke-width="1.4"/>`, cx, cy, sunRadius)
writeTransitLocalAxes(&b, cx, cy, sunRadius, options.Language)
writeTransitLocalTrack(&b, frames, mapX, mapY)
events := transitLocalEvents(local, options.Language)
planetRadius := math.Max(local.PlanetSemidiameterArcsec*scale, 2)
table := &svgchart.LabelTable{}
for _, event := range events {
x, y := transitLocalEventPosition(frames, event.contact.JD, mapX, mapY)
fill, stroke := "#1d1d1d", "#000000"
if !transitLocalSunUp(event.contact.SunAltitudeDeg) {
fill, stroke = "#9a9a9a", "#6d6d6d"
}
fmt.Fprintf(&b, `<circle class="transit-planet-%s" cx="%.3f" cy="%.3f" r="%.3f" fill="%s" fill-opacity="0.92" stroke="%s" stroke-width="0.8"/>`,
strings.ToLower(event.label), x, y, planetRadius, fill, stroke)
table.Reserve(x-planetRadius, y-planetRadius, 2*planetRadius, 2*planetRadius)
}
for _, event := range events {
x, y := transitLocalEventPosition(frames, event.contact.JD, mapX, mapY)
// 标签放在远离日心的一侧,避开日面内的轨迹与圆面。
dx, dy := x-cx, y-cy
norm := math.Hypot(dx, dy)
if norm < 1e-6 {
dx, dy, norm = 0, -1, 1
}
text := event.name + " " + at(event.contact.JD).Format("15:04:05")
var placement svgchart.LabelPlacement
placed := false
// 相邻接触相隔仅数分钟时近处位次全被占,逐圈外推并画引线,不得丢标签。
for ring := 0; ring < 6 && !placed; ring++ {
offset := planetRadius + 12 + float64(ring)*16
candidates := []svgchart.LabelPlacement{}
for _, turn := range []float64{0, 0.5, -0.5, 1, -1} {
ux := dx/norm*math.Cos(turn) - dy/norm*math.Sin(turn)
uy := dx/norm*math.Sin(turn) + dy/norm*math.Cos(turn)
anchor := "start"
if ux < -0.3 {
anchor = "end"
} else if ux < 0.3 {
anchor = "middle"
}
candidate := svgchart.LabelPlacement{X: x + ux*offset, Y: y + uy*offset + 4, Anchor: anchor}
// 标签平移进白色图框内、接触表左侧,不能伸出画面。
box := svgchart.LabelTextBox(candidate.X, candidate.Y, 11, text, anchor)
candidate.X += math.Max(0, 30-box.X) - math.Max(0, box.X+box.Width-(width-panelWidth-44))
candidate.Y += math.Max(0, top-8-box.Y) - math.Max(0, box.Y+box.Height-(height-30))
candidates = append(candidates, candidate)
}
placement, placed = table.Place(text, 11, candidates)
if placed && transitLocalLabelGap(placement, text, x, y) > planetRadius+16 {
// 引线连到标签框上离接触点最近的位置。
box := svgchart.LabelTextBox(placement.X, placement.Y, 11, text, placement.Anchor)
endX := math.Max(box.X, math.Min(x, box.X+box.Width))
endY := math.Max(box.Y, math.Min(y, box.Y+box.Height))
fmt.Fprintf(&b, `<line class="transit-event-leader" x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#555" stroke-width="0.7" opacity="0.7"/>`,
x, y, endX, endY)
}
}
if !placed {
continue
}
fmt.Fprintf(&b, `<text class="transit-event-label" x="%.3f" y="%.3f" fill="#222" stroke="#ffffff" stroke-width="2.6" paint-order="stroke" font-family="Arial, sans-serif" font-size="11" text-anchor="%s">%s</text>`,
placement.X, placement.Y, placement.Anchor, html.EscapeString(text))
}
writeTransitLocalContactTable(&b, local, events, at, width-panelWidth-36, top+10, panelWidth, options.Language)
writeTransitLocalFooter(&b, local, options, width, height)
b.WriteString(`</svg>`)
return b.String()
}
// transitLocalLabelGap 是接触点到标签框的最近距离。
func transitLocalLabelGap(placement svgchart.LabelPlacement, text string, x, y float64) float64 {
box := svgchart.LabelTextBox(placement.X, placement.Y, 11, text, placement.Anchor)
return math.Hypot(x-math.Max(box.X, math.Min(x, box.X+box.Width)), y-math.Max(box.Y, math.Min(y, box.Y+box.Height)))
}
func transitLocalSiteText(observer basic.Observer, language string) string {
ew, ns := "E", "N"
if language != "en" {
ew, ns = "东经", "北纬"
}
lon, lat := observer.Longitude, observer.Latitude
if lon < 0 {
ew = map[bool]string{true: "W", false: "西经"}[language == "en"]
lon = -lon
}
if lat < 0 {
ns = map[bool]string{true: "S", false: "南纬"}[language == "en"]
lat = -lat
}
if language == "en" {
return fmt.Sprintf("Site %.4f°%s %.4f°%s, height %.0f m", lon, ew, lat, ns, observer.Height)
}
return fmt.Sprintf("观测地 %s %.4f° %s %.4f°,海拔 %.0f 米", ew, lon, ns, lat, observer.Height)
}
func transitLocalSummaryText(local basic.PlanetTransitLocalResult, language string) string {
kind := map[bool]string{true: "完整凌日", false: "掠凌(无内切接触)"}[local.HasInternal]
visible := map[bool]string{true: "可见", false: "全程太阳在地平下"}[local.Visible]
if language == "en" {
kind = map[bool]string{true: "full transit", false: "grazing transit (no internal contacts)"}[local.HasInternal]
visible = map[bool]string{true: "visible", false: "Sun below the horizon throughout"}[local.Visible]
return fmt.Sprintf("Topocentric %s, %s | minimum separation %.1f″ | Sun %.1f″ | planet %.2f″",
kind, visible, local.MinimumSeparationArcsec, local.SunSemidiameterArcsec, local.PlanetSemidiameterArcsec)
}
return fmt.Sprintf("站心%s,%s | 最小角距 %.1f″ | 太阳视半径 %.1f″ | 行星视半径 %.2f″",
kind, visible, local.MinimumSeparationArcsec, local.SunSemidiameterArcsec, local.PlanetSemidiameterArcsec)
}
// writeTransitLocalAxes 画过日心的南北、东西虚线与方向字。
func writeTransitLocalAxes(b *strings.Builder, cx, cy, radius float64, language string) {
reach := radius + 18
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#8b6a2b" stroke-width="0.8" stroke-dasharray="3 4" stroke-opacity="0.7"/>`, cx, cy-reach, cx, cy+reach)
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#8b6a2b" stroke-width="0.8" stroke-dasharray="3 4" stroke-opacity="0.7"/>`, cx-reach, cy, cx+reach, cy)
labels := [4]string{"北", "南", "东", "西"}
if language == "en" {
labels = [4]string{"N", "S", "E", "W"}
}
for index, item := range []struct{ x, y float64 }{
{cx, cy - reach - 6}, {cx, cy + reach + 16}, {cx - reach - 12, cy + 5}, {cx + reach + 12, cy + 5},
} {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#6b5222" font-family="Georgia, 'Times New Roman', serif" font-size="13" text-anchor="middle">%s</text>`,
item.x, item.y, labels[index])
}
}
// writeTransitLocalTrack 画行星中心轨迹:太阳在视地平上的段为实线,地平下为灰色虚线。
func writeTransitLocalTrack(b *strings.Builder, frames []basic.PlanetTransitLocalFrame, mapX, mapY func(float64) float64) {
start := 0
for index := 1; index <= len(frames); index++ {
if index < len(frames) && transitLocalSunUp(frames[index].SunAltitudeDeg) == transitLocalSunUp(frames[start].SunAltitudeDeg) {
continue
}
// 段尾取下一段首点,相邻两段首尾相接。
end := index
if end == len(frames) {
end--
}
class, color, dash := "transit-track", "#24518a", "none"
if !transitLocalSunUp(frames[start].SunAltitudeDeg) {
class, color, dash = "transit-track-below-horizon", "#8a8a8a", "5 4"
}
fmt.Fprintf(b, `<path class="%s" d="`, class)
for position := start; position <= end; position++ {
command := "L"
if position == start {
command = "M"
}
fmt.Fprintf(b, `%s %.3f %.3f `, command, mapX(frames[position].PlanetXArcsec), mapY(frames[position].PlanetYArcsec))
}
fmt.Fprintf(b, `" fill="none" stroke="%s" stroke-width="1.4" stroke-dasharray="%s" stroke-linecap="round"/>`, color, dash)
start = index
}
}
// transitLocalEventPosition 取时刻最接近 jd 的采样帧位置;接触与凌甚时刻本身在采样里。
func transitLocalEventPosition(frames []basic.PlanetTransitLocalFrame, jd float64, mapX, mapY func(float64) float64) (float64, float64) {
best := 0
for index := range frames {
if math.Abs(frames[index].JD-jd) < math.Abs(frames[best].JD-jd) {
best = index
}
}
return mapX(frames[best].PlanetXArcsec), mapY(frames[best].PlanetYArcsec)
}
func writeTransitLocalContactTable(
b *strings.Builder,
local basic.PlanetTransitLocalResult,
events []transitLocalEvent,
at func(float64) time.Time,
x, y, width float64,
language string,
) {
heading := [4]string{"接触时刻", "时刻", "太阳高度", "方位"}
if language == "en" {
heading = [4]string{"Contacts", "Time", "Sun alt.", "Az."}
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="700">%s</text>`, x, y, heading[0])
columns := []float64{x, x + 78, x + 176, x + 252}
rowY := y + 26
for index, text := range []string{"", heading[1], heading[2], heading[3]} {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#555" font-family="Arial, sans-serif" font-size="11">%s</text>`, columns[index], rowY, text)
}
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#bbb" stroke-width="0.8"/>`, x, rowY+6, x+width-8, rowY+6)
for _, event := range events {
rowY += 22
fill := "#222"
if !transitLocalSunUp(event.contact.SunAltitudeDeg) {
fill = "#8a8a8a"
}
cells := []string{
event.name,
at(event.contact.JD).Format("15:04:05"),
fmt.Sprintf("%+.1f°", event.contact.SunAltitudeDeg),
fmt.Sprintf("%.1f°", event.contact.SunAzimuthDeg),
}
for index, text := range cells {
fmt.Fprintf(b, `<text class="transit-contact-row" x="%.3f" y="%.3f" fill="%s" font-family="Arial, sans-serif" font-size="12">%s</text>`,
columns[index], rowY, fill, html.EscapeString(text))
}
}
rowY += 30
lines := []string{}
duration := time.Duration((local.ExternalEgress.JD - local.ExternalIngress.JD) * 86400 * float64(time.Second)).Round(time.Second)
if language == "en" {
lines = append(lines, "Duration C1–C4 "+duration.String())
} else {
lines = append(lines, "一触至四触历时 "+duration.String())
}
for _, item := range []struct {
jd float64
zh, en string
}{{local.Sunrise, "凌日中日出 ", "Sunrise during transit "}, {local.Sunset, "凌日中日落 ", "Sunset during transit "}} {
if item.jd == 0 {
continue
}
label := item.zh
if language == "en" {
label = item.en
}
lines = append(lines, label+at(item.jd).Format("15:04:05"))
}
for _, line := range lines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#333" font-family="Arial, sans-serif" font-size="12">%s</text>`, x, rowY, html.EscapeString(line))
rowY += 19
}
}
func writeTransitLocalFooter(b *strings.Builder, local basic.PlanetTransitLocalResult, options TransitLocalSVGOptions, width, height float64) {
note := options.FooterNote
if note == "" {
if options.Language == "en" {
note = "Sky view, north up and east left. Topocentric contacts; Sun altitudes are geometric centre altitudes, grey parts have the Sun's centre below the apparent horizon (standard refraction)."
} else {
note = "天空视向,上北左东;站心接触时刻,太阳高度为日心几何高度,灰色部分太阳中心在视地平下(计入标准大气折射)。"
}
}
scaleNote := timenote.Scale(options.TimeScale, basic.JD2DateByZone(local.Greatest.JD, time.UTC, false), options.Location, options.Language)
lines := svgchart.FooterLinesWithScale(svgchart.WrapText(note, width-80, 12), scaleNote, width-80, 12, 3)
baselines, _ := svgchart.FooterBlock(len(lines), height, 12, svgchart.FooterLineHeight(12), svgchart.FooterBottomPadding(12)+18)
for index, line := range lines {
fmt.Fprintf(b, `<text x="40" y="%.3f" fill="#555" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
baselines[index], html.EscapeString(line))
}
}
+82
View File
@@ -0,0 +1,82 @@
package svg
import (
"regexp"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro"
"b612.me/astro/internal/svgchart"
)
func TestTransitLocalSVG(t *testing.T) {
venus := time.Date(2012, 6, 6, 0, 0, 0, 0, time.UTC)
chart, ok := TransitLocalSVG(TransitVenus, venus, 116.4, 39.9, 50, TransitLocalSVGOptions{Location: time.FixedZone("UTC+8", 8*3600)})
if !ok {
t.Fatal("Beijing 2012 render failed")
}
for _, want := range []string{"金星凌日站心图", `class="transit-track"`, "transit-planet-c1", "transit-planet-c2",
"transit-planet-g", "transit-planet-c3", "transit-planet-c4", "一触 06:10:13", "四触 12:49:17"} {
if !strings.Contains(chart, want) {
t.Fatalf("Beijing: missing %s", want)
}
}
if strings.Contains(chart, "transit-track-below-horizon") || strings.Contains(chart, "NaN") {
t.Fatal("Beijing sees the whole transit in daylight")
}
// 伦敦:日出时凌日已在进行,日出前的轨迹画成地平下虚线。
chart, ok = TransitLocalSVG(TransitVenus, venus, -0.1, 51.5, 20, TransitLocalSVGOptions{Language: "en"})
if !ok || !strings.Contains(chart, "transit-track-below-horizon") || !strings.Contains(chart, "Sunrise during transit") ||
!strings.Contains(chart, "Local Circumstances") {
t.Fatal("London must show the below-horizon track and sunrise row in English")
}
// 1999 水星掠凌,南纬 60° 只有一触、凌甚、四触。
chart, ok = TransitLocalSVG(TransitMercury, time.Date(1999, 11, 15, 0, 0, 0, 0, time.UTC), 150, -60, 0, TransitLocalSVGOptions{})
if !ok || !strings.Contains(chart, "掠凌") || strings.Contains(chart, "transit-planet-c2") || strings.Contains(chart, "transit-planet-c3") {
t.Fatal("grazing site must omit internal contacts")
}
if !strings.Contains(chart, "1999-11-15") {
t.Fatal("same-day query must find the 1999-11-15 transit")
}
// 一、二触相隔约 2 分钟,二触标签不得被避让丢弃。
chart, ok = TransitLocalSVG(TransitMercury, time.Date(2032, 11, 13, 0, 0, 0, 0, time.UTC), 116.4074, 39.9042, 0,
TransitLocalSVGOptions{Location: time.FixedZone("UTC+8", 8*3600)})
if !ok {
t.Fatal("Beijing 2032 render failed")
}
for _, want := range []string{">一触 ", ">二触 ", ">凌甚 ", ">三触 ", ">四触 "} {
if strings.Count(chart, want) != 1 {
t.Fatalf("Beijing 2032: label %s must appear exactly once on the disk", want)
}
}
for _, match := range regexp.MustCompile(`class="transit-event-label" x="([0-9.-]+)" y="([0-9.-]+)"[^>]*text-anchor="(\w+)">([^<]*)<`).FindAllStringSubmatch(chart, -1) {
x, _ := strconv.ParseFloat(match[1], 64)
y, _ := strconv.ParseFloat(match[2], 64)
if box := svgchart.LabelTextBox(x, y, 11, match[4], match[3]); box.X < 30 || box.X+box.Width > 960-344 {
t.Fatalf("Beijing 2032: label %q leaves the disk frame (box x %.1f..%.1f)", match[4], box.X, box.X+box.Width)
}
}
}
func TestTransitLocalSVGOptions(t *testing.T) {
venus := time.Date(2012, 6, 6, 0, 0, 0, 0, time.UTC)
if _, ok := TransitLocalSVG(3, venus, 0, 0, 0, TransitLocalSVGOptions{}); ok {
t.Fatal("unsupported planet must fail")
}
if _, ok := TransitLocalSVG(TransitVenus, venus, 0, 0, 0, TransitLocalSVGOptions{
TimeScale: astro.TimeScaleUT1, Location: time.FixedZone("CST", 8*3600)}); ok {
t.Fatal("UT1 with non-UTC location must fail")
}
if _, ok := TransitLocalSVG(TransitVenus, venus, 0, 95, 0, TransitLocalSVGOptions{}); ok {
t.Fatal("invalid latitude must fail")
}
chart, ok := TransitLocalSVG(TransitVenus, venus, 116.4, 39.9, 0, TransitLocalSVGOptions{TimeScale: astro.TimeScaleUT1, Title: "X"})
if !ok || !strings.Contains(chart, ">X<") || !strings.Contains(chart, "UT1") {
t.Fatal("UT1 labels and custom title")
}
}
+536
View File
@@ -0,0 +1,536 @@
package svg
import (
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro"
"b612.me/astro/basic"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
"b612.me/astro/internal/timenote"
)
const (
transitMapDefaultWidth = 960
transitMapDefaultHeight = 640
)
// TransitPlanet 选择凌日行星 / selects the transiting planet.
type TransitPlanet int
const (
// TransitMercury 是水星凌日 / Mercury transit.
TransitMercury TransitPlanet = 1
// TransitVenus 是金星凌日 / Venus transit.
TransitVenus TransitPlanet = 2
)
// TransitIsochrones 是等时线开关的位组合 / bit set of contact isochrones.
type TransitIsochrones uint8
const (
// TransitIsochroneC1 画一触等时线 / draw C1 isochrones.
TransitIsochroneC1 TransitIsochrones = 1 << iota
// TransitIsochroneC2 画二触等时线;掠凌没有内切接触时忽略 / draw C2 isochrones, ignored for a grazing transit.
TransitIsochroneC2
// TransitIsochroneC3 画三触等时线;掠凌没有内切接触时忽略 / draw C3 isochrones, ignored for a grazing transit.
TransitIsochroneC3
// TransitIsochroneC4 画四触等时线 / draw C4 isochrones.
TransitIsochroneC4
// TransitIsochroneExternal 是一触与四触 / C1 and C4.
TransitIsochroneExternal = TransitIsochroneC1 | TransitIsochroneC4
// TransitIsochroneAll 是全部四个接触 / all four contacts.
TransitIsochroneAll = TransitIsochroneC1 | TransitIsochroneC2 | TransitIsochroneC3 | TransitIsochroneC4
)
// TransitMapSVGOptions 控制全球凌日可见图。
// TransitMapSVGOptions controls a global planetary-transit visibility map.
type TransitMapSVGOptions struct {
// Width 和 Height 是 SVG 画布尺寸;宽度小于 640 或高度小于 420 时使用 960x640 默认值。
// Width and Height are SVG canvas dimensions. Width values below 640 and height values below 420 use the 960x640 defaults.
Width int
Height int
// Language 为 "en"(不区分大小写)时使用英文,否则使用中文。
// Language uses English for "en" (case-insensitive) and Chinese otherwise.
Language string
// Location 控制显示的事件时刻;nil 使用 date.Location()。
// Location controls displayed event times. Nil uses date.Location().
Location *time.Location
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 要求 Location 为 nil 或 UTC,否则返回 false。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 requires Location to be nil or UTC, otherwise the renderer returns false.
TimeScale astro.TimeScale
// Projection 选择地图投影;零值使用等经纬投影,正射投影视点取凌甚时的太阳直射点。
// Projection selects the map projection. The zero value uses equirectangular; orthographic centres on the subsolar point at greatest.
Projection EclipseMapProjection
// Isochrones 选择要画的站心接触等时线,可按位组合;零值不画(默认关闭,需显式开启)。
// Isochrones selects the topocentric contact isochrones to draw and may combine flags. The zero value draws none (off by default).
Isochrones TransitIsochrones
// IsochroneStep 是等时线的时间间隔;非正值按行星自动取(水星 20 秒、金星 1 分钟)。
// IsochroneStep is the isochrone spacing. Non-positive values select 20 s for Mercury and 1 min for Venus.
IsochroneStep time.Duration
// 空文本字段使用本地化的自动标签。
// Empty text fields use localized automatic labels.
Title string
FooterNote string
}
// TransitMapSVG 绘制距 date 最近一次水星或金星凌日的全球可见图:按一触、凌甚、四触太阳中心在视地平上
// (计入标准大气折射)的区域分区;Options.Isochrones 显式开启时叠加所选接触的站心等时线。找不到凌日或选项无效时返回 false。
// TransitMapSVG renders the global visibility of the Mercury or Venus transit closest to date: regions follow where the
// Sun's centre is above the apparent horizon (standard refraction) at C1, greatest and C4. Topocentric isochrones of the
// selected contacts are overlaid only when Options.Isochrones enables them. It returns false when no transit is found
// or the options are invalid.
func TransitMapSVG(planet TransitPlanet, date time.Time, options TransitMapSVGOptions) (string, bool) {
transit, ok := closestPlanetTransit(planet, date)
if !ok || !validEclipseMapProjection(options.Projection) {
return "", false
}
if options.TimeScale == astro.TimeScaleUT1 {
if options.Location != nil && options.Location != time.UTC {
return "", false
}
options.Location = time.UTC
}
options = normalizeTransitMapSVGOptions(date, options)
if !transit.HasInternal {
options.Isochrones &^= TransitIsochroneC2 | TransitIsochroneC3
}
options.Isochrones &= TransitIsochroneAll
if options.IsochroneStep <= 0 {
options.IsochroneStep = time.Minute
if planet == TransitMercury {
options.IsochroneStep = 20 * time.Second
}
}
projection := internalEclipseMapProjection(options.Projection)
if projection == "" {
projection = svgmap.ProjectionEquirectangular
}
return renderTransitMapSVG(planet, transit, options, projection), true
}
// closestPlanetTransit 返回距 date 最近的一次地心凌日;行星无效或找不到时第二个返回值为 false。
func closestPlanetTransit(planet TransitPlanet, date time.Time) (basic.PlanetTransitResult, bool) {
jd := basic.Date2JD(date.UTC())
var transit basic.PlanetTransitResult
switch planet {
case TransitMercury:
transit = basic.ClosestMercuryTransit(jd)
case TransitVenus:
transit = basic.ClosestVenusTransit(jd)
default:
return transit, false
}
return transit, transit.Valid
}
func normalizeTransitMapSVGOptions(date time.Time, options TransitMapSVGOptions) TransitMapSVGOptions {
if options.Width < 640 {
options.Width = transitMapDefaultWidth
}
if options.Height < 420 {
options.Height = transitMapDefaultHeight
}
if strings.EqualFold(options.Language, "en") {
options.Language = "en"
} else {
options.Language = "zh"
}
if options.Location == nil {
options.Location = date.Location()
}
return options
}
// transitMapContacts 是地心一触、凌甚、四触的 UTC 时刻。
type transitMapContacts struct {
start, greatest, end time.Time
internalStart, internalEnd time.Time
hasInternal bool
}
func transitMapContactsOf(transit basic.PlanetTransitResult) transitMapContacts {
at := func(jd float64) time.Time { return basic.JD2DateByZone(jd, time.UTC, false) }
contacts := transitMapContacts{
start: at(transit.ExternalIngress),
greatest: at(transit.Greatest),
end: at(transit.ExternalEgress),
hasInternal: transit.HasInternal,
}
if transit.HasInternal {
contacts.internalStart, contacts.internalEnd = at(transit.InternalIngress), at(transit.InternalEgress)
}
return contacts
}
func renderTransitMapSVG(
planet TransitPlanet,
transit basic.PlanetTransitResult,
options TransitMapSVGOptions,
projection svgmap.Projection,
) string {
contacts := transitMapContactsOf(transit)
// 底部比月食图多留两行:图例下要放可折行的说明与时标声明。
frame := eclipseMapFrame(options.Width, options.Height, projection, 142, 122)
if projection == svgmap.ProjectionOrthographic {
center := solarEclipseSubsolarPoint(contacts.greatest)
frame.CenterLongitude, frame.CenterLatitude = center.Longitude, center.Latitude
}
title := options.Title
if title == "" {
date := transitMapDisplayTime(contacts.greatest, options).Format("2006-01-02")
if options.Language == "en" {
title = fmt.Sprintf("%s Transit of %s Global Visibility", date, transitMapPlanetName(planet, "en"))
} else {
title = fmt.Sprintf("%s %s凌日全球可见图", date, transitMapPlanetName(planet, "zh"))
}
}
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s">`,
options.Width, options.Height, options.Width, options.Height, html.EscapeString(title))
b.WriteString(`<defs>`)
writeTransitMapDefinitions(&b, frame, contacts)
b.WriteString(`</defs>`)
b.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
fmt.Fprintf(&b, `<rect x="22" y="18" width="%d" height="%d" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`,
options.Width-44, options.Height-36)
titleText := title
if options.Title != "" {
titleText = svgchart.EllipsizeText(title, float64(options.Width)-52, 24)
}
fmt.Fprintf(&b, `<text x="%.3f" y="47" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="24" font-weight="700" text-anchor="middle">%s</text>`,
float64(options.Width)/2, html.EscapeString(titleText))
writeTransitMapSummary(&b, transit, contacts, options)
frame.WriteOcean(&b)
frame.WriteGraticule(&b, "transit-map-clip")
frame.WriteLand(&b, "transit-map-clip")
writeTransitMapRegions(&b, frame, contacts)
if options.Isochrones != 0 {
writeTransitMapIsochrones(&b, frame, transit, options)
}
frame.WriteFrame(&b)
legendRows := writeTransitMapLegend(&b, frame, float64(options.Width), options)
writeTransitMapFooter(&b, frame, options, projection, contacts.greatest, legendRows)
b.WriteString(`</svg>`)
return b.String()
}
func transitMapPlanetName(planet TransitPlanet, language string) string {
if language == "en" {
if planet == TransitMercury {
return "Mercury"
}
return "Venus"
}
if planet == TransitMercury {
return "水星"
}
return "金星"
}
// transitMapDisplayTime 把 UTC 时刻换成图上显示的时刻:UT1 时标给 UT1 读数,否则换到 Location。
func transitMapDisplayTime(value time.Time, options TransitMapSVGOptions) time.Time {
return transitDisplayTime(value, options.TimeScale, options.Location)
}
// transitMapSunUpPath 返回 value 时刻太阳中心在视地平上的区域面路径(evenodd)与地平边界。
// 视地平计入标准大气折射:太阳中心距直射点小于 90°+34′ 即可见,太阳视差 8.8″ 忽略。
// 半径超过 90° 的球冠环在拆分日界线时分不清内外,所以改画对日点为心、半径不足 90° 的不可见冠,
// 再用图框减去它得到可见区。
func transitMapSunUpPath(value time.Time, frame svgmap.Frame) (string, []svgmap.GeoPoint) {
subsolar := solarEclipseSubsolarPoint(value)
antisolar := svgmap.GeoPoint{Longitude: geodata.NormalizeLongitude(subsolar.Longitude + 180), Latitude: -subsolar.Latitude}
radius := 90 - basic.RefractionFromApparentAltitude(0, transitMapRefractionPressureHPa, transitMapRefractionTemperatureC)
boundary := geodata.SphericalCircle(antisolar, radius, 360)
closed := append(append([]svgmap.GeoPoint(nil), boundary...), boundary[0])
path := fmt.Sprintf("M %.3f %.3f H %.3f V %.3f H %.3f Z %s",
frame.X, frame.Y, frame.X+frame.Width, frame.Y+frame.Height, frame.X, lunarEclipseGeoPolygonPath(boundary, frame))
return path, closed
}
// 标准大气,与 basic 站心凌日的视地平判定一致。
const (
transitMapRefractionPressureHPa = 1010.0
transitMapRefractionTemperatureC = 10.0
)
// writeTransitMapDefinitions 输出一触、凌甚、四触三个太阳在地平上半球的形状与排除掩膜,分区口径同月食可见图。
func writeTransitMapDefinitions(b *strings.Builder, frame svgmap.Frame, contacts transitMapContacts) {
b.WriteString(frame.ClipDefinition("transit-map-clip"))
startPath, _ := transitMapSunUpPath(contacts.start, frame)
endPath, _ := transitMapSunUpPath(contacts.end, frame)
greatestPath, _ := transitMapSunUpPath(contacts.greatest, frame)
fmt.Fprintf(b, `<path id="transit-sun-c1-shape" fill-rule="evenodd" clip-rule="evenodd" d="%s"/>`, startPath)
fmt.Fprintf(b, `<path id="transit-sun-c4-shape" fill-rule="evenodd" clip-rule="evenodd" d="%s"/>`, endPath)
fmt.Fprintf(b, `<path id="transit-sun-greatest-shape" fill-rule="evenodd" clip-rule="evenodd" d="%s"/>`, greatestPath)
b.WriteString(`<clipPath id="transit-sun-c4"><use href="#transit-sun-c4-shape"/></clipPath>`)
b.WriteString(`<clipPath id="transit-sun-greatest"><use href="#transit-sun-greatest-shape"/></clipPath>`)
writeLunarEclipseVisibilityMask(b, "transit-not-visible-mask", frame,
"transit-sun-c1-shape", "transit-sun-c4-shape", "transit-sun-greatest-shape")
writeLunarEclipseVisibilityMask(b, "transit-not-c1-c4-mask", frame, "transit-sun-c1-shape", "transit-sun-c4-shape")
writeLunarEclipseVisibilityMask(b, "transit-not-greatest-mask", frame, "transit-sun-greatest-shape")
writeLunarEclipseVisibilityMask(b, "transit-not-c4-mask", frame, "transit-sun-c4-shape")
writeLunarEclipseVisibilityMask(b, "transit-not-c1-mask", frame, "transit-sun-c1-shape")
}
const (
transitMapColorEntire = "#5e846d"
transitMapColorSunrise = "#4e9da0"
transitMapColorSunset = "#e2aa4b"
transitMapColorNone = "#747b7d"
transitMapColorC1 = "#a3392f"
transitMapColorC2 = "#c0702a"
transitMapColorC3 = "#7a4ca0"
transitMapColorC4 = "#2f5ea3"
)
// writeTransitMapRegions 着色四类可见区:全程可见、日出时凌日已开始、日落时凌日未结束、不可见。
// 只有凌甚前后太阳在地平上的极区透镜与两端可见、凌甚不可见的高纬带都归入日落带,同月食可见图。
func writeTransitMapRegions(b *strings.Builder, frame svgmap.Frame, contacts transitMapContacts) {
_, startBoundary := transitMapSunUpPath(contacts.start, frame)
_, endBoundary := transitMapSunUpPath(contacts.end, frame)
fmt.Fprintf(b, `<g class="transit-visibility-regions" data-source="%s" clip-path="url(#transit-map-clip)">`, eclipseMapSourceTransitVisibilityRegions)
if frame.IsPolar() {
fmt.Fprintf(b, `<circle class="transit-not-visible-region" mask="url(#transit-not-visible-mask)" cx="%.3f" cy="%.3f" r="%.3f" fill="%s" fill-opacity="0.34"/>`,
frame.X+frame.Width/2, frame.Y+frame.Height/2, frame.Width/2, transitMapColorNone)
} else {
fmt.Fprintf(b, `<rect class="transit-not-visible-region" mask="url(#transit-not-visible-mask)" x="%.3f" y="%.3f" width="%.3f" height="%.3f" fill="%s" fill-opacity="0.34"/>`,
frame.X, frame.Y, frame.Width, frame.Height, transitMapColorNone)
}
fmt.Fprintf(b, `<use class="transit-sunset-region" mask="url(#transit-not-c4-mask)" href="#transit-sun-c1-shape" fill="%s" fill-opacity="0.34"/>`, transitMapColorSunset)
fmt.Fprintf(b, `<use class="transit-sunrise-region" mask="url(#transit-not-c1-mask)" href="#transit-sun-c4-shape" fill="%s" fill-opacity="0.34"/>`, transitMapColorSunrise)
fmt.Fprintf(b, `<g clip-path="url(#transit-sun-c4)"><g clip-path="url(#transit-sun-greatest)"><use class="transit-entire-region" href="#transit-sun-c1-shape" fill="%s" fill-opacity="0.34"/></g></g>`, transitMapColorEntire)
fmt.Fprintf(b, `<g clip-path="url(#transit-sun-c4)"><g mask="url(#transit-not-greatest-mask)"><use class="transit-sunset-region" href="#transit-sun-c1-shape" fill="%s" fill-opacity="0.34"/></g></g>`, transitMapColorSunset)
fmt.Fprintf(b, `<g mask="url(#transit-not-c1-c4-mask)"><use class="transit-sunset-region" href="#transit-sun-greatest-shape" fill="%s" fill-opacity="0.34"/></g>`, transitMapColorSunset)
b.WriteString(`</g>`)
writeEclipseMapGeoLine(b, frame, startBoundary, "c1-horizon", "#a56c16", 1.2, "4 3", "transit-map-clip", eclipseMapSourceTransitHorizonBoundaries)
writeEclipseMapGeoLine(b, frame, endBoundary, "c4-horizon", "#197a82", 1.2, "4 3", "transit-map-clip", eclipseMapSourceTransitHorizonBoundaries)
}
// writeTransitMapIsochrones 画选中接触的站心等时线:每条线是同一时刻看到该接触的地点,只保留该时刻太阳在视地平上的部分。
// 每隔若干条标一次时刻,标签按 LabelTable 避让。
func writeTransitMapIsochrones(b *strings.Builder, frame svgmap.Frame, transit basic.PlanetTransitResult, options TransitMapSVGOptions) {
table := &svgchart.LabelTable{}
format := "15:04"
if options.IsochroneStep%time.Minute != 0 {
format = "15:04:05"
}
for _, contact := range []struct {
kind basic.PlanetTransitContactKind
class string
color string
flag TransitIsochrones
}{
{basic.PlanetTransitC1, "c1-isochrone", transitMapColorC1, TransitIsochroneC1},
{basic.PlanetTransitC2, "c2-isochrone", transitMapColorC2, TransitIsochroneC2},
{basic.PlanetTransitC3, "c3-isochrone", transitMapColorC3, TransitIsochroneC3},
{basic.PlanetTransitC4, "c4-isochrone", transitMapColorC4, TransitIsochroneC4},
} {
if options.Isochrones&contact.flag == 0 {
continue
}
lines := basic.PlanetTransitContactIsochrones(transit, contact.kind, options.IsochroneStep)
labelEvery := transitMapIsochroneLabelEvery(len(lines))
for index, line := range lines {
segments := make([][]svgmap.GeoPoint, 0, len(line.Segments))
for _, segment := range line.Segments {
points := make([]svgmap.GeoPoint, len(segment))
for position, point := range segment {
points[position] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
segments = append(segments, points)
writeEclipseMapGeoLine(b, frame, points, contact.class, contact.color, 0.9, "none", "transit-map-clip", eclipseMapSourceTransitContactIsochrones)
}
if index%labelEvery != 0 {
continue
}
text := transitMapDisplayTime(basic.JD2DateByZone(line.JD, time.UTC, false), options).Format(format)
placement, ok := table.Place(text, 10, transitMapIsochroneLabelCandidates(segments, frame))
if !ok {
continue
}
fmt.Fprintf(b, `<text class="%s-label" x="%.3f" y="%.3f" fill="%s" stroke="#ffffff" stroke-width="2.6" paint-order="stroke" font-family="Arial, sans-serif" font-size="10" text-anchor="%s">%s</text>`,
contact.class, placement.X, placement.Y, contact.color, placement.Anchor, html.EscapeString(text))
}
}
}
// transitMapIsochroneLabelEvery 让每个接触最多标八条线。
func transitMapIsochroneLabelEvery(count int) int {
every := int(math.Ceil(float64(count) / 8))
if every < 1 {
every = 1
}
return every
}
// transitMapIsochroneLabelCandidates 从最长支路的中点向两端取画布内的候选位置。
func transitMapIsochroneLabelCandidates(segments [][]svgmap.GeoPoint, frame svgmap.Frame) []svgchart.LabelPlacement {
longest := -1
for index, segment := range segments {
if longest < 0 || len(segment) > len(segments[longest]) {
longest = index
}
}
if longest < 0 {
return nil
}
segment := segments[longest]
middle := len(segment) / 2
candidates := make([]svgchart.LabelPlacement, 0, 32)
for offset := 0; offset <= middle && len(candidates) < 32; offset++ {
for _, index := range []int{middle + offset, middle - offset} {
if index < 0 || index >= len(segment) || (offset == 0 && index != middle) {
continue
}
x, y, visible := frame.Project(segment[index].Longitude, segment[index].Latitude)
if !visible || x < frame.X+24 || x > frame.X+frame.Width-24 || y < frame.Y+14 || y > frame.Y+frame.Height-6 {
continue
}
candidates = append(candidates, svgchart.LabelPlacement{X: x, Y: y - 4, Anchor: "middle"})
}
}
return candidates
}
func writeTransitMapSummary(b *strings.Builder, transit basic.PlanetTransitResult, contacts transitMapContacts, options TransitMapSVGOptions) {
clock := func(value time.Time) string { return transitMapDisplayTime(value, options).Format("15:04:05") }
zone, _ := transitMapDisplayTime(contacts.greatest, options).Zone()
if zone == "" {
zone = "UTC"
}
if options.TimeScale == astro.TimeScaleUT1 {
zone = "UT1"
}
greatest, separation := "凌甚", "最小角距"
if options.Language == "en" {
greatest, separation = "Greatest", "least separation"
}
parts := []string{"C1 " + clock(contacts.start)}
if contacts.hasInternal {
parts = append(parts, "C2 "+clock(contacts.internalStart))
}
parts = append(parts, greatest+" "+clock(contacts.greatest))
if contacts.hasInternal {
parts = append(parts, "C3 "+clock(contacts.internalEnd))
}
parts = append(parts, "C4 "+clock(contacts.end))
line := fmt.Sprintf("%s (%s)", strings.Join(parts, " | "), zone)
fmt.Fprintf(b, `<text x="%.3f" y="82" fill="#293235" font-family="Arial, sans-serif" font-size="13" text-anchor="middle">%s</text>`,
float64(options.Width)/2, html.EscapeString(svgchart.EllipsizeText(line, float64(options.Width)-60, 13)))
note := fmt.Sprintf("%s %.1f″ | 太阳视半径 %.1f″ | 行星视半径 %.2f″(地心)", separation,
transit.MinimumSeparationArcsec, transit.SunSemidiameterArcsec, transit.PlanetSemidiameterArcsec)
if options.Language == "en" {
note = fmt.Sprintf("%s %.1f″ | solar semidiameter %.1f″ | planet semidiameter %.2f″ (geocentric)", separation,
transit.MinimumSeparationArcsec, transit.SunSemidiameterArcsec, transit.PlanetSemidiameterArcsec)
}
fmt.Fprintf(b, `<text x="%.3f" y="100" fill="#293235" font-family="Arial, sans-serif" font-size="13" text-anchor="middle">%s</text>`,
float64(options.Width)/2, html.EscapeString(note))
}
// writeTransitMapLegend 画区域色块与等时线图例,按画布宽度折行,返回行数。
func writeTransitMapLegend(b *strings.Builder, frame svgmap.Frame, canvasWidth float64, options TransitMapSVGOptions) int {
type entry struct {
text, color string
line bool
}
labels := []string{"全程可见", "日出时已在凌日", "日落时仍在凌日", "不可见", "一触等时线", "二触等时线", "三触等时线", "四触等时线"}
if options.Language == "en" {
labels = []string{"Entire transit", "Transit at sunrise", "Transit at sunset", "Not visible",
"C1 isochrones", "C2 isochrones", "C3 isochrones", "C4 isochrones"}
}
entries := []entry{
{labels[0], transitMapColorEntire, false}, {labels[1], transitMapColorSunrise, false},
{labels[2], transitMapColorSunset, false}, {labels[3], transitMapColorNone, false},
}
for index, item := range []struct {
flag TransitIsochrones
color string
}{
{TransitIsochroneC1, transitMapColorC1}, {TransitIsochroneC2, transitMapColorC2},
{TransitIsochroneC3, transitMapColorC3}, {TransitIsochroneC4, transitMapColorC4},
} {
if options.Isochrones&item.flag != 0 {
entries = append(entries, entry{labels[4+index], item.color, true})
}
}
width := func(item entry) float64 {
return lunarEclipseDetailedMapLegendIconWidth + svgchart.EstimatedTextWidth(item.text, lunarEclipseDetailedMapLegendFontSize)
}
available := canvasWidth - 2*lunarEclipseDetailedPageInset
var rows [][]entry
used := 0.0
for _, item := range entries {
if len(rows) == 0 || used+lunarEclipseDetailedMapLegendGap+width(item) > available {
rows = append(rows, nil)
used = -lunarEclipseDetailedMapLegendGap
}
rows[len(rows)-1] = append(rows[len(rows)-1], item)
used += lunarEclipseDetailedMapLegendGap + width(item)
}
baseY := frame.Y + frame.Height + 31
for rowIndex, row := range rows {
total := -lunarEclipseDetailedMapLegendGap
for _, item := range row {
total += lunarEclipseDetailedMapLegendGap + width(item)
}
x := math.Max(lunarEclipseDetailedPageInset, frame.X+frame.Width/2-total/2)
y := baseY + float64(rowIndex)*lunarEclipseDetailedMapLegendLineStep
for _, item := range row {
if item.line {
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="1.4"/>`, x, y-4, x+18, y-4, item.color)
} else {
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="18" height="9" fill="%s" fill-opacity="0.78"/>`, x, y-8, item.color)
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#465053" font-family="Arial, sans-serif" font-size="%.0f">%s</text>`,
x+lunarEclipseDetailedMapLegendIconWidth, y, lunarEclipseDetailedMapLegendFontSize, html.EscapeString(item.text))
x += width(item) + lunarEclipseDetailedMapLegendGap
}
}
return len(rows)
}
func writeTransitMapFooter(
b *strings.Builder,
frame svgmap.Frame,
options TransitMapSVGOptions,
projection svgmap.Projection,
instant time.Time,
legendRows int,
) {
text := options.FooterNote
if text == "" {
if options.Language == "en" {
text = eclipseMapProjectionLabel(projection, "en") + "; regions follow whether the Sun's centre is above the apparent horizon (standard refraction) at C1, greatest and C4"
if options.Isochrones != 0 {
text += "; isochrones are topocentric contact times"
}
text += "."
} else {
text = eclipseMapProjectionLabel(projection, "zh") + ";按一触、凌甚、四触太阳中心是否在视地平上分区(计入标准大气折射)"
if options.Isochrones != 0 {
text += ";等时线为站心接触时刻"
}
text += "。"
}
}
const footerBottomPadding = 26.0
// 页脚紧跟图例末行,不与图例重叠;行数按画布底边截断,时标声明独占最后一行。
baseline := frame.Y + frame.Height + 31 + float64(legendRows-1)*lunarEclipseDetailedMapLegendLineStep + 18
maxWidth := float64(options.Width) - frame.X - 24
lines := svgchart.WrapText(text, maxWidth, 11)
lines = svgchart.FooterLinesWithScale(lines, timenote.Scale(options.TimeScale, instant, options.Location, options.Language), maxWidth, 11,
svgchart.BaselineLineLimit(11, svgchart.FooterLineHeight(11), baseline, float64(options.Height)-footerBottomPadding))
for index, line := range lines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11">%s</text>`,
frame.X, baseline+float64(index)*svgchart.FooterLineHeight(11), html.EscapeString(line))
}
}
+91
View File
@@ -0,0 +1,91 @@
package svg
import (
"fmt"
"math"
"strings"
"testing"
"time"
"b612.me/astro"
"b612.me/astro/basic"
"b612.me/astro/internal/svgmap"
)
func TestTransitMapSVGRendersRegionsAndIsochrones(t *testing.T) {
cases := []struct {
name string
planet TransitPlanet
date time.Time
}{
{"venus-2012", TransitVenus, time.Date(2012, 6, 6, 0, 0, 0, 0, time.UTC)},
{"mercury-2019", TransitMercury, time.Date(2019, 11, 11, 0, 0, 0, 0, time.UTC)},
}
for _, tc := range cases {
for _, projection := range []EclipseMapProjection{"", EclipseMapProjectionOrthographic, EclipseMapProjectionNorthPolar} {
chart, ok := TransitMapSVG(tc.planet, tc.date, TransitMapSVGOptions{Location: time.UTC, Projection: projection, Isochrones: TransitIsochroneAll})
if !ok {
t.Fatalf("%s %q: render failed", tc.name, projection)
}
for _, want := range []string{"transit-entire-region", "transit-sunrise-region", "transit-sunset-region",
"c1-horizon", "c4-horizon", `class="c1-isochrone"`, `class="c2-isochrone"`, `class="c3-isochrone"`,
`class="c4-isochrone"`, "c1-isochrone-label"} {
if !strings.Contains(chart, want) {
t.Fatalf("%s %q: missing %s", tc.name, projection, want)
}
}
if strings.Contains(chart, "NaN") || strings.Contains(chart, "Inf") {
t.Fatalf("%s %q: non-finite coordinate", tc.name, projection)
}
}
}
}
func TestTransitMapSVGOptions(t *testing.T) {
date := time.Date(2012, 6, 6, 0, 0, 0, 0, time.UTC)
if _, ok := TransitMapSVG(3, date, TransitMapSVGOptions{}); ok {
t.Fatal("unsupported planet must fail")
}
if _, ok := TransitMapSVG(TransitVenus, date, TransitMapSVGOptions{
TimeScale: astro.TimeScaleUT1, Location: time.FixedZone("CST", 8*3600)}); ok {
t.Fatal("UT1 with non-UTC location must fail")
}
chart, ok := TransitMapSVG(TransitVenus, date, TransitMapSVGOptions{Location: time.UTC, Language: "en"})
if !ok || strings.Contains(chart, "isochrone") || !strings.Contains(chart, "Transit of Venus") {
t.Fatal("isochrones must be off by default and English labels kept")
}
chart, ok = TransitMapSVG(TransitVenus, date, TransitMapSVGOptions{Location: time.UTC, Isochrones: TransitIsochroneC2, IsochroneStep: 5 * time.Minute})
if !ok || !strings.Contains(chart, `class="c2-isochrone"`) || strings.Contains(chart, `class="c1-isochrone"`) ||
!strings.Contains(chart, "二触等时线") || strings.Contains(chart, "一触等时线") {
t.Fatal("only the selected contact must be drawn and listed in the legend")
}
}
// 太阳在视地平上的区域由图框减去对日点夜冠得到,边界距直射点 90°+地平折射。
// 曾直接画 90° 半球环,拆分日界线时分不清内外而整片填反。
func TestTransitMapSunUpBoundaryIncludesRefraction(t *testing.T) {
transit := basic.ClosestVenusTransit(basic.Date2JD(time.Date(2012, 6, 6, 0, 0, 0, 0, time.UTC)))
contacts := transitMapContactsOf(transit)
radius := 90 + basic.RefractionFromApparentAltitude(0, transitMapRefractionPressureHPa, transitMapRefractionTemperatureC)
frame := eclipseMapFrame(960, 640, svgmap.ProjectionEquirectangular, 142, 92)
for _, at := range []time.Time{contacts.start, contacts.greatest, contacts.end} {
path, _ := transitMapSunUpPath(at, frame)
if !strings.HasPrefix(path, fmt.Sprintf("M %.3f %.3f H", frame.X, frame.Y)) {
t.Fatalf("%v: sun-up path must start with the frame rectangle", at)
}
subsolar := solarEclipseSubsolarPoint(at)
_, boundary := transitMapSunUpPath(at, frame)
for _, point := range boundary {
distance := transitMapTestAngularDistance(point, subsolar)
if math.Abs(distance-radius) > 1e-6 {
t.Fatalf("%v: boundary point %.4f deg from subsolar point, want %.4f", at, distance, radius)
}
}
}
}
func transitMapTestAngularDistance(a, b svgmap.GeoPoint) float64 {
r := math.Pi / 180
cosine := math.Sin(a.Latitude*r)*math.Sin(b.Latitude*r) + math.Cos(a.Latitude*r)*math.Cos(b.Latitude*r)*math.Cos((a.Longitude-b.Longitude)*r)
return math.Acos(math.Max(-1, math.Min(1, cosine))) / r
}
@@ -0,0 +1,4 @@
{
"Chauvenet.PenumbralMagnitude": 2.2854632847154006,
"Chauvenet.UmbralMagnitude": 1.1828338861983332
}
@@ -0,0 +1,6 @@
{
"C1.ContactPositionAngle": 226.22722697257004,
"C2.ContactPositionAngle": 19.47323958289263,
"C2.MoonCenterPositionAngle": 199.47323958289263,
"C4.ContactClockwiseAngle": 310.7896361107794
}
@@ -0,0 +1,4 @@
{
"Chauvenet.PenumbralMagnitude": 2.2854632847154006,
"Chauvenet.UmbralMagnitude": 1.1828338861983332
}
@@ -0,0 +1,6 @@
{
"U1.ContactClockwiseAngle": 263.81639399705364,
"U1.ContactPositionAngle": 96.18360600294636,
"U2.ContactPositionAngle": 243.037377535088,
"U2.MoonCenterPositionAngle": 243.037377535088
}