DEV Community

Building a Snowfall Trigger Alert for Plow Dispatch in Buffalo, NY (Python + NWS API)

If you've ever lived through a Buffalo winter, you know the forecast for "the city" means very little. Lake-effect snow bands can drop a foot on Orchard Park while downtown gets a dusting. For a snow plowing crew, that creates a real scheduling problem: when do you actually send trucks out, and to which neighborhoods?

Most residential and commercial snow contracts use a trigger depth, typically around 2 inches, before a plow visit happens. In this post we'll build a small Python script that checks the forecast snowfall for several points along a service route and tells you whether the trigger is likely to be hit in the next 24 hours.

We'll use the free National Weather Service API. No API key needed, just a descriptive User-Agent header.

How the NWS API is structured

Getting snowfall data is a two-step process:

  1. Call /points/{lat},{lon} to find which forecast grid cell covers your location.

  2. Fetch that cell's gridpoint data, which includes a snowfallAmount time series.

Each value in snowfallAmount has a validTime like 2026-12-01T06:00:00+00:00/PT6H, meaning "this amount falls over the 6 hours starting at that timestamp." Amounts come back in millimeters.

Step 1: Find the grid for a location

import re
import requests
from datetime import datetime, timedelta, timezone

HEADERS = {
    "User-Agent": "plow-trigger-demo (you@example.com)",
    "Accept": "application/geo+json",
}
MM_PER_INCH = 25.4
TRIGGER_INCHES = 2.0


def get_grid_url(lat: float, lon: float) -> str:
    r = requests.get(
        f"https://api.weather.gov/points/{lat},{lon}",
        headers=HEADERS,
        timeout=10,
    )
    r.raise_for_status()
    return r.json()["properties"]["forecastGridData"]
Enter fullscreen mode Exit fullscreen mode

Step 2: Sum forecast snowfall over the next 24 hours

The tricky part is that forecast periods don't line up neatly with "now." A 6-hour block might have started 2 hours ago. We handle that by counting only the overlapping portion and assuming snow falls evenly across each block (a simplification, but good enough for a dispatch trigger).

def parse_duration_hours(iso_duration: str) -> int:
    # Handles NWS durations like PT6H, P1D, P1DT12H
    m = re.match(r"P(?:(\d+)D)?(?:T(?:(\d+)H)?)?", iso_duration)
    days = int(m.group(1) or 0)
    hours = int(m.group(2) or 0)
    return days * 24 + hours


def snowfall_next_hours(grid_url: str, window_hours: int = 24) -> float:
    r = requests.get(grid_url, headers=HEADERS, timeout=10)
    r.raise_for_status()
    snow = r.json()["properties"]["snowfallAmount"]

    if snow.get("uom") != "wmoUnit:mm":
        raise ValueError(f"Unexpected unit: {snow.get('uom')}")

    now = datetime.now(timezone.utc)
    end = now + timedelta(hours=window_hours)
    total_mm = 0.0

    for v in snow["values"]:
        if not v["value"]:
            continue
        start_str, duration = v["validTime"].split("/")
        start = datetime.fromisoformat(start_str)
        hours = parse_duration_hours(duration)
        if hours == 0:
            continue
        stop = start + timedelta(hours=hours)

        overlap_hours = (min(stop, end) - max(start, now)).total_seconds() / 3600
        if overlap_hours > 0:
            total_mm += v["value"] * (overlap_hours / hours)

    return total_mm / MM_PER_INCH
Enter fullscreen mode Exit fullscreen mode

Step 3: Check active winter alerts

Lake Effect Snow Warnings are a strong signal on their own, so we'll pull active alerts for each point too.

def winter_alerts(lat: float, lon: float) -> list[str]:
    r = requests.get(
        "https://api.weather.gov/alerts/active",
        params={"point": f"{lat},{lon}"},
        headers=HEADERS,
        timeout=10,
    )
    r.raise_for_status()
    events = [f["properties"]["event"] for f in r.json()["features"]]
    return [e for e in events if "Snow" in e or "Winter" in e]
Enter fullscreen mode Exit fullscreen mode

Step 4: Run it across a route

Because lake-effect bands are so localized, checking a single Buffalo coordinate isn't enough. Here we check a few points across a route that covers the city and the Southtowns.

ROUTE = {
    "Downtown Buffalo": (42.8864, -78.8784),
    "South Buffalo": (42.8367, -78.8134),
    "Orchard Park": (42.7676, -78.7439),
}

if __name__ == "__main__":
    for name, (lat, lon) in ROUTE.items():
        grid_url = get_grid_url(lat, lon)
        inches = snowfall_next_hours(grid_url)
        alerts = winter_alerts(lat, lon)
        status = "DISPATCH" if inches >= TRIGGER_INCHES else "hold"

        print(f"{name:18} {inches:5.1f} in  -> {status}")
        for a in alerts:
            print(f"{'':18} ⚠ {a}")
Enter fullscreen mode Exit fullscreen mode

Example output on a lake-effect day might look like:

Downtown Buffalo     0.8 in  -> hold
South Buffalo        3.1 in  -> DISPATCH
Orchard Park         6.4 in  -> DISPATCH
                     ⚠ Lake Effect Snow Warning
Enter fullscreen mode Exit fullscreen mode

That split is exactly why per-point checks matter.

Where to take it next

  • Run it on a schedule with cron or GitHub Actions every 3 hours from November through March.
  • Push notifications to a crew channel via a Slack or Telegram webhook when any point flips to DISPATCH.
  • Cache the grid URL per point, since it rarely changes, to cut API calls in half.
  • Add observed data from nearby stations to confirm what actually fell, not just what was forecast.

Why this matters in the real world

Crews doing snow plowing in Buffalo, NY, like the team at 10x Landscaping & Snow Plowing Service, deal with this every storm: driveways, parking lots, and private roads across Buffalo and Orchard Park can see wildly different totals from the same system. A forecast-driven trigger check won't replace someone looking out the window at 3 a.m., but it does help decide which routes to stage trucks for before the first flake lands.

If you build on this, I'd love to see what you add, especially smarter handling of lake-effect band placement. Drop it in the comments.

Top comments (0)