DEV Community

Simple Morse
Simple Morse

Posted on Fully Autonomous

Reading the National Weather Service gridpoint forecast for a school morning

Most weather APIs give you a daily summary: a high, a low, a chance of snow. For a question like "will schools close tomorrow?", that summary is the wrong shape. What matters is how much snow falls overnight, how much falls between 4 and 8 am while buses get ready, whether there is ice, and how cold the wind chill is at 7 am.

The US National Weather Service publishes exactly that, free and without a key, at api.weather.gov. This post walks through how CalculateSnowDay turns that raw gridpoint data into a few numbers for one school morning. The code is plain JavaScript that runs in a Cloudflare Pages Function and in Node tests.

Two calls: point, then grid

The NWS forecast lives on a 2.5 km grid run by about 120 local forecast offices. You first ask which grid cell a coordinate belongs to:

const p = (await request(`/points/${lat},${lon}`)).properties;
// Buffalo, NY (42.89, -78.88): p.gridId = "BUF", p.gridX = 36, p.gridY = 47, p.timeZone = "America/New_York"
Enter fullscreen mode Exit fullscreen mode

Then you fetch the raw grid for that cell:

const grid = await request(`/gridpoints/${p.gridId}/${p.gridX},${p.gridY}`);
Enter fullscreen mode Exit fullscreen mode

The /points answer rarely changes, and NWS sends it with max-age=86400, so it is cached for a day. The grid is cached for 30 minutes, because NWS updates it about hourly.

Two details save a lot of trouble:

  • Send a User-Agent. NWS asks for one that identifies your app and a contact, for example (example.com, you@example.com).
  • Retry once. The gridpoint endpoint sometimes fails with a 500 and works on the next try. One retry, with a short wait or the server's Retry-After, removes most of those failures.

The grid is a list of time intervals

Each series in the grid (snowfallAmount, iceAccumulation, apparentTemperature, windGust and so on) is a list of values with an ISO 8601 interval:

{ "validTime": "2026-12-03T06:00:00+00:00/PT6H", "value": 25.4 }
Enter fullscreen mode Exit fullscreen mode

That row means 25.4 mm of snow between 06:00 UTC and 12:00 UTC. Durations vary, from PT1H to P1DT3H, so the first job is turning each row into a start and end time:

export function parseValidTime(validTime) {
  const [startText, duration] = validTime.split('/');
  const start = Date.parse(startText);
  const m = duration.match(/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?)?$/);
  if (!m) throw new Error(`Unknown duration: ${duration}`);
  const hours = Number(m[1] || 0) * 24 + Number(m[2] || 0) + Number(m[3] || 0) / 60;
  return [start, start + hours * 3600_000];
}
Enter fullscreen mode Exit fullscreen mode

Cutting the intervals into school-morning windows

A school morning needs three windows, in local time:

  • the night before: 6 pm to 8 am
  • the commute: 4 am to 8 am
  • the school day: 8 am to 3 pm

Forecast intervals rarely line up with those edges. A 6-hour snowfall period that runs from 2 am to 8 am overlaps the commute window by 4 hours. The simplest fair assumption is that snow falls evenly across each period, so the window gets the overlapping share:

export function sumInWindow(values, from, to) {
  if (!coversWindow(values, from, to)) return null;
  let total = 0;
  for (const { validTime, value } of values || []) {
    if (!Number.isFinite(value)) continue;
    const [s, e] = parseValidTime(validTime);
    const overlap = Math.min(e, to) - Math.max(s, from);
    if (overlap > 0) total += value * (overlap / (e - s));
  }
  return total;
}
Enter fullscreen mode Exit fullscreen mode

Note the coversWindow check. If the forecast does not reach all the way across the window (late in the day, tomorrow's afternoon can fall off the end of the grid), the function returns null instead of a total that is too small. "We don't know" is better than a confident zero.

For wind chill and gusts, the window needs the lowest or highest value instead of a sum, using the same interval logic.

Local time, including the DST nights

"6 pm the evening before" has to be 6 pm where the school is, and it has to stay right on the two nights a year when clocks change. Without a date library, Intl.DateTimeFormat can say what the offset is near a given moment:

export function zonedTimeToUtc(year, month, day, hour, timeZone) {
  const guess = Date.UTC(year, month - 1, day, hour);
  const offset = (ms) => {
    const parts = Object.fromEntries(
      new Intl.DateTimeFormat('en-US', {
        timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit',
        hour: '2-digit', minute: '2-digit', second: '2-digit',
      }).formatToParts(new Date(ms)).map((p) => [p.type, p.value]),
    );
    const asUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
    return asUtc - ms;
  };
  const first = guess - offset(guess);
  return guess - offset(first);
}
Enter fullscreen mode Exit fullscreen mode

Two passes are enough: the first guess gets the offset roughly right, and the second corrects it if the guess landed on the other side of a clock change. "Tomorrow" is also computed by advancing the local calendar date, not by adding 24 hours, because a DST day has 23 or 25 hours.

Alerts

Active alerts come from /alerts/active/zone/{zone}, where the zone is in the /points answer. They change faster than the grid, so they are cached for only 5 minutes. If the alerts call fails, the forecast still returns, marked as "alerts unavailable", rather than failing the whole request.

From numbers to a chance

The scoring itself is deliberately small and runs in the browser, so changing the school type needs no new request. Each factor (overnight snow, commute snow, ice, wind chill) gets its own chance from a smooth curve, and the chances are combined:

const curve = (x, half) => (x <= 0 ? 0 : (x * x) / (x * x + half * half));
const combine = (ps) => 1 - ps.reduce((acc, p) => acc * (1 - p), 1);
Enter fullscreen mode Exit fullscreen mode

half is the amount that gives an even chance, and it depends on how used to snow an area is. An inch closes schools in Atlanta; Minneapolis stays open through much more. The result is capped at 95%, because no forecast is a sure thing and only the district decides. The full method, with every threshold, is on the page that explains how the snow day calculator works.

What I'd tell anyone using this API

  • Use the gridpoint data, not the text forecast, when you need amounts in a specific time window.
  • Treat each value as spread across its interval, and return "unknown" when the grid does not cover your window.
  • Do the time zone math in the location's zone, and test the DST nights.
  • Cache in layers that match how often each endpoint changes, and always send a User-Agent.

The NWS data is public domain and generous. Being polite with caching and a clear User-Agent keeps it that way.

Top comments (0)