Temperature Foresight System — Documentation
Introduction
The Temperature Foresight System is a deployed AI service for forecasting ocean temperature at offshore sites, jointly developed by VSG Labs (UMT) and PETRONAS. It serves three sites — Bintulu (D35) and Dulang (Terengganu) in the South China Sea, and Penglai in the Bohai Sea — across short (1 hour), mid (1–3 days), and long (30 days) horizons.
The system is built around a Transformer family of models, trained on HYCOM reanalysis and evaluated on Mercator/CMEMS hindcasts where applicable. Every prediction surface is held up against observed measurements, so users see both the forecast and how much to trust it.
Quick start
I want to forecast at a station
- Open Malaysia or Penglai.
- Pick a depth (model), a horizon, and a mode (latest data or a historical date).
- Click Generate Forecast. The chart, daily envelope, and metrics populate.
I want to test against my own data
- Open Backtest.
- Paste a temperature column (and optionally a date column) into the textareas.
- Click Load Data. Then click any point on the chart to issue a forecast from that timestamp.
- All four depths’ forecasts are compared simultaneously in the metrics table.
I want a live dashboard
- Open Live. Auto-refresh runs every 5 minutes.
- Switch between Bintulu and Dulang via the pill switcher in the page header.
I want to call the API
See API reference below for endpoint definitions and curl examples.
Concepts
Forecast horizons
The system serves three horizon classes:
| Class | Horizons | Use |
|---|---|---|
| Short | 1 h, 24 h | Operational hand-off, hourly planning |
| Mid | 72 h (3 days) | Workover / installation windows |
| Long | 720 h (30 days) | Voyage planning, monthly outlook |
AR vs MF (autoregressive vs multi-step one-shot)
Two complementary architectures are deployed under the hood. AR rolls a single-step prediction forward iteratively, accumulating both signal and error along the rollout. MF emits all 720 future hours in one shot, conditioned on the entire lookback window via the encoder; this avoids error compounding and is preferred for the 30-day horizon.
| AR | MF | |
|---|---|---|
| Method | Single-step, then roll | One-shot multi-step |
| Best for | 1–72 h | 720 h (30 days) |
| Lookback | 72 / 720 h | 1440 h (Malaysia), 240 samples (Penglai 3-hourly) |
| Endpoint | /api/ar/predict | /api/mf/predict |
Lookback window
Every forecast is conditioned on a lookback — the last N hours of observed temperature at the site. The lookback length depends on horizon and on the model variant (see the table above). When you pick “Latest Data” mode, the lookback ends at the most recent timestamp in the database; in “Historical” mode it ends at the date you pick.
Iterative / rolling stride
On Malaysia, Iterative mode reissues the AR forecast every horizon hours and stitches the results, letting you simulate “running the model continuously” over many days, weeks, or years.
On Penglai, Rolling 3-day stride mode is the analog for MF: it issues a 30-day forecast every 3 days and keeps only the first 3 days (24 samples) of each, stitching them. This matches the visualisation used in the Case 3 presentation slides — an “always near-horizon” view of model accuracy.
Sample cadence
Malaysia data is hourly (HYCOM reanalysis). Penglai data is 3-hourly. So a 30-day forecast is 720 samples on Malaysia but only 240 samples on Penglai. The chart code anchors daily statistics consistently regardless of cadence.
The models
Architecture
Encoder–decoder Transformer, one model per (station, depth, horizon) tuple. Single-variable (temperature only). Trained per site on the longest available HYCOM record at that location.
| Site | Depths | Cadence | Train range | Test range | Counts |
|---|---|---|---|---|---|
| Bintulu (D35), South China Sea | 3 m, 30 m, 50 m, bottom (~78 m) | Hourly | 1992–2011 | 2012 (DB) | 4 depths × 4 horizons = 16 models |
| Dulang (Terengganu), South China Sea | 3 m, 30 m, 50 m, bottom (~59 m) | Hourly | 1992–2011 | 2012 (DB) | 4 depths × 4 horizons = 16 models |
| Penglai, Bohai Sea | 4 m, 20 m | 3-hourly | 1995–2012 | 2013–2015 | 2 depths × 1 horizon (30-day MF, Case 3) = 2 models |
Model identifiers
For Malaysia, models are named M1–M4 for the four
depths (Te03m, Te30m, Te50m, TeBt). For Penglai, M1 = Te04m and
M2 = Te20m.
Page manual
Malaysia (/malaysia.php)
- Location: pick Bintulu or Dulang.
- Model: pick a depth (M1–M4).
- Forecast Horizon: 1 h, 24 h, 72 h, or 720 h. The 720 h option routes to the MF transformer; others to AR. Each horizon consumes a fixed input window of past hourly samples: 1 h→72, 24 h→720, 72 h→720, 720 h→1440.
- Prediction Mode: Latest uses the most recent DB timestamp; Historical uses your chosen date.
- Forecasting Mode: Single Horizon (one window) or Iterative (slide forward and stitch).
- Below the chart you get a daily Max/Mean/Min envelope for both Actual and Predicted, an absolute-error chart, and a metrics row.
Penglai (/penglai.php)
- Forecast Mode: Rolling 3-day stride (default; matches the Case 3 presentation) or Single 30-day.
- Model: M1 (Te04m) or M2 (Te20m).
- Date selectors: rolling start + duration (30/60/90 days), or single end-date.
- The same daily envelope + abs-error + metrics panels appear below.
Live (/live.php)
- Auto-refreshing “control room” dashboard for Bintulu / Dulang. 12 cards (4 depths × 3 horizons: 30-day MF, 3-day AR, 24-hour AR).
- Switch between Bintulu and Dulang via the pill in the page sub-header. URL param
?location=Bintuluor?location=Dulangworks too. - Each card shows the forecast curve, plus CURRENT / MIN / MAX summary stats.
Backtest (/backtest.php)
- Paste a temperature column into the right textarea (and optional date column on the left).
- Click Load Data. Then click any point on the chart to predict forward from there.
- All four depth models compare in one chart, with per-model RMSE / MAE / MAPE / Max Err.
- Backtest uses the AR endpoint for short horizons and MF for 720 h.
API reference
Two HTTP endpoints back the UI. Both accept JSON and return JSON. They’re
served via https://ocean-forecast.com and proxied to the model
services. CORS is closed by default; same-origin requests from your own page or
curl from the command line work without keys.
Endpoints
| Method & path | Purpose |
|---|---|
POST/api/ar/predict | AR transformer (DB-driven) — 1 h, 24 h, 72 h horizons (Bintulu / Dulang) |
POST/api/mf/predict | MF transformer (DB-driven) — 720 h horizon (Bintulu / Dulang) |
POST/api/ar/predict-raw | AR transformer with your own input sequence — bring-your-own lookback array |
POST/api/mf/predict-raw | MF transformer with your own input sequence — bring-your-own lookback array |
GET/api/mf/health | Health check (200 / OK) |
GET/api/ar/health | Health check (200 / OK) |
POST/api_penglai.php | Penglai Rolling/Single bridge (loads data/H_Penglai.txt + calls predict-raw) |
Request body (AR / MF /predict)
| Field | Type | Notes |
|---|---|---|
location | string | "Bintulu" or "Dulang" (case-sensitive) |
model_id | string | "M1" … "M4" |
horizon | integer (hours) | AR: 1, 24, 72. MF: 720. |
mode | string | "latest" (uses most recent DB row) or "custom" (uses custom_end_time) |
custom_end_time | ISO 8601 | e.g. "2011-08-08T00:00:00". Required when mode = "custom". |
iterative | boolean | AR only. If true, slide forward and stitch. |
total_hours | integer | Iterative only. Total hours to cover by stitching (e.g. 336 for 14 days). |
Response shape (AR / MF /predict)
{
"model": "M1_72h",
"variable": "Te03m",
"horizon_hours": 72,
"lookback": { "data": [ { "datetime": "...", "value": 29.5 }, ... ] },
"prediction": { "data": [ { "datetime": "...", "actual": 29.6, "predicted": 29.57 }, ... ] },
"metrics": {
"rmse": 0.20,
"mae": 0.16,
"mape": 9.03
}
}
When mode = "latest" there are no future actuals to compare against,
so actual is null and metrics are null.
curl examples
AR — 72-hour forecast at Bintulu, M1 (3 m depth), historical date
curl -sk -X POST https://ocean-forecast.com/api/ar/predict \
-H 'Content-Type: application/json' \
-d '{
"location": "Bintulu",
"model_id": "M1",
"horizon": 72,
"mode": "custom",
"custom_end_time": "2011-08-08T00:00:00"
}' | jq .metrics
AR — iterative 14-day rollout at Dulang, 72-h horizon
curl -sk -X POST https://ocean-forecast.com/api/ar/predict \
-H 'Content-Type: application/json' \
-d '{
"location": "Dulang",
"model_id": "M1",
"horizon": 72,
"mode": "custom",
"custom_end_time": "2011-08-08T00:00:00",
"iterative": true,
"total_hours": 336
}' | jq '.prediction.data | length'
MF — 30-day forecast at Bintulu, M4 (bottom), latest data
curl -sk -X POST https://ocean-forecast.com/api/mf/predict \
-H 'Content-Type: application/json' \
-d '{
"location": "Bintulu",
"model_id": "M4",
"horizon": 720,
"mode": "latest"
}' | jq '{first: .prediction.data[0], last: .prediction.data[-1], metrics}'
Bring-your-own input sequence — /predict-raw
When you have your own observed time series (instead of relying on what we have
in MySQL or the Penglai text file), use the predict-raw variants. They
accept a flat lookback_data array of temperatures plus optional
lookback_timestamps. The model is the same as the DB-driven endpoint;
only the input source differs.
Required lookback length depends on (model, horizon):
| Endpoint | model_id | horizon | lookback length (samples) | cadence |
|---|---|---|---|---|
/api/ar/predict-raw | M1–M4 | 1 | 72 | 1-hourly |
/api/ar/predict-raw | M1–M4 | 24 | 720 | 1-hourly |
/api/ar/predict-raw | M1–M4 | 72 | 720 | 1-hourly |
/api/mf/predict-raw | M1–M4 (Bintulu / Dulang) | 720 | 1440 | 1-hourly |
/api/mf/predict-raw | M1, M2 (Penglai) | 720 | 240 | 3-hourly |
Response is a flat array of predictions plus matching timestamps:
{
"model_id": "M1",
"variable": "Te03m",
"horizon_hours": 720,
"location": "Bintulu",
"lookback_start": "2012-04-28T01:00:00",
"lookback_end": "2012-06-27T00:00:00",
"prediction_start": "2012-06-27T01:00:00",
"predictions": [29.66, 29.65, 29.64, ... 720 values ...],
"prediction_timestamps":["2012-06-27T01:00:00", "2012-06-27T02:00:00", ...]
}
Inline example — AR 1-hour forecast with 72 realistic hourly samples
Copy-paste this whole block into a terminal. The lookback below is 72 inline values approximating three days of diurnal variation around a 29.6 °C Bintulu surface signal. The endpoint returns 1 predicted hour ahead.
curl -sk -X POST https://ocean-forecast.com/api/ar/predict-raw \
-H 'Content-Type: application/json' \
-d @- <<'JSON' | jq '{predictions, prediction_timestamps}'
{
"location": "Bintulu",
"model_id": "M1",
"horizon": 1,
"lookback_data": [
29.4, 29.4, 29.3, 29.3, 29.3, 29.3, 29.3, 29.4,
29.5, 29.6, 29.7, 29.8, 29.9, 30.0, 30.0, 29.9,
29.8, 29.7, 29.6, 29.5, 29.5, 29.4, 29.4, 29.4,
29.4, 29.3, 29.3, 29.3, 29.3, 29.3, 29.3, 29.4,
29.5, 29.6, 29.7, 29.8, 29.9, 29.9, 29.9, 29.8,
29.7, 29.6, 29.6, 29.5, 29.5, 29.4, 29.4, 29.4,
29.4, 29.3, 29.3, 29.3, 29.3, 29.3, 29.4, 29.4,
29.5, 29.6, 29.7, 29.8, 29.9, 30.0, 29.9, 29.9,
29.8, 29.7, 29.6, 29.5, 29.5, 29.4, 29.4, 29.4
]
}
JSON
Expected response (truncated):
{
"predictions": [29.42],
"prediction_timestamps": ["2026-05-02T07:00:00"]
}
Inline example — AR 1-hour forecast with timestamps you control
Same shape, but with explicit lookback_timestamps so the response’s prediction time is anchored exactly where you want.
curl -sk -X POST https://ocean-forecast.com/api/ar/predict-raw \
-H 'Content-Type: application/json' \
-d @- <<'JSON' | jq '{predictions, prediction_timestamps}'
{
"location": "Bintulu",
"model_id": "M1",
"horizon": 1,
"lookback_data": [
29.4, 29.4, 29.3, 29.3, 29.3, 29.3, 29.3, 29.4,
29.5, 29.6, 29.7, 29.8, 29.9, 30.0, 30.0, 29.9,
29.8, 29.7, 29.6, 29.5, 29.5, 29.4, 29.4, 29.4,
29.4, 29.3, 29.3, 29.3, 29.3, 29.3, 29.3, 29.4,
29.5, 29.6, 29.7, 29.8, 29.9, 29.9, 29.9, 29.8,
29.7, 29.6, 29.6, 29.5, 29.5, 29.4, 29.4, 29.4,
29.4, 29.3, 29.3, 29.3, 29.3, 29.3, 29.4, 29.4,
29.5, 29.6, 29.7, 29.8, 29.9, 30.0, 29.9, 29.9,
29.8, 29.7, 29.6, 29.5, 29.5, 29.4, 29.4, 29.4
],
"lookback_timestamps": [
"2011-08-05T00:00:00","2011-08-05T01:00:00","2011-08-05T02:00:00","2011-08-05T03:00:00",
"2011-08-05T04:00:00","2011-08-05T05:00:00","2011-08-05T06:00:00","2011-08-05T07:00:00",
"2011-08-05T08:00:00","2011-08-05T09:00:00","2011-08-05T10:00:00","2011-08-05T11:00:00",
"2011-08-05T12:00:00","2011-08-05T13:00:00","2011-08-05T14:00:00","2011-08-05T15:00:00",
"2011-08-05T16:00:00","2011-08-05T17:00:00","2011-08-05T18:00:00","2011-08-05T19:00:00",
"2011-08-05T20:00:00","2011-08-05T21:00:00","2011-08-05T22:00:00","2011-08-05T23:00:00",
"2011-08-06T00:00:00","2011-08-06T01:00:00","2011-08-06T02:00:00","2011-08-06T03:00:00",
"2011-08-06T04:00:00","2011-08-06T05:00:00","2011-08-06T06:00:00","2011-08-06T07:00:00",
"2011-08-06T08:00:00","2011-08-06T09:00:00","2011-08-06T10:00:00","2011-08-06T11:00:00",
"2011-08-06T12:00:00","2011-08-06T13:00:00","2011-08-06T14:00:00","2011-08-06T15:00:00",
"2011-08-06T16:00:00","2011-08-06T17:00:00","2011-08-06T18:00:00","2011-08-06T19:00:00",
"2011-08-06T20:00:00","2011-08-06T21:00:00","2011-08-06T22:00:00","2011-08-06T23:00:00",
"2011-08-07T00:00:00","2011-08-07T01:00:00","2011-08-07T02:00:00","2011-08-07T03:00:00",
"2011-08-07T04:00:00","2011-08-07T05:00:00","2011-08-07T06:00:00","2011-08-07T07:00:00",
"2011-08-07T08:00:00","2011-08-07T09:00:00","2011-08-07T10:00:00","2011-08-07T11:00:00",
"2011-08-07T12:00:00","2011-08-07T13:00:00","2011-08-07T14:00:00","2011-08-07T15:00:00",
"2011-08-07T16:00:00","2011-08-07T17:00:00","2011-08-07T18:00:00","2011-08-07T19:00:00",
"2011-08-07T20:00:00","2011-08-07T21:00:00","2011-08-07T22:00:00","2011-08-07T23:00:00"
]
}
JSON
MF — 30-day forecast at Bintulu from your own 1440-hour lookback
# Build a 1440-element JSON array of temperatures (any cadence, but model expects hourly).
# Here we just generate a synthetic flat 29.5 °C series for demonstration.
LOOKBACK=$(python3 -c "import json; print(json.dumps([29.5]*1440))")
curl -sk -X POST https://ocean-forecast.com/api/mf/predict-raw \
-H 'Content-Type: application/json' \
-d "$(jq -n \
--arg loc "Bintulu" \
--arg mid "M1" \
--argjson hor 720 \
--argjson lb "$LOOKBACK" \
'{location:$loc, model_id:$mid, horizon:$hor, lookback_data:$lb}')" \
| jq '{first: .predictions[0], last: .predictions[-1], n: (.predictions|length)}'
MF — same call, but with explicit timestamps
# 1440 hourly timestamps ending now, plus 1440 temperatures from your sensor.
python3 - <<'PY' > /tmp/req.json
import json, datetime
end = datetime.datetime(2012, 6, 27)
ts = [(end - datetime.timedelta(hours=1440-i)).isoformat() for i in range(1440)]
val = [29.5] * 1440 # replace with your real temperatures
print(json.dumps({
"location": "Bintulu",
"model_id": "M1",
"horizon": 720,
"lookback_data": val,
"lookback_timestamps": ts
}))
PY
curl -sk -X POST https://ocean-forecast.com/api/mf/predict-raw \
-H 'Content-Type: application/json' \
--data @/tmp/req.json \
| jq '{predictions: .predictions[:5], prediction_timestamps: .prediction_timestamps[:5]}'
AR — 72-hour forecast from your own 720-hour lookback
LOOKBACK=$(python3 -c "import json; print(json.dumps([29.5]*720))")
curl -sk -X POST https://ocean-forecast.com/api/ar/predict-raw \
-H 'Content-Type: application/json' \
-d "$(jq -n \
--arg loc "Bintulu" \
--arg mid "M1" \
--argjson hor 72 \
--argjson lb "$LOOKBACK" \
'{location:$loc, model_id:$mid, horizon:$hor, lookback_data:$lb}')" \
| jq '.predictions | length'
MF — 30-day forecast at Penglai from your own 240-sample (3-hourly) lookback
LOOKBACK=$(python3 -c "import json; print(json.dumps([24.5]*240))")
curl -sk -X POST https://ocean-forecast.com/api/mf/predict-raw \
-H 'Content-Type: application/json' \
-d "$(jq -n \
--arg loc "Penglai" \
--arg mid "M1" \
--argjson hor 720 \
--argjson lb "$LOOKBACK" \
'{location:$loc, model_id:$mid, horizon:$hor, lookback_data:$lb}')" \
| jq '{n: (.predictions|length), first_ts: .prediction_timestamps[0], last_ts: .prediction_timestamps[-1]}'
lookback_timestamps, the server
generates evenly-spaced timestamps ending at “now” at the appropriate cadence
(1 h for Malaysia, 3 h for Penglai). Always pass real timestamps when you
want the response’s prediction_timestamps to be correct.
Penglai — rolling 3-day stride over 30 days, M1 (Te04m)
curl -sk -X POST https://ocean-forecast.com/api_penglai.php \
-H 'Content-Type: application/json' \
-d '{
"model_id": "M1",
"forecast_mode": "rolling",
"rolling_start_date": "2013-02-01T00:00:00",
"rolling_duration_days": 30
}' | jq '{strides: .strides_issued, points: (.prediction.data | length), metrics}'
Penglai — single 30-day forecast, M2 (Te20m), historical end-date
curl -sk -X POST https://ocean-forecast.com/api_penglai.php \
-H 'Content-Type: application/json' \
-d '{
"model_id": "M2",
"forecast_mode": "single",
"mode": "historical",
"custom_end_time": "2014-06-01T00:00:00"
}' | jq '{variable, horizon_hours, metrics}'
Health checks
curl -sk https://ocean-forecast.com/api/ar/health
curl -sk https://ocean-forecast.com/api/mf/health
Metrics glossary
| Symbol | Name | What it tells you |
|---|---|---|
RMSE |
Root mean squared error | Penalises large errors quadratically. Good for “does the model occasionally miss badly?” |
MAE |
Mean absolute error | Average error magnitude. Easy to communicate to non-statisticians (“off by 0.16 °C on average”). |
MAPE |
Mean absolute percentage error | Scales the error by the truth. Useful when comparing across temperature ranges; can mislead near 0 °C. |
MAX ABS ERR |
Maximum absolute error | The single worst miss in the prediction window. Look at this for tail-risk / safety-critical decisions. |
FAQ & limitations
Why does my forecast look biased on the daily envelope chart?
Sub-surface temperature has very small diurnal variability (often < 0.3 °C at 4–30 m). With only 8 samples per day at Penglai, the daily Max–Min envelope is therefore narrow, and a 0.5 °C systematic bias visually “escapes” the band. This is presentational, not a model failure. The chart shows symmetric envelopes for Actual and Predicted side by side so you can see that both bands are narrow; the gap between them is the actual bias.
Can I forecast beyond the training domain?
Operationally yes — the API accepts any custom_end_time —
but performance is only validated within the training/test ranges in the table
above. Out-of-distribution inputs (extreme El Niño years, unusual storms) are
not guaranteed to be calibrated.
Why are some Penglai models a different shape than Malaysia’s?
Penglai uses a newer training pipeline (3-hourly cadence, d_model=256,
240-sample sequences). The deployed inference path introspects each loaded model
and adapts decoder shape and prediction length automatically, so a single backend
serves heterogeneous architectures.
Is there an authenticated API?
Not currently. Endpoints are open-by-IP and rate-limited at the proxy. For programmatic access at volume, contact [email protected] so we can scope access tokens or a private endpoint.
How often is the live dashboard data refreshed?
Live auto-reloads every 5 minutes and re-issues all 12 forecast calls. The underlying data source updates as fast as new HYCOM rows are ingested into the database; in practice that is a once-per-day or once-per-hour cadence depending on the upstream pipeline.