Skip to content

Sky science

Planets and the sky

How the app finds the Sun, Moon and planets, when they rise and set, and what its catalogue holds today.

What is computed

Everything about where things are, and when, is computed on the device. Nothing needs the network.

QuantityComputed forHow often
Position (altitude and azimuth)Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn, Uranus, Neptune, Pluto, and fixed objects by right ascension and declinationevery 15 minutes across a window
Rise, set and transitthe same bodiesper date and place
Moon illuminated fractionthe Moonper instant
Night windowSunper date and place

Method today

The app uses the Swiss Ephemeris through the sweph package. A position is a call to the ephemeris for a body at a Julian day, then a conversion from equatorial coordinates to altitude and azimuth for the observer.

StepSwiss Ephemeris call
Julian day from a UTC timeswe_julday
Position of a bodyswe_calc_ut, equatorial coordinates from the Swiss Ephemeris files
Altitude and azimuthswe_azalt from equatorial coordinates for the observer; the app uses the true altitude, without refraction
Rise, set and transitswe_rise_trans with the topocentric flag; the pressure and temperature arguments are left at 0
Moon phase and illuminated fractionswe_pheno_ut, element 1

Fixed objects such as stars and deep-sky objects skip the ephemeris and go straight to swe_azalt with their right ascension and declination. The bundled catalogue stores no proper motion, so positions are used as given.

One accuracy note: rise and set use the topocentric flag, but the 15-minute altitude trajectories take geocentric positions and only then convert for the observer. For the Moon that leaves out parallax, which is up to about one degree, so the moon curve can differ from the true altitude by that much (the size of the effect is from general astronomy, unverified here).

Time and place

A location's time zone comes from its coordinates, and "midnight" for a date is local midnight converted to UTC, so daylight saving shifts are handled by the time-zone database and not by hand. Altitudes are true altitudes. The sky model needs altitude, azimuth and the Moon's illuminated fraction, and these are what the ephemeris returns.

The night window

The night runs from a sunset to a sunrise. If it is before today's sunrise, the night began yesterday evening. If it is after today's sunset, it ends at tomorrow's sunrise. In the daytime it shows the coming night. The window uses sunset and sunrise, not the astronomical-darkness limit of −18° that the sky model applies hour by hour inside it.

Moon phase and the moon curve

The moon's illuminated fraction comes from the phase function: 0 at new moon, 1 at full. The phase angle, used to name the phase, comes from the same ephemeris.

MOON0%
New Moon
New Moon
MOON15%
Waxing Crescent
Waxing Crescent
MOON50%
First Quarter
First Quarter
MOON85%
Waxing Gibbous
Waxing Gibbous
MOON100%
Full Moon
Full Moon
MOON85%
Waning Gibbous
Waning Gibbous
MOON50%
Last Quarter
Last Quarter
MOON15%
Waning Crescent
Waning Crescent

These are the app's own moon cards, at the phase angles 0° to 315° in 45° steps. The full set and the Dart that builds them are on App components.

The graphs use the moon in two ways. The conditions graph plots the moon's altitude, from the ordinary 15-minute trajectory. The object visibility graph plots a moon value: every 15 minutes, the moon's altitude multiplied by its illuminated fraction while above the horizon, and 0 below. That value runs from 0 to 90 with no physical unit and stands in for how much the moon matters. See Graphs for both, and Moonlight and sky brightness for the physical replacement.

The catalogue today

The planets and the Sun and Moon come from the ephemeris. Everything else comes from a bundled SQLite database. Counted from the file in the repository on 2026-10-09, it holds:

TableRowsContent
dso3M31 (Andromeda Galaxy), M42 (Orion Nebula), M45 (Pleiades)
stars4Sirius, Vega, Betelgeuse, Polaris

That is a seed, not a catalogue. The object corpus is a core goal of the project: see the overview for the plan to reach parity with the best planetarium apps.

The licence problem

The Swiss Ephemeris is dual-licensed: GNU AGPL, which would put the whole app under the AGPL, or a paid Professional License. The choice must be made before distributing. The sweph package carries the same terms. The app is licensed under Apache-2.0, and AGPL-licensed code is a known conflict with store terms, so a store build must not include it. This is recorded in the repository's NOTICE file.

The plan

The decision is to replace the ephemeris with a free, permanent one built on JPL's planetary ephemerides (DE440), read by the app's own code. DE440 is NASA/JPL data for 1550 to 2650 in a file of about 128 MB. A reduced file, DE440s, is about 31 MB and covers 1849 to 2150 with no loss of precision in that range. Positions are stored as Chebyshev polynomials, which are simple to evaluate. The public-domain status of the files should be confirmed before relying on it (unverified).

  • Frame transforms (precession and nutation, sidereal time, aberration, light time, refraction, topocentric correction) will be ported from the IERS Conventions and the BSD-licensed ERFA library. The star map needs them too.
  • Small bodies: orbital elements from the JPL Small-Body Database and the Minor Planet Center, advanced as two-body orbits, with hyperbolic orbits for interstellar objects. Satellites: CelesTrak data with SGP4.
  • Fallback: Astronomy Engine (MIT licence, about one arcminute) if the new reader slips. One arcminute is less than a pixel on a phone showing a 60° field of view.
  • Tests: JPL Horizons output as the reference, with sweph used only as a comparison in development and never shipped.

Implementation

Excerpts of the real files, cut out by name, copied into the site and checked against the repository on every build.

astronomy_service.dart · calculateRiseSetTransit
/// Calculate Rise, Set, and Transit times for a celestial body/// Returns a Map with 'rise', 'set', 'transit' keys as DateTime (or null if not found)/// Calculate Rise, Set, and Transit times for a celestial body/// Returns a Map with 'rise', 'set', 'transit' keys as DateTime (or null if not found)Future<Map<String, DateTime?>> calculateRiseSetTransit({  required HeavenlyBody body,  String? starName,  required DateTime date,  required double lat,  required double long,}) async {  await checkInitialized();  // Look up the IANA timezone for this location (DST-aware).  final String tzName = tzmap.latLngToTimezoneString(lat, long);  final tz.Location location = tz.getLocation(tzName);  // Location midnight in UTC — accounts for DST automatically.  // e.g. London winter: 00:00 GMT = 00:00 UTC; London summer: 00:00 BST = 23:00 UTC prev day.  final tz.TZDateTime localMidnight = tz.TZDateTime(location, date.year, date.month, date.day);  final DateTime utcStart = localMidnight.toUtc();  // Convert UTC DateTime to Julian Day  final double jd = Sweph.swe_julday(    utcStart.year,    utcStart.month,    utcStart.day,    utcStart.hour + utcStart.minute / 60.0 + utcStart.second / 3600.0,    CalendarType.SE_GREG_CAL,  );  // Flags: Swiss Ephemeris, Topocentric (observer location)  final SwephFlag flags = SwephFlag.SEFLG_SWIEPH | SwephFlag.SEFLG_TOPOCTR;  // GeoPosition for the observer  final GeoPosition geopos = GeoPosition(long, lat);  // Calculate Rise  final double? rise = _calcEvent(body, jd, flags, RiseSetTransitFlag.SE_CALC_RISE, geopos);  // Calculate Set  final double? set = _calcEvent(body, jd, flags, RiseSetTransitFlag.SE_CALC_SET, geopos);  // Calculate Transit (Meridian Crossing)  final double? transit = _calcEvent(body, jd, flags, RiseSetTransitFlag.SE_CALC_MTRANSIT, geopos);  return <String, DateTime?>{    'rise': _jdToLocationTime(rise, location),    'set': _jdToLocationTime(set, location),    'transit': _jdToLocationTime(transit, location),  };}
astronomy_service.dart · calculateAltitudeTrajectory
/// Calculate Altitude Trajectory for a celestial body over a specified duration/// Returns a list of GraphPoints (time, altitude) every 15 minutes////// NOTE: Heavy calculation (>16ms) that should ideally run in isolate, but sweph's/// native FFI bindings are incompatible with Dart isolates. Future optimization may/// require switching to a pure Dart astronomy library.Future<List<GraphPoint>> calculateAltitudeTrajectory({  required HeavenlyBody body,  required DateTime startTime,  required double lat,  required double long,  Duration duration = const Duration(hours: 12),}) async {  await checkInitialized();  final List<GraphPoint> points = <GraphPoint>[];  final GeoPosition geopos = GeoPosition(long, lat);  final int totalMinutes = duration.inMinutes;  final int intervals = (totalMinutes / 15).ceil();  for (int i = 0; i <= intervals; i++) {    final DateTime time = startTime.add(Duration(minutes: i * 15));    if (time.difference(startTime) > duration) break;    final DateTime utcTime = time.toUtc();    final double jd = Sweph.swe_julday(      utcTime.year,      utcTime.month,      utcTime.day,      utcTime.hour + utcTime.minute / 60.0 + utcTime.second / 3600.0,      CalendarType.SE_GREG_CAL,    );    // Calculate Equatorial Position    final SwephFlag flags = SwephFlag.SEFLG_EQUATORIAL | SwephFlag.SEFLG_SWIEPH | SwephFlag.SEFLG_SPEED;    final CoordinatesWithSpeed xx = Sweph.swe_calc_ut(jd, body, flags);    // Convert to Horizon Coordinates    final Coordinates xin = Coordinates(xx.longitude, xx.latitude, xx.distance);    final AzimuthAltitudeInfo azAlt = Sweph.swe_azalt(      jd,      AzAltMode.SE_EQU2HOR,      geopos,      0,      10,      xin,    );    points.add(GraphPoint(time: time, value: azAlt.trueAltitude));  }  return points;}
astronomy_service.dart · calculateMoonTrajectory
/// Calculate Moon Interference Trajectory over a specified duration/// Returns a list of GraphPoints (time, interference score) every 15 minutes/// Interference = Altitude * Illumination (0-1)////// NOTE: Heavy calculation (>16ms) - see calculateAltitudeTrajectory for isolate limitation detailsFuture<List<GraphPoint>> calculateMoonTrajectory({  required DateTime startTime,  required double lat,  required double long,  Duration duration = const Duration(hours: 12),}) async {  await checkInitialized();  final List<GraphPoint> points = <GraphPoint>[];  final GeoPosition geopos = GeoPosition(long, lat);  final int totalMinutes = duration.inMinutes;  final int intervals = (totalMinutes / 15).ceil();  for (int i = 0; i <= intervals; i++) {    final DateTime time = startTime.add(Duration(minutes: i * 15));    if (time.difference(startTime) > duration) break;    final DateTime utcTime = time.toUtc();    final double jd = Sweph.swe_julday(      utcTime.year,      utcTime.month,      utcTime.day,      utcTime.hour + utcTime.minute / 60.0 + utcTime.second / 3600.0,      CalendarType.SE_GREG_CAL,    );    // Calculate Moon Position    final SwephFlag flags = SwephFlag.SEFLG_EQUATORIAL | SwephFlag.SEFLG_SWIEPH | SwephFlag.SEFLG_SPEED;    final CoordinatesWithSpeed xx = Sweph.swe_calc_ut(jd, HeavenlyBody.SE_MOON, flags);    // Calculate Moon Altitude    final Coordinates xin = Coordinates(xx.longitude, xx.latitude, xx.distance);    final AzimuthAltitudeInfo azAlt = Sweph.swe_azalt(      jd,      AzAltMode.SE_EQU2HOR,      geopos,      0,      10,      xin,    );    // Calculate Moon Phase    final List<double> pheno = Sweph.swe_pheno_ut(jd, HeavenlyBody.SE_MOON, flags);    double illumination = 0;    if (pheno.length > 1) {      illumination = pheno[1];    }    // Calculate Interference    final double altitude = azAlt.trueAltitude;    double interference = 0;    if (altitude > 0) {      interference = altitude * illumination;    }    points.add(GraphPoint(time: time, value: interference));  }  return points;}
astronomy_service.dart · getMoonPhase
/// Calculate Moon Phase (Illumination 0.0-1.0) for a specific timeFuture<double> getMoonPhase(DateTime time) async {  await checkInitialized();  final DateTime utcTime = time.toUtc();  final double jd = Sweph.swe_julday(    utcTime.year,    utcTime.month,    utcTime.day,    utcTime.hour + utcTime.minute / 60.0 + utcTime.second / 3600.0,    CalendarType.SE_GREG_CAL,  );  final SwephFlag flags = SwephFlag.SEFLG_EQUATORIAL | SwephFlag.SEFLG_SWIEPH | SwephFlag.SEFLG_SPEED;  final List<double> pheno = Sweph.swe_pheno_ut(jd, HeavenlyBody.SE_MOON, flags);  if (pheno.length > 1) {    return pheno[1];  }  return 0.0;}
astronomy_service.dart · getNightWindow
/// Calculate the "Night Window" for a given date/time/// Returns start (Sunset) and end (Sunrise)Future<Map<String, DateTime>> getNightWindow({  required DateTime date,  required double lat,  required double long,}) async {  await checkInitialized();  // Calculate events for the given date  final Map<String, DateTime?> todayEvents = await calculateRiseSetTransit(    body: HeavenlyBody.SE_SUN,    date: date,    lat: lat,    long: long,  );  final DateTime? todaySet = todayEvents['set'];  final DateTime? todayRise = todayEvents['rise'];  // Calculate events for tomorrow  final DateTime tomorrow = date.add(const Duration(days: 1));  final Map<String, DateTime?> tomorrowEvents = await calculateRiseSetTransit(    body: HeavenlyBody.SE_SUN,    date: tomorrow,    lat: lat,    long: long,  );  final DateTime? tomorrowRise = tomorrowEvents['rise'];  // Calculate events for yesterday  final DateTime yesterday = date.subtract(const Duration(days: 1));  final Map<String, DateTime?> yesterdayEvents = await calculateRiseSetTransit(    body: HeavenlyBody.SE_SUN,    date: yesterday,    lat: lat,    long: long,  );  final DateTime? yesterdaySet = yesterdayEvents['set'];  DateTime start;  DateTime end;  // Logic:  // If date is before today's sunrise (early morning), night started yesterday evening.  if (todayRise != null && date.isBefore(todayRise)) {      start = yesterdaySet ?? date.subtract(const Duration(hours: 6)); // Fallback      end = todayRise;  }  // If date is after today's sunset (evening), night starts today evening.  else if (todaySet != null && date.isAfter(todaySet)) {      start = todaySet;      end = tomorrowRise ?? date.add(const Duration(hours: 12)); // Fallback  }  // If date is during the day (between rise and set), show UPCOMING night.  else {      start = todaySet ?? date; // Fallback to now if no set      end = tomorrowRise ?? date.add(const Duration(hours: 12));  }  return <String, DateTime>{'start': start, 'end': end};}