From d14765c704219f15a3b4902ac9403cfb44fe411a Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Tue, 15 Sep 2026 02:32:34 -0500 Subject: [PATCH] fix(wind): apply current ECMWF source attribution and forecast steps --- DATA_SOURCES.md | 2 +- server/providers/wind/ifs.js | 7 ++++--- src/data/dataCredits.js | 10 ++++++---- src/data/windIfs.test.mjs | 4 ++-- src/layers/wind/index.js | 2 +- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 73bbf4b84..985435152 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -144,7 +144,7 @@ The former bundled 2026-05-25 snapshot was removed 2026-07-16. ### ECMWF IFS wind -Wind also offers ECMWF IFS 10 m forecasts from the keyless [ECMWF Open Data](https://www.ecmwf.int/en/forecasts/datasets/open-data) service. The proxy reads its JSON Lines inventory and fetches only 10u/10v GRIB messages, resampling the forecast to the display grid. Data is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). Attribution: “Contains modified Copernicus/ECMWF IFS forecast data.” Both model issue time and the selected forecast valid time appear in the layer row. These are model forecasts, not observations. +Wind also offers ECMWF IFS 10 m forecasts from the keyless [ECMWF Open Data](https://www.ecmwf.int/en/forecasts/datasets/open-data) service. The proxy reads its JSON Lines inventory and fetches only 10u/10v GRIB messages, resampling the forecast to the display grid. Data is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The [ECMWF Terms of Use](https://apps.ecmwf.int/datasets/licences/general/) also apply. In-app attribution identifies this service as based on ECMWF data and products, links the CC BY 4.0 licence, identifies resampling and animation as modifications, and retains the required ECMWF liability disclaimer. Both model issue time and the selected forecast valid time appear in the layer row. These are model forecasts, not observations. ### NOAA GFS wind diff --git a/server/providers/wind/ifs.js b/server/providers/wind/ifs.js index 433591454..56c2f788b 100644 --- a/server/providers/wind/ifs.js +++ b/server/providers/wind/ifs.js @@ -81,15 +81,16 @@ export function ifsWindRanges(entries) { /** * Round a time offset to the nearest published IFS forecast step. IFS 0.25° - * oper is 3-hourly through f144 (00/12z) or f90 (06/18z). + * oper is 3-hourly through f144, then 6-hourly to f360 for 00/12z. * @param {number} hours - Hours elapsed since the cycle run time. * @param {number} cycleHour - The cycle hour (0, 6, 12 or 18). * @returns {number} A valid forecast step. */ export function nearestIfsStep(hours, cycleHour) { - const max = cycleHour === 6 || cycleHour === 18 ? 90 : 144; + const max = cycleHour === 6 || cycleHour === 18 ? 144 : 360; const value = Math.max(0, Number.isFinite(hours) ? hours : 0); - return Math.max(0, Math.min(max, Math.round(value / 3) * 3)); + const step = value <= 144 ? Math.round(value / 3) * 3 : Math.round(value / 6) * 6; + return Math.max(0, Math.min(max, step)); } /** diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 1b3c22cda..88e13c018 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -200,16 +200,18 @@ export const DATA_CREDITS = [ { key: 'wind-gfs', html: - 'Wind: NOAA Global Forecast System (GFS) 10 m wind via ' + + 'Wind: resampled NOAA Global Forecast System (GFS) 10 m wind via ' + 'NOAA Open Data on AWS ' + '(U.S. public domain; forecast, not observations)', }, { key: 'ecmwf-ifs', html: - 'Contains modified Copernicus/ECMWF IFS forecast data, licensed under CC BY 4.0. ' + - 'Source: https://data.ecmwf.int/forecasts/ ' + - '(ECMWF Open Data)', + 'This service is based on data and products of the European Centre for Medium-Range Weather Forecasts (ECMWF). ' + + 'ECMWF Open Data · ' + + 'CC BY 4.0. ' + + 'Modified: resampled 10 m IFS forecast vectors and animated display. ' + + 'ECMWF does not accept any liability whatsoever for any error or omission in the data, their availability, or for any loss or damage arising from their use.', }, // ── Bundled snapshots ─────────────────────────────────────────── { diff --git a/src/data/windIfs.test.mjs b/src/data/windIfs.test.mjs index 7f98c808d..25c21ba38 100644 --- a/src/data/windIfs.test.mjs +++ b/src/data/windIfs.test.mjs @@ -4,9 +4,9 @@ import { ifsObjectUrls, parseIfsIndex, ifsWindRanges, nearestIfsStep, selectLate import { nearestGfsStep } from '../../server/providers/wind/catalog.js'; test('IFS inventory selects only 10 m components and distinct index suffix', () => { const urls = ifsObjectUrls({ date: '20260915', hour: 6, step: 9 }); - assert.match(urls.index, /09000|9h-oper-fc.index$/); + assert.match(urls.index, /9h-oper-fc\.index$/); assert.equal(urls.index.includes('.grib2.index'), false); assert.deepEqual(ifsWindRanges(parseIfsIndex('{"param":"10u","_offset":20,"_length":10}\n{"param":"10v","_offset":40,"_length":15}')), { u: {start:20,end:29}, v:{start:40,end:54} }); assert.deepEqual(selectLatestIfsCycle(Date.UTC(2026,8,15,4)), { date:'20260914', hour:18 }); - assert.equal(nearestIfsStep(8,6),9); assert.equal(nearestGfsStep(8.2),8); + assert.equal(nearestIfsStep(8,6),9); assert.equal(nearestIfsStep(200,6),144); assert.equal(nearestIfsStep(151,0),150); assert.equal(nearestGfsStep(8.2),8); }); diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index 105190467..ee3925503 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -89,7 +89,7 @@ export function createWindLayer({ feed, cesium = Cesium, container, createRender getRowControls() { const valid = formatWindValidTime(manifest?.cycle?.validIso); const run = formatWindValidTime(manifest?.cycle?.runIso); - return { chips: ['gfs', 'ifs'].map((value) => ({ id: `model-${value}`, label: value.toUpperCase(), active: model === value, params: { model: value }, title: value === 'ifs' ? 'ECMWF IFS 10 m forecast' : 'NOAA GFS 10 m forecast' })), legend: [{ label: '0', color: '#1e3a8a' }, { label: '10', color: '#22d3ee' }, { label: '20', color: '#fbbf24' }, { label: '30+ m/s', color: '#ef4444' }], info: `${model.toUpperCase()} forecast · Valid: ${valid || 'Unavailable'} · Issued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}` }; + return { chips: ['gfs', 'ifs'].map((value) => ({ id: `model-${value}`, label: value.toUpperCase(), active: model === value, params: { model: value }, title: value === 'ifs' ? 'ECMWF IFS 10 m forecast' : 'NOAA GFS 10 m forecast' })), legend: [{ label: '0', color: '#1e3a8a' }, { label: '10', color: '#22d3ee' }, { label: '20', color: '#fbbf24' }, { label: '30+ m/s', color: '#ef4444' }], info: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · Valid: ${valid || 'Unavailable'} · Issued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}` }; }, setRowControlsListener(listener) { rowControlsListener = typeof listener === 'function' ? listener : null; }, destroy() { layer.disable(); rendering?.destroy(); rendering = null; viewer = null; rowControlsListener = null; },