From 58c8a481edc4650b2b9f85cdfe7ff6549f5c4d7c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 19:45:16 +0400 Subject: [PATCH] Report an empty wind field as no reading instead of a NaN speed --- src/layers/wind/edgeCases.test.mjs | 104 +++++++++++++++++++++++++++++ src/layers/wind/inspection.js | 25 ++++--- 2 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 src/layers/wind/edgeCases.test.mjs diff --git a/src/layers/wind/edgeCases.test.mjs b/src/layers/wind/edgeCases.test.mjs new file mode 100644 index 000000000..424345466 --- /dev/null +++ b/src/layers/wind/edgeCases.test.mjs @@ -0,0 +1,104 @@ +// Edge cases from docs/migration/planet-separation/layers.md (wind): on a fixed +// grid direction, speed and legend agree whichever way the centre is picked, +// and an empty field is never presented as calm. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { inspectWindAtCenter } from './inspection.js'; +import { windReadingResult } from './presentation.js'; +import { sampleWind, windColor, windSpeed } from './model.js'; +import { windStats } from './index.js'; + +const cesium = { + Cartesian2: class { + constructor(x, y) { + this.x = x; + this.y = y; + } + }, + Ellipsoid: { WGS84: {} }, + Cartographic: { fromCartesian: (p) => p }, + Math: { toDegrees: (x) => (x * 180) / Math.PI }, +}; +const at = (lonDeg, latDeg) => ({ + longitude: (lonDeg * Math.PI) / 180, + latitude: (latDeg * Math.PI) / 180, +}); +const grid = { nx: 4, ny: 3, lo1: 0, la1: 90, dx: 90, dy: 90 }; +const field = (u, v) => ({ + grid, + u: Float32Array.from({ length: 12 }, () => u), + v: Float32Array.from({ length: 12 }, () => v), +}); +const options = { + units: 'km/h', + overlay: 'speed', + model: 'GFS', + validTime: '2026-09-14 18:00 UTC', + status: 'Model forecast', +}; +/** The Cesium path picks the centre itself; Navara hands in the position. */ +const viaViewer = (snapshot, point) => + inspectWindAtCenter( + snapshot, + { + camera: { pickEllipsoid: () => point }, + scene: { canvas: { clientWidth: 800, clientHeight: 600 } }, + }, + cesium, + options, + ); +const viaPosition = (snapshot, point) => + inspectWindAtCenter(snapshot, null, cesium, { ...options, position: point }); +const windLine = (reading) => + windReadingResult(reading).lines.find(({ id }) => id === 'wind').text; + +test('edge:wind: a fixed grid gives the same direction, speed and legend colour on both pick paths', () => { + // 5 m/s blowing toward the west: the wind comes FROM the east. + const snapshot = field(-5, 0); + for (const point of [at(10, 20), at(180, 0), at(-180, 0), at(-45.5, -60)]) { + const cesiumReading = viaViewer(snapshot, point); + const navaraReading = viaPosition(snapshot, point); + for (const reading of [cesiumReading, navaraReading]) { + assert.equal(reading.from, 'E'); + assert.equal(reading.speed, 5); + assert.equal(reading.wind, '18.0 km/h from E'); + assert.equal(reading.scalarValue, '18.0 km/h'); + } + assert.deepEqual(navaraReading, { ...cesiumReading }); + assert.equal(windColor(navaraReading.speed), windColor(5)); + } + // The seam is one place, not two. + const east = sampleWind({ ...grid, ...snapshot }, 180, 0); + const west = sampleWind({ ...grid, ...snapshot }, -180, 0); + assert.deepEqual(east, west); + assert.equal(windSpeed(east.u, east.v), 5); +}); + +test('edge:wind: an empty or unavailable field is not reported as calm, a real 0 m/s field is', () => { + const point = at(10, 20); + const calm = viaPosition(field(0, 0), point); + assert.equal(calm.speed, 0); + assert.equal(calm.from, 'Calm'); + assert.equal(calm.wind, '0.0 km/h ยท calm'); + + const empty = [ + null, + { unavailable: true, reason: 'Wind upstream unavailable' }, + { grid, u: null, v: null }, + // A field whose arrays hold no cells at all. + { grid, u: new Float32Array(0), v: new Float32Array(0) }, + ]; + for (const snapshot of empty) { + for (const reading of [ + viaViewer(snapshot, point), + viaPosition(snapshot, point), + ]) { + assert.equal(reading.speed, undefined, 'no speed is invented'); + assert.equal(reading.from, undefined, 'no direction is invented'); + assert.equal(reading.coordinates, 'No surface reading'); + assert.doesNotMatch(windLine(reading), /calm|NaN|undefined/i); + } + } + assert.equal(windStats(null).count, 0); + assert.equal(windStats({ unavailable: true }).error, 'Wind unavailable'); +}); diff --git a/src/layers/wind/inspection.js b/src/layers/wind/inspection.js index 99d6c3e5a..93e96fcb3 100644 --- a/src/layers/wind/inspection.js +++ b/src/layers/wind/inspection.js @@ -58,17 +58,17 @@ export function inspectWindAtCenter( new cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2), ellipsoid, )); - if (!point || !snapshot?.u || !snapshot?.v) - return { - coordinates: msg('No surface reading'), - wind: msg('Aim the map center at Earth'), - model, - validTime, - status, - explanation: msg( - 'A location reading needs a loaded forecast and the Earth at the center of the view.', - ), - }; + const noReading = () => ({ + coordinates: msg('No surface reading'), + wind: msg('Aim the map center at Earth'), + model, + validTime, + status, + explanation: msg( + 'A location reading needs a loaded forecast and the Earth at the center of the view.', + ), + }); + if (!point || !snapshot?.u || !snapshot?.v) return noReading(); const location = cesium.Cartographic.fromCartesian(point, ellipsoid); const lon = cesium.Math.toDegrees(location.longitude); const lat = cesium.Math.toDegrees(location.latitude); @@ -77,6 +77,9 @@ export function inspectWindAtCenter( lon, lat, ); + // A field without cells samples to NaN; that is no reading, never a speed. + if (!Number.isFinite(vector.u) || !Number.isFinite(vector.v)) + return noReading(); const speed = windSpeed(vector.u, vector.v); const from = windFrom(vector.u, vector.v); const scalar = ['temperature', 'pressure'].includes(overlay)