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
+246 -3
View File
@@ -155,6 +155,18 @@ func innerSolarToLunar(date time.Time) (Time, error) {
}
func innerSolarToLunarByYMD(year, month, day int) (Time, error) {
result, err := innerSolarToLunarByYMDRaw(year, month, day)
if err != nil {
return result, err
}
// 输入为儒略历独有闰日时补记 JulianOnly/JDE。
if phantom, ok := julianOnlyPhantomJDE(year, month, day); ok {
result = markJulianOnly(result, phantom)
}
return result, nil
}
func innerSolarToLunarByYMDRaw(year, month, day int) (Time, error) {
if year < ancientMinYear || year > 9999 {
return Time{}, fmt.Errorf("日期超出范围")
}
@@ -250,6 +262,14 @@ func LunarToSolar(desc string) ([]Time, error) {
}
results = append(results, date)
}
// 字符串入口先把描述解析成 time.Time,儒略历独有闰日会偏一天,这里按闰日复核一次。
if parsed, perr := parseChineseDate(desc); perr == nil && parsed.LunarMonth() != 0 && parsed.LunarDay() != 0 {
for i := range results {
if fixed, ok := julianOnlyTextResult(results[i], parsed.LunarMonth(), parsed.LunarDay(), parsed.IsLeap()); ok {
results[i] = fixed
}
}
}
return results, nil
}
@@ -289,6 +309,39 @@ func LunarToSolarSingle(year, month, day int, leap bool) (Time, error) {
// For years [-103, 1912], the lunar month index follows the historical calendar in force at that time, counted from the first month of that year.
// Years (1912, 3000] use the current GB/T 33661-2017 lunar-calendar convention.
func LunarToSolarByYMD(year, month, day int, leap bool) (Time, error) {
result, err := lunarToSolarByYMD(year, month, day, leap)
if err != nil {
return result, err
}
return finalizeLunarToSolarResult(result, year, month, day, leap), nil
}
// finalizeLunarToSolarResult 补记儒略历独有闰日与多公历候选。
func finalizeLunarToSolarResult(result Time, year, month, day int, leap bool) Time {
if lunarDateMatches(result, year, month, day, leap) {
result.solarCandidates = lunarToSolarCandidates(year, month, day, leap, result)
return result
}
// 主答案对不上请求:儒略历独有的 2 月 29 日(正向序列在那里缺一号),或改历双纪年里
// 主偏移落在了另一套纪年上(王莽 23/24 年交界)。
if phantom, ok := julianOnlyPhantomJDE(result.Solar().Year(), 2, 29); ok {
return markJulianOnlyLunar(result, year, month, day, leap, phantom)
}
alternates := lunarToSolarAlternates(year, month, day, leap)
if len(alternates) == 0 {
return result
}
primary, err := SolarToLunar(alternates[0])
if err != nil {
return result
}
if len(alternates) > 1 {
primary.solarCandidates = alternates
}
return primary
}
func lunarToSolarByYMD(year, month, day int, leap bool) (Time, error) {
if year < ancientBoundaryMinYear || year > 9999 {
return Time{}, fmt.Errorf("年份超出范围")
}
@@ -321,6 +374,186 @@ func LunarToSolarByYMD(year, month, day int, leap bool) (Time, error) {
return SolarToLunar(date)
}
// julianOnlyCivilDay 报告该日期是否只存在于儒略历( 1582 年前百年非 400 闰年的 2 月 29 日)。
func julianOnlyCivilDay(year, month, day int) bool {
if month != 2 || day != 29 {
return false
}
if basic.ValidateCivilDate(year, month, float64(day)) != nil {
return false
}
return time.Date(year, time.February, 29, 0, 0, 0, 0, time.UTC).Month() != time.February
}
// julianOnlyPhantomJDE 返回儒略历独有闰日的儒略日。
func julianOnlyPhantomJDE(year, month, day int) (float64, bool) {
if !julianOnlyCivilDay(year, month, day) {
return 0, false
}
return basic.JDECalc(year, month, float64(day)), true
}
// julianOnlyTextResult 复核字符串入口的结果是否落在儒略历独有闰日上,是则返回该闰日的结果。
func julianOnlyTextResult(result Time, month, day int, leap bool) (Time, bool) {
solar := result.Solar()
year := solar.Year()
if !julianOnlyCivilDay(year, 2, 29) {
return result, false
}
diff := Date2JDE(solar) - basic.JDECalc(year, 2, 29)
switch diff {
case 1, -1:
// +1 表示结果是闰日的标签日,-1 表示结果是闰日的前一天。
default:
return result, false
}
forward, err := innerSolarToLunarByYMD(year, 2, 29)
if err != nil {
return result, false
}
lunar := forward.Lunar()
// 只比日号与闰月标志(改历期间描述用年号月序、记录存规范月序),且与结果的农历日号相差一天。
if lunar.LunarDay() != day || lunar.IsLeap() != leap {
return result, false
}
resultDay := result.Lunar().LunarDay()
if diff > 0 {
// 结果是闰日的标签日,农历上比闰日晚一天。
if resultDay != day+1 && !(resultDay == 1 && day >= 29) {
return result, false
}
} else {
// 结果是闰日的前一天,农历上比闰日早一天。
if resultDay != day-1 && !(day == 1 && resultDay >= 29) {
return result, false
}
}
return forward, true
}
// markJulianOnly 补记儒略历独有闰日:Solar 取规范标签(后继日),精确日期放入 JDE。
func markJulianOnly(result Time, phantomJDE float64) Time {
label := basic.JDE2DateByZone(phantomJDE, getCst(), true)
result.solarTime = label
for i := range result.lunars {
result.lunars[i].solarDate = label
result.lunars[i].julianOnly = true
result.lunars[i].jde = phantomJDE
}
return result
}
// markJulianOnlyLunar 反向路径用:用正向记录改写停在标签日的农历身份,并补记闰日标志。
func markJulianOnlyLunar(result Time, year, month, day int, leap bool, phantomJDE float64) Time {
label := basic.JDE2DateByZone(phantomJDE, getCst(), true)
result = markJulianOnly(result, phantomJDE)
forward, err := innerSolarToLunarByYMD(label.Year(), 2, 29)
if err != nil || !lunarDateMatches(forward, year, month, day, leap) {
return result
}
lunars := make([]LunarTime, len(forward.lunars))
copy(lunars, forward.lunars)
for i := range lunars {
lunars[i].julianOnly = true
lunars[i].jde = phantomJDE
}
result.lunars = lunars
return result
}
// markJulianOnlyJDN 在 JDN 层面判定儒略历独有闰日(古历与秦汉反向直接由 JDN 造记录)。
func markJulianOnlyJDN(result Time, jdn int) Time {
label := basic.JDE2DateByZone(float64(jdn)-0.5, getCst(), true)
phantom, ok := julianOnlyPhantomJDE(label.Year(), 2, 29)
if !ok || int(math.Floor(phantom+0.5)) != jdn {
return result
}
return markJulianOnly(result, phantom)
}
// lunarDateMatches 报告结果里是否有记录正是该农历日。
func lunarDateMatches(result Time, year, month, day int, leap bool) bool {
for _, l := range result.Lunars() {
if l.LunarYear() == year && l.LunarMonth() == month && l.LunarDay() == day && l.IsLeap() == leap {
return true
}
}
return false
}
// lunarReformYear 报告该年是否处于改历双纪年窗口(王莽/魏明帝/武则天/唐肃宗)。
func lunarReformYear(year int) bool {
return (year >= 9 && year <= 23) || (year >= 237 && year <= 240) ||
(year >= 689 && year <= 700) || (year >= 761 && year <= 762)
}
// lunarToSolarCandidates 收集该农历日的全部合法公历候选,主答案在前;单候选返回 nil。
func lunarToSolarCandidates(year, month, day int, leap bool, primary Time) []time.Time {
candidates := []time.Time{primary.Solar()}
for _, date := range lunarToSolarAlternates(year, month, day, leap) {
duplicated := false
for _, existing := range candidates {
if existing.Equal(date) {
duplicated = true
break
}
}
if !duplicated {
candidates = append(candidates, date)
}
}
if len(candidates) == 1 {
return nil
}
return candidates
}
// lunarToSolarAlternates 收集另一套改历月序(或颛顼历)口径下能正向回到该农历日的公历日。
func lunarToSolarAlternates(year, month, day int, leap bool) []time.Time {
var alternates []time.Time
appendIfLegal := func(result Time, ok bool) {
if !ok || !lunarDateMatches(result, year, month, day, leap) {
return
}
date := result.Solar()
for _, existing := range alternates {
if existing.Equal(date) {
return
}
}
alternates = append(alternates, date)
}
switch {
case year <= qinHanMaxYear:
// 太初改历交接:颛顼历与默认(太初)口径各是一个候选。
result, ok := lunarToSolarQinHan(year, month, day, leap)
if ok {
appendIfLegal(tagCalendar(result, AncientCalendarQinHan, ancientCalendarName(AncientCalendarQinHan)), true)
}
case year <= 1912:
// 改历双纪年:换另一套月序偏移(0/1/2,255 在 rapidSolarHan2Qing 内部等价于 2)再算一次。
if !lunarReformYear(year) {
break
}
primaryDiff := yearDiffLunar(year, month, day)
if primaryDiff == 255 {
primaryDiff = 2
}
for _, diff := range []int{0, 1, 2} {
if diff == primaryDiff {
continue
}
date := rapidSolarHan2Qing(year, month, day, leap, diff, nil)
if date.IsZero() {
continue
}
result, err := SolarToLunar(date)
appendIfLegal(result, err == nil)
}
}
return alternates
}
func lunarToSolarHanQingDefault(year, month, day int, leap bool) (Time, bool) {
date := rapidSolarHan2Qing(year, month, day, leap, yearDiffLunar(year, month, day), nil)
if date.IsZero() {
@@ -514,6 +747,12 @@ var chineseMonths = map[string]int{
"七": 7, "八": 8, "九": 9, "十": 10,
}
// 两个正则提到包级:文本入口每次解析都会用到,每次重编译会白花几十微秒。
var (
chineseDatePrefixYearPattern = regexp.MustCompile(`^([-一二三四五六七八九十零〇\d]+?)年`)
chineseDatePattern = regexp.MustCompile(`^([\p{Han}]+?)([-负負一二三四五六七八九十零〇\d]*?元?)年([\p{Han}\d]+?)月([\p{Han}\d]+?)日?$`)
)
func parseChineseDate(dateStr string) (LunarTime, error) {
var result LunarTime
var err error
@@ -521,7 +760,7 @@ func parseChineseDate(dateStr string) (LunarTime, error) {
if strings.HasPrefix(dateStr, "前") {
originDateStr := dateStr
dateStr = strings.TrimPrefix(dateStr, "前")
re := regexp.MustCompile(`^([-一二三四五六七八九十零〇\d]+?)年`)
re := chineseDatePrefixYearPattern
matches := re.FindStringSubmatch(dateStr)
if len(matches) == 2 {
year, err := parseDirectYear(matches[1])
@@ -535,7 +774,7 @@ func parseChineseDate(dateStr string) (LunarTime, error) {
}
dateStr = "公元" + dateStr
// 正则表达式匹配日期格式
re := regexp.MustCompile(`^([\p{Han}]+?)([-负負一二三四五六七八九十零〇\d]*?元?)年([\p{Han}\d]+?)月([\p{Han}\d]+?)日?$`)
re := chineseDatePattern
matches := re.FindStringSubmatch(dateStr)
if len(matches) < 5 {
return result, fmt.Errorf("无效的日期格式: %s", dateStr)
@@ -741,7 +980,11 @@ func GanZhiOfYear(year int) string {
// GanZhiOfDay 日干支 / sexagenary day name.
func GanZhiOfDay(t time.Time) string {
jde := Date2JDE(time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, getCst()))
return ganZhiOfJDE(Date2JDE(time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, getCst())))
}
// ganZhiOfJDE 由儒略日直接求日干支(儒略历独有闰日无法表示为 time.Time)。
func ganZhiOfJDE(jde float64) string {
diff := int(jde - 2451550.5)
if diff >= 0 {
return tiangan[diff%10] + dizhi[diff%12]
+34 -3
View File
@@ -140,11 +140,12 @@ func LunarToSolarByYMDWithCalendar(year, month, day int, leap bool, system Ancie
return Time{}, fmt.Errorf("日期超出范围")
}
lmonth.day = day
date := ancientJDNToDate(lmonth.startJDN + day - 1)
jdn := lmonth.startJDN + day - 1
date := ancientJDNToDate(jdn)
if !ancientSolarYearInRange(date.Year()) {
return Time{}, fmt.Errorf("未找到对应日期")
}
return ancientTime(date, lmonth), nil
return markJulianOnlyJDN(ancientTime(date, lmonth), jdn), nil
}
func calendricalJieQiWithCalendar(year, term int, system AncientCalendarSystem) (time.Time, error) {
@@ -284,16 +285,46 @@ func lunarToSolarAncientDefault(year, month, day int, leap bool) (Time, bool) {
if !ok {
return Time{}, false
}
if result, ok := lunarToSolarAncientWithDefault(year, month, day, leap, system); ok {
return result, true
}
// 岁首跨公历年时正向按公历年选古历、反向只能按农历年选,两者会差一天;
// 这里换另一种默认古历再试,并要求与正向的选历一致。
if alternate, ok := ancientAlternateDefaultSystem(system); ok {
if result, ok := lunarToSolarAncientWithDefault(year, month, day, leap, alternate); ok {
return result, true
}
}
return Time{}, false
}
// lunarToSolarAncientWithDefault 用指定古历求农历日,并要求与正向的默认选历一致。
func lunarToSolarAncientWithDefault(year, month, day int, leap bool, system AncientCalendarSystem) (Time, bool) {
result, err := LunarToSolarByYMDWithCalendar(year, month, day, leap, system)
if err != nil {
return Time{}, false
}
if !ancientSolarYearInRange(result.Solar().In(getCst()).Year()) {
solarYear := result.Solar().In(getCst()).Year()
if !ancientSolarYearInRange(solarYear) {
return Time{}, false
}
if forwardSystem, ok := defaultAncientCalendarSystemForYear(solarYear); !ok || forwardSystem != system {
return Time{}, false
}
return result, true
}
// ancientAlternateDefaultSystem 返回另一种默认古历(春秋 ↔ 周),用于岁首跨界回退。
func ancientAlternateDefaultSystem(system AncientCalendarSystem) (AncientCalendarSystem, bool) {
switch system {
case AncientCalendarChunqiu:
return AncientCalendarZhou, true
case AncientCalendarZhou:
return AncientCalendarChunqiu, true
}
return AncientCalendarDefault, false
}
func innerSolarToLunarByYMDWithCalendar(year, month, day int, hmi time.Time, system AncientCalendarSystem) (Time, error) {
if system == AncientCalendarQinHan {
if err := validateQinHanCalendarSolarInput(year, month, day); err != nil {
+28 -14
View File
@@ -3,6 +3,7 @@ package calendar
import (
"fmt"
"math"
"sync"
"time"
"b612.me/astro/basic"
@@ -58,22 +59,35 @@ func hanQingJieQiPatternDelta(patternID, pos int) int {
return 15
}
// hanQingJieQiPatternExceptions 是未排序的 95 项例外表,先缓存成 map 再查,避免每次线性扫描。
var (
hanQingJieQiPatternExceptionOnce sync.Once
hanQingJieQiPatternExceptionByKey map[int]int
)
func hanQingJieQiPatternExceptionDelta(key int) (int, bool) {
for _, item := range hanQingJieQiPatternExceptions {
if int(item&0x0FFF) != key {
continue
hanQingJieQiPatternExceptionOnce.Do(func() {
m := make(map[int]int, len(hanQingJieQiPatternExceptions))
for _, item := range hanQingJieQiPatternExceptions {
k := int(item & 0x0FFF)
if _, exists := m[k]; exists {
continue
}
switch item >> 12 {
case 0:
m[k] = 12
case 1:
m[k] = 14
case 2:
m[k] = 17
default:
m[k] = 15
}
}
switch item >> 12 {
case 0:
return 12, true
case 1:
return 14, true
case 2:
return 17, true
}
return 15, true
}
return 0, false
hanQingJieQiPatternExceptionByKey = m
})
delta, ok := hanQingJieQiPatternExceptionByKey[key]
return delta, ok
}
func packedBits(data []byte, offset, width int) int {
File diff suppressed because one or more lines are too long
+31 -17
View File
@@ -1,6 +1,7 @@
package calendar
func liaoJinYuanCals() map[int]uint32 {
// liaoJinYuanCals 辽/金/元历相对宋基准表的年份修正表,编码同 getHanQingCals()。
var liaoJinYuanCals = memoUint32Map(func() map[int]uint32 {
return map[int]uint32{
949: 2996838400,
955: 1237097216,
@@ -12,7 +13,9 @@ func liaoJinYuanCals() map[int]uint32 {
973: 2464163072,
985: 3031177216,
986: 1788881664,
993: 1767184896,
994: 3645910016,
1000: 2774542080,
999: 2775716864,
1001: 3530398464,
1012: 3579124224,
@@ -35,9 +38,13 @@ func liaoJinYuanCals() map[int]uint32 {
1073: 2991597824,
1075: 3601470208,
1077: 2513836800,
1074: 3040878080,
1078: 2511351296,
1080: 2763790592,
1093: 1531977216,
1094: 728372224,
1102: 1523258624,
1103: 1456482304,
1105: 2733216256,
1113: 635048960,
1121: 1269470464,
@@ -49,15 +56,18 @@ func liaoJinYuanCals() map[int]uint32 {
1155: 1530929920,
1163: 3595576320,
1168: 2461018624,
1175: 633116928,
1176: 628108288,
1178: 2839221504,
1184: 1264594176,
1198: 3040880384,
1207: 2904563200,
1208: 3047822080,
1209: 727721472,
1239: 1370498304,
1250: 3576701440,
1251: 1520769024,
1252: 3600820736,
1253: 1523588864,
1256: 2732596480,
1258: 3511690240,
@@ -66,9 +76,9 @@ func liaoJinYuanCals() map[int]uint32 {
1273: 1169888512,
1276: 1784254976,
}
}
})
func liaoJinYuanEras() []Era {
var liaoJinYuanEras = memoEras(func() []Era {
return []Era{
{
Year: 1264,
@@ -337,9 +347,9 @@ func liaoJinYuanEras() []Era {
Dynasty: "辽",
},
}
}
})
func liaoJinYuanEraMap() map[string][][]int {
var liaoJinYuanEraMap = memoEraMap(func() map[string][][]int {
return map[string][][]int{
"中统": [][]int{{1260, 1264}},
"元宪宗": [][]int{{1251, 1259}},
@@ -385,25 +395,29 @@ func liaoJinYuanEraMap() map[string][][]int {
"大同": [][]int{{947, 947}},
"会同": [][]int{{939, 946}},
}
}
})
func innerSolarToLunarLiaoJinYuan(date Time) Time {
year := date.solarTime.Year()
month := int(date.solarTime.Month())
day := date.solarTime.Day()
// 农历年早于对应年号表首年时 eras 为空,此类候选与主流日期完全相同且无朝代,跳过追加。
if year >= 947 && year <= 1279 {
lyear, lmonth, ganzhiMonth, lday, isLeap, ldesc := rapidLunarHan2Qing(year, month, day, 0, liaoJinYuanCals)
date.lunars = append(date.lunars, LunarTime{
solarDate: date.Solar(),
year: lyear,
month: lmonth,
day: lday,
leap: isLeap,
desc: ldesc,
comment: "",
ganzhiMonth: ganzhiMonth,
eras: innerEras(lyear, liaoJinYuanEras),
})
eras := innerEras(lyear, liaoJinYuanEras)
if len(eras) > 0 {
date.lunars = append(date.lunars, LunarTime{
solarDate: date.Solar(),
year: lyear,
month: lmonth,
day: lday,
leap: isLeap,
desc: ldesc,
comment: "",
ganzhiMonth: ganzhiMonth,
eras: eras,
})
}
}
return date
}
+82 -68
View File
@@ -1,7 +1,7 @@
package calendar
// 后秦
func houQinCals() map[int]uint32 {
var houQinCals = memoUint32Map(func() map[int]uint32 {
return map[int]uint32{
384: 2862622464,
385: 3042089728,
@@ -38,9 +38,9 @@ func houQinCals() map[int]uint32 {
416: 2863672576,
417: 1431708160,
}
}
})
func houQinEras() []Era {
var houQinEras = memoEras(func() []Era {
return []Era{
{
Year: 416,
@@ -73,9 +73,9 @@ func houQinEras() []Era {
Dynasty: "后秦",
},
}
}
})
func houQinEraMap() map[string][][]int {
var houQinEraMap = memoEraMap(func() map[string][][]int {
return map[string][][]int{
"永和": [][]int{{416, 417}},
"弘始": [][]int{{399, 416}},
@@ -83,10 +83,10 @@ func houQinEraMap() map[string][][]int {
"建初": [][]int{{386, 394}},
"白雀": [][]int{{384, 386}},
}
}
})
// 北魏、西魏、北周、隋
func weiZhouSuiCals() map[int]uint32 {
var weiZhouSuiCals = memoUint32Map(func() map[int]uint32 {
return map[int]uint32{
398: 1454219776,
399: 1436562688,
@@ -281,9 +281,9 @@ func weiZhouSuiCals() map[int]uint32 {
588: 1788879360,
589: 2907837952,
}
}
})
func weiZhouSuiEras() []Era {
var weiZhouSuiEras = memoEras(func() []Era {
return []Era{
{
Year: 601,
@@ -574,9 +574,9 @@ func weiZhouSuiEras() []Era {
Dynasty: "北魏",
},
}
}
})
func weiZhouSuiEraMap() map[string][][]int {
var weiZhouSuiEraMap = memoEraMap(func() map[string][][]int {
return map[string][][]int{
//"开皇": [][]int{{581, 600}},
"大象": [][]int{{579, 580}},
@@ -626,9 +626,9 @@ func weiZhouSuiEraMap() map[string][][]int {
"皇始": [][]int{{396, 397}},
"登国": [][]int{{386, 396}},
}
}
})
func beiLiangCals() map[int]uint32 {
var beiLiangCals = memoUint32Map(func() map[int]uint32 {
return map[int]uint32{
// currect here
412: 1432100352,
@@ -660,9 +660,9 @@ func beiLiangCals() map[int]uint32 {
438: 3578800384,
439: 1454219008,
}
}
})
func beiLiangEras() []Era {
var beiLiangEras = memoEras(func() []Era {
return []Era{
{
Year: 433,
@@ -707,9 +707,9 @@ func beiLiangEras() []Era {
Dynasty: "北凉",
},
}
}
})
func beiLiangEraMap() map[string][][]int {
var beiLiangEraMap = memoEraMap(func() map[string][][]int {
return map[string][][]int{
"承和": [][]int{{433, 439}},
"义和": [][]int{{431, 433}},
@@ -719,8 +719,9 @@ func beiLiangEraMap() map[string][][]int {
"天玺": [][]int{{399, 401}},
"神玺": [][]int{{397, 399}},
}
}
func dongWeiBeiQiCals() map[int]uint32 {
})
var dongWeiBeiQiCals = memoUint32Map(func() map[int]uint32 {
return map[int]uint32{
534: 2874547712,
535: 2865770752,
@@ -767,9 +768,9 @@ func dongWeiBeiQiCals() map[int]uint32 {
576: 2865769984,
577: 1431315200,
}
}
})
func dongWeiBeiQiEras() []Era {
var dongWeiBeiQiEras = memoEras(func() []Era {
return []Era{
{
Year: 577,
@@ -838,9 +839,9 @@ func dongWeiBeiQiEras() []Era {
Dynasty: "东魏",
},
}
}
})
func dongWeiBeiQiEraMap() map[string][][]int {
var dongWeiBeiQiEraMap = memoEraMap(func() map[string][][]int {
return map[string][][]int{
"承光": [][]int{{577, 577}},
"武平": [][]int{{570, 576}},
@@ -854,67 +855,80 @@ func dongWeiBeiQiEraMap() map[string][][]int {
"元象": [][]int{{538, 539}},
"天平": [][]int{{534, 538}},
}
}
})
func innerSolarToLunarNanBeiChao(date Time) Time {
year := date.solarTime.Year()
month := int(date.solarTime.Month())
day := date.solarTime.Day()
// 农历年早于对应年号表首年时 eras 为空,此类候选与主流日期完全相同且无朝代,跳过追加。
if year >= 384 && year <= 417 {
lyear, lmonth, ganzhiMonth, lday, isLeap, ldesc := rapidLunarHan2Qing(year, month, day, 0, houQinCals)
date.lunars = append(date.lunars, LunarTime{
solarDate: date.solarTime,
year: lyear,
month: lmonth,
day: lday,
leap: isLeap,
desc: ldesc,
comment: "",
ganzhiMonth: ganzhiMonth,
eras: innerEras(lyear, houQinEras),
})
eras := innerEras(lyear, houQinEras)
if len(eras) > 0 {
date.lunars = append(date.lunars, LunarTime{
solarDate: date.solarTime,
year: lyear,
month: lmonth,
day: lday,
leap: isLeap,
desc: ldesc,
comment: "",
ganzhiMonth: ganzhiMonth,
eras: eras,
})
}
}
if year >= 398 && year <= 589 {
lyear, lmonth, ganzhiMonth, lday, isLeap, ldesc := rapidLunarHan2Qing(year, month, day, 0, weiZhouSuiCals)
date.lunars = append(date.lunars, LunarTime{
solarDate: date.solarTime,
year: lyear,
month: lmonth,
day: lday,
leap: isLeap,
desc: ldesc,
comment: "",
ganzhiMonth: ganzhiMonth,
eras: innerEras(lyear, weiZhouSuiEras),
})
eras := innerEras(lyear, weiZhouSuiEras)
if len(eras) > 0 {
date.lunars = append(date.lunars, LunarTime{
solarDate: date.solarTime,
year: lyear,
month: lmonth,
day: lday,
leap: isLeap,
desc: ldesc,
comment: "",
ganzhiMonth: ganzhiMonth,
eras: eras,
})
}
}
if year >= 397 && year <= 439 {
lyear, lmonth, ganzhiMonth, lday, isLeap, ldesc := rapidLunarHan2Qing(year, month, day, 0, beiLiangCals)
date.lunars = append(date.lunars, LunarTime{
solarDate: date.solarTime,
year: lyear,
month: lmonth,
day: lday,
leap: isLeap,
ganzhiMonth: ganzhiMonth,
desc: ldesc,
comment: "",
eras: innerEras(lyear, beiLiangEras),
})
eras := innerEras(lyear, beiLiangEras)
if len(eras) > 0 {
date.lunars = append(date.lunars, LunarTime{
solarDate: date.solarTime,
year: lyear,
month: lmonth,
day: lday,
leap: isLeap,
ganzhiMonth: ganzhiMonth,
desc: ldesc,
comment: "",
eras: eras,
})
}
}
if year >= 534 && year <= 577 {
lyear, lmonth, ganzhiMonth, lday, isLeap, ldesc := rapidLunarHan2Qing(year, month, day, 0, dongWeiBeiQiCals)
date.lunars = append(date.lunars, LunarTime{
solarDate: date.solarTime,
year: lyear,
month: lmonth,
day: lday,
leap: isLeap,
desc: ldesc,
ganzhiMonth: ganzhiMonth,
comment: "",
eras: innerEras(lyear, dongWeiBeiQiEras),
})
eras := innerEras(lyear, dongWeiBeiQiEras)
if len(eras) > 0 {
date.lunars = append(date.lunars, LunarTime{
solarDate: date.solarTime,
year: lyear,
month: lmonth,
day: lday,
leap: isLeap,
desc: ldesc,
ganzhiMonth: ganzhiMonth,
comment: "",
eras: eras,
})
}
}
return date
}
+37 -30
View File
@@ -1,6 +1,6 @@
package calendar
func nanMingCals() map[int]uint32 {
var nanMingCals = memoUint32Map(func() map[int]uint32 {
return map[int]uint32{
1645: 1232804864,
1646: 1232088832,
@@ -42,9 +42,9 @@ func nanMingCals() map[int]uint32 {
1682: 3529516544,
1683: 3912440576,
}
}
})
func nanMingEras01() []Era {
var nanMingEras01 = memoEras(func() []Era {
return []Era{
{
Year: 1646,
@@ -70,9 +70,9 @@ func nanMingEras01() []Era {
Dynasty: "明",
},
}
}
})
func nanMingEras02() []Era {
var nanMingEras02 = memoEras(func() []Era {
return []Era{
{
Year: 1647,
@@ -81,48 +81,55 @@ func nanMingEras02() []Era {
Dynasty: "南明",
},
}
}
})
func nanMingEraMap() map[string][][]int {
var nanMingEraMap = memoEraMap(func() map[string][][]int {
return map[string][][]int{
"永历": [][]int{{1647, 1683}},
"鲁王监国": [][]int{{1646, 1653}},
"隆武": [][]int{{1645, 1646}},
"弘光": [][]int{{1645, 1645}},
}
}
})
func innerSolarToLunarNanMing(date Time) Time {
year := date.solarTime.Year()
month := int(date.solarTime.Month())
day := date.solarTime.Day()
// 农历年早于对应年号表首年时 eras 为空,此类候选与主流日期完全相同且无朝代,跳过追加。
if year > 1644 && year < 1654 {
lyear, lmonth, ganzhiMonth, lday, isLeap, ldesc := rapidLunarHan2Qing(year, month, day, 0, nanMingCals)
date.lunars = append(date.lunars, LunarTime{
solarDate: date.solarTime,
year: lyear,
month: lmonth,
day: lday,
leap: isLeap,
desc: ldesc,
ganzhiMonth: ganzhiMonth,
comment: "",
eras: innerEras(lyear, nanMingEras01),
})
eras := innerEras(lyear, nanMingEras01)
if len(eras) > 0 {
date.lunars = append(date.lunars, LunarTime{
solarDate: date.solarTime,
year: lyear,
month: lmonth,
day: lday,
leap: isLeap,
desc: ldesc,
ganzhiMonth: ganzhiMonth,
comment: "",
eras: eras,
})
}
}
if year > 1646 && year < 1684 {
lyear, lmonth, ganzhiMonth, lday, isLeap, ldesc := rapidLunarHan2Qing(year, month, day, 0, nanMingCals)
date.lunars = append(date.lunars, LunarTime{
solarDate: date.solarTime,
year: lyear,
month: lmonth,
day: lday,
leap: isLeap,
desc: ldesc,
ganzhiMonth: ganzhiMonth,
comment: "",
eras: innerEras(lyear, nanMingEras02),
})
eras := innerEras(lyear, nanMingEras02)
if len(eras) > 0 {
date.lunars = append(date.lunars, LunarTime{
solarDate: date.solarTime,
year: lyear,
month: lmonth,
day: lday,
leap: isLeap,
desc: ldesc,
ganzhiMonth: ganzhiMonth,
comment: "",
eras: eras,
})
}
}
return date
}
+12 -3
View File
@@ -39,7 +39,15 @@ func innerSolarToLunarQinHan(date time.Time) (Time, bool) {
}
func innerSolarToLunarQinHanByYMD(year, month, day int) (Time, bool) {
return innerSolarToLunarQinHan(time.Date(year, time.Month(month), day, 0, 0, 0, 0, getCst()))
// 直接按 JDN 取日期:儒略历独有的闰日先转 time.Time 会被规范化成后继日。
jdn := qinHanDateJDN(year, month, day)
date := qinHanJDNToDate(jdn)
lmonth, ok := qinHanMonthBySolar(year, month, day)
if !ok {
return Time{}, false
}
lmonth.day = jdn - lmonth.startJDN + 1
return markJulianOnlyJDN(qinHanTime(date, lmonth), jdn), true
}
func lunarToSolarQinHan(year, month, day int, leap bool) (Time, bool) {
@@ -51,8 +59,9 @@ func lunarToSolarQinHan(year, month, day int, leap bool) (Time, bool) {
return Time{}, false
}
lmonth.day = day
date := qinHanJDNToDate(lmonth.startJDN + day - 1)
return qinHanTime(date, lmonth), true
jdn := lmonth.startJDN + day - 1
date := qinHanJDNToDate(jdn)
return markJulianOnlyJDN(qinHanTime(date, lmonth), jdn), true
}
func rapidSolarQinHan(year, month, day int, leap bool) (time.Time, bool) {
+48 -34
View File
@@ -1,7 +1,7 @@
package calendar
// 蜀汉朔日表
func shuCals() map[int]uint32 {
var shuCals = memoUint32Map(func() map[int]uint32 {
return map[int]uint32{
237: 2862623744,
238: 3042255104,
@@ -31,44 +31,50 @@ func shuCals() map[int]uint32 {
262: 2862622208,
263: 3578927872,
}
}
})
func shuEras() []Era {
var shuEras = memoEras(func() []Era {
return []Era{
{
Year: 264,
Emperor: "魏元帝",
OtherNianHaoStart: "咸熙",
Dynasty: "魏",
},
{
Year: 263,
Emperor: "蜀后主",
OtherNianHaoStart: "炎兴",
Dynasty: "蜀",
},
{
Year: 258,
Emperor: "蜀后主",
Nianhao: "景耀",
Dynasty: "蜀",
},
{
Year: 238,
Emperor: "蜀后主",
Nianhao: "延熙",
Dynasty: "蜀",
},
{
Year: 223,
Emperor: "蜀后主",
OtherNianHaoStart: "建兴",
Dynasty: "蜀",
},
{
Year: 221,
Emperor: "蜀昭烈帝",
Nianhao: "章武",
Dynasty: "蜀",
},
}
}
})
func shuEraMap() map[string][][]int {
var shuEraMap = memoEraMap(func() map[string][][]int {
return map[string][][]int{
"炎兴": [][]int{{263, 263}},
"景耀": [][]int{{258, 263}},
@@ -76,9 +82,9 @@ func shuEraMap() map[string][][]int {
"建兴": [][]int{{223, 237}},
"章武": [][]int{{221, 223}},
}
}
})
func wuCals() map[int]uint32 {
var wuCals = memoUint32Map(func() map[int]uint32 {
return map[int]uint32{
223: 1432367360,
224: 2862622208,
@@ -139,9 +145,9 @@ func wuCals() map[int]uint32 {
279: 1454087680,
280: 1436561664,
}
}
})
func wuEraMap() map[string][][]int {
var wuEraMap = memoEraMap(func() map[string][][]int {
return map[string][][]int{
"天玺": [][]int{{276, 276}},
"天册": [][]int{{275, 276}},
@@ -160,9 +166,9 @@ func wuEraMap() map[string][][]int {
"黄龙": [][]int{{229, 231}},
"黄武": [][]int{{222, 229}},
}
}
})
func wuEras() []Era {
var wuEras = memoEras(func() []Era {
return []Era{
{
Year: 277,
@@ -267,39 +273,47 @@ func wuEras() []Era {
Dynasty: "吴",
},
}
}
})
func innerSolarToLunarSanGuo(date Time) Time {
year := date.solarTime.Year()
month := int(date.solarTime.Month())
day := date.solarTime.Day()
// 农历年早于年号表首年(如 221 年初尚属农历 220 年)时 eras 为空,
// 该候选与其它候选日期完全相同且无朝代,不再追加。
if year >= 221 && year <= 263 {
lyear, lmonth, ganzhiMonth, lday, isLeap, ldesc := rapidLunarHan2Qing(year, month, day, 0, shuCals)
date.lunars = append(date.lunars, LunarTime{
solarDate: date.solarTime,
year: lyear,
month: lmonth,
day: lday,
leap: isLeap,
desc: ldesc,
comment: "",
ganzhiMonth: ganzhiMonth,
eras: innerEras(lyear, shuEras),
})
eras := innerEras(lyear, shuEras)
if len(eras) > 0 {
date.lunars = append(date.lunars, LunarTime{
solarDate: date.solarTime,
year: lyear,
month: lmonth,
day: lday,
leap: isLeap,
desc: ldesc,
comment: "",
ganzhiMonth: ganzhiMonth,
eras: eras,
})
}
}
if year >= 222 && year <= 280 {
lyear, lmonth, ganzhiMonth, lday, isLeap, ldesc := rapidLunarHan2Qing(year, month, day, 0, wuCals)
date.lunars = append(date.lunars, LunarTime{
solarDate: date.solarTime,
year: lyear,
month: lmonth,
day: lday,
leap: isLeap,
desc: ldesc,
ganzhiMonth: ganzhiMonth,
comment: "",
eras: innerEras(lyear, wuEras),
})
eras := innerEras(lyear, wuEras)
if len(eras) > 0 {
date.lunars = append(date.lunars, LunarTime{
solarDate: date.solarTime,
year: lyear,
month: lmonth,
day: lday,
leap: isLeap,
desc: ldesc,
ganzhiMonth: ganzhiMonth,
comment: "",
eras: eras,
})
}
}
return date
}
+71 -42
View File
@@ -1,5 +1,47 @@
package calendar
import "sync"
// memoEras 缓存只读的年号表:文本入口每次解析都会重建这些表。
func memoEras(build func() []Era) func() []Era {
var once sync.Once
var eras []Era
return func() []Era {
once.Do(func() { eras = build() })
return eras
}
}
// memoEraMap 缓存只读的年号映射表。
func memoEraMap(build func() map[string][][]int) func() map[string][][]int {
var once sync.Once
var m map[string][][]int
return func() map[string][][]int {
once.Do(func() { m = build() })
return m
}
}
// memoUint32s 缓存只读的打包历表(如 2018 项的汉清年表)。
func memoUint32s(build func() []uint32) func() []uint32 {
var once sync.Once
var table []uint32
return func() []uint32 {
once.Do(func() { table = build() })
return table
}
}
// memoUint32Map 缓存只读的年份→magic 映射表。
func memoUint32Map(build func() map[int]uint32) func() map[int]uint32 {
var once sync.Once
var table map[int]uint32
return func() map[int]uint32 {
once.Do(func() { table = build() })
return table
}
}
type Era struct {
//完整农历年的公历年映射
Year int
@@ -47,46 +89,32 @@ func innerEras(year int, eraSource func() []Era) []EraDesc {
}
break
}
// 表内只有一条命中、且它恰是 year 年新起年号时,该条会落在 secEra,firstEra 仍为零值;
// 因此按槽位是否为 Year==0 判断“无命中”,不能只看 firstEra。
var result []EraDesc
nianHao := firstEra.Nianhao
if firstEra.OtherNianHaoStart != "" {
nianHao = firstEra.OtherNianHaoStart
}
result = append(result, EraDesc{
YearOfNianHao: year - firstEra.Year + 1 + firstEra.Offset,
Emperor: firstEra.Emperor,
Nianhao: nianHao,
Dynasty: firstEra.Dynasty,
})
nianHao = secEra.Nianhao
if secEra.OtherNianHaoStart != "" {
nianHao = secEra.OtherNianHaoStart
}
if secEra.Year != 0 {
for _, e := range []Era{firstEra, secEra, thirdEra} {
if e.Year == 0 {
continue
}
nianHao := e.Nianhao
if e.OtherNianHaoStart != "" {
nianHao = e.OtherNianHaoStart
}
result = append(result, EraDesc{
YearOfNianHao: year - secEra.Year + 1 + secEra.Offset,
Emperor: secEra.Emperor,
YearOfNianHao: year - e.Year + 1 + e.Offset,
Emperor: e.Emperor,
Nianhao: nianHao,
Dynasty: secEra.Dynasty,
Dynasty: e.Dynasty,
})
}
nianHao = thirdEra.Nianhao
if thirdEra.OtherNianHaoStart != "" {
nianHao = thirdEra.OtherNianHaoStart
// 年份早于表内首个年号时无任何命中,返回 nil 由上层用年份描述兜底。
if len(result) == 0 {
return nil
}
if thirdEra.Year != 0 {
result = append(result, EraDesc{
YearOfNianHao: year - thirdEra.Year + 1 + thirdEra.Offset,
Emperor: thirdEra.Emperor,
Nianhao: nianHao,
Dynasty: thirdEra.Dynasty,
})
}
return result
}
func nianHaoMap() map[string][][]int {
var nianHaoMap = memoEraMap(func() map[string][][]int {
m := map[string][][]int{
"民国": [][]int{{1912, 1949}},
"宣统": [][]int{{1909, 1911}},
@@ -405,7 +433,7 @@ func nianHaoMap() map[string][][]int {
}
addNianHaoYouCompatAliases(m)
return m
}
})
func addNianHaoYouCompatAliases(m map[string][][]int) {
for alias, canonical := range map[string]string{
@@ -426,7 +454,7 @@ func addNianHaoYouCompatAliases(m map[string][][]int) {
}
}
func hanEras() []Era {
var hanEras = memoEras(func() []Era {
return []Era{
{
Year: 220,
@@ -861,9 +889,9 @@ func hanEras() []Era {
Dynasty: "西汉",
},
}
}
})
func weiJinNanBeiChaoEras() []Era {
var weiJinNanBeiChaoEras = memoEras(func() []Era {
return []Era{
{
Year: 617,
@@ -1224,6 +1252,7 @@ func weiJinNanBeiChaoEras() []Era {
Year: 304,
Emperor: "晋惠帝",
OtherNianHaoStart: "永兴",
Dynasty: "西晋",
},
{
Year: 302,
@@ -1340,9 +1369,9 @@ func weiJinNanBeiChaoEras() []Era {
Dynasty: "东汉",
},
}
}
})
func tangEras() []Era {
var tangEras = memoEras(func() []Era {
return []Era{
{
Year: 904,
@@ -1795,9 +1824,9 @@ func tangEras() []Era {
Dynasty: "隋",
},
}
}
})
func wudaiSongYuanEras() []Era {
var wudaiSongYuanEras = memoEras(func() []Era {
return []Era{
{
Year: 1341,
@@ -2305,9 +2334,9 @@ func wudaiSongYuanEras() []Era {
Dynasty: "唐",
},
}
}
})
func mingQingEras() []Era {
var mingQingEras = memoEras(func() []Era {
return []Era{
{
Year: 1949,
@@ -2496,4 +2525,4 @@ func mingQingEras() []Era {
Dynasty: "元",
},
}
}
})
+80
View File
@@ -0,0 +1,80 @@
package calendar
import (
"strings"
"testing"
)
// 年份早于年号表首个条目时,innerEras 曾追加零值 EraDesc,描述退化成空年号的“年十月廿五”。
// 兜底档(≤ −104 送进明清表)与三国/南北朝/南明的补充分支都会命中,这里锁住修复后的不变量:
// 任何农历记录的年号描述都非空,且不会以“年”开头(即不存在空年号)。
// When a year precedes the first entry of an era table, innerEras used to append a zero-valued
// EraDesc and the description degenerated to an empty era name ("年十月廿五"). The catch-all branch
// (years <= -104 sent to the Ming/Qing table) and the Three Kingdoms / Northern-Southern dynasties /
// Southern Ming supplements all hit it. These assertions pin the invariant after the fix: every lunar
// record has a non-empty era description that never starts with "年" alone.
func TestEraDescriptionNeverEmpty(t *testing.T) {
cases := []struct {
year, month, day int
primaryEra string
}{
{-104, 12, 20, "前一零五年"}, // 报告里的样例:太初改历交接年
{-103, 1, 5, "前一零五年"}, // 同上,跨公历年
{-104, 1, 1, "元封六年"}, // 同一年里年号仍正常
{221, 1, 5, "延康元年"}, // 三国补充分支(农历年 220 早于魏表首年)
{384, 1, 5, "太元八年"}, // 南北朝补充分支
{397, 1, 5, "太元二十一年"}, // 北凉补充分支
{534, 1, 5, "中大通五年"}, // 东魏北齐补充分支
{1647, 1, 5, "顺治三年"}, // 南明补充分支(农历年 1646 早于永历表首年)
{1648, 1, 5, "顺治四年"},
{1912, 12, 25, "民国元年"},
}
for _, c := range cases {
fwd, err := SolarToLunarByYMD(c.year, c.month, c.day)
if err != nil {
t.Fatalf("SolarToLunarByYMD(%d,%d,%d) error: %v", c.year, c.month, c.day, err)
}
if len(fwd.Lunars()) == 0 {
t.Fatalf("%d-%02d-%02d: no lunar records", c.year, c.month, c.day)
}
for i, l := range fwd.Lunars() {
for _, desc := range l.LunarDescWithDynastyAndEmperor() {
if strings.TrimSpace(desc) == "" || strings.HasPrefix(strings.TrimSpace(desc), "年") {
t.Errorf("%d-%02d-%02d record %d: empty-era description %q", c.year, c.month, c.day, i, desc)
}
}
for _, info := range l.LunarInfo() {
if strings.TrimSpace(info.EraDesc) == "" || strings.TrimSpace(info.EraDesc) == "年" {
t.Errorf("%d-%02d-%02d record %d: empty era description %q", c.year, c.month, c.day, i, info.EraDesc)
}
if strings.TrimSpace(info.LunarWithEraDesc) == "" || strings.HasPrefix(strings.TrimSpace(info.LunarWithEraDesc), "年") {
t.Errorf("%d-%02d-%02d record %d: empty-era description %q", c.year, c.month, c.day, i, info.LunarWithEraDesc)
}
}
}
if got := fwd.LunarInfo()[0].EraDesc; got != c.primaryEra {
t.Errorf("%d-%02d-%02d primary era = %q, want %q", c.year, c.month, c.day, got, c.primaryEra)
}
}
}
// 抽样式全库扫描:跨朝代边界的年份最容易踩到零值年号,这里按区间取样确认没有空年号描述。
// A sampled sweep: years at dynasty boundaries are the ones that hit the zero-valued era, so sample
// each band and confirm no record carries an empty era description.
func TestEraDescriptionSampledSweep(t *testing.T) {
for year := -200; year <= 1920; year += 3 {
for _, md := range [][2]int{{1, 5}, {5, 20}, {11, 25}} {
fwd, err := SolarToLunarByYMD(year, md[0], md[1])
if err != nil {
continue
}
for i, l := range fwd.Lunars() {
for _, info := range l.LunarInfo() {
if strings.TrimSpace(info.EraDesc) == "" || strings.TrimSpace(info.EraDesc) == "年" {
t.Fatalf("%d-%02d-%02d record %d: empty era description (desc %q)", year, md[0], md[1], i, info.LunarWithEraDesc)
}
}
}
}
}
}
+102
View File
@@ -0,0 +1,102 @@
package calendar
import (
"testing"
)
// 年号表的每条记录都必须带朝代,否则 LunarInfo.Dynasty 会随该条记录变空。
// Every era-table record must carry a dynasty, otherwise LunarInfo.Dynasty goes empty for that record.
func TestEraTablesCarryDynasty(t *testing.T) {
tables := map[string][]Era{
"hanEras": hanEras(),
"weiJinNanBeiChaoEras": weiJinNanBeiChaoEras(),
"tangEras": tangEras(),
"wudaiSongYuanEras": wudaiSongYuanEras(),
"mingQingEras": mingQingEras(),
"liaoJinYuanEras": liaoJinYuanEras(),
"houQinEras": houQinEras(),
"weiZhouSuiEras": weiZhouSuiEras(),
"beiLiangEras": beiLiangEras(),
"dongWeiBeiQiEras": dongWeiBeiQiEras(),
"shuEras": shuEras(),
"wuEras": wuEras(),
"nanMingEras01": nanMingEras01(),
"nanMingEras02": nanMingEras02(),
"zhouAncientEras": zhouAncientEras,
"luAncientEras": luAncientEras,
"qinWarringAncientEras": qinWarringAncientEras,
"qinHanAncientEras": qinHanAncientEras,
}
for name, eras := range tables {
for i, e := range eras {
if e.Dynasty == "" {
t.Errorf("%s[%d]: Year=%d Emperor=%q Nianhao=%q OtherNianHaoStart=%q has empty Dynasty", name, i, e.Year, e.Emperor, e.Nianhao, e.OtherNianHaoStart)
}
}
}
}
func TestXijinYongxingDynasty(t *testing.T) {
got, err := SolarToLunarByYMD(304, 6, 1)
if err != nil {
t.Fatalf("SolarToLunarByYMD(304,6,1) error: %v", err)
}
info := got.LunarInfo()
if len(info) == 0 {
t.Fatal("no lunar info")
}
for _, li := range info {
if li.Nianhao == "永兴" && li.Dynasty != "西晋" {
t.Errorf("永兴 record dynasty = %q, want 西晋", li.Dynasty)
}
}
if got.Lunar().Eras()[0].Dynasty != "西晋" {
t.Errorf("primary dynasty = %q, want 西晋", got.Lunar().Eras()[0].Dynasty)
}
}
// 补充年号分支在农历年早于该表首年时,曾追加一条与主流日期完全相同却没有朝代的候选。
func TestSupplementLunarsCarryDynasty(t *testing.T) {
for _, d := range [][3]int{
{221, 1, 5}, {222, 1, 5}, {384, 1, 5}, {397, 1, 5}, {534, 1, 5},
{1647, 1, 1}, {1648, 1, 1},
} {
got, err := SolarToLunarByYMD(d[0], d[1], d[2])
if err != nil {
t.Fatalf("%v error: %v", d, err)
}
for i, l := range got.Lunars() {
if len(l.Eras()) == 0 {
t.Errorf("%v record %d: no era", d, i)
}
for _, info := range l.LunarInfo() {
if info.Dynasty == "" {
t.Errorf("%v record %d: empty dynasty (%q)", d, i, info.EraDesc)
}
}
}
}
}
// 永历年号表只有一条记录,曾因 innerEras 把“年==表首年”的唯一命中放进 secEra 而整条丢失。
func TestYongliFirstYear(t *testing.T) {
for _, d := range [][3]int{{1647, 6, 1}, {1648, 1, 1}} {
got, err := SolarToLunarByYMD(d[0], d[1], d[2])
if err != nil {
t.Fatalf("%v error: %v", d, err)
}
found := false
for _, info := range got.LunarInfo() {
if info.Nianhao != "永历" {
continue
}
found = true
if info.Dynasty != "南明" || info.Emperor != "南明/明郑" || info.YearOfNianhao != 1 {
t.Errorf("%v: 永历 record = %+v, want 南明/明郑 永历元年", d, info)
}
}
if !found {
t.Errorf("%v: 永历 record missing", d)
}
}
}
+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)
}
}
}
}
+99
View File
@@ -0,0 +1,99 @@
package calendar
import (
"testing"
"time"
)
// 辽/金/元历修正表曾漏掉 9 个年份,其中缺 1093 会让辽历 1094-01-19 走进第 13 个月并越界 panic。
func TestLiaoJinYuanCorrectionTableIsComplete(t *testing.T) {
want := map[int]uint32{
993: 1767184896,
1000: 2774542080,
1074: 3040878080,
1093: 1531977216,
1102: 1523258624,
1103: 1456482304,
1175: 633116928,
1208: 3047822080,
1252: 3600820736,
}
table := liaoJinYuanCals()
for year, magic := range want {
got, ok := table[year]
if !ok {
t.Fatalf("liaoJinYuanCals() 缺少 %d 年(源表 y%d 有修正)", year, year)
}
if got != magic {
t.Fatalf("liaoJinYuanCals()[%d] = %d, want %d", year, got, magic)
}
}
if len(table) != 72 {
t.Fatalf("liaoJinYuanCals() 行数 = %d, want 72(与补全后的表行数一致)", len(table))
}
}
// 缺行年份的岁末/年初边界:既要不再 panic,也要给出源表推出的那一天。
// Year boundaries of the years that used to be missing: they must not panic and must report
// the day the source table implies.
func TestSolarToLunarNorthernCalendarYearBoundaries(t *testing.T) {
cases := []struct {
year, month, day int
want string
}{
{1094, 1, 19, "辽 大安九年腊月三十"}, // 缺 1093 行:旧实现 panic(index out of range [13])
{1176, 2, 12, "金 大定十五年腊月三十"}, // 缺 1175 行:旧实现回退"无法获取农历信息"
{1209, 2, 6, "金 泰和八年腊月三十"}, // 缺 1208 行:同上
{1252, 2, 11, "元 元宪宗二年正月初一"}, // 缺 1252 行:同上
}
for _, tc := range cases {
date := time.Date(tc.year, time.Month(tc.month), tc.day, 0, 0, 0, 0, getCst())
got, err := SolarToLunar(date)
if err != nil {
t.Fatalf("SolarToLunar(%s) err=%v", date.Format("2006-01-02"), err)
}
if !containsLine(got.LunarDescWithDynasty(), tc.want) {
t.Fatalf("SolarToLunar(%s) = %v, want a line %q",
date.Format("2006-01-02"), got.LunarDescWithDynasty(), tc.want)
}
}
}
// 缺行年份还会让整月错位 30 天(旧表把闰月当普通月)。这里钉住错位段的头一天。
// Missing rows also shifted a whole month by 30 days (the old table treated a leap month as
// an ordinary one). Pin the first day of each shifted stretch.
func TestSolarToLunarNorthernCalendarMonthAlignment(t *testing.T) {
cases := []struct {
year, month, day int
want string
}{
{1102, 11, 11, "辽 乾统二年九月廿九"},
{1102, 11, 12, "辽 乾统二年九月三十"}, // 旧表:十月初一
{1102, 11, 13, "辽 乾统二年十月初一"},
{1103, 9, 2, "辽 乾统三年七月廿九"},
{1103, 9, 3, "辽 乾统三年七月三十"}, // 旧表:八月初一
{1103, 9, 4, "辽 乾统三年八月初一"},
{1252, 2, 10, "元 元宪宗元年腊月廿九"},
{1252, 2, 11, "元 元宪宗二年正月初一"}, // 旧表:回退串
}
for _, tc := range cases {
date := time.Date(tc.year, time.Month(tc.month), tc.day, 0, 0, 0, 0, getCst())
got, err := SolarToLunar(date)
if err != nil {
t.Fatalf("SolarToLunar(%s) err=%v", date.Format("2006-01-02"), err)
}
if !containsLine(got.LunarDescWithDynasty(), tc.want) {
t.Fatalf("SolarToLunar(%s) = %v, want a line %q",
date.Format("2006-01-02"), got.LunarDescWithDynasty(), tc.want)
}
}
}
func containsLine(lines []string, want string) bool {
for _, line := range lines {
if line == want {
return true
}
}
return false
}
+69
View File
@@ -0,0 +1,69 @@
package calendar
import (
"testing"
"time"
)
func benchTime(tb testing.TB, y, m, d int) time.Time {
tb.Helper()
return time.Date(y, time.Month(m), d, 0, 0, 0, 0, getCst())
}
func BenchmarkSolarToLunarHanQing(b *testing.B) {
date := benchTime(b, 1000, 7, 7)
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if _, err := SolarToLunar(date); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkSolarToLunarModern(b *testing.B) {
date := benchTime(b, 2025, 1, 1)
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if _, err := SolarToLunar(date); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkLunarToSolarText(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if _, err := LunarToSolar("元丰六年十月十二"); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkLunarToSolarByYMD(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if _, err := LunarToSolarByYMD(1083, 10, 12, false); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkTimeAdd(b *testing.B) {
t, err := LunarToSolarByYMD(2024, 1, 1, false)
if err != nil {
b.Fatal(err)
}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = t.Add(time.Hour)
}
}
func BenchmarkCalendricalJieQi(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if _, err := CalendricalJieQi(1000, JQ_冬至); err != nil {
b.Fatal(err)
}
}
}
+166
View File
@@ -0,0 +1,166 @@
package calendar
import (
"fmt"
"testing"
"time"
)
// 1582 改历:反向的 +10 天窗口必须与正向的 −10 天修正对称(1582-10-05..1583-01-13)。
// formatAncientSolar 统一负年份的显示。
func formatAncientSolar(t time.Time) string {
year, sign := t.Year(), ""
if year < 0 {
year, sign = -year, "-"
}
return fmt.Sprintf("%s%04d-%02d-%02d", sign, year, int(t.Month()), t.Day())
}
func TestGregorianReformReverseMatchesForward(t *testing.T) {
cases := []struct {
ly, lm, ld int
leap bool
want string
}{
{1582, 12, 8, false, "1583-01-01"}, // 连续坐标 1582-12-22,原有 1582 规则覆盖
{1582, 12, 18, false, "1583-01-11"}, // 连续坐标 1583-01-01,修复前返回 1583-01-01
{1582, 12, 30, false, "1583-01-23"}, // 连续坐标 1583-01-13,修复前返回 1583-01-13
{1583, 1, 1, false, "1583-01-24"}, // 连续坐标 1583-01-14,本就不该 +10
}
for _, tc := range cases {
got, err := LunarToSolarByYMD(tc.ly, tc.lm, tc.ld, tc.leap)
if err != nil {
t.Fatalf("LunarToSolarByYMD(%d,%d,%d) err=%v", tc.ly, tc.lm, tc.ld, err)
}
if s := got.Solar().Format("2006-01-02"); s != tc.want {
t.Fatalf("LunarToSolarByYMD(%d,%d,%d) = %s, want %s", tc.ly, tc.lm, tc.ld, s, tc.want)
}
}
// 改历前后整段往返一致(跳过被删除的 1582-10-05..14,它们在公历里不存在)。
// The whole span round-trips apart from the ten days the reform deleted.
for day := time.Date(1582, 10, 15, 0, 0, 0, 0, getCst()); day.Before(time.Date(1583, 3, 1, 0, 0, 0, 0, getCst())); day = day.AddDate(0, 0, 1) {
y, m, d := day.Year(), int(day.Month()), day.Day()
lunar, err := SolarToLunarByYMD(y, m, d)
if err != nil {
t.Fatalf("SolarToLunarByYMD(%d,%d,%d) err=%v", y, m, d, err)
}
l := lunar.Lunar()
back, err := LunarToSolarByYMD(l.LunarYear(), l.LunarMonth(), l.LunarDay(), l.IsLeap())
if err != nil {
t.Fatalf("LunarToSolarByYMD(%d,%d,%d) err=%v", l.LunarYear(), l.LunarMonth(), l.LunarDay(), err)
}
if s := back.Solar(); s.Year() != y || int(s.Month()) != m || s.Day() != d {
t.Fatalf("%d-%02d-%02d → 农历 %d/%d/%d → 反向 %s(改历往返不一致)",
y, m, d, l.LunarYear(), l.LunarMonth(), l.LunarDay(), s.Format("2006-01-02"))
}
}
}
// 先秦岁首跨界:正向按公历年选默认古历、反向按农历年选,交界处必须自洽。
// 农历 -479 年正月落在公历 -480 年 12 月(春秋历),此前反向按农历年选了周历,整体差 1 天。
// The pre-Qin year-start crossing: the forward picks the default ancient calendar by civil
// year while the reverse picks by lunar year. Lunar year -479 month 1 falls in civil December
// -480 (Chunqiu calendar); the reverse used to pick Zhou and shift those 20 days by one day.
func TestAncientYearStartCrossingRoundTrips(t *testing.T) {
cases := []struct {
ly, lm, ld int
want string
}{
{-479, 1, 1, "-0480-12-12"},
{-479, 1, 20, "-0480-12-31"},
{-479, 1, 22, "-0479-01-01"},
{-479, 1, 29, "-0479-01-08"},
}
for _, tc := range cases {
got, err := LunarToSolarByYMD(tc.ly, tc.lm, tc.ld, false)
if err != nil {
t.Fatalf("LunarToSolarByYMD(%d,%d,%d) err=%v", tc.ly, tc.lm, tc.ld, err)
}
if s := formatAncientSolar(got.Solar()); s != tc.want {
t.Fatalf("LunarToSolarByYMD(%d,%d,%d) = %s, want %s", tc.ly, tc.lm, tc.ld, s, tc.want)
}
}
// 该农历日在正向可达的范围内没有对应公历日(春秋历说是 -479-01-01、周历说是 -480-12-31,
// 而正向对这两个公历日分别给出周历 1/22 与春秋历 1/20),默认口径必须报错而不是给错日子。
// That lunar day has no civil date in the default frame (the forward maps -479-01-01 to Zhou
// 1/22 and -480-12-31 to Chunqiu 1/20), so the default entry point must report an error.
if _, err := LunarToSolarByYMD(-479, 1, 21, false); err == nil {
t.Fatal("LunarToSolarByYMD(-479,1,21) 应报错:该农历日在默认历法口径下没有公历日")
}
// 交界窗口往返一致。
// The junction window round-trips.
for day := time.Date(-480, 12, 1, 0, 0, 0, 0, getCst()); day.Before(time.Date(-479, 2, 1, 0, 0, 0, 0, getCst())); day = day.AddDate(0, 0, 1) {
y, m, d := day.Year(), int(day.Month()), day.Day()
lunar, err := SolarToLunarByYMD(y, m, d)
if err != nil {
continue
}
l := lunar.Lunar()
back, err := LunarToSolarByYMD(l.LunarYear(), l.LunarMonth(), l.LunarDay(), l.IsLeap())
if err != nil {
t.Fatalf("%d-%02d-%02d → 农历 %d/%d/%d → 反向 err=%v", y, m, d, l.LunarYear(), l.LunarMonth(), l.LunarDay(), err)
}
if s := back.Solar(); s.Year() != y || int(s.Month()) != m || s.Day() != d {
t.Fatalf("%d-%02d-%02d → 农历 %d/%d/%d → 反向 %s(岁首跨界往返不一致)",
y, m, d, l.LunarYear(), l.LunarMonth(), l.LunarDay(), formatAncientSolar(s))
}
}
}
// 王莽改历的 23/24 年交界:同一套月序偏移会把"腊月三十"反解到腊月初一(标签碰撞),
// 此时应改用另一套偏移下能正向回到该农历日的那一天,并把主答案换成它。
// At the Wang Mang 23/24 boundary the primary month offset resolves "12th month, day 30" onto the
// 1st of that month (a label collision); the reverse must switch to the offset whose civil day
// converts forward to the requested lunar date and make that day the primary answer.
func TestWangMangBoundaryReverseIsSelfConsistent(t *testing.T) {
cases := []struct {
ly, lm, ld int
wantSolar string
selfConsist bool
}{
{23, 12, 29, "0023-12-30", true},
{23, 12, 30, "0024-01-29", true},
{24, 1, 1, "0024-01-30", true},
// 23/12/31 在两套月序下都没有对应的公历日(该年有两个腊月,第二个腊月只有 30 天),
// 保持既有行为返回偏移 1 的结果,只断言主答案不变成空值。
{23, 12, 31, "0024-01-01", false},
}
for _, c := range cases {
res, err := LunarToSolarByYMD(c.ly, c.lm, c.ld, false)
if err != nil {
t.Fatalf("LunarToSolarByYMD(%d,%d,%d) error: %v", c.ly, c.lm, c.ld, err)
}
if got := formatAncientSolar(res.Solar()); got != c.wantSolar {
t.Errorf("lunar %d/%d/%d -> %s, want %s", c.ly, c.lm, c.ld, got, c.wantSolar)
}
if !c.selfConsist {
continue
}
fwd, err := SolarToLunarByYMD(res.Solar().Year(), int(res.Solar().Month()), res.Solar().Day())
if err != nil {
t.Fatalf("SolarToLunarByYMD(%s) error: %v", c.wantSolar, err)
}
l := fwd.Lunar()
if l.LunarYear() != c.ly || l.LunarMonth() != c.lm || l.LunarDay() != c.ld || l.IsLeap() {
t.Errorf("lunar %d/%d/%d -> %s converts forward to %d/%d/%d",
c.ly, c.lm, c.ld, c.wantSolar, l.LunarYear(), l.LunarMonth(), l.LunarDay())
}
if res.JDE() != fwd.JDE() {
t.Errorf("lunar %d/%d/%d: JDE=%.1f, forward gives %.1f", c.ly, c.lm, c.ld, res.JDE(), fwd.JDE())
}
for _, cand := range res.SolarCandidates() {
candFwd, err := SolarToLunarByYMD(cand.Year(), int(cand.Month()), cand.Day())
if err != nil {
t.Errorf("lunar %d/%d/%d candidate %s does not convert: %v", c.ly, c.lm, c.ld, formatAncientSolar(cand), err)
continue
}
cl := candFwd.Lunar()
if cl.LunarYear() != c.ly || cl.LunarMonth() != c.lm || cl.LunarDay() != c.ld || cl.IsLeap() {
t.Errorf("lunar %d/%d/%d candidate %s converts to %d/%d/%d",
c.ly, c.lm, c.ld, formatAncientSolar(cand), cl.LunarYear(), cl.LunarMonth(), cl.LunarDay())
}
}
}
}
+118
View File
@@ -0,0 +1,118 @@
package calendar
import (
"testing"
"time"
)
// L3 契约:Solar() 保持库内默认选择,SolarCandidates() 给出全部合法候选(主答案在首),
// 且每个候选都能正向回到该农历日。
func TestSolarCandidatesForReformWindows(t *testing.T) {
cases := []struct {
year, month, day int
leap bool
want []string
}{
{762, 4, 1, false, []string{"0762-03-01", "0762-04-29"}}, // 唐肃宗改制
{700, 11, 1, false, []string{"0700-12-15", "0700-10-17"}}, // 武则天改制
{700, 12, 1, false, []string{"0700-11-15", "0701-01-14"}}, // 武则天改制
{23, 12, 1, false, []string{"0023-12-02", "0023-12-31"}}, // 王莽改制
{239, 12, 1, false, []string{"0239-12-13", "0240-01-12"}}, // 魏明帝改制
{-104, 10, 1, false, []string{"-0104-11-26", "-0105-11-08"}}, // 太初改历交接
}
for _, c := range cases {
res, err := LunarToSolarByYMD(c.year, c.month, c.day, c.leap)
if err != nil {
t.Fatalf("LunarToSolarByYMD(%d,%d,%d,%v) error: %v", c.year, c.month, c.day, c.leap, err)
}
candidates := res.SolarCandidates()
if len(candidates) != len(c.want) {
t.Errorf("%d/%d/%d: got %d candidates %v, want %d %v",
c.year, c.month, c.day, len(candidates), formatCandidates(candidates), len(c.want), c.want)
continue
}
if !candidates[0].Equal(res.Solar()) {
t.Errorf("%d/%d/%d: first candidate %s is not Solar() %s",
c.year, c.month, c.day, formatAncientSolar(candidates[0]), formatAncientSolar(res.Solar()))
}
for i, v := range candidates {
if got := formatAncientSolar(v); got != c.want[i] {
t.Errorf("%d/%d/%d: candidate %d = %s, want %s", c.year, c.month, c.day, i, got, c.want[i])
}
for j := 0; j < i; j++ {
if candidates[j].Equal(v) {
t.Errorf("%d/%d/%d: candidate %d duplicates candidate %d (%s)",
c.year, c.month, c.day, i, j, formatAncientSolar(v))
}
}
fwd, err := SolarToLunarByYMD(v.Year(), int(v.Month()), v.Day())
if err != nil {
t.Errorf("%d/%d/%d: candidate %s does not convert forward: %v",
c.year, c.month, c.day, formatAncientSolar(v), err)
continue
}
l := fwd.Lunar()
if l.LunarYear() != c.year || l.LunarMonth() != c.month || l.LunarDay() != c.day || l.IsLeap() != c.leap {
t.Errorf("%d/%d/%d: candidate %s converts back to lunar %d/%d/%d leap=%v",
c.year, c.month, c.day, formatAncientSolar(v),
l.LunarYear(), l.LunarMonth(), l.LunarDay(), l.IsLeap())
}
}
}
}
// 改历窗口外只有一个候选,且 SolarCandidates() 返回的是副本。
func TestSolarCandidatesAreSingleOutsideReformWindows(t *testing.T) {
cases := []struct{ year, month, day int }{
{763, 4, 1}, {701, 11, 1}, {24, 12, 1}, {241, 12, 1}, {-103, 10, 1},
{-105, 10, 1}, {2025, 1, 1}, {1912, 1, 1}, {1000, 7, 7}, {1, 1, 1},
}
for _, c := range cases {
res, err := LunarToSolarByYMD(c.year, c.month, c.day, false)
if err != nil {
t.Fatalf("LunarToSolarByYMD(%d,%d,%d) error: %v", c.year, c.month, c.day, err)
}
candidates := res.SolarCandidates()
if len(candidates) != 1 {
t.Errorf("%d/%d/%d: got %d candidates %v, want a single one",
c.year, c.month, c.day, len(candidates), formatCandidates(candidates))
continue
}
if !candidates[0].Equal(res.Solar()) {
t.Errorf("%d/%d/%d: single candidate %s is not Solar() %s",
c.year, c.month, c.day, formatAncientSolar(candidates[0]), formatAncientSolar(res.Solar()))
}
// 返回值必须是副本。
candidates[0] = candidates[0].AddDate(1, 1, 1)
if again := res.SolarCandidates(); !again[0].Equal(res.Solar()) {
t.Errorf("%d/%d/%d: SolarCandidates() is not a copy (%s)", c.year, c.month, c.day, formatAncientSolar(again[0]))
}
}
}
// 1582 改历跳过的十天本身不存在,与 L2/L3 无关。
func TestGregorianSkippedDaysHaveNoCandidates(t *testing.T) {
for day := 5; day <= 14; day++ {
if _, err := SolarToLunarByYMD(1582, 10, day); err == nil {
t.Errorf("SolarToLunarByYMD(1582,10,%d) unexpectedly succeeded", day)
}
}
res, err := LunarToSolarByYMD(1582, 9, 18, false)
if err != nil {
t.Fatalf("LunarToSolarByYMD(1582,9,18) error: %v", err)
}
if got := len(res.SolarCandidates()); got != 1 {
t.Errorf("lunar 1582/9/18 has %d candidates, want 1", got)
}
if _, err := time.Parse("2006-01-02", formatAncientSolar(res.Solar())); err != nil {
t.Errorf("solar label %q is not a valid date", formatAncientSolar(res.Solar()))
}
}
func formatCandidates(candidates []time.Time) []string {
out := make([]string, 0, len(candidates))
for _, v := range candidates {
out = append(out, formatAncientSolar(v))
}
return out
}
+75 -12
View File
@@ -32,6 +32,10 @@ type LunarInfo struct {
CalendarSystem AncientCalendarSystem `json:"calendarSystem"`
// CalendarName 历法名称
CalendarName string `json:"calendarName"`
// JDE 该农历日精确的儒略日 / exact Julian day.
JDE float64 `json:"jde"`
// JulianOnly 是否只存在于儒略历 / Julian-calendar-only date.
JulianOnly bool `json:"julianOnly,omitempty"`
// Dynasty 朝代,如唐、宋、元、明、清等
Dynasty string `json:"dynasty"`
// Emperor 皇帝姓名(仅供参考,多个皇帝用同一个年号的场景,此处不准)
@@ -51,6 +55,8 @@ type LunarInfo struct {
type Time struct {
solarTime time.Time
lunars []LunarTime
// solarCandidates 是该农历日的全部合法公历候选,首个元素恒等于 solarTime;单候选时为 nil。
solarCandidates []time.Time
}
// Solar 公历时间 / solar time.
@@ -162,22 +168,29 @@ func (t Time) Lunar() LunarTime {
// Add 时间偏移 / add a duration.
//
// 返回公历时间偏移后的农历结果。
// Returns the lunar-calendar result after applying the duration to the stored civil time.
// 按公历时间轴精确相加;区间跨过 1582 改历空窗时改走儒略日轴,跳过不存在的 10 天。
// Adds exactly along the civil timeline; when the interval crosses the 1582 reform gap it moves
// along the Julian-day axis instead, skipping the ten days that do not exist.
func (t Time) Add(d time.Duration) Time {
if d < time.Second {
newT := t.solarTime.Add(d)
rT, _ := SolarToLunar(newT)
return rT
newT := t.solarTime.Add(d)
if crossesGregorianReformGap(t.solarTime, newT) {
jde := Date2JDE(t.solarTime) + d.Seconds()/86400.0
newT = basic.JDE2DateByZone(jde, t.solarTime.Location(), true)
}
sec := d.Seconds()
jde := Date2JDE(t.solarTime)
jde += sec / 86400.0
newT := basic.JDE2DateByZone(jde, t.solarTime.Location(), true)
rT, _ := SolarToLunar(newT)
return rT
}
// crossesGregorianReformGap 报告两个时刻之间是否包含 1582 改历跳过的 10 天(公历 1582-10-05..14)。
func crossesGregorianReformGap(from, to time.Time) bool {
start := time.Date(1582, 10, 5, 0, 0, 0, 0, from.Location())
end := time.Date(1582, 10, 15, 0, 0, 0, 0, from.Location())
if to.Before(from) {
from, to = to, from
}
return from.Before(end) && !to.Before(start)
}
type LunarTime struct {
solarDate time.Time
//农历年
@@ -200,10 +213,53 @@ type LunarTime struct {
calendarSystem AncientCalendarSystem
//历法名称
calendarName string
// julianOnly 该农历日是否只存在于儒略历(如 700-02-29);Solar 取后继日作为标签。
julianOnly bool
// jde 该农历日精确的儒略日,仅在 julianOnly 时显式保存。
jde float64
eras []EraDesc
}
// JulianOnly 该农历日是否只存在于儒略历 / whether the date exists only in the Julian calendar.
func (l LunarTime) JulianOnly() bool {
return l.julianOnly
}
// JDE 该农历日精确的儒略日;儒略历独有闰日比 Solar() 早一天 / exact Julian day.
func (l LunarTime) JDE() float64 {
if l.julianOnly && l.jde > 0 {
return l.jde
}
return Date2JDE(l.solarDate)
}
// JulianOnly 主历法农历日是否只存在于儒略历 / whether the primary date is Julian-calendar-only.
func (t Time) JulianOnly() bool {
if len(t.lunars) == 0 {
return false
}
return t.lunars[0].JulianOnly()
}
// JDE 主历法农历日精确的儒略日 / exact Julian day of the primary lunar date.
func (t Time) JDE() float64 {
if len(t.lunars) == 0 {
return Date2JDE(t.solarTime)
}
return t.lunars[0].JDE()
}
// SolarCandidates 该农历日的全部合法公历候选,首个恒等于 Solar() / every legal civil date, Solar() first.
func (t Time) SolarCandidates() []time.Time {
if len(t.solarCandidates) == 0 {
return []time.Time{t.solarTime}
}
out := make([]time.Time, len(t.solarCandidates))
copy(out, t.solarCandidates)
return out
}
// ShengXiao 生肖 / Chinese zodiac.
func (l LunarTime) ShengXiao() string {
shengxiao := []string{"猴", "鸡", "狗", "猪", "鼠", "牛", "虎", "兔", "龙", "蛇", "马", "羊"}
@@ -231,6 +287,9 @@ func (l LunarTime) GanZhiMonth() string {
// GanZhiDay 日干支 / sexagenary day name.
func (l LunarTime) GanZhiDay() string {
if l.julianOnly && l.jde > 0 {
return ganZhiOfJDE(l.jde)
}
return GanZhiOfDay(l.solarDate)
}
@@ -350,7 +409,7 @@ func (l LunarTime) LunarInfo() []LunarInfo {
LunarMonthDayDesc: l.desc,
GanzhiYear: GanZhiOfYear(l.year),
GanzhiMonth: l.ganzhiMonth,
GanzhiDay: GanZhiOfDay(l.solarDate),
GanzhiDay: l.GanZhiDay(),
CalendarSystem: l.calendarSystem,
CalendarName: l.calendarName,
Dynasty: v.Dynasty,
@@ -360,6 +419,8 @@ func (l LunarTime) LunarInfo() []LunarInfo {
EraDesc: v.String(),
LunarWithEraDesc: v.String() + l.desc,
ChineseZodiac: l.ShengXiao(),
JDE: l.JDE(),
JulianOnly: l.julianOnly,
}
res = append(res, li)
}
@@ -374,7 +435,7 @@ func (l LunarTime) LunarInfo() []LunarInfo {
LunarMonthDayDesc: l.desc,
GanzhiYear: GanZhiOfYear(l.year),
GanzhiMonth: l.ganzhiMonth,
GanzhiDay: GanZhiOfDay(l.solarDate),
GanzhiDay: l.GanZhiDay(),
CalendarSystem: l.calendarSystem,
CalendarName: l.calendarName,
Dynasty: "",
@@ -384,6 +445,8 @@ func (l LunarTime) LunarInfo() []LunarInfo {
EraDesc: lunarYearDesc(l.year) + "年",
LunarWithEraDesc: lunarYearDesc(l.year) + "年" + l.desc,
ChineseZodiac: l.ShengXiao(),
JDE: l.JDE(),
JulianOnly: l.julianOnly,
}
res = append(res, li)
}
+69
View File
@@ -0,0 +1,69 @@
package calendar
import (
"testing"
"time"
)
func addBase(t *testing.T, y, m, d int) Time {
t.Helper()
res, err := LunarToSolarByYMD(y, 1, 1, false) // 只是拿一个合法 Time,日期另行构造
_ = res
if err != nil {
t.Fatal(err)
}
fwd, err := SolarToLunarByYMD(y, m, d)
if err != nil {
t.Fatalf("SolarToLunarByYMD(%d,%d,%d) error: %v", y, m, d, err)
}
return fwd
}
// Add 必须与 time.Time 一样精确;此前 d≥1s 时经儒略日往返,普通日期会漂几微秒。
// Add must be as exact as time.Time; it used to round-trip through the Julian day for d >= 1s,
// which drifted by a few microseconds on ordinary dates.
func TestTimeAddIsExact(t *testing.T) {
base := addBase(t, 2024, 3, 1)
cases := []struct {
d time.Duration
want string
}{
{time.Millisecond, "2024-03-01 00:00:00.001"},
{time.Second, "2024-03-01 00:00:01.000"},
{time.Minute, "2024-03-01 00:01:00.000"},
{time.Hour, "2024-03-01 01:00:00.000"},
{24 * time.Hour, "2024-03-02 00:00:00.000"},
{30 * 24 * time.Hour, "2024-03-31 00:00:00.000"},
{-time.Hour, "2024-02-29 23:00:00.000"},
{-time.Millisecond, "2024-02-29 23:59:59.999"},
}
for _, c := range cases {
if got := base.Add(c.d).Solar().Format("2006-01-02 15:04:05.000"); got != c.want {
t.Errorf("Add(%v) = %s, want %s", c.d, got, c.want)
}
}
}
// 1582 改历跳过的 10 天不能出现在结果里:跨过该窗口的偏移要落在 10 天之后。
// The ten days skipped by the 1582 reform must never appear: an offset crossing that window lands
// after them.
func TestTimeAddSkipsGregorianReformGap(t *testing.T) {
cases := []struct {
y, m, d int
delta time.Duration
want string
}{
{1582, 10, 4, 24 * time.Hour, "1582-10-15"},
{1582, 10, 15, -24 * time.Hour, "1582-10-04"},
{1582, 9, 20, 30 * 24 * time.Hour, "1582-10-30"},
{1582, 10, 20, -30 * 24 * time.Hour, "1582-09-10"},
{1582, 9, 20, 24 * time.Hour, "1582-09-21"},
{1582, 10, 20, 24 * time.Hour, "1582-10-21"},
}
for _, c := range cases {
got := addBase(t, c.y, c.m, c.d).Add(c.delta).Solar().Format("2006-01-02")
if got != c.want {
t.Errorf("%d-%02d-%02d Add(%v) = %s, want %s", c.y, c.m, c.d, c.delta, got, c.want)
}
}
}