lunisolar-ephemeris
v0.4.1
Published
Chinese lunisolar calendar with second-accurate JPL DE440 solar terms. Zero runtime dependencies. Absolute-instant and wall-clock semantics are distinct types.
Maintainers
Readme
lunisolar-ephemeris
Chinese lunisolar calendar for TypeScript. Second-accurate solar terms from JPL DE440.
Zero runtime dependencies. No Node, DOM, or Intl APIs — runs in WeChat Mini Programs.
npm i lunisolar-ephemerisWhy another one
Two things no other JS library does:
- Solar terms are computed from JPL DE440 and stored to the second — not looked up from a low-precision table, not approximated by a low-order series.
- Absolute instants and wall-clock readings are distinct types that cannot be mixed. This is the single easiest thing to get wrong in this domain, and every other library leaves it implicit.
The distinction that matters
Mangzhong (芒种) 2033 is one physical moment. A clock in Beijing reads 16:13:27;
a clock in New York reads 04:13:27.
| | New York, local 10:00 |
|---|---|
| Absolute instant — the term is that moment | already past Mangzhong |
| Wall-clock reading — the term is the reading 16:13:27 | not yet (10:00 < 16:13) |
Under wall-clock semantics the answer is timezone-independent: reading is compared against reading, and the zone cancels. That is what the common Chinese divination implementations do — which means a library offering only absolute instants cannot reproduce any existing chart. A library offering only wall-clock is astronomically wrong. You need both, and the caller must not be able to pick one by accident.
Date is a trap here: new Date('2033-06-05T16:13:27') parses as local, and
new Date(2033, 5, 5, 10, 0) is local too — tag both the same way, compare them, and the
zone silently cancels. You have quietly built wall-clock semantics without deciding to.
How the types prevent it
import { ephemeris, Instant, WallClock } from 'lunisolar-ephemeris';
const e = ephemeris(); // default: absolute instants
e.solarTermAt(Instant.fromDate(new Date())); // ✅
e.solarTermAt(WallClock.of(2033, 6, 5, 10)); // ❌ compile error
const w = ephemeris({ reckoning: 'wall-clock' });
w.solarTermAt(WallClock.of(2033, 6, 5, 10)); // ✅
w.solarTermAt(Instant.fromDate(new Date())); // ❌ compile errorThere is no implicit conversion between the two. Crossing over requires naming an offset:
Instant.fromWallClock(reading, 8 * 3600); // offset is mandatory
WallClock.fromInstant(instant, -4 * 3600);A bare Date is never accepted. The only two doors are named for what they do:
Instant.fromDate(d) (a Date is an instant — safe) and
WallClock.fromDateLocalFields(d) (read the host-local clock digits).
Every result carries the reckoning it was computed under, so downstream code never has to guess.
Usage
import { ephemeris, Instant, PillarRules } from 'lunisolar-ephemeris';
const e = ephemeris();
const now = Instant.fromDate(new Date());
const term = e.solarTermAt(now);
term.begin.name; // '芒种'
term.begin.instant; // Instant
term.begin.beijing; // WallClock — 2033-06-05T16:13:27
term.begin.beijingOffsetSeconds; // 28800 (27940 before 1929, see below)
const lunar = e.lunarDateAt(now);
lunar.lunarYear; lunar.month; lunar.isLeap; lunar.day;
lunar.lunarYearPillar.text; // '癸丑' — lunar-year ganzhi, bounded by New Year's Day
lunar.certainty; // { level: 'certain' } | { level: 'uncertain', ... }
const p = e.fourPillarsAt(now, PillarRules.standard);
p.year.text; p.month.text; p.day.text; p.hour.text; // e.g. 癸丑 戊午 甲子 甲子
p.rule; // 'standard/v1' — store this with the record
p.deviation; // null unless a coarse rule changed this answer — see belowCoarse rules announce themselves
termGranularity and dayBoundary trade astronomical accuracy for convention. When one of
them actually changes an answer, the result says so and hands you the exact one:
const coarse = { ...PillarRules.standard, id: 'mine', termGranularity: 'day' as const };
// Mangzhong 2033 lands at 16:13:27. Query before it, with whole-day granularity:
e.fourPillarsAt(t1000, coarse).deviation;
// { pillars: ['month'], exact: { year: '癸丑', month: '丁巳', day: '甲子', hour: '己巳' } }
// Query after it — day and instant agree, so nothing is flagged:
e.fourPillarsAt(t1630, coarse).deviation; // nullIt reports this query actually differs, not you picked a non-default rule — the coarse
rules only bite near a term boundary, so a blanket warning would be noise. The default
(instant + midnight) is always null, and costs nothing extra.
True solar time is not counted as a deviation: it is a correction toward physical accuracy
rather than a discretization trade-off, and it reports itself via trueSolarTimeShiftSeconds.
On shichen: no known implementation uses it. It entered this library from an inference
about the verification corpus that has since been disproved (see docs/verification.md §2.4.1),
and there is no cited textual basis for it either. day and instant each have a known
implementation — lunar-typescript provides both. Treat shichen as unsupported.
When does this shichen end
Once true solar time is corrected, the shichen boundary is no longer on the hour — so the library reports it instead of leaving you to guess:
const rule = { ...PillarRules.standard, id: 'mine', trueSolarTime: { longitude: 116.4 } };
const b = e.shichenBoundsAt('wall-clock', WallClock.of(2026, 8, 21, 16, 17, 43), rule);
b.shichen; // '申'
b.begin; // 2026-08-21 15:17:36
b.end; // 2026-08-21 17:17:35 — not 17:00Beijing sits at 116.4°E, west of the 120°E standard meridian, so its true solar time lags the clock and the boundary falls later. The two bounds are 7199 s apart, not 7200: the correction is re-evaluated at each end, and an apparent solar day is not exactly 24 h.
[begin, end) is half-open, matching how pillars switch: the boundary instant already
belongs to the next shichen. begin and end are given in the caller's own terms —
an Instant under 'instant', a wall-clock reading under 'wall-clock'.
The boundary is worth reporting only because it is actually accurate to the second: the
equation of time is within 2.4 s of JPL DE440 across 1900–2100 (see below), and the
correction is re-evaluated at the boundary rather than at the query, which is otherwise
a 2.5 s error. shichenBoundsAt and fourPillarsAt are pinned to agree second-by-second
by a property test, so the countdown you show a user cannot drift from the pillar you
computed for them.
Two different "year ganzhi"
These are not the same quantity and conflating them is why four popular libraries disagree in 193 out of 201 years:
fourPillars.year— the year pillar, bounded by Lichun (立春).lunarDate.lunarYearPillar— the lunar year's name, bounded by Lunar New Year's Day.
Accuracy and size
Solar terms and new moons are generated with Skyfield + JPL DE440 (scripts/gen-ephemeris.py),
then packed losslessly. Decoding is integer-only, so it is bit-identical to the generator.
| bundle (minified + gzip) | |
|---|---|
| solar terms only | 10.6 KB |
| four pillars only | 11.6 KB |
| everything | 20.7 KB |
| lunar-typescript (6tail), for comparison | 97.7 KB |
Measured with esbuild --bundle --format=esm --minify on an entry that consumes the
named exports, then gzip -9. The everything figure had drifted (18.9 KB was accurate
before v0.2.0); it is now measured rather than remembered.
Runtime computation was measured and rejected: 1″ of solar longitude ≈ 24.4 s of time, so second-level accuracy needs ~0.04″, which needs essentially the full VSOP87 Earth series — larger, slower and less accurate than the packed table over 1900–2100.
Range
1900–2100. Anything outside throws OutOfRangeError. It never silently returns a wrong value.
Hard cases
| | this library | notes |
|---|---|---|
| 2033 leap month | leap 11th month, from 2033-12-22 | falls out of the standard rules with no special case; date-chinese still places it after the 7th month, a rule retired decades ago |
| 1933 leap 5th month | 30 days | the following new moon is at 00:02:45 Beijing — 2.8 minutes past midnight; solarlunar puts it on the previous day and gets 29 |
| 1916 New Year | Feb 3 | from 1914 to 1928 the calendar used Beijing local mean time (UTC+07:45:40), not UTC+08:00 |
| 2057-09-29, 2089-09-04, 2097-08-08 | flagged uncertain | see below |
Uncertainty is derived, not hard-coded
For dates decades out, the extrapolation error in ΔT (TT−UT1) reaches minutes. When an event falls closer to Beijing midnight than that uncertainty, the date genuinely cannot be pinned down.
The flag comes from perturbation analysis: shift every instant of the era by ±ΔT-uncertainty, rebuild the calendar, and report whatever changed. One mechanism, three channels — month-start date, solar-term date, and leap-month placement. Enumerating cases by hand missed two of them.
if (lunar.certainty.level === 'uncertain') {
lunar.certainty.deltaTUncertaintySeconds; // 61
for (const r of lunar.certainty.reasons) {
// { kind: 'new-moon-near-midnight', marginSeconds: 44,
// alternativeStartDate: { year: 2057, month: 9, day: 28 } }
// { kind: 'leap-month-attribution', alternative: { month, isLeap } }
}
}
term.certainty; // solar-term dates carry the same flagA smaller margin in the past is still certain: 1933-07-23 is 2.8 minutes from midnight and 2018-11-08 is 2.0, but ΔT there is measured, not predicted. This is why the threshold grows with the epoch instead of being a fixed window — a fixed one would manufacture doubt about settled facts.
A new moon near midnight moves two months: when the next month starts, and how long this one is. So the flag reaches the previous month's last day too — the day that is either "30th of the 8th month" or "1st of the 9th". It reaches only that day, not the whole preceding month. Across 1900–2100, 91 days out of 73,412 carry a flag.
On calibration. The Hong Kong Observatory publishes seven uncertain entries. That list cannot be reproduced by any single monotone threshold — 2051 Vernal Equinox (38 s margin) is flagged while 2085 Moderate Cold (140 s) is not, which is self-contradictory for any smooth ΔT model. So this library takes the conservative side: it never claims certainty where HKO warns, at the cost of flagging a few entries HKO does not. Over-flagging is safe; under-flagging is not. Both raw numbers are exposed so you can re-adjudicate on your own threshold.
The one deliberate departure is the 2021 Winter Solstice, which HKO marks uncertain and this library reports as certain — that date is now in the past and ΔT for it is measured, so it is a settled fact. HKO's note was written when it was still a prediction.
On 2057 and 2097 we land on the opposite side from HKO. That is not a bug to paper over — it is
the uncertainty itself, and alternativeStartDate names the other candidate.
Leap-month attribution never triggers over 1900–2100. That is a measured negative, not an unimplemented path: it takes roughly a 30-minute shift to move a leap month, and the largest ΔT uncertainty in range is 337 seconds. The test proves the channel is live before asserting it is empty.
Verification
Ground truth, not implementation cross-checks:
- 87,523 rows of third-party chart output (20 years × 12 double-hours daily) — year, month, day and hour pillars plus decade-head all match with zero discrepancies.
- Hong Kong Observatory conversion tables, 1901–2100: 2474 month starts and 4800 solar-term dates. Only 2 month-start disagreements — and both are the dates this library already flags as uncertain, with HKO's date named as the alternative. Six solar-term disagreements, four of them within 60 seconds of midnight.
- Solar term table cross-checked point-by-point against an independently written DE440 solver: 4104/4104 identical to the second.
- Refereed against the raw ephemeris: feeding each library's claimed solar-term instants back
into DE440 and measuring where the Sun actually was. This library is within ±0.4 s at every
sampled point;
lunar-typescriptis 25–31 s off andastronomy-engineup to 43 s off over 1911–1913. The sample was deliberately chosen where this library disagrees with HKO — that is, where it is least flattering. - Table packing verified lossless on all 7458 points.
- 20 mutation tests: each reverts a specific bug and asserts the suite actually fails. All 20 are caught. Two real holes were found this way — dead range-check code, and an uncertainty channel no test exercised.
Beijing time has three eras
Before 1914 the official standard was Beijing apparent solar time; from 1914 it was Beijing
local mean solar time (UTC+07:45:40); from 1929, China Standard Time (UTC+08:00). This library
uses UTC+07:45:40 for 1914–1928 and UTC+08:00 everywhere else — including before 1914, which
is deliberately not the historical standard. No modern table reproduces pre-1914 apparent solar
time: HKO and the table-driven libraries all effectively use standard time there, and 6tail's
pre-1914 solar terms sit 14:45–14:52 later than local mean time, exactly the meridian difference.
This library follows that established published convention rather than inventing a model nobody
uses. Two disagreements remain in 1912–1913, pinned in test/hko.test.ts so they cannot drift.
docs/verification.md records the evidence chain, including what the corpus and HKO tables
cannot settle.
Third-party data
test/fixtures/hko-digest.csv.gz contains dates extracted from the Hong Kong Observatory's
Gregorian-Lunar Calendar Conversion Table, provided via DATA.GOV.HK. The intellectual property
rights in that Data are owned by the Government of the Hong Kong Special Administrative Region
and the relevant organisations. It is reproduced here under the
DATA.GOV.HK Terms and Conditions, Version 1.2
(26 May 2025), and is provided on an "AS IS" basis without warranty. It is used only as a test
fixture and is not part of the published npm package.
test/fixtures/rebu-corpus-2016-2036.csv.gz is third-party chart output collected by the
QiTianYiShu project; also a test fixture only.
License
MIT — applies to this library's own source, tests and documentation. See Third-party data above for the test fixtures.
