Wind
Global 10 m surface-wind forecasts from NOAA GFS and ECMWF open data (a 0.25° latitude-longitude grid). These are numerical-model forecasts, which are separate from observations. Two forms are available: color-scaled wind-speed tiles (WebP), and a binary wind field with u/v components (WND1) so you can draw a particle animation on the client.
Model model |
Time step | Forecast length |
|---|---|---|
gfs |
Hourly from 0–120 hours, then every 3 hours | Up to 384 hours |
ecmwf |
00 and 12 UTC cycles: every 3 hours from 0–144 hours, then every 6 hours. 06 and 18 UTC cycles: every 3 hours | 00 and 12 UTC out to 240 hours; 06 and 18 UTC out to 90 hours |
Each valid time keeps only the latest model run (cycle). Data whose valid time has already passed is kept for about 48 hours.
Shared rules (coordinates, caching, attribution, and full map examples) are in Map tiles.
Data license
Section titled “Data license”- Wind (GFS)Attribution · Commercial use OK
AttributionData: NOAA GFS, ExpTech Studio (exptech.dev)
- Wind (ECMWF)Attribution · Commercial use OK
AttributionData: ECMWF open data (CC BY 4.0), ExpTech Studio (exptech.dev)
- NOAA GFS: U.S. government data, generally free of use restrictions; do not imply NOAA endorses your product.
- ECMWF open data: ECMWF open data is licensed under CC BY 4.0: credit ECMWF, name the licence and indicate that the data was processed.
Where to show it, examples and commercial-use requests: Data licensing and attribution.
Authentication
Section titled “Authentication”No API Token is required.
Available times
Section titled “Available times”modelgfs or 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())Success (an empty array [ ] when there is no data). Gzip-compressed when the client supports it
[ { "t": 1794603600, "c": 1794592800 }, { "t": 1794614400, "c": 1794614400 }, { "t": 1794625200, "c": 1794614400 }]model is not gfs or ecmwftext/html
Server error (plain-text error message)text/plain
Try it
Response fields
Section titled “Response fields”Sorted by valid time, oldest first. The list includes times that have already passed and forecast times in the future.
| Field | Type | Description |
|---|---|---|
[].t | integer | Valid time (the time this wind field represents), in Unix seconds. |
[].c | integer | Model cycle time that produced this forecast, in Unix seconds. Required when you build a tile or WND1 URL. |
Wind-speed tiles
Section titled “Wind-speed tiles”https://static.core-tnn1.exptech.dev/api/v2/tiles/wind/{model}/{cycle}/{time}/{z}/{x}/{y}.webp modelgfs or ecmwf.
cycleThe c of that list entry (Unix seconds). Returns 404 when it does not match the cycle currently kept.
timeThe t of that list entry (Unix seconds).
zZoom level, 0–11. Outside that range the response is a 1 × 1 transparent GIF.
xXYZ tile column.
yXYZ tile row (north to south).
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, global coverage, translucent (stronger wind is more opaque), lossy. Includes Cache-Control: public, max-age=1800. If z is outside 0–11, the response is a 1 × 1 transparent GIF (image/gif)binary image/webp
If-None-Match matches the ETag. No contentNo response body
cycle or time is 0
bad cycleNo data for that time, or the cycle has been replaced by a newer model run (application/json; the body does not say which)
{ "error": "Data not found for specified timestamp"}Server errorNo response body
Try it
Color scale
Section titled “Color scale”Wind speed is 0–32 m/s. Above 32 m/s uses the color for 32. Colors are linearly interpolated between stops:
| Wind speed (m/s) | Color |
|---|---|
| 0 | #282C6E |
| 2 | #3056A8 |
| 4 | #348CBA |
| 6 | #40B296 |
| 8 | #68C45E |
| 11 | #B0D048 |
| 14 | #E2BE4A |
| 17 | #E28C42 |
| 21 | #D6543C |
| 25 | #BA3478 |
| 28 | #9E3CB4 |
| 32 | #EED6FF |
Raw wind field (WND1)
Section titled “Raw wind field (WND1)”When you draw a wind-particle animation yourself, or you need wind direction and speed at a point, use these endpoints to download the u/v components for the whole grid.
Available times (v1)
Section titled “Available times (v1)”modelgfs or 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())Success (an empty array [ ] when there is no data). Gzip-compressed when the client supports it
[ { "t": 1794614400, "c": 1794614400 }, { "t": 1794625200, "c": 1794614400 }]model is not gfs or ecmwftext/html
Server error (plain-text error message)text/plain
Try it
The response format is identical to /api/v2/tiles/wind/{model}/list (t and c). The two lists are interchangeable.
Download the wind field
Section titled “Download the wind field”https://static.core-tnn1.exptech.dev/api/v1/wind/{model}/{cycle}/{time}.bin modelgfs or ecmwf.
cycleThe c of that list entry (Unix seconds). Returns 404 when it does not match the cycle currently kept.
timeThe t of that list entry (Unix seconds).
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 format. Includes Cache-Control: public, max-age=1800. Gzip-compressed in transit when the client supports it (Content-Encoding: gzip). A browser fetch decompresses itbinary WND1
If-None-Match matches the ETag. No contentNo response body
No data for that time, or the cycle has been replaced by a newer model run
{ "error": "Data not found for specified timestamp"}Server error (plain-text error message)text/plain
Try it
WND1 format
Section titled “WND1 format”Everything is little-endian. A fixed 67-byte header, then the model name, then the u and v planes.
| Offset (bytes) | Type | Field | Description |
|---|---|---|---|
| 0 | 4 bytes ASCII | magic | Fixed WND1 |
| 4 | uint16 | version | Currently 1 |
| 6 | uint16 | width | Grid width W |
| 8 | uint16 | height | Grid height H |
| 10 | float64 | lat0 | Latitude of row 0 (degrees) |
| 18 | float64 | lon0 | Longitude of column 0 (degrees) |
| 26 | float64 | dlat | Latitude step per row (degrees, signed; negative means north to south) |
| 34 | float64 | dlon | Longitude step per column (degrees, signed) |
| 42 | float32 | uMin | Quantized lower bound of the u component (m/s) |
| 46 | float32 | uMax | Quantized upper bound of the u component (m/s) |
| 50 | float32 | vMin | Quantized lower bound of the v component (m/s) |
| 54 | float32 | vMax | Quantized upper bound of the v component (m/s) |
| 58 | uint64 | time | Valid time, Unix seconds |
| 66 | uint8 | nameLen | Length N of the model name |
| 67 | N bytes ASCII | model | gfs or ecmwf |
| 67 + N | uint8 × W × H | u | East-west component (positive toward the east), row by row |
| 67 + N + W × H | uint8 × W × H | v | North-south component (positive toward the north), row by row |
- Decode a value:
u = uMin + byte × (uMax − uMin) / 255, and the same for v. The unit is m/s. The bounds are the minimum and maximum of this wind field. They differ in every file. - Grid position: The offset of row
j, columniisj × W + i. The position is latitudelat0 + j × dlatand longitudelon0 + i × dlon. Longitude crosses ±180°. Convert it yourself. - Missing values: Missing values in the source data are quantized to
0(the lower bound), so they cannot be told apart from a real minimum. When the whole field is missing, both bounds are 0. - Always read the grid size, origin, and step from the header. Do not hard-code them.
Advanced examples
Section titled “Advanced examples”Wind-speed layer in MapLibre GL JS
Section titled “Wind-speed layer in MapLibre GL JS”Take the list entry whose valid time is closest to now, build the tile URL from its c and t, and set the attribution for the model.
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" ? "Data: NOAA GFS, ExpTech Studio (exptech.dev)" : "Data: ECMWF open data (CC BY 4.0), ExpTech Studio (exptech.dev)", }); map.addLayer({ id: "wind", type: "raster", source: "wind" }); return best;}Decode WND1 and read the wind at a point
Section titled “Decode WND1 and read the wind at a point”Decode the whole grid with the WND1 format above, then take wind direction and speed at the nearest grid point.
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 };}
// Wind at the nearest grid point (u eastward, v northward, 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; // Direction the wind comes from. 0° is a north wind 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));- The cycle changes. After a new model run, the same valid time is served from the new cycle. Tile and WND1 URLs for the old cycle return 404 (a retry within about 10 seconds is still 404). On a 404, fetch the list again and build the URL with the new
c. - Use only
tandcfrom the list. Both are Unix seconds. - If
modelis notgfsorecmwf, or a path segment is not a number, the response is 404. - The bytes for a given URL are fixed, so cache them on the client for a long time.
- GFS and ECMWF have different time resolution and forecast length, so one cannot stand in for the other. When you compare them, use the same valid time.
- On the map, credit the source for the model you use, with the suggested attribution in Data license.
