feat: 引入可切换星历模型并扩展天象观测链路
- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算 - 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询 - 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出 - 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解 - 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤 - 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持 - 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理 - 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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'05.2""},
|
||||
{"2025-03-14", time.Date(2025, 3, 14, 0, 0, 0, 0, cst), "11h38m23.0s", "+02°40'54.3""},
|
||||
}
|
||||
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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// 图例只能画一次:横版曾经在渲染器与横版面板里各画一次,而且两处传入的中心带参数还不一致。
|
||||
// 图例只能画一次,横版渲染器与面板不得重复绘制。
|
||||
func TestSolarEclipseMapLegendRendersOnce(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user