Skip to content

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

SymbolMeaningUnit
cccloud cover as a fraction of the sky, 0 to 1none
rrartificial brightness over natural brightness at the placenone
bmoonb_{\text{moon}}scattered moonlight over natural brightnessnone
reffr_{\text{eff}}r+bmoonr + b_{\text{moon}}, the artificial and moon light togethernone
NELMeff\text{NELM}_{\text{eff}}limiting magnitude with the moon includedmagnitude
uuutility of an hour: expected visible stars relative to a natural clear skynone
Δlight, Δmoon\Delta_{\text{light}},\ \Delta_{\text{moon}}limiting magnitude lost to light pollution, and to the moonmagnitude

The verdict comes from four ingredients: the place (how much artificial light there is, the zone ratio rr), 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

InputSourceUsed for
Sun altitudeephemerisdark: at or below −18°
Moon altitude, azimuth, illuminated fractionephemerismoonlight bmoonb_{\text{moon}}
Total cloud coverforecast (Open-Meteo cloud_cover)cloud fraction cc, percent over 100
Aerosol optical depth at 550 nmforecast (CAMS through Open-Meteo Air Quality), or noneextinction kVk_V
Elevationforecast response or an elevation modelextinction kVk_V
Artificial lightzone data: the ratio rr from the cell's sky brightness, or rr directly after calibrationrr

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 cc the cloud fraction:

usable=dark  ∧  c≤0.70  ∧  NELMeff≥3.5\text{usable} = \text{dark} \;\wedge\; c \le 0.70 \;\wedge\; \text{NELM}_{\text{eff}} \ge 3.5

u=(1−c)  10 0.48 (NELMeff−NELMnatural)when usable, else 0u = (1 - c)\;10^{\,0.48\,(\text{NELM}_{\text{eff}} - \text{NELM}_{\text{natural}})} \quad \text{when usable, else } 0

The utility uu 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 cc, the artificial ratio rr and reff=r+bmoonr_{\text{eff}} = r + b_{\text{moon}}. The moon pushes the sky into a worse effective zone, using the ladder from Zone scale.

OrderConditionState
1c>0.70c > 0.70Cloudy
2effective zone 9 and the artificial zone is also 9Too much light
3effective zone 8, or zone 9 caused only by the moonFew stars
4effective zone 6 or 7Planets visible
5effective zone 4 or 5Starry sky
6effective zone 1, 2 or 3Milky Way visible

Cloud caps. Milky Way needs c≤0.30c \le 0.30, otherwise it becomes Starry sky. Starry sky needs c≤0.50c \le 0.50, otherwise it becomes Planets visible. The cuts 0.30, 0.50 and 0.70 are the values the app already uses (proposals to tune).

Milky Way visible
Starry skies
Planets visible
Few stars
Cloudy
Too much light

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 causeWhen
Cloudthe state is Cloudy, or cloud capped the state
The moonotherwise, Δmoon≥0.25\Delta_{\text{moon}} \ge 0.25 and Δmoon>Δlight\Delta_{\text{moon}} > \Delta_{\text{light}}
Light pollutionotherwise, Δlight≥0.25\Delta_{\text{light}} \ge 0.25
Nothing significantotherwise

The two losses are measured in magnitudes of limiting magnitude:

Δlight=NELMnatural−NELM(r)Δmoon=NELM(r)−NELM(r+bmoon)\Delta_{\text{light}} = \text{NELM}_{\text{natural}} - \text{NELM}(r) \qquad \Delta_{\text{moon}} = \text{NELM}(r) - \text{NELM}(r + b_{\text{moon}})

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 reffr_{\text{eff}}, 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 ΔSQM=SQM(r)−SQMeff\Delta\text{SQM} = \text{SQM}(r) - \text{SQM}_{\text{eff}} in magnitudes, at the zenith or at an object. The night state already includes it through reffr_{\text{eff}}.

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.

TodayBecomes
QualitativeConditionService.evaluate (40/35/25 score, Bortle cut-offs)skyState
DarknessCalculator.calculateDarkness (4 isin⁡a4\,i\sin a for a moon at altitude aa)sqmEffective with moonRatio
PrimeViewCalculator (cloud 0.7, moon 0.3, lowest average)bestWindow
BortleMpsasConverterremoved
QualityCalculator and StargazingLogic (unused)removed

Today's evaluation, for reference:

qualitative_condition_service.dart · evaluate
/// 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.

astr_sky.py · hour_quality
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}
astr_sky.py · best_window
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 best
astr_sky.py · sky_state
def 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 state
astr_sky.py · why
def 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}
astr_sky.py · night_state
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), None
astr_sky.py · instant_state
def 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"])
astr_sky.py · moon_hours
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