Skip to content

Sky science

Light pollution data

How a satellite image of the Earth at night becomes a zone for every 0.7 km² cell, and how the app looks it up.

The pipeline

A satellite looks down and measures light that escapes upwards. A person on the ground looks up and sees light scattered through the air from cities tens of kilometres away. The pipeline turns the first into an estimate of the second, one cell at a time.

  1. Read

    The annual night-lights image is read in strips of 200 rows. Each pixel above a small floor becomes an H3 cell, keeping the maximum radiance per cell in a disk-backed accumulator. generate_zones_vnl.py

  2. Downsample

    The image is block-averaged by a factor of 12 into a coarse grid of about 5.5 km pixels. apply_skyglow.py

  3. Scatter

    A scatter kernel is convolved with the coarse grid (FFT) to estimate skyglow from every lit pixel within 80 km. apply_skyglow.py

  4. Rescan

    The full-resolution image is read again. Each pixel gets its direct radiance plus the scatter from the coarse grid. Cells at or above 0.25 nW/cm²/sr are kept. apply_skyglow.py

  5. Write

    Cells are sorted by H3 index and written to a binary zones.db with a zone, the radiance and a sky brightness per cell. apply_skyglow.py

  6. Publish

    The file is split into SQL parts for the D1 database and uploaded to R2 for download. export_zones_to_sql.py, upload_to_r2.py

Cells below the floor are not stored. A location that is not in the file is a dark site and counts as zone 1.

Source data

The input is the VIIRS Nighttime Lights annual composite for 2024 from the Earth Observation Group at the Colorado School of Mines, a 15 arc-second global grid of average radiance in nW/cm²/sr. Many of the group's products are published under CC BY 4.0 and must be cited. The licence of the exact 2024 product used here is still to be confirmed (unverified).

Elvidge, C.D., Zhizhin, M., Ghosh, T., Hsu, F.-C., Taneja, J. (2021). Annual time series of global VIIRS nighttime lights derived from monthly averages: 2012 to 2019. Remote Sensing 13(5), 922.

Skyglow

Each lit source pixel adds a little light at the observer. The kernel is Garstang-type: an exponential loss with distance, then a power-law falloff.

k(d)=F e−d/L1+(d/d0)β,skyglow=∑iRi k(di)(di≤80 km)k(d) = \frac{F\,e^{-d/L}}{1 + (d/d_0)^{\beta}}, \qquad \text{skyglow} = \sum_i R_i\,k(d_i) \quad (d_i \le 80\ \text{km})

Values are read from scripts/apply_skyglow.py when the site is built.
SymbolMeaningValueScript constant
FFScatter fraction: share of upward light that scatters horizontally (the script's default).0.12SCATTER_FRACTION
LLScale length, km: exponential attenuation length.20SCATTER_SCALE_KM
d0d_0Reference distance, km: where the power law takes over.10D_REF_KM
β\betaPower: power-law falloff.2.5SCATTER_POWER
dmax⁡d_{\max}Maximum radius, km: scatter beyond this is dropped.80MAX_RADIUS_KM
ppCoarse pixel, km: size of one pixel of the downsampled grid at the equator.5.55PIXEL_KM
nnDownsample factor: 15 arc-second pixels averaged into one coarse pixel.12DOWNSAMPLE
hhH3 resolution: cell size of the stored zones.8H3_RESOLUTION

Production data was regenerated with F=0.06F = 0.06 instead of the script's default. That run reset the accumulator and passed --fraction 0.06 on the command line, so the script's own default still reads 0.12 and a plain run does not reproduce the data. The earlier documentation also listed a table of distances that did not follow from the formula. This is the table the formula gives for the production fraction:

1e111e-11e-21e-31e-41e-51e-6125102050100kernel stops at 80 km

Contribution to the sky of one lit pixel of 40 nW/cm²/sr (vertical, nW/cm²/sr, log scale) against distance in kilometres (horizontal, log scale). Dashed: F = 0.12 (script default). Solid: F = 0.06 (production data).

F = 0.06. One lit pixel of 40 nW/cm²/sr. A city is many such pixels, so its skyglow is far larger.
Distance kmk(d)Contribution nW/cm²/sr
53.97e-21.6
101.82e-20.73
203.32e-30.13
308.07e-40.032
508.66e-50.0035
806.04e-60.00024

References: Garstang (1986), PASP 98, 364; Cinzano, Falchi and Elvidge (2001), MNRAS 328, 689.

H3 cells

Cells use Uber's H3 grid at resolution 8: hexagons of about 0.74 km² with an edge of about 0.46 km. A latitude and longitude maps to one 64-bit cell index, so a lookup is a single key. The app and the pipeline both use resolution 8.

H3 indices use the full unsigned 64-bit range, but SQLite and D1 store signed integers, so the worker converts between them.

Zones from radiance

The stored zone comes from radiance thresholds, and the stored sky brightness from a fit. These are the legacy chain, described with the new definition on Zone scale.

generate_zones_vnl.py · ZONE_THRESHOLDS
# VNL radiance thresholds (nW/cm²/sr) → Bortle zoneZONE_THRESHOLDS = [    (125.0, 9),   # Inner city (NYC, London centers)    (50.0,  8),   # Dense urban    (20.0,  7),   # Urban (Dehradun ~40 → Zone 7)    (9.0,   6),   # Bright suburban    (3.0,   5),   # Suburban    (1.0,   4),   # Rural/suburban transition    (0.50,  3),   # Rural sky    (0.25,  2),   # Typical dark site]
generate_zones_vnl.py · radiance_to_zone
def radiance_to_zone(radiance: float) -> int:    if radiance <= 0:        return 1    for threshold, zone in ZONE_THRESHOLDS:        if radiance >= threshold:            return zone    return 1 if radiance < ZONE2_RADIANCE else 2
generate_zones_vnl.py · radiance_to_sqm
def radiance_to_sqm(radiance: float) -> float:    """Convert VNL radiance to Sky Quality Meter reading (mag/arcsec²)."""    if radiance <= 0:        return 22.0    sqm = 22.0 - 1.7 * math.log10(1.0 + 2.0 * radiance)    return max(16.0, min(22.0, sqm))

The comment on the thresholds still says "Bortle", which is a leftover from an earlier name. The zones are Astr zones and are not Bortle classes.

The zones.db file

PartSizeContent
Header16 bytesThe ASCII bytes ASTR, a version word (01 00 00 00), then the record count as an unsigned 64-bit little-endian integer
Record20 bytesH3 index (unsigned 64-bit), zone (1 byte), radiance (32-bit float), sky brightness (32-bit float), 3 bytes of padding, all little-endian
OrderSorted by H3 index, so a lookup is a binary search
StoredOnly cells in zone 2 or above; absence means zone 1

The current file holds 37,528,537 records in 750,570,756 bytes (716 MB).

Serving and lookup

A Cloudflare Worker answers GET /zone/{h3 hex} from a D1 table zones(h3, zone, radiance, sqm) and returns JSON. For a cell that is not in the table it answers zone 1 with implicit: true, which matches the app's local behaviour. Responses are cached for an hour, because the data is static. GET /download streams the whole file from R2.

The app converts the location to a resolution-8 cell, then asks CachedZoneRepository: a Hive cache first, then the Worker, then an expired cache entry if the network fails, then the dark-site default. Cache entries expire after a year. See Offline and sync.

Validation

The final check before deployment compared 25 places across continents, urban cores and dark sites, against expectations derived from the Lorenz light-pollution atlas.

22 of 25 match. Final validation run of Phase 2 plan 04 (2026-06-14) on zones.db SHA-256 09cb4d9920c65fc70eebfa098223809e9b7dd451aecc01965d6aef54f7b83f08. Expected zones were derived from the Lorenz atlas with the legacy thresholds. Radiance is the value stored for the cell; none means the cell is not in the database, which is zone 1. The causes are the run's own assessment, not measured.
PlaceExpectedStoredRadianceLadder zoneNote
New York, USA99247.769
London, UK99206.979
Tokyo, Japan8867.438
Sydney, Australia99148.369
Paris, France8897.868
Berlin, Germany8880.538
Mumbai, India8893.528
São Paulo, Brazil99179.619
Cairo, Egypt99166.629
Cape Town, South Africa8883.898
Death Valley, USA11none1
Atacama Desert, Chile11none1
Teide, Canary Islands330.963
Mauna Kea, Hawaii11none1
Namib Desert, Namibia11none1
Antarctica McMurdo11none1
Greenland Nuuk6615.926
Sahara Desert11none1
Gobi Desert, Mongolia11none1
Outback, Australia11none1
Reykjavik, Iceland79125.829A city of about 230 thousand at 125 nW matches New York in density, which is implausible. Likely aurora or polar-night noise at 65°N.
Singapore9736.977One of the densest city centres in Asia reads only 37 nW. Likely an equatorial under-read from persistent cloud and a low viewing angle.
Wellington, NZ8859.478
Anchorage, Alaska88124.669
Ushuaia, Argentina78105.758Reads 105.75 nW for a town of about 80 thousand. Likely high-latitude VIIRS noise.

The last column shows the zone each stored radiance would get under the doubling ladder: only Anchorage changes. The three mismatches are traced to the satellite data, not to the scatter model, and no change to the scatter parameters can fix them.

Limits

  • Blue light. VIIRS does not see below 500 nm, so white LED lighting is under-counted (Falchi et al. 2016).
  • Clouds and snow. Clouds can amplify skyglow near cities up to ten times, and snow changes ground reflection. The model assumes a clear night.
  • Polar and equatorial noise. Aurora and polar-night noise inflate high latitudes, and persistent cloud deflates equatorial cities.
  • Kernel geometry. The kernel uses a fixed 5.55 km pixel, so its east-west footprint shrinks relative to the north-south one away from the equator.
  • Near field. Scatter inside a coarse pixel is excluded, so light from the immediate neighbourhood of a cell may be under-counted.
  • Radius. The 80 km cutoff is shorter than the 195 km Falchi integrates to.
  • Terrain. No elevation, mountain screening or varying aerosol is modelled.
  • Staleness. The data is one annual composite and is regenerated yearly.

Regenerating

Changing a scatter parameter downwards needs the accumulator reset, because it keeps the maximum value per cell. The retune to 0.06 took 17 batches of ten strips at about 3.5 minutes each, around 54 minutes in total.

text
python scripts/generate_zones_vnl.py --tif <VNL annual composite>python scripts/apply_skyglow.py --tif <VNL annual composite> --accum zones_accumulator.db \    --fraction 0.06 --reset-accum --batch 10python scripts/export_zones_to_sql.pypython scripts/upload_to_r2.py

The commands are from the pipeline's own guide in archive/data-pipeline/README.md; the flag names are in the scripts below.

Implementation

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

apply_skyglow.py · create_scatter_kernel
def create_scatter_kernel():    """Atmospheric scatter PSF. Center zeroed (no self-scatter)."""    radius_px = int(MAX_RADIUS_KM / PIXEL_KM) + 1    size = 2 * radius_px + 1    kernel = np.zeros((size, size), dtype=np.float64)    c = radius_px    for y in range(size):        for x in range(size):            d = math.sqrt(((y - c) * PIXEL_KM)**2 + ((x - c) * PIXEL_KM)**2)            if d < 0.5 or d > MAX_RADIUS_KM:                continue            kernel[y, x] = (SCATTER_FRACTION *                            math.exp(-d / SCATTER_SCALE_KM) /                            (1.0 + (d / D_REF_KM) ** SCATTER_POWER))    return kernel.astype(np.float32)
apply_skyglow.py · write_zones_db
def write_zones_db(accum_path, output_path):    conn = sqlite3.connect(str(accum_path))    total = conn.execute('SELECT COUNT(*) FROM cells').fetchone()[0]    print(f"\nWriting {total:,} cells to {output_path}")    written = 0    skipped = 0    with open(output_path, 'wb') as f:        f.write(b'ASTR\x01\x00\x00\x00')        count_pos = f.tell()        f.write(struct.pack('<Q', 0))        cursor = conn.execute('SELECT h3, radiance FROM cells ORDER BY h3')        while True:            rows = cursor.fetchmany(100_000)            if not rows: break            for h3_int, rad in rows:                zone = radiance_to_zone(rad)                if zone <= 1:                    skipped += 1; continue                sqm = radiance_to_sqm(rad)                f.write(struct.pack('<Q', h3_int))                f.write(struct.pack('B', zone))                f.write(struct.pack('<f', rad))                f.write(struct.pack('<f', sqm))                f.write(b'\x00\x00\x00')                written += 1        f.seek(count_pos)        f.write(struct.pack('<Q', written))    sha = hashlib.sha256()    with open(output_path, 'rb') as f:        for chunk in iter(lambda: f.read(8192), b''): sha.update(chunk)    size_mb = output_path.stat().st_size / (1024**2)    print(f"\n{'='*50}")    print(f"SUCCESS!")    print(f"  Records: {written:,} (skipped {skipped:,} Zone 1)")    print(f"  Size: {size_mb:.1f} MB")    print(f"  SHA-256: {sha.hexdigest()}")    print(f"{'='*50}")    # Quick validation    print("\nValidation:")    for name, lat, lon in [        ("Bhadraj Temple", 30.5167, 78.0333),        ("Dehradun", 30.3165, 78.0322),        ("Hanle", 32.7795, 78.9641),        ("New York City", 40.7128, -74.0060),        ("Null Island", 0.0, 0.0),    ]:        h3_cell = h3.latlng_to_cell(lat, lon, H3_RESOLUTION)        h3_int = int(h3_cell, 16)        row = conn.execute('SELECT radiance FROM cells WHERE h3=?', (h3_int,)).fetchone()        if row:            z = radiance_to_zone(row[0])            print(f"  {name}: Zone {z} (radiance={row[0]:.4f})")        else:            print(f"  {name}: Zone 1 (Implicit)")    conn.close()