lunar-lite
A compact, table-backed Rust library for Chinese lunisolar (农历) date conversion and stem-branch (干支) calculation.
What it does
lunar-lite converts between Gregorian solar dates and Chinese lunisolar dates, including leap-month handling, traditional twelve-branch time index (时辰, shíchen) calculation, sexagenary (干支, ganzhi) stem-branch cycle positions, and four-pillar (四柱 / 八字 BaZi) year/month/day/hour stem-branch calculation.
Supported lunar years: 1850..=2150
What it does not do
See Non-goals.
Design
lunar-lite aims to be small, deterministic, and idiomatic Rust. It does not embed a runtime astronomical engine, but it also does not store a huge day-by-day solar/lunar mapping table.
- Lunar/solar conversion stores year structure, not every date mapping. Each supported lunar year is represented by compact metadata: the Gregorian date of Chinese New Year, the leap-month position, the number of lunar months, the ordered month codes, and the length of each lunar month. Solar-to-lunar conversion resolves the lunar year and walks the month lengths by offset; lunar-to-solar conversion performs the inverse offset lookup.
- Stem-branch calculation stores precise solar-term boundaries, not precomputed pillars. The generated solar-term table stores the exact date-time of the 12 Jie (节) boundaries for each supported Gregorian year. In
Exactmode, the month branch is determined by the most recent Jie boundary, while the month Heavenly Stem is derived from the relevant sui/year stem using 五虎遁. - Runtime stays pure Rust and lightweight. Generated tables are committed under
src/generated/; runtime users do not need Node.js,lunar-typescript, orlunar-lite. There is no runtime I/O, no runtime JavaScript dependency, and no allocations on the conversion hot path.
Installation
Usage
Solar → lunar
use ;
let solar = SolarDate ;
let lunar = solar_to_lunar.unwrap;
// LunarDate { year: 2023, month: 1, day: 1, is_leap_month: false }
Lunar → solar
use ;
let lunar = LunarDate ;
let solar = lunar_to_solar.unwrap;
// SolarDate { year: 2023, month: 3, day: 22 }
Leap-month normalization
use ;
// 2024 has no leap month 1 — the flag is silently dropped.
let date = LunarDate ;
let normalized = normalize_lunar_date.unwrap;
// LunarDate { year: 2024, month: 1, day: 1, is_leap_month: false }
Time index (时辰)
use time_index;
assert_eq!; // early Zi 00:00–00:59
assert_eq!; // Chou 01:00–02:59
assert_eq!; // late Zi 23:00–23:59
Index mapping:
| Index | Branch | Hours |
|---|---|---|
| 0 | 子 (early Zi) | 00:00–00:59 |
| 1 | 丑 Chou | 01:00–02:59 |
| 2 | 寅 Yin | 03:00–04:59 |
| 3 | 卯 Mao | 05:00–06:59 |
| 4 | 辰 Chen | 07:00–08:59 |
| 5 | 巳 Si | 09:00–10:59 |
| 6 | 午 Wu | 11:00–12:59 |
| 7 | 未 Wei | 13:00–14:59 |
| 8 | 申 Shen | 15:00–16:59 |
| 9 | 酉 You | 17:00–18:59 |
| 10 | 戌 Xu | 19:00–20:59 |
| 11 | 亥 Hai | 21:00–22:59 |
| 12 | 子 (late Zi) | 23:00–23:59 |
Zi hour is split: the early half (0) begins the current day, the late half (12) closes it.
When you only need the hour branch (时支) rather than the full hour pillar, use the
index helpers. Both 子 halves resolve to the same branch, and the predicates return
Result<bool, _> so an invalid index is never silently treated as false:
use ;
assert_eq!; // early 子
assert_eq!; // late 子
assert_eq!;
assert!;
assert!;
assert!; // LunarError::InvalidTimeIndex
Sexagenary cycle (干支)
The sexagenary cycle pairs the ten Heavenly Stems (天干) with the twelve Earthly
Branches (地支) into sixty positions (六十甲子). The conventional anchor 1984 = 甲子
(JiaZi) is used for year pillars.
use ;
// Year pillar from a lunar year.
let pillar = from_lunar_year;
assert_eq!; // 甲
assert_eq!; // 辰 -> 甲辰
// Position within the sixty-step cycle (0 = JiaZi, 59 = GuiHai).
assert_eq!;
assert_eq!;
// Validated construction: only the sixty parity-matched pairs are accepted.
assert!;
assert!;
HeavenlyStem and EarthlyBranch each expose index, from_index, and a
wrapping offset; the HEAVENLY_STEMS and EARTHLY_BRANCHES constants give the
canonical cyclic ordering.
When a domain rule needs the lunar birth-year stem/branch (生年干/生年支) — as
opposed to the four-pillar yearly pillar, which may use the 立春 (LiChun) boundary
under YearDivide::Exact — use the lunar-year helpers, which read directly from the
lunar year:
use ;
use ;
assert_eq!;
assert_eq!; // 生年干 甲
assert_eq!; // 生年支 辰
Four pillars (四柱 / 八字)
The four-pillar stem-branch API computes the year, month, day, and hour pillars
(FourPillars) for a Gregorian date and a 时辰 index. It is a faithful port of the
TypeScript lunar-lite function
getHeavenlyStemAndEarthlyBranchBySolarDate and is validated against its output.
The Rust-native entry points are four_pillars_from_solar_date (default options)
and four_pillars_from_solar_date_with_options. The long
get_heavenly_stem_and_earthly_branch_by_solar_date[_with_options] names are kept
to document parity with the TypeScript reference; HeavenlyStemAndEarthlyBranchDate
remains available as a type alias for FourPillars for the same reason.
use ;
let solar = SolarDate ;
// Simplest call: default options (Exact, Exact, matching the TypeScript reference).
// time_index 2 == 寅时 (03:00–04:59).
let pillars: FourPillars = four_pillars_from_solar_date.unwrap;
assert_eq!; // 庚辰
assert_eq!; // 甲申
// Choose boundary conventions explicitly:
let options = StemBranchOptions ;
let _ = four_pillars_from_solar_date_with_options;
The wall-clock time is synthesized from time_index (hour = max(time_index * 2 - 1, 0), minute = 30), matching the reference. time_index is 0..=12, where both
0 (early 子) and 12 (late 子) map to the 子 branch; 12 additionally rolls the
day pillar forward to the next day (晚子时).
Year pillar — YearDivide:
Normal: uses the lunar year, so the pillar changes at Chinese New Year.Exact: uses the 立春 (LiChun) boundary, compared at date granularity — on or after the 立春 calendar date counts as the new year.
Month pillar — MonthDivide:
Normal: derived from the lunar month via 五虎遁 (not solar terms).Exact: derived from the 12 Jie (节) solar-term boundaries, switching at the exact second of each term.
The month pillar uses solar terms, not the lunar month, in
Exactmode. The two modes are intentionally asymmetric:year:Exactresolves at date granularity whilemonth:Exactresolves at second granularity, reproducing the reference.
Supported range: four-pillar calculation covers 1850-01-01 ..= 2150-12-31.
Exact options cover the whole range; Normal options additionally depend on the
lunar-year table, so solar dates before Chinese New Year 1850 (lunar year 1849)
return LunarError::YearOutOfRange.
Leap months
The Chinese lunisolar calendar inserts an intercalary (leap) month roughly every three years. LunarDate carries an is_leap_month: bool field to distinguish the leap copy of a month from the regular one.
normalize_lunar_date is the safe entry point for externally-supplied dates:
- If
is_leap_month = trueand the year actually has a leap month at that position, the date is kept as-is. - If
is_leap_month = truebut the year has no leap month at that position, the flag is cleared and the date is treated as the regular month. - After normalization the actual day count is validated; an out-of-range day returns
LunarError::InvalidLunarDate.
lunar_to_solar calls normalize_lunar_date internally, so passing a fake leap flag is safe.
Error handling
Date/time conversion functions return Result<_, LunarError>.
Stem-branch validation returns Result<_, StemBranchError>.
| Variant | Meaning |
|---|---|
LunarError::InvalidSolarDate |
Solar date is not a valid calendar date |
LunarError::InvalidLunarDate |
Lunar date is structurally invalid or day exceeds month length |
LunarError::YearOutOfRange |
Year is outside 1850..=2150 |
LunarError::InvalidTime |
Hour > 23 or minute > 59 |
LunarError::InvalidTimeIndex |
时辰 index is outside 0..=12 |
LunarError::SolarTermOutOfRange |
Gregorian year is outside the solar-term table (1850..=2150) |
StemBranchError::InvalidStemBranchPair |
The stem and branch do not form a valid sexagenary pair |
Reference data generation
The static tables in src/generated/ are produced by Node.js scripts under
tools/lunar-lite-reference/scripts/:
| Script | Generates |
|---|---|
dump-year-info.mjs |
src/generated/year_info.rs (lunar-year metadata) + year-info fixtures |
generate-solar-terms.mjs |
src/generated/solar_terms.rs (the 12 Jie per year, 1850..=2150) |
generate-four-pillars-fixtures.mjs |
tests/fixtures/four_pillars.json (four-pillar compatibility cases) |
The solar-term and year-info scripts use lunar-typescript as their reference source; the four-pillar fixtures use lunar-lite. The solar-term generator fails unless every year yields exactly 12 strictly-ordered Jie boundaries.
Runtime users do not need Node.js, lunar-typescript, or lunar-lite. The generated files are committed to the repository; regeneration is only needed when extending the supported range or updating the reference data.
To regenerate:
Compatibility with lunar-typescript
Conversion results are generated from lunar-typescript and are expected to match it for all dates in the supported range. The crate does not embed the full astronomical engine; it is a lightweight Rust consumer of pre-computed data. Results are described as "generated from the reference implementation" rather than independently verified against historical astronomical records.
Non-goals
- Solar terms (节气) API — Jie boundaries back the four-pillar month pillar but are not exposed as a standalone public API.
- True solar time correction — time zone offsets based on longitude are not applied; the four-pillar time is synthesized from
time_index. - Zi Wei Dou Shu (紫微斗数) charting — out of scope.
- Runtime JavaScript dependency — the crate is pure Rust at runtime.
Release process
Releases are managed by release-plz.
After changes are merged into main, release-plz opens or updates a Release PR.
Review the version bump and changelog in that PR. When the Release PR is merged,
the workflow publishes the crate to crates.io and creates the GitHub release/tag.
Required repository secret:
CARGO_REGISTRY_TOKEN: crates.io API token with permission to publishlunar-lite.
License
MIT — see LICENSE.