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:
Call
/points/{lat},{lon}to find which forecast grid cell covers your location.Fetch that cell's gridpoint data, which includes a
snowfallAmounttime 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"]
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
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]
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}")
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
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)