Architecture
How the pieces fit together, for anyone modifying the system.
Overview
Web portal (Cloudflare Pages) Python package (PyPI)
weather-data-gui.pages.dev TeamOverbyeWeather
| |
+----------------+-----------------+
|
FastAPI backend (Railway)
weather-data-gui.up.railway.app
|
Google Drive (service account)
Both front ends are thin. All catalog logic, cropping, and Drive access live in the backend; the browser and the Python package only make HTTP requests.
Repository layout
frontend/ static site — index.html, main.js, styles.css
backend/
main.py FastAPI app, endpoints, concurrency gates
catalog.py Drive scanning, filename matching, 30-min cache
download.py file lookup, ZIP bundling, fetch_and_crop()
pww_io.py PWW read / crop / write
regions.py state bounding boxes and ISO zones
status.py pipeline health
package/TeamOverbyeWeather/
client.py WeatherClient.download() and discovery
registry.py (source, type) -> API key mapping
localcrop.py client-side crop fallback
pww_io.py copy of the backend module
errors.py exception types
sources/ back-compatible per-source wrappers
docs/ this documentation
Three places that must agree
Adding or renaming a data source means touching all three:
Location |
What it defines |
|---|---|
|
API key → catalog key and list key |
|
user-facing source/type → API key |
|
the portal’s picker |
They are kept deliberately parallel. If a source works in the portal but not the package, this mismatch is the first thing to check.
package/TeamOverbyeWeather/pww_io.py is a copy of backend/pww_io.py.
Change one, copy to the other — a divergence here produces crops that differ
between server and client.
The crop pipeline
fetch_and_crop() in backend/download.py is the core:
Resolve
(source, date_key)to a Drive file idStream the file to
/tmp— never fully into memoryDetect the shape by content, not by source name
Crop each part to the bounding box, then stitch along time
Apply the time crop if requested
Write PWW bytes and return them
Step 3 matters. zipfile.is_zipfile() decides whether to unzip, so a source that
changes format does not need a code change. Four on-disk shapes are handled: a
bare .pww, a zip with one .pww, a zip with four quarter .pww files, and a
zip of daily zips.
Cropping each member before concatenating keeps peak memory at one cropped member instead of a whole month.
package/TeamOverbyeWeather/localcrop.py mirrors this for the client-side
fallback, reading from disk rather than Drive.
Concurrency and load shedding
Heavy endpoints sit behind _Gate(concurrency, max_wait) in main.py. Each gate
runs a few requests concurrently, queues the rest, and returns 503 with
Retry-After once too many are waiting — shedding load rather than accepting
work that would exhaust memory.
Gate |
Covers |
Environment variable |
Default |
|---|---|---|---|
|
single-file streams |
|
6 |
|
region crops |
|
2 |
|
multi-file ZIP builds |
|
2 |
all |
queue depth before 503 |
|
50 |
All are tunable on Railway without a code change. Region crops are the heaviest
path — lower REGION_CONCURRENCY first if memory is tight.
For streamed responses the slot is taken before the response is returned and
released in the generator’s finally, so the limit holds for the whole transfer
rather than just the headers.
Why cropping is server-side
Client-side cropping always requires transferring the whole file first. Measured:
Request |
Transferred |
|---|---|
NOAA cycle, uncropped |
120 MB |
Cropped to Texas |
7.3 MB |
Texas plus a 6-hour window |
240 KB |
HRRR 15-minute day, uncropped |
534 MB |
The server is the only place that can avoid moving the large number. The gates above exist so it can do that safely; the 413 fallback exists for the one case where it genuinely cannot.
Deployment
Frontend — Cloudflare Pages, auto-deploys from main. Output directory
frontend, no build step.
Backend — Railway, auto-deploys from main, roughly 90 seconds. The service
root directory must be backend, since the Dockerfile lives there. Required
environment variables are GDRIVE_CREDENTIALS_JSON_CONTENT (the whole
service-account JSON) and CORS_ORIGINS.
After deploying, verify:
curl <url>/api/health
curl <url>/api/debug/folders
curl <url>/api/catalog/refresh
Do not set GDRIVE_*_FOLDER_ID variables — several are stale, so folder ids are
hard-coded in catalog.py.
Package — built from package/ and published to PyPI.
Documentation — this site, built by Read the Docs from docs/.