From 27cdf8cd47b79fa1796f8f5132850d6e50d0a424 Mon Sep 17 00:00:00 2001 From: fakehec <46029795+fakehec@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:01:40 +0000 Subject: [PATCH 1/2] Add regional radar providers: IPMA (Portugal) and NOAA NEXRAD (CONUS + Puerto Rico) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three display-tier radar providers on the existing weather-radar provider contract (auto-discovered via providers/*.js), extending coverage beyond the shipped DWD/DPC/RainViewer set. Independent of the AEMET PR (#2); they rely only on the in-tree provider contract/registry. - ipma — IPMA national radar composite (Portugal). Two-step keyless feed (transf/radar/imgs-radar.json -> palette PNG); palette PNG decoded with Node's built-in zlib (no image-codec dependency), georeferenced as equirectangular EPSG:4326 and reprojected to tiles. lib/ipma-raster.js is a pure, unit-tested decode/reproject core. - noaa — NOAA NEXRAD CONUS mosaic. XYZ passthrough to the Iowa Environmental Mesonet nexrad-n0q web-mercator tile cache (keyless), like RainViewer. - noaa-pr — NOAA NEXRAD Puerto Rico / USVI. WMS GetMap adapter over the same IEM nexrad-n0q layer (the CONUS XYZ mosaic does not reach the Caribbean). All keyless, live-only, display tier only (no downloadRaw/cellsFromRaw). Each ships unit tests (node --test) and is added to the lint script. Verified against the live feeds. --- lib/ipma-provider.js | 152 ++++++++++++++++++++++ lib/ipma-raster.js | 236 ++++++++++++++++++++++++++++++++++ lib/noaa-pr-provider.js | 103 +++++++++++++++ lib/noaa-provider.js | 107 +++++++++++++++ package.json | 2 +- providers/ipma.js | 50 +++++++ providers/noaa-pr.js | 33 +++++ providers/noaa.js | 34 +++++ test/ipma-raster.test.js | 135 +++++++++++++++++++ test/noaa-pr-provider.test.js | 59 +++++++++ test/noaa-provider.test.js | 62 +++++++++ 11 files changed, 972 insertions(+), 1 deletion(-) create mode 100644 lib/ipma-provider.js create mode 100644 lib/ipma-raster.js create mode 100644 lib/noaa-pr-provider.js create mode 100644 lib/noaa-provider.js create mode 100644 providers/ipma.js create mode 100644 providers/noaa-pr.js create mode 100644 providers/noaa.js create mode 100644 test/ipma-raster.test.js create mode 100644 test/noaa-pr-provider.test.js create mode 100644 test/noaa-provider.test.js diff --git a/lib/ipma-provider.js b/lib/ipma-provider.js new file mode 100644 index 0000000..1fb6456 --- /dev/null +++ b/lib/ipma-provider.js @@ -0,0 +1,152 @@ +'use strict' + +// IPMA national radar reflectivity composite provider (Portugal / eastern +// Atlantic approaches). +// +// Data source: IPMA public web assets, a two-step handshake like AEMET but +// keyless. A metadata JSON (`.../transf/radar/imgs-radar.json`) lists the most +// recent 5-minute frames as `{ "Portugal": [ { date, path }, ... ] }` newest +// first, where `path` is null for frames not yet published; the current frame +// is the first entry with a non-null path, served from `imageBase + path` +// (a palette PNG composite). The feed exposes only recent frames and no +// per-frame query, so this adapter is live-only: it advertises neither +// `timeline` nor historical `tile(time)`. +// +// Display tier only (`products/latest/tile`): the upstream product is a +// rendered image, so per the provider specification §7 this adapter does NOT +// advertise `downloadRaw`/`cellsFromRaw`. +// +// Licensing: © IPMA (https://www.ipma.pt). Attribution surfaced in product +// metadata; callers must honour IPMA's terms for any caching/redistribution. + +const { georefBounds, decodeEchoes, renderTile, transparentPng } = require('./ipma-raster') + +const DEFAULT_METADATA_URL = 'https://www.ipma.pt/resources.www/transf/radar/imgs-radar.json' +const DEFAULT_IMAGE_BASE = 'https://www.ipma.pt/resources.www/transf/radar/por/' + +const PRODUCTS = Object.freeze({ + COMPOSITE: Object.freeze({ + raw: false, + layer: 'radar:composite', + title: 'IPMA Radar Composite (Portugal)', + description: 'IPMA national radar reflectivity composite, 5-minute cadence', + kind: 'raster', + units: 'reflectivity (IPMA native colour ramp)', + period: 'PT5M', + bounds: georefBounds(), + attribution: '© IPMA', + palette: 'IPMA native reflectivity ramp (~150 colours); not mapped to dBZ', + nodata: 'transparent: no echo, or map furniture (coastline/borders)', + observationOnly: true + }) +}) + +function timeoutSignal (ms) { + return AbortSignal.timeout + ? AbortSignal.timeout(ms) + : (() => { const c = new AbortController(); setTimeout(() => c.abort(), ms).unref?.(); return c.signal })() +} + +// IPMA frame timestamps ("YYYY-MM-DD HH:mm") carry no zone; treated as UTC. +function parseFrameEpoch (date) { + const t = Date.parse(`${String(date).trim().replace(' ', 'T')}:00Z`) + return Number.isFinite(t) ? t : Date.now() +} + +class IpmaRadarProvider { + constructor (cfg = {}) { + this.id = 'ipma' + this.name = 'IPMA Portugal Radar' + this.attribution = '© IPMA' + this.bounds = georefBounds() + this.cfg = { + metadataUrl: DEFAULT_METADATA_URL, + imageBase: DEFAULT_IMAGE_BASE, + requestTimeoutMs: 10000, + frameCacheSeconds: 60, + ...cfg + } + // product -> { fetchedAt, imageUrl, epochMs, raster } + this._cache = new Map() + } + + products () { return PRODUCTS } + + _assertProduct (product) { + if (!PRODUCTS[product]) { + throw new Error(`IPMA provider: unknown product '${product}' (have: ${Object.keys(PRODUCTS).join(', ')})`) + } + } + + // Resolve the current frame: metadata GET -> newest entry with a non-null + // path -> absolute image URL. Cached for frameCacheSeconds so latest()+tile() + // share one round-trip. + async _resolveFrame (product) { + const now = Date.now() + const cached = this._cache.get(product) + if (cached && (now - cached.fetchedAt) < this.cfg.frameCacheSeconds * 1000) return cached + const r = await fetch(this.cfg.metadataUrl, { + headers: { accept: 'application/json' }, + signal: timeoutSignal(this.cfg.requestTimeoutMs) + }) + if (!r.ok) throw new Error(`IPMA metadata HTTP ${r.status}`) + const meta = await r.json() + const frames = meta && Array.isArray(meta.Portugal) ? meta.Portugal : null + if (!frames) throw new Error('IPMA metadata: missing "Portugal" frame list') + const current = frames.find((f) => f && f.path) + if (!current) throw new Error('IPMA metadata: no published frame available') + const base = this.cfg.imageBase.endsWith('/') ? this.cfg.imageBase : this.cfg.imageBase + '/' + const frame = { + fetchedAt: now, + imageUrl: base + current.path, + epochMs: parseFrameEpoch(current.date), + raster: null + } + this._cache.set(product, frame) + return frame + } + + async latest (product) { + this._assertProduct(product) + const frame = await this._resolveFrame(product) + return { + product, + time: new Date(frame.epochMs).toISOString(), + epochMs: frame.epochMs, + period: 'PT5M', + source: this.id + } + } + + async _raster (product) { + const frame = await this._resolveFrame(product) + if (frame.raster) return frame.raster + const r = await fetch(frame.imageUrl, { + headers: { accept: 'image/png,image/*;q=0.8' }, + signal: timeoutSignal(this.cfg.requestTimeoutMs) + }) + if (!r.ok) throw new Error(`IPMA image HTTP ${r.status}`) + const ct = r.headers.get('content-type') || '' + if (!ct.includes('image/png')) throw new Error(`IPMA image returned ${ct}`) + const buffer = Buffer.from(await r.arrayBuffer()) + frame.raster = decodeEchoes(buffer) + return frame.raster + } + + async tile (product, tileRequest, time) { + this._assertProduct(product) + const bbox = Array.isArray(tileRequest) ? tileRequest : tileRequest && tileRequest.bbox3857 + if (!Array.isArray(bbox) || bbox.length !== 4) { + throw new Error('IPMA provider requires bbox3857 in the tile request') + } + const size = (tileRequest && tileRequest.size) || 256 + const cached = this._cache.get(product) + if (time && time.epochMs && cached && time.epochMs < cached.epochMs - 5 * 60 * 1000) { + throw new Error('IPMA provider does not support historical frames') + } + const raster = await this._raster(product) + return renderTile(raster, bbox, size) + } +} + +module.exports = { IpmaRadarProvider, PRODUCTS, georefBounds, transparentPng } diff --git a/lib/ipma-raster.js b/lib/ipma-raster.js new file mode 100644 index 0000000..a49e17f --- /dev/null +++ b/lib/ipma-raster.js @@ -0,0 +1,236 @@ +'use strict' + +// Deterministic decode / reprojection / render core for the IPMA (Portugal) +// national radar composite. Network-free so it can be unit-tested with a PNG +// fixture (see test/ipma-raster.test.js). +// +// The IPMA product (`.../transf/radar/por/pcr-.png`) is a rendered, +// palette-indexed PNG reflectivity composite (1500x2331). Unlike the AEMET +// product it is a PNG, but PNG palette decode needs only Node's built-in zlib, +// so this module carries no image-codec dependency (contrast lib/aemet-*). +// +// Display tier only: the composite is a rendered image with IPMA's own +// reflectivity colour ramp (~150 colours, no published per-colour dBZ legend +// we map here), so this adapter renders the native echo colours and advertises +// neither `downloadRaw` nor `cellsFromRaw` — same stance as the AEMET adapter. +// +// Georeferencing: the composite is treated as an equirectangular (plate-carrée, +// EPSG:4326) image over mainland Portugal. Bounds below are an initial fit +// [west, south, east, north]; refine against the IPMA GeoServer WMS +// (divgmwebgis.ipma.pt) GetCapabilities bbox or known coastline landmarks. +// Accurate to a few km across the mainland arc, deteriorating at the edges. +// +// Licensing: © IPMA. Attribution surfaced in product metadata; callers must +// honour IPMA's terms for any caching/redistribution. + +const zlib = require('node:zlib') + +// --- Georeference (linear EPSG:4326, plate-carrée) ------------------------ +// lon = minLon + (x + 0.5)/width * (maxLon - minLon) +// lat = maxLat - (y + 0.5)/height * (maxLat - minLat) +const GEOREF = Object.freeze({ + minLon: -9.6, + minLat: 36.8, + maxLon: -6.0, + maxLat: 42.2, + width: 1500, + height: 2331 +}) + +function pxToLonLat (x, y) { + return [ + GEOREF.minLon + ((x + 0.5) / GEOREF.width) * (GEOREF.maxLon - GEOREF.minLon), + GEOREF.maxLat - ((y + 0.5) / GEOREF.height) * (GEOREF.maxLat - GEOREF.minLat) + ] +} +function lonLatToPx (lon, lat) { + return [ + ((lon - GEOREF.minLon) / (GEOREF.maxLon - GEOREF.minLon)) * GEOREF.width - 0.5, + ((GEOREF.maxLat - lat) / (GEOREF.maxLat - GEOREF.minLat)) * GEOREF.height - 0.5 + ] +} +function georefBounds () { + return [GEOREF.minLon, GEOREF.minLat, GEOREF.maxLon, GEOREF.maxLat] +} + +// --- Web Mercator (EPSG:3857) -> lon/lat ---------------------------------- +const R3857 = 6378137 +function merToLonLat (mx, my) { + const lon = (mx / R3857) * (180 / Math.PI) + const lat = (Math.atan(Math.exp(my / R3857)) * 2 - Math.PI / 2) * (180 / Math.PI) + return [lon, lat] +} + +// --- PNG (8-bit palette, colour type 3) decoder --------------------------- +// Minimal, dependency-free: parse chunks, inflate IDAT, reverse the per-row +// filters (bytes-per-pixel = 1 for an 8-bit indexed image). Returns the raw +// palette indices plus PLTE/tRNS so the caller decides colour semantics. +function paeth (a, b, c) { + const p = a + b - c + const pa = Math.abs(p - a) + const pb = Math.abs(p - b) + const pc = Math.abs(p - c) + if (pa <= pb && pa <= pc) return a + return pb <= pc ? b : c +} +function decodePalettePng (buffer) { + if (buffer.length < 8 || buffer.readUInt32BE(0) !== 0x89504e47) { + throw new Error('IPMA raster: not a PNG') + } + let pos = 8 + let width = 0; let height = 0; let bitDepth = 0; let colorType = 0; let interlace = 0 + let palette = null; let trns = null + const idat = [] + while (pos + 8 <= buffer.length) { + const len = buffer.readUInt32BE(pos) + const type = buffer.toString('ascii', pos + 4, pos + 8) + const data = buffer.subarray(pos + 8, pos + 8 + len) + if (type === 'IHDR') { + width = data.readUInt32BE(0); height = data.readUInt32BE(4) + bitDepth = data[8]; colorType = data[9]; interlace = data[12] + } else if (type === 'PLTE') palette = Buffer.from(data) + else if (type === 'tRNS') trns = Buffer.from(data) + else if (type === 'IDAT') idat.push(Buffer.from(data)) + else if (type === 'IEND') break + pos += 12 + len + } + if (colorType !== 3 || bitDepth !== 8 || interlace !== 0 || !palette) { + throw new Error(`IPMA raster: unsupported PNG (${width}x${height} colorType=${colorType} bitDepth=${bitDepth} interlace=${interlace})`) + } + const raw = zlib.inflateSync(Buffer.concat(idat)) + const stride = width + 1 + if (raw.length < height * stride) throw new Error('IPMA raster: short IDAT') + const index = new Uint8Array(width * height) + const prev = new Uint8Array(width) + const cur = new Uint8Array(width) + for (let y = 0; y < height; y++) { + const ft = raw[y * stride] + const base = y * stride + 1 + for (let x = 0; x < width; x++) { + const v = raw[base + x] + const a = x ? cur[x - 1] : 0 + const b = prev[x] + const c = x ? prev[x - 1] : 0 + let out + switch (ft) { + case 0: out = v; break + case 1: out = v + a; break + case 2: out = v + b; break + case 3: out = v + ((a + b) >> 1); break + case 4: out = v + paeth(a, b, c); break + default: throw new Error(`IPMA raster: bad PNG filter ${ft}`) + } + cur[x] = out & 255 + } + index.set(cur, y * width) + prev.set(cur) + } + return { width, height, index, palette, trns } +} + +// Decode the composite into an RGBA echo grid: transparent (tRNS==0) pixels are +// no-echo, pure-black opaque pixels are map furniture (coastline/borders) and +// are dropped, everything else keeps IPMA's native ramp colour (alpha 255 here; +// a display alpha is applied at render time). +function decodeEchoes (buffer) { + const { width, height, index, palette, trns } = decodePalettePng(buffer) + if (width !== GEOREF.width || height !== GEOREF.height) { + throw new Error(`IPMA raster: unexpected image size ${width}x${height} (expected ${GEOREF.width}x${GEOREF.height})`) + } + const rgba = new Uint8Array(width * height * 4) + for (let i = 0; i < width * height; i++) { + const idx = index[i] + const alpha = trns && idx < trns.length ? trns[idx] : 255 + if (alpha === 0) continue // no echo + const r = palette[idx * 3]; const g = palette[idx * 3 + 1]; const b = palette[idx * 3 + 2] + if (r === 0 && g === 0 && b === 0) continue // furniture (coastline/borders) + const o = i * 4 + rgba[o] = r; rgba[o + 1] = g; rgba[o + 2] = b; rgba[o + 3] = 255 + } + return { width, height, rgba } +} + +// --- Rendering ------------------------------------------------------------- +const DISPLAY_ALPHA = 190 + +// Nearest-neighbour warp of the echo grid into a web-map tile. +// `bbox3857 = [minX, minY, maxX, maxY]`. Returns PNG bytes (Buffer). +function renderTile (raster, bbox3857, size) { + const [minX, minY, maxX, maxY] = bbox3857 + const out = new Uint8Array(size * size * 4) + const { width, height, rgba } = raster + for (let j = 0; j < size; j++) { + const my = maxY - ((j + 0.5) / size) * (maxY - minY) + for (let i = 0; i < size; i++) { + const mx = minX + ((i + 0.5) / size) * (maxX - minX) + const [lon, lat] = merToLonLat(mx, my) + const [gx, gy] = lonLatToPx(lon, lat) + const xi = Math.round(gx); const yi = Math.round(gy) + if (xi < 0 || yi < 0 || xi >= width || yi >= height) continue + const s = (yi * width + xi) * 4 + if (rgba[s + 3] === 0) continue + const o = (j * size + i) * 4 + out[o] = rgba[s]; out[o + 1] = rgba[s + 1]; out[o + 2] = rgba[s + 2]; out[o + 3] = DISPLAY_ALPHA + } + } + return encodePng(size, size, out) +} + +// --- Minimal PNG encoder (RGBA, no filtering) — identical to lib/aemet-raster. +const CRC_TABLE = (() => { + const t = new Uint32Array(256) + for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1) + t[n] = c >>> 0 + } + return t +})() +function crc32 (buf) { + let c = 0xFFFFFFFF + for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8) + return (c ^ 0xFFFFFFFF) >>> 0 +} +function pngChunk (type, data) { + const name = Buffer.from(type, 'ascii') + const out = Buffer.allocUnsafe(data.length + 12) + out.writeUInt32BE(data.length, 0) + name.copy(out, 4) + data.copy(out, 8) + out.writeUInt32BE(crc32(Buffer.concat([name, data])), data.length + 8) + return out +} +function encodePng (width, height, rgba) { + const header = Buffer.alloc(13) + header.writeUInt32BE(width, 0) + header.writeUInt32BE(height, 4) + header[8] = 8 + header[9] = 6 + const scanlines = Buffer.alloc(height * (width * 4 + 1)) + for (let row = 0; row < height; row++) { + Buffer.from(rgba.buffer, rgba.byteOffset + row * width * 4, width * 4) + .copy(scanlines, row * (width * 4 + 1) + 1) + } + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + pngChunk('IHDR', header), + pngChunk('IDAT', zlib.deflateSync(scanlines)), + pngChunk('IEND', Buffer.alloc(0)) + ]) +} +function transparentPng (size) { + return encodePng(size, size, new Uint8Array(size * size * 4)) +} + +module.exports = { + GEOREF, + pxToLonLat, + lonLatToPx, + georefBounds, + merToLonLat, + decodePalettePng, + decodeEchoes, + renderTile, + encodePng, + transparentPng +} diff --git a/lib/noaa-pr-provider.js b/lib/noaa-pr-provider.js new file mode 100644 index 0000000..540659d --- /dev/null +++ b/lib/noaa-pr-provider.js @@ -0,0 +1,103 @@ +'use strict' + +// NOAA NEXRAD Puerto Rico / US Virgin Islands base-reflectivity provider. +// +// The CONUS XYZ mosaic (lib/noaa-provider.js) does not reach Puerto Rico, so +// this adapter draws the TJUA (San Juan) coverage from the Iowa Environmental +// Mesonet WMS `nexrad-n0q` layer, which spans the full NWS network including +// the Caribbean territories. It is a WMS GetMap adapter: the generic tile +// request carries `bbox3857`, so the core stays free of WMS specifics (see the +// provider contract). Keyless, live-only, display tier only. +// +// Licensing: NWS/NOAA NEXRAD is US public-domain; imagery via the Iowa +// Environmental Mesonet (IEM). + +// Puerto Rico + USVI + nearby approaches [west, south, east, north]. +const PR_BOUNDS = [-68.5, 16.5, -63.5, 19.8] +const DEFAULT_WMS_URL = 'https://mesonet.agron.iastate.edu/cgi-bin/wms/nexrad/n0q.cgi' + +const PRODUCTS = Object.freeze({ + PR: Object.freeze({ + raw: false, + layer: 'radar:pr', + title: 'NOAA NEXRAD Puerto Rico (TJUA)', + description: 'NWS NEXRAD base reflectivity over Puerto Rico / USVI via Iowa State Mesonet WMS, ~5-minute cadence', + kind: 'raster', + units: 'reflectivity (NEXRAD N0Q colour ramp)', + period: 'PT5M', + bounds: PR_BOUNDS, + attribution: '© NOAA/NWS NEXRAD — Iowa State Mesonet (IEM)', + minZoom: 0, + maxZoom: 11, + observationOnly: true + }) +}) + +function timeoutSignal (ms) { + return AbortSignal.timeout + ? AbortSignal.timeout(ms) + : (() => { const c = new AbortController(); setTimeout(() => c.abort(), ms).unref?.(); return c.signal })() +} + +class NoaaPrRadarProvider { + constructor (cfg = {}) { + this.id = 'noaa-pr' + this.name = 'NOAA NEXRAD Puerto Rico' + this.attribution = '© NOAA/NWS NEXRAD — Iowa State Mesonet (IEM)' + this.bounds = PR_BOUNDS + this.cfg = { + wmsUrl: DEFAULT_WMS_URL, + layer: 'nexrad-n0q', + requestTimeoutMs: 12000, + ...cfg + } + } + + products () { return PRODUCTS } + + _assertProduct (product) { + if (!PRODUCTS[product]) { + throw new Error(`NOAA-PR provider: unknown product '${product}' (have: ${Object.keys(PRODUCTS).join(', ')})`) + } + } + + _floorCadence (epochMs) { + const p = 5 * 60 * 1000 + return Math.floor(epochMs / p) * p + } + + async latest (product) { + this._assertProduct(product) + const epochMs = this._floorCadence(Date.now()) + return { product, time: new Date(epochMs).toISOString(), epochMs, period: 'PT5M', source: this.id } + } + + // WMS GetMap over the requested web-mercator bbox. WMS 1.1.1 keeps the + // EPSG:3857 axis order as minx,miny,maxx,maxy (no 1.3.0 axis swap). + async tile (product, tileRequest) { + this._assertProduct(product) + const bbox = Array.isArray(tileRequest) ? tileRequest : tileRequest && tileRequest.bbox3857 + if (!Array.isArray(bbox) || bbox.length !== 4) { + throw new Error('NOAA-PR provider requires bbox3857 in the tile request') + } + const size = (tileRequest && tileRequest.size) || 256 + const u = new URL(this.cfg.wmsUrl) + const q = u.searchParams + q.set('service', 'WMS'); q.set('version', '1.1.1'); q.set('request', 'GetMap') + q.set('layers', this.cfg.layer); q.set('styles', '') + q.set('format', 'image/png'); q.set('transparent', 'true') + q.set('srs', 'EPSG:3857') + q.set('width', String(size)); q.set('height', String(size)) + q.set('bbox', bbox.join(',')) + const r = await fetch(u, { + headers: { accept: 'image/png,image/*;q=0.8', 'user-agent': 'signalk-storm-intelligence' }, + signal: timeoutSignal(this.cfg.requestTimeoutMs) + }) + if (!r.ok) throw new Error(`NOAA-PR WMS HTTP ${r.status}`) + const ct = r.headers.get('content-type') || '' + if (!ct.includes('image/png')) throw new Error(`NOAA-PR WMS returned ${ct}`) + return Buffer.from(await r.arrayBuffer()) + } +} + +module.exports = { NoaaPrRadarProvider, PRODUCTS, PR_BOUNDS } diff --git a/lib/noaa-provider.js b/lib/noaa-provider.js new file mode 100644 index 0000000..b1c85bf --- /dev/null +++ b/lib/noaa-provider.js @@ -0,0 +1,107 @@ +'use strict' + +// NOAA NEXRAD (United States) base-reflectivity mosaic provider. +// +// Data source: the NWS NEXRAD national mosaic served as web-map (EPSG:3857) +// XYZ tiles by the Iowa State University / Iowa Environmental Mesonet (IEM) +// tile cache — keyless and already reprojected, so this adapter is a plain XYZ +// passthrough (like RainViewer) with no image decode or georeferencing. The +// cache serves the current composite (~5-minute cadence); there is no +// per-frame query, so the adapter is live-only and advertises no historical +// tile(time) or timeline. +// +// Display tier only (products/latest/tile): the upstream tile is a rendered +// N0Q reflectivity image, so no downloadRaw/cellsFromRaw — same stance as the +// AEMET/IPMA adapters. +// +// Licensing: NWS/NOAA NEXRAD data is US public-domain; tiles courtesy of the +// Iowa Environmental Mesonet (IEM). Attribution surfaced in product metadata. + +const DEFAULT_TILE_URL = 'https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0q-900913/{z}/{x}/{y}.png' +// Continental US coverage hint [west, south, east, north]. +const CONUS_BOUNDS = [-127.5, 20.0, -66.0, 51.0] + +const PRODUCTS = Object.freeze({ + COMPOSITE: Object.freeze({ + raw: false, + layer: 'radar:composite', + title: 'NOAA NEXRAD Composite (US)', + description: 'NWS NEXRAD base reflectivity mosaic (CONUS) via Iowa State Mesonet, ~5-minute cadence', + kind: 'raster', + units: 'reflectivity (NEXRAD N0Q colour ramp)', + period: 'PT5M', + bounds: CONUS_BOUNDS, + attribution: '© NOAA/NWS NEXRAD — Iowa State Mesonet (IEM)', + minZoom: 0, + maxZoom: 10, + observationOnly: true + }) +}) + +function timeoutSignal (ms) { + return AbortSignal.timeout + ? AbortSignal.timeout(ms) + : (() => { const c = new AbortController(); setTimeout(() => c.abort(), ms).unref?.(); return c.signal })() +} + +class NoaaRadarProvider { + constructor (cfg = {}) { + this.id = 'noaa' + this.name = 'NOAA NEXRAD Radar (US)' + this.attribution = '© NOAA/NWS NEXRAD — Iowa State Mesonet (IEM)' + this.bounds = CONUS_BOUNDS + this.cfg = { + tileUrl: DEFAULT_TILE_URL, + requestTimeoutMs: 10000, + ...cfg + } + } + + products () { return PRODUCTS } + + _assertProduct (product) { + if (!PRODUCTS[product]) { + throw new Error(`NOAA provider: unknown product '${product}' (have: ${Object.keys(PRODUCTS).join(', ')})`) + } + } + + _floorCadence (epochMs) { + const p = 5 * 60 * 1000 + return Math.floor(epochMs / p) * p + } + + async latest (product) { + this._assertProduct(product) + const epochMs = this._floorCadence(Date.now()) + return { + product, + time: new Date(epochMs).toISOString(), + epochMs, + period: 'PT5M', + source: this.id + } + } + + // XYZ passthrough: fetch the upstream web-mercator tile and return its bytes. + async tile (product, tileRequest) { + this._assertProduct(product) + const { z, x, y } = tileRequest || {} + if (![z, x, y].every(Number.isInteger)) { + throw new Error('NOAA provider requires XYZ tile coordinates') + } + const url = this.cfg.tileUrl + .replace('{z}', String(z)) + .replace('{x}', String(x)) + .replace('{y}', String(y)) + const r = await fetch(url, { + headers: { accept: 'image/png,image/*;q=0.8', 'user-agent': 'signalk-storm-intelligence' }, + signal: timeoutSignal(this.cfg.requestTimeoutMs) + }) + if (!r.ok) throw new Error(`NOAA tile HTTP ${r.status}`) + const ct = r.headers.get('content-type') || '' + if (!ct.includes('image/png')) throw new Error(`NOAA tile returned ${ct}`) + return Buffer.from(await r.arrayBuffer()) + } +} + +module.exports = { NoaaRadarProvider, PRODUCTS, CONUS_BOUNDS } diff --git a/package.json b/package.json index 71eff88..2433826 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "scripts": { "test": "node --test", "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=60", - "lint": "node --check index.js && node --check public/dashboard.js && node --check lib/version.js && node --check lib/inference-engine.js && node --check lib/inference-registry.js && node --check lib/inference-algorithm-contract.js && node --check inference-algorithms/kinematic-polygon.js && node --check inference-algorithms/multisensor-evidence.js && node --check inference-algorithms/multimodal-dnn.js && node --check inference-algorithms/llm-openai-compatible.js && node --check lib/openai-compatible-client.js && node --check lib/multimodal-features.js && node --check lib/pretrained-dnn.js && node --check scripts/train-reference-dnn.js && node --check scripts/quality-check.js && node --check lib/provider-registry.js && node --check lib/provider-contract.js && node --check lib/observation-provider-registry.js && node --check lib/observation-provider-contract.js && node --check lib/lightning-engine.js && node --check lib/lightning-overlay.js && node --check lib/environment-fusion.js && node --check lib/weather-api-fusion.js && node --check observation-providers/http-json-lightning.js && node --check observation-providers/blitzortung-lightning.js && node --check observation-providers/dpc-ltg-density.js && node --check lib/storage.js && node --check lib/tile-store.js && node --check lib/playback.js && node --check lib/storm-engine.js && node --check lib/hrd-parser.js && node --check lib/hazard-overlay.js && node --check lib/dpc-v2-raster.js && node --check lib/rainviewer-provider.js && node --check providers/rainviewer.js", + "lint": "node --check index.js && node --check public/dashboard.js && node --check lib/version.js && node --check lib/inference-engine.js && node --check lib/inference-registry.js && node --check lib/inference-algorithm-contract.js && node --check inference-algorithms/kinematic-polygon.js && node --check inference-algorithms/multisensor-evidence.js && node --check inference-algorithms/multimodal-dnn.js && node --check inference-algorithms/llm-openai-compatible.js && node --check lib/openai-compatible-client.js && node --check lib/multimodal-features.js && node --check lib/pretrained-dnn.js && node --check scripts/train-reference-dnn.js && node --check scripts/quality-check.js && node --check lib/provider-registry.js && node --check lib/provider-contract.js && node --check lib/observation-provider-registry.js && node --check lib/observation-provider-contract.js && node --check lib/lightning-engine.js && node --check lib/lightning-overlay.js && node --check lib/environment-fusion.js && node --check lib/weather-api-fusion.js && node --check observation-providers/http-json-lightning.js && node --check observation-providers/blitzortung-lightning.js && node --check observation-providers/dpc-ltg-density.js && node --check lib/storage.js && node --check lib/tile-store.js && node --check lib/playback.js && node --check lib/storm-engine.js && node --check lib/hrd-parser.js && node --check lib/hazard-overlay.js && node --check lib/dpc-v2-raster.js && node --check lib/rainviewer-provider.js && node --check providers/rainviewer.js && node --check lib/ipma-raster.js && node --check lib/ipma-provider.js && node --check providers/ipma.js && node --check lib/noaa-provider.js && node --check providers/noaa.js && node --check lib/noaa-pr-provider.js && node --check providers/noaa-pr.js", "check": "npm run lint && npm run test:coverage", "pack:dry": "npm pack --dry-run", "live-check": "node scripts/live-check.js", diff --git a/providers/ipma.js b/providers/ipma.js new file mode 100644 index 0000000..889e053 --- /dev/null +++ b/providers/ipma.js @@ -0,0 +1,50 @@ +'use strict' + +const { IpmaRadarProvider, PRODUCTS } = require('../lib/ipma-provider') + +// IPMA national radar composite. Keyless public feed, so unlike AEMET there is +// no API key and no injected image codec: the palette PNG is decoded with +// Node's built-in zlib inside lib/ipma-raster.js. + +const defaults = Object.freeze({ + metadataUrl: 'https://www.ipma.pt/resources.www/transf/radar/imgs-radar.json', + imageBase: 'https://www.ipma.pt/resources.www/transf/radar/por/', + frameCacheSeconds: 60 +}) + +module.exports = { + id: 'ipma', + name: 'IPMA Portugal Radar', + products: PRODUCTS, + defaults, + recommended: { enabled: false, display: [], prefetch: [], acquire: [] }, + settingsSchema: { + properties: { + metadataUrl: { + title: 'IPMA radar metadata URL', + type: 'string', + default: defaults.metadataUrl + }, + imageBase: { + title: 'IPMA radar image base URL', + type: 'string', + default: defaults.imageBase + }, + frameCacheSeconds: { + title: 'Frame cache (seconds)', + type: 'integer', + minimum: 30, + maximum: 600, + default: defaults.frameCacheSeconds + } + } + }, + create ({ common, settings }) { + return new IpmaRadarProvider({ + requestTimeoutMs: common.requestTimeoutMs, + metadataUrl: settings.metadataUrl || defaults.metadataUrl, + imageBase: settings.imageBase || defaults.imageBase, + frameCacheSeconds: settings.frameCacheSeconds || defaults.frameCacheSeconds + }) + } +} diff --git a/providers/noaa-pr.js b/providers/noaa-pr.js new file mode 100644 index 0000000..c3bba92 --- /dev/null +++ b/providers/noaa-pr.js @@ -0,0 +1,33 @@ +'use strict' + +const { NoaaPrRadarProvider, PRODUCTS } = require('../lib/noaa-pr-provider') + +// NOAA NEXRAD Puerto Rico / USVI via the Iowa Environmental Mesonet WMS +// (keyless). Separate from the CONUS provider (providers/noaa.js) because the +// CONUS XYZ mosaic does not reach the Caribbean territories. + +const defaults = Object.freeze({ + wmsUrl: 'https://mesonet.agron.iastate.edu/cgi-bin/wms/nexrad/n0q.cgi', + layer: 'nexrad-n0q' +}) + +module.exports = { + id: 'noaa-pr', + name: 'NOAA NEXRAD Puerto Rico', + products: PRODUCTS, + defaults, + recommended: { enabled: false, display: [], prefetch: [], acquire: [] }, + settingsSchema: { + properties: { + wmsUrl: { title: 'NEXRAD WMS endpoint', type: 'string', default: defaults.wmsUrl }, + layer: { title: 'WMS layer', type: 'string', default: defaults.layer } + } + }, + create ({ common, settings }) { + return new NoaaPrRadarProvider({ + requestTimeoutMs: common.requestTimeoutMs, + wmsUrl: settings.wmsUrl || defaults.wmsUrl, + layer: settings.layer || defaults.layer + }) + } +} diff --git a/providers/noaa.js b/providers/noaa.js new file mode 100644 index 0000000..c7b3d7b --- /dev/null +++ b/providers/noaa.js @@ -0,0 +1,34 @@ +'use strict' + +const { NoaaRadarProvider, PRODUCTS } = require('../lib/noaa-provider') + +// NOAA NEXRAD (US) via the Iowa Environmental Mesonet XYZ tile cache. Keyless +// web-mercator passthrough — no API key and no injected image codec. + +const defaults = Object.freeze({ + tileUrl: 'https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0q-900913/{z}/{x}/{y}.png' +}) + +module.exports = { + id: 'noaa', + name: 'NOAA NEXRAD Radar (US)', + products: PRODUCTS, + defaults, + recommended: { enabled: false, display: [], prefetch: [], acquire: [] }, + settingsSchema: { + properties: { + tileUrl: { + title: 'NEXRAD XYZ tile URL template', + description: 'Web-mercator {z}/{x}/{y} PNG tiles. Default: Iowa State Mesonet nexrad-n0q.', + type: 'string', + default: defaults.tileUrl + } + } + }, + create ({ common, settings }) { + return new NoaaRadarProvider({ + requestTimeoutMs: common.requestTimeoutMs, + tileUrl: settings.tileUrl || defaults.tileUrl + }) + } +} diff --git a/test/ipma-raster.test.js b/test/ipma-raster.test.js new file mode 100644 index 0000000..baaed3a --- /dev/null +++ b/test/ipma-raster.test.js @@ -0,0 +1,135 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert/strict') +const zlib = require('node:zlib') + +const { + GEOREF, pxToLonLat, lonLatToPx, georefBounds, merToLonLat, + decodePalettePng, renderTile, encodePng, transparentPng +} = require('../lib/ipma-raster') + +// Build a minimal 8-bit palette (colour type 3) PNG from an index grid, a flat +// [r,g,b,...] palette and an optional tRNS alpha array. `filters` picks the row +// filter type per row so the decoder's un-filtering is exercised for each of +// the five PNG filter types. +function crc32 (buf) { + let c = 0xffffffff + for (let i = 0; i < buf.length; i++) { + c ^= buf[i] + for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1) + } + return (c ^ 0xffffffff) >>> 0 +} +function chunk (type, data) { + const name = Buffer.from(type, 'ascii') + const out = Buffer.alloc(data.length + 12) + out.writeUInt32BE(data.length, 0) + name.copy(out, 4) + data.copy(out, 8) + out.writeUInt32BE(crc32(Buffer.concat([name, data])), data.length + 8) + return out +} +function paeth (a, b, c) { + const p = a + b - c + const pa = Math.abs(p - a); const pb = Math.abs(p - b); const pc = Math.abs(p - c) + return pa <= pb && pa <= pc ? a : (pb <= pc ? b : c) +} +function buildPalettePng (w, h, grid, palette, trns, filters) { + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4) + ihdr[8] = 8; ihdr[9] = 3; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0 + const raw = Buffer.alloc(h * (w + 1)) + const prev = new Uint8Array(w) + for (let y = 0; y < h; y++) { + const ft = filters[y] + raw[y * (w + 1)] = ft + for (let x = 0; x < w; x++) { + const v = grid[y * w + x] + const a = x ? grid[y * w + x - 1] : 0 + const b = prev[x] + const c = x ? prev[x - 1] : 0 + let enc + switch (ft) { + case 0: enc = v; break + case 1: enc = v - a; break + case 2: enc = v - b; break + case 3: enc = v - ((a + b) >> 1); break + case 4: enc = v - paeth(a, b, c); break + } + raw[y * (w + 1) + 1 + x] = enc & 255 + } + for (let x = 0; x < w; x++) prev[x] = grid[y * w + x] + } + const chunks = [ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk('IHDR', ihdr), + chunk('PLTE', Buffer.from(palette)) + ] + if (trns) chunks.push(chunk('tRNS', Buffer.from(trns))) + chunks.push(chunk('IDAT', zlib.deflateSync(raw))) + chunks.push(chunk('IEND', Buffer.alloc(0))) + return Buffer.concat(chunks) +} + +function isPng (buf) { + return buf.length > 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 +} + +test('decodePalettePng un-filters every filter type and returns exact indices', () => { + const w = 4; const h = 5 + const grid = [] + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) grid.push((y * 7 + x * 3) & 255) + const palette = [0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 10, 20, 30] + const trns = [0, 255, 255, 255, 255] + const png = buildPalettePng(w, h, grid, palette, trns, [0, 1, 2, 3, 4]) + const dec = decodePalettePng(png) + assert.equal(dec.width, w) + assert.equal(dec.height, h) + assert.deepEqual(Array.from(dec.index), grid) + assert.equal(dec.palette.length, 15) + assert.equal(dec.trns[0], 0) +}) + +test('georef bounds and round-trip are consistent', () => { + assert.deepEqual(georefBounds(), [GEOREF.minLon, GEOREF.minLat, GEOREF.maxLon, GEOREF.maxLat]) + // NW corner pixel (0,0) sits just inside the NW bound. + const [lon, lat] = pxToLonLat(0, 0) + assert.ok(lon > GEOREF.minLon && lon < GEOREF.minLon + 0.01) + assert.ok(lat < GEOREF.maxLat && lat > GEOREF.maxLat - 0.01) + // Inverse is consistent to sub-pixel. + const [x, y] = lonLatToPx(lon, lat) + assert.ok(Math.abs(x - 0) < 1e-6 && Math.abs(y - 0) < 1e-6) +}) + +test('web mercator inverse maps the origin to 0,0', () => { + const [lon, lat] = merToLonLat(0, 0) + assert.ok(Math.abs(lon) < 1e-9 && Math.abs(lat) < 1e-9) +}) + +test('renderTile paints an echo at its location and stays transparent off-extent', () => { + // One red echo at grid pixel (750, 1165) ~ centre of the composite. + const width = GEOREF.width; const height = GEOREF.height + const rgba = new Uint8Array(width * height * 4) + const xi = 750; const yi = 1165 + const o = (yi * width + xi) * 4 + rgba[o] = 255; rgba[o + 1] = 0; rgba[o + 2] = 0; rgba[o + 3] = 255 + const raster = { width, height, rgba } + + const [lon, lat] = pxToLonLat(xi, yi) + const R = 6378137 + const mx = (lon * Math.PI / 180) * R + const my = Math.log(Math.tan(Math.PI / 4 + lat * Math.PI / 360)) * R + const d = 20000 // ~20 km half-window in metres + const png = renderTile(raster, [mx - d, my - d, mx + d, my + d], 64) + assert.ok(isPng(png)) + // A tile far outside the Portugal extent has no echoes -> fully transparent. + const empty = renderTile(raster, [0, 0, 10000, 10000], 32) + assert.ok(isPng(empty)) + assert.deepEqual(empty, transparentPng(32)) +}) + +test('encodePng and transparentPng produce valid PNG signatures', () => { + assert.ok(isPng(encodePng(2, 2, new Uint8Array(2 * 2 * 4)))) + assert.ok(isPng(transparentPng(8))) +}) diff --git a/test/noaa-pr-provider.test.js b/test/noaa-pr-provider.test.js new file mode 100644 index 0000000..05f4da7 --- /dev/null +++ b/test/noaa-pr-provider.test.js @@ -0,0 +1,59 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert/strict') +const { NoaaPrRadarProvider, PRODUCTS, PR_BOUNDS } = require('../lib/noaa-pr-provider') + +function withStubFetch (impl, fn) { + const orig = globalThis.fetch + globalThis.fetch = impl + return Promise.resolve().then(fn).finally(() => { globalThis.fetch = orig }) +} +const pngResponse = () => ({ ok: true, headers: { get: () => 'image/png' }, arrayBuffer: async () => Uint8Array.from([137, 80, 78, 71]).buffer }) + +test('product PR is display-only over the Puerto Rico bounds', () => { + const c = PRODUCTS.PR + assert.equal(c.kind, 'raster') + assert.equal(c.observationOnly, true) + assert.equal(c.raw, false) + assert.deepEqual(c.bounds, PR_BOUNDS) +}) + +test('latest floors to 5 minutes and is UTC ISO', () => + new NoaaPrRadarProvider().latest('PR').then((l) => { + assert.equal(l.period, 'PT5M'); assert.equal(l.source, 'noaa-pr') + assert.equal(l.epochMs % (5 * 60 * 1000), 0) + assert.equal(new Date(l.epochMs).toISOString(), l.time) + })) + +test('tile builds a WMS 1.1.1 GetMap with the requested bbox3857', () => { + let seen = null + return withStubFetch(async (url) => { seen = new URL(url.toString()); return pngResponse() }, async () => { + const p = new NoaaPrRadarProvider() + const bbox = [-7625000, 1870000, -7070000, 2250000] + const buf = await p.tile('PR', { bbox3857: bbox, size: 512 }) + const q = seen.searchParams + assert.equal(q.get('service'), 'WMS') + assert.equal(q.get('version'), '1.1.1') + assert.equal(q.get('request'), 'GetMap') + assert.equal(q.get('layers'), 'nexrad-n0q') + assert.equal(q.get('srs'), 'EPSG:3857') + assert.equal(q.get('transparent'), 'true') + assert.equal(q.get('width'), '512') + assert.equal(q.get('bbox'), bbox.join(',')) + assert.ok(Buffer.isBuffer(buf) && buf[0] === 137) + }) +}) + +test('tile without bbox3857 throws', async () => { + await assert.rejects(() => new NoaaPrRadarProvider().tile('PR', {}), /bbox3857/) +}) + +test('unknown product throws', async () => { + await assert.rejects(() => new NoaaPrRadarProvider().latest('CONUS'), /unknown product/) +}) + +test('non-200 WMS throws', () => + withStubFetch(async () => ({ ok: false, status: 500, headers: { get: () => '' } }), async () => { + await assert.rejects(() => new NoaaPrRadarProvider().tile('PR', { bbox3857: [0, 0, 1, 1] }), /HTTP 500/) + })) diff --git a/test/noaa-provider.test.js b/test/noaa-provider.test.js new file mode 100644 index 0000000..9fd896d --- /dev/null +++ b/test/noaa-provider.test.js @@ -0,0 +1,62 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert/strict') +const { NoaaRadarProvider, PRODUCTS, CONUS_BOUNDS } = require('../lib/noaa-provider') + +function withStubFetch (impl, fn) { + const orig = globalThis.fetch + globalThis.fetch = impl + return Promise.resolve().then(fn).finally(() => { globalThis.fetch = orig }) +} +const pngResponse = () => ({ + ok: true, + headers: { get: () => 'image/png' }, + arrayBuffer: async () => Uint8Array.from([137, 80, 78, 71]).buffer +}) + +test('products advertises a display-only COMPOSITE over CONUS', () => { + const p = new NoaaRadarProvider() + const c = PRODUCTS.COMPOSITE + assert.equal(c.kind, 'raster') + assert.equal(c.observationOnly, true) + assert.equal(c.raw, false) + assert.deepEqual(c.bounds, CONUS_BOUNDS) +}) + +test('latest floors to the 5-minute cadence and is UTC ISO', () => { + const p = new NoaaRadarProvider() + return p.latest('COMPOSITE').then((l) => { + assert.equal(l.period, 'PT5M') + assert.equal(l.source, 'noaa') + assert.equal(l.epochMs % (5 * 60 * 1000), 0) + assert.equal(new Date(l.epochMs).toISOString(), l.time) + }) +}) + +test('unknown product throws', async () => { + const p = new NoaaRadarProvider() + await assert.rejects(() => p.latest('NOPE'), /unknown product/) +}) + +test('tile substitutes z/x/y into the URL template and returns the bytes', () => { + let seen = null + return withStubFetch(async (url) => { seen = url; return pngResponse() }, async () => { + const p = new NoaaRadarProvider({ tileUrl: 'https://x/{z}/{x}/{y}.png' }) + const buf = await p.tile('COMPOSITE', { z: 5, x: 8, y: 12 }) + assert.equal(seen, 'https://x/5/8/12.png') + assert.ok(Buffer.isBuffer(buf) && buf[0] === 137) + }) +}) + +test('tile rejects non-integer coordinates', async () => { + const p = new NoaaRadarProvider() + await assert.rejects(() => p.tile('COMPOSITE', { z: 5, x: 'a', y: 12 }), /XYZ tile coordinates/) +}) + +test('tile throws on a non-200 upstream', () => { + return withStubFetch(async () => ({ ok: false, status: 503, headers: { get: () => '' } }), async () => { + const p = new NoaaRadarProvider() + await assert.rejects(() => p.tile('COMPOSITE', { z: 1, x: 0, y: 0 }), /HTTP 503/) + }) +}) From 41d85bad5a5f8e1b354bfb9f5a5c767145874e15 Mon Sep 17 00:00:00 2001 From: fakehec <46029795+fakehec@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:38:49 +0000 Subject: [PATCH 2/2] Smooth IPMA radar tiles with coverage-weighted bilinear warp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderTile sampled the echo grid with nearest-neighbour rounding, so at display zoom each source cell rendered as a hard block with jagged edges. Replace it with a bilinear warp that averages colour only over valid (opaque) source neighbours — transparent no-echo pixels never bleed into the colour — and drives the alpha from the covered fraction of the 2x2 footprint, so echo edges feather out instead of blocking. Adds a regression test asserting the render produces partial-alpha edge pixels (which nearest-neighbour cannot). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ET9sdhUU4uyew792nfV2Sa --- lib/ipma-raster.js | 25 +++++++++++++++++------ test/ipma-raster.test.js | 43 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/lib/ipma-raster.js b/lib/ipma-raster.js index a49e17f..f941ec5 100644 --- a/lib/ipma-raster.js +++ b/lib/ipma-raster.js @@ -153,7 +153,11 @@ function decodeEchoes (buffer) { // --- Rendering ------------------------------------------------------------- const DISPLAY_ALPHA = 190 -// Nearest-neighbour warp of the echo grid into a web-map tile. +// Bilinear warp of the echo grid into a web-map tile, with coverage-weighted +// alpha for soft echo edges. Colour is averaged only over valid (opaque) source +// neighbours, so transparent no-echo pixels never bleed into the colour; the +// covered fraction of the 2x2 footprint drives the alpha, so echo edges fade +// instead of rendering as hard blocks. // `bbox3857 = [minX, minY, maxX, maxY]`. Returns PNG bytes (Buffer). function renderTile (raster, bbox3857, size) { const [minX, minY, maxX, maxY] = bbox3857 @@ -165,12 +169,21 @@ function renderTile (raster, bbox3857, size) { const mx = minX + ((i + 0.5) / size) * (maxX - minX) const [lon, lat] = merToLonLat(mx, my) const [gx, gy] = lonLatToPx(lon, lat) - const xi = Math.round(gx); const yi = Math.round(gy) - if (xi < 0 || yi < 0 || xi >= width || yi >= height) continue - const s = (yi * width + xi) * 4 - if (rgba[s + 3] === 0) continue + const x0 = Math.floor(gx); const y0 = Math.floor(gy) + const fx = gx - x0; const fy = gy - y0 + let ws = 0; let rs = 0; let gs = 0; let bs = 0 + for (let c = 0; c < 4; c++) { + const dx = c & 1; const dy = c >> 1 + const xi = x0 + dx; const yi = y0 + dy + if (xi < 0 || yi < 0 || xi >= width || yi >= height) continue + const s = (yi * width + xi) * 4 + if (rgba[s + 3] === 0) continue + const w = (dx ? fx : 1 - fx) * (dy ? fy : 1 - fy) + ws += w; rs += w * rgba[s]; gs += w * rgba[s + 1]; bs += w * rgba[s + 2] + } + if (ws <= 0) continue const o = (j * size + i) * 4 - out[o] = rgba[s]; out[o + 1] = rgba[s + 1]; out[o + 2] = rgba[s + 2]; out[o + 3] = DISPLAY_ALPHA + out[o] = Math.round(rs / ws); out[o + 1] = Math.round(gs / ws); out[o + 2] = Math.round(bs / ws); out[o + 3] = Math.round(DISPLAY_ALPHA * ws) } } return encodePng(size, size, out) diff --git a/test/ipma-raster.test.js b/test/ipma-raster.test.js index baaed3a..7cd6ebe 100644 --- a/test/ipma-raster.test.js +++ b/test/ipma-raster.test.js @@ -76,6 +76,24 @@ function isPng (buf) { return buf.length > 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 } +// Decode the alpha channel of an RGBA PNG produced by encodePng (filter 0). +function pngAlphas (buf) { + let p = 8; let width = 0; let height = 0; const idat = [] + while (p < buf.length) { + const len = buf.readUInt32BE(p); const type = buf.toString('ascii', p + 4, p + 8) + const data = buf.subarray(p + 8, p + 8 + len) + if (type === 'IHDR') { width = data.readUInt32BE(0); height = data.readUInt32BE(4) } + else if (type === 'IDAT') idat.push(data) + else if (type === 'IEND') break + p += 12 + len + } + const rawimg = zlib.inflateSync(Buffer.concat(idat)) + const stride = width * 4 + 1 + const alphas = [] + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) alphas.push(rawimg[y * stride + 1 + x * 4 + 3]) + return { width, height, alphas } +} + test('decodePalettePng un-filters every filter type and returns exact indices', () => { const w = 4; const h = 5 const grid = [] @@ -129,6 +147,31 @@ test('renderTile paints an echo at its location and stays transparent off-extent assert.deepEqual(empty, transparentPng(32)) }) +test('renderTile bilinear smoothing feathers echo edges (partial alpha)', () => { + // Single opaque echo pixel; zoom in tight so it is upsampled across many + // output pixels. Nearest-neighbour would give only 0 or DISPLAY_ALPHA; the + // coverage-weighted bilinear warp must produce intermediate alpha at the edge. + const width = GEOREF.width; const height = GEOREF.height + const rgba = new Uint8Array(width * height * 4) + const xi = 750; const yi = 1165 + const o = (yi * width + xi) * 4 + rgba[o] = 255; rgba[o + 1] = 0; rgba[o + 2] = 0; rgba[o + 3] = 255 + const raster = { width, height, rgba } + + const [lon, lat] = pxToLonLat(xi, yi) + const R = 6378137 + const mx = (lon * Math.PI / 180) * R + const my = Math.log(Math.tan(Math.PI / 4 + lat * Math.PI / 360)) * R + const d = 4000 // tight ~4 km half-window -> heavy upsampling + const png = renderTile(raster, [mx - d, my - d, mx + d, my + d], 128) + assert.ok(isPng(png)) + const { alphas } = pngAlphas(png) + const drawn = alphas.filter(a => a > 0).length + const partial = alphas.filter(a => a > 0 && a < 190).length + assert.ok(drawn > 0, 'the echo is rendered') + assert.ok(partial > 0, 'edges are feathered with partial alpha (not hard blocks)') +}) + test('encodePng and transparentPng produce valid PNG signatures', () => { assert.ok(isPng(encodePng(2, 2, new Uint8Array(2 * 2 * 4)))) assert.ok(isPng(transparentPng(8)))