ephemeris 1.0.0-beta.6
ephemeris: ^1.0.0-beta.6 copied to clipboard
Idiomatic Dart bindings for the Taiyin ephemeris C API.
Ephemeris for Dart ("Taiyin") #
Pre-release: the current package line is
1.0.0-beta.6. Public APIs and native packaging may still change before1.0.0.
An idiomatic Dart wrapper around the versioned C ABI in
taiyin-ephemeris.
Install it with:
dart pub add ephemeris
Generated API reference: https://pub.dev/documentation/ephemeris/latest/
For a task-oriented introduction, start with the documentation index. This README remains the broad API overview; the guides explain common workflows, configuration choices, data routing, custom callbacks, and isolate ownership with smaller examples.
For a typical major-body OPM2 product, reconstruction differs from its source DE441/DE442 states by roughly 0.001 arcsec. This is a compression metric, not a blanket end-to-end accuracy guarantee. The Dart package can load OPM2 files and original NASA/JPL BSP/SPK kernels; without an external precision source it can use the built-in semi-analytical fallback. A separately downloadable full-range DE441 OPM2 product is available and is not bundled in this prerelease. See accuracy and ephemeris data.
This repository is a pub workspace with three packages, mirroring the Python binding's monorepo layout:
ephemeris(this package) — the core ephemeris: runtime, time, positions, stars, visibility, events, eclipses, Chinese calendar, and Ganzhi. It also bundles the pinned native library underlib/native/and a lite fixed-star catalog underlib/data/; both load automatically.ephemeris_bazi— the optional BaZi (八字) extension. Importing it addscontext.bazi/context.createBazi()toEphemerisContext.ephemeris_ziwei— the optional Ziwei Doushu (紫微斗数) extension, with the default rule profile bundled underlib/data/ziwei/rules/. Importing it addscontext.ziwei/context.createZiwei()toEphemerisContext.
The core package deliberately has two layers:
lib/src/bindings/taiyin_bindings.g.dartis generated byffigenand stays private; extension packages consume it throughpackage:ephemeris/ffi.dart.lib/ephemeris.dartexposes Dart enums, immutable results, exceptions, and safe native context ownership.
Platform support #
This package supports Dart Native only through dart:ffi. This prerelease
bundles ready-to-load native modules for macOS arm64, Linux x64, and Windows
x64. Applications on another native platform or architecture can provide a
compatible Taiyin build through an explicit path or the platform loader. Dart
Web and Flutter Web are not currently supported: browsers cannot load the
native C ABI shared library. A future Web target would require a separate
WebAssembly/JavaScript binding and packaging path.
Build the native library #
From taiyin-ephemeris:
cmake -S . -B build-c-api-release -DCMAKE_BUILD_TYPE=Release
cmake --build build-c-api-release --target taiyin_c
Use #
import 'package:ephemeris/ephemeris.dart' as taiyin;
void main() {
final ephemeris = taiyin.Ephemeris.open();
final context = ephemeris.createContext();
try {
final moonResult = context.position.atTt(
taiyin.Body.moon,
taiyin.TtJulianDate.fromDouble(2460409.0),
flags: {
taiyin.PositionFlag.xyz,
taiyin.PositionFlag.speed,
},
);
print(moonResult.value.coordinates);
print(moonResult.value.rates);
print(moonResult.flags.values);
print(context.lastDiagnostic?.attemptedMethodId);
} finally {
context.close();
}
}
libraryPath can be omitted: the package first tries the
TAIYIN_LIBRARY_PATH environment variable, then the shared library bundled
under lib/native/, and finally the platform-standard loader name.
Naming and imports #
The public API matches the Python binding and drops the old Taiyin prefix, so
most types have short bare names (Body, Position, Time, Vector3,
Ganzhi, BaziChart…). Dart imports place every exported name into the
importing library's scope — there is no Python-style from taiyin import X
granularity — so a bare Vector3 can collide with vector_math, a Position
with another package's, and so on.
Recommended default: prefix the import.
import 'package:ephemeris/ephemeris.dart' as taiyin;
final ephemeris = taiyin.Ephemeris.open();
final context = ephemeris.createContext();
final moon = context.position.atTt(
taiyin.Body.moon,
taiyin.TtJulianDate.fromDouble(2460409.0),
).value;
context.close();
Prefixing makes collisions impossible. Note that collisions only error on the line that actually uses a bare conflicting name — importing the full package never errors by itself — but the prefixed form removes the question entirely.
Reach for the prefixed form in any file that coexists with other packages or defines its own classes. Alternatively, pull only the names a file uses:
import 'package:ephemeris/ephemeris.dart'
show Ephemeris, EphemerisContext, Body, Position, PositionFlag;
The snippets elsewhere in this README use plain imports for readability; in real code that coexists with other packages, prefer the prefixed form above.
Runtime and release metadata #
Ephemeris exposes its semantic version and major-release codename independently:
print(ephemeris.libraryVersion); // 1.0.0-beta.8
print(ephemeris.libraryCodename); // Singularity
The Ephemeris object represents the process-wide native engine. It manages
ephemeris sources, Earth-orientation data, the lunar-limb model, and the
ephemeris segment cache. Finish global setup before starting concurrent
calculations:
ephemeris
..addSourcePath('/path/to/ephemeris-data')
..loadBuiltinEopTable()
..clearEphemerisCache();
print(ephemeris.catalogSize);
print(ephemeris.cacheEntryCount);
Ephemeris.open() hides the native runtime initialization step; ordinary callers
only open the engine. Call it once in the application's main isolate: every
current open() call reinitializes the process-wide runtime. Worker isolates
must use Ephemeris.attach() instead. attach() loads the same native library
in that isolate and creates a Dart runtime facade, but deliberately does not
initialize or replace the already configured process-wide runtime, catalog, EOP
table, or caches.
ephemeris.createContext() creates an independent EphemerisContext for one
user or calculation policy. This is the same operation in every isolate: a
worker calls Ephemeris.attach() first and then calls createContext() on that
facade. Closing a context releases only that context; it does not reset
process-wide runtime data.
Time values #
Calendar values preserve astronomical year numbering and integer nanoseconds:
final utcCalendar = AstroDateTime(
2026, 7, 19, 12, 34, 56, 123456789,
);
final utc = utcCalendar.toUtcJulianDate();
final dartInstant = DateTime.parse('2003-03-13T14:15:00+08:00');
final dartUtc = dartInstant.toUtcJulianDate();
final scales = context.time.scalesFromUtc(utcCalendar);
final tt = scales.value.value.tt;
final ut1 = scales.value.value.ut1;
final tdb = scales.value.value.tdb;
// Event searches usually return UT1. Convert to UTC before displaying a
// modern civil timestamp, or format the coordinate explicitly as UT1.
final utcAgain = context.time.ut1ToUtc(ut1);
final utcClock = context.time.utcCalendarFromUt1(ut1);
final ut1Clock = context.time.calendarFromUt1(ut1);
DateTime.toUtcJulianDate() is the single adapter for Dart's built-in time
type. It converts through microsecondsSinceEpoch, so a local DateTime and
the corresponding toUtc() value produce the same physical instant. Prefer an
explicit offset or DateTime.utc(...) in portable source code; a constructor
such as DateTime(2024, 2, 10, 12) uses the host machine's local timezone.
Code that already stores a Unix timestamp can call
utcJulianDateFromUnixMicroseconds(value) directly; its unit is deliberately
part of the function name.
The adapter does not read or override a Chinese-calendar context. When
dartUtc is passed to fourPillarsInstant(), calculateInstant(), or another
calendar extension, that bound context still decides the local timezone and
day-boundary policy. Dart DateTime is limited to microseconds; use
AstroDateTime when nanoseconds or astronomical/BCE calendar fields matter.
In short, use DateTime plus an ...Instant() entry point when an instant is
already known; use AstroDateTime plus an ...Local() entry point when the
context should interpret wall-clock fields under its configured calendar
policy.
JulianDate<S> stores an integer day and a normalized fractional day. Its time
scale is part of the Dart type, so a Ut1JulianDate cannot be passed to
positionTt. The seven public aliases (UtcJulianDate, TaiJulianDate,
TtJulianDate, Ut1JulianDate, TdbJulianDate,
LocalMeanSolarJulianDate, and LocalApparentSolarJulianDate) shorten type
annotations and constructors; aliases do not convert between scales.
toJulianDate<S>() only interprets calendar fields in that scale. For physical
UTC/TAI/TT/UT1/TDB conversion, use context.time.scalesFromUtc() or the
explicit conversion methods on context.time; those paths apply leap-second,
EOP, Delta-T, and TDB-model data as required. The split representation crosses
the FFI boundary end to end: the ABI-10 native entry points use
taiyin_split_julian_date for every calculation time, so the Dart value is
never merged to a scalar double mid-calculation.
Strict UTC conversion throws EarthOrientationDataError when no EOP table is
loaded or the requested instant is outside its coverage, and
LeapSecondDataError when leap-second data is unavailable. Both extend
TimeScaleError. Call context.time.setAllowUtcOutOfRangeEstimate(true) only
when an explicit UT1 + Delta-T fallback is acceptable; successful fallback is
reported through ResultFlag.timeScaleFallback rather than hidden.
Automatic reverse conversion is available through taiToUtc(), ttToUtc(),
ut1ToUtc(), and tdbToUtc(). The corresponding automatic UT1 routes are
utcToUt1(), taiToUt1(), ttToUt1(), and tdbToUt1(). Passing
dut1Seconds to utcToUt1() or deltaTSeconds to ttToUt1() keeps the
explicit-offset route. ut1ToUtc() is strict by default and follows the same
explicit estimate policy above when EOP coverage is unavailable.
Automatic TAI/TT/TDB-to-UT1 conversion goes through TT directly, so an allowed
historical Delta-T fallback does not require a leap-second table. TAI/TT/TDB
reverse conversion of an inserted UTC leap second throws
UtcLeapSecondRepresentationError, because UtcJulianDate cannot preserve
the second: 60 label without changing the instant. UT1 alone cannot
distinguish that label from the following representable midnight in the
bundled model, so ut1ToUtc() resolves the ambiguous coordinate to midnight.
Julian-day convention #
Every Julian day produced or consumed by AstroDateTime follows the standard
Julian-day coordinate convention. A split value's Dart type identifies its
time scale; scalar Julian days are untyped. Formatting or applying a civil
offset does not physically convert UTC, TAI, TT, UT1, or TDB.
A standalone AstroDateTime civil value does not identify an instant; its
relationship to a standard Julian day is decided by the caller-supplied
utcOffsetHours argument:
final birthBeijing = AstroDateTime(2024, 2, 10, 12); // 12:00 UTC+8 wall clock
final instantJd = birthBeijing.toJulianDay(utcOffsetHours: 8); // → standard JD of the true instant
final j2000 = birthBeijing.toJ2000(utcOffsetHours: 8); // → days from J2000.0
final instantSplit = birthBeijing.toUtcJulianDate( // split, full precision
utcOffsetHours: 8,
);
final backToBeijing = AstroDateTime.fromJulianDay( // standard JD → 12:00 UTC+8 wall clock
instantJd,
utcOffsetHours: 8,
);
toJulianDay / toJ2000 / toJulianDate subtract the offset (civil →
instant); fromJulianDay / fromJulianDate / fromJ2000 add it (instant →
civil). An omitted offset treats the civil fields as a UTC reading. The offset
is a conversion argument, never a stored property on AstroDateTime —
which is why true-solar-time values (whose relationship to UTC depends on the
date, not a fixed zone) fit the same type without lying. Feed toJulianDate's
split result straight to a calculation; the scalar toJulianDay merge is exact
only to about 40 µs, so prefer the split form when nanosecond precision
matters. The uniform 86,400-second-day model cannot display a leap second
(second: 60); a non-zero offset absorbs it before the shift, so read
leap-second instants as UTC. For converting a UTC civil instant into other
scales use the context-owned time service below.
Use the context-owned time service for actual scale conversion:
final utcCalendar = AstroDateTime(2000, 1, 1);
final scalesResult = context.time.scalesFromUtc(utcCalendar);
final scales = scalesResult.value;
print(scales.utc);
print(scales.ut1);
print(scales.tt);
print(scales.tdb);
print(scales.diagnostic.route);
print(scalesResult.flags.values);
Time also exposes explicit UTC/TAI/TT/UT1/TDB conversions, Delta-T
estimation, precise conversions with caller-supplied TAI−UTC and DUT1, and
context policy/model configuration. Calendar conversion, UTC/TAI/TT/UT1/TDB
conversion, and aggregate time-scale results all use Taiyin's split-Julian-Date
C ABI, preserving sub-microsecond coordinate separation across the FFI
boundary. This is a time-coordinate guarantee, not a claim that the current
ephemeris calculation core resolves positions or events at that scale.
Values and result flags #
Native ABI-10 calculations return an immutable named record:
typedef OperationResult<T> = ({T value, ResultFlags flags});
Use .value when only the answer matters, or destructure both fields when the
execution route matters:
final (value: mars, flags: resultFlags) = context.position.atUt1(
Body.mars,
Ut1JulianDate.fromDouble(2460310.5),
);
print(mars.coordinates);
if (resultFlags.contains(ResultFlag.fallbackOccurred)) {
print('The requested route used a fallback.');
}
Fatal native statuses throw a typed EphemerisError subclass such as
EphemerisRouteError, DataFileError, TimeScaleError, or
EventSearchError. error.resultFlags preserves execution facts reported
before failure. context.lastResultFlags and context.lastDiagnostic are
debugging conveniences; in concurrent code, trust the record or exception
returned by that specific call rather than mutable “last call” state.
Positions and Cartesian states #
context.position exposes single-target and batch calculations at TDB, TT, UT1,
explicit Delta-T, and UTC inputs. Each call publishes its native ephemeris
diagnostic to context.lastDiagnostic. Cartesian states include position in AU, velocity in AU/day, and
acceleration in AU/day². The current native position/state engine accepts one
absolute double Julian date, so split Dart coordinates are intentionally
quantized at this calculation boundary (about 40 microseconds near the present
epoch).
The automatic route compares the compatible provider, model, source-priority,
frame, center, and coverage metadata in the runtime catalog; it does not apply
a blanket “SPK always beats OPM2” rule. To select the built-in model explicitly,
configure the context with RouteRule.semiAnalytic. The model covers
approximately calendar years -3000 through +3000.
For example:
final state = context.position.stateAtTt(
Body.moon,
TtJulianDate.fromDouble(2460409.0),
).value;
print(state.positionAu);
print(context.lastDiagnostic?.frame);
Single-target failures throw EphemerisError with its native diagnostic
attached. Every call also publishes its native diagnostic snapshot to
context.lastDiagnostic. Batch calls fail atomically if any requested body
fails; the exception's diagnostics list retains every per-target diagnostic
and context.lastDiagnostic identifies the primary failed target.
The context.positionTt and context.positionUt conveniences remain
available and delegate to this module.
Sidereal positions and houses #
context.astrology calculates built-in ayanamshas, sidereal positions, and
houses. siderealPositionAtTt/siderealPositionAtUt1 are the compact,
ecliptic-spherical APIs: they always use radians and intentionally reject
equatorial and Cartesian flags. Use siderealCoordinatesAtTt or
siderealCoordinatesAtUt1 when you need equatorial, xyz, or both. Their
result explicitly reports the selected sidereal ecliptic frame, or—with
equatorial—a Swiss Ephemeris-compatible tropical mean/true equator-of-date
frame. noNutation has no effect on the sidereal ecliptic path; on the
equatorial path it selects the mean rather than true equator; ayanamsha and
sidereal precession policy do not affect that path. referencePlane selects
the ordinary ecliptic of date, fixed J2000 ecliptic, a fixed ecliptic at a
typed TT/UT1 epoch, or the solar-system invariable plane.
meanEclipticAtEpoch and solarSystemInvariable require
SiderealReferenceEpoch.tt(...) or .ut1(...); meanEclipticJ2000
is fixed at J2000.0 and does not accept an epoch. Fixed and invariable planes
are full 3-D rotations, not longitude offsets. Request
PositionFlag.speed to include rates. Time-based houses need an observer
configured on the context, while housesFromArmc accepts
explicit ARMC, latitude, and true obliquity. Returned house numbers are
one-based. House cusps are a zero-indexed list of length 12: index i is the
cusp of house i + 1.
The same module exposes geocentric lunar points: lunarTrueNodeAtTt /
lunarMeanNodeAtTt (and UT1 counterparts) return ascending or descending
node directions; lunarMeanApogeeAtTt, lunarOsculatingApogeeAtTt, and
lunarFittedApogeeAtTt distinguish the conventional mean, instantaneous
two-body, and DE441 fitted-natural apogee conventions. Their angular values
and rates are always radians; the mean apogee has no physical distance and is
represented with nullable distance fields.
context.configuration.setObserverLocation(
const ObserverLocation(
longitudeDegrees: 116.3833,
latitudeDegrees: 39.9167,
),
);
final siderealMoon = context.astrology.siderealPositionAtTt(
Body.moon,
TtJulianDate.fromDouble(2460409.0),
ayanamsha: Ayanamsha.lahiri,
).value;
final fixedJ2000SiderealMoon = context.astrology.siderealCoordinatesAtTt(
Body.moon,
TtJulianDate.fromDouble(2460409.0),
referencePlane: SiderealReferencePlane.meanEclipticAtEpoch,
referenceEpoch: SiderealReferenceEpoch.tt(
TtJulianDate.fromDouble(2451545.0),
),
).value;
final siderealMoonRa = context.astrology.siderealCoordinatesAtTt(
Body.moon,
TtJulianDate.fromDouble(2460409.0),
ayanamsha: Ayanamsha.lahiri,
flags: {
PositionFlag.equatorial,
PositionFlag.speed,
},
).value;
final houses = context.astrology.housesAtUt1(
Ut1JulianDate.fromDouble(2460311.0),
system: HouseSystem.placidus,
).value;
final moonHouse = context.astrology.housePositionOf(
houses,
siderealMoon.siderealLongitudeRadians,
).value;
The current native astrology calculations take a scalar absolute Julian date, so typed split Dart coordinates are intentionally quantized at the physical calculation boundary (about 40 microseconds near the present epoch).
Custom ayanamsha and house-system models can also be backed by Dart callbacks. They are process-wide setup-time registrations: keep the handles alive, close them before discarding the callbacks, and do not change registrations while any isolate is calculating.
import 'dart:math' as math;
double customAyanamsha(CustomAyanamshaRequest request) => 0.123;
List<double> customCusps(CustomHouseSystemRequest request) {
final step = 2 * math.pi / 12;
return [
for (var i = 0; i < 12; i++)
(request.ascendantRadians + i * step) % (2 * math.pi),
];
}
final ayanamsha = ephemeris.registerCustomAyanamshaModel(
10001,
evaluator: customAyanamsha,
);
final houses = ephemeris.registerCustomHouseSystemModel(
10001,
evaluator: customCusps,
fallback: HouseSystem.porphyry,
);
try {
final value = context.astrology.ayanamshaAtTt(
TtJulianDate.fromDouble(2460409.0),
ayanamsha: ayanamsha.model,
).value;
final chartHouses = context.astrology.housesAtUt1(
Ut1JulianDate.fromDouble(2460311.0),
system: houses.model,
).value;
} finally {
houses.close();
ayanamsha.close();
}
Opening the process runtime again, or calling either clearCustom…Models
method, closes the matching Dart handles because native callback pointers have
already been removed. Callbacks may run for worker-isolate calculations, so
their closures may capture only transitively immutable state.
Custom negative target IDs can be backed by Dart evaluators. Register them once on the process-wide runtime before starting concurrent calculations:
List<double> virtualPoint(CustomTargetRequest request) => [
1.0,
2.0,
3.0,
0.0,
0.0,
0.0,
];
final registration = ephemeris.registerCustomTarget(
-200001,
positionEvaluator: virtualPoint,
);
final result = context.position.atTt(
registration.target,
TtJulianDate.fromDouble(2460409.0),
flags: {PositionFlag.xyz},
).value;
registration.close();
An optional stateEvaluator supplies an exact Cartesian state; otherwise the
native runtime uses finite differences. An evaluator can call
request.positionOf(...) to calculate a dependency with the borrowed native
context at the same TDB/TT epoch.
Evaluators may run for calculations started by worker isolates, so their
closures may capture only transitively immutable values and must not read
isolate-local mutable globals. The Dart VM rejects mutable captures during
registration. Keep each registration handle alive and call close() before
discarding it. Registration and closing are setup-time operations and must not
overlap calculations. Opening the process-wide runtime again clears existing
custom registrations, including stale callbacks left by a Dart Hot Restart.
See custom target lifecycle and concurrency
for the complete threading, isolate, reset, and borrowed-request rules.
A complete runnable tour of all three Dart-backed callbacks (custom target,
custom house system, custom ayanamsha) is in
example/custom_callbacks_example.dart:
dart run example/custom_callbacks_example.dart
Context configuration #
context.configuration owns observer, atmosphere, astronomy-model,
apparent-position, deflection, light-time, and eclipse configuration. Configure
a context before using it concurrently:
const beijing = ObserverLocation(
longitudeDegrees: 116.391,
latitudeDegrees: 39.907,
heightMeters: 50,
);
context.configuration
..setObserverLocation(beijing)
..setStandardAtmosphere()
..setAtmospherePolicy({
AtmospherePolicyFlag.allowStandardFallback,
})
..setAstroModels(
const AstroModelConfig(
precessionModel: PrecessionModel.iau2006,
nutationModel: NutationModel.iau2000A,
),
)
..setApparentConfig(
ApparentConfig(
flags: const {
ApparentFlag.lightTime,
ApparentFlag.spherical,
ApparentFlag.aberration,
},
outputFrame: ApparentFrame.trueEquatorOfDate,
),
);
Simple topocentric setup accepts typed UT1 and TT coordinates. Precise
topocentric setup accepts typed UTC and TT coordinates and requires an EOP
table in the native runtime. context.clone() copies all context-owned
configuration, including custom deflectors; later changes and reset() calls
are independent between the two instances.
Observed positions #
context.observed calculates complete apparent and observed positions for the
Sun, Moon, and eight planets at UT1 or UTC inputs. Results include geometric
and apparent Cartesian states, spherical longitude/latitude, light time, native
diagnostics, and optional horizontal and refracted coordinates:
context.configuration
..setObserverLocation(beijing)
..setAtmospherePolicy({
AtmospherePolicyFlag.allowStandardFallback,
});
final observedSun = context.observed.atUtc(
Body.sun,
AstroDateTime(2024, 4, 8, 18),
flags: {
ObservedFlag.speed,
ObservedFlag.topocentric,
ObservedFlag.refraction,
},
).value;
print(observedSun.refractedHorizontal?.altitudeRadians);
Horizontal and refracted output require the topocentric flag and a configured
observer location. Refraction additionally requires complete atmosphere data
or standard-atmosphere fallback. Add strictMeteorology to forbid fallback.
UTC calculations require Earth-orientation data covering the requested date.
Visibility searches #
context.visibility searches a UT1 interval for lunar, solar, planetary, and
catalogued-star rise/set or upper/lower meridian transit. The context must have
an observer location; rise/set searches using their default atmospheric
refraction also require atmosphere data or a standard-atmosphere fallback.
context.configuration
..setObserverLocation(
const ObserverLocation(
longitudeDegrees: 116.3833,
latitudeDegrees: 39.9167,
),
)
..setStandardAtmosphere();
final start = Ut1JulianDate.fromDouble(2460409.0);
final sunrise = context.visibility.solarRiseSetAtUt1(
start,
start.add(const Duration(days: 1)),
event: VisibilityEventKind.rise,
).value;
if (sunrise.coordinate case final coordinate?) {
print(coordinate);
} else {
print(sunrise.altitudeState); // e.g. alwaysAbove
}
Set horizonAltitudeRadians to use a custom geometric horizon. The default
empty flags set means refraction; use VisibilityFlag.noRefraction to
disable it. fixedDiscSize is available for Sun and Moon only—physical planet
and star searches intentionally reject it. Twilight uses
solarTwilightAtUt1, while solarRiseSetFastAtTt and
solarTransitFastAtTt provide fast approximate solar values from an explicit
observer location. The fast rise/set route takes the same VisibilityLimb
and VisibilityFlag options as the searches, including
VisibilityFlag.strictMeteorology for refraction that rejects the
standard-atmosphere fallback. Star searches additionally require the requested
key in the process-wide star catalog. Search result dates are scalar native
outputs and therefore have the same roughly 40-microsecond present-epoch
precision boundary as other physical calculations.
Heliacal visibility #
context.heliacal evaluates whether a planet or catalogued star can be seen
in bright twilight, then searches for its next morning/evening first or last
heliacal appearance. It uses the context's observer location and selected
heliacal model (schaefer1993 by default). A body target cannot be the Sun,
Moon, Earth, or solar-system barycenter; star targets must be present in the
process-wide catalog.
context.configuration
..setGeocentricObserver(
observerId: Body.earth.id,
centerId: Body.earth.id,
)
..setObserverLocation(
const ObserverLocation(
longitudeDegrees: 116.3833,
latitudeDegrees: 39.9167,
),
)
..setAtmospherePolicy({
AtmospherePolicyFlag.allowStandardFallback,
})
..useSolarDeflector()
..setApparentConfig(
const ApparentConfig(
flags: {
ApparentFlag.spherical,
ApparentFlag.lightTime,
ApparentFlag.aberration,
ApparentFlag.gravitationalDeflection,
},
outputFrame: ApparentFrame.trueEclipticOfDate,
),
)
..setHeliacalVisibilityModel(HeliacalVisibilityModel.schaefer1993);
final event = context.heliacal.nextBodyEventAtUt1(
Body.venus,
Ut1JulianDate.fromDouble(2460758.7),
event: HeliacalEventKind.morningFirst,
maxSearchDays: 5,
conditions: const HeliacalVisibilityConditions(
extinctionMagnitudePerAirmass: 0.25,
),
).value;
print(event.coordinate);
conditions values are optional: omit one to use the selected profile's
calibrated or derived value; supplied values must be finite and strictly
positive (the native model rejects zero extinction and zero brightness).
includeMoonlight requests the model's moonlight
term, while strictMeteorology requires complete explicit atmosphere and
meteorological-range data rather than fallback. Position corrections use the
separate positionFlags set; only truePosition, astrometric,
noAberration, and noGravitationalDeflection apply. Event dates are scalar
native outputs and have the same roughly 40-microsecond present-epoch precision
boundary as other physical calculations.
Lunar occultations #
context.occultation searches the next lunar occultation of a catalogued star
or a solar-system/custom target. Local searches and local visibility samples
use the geographic observer already configured on the context; star searches
also require the star key to be present in the process-wide catalog.
final event = context.occultation.nextLocalStarAtUt1(
'antares',
Ut1JulianDate.fromDouble(2460310.5),
).value;
final visibility = context.occultation.localStarVisibilityAtUt1(
'antares',
event,
options: {OccultationVisibilityOption.refraction},
).value;
print(event.firstContact);
print(visibility.visibleIntervals);
Use nextGeocentricStarAtUt1 or nextGeocentricBodyAtUt1 for geocentric
searches. Body methods accept targetRadiusKilometers: omit it for the native
physical disc, use zero for a point source, or supply a positive custom radius.
Pass that same custom radius to bodyWhereAtUt1 when deriving global paths.
starWhereAtUt1 and bodyWhereAtUt1 expose the global maximum, center line,
outer limits, and visible-region polygon as immutable lists capped by the
native ABI's documented fixed capacities. Search type filters and the optional
lunar-limb correction are OccultationSearchOptions; the correction
requires a global TLL1 lunar-limb model loaded through Ephemeris.
Occultation inputs and returned dates currently cross the C ABI as a scalar JD
double, so they have the same roughly 40-microsecond present-epoch precision
boundary as other physical calculations.
Lunar eclipses #
context.eclipses solves a selected lunar-eclipse lunation, searches for the
next eclipse or a bounded sequence, and derives the contacts visible at the
observer stored on the context. TT and UT1 routes retain their respective
typed JulianDate coordinates.
final eclipse = context.eclipses.nextLunarAtUt1(
Ut1JulianDate.fromDouble(2460926.0),
kinds: {EclipseKind.total},
options: {LunarEclipseSearchOption.includeContacts},
).value;
final local = context.eclipses.localLunarVisibilityAtUt1(
eclipse,
options: {LocalLunarEclipseVisibilityOption.refraction},
).value;
print(eclipse.contacts[LunarEclipseContact.greatest]);
print(local.contacts[LunarEclipseContact.greatest]);
An empty kinds filter accepts penumbral, partial, and total lunar eclipses.
solveLunarAtTt and solveLunarAtUt1 can legitimately return a no-eclipse
lunation: then hasEclipse is false and the maximum/contact values are null.
For a non-empty eclipse, local visibility requires the contact data produced
by includeContacts; nextLocalLunarAtTt and nextLocalLunarAtUt1 request
it internally. Local calculations require an observer location, and
refraction requires the usual atmosphere configuration or fallback policy.
Use excludePenumbral to filter penumbral-only results, backward only for
next-event searches, and lunarLimbCorrection only after loading a TLL1 lunar
limb model through Ephemeris. Eclipse inputs and outputs currently cross the C
ABI as scalar Julian-date doubles, with the same roughly 40-microsecond
present-epoch precision boundary as other physical calculations.
Solar eclipses #
The same context.eclipses service also provides global and observer-local
solar-eclipse APIs. Global methods solve or search the shadow path; local
methods require an observer location configured on the context and return only
the eclipse circumstances at that location.
final global = context.eclipses.nextSolarAtUt1(
Ut1JulianDate.fromDouble(2460400.0),
kinds: {EclipseKind.total},
options: {SolarEclipseSearchOption.includeContacts},
).value;
final local = context.eclipses.nextLocalSolarAtUt1(
Ut1JulianDate.fromDouble(2460400.0),
kinds: {EclipseKind.total},
).value;
final geometry = context.eclipses.localSolarCircumstancesAtUt1(
local.maximum!,
).value;
final maximumUtc = context.time.utcCalendarFromUt1(global.maximum!).value;
print(global.contacts[SolarEclipseContact.greatest]);
print(local.contacts[LocalSolarEclipseContact.greatest]);
print(maximumUtc);
print(geometry.obscuration);
Global contacts are optional and use P1/C1/greatest/C4/P4 slots. Local solar
contacts are a distinct C1/C2/C3/C4/greatest array, exposed by a separate enum.
Local solve and next-search methods request those contacts internally. The
returned kind can additionally contain central or noncentral; search
filters accept only partial, total, annular, and hybrid eclipse types.
Local solar methods accept LocalSolarEclipseVisibilityOption values to select
the rise/set window used for the sunrise and sunset magnitudes. Neither set (the
default) keeps the geometric window; refraction selects the apparent
(refracted) window, and strictMeteorology — valid only together with
refraction — requires complete explicit atmosphere data instead of the
standard-atmosphere fallback:
final refracted = context.eclipses.solveLocalSolarAtUt1(
Ut1JulianDate.fromDouble(2460409.262231433),
visibilityOptions: {
LocalSolarEclipseVisibilityOption.refraction,
LocalSolarEclipseVisibilityOption.strictMeteorology,
},
).value;
As with lunar eclipses, these physical calculation times currently cross the C ABI as scalar Julian-date doubles, with the existing roughly 40-microsecond present-epoch precision boundary.
Event searches #
context.events provides typed UT1 and TT searches for longitude crossings,
stations, aspects, lunar phases, greatest elongation, minimum angular
separation, and Mercury/Venus solar transits. The bounded searches require a
positive maximum step and an explicit result capacity; maxResults defaults to
16 and a too-small capacity is reported as a native error rather than silently
truncating results.
final start = Ut1JulianDate.fromDouble(2460380.5);
final phases = context.events.lunarPhaseCrossingsAtUt1(
0, // new moon
start,
start.add(const Duration(days: 60)),
maxStepDays: 1,
maxResults: 4,
).value;
final transit = context.events.nextSolarTransitAtUt1(
Body.mercury,
Ut1JulianDate.fromDouble(2458799.0),
).value;
print(phases.map((date) => date.toDouble()));
print(transit.greatest);
Pass ordinary coordinate-correction choices through positionFlags; each
method rejects incompatible xyz and equatorial output requests before calling
native code. EventSearchOption.reverse is available only for the
native searches that support reverse lookup. Local solar-transit methods use an
explicit observer argument and can request refraction or noRefraction
(never both). The event targets use Target, so custom native target IDs
remain usable where the corresponding C ABI permits them.
All event inputs and returned coordinates cross the current C ABI as a single
JD double. Dart retains the split JulianDate representation away from that
boundary, but event calculations have the same roughly 40-microsecond
present-epoch precision limit as the other physical calculation APIs.
Solar time and body phenomena #
context.solarTime calculates the equation of time from UT1 or TT and converts
between local mean and apparent solar time through the native scalar-JD ABI.
LocalMeanSolarTime and LocalApparentSolarTime carry both a typed coordinate
and its longitude, so conversions cannot use the wrong coordinate kind or a
mismatched meridian:
final ut1 = Ut1JulianDate.fromDouble(2460311.0);
final equation = context.solarTime.equationOfTimeAtUt1(ut1).value;
final longitudeRadians = 116.3833 * 3.141592653589793 / 180;
final localMean = LocalMeanSolarTime.fromUt1(
ut1,
longitudeRadians: longitudeRadians,
);
final localApparent = context.solarTime.meanToApparent(localMean).value;
print(equation.equationSeconds);
print(localApparent.coordinate);
Solar-time calculations have the same roughly 40-microsecond present-epoch
precision boundary as the other physical calculation APIs. JulianDate keeps
its split representation for Dart-side arithmetic and time-scale conversion,
but it is quantized when calling this native calculation family. The limitation
comes from native physical ephemeris and Earth-rotation/GAST evaluation, not
from Dart's split-date bookkeeping; restoring a split wrapper alone cannot
improve it.
context.phenomena calculates phase angle, illuminated fraction, solar
elongation, apparent diameter, and apparent magnitude for the Sun, Moon, and
physical planets. Lunar results additionally contain geocentric horizontal
parallax:
final moonPhenomena = context.phenomena.atUt1(
Body.moon,
Ut1JulianDate.fromDouble(2460416.2916666665),
).value;
print(moonPhenomena.illuminatedFraction);
print(moonPhenomena.apparentMagnitude);
print(moonPhenomena.geocentricHorizontalParallaxRadians);
Set origin: PhenomenaOrigin.topocentric to make observer-dependent
phenomena reflect the observer configured on the context. Lunar horizontal
parallax is explicitly exposed as geocentricHorizontalParallaxRadians and
remains geocentric in either mode.
Both modules attach the native ephemeris diagnostic to every successful result
and throw EphemerisError with that diagnostic on native failure.
Osculating orbits and orbital events #
context.orbits exposes the complete native orbital module. Calculations use
the body's fixed physical primary: the Moon is Earth-centered, while Earth,
EMB, and major planets or planet barycenters are Sun-centered.
final start = Ut1JulianDate.fromDouble(2460409.0);
final orbit = context.orbits.osculatingAtUt1(
Body.moon,
start,
).value;
final perigee = context.orbits.searchApsisFromUt1(
Body.moon,
ApsisKind.pericenter,
start,
).value;
final previousNode = context.orbits.searchPlaneNodeFromUt1(
Body.moon,
PlaneNodeKind.ascending,
start,
direction: OrbitalSearchDirection.reverse,
).value;
print(orbit.semiMajorAxisAu);
print(perigee.coordinate);
print(previousNode.referencePlaneAngleRadians);
Osculating reference points are instantaneous orbit geometry, not searched
passage times. Orbital operations are always geometric and accept only the
explicit allowBarycenterApproximation policy; observer-dependent position
flags are intentionally unavailable. The upstream orbital C ABI operates on
scalar Julian dates, so split Dart coordinates are merged at that final FFI
boundary while TT and UT1 remain distinct Dart types.
Fixed stars #
The package automatically loads a lite TSC1 catalog containing 2,057 stars and
12,242 aliases. It includes every HIP star used by Stellarium's Chinese and
western-zodiac line figures, so traditional Chinese star names such as 织女一
work without locating a separate data file. Pass
RuntimeOptions(loadPackagedData: false) to disable packaged data.
Fixed-star catalogs are process-wide resources. Additional TSC1 catalogs can be loaded from a file or bytes, and editable TSF1 catalogs can be loaded during application setup:
ephemeris.starCatalog.addTsc1(
'/path/to/stars-fixed-traditional.tsc1',
);
print(ephemeris.starCatalog.count);
print(ephemeris.starCatalog.magnitudeOf('spica'));
The native runtime copies data passed to addTsc1Bytes, so the caller may
discard or modify its Uint8List after the method returns. Do not add or clear
catalogs while calculations are running in any isolate.
Star calculations use the user-owned context:
final spica = context.stars.atTt(
'spica',
TtJulianDate.fromDouble(2460409.0),
flags: {
PositionFlag.xyz,
PositionFlag.speed,
},
).value;
final observedSpica = context.stars.observedAtUt1(
'spica',
Ut1JulianDate.fromDouble(2460409.0),
flags: {
ObservedFlag.topocentric,
ObservedFlag.horizontal,
},
).value;
context.stars exposes single and batch position routes for TDB, TT, UT1, and
explicit Delta-T; each call's diagnostic lands on context.lastDiagnostic.
All fixed-star batches fail atomically if any requested star fails. Batch
exceptions expose every available per-star diagnostic through
EphemerisError.diagnostics; successful calls never contain placeholder NaN
entries.
This package requires an ABI-10 native library that reports the
Capability.splitTime and Capability.chineseCalendar
capabilities and exposes the required runtime, star, solar-time, phenomena,
Chinese-calendar, and Ganzhi-rule symbols. Incomplete ABI-10 builds are rejected
during Ephemeris.open or Ephemeris.attach with a clear compatibility error
instead of failing later during a lazy symbol lookup.
The Ganzhi calendar is always built into the core package. BaZi and Ziwei are
physically separate native modules living in their own Dart packages:
taiyin_bazi loads libtaiyin_bazi, while taiyin_ziwei loads
libtaiyin_ziwei. The root package ships only the core libtaiyin; importing
an extension adds its Dart API, and the first extension call lazily loads that
package's native module. A missing module raises UnsupportedError without
breaking core astronomy or Chinese-calendar calls.
The native engine is process-wide, so call Ephemeris.open once. Calling it again
currently replaces the global catalog, cache, EOP table, and lunar-limb model.
Finish global configuration before starting concurrent calculations. Create
separate contexts with ephemeris.createContext() or context.clone(); every
context owns and releases its native user state independently.
Dart runs synchronous FFI calls on the calling isolate. Future.wait inside one
isolate does not turn those calls into parallel native work, and an
EphemerisContext must not be sent to or reconstructed from its native address
in another isolate. For CPU parallelism, use worker isolates and let each worker
attach a runtime facade and create its own context. These contexts have
independent mutable configuration and diagnostics while sharing the
process-wide native ephemeris catalog and segment caches. BaZi and Ziwei follow
the same rule: each worker creates its own extension context; native code pages
are mapped once by the operating system. Do not mutate the same Ziwei chart
from multiple isolates. This is also the recommended server model: one context
per worker or logical user, with runtime mutation and shutdown kept outside
active calculations.
| API family | Parallel execution | Required ownership |
|---|---|---|
| Positions, events, visibility, orbits, occultations, eclipses, calendar | Worker isolates | Each worker calls Ephemeris.attach().createContext(). |
| BaZi | Worker isolates | Each worker obtains its own context.bazi or createBazi(). |
| Ziwei | Worker isolates | Each worker obtains its own Ziwei context and chart; never send or share a chart pointer. |
| Custom native callbacks | Worker isolates supported | Register before workers start; callback lifetime remains process-wide. |
| Runtime/catalog/configuration mutation and shutdown | Serialized | Perform outside active calculations. |
The isolate regression suite exercises core positions, event search, eclipses, Chinese-calendar conversion, BaZi, Ziwei, and custom targets concurrently.
A worker isolate must not receive a EphemerisContext through a SendPort.
Instead, send plain Dart inputs and let the worker attach a new context to the
already-open process runtime:
final workerEphemeris = Ephemeris.attach(libraryPath: libraryPath);
final workerContext = workerEphemeris.createContext();
try {
// Concurrent calculation using this isolate's own context.
} finally {
workerContext.close();
}
Chinese calendar and Ganzhi #
The Chinese-calendar module is always built into taiyin_c and provides
winter-solstice-based lunar years, solar terms, and solar/lunar conversion.
context.chineseCalendar returns a cached context using the default
astronomical profile; context.createChineseCalendar(config) creates a
caller-owned context:
final lunar = context.chineseCalendar
.fromSolar(const SolarDate(year: 2024, month: 2, day: 10))
.value;
print(lunar); // 2024-01-01 (甲辰正月初一)
final year = context.chineseCalendar
.calcYearUt(Ut1JulianDate.fromDouble(2460348.0))
.value;
print(year.solarTermCount); // 25
final localTime = AstroDateTime(2024, 2, 10, 12);
final lunarResult = context.chineseCalendar.fromLocal(localTime);
final pillarsResult = context.chineseCalendar.fourPillarsLocal(localTime);
final pillars = pillarsResult.value;
print(lunarResult.value); // 正月初一
print(pillars.year); // 甲辰
The Ganzhi module is part of the core package and always available:
final day = context.ganzhi.dayPillar(AstroDateTime(2024, 2, 10));
BaZi (package:ephemeris_bazi) #
The BaZi module is an optional extension package. Add ephemeris_bazi and
import it alongside the core; the import adds context.bazi and
context.createBazi() to every EphemerisContext. The first call loads the
package's separate libtaiyin_bazi module:
import 'package:ephemeris/ephemeris.dart';
import 'package:ephemeris_bazi/ephemeris_bazi.dart';
final bazi = context.bazi;
final result = bazi.calculateLocal(
AstroDateTime(2003, 3, 13, 14, 15),
gender: BaziGender.male,
);
final chart = result.value.chart;
final qiyun = result.value.qiyun;
final dayun = bazi.fillDayun(
birthCivilTime: result.value.localTime,
chart: chart,
qiyun: qiyun,
requestedCount: 5,
);
A BaZi context binds one ChineseCalendarContext at creation — the cached
default calendar unless you pass calendar: to createBazi — and
calcQiyun/calcRenyuanSiling resolve solar terms through it. The bound
calendar must belong to the same EphemerisContext.
Set TAIYIN_BAZI_LIBRARY_PATH or pass libraryPath: to createBazi to
override the bundled module.
Ziwei Doushu (package:ephemeris_ziwei) #
The Ziwei module is an optional extension package bundling its separate
libtaiyin_ziwei module and the default TOML rule profile under
lib/data/ziwei/rules/. Importing it adds context.ziwei and
context.createZiwei(); the native module is loaded on first use:
import 'package:ephemeris/ephemeris.dart';
import 'package:ephemeris_ziwei/ephemeris_ziwei.dart';
final ziwei = context.ziwei;
final chartResult = ziwei.calculateLocal(
AstroDateTime(2003, 3, 13, 14, 15),
gender: ZiweiGender.male,
);
final chart = chartResult.value;
print(chart.anchors.bureau);
print(chart.summary.bureauId);
ZiweiDataCatalog loads a TOML rule profile (the bundled default when
omitted) and can be shared across Ziwei contexts; pass profilePath to use a
custom rule profile. A Ziwei context borrows the cached default
Chinese-calendar context; pass calendar: to createZiwei to bind a
different calendar from the same EphemerisContext.
Application- or school-specific JSON tables are added as immutable named
options rather than overwriting bundled TOML rules. Select the module label
through ZiweiOptionSelection; ZiweiRuleset.removeModule(label) removes all
user contributions registered under that label.
Set TAIYIN_ZIWEI_LIBRARY_PATH or pass libraryPath: to createZiwei /
ZiweiDataCatalog to override the bundled module.
Regenerate bindings #
The development layout assumes dart-ephemeris and taiyin-ephemeris are sibling
directories:
dart pub get
dart run ffigen --config ffigen.yaml
dart format .
dart analyze
dart test
Native integration tests use three pinned modular ABI-10 libraries: core under
this package's lib/native/, and the extension libraries under their sibling
packages. Override them when necessary:
TAIYIN_TEST_LIBRARY=/path/to/libtaiyin.dylib dart test
TAIYIN_BAZI_LIBRARY_PATH=/path/to/libtaiyin_bazi.dylib dart test
TAIYIN_ZIWEI_LIBRARY_PATH=/path/to/libtaiyin_ziwei.dylib dart test
Run each package's suite from its own directory; all three have independent test suites.
The upstream C++ project currently registers 74 CTest suites. Their public
behavior and numerical oracles are being ported as black-box Dart tests; see
test/ported/README.md for the coverage map. Tests of
C++-only implementation details are not copied literally, but their observable
oracles are reused when a corresponding C ABI operation exists.
Native distribution #
For local development each package's bundled lib/native/ copy is loaded
automatically. Explicit paths and TAIYIN_LIBRARY_PATH,
TAIYIN_BAZI_LIBRARY_PATH, or TAIYIN_ZIWEI_LIBRARY_PATH override them. The
packages currently bundle:
- macOS arm64:
libtaiyin.dylib,libtaiyin_bazi.dylib,libtaiyin_ziwei.dylib - Linux x64:
libtaiyin.so,libtaiyin_bazi.so,libtaiyin_ziwei.so - Windows x64:
taiyin.dll,taiyin_bazi.dll,taiyin_ziwei.dll, together with the required MinGW-w64 runtime DLLs
Unsupported architectures fall back to an explicitly configured or
platform-installed shared library. iOS applications statically link Taiyin and
use DynamicLibrary.process().