Skip to content

Commit d13e47d

Browse files
committed
feat: implement flood calibration and prediction tasks
- Add flood calibration task to re-learn link models from historical river levels. - Introduce flood prediction task to evaluate calibrated links and send alerts based on upstream readings. - Create utility functions for flood prediction, including model calibration and exceedance event detection. - Implement river station utilities for parsing sensor data and calculating distances. - Add tests for flood calibration, prediction, river levels, and utility functions to ensure functionality and reliability.
1 parent 6456963 commit d13e47d

31 files changed

Lines changed: 1952 additions & 50 deletions

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ DATABASE_PORT=
99
DATABASE_USERNAME=
1010
DATABASE_PASSWORD=
1111
ALERT_ZONE=D1
12+
# Hysteresis deadband in metres around river thresholds (anti-flapping). Default 0.05.
13+
RIVER_THRESHOLD_MARGIN=0.05

README.md

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,14 @@ The system interfaces with various official and unofficial sources to provide a
2323

2424
4. **River levels (Allerta Meteo hydrometric stations)**
2525
- **Feature:** Monitoring of water levels at configured hydrometric stations.
26-
- **Details:** Periodically polls the Allerta Meteo time-series endpoint for each registered station and compares the latest reading against operator-defined thresholds (`soglia1`, `soglia2`, `soglia3`). Station metadata and thresholds live in the `rivers` table; each check is appended to `river_levels`.
26+
- **Details:** Periodically polls the Allerta Meteo time-series endpoint for each registered station and compares the latest reading against operator-defined thresholds (`soglia1`, `soglia2`, `soglia3`). Station metadata and thresholds live in the `rivers` table; each new reading is appended to `river_levels` (de-duplicated by measurement time). A hysteresis deadband (`RIVER_THRESHOLD_MARGIN`, default 0.05 m) avoids alert flapping when a level hovers around a threshold.
2727
- **Notification:** Sends a Telegram message only on crossing events — when a reading rises above or falls below a threshold relative to the previous check.
2828

29+
5. **Flood prediction (empirical precursor model)**
30+
- **Feature:** Warns that a downstream point of interest is likely to exceed a threshold, with an estimated lead time, based on what upstream gauges are doing now.
31+
- **Details:** For each configured `river_link` (an upstream gauge → a downstream point), a daily calibration mines the accumulated `river_levels` history for past downstream exceedances and learns, from those events only, the upstream `precursor_level` and the typical `lead_time`. A link stays inactive until it has enough historical events. Online, when the upstream gauge reaches the learned level while rising, a single prediction is emitted and recorded in `link_predictions` for later scoring. No AI — event detection plus robust statistics.
32+
- **Bootstrap:** Since the live API only retains ~2.4 days, historical data is backfilled from the free ARPAE-SIMC open archive (see below). Without a backfill the model simply stays dormant until enough live events accrue.
33+
2934
## Scheduled Tasks (Crons)
3035

3136
The application relies on scheduled tasks (crons) to automate the weather monitoring flow:
@@ -38,6 +43,10 @@ The application relies on scheduled tasks (crons) to automate the weather monito
3843
- Verifies and sends the Estofex map for the following day, following the same conditional logic based on ongoing alerts.
3944
- **River Levels Check**
4045
- Every 5 minutes, for each row in the `rivers` table, fetches the latest hydrometric reading and appends it to `river_levels`. Sends a Telegram message only when the reading crosses one of the configured thresholds since the previous check.
46+
- **Flood Prediction Check**
47+
- Every 5 minutes, evaluates each calibrated `river_link`: if the upstream gauge has reached its learned precursor level while rising, emits a single de-duplicated flood-arrival prediction.
48+
- **Flood Calibration**
49+
- Daily, re-learns every link's model (lead time + precursor level) from the accumulated `river_levels` history. Also runnable on demand after a backfill.
4150

4251
## Database bootstrap
4352

@@ -55,9 +64,29 @@ Stations are managed via HTTP (port 3000):
5564
- `POST /rivers` — create; body `{ station_id, river_name, station_name, soglia1?, soglia2?, soglia3? }`
5665
- `PATCH /rivers/:id` — update any of `river_name`, `station_name`, `soglia1`, `soglia2`, `soglia3`
5766
- `DELETE /rivers/:id` — delete (cascades to `river_levels`)
58-
- `POST /river-levels` — trigger an on-demand check (returns `{ checked, crossings, skipped }`)
67+
- `POST /river-levels` — trigger an on-demand check (returns `{ checked, crossings, skipped, unchanged }`)
68+
- `GET /rivers/nearest?lat=&lon=&limit=` — discovery: nearest stations to a point, with coordinates and the official soglie the Allerta sensor list reports (suggestions only)
69+
70+
The `station_id` is the Allerta Meteo `idstazione`; threshold values are operator-defined (the `nearest` endpoint surfaces the portal's official soglie as suggestions, but they are not auto-applied).
71+
72+
## Flood prediction (links, calibration, backfill)
73+
74+
Both ends of a link must be registered as `rivers` so their readings accumulate in `river_levels`.
5975

60-
The `station_id` is the Allerta Meteo `idstazione`; threshold values must be looked up manually from the [Allerta Meteo portal](https://allertameteo.regione.emilia-romagna.it/) and stored alongside the station.
76+
- `GET /river-links` — list links and their learned models
77+
- `POST /river-links` — create; body `{ upstream_river_id, downstream_river_id, target_threshold? }` (`target_threshold` 1–3, default 1)
78+
- `DELETE /river-links/:id` — delete a link
79+
- `POST /flood-calibration` — re-learn all link models from history (returns `{ calibrated, active, skipped }`)
80+
- `POST /flood-prediction` — evaluate all links now (returns `{ evaluated, predictions, skipped }`)
81+
- `POST /flood-backfill` — body `{ from: "YYYY-MM", to: "YYYY-MM" }`; backfills `river_levels` from the ARPAE-SIMC open archive (`https://dati-simc.arpae.it/opendata/osservati/meteo/storico/`) for every registered station. Returns `202` immediately and runs in the background (it streams ~20 MB/month). Run it once, then `POST /flood-calibration`.
82+
83+
Example bootstrap for the Molinella stations (Idice S. Antonio + Reno Gandazzolo and their upstream gauges):
84+
85+
```bash
86+
# register stations, link them, then:
87+
curl -X POST localhost:3000/flood-backfill -H 'content-type: application/json' -d '{"from":"2020-01","to":"2024-12"}'
88+
curl -X POST localhost:3000/flood-calibration
89+
```
6190

6291
## Configuration and Installation
6392

sql/rivers.sql

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,53 @@ CREATE TABLE IF NOT EXISTS river_levels (
1818
soglia1_above BOOLEAN,
1919
soglia2_above BOOLEAN,
2020
soglia3_above BOOLEAN,
21-
created_on TIMESTAMPTZ NOT NULL DEFAULT now()
21+
created_on TIMESTAMPTZ NOT NULL DEFAULT now(),
22+
-- One stored reading per (station, measurement time): the poller runs more often than the
23+
-- sensor publishes, so this de-duplicates repeated reads of the same measurement.
24+
UNIQUE (river_id, measured_at)
2225
);
2326

27+
-- For databases created before the UNIQUE clause above existed:
28+
-- ALTER TABLE river_levels ADD CONSTRAINT river_levels_river_id_measured_at_key
29+
-- UNIQUE (river_id, measured_at);
30+
2431
CREATE INDEX IF NOT EXISTS river_levels_river_id_created_on_idx
2532
ON river_levels (river_id, created_on DESC);
33+
34+
CREATE INDEX IF NOT EXISTS river_levels_river_id_measured_at_idx
35+
ON river_levels (river_id, measured_at ASC);
36+
37+
-- Flood prediction: an upstream gauge whose readings historically preceded a threshold
38+
-- exceedance at a downstream point of interest. Both ends are registered rivers so the poller
39+
-- collects their readings into river_levels. Learned parameters are filled by calibration.
40+
CREATE TABLE IF NOT EXISTS river_links (
41+
id SERIAL PRIMARY KEY,
42+
upstream_river_id INTEGER NOT NULL REFERENCES rivers(id) ON DELETE CASCADE,
43+
downstream_river_id INTEGER NOT NULL REFERENCES rivers(id) ON DELETE CASCADE,
44+
-- Which downstream soglia (1/2/3) defines the exceedance event we predict.
45+
target_threshold SMALLINT NOT NULL DEFAULT 1,
46+
lead_time_minutes INTEGER,
47+
precursor_level NUMERIC,
48+
sample_size INTEGER NOT NULL DEFAULT 0,
49+
model_json JSONB,
50+
last_calibrated_on TIMESTAMPTZ,
51+
created_on TIMESTAMPTZ NOT NULL DEFAULT now(),
52+
updated_on TIMESTAMPTZ NOT NULL DEFAULT now(),
53+
UNIQUE (upstream_river_id, downstream_river_id),
54+
CHECK (upstream_river_id <> downstream_river_id)
55+
);
56+
57+
-- One row per prediction emitted, scored later against what actually happened downstream.
58+
CREATE TABLE IF NOT EXISTS link_predictions (
59+
id SERIAL PRIMARY KEY,
60+
link_id INTEGER NOT NULL REFERENCES river_links(id) ON DELETE CASCADE,
61+
predicted_at TIMESTAMPTZ NOT NULL,
62+
predicted_exceedance_at TIMESTAMPTZ NOT NULL,
63+
upstream_value NUMERIC NOT NULL,
64+
actual_exceedance_at TIMESTAMPTZ,
65+
outcome TEXT NOT NULL DEFAULT 'pending',
66+
created_on TIMESTAMPTZ NOT NULL DEFAULT now()
67+
);
68+
69+
CREATE INDEX IF NOT EXISTS link_predictions_link_id_predicted_at_idx
70+
ON link_predictions (link_id, predicted_at DESC);

src/config/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface Config {
1212
telegram_token: string
1313
chat_id: string
1414
alert_zone: string
15+
river_threshold_margin: number
1516
}
1617

1718
export const getNodeEnv = (): NodeEnv => process.env.NODE_ENV as NodeEnv

src/config/development.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Config } from './config'
2+
import { getRiverThresholdMargin } from './env'
23
import dotenv from 'dotenv'
34

45
dotenv.config()
@@ -14,4 +15,5 @@ export const developmentConfig: Config = {
1415
telegram_token: process.env.TELEGRAM_2_TOKEN as string,
1516
chat_id: process.env.CHAT_ID as string,
1617
alert_zone: process.env.ALERT_ZONE || 'D1',
18+
river_threshold_margin: getRiverThresholdMargin(),
1719
}

src/config/env.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Environment-variable helpers shared by the per-environment config objects. Kept in its own
2+
// module (importing nothing from ./config) so it does not create a runtime import cycle.
3+
4+
const DEFAULT_RIVER_THRESHOLD_MARGIN = 0.05
5+
6+
/**
7+
* Hysteresis deadband (in metres) applied around river thresholds to avoid alert flapping.
8+
* Falls back to a small default when RIVER_THRESHOLD_MARGIN is unset or invalid.
9+
*/
10+
export const getRiverThresholdMargin = (): number => {
11+
const raw = process.env.RIVER_THRESHOLD_MARGIN
12+
13+
// Treat unset/blank as "use the default" — Number('') is 0, which would silently disable the
14+
// deadband rather than fall back.
15+
if (raw === undefined || raw.trim() === '') {
16+
return DEFAULT_RIVER_THRESHOLD_MARGIN
17+
}
18+
19+
const margin = Number(raw)
20+
21+
return Number.isFinite(margin) && margin >= 0 ? margin : DEFAULT_RIVER_THRESHOLD_MARGIN
22+
}

src/config/production.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Config } from './config'
2+
import { getRiverThresholdMargin } from './env'
23

34
export const productionConfig: Config = {
45
database: {
@@ -11,4 +12,5 @@ export const productionConfig: Config = {
1112
telegram_token: process.env.TELEGRAM_TOKEN as string,
1213
chat_id: process.env.CHANNEL_ID as string,
1314
alert_zone: process.env.ALERT_ZONE || 'D1',
15+
river_threshold_margin: getRiverThresholdMargin(),
1416
}

src/models/link-prediction.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { database } from '..'
2+
3+
const tableName = 'link_predictions'
4+
5+
export type PredictionOutcome = 'pending' | 'hit' | 'miss'
6+
7+
export interface LinkPrediction {
8+
id: number
9+
link_id: number
10+
predicted_at: string
11+
predicted_exceedance_at: string
12+
upstream_value: number
13+
actual_exceedance_at: string | null
14+
outcome: PredictionOutcome
15+
created_on: string
16+
}
17+
18+
export type CreatableLinkPrediction = Omit<LinkPrediction, 'id' | 'outcome' | 'actual_exceedance_at' | 'created_on'>
19+
20+
export const createLinkPrediction = async (prediction: CreatableLinkPrediction): Promise<LinkPrediction> => {
21+
return database.create<LinkPrediction>(tableName, {
22+
...prediction,
23+
actual_exceedance_at: null,
24+
outcome: 'pending',
25+
created_on: new Date().toISOString(),
26+
})
27+
}
28+
29+
export const getLatestLinkPrediction = async (linkId: number): Promise<LinkPrediction | undefined> => {
30+
const rows = await database.query<LinkPrediction>(
31+
`SELECT * FROM ${tableName} WHERE link_id = $1 ORDER BY predicted_at DESC, id DESC LIMIT 1`,
32+
[linkId]
33+
)
34+
35+
return rows[0]
36+
}

src/models/river-level.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ export interface RiverLevel {
1616
export type CreatableRiverLevel = Omit<RiverLevel, 'id' | 'created_on'>
1717

1818
export const getLatestRiverLevel = async (riverId: number): Promise<RiverLevel | undefined> => {
19-
const query = `SELECT * FROM ${tableName} WHERE river_id = $1 ORDER BY created_on DESC, id DESC LIMIT 1`
19+
// Order by measured_at (not created_on): a historical backfill inserts old measurements with a
20+
// current created_on, so created_on no longer tracks measurement recency.
21+
const query = `SELECT * FROM ${tableName} WHERE river_id = $1 ORDER BY measured_at DESC, id DESC LIMIT 1`
2022

2123
const rows = await database.query<RiverLevel>(query, [riverId])
2224

@@ -26,3 +28,62 @@ export const getLatestRiverLevel = async (riverId: number): Promise<RiverLevel |
2628
export const createRiverLevel = async (level: CreatableRiverLevel): Promise<RiverLevel> => {
2729
return database.create<RiverLevel>(tableName, { ...level, created_on: new Date().toISOString() })
2830
}
31+
32+
/**
33+
* Inserts a reading only if no row already exists for the same (river_id, measured_at). Returns
34+
* the created row, or undefined when the reading was already stored. Relies on the
35+
* UNIQUE (river_id, measured_at) constraint and keeps the table from accumulating duplicate rows
36+
* when the sensor has not published a newer reading between polls.
37+
*/
38+
export const createRiverLevelIfNew = async (level: CreatableRiverLevel): Promise<RiverLevel | undefined> => {
39+
return database.createOrIgnore<RiverLevel>(
40+
tableName,
41+
{ ...level, created_on: new Date().toISOString() },
42+
'river_id, measured_at'
43+
)
44+
}
45+
46+
export const getRiverLevelsSince = async (riverId: number, since: string): Promise<RiverLevel[]> => {
47+
const query = `SELECT * FROM ${tableName} WHERE river_id = $1 AND measured_at >= $2 ORDER BY measured_at ASC`
48+
49+
return database.query<RiverLevel>(query, [riverId, since])
50+
}
51+
52+
export type BackfillRiverLevel = Omit<CreatableRiverLevel, never>
53+
54+
/**
55+
* Inserts many readings in one statement, skipping any (river_id, measured_at) already present.
56+
* Returns the number of rows actually inserted. Used by the historical backfill; callers must keep
57+
* each batch under the Postgres parameter limit (7 params per row).
58+
*/
59+
export const bulkInsertRiverLevels = async (rows: BackfillRiverLevel[]): Promise<number> => {
60+
if (rows.length === 0) {
61+
return 0
62+
}
63+
64+
const now = new Date().toISOString()
65+
const values: (string | number | boolean | null)[] = []
66+
const tuples = rows.map((row, index) => {
67+
const base = index * 7
68+
values.push(
69+
row.river_id,
70+
row.value,
71+
row.measured_at,
72+
row.soglia1_above,
73+
row.soglia2_above,
74+
row.soglia3_above,
75+
now
76+
)
77+
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7})`
78+
})
79+
80+
const query = `INSERT INTO ${tableName}
81+
(river_id, value, measured_at, soglia1_above, soglia2_above, soglia3_above, created_on)
82+
VALUES ${tuples.join(', ')}
83+
ON CONFLICT (river_id, measured_at) DO NOTHING
84+
RETURNING id`
85+
86+
const inserted = await database.query<{ id: number }>(query, values)
87+
88+
return inserted.length
89+
}

src/models/river-link.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { database } from '..'
2+
3+
const tableName = 'river_links'
4+
5+
export interface RiverLink {
6+
id: number
7+
upstream_river_id: number
8+
downstream_river_id: number
9+
target_threshold: number
10+
lead_time_minutes: number | null
11+
precursor_level: number | null
12+
sample_size: number
13+
model_json: unknown | null
14+
last_calibrated_on: string | null
15+
created_on: string
16+
updated_on: string
17+
}
18+
19+
export interface CreatableRiverLink {
20+
upstream_river_id: number
21+
downstream_river_id: number
22+
target_threshold?: number
23+
}
24+
25+
export interface RiverLinkModelPatch {
26+
lead_time_minutes: number | null
27+
precursor_level: number | null
28+
sample_size: number
29+
model_json: unknown
30+
}
31+
32+
export const getRiverLinks = async (): Promise<RiverLink[]> => {
33+
return database.query<RiverLink>(`SELECT * FROM ${tableName} ORDER BY id ASC`)
34+
}
35+
36+
export const getRiverLinkById = async (id: number): Promise<RiverLink | undefined> => {
37+
const rows = await database.query<RiverLink>(`SELECT * FROM ${tableName} WHERE id = $1`, [id])
38+
39+
return rows[0]
40+
}
41+
42+
export const createRiverLink = async (link: CreatableRiverLink): Promise<RiverLink> => {
43+
const now = new Date().toISOString()
44+
45+
return database.create<RiverLink>(tableName, {
46+
upstream_river_id: link.upstream_river_id,
47+
downstream_river_id: link.downstream_river_id,
48+
target_threshold: link.target_threshold ?? 1,
49+
lead_time_minutes: null,
50+
precursor_level: null,
51+
sample_size: 0,
52+
model_json: null,
53+
last_calibrated_on: null,
54+
created_on: now,
55+
updated_on: now,
56+
})
57+
}
58+
59+
export const updateRiverLinkModel = async (id: number, patch: RiverLinkModelPatch): Promise<RiverLink> => {
60+
return database.edit<RiverLink>(
61+
tableName,
62+
{
63+
lead_time_minutes: patch.lead_time_minutes,
64+
precursor_level: patch.precursor_level,
65+
sample_size: patch.sample_size,
66+
model_json: patch.model_json === undefined ? null : JSON.stringify(patch.model_json),
67+
last_calibrated_on: new Date().toISOString(),
68+
updated_on: new Date().toISOString(),
69+
},
70+
id
71+
)
72+
}
73+
74+
export const deleteRiverLink = async (id: number): Promise<void> => {
75+
await database.delete(tableName, id)
76+
}

0 commit comments

Comments
 (0)