feat: 新增月掩与日月食地理绘图并提升观测计算精度

- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算
- 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出
- 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口
- 扩展日食中心线、南北界及偏食足迹采样,支持极区投影
- 修正站心时角、月出月落、月球视半径、折射和恒星自行计算
- 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
This commit is contained in:
2026-08-06 12:00:56 +08:00
parent 25dc7ac0bc
commit 9ee2163cc7
137 changed files with 21770 additions and 1746 deletions
+150
View File
@@ -0,0 +1,150 @@
package basic
import (
"errors"
"math"
"testing"
"time"
. "b612.me/astro/tools"
)
func TestSunRiseSetDynamicResidual(t *testing.T) {
const (
longitude = 116.4074
latitude = 39.9042
timeZone = 8.0
height = 0.0
)
jd := JDECalc(2025, 6, 5)
for _, event := range []struct {
name string
get func(float64, float64, float64, float64, float64, float64) (float64, error)
}{
{name: "rise", get: GetSunRiseTime},
{name: "set", get: GetSunSetTime},
} {
eventJD, err := event.get(jd, longitude, latitude, timeZone, 1, height)
if err != nil {
t.Fatalf("%s: %v", event.name, err)
}
naturalTimeZone := math.Round(longitude / 15)
localJD := eventJD + naturalTimeZone/24 - timeZone/24
residual := sunRiseSetResidual(localJD, longitude, latitude, naturalTimeZone, 1, height, -1)
if math.Abs(residual) > 0.001 {
t.Fatalf("%s dynamic horizon residual = %.9f degrees", event.name, residual)
}
fixedResidual := SunHeight(localJD, longitude, latitude, naturalTimeZone) - StandardAltitudeSun(1, height, latitude)
if math.Abs(fixedResidual) < 0.01 {
t.Fatalf("%s still matches the legacy fixed-altitude event: residual %.9f degrees", event.name, fixedResidual)
}
}
}
func TestSunApparentStateReusesDistanceWithoutChangingCoordinates(t *testing.T) {
const jd = 2460827.5
ra, dec, distanceAU := hSunApparentRaDecDistanceN(jd, -1)
wantRA, wantDec := LoBoToRaDec(jd, HSunApparentLoN(jd, -1), HSunTrueBoN(jd, -1))
assertClose(t, "sun state RA", ra, wantRA, 1e-12)
assertClose(t, "sun state Dec", dec, wantDec, 1e-12)
assertClose(t, "sun state distance", distanceAU, EarthAwayN(jd, -1), 1e-15)
}
func TestSunRiseSetDynamicGrazingKeepsDateAndDirection(t *testing.T) {
date := time.Date(2025, 6, 10, 0, 0, 0, 0, time.UTC)
jd := Date2JDE(date)
dayStart := math.Floor(jd) + 0.5
rise, err := GetSunRiseTime(jd, 0, 66, 0, 1, 0)
if err != nil {
t.Fatalf("sunrise: %v", err)
}
set, err := GetSunSetTime(jd, 0, 66, 0, 1, 0)
if err != nil {
t.Fatalf("sunset: %v", err)
}
assertRiseSetEvent(t, "sunrise", rise, dayStart, true, func(eventJD float64) float64 {
return sunRiseSetResidual(eventJD, 0, 66, 0, 1, 0, -1)
})
assertRiseSetEvent(t, "sunset", set, dayStart, false, func(eventJD float64) float64 {
return sunRiseSetResidual(eventJD, 0, 66, 0, 1, 0, -1)
})
}
func TestMoonSetDynamicGrazingKeepsDirection(t *testing.T) {
date := time.Date(2025, 1, 31, 0, 0, 0, 0, time.UTC)
jd := Date2JDE(date)
dayStart := math.Floor(jd) + 0.5
set, err := GetMoonSetTime(jd, 0, 80, 0, 1, 0)
if err != nil {
t.Fatalf("moonset: %v", err)
}
assertRiseSetEvent(t, "moonset", set, dayStart, false, func(eventJD float64) float64 {
return moonRiseSetResidual(eventJD, 0, 80, 0, 1, 0, -1)
})
}
func TestMoonRiseSetDirectionalFallbackPreservesMissingEventError(t *testing.T) {
date := time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC)
jd := Date2JDE(date)
_, err := GetMoonRiseTime(jd, -42.6043, 71.7069, -3, 1, 0)
if !errors.Is(err, ErrNeverRise) {
t.Fatalf("moonrise error = %v, want %v", err, ErrNeverRise)
}
}
func TestMoonRiseSetDynamicUsesObserverHeightForParallax(t *testing.T) {
date := time.Date(2025, 6, 5, 0, 0, 0, 0, time.UTC)
jd := Date2JDE(date)
const (
longitude = 116.4074
latitude = 39.9042
height = 10000.0
)
rise, err := GetMoonRiseTime(jd, longitude, latitude, 0, 1, height)
if err != nil {
t.Fatalf("moonrise: %v", err)
}
residual := moonRiseSetResidualAtObserverHeight(rise, longitude, latitude, 0, 1, height)
if math.Abs(residual) > 1e-5 {
t.Fatalf("moonrise observer-height residual = %.12f degrees", residual)
}
}
func assertRiseSetEvent(t *testing.T, name string, eventJD, dayStart float64, isRise bool, residual func(float64) float64) {
t.Helper()
if eventJD < dayStart || eventJD >= dayStart+1 {
t.Fatalf("%s %.12f is outside civil day [%.12f, %.12f)", name, eventJD, dayStart, dayStart+1)
}
const step = 1.0 / 1440
slope := (residual(eventJD+step) - residual(eventJD-step)) / (2 * step)
if isRise && slope <= 0 {
t.Fatalf("%s slope = %.9f degrees/day, want positive", name, slope)
}
if !isRise && slope >= 0 {
t.Fatalf("%s slope = %.9f degrees/day, want negative", name, slope)
}
if value := residual(eventJD); math.Abs(value) > 1e-4 {
t.Fatalf("%s residual = %.12f degrees", name, value)
}
}
func moonRiseSetResidualAtObserverHeight(jd, longitude, latitude, timeZone, zenithShift, height float64) float64 {
calculationJD := TD2UT(jd-timeZone/24, true)
ra, dec := HMoonTrueRaDecN(calculationJD, -1)
distanceKM := HMoonAwayN(calculationJD, -1)
topocentricRA, topocentricDec := TopocentricRaDec(ra, dec, latitude, longitude,
jd-timeZone/24, distanceKM/angularDiameterAstronomicalUnitKM, height)
siderealTime := Limit360(ApparentSiderealTime(jd-timeZone/24)*15 + longitude)
hourAngle := Limit360(siderealTime - topocentricRA)
altitude := ArcSin(Sin(latitude)*Sin(topocentricDec) + Cos(topocentricDec)*Cos(latitude)*Cos(hourAngle))
residual := altitude + HeightDegreeByLat(height, latitude)
if zenithShift != 0 {
residual += RefractionFromTrueAltitude(altitude, refractionStandardPressureHPa, refractionStandardTemperatureC)
residual += angularSemidiameterArcsec(moonEquatorialRadiusKM, distanceKM) / 3600
}
return residual
}