Work Dark Sky Verdict

02 / Mobile app · API · Astronomy

Dark Sky Verdict

Weather, moon phase and light pollution scored into a go or no-go call for stargazing, and a multi-night route to chase it.

Case study

Whether tonight is worth driving for is a question with a real answer. Most stargazing apps show you a forecast and leave the judgement to you.

WHAT IT DOES

A go or no-go call, not a dashboard.

Tell it where your nights start and how far you will drive, and it answers three questions. Is tonight worth it. If not tonight, which night. And where should you stand when you get there.

Every night at every one of 75 destinations, 12 dark sites and all 63 national parks, is scored on four things: cloud cover from the forecast, moon phase, the site's Bortle rating for light pollution, and drive time. On top of that sit a multi-night trip planner and a constellation chaser, which answer a different question: not where to go tonight, but where to be over the next sixteen nights.

WHAT YOU ARE LOOKING AT

This is a web build of a native mobile app, published so the functionality can be tried without installing anything.

Dark Sky Verdict is built in React Native with Expo, and its real home is an iPhone. It is a portrait app with a red-light field mode, designed to be used outside at night with one hand. What is embedded here is the same codebase exported to the web so the flow can be clicked through in a browser.

The screens, the navigation, the scoring and the live API calls are identical. Two things do not survive, and both are limits of the browser rather than of the app: the interactive parks map depends on react-native-maps, which is iOS and Android only, and push notifications, calendar integration and device location have no web equivalent. Treat this as a working demonstration of the logic, not as the shipping product.

ARCHITECTURE

Two deployables that know almost nothing about each other.

  • Expo Router front end 14 file-based routes. A NightModeProvider at the root swaps a five-token palette, so a red-light field mode recolours the entire interface without a single per-screen conditional. Typography is Sora at three weights behind a TYPE object, not inline font names.
  • FastAPI scoring service 10 endpoints in api.py plus an included router from constellations.py. trip_engine.py is strictly pure, no network and no imports from api.py, so the caller gathers weather and drive times and hands them in. That is what lets it run standalone against synthetic sites, which is how the beam search was actually debugged.
  • scoring_bridge.py A 13-line seam that exists purely to break an import cycle. api.py includes the router from constellations.py, and that router's /journeys handler needs api.top_nights, so a module-level import in either direction deadlocks. The bridge re-exports the one function and is imported inside the handler, by which point api is fully loaded.
  • Open-Meteo and Astral Cloud in three layers, temperature and dew point from Open-Meteo. Sun, moon and twilight from Astral. No API keys anywhere in the stack.

The API contract is not a document, it is src/api/mock.js. Every mock fixture has exactly the shape the live endpoint returns, and the client picks between them on one environment variable, so the entire app runs offline on bundled data and going live is a config change rather than a code change.

const USE_MOCK = process.env.EXPO_PUBLIC_USE_MOCK !== "false";
const BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:8000";

export async function fetchVerdicts(override) {
  if (USE_MOCK) return MOCK_VERDICTS;
  const { lat, lon, maxDriveHours } = { ...(await getOrigin()), ...override };
  const data = await get(`/verdicts?lat=${lat}&lon=${lon}&max_drive=${maxDriveHours}`);
  return data;
}
src/api/client.js

Defaulting USE_MOCK to true rather than false is deliberate: a fresh clone runs every screen with no backend, no keys and no network. That is also why the web build could ship and be useful before the FastAPI service was deployed at all.

One more detail in that client. FastAPI explains refusals in a detail field, and an early version threw those away, which left the UI with a spinner and nothing to say. The error path now carries detail and status through to the caller, so a screen can render the actual reason it has nothing to show.

THE SCORING MODEL

One weighted sum, and one factor that is not in it.

Four inputs, normalised to 0 to 1 and combined at fixed weights. Cloud dominates because clear skies matter most, moon is next because a full moon washes out everything, Bortle is the site quality term and drive time is a convenience penalty.

W_CLOUDS = 0.40   # clear skies matter most
W_MOON = 0.30     # a full moon washes out everything
W_DARKNESS = 0.20 # site quality (Bortle)
W_DRIVE = 0.10    # convenience penalty
backend/dark_sky_verdict.py
def score_site_night(site, night, wx, home_lat, home_lon, max_drive,
                     max_cloud=MAX_CLOUD_PCT):
    dh = drive_hours_from(home_lat, home_lon, site)
    if dh > max_drive:
        return None
    rec = wx["nights"].get(night)
    if rec is None:
        return None
    if too_cloudy(rec, max_cloud):
        return None  # omitted, not ranked low
    clearness = 1.0 - rec["effective_cloud"] / 100.0
    total = (W_CLOUDS * clearness
             + W_MOON * moon_darkness(night)
             + W_DARKNESS * site_darkness(site["bortle"])
             + W_DRIVE * drive_convenience(dh, max_drive))
backend/api.py

The normalisers are deliberately boring and live in one place: site_darkness is (9 - bortle) / 8, so Bortle 1 maps to 1.0 and Bortle 9 to 0.0. moon_darkness takes astral's 0 to 27.99 phase value and returns abs(phase - 14) / 14, which is 1.0 at new moon and 0.0 at full. Because it is computed rather than fetched, scanning sixteen nights costs nothing, and that is what makes a multi-night planner viable at all.

Note what the early return does. Cloud is a gate, not a weight: past the threshold a night is dropped rather than scored down, because it is not a viewing night at any darkness. The threshold defaults to 10% and every endpoint takes max_cloud, so the traveller sets it. Below the gate cloud is still weighted, so 2% beats 9%.

Raw total cloud cover is the wrong number to gate on, though. Low stratus ruins a night and high cirrus barely dents it, so the three layers Open-Meteo reports are combined as independent transmission rather than averaged.

CLOUD_OPACITY = {"low": 0.90, "mid": 0.60, "high": 0.30}

def _layered_cloud_pct(low, mid, high):
    """Combine cloud layers into an effective obscuration %, weighting low
    cloud heavily and high cirrus lightly (independent-transmission model)."""
    transmitted = 1.0
    for pct, key in ((low, "low"), (mid, "mid"), (high, "high")):
        transmitted *= 1.0 - (pct or 0) / 100.0 * CLOUD_OPACITY[key]
    return round((1.0 - transmitted) * 100)
backend/api.py

A sky reading 60% total cover that is all high cirrus comes out at 18% effective and passes a 20% gate. The same 60% as low cloud comes out at 54% and does not. Every gate, every score and every ranking reads effective_cloud, never the raw figure.

In the multi-night and nationwide rankings darkness is promoted out of the weighted sum entirely and becomes the primary sort key, so a darker sky always outranks a brighter one however far away it is. No amount of convenience turns a Bortle 3 into a Bortle 1. The weighted score only orders places that are equally dark and equally clear.

POSITIONAL ASTRONOMY, NO EPHEMERIS

Fixed stars do not move on human timescales, so pointing you at one is sidereal time arithmetic.

The constellation engine ships no ephemeris file and makes no API calls. Each constellation carries the right ascension and declination of its visual centre, and turning that into an altitude and a compass bearing for an observer is Julian date, Greenwich Mean Sidereal Time, hour angle, then two lines of spherical trigonometry. Accurate to well under a degree, against a question whose real precision requirement is which way do I look.

def radec_to_altaz(ra_hours, dec_deg, lat_deg, lon_deg, dt_utc):
    lst = (_gmst_hours(dt_utc) + lon_deg / 15.0) % 24.0   # local sidereal time
    ha  = math.radians((lst - ra_hours) * 15.0)           # hour angle
    dec = math.radians(dec_deg)
    lat = math.radians(lat_deg)

    sin_alt = (math.sin(dec) * math.sin(lat)
               + math.cos(dec) * math.cos(lat) * math.cos(ha))
    alt = math.asin(max(-1.0, min(1.0, sin_alt)))

    cos_az = ((math.sin(dec) - math.sin(alt) * math.sin(lat))
              / (math.cos(alt) * math.cos(lat)))
    az = math.acos(max(-1.0, min(1.0, cos_az)))
    if math.sin(ha) > 0:                                  # west of meridian
        az = 2 * math.pi - az
    return math.degrees(alt), math.degrees(az)
backend/constellations.py

The clamps on asin and acos are not defensive noise. Floating point drift pushes those arguments a hair past 1.0 near the zenith and the call raises a domain error, which in practice means the function fails exactly when a constellation is best placed.

That function is also why the constellation chaser evaluates each candidate site at its own coordinates rather than from the traveller's home. Altitude is a function of the observer's latitude, so scoring the whole country from one latitude ranks every place by a sky none of them have, and every constellation returns roughly the same list. Measured properly the results separate hard: Cassiopeia at declination +60 sends you to Alaska, Pegasus at +20 sends you to south Texas.

A fixed 25 degree cutoff for well placed does not survive contact with real declinations either. Scorpius sits at -30 and tops out around 21 degrees from Missouri, so a fixed threshold reports it as never visible, in a season when it is the best thing in the sky. The threshold adapts to what a given constellation can actually reach from a given latitude.

def placement_threshold(dec_deg, lat_deg):
    """Adaptive threshold: 25 deg normally, but southern constellations
    that never climb high from this latitude (Scorpius from Missouri
    tops out ~21 deg) count as well placed at 75% of their maximum
    possible altitude, floored at 12 deg."""
    max_alt = 90.0 - abs(lat_deg - dec_deg)
    return max(12.0, min(MIN_ALTITUDE, max_alt * 0.75))
backend/constellations.py

THE TRIP PLANNER

Beam search over site and date, with staying put as a legal move.

A multi-night trip is a sequence of (site, night) pairs. Enumerating them is exponential, so the planner keeps a beam of the 40 best partial itineraries and extends them one night at a time. The state is a path, accumulated driving and accumulated score; the move set at each step is every destination whose leg from the current one fits the daily cap, plus the current one itself.

for n in range(1, days):
    dn = nights[n].isoformat()
    nxt = []
    for st in beam:
        cur = st["path"][-1][0]
        for sid in dests:
            if dn not in scores.get(sid, {}):
                continue
            if sid == cur:
                leg = 0.0
            else:
                leg = drive_between[cur].get(sid, 1e9)
                if leg > max_drive_hours:
                    continue
            nxt.append({
                "path":  st["path"] + [(sid, nights[n])],
                "drive": st["drive"] + leg,
                "score": st["score"] + scores[sid][dn],
            })
    nxt.sort(key=lambda x: -(x["score"] - DRIVE_COST_PER_HOUR * x["drive"]))
    beam = nxt[:BEAM_WIDTH]
backend/trip_engine.py

Making stay another night a zero-cost move is the whole reason base-camping emerges without being special-cased. The objective is summed night score minus 1.5 points per driving hour, which prefers efficient routes without forbidding ambitious ones. trip_engine.py is pure: no network, no astral, no import of api.py. The caller gathers weather and drive times and hands them in, which is what makes the module runnable on its own with synthetic sites.

Ranked results are then deduplicated by route signature, collapsing consecutive same-site nights, so the three options offered are genuinely different journeys rather than one route at three shifted start dates.

The distance is no object mode needed the planner changed rather than configured. It normally requires night one to be a legal leg from home and the last night a legal leg back, which is right for a road trip and fatal for a trip to Alaska. An ignore_home flag drops that anchoring for this mode alone while still capping every leg between stops, so each day stays drivable.

That fix created a reporting bug worth naming. total_drive_hours counts driving during the trip, so three nights parked at Glacier Bay is legitimately 0.0, and the UI read no driving on a trip requiring 46 hours to reach. The option now carries travel_to_start_hours and home_travel_included alongside it, and the front end renders the two facts separately.

{opt.home_travel_included === false
  ? (opt.total_drive_hours > 0
      ? `${opt.total_drive_hours}h between stops · ${opt.travel_to_start_hours}h to get there`
      : `no driving once there · ${opt.travel_to_start_hours}h to get there`)
  : `${opt.total_drive_hours}h driving`}
app/trip-options.js

Candidate selection changes per mode too. Ranking the whole country by darkness for the unlimited mode produces a scattered set with no legal legs between any two sites, so it anchors on the six darkest and pulls in each anchor's drivable neighbourhood instead.

KEEPING IT FAST

The scoring is cheap. The weather is not, so most of the engineering is about not fetching it.

Astronomy is arithmetic and costs nothing. Weather is one HTTP round trip per location, and a nationwide ranking across 75 destinations and 16 nights would be unusable if done naively. Three things keep it bounded.

  • Cache by rounded coordinate Responses are keyed on (lat, lon) rounded to three decimals, about 100 m, and held for an hour. Many users and all 16 nights at a site share one upstream request, and one fetch returns the whole window rather than one call per night.
  • Walk the Bortle bands and stop The nationwide ranking works down darkness bands and stops as soon as it has enough clear places. Typically that means the 12 Bortle 1 sites and nothing else. MAX_WEATHER_LOOKUPS caps the pathological case where the entire country is overcast at 40 fetches.
  • Fetch a band in parallel The lookups within a band are independent, so they go out on a ThreadPoolExecutor with 8 workers instead of one round trip at a time.

The band walk is what makes darkness-as-sort-key cheap as well as correct. Because a darker sky always outranks a brighter one, the search can stop the moment a band fills the result set: nothing in a brighter band could outrank what is already held, so there is no reason to pay for its weather.

One deliberate omission: the picker that ranks places for a constellation runs on pure astronomy and the static Bortle rating, with no weather at all, so it returns in milliseconds. Weather enters only once the list is short.

PORTING A NATIVE APP TO THE WEB

Three specific things break, and none of them fail in an obvious way.

react-native-maps imports React Native internals directly, so any web bundle that touches it fails to build. A .web.js override of the screen is not enough on its own, because expo-router enumerates the whole app directory with require.context and drags the native file in regardless, and renaming it .native.js does not help either since require.context picks that up too. The fix is at the resolver, targeting the platform rather than the file.

const upstream = config.resolver.resolveRequest;
config.resolver.resolveRequest = (context, moduleName, platform) => {
  if (platform === "web" && moduleName === "react-native-maps") {
    return {
      type: "sourceFile",
      filePath: path.resolve(__dirname, "src/shims/react-native-maps.web.js"),
    };
  }
  return (upstream || context.resolveRequest)(context, moduleName, platform);
};
metro.config.js

Native resolution is untouched, and the web screen it unblocks is not a degraded basemap. It plots all 63 parks by projected latitude and longitude as a star field, sized and brightened by inverted Bortle so the darkest skies are the brightest points, with a searchable list beneath it carrying the keyboard and screen reader path.

The second break is routing. Expo's static export writes onboarding/parks-map.html, but expo-router's client router only matches the extensionless path, so on a plain static host the clean URL 404s and the .html path renders Unmatched Route. A 31-line post-build step rewrites every route to directory form, which makes clean URLs work on any static host with no rewrite rules at all. It skips any file already named index.html and anything beginning with +, since the 404 target has to stay where the server expects it.

The third is the quietest. Expo inlines EXPO_PUBLIC_ variables at transform time, and the transform cache does not know an env file changed, so a stale build kept USE_MOCK true, tree-shook the entire network path out of the bundle, and produced a web app that looked completely correct while never once calling the API. Committed .env.production and .env.development plus an explicit --clear on export is the fix, and the build is now checked for the live API hostname before it is deployed.

DEPLOYMENT

Static front end and hardened API service, on separate hosts.

The API runs under uvicorn bound to 127.0.0.1:8000, managed by systemd as darksky-api.service with enable-at-boot and restart-always, behind nginx with a Let's Encrypt certificate and automatic renewal. It is never reachable except through the proxy.

The unit runs as an unprivileged darksky user with NoNewPrivileges, ProtectSystem=strict and ProtectHome, so a compromise of the Python process gets a read-only filesystem and no path upward. CORS is restricted to the deployed origins plus localhost rather than left open.

The front end is a static export on ordinary shared hosting, entirely separate from the API, which means the site cannot be taken down by a backend deploy and the app degrades to a clear error rather than a blank screen if the service is restarting.

Weather from Open-Meteo, three cloud layers plus temperature and dew point, no API key. Sun, moon and twilight via Astral. Geocoding through OpenStreetMap Nominatim, proxied by the API. Constellation right ascension and declination compiled from standard catalogue centres; Bortle ratings estimated and cross checked against DarkSky International's certified park list. Front end Expo and React Native, API FastAPI on Python, deployed on a Hostinger VPS behind nginx with Let's Encrypt.

Have something that needs building?

Get a quote