Skip to content

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 LartL_{\text{art}} the zenith luminance added by artificial light and LnatL_{\text{nat}} the natural zenith luminance:

r=LartLnat,Lnat=174 μcd/m2  ≡  22.0 mag/arcsec2r = \frac{L_{\text{art}}}{L_{\text{nat}}}, \qquad L_{\text{nat}} = 174\ \mu\text{cd/m}^2 \;\equiv\; 22.0\ \text{mag/arcsec}^2

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.

SymbolMeaningUnit
rrratio of artificial to natural zenith brightnessnone
LartL_{\text{art}}zenith luminance added by artificial lightµcd/m²
LnatL_{\text{nat}}natural zenith luminance, 174µcd/m²
RRradiance seen by the satellite, plus modelled skyglownW/cm²/sr
SQM\text{SQM}sky brightness as a sky meter reads it (a larger number is darker)mag/arcsec²
NELM\text{NELM}naked-eye limiting magnitude, the faintest star you can seemagnitude
zzthe zone, 1 to 9none

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 r=0.32r = 0.32. Each edge is twice the one before it, and a value exactly on an edge belongs to the higher zone.

zone(r)={1r<0.322+⌊log⁡2(r/0.32)⌋0.32≤r<40.969r≥40.96\text{zone}(r) = \begin{cases} 1 & r < 0.32 \\ 2 + \lfloor \log_2 (r / 0.32) \rfloor & 0.32 \le r < 40.96 \\ 9 & r \ge 40.96 \end{cases}

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.

VISIBILITYZone 1
Dark Sky22.00 MPSAS
VISIBILITYZone 2
Dark Sky21.85 MPSAS
VISIBILITYZone 3
Rural Sky21.60 MPSAS
VISIBILITYZone 4
Rural Sky21.30 MPSAS
VISIBILITYZone 5
Suburban Sky20.90 MPSAS
VISIBILITYZone 6
Suburban Sky20.40 MPSAS
VISIBILITYZone 7
Urban Sky19.80 MPSAS
VISIBILITYZone 8
Urban Sky19.00 MPSAS
VISIBILITYZone 9
Urban Sky18.20 MPSAS

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.

0.3221.70
0.6421.46
1.2821.11
2.5620.62
5.1220.03
10.2419.37
20.4818.67
40.9617.94

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.

SQM and NELM are shown at the lower edge of each zone. Zone 9 has no upper edge.
Zoner fromr toArtificial µcd/m²SQMNELM
100.32022.006.62
20.320.645621.706.48
30.641.2811121.466.37
41.282.5622321.116.17
52.565.1244520.625.89
65.1210.2489120.035.52
710.2420.48178219.375.05
820.4840.96356418.674.51
940.96none712717.943.92

A doubling is 0.753 magnitudes, so adjacent zones differ by about three quarters of a magnitude of sky brightness.

Why this ladder

  1. It reproduces the production data. Converting the radiance thresholds that built the current zones.db into ratios gives edges within 0.87 to 1.25 times the ladder (table below).
  2. 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).
  3. 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 r=0.32r = 0.32 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

SQM=22.0−2.5log⁡10(1+r)r=100.4 (22.0−SQM)−1Lart=174 r\text{SQM} = 22.0 - 2.5\log_{10}(1 + r) \qquad r = 10^{0.4\,(22.0 - \text{SQM})} - 1 \qquad L_{\text{art}} = 174\,r

NELM=7.93−5log⁡10 ⁣(10 4.316−SQM/5+1)\text{NELM} = 7.93 - 5\log_{10}\!\left(10^{\,4.316 - \text{SQM}/5} + 1\right)

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

InputResult
r<0r < 0Counts as 0, zone 1
rr is not a numberError (ValueError in Python, ArgumentError in Dart)
rr is infiniteZone 9
A cell is absent from zones.dbImplicit zone 1, sky brightness 22.0
SQM≥22.0\text{SQM} \ge 22.0r=0r = 0

Current data and the legacy chain

The current zones.db was built from radiance RR in nW/cm²/sr (VIIRS light plus modelled skyglow, see Light pollution data) with a fit and thresholds. This is the legacy chain:

SQM=22.0−1.7log⁡10(1+2R)⇒r=(1+2R)0.68−1\text{SQM} = 22.0 - 1.7\log_{10}(1 + 2R) \quad\Rightarrow\quad r = (1 + 2R)^{0.68} - 1

0.3221.70
0.6421.46
1.2821.11
2.5620.62
5.1220.03
10.2419.37
20.4818.67
40.9617.94
0.317
0.602
1.11
2.76
6.41
11.5
22.1
41.8

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.

The legacy chain converts radiance R to a ratio with r = (1 + 2R)^0.68 - 1. The ladder edge is shown as the radiance at which that chain reaches it.
Zone startsLegacy threshold RLegacy implied rLadder edge rLadder edge as RLegacy / ladder
20.250.320.320.2520.99
30.50.600.640.5350.94
411.111.281.1800.87
532.762.562.7351.08
696.415.126.6771.25
72011.4910.2417.0471.12
85022.0620.4844.9821.08
912541.8340.96121.2571.02

After calibration the chain becomes Lart=k ReffL_{\text{art}} = k\,R_{\text{eff}} with kk fitted to ground measurements, and the zone is computed from r=Lart/174r = L_{\text{art}} / 174. 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.
Computed 2026-10-09 over every record of assets/db/zones.db (SHA-256 09cb4d9920c65fc70eebfa098223809e9b7dd451aecc01965d6aef54f7b83f08): radiance to implied ratio r = (1 + 2R)^0.68 - 1, then the ladder zone, compared with the stored zone.
Stored zoneCellsMove down oneStayMove up one
28,202,608110,5538,092,0550
38,293,687739,9447,553,7430
412,642,0732,206,3169,667,761767,996
55,391,07104,432,377958,694
61,649,16601,396,554252,612
7962,3150886,39975,916
8336,4080332,5473,861
951,209051,2090

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

TodayHoldsShould be
JSON bortlethe Astr zonezone
JSON ratio and ZoneData.ratiothe radiance RR in nW/cm²/srradiance, so that the name ratio can mean rr
bortleClass in older codethe Astr zoneastrZone (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 kk 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.

scripts/astr_zone.py
"""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.0