ephemeris 1.0.0-beta.6 copy "ephemeris: ^1.0.0-beta.6" to clipboard
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 before 1.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 under lib/native/ and a lite fixed-star catalog under lib/data/; both load automatically.
  • ephemeris_bazi — the optional BaZi (八字) extension. Importing it adds context.bazi / context.createBazi() to EphemerisContext.
  • ephemeris_ziwei — the optional Ziwei Doushu (紫微斗数) extension, with the default rule profile bundled under lib/data/ziwei/rules/. Importing it adds context.ziwei / context.createZiwei() to EphemerisContext.

The core package deliberately has two layers:

  • lib/src/bindings/taiyin_bindings.g.dart is generated by ffigen and stays private; extension packages consume it through package:ephemeris/ffi.dart.
  • lib/ephemeris.dart exposes 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().

0
likes
160
points
254
downloads

Documentation

API reference

Publisher

verified publisherredsc1.com

Weekly Downloads

Idiomatic Dart bindings for the Taiyin ephemeris C API.

Repository (GitHub)
View/report issues

Topics

#astronomy #ephemeris #celestial-mechanics #chinese-calendar #astrology

License

MPL-2.0 (license)

Dependencies

ffi

More

Packages that depend on ephemeris