From 889eafcf756787c0357ff35afede271b15c0c99b Mon Sep 17 00:00:00 2001 From: Jonas Hertner Date: Sat, 10 Oct 2026 18:28:34 +0200 Subject: [PATCH 1/3] Add a national water budget page from official sources The new budget page (budget.html, five languages) shows: - Switzerland's annual water balance from BAFU's national table: precipitation, evaporation, outflow and storage change, for the latest year, the 1991-2020 normal and 12 catchments; - the stores: groundwater, lakes, glacier ice, soil and reservoirs; - drinking-water supply and other uses (BFS from SVGW); - current conditions: monthly outflow against the mean, groundwater and lake classes, the weekly drought index by region, reservoir fill and glacier loss. build/19-budget.mjs and build/20-conditions.mjs fetch the official files and run in the weekly refresh. verify-site checks the balance closure and data freshness. Vintage records the three new sources. The method page and the navigation link to the new page. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 + ROADMAP.md | 14 +- build/08-vintage.mjs | 51 ++++++ build/14-pages.py | 14 +- build/19-budget.mjs | 256 ++++++++++++++++++++++++++ build/20-conditions.mjs | 211 ++++++++++++++++++++++ build/lib-zip.mjs | 25 +++ build/pages/index.en.html | 6 +- build/pages/updates.json | 78 +++++++- package.json | 2 +- scripts/check-external-links.mjs | 2 +- scripts/check-performance-budget.mjs | 2 +- scripts/verify-site.mjs | 43 +++-- site/about.html | 8 +- site/budget.html | 259 +++++++++++++++++++++++++++ site/budget.js | 186 +++++++++++++++++++ site/data/budget.json | 1 + site/data/conditions.json | 1 + site/data/provenance.json | 2 +- site/data/reservoirs.json | 2 +- site/data/vintage.json | 2 +- site/de/about.html | 9 +- site/de/budget.html | 195 ++++++++++++++++++++ site/de/index.html | 7 +- site/de/law.html | 9 +- site/de/method.html | 21 ++- site/de/sources.html | 9 +- site/en/index.html | 6 +- site/fr/about.html | 9 +- site/fr/budget.html | 195 ++++++++++++++++++++ site/fr/index.html | 7 +- site/fr/law.html | 9 +- site/fr/method.html | 21 ++- site/fr/sources.html | 9 +- site/i18n-data.js | 78 ++++++++ site/i18n.js | 194 ++++++++++++++++++++ site/index.html | 7 +- site/it/about.html | 9 +- site/it/budget.html | 195 ++++++++++++++++++++ site/it/index.html | 7 +- site/it/law.html | 9 +- site/it/method.html | 21 ++- site/it/sources.html | 9 +- site/law.html | 8 +- site/method.html | 20 ++- site/rm/about.html | 9 +- site/rm/budget.html | 195 ++++++++++++++++++++ site/rm/index.html | 7 +- site/rm/law.html | 9 +- site/rm/method.html | 21 ++- site/rm/sources.html | 9 +- site/site.css | 105 +++++++++++ site/sitemap.xml | 45 +++++ site/sources.html | 8 +- 54 files changed, 2488 insertions(+), 151 deletions(-) create mode 100644 build/19-budget.mjs create mode 100644 build/20-conditions.mjs create mode 100644 build/lib-zip.mjs create mode 100644 site/budget.html create mode 100644 site/budget.js create mode 100644 site/data/budget.json create mode 100644 site/data/conditions.json create mode 100644 site/de/budget.html create mode 100644 site/fr/budget.html create mode 100644 site/it/budget.html create mode 100644 site/rm/budget.html diff --git a/README.md b/README.md index 62a819e..bf733b0 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ primary law and reproducible methods that can support legal action when needed. | Wetlands | Five federal inventories of protected wetlands | | Sources and groundwater | Groundwater bodies, headwaters, sub-catchments and drinking-water protection zones | | Monitoring duties | Federal duties and a 26-canton public-evidence audit; evidence and freshness, not a compliance score | +| Water budget | BAFU's annual national and catchment balance, the stores in ice, lakes, groundwater, soil and reservoirs, public drinking-water supply, and the latest complete month of outflow, groundwater, lake levels and drought index; a separate [page](https://opengovclimate.ch/riverflow/budget.html) that also lists the missing data and who holds it | This is not a forecast, a finding of breach or legal advice. It is also not a complete account of Swiss water. Important omissions are listed on the @@ -144,6 +145,8 @@ node build/12-wetlands.mjs node build/13-basins.mjs node build/17-quality.mjs node build/18-monitoring.mjs +node build/19-budget.mjs +node build/20-conditions.mjs node build/08-vintage.mjs python3 build/14-pages.py build node build/15-social-card.mjs diff --git a/ROADMAP.md b/ROADMAP.md index 3028eba..72d01f1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,16 +19,16 @@ A new layer is ready only when it has: | Pathway | Current state | Next evidence needed | |---|---|---| -| Precipitation | Named in the cycle; not mapped | MeteoSwiss gridded precipitation and station provenance | +| Precipitation | BAFU's annual modelled national and catchment totals on the budget page | MeteoSwiss gridded precipitation, a citable national monthly series and station provenance | | Snow | Named; glacier ice mapped | Operational snow-water equivalent and uncertainty | -| Glacier ice | Six dated inventories | Annual mass balance and basin contribution | -| Soil and infiltration | Gap disclosed | Soil moisture and an explicit recharge model | -| Groundwater | Bodies shown as WMS context | NAQUA levels and quality, spring discharge, recharge | +| Glacier ice | Six dated inventories; national volume from the GLAMOS bulletin | Annual mass balance and volume as an open series, and basin contribution | +| Soil and infiltration | Modelled soil moisture in the weekly drought index | Measured soil moisture and an explicit recharge model | +| Groundwater | Bodies shown as WMS context; monthly levels against each site's own record | Storage, NAQUA quality, spring discharge, recharge | | Rivers | Live gauges plus network estimates | Small streams, travel time, diversions and uncertainty | -| Lakes | Context only | Levels, profiles, quality and residence time | +| Lakes | Monthly levels against each site's own record | Profiles, quality and residence time | | Wetlands | Five federal inventories | Hydrological condition, buffer pressures and restoration | -| Storage and use | Reservoir series and registers | Current permits, withdrawals, return flows and concessions | -| Evapotranspiration | Gap disclosed | Observed or modelled basin water loss | +| Storage and use | Reservoir series, registers, store estimates and annual drinking-water supply | Current permits, withdrawals by sector, return flows and concessions | +| Evapotranspiration | BAFU's annual modelled national and catchment totals | Current gridded actual evapotranspiration | | Borders | Swiss context | Upstream and downstream basin continuity | ## Monitoring and enforcement diff --git a/build/08-vintage.mjs b/build/08-vintage.mjs index ccce769..cd06855 100644 --- a/build/08-vintage.mjs +++ b/build/08-vintage.mjs @@ -103,6 +103,39 @@ const SOURCES = [ fixed: null, // filled from reservoirs.json below: the last row of the file note: 'The file allows only map.geo.admin.ch to read it from a browser, so it is baked in at build time. The Pages workflow rebuilds weekly to keep the baked copy fresh.', }, + { + key: 'waterBalance', layer: null, + name: 'Natural water balance of Switzerland and its large catchments', + holder: 'BAFU', cls: 'modelled', cadence: 'annual; the table is replaced each year', live: false, + url: 'https://www.bafu.admin.ch/de/wasserhaushalt-heute', + links: [ + { label: 'method', url: 'https://www.bafu.admin.ch/dam/de/sd-web/r5wsqRim2y1u/WHH_CH_WEL_2017_3_Methode.pdf' }, + ], + licence: 'No licence stated on the file; federal website terms, with source attribution.', + fixed: null, // filled from budget.json below: the end of the balance year + note: 'PREVAH model output constrained by measured discharge. Read on the water budget page, which keeps every year it has read.', + }, + { + key: 'drought', layer: null, + name: 'Drought platform, numeric data: discharge, groundwater, lake levels and drought index', + holder: 'BAFU, MeteoSwiss, swisstopo', cls: 'measured', cadence: 'daily, weekly and monthly files', live: false, + url: 'https://data.geo.admin.ch/ch.bafu.trockenheitsdaten-numerisch', + links: [ + { label: 'drought platform', url: 'https://www.trockenheit.admin.ch/' }, + ], + licence: 'FSDI general terms of use, free use with source attribution', + fixed: null, // filled from conditions.json below: the platform's own update date + note: 'Only complete weeks and months are used. The drought index is partly modelled: it combines measured precipitation and discharge with modelled soil moisture.', + }, + { + key: 'drinkingWater', layer: null, + name: 'Public drinking-water supply by origin', + holder: 'BFS, from SVGW', cls: 'survey', cadence: 'annual', live: false, + url: 'https://dam-api.bfs.admin.ch/hub/api/dam/assets/36409183/master', + licence: 'BFS terms of use: free use with source attribution', + fixed: null, // filled from budget.json below: the end of the latest year + note: 'An extrapolation from the suppliers that report to the SVGW. Other uses are national estimates for 2006 or carry no year.', + }, { key: 'dams', layer: 'ch.bfe.stauanlagen-bundesaufsicht', name: 'Dams under federal supervision', @@ -294,6 +327,21 @@ try { qualityLatest = q.meta?.sourceLast ?? null; } catch { /* quality not built yet; the field stays null and the page says so */ } +// The water budget dates itself by the period it describes: the end of the balance +// year, the end of the latest drinking-water year, and the drought platform's own +// update date. +let balanceEnd = null, drinkingEnd = null, droughtUpdated = null; +try { + const b = JSON.parse(await fs.readFile(new URL('../site/data/budget.json', import.meta.url), 'utf8')); + balanceEnd = b.year ? `${b.year}-12-31` : null; + const last = b.use?.drinking?.rows?.at(-1); + drinkingEnd = last ? `${last.y}-12-31` : null; +} catch { /* run build/19-budget.mjs first */ } +try { + const c = JSON.parse(await fs.readFile(new URL('../site/data/conditions.json', import.meta.url), 'utf8')); + droughtUpdated = c.meta?.updated ?? null; +} catch { /* run build/20-conditions.mjs first */ } + const sources = []; // The protection zones have no data state of their own. A national picture made of // 26 cantonal deliveries is exactly as current as the canton that delivered last, @@ -315,6 +363,9 @@ for (const s of SOURCES) { if (s.key === 'reservoirFill') ds = fillLatest; if (s.key === 'quality') ds = qualityLatest; if (s.key === 'zones') ds = zoneOldest; + if (s.key === 'waterBalance') ds = balanceEnd; + if (s.key === 'drinkingWater') ds = drinkingEnd; + if (s.key === 'drought') ds = droughtUpdated; sources.push({ key: s.key, name: s.key === 'zones' && zoneCt ? `${s.name} (oldest delivery: ${zoneCt})` : s.name, diff --git a/build/14-pages.py b/build/14-pages.py index 2f97395..414421a 100644 --- a/build/14-pages.py +++ b/build/14-pages.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Five pages in five languages, with German at the publication root. +"""Six pages in five languages, with German at the publication root. The English files under site/ are the originals except for the landing page, whose durable source is build/pages/index.en.html. The translated files are @@ -15,7 +15,7 @@ reads "Law" cannot rewrite the word law in a paragraph below it. python3 build/14-pages.py missing de [page ...] what German still owes - python3 build/14-pages.py build write the twenty files + python3 build/14-pages.py build write the twenty-four files and the sitemap """ import json, os, re, subprocess, sys @@ -24,7 +24,7 @@ HERE = os.path.dirname(os.path.abspath(__file__)) SITE = os.path.join(os.path.dirname(HERE), 'site') TRANS = os.path.join(HERE, 'pages') -PAGES = ['index.html', 'about.html', 'method.html', 'law.html', 'sources.html'] +PAGES = ['index.html', 'budget.html', 'about.html', 'method.html', 'law.html', 'sources.html'] LANGS = ['de', 'fr', 'it', 'rm'] # A chunk stops at any of these. Inline markup (b, i, em, a, code, sub, abbr, br) @@ -185,7 +185,7 @@ def load(lang): # --- the mechanical half --------------------------------------------------- ASSETS = ('fonts.css', 'tokens.css', 'style.css', 'site.css', 'favicon.svg', 'i18n.js', 'i18n-data.js', 'i18n-map.js', 'theme.js', 'fmt.js', 'gschg31.js', - 'legal-alerts.js', 'site.js', 'app.js') + 'legal-alerts.js', 'site.js', 'budget.js', 'app.js') BASE = 'https://opengovclimate.ch/riverflow/' @@ -214,7 +214,7 @@ def relocate(raw, lang, page): # English reading pages keep their stable root URLs, while the English # map now lives at /en/. if lang == 'en': - for target in ('method.html', 'sources.html', 'law.html', 'about.html'): + for target in ('method.html', 'sources.html', 'law.html', 'about.html', 'budget.html'): raw = raw.replace('href="%s' % target, 'href="../%s' % target) alts = [ '' % ('./' if lang == 'en' else '../en/'), @@ -252,7 +252,7 @@ def root_german(raw): for attr in ('href', 'src'): raw = re.sub(r'%s="\.\./%s' % (attr, re.escape(asset)), '%s="%s' % (attr, asset), raw) - for target in ('method.html', 'sources.html', 'law.html', 'about.html'): + for target in ('method.html', 'sources.html', 'law.html', 'about.html', 'budget.html'): raw = raw.replace('href="%s' % target, 'href="de/%s' % target) alts = [ '', @@ -332,7 +332,7 @@ def build(): print('%s/%s: %d chunk(s) untranslated' % (lang, page, len(todo))) owed += len(todo) open(os.path.join(SITE, lang, page), 'w', encoding='utf-8').write(out) - print('%s: five pages written' % lang) + print('%s: %d pages written' % (lang, len(PAGES))) open(os.path.join(SITE, 'index.html'), 'w', encoding='utf-8').write( root_german(open(os.path.join(SITE, 'de', 'index.html'), encoding='utf-8').read())) sitemap() diff --git a/build/19-budget.mjs b/build/19-budget.mjs new file mode 100644 index 0000000..84b7d10 --- /dev/null +++ b/build/19-budget.mjs @@ -0,0 +1,256 @@ +// Build budget.json: the national water balance of Switzerland, as BAFU states it. +// +// Source: BAFU Hydrologie, "Natürlicher Wasserhaushalt der ganzen Schweiz und +// bedeutender Grosseinzugsgebiete", an Excel table linked from the page +// "Wasserhaushalt heute". It gives, for political Switzerland, hydrological +// Switzerland and twelve large catchments, precipitation P, runoff R, +// evapotranspiration E and storage change dS in mm per year, for 1981–2010, +// 1991–2020 and the latest calendar year. The method is Zappa et al. (2017), +// "Wasserhaushalt der Schweiz 2.0": the PREVAH model on a 200 m grid, with measured +// discharge treated as exact and the model error pushed into precipitation. +// +// What the table can claim, and what it cannot: +// +// - It is a model result constrained by measurement, not a measurement. The page +// says so beside every number. +// - It is the natural balance. Abstraction, use and return flows are not in it. +// - BAFU replaces the file each year; earlier years are not republished in it. +// This script keeps every year it has ever read (see `years` below), so the +// site accumulates the series that the source does not. +// - The file name says 2021 while the title and columns say 2024. The year is +// read from the title and the column headers, never from the file name. +// +// The script refuses to write if the table does not close: P − R − E − dS must be +// zero within rounding for every row, and runoff plus inflow from abroad must equal +// total outflow. A layout change at BAFU therefore fails the build rather than +// shifting a column into the wrong term. +// +// Stocks (glaciers, lakes, groundwater, reservoirs, soil) are not in the BAFU table. +// They are listed in STOCKS below, each with its holder, document and pinpoint, and +// the reservoir figures are read from reservoirs.json so that they cannot drift +// from the reservoir layer. +import fs from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { unzip } from './lib-zip.mjs'; + +const PAGE = 'https://www.bafu.admin.ch/de/wasserhaushalt-heute'; +const KNOWN = 'https://www.bafu.admin.ch/dam/de/sd-web/xrKgtJ0MOUQk/tabelle-wasserhaushalt-schweiz-2021.xlsx'; +const OUT = new URL('../site/data/budget.json', import.meta.url); +const SCHEMA = 1; +// Political Switzerland, the area every national mm figure in the table refers to. +// Zappa et al. 2017, Bild 1 (Wasser Energie Luft 109(3), p. 205). +const AREA_KM2 = 41285; +const km3 = mm => Math.round(mm * AREA_KM2 / 1e5) / 10; // mm × km² / 10⁶, one decimal + +// ---- fetch: find the current table on the BAFU page, fall back to the known URL +async function get(url) { + for (let i = 0; i < 3; i++) { + try { + const r = await fetch(url); + if (r.ok) return r; + if (r.status < 500) throw new Error(`${url} ${r.status}`); + } catch (e) { if (i === 2) throw e; } + await new Promise(res => setTimeout(res, 1500 * (i + 1))); + } + throw new Error(`${url} unreachable`); +} +let tableUrl = KNOWN; +try { + const html = await (await get(PAGE)).text(); + const m = /href="([^"]+wasserhaushalt[^"]*\.xlsx)"/i.exec(html); + if (m) tableUrl = new URL(m[1], PAGE).href; + else console.warn('budget: no .xlsx link on the BAFU page; using the last known URL'); +} catch (e) { + console.warn(`budget: BAFU page unreachable (${e.message}); using the last known URL`); +} +const buf = Buffer.from(await (await get(tableUrl)).arrayBuffer()); +const sha256 = createHash('sha256').update(buf).digest('hex'); + +const zip = unzip(buf); +const xml = name => { + if (!zip[name]) throw new Error(`table has no ${name}`); + return zip[name].toString('utf8'); +}; +const ent = s => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') + .replace(/'/g, "'").replace(/&#(\d+);/g, (_, d) => String.fromCharCode(+d)).replace(/&/g, '&'); +const shared = [...xml('xl/sharedStrings.xml').matchAll(/([\s\S]*?)<\/si>/g)] + .map(m => ent([...m[1].matchAll(/]*>([\s\S]*?)<\/t>/g)].map(t => t[1]).join(''))); +const cells = {}; +for (const m of xml('xl/worksheets/sheet1.xml').matchAll(/]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) { + const v = /([\s\S]*?)<\/v>/.exec(m[4] ?? ''); + if (!v) continue; + (cells[m[2]] ??= {})[m[1]] = /t="s"/.test(m[3]) ? shared[+v[1]] : /t="str"/.test(m[3]) ? ent(v[1]) : +v[1]; +} +const rows = Object.entries(cells).map(([r, c]) => ({ r: +r, c })).sort((a, b) => a.r - b.r); + +// ---- read: the year from the title, then check the column headers agree +const title = rows.find(x => typeof x.c.A === 'string' && /Wasserhaushalt/.test(x.c.A))?.c.A; +const year = +(/für (\d{4})/.exec(title ?? '')?.[1] ?? NaN); +if (!Number.isInteger(year)) throw new Error(`budget: no year in the table title (${title})`); +const COLS = { P: ['B', 'C', 'D'], R: ['E', 'F', 'G'], E: ['H', 'I', 'J'], dS: ['K', 'L', 'M'] }; +const head = rows.find(x => Object.values(COLS).every(([, b, c]) => x.c[b] === 1991 && x.c[c] === year)); +if (!head) throw new Error(`budget: column headers are not 1981 | 1991 | ${year} in four groups; the layout changed`); +const stamp = rows.map(x => x.c.A).find(a => typeof a === 'string' && /Bundesamt für Umwelt/.test(a)) ?? ''; + +const PERIODS = ['1981-2010', '1991-2020', String(year)]; +const label = s => s.trim(); +function balance(x) { + const out = {}; + PERIODS.forEach((p, i) => { + const t = {}; + for (const [k, cols] of Object.entries(COLS)) { + const v = x.c[cols[i]]; + if (typeof v !== 'number' || !Number.isFinite(v)) throw new Error(`budget: ${label(x.c.A)} ${k} ${p} is not a number`); + t[k] = Math.round(v); + } + // Closure. The source rounds each term to whole mm, so allow 2 mm. + const gap = Math.round(x.c[COLS.P[i]] - x.c[COLS.R[i]] - x.c[COLS.E[i]] - x.c[COLS.dS[i]]); + if (Math.abs(gap) > 2) throw new Error(`budget: ${label(x.c.A)} ${p} does not close (P−R−E−dS = ${gap} mm)`); + out[p] = t; + }); + return out; +} + +const iNat = rows.findIndex(x => typeof x.c.A === 'string' && /^Politische Schweiz/.test(label(x.c.A))); +const iHyd = rows.findIndex(x => typeof x.c.A === 'string' && /^Hydrologische Schweiz/.test(label(x.c.A))); +if (iNat < 0 || iHyd < 0) throw new Error('budget: national rows not found'); +const national = balance(rows[iNat]); +const hydrological = balance(rows[iHyd]); + +// Inflow from abroad and total outflow sit between the two national rows, with +// their three values in whichever columns the sheet happens to use. They are taken +// in column order and must satisfy R + inflow = outflow in every period. +const numbersOf = x => Object.keys(x.c).filter(k => k !== 'A' && typeof x.c[k] === 'number') + .sort((a, b) => a.length - b.length || a.localeCompare(b)).map(k => x.c[k]); +const between = rows.slice(iNat + 1, iHyd); +const inRow = between.find(x => /Zufluss aus dem Ausland/.test(x.c.A ?? '')); +const outRow = between.find(x => /Gesamtabfluss/.test(x.c.A ?? '')); +if (!inRow || !outRow) throw new Error('budget: inflow or total outflow row not found'); +const inflow = numbersOf(inRow), outflow = numbersOf(outRow); +if (inflow.length !== 3 || outflow.length !== 3) throw new Error('budget: inflow/outflow rows do not have three periods'); +PERIODS.forEach((p, i) => { + national[p].inflow = Math.round(inflow[i]); + national[p].outflow = Math.round(outflow[i]); + const gap = national[p].R + national[p].inflow - national[p].outflow; + if (Math.abs(gap) > 2) throw new Error(`budget: ${p} runoff + inflow ≠ total outflow (${gap} mm)`); +}); +for (const p of PERIODS) { + const t = national[p]; + t.km3 = { P: km3(t.P), R: km3(t.R), E: km3(t.E), dS: km3(t.dS), inflow: km3(t.inflow), outflow: km3(t.outflow) }; +} + +// The large catchments are in mm over their own area, which the table does not +// state, so they are never converted to volumes here. +const basins = rows.slice(0, iNat) + .filter(x => typeof x.c.A === 'string' && /-/.test(x.c.A) && typeof x.c.B === 'number') + .map(x => ({ name: label(x.c.A), ...balance(x) })); +if (basins.length < 10) throw new Error(`budget: only ${basins.length} catchment rows found`); + +// ---- stocks: not in the table. Each with holder, document and pinpoint. +const res = JSON.parse(await fs.readFile(new URL('../site/data/reservoirs.json', import.meta.url), 'utf8')); +const STOCKS = [ + { + key: 'groundwater', km3: 150, approx: true, cls: 'estimate', date: '2021', + usable: 18, usableNote: 'km³ per year sustainably usable', + holder: 'NCCS, BAFU, MeteoSwiss', doc: 'Hydro-CH2018 brochure “Schweizer Gewässer im Klimawandel”, 2021', pin: 'p. 7', + url: 'https://www.bafu.admin.ch/dam/de/sd-web/IX5Cqzys7Y9C/de_NCCS_Broschuere_Hydro-CH2018%20(3).pdf', + note: 'An estimate going back to Sinreich et al. (2012). Zappa et al. (2017, p. 208) describe the 18 km³ as the usable part of the store each year; the brochure as a sustainably usable volume per year. Not a measured stock.', + }, + { + key: 'lakes', km3: 130, approx: true, cls: 'estimate', date: '2021', + usable: 2, usableNote: 'km³ per year sustainably usable', + holder: 'NCCS, BAFU, MeteoSwiss', doc: 'Hydro-CH2018 brochure', pin: 'p. 7', + url: 'https://www.bafu.admin.ch/dam/de/sd-web/IX5Cqzys7Y9C/de_NCCS_Broschuere_Hydro-CH2018%20(3).pdf', + note: 'Counts the whole volume of the border lakes, including the parts in France, Germany, Austria and Italy. BAFU’s own total of about 325 km³ for all stores counts only the Swiss shares. The two are not reconciled in the sources.', + }, + { + key: 'glaciers', km3: 45.1, approx: true, cls: 'model', date: '2025-09-30', + lossLast: 1.4, lossLastPct: 3.0, lossYear: 2025, km3In2000: 74.9, + holder: 'GLAMOS', doc: 'Swiss Glacier Bulletin 2025 (Annual Report 146), February 2026', pin: 'p. 10, Fig. 3.6', + url: 'https://doi.glamos.ch/pubs/glbulletin/glbulletin_2025.pdf', + note: 'Ice volume, not water equivalent. Mass balance measured on a set of glaciers and extrapolated over a 2021 ice-thickness inventory. The 2000 volume is from the 2024 bulletin, Fig. 3.6.', + }, + { + key: 'soil', km3: 9, approx: false, upTo: true, cls: 'estimate', date: '2014', + holder: 'BAFU', doc: 'Zappa et al., “Wasserhaushalt der Schweiz 2.0”, Wasser Energie Luft 109(3), 2017', pin: 'p. 208', + url: 'https://www.bafu.admin.ch/dam/de/sd-web/r5wsqRim2y1u/WHH_CH_WEL_2017_3_Methode.pdf', + note: 'An upper bound for water held in the soil, after Jörg-Hess et al. (2014). The amount actually present varies through the year.', + }, + { + key: 'reservoirs', km3: Math.round(res.totals.volumeMioM3 / 100) / 10, approx: false, cls: 'register', + date: res.built, usable: 3.5, usableNote: 'km³ usable (Hydro-CH2018 brochure, p. 6)', + fill: { d: res.fill.latest.d, pct: res.fill.latest.pct, gwh: res.fill.latest.gwh, max: res.fill.latest.max, rank: res.fill.latest.rank }, + holder: 'BFE', doc: 'Register of dams under federal supervision; weekly filling level of the storage lakes', pin: 'reservoirs.json on this site', + url: 'https://www.uvek-gis.admin.ch/BFE/ogd/17/ogd17_fuellungsgrad_speicherseen.csv', + note: 'The volume is the sum of full-supply volumes of the 225 federally supervised dams, including weirs and flood-retention structures. The weekly filling level is reported in GWh of stored energy, not in cubic metres, so it is shown as a share and never converted to a volume.', + }, +]; + +// ---- use: what is taken out. Drinking water is a federal table; the rest is old. +// BFS T 02.03.01.01 "Trinkwasser", an SVGW extrapolation from reporting utilities. +// Spring + ground + lake water must add to the total, or the layout changed. +const BFS = 'https://dam-api.bfs.admin.ch/hub/api/dam/assets/36409183/master'; +const bz = unzip(Buffer.from(await (await get(BFS)).arrayBuffer())); +const bss = [...bz['xl/sharedStrings.xml'].toString('utf8').matchAll(/([\s\S]*?)<\/si>/g)] + .map(m => ent([...m[1].matchAll(/]*>([\s\S]*?)<\/t>/g)].map(t => t[1]).join(''))); +const brows = []; +for (const r of bz['xl/worksheets/sheet1.xml'].toString('utf8').matchAll(/]*>([\s\S]*?)<\/row>/g)) { + const c = {}; + for (const m of r[1].matchAll(/]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) { + const v = /([\s\S]*?)<\/v>/.exec(m[3] ?? ''); + if (v) c[m[1]] = /t="s"/.test(m[2]) ? bss[+v[1]] : +v[1]; + } + brows.push(c); +} +if (!/Quell/.test(brows.map(c => c.B).join(' ')) || !/Grund/.test(brows.map(c => c.C).join(' '))) + throw new Error('budget: BFS drinking-water columns are not spring | ground | lake | total'); +const drinking = brows.filter(c => Number.isInteger(c.A) && c.A >= 2000 && [c.B, c.C, c.D, c.E, c.F].every(Number.isFinite)) + .map(c => { + if (Math.abs(c.B + c.C + c.D - c.E) > 2) throw new Error(`budget: BFS ${c.A} sources do not add to the total`); + return { y: c.A, spring: +c.B.toFixed(1), ground: +c.C.toFixed(1), lake: +c.D.toFixed(1), total: +c.E.toFixed(1), lpd: +c.F.toFixed(1) }; + }); +if (!drinking.length) throw new Error('budget: no drinking-water rows'); +const bnote = brows.map(c => c.A).filter(a => typeof a === 'string'); +const USE = { + drinking: { + rows: drinking, holder: 'BFS, from SVGW', url: BFS, table: 'T 02.03.01.01', + basis: bnote.find(a => /Jahr \d{4}: Hochrechnung/.test(a)) ?? null, + changed: (/Letzte Änderung:\s*(\d\d)\.(\d\d)\.(\d{4})/.exec(bnote.join(' ')) ?? []).slice(1).reverse().join('-') || null, + }, + // BAFU, "Wassernutzung", page of 25 September 2025. These are the only national + // figures for the other sectors, and most describe 2006. They are shown with + // their year and never added to the drinking-water total. + other: [ + { key: 'industry', mioM3: 1120, year: 2006, note: 'Self-supply of industry, trade and services; about two thirds for cooling and air conditioning.' }, + { key: 'nuclear', mioM3: 1600, year: null, note: 'River water passed through the cooling of nuclear power plants, per year; the page gives no year. Mostly returned, warmer.' }, + { key: 'agriculture', mioM3: 410, year: 2006, note: 'Agricultural demand; about one third for irrigation. Abstracted volumes are measured in only a few cantons.' }, + { key: 'snow', mioM3: 15, year: 2006, note: 'Water used to make snow on ski runs.' }, + ], + otherSource: { holder: 'BAFU', page: 'Wassernutzung', date: '2025-09-25', url: 'https://www.bafu.admin.ch/de/wassernutzung' }, +}; + +// ---- keep every year ever read: BAFU overwrites its file, this file does not +let years = {}; +try { + const old = JSON.parse(await fs.readFile(OUT, 'utf8')); + if (old.meta?.schema === SCHEMA) years = old.years ?? {}; +} catch { /* first build */ } +years[year] = { ...national[String(year)], sourceSha256: sha256, sourceStamp: stamp }; + +const out = { + meta: { + schema: SCHEMA, built: new Date().toISOString().slice(0, 10), + source: { title: title.trim(), stamp, url: tableUrl, page: PAGE, sha256, holder: 'BAFU, Abteilung Hydrologie', contact: 'hydrologie@bafu.admin.ch' }, + method: { doc: 'Zappa et al., “Wasserhaushalt der Schweiz 2.0”, Wasser Energie Luft 109(3), 2017, pp. 203–211', url: 'https://www.bafu.admin.ch/dam/de/sd-web/r5wsqRim2y1u/WHH_CH_WEL_2017_3_Methode.pdf' }, + areaKm2: AREA_KM2, hydrologicalAreaKm2: 53566, + }, + year, periods: PERIODS, national, hydrological, basins, years, stocks: STOCKS, use: USE, +}; +await fs.writeFile(OUT, JSON.stringify(out)); + +const t = national[String(year)], n = national['1991-2020']; +console.log(`water budget ${year} (BAFU table, ${stamp || 'undated'}; sha256 ${sha256.slice(0, 12)})`); +console.log(` P ${t.P} mm (${t.km3.P} km³), ${Math.round(100 * t.P / n.P)} % of 1991–2020`); +console.log(` R ${t.R} E ${t.E} dS ${t.dS} inflow ${t.inflow} outflow ${t.outflow} mm`); +console.log(` ${basins.length} catchments, ${Object.keys(years).length} year(s) kept`); +console.log(` drinking water ${drinking.at(-1).y}: ${drinking.at(-1).total} million m³`); diff --git a/build/20-conditions.mjs b/build/20-conditions.mjs new file mode 100644 index 0000000..1fa8c75 --- /dev/null +++ b/build/20-conditions.mjs @@ -0,0 +1,211 @@ +// Build conditions.json: where the national water budget stands now. +// +// The BAFU balance (19-budget.mjs) is annual and arrives nine months after the +// year ends. This file answers the question in between, from the numeric data of +// the federal drought platform (BAFU, MeteoSwiss, swisstopo; trockenheit.admin.ch), +// published weekly on data.geo.admin.ch as zipped CSV: +// +// outflow monthly mean discharge of the Rhine at Basel (2289) and the Rhône +// at Chancy (2174), the two gauges where most Swiss water leaves +// the country, against the 1991–2020 mean for the same month; +// groundwater NAQUA levels and spring discharges, each against its own record +// for the same calendar month; +// lakes lake levels, the same way; +// drought the combined drought index (CDI, 1 not dry to 5 extremely dry) +// and modelled soil moisture for the 38 drought regions. +// +// Rules this script keeps: +// +// - Only complete periods. The source dates a week or month by its first day +// (Handbuch "Numerische Daten der Trockenheitsplattform", v1.1, 18.05.2026, +// 3.3.2), and its current files include the running week and month. A month is +// used only once it has ended before the file's own update date. +// - Every comparison is against the station's own record for the same calendar +// month, computed here from the published monthly means of 1991–2020, and only +// where at least MIN_YEARS of that window exist. The platform's own thresholds +// are used for one thing only: to confirm that a low value means dry water at +// that station (minimum below the 20th percentile in every month). A station +// that fails the check is left out and counted, not guessed at. +// - A level or a percentile is not a volume. Nothing here is converted to km³ +// except the outflow, where discharge × time is a volume by definition. +// +// The station classes (which ids are groundwater sites, which are lakes) come from +// the two hydrodaten map feeds. They are used for names and classes only, never for +// values. +import fs from 'node:fs/promises'; +import { unzip } from './lib-zip.mjs'; + +const BASE = 'https://data.geo.admin.ch/ch.bafu.trockenheitsdaten-numerisch'; +const ZIP = item => `${BASE}/trockenheitsdaten-numerisch_${item}/trockenheitsdaten-numerisch_${item}.csv.zip`; +const FEED = f => `https://www.hydrodaten.admin.ch/web-hydro-maps/${f}`; +const OUT = new URL('../site/data/conditions.json', import.meta.url); +const OUTFLOW = [ + { id: '2289', name: 'Rhein – Basel, Rheinhalle' }, + { id: '2174', name: 'Rhône – Chancy, Aux Ripes' }, +]; +const REF = [1991, 2020]; +const MIN_YEARS = 15; + +async function get(url, kind = 'buffer') { + for (let i = 0; i < 3; i++) { + try { + const r = await fetch(url); + if (r.ok) return kind === 'json' ? r.json() : Buffer.from(await r.arrayBuffer()); + if (r.status < 500) throw new Error(`${url} ${r.status}`); + } catch (e) { if (i === 2) throw e; } + await new Promise(res => setTimeout(res, 2000 * (i + 1))); + } + throw new Error(`${url} unreachable`); +} + +// ---- read the three archives +const csv = (files, name) => { + const b = Object.entries(files).find(([k]) => k === name || k.endsWith(`/${name}`))?.[1]; + if (!b) throw new Error(`conditions: ${name} missing from archive`); + const lines = b.toString('utf8').split(/\r?\n/); + const updated = lines.map(l => /letzte Aktualisierung:\s*(\d\d)\.(\d\d)\.(\d{4})/.exec(l)).find(Boolean); + const body = lines.filter(l => l && !l.startsWith('#')); + const head = body.shift().split(';'); + return { updated: updated ? `${updated[3]}-${updated[2]}-${updated[1]}` : null, + rows: body.map(l => Object.fromEntries(l.split(';').map((v, i) => [head[i], v]))) }; +}; +const current = unzip(await get(ZIP('current'))); +const historic = unzip(await get(ZIP('historic'))); +const reference = unzip(await get(ZIP('reference'))); + +const curStations = csv(current, 'monthly_current_stations.csv'); +const updated = curStations.updated; +if (!updated) throw new Error('conditions: the current file carries no update date'); +const iso = d => { const [dd, mm, yyyy] = d.split('.'); return `${yyyy}-${mm}-${dd}`; }; +const ym = d => iso(d).slice(0, 7); + +// The latest complete month is the one before the month of the update date. +const u = new Date(`${updated}T00:00:00Z`); +const lastMonth = new Date(Date.UTC(u.getUTCFullYear(), u.getUTCMonth() - 1, 1)).toISOString().slice(0, 7); +const addMonths = (m, k) => { const [y, mo] = m.split('-').map(Number); return new Date(Date.UTC(y, mo - 1 + k, 1)).toISOString().slice(0, 7); }; +const daysIn = m => { const [y, mo] = m.split('-').map(Number); return new Date(Date.UTC(y, mo, 0)).getUTCDate(); }; + +// ---- monthly series per station: historic first, current overrides +const series = new Map(); // id -> { unit, label, v: Map(ym -> value) } +for (const src of [csv(historic, 'monthly_historic_stations.csv').rows, curStations.rows]) { + for (const r of src) { + const v = parseFloat(r.value); + if (!Number.isFinite(v)) continue; + const m = ym(r.measured_at); + if (m > lastMonth) continue; // the running month is never used + let s = series.get(r.hydro_station_id); + if (!s) series.set(r.hydro_station_id, s = { unit: r.unit, label: r.label, v: new Map() }); + s.v.set(m, v); + } +} + +// The platform's own reference thresholds, used only to confirm direction. +const refRows = csv(reference, 'monthly_reference_stations.csv').rows; +const lowIsDry = new Map(refRows.map(r => { + let ok = true; + for (let k = 1; k <= 12; k++) { + const mn = parseFloat(r[`min_${k}`]), th = parseFloat(r[`threshold1_${k}`]); + if (Number.isFinite(mn) && Number.isFinite(th) && mn > th) ok = false; + } + return [r.hydro_station_id, ok]; +})); + +// Empirical quantile, linear between order statistics. +const quantile = (xs, q) => { + const a = [...xs].sort((x, y) => x - y), h = (a.length - 1) * q, lo = Math.floor(h); + return a[lo] + (a[Math.min(lo + 1, a.length - 1)] - a[lo]) * (h - lo); +}; +function climatology(s, m) { + const mo = m.slice(5); + const xs = []; + for (let y = REF[0]; y <= REF[1]; y++) { const v = s.v.get(`${y}-${mo}`); if (v !== undefined) xs.push(v); } + if (xs.length < MIN_YEARS) return null; + return { n: xs.length, mean: xs.reduce((a, b) => a + b, 0) / xs.length, p20: quantile(xs, 0.2), p80: quantile(xs, 0.8) }; +} + +// ---- outflow at the two main border gauges +const outflowMonths = []; +for (let k = 23; k >= 0; k--) { + const m = addMonths(lastMonth, -k); + const q = OUTFLOW.map(g => series.get(g.id)?.v.get(m)); + const c = OUTFLOW.map(g => series.has(g.id) ? climatology(series.get(g.id), m) : null); + if (q.some(v => v === undefined) || c.some(x => !x)) throw new Error(`conditions: outflow gauge missing for ${m}`); + const qs = q.reduce((a, b) => a + b, 0), ns = c.reduce((a, b) => a + b.mean, 0); + outflowMonths.push({ m, q: Math.round(qs), norm: Math.round(ns), pct: Math.round(100 * qs / ns), + byGauge: q.map(v => Math.round(v)) }); +} +const vol = (rows, key) => rows.reduce((a, r) => a + r[key] * daysIn(r.m) * 86400, 0) / 1e9; +const last12 = outflowMonths.slice(-12); +const outflow = { + gauges: OUTFLOW, months: outflowMonths, + last12: { from: last12[0].m, to: last12.at(-1).m, + km3: Math.round(vol(last12, 'q') * 10) / 10, normKm3: Math.round(vol(last12, 'norm') * 10) / 10 }, +}; +outflow.last12.pct = Math.round(100 * outflow.last12.km3 / outflow.last12.normKm3); + +// ---- groundwater and lakes, each against its own same-month record +const [pq, gw] = await Promise.all([get(FEED('hydro_sensor_pq.geojson'), 'json'), get(FEED('hydro_sensor_pq_groundwater.geojson'), 'json')]); +const props = f => f.properties ?? {}; +const lakeIds = new Map(pq.features.filter(f => props(f).kind === 'lake').map(f => [String(props(f).key), props(f).label])); +const gwIds = new Map(gw.features.map(f => [String(props(f).key), props(f).label])); +if (lakeIds.size < 10 || gwIds.size < 20) throw new Error(`conditions: station feeds look incomplete (${lakeIds.size} lakes, ${gwIds.size} groundwater sites)`); + +function classify(ids, kind) { + const stations = [], skipped = { noRecent: 0, shortRecord: 0, direction: 0 }; + for (const [id, name] of ids) { + const s = series.get(id); + if (!s || !s.v.has(lastMonth)) { skipped.noRecent++; continue; } + if (lowIsDry.get(id) === false) { skipped.direction++; continue; } + const c = climatology(s, lastMonth); + if (!c) { skipped.shortRecord++; continue; } + const v = s.v.get(lastMonth); + stations.push({ id, name, unit: s.unit, kind: kind === 'gw' ? (s.label === 'Abfluss' ? 'spring' : 'level') : 'lake', + v: +v.toFixed(3), p20: +c.p20.toFixed(3), p80: +c.p80.toFixed(3), years: c.n, + state: v < c.p20 ? 'low' : v > c.p80 ? 'high' : 'normal' }); + } + stations.sort((a, b) => a.name.localeCompare(b.name, 'de')); + const count = st => stations.filter(x => x.state === st).length; + return { month: lastMonth, n: stations.length, low: count('low'), normal: count('normal'), high: count('high'), skipped, stations }; +} +const groundwater = classify(gwIds, 'gw'); +const lakes = classify(lakeIds, 'lake'); + +// ---- drought regions: the latest complete week with a CDI in nearly every region +const weekly = csv(current, 'weekly_current_regions.csv').rows; +const regionNames = new Map(csv(reference, 'regions.csv').rows.map(r => [r.drought_region_id, r.name_de])); +const byWeek = new Map(); +for (const r of weekly) { + const d = iso(r.measured_at); + if (Date.parse(d) + 7 * 86400000 > Date.parse(updated)) continue; // week not complete + (byWeek.get(d) ?? byWeek.set(d, []).get(d)).push(r); +} +const regionsN = regionNames.size; +const weeks = [...byWeek.keys()].sort() + .filter(d => byWeek.get(d).filter(r => r.cdi !== '').length >= 0.9 * regionsN); +if (!weeks.length) throw new Error('conditions: no complete week with a drought index'); +const levels = rows => [1, 2, 3, 4, 5].map(l => rows.filter(r => +r.cdi === l).length); +const lastWeek = weeks.at(-1), lw = byWeek.get(lastWeek); +const soil = lw.map(r => parseFloat(r.soil_moisture_ufc)).filter(Number.isFinite); +const drought = { + week: lastWeek, regions: regionsN, levels: levels(lw), + soilMedian: soil.length ? Math.round(quantile(soil, 0.5)) : null, + driest: lw.filter(r => +r.cdi >= 3).map(r => ({ id: r.drought_region_id, name: regionNames.get(r.drought_region_id) ?? r.drought_region_id, cdi: +r.cdi })) + .sort((a, b) => b.cdi - a.cdi || a.name.localeCompare(b.name, 'de')), + weeks: weeks.slice(-52).map(d => [d, ...levels(byWeek.get(d))]), +}; + +const out = { + meta: { + built: new Date().toISOString().slice(0, 10), updated, lastMonth, ref: REF, minYears: MIN_YEARS, + source: { holder: 'BAFU, MeteoSwiss, swisstopo', name: 'Trockenheitsplattform – numerische Daten', url: `${BASE}`, page: 'https://www.trockenheit.admin.ch/' }, + }, + outflow, groundwater: { ...groundwater }, lakes: { ...lakes }, drought, +}; +await fs.writeFile(OUT, JSON.stringify(out)); + +const o = outflow.months.at(-1); +console.log(`conditions, source updated ${updated}; latest complete month ${lastMonth}`); +console.log(` outflow Basel + Chancy ${o.q} m³/s = ${o.pct} % of the ${REF.join('–')} mean for the month; 12 months ${outflow.last12.km3} km³ (${outflow.last12.pct} %)`); +console.log(` groundwater ${groundwater.n} sites: ${groundwater.low} low, ${groundwater.normal} normal, ${groundwater.high} high; skipped ${JSON.stringify(groundwater.skipped)}`); +console.log(` lakes ${lakes.n}: ${lakes.low} low, ${lakes.normal} normal, ${lakes.high} high; skipped ${JSON.stringify(lakes.skipped)}`); +console.log(` drought week ${lastWeek}: CDI levels 1–5 ${drought.levels.join('/')} of ${regionsN}; median soil moisture ${drought.soilMedian} %`); diff --git a/build/lib-zip.mjs b/build/lib-zip.mjs new file mode 100644 index 0000000..fb6e0b6 --- /dev/null +++ b/build/lib-zip.mjs @@ -0,0 +1,25 @@ +// Read a zip archive (an .xlsx is one) from a Buffer without a dependency: walk the +// central directory and inflate each entry. Returns { name: Buffer }. Entries in +// an unsupported compression method come back as null rather than as garbage. +import zlib from 'node:zlib'; + +export function unzip(b) { + let eocd = b.length - 22; + while (eocd >= 0 && b.readUInt32LE(eocd) !== 0x06054b50) eocd--; + if (eocd < 0) throw new Error('not a zip file'); + const n = b.readUInt16LE(eocd + 10); + let p = b.readUInt32LE(eocd + 16); + const files = {}; + for (let i = 0; i < n; i++) { + if (b.readUInt32LE(p) !== 0x02014b50) throw new Error('bad zip directory'); + const method = b.readUInt16LE(p + 10), size = b.readUInt32LE(p + 20); + const nameLen = b.readUInt16LE(p + 28), extraLen = b.readUInt16LE(p + 30), commentLen = b.readUInt16LE(p + 32); + const local = b.readUInt32LE(p + 42); + const name = b.toString('utf8', p + 46, p + 46 + nameLen); + const start = local + 30 + b.readUInt16LE(local + 26) + b.readUInt16LE(local + 28); + const raw = b.subarray(start, start + size); + files[name] = method === 8 ? zlib.inflateRawSync(raw) : method === 0 ? raw : null; + p += 46 + nameLen + extraLen + commentLen; + } + return files; +} diff --git a/build/pages/index.en.html b/build/pages/index.en.html index ca1d16f..a139ff8 100644 --- a/build/pages/index.en.html +++ b/build/pages/index.en.html @@ -34,8 +34,8 @@ - - + + @@ -71,6 +71,7 @@

Water and ice in Switzerland