From 2e360bd2e3059aa515f8df763de9b6404caeb16a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 16:08:02 +0000 Subject: [PATCH 1/2] Add a TRMNL-friendly flood risk display page Adds a minimal, high-contrast /trmnl/ page rendering a 5-day bar chart of max daily flood risk, sized for an 800x480 e-ink display. Uses plain server-rendered HTML/CSS with no JS or color gradients, so it works well as a periodic screenshot on a TRMNL device. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016NVMretUFqu4jZQxyamJK6 --- .../templates/webapp/trmnl_display.html | 85 +++++++++++++++++++ manyfews/webapp/tests.py | 8 ++ manyfews/webapp/urls.py | 1 + manyfews/webapp/views.py | 39 +++++++++ 4 files changed, 133 insertions(+) create mode 100644 manyfews/webapp/templates/webapp/trmnl_display.html diff --git a/manyfews/webapp/templates/webapp/trmnl_display.html b/manyfews/webapp/templates/webapp/trmnl_display.html new file mode 100644 index 00000000..48aa04a7 --- /dev/null +++ b/manyfews/webapp/templates/webapp/trmnl_display.html @@ -0,0 +1,85 @@ +{% load i18n %} + + + + + + {% trans "Flood Risk Forecast" %} + + + +
+

{% trans "Flood Risk – Next 5 Days" %}

+

{% trans "Updated" %} {{ generated_at|date:"D j M, H:i" }}

+
+ {% for d in daily_risks %} +
+
{{ d.max_percentage_risk }}%
+
+
{{ d.date|date:"D" }}
{{ d.date|date:"j M" }}
+
+ {% endfor %} +
+
+ + diff --git a/manyfews/webapp/tests.py b/manyfews/webapp/tests.py index cc134bc9..9adfefbd 100644 --- a/manyfews/webapp/tests.py +++ b/manyfews/webapp/tests.py @@ -6,6 +6,7 @@ from django.contrib.staticfiles.testing import StaticLiveServerTestCase from django.core import mail from django.test import TestCase, LiveServerTestCase +from django.urls import reverse from selenium import webdriver from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.by import By @@ -50,6 +51,13 @@ def test_check_verification_code_requires_approved_status(self, client_cls): assert twilio_alerts.check_verification_code("+441234567890", "123456") is False +class TrmnlDisplayTests(TestCase): + def test_trmnl_display_returns_5_day_bars(self): + response = self.client.get(reverse("trmnl_display")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content.decode().count("bar-column"), 5) + + class ConverterTestCase(TestCase): def test_bounding_box_url_parameter_converter(self): converter = BoundingBoxUrlParameterConverter() diff --git a/manyfews/webapp/urls.py b/manyfews/webapp/urls.py index ef8277d4..318d8a8e 100644 --- a/manyfews/webapp/urls.py +++ b/manyfews/webapp/urls.py @@ -12,6 +12,7 @@ name="depths", ), path("river-flows", views.river_flows, name="river_flows"), + path("trmnl/", views.trmnl_display, name="trmnl_display"), path("alerts/verify", views.verify_alert, name="verify"), path( "alerts/resend-verification/", views.resend_verification, name="verify" diff --git a/manyfews/webapp/views.py b/manyfews/webapp/views.py index 87fd718c..0194fe16 100644 --- a/manyfews/webapp/views.py +++ b/manyfews/webapp/views.py @@ -96,6 +96,45 @@ def index(request): return HttpResponse(template.render({"daily_risks": daily_risks}, request)) +def _max_daily_risks(num_days): + """ + Return `num_days` dicts of {day_number, date, max_risk, max_percentage_risk} + starting today, where max_risk is the maximum PercentageFloodRisk.risk + across that day's four 6-hourly readings (0/6/12/18h), defaulting to 0 + when a reading is missing. + """ + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + results = [] + for i in range(num_days): + day = today + timedelta(days=i) + risks = [] + for j in range(4): + percentage_flood_risk = PercentageFloodRisk.objects.filter( + date=day + timedelta(hours=j * 6) + ).first() + risks.append(percentage_flood_risk.risk if percentage_flood_risk else 0) + max_risk = max(risks) + results.append( + { + "day_number": i, + "date": day, + "max_risk": max_risk, + "max_percentage_risk": round(max_risk * 100), + } + ) + return results + + +def trmnl_display(request): + template = loader.get_template("webapp/trmnl_display.html") + return HttpResponse( + template.render( + {"daily_risks": _max_daily_risks(5), "generated_at": timezone.now()}, + request, + ) + ) + + def depth_predictions(request, day, hour, bounding_box): # Get the depth predictions for this bounding box and day days ahead today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) From 96e9e6985ca53dadacab0e1e00f754090a419b67 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 16:15:53 +0000 Subject: [PATCH 2/2] Add standalone TRMNL flood risk page to the static site Runs the same client-side weather -> hydrology -> emulator pipeline as site/js/app.js, with no backend, to render a 5-day max-daily-risk bar chart matching the look of the Django-backed /trmnl/ page. Risk per day is the max share of non-channel grid cells wet at the median forecast flow, since the Django backend's risk formula divides by constants that don't match this grid (see docs/UPSTREAM_ISSUES.md) and isn't reusable as-is. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016NVMretUFqu4jZQxyamJK6 --- site/js/trmnl.js | 123 +++++++++++++++++++++++++++++++++++++++++++++++ site/trmnl.html | 78 ++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 site/js/trmnl.js create mode 100644 site/trmnl.html diff --git a/site/js/trmnl.js b/site/js/trmnl.js new file mode 100644 index 00000000..d0b1d652 --- /dev/null +++ b/site/js/trmnl.js @@ -0,0 +1,123 @@ +/** + * Standalone TRMNL display: runs the same client-side forecast pipeline as + * app.js (live weather -> hydrology model -> flood emulator), reduced to a + * single output - a 5-day bar chart of max daily flood risk. No server, no + * map, no controls; designed to be screenshotted by a TRMNL e-ink display. + */ + +import { FloodEmulator } from './emulator.js'; +import { generateRiverFlows } from './hydrology.js'; +import { fetchForecast, fetchHistory } from './weather.js'; + +const DAYS = 5; +const DEPTH_THRESHOLD_M = 0.01; +const RISK_PERCENTILE = 50; // matches the Django backend's median-depth-based risk + +const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const MONTH_NAMES = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', +]; + +const $ = (id) => document.getElementById(id); + +/** Run every ensemble member from the same spun-up state and pool flows per step. */ +function runEnsemble(members, paramSets, spunUpState, catchment) { + const perMember = members.map((weather) => + generateRiverFlows(weather, paramSets, spunUpState, catchment) + ); + const nSteps = perMember[0].flows[0].length; + const nSets = paramSets.length; + const pooledPerStep = []; + for (let step = 0; step < nSteps; step++) { + const pooled = new Float64Array(perMember.length * nSets); + let k = 0; + for (const result of perMember) { + for (let s = 0; s < nSets; s++) pooled[k++] = result.flows[s][step]; + } + pooled.sort(); + pooledPerStep.push(pooled); + } + return { times: members[0].times, pooledPerStep }; +} + +/** + * Fraction of the floodable (non-channel) grid that is wet, at the median + * flow for each 6-hourly step, maxed over each day. + * + * There is no canonical "risk %" to replicate here: the Django backend's + * formula divides by an arbitrary, undocumented cell count that doesn't + * match this grid (see docs/UPSTREAM_ISSUES.md). Risk is instead the share + * of non-channel cells that are wet, which is self-calibrating to whatever + * grid is loaded rather than relying on those broken constants. + */ +function dayRiskPercentages(emulator, ensemble) { + let channelCells = 0; + for (let i = 0; i < emulator.n; i++) channelCells += emulator.channel[i]; + const floodableCells = emulator.n - channelCells; + + const risks = []; + for (let day = 0; day < DAYS; day++) { + let maxRisk = 0; + for (let j = 0; j < 4; j++) { + const step = day * 4 + j; + if (step >= ensemble.pooledPerStep.length) break; + const depth = emulator.depthPercentile(ensemble.pooledPerStep[step], RISK_PERCENTILE); + const wet = emulator.wetCells(depth, DEPTH_THRESHOLD_M); + maxRisk = Math.max(maxRisk, wet / floodableCells); + } + risks.push(Math.round(Math.min(maxRisk, 1) * 100)); + } + return risks; +} + +function dayLabel(date) { + return `${DAY_NAMES[date.getUTCDay()]}
${date.getUTCDate()} ${MONTH_NAMES[date.getUTCMonth()]}`; +} + +function renderBars(times, risks) { + $('chart').innerHTML = risks + .map((pct, i) => ` +
+
${pct}%
+
+
${dayLabel(times[i * 4])}
+
`) + .join(''); +} + +function setStatus(text) { + $('subtitle').textContent = text; +} + +function formatTimestamp(date) { + const hh = String(date.getHours()).padStart(2, '0'); + const mm = String(date.getMinutes()).padStart(2, '0'); + return `Updated ${DAY_NAMES[date.getDay()]} ${date.getDate()} ${MONTH_NAMES[date.getMonth()]}, ${hh}:${mm}`; +} + +async function main() { + try { + setStatus('Loading forecast…'); + + const emulator = await FloodEmulator.load('data'); + const meta = emulator.meta; + const params = await (await fetch('data/params.json')).json(); + + const history = await fetchHistory(meta.catchment); + const seed = params.sets.map(() => [...params.initial_state]); + const spunUpState = generateRiverFlows( + history, params.sets, seed, meta.catchment + ).state; + + const forecast = await fetchForecast(meta.catchment); + const ensemble = runEnsemble(forecast, params.sets, spunUpState, meta.catchment); + + renderBars(ensemble.times, dayRiskPercentages(emulator, ensemble)); + setStatus(formatTimestamp(new Date())); + } catch (error) { + console.error(error); + setStatus(`Unable to load forecast: ${error.message}`); + } +} + +main(); diff --git a/site/trmnl.html b/site/trmnl.html new file mode 100644 index 00000000..f0b0f591 --- /dev/null +++ b/site/trmnl.html @@ -0,0 +1,78 @@ + + + + + +Flood Risk Forecast + + + + +
+

Flood Risk – Next 5 Days

+

Loading forecast…

+
+
+ + +