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 binds thirteen LangChain tools: an Open-Meteo geocoder and twelve weather tools backed by public NOAA and model archives, with no weather API keys required. The scheduled deployment currently uses Atco, NJ 08004 as its configurable default location. Stormy AI 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, within a prompt that requires every tool, but it does not perform the calculations or 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 checked-in deployment configuration 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, delivery pipeline, and AWS resources configured to run the project fourteen times per day. The sections below describe how those pieces fit together.

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 hourly from 8 a.m. through 8 p.m., plus a 4 a.m. overnight run, 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, forecast-zone maps, radar plots, METAR station-model plots, and GFS charts are durable objects. After a successful report upload, a tiny root-level latest.txt object points to the newest Markdown file, decoupling the agent from this Flask website. Local-only runs skip S3 and keep every artifact on disk.

Continuous integration and delivery

Validate every branch; deploy only from main

Stormy AI uses two push-triggered GitHub Actions workflows with different responsibilities and permissions. .github/workflows/tests.yml validates every branch without a GitHub environment or production secrets. .github/workflows/deploy.yml responds only to pushes on main; its deployment jobs use the prod GitHub environment and are the only jobs that receive AWS credentials.

any branch push
tests.yml
uv sync --frozen
make lint + make test
main branch push
deploy.yml
classify changed paths
image path: ARM64 → ECR
or
infrastructure path: Terraform → AWS

Continuous integration reproduces the local checks

The Test and lint workflow checks out the exact commit, installs uv with dependency caching, installs the project's configured Python version, and recreates the locked environment with uv sync --frozen. It then delegates to make lint and make test. The Makefile is the shared contract: flake8, isort, Black, and pytest run through the same commands locally and in GitHub. The workflow needs only read access to repository contents. It runs for branch pushes; there is no separate pull_request trigger.

Continuous delivery routes only the work that changed

The Deploy workflow first uses dorny/paths-filter to classify the commit. Application, configuration, dependency, test, or container changes start a native ARM64 build and publish wx_briefing_agent:latest to ECR. Docker Buildx reuses a GitHub Actions cache to avoid rebuilding every unchanged layer. Changes under infra/ start the Terraform path, which configures AWS and runs make infra-init, make infra-plan, and make infra-apply. Because the Makefile supports both paths, changing it activates both jobs.

The prod environment scopes deployment secrets, while the deploy-main concurrency group queues production runs instead of cancelling one halfway through an image push or Terraform apply. AWS authentication currently uses access-key secrets. The image is published only as latest, and the Terraform job automatically applies after planning in the same job; it does not save an immutable image tag or a reviewed plan artifact for rollback.

Discover and validate

make help lists the documented targets. make lint checks flake8, isort, and Black; make test runs pytest; and make format applies the repository's formatting rules.

Build and inspect

make build creates the Linux/ARM64 image. make local_run executes a briefing in the container, while make shell and make exec_shell support interactive diagnosis.

Publish the image

make auth logs Docker into ECR, make push publishes the image, and make build_and_push composes the build, authentication, and push steps for a manual release.

Operate AWS

make infra-bootstrap creates the Secrets Manager entries that Terraform looks up. infra-init, infra-plan, and infra-apply manage the Terraform stack. make infra-run-agent starts one ad hoc ECS task and verifies its configured CPU and memory before launch.

Code walkthrough

The LangGraph state machine

The entry point in main.py passes a location to run_briefing(). That function constructs a prompt requesting 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 instructs the model to call every tool once per briefing unless it fails or returns unusable data, in a sensible order: geocode, alerts, current conditions, MRMS, HRRR, NEXRAD analysis and plot, the METAR station-model plot, lightning, skew-T, GFS, forecast, Area Forecast Discussion, then the write-up. The graph does not separately reject a final answer when the model stops early, so completion remains model-driven.

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 secret lookup/injection
MakefileShared local and CI commands for quality checks, containers, Terraform, and manual runs
.github/workflows/tests.ymlLint and pytest validation on every branch push
.github/workflows/deploy.ymlPath-filtered ARM64 image publishing and infrastructure delivery from main

Tool implementation

The thirteen 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 ranks nearby sites from Py-ART's station table and tries up to eight candidates until it finds readable recent Level II data in the Unidata public S3 archive. It analyzes the lowest sweep and 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 when uploads are enabled. Only the analysis dictionary enters diagnostic state; the plot is presentation.

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

plot_metar_observations

Uses the NWS points endpoint to discover nearby METAR/ASOS stations, keeps as many as 35 stations inside a radar-matched regional extent, and fetches their latest observations concurrently. Timestamped observations older than three hours, or observations lacking both usable temperature and dewpoint data, are skipped. Sea-level pressure is preferred, with barometric pressure as a fallback.

MetPy and Matplotlib render classic station models: temperature, dewpoint, coded pressure, sky cover, wind barbs, and station IDs. The PNG is saved locally and, when uploads are enabled, published under the metar/ S3 prefix. This complements the nearest-station text from current_conditions with a view of the surrounding surface pattern.

tools/metar.py · NWS API · MetPy · Cartopy · Matplotlib · concurrent station fetches
11

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
12

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
13

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 each of F024, F048, and F072, the code extracts point guidance and renders four fixed-domain regional maps—twelve images in the standard run: 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. Each image result includes its cycle, valid time, and local path. Successful uploads add an S3 URI and public HTTPS URL; local mode or an upload failure keeps the local path as the Markdown fallback.

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

When MRMS detects no precipitation at the point, the code checks whether the nearest radar echo is closer than the nearest surface-precipitation cell, or whether no surface-precipitation cell exists. Combined with a modeled surface dewpoint depression of at least 10 °C, that produces 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 radar image if the model omitted it. ensure_metar_image_markdown() places the station-model map in Current Weather, preferably immediately after radar. ensure_gfs_guidance_markdown() does the same for all required day-one through day-three charts. normalize_briefing_images() converts Markdown image syntax and normalizes existing <img> tags into consistently sized elements.

Final AIMessage
Normalize and guarantee plots
Add update metadata
Write local Markdown
if enabled: 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/metar/<YYYY-MM-DD>/<HH_MM>.png
s3://stormy-ai-files/forecast_zones/<zone-id>.png
s3://stormy-ai-files/models/gfs/<YYYY-MM-DD>/<type>/<forecast_hour>.png
s3://stormy-ai-files/latest.txt  →  newest briefing S3 URI

The briefing key uses its generation time converted to UTC. Radar and METAR keys use their source observation times, while GFS keys use the model-cycle date.

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 thirteen 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:baseten. The model factory normalizes the provider suffix and sends requests through the Hugging Face router's OpenAI-compatible endpoint. This is a pragmatic cloud choice: the ECS task can reach a hosted, tool-capable chat model without running a separate model-serving stack. It is the current deployment choice, not a claim that one model will remain best forever.

Language model options for Stormy AI
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 thirteen-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 thirteen 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.