Documentation · concepts, manual, models, API

Temperature Foresight System — Documentation

Concepts, user manual, model overview, and HTTP API reference.
Last updated 2026-05-02. Direct comments to [email protected].

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.

Audience. This system is built for ocean engineers, planners, and decision-makers who need site-specific temperature forecasts on demand — not climate-scale projections. Forecasts are at fixed depths, single-variable (temperature), and bounded by the training domain.

Quick start

I want to forecast at a station

  1. Open Malaysia or Penglai.
  2. Pick a depth (model), a horizon, and a mode (latest data or a historical date).
  3. Click Generate Forecast. The chart, daily envelope, and metrics populate.

I want to test against my own data

  1. Open Backtest.
  2. Paste a temperature column (and optionally a date column) into the textareas.
  3. Click Load Data. Then click any point on the chart to issue a forecast from that timestamp.
  4. All four depths’ forecasts are compared simultaneously in the metrics table.

I want a live dashboard

  1. Open Live. Auto-refresh runs every 5 minutes.
  2. 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:

ClassHorizonsUse
Short1 h, 24 hOperational hand-off, hourly planning
Mid72 h (3 days)Workover / installation windows
Long720 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.

ARMF
MethodSingle-step, then rollOne-shot multi-step
Best for1–72 h720 h (30 days)
Lookback72 / 720 h1440 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.

SiteDepthsCadenceTrain rangeTest rangeCounts
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 M1M4 for the four depths (Te03m, Te30m, Te50m, TeBt). For Penglai, M1 = Te04m and M2 = Te20m.

Page manual

Malaysia (/malaysia.php)

Penglai (/penglai.php)

Live (/live.php)

Backtest (/backtest.php)

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 & pathPurpose
POST/api/ar/predictAR transformer (DB-driven) — 1 h, 24 h, 72 h horizons (Bintulu / Dulang)
POST/api/mf/predictMF transformer (DB-driven) — 720 h horizon (Bintulu / Dulang)
POST/api/ar/predict-rawAR transformer with your own input sequence — bring-your-own lookback array
POST/api/mf/predict-rawMF transformer with your own input sequence — bring-your-own lookback array
GET/api/mf/healthHealth check (200 / OK)
GET/api/ar/healthHealth check (200 / OK)
POST/api_penglai.phpPenglai Rolling/Single bridge (loads data/H_Penglai.txt + calls predict-raw)

Request body (AR / MF /predict)

FieldTypeNotes
locationstring"Bintulu" or "Dulang" (case-sensitive)
model_idstring"M1""M4"
horizoninteger (hours)AR: 1, 24, 72. MF: 720.
modestring"latest" (uses most recent DB row) or "custom" (uses custom_end_time)
custom_end_timeISO 8601e.g. "2011-08-08T00:00:00". Required when mode = "custom".
iterativebooleanAR only. If true, slide forward and stitch.
total_hoursintegerIterative 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):

Endpointmodel_idhorizonlookback length (samples)cadence
/api/ar/predict-rawM1–M41721-hourly
/api/ar/predict-rawM1–M4247201-hourly
/api/ar/predict-rawM1–M4727201-hourly
/api/mf/predict-rawM1–M4 (Bintulu / Dulang)72014401-hourly
/api/mf/predict-rawM1, M2 (Penglai)7202403-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]}'
Tip. If you omit 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
Note. The endpoints sit behind nginx with proxy timeouts of 180 s (AR) and 120 s (MF). Iterative AR rollouts of 1–2 years can approach the AR timeout; if you need longer rollouts, batch the calls or run them from the same VPC.

Metrics glossary

SymbolNameWhat 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.

Source. Frontend, training pipelines, and deployment notes are maintained by the VSG Labs / PETRONAS team. For access, integration, or research collaboration, contact [email protected].