Skip to content
Console

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.

Upstream terms
  • 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.

No API Token is required.

GEThttps://api.core-tnn1.exptech.dev/api/v2/tiles/wind/{model}/list
ServerTNN-1
Path parameters
  • modelstringRequired

    gfs or ecmwf.

Request
curl --compressed "https://api.core-tnn1.exptech.dev/api/v2/tiles/wind/<model>/list"
Response

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
Path parameters

The token is sent from your browser directly to the API.

Sorted by valid time, oldest first. The list includes times that have already passed and forecast times in the future.

FieldTypeDescription
[].tintegerValid time (the time this wind field represents), in Unix seconds.
[].cintegerModel cycle time that produced this forecast, in Unix seconds. Required when you build a tile or WND1 URL.
GEThttps://static.core-tnn1.exptech.dev/api/v2/tiles/wind/{model}/{cycle}/{time}/{z}/{x}/{y}.webp
ServerTNN-1
Path parameters
  • modelstringRequired

    gfs or ecmwf.

  • cycleintegerRequired

    The c of that list entry (Unix seconds). Returns 404 when it does not match the cycle currently kept.

  • timeintegerRequired

    The t of that list entry (Unix seconds).

  • zintegerRequired

    Zoom level, 0–11. Outside that range the response is a 1 × 1 transparent GIF.

  • xintegerRequired

    XYZ tile column.

  • yintegerRequired

    XYZ tile row (north to south).

Request
curl -o output.webp "https://static.core-tnn1.exptech.dev/api/v2/tiles/wind/<model>/<cycle>/<time>/<z>/<x>/<y>.webp"
Response

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 cycle

No 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
Path parameters

The token is sent from your browser directly to the API.

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

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.

GEThttps://api.core-tnn1.exptech.dev/api/v1/wind/{model}/list
ServerTNN-1
Path parameters
  • modelstringRequired

    gfs or ecmwf.

Request
curl --compressed "https://api.core-tnn1.exptech.dev/api/v1/wind/<model>/list"
Response

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
Path parameters

The token is sent from your browser directly to the API.

The response format is identical to /api/v2/tiles/wind/{model}/list (t and c). The two lists are interchangeable.

GEThttps://static.core-tnn1.exptech.dev/api/v1/wind/{model}/{cycle}/{time}.bin
ServerTNN-1
Path parameters
  • modelstringRequired

    gfs or ecmwf.

  • cycleintegerRequired

    The c of that list entry (Unix seconds). Returns 404 when it does not match the cycle currently kept.

  • timeintegerRequired

    The t of that list entry (Unix seconds).

Request
curl -o output.bin --compressed "https://static.core-tnn1.exptech.dev/api/v1/wind/<model>/<cycle>/<time>.bin"
Response

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
Path parameters

The token is sent from your browser directly to the API.

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, column i is j × W + i. The position is latitude lat0 + j × dlat and longitude lon0 + 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.

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 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 t and c from the list. Both are Unix seconds.
  • If model is not gfs or ecmwf, 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.