Vol. 07 · Dispatch2026-06-26

Computing the 24 solar terms in 80 lines, no lookup tables

How the Shouxing polynomial gives you every Chinese solar term from 1900–2100 to ±1 day, with two small constant arrays and a documented list of exception years — instead of either a 200-year lookup table or a 50 KB VSOP87 ephemeris.

by MetaWorldOS Engineering
TypeScriptAstronomyCalendarSolar Terms节气

TL;DR — The 24 Chinese solar terms (节气) are astronomically defined: each one is the moment the Sun reaches a multiple of 15° of ecliptic longitude. The naive engineering answer is a 200-year lookup table; the academic answer is VSOP87 + Newton–Raphson. The middle path is the Shouxing formula — a closed-form polynomial with ~30 constants and 12 documented exception years, accurate to ±1 day across 1900–2100, in about 80 lines of TypeScript. This is the math, the code, and the gotchas.

Live demo: /universe/birthday — the Birthday Sky surfaces your closest solar term (“born 3 days before 立夏”) alongside the visible-sky snapshot.


The Birthday Sky feature needed to answer a small question: “which solar term were you born under?” On a Chinese-speaking surface that question carries real cultural weight — 立春 (Start of Spring), 立秋 (Start of Autumn), 春分 (Vernal Equinox) all anchor traditional cuisine, planting calendars, and folk meanings. People want their birthday named in that vocabulary, not just as a Gregorian date.

The math is older than most calendars. The engineering choices are surprisingly recent.


What a solar term actually is

It is not a date. It is a direction — the apparent ecliptic longitude of the Sun, measured from the vernal equinox.

The Earth’s orbit divides into 24 equal arcs of 15° each. Each arc starts at a named solar term:

Term Ecliptic longitude
春分 (Spring Equinox) 0°
清明 (Pure Brightness) 15°
谷雨 (Grain Rain) 30°
立夏 (Start of Summer) 45°
… …
冬至 (Winter Solstice) 270°
小寒 (Minor Cold) 285°
… …

So “立春 falls on February 4th” is a statement about when the Sun crosses 315° of ecliptic longitude. The civil date is just the readout — it drifts by hours year-to-year because a tropical year is 365.2422 days, not 365.

This matters because the terms are objective; the dates are a derivation. There are two honest ways to compute them.


Option A: the lookup table

The cheapest engineering answer: hard-code every term for every supported year.

const SOLAR_TERMS_BY_YEAR: Record<number, Date[]> = {
    2024: [new Date('2024-01-06T10:49Z'), ...],
    2025: [new Date('2025-01-05T16:33Z'), ...],
    ...
};

24 terms × 200 years = 4,800 entries. Maybe 100 KB shipped, maybe a JSON sidecar. Cons:

  • Outside the table, you have nothing. A user born in 1882 or 2120 gets a fallback string.
  • Annual maintenance: every year you have to extend the table from a reliable source. If you forget, your site silently goes stale.
  • Trust assumption: you’re trusting whatever 200-year ephemeris you imported once. If someone reports a date is off by a day, you have no way to recompute and verify.

This is what most cultural apps actually do, and what we wanted to avoid.


Option B: a full ephemeris

The astronomically pure answer: pull in VSOP87 (Variations Séculaires des Orbites Planétaires), evaluate the Sun’s apparent ecliptic longitude as a function of Julian Date, and root-find each 15° crossing with Newton–Raphson.

// Pseudo-code: find the moment the Sun crosses targetLongitude (radians).
function solveTermMoment(targetLong: number, startJD: number): number {
    let JD = startJD;
    for (let i = 0; i < 8; i++) {
        const lambda = sunApparentEclipticLongitude(JD); // VSOP87 → ~kB of coeffs
        const diff = wrapAngle(lambda - targetLong);
        const dLambda = 2 * Math.PI / 365.2422;          // rad/day, mean rate
        JD -= diff / dLambda;
        if (Math.abs(diff) < 1e-7) break;
    }
    return JD;
}

This gives you accuracy to seconds, valid for thousands of years, with documented error bounds. The downsides:

  • 50–100 KB of coefficients in the bundle (VSOP87 has truncated tables, but even the small ones are large).
  • Implementation complexity: nutation, aberration, frame conversions, light-time. The math is real.
  • Not portable: most of the published JavaScript implementations are GPL or AGPL.

For a backend research tool, this is the right call. For a feature that just needs to say “three days before 立夏”, it’s wildly oversized.


Option C: the Shouxing polynomial (the middle path)

There’s a 1980s-era closed-form approximation, the 寿星公式 (“Shouxing formula”), named after the Chinese astronomer Liu Anguo (刘安国). It encodes each solar term as:

day_of_month = floor(Y · 0.2422 + C) − floor(Y / 4)

Where:

  • Y is the last two digits of the year (e.g. 2024 → 24)
  • 0.2422 is the fractional excess of the tropical year over 365 days
  • C is a per-term, per-century constant
  • The floor(Y/4) correction handles the Gregorian leap-year rule

That’s it. One line of arithmetic per term. The constants are small:

// C21: per-term constants for the 21st century (2000–2099)
const C21 = [
    5.4055, 20.12,    // 小寒、大寒
    3.87, 18.73,      // 立春、雨水
    5.63, 20.646,     // 惊蛰、春分
    4.81, 20.1,       // 清明、谷雨
    5.52, 21.04,      // 立夏、小满
    5.678, 21.37,     // 芒种、夏至
    7.108, 22.83,     // 小暑、大暑
    7.5, 23.13,       // 立秋、处暑
    7.646, 23.042,    // 白露、秋分
    8.318, 23.438,    // 寒露、霜降
    7.438, 22.36,     // 立冬、小雪
    7.18, 21.94,      // 大雪、冬至
];

A second array (C20) covers 1900–1999. The two arrays together are 48 numbers. The arithmetic is two integer floors and a multiplication. Total bundle cost: under 1 KB including the metadata.

The implementation, with the small set of documented exception years baked in:

function termDayOfMonth(year: number, idx: number): number {
    if (year < 1900 || year > 2100) {
        // Smooth interpolation outside the strict domain. Error is at most
        // 1 day for several decades on either side.
        const Y = (year - 2000) % 100;
        const C = C21[idx];
        return Math.floor(Y * 0.2422 + C) - Math.floor(Y / 4);
    }

    const inC21 = year >= 2000;
    const C = (inC21 ? C21 : C20)[idx];
    const Y = (year % 100);
    let day = Math.floor(Y * 0.2422 + C) - Math.floor(Y / 4);

    // The formula is off by exactly one day for these specific
    // (term, year) pairs. Derived empirically by comparing to JPL
    // ephemerides over the 1900–2100 window.
    if (inC21) {
        if (idx === 0  && year === 2019) day += 1;  // 小寒
        if (idx === 1  && year === 2082) day += 1;  // 大寒
        if (idx === 12 && year === 2016) day -= 1;  // 小暑
        if (idx === 15 && year === 2002) day += 1;  // 处暑
    } else {
        if (idx === 8  && year === 1911) day += 1;
        if (idx === 11 && year === 1928) day += 1;
        if (idx === 13 && (year === 1925 || year === 2016)) day += 1;
        if (idx === 16 && year === 1927) day += 1;
        if (idx === 17 && year === 1942) day += 1;
        if (idx === 19 && year === 2089) day += 1;
        if (idx === 22 && year === 1954) day += 1;
        if (idx === 23 && (year === 1918 || year === 2021)) day -= 1;
    }
    return day;
}

12 exception cases, each documented inline. Accuracy: ±1 day vs. JPL ephemerides across 1900–2100, with the listed cases corrected to exact. Outside the supported window the smooth fallback degrades by ~1 extra day per century-or-so — fine for the only thing we use it for, which is naming the user’s birth context.


Why we accept ±1 day

The thing the formula gets wrong is the precise hour the Sun crosses 15°. For 立春 in some years that’s 23:55 on Feb 3rd; in others it’s 00:30 on Feb 4th. The civil date wobbles. Across 200 years there are 12 calendar dates where the formula rounds the wrong way.

For navigation or eclipse prediction that error is unacceptable. For “you were born 3 days before 立夏”, it isn’t. Nobody’s birthday narrative changes because the Sun crossed an ecliptic boundary 35 minutes earlier or later than the polynomial predicts.

This is the engineering lesson. The accuracy you need is set by the consumer of the answer, not by what’s astronomically derivable. Spending 50 KB of bundle on seconds-accurate term times would be the same kind of mistake as serving raw kilometers to a WebGL pipeline expecting unit-scale coordinates.


What you build on top

Once termDayOfMonth exists, the rest is small. List every term for any year:

export function solarTermsInYear(year: number): SolarTerm[] {
    return SOLAR_TERMS.map(([zh, en, month], idx) => ({
        zh,
        en,
        date: new Date(Date.UTC(year, month - 1, termDayOfMonth(year, idx))),
    }));
}

Then locate any date’s contextual term — the latest term that’s already happened, plus the next upcoming one:

export function solarTermContext(date: Date): SolarTermContext {
    const year = date.getUTCFullYear();
    // Pull last year's terms + this year's + next year's so we always
    // have a neighbor on both sides regardless of where in the year we are.
    const all = [
        ...solarTermsInYear(year - 1),
        ...solarTermsInYear(year),
        ...solarTermsInYear(year + 1),
    ];

    const t = date.getTime();
    let lastIdx = 0;
    for (let i = 0; i < all.length; i++) {
        if (all[i].date.getTime() <= t) lastIdx = i;
        else break;
    }
    const current = all[lastIdx];
    const next = all[lastIdx + 1] ?? all[lastIdx];
    const MS = 86_400_000;
    return {
        current,
        daysSinceCurrent: Math.floor((t - current.date.getTime()) / MS),
        next,
        daysUntilNext: Math.max(0, Math.ceil((next.date.getTime() - t) / MS)),
    };
}

Two helpers, ~30 lines. They drive every solar-term feature in the product: the Birthday Sky narrative (“立夏 was three days after you were born — your story’s first season opened with summer”), the day-of-year tooltip in the time scrubber, the Bazi context surfaces.


Cross-cultural mapping

The four cardinal terms align exactly with Western astronomical events:

24-term Ecliptic λ Western event
春分 (Spring Equinox) 0° Vernal equinox; Sun enters Aries
夏至 (Summer Solstice) 90° Summer solstice; Sun enters Cancer
秋分 (Autumn Equinox) 180° Autumnal equinox; Sun enters Libra
冬至 (Winter Solstice) 270° Winter solstice; Sun enters Capricorn

The other 20 are uniquely Chinese — they’re agriculture and weather names, not zodiacal. But the underlying coordinate (multiple of 15° solar longitude) is the same as the boundaries of the 12 tropical zodiac signs, just sampled at a finer pitch. A user’s tropical sun sign and their solar-term context are computed from the same number. Internally we reuse the term computation to also resolve the user’s sun sign for the Birthday Sky narrative — one solar-longitude function, two cultural lenses on the answer.


What we deliberately didn’t do

  • True solar time of day. The formula returns a calendar date (UTC). The actual term moment can fall before or after local midnight, so on the edge day a user born just after midnight in Beijing may technically be born “in” the next term. We don’t disambiguate. For naming purposes, the calendar day is the cultural unit anyway.
  • Lunar date conversion (农历). That’s a separate problem with its own irregularities and is not closed-form — it needs either a lookup table or a real lunar ephemeris. We left that as a TODO.
  • Time-zone awareness. Term dates here are UTC. A purist would render in the user’s locale + Beijing time for cultural alignment. We accept the ~12-hour wobble at locale boundaries.
  • Years before 1900 / after 2100. The smooth fallback returns a value; we don’t surface it confidently. If you build a “what term was Confucius born under” feature, fall back to the VSOP path.

What it weighs

Module Lines
SOLAR_TERMS constant (24 rows, zh/en/month) 26
C20 + C21 constant arrays 30
termDayOfMonth (formula + 12 exception cases) 32
solarTermsInYear + solarTermContext 28
Total ~120

About 80 lines of arithmetic, 40 lines of data. Bundle cost: 1.5 KB un-gzipped, ~600 bytes gzipped. Comparable cost to a single icon SVG. Compared to a 200-year lookup table (~80 KB JSON) or a VSOP87 import (~50 KB JS + dependencies), the savings is 1–2 orders of magnitude.


Lessons for your past self

  1. The accuracy your domain needs is the budget. ±1 day is fine for cultural copy. Don’t ship 50 KB of ephemeris to render “3 days before 立夏” — the user will never see the difference.
  2. Closed-form formulas are not just for textbooks. When a 60-year-old approximation gives you ±1 day from JPL truth across 200 years, that’s not “old math”; that’s a battle-tested compression of 4,800 lookup entries into 48 numbers.
  3. Document your exceptions inline. The 12 outlier years aren’t bugs in the formula — they’re an artifact of the Sun crossing 15° boundaries near midnight in specific years. Naming them in code (with a comment explaining why) is the difference between maintainable and mysterious.
  4. Share the math across cultures, not the data. Tropical sun signs and Chinese solar terms are the same coordinate at different sampling rates. One function, two views — and your bundle stays small.
  5. Don’t deny ranges you don’t support. Outside 1900–2100 we fall back to the smooth formula and trust it. Better than throwing or hiding the feature — most users in the “out of range” tail just want an approximation, not perfection.
  6. Lookup tables are an anti-pattern for cyclic data. Anything periodic — solar terms, moon phases, planet positions — has a closed-form approximation that beats the table on bundle, maintenance, and trust. Bias toward the formula.

Try it

The Birthday Sky scene at /universe/birthday surfaces the closest solar term in the personalized narrative beneath the 3D sky snapshot. Type any birthday — the narrative names your term, the days since/until, and pairs it with the tropical sign and traditional Chinese 时辰 (two-hour Earthly Branch) from the time of day.

If you’re building a calendar product and have questions about the formula, the exception years, the western-zodiac mapping, or the cross-cultural narrative shape — reach out via Contact.

— Read Next —

Recommended Dispatches

More engineering deep-dives into 3D rendering, physics simulation, and game architecture

Get an email when we publish a new post — engineering deep-dives, ~once a month, no marketing.