feat: 完善日月食与月掩几何链路并扩展历法接口

- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
2026-09-17 12:27:40 +08:00
parent 9ee2163cc7
commit 2bf8478639
428 changed files with 85981 additions and 7998 deletions
+323 -1
View File
@@ -36,6 +36,7 @@ func TestFindPlanetOccultationSVGsSaturnFiniteDisk(t *testing.T) {
`class="occultation-time-marker"`,
`class="event-marker event-total-start"`, `class="event-marker event-greatest"`,
`class="event-marker event-total-end"`, `class="event-marker-leader"`,
`class="occultation-rise-set-boundary occultation-start-rise"`, `stroke="#d97706"`, "初掩/掩甚/终掩月升月落线",
} {
if !strings.Contains(diagram, want) {
t.Fatalf("planetary SVG missing %q", want)
@@ -68,6 +69,316 @@ func TestFindPlanetOccultationSVGsSaturnFiniteDisk(t *testing.T) {
if got := strings.Count(diagram, `fill="#087f8c" stroke="#ffffff" stroke-width="1.0"`); got < 2 {
t.Fatalf("planetary SVG total-contact marker count = %d, want at least 2", got)
}
bandIndex := strings.Index(diagram, `class="occultation-band"`)
curveIndex := strings.Index(diagram, `class="occultation-rise-set-boundary occultation-start-rise"`)
if bandIndex < 0 || curveIndex < 0 || curveIndex < bandIndex {
t.Fatalf("planetary SVG draws rise/set curves before the filled band: band=%d curve=%d", bandIndex, curveIndex)
}
curveEnd := -1
if curveIndex >= 0 {
curveEnd = strings.Index(diagram[curveIndex:], "/>")
}
if curveEnd < 0 || strings.Contains(diagram[curveIndex:curveIndex+curveEnd], "stroke-dasharray") {
t.Fatal("planetary rise/set phase line is rendered with a gap-producing dash pattern")
}
}
func TestPlanetOccultationLegendOmitsDisabledRiseSetCurves(t *testing.T) {
path := samplePlanetOccultationPath()
path.RiseSetCurves = nil
diagram, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{})
if err != nil {
t.Fatalf("PlanetOccultationPathSVG: %v", err)
}
if strings.Contains(diagram, "初掩/掩甚/终掩月升月落线") || strings.Contains(diagram, "Rise/set phase lines") {
t.Fatal("planetary SVG legend claims disabled rise/set curves are present")
}
}
func TestPlanetOccultationSVGWithoutFootprintsUsesLimitBands(t *testing.T) {
path := samplePlanetOccultationPath()
path.PartialFootprints = nil
path.TotalFootprints = nil
diagram, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{})
if err != nil {
t.Fatalf("PlanetOccultationPathSVG: %v", err)
}
for _, className := range []string{
`class="occultation-band"`, `class="total-occultation-band"`,
`class="northern-limit"`, `class="southern-limit"`,
`class="northern-total-limit"`, `class="southern-total-limit"`,
} {
if !strings.Contains(diagram, className) {
t.Fatalf("planetary SVG without footprints is missing %s", className)
}
}
}
func TestPlanetOccultationSVG20240725UsesCompactBandFootprints(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone)
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationSaturn,
moon.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900,
DisableRiseSet: true, DisableFootprints: true,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
if len(path.PartialFootprints) != 0 || len(path.TotalFootprints) != 0 ||
len(path.PartialBandFootprints) == 0 || len(path.TotalBandFootprints) == 0 {
t.Fatalf("dense/compact footprint counts partial=%d/%d total=%d/%d",
len(path.PartialFootprints), len(path.PartialBandFootprints),
len(path.TotalFootprints), len(path.TotalBandFootprints))
}
diagram, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{
Width: 1200, Height: 800, Projection: MapProjectionEquirectangular,
})
if err != nil {
t.Fatalf("PlanetOccultationPathSVG: %v", err)
}
for _, className := range []string{
`class="northern-limit"`, `class="southern-limit"`,
`class="northern-total-limit"`, `class="southern-total-limit"`,
} {
if strings.Contains(diagram, className) {
t.Fatalf("compact planetary SVG still overlays discontinuous auxiliary line %s", className)
}
}
x, y := planetOccultationSVGMapPoint(
t, diagram, MapProjectionEquirectangular, path.Greatest.Longitude, path.Greatest.Latitude,
)
for _, className := range []string{`class="occultation-band"`, `class="total-occultation-band"`} {
pathData := planetOccultationSVGPathData(t, diagram, strings.TrimSuffix(strings.TrimPrefix(className, `class="`), `"`))
if !strings.Contains(pathData, "Z") {
t.Fatalf("compact planetary SVG %s is not explicitly closed", className)
}
if !planetOccultationSVGPathContains(pathData, x, y) {
t.Fatalf("compact planetary SVG %s excludes greatest at %.3f, %.3f", className, x, y)
}
}
}
func TestPlanetOccultationSVGMars20250729MergesTotalRiseSetBoundaryBand(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationMars,
moon.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
DisableFootprints: true, IncludeFootprintTimeline: true,
FootprintTimelineStep: 5 * time.Minute,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
if len(path.TotalBandFootprints) < 20 {
t.Fatalf("total compact footprint count=%d, want sparse support samples", len(path.TotalBandFootprints))
}
if len(path.TotalRiseSetCurves) == 0 {
t.Fatal("total compact band is missing inner-contact rise/set curves")
}
diagram, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{
Width: 1200, Height: 800, Projection: MapProjectionEquirectangular,
})
if err != nil {
t.Fatalf("PlanetOccultationPathSVG: %v", err)
}
pathData := planetOccultationSVGPathData(t, diagram, "total-occultation-band")
subpaths := strings.Count(pathData, "M ")
if subpaths >= len(path.TotalBandFootprints)/2 {
t.Fatalf("Mars 2025-07-29 total band has %d subpaths for %d compact footprints, want merged boundary geometry",
subpaths, len(path.TotalBandFootprints))
}
if !strings.Contains(pathData, "Z") {
t.Fatal("Mars 2025-07-29 total boundary band is not explicitly closed")
}
}
func TestPlanetOccultationPathSVGJupiter20230517PolarBandContainsPole(t *testing.T) {
start := time.Date(2023, time.May, 17, 0, 0, 0, 0, time.UTC)
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationJupiter,
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one Jupiter path", len(paths), err)
}
for _, projection := range []struct {
name string
value MapProjection
longitude, latitude float64
}{
{name: "north-polar", value: MapProjectionNorthPolar, longitude: 0, latitude: 90},
{name: "equirectangular", value: MapProjectionEquirectangular, longitude: 0, latitude: 89},
} {
diagram, renderErr := PlanetOccultationPathSVG(paths[0], PlanetOccultationSVGOptions{
Width: 1200, Height: 800, Projection: projection.value,
})
if renderErr != nil {
t.Fatalf("%s PlanetOccultationPathSVG() error = %v", projection.name, renderErr)
}
x, y := planetOccultationSVGMapPoint(t, diagram, projection.value, projection.longitude, projection.latitude)
for _, className := range []string{"occultation-band", "total-occultation-band"} {
pathData := planetOccultationSVGPathData(t, diagram, className)
if !planetOccultationSVGPathContains(pathData, x, y) {
t.Fatalf("%s %s does not contain %.1f°E %.1f°N at %.3f, %.3f", projection.name, className,
projection.longitude, projection.latitude, x, y)
}
}
}
}
func TestPlanetOccultationPathSVGJupiter20230517UsesFineFootprintSweep(t *testing.T) {
start := time.Date(2023, time.May, 17, 0, 0, 0, 0, time.UTC)
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationJupiter,
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one Jupiter path", len(paths), err)
}
path := paths[0]
for _, series := range []struct {
name string
footprints []moon.PlanetOccultationFootprint
}{
{name: "partial", footprints: path.PartialFootprints},
{name: "total", footprints: path.TotalFootprints},
} {
if len(series.footprints) < 100 {
t.Fatalf("%s footprint count = %d, want fine sweep sampling", series.name, len(series.footprints))
}
for index := 1; index < len(series.footprints); index++ {
gap := series.footprints[index].Time.Sub(series.footprints[index-1].Time)
if gap > time.Minute+time.Millisecond {
t.Fatalf("%s footprint gap at %d = %s, want at most one minute", series.name, index, gap)
}
}
}
}
func planetOccultationSVGMapCenter(t *testing.T, diagram string) (float64, float64) {
t.Helper()
attributes := planetOccultationSVGElementAttributes(t, diagram, "circle", "map-ocean")
return planetOccultationSVGFloatAttribute(t, attributes, "cx"),
planetOccultationSVGFloatAttribute(t, attributes, "cy")
}
func planetOccultationSVGMapPoint(
t *testing.T, diagram string, projection MapProjection, longitude, latitude float64,
) (float64, float64) {
t.Helper()
if projection == MapProjectionNorthPolar || projection == MapProjectionSouthPolar {
return planetOccultationSVGMapCenter(t, diagram)
}
attributes := planetOccultationSVGElementAttributes(t, diagram, "rect", "map-ocean")
x := planetOccultationSVGFloatAttribute(t, attributes, "x")
y := planetOccultationSVGFloatAttribute(t, attributes, "y")
width := planetOccultationSVGFloatAttribute(t, attributes, "width")
height := planetOccultationSVGFloatAttribute(t, attributes, "height")
return x + (longitude+180)/360*width, y + (90-latitude)/180*height
}
func planetOccultationSVGPathData(t testing.TB, diagram, className string) string {
t.Helper()
attributes := planetOccultationSVGElementAttributes(t, diagram, "path", className)
value, ok := attributes["d"]
if !ok {
t.Fatalf("SVG path %q has no d attribute", className)
}
return value
}
func planetOccultationSVGElementAttributes(
t testing.TB,
diagram, elementName, className string,
) map[string]string {
t.Helper()
decoder := xml.NewDecoder(strings.NewReader(diagram))
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("decode planetary SVG: %v", err)
}
start, ok := token.(xml.StartElement)
if !ok || start.Name.Local != elementName {
continue
}
attributes := make(map[string]string, len(start.Attr))
for _, attribute := range start.Attr {
attributes[attribute.Name.Local] = attribute.Value
}
if attributes["class"] == className {
return attributes
}
}
t.Fatalf("SVG element %s.%s not found", elementName, className)
return nil
}
func planetOccultationSVGFloatAttribute(t testing.TB, attributes map[string]string, name string) float64 {
t.Helper()
value, ok := attributes[name]
if !ok {
t.Fatalf("SVG element has no %s attribute", name)
}
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
t.Fatalf("parse SVG %s attribute %q: %v", name, value, err)
}
return parsed
}
func planetOccultationSVGPathContains(pathData string, x, y float64) bool {
fields := strings.Fields(pathData)
polygon := make([][2]float64, 0)
for index := 0; index < len(fields); {
switch fields[index] {
case "M", "L":
if index+2 >= len(fields) {
return false
}
pointX, xErr := strconv.ParseFloat(fields[index+1], 64)
pointY, yErr := strconv.ParseFloat(fields[index+2], 64)
if xErr != nil || yErr != nil {
return false
}
polygon = append(polygon, [2]float64{pointX, pointY})
index += 3
case "Z":
if planetOccultationSVGPolygonContains(polygon, x, y) {
return true
}
polygon = polygon[:0]
index++
default:
return false
}
}
return false
}
func planetOccultationSVGPolygonContains(polygon [][2]float64, x, y float64) bool {
inside := false
for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 {
a, b := polygon[previous], polygon[current]
if (a[1] > y) == (b[1] > y) {
continue
}
if a[0]+(y-a[1])*(b[0]-a[0])/(b[1]-a[1]) > x {
inside = !inside
}
}
return inside
}
func TestPlanetOccultationPathSVGWrapsFivePhaseSummaryAtRenderedFontSize(t *testing.T) {
@@ -77,7 +388,7 @@ func TestPlanetOccultationPathSVGWrapsFivePhaseSummaryAtRenderedFontSize(t *test
SummaryText: strings.Repeat("A", 130),
})
options = planetOccultationSVGDefaults(path, options)
lines := starOccultationSVGHeaderLines(planetOccultationStarShape(path, path.TargetID), options)
lines := starOccultationSVGHeaderLines(planetOccultationStarShape(path, path.TargetID), options, starOccultationSVGTextFlags(options))
if len(lines) < 3 {
t.Fatalf("planetary SVG header lines = %d, want wrapped summary plus greatest line", len(lines))
}
@@ -187,6 +498,17 @@ func TestPlanetOccultationPathSVGRejectsInvalidPath(t *testing.T) {
}
}
func TestPlanetOccultationPathSVGRejectsMalformedRiseSetCurve(t *testing.T) {
path := samplePlanetOccultationPath()
curve := sampleOccultationRiseSetCurve(path.Start.Time)
curve.Direction = moon.RiseSetDirection("bogus")
path.RiseSetCurves = []moon.OccultationRiseSetCurve{curve}
_, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{})
if !errors.Is(err, ErrInvalidPlanetOccultationPath) {
t.Fatalf("PlanetOccultationPathSVG() error = %v, want ErrInvalidPlanetOccultationPath", err)
}
}
func TestPlanetOccultationPathSVGSupportsPartialOnlyGlobalBand(t *testing.T) {
path := samplePlanetOccultationPath()
path.HasTotalBand = false