Sky science
Sky states
How an hour of sky becomes one of six states, how a night picks its best window, and how the app says what is limiting the sky.
Overview
| Symbol | Meaning | Unit |
|---|---|---|
| cloud cover as a fraction of the sky, 0 to 1 | none | |
| artificial brightness over natural brightness at the place | none | |
| scattered moonlight over natural brightness | none | |
| , the artificial and moon light together | none | |
| limiting magnitude with the moon included | magnitude | |
| utility of an hour: expected visible stars relative to a natural clear sky | none | |
| limiting magnitude lost to light pollution, and to the moon | magnitude |
The verdict comes from four ingredients: the place (how much artificial light there is, the zone ratio ), the sky (cloud cover and air clarity from a forecast), the moon (scattered moonlight, see Moonlight and sky brightness) and the Sun (whether it is dark at all). It is one of six states, plus a plain statement of what limits the sky: cloud, moon, light pollution or nothing.
Every number below is computed by a TypeScript port that was checked at build time against 162 shared test cases, the same ones the Python and Dart implementations pass.
Inputs per hour
| Input | Source | Used for |
|---|---|---|
| Sun altitude | ephemeris | dark: at or below −18° |
| Moon altitude, azimuth, illuminated fraction | ephemeris | moonlight |
| Total cloud cover | forecast (Open-Meteo cloud_cover) | cloud fraction , percent over 100 |
| Aerosol optical depth at 550 nm | forecast (CAMS through Open-Meteo Air Quality), or none | extinction |
| Elevation | forecast response or an elevation model | extinction |
| Artificial light | zone data: the ratio from the cell's sky brightness, or directly after calibration |
Each sample stands for one hour. Cloud cover is a model output, not something Astr calculates: a grid-cell fraction of the sky covered, so it says how much of the sky is covered and not whether a given star is hidden. See Weather and clouds.
Usable hours and the best window
For each hour, with the cloud fraction:
The utility is the expected number of naked-eye stars you can see relative to a natural clear sky: the clear fraction times the star-count ratio. The slope 0.48 is the logarithmic slope of commonly cited all-sky star counts, to be recomputed from the catalogue once the corpus exists (unverified). The three thresholds are proposals.
Best window. Split the night into runs of consecutive usable hours. The best window is the run with the greatest summed utility, at least 2 hours long. Ties go to the earlier run. If no run qualifies, there is no window.
The six states
The state is a function of the cloud fraction , the artificial ratio and . The moon pushes the sky into a worse effective zone, using the ladder from Zone scale.
| Order | Condition | State |
|---|---|---|
| 1 | Cloudy | |
| 2 | effective zone 9 and the artificial zone is also 9 | Too much light |
| 3 | effective zone 8, or zone 9 caused only by the moon | Few stars |
| 4 | effective zone 6 or 7 | Planets visible |
| 5 | effective zone 4 or 5 | Starry sky |
| 6 | effective zone 1, 2 or 3 | Milky Way visible |
Cloud caps. Milky Way needs , otherwise it becomes Starry sky. Starry sky needs , otherwise it becomes Planets visible. The cuts 0.30, 0.50 and 0.70 are the values the app already uses (proposals to tune).






The six states with the app's own backgrounds.
The moon cannot create "Too much light". That state describes light pollution, so a moon-driven worst case is Few stars.
Why the bands are where they are. On a moonless clear night, zones 1 to 3 have a limiting magnitude of 6.2 or better and the Milky Way is intact. Zones 4 and 5 are the Milky Way fading band and beyond (5.5 to 6.2). Zones 6 and 7 leave the brightest stars and planets (4.5 to 5.5), zone 8 leaves few stars (3.9 to 4.5), and zone 9 is the top class. The state boundaries are zone boundaries, so the zone and the verdict never disagree on a moonless night.
What is limiting the sky
why returns the reason, so the app can say why and not only what.
| Primary cause | When |
|---|---|
| Cloud | the state is Cloudy, or cloud capped the state |
| The moon | otherwise, and |
| Light pollution | otherwise, |
| Nothing significant | otherwise |
The two losses are measured in magnitudes of limiting magnitude:
The 0.25 cut is a proposal.
The night, the hero and the chip
- Best window tonight
- Planets visible, 21:00 to 05:00
- Right now, 23:00
- Starry sky
- Limiting factor
- the moon
An example night: the moon rises at 21:30 and the cloud forecast clears before dawn. Dark hours run 21:00 to 04:00. The bottom figure is the effective limiting magnitude, and the outlined hours are the best window.
- Night state. The state of the best window, computed from the window's mean cloud and mean , so a moon-up hour counts in luminance and not in magnitudes. With no window, the state comes from the dark hours, so a cloudy night or a city sky still gets an honest label. With no dark hour at all (polar summer) there is no verdict.
- Hero. Shows the night state, and the date arrows move the night.
- Right now. The same state function applied to the current hour. It is empty when the Sun is not below −18°. Because both use the same function, the two can never disagree about the method.
- The night. Runs from sunset to the next sunrise, as the app computes today. Dark hours inside it are those with the Sun at or below −18°.
Moon time
Moon time is two numbers over the dark hours: how many have the moon up and how many have it down. The moon's cost is in magnitudes, at the zenith or at an object. The night state already includes it through .
Try it
- State
- Starry sky
- Limiting factor
- The moon
- Extinction k_V
- 0.176
- Moonlight
- 2.39 × natural
- Sky brightness
- 20.64 mag/arcsec²
- Limiting magnitude
- 5.91
- Light pollution cost
- 0.05 mag
- Moon cost
- 0.67 mag
Zenith sky at astronomical night. The state and the cause come from the same functions the app uses.
What this replaces
Today's home screen reads a single current-hour cloud value for today, and a day average for other dates, then scores cloud (40%), darkness (35%) and moon illumination (25%) with Bortle-style cut-offs. The new model replaces it.
| Today | Becomes |
|---|---|
QualitativeConditionService.evaluate (40/35/25 score, Bortle cut-offs) | skyState |
DarknessCalculator.calculateDarkness ( for a moon at altitude ) | sqmEffective with moonRatio |
PrimeViewCalculator (cloud 0.7, moon 0.3, lowest average) | bestWindow |
BortleMpsasConverter | removed |
QualityCalculator and StargazingLogic (unused) | removed |
Today's evaluation, for reference:
/// Evaluates current observing conditions and returns qualitative result////// [cloudCover] Cloud coverage percentage (0-100)/// [moonIllumination] Moon illumination fraction (0.0-1.0)/// [mpsas] Sky brightness in magnitudes per square arcsecond (17-22)////// Returns [ConditionResult] with quality assessment and adviceConditionResult evaluate({ required double cloudCover, required double moonIllumination, required double mpsas,}) { // Normalize inputs to 0-1 scale (higher = better) final double cloudScore = _normalizeCloudCover(cloudCover); final double moonScore = _normalizeMoonIllumination(moonIllumination); final double darknessScore = _normalizeMPSAS(mpsas); // Weighted combination // Cloud cover is most critical (40%), followed by darkness (35%) and moon (25%) const double cloudWeight = 0.40; const double darknessWeight = 0.35; const double moonWeight = 0.25; final double overallScore = (cloudScore * cloudWeight) + (darknessScore * darknessWeight) + (moonScore * moonWeight); // Determine quality and generate advice return _determineQualityAndAdvice( overallScore: overallScore, cloudCover: cloudCover, moonIllumination: moonIllumination, mpsas: mpsas, );}Limits and proposals
- Twilight brightness is not modelled; the model is gated at −18°.
- Clouds are one number per hour from a forecast model. Moonlit clouds and cloud-amplified skyglow near cities are ignored, and a low cover does not guarantee a clear line to a given star.
- The cloud cuts (0.30, 0.50, 0.70), the 3.5 limiting-magnitude floor, the two-hour minimum and the 0.25 magnitude cause cut are proposals to tune against observer reports.
- The star-count slope is not verified.
Implementation
Excerpts of the real files, cut out by name, copied into the site and checked against the repository on every build.
def hour_quality(dark, cloud, r_art, b_moon): """Per-hour values. cloud is a fraction 0..1. Returns a dict.""" sqm = sqm_effective(r_art, b_moon) nelm = nelm_from_sqm(sqm) usable = bool(dark) and cloud <= CLOUD_USABLE_MAX and nelm >= NELM_USABLE_MIN utility = (1.0 - cloud) * relative_star_count(nelm) if usable else 0.0 return {"dark": bool(dark), "cloud": cloud, "r_art": r_art, "b_moon": b_moon, "sqm": sqm, "nelm": nelm, "usable": usable, "utility": utility}def best_window(hours, min_hours=MIN_WINDOW_HOURS): """Contiguous run of usable hours with the greatest summed utility, as (start, end_exclusive) or None. Each sample stands for one hour. Ties go to the earlier window. """ best, best_u = None, 0.0 i = 0 n = len(hours) while i < n: if not hours[i]["usable"]: i += 1 continue j = i total = 0.0 while j < n and hours[j]["usable"]: total += hours[j]["utility"] j += 1 if j - i >= min_hours and total > best_u: best, best_u = (i, j), total i = j return bestdef sky_state(cloud, r_art, r_eff): """One of the six sky states from the cloud fraction, the artificial ratio and artificial + moon ratio.""" if cloud > CLOUD_USABLE_MAX: return "cloudy" base_zone = zone_from_ratio(r_art) z = zone_from_ratio(r_eff) if z == 9 and base_zone < 9: z = 8 # the moon can make the sky poor but never "too much light" if z == 9: return "tooMuchLight" if z == 8: state = "fewStars" elif z >= 6: state = "planetsVisible" elif z >= 4: state = "starrySkies" else: state = "milkyWayVisible" if state == "milkyWayVisible" and cloud > CLOUD_MILKY_WAY_MAX: state = "starrySkies" if state == "starrySkies" and cloud > CLOUD_STARRY_MAX: state = "planetsVisible" return statedef why(cloud, r_art, r_eff): """What limits the sky: a dict with primary ('cloud', 'moon', 'light' or 'none') and the losses. light_loss and moon_loss are limiting-magnitude losses in magnitudes: light pollution against a natural sky, and the moon on top of the artificial sky. Cloud is named when it makes the state cloudy or caps it. """ state = sky_state(cloud, r_art, r_eff) capped = state == "cloudy" or state != sky_state(0.0, r_art, r_eff) nelm_natural = nelm_from_sqm(REFERENCE_SQM) nelm_art = nelm_from_sqm(sqm_effective(r_art)) nelm_all = nelm_from_sqm(sqm_effective(r_eff)) light_loss = nelm_natural - nelm_art moon_loss = nelm_art - nelm_all if capped: primary = "cloud" elif moon_loss >= LOSS_NOTICEABLE_MAG and moon_loss > light_loss: primary = "moon" elif light_loss >= LOSS_NOTICEABLE_MAG: primary = "light" else: primary = "none" return {"primary": primary, "light_loss": light_loss, "moon_loss": moon_loss}def night_state(hours): """(state, window) for a night of hourly samples, or None when there is no astronomical night. With a usable window the state comes from that window. Without one it comes from the dark hours, so a cloudy night or a city sky still gets an honest state. """ window = best_window(hours) if window is not None: return window_state(hours, window), window dark = [h for h in hours if h["dark"]] if not dark: return None cloud = sum(h["cloud"] for h in dark) / len(dark) r_art = sum(h["r_art"] for h in dark) / len(dark) r_eff = sum(h["r_art"] + h["b_moon"] for h in dark) / len(dark) return sky_state(cloud, r_art, r_eff), Nonedef instant_state(hour): """State for the 'right now' chip. None when the Sun is not below -18 degrees (day or twilight).""" if not hour["dark"]: return None return sky_state(hour["cloud"], hour["r_art"], hour["r_art"] + hour["b_moon"])def moon_hours(dark_flags, moon_altitudes): """(dark hours with the moon up, dark hours with the moon down). Inputs are per-hour samples.""" up = sum(1 for d, a in zip(dark_flags, moon_altitudes) if d and a > 0.0) down = sum(1 for d, a in zip(dark_flags, moon_altitudes) if d and a <= 0.0) return up, down/// Per-hour values. [cloud] is a fraction 0 to 1.static SkyHour hourQuality({ required bool dark, required double cloud, required double rArt, required double bMoon,}) { final double sqm = sqmEffective(rArt, bMoon: bMoon); final double nelm = AstrZoneScale.nelmFromSqm(sqm); final bool usable = dark && cloud <= cloudUsableMax && nelm >= nelmUsableMin; final double utility = usable ? (1 - cloud) * relativeStarCount(nelm) : 0; return SkyHour( dark: dark, cloud: cloud, rArt: rArt, bMoon: bMoon, sqm: sqm, nelm: nelm, usable: usable, utility: utility, );}/// The run of usable hours with the greatest summed utility, or null. Ties go to the earlier run.static SkyWindow? bestWindow(List<SkyHour> hours, {int minHours = minWindowHours}) { SkyWindow? best; double bestUtility = 0; int i = 0; while (i < hours.length) { if (!hours[i].usable) { i++; continue; } int j = i; double total = 0; while (j < hours.length && hours[j].usable) { total += hours[j].utility; j++; } if (j - i >= minHours && total > bestUtility) { best = SkyWindow(i, j); bestUtility = total; } i = j; } return best;}/// One of the six sky states from the cloud fraction, the artificial ratio and artificial + moon ratio.static SkyState skyState({required double cloud, required double rArt, required double rEff}) { if (cloud > cloudUsableMax) return SkyState.cloudy; final int baseZone = AstrZoneScale.zoneFromRatio(rArt); int z = AstrZoneScale.zoneFromRatio(rEff); if (z == 9 && baseZone < 9) z = 8; // the moon can make the sky poor but never "too much light" if (z == 9) return SkyState.tooMuchLight; SkyState state; if (z == 8) { state = SkyState.fewStars; } else if (z >= 6) { state = SkyState.planetsVisible; } else if (z >= 4) { state = SkyState.starrySkies; } else { state = SkyState.milkyWayVisible; } if (state == SkyState.milkyWayVisible && cloud > cloudMilkyWayMax) state = SkyState.starrySkies; if (state == SkyState.starrySkies && cloud > cloudStarryMax) state = SkyState.planetsVisible; return state;}/// What limits the sky. Cloud is named when it makes the state cloudy or caps it.static SkyCause why({required double cloud, required double rArt, required double rEff}) { final SkyState state = skyState(cloud: cloud, rArt: rArt, rEff: rEff); final bool capped = state == SkyState.cloudy || state != skyState(cloud: 0, rArt: rArt, rEff: rEff); final double nelmNatural = AstrZoneScale.nelmFromSqm(AstrZoneScale.referenceSqm); final double nelmArt = AstrZoneScale.nelmFromSqm(sqmEffective(rArt)); final double nelmAll = AstrZoneScale.nelmFromSqm(sqmEffective(rEff)); final double lightLoss = nelmNatural - nelmArt; final double moonLoss = nelmArt - nelmAll; String primary; if (capped) { primary = 'cloud'; } else if (moonLoss >= lossNoticeableMag && moonLoss > lightLoss) { primary = 'moon'; } else if (lightLoss >= lossNoticeableMag) { primary = 'light'; } else { primary = 'none'; } return SkyCause(primary, lightLoss, moonLoss);}/// State for a night of hourly samples, or null when there is no astronomical night.static NightSky? nightState(List<SkyHour> hours) { final SkyWindow? window = bestWindow(hours); final List<SkyHour> span = window != null ? hours.sublist(window.start, window.end) : hours.where((SkyHour h) => h.dark).toList(); if (span.isEmpty) return null; final double n = span.length.toDouble(); final double cloud = span.fold<double>(0, (double a, SkyHour h) => a + h.cloud) / n; final double rArt = span.fold<double>(0, (double a, SkyHour h) => a + h.rArt) / n; final double rEff = span.fold<double>(0, (double a, SkyHour h) => a + h.rArt + h.bMoon) / n; return NightSky(skyState(cloud: cloud, rArt: rArt, rEff: rEff), window);}/// State for the "right now" chip. Null when the Sun is not below -18 degrees.static SkyState? instantState(SkyHour hour) { if (!hour.dark) return null; return skyState(cloud: hour.cloud, rArt: hour.rArt, rEff: hour.rArt + hour.bMoon);}/// (dark hours with the moon up, dark hours with the moon down).static (int, int) moonHours(List<bool> dark, List<double> moonAltitudes) { int up = 0; int down = 0; for (int i = 0; i < dark.length && i < moonAltitudes.length; i++) { if (!dark[i]) continue; if (moonAltitudes[i] > 0) { up++; } else { down++; } } return (up, down);}