How we decide
whether tonight is worth it
A two-track guide to the new scoring engine: plain language for everyone, and the full physical model — with formulas, references, and API field names — for the technically curious.
Two clear nights should look identical
The old GoStargazing score asked a language model to nudge the final number up or down by as much as ten points. That meant the same site on two physically identical nights could produce different scores — useful for narrative, lousy for trust.
We rebuilt the math so every score is fully determined by physical inputs. Same sky, same number, every time. The AI is still here, but only to describe the result in plain English after the math is done — it can no longer reach in and change a digit.
Two nights that look the same to the sky now look the same to the score.
Technical details · R10, R8
Phase 1 R10 R8
The pre-overhaul pipeline finished with an LLM (google/gemini-3-flash-preview via OpenRouter) returning adjusted_*_index values bounded to ±0.100. Identical inputs produced non-identical outputs because the model's tempered randomness sat on the numeric output path.
R10 — remove LLM numeric mutation. The module formerly named stargazing/ai_adjust.py was renamed stargazing/narrate.py. Its only exported function is now get_narration(score_trace) -> str; the OpenRouter client survives, the score-mutation branch was deleted. Narration is opt-in via ?narrate=1 on /api/score, so a 1–3 s LLM round-trip no longer blocks every request, and an LLM outage has zero numeric effect.
R8 — split sky-quality from feasibility. compute_stargazing_indices now returns two distinct [0, 1] scores instead of a single mashed-together number:
"sky_quality": geometric blend of LP, cloud, moon,
transparency, seeing (optical state)
"feasibility": fog, dew, wind, comfort, precip risk
(whether you can actually set up)
The legacy aliases astrophotography_index and stargazing_index are preserved for one release cycle and computed as sky_quality × feasibility. The frontend reads finalScore.mean from the new contract.
Tonight's best window, not just one number
"How is the sky right now?" is the wrong question if you're planning a session at 8 p.m. for a window that peaks at 10:42 p.m. The old score answered for one moment and shrugged about the rest.
The new engine scores every 15- to 60-minute slice of tonight independently, then finds the longest continuous run that meets your usability bar. You see a window — 21:42 to 00:18, 2 h 36 m usable — not a single instant.
If nothing tonight meets the bar, we say so plainly and tell you which single moment was the best the sky managed.
Technical details · R9
Phase 3 R9
The old per-night scorer (_compute_night at stargazing/index.py:487–552) was refactored into _compute_bin(bin_dt, bin_minutes=30). Static fetches (light-pollution atlas lookup, elevation, target geometry) are cached outside the loop; only weather, air quality, and astronomy refresh per bin. Open-Meteo's hourly arrays are interpolated to bin resolution; 7Timer's 3 h bands are nearest-neighbor; NWS periods are matched to the bin's local time.
A new module stargazing/session.py exposes score_session(config, start, end, mode, bin_minutes=30) -> SessionResult returning:
bins: list[BinScore]
best_window: { start, end, mean_score, usable_minutes }
session_score: duration-weighted mean over usable bins
instant_score: single-bin score at the requested moment
Best-window optimizer. Longest contiguous run of bins where mean_score ≥ mode.usable_threshold (0.55 for naked-eye, 0.65 for planetary, 0.70 for astrophoto), satisfying mode.session_min_minutes (30/45/120). If no contiguous window meets the threshold, the API falls back to the highest single bin and the frontend renders a "best instant" card.
Public surface: GET /api/session?lat=&lon=&mode=&window_hours= returns the full SessionResult; GET /api/score?mode= reports bestWindow, sessionScore, instantScore, and usableMinutes alongside the legacy keys.
Tell us what you're trying to do
"Good sky" depends entirely on what you're trying to do with it. A first-quarter moon kills astrophotography but is perfect for showing a kid the craters. A boiling jet stream is unusable for planetary work and barely noticeable to the naked eye.
The new score asks first: naked-eye, planetary, or astrophoto? Then it weights the factors that actually matter for that activity. Air turbulence dominates planetary scoring and barely registers for naked-eye. Moonlight is a hard penalty for astrophoto and a soft one for casual sky-watching.
Switching modes changes the score without changing the underlying data — same sky, different verdict, because you're measuring different things.
Technical details · R7
Phase 3 R7
Defined in stargazing/modes.py and applied by _compute_bin. The old per-mode weight constants in stargazing/scoring.py (ASTROPHOTOGRAPHY_DEGRADER_WEIGHTS, STARGAZING_DEGRADER_WEIGHTS) were deleted.
| Mode | seeing weight | moon tolerance | usable threshold | min minutes | target altitude policy |
|---|---|---|---|---|---|
| naked_eye | 0.10 | 0.50 | 0.55 | 30 | zenith |
| planetary | 0.40 | 0.80 | 0.65 | 45 | highest_planet > 20° |
| astrophoto | 0.20 | 0.05 | 0.70 | 120 | anti_moon · min(meridian, 60°) |
Target geometry — deterministic fallback. When the user has not pinned a target, PyEphem (already a dependency) computes the policy-default for each bin. The chosen alt/az is exposed at targetAltAz on every /api/score response and surfaces as a reason in the trace so users understand why a 32° airmass penalty applies.
Modes 4–6 (milky_way, dso_visual, meteor) are modeled internally but not exposed in this release cycle.
We show a range, not a guess
Three different forecast services often disagree about how cloudy tonight will be. The old engine let the most pessimistic one win and pretended the answer was a clean single number. That was honest about clouds but dishonest about confidence.
The new engine pulls every weather variable from every source it can reach, blends them by track record, and shows the resulting range. Score 0.74, plausibly between 0.61 and 0.83 tells you something useful: "this is solidly OK," not "exactly 0.74 ± nothing."
When the forecasters loudly disagree, the score stays the same but the range widens — visible as a thicker ring around the gauge.
Technical details · R11
Phase 2 R11
The new package stargazing/providers/ wraps each fetcher (openmeteo.py, nws.py, seventimer.py, smoke.py, firms.py, swpc.py) behind a common contract:
@dataclass
class Observation:
source: str # "openmeteo" | "nws" | "7timer" | ...
variable: str # "cloud_cover_low" | "precip_prob" | ...
value: float | int | str
units: str
valid_at: datetime
issued_at: datetime | None
freshness_minutes: float | None
confidence: float | None
An EnsemblePolicy consumes the per-variable observation list and emits:
@dataclass
class EnsembleResult:
mean, sigma, p10, p90: float
members: list[Observation]
skill_weights: dict[str, float]
Confidence interval on the public score. The scoring function is pure-Python and cheap enough that we Monte-Carlo it 64 times (visible at finalScore.samples = 64), sampling each input ensemble at every iteration, then aggregate. The result surfaces as finalScore.{mean, p10, p90, sigma} on every response.
Disagreement widens, doesn't worsen. The old worst-source-wins reducer (scoring.py:_blend_cloud_cover) was replaced by the ensemble. When sources spread far apart, the reported mean stays put but sigma grows and a uncertainty-role entry appears in reasons[]. The per-variable spread is browsable under ensemble.{variable}.disagreement.
Source-skill table. data/skill_table.json keyed by (source, variable, region, horizon_hours) -> expected MAE/Brier. Bootstrapped uniform; Phase 5 updates it weekly from retrospective NOAA observations.
Magnitudes are tricky; we use real units now
Light pollution is usually published in magnitudes per square arcsecond — a logarithmic scale where each step is roughly 2.5× brighter. Adding moonlight or a cloudy city glow to that scale produces garbage if you treat the numbers like ordinary arithmetic.
Internally, every sky-brightness term — natural background, city glow, moonlight, cloud-amplified glow — is now expressed in linear units (nano-candela per square meter, a real physical quantity), summed, and converted back to magnitudes only when we display the number.
The number you see is the same kind of unit professional astronomers use to compare observatory sites.
Technical details · R2
Phase 4 R2 physics.linear_lp
The module stargazing/physics/brightness.py exposes three primitives:
mpsas_to_luminance(mpsas) -> float # ncd/m²
luminance_to_mpsas(L_ncd) -> float
natural_sky_brightness_ratio(L) -> float
Round-trip identity: luminance_to_mpsas(mpsas_to_luminance(m)) == m to within float epsilon. The DJ Lorenz 2024 atlas [14] still feeds the pixel lookup; the returned mpsas is converted to luminance immediately, summed linearly with moonlight (R5), Kyba cloud-amplified urban skyglow (R3), and any aerosol scatter contribution (R6), then converted back to mpsas for the physical.predictedMpsas field.
Bortle is no longer a primitive — it becomes a display-time bin computed from predictedMpsas. The public fields exposed under physical are:
predictedMpsas # total sky brightness at the target alt/az
naturalSkyBrightnessRatio # L_total / L_natural_zenith
limitingMagnitudeEstimate # from L_total via NPS guide [3]
naturalSkyBrightnessCdm2
artificialSkyBrightnessCdm2
totalSkyBrightnessCdm2
Reference baselines: natural zenith ~21.8 mpsas (Falchi et al. 2016 [4]); Bortle 1 dark site ~22.0; Bortle 7 suburban ~17.5–18.0.
A full Moon near your target hurts more than one across the sky
A full Moon below the horizon doesn't dim a single star. A full Moon thirty degrees from what you're trying to photograph wrecks the shot. The old algorithm treated "moon brightness" as a single number — phase only — and missed the geometry that actually determines how much glare you'll experience.
The new model is from a 1991 astronomy paper that's still the standard. It accounts for the Moon's phase, where the Moon is in the sky, where you're looking, the angle between those two, and how much atmosphere is in between. The penalty for moonlight goes to zero when the Moon is below your horizon — even at full phase.
Technical details · R5
Phase 4 R5 physics.ks_moonlight
The Krisciunas & Schaefer 1991 V-band scattered-moonlight model [6] is implemented at stargazing/physics/moonlight.py. Inputs:
alpha moon phase angle (radians)
Z_moon moon zenith distance = 90° - moon_altitude
Z_obj target zenith distance = 90° - target_altitude
rho moon-target angular separation
k atmospheric extinction coefficient (R6)
The model returns the scattered moonlight contribution in nano-lambert, converted to ncd/m² and added linearly to the sky-brightness budget in brightness.py. The exact formulation matches the published equations (illuminance, Rayleigh + Mie scattering functions, optical-depth term) — no novel coefficients, no curve-fits.
Behaviour summary:
- Moon below horizon (
moonAltitudeDeg < 0) ⇒ zero scattered contribution regardless of phase. - Full Moon at zenith on a Bortle 1 site is brighter than full Moon at altitude 0°, as required by the Phase 0 spike's gate criteria.
- Target geometry comes from the active mode's
target_altitude_policy(see §3).
Exposed at: physical.moonAltitudeDeg, physical.moonAzimuthDeg, physical.moonIllumination, physical.moonLuminance.
Clouds block stars and brighten the sky over cities
Anyone who has lived in a city has noticed this: clouds make a dark sky darker, but they make a city sky brighter. The cloud layer above a bright street acts as a giant reflector, bouncing the city's lights back at you.
The old algorithm treated clouds as a single ceiling: more clouds = lower score. The new model splits this into two effects. First: what fraction of the sky is physically blocked? Second: how much brighter does the cloud deck make the unblocked sky? Over a national park, those numbers move together. Over Los Angeles, they move in opposite directions.
Technical details · R3
Phase 4 R3 physics.cloud_amplification
Implemented at stargazing/physics/clouds.py:
obstruction_probability(low, mid, high) -> float
cloud_base_height(low, mid, high) -> float (km)
skyglow_amplification(L_artificial,
cloud_cover,
base_height_km) -> float multiplier
The Kyba et al. 2015 multiplier [5] spans roughly 0.7× (clear) to 18× (overcast over a heavily lit basin), depending on the local artificial-brightness baseline and cloud base height. Low cloud over bright cities amplifies the most; high cirrus over a dark site barely changes things.
Cloud-cover ensemble inputs (ensemble.cloud_cover_low, cloud_cover_mid, cloud_cover_high) feed both functions per bin. The scoring path uses the obstruction probability as a sky-quality multiplier (stars blocked) and the amplification multiplier as a contributor to physical.totalSkyBrightnessCdm2 (sky brightened). The two effects are surfaced separately:
physical.cloudObstructionProbability # 0..1
physical.cloudBaseHeightKm # km
physical.kybaAmplification # multiplier
This separation also lets the reasons panel distinguish "low clouds blocked your stars" from "cloud deck over the city made the sky 4× brighter" — same data, different physical phenomenon.
Stars near the horizon look worse, even on clear nights
The next time you see a planet rising, look at it through binoculars and then again three hours later when it's high overhead. It will be sharper and brighter the second time — same planet, same eye, no clouds added. The reason is that you're looking through a lot more atmosphere when you point near the horizon.
The new algorithm knows where in the sky you (or the night's best target) are pointing, and applies an atmospheric penalty that grows as the angle drops. A planet at 20° altitude scores measurably worse than the same planet at zenith, even if the rest of the sky is identical. Smoke, dust, and water vapor all multiply this penalty.
Technical details · R6
Phase 4 R6 physics.airmass_extinction
At stargazing/physics/extinction.py:
airmass(altitude_deg) -> float
# Kasten & Young 1989 [20] — more accurate
# than sec(z) at low altitudes.
extinction_coefficient(aod, dust, tcwv,
visibility, humidity_profile) -> float k
# Combines aerosol optical depth (smoke, dust),
# total column water vapor, and surface visibility
# into a single mag/airmass coefficient.
Kasten–Young approximation (the actual formula in code):
X(z) = 1 / (cos(z) + 0.50572 * (96.07995 - z)^-1.6364)
The same k coefficient is applied to (a) light-pollution luminance traveling along the line of sight, (b) Krisciunas–Schaefer scattered moonlight, and (c) the transparency degrader. A wildfire that triples aerosol optical depth therefore degrades three independent terms through one coefficient — physically correct, and naturally surfaced through the same reason trace entry.
Fresh smoke nowcasts come from stargazing/providers/firms.py (NASA FIRMS active-fire data within 500 km upwind, used when AOD is stale > 6 h [15] [16]). Exposed at physical.aerosolOpticalDepth, physical.extinctionCoefficient, physical.targetAirmass, physical.targetAltitudeDeg.
Rain probability is a warning, not a dimmer
A 30 % chance of rain doesn't make tonight's clear sky any less clear. It means you might get rained on while you're setting up. That's a real and important consideration, but it's not the same as bad sky.
The old algorithm let probability-of-precipitation pull the optical sky-quality score downward — so a perfect dry night under a 40 % PoP forecast would read as a worse sky than the same physical sky under a 5 % PoP forecast. The new engine moves PoP into the "feasibility" track instead: it warns you, it shortens the usable window, but it stops pretending it knows your sky is bad when only the chance of rain is high.
Technical details · R4
Phase 4 R4
The pre-overhaul scoring.py:307 contained:
precip_ceiling = 1 - PoP / 100 # WRONG: dims sky quality
That line was deleted. PoP and QPF moved entirely into the feasibility track as risk terms, where they (a) lower usableMinutes when high, (b) emit a precip_risk reason of role risk with effect proportional to PoP × QPF, and (c) reduce finalScore only through the sky_quality × feasibility product.
The hard precipitation gate (active rain/snow) survives — when an ensemble reports precipitation_present at the target bin, that bin scores 0.01 and emits a precipitation reason of role gate per the NOAA NWS PoP definition [9].
NWS PoP definition (verbatim): "The probability of precipitation is the probability that measurable precipitation (≥ 0.01") will occur at any given point in the forecast area during the forecast period." A 40 % PoP is therefore an areal-coverage-weighted probability, not a sky-quality dimmer.
Every score is reproducible, forever
If you share a link with a friend that shows tonight's sky as a 0.82, your friend should see 0.82 too — not 0.81 because a model was nudged differently, not 0.84 because a fetcher chose a different mirror. The score should be reproducible, byte-for-byte, the next time anyone runs the math with the same inputs.
Every score we produce now carries a model version string that identifies the exact algorithm that generated it, plus a deterministic trace of where each input came from. Six months from now, looking at a record from tonight, you can tell which version produced it — and we keep old versions runnable so it's actually reproducible.
Technical details · R10 + Phase 5
Phase 1 Phase 5 R10
Deterministic reason trace. stargazing/reasoner.py reads the component dict returned by compute_stargazing_indices and emits an ordered reasons[] array of {factor, role, effect, explanation} entries. Roles: gate (hard cap, e.g. active precip, daylight), risk (lowers usable minutes, e.g. PoP), degrader (reduces score, e.g. moonlight), uncertainty (widens CI, e.g. source disagreement). Identical inputs ⇒ identical reasons.
Source trace. Every score response includes sourceTrace[] with per-variable provenance: which provider, freshness in minutes, reported confidence. The frontend renders this as a collapsed "what fed this score" panel with freshness pills.
Model versioning. Every public response carries modelVersion: "2026.06.0" at the top level and persists with the record in data/forecasts.db. The forecast store schema bumped from v1 to v2 (Phase 3) with a read-time adapter (_adapt_v1_to_v2(payload)) so old shared links still resolve.
No more query log. stargazing/query_log.py was deleted; its observational role is subsumed by the residual store (§11) and forecast_store.
We compare our forecasts to real observations
A forecast that says "tonight will be clear" is only worth something if "clear" turns out to be true most of the time. The traditional way to check is to wait until tomorrow, look at what the actual sky did, and score yourself against that.
Every six hours, the engine pulls real cloud-cover and visibility observations from the nearest airport weather stations (the same ones that report to pilots) and compares them to what we predicted. Once a week it pulls satellite cloud measurements over the United States and does the same comparison. The results feed back into the source-skill table — providers that turn out to be more accurate get more weight next time.
No personal feedback channel yet — that's coming. For now, ground truth comes from NOAA and the GOES-R satellite.
Technical details · R12 (no-human subset)
Phase 5 R12
Residual store. A new SQLite table inside the existing data/forecasts.db:
CREATE TABLE observations (
forecast_id TEXT,
observed_at TEXT,
source TEXT, -- "metar" | "asos" | "goes" | "noaa_obs"
variable TEXT, -- "cloud_cover_pct" | "visibility_m" |
-- "precip_present"
observed_value REAL,
predicted_value REAL,
lead_time_minutes INTEGER,
ingested_at TEXT,
PRIMARY KEY (forecast_id, source, variable, observed_at)
)
Typed API at stargazing/calibration/store.py.
METAR/ASOS ingest. stargazing/calibration/metar_ingest.py polls aviationweather.gov every 6 h for the nearest reporting station to each scored query, joining observed cloud cover, visibility, and precipitation against the matching predicted values. Station coverage is airport-only — matches are by nearest within 100 km, with station ID + distance recorded for transparency.
GOES-R retrospective cloud (CONUS only). stargazing/calibration/goes_ingest.py pulls cloud optical depth gridded product from NOAA CLASS on a daily rolling 7-day window [10] [11].
Reliability dashboards. GET /calibration renders reliability diagrams (predicted probability vs observed frequency), MAE/RMSE by lead time, and false-clear vs false-bad rates (asymmetric weighting — false-clear is worse). The route is env-gated behind GOSTARGAZING_CALIBRATION_ENABLED=1 and returns 404 when disabled, so the dashboard is operator-internal.
Skill-table update. Weekly cron at stargazing/calibration/update_skill.py consumes the residual store, recomputes per-source per-variable MAE-weighted skill weights, and writes back to data/skill_table.json. Caps prevent any single source from dominating.
Model A/B framework. stargazing/calibration/ab.py runs candidate model versions in shadow mode against the active version; both scores are stored and scored retrospectively. Promotion is manual (a config.yaml setting), but the data to make the call is automated.
Acknowledged deferrals
The 2026 overhaul completed every revision unit that could be built without human-in-the-loop ingestion. A handful of follow-on items are explicitly out of scope for this cycle and disclosed here for transparency.
- User feedback channel. "Was tonight worth it?" — verified observer reports that would close the loop between forecast and lived experience. Planned for a follow-on once the deterministic baseline is settled.
- SQM device ingestion. Sky-quality meters provide ground truth that no satellite or atmospheric model can match. Awaiting a stable ingestion path before rollout.
- All-sky cameras. Cloud cover, transparency, and even the Milky Way's visibility could be measured directly from continuously streaming camera feeds. Webhook ingestion infrastructure not yet built.
- Three additional observing modes.
milky_way,dso_visual, andmeteorare modeled in the codebase but not surfaced in the UI this cycle — they need their own usable-threshold tuning, mode-default geometries, and reasons-panel copy. - VIIRS Day-Night Band annual update. The light-pollution atlas update is a separate annual cron, not part of this overhaul.
Roadmap notes
All five items are scaffolding-complete in the codebase: the ingestion-side data model, residual-store columns, and reasons-trace roles exist for each. The only missing pieces are the verified ingestion paths (user accounts + report submission for the first; device-pairing for the second; webhook auth + storage for the third), the threshold tuning for the additional modes, and the cron registration for the VIIRS update. None require algorithmic changes to land — they extend the existing provider abstraction (§4) rather than modifying it.
Primary sources
The complete reference list from the algorithm review document. Citations appear in the technical tracks above; numbered IDs are stable and link-targetable (e.g. #ref-4).
- NOAA Global Monitoring Laboratory. Solar Calculator Glossary. Available at gml.noaa.gov/grad/solcalc/glossary.html.
- National Weather Service, Louisville. Twilight Types. Available at weather.gov/lmk/twilight-types.
- National Park Service. Night Skies Report Guide / Metrics Guide. Available at nps.gov/subjects/nightskies.
- Falchi, F., Cinzano, P., Duriscoe, D., Kyba, C. C. M., Elvidge, C. D., Baugh, K., Portnov, B. A., Rybnikova, N. A., & Furgoni, R. (2016). The new world atlas of artificial night sky brightness. Science Advances, 2(6), e1600377. DOI: 10.1126/sciadv.1600377.
- Kyba, C. C. M., Tong, K. P., Bennie, J., Birriel, I., Birriel, J. J., Cool, A., Danielsen, A., Davies, T. W., den Outer, P. N., Edwards, W., Ehlert, R., Falchi, F., Fischer, J., Giacomelli, A., Giubbilini, F., Haaima, M., Hesse, C., Heygster, G., Hölker, F., … Gaston, K. J. (2015). Worldwide variations in artificial skyglow. Scientific Reports, 5, 8409. DOI: 10.1038/srep08409.
- Krisciunas, K., & Schaefer, B. E. (1991). A Model of the Brightness of Moonlight. Publications of the Astronomical Society of the Pacific, 103, 1033. DOI: 10.1086/132921.
- Rhodes, B. (2011). PyEphem: Astronomical Ephemeris for Python. Astrophysics Source Code Library, ascl:1112.014. Available at rhodesmill.org/pyephem.
- Morris, B. M., Tollerud, E., Sipőcz, B., Deil, C., Douglas, S. T., Berlanga Medina, J., Vyhmeister, K., Smith, T. R., Littlefair, S., Price-Whelan, A. M., Gee, W. T., & Jeschke, E. (2018). astroplan: An Open Source Observation Planning Package in Python. The Astronomical Journal, 155(3), 128. DOI: 10.3847/1538-3881/aaa47e.
- NWS Peachtree City. FAQ: What is the Meaning of PoP. Available at weather.gov/ffc/pop.
- NOAA. GOES-R Series — Products Overview. Available at goes-r.gov/products/overview.html.
- NOAA. GOES-R Series — Cloud Optical Depth. Available at goes-r.gov/products/baseline-cloud-optical-depth.html.
- Open-Meteo. Weather Forecast API Documentation. Available at open-meteo.com/en/docs.
- Open-Meteo. Air Quality API. Available at open-meteo.com/en/docs/air-quality-api.
- NASA Earthdata. Black Marble. Available at blackmarble.gsfc.nasa.gov.
- NASA Earthdata. FIRMS (Fire Information for Resource Management System). Available at firms.modaps.eosdis.nasa.gov.
- NOAA. HRRR-Smoke. Available at rapidrefresh.noaa.gov/hrrr/HRRRsmoke.
- NOAA/NWS Space Weather Prediction Center. Aurora 30-Minute Forecast. Available at swpc.noaa.gov/products/aurora-30-minute-forecast.
- Jechow, A., Hölker, F., & Kyba, C. C. M. (2019). Snowglow — The Amplification of Skyglow by Snow and Clouds can Exceed Full Moon Illuminance in Suburban Areas. Journal of Imaging, 5(8), 69. DOI: 10.3390/jimaging5080069.
- 7Timer! Documentation. Available at 7timer.info/doc.php.
- Kasten, F., & Young, A. T. (1989). Revised optical air mass tables and approximation formula. Applied Optics, 28(22), 4735–4738. DOI: 10.1364/AO.28.004735.
Algorithm versions
Every score the API returns includes a modelVersion field. You can always check which version produced a given result.
-
2026.06.0
Retrospective calibration scaffolding (Phase 5, R12 no-human subset): residual store, METAR/ASOS ingest, GOES-R retrospective cloud ingest, env-gated
/calibrationreliability dashboards, weekly source-skill table update, shadow-mode A/B framework. Frontend revamp ships in the same cycle: mode selector, score gauge with confidence-interval ring, best-window card, deterministic reasons panel, physical-state block, source trace. -
2026.05.x
Physical brightness model (Phase 4, R2/R3/R5/R6): linear-luminance light-pollution math, Krisciunas–Schaefer scattered-moonlight model, Kyba cloud-amplified skyglow, Kasten–Young airmass × aerosol extinction. PoP moved from optical sky-quality to feasibility risk (R4). Upper-air winds from Open-Meteo pressure levels feed a wind-shear seeing proxy. FIRMS smoke nowcast and SWPC aurora oval provider added. All four physics flags gated through
config.yamlfor controlled rollout. -
2026.04.x
Session, modes, best window (Phase 3, R7/R9): per-bin scoring at 15–60 min resolution, best-window optimizer with mode-aware usable threshold, three-mode table (
naked_eye,planetary,astrophoto),/api/sessionendpoint,?mode=on/api/score, forecast store schema bump v1→v2 with read-time adapter for legacy shared links. Mode-default target geometry from PyEphem. -
2026.03.x
Provider abstraction and ensemble scaffolding (Phase 2, R11):
Observationdataclass,EnsemblePolicywith mean / sigma / p10 / p90, 64-sample Monte-Carlo confidence interval onfinalScore, source-skill table seeded uniform. Worst-source-wins reducer replaced by ensemble; source disagreement widens the CI rather than degrading the score. -
2026.02.x
Deterministic cleanup (Phase 1, R1/R8/R10): USNO moon scraper deleted in favour of local PyEphem; LLM removed from the numeric path (renamed
ai_adjust.py→narrate.py, opt-in narration);sky_qualitysplit fromfeasibilityin the output; deterministicreasons[]trace module. - 2026.01.x Physics regression spike (Phase 0): notebook validation of mpsas⇔luminance round-trip, Krisciunas–Schaefer behaviour, Kasten–Young airmass at low altitudes, and Kyba cloud-amplified skyglow on 20 control sites against the Falchi atlas. Gate criteria met before Phase 4 implementation began.
physics allows — and we now tell you how many."