Wave height forecast model - Europlatform (ADC)
Forecasts significant wave height (Hm0) and swell height (HE10) at the Europlatform buoy in the North Sea, every hour, 48 hours ahead. Built by Amsterdam Data Collective for Rijkswaterstaat's AIWaM programme, challenge 31219005, as the PR02 concept model.
Model details
- Architecture: Temporal Fusion Transformer (Lim et al. 2021, https://arxiv.org/abs/1912.09363), with a separate output head for each target.
- Size: 1,255,720 parameters. Hidden size 64, 4 attention heads, 2 LSTM layers. No spatial encoder.
- Output: the 0.1, 0.5 and 0.9 quantiles of Hm0 and HE10 for each of the next 48 hours. The submission file uses the median (0.5).
- Input history: 168 hours ending at the forecast origin.
- Trained on: three buoys together (Europlatform, K13a, A12), sharing one feature scaler. Forecasts are made for Europlatform.
- Checkpoint:
best_poc_he10levers_v2021_s42.ckpt, the best of 17 training epochs (selected at epoch 1). - Developed by: Amsterdam Data Collective.
- Licence: model weights CC BY 4.0.
- Contact: Laurent Smeets, laurent.s@adc-consulting.com
Intended use
A fast stand-in for the SWAN wave model, so the full 51-member ECMWF ensemble can be run within RWS's budget of one hour on one T4 GPU. The quantile output gives uncertainty information for the HMC use cases in PVE01 (swell between 0.5 and 1.5 m, Hm0 between 2 and 3.5 m, 12 to 24 hours ahead).
Out of scope: locations other than Europlatform, lead times beyond 48 hours, and safety decisions without a human forecaster. This is a concept model, not a production system.
Repository layout
| Path | Contents |
|---|---|
src/wave_predict/ | model, feature engineering, data loading and training code |
scripts/ | inference (make_submission.py), scoring, weight export and the package build |
rws/ | RWS's compute_scores.py, unmodified |
checkpoint/ | the trained checkpoint and its feature settings |
data/raw/multi.json | the scaler the model was trained with |
data/processed/features/ | the feature tables the model was trained on |
submission/ | build settings and the source of this model card |
Dockerfile | the inference image |
One command builds the delivery package in outputs/submission_package/. It
adds weights/ (model.safetensors, config.json, the checkpoint and the
scaler), features/, forecast/ and MANIFEST.json, which lists every file
with its SHA-256 hash:
uv run python scripts/build_submission.py --config submission/submission.toml
Inputs
All inputs are data provided by RWS under challenge 31219005 (PVE02.03).
| Group | Features |
|---|---|
| Static (6) | depth, distance to coast, sin/cos of latitude and longitude, from the SWAN bathymetry |
| Known for the next 48h (24) | ERA5 wind u10/v10; D-Hydro water level and currents; wind integrated along the NW, W and SW fetch corridors; TPAR boundary Hm0, Tp, direction (sin/cos) and directional spreading, plus copies lagged 9 h; hour, day of year and month as sin/cos |
| Observed history (12) | Hm0, HE10 and mean wave direction (sin/cos) at K13a, A12 and Europlatform |
Buoy observations are always at least 2 hours old at the forecast origin, which meets the 1-hour rule in PVE03.01. F3 is not used: the delivered data has no Hm0 or HE10 values for it.
The model expects these exact feature settings, which differ from the
defaults in config.py. scripts/make_submission.py sets them itself, and
weights/config.json records them.
INCLUDE_OBS_DIRECTION=true
INCLUDE_BOUNDARY_SPREADING=true
INCLUDE_OBS_PERIOD=false
INCLUDE_OBS_F3=false
How to install
Requires Python 3.12 or later and uv. From this folder:
uv sync
uv.lock pins every dependency, so the environment is the same on every
machine.
How to run inference on the validation dataset
Run from the repository root.
On new data, such as the withheld validation set. Pass the RWS raw files in their delivered formats:
uv run python scripts/make_submission.py \
--checkpoint checkpoint/best_poc_he10levers_v2021_s42.ckpt \
--scaler data/raw/multi.json \
--wind "<wind folder>/*.nc" \
--water-level "<water level folder>/*.nc" \
--currents "<currents folder>/*.nc" \
--tpar "<boundary folder>/*.tpar" \
--bathymetry <path>/swan-ns-j22_6-v1a_adjust.bot \
--observations <dir>/europlatform_Hm0.csv \
<dir>/europlatform_HTE3.csv \
<dir>/europlatform_Th0.csv \
<dir>/k13a_Hm0.csv <dir>/k13a_HTE3.csv <dir>/k13a_Th0.csv \
<dir>/a12_Hm0.csv <dir>/a12_HTE3.csv <dir>/a12_Th0.csv \
--start <first origin> --end <last origin, exclusive> \
--model-name ADC
The files must cover the forecast period plus 7 days before (input history) and 2 days after (forecast horizon). The script checks this and stops with a clear message if anything is missing.
To reproduce the forecast in this package, from the shipped feature table:
uv run python scripts/make_submission.py \
--checkpoint checkpoint/best_poc_he10levers_v2021_s42.ckpt \
--scaler data/raw/multi.json \
--feature-table data/processed/features/europlatform.parquet \
--start 2021-01-01 --end 2022-01-01 \
--model-name ADC
In both cases --scaler must be data/raw/multi.json, the scaler the model
was trained with. Any other scaler gives wrong forecasts without an error.
In Docker. The image runs the raw-data command above as a non-root user.
Mount the RWS data folders (bc/, cur/, wind/, wl/, observations/)
at /input; the forecast is written to /output:
docker build -t wave-predict .
docker run --rm --gpus all -v <data>:/input:ro -v <output>:/output \
-e START=<first origin> -e END=<last origin, exclusive> wave-predict
Output: forecast/ADC_europlatform_2021.nc, NetCDF4, CF-1.8. One forecast per hour of the
period (8,760 origins, 2021-01-01 00:00:00 to 2021-12-31 23:00:00) at 12, 24 and 48
hours ahead. Variables Hm0 and HE10 (the names compute_scores.py reads)
and hm0 and he10 (the names in PVE03.06), with standard names
sea_surface_wave_significant_height and
sea_surface_swell_wave_significant_height, in metres. No missing values.
If a wind file has an ensemble member dimension, every member is run and
the members' medians are averaged.
Loading the weights without the checkpoint
weights/model.safetensors holds only the weight tensors, so loading it
cannot run code (unlike the pickle-based .ckpt). weights/config.json
holds the architecture and feature settings. To rebuild the model:
from scripts.export_weights import load_safetensors_model
model = load_safetensors_model("weights")
It gives exactly the same output as the checkpoint; this was verified when the weights were exported.
How to train
Training data
data/processed/features/ holds the feature tables the model was trained on: a12.parquet, europlatform.parquet, k13a.parquet. ADC computed them from the RWS raw data delivered for this challenge. Each covers 2006-12-30 to 2022-12-31, hourly. Training reads them from there, so it needs no raw data.
To rebuild the tables from the RWS raw data instead, put the raw files under
data/raw/ in their delivered folders (wind/, wl/, cur/, bc/, bot/,
Observaties_locaties_MVO/) and run, once per buoy:
INCLUDE_OBS_DIRECTION=true INCLUDE_BOUNDARY_SPREADING=true \
INCLUDE_OBS_PERIOD=false INCLUDE_OBS_F3=false \
BOUNDARY_LAG_MODE=fixed BOUNDARY_PROPAGATION_LAG_H=9 \
uv run python -m wave_predict.features.build_features --scope local \
--location-id europlatform \
--bathymetry-path data/raw/bot/swan-ns-j22_6-v1a_adjust.bot
Repeat with --location-id k13a and --location-id a12. Each table is
written to data/processed/features/<buoy>.parquet. Use the corrected
observation files RWS delivered on 2026-09-16: earlier files give different
tables.
Training command
With the three tables in data/processed/features/, run from the repository
root:
INCLUDE_OBS_DIRECTION=true INCLUDE_BOUNDARY_SPREADING=true \
INCLUDE_OBS_PERIOD=false INCLUDE_OBS_F3=false \
RANDOM_SEED=42 \
uv run python -m wave_predict.training.train \
--location-id europlatform \
--locations europlatform,k13a,a12 \
--separate-heads \
--encoder-steps 168 \
--val-year 2021 --test-year 2022 \
--lr 3e-4 --dropout 0.2 \
--he10-storm-weight 6.0 --he10-oversample-factor 3.0 \
--checkpoint-monitor val_rmse_peak_he10_swellmask \
--early-stop-monitor val_rmse_peak_he10_swellmask \
--run-name E5_he10levers_final
The four INCLUDE_* variables decide which input columns the model gets.
Other values produce a model with different inputs, which the inference code
in this package will not load.
The best checkpoint is saved as
checkpoints/europlatform/best_E5_he10levers_final.ckpt; best_poc_he10levers_v2021_s42.ckpt
is that file, renamed. The fitted scaler is saved to
data/processed/scalers/multi.json. Everything stays on the local disk.
| Setting | Value |
|---|---|
| Random seed | 42 |
| Buoys | Europlatform, K13a and A12, trained together with one shared scaler (data/raw/multi.json) |
| Years | training: 2007-2022 except 2021 and 2022; validation: 2021; test: 2022 (unused, the buoy data ends 2021-12-31) |
| Training windows | 106,014 (Europlatform), 94,844 (K13a), 62,404 (A12) |
| Model | hidden size 64, 4 attention heads, 2 LSTM layers, one output head per target, dropout 0.2 |
| Loss | quantile loss; storm hours (Hm0 > 2 m or HE10 > 0.5 m) weighted 3x, HE10 storm hours 6x |
| Sampling | windows with an HE10 swell peak drawn 3x as often |
| Optimiser | Adam, learning rate 0.0003, weight decay 0.0001, batch size 32; learning rate halved after 7 epochs without improvement |
| Epochs | at most 100; early stopping on HE10 peak error ended training after 17 epochs; the checkpoint is from epoch 1 |
Evaluation
RWS scores the forecast with its own compute_scores.py. Before delivery,
the forecast in this package was scored with that script, unmodified, to
confirm that RWS can read and score the file.
Hardware, runtime and energy
Inference. One forecast with 51 ensemble members, on an NVIDIA T4 (16 GB) with 4 vCPUs, and the process capped at 12 GB of RAM with no swap:
| Measure | Result | Requirement |
|---|---|---|
| Run time, 51 members | 4 min 1 s | under 1 hour (PVE04.01) |
| Peak GPU memory | 443 MiB | at most 16 GB (PVE03.04) |
| Peak CPU memory | 2.12 GB | at most 12 GB (PVE03.04) |
The delivered ERA5 wind has no ensemble members, so the 51 members were the same wind field repeated. The numbers measure speed and memory, not a real ECMWF-EPS ensemble, which would need more CPU time to prepare its inputs. Most of the run time is input preparation, not the GPU.
On cpu, 1 member for 8,760 forecasts took 57 s end to end, including loading the data.
Training. One NVIDIA L40S (46 GB): 6,523 s (1 h 49 min) for 17 epochs, peak GPU memory 755 MB.
Energy. Board power at full load times run time, so an upper bound:
| Phase | Hardware | Power | Time | Energy |
|---|---|---|---|---|
| Training | 1x L40S | 350 W | 6,523 s | 0.63 kWh |
| Inference, one 51-member forecast | 1x T4 | 70 W | 241 s | 0.005 kWh |
Limitations
- Swell peaks are the weak point. HE10 errors during sustained swell events are well above SWAN's, and on our own checks the HE10 scores fall below the 70% floor. Hm0 performs close to SWAN. The pre-production phase targets this directly.
- One seed. Trained with seed 42 only. HE10 peak results vary between seeds by about as much as this model improves on the previous version, so the improvement is not yet confirmed.
- No untouched test year. The buoy data ends 2021-12-31, so 2022 cannot be scored. 2021 was used to select the checkpoint and is also the year reported here.
- Incomplete buoy history. In this run, 27% of hours lacked a complete buoy history, mostly because of gaps in A12. For those hours the model forecasts from the forcing alone, as it was trained to.
- Early checkpoint. The saved checkpoint is from epoch 1 of 17; validation error rose after that.
- Wind source. Trained on ERA5 reanalysis wind. Operational use would feed ECMWF-EPS or Harmonie KEPS, which may differ in resolution and bias. This is not corrected for.
Reproducibility
- Python 3.12, dependencies pinned in
uv.lock. - Random seed 42 for all random operations, with deterministic PyTorch
algorithms (
wave_predict.seed_everything()). - Checkpoint SHA-256:
052dfd7b9813040bdb22187ecd4282ec77aa2a2a52764365a65052c1a241091a - Weights (
model.safetensors) SHA-256:d2c9354986aac839c53f3c51ddc33e21a82345e8522bf8caa0d61bf19b3982db - Scaler (
multi.json) SHA-256:0a675a010d8336b2126006597b5975405208031f1a550fc322b9ee62f13d8599 forecast/*.run.jsonrecords the hash of every input file used.- Repeat training (PVE03.03): training this configuration a second time gave an identical checkpoint. All 563 weight tensors, every epoch's metrics and every evaluation result matched. The requirement allows 5%.
MLOps
How each stage of the model's life is handled (PVE02.05). Stages 1 to 4 are in place for this concept model; stages 5 and 6 are planned for pre-production.
| Stage | How |
|---|---|
| 1. Build and train | Code in git, dependencies pinned in uv.lock, seed 42 with deterministic algorithms. One documented command rebuilds the feature tables and one trains the model. Training runs are logged with MLflow. |
| 2. Validation | The checkpoint is selected on the 2021 validation year, which is excluded from training. The forecast is scored with RWS's compute_scores.py, unmodified, before delivery. |
| 3. Acceptance | Every build checks that the model's feature settings match the checkpoint, that the forecast covers every hourly origin with no missing or implausible values, and that the exported weights give exactly the checkpoint's output. MANIFEST.json records the SHA-256 of every delivered file. An automated test suite covers these checks. |
| 4. Production | A Docker image runs inference as a non-root user, reading the RWS data from /input and writing the forecast to /output. Each run records the hash of every input file it used. |
| 5. Monitoring (planned) | Score the operational forecasts against incoming buoy observations each month with the same scorer, per location and per lead time. |
| 6. Retraining (planned) | Retrain with the same command when a new year of data arrives or the monthly scores fall below the acceptance floor. The new model goes through stages 2 and 3 before it replaces the old one. |