// Core-Weather for one sample, from source.coop: the hourly (±24 h) and daily (±30 d) rows of its acquisition. // // sample (cell name, timestamp "20221211T114501") -> /year=YYYY/YYYY-MM-DD.parquet (one row group, // rows sorted by grid_cell) -> footer, grid_cell and acquired (whole column, ~20 KB) -> the sample's row range // -> the chart columns for that range only -> one small chart per variable, step 0 marked // ERA5 for temperature, precipitation and cloud: it covers every cell (ERA5-Land is land only, and south of 57.1°S // is not filled yet). import { compressors, newStats, openFile, parquetRead } from './parquet.js' export const WEATHER = 'https://data.source.coop/major-tom/core-weather' // [column, label, source, unit, value as shown, mark]: line (states), bars from zero (amounts), dots (one overpass a day) const HOURLY = [ ['era5_t2m', 'Temperature', 'ERA5, 2 m', '°C', v => v - 273.15, 'line'], ['era5_tp', 'Precipitation', 'ERA5', 'mm', v => v * 1000, 'bars'], ['era5_tcc', 'Cloud cover', 'ERA5', '%', v => v * 100, 'line'], ] const DAILY = [ ['era5_t2m_mean', 'Temperature', 'ERA5, 2 m, daily mean', '°C', v => v - 273.15, 'line'], ['era5_tp_sum', 'Precipitation', 'ERA5', 'mm', v => v * 1000, 'bars'], ['cams_pm2p5_mean', 'PM2.5', 'CAMS', 'µg/m³', v => v * 1e9, 'line'], ['s5p_no2', 'NO₂ column', 'Sentinel-5P', 'µmol/m²', v => v * 1e6, 'dots'], ] const ms = v => (v instanceof Date ? v.getTime() : typeof v === 'bigint' ? Number(v / 1000000n) : Number(v)) /** The rows of one table for (cell, acquired ms): {step: [...], : [...]}, or null if the cell is absent. */ async function readTable(table, day, cell, acquired, columns, step, stats) { const url = `${WEATHER}/${table}/year=${day.slice(0, 4)}/${day}.parquet` const { file, metadata } = await openFile(url, stats) let keys await parquetRead({ file, metadata, compressors, columns: ['grid_cell', 'acquired'], rowFormat: 'object', onComplete(rows) { keys = rows } }) let rowStart = -1, rowEnd = -1 for (let i = 0; i < keys.length; i++) { if (keys[i].grid_cell !== cell || ms(keys[i].acquired) !== acquired) { if (rowStart >= 0) break; continue } if (rowStart < 0) rowStart = i rowEnd = i + 1 } if (rowStart < 0) return null let rows await parquetRead({ file, metadata, compressors, columns: [step, ...columns], rowStart, rowEnd, rowFormat: 'object', onComplete(r) { rows = r } }) const out = { step: rows.map(r => Number(r[step])) } for (const c of columns) out[c] = rows.map(r => (r[c] === null || r[c] === undefined ? null : Number(r[c]))) return out } /** Both tables for one sample: {hourly, daily, stats}; a table is null where Core-Weather has no row. */ export async function loadWeather(cell, timestamp) { const stats = newStats() const t = timestamp, day = `${t.slice(0, 4)}-${t.slice(4, 6)}-${t.slice(6, 8)}` const acquired = Date.UTC(+t.slice(0, 4), +t.slice(4, 6) - 1, +t.slice(6, 8), +t.slice(9, 11), +t.slice(11, 13), +t.slice(13, 15)) const [hourly, daily] = await Promise.all([ readTable('hourly', day, cell, acquired, HOURLY.map(c => c[0]), 'step_h', stats), readTable('daily', day, cell, acquired, DAILY.map(c => c[0]), 'step_d', stats), ]) stats.read = performance.now() - stats.t0 return { hourly, daily, stats } } /** One block per variable: name, source and the value at the acquisition above a chart of the steps; then the * steps' axis. */ export function weatherRows(table, which) { if (!table) return [] const spec = which === 'hourly' ? HOURLY : DAILY, unit = which === 'hourly' ? 'h' : 'd' const blocks = spec.map(([column, label, source, units, show, mark]) => { const values = table[column].map(v => (v === null || Number.isNaN(v) ? null : show(v))) const at0 = values[table.step.indexOf(0)] const block = document.createElement('div') block.className = 'wx-row' block.innerHTML = `
${label} ${source}` + `${at0 === null || at0 === undefined ? '–' : `${fmt(at0)} ${units}`}
` + chart(table.step, values, mark) return block }) const s0 = table.step[0], s1 = table.step[table.step.length - 1] const axis = document.createElement('div') axis.className = 'wx-axis' axis.innerHTML = `${s0} ${unit}acquisition+${s1} ${unit}` return [...blocks, axis] } const fmt = v => (Math.abs(v) >= 100 ? v.toFixed(0) : Math.abs(v) >= 10 ? v.toFixed(1) : v.toFixed(2)) /** A chart of one series over the steps: its own y range (from zero for bars), the acquisition as a vertical line * with a dot, the range's ends written at the right. Drawn at 300 x 44 and scaled as a whole (no distortion). */ function chart(steps, values, mark) { const W = 300, H = 44, L = 2, R = 40, T = 5, B = 5 const known = values.filter(v => v !== null) const empty = `` if (!known.length) return empty let lo = Math.min(...known), hi = Math.max(...known) if (mark === 'bars') lo = 0 if (hi === lo) hi = lo + 1 const s0 = steps[0], s1 = steps[steps.length - 1], step = (W - L - R) / (s1 - s0) const x = s => L + (s - s0) * step const y = v => H - B - ((v - lo) / (hi - lo)) * (H - T - B) let body = '' if (mark === 'bars') { const w = Math.max(step * 0.7, 1) steps.forEach((s, i) => { if (values[i] > 0) body += `` }) body = `${body}` } else if (mark === 'dots') { steps.forEach((s, i) => { if (values[i] !== null) body += `` }) body = `${body}` } else { const runs = [[]] steps.forEach((s, i) => (values[i] === null ? runs.push([]) : runs[runs.length - 1].push([x(s), y(values[i])]))) for (const run of runs.filter(r => r.length)) { const line = run.map(([a, b], k) => `${k ? 'L' : 'M'}${a.toFixed(1)},${b.toFixed(1)}`).join('') body += `` + `` } } const i0 = steps.indexOf(0), v0 = values[i0] const now = `` + (v0 !== null && v0 !== undefined ? `` : '') const ends = `${fmt(hi)}${fmt(lo)}` return `` }