Sky science
Zone scale
Nine zones of artificial sky brightness, each twice the one before, defined on the ratio of artificial to natural light.
Definition
A zone is a band of artificial sky brightness relative to the natural sky, at the zenith, on a clear night with a standard atmosphere and no moon. With the zenith luminance added by artificial light and the natural zenith luminance:
The value 174 µcd/m² for a natural sky is from Falchi et al. (2016). Zero means a natural sky, and one means artificial light equals the natural background.
| Symbol | Meaning | Unit |
|---|---|---|
| ratio of artificial to natural zenith brightness | none | |
| zenith luminance added by artificial light | µcd/m² | |
| natural zenith luminance, 174 | µcd/m² | |
| radiance seen by the satellite, plus modelled skyglow | nW/cm²/sr | |
| sky brightness as a sky meter reads it (a larger number is darker) | mag/arcsec² | |
| naked-eye limiting magnitude, the faintest star you can see | magnitude | |
| the zone, 1 to 9 | none |
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.
The ladder
Zones are a doubling ladder that starts at . Each edge is twice the one before it, and a value exactly on an edge belongs to the higher zone.
Implementations compare against the list of edges instead of computing a logarithm, so a value on an edge cannot land on the wrong side through rounding.
The nine visibility cards, as the app shows them: the zone, a sky label and the sky brightness at the middle of the zone, with five rating bars.
Ratio r on a logarithmic axis. The bands are the nine zones, and the numbers on the ladder are the zone edges with the sky brightness in mag/arcsec² under each.
| Zone | r from | r to | Artificial µcd/m² | SQM | NELM |
|---|---|---|---|---|---|
| 1 | 0 | 0.32 | 0 | 22.00 | 6.62 |
| 2 | 0.32 | 0.64 | 56 | 21.70 | 6.48 |
| 3 | 0.64 | 1.28 | 111 | 21.46 | 6.37 |
| 4 | 1.28 | 2.56 | 223 | 21.11 | 6.17 |
| 5 | 2.56 | 5.12 | 445 | 20.62 | 5.89 |
| 6 | 5.12 | 10.24 | 891 | 20.03 | 5.52 |
| 7 | 10.24 | 20.48 | 1782 | 19.37 | 5.05 |
| 8 | 20.48 | 40.96 | 3564 | 18.67 | 4.51 |
| 9 | 40.96 | none | 7127 | 17.94 | 3.92 |
A doubling is 0.753 magnitudes, so adjacent zones differ by about three quarters of a magnitude of sky brightness.
Why this ladder
- It reproduces the production data. Converting the radiance thresholds that built the current
zones.dbinto ratios gives edges within 0.87 to 1.25 times the ladder (table below). - It matches a published scale. The Falchi et al. (2016) atlas classes artificial light as a ratio to natural: below 0.01, 0.01 to 0.02, 0.02 to 0.04, 0.04 to 0.08, 0.08 to 0.16, 0.16 to 0.32, with the Milky Way lost at about 1.28 to 2.56 and a top class above about 41 (over 7130 µcd/m²). The doubling between the quoted values is inferred, so confirm it against the paper's legend (unverified).
- The zones land on things you can see. Zone 4 is the band where the Milky Way fades, and zone 9 is the top class. Zone 1 merges the six darkest published classes, because below the sky is within a third of natural and the differences are not usable.
Zones are not Bortle classes, and the scale does not use Bortle terms.
The earlier specification (version 1.0) used a factor of 2.5 per zone from 0.05 and divided radiance by 0.171. That constant is a luminance in mcd/m², not a satellite radiance in nW/cm²/sr, so its edges did not match the production data.
Conversions
NELM is the naked-eye limiting magnitude, an empirical fit between sky-meter readings and observers' limiting magnitudes (Unihedron and AAVSO). It is informative: the zone definition does not depend on it. A real sky meter includes starlight above the 22.0 baseline, so dark-site readings sit a little brighter than 22.0 and must be adjusted before comparison.
Try it
- Zone
- 3
- Sky brightness
- 21.25 mag/arcsec²
- Artificial luminance
- 174 µcd/m²
- Limiting magnitude
- 6.25
Drag to move along the ladder. Zone edges are 0.32, 0.64, 1.28 ... 40.96.
Edge cases
| Input | Result |
|---|---|
| Counts as 0, zone 1 | |
| is not a number | Error (ValueError in Python, ArgumentError in Dart) |
| is infinite | Zone 9 |
A cell is absent from zones.db | Implicit zone 1, sky brightness 22.0 |
Current data and the legacy chain
The current zones.db was built from radiance in nW/cm²/sr (VIIRS light plus modelled skyglow, see Light pollution data) with a fit and thresholds. This is the legacy chain:
Ratio r on a logarithmic axis. The bands are the nine zones, and the numbers on the ladder are the zone edges with the sky brightness in mag/arcsec² under each. The lower row marks where the legacy thresholds fall, in the same units.
| Zone starts | Legacy threshold R | Legacy implied r | Ladder edge r | Ladder edge as R | Legacy / ladder |
|---|---|---|---|---|---|
| 2 | 0.25 | 0.32 | 0.32 | 0.252 | 0.99 |
| 3 | 0.5 | 0.60 | 0.64 | 0.535 | 0.94 |
| 4 | 1 | 1.11 | 1.28 | 1.180 | 0.87 |
| 5 | 3 | 2.76 | 2.56 | 2.735 | 1.08 |
| 6 | 9 | 6.41 | 5.12 | 6.677 | 1.25 |
| 7 | 20 | 11.49 | 10.24 | 17.047 | 1.12 |
| 8 | 50 | 22.06 | 20.48 | 44.982 | 1.08 |
| 9 | 125 | 41.83 | 40.96 | 121.257 | 1.02 |
After calibration the chain becomes with fitted to ground measurements, and the zone is computed from . Until then, the best available zone is the ladder applied to the legacy ratio.
What changes
Adopting the ladder on the current data, measured over every stored cell:
- 86.4% of 37,528,537 stored cells keep their zone.
- 13.6% (5,115,892) move by exactly one zone: 2,059,079 up and 3,056,813 down. None moves by more.
| Stored zone | Cells | Move down one | Stay | Move up one |
|---|---|---|---|---|
| 2 | 8,202,608 | 110,553 | 8,092,055 | 0 |
| 3 | 8,293,687 | 739,944 | 7,553,743 | 0 |
| 4 | 12,642,073 | 2,206,316 | 9,667,761 | 767,996 |
| 5 | 5,391,071 | 0 | 4,432,377 | 958,694 |
| 6 | 1,649,166 | 0 | 1,396,554 | 252,612 |
| 7 | 962,315 | 0 | 886,399 | 75,916 |
| 8 | 336,408 | 0 | 332,547 | 3,861 |
| 9 | 51,209 | 0 | 51,209 | 0 |
Only the 25-city validation set's Anchorage changes zone (8 to 9, at a radiance of 124.66, right on the edge). This measures how the definition changes today's data. It does not measure accuracy, which is the next section.
Names to fix
| Today | Holds | Should be |
|---|---|---|
JSON bortle | the Astr zone | zone |
JSON ratio and ZoneData.ratio | the radiance in nW/cm²/sr | radiance, so that the name ratio can mean |
bortleClass in older code | the Astr zone | astrZone (already renamed in the app model) |
The stored record is the H3 index, zone, radiance and sky brightness in 20 bytes, sorted by H3 index behind a 16-byte header, and only cells in zone 2 or above are stored.
Calibration and validation
The goal is to replace the legacy fit with a physical chain whose error is measured against ground data and published.
- Data. Globe at Night (CC BY 4.0) and other openly licensed sky-meter networks. The Falchi atlas (CC BY-NC) and the Lorenz atlas are comparison references only, never used to fit a parameter. Use only readings taken in astronomical darkness, moonless and clear, and subtract a natural baseline so a pristine site does not read as artificially lit.
- Parameters to fit. The scale factor and the kernel's fraction, scale length, power and reference distance. Test whether the 80 km kernel radius truncates real skyglow, since Falchi integrates to 195 km.
- Split by region, not by station, because nearby stations see the same radiance field and would leak.
- Metrics per stratum and overall: bias, mean error and root-mean-square error in mag/arcsec², the zone confusion matrix, the share within one zone, and the station count.
- Adoption rule. The calibrated chain replaces the legacy chain only if its held-out error beats the legacy baseline by a stated margin. Proposed targets, to be set after the baseline is measured: bias within 0.10 mag/arcsec² and error within 0.30. For reference, Falchi reports a residual of 0.15 for the atlas.
- Failure tags. Every failing station gets a cause: aurora or polar noise, equatorial cloud, snow, altitude, local lighting or no data. Reykjavik, Singapore and Ushuaia in the 25-city check already fail this way.
Model issues to test, found by reading the code: the kernel uses a fixed 5.55 km pixel, so its east-west footprint is distorted away from the equator; scatter inside the coarse pixel is excluded; the radius stops at 80 km; no elevation, mountain screening or aerosol variation is modelled; VIIRS is blind below 500 nm, so white LEDs are under-counted; and clouds can amplify skyglow near cities up to ten times (Falchi 2016).
Implementation
These are the real files, copied into the site and checked against the repository on every build.
"""Astr Zone scale v2.0 reference implementation.Spec: docs/astr_zone_scale.md. Test vectors: test/fixtures/astr_zone_scale.vectors.json.The Dart implementation (lib/core/utils/astr_zone_scale.dart) must give the same results.r is the ratio of artificial to natural zenith sky brightness, L_artificial / L_natural.Zone 1 is r < 0.32. Zone n (2 to 8) is [0.32 * 2^(n-2), 0.32 * 2^(n-1)). Zone 9 is r >= 40.96.Boundaries are lower-inclusive."""import mathNATURAL_UCD_PER_M2 = 174.0 # natural zenith brightness, microcandela per m^2 (Falchi et al. 2016)REFERENCE_SQM = 22.0 # mag/arcsec^2 that corresponds to the natural brightness aboveFIRST_EDGE = 0.32 # r at which zone 2 beginsSTEP = 2.0 # each zone edge is twice the previous oneZONE_COUNT = 9EDGES = tuple(FIRST_EDGE * STEP ** k for k in range(ZONE_COUNT - 1)) # start of zones 2..9# Legacy v2 chain (interim, used to build the current zones.db). Radiance in nW/cm^2/sr.LEGACY_THRESHOLDS = ((125.0, 9), (50.0, 8), (20.0, 7), (9.0, 6), (3.0, 5), (1.0, 4), (0.50, 3), (0.25, 2))LEGACY_SQM_SLOPE = 1.7LEGACY_SQM_GAIN = 2.0def _check_ratio(r): if isinstance(r, bool) or not isinstance(r, (int, float)) or math.isnan(r): raise ValueError(f"r must be a number, got {r!r}")def zone_from_ratio(r): """Zone 1-9 for an artificial/natural ratio. Negative r counts as 0. NaN raises ValueError.""" _check_ratio(r) zone = 1 for edge in EDGES: if r >= edge: zone += 1 else: break return zonedef sqm_from_ratio(r): """Zenith sky brightness in mag/arcsec^2 for a ratio r (r < 0 counts as 0).""" _check_ratio(r) return REFERENCE_SQM - 2.5 * math.log10(1.0 + max(r, 0.0))def ratio_from_sqm(sqm): """Inverse of sqm_from_ratio. An SQM above the reference gives 0.""" if math.isnan(sqm): raise ValueError("sqm must be a number") return max(10.0 ** (0.4 * (REFERENCE_SQM - sqm)) - 1.0, 0.0)def artificial_ucd_from_ratio(r): """Artificial zenith luminance in microcandela per m^2.""" _check_ratio(r) return max(r, 0.0) * NATURAL_UCD_PER_M2def nelm_from_sqm(sqm): """Naked-eye limiting magnitude from an SQM value (empirical, Unihedron/AAVSO relation).""" return 7.93 - 5.0 * math.log10(10.0 ** (4.316 - sqm / 5.0) + 1.0)def legacy_zone_from_radiance(radiance): """Zone assigned by the production pipeline's radiance thresholds.""" if radiance <= 0: return 1 for threshold, zone in LEGACY_THRESHOLDS: if radiance >= threshold: return zone return 1def legacy_ratio_from_radiance(radiance): """r implied by the legacy SQM fit 22 - 1.7 log10(1 + 2R): r = (1 + 2R)^0.68 - 1.""" if radiance <= 0: return 0.0 return (1.0 + LEGACY_SQM_GAIN * radiance) ** (0.4 * LEGACY_SQM_SLOPE) - 1.0import 'dart:math' as math;/// Astr Zone scale v2.0.////// Spec: `docs/astr_zone_scale.md`. Test vectors: `test/fixtures/astr_zone_scale.vectors.json`./// The Python reference (`scripts/astr_zone.py`) must give the same results.////// `r` is the ratio of artificial to natural zenith sky brightness. Zone 1 is/// `r < 0.32`. Zone n (2 to 8) is `[0.32 * 2^(n-2), 0.32 * 2^(n-1))`. Zone 9 is/// `r >= 40.96`. Boundaries are lower-inclusive.abstract final class AstrZoneScale { /// Natural zenith brightness in microcandela per square metre (Falchi et al. 2016). static const double naturalMicroCdPerM2 = 174; /// Sky brightness in mag/arcsec² that corresponds to the natural brightness. static const double referenceSqm = 22; /// The ratio at which zone 2 begins. static const double firstEdge = 0.32; /// Each zone edge is this multiple of the previous one. static const double step = 2; static const int zoneCount = 9; /// The ratio at which zones 2 to 9 begin. static const List<double> edges = <double>[ 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, // ]; /// Legacy radiance thresholds (nW/cm²/sr) of the production pipeline, highest first. static const List<(double, int)> legacyThresholds = <(double, int)>[ (125, 9), (50, 8), (20, 7), (9, 6), (3, 5), (1, 4), (0.5, 3), (0.25, 2), // ]; /// Zone 1-9 for a ratio. A negative [r] counts as 0. Throws if [r] is NaN. static int zoneFromRatio(double r) { _checkNumber(r, 'r'); int zone = 1; for (final double edge in edges) { if (r >= edge) { zone++; } else { break; } } return zone; } /// Zenith sky brightness in mag/arcsec² for a ratio (a negative [r] counts as 0). static double sqmFromRatio(double r) { _checkNumber(r, 'r'); return referenceSqm - 2.5 * _log10(1 + math.max(r, 0)); } /// Inverse of [sqmFromRatio]. An SQM above the reference gives 0. static double ratioFromSqm(double sqm) { _checkNumber(sqm, 'sqm'); return math.max(math.pow(10, 0.4 * (referenceSqm - sqm)).toDouble() - 1, 0); } /// Artificial zenith luminance in microcandela per square metre. static double artificialMicroCdFromRatio(double r) { _checkNumber(r, 'r'); return math.max(r, 0) * naturalMicroCdPerM2; } /// Naked-eye limiting magnitude from an SQM value (empirical, Unihedron/AAVSO relation). static double nelmFromSqm(double sqm) { return 7.93 - 5 * _log10(math.pow(10, 4.316 - sqm / 5).toDouble() + 1); } /// Zone assigned by the production pipeline's radiance thresholds. static int legacyZoneFromRadiance(double radiance) { if (radiance <= 0) return 1; for (final (double, int) entry in legacyThresholds) { if (radiance >= entry.$1) return entry.$2; } return 1; } /// The ratio implied by the legacy SQM fit `22 - 1.7 log10(1 + 2R)`: `(1 + 2R)^0.68 - 1`. static double legacyRatioFromRadiance(double radiance) { if (radiance <= 0) return 0; return math.pow(1 + 2 * radiance, 0.4 * 1.7).toDouble() - 1; } static double _log10(double x) => math.log(x) / math.ln10; static void _checkNumber(double value, String name) { if (value.isNaN) { throw ArgumentError.value(value, name, 'must be a number'); } }}