How we decide
whether tonight is worth it
A plain guide to the five things that actually matter when you look up — and the full algorithm, for the curious.
Your worst factor sets a ceiling.
Everything else can only lower it.
If you're in the middle of a bright city, a crystal-clear sky with a new moon still won't let you see the Milky Way. The light pollution sets a ceiling — the most you could ever see from that spot — and no amount of good weather can raise it.
Five things combine to predict tonight's sky. Four of them — light pollution, clouds, darkness, and precipitation — act as ceilings. Any one of them alone can sink your score. The rest — the moon, transparency, and seeing — act as reducers: they can pull the score down from the ceiling, but never raise it.
Scores are computed using Liebig's law of the minimum: score = ceiling × degradation. The ceiling is the minimum over the hard-limiting factors (LP, cloud, darkness, precipitation). Degradation is the weighted geometric mean of soft factors (moon, transparency, seeing). A final modifier stack (fog risk, wind) multiplies in.
ceiling = min(LP, cloud, darkness, 1 − precip_prob)
degradation = moon^w_m × transparency^w_t × seeing^w_s
score = ceiling × degradation × modifier
Each component is normalized to [0, 1]. Ceilings are absolute — a score of 0.26 for light pollution guarantees the final score ≤ 0.26. The model replaces the previous weighted-geometric-mean implementation, which could under-weight the light pollution factor and produce scores well above the physical ceiling.
What tonight's sky is made of
Source: DJ Lorenz 2024 North America atlas (15480×8160 palette-indexed PNG, equirectangular). A pixel lookup yields a Bortle band, which maps to a sky brightness in mpsas (magnitudes per square arcsecond) via the Light Pollution Index model.
def light_pollution_score(mpsas):
linear = clamp((mpsas - 16) / 6, 0, 1)
return linear ** 1.3 # perceptual curve
# mpsas ≈ 17.5 (Bortle 7) → 0.17
# mpsas ≈ 19 (Bortle 5) → 0.41
# mpsas ≈ 21 (Bortle 3) → 0.79
# mpsas ≈ 22 (Bortle 1) → 1.00
The ^1.3 curve reflects that sky brightness is logarithmic — each 1-mpsas step is ≈ 2.5× brighter sky — so inner-city bands need to degrade faster than a linear scale would suggest. High elevation slightly raises this ceiling (thinner atmosphere), capped at 1.0 so it can never exceed the pristine value.
Sources: 7Timer (astronomy-specific), NWS (US-only, localized), Open-Meteo (layer-aware: low/mid/high clouds). Blended into a unified 1–9 band.
cloud_score(cc) = ((9 − cc) / 8) ** 1.5
Blend rule: when sources strongly disagree (spread > 0.33 on the normalized scale), the worst source wins — honest pessimism beats a charitable average that splits the difference between "clear" and "overcast."
Source: PyEphem computes the sun's altitude for the observer's location and time. The darkness_quality field smoothly ramps from 0 (sun above horizon) through 0.5 (civil/nautical twilight) to 1.0 (sun below −18°, astronomical dark).
darkness_ceiling = darkness_quality # 0.0 (daylight) → 1.0 (full dark)
As a ceiling, daytime drives the whole score to zero instantly — no smooth multiplier can "leak" through.
Sources: NWS precipitation chance, Open-Meteo precipitation probability, 7Timer precipitation type.
if active precip detected: score = 0.010 # hard gate
else:
precip_ceiling = clamp(1 − best_precip_prob/100, 0, 1)
# 60% rain → ceiling 0.40
# 80% rain → ceiling 0.20
moon_score_stargazing(frac, impact):
base = max(0.05, (1 − frac) ** 0.7)
# Altitude-aware: moon below horizon → impact=0 → score → 1.0
return base + (1 − base) × (1 − impact)
moon_score_astrophotography(frac, impact):
base = (1 − frac) ** 1.2 # hard zero at full moon
return base + (1 − base) × (1 − impact)
Astrophotography is more moon-sensitive (bright sky ruins long exposures). Naked-eye observation tolerates moonlight slightly better, so the stargazing floor is 0.05, not 0.
Sources: 7Timer transparency band, Open-Meteo surface visibility, PM2.5/AOD from smoke data.
transparency_score(t) = (8 − t) / 7
ts = ts × 0.55 + visibility_score × 0.45
ts = ts × (1 − smoke_penalty)
seeing_score(s) = (8 − s) / 7
# seeing 1 (<0.5″) → 1.0 (exceptional)
# seeing 4 → 0.57 (typical good)
# seeing 8 (>2.5″) → 0.0 (unusable for fine detail)
Ceilings vs. degraders
A factor is a ceiling if, alone and at its worst, it can make stars invisible. A factor is a degrader if it can pull quality down within whatever ceiling the hard factors set, but can't, on its own, prevent observation.
| Factor | Role | Worst-case effect |
|---|---|---|
| Light pollution | Ceiling | City sky — faint stars invisible no matter the weather |
| Cloud cover | Ceiling | Overcast — nothing visible through clouds |
| Darkness | Ceiling | Daylight — sun drowns out all stars |
| Precipitation | Ceiling | Rain/snow — observation impossible |
| Moon | Degrader | Full moon — washes out dim stars, bright ones remain |
| Transparency | Degrader | Heavy haze — dims stars, doesn't hide them |
| Seeing | Degrader | Turbulent air — blurs detail, doesn't hide stars |
After ceiling × degradation
A small stack of soft modifiers multiplies the ceiling×degradation product. These represent practical observing conditions that don't fit cleanly into ceiling or degrader roles.
- Fog risk — if the air is near saturation (dewpoint spread < 1°C), score ×0.50; 1–2°C ramps to 0.65; 2–4°C ramps back to 1.0.
- Wind — > 25 mph ×0.82, 15–25 mph ramps to 0.97, 8–15 mph ×0.97. Hurts tripod stability and observer comfort.
- Humidity — only used when dewpoint data is unavailable; > 75% relative humidity ×0.95.
One last pass for context
An LLM (Gemini 3 Flash via OpenRouter) sees all the raw data, the computed scores, and the physical ceiling, then returns an adjusted score with a short reasoning string. It catches context the algorithm can't: an urban light dome outside the Bortle zone, a cold front clearing a humid night, a known dark-sky site's microclimate.
Three guardrails keep it honest:
- Adjustment bounded to ±0.100 from the computed score.
- Code-level clamp: final score is capped at the physical ceiling, regardless of what the model returns.
- On any error — network, parse, empty response — the computed score is returned unchanged.
Where every number comes from
| Source | Provides | Coverage |
|---|---|---|
| USNO | Moon illumination fraction (daily) | Global |
| 7Timer | Astronomy-specific cloud/seeing/transparency (72h, 3h intervals) | Global |
| NWS | Real-time cloud cover, precip, dewpoint, wind | US only |
| Open-Meteo | Layered clouds (low/mid/high), visibility, precip probability | Global |
| PyEphem | Sun/moon altitude, twilight times, dark window | Global (local computation) |
| DJ Lorenz 2024 | Light pollution atlas (Bortle zones, mpsas) | North America |
| Open-Meteo Elevation | Elevation in meters (atmospheric thickness) | Global |
| Open-Meteo Air Quality | PM2.5, PM10, aerosol optical depth | Global |
Where the forecast can be wrong
- Light pollution is a snapshot. The atlas is from 2024 and doesn't reflect brand-new development or month-by-month light changes.
- Cloud sources can disagree. When they do, the worst source wins, but this means we may over-estimate clouds on an honest fair-weather night.
- Moon altitude is a point-in-time value. The moon rises and sets within a session; the score reflects the instant you queried.
- Forecast horizon matters. 7-day forecasts lean heavily on 7Timer and NWS; the further out you look, the softer the numbers.
- The AI adjustment is optional. When the OpenRouter API is unreachable, you get the raw computed score with a failure note in the reasoning.
your ceiling allows, minus what the weather takes."