@0xf3cd/celestial
Astronomical calculations and Gregorian / Chinese Lunar date conversion from CelestialCalendar, as an ESM package that bundles its own WebAssembly module.
This guide describes the current source, including unreleased changes. The install command below gets the latest published version on npm; use its matching documentation. See Migrating to 1.0 for the removed logging API, Sun distance rename and changed error classes.
Install
Install from npm in your application directory:
npm install @0xf3cd/celestial
For each release, the matching GitHub Release also
provides celestial-wasm.zip, which contains a tarball byte-identical to the npm publication and its SHA-256
checksum file.
Node 22 or newer is supported. Browser consumers are tested on Chrome and Firefox. Importing the package performs no
I/O; call init() once and await it before using the synchronous calculation APIs.
Each JavaScript block below is a standalone ES module for example.mjs in your application directory; run it with
node example.mjs from that directory.
Examples
Chart Snapshot at a Fixed Instant
import { init, chart } from "@0xf3cd/celestial";
await init();
const snapshot = chart.snapshot(
{ year: 2026, month: 1, day: 1, fraction: 0.5 },
{
scale: "utc",
location: { latitudeDeg: 51.5, longitudeDeg: 0 },
system: "placidus",
model: "default",
},
);
console.log(snapshot.times); // { jdUt1, jdeTt }
console.log(snapshot.bodies[2]); // Mercury: position, distance and longitude rate
console.log(snapshot.houses.cuspsDeg); // twelve house cusps in house order
civil is a Gregorian date with a day fraction in [0, 1); a JavaScript Date is not converted automatically.
scale is required and is "utc" or "ut1"; input before 1972-01-01 must select scale: "ut1" explicitly.
Civil years are limited to 1885–2099. Latitude is north-positive degrees in (-90, 90); longitude is east-positive
degrees in [-180, 180]. system is required and is "equal", "wholeSign", or "placidus".
Omitting model, or setting it as an own property to undefined or "default", selects Algo5; "algo1" through
"algo5" select a model explicitly. Input fields must be own properties: an inherited model throws instead of
silently falling back to the default model. options accepts only the four keys scale, location, system, and
model; a misspelled or extra key throws TypeError.
The result contains times: { jdUt1, jdeTt } and fourteen bodies in a fixed order, whose target values are
sun, moon, mercury, venus, mars, jupiter, saturn, uranus, neptune, pluto, meanAscending,
meanDescending, trueAscending, and trueDescending. Each member has longitudeDeg, latitudeDeg,
distanceAu, and longitudeRateDegPerTtDay. All members use the true ecliptic and equinox of date: the Sun, Moon,
and planets give apparent geocentric coordinates; the nodes give mean / true node longitudes with zero latitude and
null for distanceAu. All distances are in AU, including the Moon's; the standalone
moon.apparentGeocentricCoordinate() gives the Moon's distance in kilometres. longitudeRateDegPerTtDay is the
instantaneous longitude rate in degrees per TT day: a negative value means the longitude is decreasing at that
instant (retrograde motion).
houses contains ascendantDeg, midheavenDeg, descendantDeg, imumCoeliDeg, and cuspsDeg, with every angle
in [0, 360) degrees. The result is made of independent plain objects and arrays; later calls or WASM memory growth
do not change an earlier result.
The core checks the converted JDE(TT) against the chart range [2409543.875, 2488069.125); Algo4 also requires
derived JDE < 2464328.5, checked before reverse conversion. This deliberately excludes the final roughly 71.5
UT1 seconds of 2034 under Algo4. Common civil/model errors may precede a chart joint-window error.
UT1 derived from UTC is modelled, not observed DUT1; future UTC holds the last known leap-table offset. See
Time scales. Ascendant
directions that degenerate at round-off scale, and Placidus polar cases, fail. Houses follow the house operation's
nutation default, currently IAU 1980; body corrections remain fixed IAU 1980. Type or record-shape errors throw
TypeError; invalid dates, day fractions, locations, option strings or native-domain rejections throw RangeError; calling before
initialization, allocation failure, native calculation failure, or a malformed native success record throws
CelestialError, with operation set to "chart.snapshot". recorded is true when native reported a recognized
error kind, even if its message is empty. A failure never returns a partial snapshot.
Today's Lunar Date
Use /date to choose the fixed UTC+8 civil date, then convert it with the selected algorithm; "today" here does not
depend on the host time zone. Destructure only the year, month, and day: the full civil record contains fraction,
which a Gregorian date input must not carry.
import * as celestial from "@0xf3cd/celestial";
import { dateToCivilAtOffset } from "@0xf3cd/celestial/date";
await celestial.init();
const { year, month, day } = dateToCivilAtOffset(new Date(), 480);
const gregorian = { year, month, day };
const lunar = celestial.lunar.fromGregorian("algo3", gregorian);
console.log(gregorian, lunar);
The date bridge does not convert clock UTC to UT1 or TT. For the lunar date basis and algorithm year domains, see Lunar Algorithms.
UTC and JDE(TT) conversions
time.utcToJde() and time.jdeToUtc() are available from 1.1.0. Before 1972 they treat UTC civil fields as UT1
using Algo5, the fixed ΔT model; chart queries still reject pre-1972 UTC. Leap-second boundaries are not generally
invertible. After the leap table's last entry, these conversions retain its final TT-minus-UTC offset.
import { init, time } from "@0xf3cd/celestial";
await init();
const utc = { year: 2026, month: 10, day: 28, fraction: (19 * 3600 + 35 * 60 + 37) / 86400 };
const jdeTt = time.utcToJde(utc);
console.log(time.jdeToUtc(jdeTt));
fraction is a fraction of a day (0.5 is noon). JDE is a floating-point day count, so the converted civil
fields can differ slightly from the input.
For UTC input → next Jieqi → UTC+8 display, see the guide with runnable Python and JavaScript programs. It also gives the separate forward/inverse input domains and noninvertible boundaries.
Next Jieqi and Remaining Days
The supplied cutoff is 2026-12-31 00:00 UT1. No Date timestamp is treated as UT1.
import * as celestial from "@0xf3cd/celestial";
await celestial.init();
const cutoffUt1 = { year: 2026, month: 12, day: 31, fraction: 0 };
const cutoffJd = celestial.time.ut1ToJd(cutoffUt1);
const upcoming = [2026, 2027]
.flatMap((year) => Object.values(celestial.Jieqi).map((jieqi) => {
const event = celestial.jieqi.moment(year, jieqi);
return { ...event, jdUt1: celestial.time.ut1ToJd(event.momentUt1) };
}))
.filter((event) => event.jdUt1 > cutoffJd)
.sort((a, b) => a.jdUt1 - b.jdUt1)[0];
if (upcoming === undefined) throw new Error("No later Jieqi in the queried years.");
console.log(celestial.jieqi.name(upcoming.jieqi));
console.log("UT1:", upcoming.momentUt1);
console.log("Remaining UT1 days:", upcoming.jdUt1 - cutoffJd);
This enumerates all terms in both years and orders them by moment, not enum value. Remaining days are fractional
UT1 days. momentUt1 is UT1, not UTC or a UTC+8 wall clock. This is a fixed contemporary query across one year
boundary, not a search algorithm for the entire API domain; the query-year domain and failure conditions of
jieqi.moment() are listed under API Contract.
Moon Illumination and a Jieqi Moment
2448724.5 is a JDE on TT. The Jieqi result's momentUt1 is a UT1 civil moment, not a UTC+8 display time.
import * as celestial from "@0xf3cd/celestial";
await celestial.init();
const moon = celestial.moon.illumination(2448724.5);
const lichun = celestial.jieqi.moment(2026, celestial.Jieqi.LICHUN);
console.log(moon.fraction, lichun.momentUt1, celestial.jieqi.name(lichun.jieqi));
Gregorian / Lunar Round Trip
import * as celestial from "@0xf3cd/celestial";
await celestial.init();
const result = celestial.lunar.fromGregorian("algo3", { year: 2026, month: 8, day: 15 });
console.log(result);
console.log(celestial.lunar.toGregorian("algo3", result));
Initialization and Browser Deployment
Concurrent and repeated init() calls share one initialization promise, resolving to undefined. A failed load
rejects with the loader's original error, and a later explicit call may retry. Before initialization completes,
namespace methods throw CelestialError before argument validation, with operation set to the public method name
and recorded === false, even for zero-count requests.
For browser deployment, make sure the package's .wasm asset is reachable at the URL your build output points to,
and have the static server send it with Content-Type: application/wasm.
API Contract
The root entry provides the time, sun, moon, jieqi, lunar, and chart namespaces, and the
package ships TypeScript declarations. The package uses only the jieqi / lunar names, with no solarTerms /
lunarCalendar aliases. Model and Moon phase options are string-literal unions such as "full" and
"algo3". There is no logging configuration or init configuration argument. Independent position, equatorial,
house, event, capability and model-text queries are not public JavaScript APIs.
Jieqi is a frozen object of 24 named constants, from Jieqi.LICHUN = 0 to
Jieqi.DAHAN = 23, spelled as in the Python package. The TypeScript type Jieqi is the matching
0 | 1 | ... | 23 union; a plain number variable must be narrowed before it is passed to jieqi.moment() or
jieqi.name(). jieqi.name() returns the Chinese name, for example "立春" for Jieqi.LICHUN.
Keep time scales and units distinct:
- Operation names for JD inputs and outputs distinguish UT1 from JDE (TT).
jieqi.moment()returns{ jieqi, momentUt1 }, using fixed Algo5 for TT-to-UT1 even if a selectable-model default changes. Its civil moment is UT1, not UTC or a UTC+8 wall clock; the same instant shown in UTC+8 can fall on a different date. Establish the time-scale conversion before displaying it as UTC or a fixed-offset time. The returned UT1 year can differ from the requested year.sun.apparentSolarTime()takes a UTC civil moment and an east-positive geographic longitude in[-180, 180]degrees.- Angle results are in degrees. Sun distance is
distanceAuin AU. Moon distance frommoon.apparentGeocentricCoordinate()is in kilometres;chart.snapshot()gives every distance, the Moon's included, in AU. time.deltaT()returns seconds.- The equation of time is in degrees of hour angle; multiply by 240 for seconds of time.
sun.apparentGeocentricCoordinate(jde)andmoon.apparentGeocentricCoordinate(jde)each return one coordinate record for the given TT-based JDE.
jieqi.moment(year, index) accepts a Gregorian year in [401, 32766] and a Jieqi index in [0, 23].
Even with in-range input, it throws CelestialError when the native calculation cannot produce a unique moment.
sun.longitudeCrossings(year, longitudeDeg), moon.phaseMoments(year, phase), and moon.newMoonsInYear(year)
accept Gregorian years in [1, 32766].
time.localApparentSiderealTime(jdUt1, longitudeDeg) accepts a finite JD on UT1 whose Gregorian year is in
[401, 32766], and a finite, east-positive geographic longitude in [-180, 180] degrees. It returns degrees in
[0, 360). The native boundary checks the year range; leaving it throws RangeError, just as the JavaScript
finiteness and longitude checks do. This operation retains fixed Algo5 ΔT and fixed IAU 1980 nutation.
sun.longitudeCrossings(year, longitudeDeg) accepts a finite apparent geocentric solar longitude in [0, 360)
degrees. It returns an array of TT-based JDEs, or [] when the Sun does not reach that longitude in the year.
After initialization, wrong shapes and types throw TypeError. Invalid input or a declared-domain rejection
throws RangeError, whether found in JavaScript or native code. Computation/resource/internal failures and
malformed native success records throw CelestialError, whose operation is the public method name and whose
recorded is true for a recognized native error kind, even when its message is empty; binding-local failures set
it to false. A legitimate absence remains null or [].
Finite position inputs can still fail computation; coordinate validity promises neither physical plausibility nor accuracy at remote epochs. There is no new universal position-year or obliquity cutoff. Caller-owned WASM output buffers are limited to 16 MiB by the binding.
moon.newMoonsAfter(jde, count) accepts count in [0, 4096]; zero returns [].
The cap keeps the WASM output buffer within 32 KiB.
Date Records
The three input kinds below are mutually exclusive, both in TypeScript and at runtime:
| Type | Required own fields | Excluded fields |
|---|---|---|
GregorianDate |
year, month, day |
fraction, isLeap |
CivilDateTime |
year, month, day, fraction |
isLeap |
LunarDate |
year, month, day, isLeap |
fraction |
Civil-moment and Gregorian-date inputs need an integer year and a valid Gregorian month and day. The native operation
determines its admitted year window; the /date bridge requires civil years in [1, 32767].
A lunar year and date must exist in the selected algorithm. fraction is a finite day fraction in
[0, 1). The runtime allows unrelated extra properties, but rejects an excluded field even when it is inherited or
set to undefined. With TypeScript's exactOptionalPropertyTypes enabled, type checking also rejects an explicit
undefined on the optional never fields.
Native GregorianDate and CivilDateTimeResult results use astronomical years in [-32767, 32767], with year 0
denoting 1 BCE. CivilDateTimeResult adds an integer hour and minute and a possibly fractional second to CivilDateTime.
time.jdeToUt1(), sun.apparentSolarTime(), and JieqiMoment.momentUt1 use this output.
These fields are derived from the day fraction and are not rounded to milliseconds; second counts seconds, not
milliseconds. A result can be reused as a civil-moment input when it fits the receiving operation's input window.
Functions read fraction, not the derived clock fields. The operation name, not this shared record type, says which time scale applies.
Record inputs are recognized by shape: explicit own fields and excluded tags decide the kind, not the constructor,
and Date methods or timestamps are never read. A plain Date lacks the required fields; use the date bridge below
to turn a timestamp into civil fields. A civil-moment result or a lunar date cannot be passed directly to
lunar.fromGregorian(); choose the date basis first, then build a GregorianDate explicitly.
Fixed-Offset Date Bridge
@0xf3cd/celestial/date is a pure calendar bridge with no runtime dependencies. It does not import the root entry or
load WASM, and needs no init(). Offsets are minutes east of UTC; 480 selects fixed UTC+8.
import { civilAtOffsetToDate, dateToCivilAtOffset } from "@0xf3cd/celestial/date";
const date = new Date("2024-02-09T16:00:00.123Z");
const eastEight = dateToCivilAtOffset(date, 480);
const restored = civilAtOffsetToDate(eastEight, 480);
console.log(eastEight);
console.log(restored.toISOString(), restored.getTime() === date.getTime());
Here 2024-02-09T16:00:00.123Z becomes the UTC+8 civil date 2024-02-10, 00:00:00.123. The same-offset round trip
preserves the Date millisecond timestamp, so the last line ends with true.
| Function | Input | Output |
|---|---|---|
dateToCivilUtc(date) |
Date |
CivilDateTimeResult in UTC |
civilUtcToDate(civil) |
CivilDateTime in UTC |
Date |
dateToCivilAtOffset(date, offsetMinutesEast) |
Date, fixed offset |
CivilDateTimeResult at that offset |
civilAtOffsetToDate(civil, offsetMinutesEast) |
CivilDateTime at a fixed offset |
Date |
offsetMinutesEast is a safe integer in [-1439, 1439], in minutes, positive east of UTC.
Conversion uses epoch arithmetic and UTC fields, never the host's local time zone, Intl, locale-dependent parsing,
IANA zones, or DST rules.
Converting a Date to a civil moment keeps the host value's millisecond resolution; the fractional second carries
no extra sub-millisecond information. Converting a civil moment to a Date rounds the non-negative local time of day
to the nearest millisecond, rounds an exact half millisecond toward the later civil moment, and handles carry into
the next day explicitly. For any representable Date whose civil year at the chosen offset is in [1, 32767], a
Date -> civil -> Date round trip at the same offset preserves getTime() exactly.
The year bounds constrain the local civil record, not necessarily the UTC value that carries it. Both the input local
year and the local year after rounding carry must be in [1, 32767]; rounding the end of local year 32767 into 32768
is rejected. At the offset edges, the returned UTC Date can have year 0 or 32768 and still converts back with the
same offset. The UTC-only functions apply the year domain to the UTC civil fields.
Wrong types, missing civil fields, mixed date kinds, and non-integer date or offset fields throw TypeError.
An invalid Date, an invalid Gregorian date, non-finite or unsafe numbers, an out-of-range day fraction or offset,
and a local year outside the domain throw RangeError.
The bridge converts no time scales and does not handle leap seconds; it provides neither Date to JDE nor UTC to UT1
conversion. Do not pass momentUt1 to civilUtcToDate() as though it were UTC: UT1 is not UTC, and the library
does not model observed DUT1.
Time scales explains how far
apart the two can be.
Lunar Algorithms
Select "algo1", "algo2" or "algo3". Gregorian inputs and outputs are calendar-date labels, not instants.
For sources, date bases, Lunar-year domains and selection roles, see
Lunar algorithms.
lunar.supportedYearRange(algorithm) queries the native algorithm's inclusive range of lunar years, not Gregorian
January-to-December bounds. lunar.yearInfo(algorithm, year) returns the Gregorian firstDay, the traditional
leapMonth number or null, and monthLengths in calendar order, with a leap month directly after its ordinary
namesake. lunar.toGregorian() takes a traditional month number in [1, 12] and an explicit isLeap boolean, not a
positional month index. The native operation validates the selected algorithm's lunar year and date;
the JavaScript package keeps no separate range table or cache.
All four lunar methods require await init() first; a call before initialization completes throws CelestialError
with recorded === false, before argument validation. After initialization:
TypeErrormeans a wrong input type, a missing own field, an excluded date-kind tag, a non-integer date field, or a non-booleanisLeap.RangeErrormeans an unknown algorithm, a non-finite or unsafe number, an invalid Gregorian date, a lunar year outside the native range, or a lunar month/day outside[1, 12]/[1, 30].RangeErroralso covers native invalid-input rejections, including a Gregorian date label outside the selected algorithm's coverage or a lunar leap-month/day combination that does not exist.CelestialErrormeans computation/resource/internal failure or a malformed native success record, withoperationnaming the caller, such as"lunar.yearInfo", rather than an internal range query.
For example, lunar.fromGregorian("algo1", { year: 1900, month: 1, day: 1 }) passes Gregorian validation, but algo1
cannot represent that date, so it throws RangeError; lunar.yearInfo("algo1", 1900) also throws RangeError.
Delta T Models
time.deltaT(year, model) returns TT minus UT1 in seconds for a finite decimal Gregorian year.
Omit model or pass "default" to select Algo5; "algo1" through "algo5" select a model explicitly.
Sources, maintenance status, hard domains and selection roles are in
Delta T models.
License
Project-authored package material is licensed under MIT.
Bundled third-party components retain their own terms in THIRD_PARTY_NOTICES.txt.