Project deep dive
How Stormy AI turns raw weather data into a scheduled briefing
Hello, my name is Barry Martin, Lead Data Engineer at Zelis and creator of Stormy AI. Stormy AI is a Python and LangGraph weather agent. It geocodes a place (for now hardcoded as Atco, NJ for this site) through Open-Meteo, executes twelve specialized tools against public NOAA and model archives (no weather API keys), combines observations and numerical guidance in a deterministic diagnostic layer, asks a configurable language model to write the report, and publishes the Markdown and supporting graphics to Amazon S3.
The maps and plots in the briefing are not screenshots copied from NOAA. Stormy AI's deterministic Python tools download numerical grids and radar volumes from public NOAA and model archives, subset the data around the briefing location, calculate derived fields, and render original figures with Matplotlib, Cartopy, MetPy, Py-ART, and xarray. The language model can decide when to call those tools, but it does not draw the graphics.
The narrative is written by the configured language model after the tools return observations, forecasts, model
fields, and deterministic diagnostic summaries. The current deployment uses
zai-org/GLM-5.3-Flash:baseten through Hugging Face Inference Providers and its OpenAI-compatible
endpoint—not the OpenAI API. The provider and model are configuration choices, so the same workflow can also
run against a local Ollama model or another supported Hugging Face model without changing the agent graph.
I built Stormy AI because a useful weather briefing should do more than repeat a single forecast. It should compare current observations with model guidance, look for disagreements, and explain what matters without hiding the meteorology. This page opens that process up: the graph state, tool contracts, diagnostic calculations, publishing code, model choice, and AWS resources that run the project four times per day. Below describes the project in more detail. Let's get into it!
Architecture and infrastructure
A scheduled batch job, not a continuously running service
Stormy AI only needs compute while it is building a briefing. EventBridge Scheduler launches one ECS Fargate
task at midnight, 6 a.m., noon, and 6 p.m. in the America/New_York timezone. Each run uses the
configured default location (Atco, NJ 08004 unless overridden). Fargate pulls the ARM64 container
from ECR, retrieves the Hugging Face token from Secrets Manager, gathers public weather and geocoding data over
an egress-only network path, and exits after the report is written.
stormy_ai/infra from existing AWS dependencies
and the separately deployed Flask consumer. Solid arrows show runtime data flow; dotted arrows show image,
secret, or IAM relationships. Select the diagram to open the full-size version.
ECS Fargate
The task definition uses Linux on ARM64 with 16 vCPU and 120 GiB of memory. That is intentionally larger than a typical API container: GRIB2, NetCDF, radar volumes, xarray datasets, Py-ART, MetPy, and Cartopy can all hold substantial arrays during one run.
IAM and secrets
EventBridge Scheduler assumes a dedicated role that can run the ECS task and pass its runtime roles. The
execution role pulls the ECR image, writes logs, and reads stormy-ai/hf-token; the task role can
list and modify objects under stormy-ai-files. No static AWS credentials are baked into the image.
Networking and logs
The task runs in public subnets with a public IP and a security group that defines outbound traffic only.
CloudWatch receives container logs under /ecs/wx-briefing-agent with fourteen-day retention.
S3 as the handoff
Briefings, radar plots, and GFS charts are durable objects. A tiny root-level latest.txt object
points to the newest Markdown file, decoupling the agent from this Flask website.
Code walkthrough
The LangGraph state machine
The entry point in main.py passes a location to run_briefing(). That function constructs a
strict request requiring every tool, invokes the compiled graph, collects plot metadata from the resulting tool
messages, normalizes image markup, and writes the finished report.
main.py
briefing.run_briefing()
graph.invoke()
ensure_* markdown
write_briefing_markdown()
1. Structured state beside the conversation
WeatherState extends LangGraph's MessagesState. The message list contains the conversation
and tool responses, while five explicit fields hold only the meteorological data needed for deterministic fusion.
This distinction keeps the diagnostic code from scraping values back out of arbitrary prose.
class WeatherState(MessagesState):
mrms: NotRequired[dict | None]
nexrad: NotRequired[dict | None]
hrrr: NotRequired[dict | None]
lightning: NotRequired[dict | None]
diagnosis: NotRequired[dict | None]
2. Four graph nodes and one loop
The graph is small on purpose. reset_weather prevents stale data from crossing runs. The
agent node calls the configured chat model with all tools bound. The tools node wraps
LangGraph's ToolNode and calls gc.collect() after each batch to release large GRIB and
radar allocations. collect_weather copies selected results into structured state before returning
control to the model.
START → reset_weather → agent
│
tool calls present?
yes │ no
▼ └────────────→ END
tools
▼
collect_weather
└───────────────→ agent
There is no separate planner model and no persistent checkpointer. The same model chooses tools and writes the final report. The conditional edge ends only when the last AI message contains no tool calls. The system prompt requires every tool exactly once per briefing, in a sensible order: geocode, alerts, current conditions, MRMS, HRRR, NEXRAD analysis and plot, lightning, skew-T, GFS, forecast, Area Forecast Discussion, then the write-up.
3. Tool messages become typed weather inputs
collect_weather_results() scans only messages since the most recent HumanMessage. It maps
four tool names to state fields, parses JSON-like content with parse_tool_content(), and keeps the newest
result if a tool was called more than once.
WEATHER_TOOL_STATE_MAP = {
"get_mrms_precipitation": "mrms",
"analyze_nexrad_level2": "nexrad",
"get_hrrr_environment": "hrrr",
"get_lightning": "lightning",
}
The radar plotting tool is deliberately absent from this map. A PNG path belongs in the finished report, but it is
not meteorological evidence. When both MRMS and HRRR are present, the collector calls
diagnose_precipitation() and stores its returned dictionary under diagnosis.
4. The diagnosis is injected back into the prompt
On the next pass through call_model(), build_system_prompt() serializes the diagnosis as
JSON inside a <weather_diagnosis> block. The prompt tells the model that this block takes precedence
over its own interpretation of raw fields. If only one of the minimum inputs has arrived, the prompt instead tells
the model to continue gathering data.
Where the important code lives
src/stormy_ai/agent.pyState definition, graph nodes, routing, result collection, diagnosis injectionsrc/stormy_ai/diagnostics.pyDeterministic precipitation, convection, hail-signal, and virga rulessrc/stormy_ai/briefing.pyRun orchestration, image post-processing, scheduling metadata, local and S3 outputsrc/stormy_ai/tools/LangChain tools, Pydantic input schemas, downloads, calculations, and plotssrc/stormy_ai/llm.pyOllama and Hugging Face model factoryinfra/Terraform for ECS, EventBridge Scheduler, IAM, networking, logging, and secretsTool implementation
The twelve tools and what their code actually does
The tools are not thin wrappers around one commercial weather endpoint. They acquire several public datasets, handle their coordinate systems and file formats, perform spatial and thermodynamic calculations, and return either structured dictionaries or official NWS text. Pydantic schemas constrain the arguments the model can send.
geocode_location
Calls Open-Meteo's geocoding API and returns the top match with latitude and longitude. The helper
_search_names() retries without a trailing ZIP code when a string such as
“Atco, NJ 08004” does not resolve on the first attempt. Geocoding first prevents the LLM from inventing
coordinates for every downstream tool.
NWS: current_conditions, get_alerts, get_forecast, forecast_discussion
NwsApi begins with the NWS points endpoint, which supplies the local forecast office, forecast
URLs, and nearby stations. Current conditions try up to five stations until a usable latest observation is
found. Forecast output combines approximately eight 12-hour periods with about 72 hourly rows. Alerts retain
event, severity, times, descriptions, and instructions; the Area Forecast Discussion preserves the local
forecast office's reasoning.
These tools return human-readable text because their source products are already official public communication. The prompt treats alerts and forecasts as authoritative and does not permit radar or model fields to manufacture warning language.
get_mrms_precipitation
Reads the newest Multi-Radar/Multi-Sensor precipitation-rate and composite-reflectivity GRIB2 files from
NOAA's public S3 bucket. get_mrms_data() searches today and yesterday, decompresses the selected
.grib2.gz object into a temporary file, opens it with xarray/cfgrib, and removes the temporary file.
MRMS uses 0–360° longitude, so the module normalizes coordinates before nearest-grid sampling. Haversine distance grids turn a rectangular subset into a true circular analysis radius. The result includes point and area rates, precipitation coverage, reflectivity, and distances to the nearest surface precipitation and meaningful radar echo. In this code, precipitation begins at 0.1 mm/hr and a meaningful echo at 10 dBZ.
get_hrrr_environment
Uses Herbie to select a current HRRR surface file and a pressure-level file from the same cycle. The code samples the nearest curvilinear grid cell and extracts 2 m temperature and dewpoint, surface pressure, precipitation rate, categorical rain/snow/freezing-rain/ice-pellet flags, freezing-level height, CAPE, and CIN.
A 1000–500 hPa profile is assembled from temperature and relative humidity. Helpers detect a warm nose, count freezing-level crossings, determine whether the entire sampled column is below freezing, and collapse the categorical flags into one model precipitation type. HRRR is guidance, so the diagnostic layer uses MRMS to confirm whether precipitation is actually reaching the point.
NEXRAD: analyze_nexrad_level2 and plot_nexrad_level2
The radar module selects the nearest WSR-88D site from Py-ART's station table, downloads the newest Level II volume from the Unidata public S3 archive, and analyzes the lowest sweep. It calculates beam height and field statistics for reflectivity, radial velocity, differential reflectivity, correlation coefficient, PhiDP, and KDP where it can be derived.
strong_echo_diagnostics() isolates gates at or above 50 dBZ and summarizes their dual-pol
values for the conservative hail-signal rules. The companion plotting tool renders a geographic PNG with
Cartopy and uploads it to S3. Only the analysis dictionary enters diagnostic state; the plot is presentation.
get_lightning
Selects GOES-19 East or GOES-18 West according to longitude, finds GLM Level 2 LCFA NetCDF files covering a recent time window, and reads flash centroids near the requested point. Haversine distance and bearing helpers report the nearest activity, while a split-window comparison describes whether flash counts are increasing, decreasing, or steady.
GLM observes total lightning rather than verified ground strikes. The tool therefore supplies electrical context to the convective score; it never claims that lightning struck a particular address.
analyze_current_skewt
Builds a model sounding from HRRR surface fields and isobaric temperature, humidity, height, and wind data extending toward 100 hPa. MetPy derives dewpoint aloft and calculates surface-based and mixed-layer CAPE/CIN, LCL, LFC, EL, precipitable water, DCAPE, 0–1/0–3/0–6 km shear, lapse rates, classic indices, and the freezing level.
The minimum valid-profile check and returned limitations are important: this is an HRRR-derived model sounding, not a nearby radiosonde observation. It provides vertical context without pretending the model profile was measured by a balloon.
get_gfs_guidance
Finds the newest GFS cycle for which the longest requested forecast lead is available, then forces every requested lead to use that same cycle. This prevents a three-day briefing from silently mixing new short-range guidance with an older long-range run.
For F024, F048, and F072, the code extracts point guidance and renders four fixed-domain regional maps: surface pressure/thickness/precipitation/wind, 500 hPa height/vorticity/wind, 850 hPa humidity/wind, and 300 hPa height/jet-level wind. Fixed map extents and thresholds make the days visually comparable. Every image returns a local path, S3 URI, public HTTPS URL, cycle, and valid time.
Deterministic meteorology
Why some conclusions stay outside the language model
Radar reflectivity alone cannot establish surface precipitation type. A model flag alone cannot prove anything is
falling. Stormy AI resolves that problem in diagnostics.py, where explicit functions interpret each
source and combine them into a single auditable dictionary.
Precipitation type
Snow requires an entirely subfreezing sampled column plus the HRRR snow flag. Freezing rain requires a subfreezing surface, a warm layer aloft, and the freezing-rain flag. Conflicting evidence falls back to mixed or unknown instead of forcing certainty.
Convective character
Active GLM lightning adds three score points; reflectivity of at least 45 dBZ adds two (with an extra point at 55 dBZ or above); MRMS rates of at least 25 mm/hr and HRRR CAPE of at least 500 J/kg add supporting points. A score of three marks the environment as convective.
Possible hail signal
The conservative check combines at least 55 dBZ reflectivity, 50+ dBZ gates, low differential reflectivity, and reduced correlation coefficient. Three indicators are needed, and the output is still labeled a possible radar signal rather than a surface hail report.
Possible virga
Nearby radar echoes without MRMS surface precipitation, combined with a modeled surface dewpoint depression of at least 10 °C, produce a possible evaporation clue. The code describes consistency with virga; it does not claim virga was directly observed.
Briefing assembly and publishing
The LLM output is not written directly to disk
The final AI message first passes through deterministic post-processing in briefing.py.
ensure_radar_image_markdown() inserts the public radar image if the model omitted it.
ensure_gfs_guidance_markdown() does the same for all required day-one through day-three charts.
normalize_briefing_images() converts Markdown or bare S3 image references into sized HTTPS
<img> elements.
Final AIMessages3://stormy-ai-files/briefings/<YYYY-MM-DD>/<zip>/<HH_MM>.md (UTC keys)
s3://stormy-ai-files/radar/<YYYY-MM-DD>/<HH_MM>.png
s3://stormy-ai-files/models/gfs/<YYYY-MM-DD>/<type>/<forecast_hour>.png
s3://stormy-ai-files/latest.txt → newest briefing S3 URI
This site's /stormy-ai/ route reads latest.txt, validates the returned S3 URI, fetches that
object, renders the Markdown, and returns a friendly 503 state when S3 is unavailable. This page lives at
/stormy-ai/about/. The agent repository and the website can therefore be deployed independently.
Language-model selection
Choosing the model for a long, tool-heavy weather run
Model selection for Stormy AI is less about finding the model with the most creative prose and more about finding one that can reliably finish a long agent run. A briefing can contain several rounds of tool calls, large NWS text products, structured radar and model results, and metadata for many generated images. The model has to retain that context, call every required tool, preserve units and timestamps, and stop calling tools when it is finally ready to write.
The current cloud selection
The checked-in deployment configuration selects zai-org/GLM-5.3-Flash, routed through the Hugging Face
Inference API to Baseten. This is a pragmatic cloud choice: the ECS task can reach a hosted, tool-capable chat model
without running a second model-serving stack, and the provider is available through the same OpenAI-compatible
interface already used by LangChain. It is the current deployment choice, not a claim that one model will remain
best forever.
| Option | Where it fits | Tradeoff |
|---|---|---|
| GLM-5.3-Flash via Baseten Current cloud configuration |
Scheduled ECS runs that need a remotely accessible model and no persistent inference server. | Requires HF_TOKEN, outbound network access, and an available inference provider. |
| Ollama model Local development option |
Private experimentation, prompt development, and testing without sending requests to a hosted model. | The developer must provide enough local compute and keep the Ollama server running. |
| Another HF provider/model Configuration change |
Comparing cost, latency, context limits, or tool-calling behavior without changing graph code. | Must be rechecked against the full twelve-tool briefing, not only a single chat response. |
Why temperature is zero
The configured temperature is 0 because the desired variability comes from the atmosphere, not from
the wording engine. Lower sampling variability helps keep section order, tool behavior, units, and phrasing more
repeatable between scheduled runs. It does not make the report deterministic—the source data, provider behavior,
and tool-call sequence can still vary—but it removes unnecessary creativity from a factual briefing workflow.
llm:
provider: huggingface
model: zai-org/GLM-5.3-Flash:baseten
temperature: 0
huggingface:
base_url: https://router.huggingface.co/v1
inference_provider: baseten
How the code keeps the choice replaceable
create_chat_model() is a small factory. It returns ChatOllama for local execution or an
OpenAI-compatible ChatOpenAI client for Hugging Face Inference Providers. Environment variables can
override the provider, model, inference provider, Ollama base URL, and temperature. Everything after that factory receives
the same LangChain chat-model contract, so agent.py, the tool schemas, and the graph edges do not know
which backend was selected.
A replacement model should be evaluated with the complete workflow: whether it geocodes first, calls all twelve tools with valid arguments, waits for the diagnostic state, preserves source values, includes the required report sections, and finishes without entering a tool loop. A good standalone answer is not enough. The test that matters is a complete, meteorologically faithful briefing.