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
+293
View File
@@ -0,0 +1,293 @@
package calendar
import (
"testing"
"time"
"b612.me/astro/basic"
)
// L2 契约:儒略历独有闰日(1582 年前百年非 400 闰年的 2 月 29 日)的 Solar 取后继日标签,
// JDE 取精确儒略日,JulianOnly 为真,农历身份字段描述这一天本身。
var julianOnlyYears = []int{
100, 200, 300, 500, 600, 700, 900, 1000, 1100, 1300, 1400, 1500,
-700, -600, -500, -300, -200, -100,
}
// lunarIdentity 是农历记录的身份字段,用于比较正向与反向是否描述同一天。
type lunarIdentity struct {
year, month, day int
leap bool
desc string
ganzhiDay string
ganzhiMonth string
calendars int
}
func identityOf(t Time) lunarIdentity {
l := t.Lunar()
return lunarIdentity{
year: l.LunarYear(),
month: l.LunarMonth(),
day: l.LunarDay(),
leap: l.IsLeap(),
desc: l.MonthDay(),
ganzhiDay: l.GanZhiDay(),
ganzhiMonth: l.GanZhiMonth(),
calendars: len(t.LunarInfo()),
}
}
func TestJulianOnlyLeapDayForwardReverseAgree(t *testing.T) {
for _, year := range julianOnlyYears {
phantom := basic.JDECalc(year, 2, 29)
if phantom != phantom { // NaN 保护:这些年份必须被历法层承认
t.Fatalf("year %d: basic.JDECalc(%d,2,29) is NaN", year, year)
}
label := basic.JDE2DateByZone(phantom, time.FixedZone("CST", 8*3600), true)
fwd, err := SolarToLunarByYMD(year, 2, 29)
if err != nil {
t.Fatalf("year %d: SolarToLunarByYMD(%d,2,29) error: %v", year, year, err)
}
if !fwd.JulianOnly() || fwd.JDE() != phantom {
t.Errorf("year %d: forward JulianOnly=%v JDE=%.1f, want true %.1f",
year, fwd.JulianOnly(), fwd.JDE(), phantom)
}
if got := fwd.Solar().Format("2006-01-02"); got != label.Format("2006-01-02") {
t.Errorf("year %d: forward Solar=%s, want canonical label %s", year, got, label.Format("2006-01-02"))
}
want := identityOf(fwd)
rev, err := LunarToSolarByYMD(want.year, want.month, want.day, want.leap)
if err != nil {
t.Fatalf("year %d: LunarToSolarByYMD(%d,%d,%d,%v) error: %v",
year, want.year, want.month, want.day, want.leap, err)
}
if !rev.JulianOnly() || rev.JDE() != phantom {
t.Errorf("year %d: reverse JulianOnly=%v JDE=%.1f, want true %.1f",
year, rev.JulianOnly(), rev.JDE(), phantom)
}
if got := rev.Solar().Format("2006-01-02"); got != label.Format("2006-01-02") {
t.Errorf("year %d: reverse Solar=%s, want canonical label %s", year, got, label.Format("2006-01-02"))
}
if got := identityOf(rev); got != want {
t.Errorf("year %d: reverse identity %+v, want %+v (requested lunar %d/%d/%d leap=%v)",
year, got, want, want.year, want.month, want.day, want.leap)
}
info := rev.LunarInfo()[0]
if !info.JulianOnly || info.JDE != phantom {
t.Errorf("year %d: LunarInfo JulianOnly=%v JDE=%.1f, want true %.1f", year, info.JulianOnly, info.JDE, phantom)
}
// 日干支必须取自闰日自己的儒略日,而不是标签日 Solar()。
// The sexagenary day name must come from the leap day's own Julian day, not from the label day.
if got, labelGanzhi := rev.Lunar().GanZhiDay(), GanZhiOfDay(rev.Solar()); got != ganZhiOfJDE(phantom) {
t.Errorf("year %d: GanZhiDay=%s, want %s (label %s gives %s)",
year, got, ganZhiOfJDE(phantom), rev.Solar().Format("2006-01-02"), labelGanzhi)
}
}
}
// 儒略历闰日两侧仍是普通日子:往返正常且农历日不与闰日重复。
func TestJulianOnlyLeapDayNeighboursStayOrdinary(t *testing.T) {
for _, year := range julianOnlyYears {
phantom := basic.JDECalc(year, 2, 29)
fwd, err := SolarToLunarByYMD(year, 2, 29)
if err != nil {
t.Fatalf("year %d: SolarToLunarByYMD(%d,2,29) error: %v", year, year, err)
}
leapLunar := identityOf(fwd)
for _, c := range []struct {
month, day int
wantJDE float64
}{
{2, 28, phantom - 1},
{3, 1, phantom + 1},
} {
near, err := SolarToLunarByYMD(year, c.month, c.day)
if err != nil {
t.Fatalf("year %d: SolarToLunarByYMD(%d,%d,%d) error: %v", year, year, c.month, c.day, err)
}
nearID := identityOf(near)
if nearID == leapLunar {
t.Errorf("year %d: %d-%02d shares the leap day's lunar date %d/%d/%d leap=%v",
year, year, c.day, nearID.year, nearID.month, nearID.day, nearID.leap)
}
rev, err := LunarToSolarByYMD(nearID.year, nearID.month, nearID.day, nearID.leap)
if err != nil {
t.Fatalf("year %d: reverse of %d-%02d lunar %d/%d/%d error: %v",
year, year, c.day, nearID.year, nearID.month, nearID.day, err)
}
if rev.JulianOnly() {
t.Errorf("year %d: %d-%02d flagged JulianOnly", year, year, c.day)
}
if rev.JDE() != c.wantJDE {
t.Errorf("year %d: %d-%02d JDE=%.1f, want %.1f", year, year, c.day, rev.JDE(), c.wantJDE)
}
if got := identityOf(rev); got != nearID {
t.Errorf("year %d: %d-%02d reverse identity %+v, want %+v", year, year, c.day, got, nearID)
}
}
}
}
// julianOnlyCivilDay 的分类必须只覆盖"仅儒略历存在"的闰日。
func TestJulianOnlyCivilDayClassification(t *testing.T) {
for _, year := range append(append([]int{}, julianOnlyYears...), 400, 800, 1200, 1600, 2000) {
want := false
for _, v := range julianOnlyYears {
if v == year {
want = true
break
}
}
if got := julianOnlyCivilDay(year, 2, 29); got != want {
t.Errorf("julianOnlyCivilDay(%d,2,29)=%v, want %v", year, got, want)
}
}
// 不存在的日期与普通闰日都不属于这一类。
for _, c := range []struct{ y, m, d int }{
{1900, 2, 29}, {1582, 2, 29}, {2024, 2, 29}, {2023, 2, 29}, {100, 2, 28}, {100, 3, 1},
} {
if julianOnlyCivilDay(c.y, c.m, c.d) {
t.Errorf("julianOnlyCivilDay(%d,%d,%d)=true, want false", c.y, c.m, c.d)
}
}
}
// 普通日期不受 L2 影响:JulianOnly 为假、JDE 与 Solar 一致、身份与正向一致。
func TestOrdinaryDatesCarryNoJulianOnlyFlag(t *testing.T) {
cases := []struct{ y, m, d int }{
{2025, 1, 1}, {2025, 6, 15}, {1912, 2, 12}, {1900, 5, 5}, {1582, 10, 15},
{1000, 7, 7}, {700, 3, 2}, {500, 3, 1}, {1, 1, 1}, {0, 6, 1}, {-100, 3, 2}, {-500, 2, 28},
}
for _, c := range cases {
t.Run(time.Date(c.y, time.Month(c.m), c.d, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), func(t *testing.T) {
fwd, err := SolarToLunarByYMD(c.y, c.m, c.d)
if err != nil {
t.Fatalf("SolarToLunarByYMD(%d,%d,%d) error: %v", c.y, c.m, c.d, err)
}
id := identityOf(fwd)
rev, err := LunarToSolarByYMD(id.year, id.month, id.day, id.leap)
if err != nil {
t.Fatalf("reverse of %d/%d/%d leap=%v error: %v", id.year, id.month, id.day, id.leap, err)
}
if rev.JulianOnly() {
t.Errorf("%d-%02d-%02d flagged JulianOnly", c.y, c.m, c.d)
}
if want := Date2JDE(rev.Solar()); rev.JDE() != want {
t.Errorf("%d-%02d-%02d JDE=%.1f, want %.1f (Solar 推出值)", c.y, c.m, c.d, rev.JDE(), want)
}
if got := identityOf(rev); got != id {
t.Errorf("%d-%02d-%02d reverse identity %+v, want %+v", c.y, c.m, c.d, got, id)
}
info := rev.LunarInfo()
for _, v := range info {
if v.JulianOnly {
t.Errorf("%d-%02d-%02d LunarInfo flagged JulianOnly", c.y, c.m, c.d)
}
if v.JDE != rev.JDE() {
t.Errorf("%d-%02d-%02d LunarInfo JDE=%.1f, want %.1f", c.y, c.m, c.d, v.JDE, rev.JDE())
}
}
})
}
}
// 字符串入口 LunarToSolar 曾把儒略历独有闰日整体偏一天(先解析成 time.Time 再回代);
// 现在按"结果紧邻闰日且闰日记录正是描述所指"复核。用例含武则天年号写法。
func TestLunarToSolarTextHandlesJulianOnlyLeapDays(t *testing.T) {
cases := []struct {
desc string
year int
month int
day int
leap bool
}{
{"100年二月初二", 100, 2, 2, false},
{"200年正月廿七", 200, 1, 27, false},
{"300年正月廿三", 300, 1, 23, false},
{"500年正月十五", 500, 1, 15, false},
{"600年闰正月初十", 600, 1, 10, true},
{"700年四月初五", 700, 4, 5, false},
{"900年正月廿六", 900, 1, 26, false},
{"1000年正月廿二", 1000, 1, 22, false},
{"1100年正月十八", 1100, 1, 18, false},
{"1300年二月初九", 1300, 2, 9, false},
{"1400年二月初五", 1400, 2, 5, false},
{"1500年二月初一", 1500, 2, 1, false},
{"前701年二月初八", -700, 2, 8, false},
{"前601年三月初三", -600, 3, 3, false},
{"前501年三月廿八", -500, 3, 28, false},
{"前301年三月二十", -300, 3, 20, false},
{"前201年正月十五", -200, 1, 15, false},
{"前101年正月十一", -100, 1, 11, false},
{"圣历三年二月初五", 700, 4, 5, false},
{"久视元年二月初五", 700, 4, 5, false},
{"永元十二年二月初二", 100, 2, 2, false},
}
for _, c := range cases {
phantom := basic.JDECalc(c.year, 2, 29)
label := basic.JDE2DateByZone(phantom, time.FixedZone("CST", 8*3600), true)
results, err := LunarToSolar(c.desc)
if err != nil {
t.Fatalf("%s: LunarToSolar error: %v", c.desc, err)
}
if len(results) != 1 {
t.Errorf("%s: got %d results, want 1", c.desc, len(results))
continue
}
got := results[0]
if !got.JulianOnly() || got.JDE() != phantom {
t.Errorf("%s: JulianOnly=%v JDE=%.1f, want true %.1f", c.desc, got.JulianOnly(), got.JDE(), phantom)
}
if solar := got.Solar().Format("2006-01-02"); solar != label.Format("2006-01-02") {
t.Errorf("%s: Solar=%s, want canonical label %s", c.desc, solar, label.Format("2006-01-02"))
}
lunar := got.Lunar()
if lunar.LunarYear() != c.year || lunar.LunarMonth() != c.month || lunar.LunarDay() != c.day || lunar.IsLeap() != c.leap {
t.Errorf("%s: lunar %d/%d/%d leap=%v, want %d/%d/%d leap=%v", c.desc,
lunar.LunarYear(), lunar.LunarMonth(), lunar.LunarDay(), lunar.IsLeap(), c.year, c.month, c.day, c.leap)
}
}
}
// 闰日前后的描述必须仍是普通日期。
func TestLunarToSolarTextKeepsLeapDayNeighboursOrdinary(t *testing.T) {
for _, year := range julianOnlyYears {
phantom := basic.JDECalc(year, 2, 29)
for _, offset := range []int{-1, 1} {
// 取闰日前后各一天,用它们自己的农历日构造描述。
neighbour := basic.JDE2DateByZone(phantom+float64(offset), time.FixedZone("CST", 8*3600), true)
fwd, err := SolarToLunarByYMD(neighbour.Year(), int(neighbour.Month()), neighbour.Day())
if err != nil {
t.Fatalf("year %d: SolarToLunarByYMD(%s) error: %v", year, neighbour.Format("2006-01-02"), err)
}
id := identityOf(fwd)
prefix := ""
if id.year < 0 {
prefix = "前" + number2Chinese(1-id.year, true)
} else {
prefix = number2Chinese(id.year, true)
}
desc := prefix + "年" + formatLunarDateString(id.month, id.day, id.leap, 0)
results, err := LunarToSolar(desc)
if err != nil {
t.Fatalf("%s: LunarToSolar error: %v", desc, err)
}
if len(results) != 1 {
t.Errorf("%s: got %d results, want 1", desc, len(results))
continue
}
got := results[0]
if got.JulianOnly() {
t.Errorf("%s: unexpectedly flagged JulianOnly", desc)
}
if want := phantom + float64(offset); got.JDE() != want {
t.Errorf("%s: JDE=%.1f, want %.1f", desc, got.JDE(), want)
}
if got := identityOf(results[0]); got != id {
t.Errorf("%s: identity %+v, want %+v", desc, got, id)
}
}
}
}