feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"b612.me/astro/internal/geodata"
|
||||
)
|
||||
|
||||
// TestSolarEclipseGrazingClosureRootsAreRecovered pins the events whose
|
||||
// greatest-at-horizon closure arcs the analytic seedings miss: a grazing
|
||||
// closure root can sit outside the sampled horizon branches, and the local
|
||||
// classification then refuses the solved root. Recovering both from the sampled
|
||||
// sweep restores the analytic critical envelope, which covers the visible
|
||||
// annulus better than the sampled union that used to replace it.
|
||||
func TestSolarEclipseGrazingClosureRootsAreRecovered(t *testing.T) {
|
||||
for _, date := range [][3]int{{1136, 6, 1}, {-1480, 12, 27}, {5705, 6, 17}} {
|
||||
seed := JDECalc(date[0], date[1], float64(date[2]))
|
||||
result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{
|
||||
StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0,
|
||||
})
|
||||
if result.Eclipse.Centrality != SolarEclipseCentralTwoLimits {
|
||||
t.Fatalf("%04d-%02d-%02d centrality=%s, want two limits",
|
||||
date[0], date[1], date[2], result.Eclipse.Centrality)
|
||||
}
|
||||
if len(result.CentralBandHorizonClosures) != 2 {
|
||||
t.Fatalf("%04d-%02d-%02d horizon closures=%d, want two",
|
||||
date[0], date[1], date[2], len(result.CentralBandHorizonClosures))
|
||||
}
|
||||
if result.CentralBandSampled {
|
||||
t.Fatalf("%04d-%02d-%02d fell back to the sampled footprint union",
|
||||
date[0], date[1], date[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolarEclipseGrazingEventsWithoutClosuresStaySampled pins the other half
|
||||
// of the criterion: a two-limit grazing event whose caps are not bounded by the
|
||||
// greatest-at-horizon condition has no closure arc at all, and its band must
|
||||
// stay a valid closed reconstruction instead of silently disappearing. The
|
||||
// reasons are measured, not assumed: 4862-09-28 ends 40 km inside the horizon
|
||||
// (+0.36 degrees at the cap, so the umbral rim bounds it) and 1552-07-21 has its
|
||||
// boundary running along the horizon (+0.004 then -0.000 degrees), which makes
|
||||
// the arc degenerate.
|
||||
func TestSolarEclipseGrazingEventsWithoutClosuresStaySampled(t *testing.T) {
|
||||
for _, date := range [][3]int{{4862, 9, 28}, {1552, 7, 21}} {
|
||||
seed := JDECalc(date[0], date[1], float64(date[2]))
|
||||
result := SolarEclipsePartialFootprints(seed, SolarEclipsePartialFootprintOptions{
|
||||
StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0,
|
||||
})
|
||||
if result.Eclipse.Centrality != SolarEclipseCentralTwoLimits {
|
||||
t.Fatalf("%04d-%02d-%02d centrality=%s, want two limits",
|
||||
date[0], date[1], date[2], result.Eclipse.Centrality)
|
||||
}
|
||||
if len(result.CentralBandHorizonClosures) != 0 {
|
||||
t.Fatalf("%04d-%02d-%02d closures=%d, expected none",
|
||||
date[0], date[1], date[2], len(result.CentralBandHorizonClosures))
|
||||
}
|
||||
if len(result.CentralBandSegments) == 0 {
|
||||
t.Fatalf("%04d-%02d-%02d exported no central band", date[0], date[1], date[2])
|
||||
}
|
||||
ring := result.CentralBandSegments[0]
|
||||
if len(ring) < 4 || solarEclipsePathDistanceKM(ring[0], ring[len(ring)-1]) > 0.01 {
|
||||
t.Fatalf("%04d-%02d-%02d central band is not closed", date[0], date[1], date[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolarEclipse11360601GrazingAnnularBandContainsSweep pins the grazing polar
|
||||
// annular event whose shadow axis runs almost parallel to the surface. Its
|
||||
// instantaneous antumbral footprint is a long spindle: the rim is cut by the
|
||||
// horizon over the contact intervals and fully closed over the middle of the
|
||||
// path. The analytic envelope has no horizon roots there, and the open-arc
|
||||
// sweep drops the closed middle samples, so the band used to be exported as a
|
||||
// chordal ribbon hundreds of kilometres smaller than the umbra it describes.
|
||||
func TestSolarEclipse11360601GrazingAnnularBandContainsSweep(t *testing.T) {
|
||||
result := SolarEclipsePartialFootprints(
|
||||
JDECalc(1136, 6, 1),
|
||||
SolarEclipsePartialFootprintOptions{
|
||||
StepDays: 2.0 / 1440.0, BoundaryPoints: 96, RiseSetStepDays: 2.0 / 1440.0,
|
||||
},
|
||||
)
|
||||
if result.Eclipse.Type != SolarEclipseAnnular ||
|
||||
result.Eclipse.Centrality != SolarEclipseCentralTwoLimits {
|
||||
t.Fatalf("type=%s centrality=%s, want a two-limit annular eclipse",
|
||||
result.Eclipse.Type, result.Eclipse.Centrality)
|
||||
}
|
||||
// The band must be the analytic critical envelope, which needs both
|
||||
// greatest-at-horizon closure arcs. Recovering the second root from the
|
||||
// sampled sweep is what restored them; without it the event silently falls
|
||||
// back to a sampled union that leaves 0.3% of the visible annulus uncovered.
|
||||
if len(result.CentralBandHorizonClosures) != 2 {
|
||||
t.Fatalf("horizon closures=%d, want two", len(result.CentralBandHorizonClosures))
|
||||
}
|
||||
if result.CentralBandSampled {
|
||||
t.Fatal("central band fell back to the sampled footprint union")
|
||||
}
|
||||
if len(result.CentralBandSegments) != 1 {
|
||||
t.Fatalf("central-band segments=%d, want one closed band", len(result.CentralBandSegments))
|
||||
}
|
||||
ring := result.CentralBandSegments[0]
|
||||
if len(ring) < 4 || solarEclipsePathDistanceKM(ring[0], ring[len(ring)-1]) > 0.01 {
|
||||
t.Fatal("central-band envelope is not closed")
|
||||
}
|
||||
if !solarEclipseBandContainsFootprintsWithinKM(
|
||||
result.CentralBandSegments, result.CentralBandFootprints,
|
||||
solarEclipseCentralBandUnionContainmentToleranceKM,
|
||||
) {
|
||||
t.Fatal("central band does not contain the sampled umbral footprints")
|
||||
}
|
||||
// The spindle reaches the poleward tip far above the center-line interval
|
||||
// the old ribbon covered; a point on that tip must stay inside the band.
|
||||
for _, probe := range []SolarEclipsePathPoint{
|
||||
{Longitude: 120, Latitude: 73},
|
||||
{Longitude: 125, Latitude: 71},
|
||||
} {
|
||||
matrix := [][]geodata.GeoPoint{geodataRingFromPath(ring)}
|
||||
point := []geodata.GeoPoint{{Longitude: probe.Longitude, Latitude: probe.Latitude}}
|
||||
if !geodata.SphericalPolygonsContainPoints(matrix, point)[0] {
|
||||
t.Fatalf("central band misses (%g,%g)", probe.Longitude, probe.Latitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func geodataRingFromPath(points []SolarEclipsePathPoint) []geodata.GeoPoint {
|
||||
ring := make([]geodata.GeoPoint, len(points))
|
||||
for index, point := range points {
|
||||
ring[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||||
}
|
||||
return ring
|
||||
}
|
||||
Reference in New Issue
Block a user