Corrected sky brightness estimates from fused satellite and ground-truth data.
Duskmeter reconciles VIIRS satellite radiance with citizen-science sky brightness observations to produce site-by-site corrected light pollution estimates. It quantifies the spectral divergence between what the satellite sees and what the human eye observes, attributing the gap to LED blue-light emission that falls outside the VIIRS sensor window.
Every public light pollution map (NOAA, Light Pollution Atlas, the World Atlas) uses the VIIRS Day/Night Band as its only data source. That sensor was calibrated for high-pressure sodium lamps, which emit almost all of their light at 589 nm inside the sensor's detection range of roughly 500 to 900 nm.
White and blue-rich LEDs (now the dominant streetlight type worldwide) emit strongly at around 450 nm, below the sensor's window. The satellite literally cannot see the fastest-growing source of light pollution. The result is that every public map systematically underestimates sky brightness wherever LEDs have replaced sodium lighting.
Duskmeter cross-references VIIRS data with ground-truth observations from Globe at Night and SQM meters to estimate what the sky actually looks like, not just what the satellite reports.
Duskmeter uses a per-site physical calibration model to relate satellite radiance to ground-level sky brightness:
predicted_brightness = α × VIIRS_radiance + β
- α (slope): The atmospheric scattering coefficient. How much upwelling light scatters back to the ground. Depends on elevation, humidity, and aerosol load.
- β (intercept): The natural sky background. Airglow, zodiacal light, and unresolved starlight.
For sites without calibration data, α and β come from published population-level priors (Falchi 2016, Duriscoe 2013, Kyba 2017) specific to each site type (observatory, dark-sky park, or urban). Sites with enough matched SQM and VIIRS readings can be fitted automatically using Bayesian linear regression.
The fusion pipeline:
- Ingest: VIIRS yearly GeoTIFFs are pre-processed once into SQLite via
POST /api/admin/ingest-viirs. Supports BigTIFF (64-bit offsets for files over 4 GB) and regular TIFF. - Calibrate: The system tries to fit a site-specific α and β from matched VIIRS and ground-truth readings. Falls back to population priors if fewer than 8 matched readings are available.
- Predict: Each VIIRS reading is converted to predicted sky brightness using the calibration.
- Compare: Residuals between predicted and observed brightness are computed. If residuals grow over time, that is the signature of LED spectral divergence.
- Report: The corrected trend, its uncertainty, the spectral divergence fraction, and the calibration source are stored and displayed.
| Layer | Technology |
|---|---|
| Framework | SvelteKit |
| 3D globe | Three.js |
| Styling | Tailwind CSS v4 |
| Maps | Leaflet |
| Charts | Canvas 2D API (custom trend charts) |
| Database | better-sqlite3 |
| Statistics | Bayesian linear regression with conjugate priors |
| Language | TypeScript, end-to-end |
npm install
npm run devThe development server runs at http://localhost:5173.
| Command | Description |
|---|---|
npm run dev |
Start development server |
npm run build |
Build for production |
npm run preview |
Preview production build |
npm run check |
Run Svelte type checking |
npm run test |
Run unit tests (Vitest) |
| Variable | Required | Default | Description |
|---|---|---|---|
ADMIN_SECRET |
Yes | — | Shared token to protect admin endpoints. Pass via Authorization: Bearer <token> header. Without it, sync and ingest endpoints return 401. |
NODE_ENV |
No | development |
Set to production to disable the TIFF file reader fallback. In production, VIIRS data must be pre-ingested. |
LLM_PROVIDER |
No | ollama |
LLM provider for site narrative generation. Options: ollama, openai, anthropic. |
LLM_MODEL |
No | llama3 |
Model name for the selected provider. |
OPENAI_API_KEY |
No | — | Required only when LLM_PROVIDER=openai. |
ANTHROPIC_API_KEY |
No | — | Required only when LLM_PROVIDER=anthropic. |
OLLAMA_BASE_URL |
No | http://localhost:11434 |
Ollama server URL. |
ASSESS_INTERVAL_MINUTES |
No | 360 |
Minutes between scheduled Bayesian assessments. |
A prebuilt image is published to GitHub Container Registry on every release. Tags follow semantic versioning: latest, v1, v1.0, v1.0.0.
- Docker and Docker Compose installed
- A VIIRS yearly file downloaded from EOG and placed at
data/viirs/VNL_npp_{year}_global_vcmslcfg_v2_c*.average.dat.tif.gz - A value for
ADMIN_SECRET— set it in a.envfile or export it as an environment variable
The application uses SQLite with a single file as its database. When running in development (npm run dev), the file is named duskmeter-dev.sqlite. When running in production (Docker or node build), it uses duskmeter.sqlite. This separation prevents your development work from accidentally overwriting production data.
The database file is bind-mounted from your host into the container:
./duskmeter.sqlite (on your machine) → /app/duskmeter.sqlite (inside the container)
The container reads and writes directly to your host file. When the container restarts, is stopped, or is replaced with a newer image, the file stays on your machine — nothing is lost.
# 1. Ensure the VIIRS file is in place
ls data/viirs/VNL_npp_*.tif.gz
# 2. Start the container (creates an empty database, seeds 12 default sites)
docker compose up -d
# 3. Ingest the VIIRS GeoTIFF into the database (reads pixel values at each site)
curl -X POST "http://localhost:3000/api/admin/ingest-viirs" \
-H "Authorization: Bearer your-secret"
# 4. Run the initial sync (fetches Globe at Night data, computes metrics)
curl -X POST "http://localhost:3000/api/sync?force=true" \
-H "Authorization: Bearer your-secret"
# 5. Verify
curl http://localhost:3000/api/health
# {"status":"ok","db":"connected","sites":12}
# 6. Open the dashboard
open http://localhost:3000If you have been running npm run dev and already have real VIIRS data and metrics in your database:
# Rename the dev database for production use
cp duskmeter-dev.sqlite duskmeter.sqlite
# Start the container — no re-ingestion needed, data is already in the database
docker compose up -d
# Verify
curl http://localhost:3000/api/health
# {"status":"ok","db":"connected","sites":12}| Component | Host location | Container location | What happens |
|---|---|---|---|
| VIIRS GeoTIFF files | ./data/viirs/ |
/app/data/viirs/ |
Mounted read/write. Ingest decompresses the file in /tmp, reads pixel values, stores results in the database, and deletes the temp file |
| Globe at Night cache | ./data/gan/ |
/app/data/gan/ |
Mounted read/write. The container fetches remote data from NOIRLab and caches it here for faster subsequent syncs |
| SQLite database | ./duskmeter.sqlite |
/app/duskmeter.sqlite |
Bind-mounted. The container reads and writes directly to your host file. Survives container restarts, rebuilds, and image updates |
| Application logs | stdout | stdout | View with docker compose logs -f duskmeter. JSON lines with timestamps, levels, and context |
When a new yearly composite is released, download it and place it alongside existing files in data/viirs/. Then run ingestion and sync again — only the new year's data is processed:
curl -X POST "http://localhost:3000/api/admin/ingest-viirs" \
-H "Authorization: Bearer your-secret"
curl -X POST "http://localhost:3000/api/sync?force=true" \
-H "Authorization: Bearer your-secret"The entire application state is in one file. Back it up with:
cp duskmeter.sqlite duskmeter.sqlite.$(date +%Y-%m-%d).bakThe VIIRS files and Globe at Night cache in ./data/ are reproducible — they can be re-downloaded from their respective sources. Only the database needs backing up.
docker compose pull # Pull the latest published image
docker compose up -d # Replace the container — database and data files are untouched# Health check — confirms DB is connected, shows site count and last sync time
curl http://localhost:3000/api/health
# List all sites with their corrected trends
curl http://localhost:3000/api/sites | python3 -m json.tool | head -20
# Check for active alerts
curl http://localhost:3000/api/alerts | python3 -m json.tool | head -10npm install
npm run build
# Place your VIIRS yearly file
cp VNL_npp_2025_global_vcmslcfg_v2_c202604011200.average.dat.tif.gz data/viirs/
# Ingest and sync
curl -X POST "http://localhost:3000/api/admin/ingest-viirs" \
-H "Authorization: Bearer your-secret"
curl -X POST "http://localhost:3000/api/sync?force=true" \
-H "Authorization: Bearer your-secret"
# Start
NODE_ENV=production ADMIN_SECRET=your-secret node buildThe production server listens on port 3000 by default. Set PORT to override.
curl http://localhost:3000/api/health
# {"status":"ok","version":"0.1.0","uptime":120,"db":"connected","sites":12,"lastSync":"..."}Two GitHub Actions workflows run automatically:
| Workflow | Trigger | What It Does |
|---|---|---|
| CI | Push to main, pull requests |
Type check, run tests, verify production build |
| Release | GitHub Release published | Build multi-arch Docker image (linux/amd64, linux/arm64), push to ghcr.io/thelinuxguy-ssh/duskmeter with semver tags + latest |
To create a release:
git tag v0.1.0
git push origin v0.1.0Then create a release on GitHub from that tag. The workflow builds and publishes the container automatically.
Duskmeter relies on two public datasets:
Annual cloud-free composite nighttime lights at roughly 15 arc-second resolution, produced by the Earth Observation Group, Payne Institute for Public Policy, Colorado School of Mines. VIIRS/DNB data are in the public domain.
To use real satellite data:
- Download yearly files from
eogdata.mines.eduand place them indata/viirs/. - The minimum required file is the average radiance composite:
VNL_npp_{year}_global_vcmslcfg_v2_c*.average.dat.tif.gz - Optionally, add the cloud-free coverage file to get observation counts per pixel:
VNL_npp_{year}_global_vcmslcfg_v2_c*.cf_cvg.dat.tif.gz - Run ingestion:
curl -X POST "http://localhost:3000/api/admin/ingest-viirs" -H "Authorization: Bearer your-secret" - The ingestion pipeline decompresses the gzipped TIFF, reads pixel values at each site location, stores them in the database, and cleans up the temporary file.
A deterministic synthetic fallback is used for any year without a local file. The ingest endpoint takes roughly one to two minutes per yearly file.
Citizen-science measurements of naked-eye limiting magnitude and sky quality (SQM). Data copyright Globe at Night, licensed under CC BY 4.0, fetched and cached locally in data/gan/. Attribution is required per license terms.
When distributing Duskmeter output, credit the Earth Observation Group (VIIRS/DNB) and Globe at Night contributors (CC BY 4.0).
src/
├── app.css # Global styles, theme tokens, form controls
├── app.html # HTML shell
├── hooks.server.ts # Server-side hooks
├── assets/ # Static assets (favicon)
├── lib/
│ ├── components/
│ │ ├── Globe.svelte # Three.js 3D Earth with heatmap-style light pollution
│ │ ├── MapView.svelte # Leaflet map with site markers and zoom-to
│ │ ├── SiteList.svelte # Sidebar with per-site metrics and calibration info
│ │ ├── SiteDetail.svelte# Modal with full metrics, charts, and Bayesian assessment
│ │ ├── TrendChart.svelte# Canvas 2D trend visualization
│ │ ├── Header.svelte # App header with nav, theme toggle, and refresh
│ │ ├── FilterBar.svelte # Search and filter controls
│ │ └── ui/ # Reusable UI components (buttons, badges, cards, logo)
│ ├── data/
│ │ ├── types.ts # Shared TypeScript interfaces
│ │ ├── mock.ts # Client-side mock data fallback
│ │ └── world-rings.json # Continent outline polygons for the globe
│ ├── server/
│ │ ├── db.ts # Lazy-init SQLite with schema and migrations
│ │ ├── seed.ts # Initial site and data seeding
│ │ ├── recalculate.ts # Orchestrates recalculation of site metrics
│ │ ├── assess.ts # Bayesian assessment pipeline
│ │ ├── alerts.ts # Alert generation engine
│ │ ├── scheduler.ts # Background assessment scheduling
│ │ ├── llm.ts # Optional LLM narrative (Ollama, OpenAI, Anthropic)
│ │ ├── export.ts # CSV stringifier
│ │ └── data-sources/
│ │ ├── viirs.ts # BigTIFF/GeoTIFF parser, pixel reader, synthetic fallback
│ │ ├── calibration.ts # Site calibration model with Bayesian fitting and population priors
│ │ ├── calculator.ts # Calibration-based fusion metrics
│ │ ├── bayesian.ts # Bayesian linear regression with conjugate priors
│ │ ├── globe-at-night.ts# Globe at Night data fetcher and cache
│ │ ├── quality-engine.ts# Ground report quality filtering by moon/cloud
│ │ └── change-point.ts # Sliding-window change point detection
│ └── stores/
│ ├── sites.ts # Site data stores, filters, fetch/sync/assess functions
│ ├── theme.ts # Theme mode store (light, dark, night-vision)
│ └── ui.ts # UI state store
└── routes/
├── (marketing)/ # Landing page with hero, globe, and methodology sections
├── (app)/ # Dashboard, global overview, methodology, alerts
└── api/ # REST API for sites, sync, ingest, export, and calibration
| Endpoint | Method | Purpose |
|---|---|---|
/api/sites |
GET | List all sites with metrics |
/api/sites/{id} |
GET | Site detail with readings, metrics, alerts, and assessment |
/api/sites/{id}/refresh |
POST | Refresh single site from data sources |
/api/sites/{id}/assess |
POST | Run Bayesian assessment for a site |
/api/sites/{id}/calibration |
POST | Upload SQM/limiting magnitude readings for calibration |
/api/sites/{id}/export |
GET | Export site data as CSV or JSON |
/api/sync |
POST | Full sync of all sites (VIIRS, ground, metrics) |
/api/admin/ingest-viirs |
POST | Ingest VIIRS yearly GeoTIFF into database |
/api/assess-all |
POST | Run Bayesian assessment for all sites |
/api/alerts |
GET | List active alerts |
/api/alerts/{id}/acknowledge |
POST | Acknowledge an alert |
/api/export |
GET | Export all site metrics as CSV or JSON |
- Calibration data: Most sites currently use population-level priors rather than site-specific calibration because calibrated SQM readings matched to satellite overpasses are not yet available for all locations. The corrected trends show appropriately wide uncertainty bounds for population-calibrated sites.
- Single-year VIIRS data: The repository ships with synthetic data for years 2012 through 2024 and real VIIRS data for 2025 only. Corrected trends will become more stable as more real satellite years are ingested.
- Coverage files: Cloud-free observation counts default to 1 per site when the
cf_cvgfile is not placed alongside the average file. This reduces the precision of data quality scoring. - Ground observations: Globe at Night provides sparse and noisy citizen-science reports. The quality engine filters reports with high moon illumination or cloud cover, but the data remains inherently less dense than the satellite record.
Contributions are welcome. See the issues directory for a curated list of good first issues and medium-difficulty tasks, ranging from TypeScript bug fixes to new feature implementation.
In short:
- Fork the repository.
- Create a feature branch.
- Run
npm run checkbefore committing. - Submit a pull request.