Files
astro/internal/lunarhorizon/lunarhorizon.go
T
b612 2bf8478639 feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
2026-09-17 12:27:40 +08:00

118 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package lunarhorizon 在等距圆柱地图坐标里细化月食可见区使用的站心地平线。
//
// 极点附近一小段球面地平弧可以横跨近 180° 经度,把它的端点直接连成多边形会在极区
// 切出地平线以外的假可见帽。这里按地图坐标误差自适应细分,并把新增顶点用同一个站心
// 地平方程校正回零高度。
//
// Package lunarhorizon refines the topocentric horizon used by lunar-eclipse visibility
// regions in equirectangular map coordinates.
//
// Near a pole a short spherical horizon arc can span almost 180 degrees of longitude, so
// joining its endpoints directly cuts a false visible cap outside the horizon. This package
// subdivides by error measured in map coordinates and corrects every inserted vertex onto the
// same topocentric zero-altitude curve.
package lunarhorizon
import (
"math"
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/geodata"
)
// normalizeLongitude 把经度归一化到 [-180, 180);internal/geodata 与 geojson 各有一份等价的未导出实现,无法跨包复用。
func normalizeLongitude(value float64) float64 {
value = math.Mod(value+180, 360)
if value < 0 {
value += 360
}
return value - 180
}
// defaultToleranceDegrees 是 GeoJSON 导出使用的误差门限。GeoJSON 客户端可以无限放大,
// 因此这里取远小于任何地图像素的角度。
// defaultToleranceDegrees is the error tolerance used by the GeoJSON export. GeoJSON clients
// can zoom without limit, so it stays far below any map pixel.
const defaultToleranceDegrees = 0.002
// DefaultToleranceDegrees 暴露默认门限,供固定分辨率的调用方设置下限。
// DefaultToleranceDegrees exposes the default tolerance so fixed-resolution callers can floor it.
const DefaultToleranceDegrees = defaultToleranceDegrees
// Refine 用 GeoJSON 导出的默认门限细分一条站心地平线。
// Refine subdivides one topocentric horizon with the GeoJSON export's default tolerance.
//
// Near a pole, a short spherical horizon arc can span almost 180 degrees of
// longitude. Refine in map coordinates before clipping; extra vertices are
// corrected to the same topocentric zero-altitude curve as the source ring.
func Refine(points []geodata.GeoPoint, at time.Time) []geodata.GeoPoint {
return RefineWithin(points, at, defaultToleranceDegrees)
}
// RefineWithin 用给定误差门限(单位:度)细分一条站心地平线。插入的顶点被校正回零高度,
// 因此细分只增加描述精度,不改变曲线本身;固定分辨率的目标(例如 SVG 地图)可以用
// 与像素尺度相称的门限,避免为了显示不出来的精度生成成千上万个顶点。
// RefineWithin subdivides one topocentric horizon with the given error tolerance in degrees.
// Inserted vertices are corrected back onto zero altitude, so refinement adds description
// accuracy without moving the curve. A fixed-resolution target such as an SVG map can pass a
// tolerance matched to its pixel scale instead of emitting vertices no display can resolve.
func RefineWithin(points []geodata.GeoPoint, at time.Time, toleranceDegrees float64) []geodata.GeoPoint {
if !(toleranceDegrees > 0) {
toleranceDegrees = defaultToleranceDegrees
}
if len(points) < 3 {
return points
}
jd := basic.Date2JDE(at.UTC())
tt := basic.TD2UT(jd, true)
ra, dec := basic.HMoonTrueRaDec(tt)
distanceAU := basic.HMoonAway(tt) / 149597870.7
sidereal := basic.ApparentSiderealTime(jd) * 15
center := geodata.GeoPoint{Longitude: normalizeLongitude(ra - sidereal), Latitude: dec}
// Every correction is at the same instant. Reuse its full ephemeris while
// retaining the ellipsoid and topocentric transform used by HMoonHeight.
altitudeAt := func(point geodata.GeoPoint) float64 {
ra, dec := basic.TopocentricRaDec(ra, dec, point.Latitude, point.Longitude, jd, distanceAU, 0)
hourAngle := (sidereal + point.Longitude - ra) * math.Pi / 180
latitude := point.Latitude * math.Pi / 180
declination := dec * math.Pi / 180
return math.Asin(math.Sin(latitude)*math.Sin(declination)+
math.Cos(declination)*math.Cos(latitude)*math.Cos(hourAngle)) * 180 / math.Pi
}
result := make([]geodata.GeoPoint, 0, len(points))
var refine func(geodata.GeoPoint, geodata.GeoPoint, int)
refine = func(first, second geodata.GeoPoint, depth int) {
middle := geodata.InterpolateGreatCircle(first, second, 0.5)
linearLongitude := first.Longitude + math.Remainder(second.Longitude-first.Longitude, 360)/2
errorDeg := math.Hypot(math.Remainder(middle.Longitude-linearLongitude, 360), middle.Latitude-(first.Latitude+second.Latitude)/2)
if errorDeg <= toleranceDegrees || depth >= 20 {
result = append(result, first)
return
}
for iteration := 0; iteration < 5; iteration++ {
altitude := altitudeAt(middle)
if math.Abs(altitude) < 1e-10 {
break
}
angleDeg := basic.StarAngularSeparation(middle.Longitude, middle.Latitude, center.Longitude, center.Latitude)
if !(angleDeg > 0) {
// 只有传入的环并非地平线、中点与该瞬时月下点重合时才会走到这里;此时无法
// 沿"朝向月下点"的方向修正,保留未修正的中点比产生 NaN 顶点安全。
// Reached only when the input ring is not a horizon ring and the midpoint
// coincides with that instant's sub-lunar point. There is no direction toward
// the sub-lunar point to correct along, so keep the uncorrected midpoint rather
// than emit a NaN vertex.
break
}
middle = geodata.InterpolateGreatCircle(middle, center, -altitude/angleDeg)
}
refine(first, middle, depth+1)
refine(middle, second, depth+1)
}
for index, point := range points {
refine(point, points[(index+1)%len(points)], 0)
}
return result
}