A containerised stand-in for a deep-learning weather-forecasting model. It reads a zarr store, runs a real (but meteorologically meaningless) convolutional network on the GPU, and writes a zarr store. Everything is configured through environment variables.
It exists so the infrastructure around a real MLWP model — workflow orchestration, storage plumbing, GPU scheduling, error handling — can be built and tested without waiting for the real model. To be useful in that role it is deliberately:
- strict — every expectation about the input is declared in configuration and checked, and each class of failure gets its own exit code;
- honest about the GPU — a real forward pass runs on the device for every output mode, so a broken CUDA setup fails here rather than three weeks later.
-
Variables live on a 2D regular grid, either geographic (lat/lon) or projected (x/y). 1D horizontal coordinates, monotonic, evenly spaced.
-
There is a time axis, strictly increasing and evenly spaced.
-
Variables are
(time, y, x), or(time, level, y, x)when a level coordinate is declared. Dimension order in the store does not matter; it is transposed as needed. -
Every coordinate describes itself with CF attributes. Coordinates are identified with cf-xarray from their attributes alone — never from their names — so a store whose
timevariable carries no CF metadata is rejected (exit 3), however obvious the name looks:Axis Identified by (any one of) time axis: T,standard_name: time, or CF time units (hours since …)y axis: Y,standard_name: latitude/projection_y_coordinate/grid_latitude, orunits: degrees_northx axis: X,standard_name: longitude/projection_x_coordinate/grid_longitude, orunits: degrees_eastlevel axis: Z,positive: up/down, a verticalstandard_name(air_pressure,height, …), or pressure unitsA projected store that also carries 2D latitude/longitude auxiliary coordinates resolves to its projected
x/y, sinceaxis/projection standard names win.
A variable this application does not read has no effect, so N_FORECAST_STEP=8 (no S)
would quietly leave N_FORECAST_STEPS at its default. To catch that, any set variable
that looks meant for this application but is not one it reads — a close misspelling or
wrong-case spelling of a known name, or an unknown SRC_/DST_ name — is logged at
startup as a warning with a suggestion:
WARNING | dummy_mlwp.envvars - Environment variable N_FORECAST_STEP has no effect: this application does not read it. Did you mean N_FORECAST_STEPS?
This is a warning, not an error: a container's environment is mostly not ours —
Kubernetes service links such as INPUT_SERVICE_HOST, NVIDIA_*, boto's own AWS_* —
and none of it should fail a run. Only names are logged, never values.
| Variable | Default | Meaning |
|---|---|---|
INPUT_ZARR |
required | Input store URI — local path, s3://…, gs://… |
OUTPUT_ZARR |
required | Output store URI |
INPUT_VARIABLES |
required | Variables the input must contain |
OUTPUT_VARIABLES |
required | Variables to write |
LEVEL_COORDS |
"" |
Level coordinate declarations |
Variable spec grammar, per comma-separated entry:
name[=standard_name][:units][@levelCoordName]
LEVEL_COORDS=isobaricInhPa:850/500/250,heightAboveGround:10/100
INPUT_VARIABLES=t2m=air_temperature:K,u10=eastward_wind,v10,t:K@isobaricInhPa
OUTPUT_VARIABLES=t2m=air_temperature:K,tp:mm,z:m2s-2@isobaricInhPa,leewaveOn input the spec is an assertion: the variable must exist with exactly those dimensions,
a referenced level coordinate must match LEVEL_COORDS value for value and be
CF-identified as vertical, and any declared standard_name or units must equal the
variable's attribute exactly. Both are compared as plain strings, with no unit parsing or
normalisation: u10:m/s fails against a store that says m s-1, and a variable without
the attribute fails any declared value. Leave either off an input entry to skip its
check. On output the spec is a construction instruction — standard_name and units
are written to the variable's attributes.
Both are optional, because not every field has a CF standard name — a model-specific
scalar such as leewave above is written with neither, and nothing is invented for it.
Its coordinates still carry full CF attributes: every output coordinate (time, y, x,
levels) gets an axis and a standard_name, so a reader can identify them without
knowing our names. Horizontal coordinates and level coordinates read from the input
keep the input's attributes. A level coordinate used only by OUTPUT_VARIABLES has
nothing to copy from, so it must be one whose CF description is built in —
isobaricInhPa, isobaricInPa, heightAboveGround, heightAboveSea,
depthBelowLand, hybrid (following cfgrib) — or the run fails at startup (exit 2).
| Variable | Default | Meaning |
|---|---|---|
N_INPUT_TIMESTEPS |
unset (all) | How many of the input's timesteps to feed the model. Unset: all of them. n > 0: the first n (e.g. 2). n < 0: the last |n| (e.g. -2). Asking for more than the store holds is an error, not a silent truncation. |
N_FORECAST_STEPS |
-1 |
-1: one prediction per input timestep (regression/classification framing); the output time coordinate is the input's. K > 0: K future steps at the input's time resolution, starting one dt after the last input time. |
FORECAST_TIMESTEP |
unset | ISO 8601 duration, e.g. PT6H. Only needed when N_FORECAST_STEPS > 0 and the input has a single timestep, so dt cannot be inferred. |
N_INPUT_TIMESTEPS is applied before anything else looks at the time axis, so the
"last input time" that anchors the forecast is the last selected one:
# Input has 8 timesteps at 6h. Use only the last 2, then forecast 4 steps on from there.
N_INPUT_TIMESTEPS=-2 N_FORECAST_STEPS=4The output always carries forecastReferenceTime (the last input time) and a leadTime
coordinate. With N_FORECAST_STEPS=-1 lead times are zero or negative — the honest
description of a diagnostic evaluated on its own input times.
| Variable | Default | Meaning |
|---|---|---|
TIME_COORD |
auto | Pick the time coordinate when CF attributes identify several |
X_COORD |
auto | Pick the x/longitude coordinate when CF attributes identify several |
Y_COORD |
auto | Pick the y/latitude coordinate when CF attributes identify several |
An override chooses between CF-identified candidates; it does not excuse a coordinate that lacks CF attributes. Naming one that is not identified as that axis is an input error.
| Variable | Default | Meaning |
|---|---|---|
OUTPUT_MODE |
random |
random, persistence, constant, zeros — see below |
RANDOM_SEED |
0 |
Seeds the network weights; runs are reproducible |
CONSTANT_VALUE |
0.0 |
Used by constant mode |
DEVICE |
auto (cuda in the container image) |
auto, cuda, cpu. cuda fails hard if no device is visible |
MODEL_HIDDEN_CHANNELS |
64 |
Network width — how much GPU work happens |
MODEL_LAYERS |
4 |
Network depth |
ZARR_FORMAT |
auto |
auto (match the input), 2, 3 |
LOG_LEVEL |
INFO |
loguru level |
Output modes. The forward pass runs in every mode — the mode only decides what is kept. This is deliberate: the GPU check must not be switchable off by configuration.
| Mode | Output |
|---|---|
random |
The network's output. Seeded weights over real input data, so it is deterministic, GPU-computed, and rescaled to each variable's plausible range (a predicted t2m lands near 273 K, not near 0). |
persistence |
The last input timestep of the matching variable, repeated across the output steps. Output variables with no input counterpart fall back to the network output. |
constant |
Every value is CONSTANT_VALUE. |
zeros |
Every value is 0. |
With N_FORECAST_STEPS = K > 0 the network is rolled forward autoregressively, one
forward pass per step, so wall-clock time scales with the forecast length the way a real
model does — which is the property a scheduler test actually cares about.
| Code | Meaning |
|---|---|
| 0 | Success |
| 2 | Configuration error — a missing or malformed environment variable |
| 3 | Input error — the store does not match the configured expectations |
| 4 | Device error — a GPU was requested but is unusable |
| 5 | Storage error — a store could not be reached, read or written: missing or rejected credentials, access denied, an unreachable endpoint, a read-only destination |
| 1 | Anything unexpected (traceback logged) |
An input store that does not exist — no such path, bucket or key — is an input error
(3), not a storage error: the backend answered, and INPUT_ZARR points at nothing. (S3
answers "access denied" rather than "not found" when the caller may not list the bucket,
so there a missing store is a 5.) On the output side nothing is expected to exist
beforehand, so any failure to write is a 5. A storage error's message names the side, the
URI and the underlying error, and says what to check — for S3, whether that side was
anonymous and which endpoint it used.
Configuration parsing and input validation both collect every problem before failing, so one run of a misconfigured pipeline reports all of them rather than one per debugging cycle.
Logs go to stderr at LOG_LEVEL. With no command line and no config file, the log is
the only record of what a run actually used, so startup records two things at INFO:
- the version, as the very first line — before the configuration is parsed, so even a run that exits 2 says which version failed;
- the effective configuration, as soon as it parses: every setting, defaults included, one line each, labelled with the environment variable that controls it.
INFO | __main__ - Starting dummy-mlwp-model 0.3.1
INFO | __main__ - Effective configuration (defaults included):
INFO | __main__ - INPUT_ZARR = s3://analysis/hres.zarr
INFO | __main__ - INPUT_VARIABLES = t2m,u10,v10,t:K@isobaricInhPa
INFO | __main__ - LEVEL_COORDS = isobaricInhPa:850/500/250
INFO | __main__ - N_INPUT_TIMESTEPS = unset (all)
INFO | __main__ - N_FORECAST_STEPS = 8
...
The storage options are not repeated there; they are logged just before it, with credentials masked (see Remote stores).
uv sync --extra cpu --extra remote --extra dev # on linux/x86_64; see Development
python scripts/make_test_input.py /tmp/in.zarr --kind projected --levels 850 500 250
INPUT_ZARR=/tmp/in.zarr \
OUTPUT_ZARR=/tmp/out.zarr \
LEVEL_COORDS=isobaricInhPa:850/500/250 \
INPUT_VARIABLES=t2m=air_temperature:K,u10,v10,t:K@isobaricInhPa \
OUTPUT_VARIABLES=t2m=air_temperature:K,tp:mm,z:m2s-2@isobaricInhPa \
N_FORECAST_STEPS=8 \
DEVICE=cpu \
python -m dummy_mlwpdocker build -t dummy-mlwp:local .
docker run --rm --gpus all \
-v /tmp:/data \
-e INPUT_ZARR=/data/in.zarr \
-e OUTPUT_ZARR=/data/out.zarr \
-e INPUT_VARIABLES=t2m,u10,v10 \
-e OUTPUT_VARIABLES=t2m:K,tp:mm \
-e N_FORECAST_STEPS=8 \
-e DEVICE=cuda \
-e MODEL_HIDDEN_CHANNELS=256 \
ghcr.io/dmidk/dummy-mlwp-model:latestPrebuilt images are published to ghcr.io/dmidk/dummy-mlwp-model for version tags only
(see Versioning). The examples use :latest for brevity; in pipelines, pin
an exact version such as :0.1.0, so a new release cannot change their behaviour
unexpectedly. :latest is the most recently published final release. Docker does not
update an image it has already downloaded, so with :latest, run docker pull (or
docker run --pull always) to pick up a new release.
To confirm the GPU is genuinely in use, look for the log line reporting the device name
and a non-zero peak GPU memory, and check that raising MODEL_HIDDEN_CHANNELS increases
both.
The image sets DEVICE=cuda, so it requires a GPU by default: started without --gpus all
(or without a working driver) it exits 4 rather than silently falling back to CPU. To run
it on a machine without a GPU, override it with -e DEVICE=cpu, as the CI smoke test
does. Outside the image the default stays auto.
s3:// and gs:// URIs work for both input and output via fsspec; install the extra
that matches your backend (it is already in the container image):
uv pip install "dummy-mlwp-model[remote]"Access is anonymous unless credentials are actually given. Reading a public bucket
is the common case for a test rig, and defaulting to signed requests turns that into a
confusing NoCredentialsError. Naming a profile, supplying keys, or running under an
IAM role (ECS/EKS/EC2) all count as credentials and switch signing back on. The startup
log says which mode each side ended up in, and if a store cannot be reached the run exits
5 with a message that says so too.
So a public source bucket needs no configuration at all:
INPUT_ZARR=s3://public-weather-data/analysis.zarr \
OUTPUT_ZARR=/data/out.zarr \
INPUT_VARIABLES=t2m,u10,v10 OUTPUT_VARIABLES=t2m:K \
python -m dummy_mlwpThe input and output stores are configured independently, so they need not share a
host or an account. Each side reads its own variables, prefixed SRC_ for the input
and DST_ for the output:
| Variable | Meaning |
|---|---|
SRC_AWS_PROFILE / DST_AWS_PROFILE |
Named profile from ~/.aws/config — the main knob |
SRC_AWS_ACCESS_KEY_ID / DST_AWS_ACCESS_KEY_ID |
Explicit key, when a profile is not practical |
SRC_AWS_SECRET_ACCESS_KEY / DST_… |
Matching secret |
SRC_AWS_SESSION_TOKEN / DST_… |
Session token for temporary credentials |
SRC_S3_ANON / DST_S3_ANON |
Force anonymous (true) or signed (false) access |
SRC_S3_ENDPOINT_URL / DST_S3_ENDPOINT_URL |
Endpoint override, when the AWS config is not available |
SRC_STORAGE_OPTIONS / DST_STORAGE_OPTIONS |
JSON object of any other fsspec option |
The unprefixed spellings — AWS_PROFILE, AWS_ACCESS_KEY_ID, S3_ENDPOINT_URL or
AWS's own AWS_ENDPOINT_URL — still apply to both sides, with the prefixed one winning
where both are set. *_STORAGE_OPTIONS is merged last, so it can override any of the
above and reach s3fs or gcsfs options that have no dedicated variable.
For an S3-compatible host that is not AWS, put the endpoint on the profile rather
than in the environment. Modern botocore reads endpoint_url straight from a profile
in ~/.aws/config:
# ~/.aws/config
[profile dmi-minio]
region = dk-east-1
endpoint_url = https://s3.dmi.dk# ~/.aws/credentials
[dmi-minio]
aws_access_key_id = minio-writer
aws_secret_access_key = ...One profile name now carries the host, the region and the credentials together:
AWS_PROFILE=dmi-minio \
INPUT_ZARR=s3://analysis/hres.zarr \
OUTPUT_ZARR=s3://forecasts/dummy-run.zarr \
INPUT_VARIABLES=t2m,u10,v10 OUTPUT_VARIABLES=t2m:K,tp:mm \
N_FORECAST_STEPS=12 \
python -m dummy_mlwpIf your botocore predates per-profile endpoint_url, the equivalent services form
works too:
[services dmi]
s3 =
endpoint_url = https://s3.dmi.dk
[profile dmi-minio]
services = dmi
region = dk-east-1Both stores on the same on-premise MinIO / Ceph / DMI object store, with the endpoint
coming from the profile above. Mount ~/.aws read-only into the container:
docker run --rm --gpus all \
-v ~/.aws:/home/model/.aws:ro \
-e AWS_PROFILE=dmi-minio \
-e INPUT_ZARR=s3://analysis/hres.zarr \
-e OUTPUT_ZARR=s3://forecasts/dummy-run.zarr \
-e INPUT_VARIABLES=t2m,u10,v10 \
-e OUTPUT_VARIABLES=t2m:K,tp:mm \
-e N_FORECAST_STEPS=12 \
-e DEVICE=cuda \
ghcr.io/dmidk/dummy-mlwp-model:latestThe image runs as uid 1000 with home /home/model, which is why the mount goes there —
that is where botocore looks for config and credentials.
Where mounting a config file is not practical — a Kubernetes Job with only a Secret to work with, say — give the endpoint and keys directly instead:
-e S3_ENDPOINT_URL=https://s3.dmi.dk \
-e AWS_ACCESS_KEY_ID=minio-writer \
-e AWS_SECRET_ACCESS_KEY=...This is what SRC_* and DST_* exist for: two profiles, each carrying its own
endpoint and credentials, so a single run reads from one host and writes to another
without the two ever sharing a credential.
# ~/.aws/config
[profile era5-reader]
region = eu-west-1
# no endpoint_url: this one is real AWS
[profile dmi-minio]
region = dk-east-1
endpoint_url = https://s3.dmi.dk# ~/.aws/credentials
[era5-reader]
aws_access_key_id = AKIA...
aws_secret_access_key = ...
[dmi-minio]
aws_access_key_id = minio-writer
aws_secret_access_key = ...docker run --rm --gpus all \
-v ~/.aws:/home/model/.aws:ro \
-e SRC_AWS_PROFILE=era5-reader \
-e INPUT_ZARR=s3://era5-source/analysis.zarr \
-e DST_AWS_PROFILE=dmi-minio \
-e OUTPUT_ZARR=s3://forecasts/dummy-run.zarr \
-e INPUT_VARIABLES=t2m,u10,v10 \
-e OUTPUT_VARIABLES=t2m:K,tp:mm \
-e N_INPUT_TIMESTEPS=-2 \
-e N_FORECAST_STEPS=12 \
-e DEVICE=cuda \
ghcr.io/dmidk/dummy-mlwp-model:latestTwo profile names is the whole configuration: each side resolves its own host, region and credentials from the AWS config, exactly as the AWS CLI would.
A public source paired with an authenticated destination needs even less — anonymous is already the default for the side with no credentials:
-e INPUT_ZARR=s3://public-weather-data/analysis.zarr \
-e DST_AWS_PROFILE=dmi-minio \
-e OUTPUT_ZARR=s3://forecasts/dummy-run.zarrGoogle Cloud Storage works the same way through gs:// URIs, with
GOOGLE_APPLICATION_CREDENTIALS or DST_STORAGE_OPTIONS='{"project":"..."}'.
The resolved options for each side are logged at startup, so you can confirm which
account and host a run used. Any value whose name contains key, secret, token,
password, passwd, passphrase, credential, auth or connection_string is
masked as *** at any depth, including inside client_kwargs, config_kwargs or a
service-account object in *_STORAGE_OPTIONS, as is the password in a URL such as
https://user:pass@proxy. Profile names, regions and endpoint hosts stay visible.
The version is derived from the git tag by hatch-vcs. Tag a release as v1.2.3 and
the wheel, the dummy_mlwp.__version__ attribute, the output store's source attribute,
and the container tag all follow.
Pushing a version tag is also the only thing that publishes an image:
git tag -a v0.1.0 -m "v0.1.0" && git push origin v0.1.0Make every release tag annotated (-a), as the release candidates are. When a final
tag and an RC tag point at the same commit, git describe prefers an annotated tag over
a lightweight one, so a lightweight v0.1.0 would build as the RC's version and the
publish job's version check would stop the release.
| Tag | Image tags |
|---|---|
v0.1.0 |
0.1.0, 0.1, latest |
v0.2.0rc1 (any PEP 440 pre-, post- or dev release) |
0.2.0rc1 only; latest does not move |
A pre-release tag is the way to get a test image onto a GPU host before a release.
Pull requests and pushes to main build and smoke-test the image, but do not push it,
and only when something that goes into the image changed. The publish job checks that
the image's version matches the tag before pushing anything.
uv sync --extra cpu --extra remote --extra dev
pytest
ruff check . && ruff format --check .The test suite is CPU-only and needs no container. To use uv run instead, give it the
same extras (uv run --extra cpu --extra remote --extra dev pytest): without them it
syncs the environment to the lock without the extras, which on Linux replaces the CPU
torch with PyPI's build of the same version and its CUDA libraries.
Dependency versions are pinned in uv.lock, and CI and the container image both install
from it. torch comes as a CPU build (cpu extra, for CI and development) or a CUDA 12.4
build (cu124 extra, for the image), both at the same version; a constraint in
pyproject.toml holds torch to it even with neither extra. One CI job always runs
the container's own Python version with the locked dependencies, so CI tests exactly
what the image ships. After changing a dependency, run uv lock; CI fails if the lock
is out of date.
The lock resolves for every platform, but only linux/x86_64, where CI and the container
run, is tested. Elsewhere uv sync needs a torch wheel for your platform and Python
(there is none for Intel Macs, for example); without one, uv pip install -e ".[dev]"
installs unpinned versions instead.