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

- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算
- 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询
- 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出
- 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解
- 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤
- 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持
- 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理
- 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
This commit is contained in:
2026-10-15 23:54:15 +08:00
parent 16c62a97d5
commit 044f2f4381
674 changed files with 62983 additions and 8396 deletions
+417
View File
@@ -0,0 +1,417 @@
package moon
import (
"time"
)
// Altitude 月亮高度角,date 的时区参与地方时计算 / lunar altitude; the zone of date enters local time.
func Altitude(date time.Time, lon, lat float64) float64 {
return defaultModule.Altitude(date, lon, lat)
}
// ApogeesInMonth 指定年月内的所有月球远地点 / all lunar apogees in the given Gregorian month.
func ApogeesInMonth(year int, month time.Month) []ApsisInfo {
return defaultModule.ApogeesInMonth(year, month)
}
// ApparentAltitude 月亮视高度角 / apparent lunar altitude.
func ApparentAltitude(date time.Time, lon, lat, pressureHPa, temperatureC float64) float64 {
return defaultModule.ApparentAltitude(date, lon, lat, pressureHPa, temperatureC)
}
// ApparentDec 月亮站心视赤纬 / apparent topocentric declination.
func ApparentDec(date time.Time, lon, lat float64) float64 {
return defaultModule.ApparentDec(date, lon, lat)
}
// ApparentLo 月亮地心视黄经 / apparent geocentric ecliptic longitude.
func ApparentLo(date time.Time) float64 { return defaultModule.ApparentLo(date) }
// ApparentRa 月亮站心视赤经,date 的时区参与地方时计算 / apparent topocentric right ascension; the zone of date enters local time.
func ApparentRa(date time.Time, lon, lat float64) float64 {
return defaultModule.ApparentRa(date, lon, lat)
}
// ApparentRaDec 月亮站心视赤经、视赤纬 / apparent topocentric right ascension and declination.
func ApparentRaDec(date time.Time, lon, lat float64) (float64, float64) {
return defaultModule.ApparentRaDec(date, lon, lat)
}
// ApparentZenith 月亮视天顶距 / apparent lunar zenith distance.
func ApparentZenith(date time.Time, lon, lat, pressureHPa, temperatureC float64) float64 {
return defaultModule.ApparentZenith(date, lon, lat, pressureHPa, temperatureC)
}
// AscendingNode 月球升交点黄经 / ascending node longitude of the Moon.
func AscendingNode(date time.Time) float64 { return defaultModule.AscendingNode(date) }
// AscendingNodeN 月球升交点黄经(n 变体) / n-parameterised ascending node longitude of the Moon.
func AscendingNodeN(date time.Time, n int) float64 { return defaultModule.AscendingNodeN(date, n) }
// Azimuth 月亮方位角,正北为 0° 向东增加 / lunar azimuth from north through east.
func Azimuth(date time.Time, lon, lat float64) float64 { return defaultModule.Azimuth(date, lon, lat) }
// BrightLimbPositionAngle 月亮明亮边缘位置角,单位度 / position angle of the Moon's bright limb in degrees.
func BrightLimbPositionAngle(date time.Time) float64 {
return defaultModule.BrightLimbPositionAngle(date)
}
// BrightLimbPositionAngleN 月亮明亮边缘位置角(n 变体),单位度 / n-parameterised position angle of the Moon's bright limb in degrees.
func BrightLimbPositionAngleN(date time.Time, n int) float64 {
return defaultModule.BrightLimbPositionAngleN(date, n)
}
// ClosestConjunctionWithPlanet 最近一次行星合月(赤经合) / closest Moon-planet conjunction.
func ClosestConjunctionWithPlanet(date time.Time, planet ConjunctionPlanet) time.Time {
return defaultModule.ClosestConjunctionWithPlanet(date, planet)
}
// ClosestFirstQuarter 最近上弦英文别名 / English alias for ClosestShangXianYue.
func ClosestFirstQuarter(date time.Time) time.Time { return defaultModule.ClosestFirstQuarter(date) }
// ClosestFullMoon 最近望月英文别名 / English alias for ClosestWangYue.
func ClosestFullMoon(date time.Time) time.Time { return defaultModule.ClosestFullMoon(date) }
// ClosestLastQuarter 最近下弦英文别名 / English alias for ClosestXiaXianYue.
func ClosestLastQuarter(date time.Time) time.Time { return defaultModule.ClosestLastQuarter(date) }
// ClosestMaximumNorthDeclination 离指定时刻最近一次月球最大北赤纬 / closest maximum northern lunar declination to date.
func ClosestMaximumNorthDeclination(date time.Time) MaximumDeclinationInfo {
return defaultModule.ClosestMaximumNorthDeclination(date)
}
// ClosestMaximumSouthDeclination 离指定时刻最近一次月球最大南赤纬 / closest maximum southern lunar declination to date.
func ClosestMaximumSouthDeclination(date time.Time) MaximumDeclinationInfo {
return defaultModule.ClosestMaximumSouthDeclination(date)
}
// ClosestNewMoon 最近朔月英文别名 / English alias for ClosestShuoYue.
func ClosestNewMoon(date time.Time) time.Time { return defaultModule.ClosestNewMoon(date) }
// ClosestShangXianYue 最近上弦,结果保持 date 的时区 / closest first quarter in date's zone.
func ClosestShangXianYue(date time.Time) time.Time { return defaultModule.ClosestShangXianYue(date) }
// ClosestShuoYue 最近朔月,结果保持 date 的时区 / closest new moon in date's zone.
func ClosestShuoYue(date time.Time) time.Time { return defaultModule.ClosestShuoYue(date) }
// ClosestWangYue 最近望月,结果保持 date 的时区 / closest full moon in date's zone.
func ClosestWangYue(date time.Time) time.Time { return defaultModule.ClosestWangYue(date) }
// ClosestXiaXianYue 最近下弦,结果保持 date 的时区 / closest last quarter in date's zone.
func ClosestXiaXianYue(date time.Time) time.Time { return defaultModule.ClosestXiaXianYue(date) }
// CulminationTime 月亮中天时刻,取 date 时区的当地日期 / culmination time on date's local day.
func CulminationTime(date time.Time, lon, lat float64) time.Time {
return defaultModule.CulminationTime(date, lon, lat)
}
// DescendingNode 月球降交点黄经 / descending node longitude of the Moon.
func DescendingNode(date time.Time) float64 { return defaultModule.DescendingNode(date) }
// DescendingNodeN 月球降交点黄经(n 变体) / n-parameterised descending node longitude of the Moon.
func DescendingNodeN(date time.Time, n int) float64 { return defaultModule.DescendingNodeN(date, n) }
// Diameter 月亮视直径,单位角秒 / apparent lunar diameter in arcseconds.
func Diameter(date time.Time) float64 { return defaultModule.Diameter(date) }
// DiameterN 月亮视直径(n 变体),单位角秒 / n-parameterised apparent lunar diameter in arcseconds.
func DiameterN(date time.Time, n int) float64 { return defaultModule.DiameterN(date, n) }
// DownTime 月落时刻别名,同 SetTime / deprecated alias of SetTime.
func DownTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) {
return defaultModule.DownTime(date, lon, lat, height, aero)
}
// EarthDistance 地月距离(千米) / Earth-Moon distance in kilometers.
func EarthDistance(date time.Time) float64 {
return defaultModule.EarthDistance(date)
}
// FindBestPlanetOccultations 窗口内每次行星月掩的全球海平面几何掩甚点,可见性不参与选点 / global sea-level geometric greatest point of each planetary occultation.
func FindBestPlanetOccultations(start, end time.Time, planet OccultationPlanet,
options OccultationSearchOptions) ([]PlanetOccultationInfo, error) {
return defaultModule.FindBestPlanetOccultations(start, end, planet, options)
}
// FindBestStarOccultations 窗口内每次恒星月掩的全球海平面几何掩甚点,可见性不参与选点 / global sea-level geometric greatest point of each stellar occultation.
func FindBestStarOccultations(start, end time.Time, star StarCoordinate, options OccultationSearchOptions) ([]StarOccultationInfo, error) {
return defaultModule.FindBestStarOccultations(start, end, star, options)
}
// FindPlanetOccultationPaths 窗口内每次行星月掩的全球外接触掩带 / global outer-contact footprint of each planetary occultation.
func FindPlanetOccultationPaths(start, end time.Time, planet OccultationPlanet,
options OccultationPathOptions) ([]PlanetOccultationPath, error) {
return defaultModule.FindPlanetOccultationPaths(start, end, planet, options)
}
// FindPlanetOccultations 固定地点的有限盘面行星月掩,忽略行星环 / planetary occultations at a fixed site, rings excluded.
func FindPlanetOccultations(start, end time.Time, planet OccultationPlanet, longitude, latitude, height float64,
options OccultationSearchOptions) ([]PlanetOccultationInfo, error) {
return defaultModule.FindPlanetOccultations(start, end, planet, longitude, latitude, height, options)
}
// FindStarOccultationPaths 点源恒星月掩的全球掩带,不加载内嵌星表 / global footprint of a stellar occultation; no embedded catalog is loaded.
func FindStarOccultationPaths(start, end time.Time, star StarCoordinate, options OccultationPathOptions) ([]StarOccultationPath, error) {
return defaultModule.FindStarOccultationPaths(start, end, star, options)
}
// FindStarOccultations 固定地点的点源恒星月掩,不加载内嵌星表 / stellar occultations at a fixed site; no embedded catalog is loaded.
func FindStarOccultations(start, end time.Time, star StarCoordinate, longitude, latitude, height float64,
options OccultationSearchOptions) ([]StarOccultationInfo, error) {
return defaultModule.FindStarOccultations(start, end, star, longitude, latitude, height, options)
}
// FirstQuarter 上弦英文别名 / English alias for ShangXianYue.
func FirstQuarter(year float64) time.Time { return defaultModule.FirstQuarter(year) }
// FullMoon 望月英文别名 / English alias for WangYue.
func FullMoon(year float64) time.Time { return defaultModule.FullMoon(year) }
// GeocentricApparentDec 月亮地心视赤纬 / apparent geocentric declination.
func GeocentricApparentDec(date time.Time) float64 { return defaultModule.GeocentricApparentDec(date) }
// GeocentricApparentRa 月亮地心视赤经 / apparent geocentric right ascension.
func GeocentricApparentRa(date time.Time) float64 { return defaultModule.GeocentricApparentRa(date) }
// GeocentricApparentRaDec 月亮地心视赤经、视赤纬 / apparent geocentric right ascension and declination.
func GeocentricApparentRaDec(date time.Time) (float64, float64) {
return defaultModule.GeocentricApparentRaDec(date)
}
// HourAngle 月亮时角,date 的时区参与地方时计算 / lunar hour angle; the zone of date enters local time.
func HourAngle(date time.Time, lon, lat float64) float64 {
return defaultModule.HourAngle(date, lon, lat)
}
// LastConjunctionWithPlanet 上一次行星合月(赤经合) / previous Moon-planet conjunction.
func LastConjunctionWithPlanet(date time.Time, planet ConjunctionPlanet) time.Time {
return defaultModule.LastConjunctionWithPlanet(date, planet)
}
// LastFirstQuarter 上一次上弦英文别名 / English alias for LastShangXianYue.
func LastFirstQuarter(date time.Time) time.Time { return defaultModule.LastFirstQuarter(date) }
// LastFullMoon 上一次望月英文别名 / English alias for LastWangYue.
func LastFullMoon(date time.Time) time.Time { return defaultModule.LastFullMoon(date) }
// LastLastQuarter 上一次下弦英文别名 / English alias for LastXiaXianYue.
func LastLastQuarter(date time.Time) time.Time { return defaultModule.LastLastQuarter(date) }
// LastMaximumNorthDeclination 指定时刻之前最近一次月球最大北赤纬 / last maximum northern lunar declination at or before date.
func LastMaximumNorthDeclination(date time.Time) MaximumDeclinationInfo {
return defaultModule.LastMaximumNorthDeclination(date)
}
// LastMaximumSouthDeclination 指定时刻之前最近一次月球最大南赤纬 / last maximum southern lunar declination at or before date.
func LastMaximumSouthDeclination(date time.Time) MaximumDeclinationInfo {
return defaultModule.LastMaximumSouthDeclination(date)
}
// LastNewMoon 上一次朔月英文别名 / English alias for LastShuoYue.
func LastNewMoon(date time.Time) time.Time { return defaultModule.LastNewMoon(date) }
// LastQuarter 下弦英文别名 / English alias for XiaXianYue.
func LastQuarter(year float64) time.Time { return defaultModule.LastQuarter(year) }
// LastShangXianYue 上一次上弦,结果保持 date 的时区 / previous first quarter in date's zone.
func LastShangXianYue(date time.Time) time.Time { return defaultModule.LastShangXianYue(date) }
// LastShuoYue 上一次朔月,结果保持 date 的时区 / previous new moon in date's zone.
func LastShuoYue(date time.Time) time.Time { return defaultModule.LastShuoYue(date) }
// LastWangYue 上一次望月,结果保持 date 的时区 / previous full moon in date's zone.
func LastWangYue(date time.Time) time.Time { return defaultModule.LastWangYue(date) }
// LastXiaXianYue 上一次下弦,结果保持 date 的时区 / previous last quarter in date's zone.
func LastXiaXianYue(date time.Time) time.Time { return defaultModule.LastXiaXianYue(date) }
// MaximumNorthDeclinationsInMonth 指定年月内的所有月球最大北赤纬事件 / all maximum northern lunar declination events in the given Gregorian month.
func MaximumNorthDeclinationsInMonth(year int, month time.Month) []MaximumDeclinationInfo {
return defaultModule.MaximumNorthDeclinationsInMonth(year, month)
}
// MaximumSouthDeclinationsInMonth 指定年月内的所有月球最大南赤纬事件 / all maximum southern lunar declination events in the given Gregorian month.
func MaximumSouthDeclinationsInMonth(year int, month time.Month) []MaximumDeclinationInfo {
return defaultModule.MaximumSouthDeclinationsInMonth(year, month)
}
// NewMoon 朔月英文别名 / English alias for ShuoYue.
func NewMoon(year float64) time.Time { return defaultModule.NewMoon(year) }
// NextConjunctionWithPlanet 下一次行星合月(赤经合) / next Moon-planet conjunction.
func NextConjunctionWithPlanet(date time.Time, planet ConjunctionPlanet) time.Time {
return defaultModule.NextConjunctionWithPlanet(date, planet)
}
// NextFirstQuarter 下一次上弦英文别名 / English alias for NextShangXianYue.
func NextFirstQuarter(date time.Time) time.Time { return defaultModule.NextFirstQuarter(date) }
// NextFullMoon 下一次望月英文别名 / English alias for NextWangYue.
func NextFullMoon(date time.Time) time.Time { return defaultModule.NextFullMoon(date) }
// NextLastQuarter 下一次下弦英文别名 / English alias for NextXiaXianYue.
func NextLastQuarter(date time.Time) time.Time { return defaultModule.NextLastQuarter(date) }
// NextMaximumNorthDeclination 指定时刻之后最近一次月球最大北赤纬 / next maximum northern lunar declination after date.
func NextMaximumNorthDeclination(date time.Time) MaximumDeclinationInfo {
return defaultModule.NextMaximumNorthDeclination(date)
}
// NextMaximumSouthDeclination 指定时刻之后最近一次月球最大南赤纬 / next maximum southern lunar declination after date.
func NextMaximumSouthDeclination(date time.Time) MaximumDeclinationInfo {
return defaultModule.NextMaximumSouthDeclination(date)
}
// NextNewMoon 下一次朔月英文别名 / English alias for NextShuoYue.
func NextNewMoon(date time.Time) time.Time { return defaultModule.NextNewMoon(date) }
// NextShangXianYue 下一次上弦,结果保持 date 的时区 / next first quarter in date's zone.
func NextShangXianYue(date time.Time) time.Time { return defaultModule.NextShangXianYue(date) }
// NextShuoYue 下一次朔月,结果保持 date 的时区 / next new moon in date's zone.
func NextShuoYue(date time.Time) time.Time { return defaultModule.NextShuoYue(date) }
// NextWangYue 下一次望月,结果保持 date 的时区 / next full moon in date's zone.
func NextWangYue(date time.Time) time.Time { return defaultModule.NextWangYue(date) }
// NextXiaXianYue 下一次下弦,结果保持 date 的时区 / next last quarter in date's zone.
func NextXiaXianYue(date time.Time) time.Time { return defaultModule.NextXiaXianYue(date) }
// ParallacticAngle 月亮站心视差角(天顶方向角) / topocentric lunar parallactic angle.
func ParallacticAngle(date time.Time, lon, lat float64) float64 {
return defaultModule.ParallacticAngle(date, lon, lat)
}
// PerigeesInMonth 指定年月内的所有月球近地点 / all lunar perigees in the given Gregorian month.
func PerigeesInMonth(year int, month time.Month) []ApsisInfo {
return defaultModule.PerigeesInMonth(year, month)
}
// Phase 月面受照比例,取值 [0, 1] / illuminated fraction in [0, 1].
func Phase(date time.Time) float64 { return defaultModule.Phase(date) }
// PhaseDesc 中文月相名称 / Chinese lunar phase name.
func PhaseDesc(date time.Time) string { return defaultModule.PhaseDesc(date) }
// Physical 月球物理观测参数 / physical observing parameters of the Moon.
func Physical(date time.Time) PhysicalInfo { return defaultModule.Physical(date) }
// PhysicalN 月球物理观测参数(n 变体) / n-parameterised physical observing parameters of the Moon.
func PhysicalN(date time.Time, n int) PhysicalInfo { return defaultModule.PhysicalN(date, n) }
// PlanetOccultationDiagram 已求解固定地点行星月掩的图形几何 / diagram geometry of a solved fixed-site planetary occultation.
func PlanetOccultationDiagram(
info PlanetOccultationInfo,
options PlanetOccultationDiagramOptions,
) PlanetOccultationDiagramResult {
return defaultModule.PlanetOccultationDiagram(info, options)
}
// PlanetOccultationFootprintsAt 指定时刻行星偏掩、全掩可见足迹 / visible partial and total planetary footprints at one instant.
func PlanetOccultationFootprintsAt(at time.Time, planet OccultationPlanet) (PlanetOccultationInstant, error) {
return defaultModule.PlanetOccultationFootprintsAt(at, planet)
}
// RiseTime 月出时刻,取 date 时区的当地日期;height 为椭球高(米),aero 为真时按大气折射与实时视半径算上缘 / moonrise on date's local day.
func RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) {
return defaultModule.RiseTime(date, lon, lat, height, aero)
}
// Semidiameter 月亮视半径,单位角秒 / apparent lunar semidiameter in arcseconds.
func Semidiameter(date time.Time) float64 { return defaultModule.Semidiameter(date) }
// SemidiameterN 月亮视半径(n 变体),单位角秒 / n-parameterised apparent lunar semidiameter in arcseconds.
func SemidiameterN(date time.Time, n int) float64 { return defaultModule.SemidiameterN(date, n) }
// SetTime 月落时刻,参数同 RiseTime / moonset on date's local day.
func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) {
return defaultModule.SetTime(date, lon, lat, height, aero)
}
// ShangXianYue 上弦锚点解,year 为公历小数年,结果为 UTC / first quarter near a decimal-year anchor, in UTC.
func ShangXianYue(year float64) time.Time { return defaultModule.ShangXianYue(year) }
// ShuoYue 朔月锚点解,year 为公历小数年,结果为 UTC / new moon near a decimal-year anchor, in UTC.
func ShuoYue(year float64) time.Time { return defaultModule.ShuoYue(year) }
// StarOccultationLimbProfile 精密恒星月掩:给出全部遮挡区间与带内采样,limb 为 nil 时用模块月缘 / precise stellar occultation; nil limb uses the module profile.
func StarOccultationLimbProfile(info StarOccultationInfo, star StarCoordinate, limb LunarLimb, options StarOccultationLimbProfileOptions) (StarOccultationLimbProfileResult, error) {
return defaultModule.StarOccultationLimbProfile(info, star, limb, options)
}
// StarOccultationDiagram 已求解固定地点恒星月掩的图形几何 / diagram geometry of a solved fixed-site stellar occultation.
func StarOccultationDiagram(
info StarOccultationInfo,
star StarCoordinate,
options StarOccultationDiagramOptions,
) StarOccultationDiagramResult {
return defaultModule.StarOccultationDiagram(info, star, options)
}
// StarOccultationFootprintAt 指定时刻点源恒星月掩可见足迹 / visible stellar-occultation footprint at one instant.
func StarOccultationFootprintAt(at time.Time, star StarCoordinate) (StarOccultationInstant, error) {
return defaultModule.StarOccultationFootprintAt(at, star)
}
// SunMoonLoDiff 月日视黄经差,取 [0, 360),朔近 0°、望近 180° / Moon minus Sun apparent longitude in [0, 360).
func SunMoonLoDiff(date time.Time) float64 { return defaultModule.SunMoonLoDiff(date) }
// TopocentricBrightLimbPositionAngle 月亮站心明亮边缘位置角(度),height 为椭球高(米) / topocentric bright-limb position angle in degrees.
func TopocentricBrightLimbPositionAngle(date time.Time, observerLon, observerLat, height float64) float64 {
return defaultModule.TopocentricBrightLimbPositionAngle(date, observerLon, observerLat, height)
}
// TopocentricBrightLimbPositionAngleN 月亮站心明亮边缘位置角(n 变体),单位度 / n-parameterised topocentric position angle of the Moon's bright limb in degrees.
func TopocentricBrightLimbPositionAngleN(date time.Time, observerLon, observerLat, height float64, n int) float64 {
return defaultModule.TopocentricBrightLimbPositionAngleN(date, observerLon, observerLat, height, n)
}
// TopocentricPhysical 月球站心物理观测参数,height 为椭球高(米) / topocentric physical observing parameters of the Moon.
func TopocentricPhysical(date time.Time, observerLon, observerLat, height float64) PhysicalInfo {
return defaultModule.TopocentricPhysical(date, observerLon, observerLat, height)
}
// TopocentricPhysicalN 月球站心物理观测参数(n 变体) / n-parameterised topocentric physical observing parameters of the Moon.
func TopocentricPhysicalN(date time.Time, observerLon, observerLat, height float64, n int) PhysicalInfo {
return defaultModule.TopocentricPhysicalN(date, observerLon, observerLat, height, n)
}
// TrueBo 月亮地心真黄纬 / geocentric true ecliptic latitude.
func TrueBo(date time.Time) float64 { return defaultModule.TrueBo(date) }
// TrueBoN 月亮地心真黄纬,n<0 取完整级数,n>=0 时半解析模型用快速表、Legacy 按 n 截断 / geocentric true ecliptic latitude with series selector n.
func TrueBoN(date time.Time, n int) float64 { return defaultModule.TrueBoN(date, n) }
// TrueDec 月亮地心真赤纬 / geocentric true declination.
func TrueDec(date time.Time) float64 { return defaultModule.TrueDec(date) }
// TrueLo 月亮地心真黄经 / geocentric true ecliptic longitude.
func TrueLo(date time.Time) float64 { return defaultModule.TrueLo(date) }
// TrueLoN 月亮地心真黄经,n<0 取完整级数,n>=0 时半解析模型用快速表、Legacy 按 n 截断 / geocentric true ecliptic longitude with series selector n.
func TrueLoN(date time.Time, n int) float64 { return defaultModule.TrueLoN(date, n) }
// TrueRa 月亮地心真赤经 / geocentric true right ascension.
func TrueRa(date time.Time) float64 { return defaultModule.TrueRa(date) }
// TrueRaDec 月亮地心真赤经、真赤纬 / geocentric true right ascension and declination.
func TrueRaDec(date time.Time) (float64, float64) { return defaultModule.TrueRaDec(date) }
// WangYue 望月锚点解,year 为公历小数年,结果为 UTC / full moon near a decimal-year anchor, in UTC.
func WangYue(year float64) time.Time { return defaultModule.WangYue(year) }
// XiaXianYue 下弦锚点解,year 为公历小数年,结果为 UTC / last quarter near a decimal-year anchor, in UTC.
func XiaXianYue(year float64) time.Time { return defaultModule.XiaXianYue(year) }
// Zenith 月亮天顶距 / lunar zenith distance.
func Zenith(date time.Time, lon, lat float64) float64 { return defaultModule.Zenith(date, lon, lat) }
// RiseTimeWithAtmosphere 月出时刻,按给定气压(hPa)与气温(°C)计算大气折射 / moonrise time with explicit pressure and temperature.
func RiseTimeWithAtmosphere(date time.Time, lon, lat, height, pressureHPa, temperatureC float64) (time.Time, error) {
return defaultModule.RiseTimeWithAtmosphere(date, lon, lat, height, pressureHPa, temperatureC)
}
// SetTimeWithAtmosphere 月落时刻,按给定气压(hPa)与气温(°C)计算大气折射 / moonset time with explicit pressure and temperature.
func SetTimeWithAtmosphere(date time.Time, lon, lat, height, pressureHPa, temperatureC float64) (time.Time, error) {
return defaultModule.SetTimeWithAtmosphere(date, lon, lat, height, pressureHPa, temperatureC)
}
+4 -4
View File
@@ -15,13 +15,13 @@ type ApsisInfo struct {
}
// PerigeesInMonth 指定年月内的所有月球近地点 / all lunar perigees in the given Gregorian month.
func PerigeesInMonth(year int, month time.Month) []ApsisInfo {
return convertMoonApsisInfos(basic.MoonPerigees(year, month))
func (module *Moon) PerigeesInMonth(year int, month time.Month) []ApsisInfo {
return convertMoonApsisInfos(basic.MoonPerigeesWithInstance(year, month, module.inst))
}
// ApogeesInMonth 指定年月内的所有月球远地点 / all lunar apogees in the given Gregorian month.
func ApogeesInMonth(year int, month time.Month) []ApsisInfo {
return convertMoonApsisInfos(basic.MoonApogees(year, month))
func (module *Moon) ApogeesInMonth(year int, month time.Month) []ApsisInfo {
return convertMoonApsisInfos(basic.MoonApogeesWithInstance(year, month, module.inst))
}
func convertMoonApsisInfos(events []basic.ApsisEvent) []ApsisInfo {
+6 -6
View File
@@ -45,28 +45,28 @@ func validConjunctionPlanet(planet ConjunctionPlanet) bool {
}
// LastConjunctionWithPlanet 上一次行星合月(赤经合) / previous Moon-planet conjunction.
func LastConjunctionWithPlanet(date time.Time, planet ConjunctionPlanet) time.Time {
func (module *Moon) LastConjunctionWithPlanet(date time.Time, planet ConjunctionPlanet) time.Time {
if !validConjunctionPlanet(planet) {
return time.Time{}
}
jde := basic.UTC2TT(basic.Date2JD(date.UTC()))
return basic.JD2DateByZone(basic.LastMoonPlanetConjunction(jde, conjunctionPlanetToBasic(planet)), date.Location(), false)
return basic.JD2DateByZone(basic.LastMoonPlanetConjunctionWithInstance(jde, conjunctionPlanetToBasic(planet), module.inst), date.Location(), false)
}
// NextConjunctionWithPlanet 下一次行星合月(赤经合) / next Moon-planet conjunction.
func NextConjunctionWithPlanet(date time.Time, planet ConjunctionPlanet) time.Time {
func (module *Moon) NextConjunctionWithPlanet(date time.Time, planet ConjunctionPlanet) time.Time {
if !validConjunctionPlanet(planet) {
return time.Time{}
}
jde := basic.UTC2TT(basic.Date2JD(date.UTC()))
return basic.JD2DateByZone(basic.NextMoonPlanetConjunction(jde, conjunctionPlanetToBasic(planet)), date.Location(), false)
return basic.JD2DateByZone(basic.NextMoonPlanetConjunctionWithInstance(jde, conjunctionPlanetToBasic(planet), module.inst), date.Location(), false)
}
// ClosestConjunctionWithPlanet 最近一次行星合月(赤经合) / closest Moon-planet conjunction.
func ClosestConjunctionWithPlanet(date time.Time, planet ConjunctionPlanet) time.Time {
func (module *Moon) ClosestConjunctionWithPlanet(date time.Time, planet ConjunctionPlanet) time.Time {
if !validConjunctionPlanet(planet) {
return time.Time{}
}
jde := basic.UTC2TT(basic.Date2JD(date.UTC()))
return basic.JD2DateByZone(basic.ClosestMoonPlanetConjunction(jde, conjunctionPlanetToBasic(planet)), date.Location(), false)
return basic.JD2DateByZone(basic.ClosestMoonPlanetConjunctionWithInstance(jde, conjunctionPlanetToBasic(planet), module.inst), date.Location(), false)
}
+73
View File
@@ -0,0 +1,73 @@
package moon
import (
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/ephem"
)
// Moon 指定星历实例的计算模块 / computation module bound to an ephemeris instance.
type Moon struct {
inst *ephem.Instance
// lunarLimb 是月掩搜索的默认月缘剖面,nil 为圆月面。
lunarLimb basic.LunarLimb
}
// NewWithInstance 创建指定星历实例的模块,nil 使用全局模型 / create a module; nil uses the global model.
func NewWithInstance(inst *ephem.Instance) *Moon { return &Moon{inst: inst} }
// WithLunarLimb 返回使用指定月缘剖面的副本,选项中未设置月缘时采用它 / copy with a default lunar limb for occultations.
func (module *Moon) WithLunarLimb(limb basic.LunarLimb) *Moon {
copied := *module
copied.lunarLimb = limb
return &copied
}
// occultationOptions 在选项未设置月缘时补上模块默认值。
func (module *Moon) occultationOptions(options OccultationSearchOptions) OccultationSearchOptions {
if options.LunarLimb == nil {
options.LunarLimb = module.lunarLimb
}
return options
}
var defaultModule Moon
// EarthDistance 地月距离 / Earth-Moon distance.
func (module *Moon) EarthDistance(date time.Time) float64 {
jde := basic.UTC2TT(basic.Date2JD(date))
model := ephem.Active()
if module.inst != nil {
model = module.inst.Model()
}
if model == ephem.Legacy {
return basic.MoonAway(jde)
}
return basic.HMoonAwayWithInstance(jde, module.inst)
}
// PlanetOccultationInfoInUT1 返回固定地点行星月掩的 UT1 时刻副本 / copy in UT1 of a local planetary result.
func (module *Moon) PlanetOccultationInfoInUT1(info basic.PlanetOccultationInfo) basic.PlanetOccultationInfo {
return PlanetOccultationInfoInUT1(info)
}
// PlanetOccultationPathInUT1 返回行星月掩全球掩带的 UT1 时刻副本 / copy in UT1 of a planetary path.
func (module *Moon) PlanetOccultationPathInUT1(path basic.PlanetOccultationPath) basic.PlanetOccultationPath {
return PlanetOccultationPathInUT1(path)
}
// StarCoordinateFromStarData 将星表记录转换为恒星月掩搜索接受的 J2000 输入;不会加载星表 / convert a catalog record to a J2000 occultation input.
func (module *Moon) StarCoordinateFromStarData(star StarData) (StarCoordinate, error) {
return StarCoordinateFromStarData(star)
}
// StarOccultationInfoInUT1 返回固定地点恒星月掩的 UT1 时刻副本 / copy in UT1 of a local stellar result.
func (module *Moon) StarOccultationInfoInUT1(info basic.StarOccultationInfo) basic.StarOccultationInfo {
return StarOccultationInfoInUT1(info)
}
// StarOccultationPathInUT1 返回恒星月掩全球路径的 UT1 时刻副本 / copy in UT1 of a stellar path.
func (module *Moon) StarOccultationPathInUT1(path basic.StarOccultationPath) basic.StarOccultationPath {
return StarOccultationPathInUT1(path)
}
+144
View File
@@ -0,0 +1,144 @@
package moon
import (
"b612.me/astro/basic"
"b612.me/astro/internal/ephem"
"testing"
"time"
)
type contextAPI interface {
Altitude(date time.Time, lon, lat float64) float64
ApogeesInMonth(year int, month time.Month) []ApsisInfo
ApparentAltitude(date time.Time, lon, lat, pressureHPa, temperatureC float64) float64
ApparentDec(date time.Time, lon, lat float64) float64
ApparentLo(date time.Time) float64
ApparentRa(date time.Time, lon, lat float64) float64
ApparentRaDec(date time.Time, lon, lat float64) (float64, float64)
ApparentZenith(date time.Time, lon, lat, pressureHPa, temperatureC float64) float64
AscendingNode(date time.Time) float64
AscendingNodeN(date time.Time, n int) float64
Azimuth(date time.Time, lon, lat float64) float64
BrightLimbPositionAngle(date time.Time) float64
BrightLimbPositionAngleN(date time.Time, n int) float64
ClosestConjunctionWithPlanet(date time.Time, planet ConjunctionPlanet) time.Time
ClosestFirstQuarter(date time.Time) time.Time
ClosestFullMoon(date time.Time) time.Time
ClosestLastQuarter(date time.Time) time.Time
ClosestMaximumNorthDeclination(date time.Time) MaximumDeclinationInfo
ClosestMaximumSouthDeclination(date time.Time) MaximumDeclinationInfo
ClosestNewMoon(date time.Time) time.Time
ClosestShangXianYue(date time.Time) time.Time
ClosestShuoYue(date time.Time) time.Time
ClosestWangYue(date time.Time) time.Time
ClosestXiaXianYue(date time.Time) time.Time
CulminationTime(date time.Time, lon, lat float64) time.Time
DescendingNode(date time.Time) float64
DescendingNodeN(date time.Time, n int) float64
Diameter(date time.Time) float64
DiameterN(date time.Time, n int) float64
DownTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error)
EarthDistance(date time.Time) float64
FindBestPlanetOccultations(start, end time.Time, planet OccultationPlanet,
options OccultationSearchOptions) ([]PlanetOccultationInfo, error)
FindBestStarOccultations(start, end time.Time, star StarCoordinate, options OccultationSearchOptions) ([]StarOccultationInfo, error)
FindPlanetOccultationPaths(start, end time.Time, planet OccultationPlanet,
options OccultationPathOptions) ([]PlanetOccultationPath, error)
FindPlanetOccultations(start, end time.Time, planet OccultationPlanet, longitude, latitude, height float64,
options OccultationSearchOptions) ([]PlanetOccultationInfo, error)
FindStarOccultationPaths(start, end time.Time, star StarCoordinate, options OccultationPathOptions) ([]StarOccultationPath, error)
FindStarOccultations(start, end time.Time, star StarCoordinate, longitude, latitude, height float64,
options OccultationSearchOptions) ([]StarOccultationInfo, error)
FirstQuarter(year float64) time.Time
FullMoon(year float64) time.Time
GeocentricApparentDec(date time.Time) float64
GeocentricApparentRa(date time.Time) float64
GeocentricApparentRaDec(date time.Time) (float64, float64)
HourAngle(date time.Time, lon, lat float64) float64
LastConjunctionWithPlanet(date time.Time, planet ConjunctionPlanet) time.Time
LastFirstQuarter(date time.Time) time.Time
LastFullMoon(date time.Time) time.Time
LastLastQuarter(date time.Time) time.Time
LastMaximumNorthDeclination(date time.Time) MaximumDeclinationInfo
LastMaximumSouthDeclination(date time.Time) MaximumDeclinationInfo
LastNewMoon(date time.Time) time.Time
LastQuarter(year float64) time.Time
LastShangXianYue(date time.Time) time.Time
LastShuoYue(date time.Time) time.Time
LastWangYue(date time.Time) time.Time
LastXiaXianYue(date time.Time) time.Time
MaximumNorthDeclinationsInMonth(year int, month time.Month) []MaximumDeclinationInfo
MaximumSouthDeclinationsInMonth(year int, month time.Month) []MaximumDeclinationInfo
NewMoon(year float64) time.Time
NextConjunctionWithPlanet(date time.Time, planet ConjunctionPlanet) time.Time
NextFirstQuarter(date time.Time) time.Time
NextFullMoon(date time.Time) time.Time
NextLastQuarter(date time.Time) time.Time
NextMaximumNorthDeclination(date time.Time) MaximumDeclinationInfo
NextMaximumSouthDeclination(date time.Time) MaximumDeclinationInfo
NextNewMoon(date time.Time) time.Time
NextShangXianYue(date time.Time) time.Time
NextShuoYue(date time.Time) time.Time
NextWangYue(date time.Time) time.Time
NextXiaXianYue(date time.Time) time.Time
ParallacticAngle(date time.Time, lon, lat float64) float64
PerigeesInMonth(year int, month time.Month) []ApsisInfo
Phase(date time.Time) float64
PhaseDesc(date time.Time) string
Physical(date time.Time) PhysicalInfo
PhysicalN(date time.Time, n int) PhysicalInfo
PlanetOccultationDiagram(
info PlanetOccultationInfo,
options PlanetOccultationDiagramOptions,
) PlanetOccultationDiagramResult
PlanetOccultationFootprintsAt(at time.Time, planet OccultationPlanet) (PlanetOccultationInstant, error)
PlanetOccultationInfoInUT1(info basic.PlanetOccultationInfo) basic.PlanetOccultationInfo
PlanetOccultationPathInUT1(path basic.PlanetOccultationPath) basic.PlanetOccultationPath
RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error)
Semidiameter(date time.Time) float64
SemidiameterN(date time.Time, n int) float64
SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error)
ShangXianYue(year float64) time.Time
ShuoYue(year float64) time.Time
StarCoordinateFromStarData(star StarData) (StarCoordinate, error)
StarOccultationDiagram(
info StarOccultationInfo,
star StarCoordinate,
options StarOccultationDiagramOptions,
) StarOccultationDiagramResult
StarOccultationFootprintAt(at time.Time, star StarCoordinate) (StarOccultationInstant, error)
StarOccultationInfoInUT1(info basic.StarOccultationInfo) basic.StarOccultationInfo
StarOccultationPathInUT1(path basic.StarOccultationPath) basic.StarOccultationPath
SunMoonLoDiff(date time.Time) float64
TopocentricBrightLimbPositionAngle(date time.Time, observerLon, observerLat, height float64) float64
TopocentricBrightLimbPositionAngleN(date time.Time, observerLon, observerLat, height float64, n int) float64
TopocentricPhysical(date time.Time, observerLon, observerLat, height float64) PhysicalInfo
TopocentricPhysicalN(date time.Time, observerLon, observerLat, height float64, n int) PhysicalInfo
TrueBo(date time.Time) float64
TrueBoN(date time.Time, n int) float64
TrueDec(date time.Time) float64
TrueLo(date time.Time) float64
TrueLoN(date time.Time, n int) float64
TrueRa(date time.Time) float64
TrueRaDec(date time.Time) (float64, float64)
WangYue(year float64) time.Time
XiaXianYue(year float64) time.Time
Zenith(date time.Time, lon, lat float64) float64
}
var _ contextAPI = (*Moon)(nil)
func TestContextModuleIsolation(t *testing.T) {
previous := basic.GetEphemerisModel()
t.Cleanup(func() { basic.SetEphemerisModel(previous) })
date := time.Date(2024, 10, 6, 12, 0, 0, 0, time.UTC)
for _, model := range []ephem.Model{ephem.Legacy, ephem.Semianalytic} {
basic.SetEphemerisModel(basic.EphemerisModel(model))
want := ApparentLo(date)
module := NewWithInstance(ephem.NewModelInstance(model))
basic.SetEphemerisModel(basic.EphemerisModel(1 - int(model)))
if got := module.ApparentLo(date); got != want {
t.Fatalf("model %d: longitude %.12f, want %.12f", model, got, want)
}
}
}
+10 -10
View File
@@ -7,23 +7,23 @@ import (
)
// Semidiameter 月亮视半径,单位角秒 / apparent lunar semidiameter in arcseconds.
func Semidiameter(date time.Time) float64 {
return SemidiameterN(date, -1)
func (module *Moon) Semidiameter(date time.Time) float64 {
return module.SemidiameterN(date, -1)
}
// SemidiameterN 月亮视半径(截断版),单位角秒 / truncated apparent lunar semidiameter in arcseconds.
func SemidiameterN(date time.Time, n int) float64 {
// SemidiameterN 月亮视半径(n 变体),单位角秒 / n-parameterised apparent lunar semidiameter in arcseconds.
func (module *Moon) SemidiameterN(date time.Time, n int) float64 {
jd := basic.Date2JD(date.UTC())
return basic.MoonSemidiameterN(basic.UTC2TT(jd), n)
return basic.MoonSemidiameterNWithInstance(basic.UTC2TT(jd), n, module.inst)
}
// Diameter 月亮视直径,单位角秒 / apparent lunar diameter in arcseconds.
func Diameter(date time.Time) float64 {
return DiameterN(date, -1)
func (module *Moon) Diameter(date time.Time) float64 {
return module.DiameterN(date, -1)
}
// DiameterN 月亮视直径(截断版),单位角秒 / truncated apparent lunar diameter in arcseconds.
func DiameterN(date time.Time, n int) float64 {
// DiameterN 月亮视直径(n 变体),单位角秒 / n-parameterised apparent lunar diameter in arcseconds.
func (module *Moon) DiameterN(date time.Time, n int) float64 {
jd := basic.Date2JD(date.UTC())
return basic.MoonDiameterN(basic.UTC2TT(jd), n)
return basic.MoonDiameterNWithInstance(basic.UTC2TT(jd), n, module.inst)
}
+5
View File
@@ -0,0 +1,5 @@
// Package moon 月球视位置、升落、月相、天平动、视直径与物理观测参数;角度单位为度。
// Package moon covers lunar apparent place, rise/set, phase, libration, apparent diameter and physical observing parameters; angles are degrees.
// 求值接口的 N 后缀是 n 变体:n < 0 取完整级数;半解析模型下所有 n >= 0 使用同一张快速表;Legacy 按 n 截断。
// The N suffix marks an n-variant: n < 0 uses the full series, every n >= 0 uses one fast table under the semianalytic model, and Legacy truncates by n.
package moon
+28
View File
@@ -0,0 +1,28 @@
本包内嵌的月缘剖面数据不适用本仓库的 Apache-2.0,遵循其来源条款。完整声明见仓库根目录 /NOTICE(第 1 节)。
The lunar limb profile embedded in this package is NOT covered by this repository's Apache-2.0 licence; it follows the terms of its source. The full notice is section 1 of /NOTICE in the repository root.
SELENE (KAGUYA) lunar limb profile — kaguya_lalt.bin
Source data: SELENE Global Grid Topographic Data of the Moon (LALT_GGT_NUM),
dataset sln-l-lalt-5-topo-ggt-num-v2.0, version 2.0, published 2017.
Origin of the data: ISAS/JAXA.
Provided by the SELENE (KAGUYA) Data Archive (DARTS), ISAS/JAXA:
https://darts.isas.jaxa.jp/datasets/darts:sln-l-lalt-5-topo-ggt-num-v2.0
Acknowledgement, as requested by the SELENE Data Archive:
"We thank the SELENE(KAGUYA) LALT team and the SELENE Data Archive for
providing the SELENE(KAGUYA) data. SELENE is a Japanese mission developed
and operated by JAXA."
Modifications: the original PDS3 fixed-width table (1/16 degree global grid,
2880 x 5760 samples) was converted to a float32 elevation grid and resampled
into a limb-radius profile table on a 1 degree grid of selenographic
(libration) longitude and latitude and a 0.1 degree grid of position angle,
taking max (R + h) cos(alpha) over a +/-9 degree arc per direction; the
result is stored in this project's own zlib-compressed LLB1 container.
Licence: ISAS Data Policy,
https://www.isas.jaxa.jp/en/researchers/data-policy/
which states that its rules are compatible with Creative Commons Attribution
4.0 International, https://creativecommons.org/licenses/by/4.0/
Provided "as is", without warranties of any kind. ISAS/JAXA is not
responsible for the actions of data users and does not endorse this project.
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
// Package limb provides the embedded Kaguya LALT lunar limb profile.
package limb
import (
_ "embed"
"sync"
"b612.me/astro/moon/limbtable"
)
//go:embed kaguya_lalt.bin
var kaguyaTable []byte
// Profile is the decoded Kaguya lunar limb profile.
type Profile = limbtable.Profile
var (
defaultOnce sync.Once
defaultProfile *Profile
)
// Default returns the embedded Kaguya LALT profile, decoded on first use.
func Default() *Profile {
defaultOnce.Do(func() {
profile, err := Parse(kaguyaTable)
if err != nil {
panic(err)
}
defaultProfile = profile
})
return defaultProfile
}
// Parse decodes an LLB1 profile table.
func Parse(data []byte) (*Profile, error) { return limbtable.Parse(data) }
const headerSize = limbtable.HeaderSize
+110
View File
@@ -0,0 +1,110 @@
package limb
import (
"bytes"
"compress/zlib"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"math"
"testing"
)
// 内置表的 sha256;换表时须同步更新。
const kaguyaTableSHA256 = "7d49dc75497c404b78b5b259129d0f5ab17b56522128478591ea33c8cb97d072"
func TestEmbeddedTableChecksum(t *testing.T) {
sum := sha256.Sum256(kaguyaTable)
if got := hex.EncodeToString(sum[:]); got != kaguyaTableSHA256 {
t.Fatalf("embedded table sha256 = %s, want %s", got, kaguyaTableSHA256)
}
}
func TestLimbRadiusMatchesGenerator(t *testing.T) {
cases := []struct{ lon, lat, angle, radiusKM float64 }{
{0, 0, 0, 1737.432000000},
{3.3, -2.7, 123.45, 1735.688760000},
{-8.6, 7.9, 359.97, 1738.466256000},
{5.5, 1.25, 270.05, 1738.604000000},
{-1.0, -6.0, 45.0, 1737.796000000},
}
profile := Default()
for _, c := range cases {
got, ok := profile.LimbRadiusKM(c.lon, c.lat, c.angle)
if !ok {
t.Fatalf("LimbRadiusKM(%v, %v, %v) not ok", c.lon, c.lat, c.angle)
}
if math.Abs(got-c.radiusKM) > 1e-6 {
t.Errorf("LimbRadiusKM(%v, %v, %v) = %.9f, want %.9f", c.lon, c.lat, c.angle, got, c.radiusKM)
}
}
}
func TestLimbRadiusWrapsAndClamps(t *testing.T) {
profile := Default()
base, _ := profile.LimbRadiusKM(2, 3, 10)
for _, angle := range []float64{370, -350} {
if got, _ := profile.LimbRadiusKM(2, 3, angle); math.Abs(got-base) > 1e-9 {
t.Errorf("angle %v = %.9f, want %.9f", angle, got, base)
}
}
edge, _ := profile.LimbRadiusKM(9, -8, 10)
if got, _ := profile.LimbRadiusKM(12, -11, 10); got != edge {
t.Errorf("out-of-range libration = %.9f, want clamped %.9f", got, edge)
}
if _, ok := profile.LimbRadiusKM(math.NaN(), 0, 0); ok {
t.Error("NaN libration must fail")
}
}
func TestParseRejectsBadInput(t *testing.T) {
if _, err := Parse([]byte("nope")); err == nil {
t.Error("short input must fail")
}
broken := append([]byte(nil), kaguyaTable[:headerSize+16]...)
if _, err := Parse(broken); err == nil {
t.Error("truncated payload must fail")
}
huge := append([]byte(nil), kaguyaTable...)
binary.LittleEndian.PutUint32(huge[4:], 1<<30)
binary.LittleEndian.PutUint32(huge[8:], 1<<30)
if _, err := Parse(huge); err == nil {
t.Error("oversized header must fail before allocating")
}
}
func TestParseRejectsNonFiniteHeader(t *testing.T) {
cases := []struct {
name string
angleStep, ref float64
}{
{name: "NaN angle step", angleStep: math.NaN(), ref: 1737.4},
{name: "Inf angle step", angleStep: math.Inf(1), ref: 1737.4},
{name: "NaN reference radius", angleStep: 180, ref: math.NaN()},
{name: "zero reference radius", angleStep: 180, ref: 0},
}
for _, c := range cases {
if _, err := Parse(syntheticTable(c.angleStep, c.ref)); err == nil {
t.Errorf("%s: Parse err = nil, want an error", c.name)
}
}
if _, err := Parse(syntheticTable(180, 1737.4)); err != nil {
t.Fatalf("valid header: Parse err = %v", err)
}
}
// syntheticTable 最小合法 2×2×2 LLB1 表,仅表头两项可变。
func syntheticTable(angleStep, referenceRadiusKM float64) []byte {
var buf bytes.Buffer
buf.WriteString("LLB1")
for _, count := range []uint32{2, 2, 2} {
binary.Write(&buf, binary.LittleEndian, count)
}
for _, value := range []float64{0, 1, 0, 1, angleStep, referenceRadiusKM, 1} {
binary.Write(&buf, binary.LittleEndian, math.Float64bits(value))
}
writer := zlib.NewWriter(&buf)
writer.Write(make([]byte, 2*8))
writer.Close()
return buf.Bytes()
}
+153
View File
@@ -0,0 +1,153 @@
// Package limbtable decodes lunar limb profile tables without embedding a dataset.
package limbtable
import (
"bytes"
"compress/zlib"
"encoding/binary"
"errors"
"io"
"math"
)
const HeaderSize = 4 + 3*4 + 7*8
const maxCells = 1 << 24
// Profile is a lunar limb profile table.
type Profile struct {
lonCount, latCount, angleCount int
lonStart, lonStep float64
latStart, latStep float64
angleStep float64
referenceRadiusKM float64
minHeight, maxHeight float64
heights []float32
}
// Parse decodes an LLB1 profile table.
func Parse(data []byte) (*Profile, error) {
if len(data) < HeaderSize || string(data[:4]) != "LLB1" {
return nil, errors.New("limbtable: not an LLB1 table")
}
le := binary.LittleEndian
counts := [3]int{}
for index := range counts {
counts[index] = int(le.Uint32(data[4+4*index:]))
}
values := [7]float64{}
for index := range values {
values[index] = math.Float64frombits(le.Uint64(data[16+8*index:]))
}
profile := &Profile{
lonCount: counts[0], latCount: counts[1], angleCount: counts[2],
lonStart: values[0], lonStep: values[1],
latStart: values[2], latStep: values[3],
angleStep: values[4], referenceRadiusKM: values[5],
}
quantum := values[6]
finite := true
for _, value := range values {
finite = finite && isFinite(value)
}
if !finite || profile.lonCount < 2 || profile.latCount < 2 || profile.angleCount < 2 ||
!(profile.lonStep > 0) || !(profile.latStep > 0) || !(quantum > 0) || !(profile.referenceRadiusKM > 0) ||
math.Abs(profile.angleStep*float64(profile.angleCount)-360) > 1e-9 ||
profile.lonCount > maxCells/profile.latCount/profile.angleCount {
return nil, errors.New("limbtable: invalid LLB1 header")
}
reader, err := zlib.NewReader(bytes.NewReader(data[HeaderSize:]))
if err != nil {
return nil, err
}
defer reader.Close()
total := profile.lonCount * profile.latCount * profile.angleCount
raw := make([]byte, 2*total)
if _, err := io.ReadFull(reader, raw); err != nil {
return nil, err
}
row := profile.lonCount * profile.angleCount
levels := make([]int32, total)
for index := range levels {
levels[index] = int32(int16(le.Uint16(raw[2*index:])))
}
for index := row; index < total; index++ {
levels[index] += levels[index-row]
}
for lat := 0; lat < profile.latCount; lat++ {
base := lat * row
for index := base + profile.angleCount; index < base+row; index++ {
levels[index] += levels[index-profile.angleCount]
}
}
profile.heights = make([]float32, total)
profile.minHeight, profile.maxHeight = math.Inf(1), math.Inf(-1)
for index, level := range levels {
height := float32(float64(level) * quantum)
profile.heights[index] = height
profile.minHeight = math.Min(profile.minHeight, float64(height))
profile.maxHeight = math.Max(profile.maxHeight, float64(height))
}
return profile, nil
}
// LimbRadiusKM returns the Moon-centre to limb-point distance in kilometres.
func (profile *Profile) LimbRadiusKM(librationLongitude, librationLatitude, positionAngle float64) (float64, bool) {
if profile == nil || len(profile.heights) == 0 ||
!isFinite(librationLongitude) || !isFinite(librationLatitude) || !isFinite(positionAngle) {
return 0, false
}
lon, lonWeight := profile.cell(librationLongitude, profile.lonStart, profile.lonStep, profile.lonCount)
lat, latWeight := profile.cell(librationLatitude, profile.latStart, profile.latStep, profile.latCount)
angle := math.Mod(positionAngle/profile.angleStep, float64(profile.angleCount))
if angle < 0 {
angle += float64(profile.angleCount)
}
angle0 := int(angle)
if angle0 >= profile.angleCount {
angle0 = profile.angleCount - 1
}
angleWeight := angle - float64(angle0)
angle1 := (angle0 + 1) % profile.angleCount
height := 0.0
for dLat, wLat := range [2]float64{1 - latWeight, latWeight} {
for dLon, wLon := range [2]float64{1 - lonWeight, lonWeight} {
base := ((lat+dLat)*profile.lonCount + lon + dLon) * profile.angleCount
height += wLat * wLon * ((1-angleWeight)*float64(profile.heights[base+angle0]) +
angleWeight*float64(profile.heights[base+angle1]))
}
}
return profile.referenceRadiusKM + height/1000, true
}
// AngleStepDeg 位置角网格步长(度) / position-angle grid step in degrees.
func (profile *Profile) AngleStepDeg() float64 {
if profile == nil {
return 0
}
return profile.angleStep
}
// RadiusRangeKM 表内可能取到的最小、最大月缘半径(km) / smallest and largest limb radius.
func (profile *Profile) RadiusRangeKM() (float64, float64) {
if profile == nil || len(profile.heights) == 0 {
return 0, 0
}
return profile.referenceRadiusKM + profile.minHeight/1000, profile.referenceRadiusKM + profile.maxHeight/1000
}
func (profile *Profile) cell(value, start, step float64, count int) (int, float64) {
position := (value - start) / step
if position <= 0 {
return 0, 0
}
if position >= float64(count-1) {
return count - 2, 1
}
index := int(position)
return index, position - float64(index)
}
func isFinite(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
+16 -16
View File
@@ -15,43 +15,43 @@ type MaximumDeclinationInfo struct {
}
// MaximumNorthDeclinationsInMonth 指定年月内的所有月球最大北赤纬事件 / all maximum northern lunar declination events in the given Gregorian month.
func MaximumNorthDeclinationsInMonth(year int, month time.Month) []MaximumDeclinationInfo {
return convertMaximumDeclinationInfos(basic.MoonMaximumNorthDeclinations(year, month))
func (module *Moon) MaximumNorthDeclinationsInMonth(year int, month time.Month) []MaximumDeclinationInfo {
return convertMaximumDeclinationInfos(basic.MoonMaximumNorthDeclinationsWithInstance(year, month, module.inst))
}
// MaximumSouthDeclinationsInMonth 指定年月内的所有月球最大南赤纬事件 / all maximum southern lunar declination events in the given Gregorian month.
func MaximumSouthDeclinationsInMonth(year int, month time.Month) []MaximumDeclinationInfo {
return convertMaximumDeclinationInfos(basic.MoonMaximumSouthDeclinations(year, month))
func (module *Moon) MaximumSouthDeclinationsInMonth(year int, month time.Month) []MaximumDeclinationInfo {
return convertMaximumDeclinationInfos(basic.MoonMaximumSouthDeclinationsWithInstance(year, month, module.inst))
}
// LastMaximumNorthDeclination 指定时刻之前最近一次月球最大北赤纬 / last maximum northern lunar declination at or before date.
func LastMaximumNorthDeclination(date time.Time) MaximumDeclinationInfo {
return convertMaximumDeclinationInfo(date, basic.LastMoonMaximumNorthDeclination(timeToUTJDE(date)))
func (module *Moon) LastMaximumNorthDeclination(date time.Time) MaximumDeclinationInfo {
return convertMaximumDeclinationInfo(date, basic.LastMoonMaximumNorthDeclinationWithInstance(timeToUTJDE(date), module.inst))
}
// NextMaximumNorthDeclination 指定时刻之后最近一次月球最大北赤纬 / next maximum northern lunar declination after date.
func NextMaximumNorthDeclination(date time.Time) MaximumDeclinationInfo {
return convertMaximumDeclinationInfo(date, basic.NextMoonMaximumNorthDeclination(timeToUTJDE(date)))
func (module *Moon) NextMaximumNorthDeclination(date time.Time) MaximumDeclinationInfo {
return convertMaximumDeclinationInfo(date, basic.NextMoonMaximumNorthDeclinationWithInstance(timeToUTJDE(date), module.inst))
}
// ClosestMaximumNorthDeclination 离指定时刻最近一次月球最大北赤纬 / closest maximum northern lunar declination to date.
func ClosestMaximumNorthDeclination(date time.Time) MaximumDeclinationInfo {
return convertMaximumDeclinationInfo(date, basic.ClosestMoonMaximumNorthDeclination(timeToUTJDE(date)))
func (module *Moon) ClosestMaximumNorthDeclination(date time.Time) MaximumDeclinationInfo {
return convertMaximumDeclinationInfo(date, basic.ClosestMoonMaximumNorthDeclinationWithInstance(timeToUTJDE(date), module.inst))
}
// LastMaximumSouthDeclination 指定时刻之前最近一次月球最大南赤纬 / last maximum southern lunar declination at or before date.
func LastMaximumSouthDeclination(date time.Time) MaximumDeclinationInfo {
return convertMaximumDeclinationInfo(date, basic.LastMoonMaximumSouthDeclination(timeToUTJDE(date)))
func (module *Moon) LastMaximumSouthDeclination(date time.Time) MaximumDeclinationInfo {
return convertMaximumDeclinationInfo(date, basic.LastMoonMaximumSouthDeclinationWithInstance(timeToUTJDE(date), module.inst))
}
// NextMaximumSouthDeclination 指定时刻之后最近一次月球最大南赤纬 / next maximum southern lunar declination after date.
func NextMaximumSouthDeclination(date time.Time) MaximumDeclinationInfo {
return convertMaximumDeclinationInfo(date, basic.NextMoonMaximumSouthDeclination(timeToUTJDE(date)))
func (module *Moon) NextMaximumSouthDeclination(date time.Time) MaximumDeclinationInfo {
return convertMaximumDeclinationInfo(date, basic.NextMoonMaximumSouthDeclinationWithInstance(timeToUTJDE(date), module.inst))
}
// ClosestMaximumSouthDeclination 离指定时刻最近一次月球最大南赤纬 / closest maximum southern lunar declination to date.
func ClosestMaximumSouthDeclination(date time.Time) MaximumDeclinationInfo {
return convertMaximumDeclinationInfo(date, basic.ClosestMoonMaximumSouthDeclination(timeToUTJDE(date)))
func (module *Moon) ClosestMaximumSouthDeclination(date time.Time) MaximumDeclinationInfo {
return convertMaximumDeclinationInfo(date, basic.ClosestMoonMaximumSouthDeclinationWithInstance(timeToUTJDE(date), module.inst))
}
func convertMaximumDeclinationInfos(events []basic.DeclinationEvent) []MaximumDeclinationInfo {
+173 -268
View File
@@ -1,13 +1,13 @@
package moon
import (
"b612.me/astro/internal/civiltime"
"b612.me/astro/tools"
"errors"
"math"
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/civiltime"
"b612.me/astro/tools"
)
var (
@@ -36,194 +36,131 @@ func riseSetResult(date time.Time, jd float64, err error) (time.Time, error) {
}
// TrueLo 月亮真黄经 / true ecliptic longitude.
//
// 返回月亮在 date 对应绝对时刻的地心真黄经,单位度。
// Returns the Moon's geocentric true ecliptic longitude at the instant represented by date, in degrees.
func TrueLo(date time.Time) float64 {
func (module *Moon) TrueLo(date time.Time) float64 {
jd := basic.Date2JD(date.UTC())
return basic.HMoonTrueLo(basic.UTC2TT(jd))
return basic.HMoonTrueLoWithInstance(basic.UTC2TT(jd), module.inst)
}
// TrueLoN 截断项月亮真黄经 / truncated true ecliptic longitude.
//
// 参数与 TrueLo 相同;n<0 使用当前仓库内嵌的全部 ELP 项,其余值用于截断月球级数。
// Uses the same inputs as TrueLo. n<0 keeps all embedded ELP terms in this repository; other values truncate the lunar series.
func TrueLoN(date time.Time, n int) float64 {
// TrueLoN 月亮真黄经(n 变体) / n-parameterised true ecliptic longitude.
func (module *Moon) TrueLoN(date time.Time, n int) float64 {
jd := basic.Date2JD(date.UTC())
return basic.HMoonTrueLoN(basic.UTC2TT(jd), n)
return basic.HMoonTrueLoNWithInstance(basic.UTC2TT(jd), n, module.inst)
}
// TrueBo 月亮真黄纬 / true ecliptic latitude.
//
// 返回月亮在 date 对应绝对时刻的地心真黄纬,单位度。
// Returns the Moon's geocentric true ecliptic latitude at the instant represented by date, in degrees.
func TrueBo(date time.Time) float64 {
func (module *Moon) TrueBo(date time.Time) float64 {
jd := basic.Date2JD(date.UTC())
return basic.HMoonTrueBo(basic.UTC2TT(jd))
return basic.HMoonTrueBoWithInstance(basic.UTC2TT(jd), module.inst)
}
// TrueBoN 截断项月亮真黄纬 / truncated true ecliptic latitude.
//
// 参数与 TrueBo 相同;n<0 使用当前仓库内嵌的全部 ELP 项,其余值用于截断月球级数。
// Uses the same inputs as TrueBo. n<0 keeps all embedded ELP terms in this repository; other values truncate the lunar series.
func TrueBoN(date time.Time, n int) float64 {
// TrueBoN 月亮真黄纬(n 变体) / n-parameterised true ecliptic latitude.
func (module *Moon) TrueBoN(date time.Time, n int) float64 {
jd := basic.Date2JD(date.UTC())
return basic.HMoonTrueBoN(basic.UTC2TT(jd), n)
return basic.HMoonTrueBoNWithInstance(basic.UTC2TT(jd), n, module.inst)
}
// ApparentLo 月亮地心视黄经 / apparent geocentric ecliptic longitude.
//
// 返回月亮在 date 对应绝对时刻的地心视黄经,单位度。
// Returns the Moon's apparent geocentric ecliptic longitude at the instant represented by date, in degrees.
func ApparentLo(date time.Time) float64 {
func (module *Moon) ApparentLo(date time.Time) float64 {
jd := basic.Date2JD(date.UTC())
return basic.HMoonApparentLo(basic.UTC2TT(jd))
return basic.HMoonApparentLoWithInstance(basic.UTC2TT(jd), module.inst)
}
// TrueRa 月亮地心真赤经 / true geocentric right ascension.
//
// 返回月亮在 date 对应绝对时刻的地心真赤经,单位度。
// Returns the Moon's geocentric true right ascension at the instant represented by date, in degrees.
func TrueRa(date time.Time) float64 {
func (module *Moon) TrueRa(date time.Time) float64 {
jd := basic.Date2JD(date.UTC())
return basic.HMoonGeocentricTrueRa(basic.UTC2TT(jd))
return basic.HMoonGeocentricTrueRaWithInstance(basic.UTC2TT(jd), module.inst)
}
// TrueDec 月亮地心真赤纬 / true geocentric declination.
//
// 返回月亮在 date 对应绝对时刻的地心真赤纬,单位度。
// Returns the Moon's geocentric true declination at the instant represented by date, in degrees.
func TrueDec(date time.Time) float64 {
func (module *Moon) TrueDec(date time.Time) float64 {
jd := basic.Date2JD(date.UTC())
return basic.HMoonGeocentricTrueDec(basic.UTC2TT(jd))
return basic.HMoonGeocentricTrueDecWithInstance(basic.UTC2TT(jd), module.inst)
}
// TrueRaDec 月亮地心真赤经、真赤纬 / true geocentric right ascension and declination.
//
// 返回月亮在 date 对应绝对时刻的地心真赤经与真赤纬,单位度。
// Returns the Moon's geocentric true right ascension and declination at the instant represented by date, in degrees.
func TrueRaDec(date time.Time) (float64, float64) {
func (module *Moon) TrueRaDec(date time.Time) (float64, float64) {
jd := basic.Date2JD(date.UTC())
return basic.HMoonGeocentricTrueRaDec(basic.UTC2TT(jd))
return basic.HMoonGeocentricTrueRaDecWithInstance(basic.UTC2TT(jd), module.inst)
}
// GeocentricApparentRa 月亮地心视赤经 / apparent geocentric right ascension.
//
// 返回月亮在 date 对应绝对时刻的地心视赤经,单位度。
// Returns the Moon's apparent geocentric right ascension at the instant represented by date, in degrees.
func GeocentricApparentRa(date time.Time) float64 {
func (module *Moon) GeocentricApparentRa(date time.Time) float64 {
jd := basic.Date2JD(date.UTC())
return basic.HMoonGeocentricApparentRa(basic.UTC2TT(jd))
return basic.HMoonGeocentricApparentRaWithInstance(basic.UTC2TT(jd), module.inst)
}
// GeocentricApparentDec 月亮地心视赤纬 / apparent geocentric declination.
//
// 返回月亮在 date 对应绝对时刻的地心视赤纬,单位度。
// Returns the Moon's apparent geocentric declination at the instant represented by date, in degrees.
func GeocentricApparentDec(date time.Time) float64 {
func (module *Moon) GeocentricApparentDec(date time.Time) float64 {
jd := basic.Date2JD(date.UTC())
return basic.HMoonGeocentricApparentDec(basic.UTC2TT(jd))
return basic.HMoonGeocentricApparentDecWithInstance(basic.UTC2TT(jd), module.inst)
}
// GeocentricApparentRaDec 月亮地心视赤经、视赤纬 / apparent geocentric right ascension and declination.
//
// 返回月亮在 date 对应绝对时刻的地心视赤经与视赤纬,单位度。
// Returns the Moon's apparent geocentric right ascension and declination at the instant represented by date, in degrees.
func GeocentricApparentRaDec(date time.Time) (float64, float64) {
func (module *Moon) GeocentricApparentRaDec(date time.Time) (float64, float64) {
jd := basic.Date2JD(date.UTC())
return basic.HMoonGeocentricApparentRaDec(basic.UTC2TT(jd))
return basic.HMoonGeocentricApparentRaDecWithInstance(basic.UTC2TT(jd), module.inst)
}
// ApparentRa 月亮站心视赤经 / apparent topocentric right ascension.
//
// date 为观测时刻,会读取其时区参与地方时计算;lon/lat 为观测者经纬度,东正西负、北正南负;返回值单位度。
// date is the observing instant and its zone offset participates in local-time calculations. lon/lat are east-positive and north-positive; the result is in degrees.
func ApparentRa(date time.Time, lon, lat float64) float64 {
func (module *Moon) ApparentRa(date time.Time, lon, lat float64) float64 {
localJD := basic.Date2JD(date)
_, loc := date.Zone()
return basic.HMoonApparentRa(localJD, lon, lat, float64(loc)/3600.0)
return basic.HMoonApparentRaWithInstance(localJD, lon, lat, float64(loc)/3600.0, module.inst)
}
// ApparentDec 月亮站心视赤纬 / apparent topocentric declination.
//
// 参数与 ApparentRa 相同,返回月亮站心视赤纬,单位度。
// Uses the same inputs as ApparentRa and returns the Moon's apparent topocentric declination in degrees.
func ApparentDec(date time.Time, lon, lat float64) float64 {
func (module *Moon) ApparentDec(date time.Time, lon, lat float64) float64 {
localJD := basic.Date2JD(date)
_, loc := date.Zone()
return basic.HMoonApparentDec(localJD, lon, lat, float64(loc)/3600.0)
return basic.HMoonApparentDecWithInstance(localJD, lon, lat, float64(loc)/3600.0, module.inst)
}
// ApparentRaDec 月亮站心视赤经、视赤纬 / apparent topocentric right ascension and declination.
//
// 参数与 ApparentRa 相同,返回月亮站心视赤经与视赤纬,单位度。
// Uses the same inputs as ApparentRa and returns the Moon's apparent topocentric right ascension and declination in degrees.
func ApparentRaDec(date time.Time, lon, lat float64) (float64, float64) {
func (module *Moon) ApparentRaDec(date time.Time, lon, lat float64) (float64, float64) {
localJD := basic.Date2JD(date)
_, loc := date.Zone()
return basic.HMoonApparentRaDec(localJD, lon, lat, float64(loc)/3600.0)
return basic.HMoonApparentRaDecWithInstance(localJD, lon, lat, float64(loc)/3600.0, module.inst)
}
// HourAngle 月亮时角 / hour angle.
//
// date 为观测时刻,会读取其时区参与地方时计算;lon/lat 为观测者经纬度,东正西负、北正南负;返回值单位度。
// date is the observing instant and its zone offset participates in local-time calculations. lon/lat are east-positive and north-positive; the result is in degrees.
func HourAngle(date time.Time, lon, lat float64) float64 {
func (module *Moon) HourAngle(date time.Time, lon, lat float64) float64 {
localJD := basic.Date2JD(date)
_, loc := date.Zone()
return basic.MoonTimeAngle(localJD, lon, lat, float64(loc)/3600.0)
return basic.MoonTimeAngleWithInstance(localJD, lon, lat, float64(loc)/3600.0, module.inst)
}
// Azimuth 月亮方位角 / azimuth.
//
// date 为观测时刻,会读取其时区参与地方时计算;lon/lat 为观测者经纬度,东正西负、北正南负;返回值按正北为 0°、向东增加。
// date is the observing instant and its zone offset participates in local-time calculations. lon/lat are east-positive and north-positive; azimuth is measured from north toward east.
func Azimuth(date time.Time, lon, lat float64) float64 {
func (module *Moon) Azimuth(date time.Time, lon, lat float64) float64 {
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)
}
// Altitude 月亮高度角 / lunar altitude.
//
// date 为观测时刻,会读取其时区参与地方时计算;lon/lat 为观测者经纬度,东正西负、北正南负;返回值单位度。
// date is the observing instant and its zone offset participates in local-time calculations. lon/lat are east-positive and north-positive; the result is in degrees.
func Altitude(date time.Time, lon, lat float64) float64 {
func (module *Moon) Altitude(date time.Time, lon, lat float64) float64 {
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)
}
// Zenith 月亮天顶距 / lunar zenith distance.
//
// 参数与 Altitude 相同,返回值为对应时刻的天顶距,单位度。
// Uses the same inputs as Altitude and returns the zenith distance in degrees.
func Zenith(date time.Time, lon, lat float64) float64 {
return 90 - Altitude(date, lon, lat)
func (module *Moon) Zenith(date time.Time, lon, lat float64) float64 {
return 90 - module.Altitude(date, lon, lat)
}
// CulminationTime 月亮中天时刻 / culmination time.
//
// date 取其所在时区的当地日期,返回值保持相同时区;lon/lat 为观测者经纬度,东正西负、北正南负。
// date is interpreted on its local civil day and the result keeps the same time zone. lon/lat are east-positive and north-positive.
func CulminationTime(date time.Time, lon, lat float64) time.Time {
func (module *Moon) CulminationTime(date time.Time, lon, lat float64) time.Time {
date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
localJD := basic.Date2JD(date)
_, loc := date.Zone()
calcJD := basic.MoonCulminationTime(localJD, lon, lat, float64(loc)/3600.0) - float64(loc)/86400
calcJD := basic.MoonCulminationTimeWithInstance(localJD, lon, lat, float64(loc)/3600.0, module.inst) - float64(loc)/86400
return basic.JD2DateByZone(calcJD, date.Location(), false)
}
// RiseTime 月出时刻 / moonrise time.
//
// date 取其所在时区的当地日期,返回值保持相同时区;lon/lat 为观测者经纬度,东正西负、北正南负;
// height 为椭球高(大地高),单位米;aero 为 true 时按动态标准大气折射和实时月球视半径计算上缘过地平线。
// date is interpreted on its local civil day and the result keeps the same time zone. lon/lat are east-positive and north-positive;
// height 为观测者椭球高(大地高),单位米;aero 使用动态标准折射和实时月球视半径计算上缘过地平线。
// height is observer elevation in meters; aero uses dynamic standard refraction and the instantaneous lunar semidiameter for an upper-limb crossing.
func RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) {
func (module *Moon) RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) {
if result, err, handled := civiltime.Event(date, ERR_NOT_TODAY, func(d time.Time) (time.Time, error) {
return RiseTime(d, lon, lat, height, aero)
return module.RiseTime(d, lon, lat, height, aero)
}); handled {
return result, err
}
@@ -235,27 +172,19 @@ func RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, e
if aero {
aeroFloat = 1
}
riseJD, err := basic.GetMoonRiseTime(localJD, lon, lat, timezone, aeroFloat, height)
riseJD, err := basic.GetMoonRiseTimeWithInstance(localJD, lon, lat, timezone, aeroFloat, height, module.inst)
return riseSetResult(date, riseJD, err)
}
// DownTime 月落时刻别名 / deprecated moonset alias.
//
// Deprecated: use SetTime instead.
//
// 参数与 SetTime 相同,仅为兼容旧接口保留。
// Same as SetTime and kept only for backward compatibility.
func DownTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) {
return SetTime(date, lon, lat, height, aero)
func (module *Moon) DownTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) {
return module.SetTime(date, lon, lat, height, aero)
}
// SetTime 月落时刻 / moonset time.
//
// 参数与 RiseTime 相同,返回给定当地日期内的月落时刻。
// Uses the same inputs as RiseTime and returns the moonset time on the corresponding local civil day.
func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) {
func (module *Moon) SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) {
if result, err, handled := civiltime.Event(date, ERR_NOT_TODAY, func(d time.Time) (time.Time, error) {
return SetTime(d, lon, lat, height, aero)
return module.SetTime(d, lon, lat, height, aero)
}); handled {
return result, err
}
@@ -267,27 +196,21 @@ func SetTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, er
if aero {
aeroFloat = 1
}
downJD, err := basic.GetMoonSetTime(localJD, lon, lat, timezone, aeroFloat, height)
downJD, err := basic.GetMoonSetTimeWithInstance(localJD, lon, lat, timezone, aeroFloat, height, module.inst)
return riseSetResult(date, downJD, err)
}
// SunMoonLoDiff 日月黄经差 / Moon-Sun longitude difference.
//
// 返回月亮视黄经减去太阳视黄经的结果,单位度,取值范围 [0, 360);新月附近接近 0°,满月附近接近 180°。
// Returns apparent lunar longitude minus apparent solar longitude in degrees, normalized to [0, 360). It is near 0° at new moon and near 180° at full moon.
func SunMoonLoDiff(date time.Time) float64 {
func (module *Moon) SunMoonLoDiff(date time.Time) float64 {
jde := basic.UTC2TT(basic.Date2JD(date.UTC()))
sunLo := basic.HSunApparentLo(jde)
moonLo := basic.HMoonApparentLo(jde)
sunLo := basic.HSunApparentLoWithInstance(jde, module.inst)
moonLo := basic.HMoonApparentLoWithInstance(jde, module.inst)
return tools.Limit360(moonLo - sunLo)
}
// PhaseDesc 月相文字描述 / textual lunar phase description.
//
// 基于 SunMoonLoDiff 的分段结果返回中文月相名称。
// Returns a Chinese phase name derived from the segmented Moon-Sun longitude difference.
func PhaseDesc(date time.Time) string {
moonSunLoDiff := SunMoonLoDiff(date)
func (module *Moon) PhaseDesc(date time.Time) string {
moonSunLoDiff := module.SunMoonLoDiff(date)
if moonSunLoDiff >= 0 && moonSunLoDiff <= 30 {
return "新月"
} else if moonSunLoDiff > 30 && moonSunLoDiff <= 75 {
@@ -310,77 +233,72 @@ func PhaseDesc(date time.Time) string {
}
// Phase 月面受照比例 / illuminated fraction.
//
// 返回月亮在 date 对应绝对时刻的受照比例,范围 [0, 1]。
// Returns the Moon's illuminated fraction at the instant represented by date, in the range [0, 1].
func Phase(date time.Time) float64 {
func (module *Moon) Phase(date time.Time) float64 {
jd := basic.Date2JD(date.UTC())
return basic.MoonPhase(basic.UTC2TT(jd))
return basic.MoonPhaseWithInstance(basic.UTC2TT(jd), module.inst)
}
// ShuoYue 朔月锚点解 / new-moon solution near a decimal year anchor.
//
// year 为公历小数年锚点,例如 2025.0 或 2025.5;返回以该锚点求得的一次朔月时刻,结果为 UTC。
// year is a decimal Gregorian-year anchor such as 2025.0 or 2025.5. The returned time is one new moon solved near that anchor, in UTC.
func ShuoYue(year float64) time.Time {
jd := basic.TT2UTC(basic.CalcMoonSH(year, 0))
func (module *Moon) ShuoYue(year float64) time.Time {
jd := basic.TT2UTC(basic.CalcMoonSHWithInstance(year, 0, module.inst))
return basic.JD2DateByZone(jd, time.UTC, false)
}
// NextShuoYue 下一次朔月 / next new moon.
//
// 返回 date 之后最近一次朔月时刻,结果保持 date 的时区。
// Returns the next new moon after date, keeping date's time zone.
func NextShuoYue(date time.Time) time.Time {
return nextMoonPhase(date, 0)
func (module *Moon) NextShuoYue(date time.Time) time.Time {
return nextMoonPhase(date, 0, module)
}
// LastShuoYue 上一次朔月 / previous new moon.
//
// 返回 date 之前最近一次朔月时刻,结果保持 date 的时区。
// Returns the previous new moon before date, keeping date's time zone.
func LastShuoYue(date time.Time) time.Time {
return lastMoonPhase(date, 0)
func (module *Moon) LastShuoYue(date time.Time) time.Time {
return lastMoonPhase(date, 0, module)
}
// ClosestShuoYue 最近朔月 / closest new moon.
//
// 返回离 date 最近的朔月时刻,结果保持 date 的时区。
// Returns the new moon nearest to date, keeping date's time zone.
func ClosestShuoYue(date time.Time) time.Time {
return closestMoonPhase(date, 0)
func (module *Moon) ClosestShuoYue(date time.Time) time.Time {
return closestMoonPhase(date, 0, module)
}
// NewMoon 朔月英文别名 / English alias for ShuoYue.
func NewMoon(year float64) time.Time {
return ShuoYue(year)
func (module *Moon) NewMoon(year float64) time.Time {
return module.ShuoYue(year)
}
// NextNewMoon 下一次朔月英文别名 / English alias for NextShuoYue.
func NextNewMoon(date time.Time) time.Time {
return NextShuoYue(date)
func (module *Moon) NextNewMoon(date time.Time) time.Time {
return module.NextShuoYue(date)
}
// LastNewMoon 上一次朔月英文别名 / English alias for LastShuoYue.
func LastNewMoon(date time.Time) time.Time {
return LastShuoYue(date)
func (module *Moon) LastNewMoon(date time.Time) time.Time {
return module.LastShuoYue(date)
}
// ClosestNewMoon 最近朔月英文别名 / English alias for ClosestShuoYue.
func ClosestNewMoon(date time.Time) time.Time {
return ClosestShuoYue(date)
func (module *Moon) ClosestNewMoon(date time.Time) time.Time {
return module.ClosestShuoYue(date)
}
func closestMoonPhase(date time.Time, typed int) time.Time {
func closestMoonPhase(date time.Time, typed int, modules ...*Moon) time.Time {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
//0=shuo 1=wang 2=shangxian 3=xiaxian
jde := basic.UTC2TT(basic.Date2JD(date.UTC()))
if typed < 2 {
return basic.JD2DateByZone(basic.TT2UTC(basic.CalcMoonSHByJDE(jde, typed)), date.Location(), false)
return basic.JD2DateByZone(basic.TT2UTC(basic.CalcMoonSHByJDEWithInstance(jde, typed, module.inst)), date.Location(), false)
}
return basic.JD2DateByZone(basic.TT2UTC(basic.CalcMoonXHByJDE(jde, typed-2)), date.Location(), false)
return basic.JD2DateByZone(basic.TT2UTC(basic.CalcMoonXHByJDEWithInstance(jde, typed-2, module.inst)), date.Location(), false)
}
func nextMoonPhase(date time.Time, typed int) time.Time {
func nextMoonPhase(date time.Time, typed int, modules ...*Moon) time.Time {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
//0=shuo 1=wang 2=shangxian 3=xiaxian
diffCode := 0.00
switch typed {
@@ -392,7 +310,7 @@ func nextMoonPhase(date time.Time, typed int) time.Time {
diffCode = 270
}
jde := basic.UTC2TT(basic.Date2JD(date.UTC()))
cost := basic.HMoonApparentLo(jde) - basic.HSunApparentLo(jde) - float64(diffCode)
cost := basic.HMoonApparentLoWithInstance(jde, module.inst) - basic.HSunApparentLoWithInstance(jde, module.inst) - float64(diffCode)
for cost < 0 {
cost += 360
}
@@ -403,12 +321,17 @@ func nextMoonPhase(date time.Time, typed int) time.Time {
jde += (240 - cost) / 11.19
}
if typed < 2 {
return basic.JD2DateByZone(basic.TT2UTC(basic.CalcMoonSHByJDE(jde, typed)), date.Location(), false)
return basic.JD2DateByZone(basic.TT2UTC(basic.CalcMoonSHByJDEWithInstance(jde, typed, module.inst)), date.Location(), false)
}
return basic.JD2DateByZone(basic.TT2UTC(basic.CalcMoonXHByJDE(jde, typed-2)), date.Location(), false)
return basic.JD2DateByZone(basic.TT2UTC(basic.CalcMoonXHByJDEWithInstance(jde, typed-2, module.inst)), date.Location(), false)
}
func lastMoonPhase(date time.Time, typed int) time.Time {
func lastMoonPhase(date time.Time, typed int, modules ...*Moon) time.Time {
module := &defaultModule
if len(modules) > 0 {
module = modules[0]
}
//0=shuo 1=wang 2=shangxian 3=xiaxian
diffCode := 0.00
switch typed {
@@ -420,7 +343,7 @@ func lastMoonPhase(date time.Time, typed int) time.Time {
diffCode = 270
}
jde := basic.UTC2TT(basic.Date2JD(date.UTC()))
cost := basic.HMoonApparentLo(jde) - basic.HSunApparentLo(jde) - float64(diffCode)
cost := basic.HMoonApparentLoWithInstance(jde, module.inst) - basic.HSunApparentLoWithInstance(jde, module.inst) - float64(diffCode)
for cost < 0 {
cost += 360
}
@@ -431,176 +354,158 @@ func lastMoonPhase(date time.Time, typed int) time.Time {
jde -= (cost - 120) / 11.19
}
if typed < 2 {
return basic.JD2DateByZone(basic.TT2UTC(basic.CalcMoonSHByJDE(jde, typed)), date.Location(), false)
return basic.JD2DateByZone(basic.TT2UTC(basic.CalcMoonSHByJDEWithInstance(jde, typed, module.inst)), date.Location(), false)
}
return basic.JD2DateByZone(basic.TT2UTC(basic.CalcMoonXHByJDE(jde, typed-2)), date.Location(), false)
return basic.JD2DateByZone(basic.TT2UTC(basic.CalcMoonXHByJDEWithInstance(jde, typed-2, module.inst)), date.Location(), false)
}
// WangYue 望月锚点解 / full-moon solution near a decimal year anchor.
//
// year 为公历小数年锚点,例如 2025.0 或 2025.5;返回以该锚点求得的一次望月时刻,结果为 UTC。
// year is a decimal Gregorian-year anchor such as 2025.0 or 2025.5. The returned time is one full moon solved near that anchor, in UTC.
func WangYue(year float64) time.Time {
jd := basic.TT2UTC(basic.CalcMoonSH(year, 1))
func (module *Moon) WangYue(year float64) time.Time {
jd := basic.TT2UTC(basic.CalcMoonSHWithInstance(year, 1, module.inst))
return basic.JD2DateByZone(jd, time.UTC, false)
}
// NextWangYue 下一次望月 / next full moon.
//
// 返回 date 之后最近一次望月时刻,结果保持 date 的时区。
// Returns the next full moon after date, keeping date's time zone.
func NextWangYue(date time.Time) time.Time {
return nextMoonPhase(date, 1)
func (module *Moon) NextWangYue(date time.Time) time.Time {
return nextMoonPhase(date, 1, module)
}
// LastWangYue 上一次望月 / previous full moon.
//
// 返回 date 之前最近一次望月时刻,结果保持 date 的时区。
// Returns the previous full moon before date, keeping date's time zone.
func LastWangYue(date time.Time) time.Time {
return lastMoonPhase(date, 1)
func (module *Moon) LastWangYue(date time.Time) time.Time {
return lastMoonPhase(date, 1, module)
}
// ClosestWangYue 最近望月 / closest full moon.
//
// 返回离 date 最近的望月时刻,结果保持 date 的时区。
// Returns the full moon nearest to date, keeping date's time zone.
func ClosestWangYue(date time.Time) time.Time {
return closestMoonPhase(date, 1)
func (module *Moon) ClosestWangYue(date time.Time) time.Time {
return closestMoonPhase(date, 1, module)
}
// FullMoon 望月英文别名 / English alias for WangYue.
func FullMoon(year float64) time.Time {
return WangYue(year)
func (module *Moon) FullMoon(year float64) time.Time {
return module.WangYue(year)
}
// NextFullMoon 下一次望月英文别名 / English alias for NextWangYue.
func NextFullMoon(date time.Time) time.Time {
return NextWangYue(date)
func (module *Moon) NextFullMoon(date time.Time) time.Time {
return module.NextWangYue(date)
}
// LastFullMoon 上一次望月英文别名 / English alias for LastWangYue.
func LastFullMoon(date time.Time) time.Time {
return LastWangYue(date)
func (module *Moon) LastFullMoon(date time.Time) time.Time {
return module.LastWangYue(date)
}
// ClosestFullMoon 最近望月英文别名 / English alias for ClosestWangYue.
func ClosestFullMoon(date time.Time) time.Time {
return ClosestWangYue(date)
func (module *Moon) ClosestFullMoon(date time.Time) time.Time {
return module.ClosestWangYue(date)
}
// ShangXianYue 上弦锚点解 / first-quarter solution near a decimal year anchor.
//
// year 为公历小数年锚点,例如 2025.0 或 2025.5;返回以该锚点求得的一次上弦时刻,结果为 UTC。
// year is a decimal Gregorian-year anchor such as 2025.0 or 2025.5. The returned time is one first-quarter solution near that anchor, in UTC.
func ShangXianYue(year float64) time.Time {
jd := basic.TT2UTC(basic.CalcMoonXH(year, 0))
func (module *Moon) ShangXianYue(year float64) time.Time {
jd := basic.TT2UTC(basic.CalcMoonXHWithInstance(year, 0, module.inst))
return basic.JD2DateByZone(jd, time.UTC, false)
}
// NextShangXianYue 下一次上弦 / next first quarter.
//
// 返回 date 之后最近一次上弦时刻,结果保持 date 的时区。
// Returns the next first quarter after date, keeping date's time zone.
func NextShangXianYue(date time.Time) time.Time {
return nextMoonPhase(date, 2)
func (module *Moon) NextShangXianYue(date time.Time) time.Time {
return nextMoonPhase(date, 2, module)
}
// LastShangXianYue 上一次上弦 / previous first quarter.
//
// 返回 date 之前最近一次上弦时刻,结果保持 date 的时区。
// Returns the previous first quarter before date, keeping date's time zone.
func LastShangXianYue(date time.Time) time.Time {
return lastMoonPhase(date, 2)
func (module *Moon) LastShangXianYue(date time.Time) time.Time {
return lastMoonPhase(date, 2, module)
}
// ClosestShangXianYue 最近上弦 / closest first quarter.
//
// 返回离 date 最近的上弦时刻,结果保持 date 的时区。
// Returns the first quarter nearest to date, keeping date's time zone.
func ClosestShangXianYue(date time.Time) time.Time {
return closestMoonPhase(date, 2)
func (module *Moon) ClosestShangXianYue(date time.Time) time.Time {
return closestMoonPhase(date, 2, module)
}
// FirstQuarter 上弦英文别名 / English alias for ShangXianYue.
func FirstQuarter(year float64) time.Time {
return ShangXianYue(year)
func (module *Moon) FirstQuarter(year float64) time.Time {
return module.ShangXianYue(year)
}
// NextFirstQuarter 下一次上弦英文别名 / English alias for NextShangXianYue.
func NextFirstQuarter(date time.Time) time.Time {
return NextShangXianYue(date)
func (module *Moon) NextFirstQuarter(date time.Time) time.Time {
return module.NextShangXianYue(date)
}
// LastFirstQuarter 上一次上弦英文别名 / English alias for LastShangXianYue.
func LastFirstQuarter(date time.Time) time.Time {
return LastShangXianYue(date)
func (module *Moon) LastFirstQuarter(date time.Time) time.Time {
return module.LastShangXianYue(date)
}
// ClosestFirstQuarter 最近上弦英文别名 / English alias for ClosestShangXianYue.
func ClosestFirstQuarter(date time.Time) time.Time {
return ClosestShangXianYue(date)
func (module *Moon) ClosestFirstQuarter(date time.Time) time.Time {
return module.ClosestShangXianYue(date)
}
// XiaXianYue 下弦锚点解 / last-quarter solution near a decimal year anchor.
//
// year 为公历小数年锚点,例如 2025.0 或 2025.5;返回以该锚点求得的一次下弦时刻,结果为 UTC。
// year is a decimal Gregorian-year anchor such as 2025.0 or 2025.5. The returned time is one last-quarter solution near that anchor, in UTC.
func XiaXianYue(year float64) time.Time {
jd := basic.TT2UTC(basic.CalcMoonXH(year, 1))
func (module *Moon) XiaXianYue(year float64) time.Time {
jd := basic.TT2UTC(basic.CalcMoonXHWithInstance(year, 1, module.inst))
return basic.JD2DateByZone(jd, time.UTC, false)
}
// NextXiaXianYue 下一次下弦 / next last quarter.
//
// 返回 date 之后最近一次下弦时刻,结果保持 date 的时区。
// Returns the next last quarter after date, keeping date's time zone.
func NextXiaXianYue(date time.Time) time.Time {
return nextMoonPhase(date, 3)
func (module *Moon) NextXiaXianYue(date time.Time) time.Time {
return nextMoonPhase(date, 3, module)
}
// LastXiaXianYue 上一次下弦 / previous last quarter.
//
// 返回 date 之前最近一次下弦时刻,结果保持 date 的时区。
// Returns the previous last quarter before date, keeping date's time zone.
func LastXiaXianYue(date time.Time) time.Time {
return lastMoonPhase(date, 3)
func (module *Moon) LastXiaXianYue(date time.Time) time.Time {
return lastMoonPhase(date, 3, module)
}
// ClosestXiaXianYue 最近下弦 / closest last quarter.
//
// 返回离 date 最近的下弦时刻,结果保持 date 的时区。
// Returns the last quarter nearest to date, keeping date's time zone.
func ClosestXiaXianYue(date time.Time) time.Time {
return closestMoonPhase(date, 3)
func (module *Moon) ClosestXiaXianYue(date time.Time) time.Time {
return closestMoonPhase(date, 3, module)
}
// LastQuarter 下弦英文别名 / English alias for XiaXianYue.
func LastQuarter(year float64) time.Time {
return XiaXianYue(year)
func (module *Moon) LastQuarter(year float64) time.Time {
return module.XiaXianYue(year)
}
// NextLastQuarter 下一次下弦英文别名 / English alias for NextXiaXianYue.
func NextLastQuarter(date time.Time) time.Time {
return NextXiaXianYue(date)
func (module *Moon) NextLastQuarter(date time.Time) time.Time {
return module.NextXiaXianYue(date)
}
// LastLastQuarter 上一次下弦英文别名 / English alias for LastXiaXianYue.
func LastLastQuarter(date time.Time) time.Time {
return LastXiaXianYue(date)
func (module *Moon) LastLastQuarter(date time.Time) time.Time {
return module.LastXiaXianYue(date)
}
// ClosestLastQuarter 最近下弦英文别名 / English alias for ClosestXiaXianYue.
func ClosestLastQuarter(date time.Time) time.Time {
return ClosestXiaXianYue(date)
func (module *Moon) ClosestLastQuarter(date time.Time) time.Time {
return module.ClosestXiaXianYue(date)
}
// EarthDistance 地月距离 / Earth-Moon distance.
//
// 返回月亮在 date 对应绝对时刻到地球质心的距离,单位千米。
// Returns the distance from the Moon to Earth's center at the instant represented by date, in kilometers.
func EarthDistance(date time.Time) float64 {
jd := basic.Date2JD(date)
jde := basic.UTC2TT(jd)
return basic.MoonAway(jde)
// RiseTimeWithAtmosphere 月出时刻,按给定气压(hPa)与气温(°C)计算大气折射 / moonrise time with explicit pressure and temperature.
func (module *Moon) RiseTimeWithAtmosphere(date time.Time, lon, lat, height, pressureHPa, temperatureC float64) (time.Time, error) {
if result, err, handled := civiltime.Event(date, ERR_NOT_TODAY, func(d time.Time) (time.Time, error) {
return module.RiseTimeWithAtmosphere(d, lon, lat, height, pressureHPa, temperatureC)
}); handled {
return result, err
}
date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
localJD := basic.Date2JD(date)
_, loc := date.Zone()
jd, err := basic.GetMoonRiseTimeWithAtmosphere(localJD, lon, lat, float64(loc)/3600.0, height, pressureHPa, temperatureC, module.inst)
return riseSetResult(date, jd, err)
}
// SetTimeWithAtmosphere 月落时刻,按给定气压(hPa)与气温(°C)计算大气折射 / moonset time with explicit pressure and temperature.
func (module *Moon) SetTimeWithAtmosphere(date time.Time, lon, lat, height, pressureHPa, temperatureC float64) (time.Time, error) {
if result, err, handled := civiltime.Event(date, ERR_NOT_TODAY, func(d time.Time) (time.Time, error) {
return module.SetTimeWithAtmosphere(d, lon, lat, height, pressureHPa, temperatureC)
}); handled {
return result, err
}
date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
localJD := basic.Date2JD(date)
_, loc := date.Zone()
jd, err := basic.GetMoonSetTimeWithAtmosphere(localJD, lon, lat, float64(loc)/3600.0, height, pressureHPa, temperatureC, module.inst)
return riseSetResult(date, jd, err)
}
+10 -10
View File
@@ -7,23 +7,23 @@ import (
)
// AscendingNode 月球升交点黄经 / ascending node longitude of the Moon.
func AscendingNode(date time.Time) float64 {
return AscendingNodeN(date, -1)
func (module *Moon) AscendingNode(date time.Time) float64 {
return module.AscendingNodeN(date, -1)
}
// AscendingNodeN 月球升交点黄经(截断版) / truncated ascending node longitude of the Moon.
func AscendingNodeN(date time.Time, n int) float64 {
// AscendingNodeN 月球升交点黄经(n 变体) / n-parameterised ascending node longitude of the Moon.
func (module *Moon) AscendingNodeN(date time.Time, n int) float64 {
jde := basic.UTC2TT(basic.Date2JD(date.UTC()))
return basic.MoonAscendingNodeN(jde, n)
return basic.MoonAscendingNodeNWithInstance(jde, n, module.inst)
}
// DescendingNode 月球降交点黄经 / descending node longitude of the Moon.
func DescendingNode(date time.Time) float64 {
return DescendingNodeN(date, -1)
func (module *Moon) DescendingNode(date time.Time) float64 {
return module.DescendingNodeN(date, -1)
}
// DescendingNodeN 月球降交点黄经(截断版) / truncated descending node longitude of the Moon.
func DescendingNodeN(date time.Time, n int) float64 {
// DescendingNodeN 月球降交点黄经(n 变体) / n-parameterised descending node longitude of the Moon.
func (module *Moon) DescendingNodeN(date time.Time, n int) float64 {
jde := basic.UTC2TT(basic.Date2JD(date.UTC()))
return basic.MoonDescendingNodeN(jde, n)
return basic.MoonDescendingNodeNWithInstance(jde, n, module.inst)
}
+11 -10
View File
@@ -41,9 +41,12 @@ const (
// OccultationPartial 表示掩甚时有限目标盘面只有部分被覆盖。
// OccultationPartial means only part of a finite target disk is covered at greatest occultation.
OccultationPartial = basic.OccultationPartial
// OccultationGrazing 表示两边缘相切,且没有正持续时间的重叠。
// OccultationGrazing means the limbs are tangent without a positive-duration overlap.
// OccultationGrazing 表示掠掩:圆月面下两边缘相切;按月缘剖面求解时星迹始终在月缘起伏带内,可有多段遮挡。
// OccultationGrazing is a graze: tangent limbs with a circular Moon, or a track inside the limb relief band with a limb profile.
OccultationGrazing = basic.OccultationGrazing
// OccultationNone 表示月缘剖面下恒星未被遮挡,只出现在精密剖面结果中。
// OccultationNone means the star is never hidden by the limb profile; it only appears in precise profile results.
OccultationNone = basic.OccultationNone
)
// OccultationPlanet 标识有限盘面行星目标。
@@ -171,14 +174,12 @@ type StarOccultationInstant = basic.StarOccultationInstant
// PlanetOccultationInstant contains the visible partial and total planetary footprints at one requested instant.
type PlanetOccultationInstant = basic.PlanetOccultationInstant
// StarOccultationFootprintAt 计算指定时刻的精确恒星月掩可见足迹。
// StarOccultationFootprintAt calculates the exact visible stellar-occultation footprint at one instant.
func StarOccultationFootprintAt(at time.Time, star StarCoordinate) (StarOccultationInstant, error) {
return basic.StarOccultationFootprintAt(at, star)
// StarOccultationFootprintAt 计算指定时刻的精确恒星月掩可见足迹 / exact stellar occultation footprint at an instant.
func (module *Moon) StarOccultationFootprintAt(at time.Time, star StarCoordinate) (StarOccultationInstant, error) {
return basic.StarOccultationFootprintAtWithInstance(at, star, module.inst)
}
// PlanetOccultationFootprintsAt 计算指定时刻的精确行星偏掩和全掩可见足迹。
// PlanetOccultationFootprintsAt calculates the exact visible partial and total planetary footprints at one instant.
func PlanetOccultationFootprintsAt(at time.Time, planet OccultationPlanet) (PlanetOccultationInstant, error) {
return basic.PlanetOccultationFootprintsAt(at, planet)
// PlanetOccultationFootprintsAt 计算指定时刻的精确行星偏掩和全掩可见足迹 / exact planetary occultation footprints at an instant.
func (module *Moon) PlanetOccultationFootprintsAt(at time.Time, planet OccultationPlanet) (PlanetOccultationInstant, error) {
return basic.PlanetOccultationFootprintsAtWithInstance(at, planet, module.inst)
}
+46
View File
@@ -0,0 +1,46 @@
package moon
// 外部基准:2017-09-18 轩辕十四掠掩(IOTA 实测 5 段)。内置剖面不保证段数,只钉住主遮挡段与实测最后一段相差不超过 1 s。
import (
"math"
"testing"
"time"
"b612.me/astro/moon/limb"
)
func TestRegulusGraze20170918MatchesMainObservedInterval(t *testing.T) {
regulus := StarCoordinate{
ID: "HIP 49669", RA: 152.09358042, Dec: 11.96719519,
Epoch: time.Date(1991, time.April, 2, 13, 30, 0, 0, time.UTC), Frame: CoordinateFrameICRS,
ProperMotionRACosDecMasPerYear: -248.73, ProperMotionDecMasPerYear: 5.59,
ParallaxMas: 41.13, RadialVelocityKmPerSecond: 6,
}
const lon, lat, height = 22.966666666666667, 38.98208333333333, 85
start := time.Date(2017, time.September, 18, 3, 0, 0, 0, time.UTC)
events, err := FindStarOccultations(start, start.Add(time.Hour), regulus, lon, lat, height,
OccultationSearchOptions{LunarLimb: limb.Default()})
if err != nil || len(events) != 1 {
t.Fatalf("FindStarOccultations() = %d events, %v; want one", len(events), err)
}
profile, err := StarOccultationLimbProfile(events[0], regulus, limb.Default(), StarOccultationLimbProfileOptions{})
if err != nil {
t.Fatal(err)
}
if len(profile.Hidden) == 0 {
t.Fatal("graze reports no hidden interval")
}
main := profile.Hidden[len(profile.Hidden)-1]
for _, pair := range []struct {
name string
got, observed time.Time
}{
{"disappearance", main.Start, time.Date(2017, time.September, 18, 3, 29, 48, 0, time.UTC)},
{"reappearance", main.End, time.Date(2017, time.September, 18, 3, 30, 2, 950_000_000, time.UTC)},
} {
if d := pair.got.Sub(pair.observed).Seconds(); math.Abs(d) > 1 {
t.Errorf("%s %v is %.2f s from the observed %v", pair.name, pair.got, d, pair.observed)
}
}
}
+117
View File
@@ -0,0 +1,117 @@
package moon
import (
"math"
"testing"
"time"
"b612.me/astro/moon/limb"
)
// 月缘只应让接触时刻移动几秒:既要确实移动,又不能超过 15 s;掩甚与食型不变,模块副本不影响原模块。
func TestOccultationLunarLimbShiftsContacts(t *testing.T) {
location := time.FixedZone("CST", 8*3600)
start := time.Date(2024, time.August, 21, 9, 30, 0, 0, location)
end := time.Date(2024, time.August, 21, 12, 0, 0, 0, location)
plain := NewWithInstance(nil)
limbed := plain.WithLunarLimb(limb.Default())
a, err := plain.FindPlanetOccultations(start, end, OccultationSaturn, -30.072, 16.21, 0, OccultationSearchOptions{})
if err != nil || len(a) != 1 {
t.Fatalf("circular: %v, %d events", err, len(a))
}
b, err := limbed.FindPlanetOccultations(start, end, OccultationSaturn, -30.072, 16.21, 0, OccultationSearchOptions{})
if err != nil || len(b) != 1 {
t.Fatalf("limb: %v, %d events", err, len(b))
}
if !a[0].Greatest.Equal(b[0].Greatest) || a[0].Type != b[0].Type {
t.Fatal("greatest or type changed")
}
moved := false
for i, pair := range [][2]time.Time{
{a[0].ExternalImmersion, b[0].ExternalImmersion}, {a[0].InternalImmersion, b[0].InternalImmersion},
{a[0].InternalEmersion, b[0].InternalEmersion}, {a[0].ExternalEmersion, b[0].ExternalEmersion},
} {
shift := pair[1].Sub(pair[0]).Seconds()
t.Logf("Saturn contact %d shift %+.3f s", i, shift)
if math.Abs(shift) > 15 {
t.Errorf("contact %d shift %.3f s too large", i, shift)
}
if math.Abs(shift) > 0.05 {
moved = true
}
}
if !moved {
t.Error("lunar limb did not move any contact")
}
viaOptions, err := plain.FindPlanetOccultations(start, end, OccultationSaturn, -30.072, 16.21, 0,
OccultationSearchOptions{LunarLimb: limb.Default()})
if err != nil || len(viaOptions) != 1 || viaOptions[0] != b[0] {
t.Error("options limb differs from module limb")
}
again, _ := plain.FindPlanetOccultations(start, end, OccultationSaturn, -30.072, 16.21, 0, OccultationSearchOptions{})
if len(again) != 1 || again[0] != a[0] {
t.Error("WithLunarLimb changed the original module")
}
}
func TestStarOccultationLunarLimbShiftsContacts(t *testing.T) {
observer := Observer{Longitude: 121.4737, Latitude: 31.2304, Height: 4}
eventTime := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
ra, dec := occultationTestMoonTopocentricRaDec(occultationTestTimeToTT(eventTime), observer, -1)
star := StarCoordinate{ID: "synthetic", RA: ra + 0.1, Dec: dec + 0.1, Epoch: eventTime, Frame: CoordinateFrameApparentOfDate}
start, end := eventTime.Add(-3*time.Hour), eventTime.Add(3*time.Hour)
plain := NewWithInstance(nil)
a, err := plain.FindStarOccultations(start, end, star, observer.Longitude, observer.Latitude, observer.Height, OccultationSearchOptions{MaxEvents: 1})
if err != nil || len(a) != 1 {
t.Fatalf("circular: %v, %d events", err, len(a))
}
b, err := plain.WithLunarLimb(limb.Default()).FindStarOccultations(start, end, star, observer.Longitude, observer.Latitude, observer.Height, OccultationSearchOptions{MaxEvents: 1})
if err != nil || len(b) != 1 {
t.Fatalf("limb: %v, %d events", err, len(b))
}
if !a[0].Greatest.Equal(b[0].Greatest) {
t.Fatal("greatest changed")
}
for i, pair := range [][2]time.Time{{a[0].Immersion, b[0].Immersion}, {a[0].Emersion, b[0].Emersion}} {
shift := pair[1].Sub(pair[0]).Seconds()
t.Logf("star contact %d shift %+.3f s", i, shift)
if math.Abs(shift) > 15 {
t.Errorf("contact %d shift %.3f s too large", i, shift)
}
}
}
func TestStarOccultationPathLunarLimbShiftsGrazeLines(t *testing.T) {
star := StarCoordinate{
ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444,
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -28, ProperMotionDecMasPerYear: -18,
}
start := time.Date(2025, 6, 4, 16, 0, 0, 0, time.UTC)
end := time.Date(2025, 6, 5, 16, 0, 0, 0, time.UTC)
options := OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200, DisableFootprints: true, DisableRiseSet: true}
plain := NewWithInstance(nil)
circular, err := plain.FindStarOccultationPaths(start, end, star, options)
if err != nil || len(circular) != 1 {
t.Fatalf("circular: %v %d", err, len(circular))
}
limbed, err := plain.WithLunarLimb(limb.Default()).FindStarOccultationPaths(start, end, star, options)
if err != nil || len(limbed) != 1 {
t.Fatalf("limbed: %v %d", err, len(limbed))
}
for side, pair := range [][2][]OccultationPathPoint{{circular[0].NorthernLimit, limbed[0].NorthernLimit}, {circular[0].SouthernLimit, limbed[0].SouthernLimit}} {
worst, moved := 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++
}
worst = math.Max(worst, d)
}
t.Logf("side %d: %d/%d moved, max %.3f km", side, moved, len(pair[0]), worst)
if worst > 15 || moved == 0 {
t.Errorf("side %d: implausible graze shift (max %.3f km, moved %d)", side, worst, moved)
}
}
}
+6 -6
View File
@@ -6,10 +6,10 @@ import (
"b612.me/astro/basic"
)
// FindStarOccultationPaths 搜索单颗点源恒星月掩的全球掩带。
// 调用者显式提供坐标;函数不会加载内嵌星表。查询端点使用核心求解器的 10 ms 数值选择容差。
// FindStarOccultationPaths searches the global lunar-occultation footprint of one point-source star.
// Callers provide the coordinate explicitly; this function does not load the embedded star catalog. Query endpoints use the core solver's 10 ms numerical selection tolerance.
func FindStarOccultationPaths(start, end time.Time, star StarCoordinate, options OccultationPathOptions) ([]StarOccultationPath, error) {
return basic.FindStarOccultationPaths(start, end, star, options)
// FindStarOccultationPaths 搜索单颗点源恒星月掩的全球掩带 / global paths of a point-source stellar occultation.
func (module *Moon) FindStarOccultationPaths(start, end time.Time, star StarCoordinate, options OccultationPathOptions) ([]StarOccultationPath, error) {
if options.LunarLimb == nil {
options.LunarLimb = module.lunarLimb
}
return basic.FindStarOccultationPathsWithInstance(start, end, star, options, module.inst)
}
+6 -12
View File
@@ -6,20 +6,14 @@ import (
"b612.me/astro/basic"
)
// FindPlanetOccultations 搜索固定地点的有限盘面行星月掩。
// 经度东为正、纬度北为正,单位为度;高度为平均海平面以上米数。圆形目标使用行星赤道半径,忽略行星环。
// FindPlanetOccultations searches finite-disk lunar occultations of one planet at a fixed observing site.
// Longitude is east-positive in degrees, latitude is north-positive in degrees, and height is above mean sea level in meters. The circular target uses the planet's equatorial body radius; rings are excluded.
func FindPlanetOccultations(start, end time.Time, planet OccultationPlanet, longitude, latitude, height float64,
// FindPlanetOccultations 搜索固定地点的有限盘面行星月掩 / finite-disk planetary occultations at a fixed site.
func (module *Moon) FindPlanetOccultations(start, end time.Time, planet OccultationPlanet, longitude, latitude, height float64,
options OccultationSearchOptions) ([]PlanetOccultationInfo, error) {
return basic.FindPlanetOccultations(start, end, planet, longitude, latitude, height, options)
return basic.FindPlanetOccultationsWithInstance(start, end, planet, longitude, latitude, height, module.occultationOptions(options), module.inst)
}
// FindBestPlanetOccultations 返回窗口内每次有限盘面行星月掩的全球海平面几何掩甚点。
// 地平线可见性只报告、不参与点选择;查询端点使用核心求解器的 10 ms 数值选择容差。
// FindBestPlanetOccultations returns the global geometric greatest sea-level point for every finite-disk occultation of one planet in the window.
// Horizon visibility is reported but does not affect point selection. Query endpoints use the core solver's 10 ms numerical selection tolerance.
func FindBestPlanetOccultations(start, end time.Time, planet OccultationPlanet,
// FindBestPlanetOccultations 返回窗口内每次有限盘面行星月掩的全球海平面几何掩甚点 / global greatest points of planetary occultations in a window.
func (module *Moon) FindBestPlanetOccultations(start, end time.Time, planet OccultationPlanet,
options OccultationSearchOptions) ([]PlanetOccultationInfo, error) {
return basic.FindBestPlanetOccultations(start, end, planet, options)
return basic.FindBestPlanetOccultationsWithInstance(start, end, planet, module.occultationOptions(options), module.inst)
}
+6 -5
View File
@@ -1,6 +1,8 @@
package moon
import "b612.me/astro/basic"
import (
"b612.me/astro/basic"
)
// PlanetOccultationDiagramOptions 控制本地行星月掩轨迹采样。
// PlanetOccultationDiagramOptions controls local planetary-occultation track sampling.
@@ -14,11 +16,10 @@ type PlanetOccultationDiagramFrame = basic.PlanetOccultationDiagramFrame
// PlanetOccultationDiagramResult contains a fixed-site Moon-centered planetary track.
type PlanetOccultationDiagramResult = basic.PlanetOccultationDiagramResult
// PlanetOccultationDiagram 为已求解的固定地点行星月掩计算图形几何数据。
// PlanetOccultationDiagram computes diagram geometry for an already solved fixed-site planetary occultation.
func PlanetOccultationDiagram(
// PlanetOccultationDiagram 为已求解的固定地点行星月掩计算图形几何数据 / diagram geometry for a solved local planetary occultation.
func (module *Moon) PlanetOccultationDiagram(
info PlanetOccultationInfo,
options PlanetOccultationDiagramOptions,
) PlanetOccultationDiagramResult {
return basic.PlanetOccultationDiagram(info, options)
return basic.PlanetOccultationDiagramWithInstance(info, options, module.inst)
}
+6 -6
View File
@@ -6,11 +6,11 @@ import (
"b612.me/astro/basic"
)
// FindPlanetOccultationPaths 搜索窗口内每次有限盘面行星月掩的完整全球外接触掩带。
// 查询端点使用核心求解器的 10 ms 数值选择容差。
// FindPlanetOccultationPaths searches the complete global outer-contact footprint of every finite-disk planetary occultation selected by the window.
// Query endpoints use the core solver's 10 ms numerical selection tolerance.
func FindPlanetOccultationPaths(start, end time.Time, planet OccultationPlanet,
// FindPlanetOccultationPaths 搜索窗口内每次有限盘面行星月掩的完整全球外接触掩带 / global paths of finite-disk planetary occultations in a window.
func (module *Moon) FindPlanetOccultationPaths(start, end time.Time, planet OccultationPlanet,
options OccultationPathOptions) ([]PlanetOccultationPath, error) {
return basic.FindPlanetOccultationPaths(start, end, planet, options)
if options.LunarLimb == nil {
options.LunarLimb = module.lunarLimb
}
return basic.FindPlanetOccultationPathsWithInstance(start, end, planet, options, module.inst)
}
+6 -13
View File
@@ -6,20 +6,13 @@ import (
"b612.me/astro/basic"
)
// FindStarOccultations 搜索单颗点源恒星的月掩星。
//
// 输入坐标从历元传播并转换到当日视坐标系。此函数不会加载内嵌星表;需要星表搜索时,调用者必须显式加载并选择恒星。经度东为正、纬度北为正,单位为度;高度为平均海平面以上米数。
// FindStarOccultations searches for lunar occultations of one point-source star.
// The input coordinate is propagated from its epoch and converted to the apparent frame of date. This function never loads the embedded star catalog; callers that need catalog searches must load and select stars explicitly. Longitude is east-positive in degrees, latitude is north-positive in degrees, and height is the observer elevation above mean sea level in meters.
func FindStarOccultations(start, end time.Time, star StarCoordinate, longitude, latitude, height float64,
// FindStarOccultations 搜索单颗点源恒星的月掩星 / occultations of a point-source star at a fixed site.
func (module *Moon) FindStarOccultations(start, end time.Time, star StarCoordinate, longitude, latitude, height float64,
options OccultationSearchOptions) ([]StarOccultationInfo, error) {
return basic.FindStarOccultations(start, end, star, longitude, latitude, height, options)
return basic.FindStarOccultationsWithInstance(start, end, star, longitude, latitude, height, module.occultationOptions(options), module.inst)
}
// FindBestStarOccultations 返回窗口内每次恒星月掩的全球海平面几何掩甚点。
// 点选择不使用地平线或可见性评分;查询端点使用核心求解器的 10 ms 数值选择容差。
// FindBestStarOccultations returns the global geometric greatest sea-level point for every stellar occultation whose greatest instant is in the window.
// The point is selected without a horizon or visibility score. Query endpoints use the core solver's 10 ms numerical selection tolerance.
func FindBestStarOccultations(start, end time.Time, star StarCoordinate, options OccultationSearchOptions) ([]StarOccultationInfo, error) {
return basic.FindBestStarOccultations(start, end, star, options)
// FindBestStarOccultations 返回窗口内每次恒星月掩的全球海平面几何掩甚点 / global greatest points of stellar occultations in a window.
func (module *Moon) FindBestStarOccultations(start, end time.Time, star StarCoordinate, options OccultationSearchOptions) ([]StarOccultationInfo, error) {
return basic.FindBestStarOccultationsWithInstance(start, end, star, module.occultationOptions(options), module.inst)
}
+34 -5
View File
@@ -1,6 +1,8 @@
package moon
import "b612.me/astro/basic"
import (
"b612.me/astro/basic"
)
// StarOccultationDiagramOptions 控制本地恒星月掩轨迹采样。
// StarOccultationDiagramOptions controls local stellar-occultation track sampling.
@@ -14,12 +16,39 @@ type StarOccultationDiagramFrame = basic.StarOccultationDiagramFrame
// StarOccultationDiagramResult contains a fixed-site Moon-centered stellar track.
type StarOccultationDiagramResult = basic.StarOccultationDiagramResult
// StarOccultationDiagram 为已求解的固定地点恒星月掩计算图形几何数据。
// StarOccultationDiagram computes diagram geometry for an already solved fixed-site stellar occultation.
func StarOccultationDiagram(
// StarOccultationDiagram 为已求解的固定地点恒星月掩计算图形几何数据 / diagram geometry for a solved local stellar occultation.
func (module *Moon) StarOccultationDiagram(
info StarOccultationInfo,
star StarCoordinate,
options StarOccultationDiagramOptions,
) StarOccultationDiagramResult {
return basic.StarOccultationDiagram(info, star, options)
return basic.StarOccultationDiagramWithInstance(info, star, options, module.inst)
}
// LunarLimb 月缘剖面,nil 为圆月面,内置数据表在 moon/limb / lunar limb profile; nil means a circular limb.
type LunarLimb = basic.LunarLimb
// StarOccultationLimbProfileOptions 控制精密恒星月掩的星迹采样 / sampling of a precise stellar occultation.
type StarOccultationLimbProfileOptions = basic.StarOccultationLimbProfileOptions
// StarOccultationLimbSample 星迹上一个时刻的月缘几何 / limb geometry at one instant on the star track.
type StarOccultationLimbSample = basic.StarOccultationLimbSample
// OccultationInterval 一段连续遮挡 / one continuous hidden interval.
type OccultationInterval = basic.OccultationInterval
// StarOccultationLimbProfileResult 一次恒星月掩沿星迹的月缘剖面与全部遮挡区间 / limb profile and every hidden interval of a stellar occultation.
type StarOccultationLimbProfileResult = basic.StarOccultationLimbProfileResult
// StarOccultationLimbProfile 精密恒星月掩;limb 为 nil 时用模块月缘 / precise stellar occultation; nil limb uses the module profile.
func (module *Moon) StarOccultationLimbProfile(info StarOccultationInfo, star StarCoordinate, limb LunarLimb, options StarOccultationLimbProfileOptions) (StarOccultationLimbProfileResult, error) {
if limb == nil {
limb = module.lunarLimb
}
return basic.StarOccultationLimbProfileWithInstance(info, star, limb, options, module.inst)
}
// StarOccultationLimbProfileInUT1 返回精密恒星月掩的 UT1 时刻副本 / copy in UT1 of a precise stellar occultation.
func (module *Moon) StarOccultationLimbProfileInUT1(profile StarOccultationLimbProfileResult) StarOccultationLimbProfileResult {
return StarOccultationLimbProfileInUT1(profile)
}
+21 -2
View File
@@ -3,7 +3,6 @@ package moon
import (
"time"
"b612.me/astro"
"b612.me/astro/basic"
)
@@ -14,7 +13,7 @@ func occultationUT1Label(t time.Time) time.Time {
if t.IsZero() {
return t
}
return astro.LabelIn(astro.TimeScaleUT1, t)
return basic.LabelIn(basic.TimeScaleUT1, t)
}
func occultationUT1PathPoint(p basic.OccultationPathPoint) basic.OccultationPathPoint {
@@ -129,3 +128,23 @@ func PlanetOccultationInfoInUT1(info basic.PlanetOccultationInfo) basic.PlanetOc
info.ExternalEmersion = occultationUT1Label(info.ExternalEmersion)
return info
}
// StarOccultationLimbProfileInUT1 返回精密恒星月掩的 UT1 时刻副本 / copy in UT1 of a precise stellar occultation.
func StarOccultationLimbProfileInUT1(profile basic.StarOccultationLimbProfileResult) basic.StarOccultationLimbProfileResult {
profile.Occultation = StarOccultationInfoInUT1(profile.Occultation)
segments := make([][]basic.StarOccultationLimbSample, len(profile.Segments))
for i, segment := range profile.Segments {
segments[i] = append([]basic.StarOccultationLimbSample(nil), segment...)
for j := range segments[i] {
segments[i][j].Time = occultationUT1Label(segments[i][j].Time)
}
}
profile.Segments = segments
hidden := append([]basic.OccultationInterval(nil), profile.Hidden...)
for i := range hidden {
hidden[i].Start = occultationUT1Label(hidden[i].Start)
hidden[i].End = occultationUT1Label(hidden[i].End)
}
profile.Hidden = hidden
return profile
}
+2 -4
View File
@@ -7,8 +7,6 @@ import (
)
// ParallacticAngle 月亮视差角(天顶方向角) / lunar parallactic angle.
//
// 对月亮使用现有站心视时角和站心视赤纬链路,因此会显式依赖观测者经纬度。
func ParallacticAngle(date time.Time, lon, lat float64) float64 {
return basic.ParallacticAngleByHourAngle(HourAngle(date, lon, lat), ApparentDec(date, lon, lat), lat)
func (module *Moon) ParallacticAngle(date time.Time, lon, lat float64) float64 {
return basic.ParallacticAngleByHourAngle(module.HourAngle(date, lon, lat), module.ApparentDec(date, lon, lat), lat)
}
+10 -13
View File
@@ -7,26 +7,23 @@ import (
)
// BrightLimbPositionAngle 月亮明亮边缘位置角,单位度 / position angle of the Moon's bright limb in degrees.
func BrightLimbPositionAngle(date time.Time) float64 {
return BrightLimbPositionAngleN(date, -1)
func (module *Moon) BrightLimbPositionAngle(date time.Time) float64 {
return module.BrightLimbPositionAngleN(date, -1)
}
// BrightLimbPositionAngleN 月亮明亮边缘位置角(截断版),单位度 / truncated position angle of the Moon's bright limb in degrees.
func BrightLimbPositionAngleN(date time.Time, n int) float64 {
return basic.MoonBrightLimbPositionAngleN(observationTT(date), n)
// BrightLimbPositionAngleN 月亮明亮边缘位置角(n 变体),单位度 / n-parameterised position angle of the Moon's bright limb in degrees.
func (module *Moon) BrightLimbPositionAngleN(date time.Time, n int) float64 {
return basic.MoonBrightLimbPositionAngleNWithInstance(observationTT(date), n, module.inst)
}
// TopocentricBrightLimbPositionAngle 月亮站心明亮边缘位置角,单位度 / topocentric position angle of the Moon's bright limb in degrees.
//
// date 为观测时刻;observerLon/observerLat 为观测者经纬度,东正西负、北正南负;height 为椭球高(大地高),单位米。
// date is the observing instant; observerLon/observerLat are east-positive and north-positive; height is observer elevation in meters.
func TopocentricBrightLimbPositionAngle(date time.Time, observerLon, observerLat, height float64) float64 {
return TopocentricBrightLimbPositionAngleN(date, observerLon, observerLat, height, -1)
func (module *Moon) TopocentricBrightLimbPositionAngle(date time.Time, observerLon, observerLat, height float64) float64 {
return module.TopocentricBrightLimbPositionAngleN(date, observerLon, observerLat, height, -1)
}
// TopocentricBrightLimbPositionAngleN 月亮站心明亮边缘位置角(截断版),单位度 / truncated topocentric position angle of the Moon's bright limb in degrees.
func TopocentricBrightLimbPositionAngleN(date time.Time, observerLon, observerLat, height float64, n int) float64 {
return basic.MoonTopocentricBrightLimbPositionAngleN(observationTT(date), observerLon, observerLat, height, n)
// TopocentricBrightLimbPositionAngleN 月亮站心明亮边缘位置角(n 变体),单位度 / n-parameterised topocentric position angle of the Moon's bright limb in degrees.
func (module *Moon) TopocentricBrightLimbPositionAngleN(date time.Time, observerLon, observerLat, height float64, n int) float64 {
return basic.MoonTopocentricBrightLimbPositionAngleNWithInstance(observationTT(date), observerLon, observerLat, height, n, module.inst)
}
func observationTT(date time.Time) float64 {
+5 -5
View File
@@ -25,14 +25,14 @@ type PhysicalInfo struct {
}
// Physical 月球物理观测参数 / physical observing parameters of the Moon.
func Physical(date time.Time) PhysicalInfo {
return PhysicalN(date, -1)
func (module *Moon) Physical(date time.Time) PhysicalInfo {
return module.PhysicalN(date, -1)
}
// PhysicalN 月球物理观测参数(截断版) / truncated physical observing parameters of the Moon.
func PhysicalN(date time.Time, n int) PhysicalInfo {
// PhysicalN 月球物理观测参数(n 变体) / n-parameterised physical observing parameters of the Moon.
func (module *Moon) PhysicalN(date time.Time, n int) PhysicalInfo {
jd := basic.Date2JD(date.UTC())
info := basic.MoonPhysicalN(basic.UTC2TT(jd), n)
info := basic.MoonPhysicalNWithInstance(basic.UTC2TT(jd), n, module.inst)
return PhysicalInfo{
OpticalLongitude: info.OpticalLongitude,
OpticalLatitude: info.OpticalLatitude,
+5 -8
View File
@@ -7,16 +7,13 @@ import (
)
// TopocentricPhysical 月球站心物理观测参数 / topocentric physical observing parameters of the Moon.
//
// date 为观测时刻;observerLon/observerLat 为观测者经纬度,东正西负、北正南负;height 为椭球高(大地高),单位米。
// date is the observing instant; observerLon/observerLat are east-positive and north-positive; height is observer elevation in meters.
func TopocentricPhysical(date time.Time, observerLon, observerLat, height float64) PhysicalInfo {
return TopocentricPhysicalN(date, observerLon, observerLat, height, -1)
func (module *Moon) TopocentricPhysical(date time.Time, observerLon, observerLat, height float64) PhysicalInfo {
return module.TopocentricPhysicalN(date, observerLon, observerLat, height, -1)
}
// TopocentricPhysicalN 月球站心物理观测参数(截断版) / truncated topocentric physical observing parameters of the Moon.
func TopocentricPhysicalN(date time.Time, observerLon, observerLat, height float64, n int) PhysicalInfo {
info := basic.MoonTopocentricPhysicalN(observationTT(date), observerLon, observerLat, height, n)
// TopocentricPhysicalN 月球站心物理观测参数(n 变体) / n-parameterised topocentric physical observing parameters of the Moon.
func (module *Moon) TopocentricPhysicalN(date time.Time, observerLon, observerLat, height float64, n int) PhysicalInfo {
info := basic.MoonTopocentricPhysicalNWithInstance(observationTT(date), observerLon, observerLat, height, n, module.inst)
return PhysicalInfo{
OpticalLongitude: info.OpticalLongitude,
OpticalLatitude: info.OpticalLatitude,
+4 -4
View File
@@ -7,11 +7,11 @@ import (
)
// ApparentAltitude 月亮视高度角 / apparent lunar altitude.
func ApparentAltitude(date time.Time, lon, lat, pressureHPa, temperatureC float64) float64 {
return basic.ApparentAltitude(Altitude(date, lon, lat), pressureHPa, temperatureC)
func (module *Moon) ApparentAltitude(date time.Time, lon, lat, pressureHPa, temperatureC float64) float64 {
return basic.ApparentAltitude(module.Altitude(date, lon, lat), pressureHPa, temperatureC)
}
// ApparentZenith 月亮视天顶距 / apparent lunar zenith distance.
func ApparentZenith(date time.Time, lon, lat, pressureHPa, temperatureC float64) float64 {
return 90 - ApparentAltitude(date, lon, lat, pressureHPa, temperatureC)
func (module *Moon) ApparentZenith(date time.Time, lon, lat, pressureHPa, temperatureC float64) float64 {
return 90 - module.ApparentAltitude(date, lon, lat, pressureHPa, temperatureC)
}
+42
View File
@@ -0,0 +1,42 @@
package moon
import (
"time"
"b612.me/astro/basic"
)
// AltitudeLimb 取高度的位置(上缘、中心、下缘)/ limb used for the altitude.
type AltitudeLimb = basic.AltitudeLimb
const (
LimbUpper = basic.AltitudeLimbUpper
LimbCenter = basic.AltitudeLimbCenter
LimbLower = basic.AltitudeLimbLower
)
// AltitudeOptions 升落选项;零值为上缘、标准气象、计入折射 / altitude crossing options.
type AltitudeOptions = basic.AltitudeCrossingOptions
// AltitudeState 一个民用日内的穿越状态 / crossing state over one civil day.
type AltitudeState = basic.AltitudeCrossingState
const (
AltitudeCrosses = basic.AltitudeCrosses
AltitudeAlwaysAbove = basic.AltitudeAlwaysAbove
AltitudeAlwaysBelow = basic.AltitudeAlwaysBelow
AltitudeTangent = basic.AltitudeTangent
)
// AltitudeCrossingResult date 所在民用日内首次升、落与残差范围(度)/ first rise and set on the civil day.
type AltitudeCrossingResult = basic.AltitudeCrossingResult
// RiseSet 月球在 date 所在民用日内穿过观测者地平线(含 height 带来的地平俯角)的升落;options 控制月缘与大气 / lunar rise and set on the civil day.
func (module *Moon) RiseSet(date time.Time, lon, lat, height float64, options AltitudeOptions) (AltitudeCrossingResult, error) {
return basic.SkyAltitudeCrossingOnDate(basic.SkyMoon, date, lon, lat, height, -basic.HeightDegreeByLat(height, lat), options, module.inst)
}
// RiseSet 月球在 date 所在民用日内穿过观测者地平线(含 height 带来的地平俯角)的升落;options 控制月缘与大气 / lunar rise and set on the civil day.
func RiseSet(date time.Time, lon, lat, height float64, options AltitudeOptions) (AltitudeCrossingResult, error) {
return defaultModule.RiseSet(date, lon, lat, height, options)
}
+75
View File
@@ -0,0 +1,75 @@
// 被测契约:默认选项与 RiseTime/SetTime 一致;夏令时切换日按真实日长取当天事件;实例方法使用绑定实例。
package moon
import (
"testing"
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/ephem"
)
func TestRiseSetMatchesRiseTimeSetTime(t *testing.T) {
loc := time.FixedZone("CST", 8*3600)
for i := 0; i < 30; i++ {
date := time.Date(2025, 1, 1, 12, 0, 0, 0, loc).AddDate(0, 0, i*12)
got, err := RiseSet(date, 116.4, 39.9, 50, basic.AltitudeCrossingOptions{})
if err != nil {
t.Fatal(err)
}
checkEvent(t, date, "rise", got.Rise, got.HasRise, func() (time.Time, error) { return RiseTime(date, 116.4, 39.9, 50, true) })
checkEvent(t, date, "set", got.Set, got.HasSet, func() (time.Time, error) { return SetTime(date, 116.4, 39.9, 50, true) })
}
}
func TestRiseSetDaylightSavingDays(t *testing.T) {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
t.Skip(err)
}
for _, date := range []time.Time{
time.Date(2023, 3, 12, 12, 0, 0, 0, loc),
time.Date(2023, 11, 5, 12, 0, 0, 0, loc),
} {
got, err := RiseSet(date, -74, 40.7, 0, basic.AltitudeCrossingOptions{})
if err != nil {
t.Fatal(err)
}
checkEvent(t, date, "rise", got.Rise, got.HasRise, func() (time.Time, error) { return RiseTime(date, -74, 40.7, 0, true) })
checkEvent(t, date, "set", got.Set, got.HasSet, func() (time.Time, error) { return SetTime(date, -74, 40.7, 0, true) })
for _, ev := range []time.Time{got.Rise, got.Set} {
if !ev.IsZero() && (ev.Location() != loc || ev.YearDay() != date.YearDay()) {
t.Errorf("%s: event %v outside civil day", date.Format("2006-01-02"), ev)
}
}
}
}
func TestRiseSetUsesModuleInstance(t *testing.T) {
date := time.Date(2025, 3, 20, 12, 0, 0, 0, time.FixedZone("CST", 8*3600))
got, err := NewWithInstance(ephem.NewModelInstance(ephem.Legacy)).RiseSet(date, 116.4, 39.9, 0, basic.AltitudeCrossingOptions{})
if err != nil {
t.Fatal(err)
}
want, err := basic.SkyAltitudeCrossingOnDate(basic.SkyMoon, date, 116.4, 39.9, 0, 0, basic.AltitudeCrossingOptions{}, ephem.NewModelInstance(ephem.Legacy))
if err != nil {
t.Fatal(err)
}
if !got.Rise.Equal(want.Rise) || !got.Set.Equal(want.Set) {
t.Errorf("instance result %+v, want %+v", got, want)
}
}
func checkEvent(t *testing.T, date time.Time, kind string, got time.Time, has bool, ref func() (time.Time, error)) {
t.Helper()
want, err := ref()
if has != (err == nil) {
t.Errorf("%s %s: has=%v, reference err=%v", date.Format("2006-01-02"), kind, has, err)
return
}
if has {
if d := got.Sub(want); d > 100*time.Millisecond || d < -100*time.Millisecond {
t.Errorf("%s %s: %v, reference %v", date.Format("2006-01-02"), kind, got, want)
}
}
}
+14 -2
View File
@@ -3,6 +3,7 @@
package svg
import (
"b612.me/astro/internal/limbnote"
"b612.me/astro/internal/timenote"
"errors"
"fmt"
@@ -253,7 +254,8 @@ func renderOccultationPathSVG(
}
writePlanetOccultationEventsPanel(&b, *planetPath, layout, options)
}
writeStarOccultationFooter(&b, layout, options, flags, occultationScaleInstant(path, planetPath))
writeStarOccultationFooter(&b, layout, options, flags, occultationScaleInstant(path, planetPath),
occultationLimbApplied(path, planetPath))
b.WriteString(`</svg>`)
return b.String(), nil
}
@@ -757,16 +759,26 @@ func occultationScaleInstant(path moon.StarOccultationPath, planetPath *moon.Pla
return path.Greatest.Time
}
// occultationLimbApplied 报告图中界线是否按月缘剖面修正。
func occultationLimbApplied(path moon.StarOccultationPath, planetPath *moon.PlanetOccultationPath) bool {
if planetPath != nil {
return planetPath.LunarLimbApplied
}
return path.LunarLimbApplied
}
func writeStarOccultationFooter(
b *strings.Builder,
layout starOccultationSVGLayout,
options StarOccultationSVGOptions,
flags occultationSVGTextFlags,
instant time.Time,
limbApplied bool,
) {
maxWidth := layout.width - 80
lineHeight := svgchart.FooterLineHeight(11)
scaleNote := timenote.Scale(options.TimeScale, instant, options.Location, options.Language)
scaleNote := limbnote.Join(timenote.Scale(options.TimeScale, instant, options.Location, options.Language),
limbnote.Limits(limbApplied, options.Language), options.Language)
// 页脚默认排在画布底边之内(与既有版面一致),说明行放得下时声明单独占最后一行;
// 自定义说明太长时改用按字号留白的行数上限:截断说明也要保住时标声明和底边留白。
lines := occultationWrappedTextLines(starOccultationSVGFooter(options), flags.footerNote, maxWidth, 11)
+8 -4
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/limbnote"
"b612.me/astro/internal/timenote"
"errors"
"fmt"
@@ -11,6 +12,7 @@ import (
"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/moon"
@@ -217,7 +219,7 @@ func occultationDetailedMoonRows(at time.Time, language string) []svgchart.Panel
jde := basic.UTC2TT(basic.Date2JD(at.UTC()))
ra, dec := basic.HMoonTrueRaDec(jde)
sd := basic.MoonSemidiameter(jde)
hp := math.Asin(math.Sin(sd/3600*math.Pi/180)*6378.137/1737.4) * 180 / math.Pi * 3600
hp := math.Asin(math.Sin(sd/3600*math.Pi/180)*geodata.EarthWGS84RadiusKM/1737.4) * 180 / math.Pi * 3600
rows := []svgchart.PanelRow{
{Label: "赤经 R.A.", Value: occultationDetailedRA(ra)},
{Label: "赤纬 Dec.", Value: occultationDetailedDec(dec)},
@@ -363,7 +365,8 @@ func renderOccultationDetailedSVG(
return "", err
}
writeOccultationDetailedGrid(&b, view, blocks)
writeOccultationDetailedFooter(&b, view, options, occultationScaleInstant(path, planetPath))
writeOccultationDetailedFooter(&b, view, options, occultationScaleInstant(path, planetPath),
occultationLimbApplied(path, planetPath))
b.WriteString(`</svg>`)
return b.String(), nil
}
@@ -433,7 +436,7 @@ func writeOccultationDetailedSummary(
}
}
func writeOccultationDetailedFooter(b *strings.Builder, view occultationDetailedView, options OccultationDetailedSVGOptions, instant time.Time) {
func writeOccultationDetailedFooter(b *strings.Builder, view occultationDetailedView, options OccultationDetailedSVGOptions, instant time.Time, limbApplied bool) {
text := options.FooterNote
if text == "" {
if options.Language == starOccultationSVGLanguageEnglish {
@@ -448,7 +451,8 @@ func writeOccultationDetailedFooter(b *strings.Builder, view occultationDetailed
if options.FooterNote != "" {
lines = svgchart.WrapText(text, maxWidth, 11)
}
lines = svgchart.FooterLinesWithScale(lines, timenote.Scale(options.TimeScale, instant, options.Location, options.Language),
lines = svgchart.FooterLinesWithScale(lines, limbnote.Join(timenote.Scale(options.TimeScale, instant, options.Location, options.Language),
limbnote.Limits(limbApplied, options.Language), options.Language),
maxWidth, 11,
svgchart.BaselineLineLimit(11, svgchart.FooterLineHeight(11), view.footerY,
view.height-svgchart.FooterBottomPadding(11)))
+14 -2
View File
@@ -8,6 +8,7 @@ import (
"testing"
"time"
"b612.me/astro/internal/ephemtest"
"b612.me/astro/moon"
)
@@ -155,6 +156,7 @@ func TestOccultationDetailedSVGPanelValuesGolden(t *testing.T) {
t.Fatalf("StarOccultationDetailedSVG() error = %v", err)
}
star := occultationSVGDetailedPanelRows(t, starDiagram)
semiValues := ephemtest.NewValues(t, "occultation_detailed_panel")
for _, expected := range []struct {
title, label, value string
}{
@@ -172,7 +174,12 @@ func TestOccultationDetailedSVGPanelValuesGolden(t *testing.T) {
{"天平动", "纬天平动 b", "-2.79°"},
{"天平动", "自转轴位置角 c", "-21.92°"},
} {
if got := occultationSVGPanelValue(t, star, expected.title, expected.label); got != expected.value {
got := occultationSVGPanelValue(t, star, expected.title, expected.label)
// 月心地心坐标随模型变化,与行星坐标行一样按模型取期望值。
if expected.title == "月亮(地心坐标)" && strings.HasPrefix(expected.label, "赤") {
expected.value = semiValues.WantString("moon."+expected.label, expected.value, got)
}
if got != expected.value {
t.Fatalf("stellar panel %q row %q = %q, want %q", expected.title, expected.label, got, expected.value)
}
}
@@ -195,7 +202,12 @@ func TestOccultationDetailedSVGPanelValuesGolden(t *testing.T) {
{"接触时刻", "全掩始", "10:30:00"},
{"接触时刻", "全掩终", "12:30:00"},
} {
if got := occultationSVGPanelValue(t, planet, expected.title, expected.label); got != expected.value {
got := occultationSVGPanelValue(t, planet, expected.title, expected.label)
// 新模型视位置更准,坐标行按模型取期望值。
if strings.HasPrefix(expected.label, "赤") {
expected.value = semiValues.WantString("planet."+expected.label, expected.value, got)
}
if got != expected.value {
t.Fatalf("planetary panel %q row %q = %q, want %q", expected.title, expected.label, got, expected.value)
}
}
+76
View File
@@ -0,0 +1,76 @@
package svg
import (
"strings"
"testing"
"time"
"b612.me/astro/basic"
"b612.me/astro/moon"
)
type constantLimb float64
func (r constantLimb) LimbRadiusKM(_, _, _ float64) (float64, bool) { return float64(r), true }
type failingLimb struct{}
func (failingLimb) LimbRadiusKM(_, _, _ float64) (float64, bool) { return 0, false }
func TestLocalOccultationSVGAcceptsCustomLimbAndRejectsFailedLimbFlag(t *testing.T) {
start := time.Date(2034, time.November, 21, 0, 0, 0, 0, time.UTC)
end := time.Date(2034, time.November, 23, 0, 0, 0, 0, time.UTC)
find := func(profile basic.LunarLimb) moon.PlanetOccultationInfo {
t.Helper()
events, err := moon.FindPlanetOccultations(start, end, moon.OccultationJupiter,
-42.36358358336173, 21.762064240049245, 0, moon.OccultationSearchOptions{LunarLimb: profile})
if err != nil || len(events) != 1 {
t.Fatalf("FindPlanetOccultations() = %d events, %v; want one", len(events), err)
}
return events[0]
}
circular := find(nil)
custom := find(constantLimb(1750))
if !custom.LunarLimbApplied {
t.Fatal("constant 1750 km limb: LunarLimbApplied = false, want true")
}
if custom.ExternalImmersion.Equal(circular.ExternalImmersion) {
t.Fatal("constant 1750 km limb did not move the contact")
}
diagram, err := LocalPlanetOccultationSVG(custom, LocalPlanetOccultationSVGOptions{Location: time.UTC})
if err != nil {
t.Fatalf("LocalPlanetOccultationSVG(custom limb) error = %v", err)
}
if !strings.Contains(diagram, "月缘剖面") {
t.Fatal("custom limb chart does not declare the limb profile")
}
failed := find(failingLimb{})
if failed.LunarLimbApplied {
t.Fatal("failing limb: LunarLimbApplied = true, want false")
}
if !failed.ExternalImmersion.Equal(circular.ExternalImmersion) || !failed.InternalEmersion.Equal(circular.InternalEmersion) {
t.Fatal("failing limb changed the circular contacts")
}
diagram, err = LocalPlanetOccultationSVG(failed, LocalPlanetOccultationSVGOptions{Location: time.UTC})
if err != nil {
t.Fatalf("LocalPlanetOccultationSVG(failed limb) error = %v", err)
}
if strings.Contains(diagram, "月缘剖面") || !strings.Contains(diagram, "圆月面") {
t.Fatal("failed limb chart must declare the circular limb")
}
}
func TestOccultationPathLimbFlagRequiresCorrectedLimit(t *testing.T) {
start := time.Date(2034, time.November, 21, 0, 0, 0, 0, time.UTC)
end := time.Date(2034, time.November, 23, 0, 0, 0, 0, time.UTC)
paths, err := moon.FindPlanetOccultationPaths(start, end, moon.OccultationJupiter,
moon.OccultationPathOptions{Step: 10 * time.Minute, TargetSpacingKM: 400, LunarLimb: failingLimb{}})
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() = %d paths, %v; want one", len(paths), err)
}
if paths[0].LunarLimbApplied {
t.Fatal("failing limb: path LunarLimbApplied = true, want false")
}
}
+116
View File
@@ -0,0 +1,116 @@
package svg
// 月缘剖面精修过的接触时刻不落在圆月面上,站心图仍须能出图。
import (
"strings"
"testing"
"time"
"b612.me/astro/moon"
"b612.me/astro/moon/limb"
)
func TestLocalPlanetOccultationSVGAcceptsLunarLimbContacts(t *testing.T) {
events, err := moon.FindPlanetOccultations(
time.Date(2034, time.November, 21, 0, 0, 0, 0, time.UTC),
time.Date(2034, time.November, 23, 0, 0, 0, 0, time.UTC),
moon.OccultationJupiter, -42.36358358336173, 21.762064240049245, 0,
moon.OccultationSearchOptions{LunarLimb: limb.Default()},
)
if err != nil || len(events) != 1 {
t.Fatalf("FindPlanetOccultations() = %d events, %v; want one", len(events), err)
}
if !events[0].LunarLimbApplied {
t.Fatal("LunarLimbApplied = false, want true")
}
if _, err := LocalPlanetOccultationSVG(events[0], LocalPlanetOccultationSVGOptions{}); err != nil {
t.Fatalf("LocalPlanetOccultationSVG() error = %v", err)
}
}
func TestLocalStarOccultationSVGAcceptsLunarLimbContacts(t *testing.T) {
star := moon.StarCoordinate{
ID: "Antares", RA: 247.3516666666667, Dec: -26.431944444444444,
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), Frame: moon.CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -10, ProperMotionDecMasPerYear: -20, ParallaxMas: 24,
}
date := time.Date(2026, time.February, 11, 0, 0, 0, 0, time.UTC)
events, err := moon.FindStarOccultations(date, date.Add(24*time.Hour), star,
24.493397847811025, -74.18160307712625, 0,
moon.OccultationSearchOptions{LunarLimb: limb.Default()})
if err != nil || len(events) != 1 {
t.Fatalf("FindStarOccultations() = %d events, %v; want one", len(events), err)
}
if _, err := LocalStarOccultationSVG(events[0], star, LocalStarOccultationSVGOptions{}); err != nil {
t.Fatalf("LocalStarOccultationSVG() error = %v", err)
}
}
func TestOccultationSVGsDeclareLunarLimbConvention(t *testing.T) {
observer := [2]float64{-42.36358358336173, 21.762064240049245}
start := time.Date(2034, time.November, 21, 0, 0, 0, 0, time.UTC)
end := time.Date(2034, time.November, 23, 0, 0, 0, 0, time.UTC)
draw := func(applied bool) string {
t.Helper()
options := moon.OccultationSearchOptions{}
if applied {
options.LunarLimb = limb.Default()
}
events, err := moon.FindPlanetOccultations(start, end, moon.OccultationJupiter,
observer[0], observer[1], 0, options)
if err != nil || len(events) != 1 {
t.Fatalf("FindPlanetOccultations() = %d events, %v; want one", len(events), err)
}
if events[0].LunarLimbApplied != applied {
t.Fatalf("LunarLimbApplied = %v, want %v", events[0].LunarLimbApplied, applied)
}
diagram, err := LocalPlanetOccultationSVG(events[0], LocalPlanetOccultationSVGOptions{Location: time.UTC})
if err != nil {
t.Fatalf("LocalPlanetOccultationSVG() error = %v", err)
}
return diagram
}
if circular := draw(false); !strings.Contains(circular, "圆月面") {
t.Fatal("circular chart does not declare the circular limb")
}
if corrected := draw(true); !strings.Contains(corrected, "月缘剖面") {
t.Fatal("limb chart does not declare the limb profile")
}
}
func TestOccultationPathSVGsDeclareLunarLimbConvention(t *testing.T) {
start := time.Date(2034, time.November, 21, 0, 0, 0, 0, time.UTC)
end := time.Date(2034, time.November, 23, 0, 0, 0, 0, time.UTC)
for _, applied := range []bool{false, true} {
options := moon.OccultationPathOptions{Step: 10 * time.Minute, TargetSpacingKM: 400}
if applied {
options.LunarLimb = limb.Default()
}
paths, err := moon.FindPlanetOccultationPaths(start, end, moon.OccultationJupiter, options)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() = %d paths, %v; want one", len(paths), err)
}
if paths[0].LunarLimbApplied != applied {
t.Fatalf("LunarLimbApplied = %v, want %v", paths[0].LunarLimbApplied, applied)
}
want := "月面按圆月面计算。"
if applied {
want = "界线按月缘剖面修正"
}
global, err := PlanetOccultationPathSVG(paths[0], PlanetOccultationSVGOptions{Location: time.UTC})
if err != nil {
t.Fatalf("PlanetOccultationPathSVG() error = %v", err)
}
if !strings.Contains(global, want) {
t.Fatalf("global path chart (limb=%v) missing %q", applied, want)
}
detailed, err := PlanetOccultationDetailedSVG(paths[0], OccultationDetailedSVGOptions{Location: time.UTC})
if err != nil {
t.Fatalf("PlanetOccultationDetailedSVG() error = %v", err)
}
if !strings.Contains(detailed, want) {
t.Fatalf("detailed chart (limb=%v) missing %q", applied, want)
}
}
}
+629
View File
@@ -0,0 +1,629 @@
package svg
import (
"errors"
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro"
"b612.me/astro/internal/svgasset"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/timenote"
"b612.me/astro/moon"
)
const (
limbProfileSVGDefaultWidth = 920
limbProfileSVGDefaultHeight = 620
limbProfileSVGMinimumWidth = 640
limbProfileSVGMinimumHeight = 480
limbProfileSVGMaxRows = 12
// limbProfilePanelGap 栏间距须容下上栏的时刻、位置角两行刻度与下栏上沿的两行接触编号。
limbProfilePanelGap = 56.0
// limbProfileInsetMinHeight 缩略图(含标题)的最小高度,接触表按它限行。
limbProfileInsetMinHeight = 120.0
)
// ErrInvalidStarOccultationLimbProfileSVGOptions 表示精密月掩剖面图选项无效 / invalid precise occultation profile chart options.
var ErrInvalidStarOccultationLimbProfileSVGOptions = errors.New("invalid stellar occultation limb profile SVG options")
// ErrEmptyStarOccultationLimbProfile 表示剖面没有采样,恒星全程在月缘起伏带外 / the profile has no samples; the star stays outside the relief band.
var ErrEmptyStarOccultationLimbProfile = errors.New("stellar occultation limb profile has no samples")
// ErrInvalidStarOccultationLimbProfile 表示剖面数据不是 StarOccultationLimbProfile 能给出的形态 / malformed limb profile data.
var ErrInvalidStarOccultationLimbProfile = errors.New("invalid stellar occultation limb profile")
// StarOccultationLimbProfileSVGOptions 控制精密恒星月掩的月缘剖面图 / options for the precise stellar occultation profile chart.
type StarOccultationLimbProfileSVGOptions struct {
// Width、Height 为画布尺寸,非正值用默认 / canvas size; non-positive uses defaults.
Width int
Height int
// Title 为空时自动生成 / empty uses an automatic title.
Title string
// Language 为 "en" 时英文,否则中文 / "en" for English, otherwise Chinese.
Language string
// Location 控制显示时刻,nil 为 UTC+8 / display zone; nil uses UTC+8.
Location *time.Location
// TimeScale 为 TimeScaleUT1 时改用 UT1 时刻,此时 Location 须为 nil 或 UTC / label time scale.
TimeScale astro.TimeScale
}
// StarOccultationLimbProfileSVG 渲染精密恒星月掩剖面:横轴时刻、纵轴相对参考圆月面高度(km),给出遮挡区间与全部掩入复现 / precise stellar occultation limb profile chart.
func StarOccultationLimbProfileSVG(profile moon.StarOccultationLimbProfileResult, options StarOccultationLimbProfileSVGOptions) (string, error) {
if options.Width > 0 && options.Width < limbProfileSVGMinimumWidth {
return "", fmt.Errorf("%w: width must be zero or at least %d", ErrInvalidStarOccultationLimbProfileSVGOptions, limbProfileSVGMinimumWidth)
}
if options.Height > 0 && options.Height < limbProfileSVGMinimumHeight {
return "", fmt.Errorf("%w: height must be zero or at least %d", ErrInvalidStarOccultationLimbProfileSVGOptions, limbProfileSVGMinimumHeight)
}
if options.TimeScale == astro.TimeScaleUT1 && options.Location != nil && options.Location != time.UTC {
return "", fmt.Errorf("occultation SVG: UT1 labels do not take a non-UTC location")
}
samples := 0
for _, segment := range profile.Segments {
samples += len(segment)
}
if samples < 2 || profile.Occultation.Greatest.IsZero() {
return "", ErrEmptyStarOccultationLimbProfile
}
if err := validateStarOccultationLimbProfile(profile); err != nil {
return "", err
}
if options.Width <= 0 {
options.Width = limbProfileSVGDefaultWidth
}
if options.Height <= 0 {
options.Height = limbProfileSVGDefaultHeight
}
if options.TimeScale == astro.TimeScaleUT1 {
options.Location = time.UTC
profile = moon.StarOccultationLimbProfileInUT1(profile)
}
if options.Location == nil {
options.Location = time.FixedZone("UTC+8", starOccultationSVGDefaultZone)
}
if strings.EqualFold(options.Language, starOccultationSVGLanguageEnglish) {
options.Language = starOccultationSVGLanguageEnglish
} else {
options.Language = starOccultationSVGLanguageChinese
}
return renderStarOccultationLimbProfileSVG(profile, options), nil
}
type limbProfilePoint struct {
t time.Time
limbKM, starKM float64
positionAngle float64
// skyX、skyY 为恒星相对月心的天空平面坐标(km,向东、向北为正)。
skyX, skyY float64
}
// limbProfileHeights 把角量换算成月面上相对参考球的高度(km),恒星与月缘用同一比例。
func limbProfileHeights(segment []moon.StarOccultationLimbSample, referenceKM float64) []limbProfilePoint {
points := make([]limbProfilePoint, 0, len(segment))
for _, sample := range segment {
scale := referenceKM / sample.MeanRadiusArcsec
points = append(points, limbProfilePoint{
t: sample.Time,
limbKM: (sample.LimbRadiusArcsec - sample.MeanRadiusArcsec) * scale,
starKM: (sample.SeparationArcsec - sample.MeanRadiusArcsec) * scale,
positionAngle: sample.PositionAngleDeg,
skyX: sample.SeparationArcsec * scale * math.Sin(sample.PositionAngleDeg*math.Pi/180),
skyY: sample.SeparationArcsec * scale * math.Cos(sample.PositionAngleDeg*math.Pi/180),
})
}
return points
}
func limbProfileText(language, zh, en string) string {
if language == starOccultationSVGLanguageEnglish {
return en
}
return zh
}
func renderStarOccultationLimbProfileSVG(profile moon.StarOccultationLimbProfileResult, options StarOccultationLimbProfileSVGOptions) string {
info := profile.Occultation
width, height := float64(options.Width), float64(options.Height)
language := options.Language
segments := make([][]limbProfilePoint, 0, len(profile.Segments))
minKM, maxKM := math.Inf(1), math.Inf(-1)
for _, segment := range profile.Segments {
points := limbProfileHeights(segment, limbProfileReferenceKM(profile))
if len(points) < 2 {
continue
}
for _, point := range points {
minKM = math.Min(minKM, math.Min(point.limbKM, point.starKM))
maxKM = math.Max(maxKM, math.Max(point.limbKM, point.starKM))
}
segments = append(segments, points)
}
if !(maxKM > minKM) {
minKM, maxKM = -1, 1
}
padKM := (maxKM - minKM) * 0.08
minKM, maxKM = minKM-padKM, maxKM+padKM
title := options.Title
if title == "" {
target := info.TargetID
if target == "" {
target = limbProfileText(language, "恒星", "star")
}
date := info.Greatest.In(options.Location).Format("2006-01-02")
title = date + limbProfileText(language, " 精密月掩月缘剖面 · "+target, " Limb Profile · Occultation of "+target)
}
site := starOccultationFormatCoordinates(info.Observer.Longitude, info.Observer.Latitude)
summary := fmt.Sprintf(limbProfileText(language, "观测点 %s | 椭球高 %.0f 米 | 遮挡 %d 段 | 采样 %s", "Site %s | elevation %.0f m | %d hidden interval(s) | step %s"),
site, info.Observer.Height, len(profile.Hidden), profile.Step)
direction := limbProfileDirectionText(segments, profile.Hidden, language)
const top = 136.0
tableWidth := 260.0
left, right := 70.0, width-tableWidth-48
chartTop, chartBottom := top, height-122
panels := len(segments)
if panels < 1 {
panels = 1
}
panelHeight := (chartBottom - chartTop - limbProfilePanelGap*float64(panels-1)) / float64(panels)
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>`)
b.WriteString(svgasset.MoonFaceSymbol())
b.WriteString(`<marker id="limb-profile-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#d0473b"/></marker>`)
b.WriteString(`</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)
fmt.Fprintf(&b, `<text x="%.3f" y="46" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="%d" font-weight="700" text-anchor="middle">%s</text>`,
width/2, starOccultationTitleFontSize(title, width), html.EscapeString(svgchart.EllipsizeText(title, width-80, float64(starOccultationTitleFontSize(title, width)))))
fmt.Fprintf(&b, `<text x="%.3f" y="76" fill="#222222" font-family="Georgia, 'Times New Roman', serif" font-size="13" text-anchor="middle">%s</text>`,
width/2, html.EscapeString(svgchart.EllipsizeText(summary, width-80, 13)))
fmt.Fprintf(&b, `<text x="%.3f" y="98" fill="#3b4143" font-family="Georgia, 'Times New Roman', serif" font-size="12" text-anchor="middle">%s</text>`,
width/2, html.EscapeString(svgchart.EllipsizeText(direction, width-80, 12)))
for index, points := range segments {
y0 := chartTop + float64(index)*(panelHeight+limbProfilePanelGap)
writeLimbProfilePanel(&b, points, profile.Hidden, left, right, y0, y0+panelHeight, minKM, maxKM, options)
}
// 接触表与图例让出缩略图的最小高度:图例占 84 px,表头 28 px,每行 19 px。
maxRows := int((chartBottom + 30 - limbProfileInsetMinHeight - 18 - top - 28 - 84) / 19)
if maxRows < 2 {
maxRows = 2
}
if maxRows > limbProfileSVGMaxRows {
maxRows = limbProfileSVGMaxRows
}
legendBottom := writeLimbProfileContacts(&b, profile, right+28, top, width-48, maxRows, options)
writeLimbProfileInset(&b, segments, info, limbProfileReferenceKM(profile), right+28, legendBottom+18, width-48, chartBottom+30, language)
writeLimbProfileFooter(&b, info, limbProfileReferenceKM(profile), left, height-66, width-left-40, options)
b.WriteString(`</svg>`)
return b.String()
}
func writeLimbProfilePanel(b *strings.Builder, points []limbProfilePoint, hidden []moon.OccultationInterval,
left, right, top, bottom, minKM, maxKM float64, options StarOccultationLimbProfileSVGOptions) {
start, end := points[0].t, points[len(points)-1].t
span := end.Sub(start).Seconds()
if span <= 0 {
span = 1
}
x := func(t time.Time) float64 { return left + (right-left)*t.Sub(start).Seconds()/span }
y := func(km float64) float64 { return bottom - (bottom-top)*(km-minKM)/(maxKM-minKM) }
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="%.3f" height="%.3f" fill="#fafaf8" stroke="#9a9a96" stroke-width="0.8"/>`, left, top, right-left, bottom-top)
for _, interval := range hidden {
if interval.End.Before(start) || interval.Start.After(end) {
continue
}
x0, x1 := x(laterTime(interval.Start, start)), x(earlierTime(interval.End, end))
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="%.3f" height="%.3f" fill="#3b4a5a" fill-opacity="0.16"/>`, x0, top, math.Max(x1-x0, 0.6), bottom-top)
}
if minKM < 0 && maxKM > 0 {
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#b8b8b4" stroke-dasharray="4 3" stroke-width="0.8"/>`, left, y(0), right, y(0))
}
var limb, star strings.Builder
for index, point := range points {
command := "L"
if index == 0 {
command = "M"
}
fmt.Fprintf(&limb, "%s%.2f %.2f ", command, x(point.t), y(point.limbKM))
fmt.Fprintf(&star, "%s%.2f %.2f ", command, x(point.t), y(point.starKM))
}
fmt.Fprintf(b, `<path d="%s" fill="none" stroke="#6f5a3a" stroke-width="1.3"/>`, strings.TrimSpace(limb.String()))
fmt.Fprintf(b, `<path d="%s" fill="none" stroke="#9b3f36" stroke-width="1.1" stroke-dasharray="5 2"/>`, strings.TrimSpace(star.String()))
font := `font-family="Georgia, 'Times New Roman', serif"`
// 横轴按整刻度标时刻,下方配该时刻的位置角;刻度线贯穿剖面框作网格。
step := limbProfileTickStep(span, right-left)
majors := limbProfileTicks(start, end, step, options.Location)
for _, tick := range majors {
tx := x(tick)
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#e2e2de" stroke-width="0.6"/>`, tx, top, tx, bottom)
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#9a9a96" stroke-width="0.8"/>`, tx, bottom, tx, bottom+3)
// 标签夹进剖面框,靠近两端时不越出框线,也不压住框外的 PA 轴标题。
label := tick.In(options.Location).Format(limbProfileTickLayout(step))
labelX := limbProfileClampLabel(tx, svgchart.EstimatedTextWidth(label, 10), left, right)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#4f595b" %s font-size="10" text-anchor="middle">%s</text>`, labelX, bottom+14, font, label)
angle := limbProfileFormatAngle(limbProfilePositionAngleAt(points, tick))
angleX := limbProfileClampLabel(tx, svgchart.EstimatedTextWidth(angle, 9), left, right)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#6f5a3a" %s font-size="9" text-anchor="middle">%s</text>`, angleX, bottom+26, font, angle)
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#6f5a3a" %s font-size="9" text-anchor="end">PA</text>`, left-4, bottom+26, font)
minor := step / 6
if step%time.Minute != 0 {
minor = step / 5
}
major := make(map[int64]bool, len(majors))
for _, tick := range majors {
major[tick.UnixNano()] = true
}
for _, tick := range limbProfileTicks(start, end, minor, options.Location) {
if major[tick.UnixNano()] {
continue
}
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#b8b8b4" stroke-width="0.6"/>`, x(tick), bottom, x(tick), bottom+2)
}
for _, label := range limbProfileContactLabels(hidden, start, end, x, left, right) {
labelY := top - 4 - float64(label.row)*11
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#3b4a5a" stroke-width="0.8"/>`, label.x, labelY+2, label.contactX, top+6)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#3b4a5a" %s font-size="10" font-weight="700" text-anchor="middle">%d</text>`, label.x, labelY, font, label.number)
}
if ratio := limbProfileExaggeration(points, right-left, bottom-top, maxKM-minKM); ratio > 1.5 {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#747c7d" %s font-size="10" text-anchor="end">%s</text>`, right-6, top+14, font,
html.EscapeString(fmt.Sprintf(limbProfileText(options.Language, "纵向放大约 %.0f 倍", "vertical ×%.0f"), ratio)))
} else if ratio > 0 && ratio < 1/1.5 {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#747c7d" %s font-size="10" text-anchor="end">%s</text>`, right-6, top+14, font,
html.EscapeString(fmt.Sprintf(limbProfileText(options.Language, "横向放大约 %.0f 倍", "horizontal ×%.0f"), 1/ratio)))
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#4f595b" %s font-size="10" text-anchor="end">%+.1f</text>`, left-4, top+9, font, maxKM)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#4f595b" %s font-size="10" text-anchor="end">%+.1f</text>`, left-4, bottom, font, minKM)
}
const limbProfileContactLabelGap = 14.0
type limbProfileContactLabel struct {
number int
row int
x, contactX float64
}
// limbProfileContactLabels 接触编号两行排位后逐行自右向左回推,使其落在 [left, right] 内且同行间距不小于 14 px。
func limbProfileContactLabels(hidden []moon.OccultationInterval, start, end time.Time, x func(time.Time) float64, left, right float64) []limbProfileContactLabel {
var labels []limbProfileContactLabel
rows := [2][]int{}
rowEnd := [2]float64{math.Inf(-1), math.Inf(-1)}
number := 0
for _, interval := range hidden {
for _, contact := range []time.Time{interval.Start, interval.End} {
number++
if contact.Before(start) || contact.After(end) {
continue
}
cx := x(contact)
row := 0
if rowEnd[1] < rowEnd[0] {
row = 1
}
if cx-rowEnd[0] >= limbProfileContactLabelGap {
row = 0
}
labelX := math.Max(cx, rowEnd[row]+limbProfileContactLabelGap)
rowEnd[row] = labelX
rows[row] = append(rows[row], len(labels))
labels = append(labels, limbProfileContactLabel{number: number, row: row, x: labelX, contactX: cx})
}
}
halfWidth := func(label limbProfileContactLabel) float64 {
return svgchart.EstimatedTextWidth(fmt.Sprint(label.number), 10) / 2
}
for _, indices := range rows {
if len(indices) == 0 {
continue
}
limit := math.Inf(1)
for k := len(indices) - 1; k >= 0; k-- {
label := &labels[indices[k]]
label.x = math.Min(label.x, math.Min(limit, right-halfWidth(*label)))
limit = label.x - limbProfileContactLabelGap
}
// 一行塞不下时均分整行,间距小于 14 px 也不越框。
first, last := labels[indices[0]], labels[indices[len(indices)-1]]
if low, high := left+halfWidth(first), right-halfWidth(last); first.x < low && len(indices) > 1 {
for k, index := range indices {
labels[index].x = low + (high-low)*float64(k)/float64(len(indices)-1)
}
} else if first.x < low {
labels[indices[0]].x = low
}
}
return labels
}
func laterTime(a, b time.Time) time.Time {
if a.After(b) {
return a
}
return b
}
func earlierTime(a, b time.Time) time.Time {
if a.Before(b) {
return a
}
return b
}
// writeLimbProfileContacts 返回图例底边,供缩略图接在下方。
func writeLimbProfileContacts(b *strings.Builder, profile moon.StarOccultationLimbProfileResult, left, top, right float64, maxRows int, options StarOccultationLimbProfileSVGOptions) float64 {
language := options.Language
font := `font-family="Georgia, 'Times New Roman', serif"`
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111111" %s font-size="13" font-weight="700">%s</text>`, left, top+4, font,
html.EscapeString(limbProfileText(language, "掩入 / 复现", "Disappearances / reappearances")))
rows := make([][2]string, 0, 2*len(profile.Hidden))
for _, interval := range profile.Hidden {
rows = append(rows,
[2]string{fmt.Sprintf("%d %s", len(rows)+1, limbProfileText(language, "掩入", "Disappears")), interval.Start.In(options.Location).Format("15:04:05.00")},
[2]string{fmt.Sprintf("%d %s", len(rows)+2, limbProfileText(language, "复现", "Reappears")), interval.End.In(options.Location).Format("15:04:05.00")})
}
if len(rows) == 0 {
rows = append(rows, [2]string{limbProfileText(language, "未发生遮挡", "Not occulted"), ""})
}
overflow := 0
if len(rows) > maxRows {
overflow = len(rows) - (maxRows - 1)
rows = rows[:maxRows-1]
}
for index, row := range rows {
baseline := top + 28 + float64(index)*19
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#3b4143" %s font-size="12">%s</text>`, left, baseline, font, html.EscapeString(row[0]))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#222222" %s font-size="12" text-anchor="end">%s</text>`, right, baseline, font, row[1])
}
if overflow > 0 {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#747c7d" %s font-size="11">%s</text>`, left, top+28+float64(len(rows))*19, font,
html.EscapeString(fmt.Sprintf(limbProfileText(language, "另有 %d 个接触未列出", "%d more contacts not listed"), overflow)))
}
listed := len(rows)
if overflow > 0 {
listed++
}
legendTop := top + 40 + float64(listed)*19
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#6f5a3a" stroke-width="1.3"/>`, left, legendTop, left+22, legendTop)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#3b4143" %s font-size="11">%s</text>`, left+28, legendTop+4, font,
html.EscapeString(limbProfileText(language, "恒星方向月缘高度", "Limb height toward the star")))
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#9b3f36" stroke-width="1.1" stroke-dasharray="5 2"/>`, left, legendTop+18, left+22, legendTop+18)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#3b4143" %s font-size="11">%s</text>`, left+28, legendTop+22, font,
html.EscapeString(limbProfileText(language, "恒星距参考圆月面(km)", "Star above the mean limb (km)")))
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="22" height="10" fill="#3b4a5a" fill-opacity="0.16"/>`, left, legendTop+31)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#3b4143" %s font-size="11">%s</text>`, left+28, legendTop+40, font,
html.EscapeString(limbProfileText(language, "恒星被遮挡", "Star hidden")))
return legendTop + 44
}
func writeLimbProfileFooter(b *strings.Builder, info moon.StarOccultationInfo, referenceKM, left, baseline, maxWidth float64, options StarOccultationLimbProfileSVGOptions) {
note := limbProfileText(options.Language,
"纵轴为月面上相对参考球 %.1f km 的高度;月缘按剖面表插值,短于采样步长的遮挡可能漏检。",
"Heights are relative to the %.1f km reference sphere; the limb is interpolated from the profile table and events shorter than the step may be missed.")
note = fmt.Sprintf(note, referenceKM)
lines := svgchart.TruncateTextLines(svgchart.WrapTextBalanced(note, maxWidth, 10), maxWidth, 10, 2)
lines = append(lines, svgchart.EllipsizeText(timenote.Scale(options.TimeScale, info.Greatest, options.Location, options.Language), maxWidth, 10))
font := `font-family="Georgia, 'Times New Roman', serif"`
for index, line := range lines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#747c7d" %s font-size="10">%s</text>`, left, baseline+float64(index)*14, font, html.EscapeString(line))
}
}
// limbProfileTrackKM 返回恒星在天空平面上走过的弦长(km,按月面尺度)。
func limbProfileTrackKM(points []limbProfilePoint) float64 {
first, last := points[0], points[len(points)-1]
return math.Hypot(last.skyX-first.skyX, last.skyY-first.skyY)
}
// limbProfileExaggeration 纵向放大倍数:横向每像素代表的月面距离与纵向之比。
func limbProfileExaggeration(points []limbProfilePoint, widthPX, heightPX, spanKM float64) float64 {
if widthPX <= 0 || heightPX <= 0 || spanKM <= 0 {
return 0
}
return (limbProfileTrackKM(points) / widthPX) / (spanKM / heightPX)
}
// limbProfileCompass 把位置角换成八方位名。
func limbProfileCompass(positionAngle float64, language string) string {
zh := []string{"北", "东北", "东", "东南", "南", "西南", "西", "西北"}
en := []string{"N", "NE", "E", "SE", "S", "SW", "W", "NW"}
index := int(math.Floor(math.Mod(positionAngle+22.5+720, 360) / 45))
if language == starOccultationSVGLanguageEnglish {
return en[index]
}
return zh[index]
}
func limbProfileDirectionText(segments [][]limbProfilePoint, hidden []moon.OccultationInterval, language string) string {
if len(segments) == 0 {
return ""
}
first := segments[0][0]
lastSegment := segments[len(segments)-1]
last := lastSegment[len(lastSegment)-1]
if len(segments) == 1 {
return fmt.Sprintf(limbProfileText(language,
"恒星沿月缘自 PA %s(%s)移向 PA %s(%s),横向约 %.0f km;PA 自天北向东量",
"The star runs along the limb from PA %s (%s) to PA %s (%s), about %.0f km; PA measured from north through east"),
limbProfileFormatAngle(first.positionAngle), limbProfileCompass(first.positionAngle, language),
limbProfileFormatAngle(last.positionAngle), limbProfileCompass(last.positionAngle, language), limbProfileTrackKM(segments[0]))
}
// 深掩时取首次掩入与最后复现时刻的位置角,而非两栏端点。
immersion, emersion := first.positionAngle, last.positionAngle
if len(hidden) > 0 {
immersion = limbProfilePositionAngleAt(segments[0], hidden[0].Start)
emersion = limbProfilePositionAngleAt(lastSegment, hidden[len(hidden)-1].End)
}
return fmt.Sprintf(limbProfileText(language,
"恒星于 PA %s(%s)掩入、PA %s(%s)复现;上下两栏分别为掩入侧与复现侧,PA 自天北向东量",
"The star disappears at PA %s (%s) and reappears at PA %s (%s); panels show the two sides; PA measured from north through east"),
limbProfileFormatAngle(immersion), limbProfileCompass(immersion, language),
limbProfileFormatAngle(emersion), limbProfileCompass(emersion, language))
}
// limbProfileFormatAngle 按一位小数格式化位置角,舍入到 360.0° 时归一为 0.0°。
func limbProfileFormatAngle(positionAngle float64) string {
rounded := math.Round(positionAngle*10) / 10
if rounded >= 360 {
rounded -= 360
}
return fmt.Sprintf("%.1f°", rounded)
}
// writeLimbProfileInset 画掩甚时刻的站心月相缩略图(上北左东),加粗放大的月缘段并用箭头标出恒星运动方向。
func writeLimbProfileInset(b *strings.Builder, segments [][]limbProfilePoint, info moon.StarOccultationInfo, referenceKM, left, top, right, bottom float64, language string) {
size := math.Min(right-left, bottom-top-26)
if size < 60 || len(segments) == 0 {
return
}
font := `font-family="Georgia, 'Times New Roman', serif"`
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111111" %s font-size="12" font-weight="700">%s</text>`, left, top+10, font,
html.EscapeString(svgchart.EllipsizeText(limbProfileText(language, "月缘位置(上北左东)", "On the limb (N up, E left)"), right-left, 12)))
radius := size/2 - 16
cx, cy := left+(right-left)/2, top+26+size/2
writeLocalOccultationMoon(b, cx, cy, radius, "limb-profile-moon",
localOccultationMoonAppearanceAt(info.Greatest, info.Observer), "limb-profile-moon-phase")
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#4f595b" %s font-size="10" text-anchor="middle">%s</text>`, cx, cy-radius-4, font, limbProfileText(language, "北", "N"))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#4f595b" %s font-size="10" text-anchor="end">%s</text>`, cx-radius-4, cy+4, font, limbProfileText(language, "东", "E"))
limbPoint := func(positionAngle, scale float64) (float64, float64) {
angle := positionAngle * math.Pi / 180
return cx - scale*math.Sin(angle), cy - scale*math.Cos(angle)
}
for _, points := range segments {
first, last := points[0], points[len(points)-1]
x0, y0 := limbPoint(first.positionAngle, radius)
x1, y1 := limbPoint(last.positionAngle, radius)
sweep := 0
if signedPositionAngleStep(first.positionAngle, last.positionAngle) < 0 {
sweep = 1
}
fmt.Fprintf(b, `<path d="M%.2f %.2f A%.2f %.2f 0 0 %d %.2f %.2f" fill="none" stroke="#e0a32e" stroke-width="3.6"/>`, x0, y0, radius, radius, sweep, x1, y1)
}
// 恒星轨迹按真实比例画在月盘坐标里,深掩时两段之间连成穿过月面的弦。
kmToPX := radius / referenceKM
firstPoint := segments[0][0]
lastSegment := segments[len(segments)-1]
lastPoint := lastSegment[len(lastSegment)-1]
fmt.Fprintf(b, `<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#d0473b" stroke-width="1.6" marker-end="url(#limb-profile-arrow)"/>`,
cx-firstPoint.skyX*kmToPX, cy-firstPoint.skyY*kmToPX, cx-lastPoint.skyX*kmToPX, cy-lastPoint.skyY*kmToPX)
}
// signedPositionAngleStep 返回自 from 到 to 的位置角变化(度),取 (−180, 180];负值为顺时针(向西)。
func signedPositionAngleStep(from, to float64) float64 {
delta := math.Mod(to-from+540, 360) - 180
return delta
}
// limbProfileTickStep 选使相邻时刻标签不重叠的最小整刻度步长。
func limbProfileTickStep(spanSeconds, widthPX float64) time.Duration {
for _, step := range []time.Duration{5 * time.Second, 10 * time.Second, 15 * time.Second, 20 * time.Second, 30 * time.Second,
time.Minute, 2 * time.Minute, 5 * time.Minute, 10 * time.Minute, 15 * time.Minute, 30 * time.Minute, time.Hour} {
if widthPX*step.Seconds()/spanSeconds >= 56 {
return step
}
}
return 2 * time.Hour
}
func limbProfileTickLayout(step time.Duration) string {
if step%time.Minute == 0 {
return "15:04"
}
return "15:04:05"
}
// limbProfilePositionAngleAt 在相邻样本间线性插值位置角,跨 0° 时按最短方向。
func limbProfilePositionAngleAt(points []limbProfilePoint, at time.Time) float64 {
for index := 1; index < len(points); index++ {
if points[index].t.Before(at) {
continue
}
previous, next := points[index-1], points[index]
span := next.t.Sub(previous.t).Seconds()
if span <= 0 {
return next.positionAngle
}
fraction := at.Sub(previous.t).Seconds() / span
return math.Mod(previous.positionAngle+fraction*signedPositionAngleStep(previous.positionAngle, next.positionAngle)+360, 360)
}
return points[len(points)-1].positionAngle
}
// limbProfileTicks 返回 [start, end] 内按显示时区本地时钟对齐 step 的全部刻度;每个刻度按它自身所在时刻的时区偏移判断,跨夏令时切换也落在当地整刻度上。
func limbProfileTicks(start, end time.Time, step time.Duration, location *time.Location) []time.Time {
// ceil 返回不早于 at、在当地时钟上是 step 整数倍的第一个时刻;结果若落到另一偏移下,按结果的偏移重算一次。
ceil := func(at time.Time) time.Time {
withOffset := func(at time.Time, offset int) time.Time {
shift := time.Duration(offset) * time.Second
tick := at.Add(shift).Truncate(step).Add(-shift)
if tick.Before(at) {
tick = tick.Add(step)
}
return tick
}
_, offset := at.In(location).Zone()
tick := withOffset(at, offset)
if _, actual := tick.In(location).Zone(); actual != offset {
tick = withOffset(at, actual)
}
return tick
}
var ticks []time.Time
for tick := ceil(start); !tick.After(end) && len(ticks) < 1000; tick = ceil(tick.Add(step)) {
ticks = append(ticks, tick)
}
return ticks
}
// limbProfileClampLabel 返回中锚标签的 x,使宽 width 的标签整体落在 [left, right] 内。
func limbProfileClampLabel(x, width, left, right float64) float64 {
return math.Max(left+width/2, math.Min(right-width/2, x))
}
// limbProfileReferenceKM 剖面结果声明的参考月球半径,旧结果未填时用 1737.4 km。
func limbProfileReferenceKM(profile moon.StarOccultationLimbProfileResult) float64 {
if profile.ReferenceRadiusKM > 0 && !math.IsInf(profile.ReferenceRadiusKM, 0) {
return profile.ReferenceRadiusKM
}
return 1737.4
}
// validateStarOccultationLimbProfile 拒绝 StarOccultationLimbProfile 给不出的数据:至多两栏、每栏至少两个样本且时间递增、角量有限、遮挡区间有序。
func validateStarOccultationLimbProfile(profile moon.StarOccultationLimbProfileResult) error {
if len(profile.Segments) > 2 {
return fmt.Errorf("%w: at most two segments, got %d", ErrInvalidStarOccultationLimbProfile, len(profile.Segments))
}
finiteValue := func(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) }
if !finiteValue(profile.ReferenceRadiusKM) || profile.ReferenceRadiusKM < 0 {
return fmt.Errorf("%w: reference radius must be finite and non-negative", ErrInvalidStarOccultationLimbProfile)
}
for index, segment := range profile.Segments {
if len(segment) < 2 {
return fmt.Errorf("%w: segment %d has fewer than two samples", ErrInvalidStarOccultationLimbProfile, index)
}
for sampleIndex, sample := range segment {
if !finiteValue(sample.SeparationArcsec) || !finiteValue(sample.LimbRadiusArcsec) ||
!finiteValue(sample.MeanRadiusArcsec) || sample.MeanRadiusArcsec <= 0 || !finiteValue(sample.PositionAngleDeg) {
return fmt.Errorf("%w: segment %d sample %d has non-finite geometry", ErrInvalidStarOccultationLimbProfile, index, sampleIndex)
}
if sampleIndex > 0 && !sample.Time.After(segment[sampleIndex-1].Time) {
return fmt.Errorf("%w: segment %d samples must increase in time", ErrInvalidStarOccultationLimbProfile, index)
}
}
}
for index, interval := range profile.Hidden {
if !interval.End.After(interval.Start) || (index > 0 && interval.Start.Before(profile.Hidden[index-1].End)) {
return fmt.Errorf("%w: hidden intervals must be ordered", ErrInvalidStarOccultationLimbProfile)
}
}
return nil
}
+373
View File
@@ -0,0 +1,373 @@
package svg
// 精密月掩剖面图:每段遮挡都要出现在接触表里,无样本或非法选项时返回错误。
import (
"errors"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro"
"b612.me/astro/internal/svgchart"
"b612.me/astro/moon"
"b612.me/astro/moon/limb"
)
var limbProfileTestStar = moon.StarCoordinate{
ID: "Antares", RA: 247.3516666666667, Dec: -26.431944444444444,
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), Frame: moon.CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -10, ProperMotionDecMasPerYear: -20, ParallaxMas: 24,
}
func limbProfileTestGraze(t *testing.T) moon.StarOccultationLimbProfileResult {
t.Helper()
start := time.Date(2026, time.February, 10, 12, 0, 0, 0, time.UTC)
events, err := moon.FindStarOccultations(start, start.Add(36*time.Hour), limbProfileTestStar,
41.5907, -55.07036, 0, moon.OccultationSearchOptions{LunarLimb: limb.Default()})
if err != nil || len(events) != 1 {
t.Fatalf("FindStarOccultations() = %d events, %v; want one", len(events), err)
}
profile, err := moon.StarOccultationLimbProfile(events[0], limbProfileTestStar, limb.Default(), moon.StarOccultationLimbProfileOptions{})
if err != nil {
t.Fatal(err)
}
if len(profile.Hidden) < 2 {
t.Fatalf("hidden intervals = %d, want a multi-contact graze", len(profile.Hidden))
}
return profile
}
func TestStarOccultationLimbProfileSVGListsEveryContact(t *testing.T) {
profile := limbProfileTestGraze(t)
for _, language := range []string{"", "en"} {
diagram, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{Language: language, Location: time.UTC})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(diagram, "<svg") || !strings.HasSuffix(diagram, "</svg>") {
t.Fatal("output is not a complete SVG document")
}
for _, interval := range profile.Hidden {
for _, instant := range []time.Time{interval.Start, interval.End} {
if label := instant.UTC().Format("15:04:05.00"); !strings.Contains(diagram, label) {
t.Errorf("language %q: contact %s missing", language, label)
}
}
}
if got := strings.Count(diagram, `fill-opacity="0.16"`); got < len(profile.Hidden) {
t.Errorf("language %q: %d shaded rects, want at least %d", language, got, len(profile.Hidden))
}
}
}
func TestStarOccultationLimbProfileSVGDeclaresUT1(t *testing.T) {
profile := limbProfileTestGraze(t)
diagram, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{TimeScale: astro.TimeScaleUT1})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(diagram, "UT1") {
t.Error("UT1 chart does not declare the time scale")
}
if _, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{TimeScale: astro.TimeScaleUT1, Location: time.FixedZone("x", 3600)}); err == nil {
t.Error("UT1 with a non-UTC location accepted")
}
}
func TestStarOccultationLimbProfileSVGRejectsBadInput(t *testing.T) {
if _, err := StarOccultationLimbProfileSVG(moon.StarOccultationLimbProfileResult{}, StarOccultationLimbProfileSVGOptions{}); err == nil {
t.Error("empty profile accepted")
}
profile := limbProfileTestGraze(t)
if _, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{Width: 100}); err == nil {
t.Error("tiny width accepted")
}
}
func TestStarOccultationLimbProfileSVGTextStaysInsideFrame(t *testing.T) {
profile := limbProfileTestGraze(t)
text := regexp.MustCompile(`<text x="([0-9.]+)"[^>]*font-size="([0-9]+)"([^>]*)>([^<]*)</text>`)
for _, language := range []string{"zh", "en"} {
diagram, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{Language: language, Location: time.UTC})
if err != nil {
t.Fatal(err)
}
for _, match := range text.FindAllStringSubmatch(diagram, -1) {
x, _ := strconv.ParseFloat(match[1], 64)
size, _ := strconv.ParseFloat(match[2], 64)
width := svgchart.EstimatedTextWidth(match[4], size)
low, high := x, x+width
if strings.Contains(match[3], "middle") {
low, high = x-width/2, x+width/2
} else if strings.Contains(match[3], `"end"`) {
low, high = x-width, x
}
if low < 22 || high > limbProfileSVGDefaultWidth-22 {
t.Errorf("language %q: text [%.0f, %.0f] leaves the frame: %s", language, low, high, match[4])
}
}
}
}
func TestStarOccultationLimbProfileSVGDrawsPhasedMoonAndNumberedContacts(t *testing.T) {
profile := limbProfileTestGraze(t)
diagram, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{Location: time.UTC})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(diagram, `href="#le-moon"`) || !strings.Contains(diagram, `class="limb-profile-moon"`) {
t.Fatal("inset does not use the textured, phased Moon")
}
if strings.Contains(diagram, `data-illumination="1.000000"`) {
t.Error("inset ignores the lunar phase")
}
for number := 1; number <= 2*len(profile.Hidden); number++ {
if !strings.Contains(diagram, `font-weight="700" text-anchor="middle">`+strconv.Itoa(number)+`</text>`) {
t.Errorf("contact %d is not numbered on the profile", number)
}
}
}
func limbProfileTestAt(t *testing.T, latitude float64) moon.StarOccultationLimbProfileResult {
t.Helper()
info := moon.StarOccultationInfo{Greatest: time.Date(2026, time.February, 11, 3, 47, 15, 0, time.UTC),
Observer: moon.Observer{Longitude: 41.5907, Latitude: latitude}}
if latitude < -55.5 {
start := time.Date(2026, time.February, 10, 12, 0, 0, 0, time.UTC)
events, err := moon.FindStarOccultations(start, start.Add(36*time.Hour), limbProfileTestStar, 41.5907, latitude, 0,
moon.OccultationSearchOptions{LunarLimb: limb.Default()})
if err != nil || len(events) != 1 {
t.Fatalf("FindStarOccultations() = %d events, %v", len(events), err)
}
info = events[0]
}
profile, err := moon.StarOccultationLimbProfile(info, limbProfileTestStar, limb.Default(), moon.StarOccultationLimbProfileOptions{})
if err != nil {
t.Fatal(err)
}
return profile
}
type limbProfileTestText struct {
x, y, width float64
text string
}
func limbProfileTestTexts(diagram string) []limbProfileTestText {
pattern := regexp.MustCompile(`<text x="([0-9.]+)" y="([0-9.]+)"[^>]*font-size="([0-9]+)"([^>]*)>([^<]*)</text>`)
var texts []limbProfileTestText
for _, match := range pattern.FindAllStringSubmatch(diagram, -1) {
x, _ := strconv.ParseFloat(match[1], 64)
y, _ := strconv.ParseFloat(match[2], 64)
size, _ := strconv.ParseFloat(match[3], 64)
width := svgchart.EstimatedTextWidth(match[5], size)
if strings.Contains(match[4], "middle") {
x -= width / 2
} else if strings.Contains(match[4], `"end"`) {
x -= width
}
texts = append(texts, limbProfileTestText{x, y, width, match[5]})
}
return texts
}
func TestStarOccultationLimbProfileSVGDeepCase(t *testing.T) {
profile := limbProfileTestAt(t, -57.0)
if len(profile.Segments) != 2 || len(profile.Hidden) != 1 || profile.Occultation.Type != moon.OccultationTotal {
t.Fatalf("deep profile: %d segments, %d intervals, type %v", len(profile.Segments), len(profile.Hidden), profile.Occultation.Type)
}
diagram, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{Location: time.UTC})
if err != nil {
t.Fatal(err)
}
texts := limbProfileTestTexts(diagram)
for i, a := range texts {
for _, b := range texts[i+1:] {
if math.Abs(a.y-b.y) < 9 && a.x < b.x+b.width && b.x < a.x+a.width {
t.Errorf("texts overlap: %q at (%.0f,%.0f) and %q at (%.0f,%.0f)", a.text, a.x, a.y, b.text, b.x, b.y)
}
}
}
immersion := profile.Hidden[0].Start
want := ""
for _, sample := range profile.Segments[0] {
if !sample.Time.Before(immersion) {
want = fmt.Sprintf("PA %.1f°", sample.PositionAngleDeg)
break
}
}
if !strings.Contains(diagram, want) {
t.Errorf("direction text does not use the immersion position angle %s", want)
}
if !strings.Contains(diagram, "横向放大约") {
t.Error("compressed vertical scale is not labelled")
}
}
func TestStarOccultationLimbProfileSVGNotOcculted(t *testing.T) {
profile := limbProfileTestAt(t, -55.07036+0.8/111.2)
if len(profile.Hidden) != 0 || profile.Occultation.Type != moon.OccultationNone {
t.Fatalf("near-miss profile: %d intervals, type %v", len(profile.Hidden), profile.Occultation.Type)
}
diagram, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{Location: time.UTC})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(diagram, "未发生遮挡") {
t.Error("not-occulted chart does not say so")
}
if _, err := StarOccultationLimbProfileSVG(moon.StarOccultationLimbProfileResult{}, StarOccultationLimbProfileSVGOptions{}); !errors.Is(err, ErrEmptyStarOccultationLimbProfile) {
t.Errorf("empty profile error = %v, want ErrEmptyStarOccultationLimbProfile", err)
}
}
func TestLimbProfileHelpers(t *testing.T) {
kathmandu := time.FixedZone("UTC+5:45", 5*3600+45*60)
start := time.Date(2026, 2, 11, 3, 3, 7, 0, time.UTC)
for _, step := range []time.Duration{10 * time.Minute, time.Hour} {
ticks := limbProfileTicks(start, start.Add(3*time.Hour), step, kathmandu)
if len(ticks) == 0 || ticks[0].Before(start) {
t.Fatalf("step %v: ticks %v", step, ticks)
}
for _, tick := range ticks {
local := tick.In(kathmandu)
if local.Sub(time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, kathmandu))%step != 0 {
t.Errorf("step %v: tick %v not aligned in the display zone", step, local)
}
}
}
// 豪勋爵岛夏令时只拨 30 分钟:切换后 20 分钟刻度仍须落在当地整 20 分上。
lordHowe, err := time.LoadLocation("Australia/Lord_Howe")
if err == nil {
switchStart := time.Date(2026, 4, 4, 14, 0, 0, 0, time.UTC)
for _, tick := range limbProfileTicks(switchStart, switchStart.Add(2*time.Hour), 20*time.Minute, lordHowe) {
if local := tick.In(lordHowe); local.Minute()%20 != 0 || local.Second() != 0 {
t.Errorf("Lord Howe tick %v is not on a local 20-minute mark", local)
}
}
}
if got := limbProfileFormatAngle(359.96); got != "0.0°" {
t.Errorf("359.96 formats as %s", got)
}
}
func TestStarOccultationLimbProfileSVGRejectsMalformedProfiles(t *testing.T) {
good := limbProfileTestGraze(t)
clone := func() moon.StarOccultationLimbProfileResult {
profile := good
profile.Segments = make([][]moon.StarOccultationLimbSample, len(good.Segments))
for index, segment := range good.Segments {
profile.Segments[index] = append([]moon.StarOccultationLimbSample(nil), segment...)
}
profile.Hidden = append([]moon.OccultationInterval(nil), good.Hidden...)
return profile
}
cases := map[string]func(*moon.StarOccultationLimbProfileResult){
"NaN position angle": func(p *moon.StarOccultationLimbProfileResult) { p.Segments[0][3].PositionAngleDeg = math.NaN() },
"NaN mean radius": func(p *moon.StarOccultationLimbProfileResult) { p.Segments[0][3].MeanRadiusArcsec = math.NaN() },
"Inf separation": func(p *moon.StarOccultationLimbProfileResult) { p.Segments[0][3].SeparationArcsec = math.Inf(1) },
"five segments": func(p *moon.StarOccultationLimbProfileResult) {
for len(p.Segments) < 5 {
p.Segments = append(p.Segments, p.Segments[0])
}
},
"time going back": func(p *moon.StarOccultationLimbProfileResult) {
p.Segments[0][4].Time = p.Segments[0][2].Time
},
"reversed interval": func(p *moon.StarOccultationLimbProfileResult) {
p.Hidden[0].Start, p.Hidden[0].End = p.Hidden[0].End, p.Hidden[0].Start
},
}
for name, mutate := range cases {
profile := clone()
mutate(&profile)
diagram, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{Location: time.UTC})
if !errors.Is(err, ErrInvalidStarOccultationLimbProfile) {
t.Errorf("%s: err = %v, want ErrInvalidStarOccultationLimbProfile", name, err)
}
if diagram != "" {
t.Errorf("%s: produced output", name)
}
}
}
func TestStarOccultationLimbProfileSVGLabelsNeverOverlap(t *testing.T) {
base := limbProfileTestGraze(t)
start := base.Segments[0][0].Time
end := base.Segments[0][len(base.Segments[0])-1].Time
// 8 段各 8 s 的密集遮挡,加上不同起点相位,覆盖编号与首刻度的碰撞。
for phase := 0; phase < 40; phase++ {
profile := base
shift := time.Duration(phase) * 1700 * time.Millisecond
profile.Segments = [][]moon.StarOccultationLimbSample{append([]moon.StarOccultationLimbSample(nil), base.Segments[0]...)}
for index := range profile.Segments[0] {
profile.Segments[0][index].Time = profile.Segments[0][index].Time.Add(shift)
}
profile.Hidden = nil
for k := 0; k < 8; k++ {
at := start.Add(shift).Add(end.Sub(start)/3 + time.Duration(k)*12*time.Second)
profile.Hidden = append(profile.Hidden, moon.OccultationInterval{Start: at, End: at.Add(8 * time.Second)})
}
profile.Occultation.Greatest = profile.Occultation.Greatest.Add(shift)
diagram, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{Location: time.UTC})
if err != nil {
t.Fatal(err)
}
texts := limbProfileTestTexts(diagram)
for i, a := range texts {
for _, b := range texts[i+1:] {
if math.Abs(a.y-b.y) < 8 && a.x < b.x+b.width-0.5 && b.x < a.x+a.width-0.5 {
t.Fatalf("phase %d: %q at (%.1f,%.0f) overlaps %q at (%.1f,%.0f)", phase, a.text, a.x, a.y, b.text, b.x, b.y)
}
}
}
}
}
func TestStarOccultationLimbProfileSVGContactNumbersStayInsideFrame(t *testing.T) {
base := limbProfileTestGraze(t)
start := base.Segments[0][0].Time
end := base.Segments[0][len(base.Segments[0])-1].Time
const frameLeft, frameRight = 70.0, limbProfileSVGDefaultWidth - 260.0 - 48
for _, count := range []int{3, 6, 8, 12} {
for _, gap := range []time.Duration{2 * time.Second, 8 * time.Second} {
profile := base
profile.Hidden = nil
for k := count - 1; k >= 0; k-- {
at := end.Add(-time.Duration(k+1) * 2 * gap)
profile.Hidden = append(profile.Hidden, moon.OccultationInterval{Start: at, End: at.Add(gap)})
}
if profile.Hidden[0].Start.Before(start) {
t.Fatalf("%d intervals do not fit the profile span", count)
}
diagram, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{Location: time.UTC})
if err != nil {
t.Fatal(err)
}
var numbers []limbProfileTestText
for _, text := range limbProfileTestTexts(diagram) {
if _, err := strconv.Atoi(text.text); err == nil && text.y > 110 && text.y < 136 {
numbers = append(numbers, text)
}
}
if len(numbers) != 2*count {
t.Fatalf("%d intervals, gap %v: %d contact numbers, want %d", count, gap, len(numbers), 2*count)
}
for i, a := range numbers {
if a.x < frameLeft-0.5 || a.x+a.width > frameRight+0.5 {
t.Errorf("%d intervals, gap %v: number %s spans %.1f–%.1f, frame %.0f–%.0f", count, gap, a.text, a.x, a.x+a.width, frameLeft, frameRight)
}
for _, b := range numbers[i+1:] {
if a.y == b.y && a.x < b.x+b.width-0.5 && b.x < a.x+a.width-0.5 {
t.Errorf("%d intervals, gap %v: numbers %s and %s overlap", count, gap, a.text, b.text)
}
}
}
}
}
}
+18 -3
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/limbnote"
"b612.me/astro/internal/timenote"
"errors"
"fmt"
@@ -254,7 +255,7 @@ func renderLocalStarOccultationSVG(
writeLocalStarOccultationOverview(&b, diagram, events, layout, options)
writeLocalStarOccultationContacts(&b, events, layout, options)
writeLocalStarOccultationStages(&b, events, layout, options)
writeLocalStarOccultationFooter(&b, layout, options, info.Greatest)
writeLocalStarOccultationFooter(&b, layout, options, info.Greatest, info.LunarLimbApplied)
b.WriteString(`</svg>`)
return b.String()
}
@@ -374,6 +375,8 @@ func writeLocalStarOccultationTrack(
}
fmt.Fprintf(&path, "%s %.3f %.3f ", command, mapX(frame.StarXArcsec), mapY(frame.StarYArcsec))
}
// 白色衬底保证轨迹叠在暗月面与亮月面上都可见。
fmt.Fprintf(b, `<path class="local-star-track-halo" d="%s" fill="none" stroke="#ffffff" stroke-width="3.6" stroke-linecap="round" opacity="0.7"/>`, strings.TrimSpace(path.String()))
fmt.Fprintf(b, `<path class="local-star-track" d="%s" fill="none" stroke="#9b3f36" stroke-width="1.7" stroke-dasharray="5 4" opacity="0.82" marker-end="url(#local-occultation-arrow)"/>`, strings.TrimSpace(path.String()))
}
@@ -469,12 +472,22 @@ func writeLocalStarOccultationAxes(b *strings.Builder, cx, cy, radius float64, l
}
}
// 被掩目标轮廓用浅金色,配暗色衬底,暗月面与亮月面上都能分辨。
const localOccultationHiddenOutlineColor = "#f3d48e"
func writeLocalOccultationHiddenOutlineHalo(b *strings.Builder, x, y, radius float64) {
fmt.Fprintf(b, `<circle class="hidden-outline-halo" cx="%.3f" cy="%.3f" r="%.3f" fill="none" stroke="#1a1c1e" stroke-width="3" opacity="0.55"/>`, x, y, radius)
}
func writeLocalStarOccultationStar(b *strings.Builder, x, y float64, hidden bool, class, label string) {
fill, stroke, dash := "#fff7d6", "#9b3028", ""
if hidden {
fill, stroke, dash = "none", "#7d3430", ` stroke-dasharray="2 2"`
fill, stroke, dash = "none", localOccultationHiddenOutlineColor, ` stroke-dasharray="3 2"`
}
fmt.Fprintf(b, `<g class="%s" data-label="%s">`, html.EscapeString(class), html.EscapeString(label))
if hidden {
writeLocalOccultationHiddenOutlineHalo(b, x, y, 4.2)
}
fmt.Fprintf(b, `<circle cx="%.3f" cy="%.3f" r="4.2" fill="%s" stroke="%s" stroke-width="1.4"%s/>`, x, y, fill, stroke, dash)
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="1"/>`, x-6, y, x+6, y, stroke)
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="1"/>`, x, y-6, x, y+6, stroke)
@@ -558,11 +571,13 @@ func writeLocalStarOccultationFooter(
layout localStarOccultationSVGLayout,
options LocalStarOccultationSVGOptions,
instant time.Time,
limbApplied bool,
) {
directionLines, footerLines := localOccultationFooterLines(
localStarOccultationSVGDirectionText(options),
localStarOccultationSVGFooterNote(options),
timenote.Scale(options.TimeScale, instant, options.Location, options.Language),
limbnote.Join(timenote.Scale(options.TimeScale, instant, options.Location, options.Language),
limbnote.Contacts(limbApplied, options.Language), options.Language),
layout.footerY, layout.width, layout.height)
for index, line := range directionLines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#4f595b" font-family="Georgia, 'Times New Roman', serif" font-size="10">%s</text>`,
+14 -8
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/limbnote"
"b612.me/astro/internal/timenote"
"errors"
"fmt"
@@ -223,7 +224,7 @@ func renderLocalPlanetOccultationSVG(
options.Width, options.Height, options.Width, options.Height, html.EscapeString(title))
b.WriteString(`<defs>`)
b.WriteString(svgasset.MoonFaceSymbol())
b.WriteString(`<marker id="local-planet-occultation-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#9b3f36"/></marker>`)
b.WriteString(`<marker id="local-planet-occultation-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#9b3f36"/></marker>`)
b.WriteString(`</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"/>`, layout.width-44, layout.height-36)
@@ -319,8 +320,6 @@ func writeLocalPlanetOccultationOverview(
table.Reserve(mapX(event.frame.PlanetXArcsec)-radius, mapY(event.frame.PlanetYArcsec)-radius, 2*radius, 2*radius)
}
writeLocalStarOccultationAxes(b, cx, cy, moonRadius, options.Language, table)
writeLocalPlanetOccultationLunarPath(b, diagram.Frames, mapX, mapY, extent, options.Language)
writeLocalPlanetOccultationTrack(b, diagram.Frames, mapX, mapY)
for _, event := range events {
writeLocalPlanetOccultationDisk(b,
mapX(event.frame.PlanetXArcsec), mapY(event.frame.PlanetYArcsec),
@@ -330,6 +329,8 @@ func writeLocalPlanetOccultationOverview(
writeLocalOccultationMoon(b, cx, cy, moonRadius, "overview-moon",
localOccultationMoonAppearanceAt(diagram.Occultation.Greatest, diagram.Occultation.Observer),
"local-planet-occultation-moon-overview")
writeLocalPlanetOccultationLunarPath(b, diagram.Frames, mapX, mapY, extent, options.Language)
writeLocalPlanetOccultationTrack(b, diagram.Frames, mapX, mapY)
overviewLabels := make([]localOccultationOverviewLabel, 0, len(events))
for _, event := range events {
x := mapX(event.frame.PlanetXArcsec)
@@ -378,7 +379,9 @@ func writeLocalPlanetOccultationTrack(
}
fmt.Fprintf(&path, "%s %.3f %.3f ", command, mapX(frame.PlanetXArcsec), mapY(frame.PlanetYArcsec))
}
fmt.Fprintf(b, `<path class="local-planet-track" d="%s" fill="none" stroke="#9b3f36" stroke-width="1.7" stroke-dasharray="5 4" opacity="0.82" marker-end="url(#local-planet-occultation-arrow)"/>`, strings.TrimSpace(path.String()))
// 白色衬底保证轨迹叠在暗月面与亮月面上都可见。
fmt.Fprintf(b, `<path class="local-planet-track-halo" d="%s" fill="none" stroke="#ffffff" stroke-width="2.2" stroke-linecap="round" opacity="0.7"/>`, strings.TrimSpace(path.String()))
fmt.Fprintf(b, `<path class="local-planet-track" d="%s" fill="none" stroke="#9b3f36" stroke-width="1" stroke-dasharray="4 3" opacity="0.85" marker-end="url(#local-planet-occultation-arrow)"/>`, strings.TrimSpace(path.String()))
}
func writeLocalPlanetOccultationLunarPath(
@@ -447,7 +450,8 @@ func writeLocalPlanetOccultationDisk(
) {
fill, stroke, dash := "#d6ad69", "#704b2d", ""
if hidden {
fill, stroke, dash = "none", "#7d3430", ` stroke-dasharray="2 2"`
writeLocalOccultationHiddenOutlineHalo(b, x, y, math.Max(radius, 0.15))
fill, stroke, dash = "none", localOccultationHiddenOutlineColor, ` stroke-dasharray="3 2"`
}
fmt.Fprintf(b, `<circle class="%s" data-label="%s" cx="%.3f" cy="%.3f" r="%.3f" fill="%s" stroke="%s" stroke-width="1.2"%s/>`,
html.EscapeString(class), html.EscapeString(label), x, y, math.Max(radius, 0.15), fill, stroke, dash)
@@ -516,8 +520,9 @@ func writeLocalPlanetOccultationStages(
writeLocalOccultationMoon(b, cx, cy, event.frame.MoonRadiusArcsec*scale, "stage-moon",
event.appearance, fmt.Sprintf("local-planet-occultation-moon-stage-%d", index))
if event.hidden {
fmt.Fprintf(b, `<circle class="stage-planet-hidden-outline" cx="%.3f" cy="%.3f" r="%.3f" fill="none" stroke="#7d3430" stroke-width="1.1" stroke-dasharray="2 2"/>`,
x, y, displayRadius)
writeLocalOccultationHiddenOutlineHalo(b, x, y, displayRadius)
fmt.Fprintf(b, `<circle class="stage-planet-hidden-outline" cx="%.3f" cy="%.3f" r="%.3f" fill="none" stroke="%s" stroke-width="1.4" stroke-dasharray="3 2"/>`,
x, y, displayRadius, localOccultationHiddenOutlineColor)
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="10.5" font-weight="700" text-anchor="middle">%s</text>`,
cx, cy+radius+18, html.EscapeString(event.name))
@@ -560,7 +565,8 @@ func writeLocalPlanetOccultationFooter(
directionLines, footerLines := localOccultationFooterLines(
localPlanetOccultationSVGDirectionText(options),
localPlanetOccultationSVGFooterNote(info, options),
timenote.Scale(options.TimeScale, info.Greatest, options.Location, options.Language),
limbnote.Join(timenote.Scale(options.TimeScale, info.Greatest, options.Location, options.Language),
limbnote.Contacts(info.LunarLimbApplied, options.Language), options.Language),
layout.footerY, layout.width, layout.height)
for index, line := range directionLines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#4f595b" font-family="Georgia, 'Times New Roman', serif" font-size="10">%s</text>`,
+1 -1
View File
@@ -63,7 +63,7 @@ func TestOccultationSVGTimeScaleUT1(t *testing.T) {
}
}
// assertOccultationUT1Declaration 钉住 UT1 声明是可解析的 <text> 元素并写出 DUT1:曾经的裸文本写入不会渲染。
// assertOccultationUT1Declaration UT1 声明须是可解析的 <text> 元素并写出 DUT1。
func assertOccultationUT1Declaration(t *testing.T, name, diagram string) {
t.Helper()
for _, text := range occultationSVGTexts(t, diagram) {
@@ -0,0 +1,6 @@
{
"moon.赤纬 Dec.": "-00°01'34.4\"",
"moon.赤经 R.A.": "23h37m22.4s",
"planet.赤纬 Dec.": "+03°27'41.3\"",
"planet.赤经 R.A.": "00h58m06.2s"
}