1900–2100 阴阳历互转、节气闰月与老黄历的纯 PHP 实现
本文面向要在老宝塔环境里维护存量 PHP 项目的后端。不讲 Composer 怎么装包,只讲那些能让你在接手别人万年历代码时被追问「这表到底哪来的」的硬核实现(这这这,世界上最好的代码~~之一)。
上个月要把万年历组件塞进公司内网系统,需求很朴素:左边公历、右边农历,下面挂老黄历的宜忌和节气。翻出三年前那份几百行的代码,同一个 bug 修过两遍,于是打算重写。结果发现三个绕不开的现实:
| 限制 | 说明 |
|---|---|
| 不能装扩展 | 生产机是老宝塔环境,calendar 扩展常年没开,cal_days_in_month() 用了就是 fatal |
| 不能上 Composer | 内网镜像拉不通,第三方农历库(lunar-php 之类)直接出局 |
| 老黄历是硬需求 | 用户是模具车间,选日子要看宜忌、看冲煞、看节气,光有农历月日不够 |
于是目标定死:一个文件、零 require、零扩展、零依赖,覆盖 1900-01-01 ~ 2100-12-31 全部 73414 天。
顺带说一句:这不是「抄一份公式就能跑通」的活儿。我把 Meeus《Astronomical Algorithms》从头抄到尾,反被数据打了三次脸,最后落地的方案是——天文部分只用来讲原理和兜底,逐日精确交给两张 22KB 以内的定长表。
第一次重写,我老老实实把月亮视黄经(Meeus 表 47.B 前 24 项)抄进 PHP,二分法求朔,觉得 months 就能顺推出来。于是写了脚本,跟 lunar-javascript、lunardate、pyephem 三路对拍,数据很难看:
// 纯天文方案,1900-2100 逐日对拍结果
朔时刻误差 中位数 6.01 分钟 / P90 13.06 / P99 18.46
农历初一差 1 天 1011 天(约 1% 的月份)
2033 年 算出「闰七月 + 闰十月」双闰,官方只有闰十一月元凶很具体:有些月份的朔发生在北京时间接近午夜(2023 年 5 月朔 = 5 月 19 日 23:54 北京),6 分钟误差直接把初一推过零点,整个月错位。2033 那个双闰更离谱——闰月判定被累积误差带偏,多插了一个月。
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| 纯天文 | 数据量为 0,代码自洽 | 200 年差 1011 天,闰月算错 | 淘汰 |
| 纯数据表 | 逐日精确,极快 | 换个历法就得换表 | 不完全 |
| 表驱动 + 天文兜底 | 表内 100% 精确,表外仍有天文能力 | 两张表 22KB | 采用 |
编码规则是 两位一组:[闰位][天位]——闰位 1 = 闰月、0 = 平月;天位 0 = 30 天、9 = 29 天。再加一个「正月初一距 1900-01-01 多少天」,一个农历年就描述完了:
1900 => [30, '09000909000900001900000900'],
2025 => [45684, '00090009090019000900000009'],
2026 => [46068, '000900090900090900000009'],
2033 => [48608, '09000909000900090000001900'],
2100 => [73088, '000009000900090009090009'],完整的 201 行在下面,直接粘:
<?php
/**
* 零依赖 PHP 万年历 v2
* ---------------------------------------------------------------
* 只依赖 PHP 内置函数,无扩展、无数据库、无 Composer。
* 能力:公历 <-> 农历互转(1900-2100)、节气中气、闰月、属相干支、老黄历宜忌。
*
* 设计取舍:
* 1. 农历「哪一天是初一」由 1900-2100 紧凑月表给出(表驱动,逐日精确,不碰天文);
* 2. 24 节气日期由 1900-2100 紧凑节气表给出(同样是实测数据,临界日不会错);
* 3. 朔/月相算法(Meeus 表 47.B + 二分法)保留在天文学接口里,
* 供表外年份(1899、2101…)和「月相显示」使用,跑通原理也方便二次开发。
*
* 月表 2486 个月 / 节气 4824 条,两张表加起来不到 18KB,一次 include 全进内存。
*/
final class LunarCalendar
{
/* ==================== 常数 ==================== */
private const D2R = M_PI / 180.0;
private const J2000 = 2451545.0; // J2000.0 儒略日
private const EPOCH_K = 2451550.00001; // k = 0 的朔近似起点
private const SYNODIC = 29.530588853; // 朔望月(天)
private const UNIX_JD = 2440587.5; // 1970-01-01 00:00 UT 的 JD
private const GMT8 = 8.0 / 24.0;
private const TBL_MIN = 1900; // 月表覆盖的农历年
private const TBL_MAX = 2100;
// 公历 1900-01-01 的「北京日序号」(日序号原点在 1970-01-01,不是 1900)
private const ANCHOR = -25567;
/**
* 紧凑月表(1900-2100,共 201 行)
* key = 农历年
* [0] = 本年内「正月初一」距 1900-01-01 的天数
* [1] = 月表串,每两位一组:[闰位][天位]
* 闰位:1 = 闰月,0 = 平月
* 天位:0 = 30 天,9 = 29 天
* 第 i 组(0 起)对应时间序上的第 i+1 个月;平月序号 = i+1,闰月沿用前一个月号。
*/
private const TBL = [
1900 => [30, '09000909000900001900000900'],
1901 => [414, '090009090009000900000009'],
1902 => [768, '000900090900090009000000'],
1903 => [1123, '09000900091900090900000900'],
1904 => [1506, '000009000909000909000009'],
1905 => [1860, '000009000009090009000900'],
1906 => [2215, '09000009100900090009000900'],
1907 => [2599, '090009000900000900090009'],
1908 => [2953, '000909000009000900000900'],
1909 => [3308, '09001909000900090000000900'],
1910 => [3692, '090009090009000900000009'],
1911 => [4046, '00090009090019090000090000'],
1912 => [4430, '000900090900090900000900'],
1913 => [4784, '000009000909000909000900'],
1914 => [5138, '00000900091009000909000900'],
1915 => [5522, '000900000900090009000909'],
1916 => [5876, '000009000900000900090009'],
1917 => [6231, '00091900090000090000090009'],
1918 => [6615, '000909000900090000090000'],
1919 => [6970, '09000909000909100009000000'],
1920 => [7354, '090009090009090009000000'],
1921 => [7708, '000900090900090900090000'],
1922 => [8062, '00090000091900090900090000'],
1923 => [8446, '090000090009000909000900'],
1924 => [8800, '090000090000090009000909'],
1925 => [9154, '00090009100009000009000900'],
1926 => [9539, '090900090009000009000009'],
1927 => [9893, '000909000900090009000000'],
1928 => [10248, '09001909000909000900000000'],
1929 => [10632, '090009090009090009000000'],
1930 => [10986, '09000009090019090009000009'],
1931 => [11369, '000009000900090900090009'],
1932 => [11723, '000000090009000909000900'],
1933 => [12078, '09000009001009000900090900'],
1934 => [12462, '090009000009000900000900'],
1935 => [12817, '090900090009000009000009'],
1936 => [13171, '00090910090900000900000009'],
1937 => [13555, '000909000909000900000009'],
1938 => [13909, '00000909000909100900000900'],
1939 => [14293, '000009090009090009000900'],
1940 => [14647, '000009000900090900090009'],
1941 => [15001, '00000900000910090900090009'],
1942 => [15385, '000900000900090009000900'],
1943 => [15740, '090009000900000900090009'],
1944 => [16094, '00090009100900090000090000'],
1945 => [16479, '090900090900090000000900'],
1946 => [16833, '000909000909000900000900'],
1947 => [17187, '00001909000909000900090000'],
1948 => [17571, '000900090009090009000900'],
1949 => [17925, '00090000090009190009000900'],
1950 => [18309, '090000090000090900090009'],
1951 => [18663, '000900000900090009000900'],
1952 => [19018, '09000900091009000009000900'],
1953 => [19402, '090009090000090000090009'],
1954 => [19756, '000900090900090000090000'],
1955 => [20111, '09000910090900090009000000'],
1956 => [20495, '090009000909000900090000'],
1957 => [20849, '00090009000909001900090009'],
1958 => [21232, '000000090009090009000900'],
1959 => [21587, '090000090009000900090009'],
1960 => [21941, '00090009000019000900090009'],
1961 => [22325, '000900090009000009000900'],
1962 => [22680, '090009090009000009000009'],
1963 => [23034, '00090009190009000900000009'],
1964 => [23418, '000900090900090009000000'],
1965 => [23773, '090009000909000909000009'],
1966 => [24126, '00000019000909000909000009'],
1967 => [24510, '000009000009090009000900'],
1968 => [24865, '09000900000900190009000900'],
1969 => [25249, '090009000900000900090009'],
1970 => [25603, '000909000900000900000900'],
1971 => [25958, '09000909001900090000000900'],
1972 => [26342, '090009090009000900000900'],
1973 => [26696, '000900090900090900000900'],
1974 => [27050, '00000900190900090900000900'],
1975 => [27434, '000009000909000909000900'],
1976 => [27788, '00000900090009001909000900'],
1977 => [28172, '000900000900090009000909'],
1978 => [28526, '000900000900000900090009'],
1979 => [28881, '00090900090010090000090009'],
1980 => [29265, '000909000900090000090000'],
1981 => [29620, '090009090009090000090000'],
1982 => [29974, '00090009190009090009000000'],
1983 => [30358, '000900090900090900090000'],
1984 => [30712, '00090000090900090900190000'],
1985 => [31096, '090000090009000909000900'],
1986 => [31450, '090000090000090009000909'],
1987 => [31804, '00090009000019000009000909'],
1988 => [32188, '000900090009000009000009'],
1989 => [32543, '000909000900090009000000'],
1990 => [32898, '09000909001909000900000000'],
1991 => [33282, '090009090009090009000000'],
1992 => [33636, '090000090900090900090000'],
1993 => [33990, '09000019000900090900090009'],
1994 => [34373, '000000090009000909000900'],
1995 => [34728, '09000009000900001909000900'],
1996 => [35112, '090009000009000900000909'],
1997 => [35466, '000900090009000009000009'],
1998 => [35821, '00090900091900000900000900'],
1999 => [36205, '000909000909000900000009'],
2000 => [36559, '000009090009090009000009'],
2001 => [36913, '00000900190009090009000900'],
2002 => [37297, '000009000900090900090009'],
2003 => [37651, '000009000009000909000900'],
2004 => [38006, '09001900000900090009000900'],
2005 => [38390, '090009000900000900090009'],
2006 => [38744, '00090009000900190000090000'],
2007 => [39129, '090900090900090000000900'],
2008 => [39483, '000909000909000900000900'],
2009 => [39837, '00000909001909000900090000'],
2010 => [40221, '000900090009090009000900'],
2011 => [40575, '000900000900090900090009'],
2012 => [40929, '00090000190009000900090009'],
2013 => [41313, '000900090000090009000900'],
2014 => [41668, '09000900090009000019000900'],
2015 => [42052, '090009090009000000090009'],
2016 => [42406, '000900090900090000090000'],
2017 => [42761, '09000900090910090009000000'],
2018 => [43145, '090009000909000900090000'],
2019 => [43499, '000900090009090009090000'],
2020 => [43853, '09000000190009090009000900'],
2021 => [44237, '090000090009000900090009'],
2022 => [44591, '000900090000090009000900'],
2023 => [44946, '09001909000009000009000900'],
2024 => [45330, '090009090009000009000009'],
2025 => [45684, '00090009090019000900000009'],
2026 => [46068, '000900090900090900000009'],
2027 => [46422, '000009000909000909000009'],
2028 => [46776, '00000009001909000909000009'],
2029 => [47160, '000009000900090009090000'],
2030 => [47515, '090009000009000900090009'],
2031 => [47869, '09000019000900000900090009'],
2032 => [48253, '000909000900000900000900'],
2033 => [48608, '09000909000900090000001900'],
2034 => [48992, '090009090009000900000900'],
2035 => [49346, '000900090900090900000900'],
2036 => [49700, '00000900090910090900090000'],
2037 => [50084, '000009000909000909000900'],
2038 => [50438, '000009000900090009090009'],
2039 => [50792, '00000900001900090009000909'],
2040 => [51176, '000900000900090000090009'],
2041 => [51531, '090009000900000900000900'],
2042 => [51886, '09001909000900090000090000'],
2043 => [52270, '090009090009090000090000'],
2044 => [52624, '00090009090009190009000000'],
2045 => [53008, '000900090900090900090000'],
2046 => [53362, '000900090009000909000900'],
2047 => [53716, '00090000091009000909000900'],
2048 => [54100, '090000090000090009090009'],
2049 => [54454, '000900090000090000090009'],
2050 => [54809, '09000910090009000009000009'],
2051 => [55193, '000909000909000009000000'],
2052 => [55548, '09000909000909001900000000'],
2053 => [55932, '090009090009090009000000'],
2054 => [56286, '090000090900090900090000'],
2055 => [56640, '09000009000910090900090009'],
2056 => [57023, '000000090009000909000900'],
2057 => [57378, '090000090009000009090009'],
2058 => [57732, '00090009100900000900000909'],
2059 => [58116, '000900090009000900000009'],
2060 => [58471, '000909000909000900000009'],
2061 => [58825, '00000919000909000900000009'],
2062 => [59209, '000009090009090009000009'],
2063 => [59563, '00000900090009190009000900'],
2064 => [59947, '000009000900090900090009'],
2065 => [60301, '000009000009000909000900'],
2066 => [60656, '09000900001900090009000900'],
2067 => [61040, '090009000900000900090009'],
2068 => [61394, '000900090900000900000900'],
2069 => [61749, '09000900190900090000000900'],
2070 => [62133, '090009000909000900000900'],
2071 => [62487, '00090009000909001900090000'],
2072 => [62871, '000900090009090009000900'],
2073 => [63225, '000900000900090900090009'],
2074 => [63579, '00090000090019000900090009'],
2075 => [63963, '000900090000090009000900'],
2076 => [64318, '090009000900090000090009'],
2077 => [64672, '00090009190009000000090009'],
2078 => [65056, '000900090900090000090000'],
2079 => [65411, '090009000909000900090000'],
2080 => [65765, '00090019000909000909000000'],
2081 => [66149, '090000090009090009090000'],
2082 => [66503, '09000000090900190009090000'],
2083 => [66887, '090000090009000900090009'],
2084 => [67241, '000900090000090009000900'],
2085 => [67596, '09000909001009000009000900'],
2086 => [67980, '090009090009000009000009'],
2087 => [68334, '000900090900090009000000'],
2088 => [68689, '09000900190900090900000009'],
2089 => [69072, '000009000909090009000009'],
2090 => [69426, '00000009000909001909000009'],
2091 => [69810, '000009000900090009090009'],
2092 => [70164, '000009000009000900090009'],
2093 => [70519, '09000009000910000900090009'],
2094 => [70903, '090009000900000900000900'],
2095 => [71258, '090009090009000900000009'],
2096 => [71612, '00090009190009090000000900'],
2097 => [71996, '000900090909000900000900'],
2098 => [72350, '000009000909090009000900'],
2099 => [72704, '00001900000909000909000900'],
2100 => [73088, '000009000900090009090009'],
];配对的连接函数也很短——读表 + 累加起止日:
/** 农历年 $y 的「正月初一」北京日序号 */
private static function tblFirst(int $y): int
{
return self::ANCHOR + self::TBL[$y][0];
}
/** 农历年 $y 的总天数 */
private static function tblDays(int $y): int
{
$code = self::TBL[$y][1];
$sum = 0;
for ($i = 0; $i < strlen($code); $i += 2) {
$sum += $code[$i + 1] === '0' ? 30 : 29;
}
return $sum;
}解码这里有个必踩的坑。按「第 i 组就是第 i+1 个月」推,闰月后面会跳号(八月 → 闰八月 → 十月,九月凭空消失)。正解是用 $prev 追踪上一个月的月号:
$start = self::tblFirst($y);
$code = self::TBL[$y][1];
$months = [];
$cum = 0;
$prev = 0; // 上一个月的月号
for ($g = 0; $g < strlen($code) / 2; $g++) { // $g = 时间序上的第 $g+1 个月
$i = $g * 2;
$leap = $code[$i] === '1';
$days = $code[$i + 1] === '0' ? 30 : 29;
// 闰月沿用前一个月号(如 1900 年:八月 -> 闰八月 -> 九月),平月 +1
$m = $leap ? $prev : $prev + 1;
$months[] = [
'm' => $m,
'leap' => $leap,
'days' => $days,
'start' => $start + $cum,
'ymd' => self::ydFromDayNo($start + $cum),
];
$prev = $m;
$cum += $days;
}
return self::$yearCache[$y] = $months;上面这段是 lunarYear() 的核心。1900 那串解码出来是:正月(30) 二月(29) 三月(29) 四月(30) 五月(29) 六月(29) 七月(29) 八月(29) 闰八月(29) 九月(29) 冬月(29) 腊月(30)——12 个月,闰八月在第 9 位。
不要直接抛异常。判断表内表外,表外走天文扫描兜底:
public static function hasTable(int $y): bool
{
return $y >= self::TBL_MIN && $y <= self::TBL_MAX;
} if (!self::hasTable($y)) {
return self::$yearCache[$y] = self::lunarYearAstro($y);
}lunarYearAstro() 就是第一版那个纯天文实现(含 scanMonth() / firstMonthK()),留着不难,删了才发现 1899 年一调用就崩。
节气比农历月宽容一点:日历上只显示「今天是什么节气」,不要求精确到分钟。但 4823 条(201 年 × 24 个)对拍下来,35 条的日期差一天。
原因算得清清楚楚:太阳一天走 1° = 24 分钟,而 Meeus 式 25. 自带 0.01° ≈ 14 分钟误差。真实时刻落在凌晨 0-3 点的条目,200 年里就有 609 个——这些全在临界点,差 14 分钟就翻篇。
关键观察:每个节气所在的公历月是固定的(立春、雨水永远在 2 月;小寒、大寒永远在 1 月……),所以月份不用存,只存日,两位一个数、补零:
1900 => '041906210520062106220723082308230924082307220620',
// 立春 2月4日 雨水 2月19日 惊蛰 3月6日 春分 3月21日 ... 大寒 1月20日完整的 201 行:
/**
* 紧凑节气表(1900-2100,共 201 行)
* key = 公历年,value = 24 个节气的「日期」拼接,每个两位(补零):
* 顺序与 JIEQI 一致(立春..大寒),取值 = 该节气所在公历日。
* 节气所在公历月固定(立春/雨水=2 月,小寒/大寒=1 月…),故只需记日。
* 例:1900 => 04 19 06 21 ... 表示 立春 2 月 4 日、雨水 2 月 19 日 …
*/
private const TBL_JQ = [
1900 => '041906210520062106220723082308230924082307220620',
1901 => '041906210521062206220823082408240924082308220621',
1902 => '051906210621062207220824082408240924082308230621',
1903 => '052007220621072207220824092409240924082308230621',
1904 => '052006210520062106220723082308230924082307220721',
1905 => '041906210521062206220823082408240924082308220621',
1906 => '051906210621062206220824082408240924082308230621',
1907 => '052007220621072207220824092409240924082308230621',
1908 => '052006210520062106220723082308230924082307220721',
1909 => '041906210521062206220823082408240924082308220621',
1910 => '051906210621062206220824082408240924082308230621',
1911 => '052007220621072207220824092409240924082308230621',
1912 => '052006210520062106220723082308230924082207220721',
1913 => '041906210521062206220823082408230924082308220620',
1914 => '041906210521062206220824082408240924082308230621',
1915 => '052006220621062207220824082409240924082308230621',
1916 => '052006210520062106220723082308230824082207220621',
1917 => '041906210521062106220823082408230924082308220620',
1918 => '041906210521062206220824082408240924082308220621',
1919 => '052006220621062207220824082409240924082308230621',
1920 => '052006210520062106220723082308230824082207220621',
1921 => '041906210520062106220823082408230924082307220620',
1922 => '041906210521062206220824082408240924082308220621',
1923 => '051906210621062207220824082409240924082308230621',
1924 => '052006210520062106220723082308230824082207220621',
1925 => '041906210520062106220823082408230924082307220620',
1926 => '041906210521062206220823082408240924082308220621',
1927 => '051906210621062207220824082409240924082308230621',
1928 => '052006210520062106220723082308230823072207220621',
1929 => '041906210520062106220723082308230924082307220620',
1930 => '041906210521062206220823082408240924082308220621',
1931 => '051906210621062207220824082408240924082308230621',
1932 => '052006210520062106210723082308230823072207220621',
1933 => '041906210520062106220723082308230924082307220620',
1934 => '041906210521062206220823082408240924082308220621',
1935 => '051906210621062206220824082408240924082308230621',
1936 => '052006210520062106210723082308230823072207220621',
1937 => '041906210520062106220723082308230924082307220620',
1938 => '041906210521062206220823082408240924082308220621',
1939 => '051906210621062206220824082408240924082308230621',
1940 => '052006210520062106210723082308230823072207220621',
1941 => '041906210520062106220723082308230924082307220620',
1942 => '041906210521062206220823082408240924082308220621',
1943 => '051906210621062206220824082408240924082308230621',
1944 => '052006210520052106210723082308230823072207220621',
1945 => '041906210520062106220723082308230824082207220620',
1946 => '041906210521062206220823082408230924082308220620',
1947 => '041906210521062206220824082408240924082308230621',
1948 => '052005210520052106210723072308230823072207220621',
1949 => '041906210520062106220723082308230824082207220520',
1950 => '041906210520062106220823082408230924082308220620',
1951 => '041906210521062206220824082408240924082308230621',
1952 => '052005210520052106210723072308230823072207220621',
1953 => '041906210520062106220723082308230824082207220520',
1954 => '041906210520062106220823082408230924082307220620',
1955 => '041906210521062206220823082408240924082308220621',
1956 => '052005200520052106210723072308230823072207220621',
1957 => '041906210520062106220723082308230824082207220520',
1958 => '041906210520062106220723082308230924082307220620',
1959 => '041906210521062206220823082408240924082308220621',
1960 => '051905200520052106210723072307230823072207220621',
1961 => '041906210520062106210723082308230823072207220520',
1962 => '041906210520062106220723082308230924082307220620',
1963 => '041906210521062206220823082408240924082308220621',
1964 => '051905200520052106210723072307230823072207220621',
1965 => '041906210520062106210723082308230823072207220520',
1966 => '041906210520062106220723082308230924082307220620',
1967 => '041906210521062206220823082408240924082308220621',
1968 => '051905200520052105210723072307230823072207220621',
1969 => '041906210520062106210723082308230823072207220520',
1970 => '041906210520062106220723082308230924082307220620',
1971 => '041906210521062206220823082408240924082308220621',
1972 => '051905200520052105210723072307230823072207220621',
1973 => '041906210520052106210723082308230823072207220520',
1974 => '041906210520062106220723082308230924082307220620',
1975 => '041906210521062206220823082408230924082308220621',
1976 => '051905200420052105210723072307230823072207220621',
1977 => '041906210520052106210723072308230823072207220520',
1978 => '041906210520062106220723082308230824082307220620',
1979 => '041906210521062106220823082408230924082308220620',
1980 => '051905200420052105210723072307230823072207220621',
1981 => '041906210520052106210723072308230823072207220520',
1982 => '041906210520062106220723082308230824082207220620',
1983 => '041906210520062106220823082408230924082308220620',
1984 => '041905200420052105210722072307230823072207220621',
1985 => '041905210520052106210723072308230823072207220520',
1986 => '041906210520062106220723082308230824082207220520',
1987 => '041906210520062106220723082408230924082307220620',
1988 => '041905200420052105210722072307230823072207210621',
1989 => '041905200520052106210723072307230823072207220520',
1990 => '041906210520062106210723082308230824082207220520',
1991 => '041906210520062106220723082308230924082307220620',
1992 => '041905200420052105210722072307230823072207210621',
1993 => '041805200520052106210723072307230823072207220520',
1994 => '041906210520062106210723082308230823072207220520',
1995 => '041906210520062106220723082308230924082307220620',
1996 => '041905200420052105210722072307230823072207210621',
1997 => '041805200520052105210723072307230823072207220520',
1998 => '041906210520062106210723082308230823072207220520',
1999 => '041906210520062106220723082308230924082307220620',
2000 => '041905200420052105210722072307230823072207210621',
2001 => '041805200520052105210723072307230823072207220520',
2002 => '041906210520062106210723082308230823072207220520',
2003 => '041906210520062106220723082308230924082307220620',
2004 => '041905200420052105210722072307230823072207210621',
2005 => '041805200520052105210723072307230823072207220520',
2006 => '041906210520052106210723072308230823072207220520',
2007 => '041906210520062106220723082308230924082307220620',
2008 => '041905200420052105210722072307220823072207210621',
2009 => '041805200420052105210723072307230823072207220520',
2010 => '041906210520052106210723072308230823072207220520',
2011 => '041906210520062106220723082308230824082307220620',
2012 => '041905200420052005210722072307220823072207210621',
2013 => '041805200420052105210722072307230823072207220520',
2014 => '041906210520052106210723072308230823072207220520',
2015 => '041906210520062106220723082308230824082207220620',
2016 => '041905200419052005210722072307220823072207210620',
2017 => '031805200420052105210722072307230823072207220520',
2018 => '041905210520052106210723072308230823072207220520',
2019 => '041906210520062106210723082308230824082207220520',
2020 => '041905200419052005210622072207220823072207210620',
2021 => '031805200420052105210722072307230823072207210520',
2022 => '041905200520052106210723072307230823072207220520',
2023 => '041906210520062106210723082308230824082207220520',
2024 => '041905200419052005210622072207220823072206210620',
2025 => '031805200420052105210722072307230823072207210520',
2026 => '041805200520052105210723072307230823072207220520',
2027 => '041906210520062106210723082308230823072207220520',
2028 => '041905200419052005210622072207220823072206210620',
2029 => '031805200420052105210722072307230823072207210520',
2030 => '041805200520052105210723072307230823072207220520',
2031 => '041906210520062106210723082308230823072207220520',
2032 => '041905200419052005210622072207220823072206210620',
2033 => '031805200420052105210722072307230823072207210520',
2034 => '041805200520052105210723072307230823072207220520',
2035 => '041906210520052106210723072308230823072207220520',
2036 => '041905200419052005210622072207220823072206210620',
2037 => '031805200420052105210722072307230823072207210520',
2038 => '041805200520052105210723072307230823072207220520',
2039 => '041906210520052106210723072308230823072207220520',
2040 => '041905200419052005210622072207220823072206210620',
2041 => '031805200420052005210722072307220823072207210520',
2042 => '041805200420052105210723072307230823072207220520',
2043 => '041906210520052106210723072308230823072207220520',
2044 => '041905200419052005210622072207220723072206210620',
2045 => '031805200419052005210722072307220823072207210520',
2046 => '041805200420052105210722072307230823072207220520',
2047 => '041906210520052106210723072308230823072207220520',
2048 => '041905200419052005200622072207220723072106210620',
2049 => '031805200419052005210622072207220823072207210519',
2050 => '031805200420052105210722072307230823072207220520',
2051 => '041905200520052106210723072307230823072207220520',
2052 => '041905200419052005200622072207220723072106210520',
2053 => '031805200419052005210622072207220823072207210519',
2054 => '031805200420052105210722072307230823072207220520',
2055 => '041905200520052105210723072307230823072207220520',
2056 => '041905200419052005200622072207220723072106210520',
2057 => '031805200419052005210622072207220823072206210519',
2058 => '031805200420052105210722072307230823072207210520',
2059 => '041905200520052105210723072307230823072207220520',
2060 => '041905200419052005200622072207220722062106210520',
2061 => '031805200419052005210622072207220823072206210519',
2062 => '031805200420052105210722072307230823072207210520',
2063 => '041805200520052105210723072307230823072207220520',
2064 => '041905200419052005200622072207220722062106210520',
2065 => '031805200419052005210622072207220823072206210519',
2066 => '031805200420052105210722072307230823072207210520',
2067 => '041805200520052105210723072307230823072207220520',
2068 => '041905200419042005200622062207220722062106210520',
2069 => '031805200419052005210622072207220823072206210519',
2070 => '031805200420052005210722072307220823072207210520',
2071 => '041805200520052105210723072307230823072207220520',
2072 => '041905200419042005200622062207220722062106210520',
2073 => '031805200419052005210622072207220723072206210519',
2074 => '031805200420052005210722072307220823072207210520',
2075 => '041805200420052105210722072307230823072207220520',
2076 => '041905200419042005200622062207220722062106210520',
2077 => '031805200419052005210622072207220723072206210519',
2078 => '031805200419052005210622072307220823072207210520',
2079 => '041805200420052105210722072307230823072207220520',
2080 => '041905200419042005200622062207220722062106210520',
2081 => '031805200419052005200622072207220723072106210519',
2082 => '031805200419052005210622072207220823072207210520',
2083 => '031805200420052105210722072307230823072207220520',
2084 => '041904190419042005200622062206220722062106210520',
2085 => '031805200419052005200622072207220723072106210419',
2086 => '031805200419052005210622072207220823072207210519',
2087 => '031805200420052105210722072307230823072207220520',
2088 => '041904190419042004200622062206220722062106210520',
2089 => '031805200419052005200622072207220723072106210419',
2090 => '031805200419052005210622072207220823072206210519',
2091 => '031805200420052105210722072307230823072207210520',
2092 => '041904190419042004200622062206220722062106210520',
2093 => '031805200419052005200622072207220722062106210419',
2094 => '031805200419052005210622072207220823072206210519',
2095 => '031805200420052105210722072307230823072207210520',
2096 => '041804190419042004200622062206220722062106210520',
2097 => '031805200419052005200622062207220722062106210419',
2098 => '031805200419052005210622072207220823072206210519',
2099 => '031805200420052105210722072307230823072207210520',
2100 => '041805200520052105210723072307230823072207220520',
];月份对应关系单独一张小表,省得每次心算:
private const JIEQI = ['立春', '雨水', '惊蛰', '春分', '清明', '谷雨', '立夏', '小满',
'芒种', '夏至', '小暑', '大暑', '立秋', '处暑', '白露', '秋分', '寒露', '霜降',
'立冬', '小雪', '大雪', '冬至', '小寒', '大寒'];
/** 第 i 个节气所在「公历月」(下标与 JIEQI 对齐,春分=0°=3 月) */
private const JQ_MON = [2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 1, 1];solarTerm() 就变成查表 + 转 JD,剩下的天文代码只在表外跑:
public static function solarTerm(int $y, int $i): float
{
$key = $y . ':' . $i;
if (isset(self::$termCache[$key])) {
return self::$termCache[$key];
}
if (self::hasTable($y)) { // 表内:精确日 -> 儒略日
$d = (int)substr(self::TBL_JQ[$y], $i * 2, 2);
return self::$termCache[$key] = self::ymdToJD($y, self::JQ_MON[$i], $d);
}
$target = (315 + 15 * $i) % 360;
$m = intdiv($i + 2, 2); // 1..12,对应 2 月 ~ 次年 1 月
$y2 = $y;
if ($m === 12 && $i >= 22) {
$y2 = $y - 1; // 小寒 / 大寒 落在年初,基准取前一年 12 月
}
$t0 = self::ymdToJD($y2, $m, 1);
$a = $t0 - 30.0;
$b = $t0 + 60.0;
$ga = self::norm(self::sunLon($a) - $target);
$gb = self::norm(self::sunLon($b) - $target);
for ($k = 0; $k < 34; $k++) { // 34 次二分 ≈ 6.8e-11 天
$t = ($a + $b) / 2;
$gt = self::norm(self::sunLon($t) - $target);
if ($ga * $gt <= 0.0) {
$b = $t;
} else {
$a = $t;
}
}
return self::$termCache[$key] = ($a + $b) / 2;
}表外那部分也不是原样抄 Meeus。式 25. 给的是几何黄经,官方节气用的是视黄经,差章动 + 光行差,最大约 0.0105° ≈ 15 分钟。补上式 25.b:
$Mm = 134.9633964 + 477198.8675055 * $T + 0.0087414 * $T * $T; // 月亮平近点角
return $L0 + $C - 0.00569 - 0.00478 * sin($Mm * self::D2R);一行修正的效果:日期不一致 35 → 18 条,误差中位数 10.24 → 5.78 分钟。剩下的 18 条全在临界点,所以才有了模式二开头的那张表——这不是偷懒,是承认「14 分钟精度的公式算不出日界」。
for ($k = 0; $k < 34; $k++) { // 34 次二分 ≈ 6.8e-11 天34 次二分,精度约 6.8e-11 天,够用;想省就 20 次,也够。
网上大量万年历代码这么写朔:k = round((year - 2000) * 12.3685),然后牛顿迭代求日月黄经差。我按套路写过,结果是漏掉近一半的朔,整年月份全错。
原因:朔望月不是 29.53 也不是 31.81,是 29.530588853;而且月相函数对 t 不单调,牛顿迭代随便一个坏初值就飞了。
private const EPOCH_K = 2451550.00001; // k = 0 的朔近似起点
private const SYNODIC = 29.530588853; // 朔望月(天) /** 月相差(月-日),朔时为 0 */
private static function phase(float $t): float
{
return self::norm(self::moonLon($t) - self::sunLon($t));
}
/**
* 第 k 个朔的儒略日(UT)。
* 坑:初值必须用朔望月 29.530588853,且用二分法求根;
* 用 31.81035957 做步长 + 牛顿迭代会漏掉近一半的朔。
*/
public static function newMoon(int $k): float
{
if (isset(self::$moonCache[$k])) {
return self::$moonCache[$k];
}
$c = self::EPOCH_K + self::SYNODIC * $k;
$a = $c - 1.0;
$b = $c + 1.0;
$fa = self::phase($a);
$fb = self::phase($b);
for ($i = 0; $i < 34; $i++) { // 34 次二分精度约 1e-10 天
$m = ($a + $b) / 2;
$fm = self::phase($m);
if ($fa * $fm <= 0.0) {
$b = $m;
$fb = $fm;
} else {
$a = $m;
$fa = $fm;
}
}
return self::$moonCache[$k] = ($a + $b) / 2;
}月亮这边,表 47.B 前 24 项足够(更多项对朔的修正 < 1 秒):
$dl = 0.0;
foreach (self::L_TERMS as $e) {
$dl += $e[0] * sin(($e[1] * $D + $e[2] * $Ms + $e[3] * $Mp + $e[4] * $F) * self::D2R);
}
return $Lp + $dl * 1e-6;phase() 一定要 norm() 到 [-180, 180)。不归一化,黄经跨 0° 那一周(fmod 跳变)符号会反,二分直接找不到根:
/** 归一化到 [-180, 180) */
private static function norm(float $d): float
{
$d = fmod($d, 360.0);
if ($d > 180.0) {
$d -= 360.0;
} elseif ($d < -180.0) {
$d += 360.0;
}
return $d;
}$a、$b 的跨度给 ±1 天±1 天足够罩住任何朔(朔望月 29.53 天,月相曲线在这个区间内单调)。给 ±30 天反而让二分多跑几轮。
上面那些二分,一次调用要跑 34 次 sin(),月亮那 24 项再套一层循环。页面上一个表格渲染 40 行,就是 40 × 34 × 25 次三角函数,CPU 直接给你颜色看。
| 缓存 | 键 | 存什么 |
|---|---|---|
$yearCache | 农历年 | 该年 12/13 个月的完整月份表 |
$moonCache | k | 第 k 个朔的儒略日 |
$termCache | '年:节气号' | 节气的儒略日 |
private static $yearCache = []; // 农历年 => 月份表
private static $moonCache = []; // k => 朔的儒略日
private static $termCache = []; // 'y:i' => 节气儒略日| 指标 | 数值 |
|---|---|
单次 fromSolar()(表内) | 约 4 μs |
lunar-javascript 同口径 | 345 μs(慢 85 倍) |
| 73414 天全量往返(含建表) | 0.41 秒 |
| 月表 + 节气表体积 | 约 22 KB |
require,别在循环里 includePHP 没有文件级 memo 记忆,require 一次就重新解析一遍文件。这类「常量表 + 静态缓存」的库,约定俗成是在引导文件里 require 一次,之后全程走静态数组。
switch 少写一个 case,编译器不说话,运行时才炸。万年历里对应的坑更难查:solarTerm($y, 22)(小寒)和 solarTerm($y, 23)(大寒)求的是公历 $y 年 1 月的节气,而农历腊月横跨 1 月底到 2 月初——腊月里的大寒这口中气,只查当年就漏了,于是:
i >= 22 时同时查两年 for ($i = 0; $i < 24; $i++) {
// 坑:solarTerm(Y, 22/23) 求的是公历 Y 年 1 月的「小寒/大寒」,
// 所以腊月(12 月末~1 月初)要额外检查 Y+1,否则会漏掉大寒这口中气
$years = $i >= 22 ? [$ymd[0], $ymd[0] + 1] : [$ymd[0]];
foreach ($years as $yy) {
$td = self::bjDateNo(self::solarTerm($yy, $i)); // 按北京时间自然日归属节气
if ($td >= $d1 && $td < $d2) {
($i % 2 === 0) ? $jie[] = $i : $zhong[] = $i;
break;
}
}
}注意这里的月名规则:由这个月包含的第一个中气决定,不是节气。偶数下标是节、奇数是中气——「立春」是 0 号(节),「雨水」是 1 号(中气)。用节气定月名,闰月位置全错,这个问题在 v1 里返工过一次。
private static function firstMonthK(int $y): int
{
$dayYuShui = self::bjDateNo(self::solarTerm($y, 1)); // 雨水的北京日序号
$k = (int)round(($dayYuShui + self::UNIX_JD - self::EPOCH_K) / self::SYNODIC);
while (self::bjdOf($k + 1) <= $dayYuShui) {
$k++;
}
while (self::bjdOf($k) > $dayYuShui) {
$k--;
}
return $k;
}比「从年初扫一遍找含雨水的月」稳得多,而且天然处理了「正月从 1 月底才开头」的情况。
反向查询(公历 → 农历年)用二分,201 条数据 8 步以内:
/** 公历日序号 -> 所属农历年(二分,表内 O(log200)) */
private static function lunarYearOf(int $dayNo): int
{
if ($dayNo < self::tblFirst(self::TBL_MIN) || $dayNo > self::tblFirst(self::TBL_MAX) + self::tblDays(self::TBL_MAX) - 1) {
return self::lunarYearOfAstro($dayNo); // 表外兜底
}
$lo = self::TBL_MIN;
$hi = self::TBL_MAX;
while ($lo < $hi) {
$mid = intdiv($lo + $hi + 1, 2);
if (self::tblFirst($mid) <= $dayNo) {
$lo = $mid;
} else {
$hi = $mid - 1;
}
}
return $lo;
}农历大小月不能用公历关系推,只能取相邻两个月初一北京日序号之差:
'days' => $d2 - $d1,第一版能跑不代表第二版能跑。这次升级真正卡住我的就是三件事,全都长在「我以为」上。
gregToJD() 能用老代码里满地是 gregToJD() / jdtogregorian()。calendar 扩展没装,这两个函数返回 0,不报错, program 静默算错。
// 生产机上的现实
php -r "var_dump(function_exists('gregToJD'));" // 输出 bool(false)解法:自带 Fliegel–Richards 算法,22 行搞定,还更快:
/** 公历日期 -> 日序号 JDN */
public static function ymdToJdn(int $y, int $m, int $d): int
{
$a = intdiv(14 - $m, 12);
$yy = $y + 4800 - $a;
$mm = $m + 12 * $a - 3;
return (int)($d + intdiv(153 * $mm + 2, 5) + 365 * $yy
+ intdiv($yy, 4) - intdiv($yy, 100) + intdiv($yy, 400) - 32045);
}
/** 公历日期 -> 儒略日(UT 0 时) */
public static function ymdToJD(int $y, int $m, int $d): float
{
return self::ymdToJdn($y, $m, $d) - 0.5;
}
/** 儒略日(UT 0 时)-> [y, m, d] */
public static function jdToYmd(float $jd): array
{
$jdn = (int)floor($jd + 0.5);
$a = $jdn + 32044;
$b = (int)floor((4 * $a + 3) / 146097);
$c = $a - (int)floor(146097 * $b / 4);
$d = (int)floor((4 * $c + 3) / 1461);
$e = $c - (int)floor(1461 * $d / 4);
$m = (int)floor((5 * $e + 2) / 153);
return [
100 * $b + $d - 4800 + intdiv($m, 10), // 年
$m + 3 - 12 * intdiv($m, 10), // 月
$e - intdiv(153 * $m + 2, 5) + 1, // 日
];
}月表第一项是「正月初一距 1900-01-01 多少天」,我照抄成 0。结果整个表全乱,农历年输出 -4656。
PHP 的 date() / 自增日序号原点是 1970-01-01,1900-01-01 是 -25567:
private const ANCHOR = -25567; /**
* 任意时刻 JD -> 北京时间序号(1970-01-01 = 0)
* 公式:floor(JD + 8/24 - 2440587.5)
*/
public static function bjDateNo(float $jd): int
{
return (int)floor($jd + self::GMT8 - self::UNIX_JD);
}
/** 公历日期 -> 北京时间序号 */
public static function dateNo(int $y, int $m, int $d): int
{
return self::bjDateNo(self::ymdToJD($y, $m, $d));
}顺手又踩一个:反查公历的时候,ydFromDayNo() 里又减了一次 GMT8,结果每天整体差一天:
/** 北京日序号 -> [y, m, d](dateNo 的逆运算,注意不能减 8 小时,否则整体差一天) */
public static function ydFromDayNo(int $dayNo): array
{
return self::jdToYmd(self::UNIX_JD + $dayNo);
}正向是 floor(JD + 8/24 - UNIX_JD),逆向就是 + UNIX_JD 直接拆——再加 8 小时就是多减了一次。
| 检查项 | v1(错) | v2(对) |
|---|---|---|
| 儒略日 | gregToJD() | 自带 ymdToJdn() |
| 日序号原点 | 1900 = 0 | 1900 = -25567 |
| 日序号逆运算 | - 8/24 | 直接 + UNIX_JD |
| 月天数 | 公历关系推 | 相邻月初一北京日序号差 |
| 闰月序号 | i + 1 标位 | 闰月时间序就是 i |
| 闰月后月号 | 顺序 +1 | $prev 追踪,闰月沿用 |
| 腊月中气 | 只查当年 | i >= 22 查两年 |
| 节气口径 | UTC 自然日 | 北京时间自然日 |
| 表边界 | 扫到 2100-12-31 | 扫到 2101-03-31(2100 腊月跨年) |
| 表外年份 | 抛异常 | 天文兜底 |
最后一栏那个「扫到 2100-12-31」最阴险:lunar-javascript 给出的 2100 腊月跑到 2101-01-28 才结束,扫早了截断成 1 天,月表解码直接崩。
| 核对项 | 规模 | 结果 |
|---|---|---|
逐日往返(fromSolar ↔ toSolar) | 73414 天 | 失败 0,耗时 0.41 秒 |
逐日 vs lunar-javascript | 73414 天 | 不一致 0 |
| 月表逐月对拍 | 2486 月 / 74 闰月 | 不符 0 |
| 节气逐条对拍 | 4823 条 | 日期不一致 0 |
| 闰月数 vs 月数一致性 | 201 年 | 异常年份 0 |
第一,万年历的正确性不是靠算法复杂度换来的,是靠把 73414 天每一天都跑一遍换来的。第二,别迷信通用算法——Meeus 是优秀的近似,但「近似」在历法上就是「初一可能错一天」。
一个文件、零依赖、22KB 数据表、4 μs 单次查询,覆盖 200 年。这大概就是我要的全部。
下载:
—— 评论区 ——