Sky science
Moonlight and sky brightness
How artificial light, scattered moonlight and the air add up to the brightness of the sky at a given hour, and what it means for the stars you can see.
One unit for everything
Light adds as luminance, not as magnitudes. So every contribution is expressed in natural units, where 1.0 is the natural zenith sky brightness (22.0 mag/arcsec², 174 µcd/m²), and the sum is converted afterwards.
| Symbol | Meaning | Unit |
|---|---|---|
| artificial brightness over natural brightness (the zone quantity, see Zone scale) | none | |
| scattered moonlight over natural brightness | none | |
| total brightness, in natural units | none | |
| effective sky brightness with the moon added | mag/arcsec² | |
| the naked-eye limiting magnitude that follows from it | magnitude | |
| moon phase angle, 0 at full moon and 180 at new | degrees | |
| angle between the moon and the part of the sky looked at | degrees | |
| zenith distance of the sky position, and of the moon | degrees | |
| extinction coefficient in the V band: how much the air dims light | mag per airmass | |
| aerosol optical depth at 550 nm | none | |
| station pressure over sea-level pressure | none | |
| illuminated fraction of the moon, 0 to 1 | none |
SQM is the brightness of the sky in magnitudes per square arcsecond. NELM is the naked-eye limiting magnitude, the faintest star an observer can see, from an empirical fit between sky-meter readings and observers' reports (Unihedron and AAVSO).
Every number below is computed by a TypeScript port that was checked at build time against 162 shared test cases, the same ones the Python and Dart implementations pass.
Moonlight
Scattered moonlight follows Krisciunas and Schaefer (1991), "A model of the brightness of moonlight" (PASP 103, 1033). It is an empirical fit to 33 photometric measurements in the V band, and its authors report agreement with data at the 8% to 23% level. The formulas were checked against the published reference implementation in the specsim package.
Let be the phase angle in degrees (0 at full moon, 180 at new), the angle between the moon and the sky position, and the zenith distances of the sky position and the moon, and the V-band extinction coefficient in magnitudes per airmass.
Here is the illuminated fraction of the moon from the ephemeris. The last conversion, from nanolamberts to magnitudes, is Garstang (1986), eq. 19.
An anchor value. The reference implementation documents one worked value: , , , and give 19.855 mag/arcsec². The Python, Dart and TypeScript implementations all reproduce it to 0.0005, and this site's build fails if they do not.
Which part of the sky. For the verdict on the sky as a whole, use the zenith: and . For one object's visibility, use the object's direction, with the angle between the moon and the object computed from their altitudes and azimuths.
Extinction
The air dims light on its way through. The moonlight model needs the V-band extinction coefficient, built from three parts:
The first term is Rayleigh scattering scaled by station pressure (from the international standard atmosphere at the station's elevation), the second is ozone, and the third is aerosol, where is the aerosol optical depth at 550 nm and 1.0857 is , which converts an optical depth to magnitudes. With no aerosol data, use (a proposal).
| Case | AOD at 550 nm | Elevation m | P / P0 | k_V |
|---|---|---|---|---|
| Sea level, clean | 0.02 | 0 | 1.000 | 0.143 |
| Sea level, hazy | 0.1 | 0 | 1.000 | 0.230 |
| Sea level, polluted | 0.3 | 0 | 1.000 | 0.447 |
| 1000 m, typical | 0.05 | 1000 | 0.887 | 0.164 |
| 2400 m, clear (La Palma) | 0.03 | 2400 | 0.746 | 0.127 |
| 4000 m, clear | 0.02 | 4000 | 0.608 | 0.102 |
The two constants are not individually verified (unverified). As a check on the whole formula, a site at 2400 m with an optical depth of 0.03 gives 0.127, against a measured median of 0.130 for the Roque de los Muchachos observatory on clear nights.
Darkness
An hour is dark when the Sun is at or below −18°. Between sunset and that point the sky is still lit by the Sun, so there is no verdict and the app shows twilight or day. How bright the sky is during twilight is measured by Patat et al. (2006) at Paranal, and modelling it is future work.
Worked examples
A site of each zone under a range of moons, computed with the functions above.
| Site | No moon | 10% crescent, 40° | Half, 40° | Full, 20° | Full, 40° | Full, 70° |
|---|---|---|---|---|---|---|
| Zone 1 (r = 0.1) | Milky Way (21.9, 6.6) | Milky Way (21.7, 6.5) | Starry (20.7, 6.0) | Planets (19.0, 4.8) | Few stars (18.5, 4.4) | Few stars (17.7, 3.7) |
| Zone 3 (r = 0.9) | Milky Way (21.3, 6.3) | Milky Way (21.2, 6.2) | Starry (20.5, 5.8) | Planets (19.0, 4.7) | Few stars (18.5, 4.4) | Few stars (17.6, 3.7) |
| Zone 5 (r = 3) | Starry (20.5, 5.8) | Starry (20.5, 5.8) | Planets (20.0, 5.5) | Planets (18.8, 4.6) | Few stars (18.4, 4.3) | Few stars (17.6, 3.6) |
| Zone 7 (r = 12) | Planets (19.2, 4.9) | Planets (19.2, 4.9) | Planets (19.0, 4.8) | Few stars (18.4, 4.3) | Few stars (18.1, 4.0) | Few stars (17.4, 3.5) |
| Zone 8 (r = 30) | Few stars (18.3, 4.2) | Few stars (18.3, 4.2) | Few stars (18.2, 4.1) | Few stars (17.9, 3.8) | Few stars (17.7, 3.7) | Few stars (17.2, 3.3) |
| Zone 9 (r = 60) | Too much light (17.5, 3.6) | Too much light (17.5, 3.6) | Too much light (17.5, 3.5) | Too much light (17.3, 3.4) | Too much light (17.2, 3.3) | Too much light (16.9, 3.0) |
- Moon altitude
- 45°
- Full moon (solid)
- 18.4 · NELM 4.3
- Half moon (dash)
- 20.6 · NELM 5.9
- 10% crescent (dot)
- 21.7 · NELM 6.5
Effective sky brightness at the zenith as the moon climbs, with extinction k = 0.15, for a site in zone 1 that starts at 21.9 mag/arcsec² with no moon. Dashed lines mark where the state changes. The model has the moon at nearly full strength as soon as it clears the horizon, so the curves start low. Move over the chart to read the values.
A high full moon takes a pristine site to about the limiting magnitude of a zone 8 city, which matches the experience that a full moon wipes out the Milky Way. In a bright city the moon barely matters.
Try it
- State
- Starry sky
- Limiting factor
- The moon
- Extinction k_V
- 0.176
- Moonlight
- 2.39 × natural
- Sky brightness
- 20.64 mag/arcsec²
- Limiting magnitude
- 5.91
- Light pollution cost
- 0.05 mag
- Moon cost
- 0.67 mag
Zenith sky at astronomical night. The state and the cause come from the same functions the app uses.
Limits
- Twilight brightness is not modelled; the model is gated at −18°.
- Moonlight is modelled in the V band, while a sky meter is broader.
- Moonlight reflected from clouds, airglow variation, the zodiacal light and the observer's altitude are ignored.
- The artificial ratio comes from satellite data, so its error carries through.
- The Rayleigh and ozone constants are not individually verified.
Validation plan
- Moonlight. Compare the effective sky brightness with Globe at Night and sky-meter network readings taken with the moon up (the data record date and time, so the moon can be computed), in dark and suburban strata. Report bias and error in mag/arcsec².
- Limiting magnitude. Compare the model's limiting magnitude with observers' reports for the same nights.
- Cloud. Compare forecast cover with satellite cloud masks for the sampled nights.
Implementation
Excerpts of the real files, cut out by name, copied into the site and checked against the repository on every build.
def pressure_ratio(elevation_m): """Station pressure over sea-level pressure, international standard atmosphere (valid below 11 km).""" return (1.0 - 2.25577e-5 * elevation_m) ** 5.25588def extinction_k_v(aod550=0.0, elevation_m=0.0): """V-band extinction coefficient: Rayleigh scaled by pressure, ozone, and aerosol optical depth.""" return RAYLEIGH_K_SEA_LEVEL * pressure_ratio(elevation_m) + OZONE_K + TAU_TO_K * max(aod550, 0.0)def scattering_airmass(zenith_deg): """Krisciunas & Schaefer (1991) eq. 3.""" s = math.sin(math.radians(zenith_deg)) return (1.0 - 0.96 * s * s) ** -0.5def phase_angle_from_illumination(illumination): """Moon phase angle in degrees (0 = full, 180 = new) from the illuminated fraction 0..1.""" return math.degrees(math.acos(max(-1.0, min(1.0, 2.0 * illumination - 1.0))))def moon_brightness_v(obs_zenith_deg, moon_zenith_deg, separation_deg, phase_angle_deg, k_v): """Scattered moonlight in V mag/arcsec^2 (Krisciunas & Schaefer 1991). math.inf when the moon is down.""" if moon_zenith_deg >= 90.0: return math.inf alpha = abs(phase_angle_deg) m = -12.73 + 0.026 * alpha + 4e-9 * alpha ** 4 # eq. 9 i_star = 10.0 ** (-0.4 * (m + 16.57)) # eq. 8 rho = separation_deg f = 10.0 ** 5.36 * (1.06 + math.cos(math.radians(rho)) ** 2) + 10.0 ** (6.15 - rho / 40.0) # eq. 21 x_obs = scattering_airmass(obs_zenith_deg) x_moon = scattering_airmass(moon_zenith_deg) b_nl = f * i_star * 10.0 ** (-0.4 * k_v * x_moon) * (1.0 - 10.0 ** (-0.4 * k_v * x_obs)) # nanoLamberts return (20.7233 - math.log(b_nl / 34.08)) / 0.92104 # Garstang 1986 eq. 19def moon_ratio(obs_zenith_deg, moon_zenith_deg, separation_deg, phase_angle_deg, k_v): """Scattered moonlight in natural units (0 when the moon is below the horizon).""" v = moon_brightness_v(obs_zenith_deg, moon_zenith_deg, separation_deg, phase_angle_deg, k_v) if math.isinf(v): return 0.0 return 10.0 ** (0.4 * (REFERENCE_SQM - v))def angular_separation_deg(alt1_deg, az1_deg, alt2_deg, az2_deg): """Angle between two directions given as altitude and azimuth in degrees.""" a1, a2 = math.radians(alt1_deg), math.radians(alt2_deg) d_az = math.radians(az1_deg - az2_deg) c = math.sin(a1) * math.sin(a2) + math.cos(a1) * math.cos(a2) * math.cos(d_az) return math.degrees(math.acos(max(-1.0, min(1.0, c))))def sqm_effective(r_art, b_moon=0.0, b_twilight=0.0): """Effective sky brightness in mag/arcsec^2: natural + artificial + moon + twilight, in natural units.""" return REFERENCE_SQM - 2.5 * math.log10(1.0 + max(r_art, 0.0) + max(b_moon, 0.0) + max(b_twilight, 0.0))/// Station pressure over sea-level pressure, international standard atmosphere (below 11 km).static double pressureRatio(double elevationM) => math.pow(1 - 2.25577e-5 * elevationM, 5.25588).toDouble();/// V-band extinction coefficient in mag per airmass.static double extinctionKV({double aod550 = 0, double elevationM = 0}) => _rayleighKSeaLevel * pressureRatio(elevationM) + _ozoneK + _tauToK * math.max(aod550, 0);/// Krisciunas & Schaefer (1991) eq. 3.static double scatteringAirmass(double zenithDeg) { final double s = math.sin(_rad(zenithDeg)); return math.pow(1 - 0.96 * s * s, -0.5).toDouble();}/// Moon phase angle in degrees (0 full, 180 new) from the illuminated fraction 0 to 1.static double phaseAngleFromIllumination(double illumination) => _deg(math.acos((2 * illumination - 1).clamp(-1.0, 1.0)));/// Scattered moonlight in V mag/arcsec² (Krisciunas & Schaefer 1991). Infinity when the moon is down.static double moonBrightnessV({ required double obsZenithDeg, required double moonZenithDeg, required double separationDeg, required double phaseAngleDeg, required double kV,}) { if (moonZenithDeg >= 90) return double.infinity; final double alpha = phaseAngleDeg.abs(); final double m = -12.73 + 0.026 * alpha + 4e-9 * math.pow(alpha, 4); // eq. 9 final double iStar = math.pow(10, -0.4 * (m + 16.57)).toDouble(); // eq. 8 final double f = math.pow(10, 5.36) * (1.06 + math.pow(math.cos(_rad(separationDeg)), 2)) + math.pow(10, 6.15 - separationDeg / 40); // eq. 21 final double xObs = scatteringAirmass(obsZenithDeg); final double xMoon = scatteringAirmass(moonZenithDeg); final double bNl = f * iStar * math.pow(10, -0.4 * kV * xMoon) * (1 - math.pow(10, -0.4 * kV * xObs)); // nanoLamberts return (20.7233 - math.log(bNl / 34.08)) / 0.92104; // Garstang 1986 eq. 19}/// Scattered moonlight in natural units (0 when the moon is below the horizon).static double moonRatio({ required double obsZenithDeg, required double moonZenithDeg, required double separationDeg, required double phaseAngleDeg, required double kV,}) { final double v = moonBrightnessV( obsZenithDeg: obsZenithDeg, moonZenithDeg: moonZenithDeg, separationDeg: separationDeg, phaseAngleDeg: phaseAngleDeg, kV: kV, ); if (v.isInfinite) return 0; return math.pow(10, 0.4 * (AstrZoneScale.referenceSqm - v)).toDouble();}/// Angle between two directions given as altitude and azimuth in degrees.static double angularSeparationDeg(double alt1, double az1, double alt2, double az2) { final double a1 = _rad(alt1); final double a2 = _rad(alt2); final double c = math.sin(a1) * math.sin(a2) + math.cos(a1) * math.cos(a2) * math.cos(_rad(az1 - az2)); return _deg(math.acos(c.clamp(-1.0, 1.0)));}/// Effective sky brightness in mag/arcsec²: natural + artificial + moon + twilight, in natural units.static double sqmEffective(double rArt, {double bMoon = 0, double bTwilight = 0}) => AstrZoneScale.referenceSqm - 2.5 * _log10(1 + math.max(rArt, 0) + math.max(bMoon, 0) + math.max(bTwilight, 0));