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.
| Quantity | Computed for | How often |
|---|---|---|
| Position (altitude and azimuth) | Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn, Uranus, Neptune, Pluto, and fixed objects by right ascension and declination | every 15 minutes across a window |
| Rise, set and transit | the same bodies | per date and place |
| Moon illuminated fraction | the Moon | per instant |
| Night window | Sun | per 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.
| Step | Swiss Ephemeris call |
|---|---|
| Julian day from a UTC time | swe_julday |
| Position of a body | swe_calc_ut, equatorial coordinates from the Swiss Ephemeris files |
| Altitude and azimuth | swe_azalt from equatorial coordinates for the observer; the app uses the true altitude, without refraction |
| Rise, set and transit | swe_rise_trans with the topocentric flag; the pressure and temperature arguments are left at 0 |
| Moon phase and illuminated fraction | swe_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.








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:
| Table | Rows | Content |
|---|---|---|
dso | 3 | M31 (Andromeda Galaxy), M42 (Orion Nebula), M45 (Pleiades) |
stars | 4 | Sirius, 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
swephused 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.
/// 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), };}/// 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;}/// 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;}/// 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;}/// 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};}@overrideFuture<Either<Failure, VisibilityGraphData>> calculateVisibility({ required CelestialObject object, required GeoLocation location, required DateTime startTime, DateTime? endTime,}) async { try { final Duration duration = endTime != null ? endTime.difference(startTime) : const Duration(hours: 12); // 1. Calculate Object Trajectory List<GraphPoint> objectCurve; // Check for Ephemeris ID first (Planets, Sun, Moon) if (object.ephemerisId != null) { final HeavenlyBody? body = _mapToHeavenlyBody(object); if (body != null) { objectCurve = await _astronomyService.calculateAltitudeTrajectory( body: body, startTime: startTime, lat: location.latitude, long: location.longitude, duration: duration, ); } else { return Left(CalculationFailure('Failed to map object with ephemerisId to HeavenlyBody: ${object.name}')); } } // Check for RA/Dec (Stars, DSOs) else if (object.ra != null && object.dec != null) { objectCurve = await _astronomyService.calculateFixedObjectTrajectory( ra: object.ra!, dec: object.dec!, startTime: startTime, lat: location.latitude, long: location.longitude, duration: duration, ); } else { return Left(CalculationFailure('Unsupported celestial object: ${object.name} (No Ephemeris ID or RA/Dec)')); } // 2. Calculate Moon Trajectory final List<GraphPoint> moonCurve = await _astronomyService.calculateMoonTrajectory( startTime: startTime, lat: location.latitude, long: location.longitude, duration: duration, ); // 3. Calculate Optimal Windows // Logic: Object Altitude > 30 AND Moon Interference < 30 (arbitrary threshold, maybe 10?) // Let's use 30 for now as per previous mock logic. const double minObjectAltitude = 30; const double maxMoonInterference = 30; final List<TimeRange> optimalRanges = <TimeRange>[]; DateTime? windowStart; for (int i = 0; i < objectCurve.length; i++) { final GraphPoint objectPoint = objectCurve[i]; // Ensure we have a matching moon point (should be same length) final GraphPoint moonPoint = i < moonCurve.length ? moonCurve[i] : GraphPoint(time: objectPoint.time, value: 0); final double objectAlt = objectPoint.value; final double moonInterference = moonPoint.value; final bool isOptimal = objectAlt > minObjectAltitude && moonInterference < maxMoonInterference; if (isOptimal && windowStart == null) { windowStart = objectPoint.time; } else if (!isOptimal && windowStart != null) { optimalRanges.add(TimeRange(start: windowStart, end: objectPoint.time)); windowStart = null; } } // Close open window if (windowStart != null) { optimalRanges.add(TimeRange(start: windowStart, end: objectCurve.last.time)); } // 4. Calculate Sun/Moon Rise/Set Times final Map<String, DateTime?> sunTimes = await _astronomyService.calculateRiseSetTransit( body: HeavenlyBody.SE_SUN, date: startTime, lat: location.latitude, long: location.longitude, ); final Map<String, DateTime?> moonTimes = await _astronomyService.calculateRiseSetTransit( body: HeavenlyBody.SE_MOON, date: startTime, lat: location.latitude, long: location.longitude, ); return Right(VisibilityGraphData( objectCurve: objectCurve, moonCurve: moonCurve, optimalWindows: optimalRanges, sunRise: sunTimes['rise'], sunSet: sunTimes['set'], moonRise: moonTimes['rise'], moonSet: moonTimes['set'], )); } catch (e) { return Left(CalculationFailure('Error calculating visibility: $e')); }}