How the score is calculated
A precise, slightly nerdy walkthrough of every input, formula, threshold, and reality check that produces tonight’s 0–10. Aimed at people who want the receipts.
The shape of it
The score is one number from 0 to 10 that answers: "is the sky tonight worth stepping outside for?" Two physical realities matter for naked-eye stargazing — opaque clouds in the way, and a bright moon washing out the dim stars. Everything else is downstream of that.
We compute the score in six small steps from a weather forecast, then run two reality checks against it (one against multiple independent models, one against actual airport observations after sunset). A separate user-feedback loop calibrates regional bias over time.
1. Forecasting cloud cover (Open-Meteo)
We pull an 8-day hourly forecast from Open-Meteo for your exact lat/lng. Importantly, we ask for cloud cover split across three altitude bands, not the rolled-up "total" number that most weather apps show.
Each band is a percentage in [0, 100] and they overlap — a sky with 60% low cloud and 40% high cirrus reports as both, not as 100%. The split lets us weight altitudes differently in step 2.
// Open-Meteo, per location, every hour: cloud_cover_low // 0–100% (< 6500 ft / ~2 km AGL) cloud_cover_mid // 0–100% (6500–20000 ft / 2–6 km) cloud_cover_high // 0–100% (> 20000 ft / 6+ km, mostly cirrus)
2. Layer-weighting (low > mid > high)
For naked-eye stargazing, low clouds are the enemy. Stratus and stratocumulus blot out the entire sky behind them. Mid clouds (altostratus, altocumulus) dim most stars. High thin cirrus, on the other hand, just slightly fuzzes things — you can still see Orion through it.
weightedCloud =
cloud_low × 0.5 // blocks everything
+ cloud_mid × 0.3 // blocks most stars
+ cloud_high × 0.2 // thin cirrus dims, doesn't block
solidDeck = min( cloud_total, max(cloud_low, cloud_mid) )
effectiveCloud = max( weightedCloud, solidDeck )
// 100% low stratus counts as 100%, not 50%So we weight by altitude before averaging. A sky full of cirrus and a sky full of stratus produce very different stargazing experiences; the unweighted "total cloud cover" number that most apps use can’t tell them apart. One correction on top (September 2026): weighting alone said a sky with a solid low deck was only "50% cloudy", so the effective cloud is never less than the thickest low or mid layer. A fully overcast night now reads as overcast.
3. Time-weighting (prime hours > late night)
Stargazers decide based on what they’ll actually see at 22:00, not what’s true at 03:30. So when we average the night, we weight the "prime" hours (21:00 → 00:59 local) at 2× the late-night tail. A clear 04:00 can no longer drag a cloudy 22:00 up by half a point.
darkWindow = sun below −18° (astronomical dusk → dawn)
else sun below −12°, labelled "nautical" — score capped at 7.5
else none: "No true darkness tonight" — score capped at 2
headlineCloud = Σ ( effectiveCloud(h) × w(h) ) / Σ w(h)
w(h) = darkMinutes(h) / 60 // share of the hour inside darkWindow
× 2 if 21:00 ≤ h < 01:00 // prime hours
× 1 otherwise // late-night tailThe hours we average over are the astronomical-darkness window — the sun at least 18° below the horizon, computed with astronomy-engine for your exact location and timezone. Hours that are only partly dark count for the dark part only. Where the sun never reaches −18° (e.g. Amsterdam in late June) we fall back to nautical twilight (−12°), say so on the page, and cap the score at 7.5: a clear nautical night can still be good for planets and bright stars, but never great, and the page says there is no Milky Way. Where it doesn’t even reach −12° (Tromsø in June), we show "No true darkness tonight" and cap at 2. The cloud % on the page is this same average over this same window.
4. Moon penalty (illumination × real altitude)
A bright moon doesn’t darken the sky — but it does drown out everything except the brightest stars. We apply a penalty proportional to illumination, in three tiers:
- 0% penalty if illumination < 25% (new moon and crescent).
- illumination × 0.5 if 25–75% (around quarter moon).
- illumination × 1.0 if > 75% (gibbous and full).
Then we look at where the Moon actually is. For every dark hour we compute its altitude with astronomy-engine: below the horizon it costs nothing, low on the horizon it costs a little, and from 30° up it costs the full tier. The night’s penalty is the average over the same weighted dark hours as the cloud. A half-lit Moon that sets at 23:30 therefore only dents the first part of the night. (Until September 2026 we used a rule of thumb — "after midnight a bright moon is probably setting" — which could penalise a night the Moon had already left.)
penalty(h) = tier(illumination) × altitudeFactor(moonAltitude at h)
altitudeFactor = 0 if the Moon is below the horizon
= min(1, sin(alt) / sin 30°) otherwise5. Visibility gate
Even with a perfect new-moon sky, an overcast night should not score above zero. The visibility ramp enforces that: as the sky goes from fully overcast (clearness 0%) to 30% clear, the score scales linearly to its full value. Below 30% clearness, even a perfect dark sky is multiplied by less than 1.
clearness = 100 − headlineCloud visibility = min(1, clearness / 30) // clearness 0–30% → visibility ramps 0 → 1 // clearness 30%+ → visibility = 1
Without this gate, a 100%-cloudy night with a new moon scored ~4/10 from the moon-darkness term alone — physically impossible. Now it correctly scores 0.0.
6. The headline formula
moonPenalty = Σ ( penalty(h) × w(h) ) / Σ w(h) // same weights as cloud
darkness = 100 − moonPenalty
score = visibility × ( clearness × 0.6 + darkness × 0.4 ) / 10
// clamped to 0–10, rounded to 1 decimal
verdict: < 3 terrible · < 5 poor · < 7 decent
≥ 7 good · ≥ 8 great · ≥ 9 exceptional // alerts default to 7That’s the headline — the one number, identical on the city page, in alert emails, on share images and in embeds. Cloud gets 60% of the weight, moon-darkness 40%, and the visibility gate vetoes the whole thing if there are too many clouds. The same formula scores each hour of the timeline, the best two consecutive dark hours ("Best 2 hours") and the 21:00–01:00 evening block — but those two are secondary, always inside the dark window, and never replace the headline. Every verdict, emoji and label comes from the headline alone: below 7 it is never phrased as a good night.
Two reality checks on top
A single forecast can be confidently wrong. Two layers above the headline catch that.
Neither check rewrites the score upward. They only flag uncertainty (multi-model) or pull the score down toward observed reality (nowcast).
Reality check 1: multi-model disagreement (with conservative blend)
Open-Meteo will happily give you the same forecast pulled separately from three different global weather models. We ask for all three and compare what they say across the full dark window — every hour between astronomical dusk and dawn, not just the prime evening hours. Alert emails go through exactly the same check, so the number in your inbox is the number on the site.
// Three independent global numerical weather models:
// ECMWF IFS (European Centre, broadly best in Europe)
// GFS (NOAA, best US coverage)
// ICON-EU (Deutscher Wetterdienst regional)
nightSpread = max pairwise | model_i − model_j |
averaged over the full darkness window
(≈ 21:00 → 04:00, varies by season + latitude)
if nightSpread > 25:
pessimistic = max( model_avg ) // cloudiest model
delta = max(0, pessimistic − seamless) // capped at 30 pts
if delta ≥ 10:
// Bias the headline toward the cloudier model.
weightedCloud += delta × 0.4 // assume mid/low layer
score recomputed — but capped at unblended − 1.5
confidence = { level: "low", pessimisticBlend: true }
// badge: "Cautious estimate"
else:
confidence = { level: "low", pessimisticBlend: false }
// badge: "Low confidence" — warning onlyIf they disagree by more than 25 percentage points across the full darkness window (typically ~21:00 to ~04:00, varies by season + latitude), we don’t just warn — we bias the score toward the cloudiest model. The reasoning: a false-positive ("great night, go outside") costs more user trust than a false-negative ("mediocre night, stay in"), so when we’re unsure, we under-promise. The blend is capped at 1.5 score points so one noisy model can’t crater an otherwise great forecast. When the blend fires, the badge text shifts from "Low confidence" to "Cautious estimate" so you know we already adjusted, not just flagged. Earlier versions of this check only looked at prime hours (21:00–01:00) — that missed late-night splits, so the window was widened in May 2026.
Reality check 2: post-sunset observation clamp
Once the sun is down, "tonight" is no longer a forecast — it’s starting to be observable reality. We pull the most recent METAR (mandatory hourly observation from every commercial airport) within ~65 km of you, parsing the FEW/SCT/BKN/OVC layer codes into the same low/mid/high split we use internally.
If the observed weighted cloud is materially higher than what the forecast averaged for tonight, we drop the headline score to match reality. The thresholds depend on the source: METAR is real measurement so we trust small divergences; Open-Meteo current is model + station blend (the same model that produced the forecast), so we keep a conservative threshold to avoid double-counting model error.
// Post-sunset only.
// Source priority:
// 1. METAR via aviationweather.gov bbox (~65 km, 130 km fallback)
// 2. Open-Meteo current (model + station blend, last resort)
// Trigger thresholds depend on source trust:
// METAR: Δ > 15 pts AND observed > 35%
// Open-Meteo cur.: Δ > 30 pts AND observed > 50%
// If triggered:
score = min( forecastScore, observationScore )
// never raised — only loweredCrucially, the clamp only ever lowers the score. If reality looks better than the forecast we don’t bump up — the forecast already had its shot, and over-promising hurts trust more than under-promising.
Calibration: every alert email is a learning loop
Models drift. Specific cities have specific bugs (sea breeze nights in Amsterdam, late inversions in Salt Lake, marine layer in coastal California). The only way to learn the local bias of a model is to compare what it predicted against what people actually saw.
So every alert email contains three buttons: ✨ Clear, 🌤 Partly, ☁️ Cloudy. Each is an HMAC-signed link carrying the entire prediction context — no login, no DB lookup, one tap. We store the click in starsout_feedback.
// Each alert email contains 3 buttons. Each button is an
// HMAC-SHA256 signed link carrying the entire prediction context:
{
v: 1,
sub: <subscriber_id>,
d: "2026-05-07",
s: 8.2, // predicted score
c: 18, // predicted cloud cover %
lat, lng,
conf: "high",
src: "metar",
city, locale,
exp: <unix epoch + 7 days>,
o: "clear" | "partly" | "cloudy"
}
// Token is stand-alone: no DB lookup needed to validate.
// One click → upsert into starsout_feedback.
// Per-city rollup (view starsout_feedback_calibration):
hit_rate(city, month) =
100 × clear_or_partly_alerts_with_score≥7 / total_alerts_with_score≥7A weekly job rolls feedback up per city per month. Once we have ≥4 weeks of feedback per city we have enough signal to apply per-city bias correction live in the score. Until then, we just publish the calibration view internally and watch for systemic miss patterns.
What’s not in the score (and why)
- Light pollution (Bortle class): shapes the "Visible Tonight" deep-sky list — which galaxies and nebulae are realistic to chase from your spot — but doesn’t move the headline number. The number is "is the sky cooperating?", not "is your sky pristine?".
- Wind, humidity, precipitation: visible in the timeline if you scroll, but they don’t change the score. They affect comfort and seeing, not whether the sky is opaque to starlight.
- Atmospheric seeing (turbulence): matters for telescope users (planetary detail, double-star splits) but not for naked-eye stargazing. Hand-wavy proxies from surface weather aren’t reliable enough to include in the headline.
- Aerosols / wildfire smoke: real driver of star magnitude during smoke season but not yet in the model. Open-Meteo exposes some of this; folding it into the visibility gate is on the roadmap.
Sources
- Open-Meteo — hourly cloud + sunrise + multi-model spread. Free, no auth, attribution-required.
- Aviation Weather Center — METAR observations from every commercial airport, free.
- astronomy-engine — sun and Moon positions, rise/set, twilight, phase and illumination, computed locally for every city and night (MIT-licensed, no external API).
- Forecasts refresh roughly every 30 minutes; METAR every hour; multi-model fetch on every score request, cached 10 minutes.
Why this works the way it does
A stargazing score should fail honestly. Most weather apps tell you "30% cloud" when the sky is overcast because their model is wrong; their UI then implies confidence the data doesn’t deserve. We’d rather show 6.5 with a "cautious estimate" pill — a number that already accounts for the disagreement — than 8.5 with no caveat.
The score will never be perfect — atmospheric science is hard, and most stargazing failure modes are hyper-local. But every layer here exists because we caught the score lying in a specific way at least once. The calibration loop is how we keep teaching it.