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.

Traditional AWS architecture diagram for Stormy AI showing external weather sources, EventBridge Scheduler, ECS Fargate, ECR, IAM, Secrets Manager, S3, CloudWatch, and the Flask website
The diagram distinguishes resources provisioned by 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 injection
src/stormy_ai/diagnostics.pyDeterministic precipitation, convection, hail-signal, and virga rules
src/stormy_ai/briefing.pyRun orchestration, image post-processing, scheduling metadata, local and S3 output
src/stormy_ai/tools/LangChain tools, Pydantic input schemas, downloads, calculations, and plots
src/stormy_ai/llm.pyOllama and Hugging Face model factory
infra/Terraform for ECS, EventBridge Scheduler, IAM, networking, logging, and secrets

Tool 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.

01

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.

tools/geocode.py · requests · plain-text result
02–05

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.

tools/nws.py · api.weather.gov · shared User-Agent and conversion helpers
06

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.

tools/mrms.py · s3fs · xarray · cfgrib · NumPy
07

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.

tools/hrrr.py · Herbie · xarray · pandas · Pydantic
08–09

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.

tools/radar.py · arm-pyart · Cartopy · Matplotlib · Unidata S3
10

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.

tools/lightning.py · NOAA GOES S3 · h5netcdf · xarray
11

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.

tools/skewt.py · MetPy · HRRR sounding loader · NumPy/pandas
12

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.

tools/models.py · Herbie · xarray · Cartopy · Matplotlib · 1,000+ lines of plotting and data handling

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.

InterpretNormalize MRMS, HRRR, NEXRAD, and GLM
ClassifyPresence, intensity, type, convection
ExplainConfidence, evidence, limitations
InjectAuthoritative JSON for the LLM

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 AIMessage
Normalize and guarantee plots
Add update metadata
Write local Markdown
Upload report + update pointer
s3://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.

Tool callingCorrect names and valid Pydantic arguments across twelve tools
Context handlingRoom for NWS prose, profiles, radar statistics, and GFS metadata
Instruction fidelityUses every source and respects the deterministic diagnosis
Operational fitPredictable latency and availability during scheduled Fargate runs
Output disciplineStable Markdown structure without altered values or invented hazards
ReplaceabilityA model can change without rewriting tools or meteorological rules

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.