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 @@