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.
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 pushtests.ymluv sync --frozenmake lint + make testmain branch pushdeploy.ymlContinuous 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 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 secret lookup/injectionMakefileShared 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 mainTool 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.
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 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.
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.
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 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.
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
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 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/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.
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.
| 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.