Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions manyfews/webapp/templates/webapp/trmnl_display.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
{% load i18n %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=800, initial-scale=1">
<title>{% trans "Flood Risk Forecast" %}</title>
<style>
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
width: 800px;
height: 480px;
background: #ffffff;
color: #000000;
font-family: "Helvetica Neue", Arial, sans-serif;
}
.page {
width: 800px;
height: 480px;
padding: 24px 32px;
display: flex;
flex-direction: column;
}
.title {
font-size: 30px;
font-weight: 700;
text-align: center;
margin: 0 0 4px 0;
}
.subtitle {
font-size: 16px;
text-align: center;
color: #444444;
margin: 0 0 16px 0;
}
.chart {
flex: 1;
display: flex;
align-items: flex-end;
justify-content: space-evenly;
border-bottom: 3px solid #000000;
}
.bar-column {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
width: 130px;
height: 100%;
}
.bar-value {
font-size: 28px;
font-weight: 700;
margin-bottom: 6px;
}
.bar {
width: 90px;
background: #000000;
}
.bar-label {
margin-top: 10px;
font-size: 22px;
font-weight: 600;
text-align: center;
}
</style>
</head>
<body>
<div class="page">
<h1 class="title">{% trans "Flood Risk – Next 5 Days" %}</h1>
<p class="subtitle">{% trans "Updated" %} {{ generated_at|date:"D j M, H:i" }}</p>
<div class="chart">
{% for d in daily_risks %}
<div class="bar-column">
<div class="bar-value">{{ d.max_percentage_risk }}%</div>
<div class="bar" style="height: {{ d.max_percentage_risk }}%;"></div>
<div class="bar-label">{{ d.date|date:"D" }}<br>{{ d.date|date:"j M" }}</div>
</div>
{% endfor %}
</div>
</div>
</body>
</html>
8 changes: 8 additions & 0 deletions manyfews/webapp/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment on lines +55 to +58


class ConverterTestCase(TestCase):
def test_bounding_box_url_parameter_converter(self):
converter = BoundingBoxUrlParameterConverter()
Expand Down
1 change: 1 addition & 0 deletions manyfews/webapp/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<int:id>", views.resend_verification, name="verify"
Expand Down
39 changes: 39 additions & 0 deletions manyfews/webapp/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
123 changes: 123 additions & 0 deletions site/js/trmnl.js
Original file line number Diff line number Diff line change
@@ -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()]}<br>${date.getUTCDate()} ${MONTH_NAMES[date.getUTCMonth()]}`;
}
Comment on lines +73 to +75

function renderBars(times, risks) {
$('chart').innerHTML = risks
.map((pct, i) => `
<div class="bar-column">
<div class="bar-value">${pct}%</div>
<div class="bar" style="height: ${pct}%;"></div>
<div class="bar-label">${dayLabel(times[i * 4])}</div>
</div>`)
.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();
78 changes: 78 additions & 0 deletions site/trmnl.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=800, initial-scale=1">
<title>Flood Risk Forecast</title>
<meta name="description" content="5-day flood risk forecast for Majalaya, sized for a TRMNL e-ink display. Runs entirely in the browser.">
<style>
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
width: 800px;
height: 480px;
background: #ffffff;
color: #000000;
font-family: "Helvetica Neue", Arial, sans-serif;
}
.page {
width: 800px;
height: 480px;
padding: 24px 32px;
display: flex;
flex-direction: column;
}
.title {
font-size: 30px;
font-weight: 700;
text-align: center;
margin: 0 0 4px 0;
}
.subtitle {
font-size: 16px;
text-align: center;
color: #444444;
margin: 0 0 16px 0;
}
.chart {
flex: 1;
display: flex;
align-items: flex-end;
justify-content: space-evenly;
border-bottom: 3px solid #000000;
}
.bar-column {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
width: 130px;
height: 100%;
}
.bar-value {
font-size: 28px;
font-weight: 700;
margin-bottom: 6px;
}
.bar {
width: 90px;
background: #000000;
}
.bar-label {
margin-top: 10px;
font-size: 22px;
font-weight: 600;
text-align: center;
}
</style>
</head>
<body>
<div class="page">
<h1 class="title">Flood Risk – Next 5 Days</h1>
<p class="subtitle" id="subtitle">Loading forecast…</p>
<div class="chart" id="chart"></div>
</div>
<script type="module" src="js/trmnl.js"></script>
Comment on lines +74 to +76
</body>
</html>
Loading