風場
全球地面 10 公尺風場預報,來源是 NOAA GFS 與 ECMWF 開放資料(0.25° 經緯網格)。這是數值模式預報,不是觀測。 提供兩種形式:已上色的風速圖磚(WebP),以及含 u/v 分量的二進位風場(WND1),可在用戶端自行畫粒子動畫。
模式 model |
時間步長 | 預報長度 |
|---|---|---|
gfs |
0–120 小時逐時,之後每 3 小時 | 最長 384 小時 |
ecmwf |
00、12 UTC 初始場:0–144 小時每 3 小時,之後每 6 小時;06、18 UTC 初始場:每 3 小時 | 00、12 UTC 到 240 小時;06、18 UTC 到 90 小時 |
每個有效時間只保留最新一次模式運算(cycle)的結果;有效時間已過去的資料保留約 48 小時。
共通規則(座標、快取、資料來源標示、完整地圖範例)見 圖磚概要。
- 風場(GFS)註明來源・可商用
建議標示資料來源:NOAA GFS、ExpTech Studio(exptech.dev)
- 風場(ECMWF)註明來源・可商用
建議標示資料來源:ECMWF 開放資料(CC BY 4.0)、ExpTech Studio(exptech.dev)
- NOAA GFS:美國政府資料,原則上沒有使用限制;不可暗示 NOAA 推薦或背書你的產品。
- ECMWF 開放資料:ECMWF 開放資料以 CC BY 4.0 授權:須標示 ECMWF 與授權名稱,並說明資料經過處理。
標示位置、範例與商業使用申請見資料授權與來源標示。
目前不需要 API Token。
取得可用時間
Section titled “取得可用時間”modelgfs 或 ecmwf。
curl --compressed "https://api.core-tnn1.exptech.dev/api/v2/tiles/wind/<model>/list"const res = await fetch("https://api.core-tnn1.exptech.dev/api/v2/tiles/wind/<model>/list");if (!res.ok) throw new Error(`HTTP ${res.status}`);const data = await res.json();console.log(data);import requests
res = requests.get( "https://api.core-tnn1.exptech.dev/api/v2/tiles/wind/<model>/list", timeout=10,)res.raise_for_status()print(res.json())成功(沒有資料時為 [ ]);用戶端支援時以 gzip 壓縮
[ { "t": 1794603600, "c": 1794592800 }, { "t": 1794614400, "c": 1794614400 }, { "t": 1794625200, "c": 1794614400 }]model 不是 gfs 或 ecmwftext/html
伺服器錯誤(純文字錯誤訊息)text/plain
試試看
依有效時間由舊到新排列。列表同時包含已過去的時間與未來的預報時間。
| 欄位 | 類型 | 說明 |
|---|---|---|
[].t | integer | 有效時間(這筆風場代表的時刻),Unix 秒。 |
[].c | integer | 產生這筆預報的模式初始時間(cycle),Unix 秒。組圖磚與 WND1 網址時必填。 |
取得風速圖磚
Section titled “取得風速圖磚”https://static.core-tnn1.exptech.dev/api/v2/tiles/wind/{model}/{cycle}/{time}/{z}/{x}/{y}.webp modelgfs 或 ecmwf。
cycle列表中該筆的 c(Unix 秒)。與目前保存的初始時間不符時回 404。
time列表中該筆的 t(Unix 秒)。
z縮放層級,0–11。超出範圍回 1 × 1 透明 GIF。
xXYZ 圖磚欄號。
yXYZ 圖磚列號(由北往南)。
curl -o output.webp "https://static.core-tnn1.exptech.dev/api/v2/tiles/wind/<model>/<cycle>/<time>/<z>/<x>/<y>.webp"const res = await fetch("https://static.core-tnn1.exptech.dev/api/v2/tiles/wind/<model>/<cycle>/<time>/<z>/<x>/<y>.webp");if (!res.ok) throw new Error(`HTTP ${res.status}`);const blob = await res.blob();import requests
res = requests.get( "https://static.core-tnn1.exptech.dev/api/v2/tiles/wind/<model>/<cycle>/<time>/<z>/<x>/<y>.webp", timeout=10,)res.raise_for_status()with open("output.webp", "wb") as f: f.write(res.content)image/webp,256 × 256,全球範圍,半透明(風越強越不透明),有損壓縮;帶 Cache-Control: public, max-age=1800。z 不在 0–11 時改回 1 × 1 透明 GIF(image/gif)binary image/webp
If-None-Match 與 ETag 相符,沒有內容沒有回應本文
cycle 或 time 為 0
bad cycle沒有該時間,或 cycle 已被較新的模式運算取代(application/json;從內容無法分辨是哪一種)
{ "error": "Data not found for specified timestamp"}伺服器錯誤沒有回應本文
試試看
風速 0–32 m/s,超過 32 m/s 以 32 的顏色表示。顏色在節點之間線性內插:
| 風速 (m/s) | 顏色 |
|---|---|
| 0 | #282C6E |
| 2 | #3056A8 |
| 4 | #348CBA |
| 6 | #40B296 |
| 8 | #68C45E |
| 11 | #B0D048 |
| 14 | #E2BE4A |
| 17 | #E28C42 |
| 21 | #D6543C |
| 25 | #BA3478 |
| 28 | #9E3CB4 |
| 32 | #EED6FF |
取得原始風場(WND1)
Section titled “取得原始風場(WND1)”需要自己畫風場粒子動畫或取點位風向風速時,用這組端點下載整個網格的 u/v 分量。
取得可用時間(v1)
Section titled “取得可用時間(v1)”modelgfs 或 ecmwf。
curl --compressed "https://api.core-tnn1.exptech.dev/api/v1/wind/<model>/list"const res = await fetch("https://api.core-tnn1.exptech.dev/api/v1/wind/<model>/list");if (!res.ok) throw new Error(`HTTP ${res.status}`);const data = await res.json();console.log(data);import requests
res = requests.get( "https://api.core-tnn1.exptech.dev/api/v1/wind/<model>/list", timeout=10,)res.raise_for_status()print(res.json())成功(沒有資料時為 [ ]);用戶端支援時以 gzip 壓縮
[ { "t": 1794614400, "c": 1794614400 }, { "t": 1794625200, "c": 1794614400 }]model 不是 gfs 或 ecmwftext/html
伺服器錯誤(純文字錯誤訊息)text/plain
試試看
回應格式與 /api/v2/tiles/wind/{model}/list 完全相同(t、c),兩者可以互換。
modelgfs 或 ecmwf。
cycle列表中該筆的 c(Unix 秒)。與目前保存的初始時間不符時回 404。
time列表中該筆的 t(Unix 秒)。
curl -o output.bin --compressed "https://static.core-tnn1.exptech.dev/api/v1/wind/<model>/<cycle>/<time>.bin"const res = await fetch("https://static.core-tnn1.exptech.dev/api/v1/wind/<model>/<cycle>/<time>.bin");if (!res.ok) throw new Error(`HTTP ${res.status}`);const blob = await res.blob();import requests
res = requests.get( "https://static.core-tnn1.exptech.dev/api/v1/wind/<model>/<cycle>/<time>.bin", timeout=10,)res.raise_for_status()with open("output.bin", "wb") as f: f.write(res.content)application/octet-stream,WND1 格式。帶 Cache-Control: public, max-age=1800;用戶端支援時以 gzip 傳輸(Content-Encoding: gzip),瀏覽器的 fetch 會自動解壓縮binary WND1
If-None-Match 與 ETag 相符,沒有內容沒有回應本文
沒有該時間,或 cycle 已被較新的模式運算取代
{ "error": "Data not found for specified timestamp"}伺服器錯誤(純文字錯誤訊息)text/plain
試試看
WND1 格式
Section titled “WND1 格式”全部 little-endian。固定標頭 67 bytes,接著是模式名稱,再接 u、v 兩個平面。
| 位移(bytes) | 型別 | 欄位 | 說明 |
|---|---|---|---|
| 0 | 4 bytes ASCII | magic | 固定 WND1 |
| 4 | uint16 | version | 目前為 1 |
| 6 | uint16 | width | 網格欄數 W |
| 8 | uint16 | height | 網格列數 H |
| 10 | float64 | lat0 | 第 0 列的緯度(度) |
| 18 | float64 | lon0 | 第 0 欄的經度(度) |
| 26 | float64 | dlat | 每列的緯度步進(度,帶正負號;負值表示由北往南) |
| 34 | float64 | dlon | 每欄的經度步進(度,帶正負號) |
| 42 | float32 | uMin | u 分量量化下限(m/s) |
| 46 | float32 | uMax | u 分量量化上限(m/s) |
| 50 | float32 | vMin | v 分量量化下限(m/s) |
| 54 | float32 | vMax | v 分量量化上限(m/s) |
| 58 | uint64 | time | 有效時間,Unix 秒 |
| 66 | uint8 | nameLen | 模式名稱長度 N |
| 67 | N bytes ASCII | model | gfs 或 ecmwf |
| 67 + N | uint8 × W × H | u | 東西向分量(向東為正),逐列排列 |
| 67 + N + W × H | uint8 × W × H | v | 南北向分量(向北為正),逐列排列 |
- 還原數值:
u = uMin + byte × (uMax − uMin) / 255,v 同理。單位 m/s。上下限是這一份風場自己的最小、最大值,每個檔案都不同。 - 網格位置:第
j列、第i欄的位移是j × W + i,位置是緯度lat0 + j × dlat、經度lon0 + i × dlon。經度會跨越 ±180°,請自行換算。 - 缺值:原始資料的缺值會被量化成
0(也就是下限),無法與真正的最小值區分。整份風場都缺值時,上下限都是 0。 - 網格大小、起點與步進請一律從標頭讀,不要寫死。
在 MapLibre GL JS 加入風速圖層
Section titled “在 MapLibre GL JS 加入風速圖層”取列表中有效時間最接近現在的一筆,用它的 c 與 t 組圖磚網址,並依模式設定來源標示。
const API = "https://api.core-tnn1.exptech.dev";const STATIC = "https://static.core-tnn1.exptech.dev";
async function addWind(map, model = "gfs") { const frames = await (await fetch(`${API}/api/v2/tiles/wind/${model}/list`)).json(); if (frames.length === 0) return null; const now = Date.now() / 1000; let best = frames[0]; for (const f of frames) { if (Math.abs(f.t - now) < Math.abs(best.t - now)) best = f; }
map.addSource("wind", { type: "raster", tiles: [`${STATIC}/api/v2/tiles/wind/${model}/${best.c}/${best.t}/{z}/{x}/{y}.webp`], tileSize: 256, minzoom: 0, maxzoom: 11, attribution: model === "gfs" ? "資料來源:NOAA GFS、ExpTech Studio(exptech.dev)" : "資料來源:ECMWF 開放資料(CC BY 4.0)、ExpTech Studio(exptech.dev)", }); map.addLayer({ id: "wind", type: "raster", source: "wind" }); return best;}解碼 WND1 並查詢點位風場
Section titled “解碼 WND1 並查詢點位風場”依上方 WND1 格式解碼整個網格,再取最近格點的風向與風速。
const STATIC = "https://static.core-tnn1.exptech.dev";
function decodeWnd1(buffer) { const dv = new DataView(buffer); const magic = String.fromCharCode(dv.getUint8(0), dv.getUint8(1), dv.getUint8(2), dv.getUint8(3)); if (magic !== "WND1") throw new Error("not a WND1 file"); const version = dv.getUint16(4, true); if (version !== 1) throw new Error(`unsupported WND1 version ${version}`);
const width = dv.getUint16(6, true); const height = dv.getUint16(8, true); const lat0 = dv.getFloat64(10, true); const lon0 = dv.getFloat64(18, true); const dlat = dv.getFloat64(26, true); const dlon = dv.getFloat64(34, true); const uMin = dv.getFloat32(42, true); const uMax = dv.getFloat32(46, true); const vMin = dv.getFloat32(50, true); const vMax = dv.getFloat32(54, true); const time = Number(dv.getBigUint64(58, true)); const nameLen = dv.getUint8(66); const model = new TextDecoder().decode(new Uint8Array(buffer, 67, nameLen));
const n = width * height; const start = 67 + nameLen; const uBytes = new Uint8Array(buffer, start, n); const vBytes = new Uint8Array(buffer, start + n, n); const u = new Float32Array(n); const v = new Float32Array(n); const uScale = (uMax - uMin) / 255; const vScale = (vMax - vMin) / 255; for (let k = 0; k < n; k++) { u[k] = uMin + uBytes[k] * uScale; v[k] = vMin + vBytes[k] * vScale; } return { model, time, width, height, lat0, lon0, dlat, dlon, u, v };}
// 取最近格點的風(u 向東、v 向北,m/s)function windAt(field, lat, lon) { const j = Math.round((lat - field.lat0) / field.dlat); const wrap = Math.abs(360 / field.dlon); let i = Math.round((lon - field.lon0) / field.dlon); i = ((i % wrap) + wrap) % wrap; if (j < 0 || j >= field.height || i >= field.width) return null; const k = j * field.width + i; const u = field.u[k]; const v = field.v[k]; const speed = Math.hypot(u, v); const from = (Math.atan2(-u, -v) * 180 / Math.PI + 360) % 360; // 風的來向,0° = 北風 return { u, v, speed, from };}
const res = await fetch(`${STATIC}/api/v1/wind/gfs/1794614400/1794625200.bin`);const field = decodeWnd1(await res.arrayBuffer());console.log(windAt(field, 25.03, 121.56));- cycle 會變。 新的模式運算出來後,同一個有效時間會改由新的 cycle 提供,舊 cycle 的圖磚與 WND1 網址回 404(約 10 秒內重試仍是 404)。收到 404 時請重新取得列表,用新的
c組網址。 - 請只使用列表裡的
t與c,兩者都是 Unix 秒。 model不是gfs或ecmwf,或路徑不是數字時回 404。- 同一個網址的內容固定,可以在用戶端長期快取。
- GFS 與 ECMWF 的時間解析度與預報長度不同,不能直接互相替換;比較兩者時請用相同的有效時間。
- 圖上請依使用的模式,照資料授權的建議標示註明來源。
