diff --git a/.env.example b/.env.example
index 75d842f..a6d7a16 100644
--- a/.env.example
+++ b/.env.example
@@ -9,3 +9,5 @@ DATABASE_PORT=
DATABASE_USERNAME=
DATABASE_PASSWORD=
ALERT_ZONE=D1
+# Hysteresis deadband in metres around river thresholds (anti-flapping). Default 0.05.
+RIVER_THRESHOLD_MARGIN=0.05
diff --git a/.gitignore b/.gitignore
index 2ef9c53..9ba9087 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,4 +11,7 @@ dist/
.eslintcache
# IDE
-.vscode/settings.json
\ No newline at end of file
+.vscode/settings.json
+
+# Local agent / preview config
+.claude/
\ No newline at end of file
diff --git a/README.md b/README.md
index a70f753..8149746 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,16 @@ The system interfaces with various official and unofficial sources to provide a
- **Details:** Dynamically retrieves forecast maps for the following day via the Pretemp archive.
- **Notification:** If conditions require it (presence of ongoing alerts), it sends the thunderstorm forecast image to the Telegram channel.
+4. **River levels (Allerta Meteo hydrometric stations)**
+ - **Feature:** Monitoring of water levels at configured hydrometric stations.
+ - **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.
+ - **Notification:** Sends a Telegram message only on crossing events — when a reading rises above or falls below a threshold relative to the previous check.
+
+5. **Flood prediction (empirical precursor model)**
+ - **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.
+ - **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.
+ - **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.
+
## Scheduled Tasks (Crons)
The application relies on scheduled tasks (crons) to automate the weather monitoring flow:
@@ -31,6 +41,52 @@ The application relies on scheduled tasks (crons) to automate the weather monito
- Verifies and sends the Pretemp map for the following day, provided there is an ongoing alert and the map hasn't been sent yet.
- **Estofex Report Check**
- Verifies and sends the Estofex map for the following day, following the same conditional logic based on ongoing alerts.
+- **River Levels Check**
+ - 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.
+- **Flood Prediction Check**
+ - 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.
+- **Flood Calibration**
+ - Daily, re-learns every link's model (lead time + precursor level) from the accumulated `river_levels` history. Also runnable on demand after a backfill.
+
+## Database bootstrap
+
+Schema is applied manually (no migration tooling). The required tables are in [sql/rivers.sql](sql/rivers.sql):
+
+```bash
+psql "$DATABASE_URL" -f sql/rivers.sql
+```
+
+## River stations CRUD
+
+Stations are managed via HTTP (port 3000):
+
+- `GET /rivers` — list registered stations
+- `POST /rivers` — create; body `{ station_id, river_name, station_name, soglia1?, soglia2?, soglia3? }`
+- `PATCH /rivers/:id` — update any of `river_name`, `station_name`, `soglia1`, `soglia2`, `soglia3`
+- `DELETE /rivers/:id` — delete (cascades to `river_levels`)
+- `POST /river-levels` — trigger an on-demand check (returns `{ checked, crossings, skipped, unchanged }`)
+- `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)
+
+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).
+
+## Flood prediction (links, calibration, backfill)
+
+Both ends of a link must be registered as `rivers` so their readings accumulate in `river_levels`.
+
+- `GET /river-links` — list links and their learned models
+- `POST /river-links` — create; body `{ upstream_river_id, downstream_river_id, target_threshold? }` (`target_threshold` 1–3, default 1)
+- `DELETE /river-links/:id` — delete a link
+- `POST /flood-calibration` — re-learn all link models from history (returns `{ calibrated, active, skipped }`)
+- `POST /flood-prediction` — evaluate all links now (returns `{ evaluated, predictions, skipped }`)
+- `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`.
+
+Example bootstrap for the Molinella stations (Idice S. Antonio + Reno Gandazzolo and their upstream gauges):
+
+```bash
+# register stations, link them, then:
+curl -X POST localhost:3000/flood-backfill -H 'content-type: application/json' -d '{"from":"2020-01","to":"2024-12"}'
+curl -X POST localhost:3000/flood-calibration
+```
## Configuration and Installation
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..3bda89d
--- /dev/null
+++ b/index.html
@@ -0,0 +1,348 @@
+
+
+
+
+
+Simulazione piena · Mida-Sync
+
+
+
+
+
+
🌊 Simulazione piena — preallarme a valle
+
Come il sistema stima quando una piena raggiunge la pianura, osservando l'idrometro a monte. Dimostrazione · Mida-Sync
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
☔Pioggia nel bacino
+
📈Preallarme a monte
+
🌊Onda in viaggio
+
🔴Soglia a valle
+
+
+
+
+
+
+
+ idrometro a monte
+ idrometro a valle
+ soglia
+ arrivo previsto
+
+
+
+
Tempo simulato
0 h
+
Monte
– m
+
Valle
– m
+
Stato modello
In attesa
+
Arrivo previsto
–
+
Arrivo reale · errore
–
+
+
+
+
+
Come leggerlo
+
Quando l'idrometro a monte (blu) raggiunge il suo livello di preallarme, il modello lancia
+ l'avviso e stima fra quanto la piena toccherà la soglia a valle (riga rossa). L'onda viaggia lungo il
+ fiume: l'intervallo fra le due curve che attraversano la soglia è il tempo di anticipo. Alla fine puoi
+ confrontare arrivo previsto e arrivo reale: la differenza è l'errore tipico di una stima statistica
+ (le piene più forti viaggiano più veloci e arrivano un po' prima del previsto).
+
Prova a ridurre l'intensità: sotto una certa soglia il fiume non esonda e il
+ modello — correttamente — resta in silenzio.
+
+
+
+ Stima statistica a scopo dimostrativo — non è una previsione ufficiale.
+ Per allerte reali fare riferimento ad Allerta Meteo Emilia‑Romagna e alla Protezione Civile.
+
+
+
+
+
+
diff --git a/modello-predittivo-piene.pdf b/modello-predittivo-piene.pdf
new file mode 100644
index 0000000..67e5fc4
Binary files /dev/null and b/modello-predittivo-piene.pdf differ
diff --git a/sql/rivers.sql b/sql/rivers.sql
new file mode 100644
index 0000000..add6a90
--- /dev/null
+++ b/sql/rivers.sql
@@ -0,0 +1,70 @@
+CREATE TABLE IF NOT EXISTS rivers (
+ id SERIAL PRIMARY KEY,
+ station_id TEXT NOT NULL UNIQUE,
+ river_name TEXT NOT NULL,
+ station_name TEXT NOT NULL,
+ soglia1 NUMERIC,
+ soglia2 NUMERIC,
+ soglia3 NUMERIC,
+ created_on TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_on TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS river_levels (
+ id SERIAL PRIMARY KEY,
+ river_id INTEGER NOT NULL REFERENCES rivers(id) ON DELETE CASCADE,
+ value NUMERIC NOT NULL,
+ measured_at TIMESTAMPTZ NOT NULL,
+ soglia1_above BOOLEAN,
+ soglia2_above BOOLEAN,
+ soglia3_above BOOLEAN,
+ created_on TIMESTAMPTZ NOT NULL DEFAULT now(),
+ -- One stored reading per (station, measurement time): the poller runs more often than the
+ -- sensor publishes, so this de-duplicates repeated reads of the same measurement.
+ UNIQUE (river_id, measured_at)
+);
+
+-- For databases created before the UNIQUE clause above existed:
+-- ALTER TABLE river_levels ADD CONSTRAINT river_levels_river_id_measured_at_key
+-- UNIQUE (river_id, measured_at);
+
+CREATE INDEX IF NOT EXISTS river_levels_river_id_created_on_idx
+ ON river_levels (river_id, created_on DESC);
+
+CREATE INDEX IF NOT EXISTS river_levels_river_id_measured_at_idx
+ ON river_levels (river_id, measured_at ASC);
+
+-- Flood prediction: an upstream gauge whose readings historically preceded a threshold
+-- exceedance at a downstream point of interest. Both ends are registered rivers so the poller
+-- collects their readings into river_levels. Learned parameters are filled by calibration.
+CREATE TABLE IF NOT EXISTS river_links (
+ id SERIAL PRIMARY KEY,
+ upstream_river_id INTEGER NOT NULL REFERENCES rivers(id) ON DELETE CASCADE,
+ downstream_river_id INTEGER NOT NULL REFERENCES rivers(id) ON DELETE CASCADE,
+ -- Which downstream soglia (1/2/3) defines the exceedance event we predict.
+ target_threshold SMALLINT NOT NULL DEFAULT 1,
+ lead_time_minutes INTEGER,
+ precursor_level NUMERIC,
+ sample_size INTEGER NOT NULL DEFAULT 0,
+ model_json JSONB,
+ last_calibrated_on TIMESTAMPTZ,
+ created_on TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_on TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (upstream_river_id, downstream_river_id),
+ CHECK (upstream_river_id <> downstream_river_id)
+);
+
+-- One row per prediction emitted, scored later against what actually happened downstream.
+CREATE TABLE IF NOT EXISTS link_predictions (
+ id SERIAL PRIMARY KEY,
+ link_id INTEGER NOT NULL REFERENCES river_links(id) ON DELETE CASCADE,
+ predicted_at TIMESTAMPTZ NOT NULL,
+ predicted_exceedance_at TIMESTAMPTZ NOT NULL,
+ upstream_value NUMERIC NOT NULL,
+ actual_exceedance_at TIMESTAMPTZ,
+ outcome TEXT NOT NULL DEFAULT 'pending',
+ created_on TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS link_predictions_link_id_predicted_at_idx
+ ON link_predictions (link_id, predicted_at DESC);
diff --git a/src/config/config.ts b/src/config/config.ts
index 0d447ee..6b629cf 100644
--- a/src/config/config.ts
+++ b/src/config/config.ts
@@ -12,6 +12,7 @@ export interface Config {
telegram_token: string
chat_id: string
alert_zone: string
+ river_threshold_margin: number
}
export const getNodeEnv = (): NodeEnv => process.env.NODE_ENV as NodeEnv
diff --git a/src/config/development.ts b/src/config/development.ts
index 6c42f57..8b3a5d6 100644
--- a/src/config/development.ts
+++ b/src/config/development.ts
@@ -1,4 +1,5 @@
import { Config } from './config'
+import { getRiverThresholdMargin } from './env'
import dotenv from 'dotenv'
dotenv.config()
@@ -14,4 +15,5 @@ export const developmentConfig: Config = {
telegram_token: process.env.TELEGRAM_2_TOKEN as string,
chat_id: process.env.CHAT_ID as string,
alert_zone: process.env.ALERT_ZONE || 'D1',
+ river_threshold_margin: getRiverThresholdMargin(),
}
diff --git a/src/config/env.ts b/src/config/env.ts
new file mode 100644
index 0000000..9a6292f
--- /dev/null
+++ b/src/config/env.ts
@@ -0,0 +1,22 @@
+// Environment-variable helpers shared by the per-environment config objects. Kept in its own
+// module (importing nothing from ./config) so it does not create a runtime import cycle.
+
+const DEFAULT_RIVER_THRESHOLD_MARGIN = 0.05
+
+/**
+ * Hysteresis deadband (in metres) applied around river thresholds to avoid alert flapping.
+ * Falls back to a small default when RIVER_THRESHOLD_MARGIN is unset or invalid.
+ */
+export const getRiverThresholdMargin = (): number => {
+ const raw = process.env.RIVER_THRESHOLD_MARGIN
+
+ // Treat unset/blank as "use the default" — Number('') is 0, which would silently disable the
+ // deadband rather than fall back.
+ if (raw === undefined || raw.trim() === '') {
+ return DEFAULT_RIVER_THRESHOLD_MARGIN
+ }
+
+ const margin = Number(raw)
+
+ return Number.isFinite(margin) && margin >= 0 ? margin : DEFAULT_RIVER_THRESHOLD_MARGIN
+}
diff --git a/src/config/production.ts b/src/config/production.ts
index 02cef6a..21cc30d 100644
--- a/src/config/production.ts
+++ b/src/config/production.ts
@@ -1,4 +1,5 @@
import { Config } from './config'
+import { getRiverThresholdMargin } from './env'
export const productionConfig: Config = {
database: {
@@ -11,4 +12,5 @@ export const productionConfig: Config = {
telegram_token: process.env.TELEGRAM_TOKEN as string,
chat_id: process.env.CHANNEL_ID as string,
alert_zone: process.env.ALERT_ZONE || 'D1',
+ river_threshold_margin: getRiverThresholdMargin(),
}
diff --git a/src/models/link-prediction.ts b/src/models/link-prediction.ts
new file mode 100644
index 0000000..61e8552
--- /dev/null
+++ b/src/models/link-prediction.ts
@@ -0,0 +1,36 @@
+import { database } from '..'
+
+const tableName = 'link_predictions'
+
+export type PredictionOutcome = 'pending' | 'hit' | 'miss'
+
+export interface LinkPrediction {
+ id: number
+ link_id: number
+ predicted_at: string
+ predicted_exceedance_at: string
+ upstream_value: number
+ actual_exceedance_at: string | null
+ outcome: PredictionOutcome
+ created_on: string
+}
+
+export type CreatableLinkPrediction = Omit
+
+export const createLinkPrediction = async (prediction: CreatableLinkPrediction): Promise => {
+ return database.create(tableName, {
+ ...prediction,
+ actual_exceedance_at: null,
+ outcome: 'pending',
+ created_on: new Date().toISOString(),
+ })
+}
+
+export const getLatestLinkPrediction = async (linkId: number): Promise => {
+ const rows = await database.query(
+ `SELECT * FROM ${tableName} WHERE link_id = $1 ORDER BY predicted_at DESC, id DESC LIMIT 1`,
+ [linkId]
+ )
+
+ return rows[0]
+}
diff --git a/src/models/river-level.ts b/src/models/river-level.ts
new file mode 100644
index 0000000..7d2c1a8
--- /dev/null
+++ b/src/models/river-level.ts
@@ -0,0 +1,104 @@
+import { database } from '..'
+
+const tableName = 'river_levels'
+
+export interface RiverLevel {
+ id: number
+ river_id: number
+ value: number
+ measured_at: string
+ soglia1_above: boolean | null
+ soglia2_above: boolean | null
+ soglia3_above: boolean | null
+ created_on: string
+}
+
+export type CreatableRiverLevel = Omit
+
+export const getLatestRiverLevel = async (riverId: number): Promise => {
+ // Order by measured_at (not created_on): a historical backfill inserts old measurements with a
+ // current created_on, so created_on no longer tracks measurement recency.
+ const query = `SELECT * FROM ${tableName} WHERE river_id = $1 ORDER BY measured_at DESC, id DESC LIMIT 1`
+
+ const rows = await database.query(query, [riverId])
+
+ return rows[0]
+}
+
+export const createRiverLevel = async (level: CreatableRiverLevel): Promise => {
+ return database.create(tableName, { ...level, created_on: new Date().toISOString() })
+}
+
+/**
+ * Inserts a reading only if no row already exists for the same (river_id, measured_at). Returns
+ * the created row, or undefined when the reading was already stored. Relies on the
+ * UNIQUE (river_id, measured_at) constraint and keeps the table from accumulating duplicate rows
+ * when the sensor has not published a newer reading between polls.
+ */
+export const createRiverLevelIfNew = async (level: CreatableRiverLevel): Promise => {
+ return database.createOrIgnore(
+ tableName,
+ { ...level, created_on: new Date().toISOString() },
+ 'river_id, measured_at'
+ )
+}
+
+export const getRiverLevelsSince = async (riverId: number, since: string): Promise => {
+ const query = `SELECT * FROM ${tableName} WHERE river_id = $1 AND measured_at >= $2 ORDER BY measured_at ASC`
+
+ return database.query(query, [riverId, since])
+}
+
+export interface RiverReading {
+ value: number
+ measured_at: string
+}
+
+/**
+ * Lightweight reading fetch for calibration: only the two columns the model needs, ordered in time.
+ * Keeps memory low on small hosts when scanning years of history (vs SELECT * full rows).
+ */
+export const getRiverReadingsSince = async (riverId: number, since: string): Promise => {
+ const query = `SELECT value, measured_at FROM ${tableName} WHERE river_id = $1 AND measured_at >= $2 ORDER BY measured_at ASC`
+
+ return database.query(query, [riverId, since])
+}
+
+export type BackfillRiverLevel = Omit
+
+/**
+ * Inserts many readings in one statement, skipping any (river_id, measured_at) already present.
+ * Returns the number of rows actually inserted. Used by the historical backfill; callers must keep
+ * each batch under the Postgres parameter limit (7 params per row).
+ */
+export const bulkInsertRiverLevels = async (rows: BackfillRiverLevel[]): Promise => {
+ if (rows.length === 0) {
+ return 0
+ }
+
+ const now = new Date().toISOString()
+ const values: (string | number | boolean | null)[] = []
+ const tuples = rows.map((row, index) => {
+ const base = index * 7
+ values.push(
+ row.river_id,
+ row.value,
+ row.measured_at,
+ row.soglia1_above,
+ row.soglia2_above,
+ row.soglia3_above,
+ now
+ )
+ return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7})`
+ })
+
+ const query = `INSERT INTO ${tableName}
+ (river_id, value, measured_at, soglia1_above, soglia2_above, soglia3_above, created_on)
+ VALUES ${tuples.join(', ')}
+ ON CONFLICT (river_id, measured_at) DO NOTHING
+ RETURNING id`
+
+ const inserted = await database.query<{ id: number }>(query, values)
+
+ return inserted.length
+}
diff --git a/src/models/river-link.ts b/src/models/river-link.ts
new file mode 100644
index 0000000..e75d52d
--- /dev/null
+++ b/src/models/river-link.ts
@@ -0,0 +1,76 @@
+import { database } from '..'
+
+const tableName = 'river_links'
+
+export interface RiverLink {
+ id: number
+ upstream_river_id: number
+ downstream_river_id: number
+ target_threshold: number
+ lead_time_minutes: number | null
+ precursor_level: number | null
+ sample_size: number
+ model_json: unknown | null
+ last_calibrated_on: string | null
+ created_on: string
+ updated_on: string
+}
+
+export interface CreatableRiverLink {
+ upstream_river_id: number
+ downstream_river_id: number
+ target_threshold?: number
+}
+
+export interface RiverLinkModelPatch {
+ lead_time_minutes: number | null
+ precursor_level: number | null
+ sample_size: number
+ model_json: unknown
+}
+
+export const getRiverLinks = async (): Promise => {
+ return database.query(`SELECT * FROM ${tableName} ORDER BY id ASC`)
+}
+
+export const getRiverLinkById = async (id: number): Promise => {
+ const rows = await database.query(`SELECT * FROM ${tableName} WHERE id = $1`, [id])
+
+ return rows[0]
+}
+
+export const createRiverLink = async (link: CreatableRiverLink): Promise => {
+ const now = new Date().toISOString()
+
+ return database.create(tableName, {
+ upstream_river_id: link.upstream_river_id,
+ downstream_river_id: link.downstream_river_id,
+ target_threshold: link.target_threshold ?? 1,
+ lead_time_minutes: null,
+ precursor_level: null,
+ sample_size: 0,
+ model_json: null,
+ last_calibrated_on: null,
+ created_on: now,
+ updated_on: now,
+ })
+}
+
+export const updateRiverLinkModel = async (id: number, patch: RiverLinkModelPatch): Promise => {
+ return database.edit(
+ tableName,
+ {
+ lead_time_minutes: patch.lead_time_minutes,
+ precursor_level: patch.precursor_level,
+ sample_size: patch.sample_size,
+ model_json: patch.model_json === undefined ? null : JSON.stringify(patch.model_json),
+ last_calibrated_on: new Date().toISOString(),
+ updated_on: new Date().toISOString(),
+ },
+ id
+ )
+}
+
+export const deleteRiverLink = async (id: number): Promise => {
+ await database.delete(tableName, id)
+}
diff --git a/src/models/river.ts b/src/models/river.ts
new file mode 100644
index 0000000..ee95d85
--- /dev/null
+++ b/src/models/river.ts
@@ -0,0 +1,54 @@
+import { database } from '..'
+
+const tableName = 'rivers'
+
+export interface River {
+ id: number
+ station_id: string
+ river_name: string
+ station_name: string
+ soglia1: number | null
+ soglia2: number | null
+ soglia3: number | null
+ created_on: string
+ updated_on: string
+}
+
+export type CreatableRiver = Omit
+export type RiverPatch = Partial>
+
+export const getRivers = async (): Promise => {
+ const query = `SELECT * FROM ${tableName} ORDER BY id ASC`
+
+ return database.query(query)
+}
+
+export const getRiverById = async (id: number): Promise => {
+ const query = `SELECT * FROM ${tableName} WHERE id = $1`
+
+ const rows = await database.query(query, [id])
+
+ return rows[0]
+}
+
+export const getRiverByStationId = async (stationId: string): Promise => {
+ const query = `SELECT * FROM ${tableName} WHERE station_id = $1`
+
+ const rows = await database.query(query, [stationId])
+
+ return rows[0]
+}
+
+export const createRiver = async (river: CreatableRiver): Promise => {
+ const now = new Date().toISOString()
+
+ return database.create(tableName, { ...river, created_on: now, updated_on: now })
+}
+
+export const updateRiver = async (id: number, patch: RiverPatch): Promise => {
+ return database.edit(tableName, { ...patch, updated_on: new Date().toISOString() }, id)
+}
+
+export const deleteRiver = async (id: number): Promise => {
+ await database.delete(tableName, id)
+}
diff --git a/src/routes/river-links.ts b/src/routes/river-links.ts
new file mode 100644
index 0000000..3cc3541
--- /dev/null
+++ b/src/routes/river-links.ts
@@ -0,0 +1,139 @@
+import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
+import { getRiverById } from '../models/river'
+import { createRiverLink, deleteRiverLink, getRiverLinkById, getRiverLinks } from '../models/river-link'
+import { runFloodCalibration } from '../tasks/flood-calibration'
+import { runFloodPrediction } from '../tasks/flood-prediction'
+import { runFloodBackfill } from '../tasks/flood-backfill'
+import logger from '../logger'
+
+const createBodySchema = {
+ type: 'object',
+ required: ['upstream_river_id', 'downstream_river_id'],
+ additionalProperties: false,
+ properties: {
+ upstream_river_id: { type: 'integer', minimum: 1 },
+ downstream_river_id: { type: 'integer', minimum: 1 },
+ target_threshold: { type: 'integer', minimum: 1, maximum: 3 },
+ },
+}
+
+const idParamSchema = {
+ type: 'object',
+ required: ['id'],
+ additionalProperties: false,
+ properties: { id: { type: 'integer', minimum: 1 } },
+}
+
+interface IdParams {
+ id: number
+}
+
+interface CreateLinkBody {
+ upstream_river_id: number
+ downstream_river_id: number
+ target_threshold?: number
+}
+
+const emptyBodySchema = { type: 'object', additionalProperties: false, maxProperties: 0 }
+
+const monthPattern = '^[0-9]{4}-(0[1-9]|1[0-2])$'
+const backfillBodySchema = {
+ type: 'object',
+ required: ['from', 'to'],
+ additionalProperties: false,
+ properties: {
+ from: { type: 'string', pattern: monthPattern },
+ to: { type: 'string', pattern: monthPattern },
+ },
+}
+
+interface BackfillBody {
+ from: string
+ to: string
+}
+
+export const registerRiverLinksRoutes = (fastify: FastifyInstance) => {
+ fastify.route({
+ method: 'GET',
+ url: '/river-links',
+ handler: async (_request: FastifyRequest, reply: FastifyReply) => {
+ reply.status(200).send(await getRiverLinks())
+ },
+ })
+
+ fastify.route<{ Body: CreateLinkBody }>({
+ method: 'POST',
+ url: '/river-links',
+ schema: { body: createBodySchema },
+ handler: async (request, reply) => {
+ const { upstream_river_id, downstream_river_id, target_threshold } = request.body
+
+ if (upstream_river_id === downstream_river_id) {
+ return reply.status(400).send({ error: 'upstream and downstream must differ' })
+ }
+
+ const [upstream, downstream] = await Promise.all([
+ getRiverById(upstream_river_id),
+ getRiverById(downstream_river_id),
+ ])
+
+ if (!upstream || !downstream) {
+ return reply.status(404).send({ error: 'upstream or downstream river not found' })
+ }
+
+ const created = await createRiverLink({ upstream_river_id, downstream_river_id, target_threshold })
+ reply.status(201).send(created)
+ },
+ })
+
+ fastify.route<{ Params: IdParams }>({
+ method: 'DELETE',
+ url: '/river-links/:id',
+ schema: { params: idParamSchema },
+ handler: async (request, reply) => {
+ const link = await getRiverLinkById(request.params.id)
+
+ if (!link) {
+ return reply.status(404).send({ error: 'River link not found' })
+ }
+
+ await deleteRiverLink(request.params.id)
+ reply.status(204).send(undefined)
+ },
+ })
+
+ fastify.route({
+ method: 'POST',
+ url: '/flood-calibration',
+ schema: { body: emptyBodySchema },
+ handler: async (_request: FastifyRequest, reply: FastifyReply) => {
+ reply.status(200).send(await runFloodCalibration())
+ },
+ })
+
+ fastify.route({
+ method: 'POST',
+ url: '/flood-prediction',
+ schema: { body: emptyBodySchema },
+ handler: async (_request: FastifyRequest, reply: FastifyReply) => {
+ reply.status(200).send(await runFloodPrediction())
+ },
+ })
+
+ // Backfill runs for minutes (downloads/parses monthly archives), well beyond the request
+ // timeout, so it is started in the background and the caller gets an immediate acknowledgement.
+ fastify.route<{ Body: BackfillBody }>({
+ method: 'POST',
+ url: '/flood-backfill',
+ schema: { body: backfillBodySchema },
+ handler: async (request, reply) => {
+ const { from, to } = request.body
+
+ void runFloodBackfill(from, to)
+ .then((summary) => logger.info({ summary }, 'Flood backfill finished'))
+ .catch((err) => logger.error({ err }, 'Flood backfill failed'))
+
+ reply.status(202).send({ status: 'started', from, to })
+ },
+ })
+}
diff --git a/src/routes/rivers.ts b/src/routes/rivers.ts
new file mode 100644
index 0000000..a63ad0f
--- /dev/null
+++ b/src/routes/rivers.ts
@@ -0,0 +1,184 @@
+import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
+import {
+ createRiver,
+ deleteRiver,
+ getRiverById,
+ getRiverByStationId,
+ getRivers,
+ updateRiver,
+} from '../models/river'
+import { runRiverLevelCheck } from '../tasks/river-levels'
+import { getSensorStations } from '../services/river-sensors'
+import { findNearestStations, parseSensorStations } from '../utilities/river-stations'
+
+const nullableNumber = { type: ['number', 'null'] }
+
+const nearestQuerySchema = {
+ type: 'object',
+ required: ['lat', 'lon'],
+ additionalProperties: false,
+ properties: {
+ lat: { type: 'number', minimum: -90, maximum: 90 },
+ lon: { type: 'number', minimum: -180, maximum: 180 },
+ limit: { type: 'integer', minimum: 1, maximum: 50 },
+ },
+}
+
+interface NearestQuery {
+ lat: number
+ lon: number
+ limit?: number
+}
+
+const createBodySchema = {
+ type: 'object',
+ required: ['station_id', 'river_name', 'station_name'],
+ additionalProperties: false,
+ properties: {
+ station_id: { type: 'string', minLength: 1, maxLength: 64 },
+ river_name: { type: 'string', minLength: 1, maxLength: 128 },
+ station_name: { type: 'string', minLength: 1, maxLength: 128 },
+ soglia1: nullableNumber,
+ soglia2: nullableNumber,
+ soglia3: nullableNumber,
+ },
+}
+
+const patchBodySchema = {
+ type: 'object',
+ additionalProperties: false,
+ minProperties: 1,
+ properties: {
+ river_name: { type: 'string', minLength: 1, maxLength: 128 },
+ station_name: { type: 'string', minLength: 1, maxLength: 128 },
+ soglia1: nullableNumber,
+ soglia2: nullableNumber,
+ soglia3: nullableNumber,
+ },
+}
+
+const idParamSchema = {
+ type: 'object',
+ required: ['id'],
+ additionalProperties: false,
+ properties: {
+ id: { type: 'integer', minimum: 1 },
+ },
+}
+
+interface IdParams {
+ id: number
+}
+
+interface CreateRiverBody {
+ station_id: string
+ river_name: string
+ station_name: string
+ soglia1?: number | null
+ soglia2?: number | null
+ soglia3?: number | null
+}
+
+interface PatchRiverBody {
+ river_name?: string
+ station_name?: string
+ soglia1?: number | null
+ soglia2?: number | null
+ soglia3?: number | null
+}
+
+export const registerRiversRoutes = (fastify: FastifyInstance) => {
+ fastify.route({
+ method: 'GET',
+ url: '/rivers',
+ handler: async (_request: FastifyRequest, reply: FastifyReply) => {
+ const rivers = await getRivers()
+ reply.status(200).send(rivers)
+ },
+ })
+
+ fastify.route<{ Body: CreateRiverBody }>({
+ method: 'POST',
+ url: '/rivers',
+ schema: { body: createBodySchema },
+ handler: async (request, reply) => {
+ const existing = await getRiverByStationId(request.body.station_id)
+
+ if (existing) {
+ return reply.status(409).send({ error: 'Station already registered' })
+ }
+
+ const created = await createRiver({
+ station_id: request.body.station_id,
+ river_name: request.body.river_name,
+ station_name: request.body.station_name,
+ soglia1: request.body.soglia1 ?? null,
+ soglia2: request.body.soglia2 ?? null,
+ soglia3: request.body.soglia3 ?? null,
+ })
+
+ reply.status(201).send(created)
+ },
+ })
+
+ fastify.route<{ Params: IdParams; Body: PatchRiverBody }>({
+ method: 'PATCH',
+ url: '/rivers/:id',
+ schema: { params: idParamSchema, body: patchBodySchema },
+ handler: async (request, reply) => {
+ const river = await getRiverById(request.params.id)
+
+ if (!river) {
+ return reply.status(404).send({ error: 'River not found' })
+ }
+
+ const updated = await updateRiver(request.params.id, request.body)
+ reply.status(200).send(updated)
+ },
+ })
+
+ fastify.route<{ Params: IdParams }>({
+ method: 'DELETE',
+ url: '/rivers/:id',
+ schema: { params: idParamSchema },
+ handler: async (request, reply) => {
+ const river = await getRiverById(request.params.id)
+
+ if (!river) {
+ return reply.status(404).send({ error: 'River not found' })
+ }
+
+ await deleteRiver(request.params.id)
+ reply.status(204).send(undefined)
+ },
+ })
+
+ fastify.route<{ Querystring: NearestQuery }>({
+ method: 'GET',
+ url: '/rivers/nearest',
+ schema: { querystring: nearestQuerySchema },
+ handler: async (request, reply) => {
+ const stations = await getSensorStations()
+ const parsed = parseSensorStations(stations)
+ const nearest = findNearestStations(
+ parsed,
+ { lat: request.query.lat, lon: request.query.lon },
+ request.query.limit ?? 5
+ )
+
+ reply.status(200).send(nearest)
+ },
+ })
+
+ fastify.route({
+ method: 'POST',
+ url: '/river-levels',
+ schema: {
+ body: { type: 'object', additionalProperties: false, maxProperties: 0 },
+ },
+ handler: async (_request: FastifyRequest, reply: FastifyReply) => {
+ const summary = await runRiverLevelCheck()
+ reply.status(200).send(summary)
+ },
+ })
+}
diff --git a/src/scheduler.ts b/src/scheduler.ts
index ca862e0..81b4cee 100644
--- a/src/scheduler.ts
+++ b/src/scheduler.ts
@@ -2,6 +2,9 @@ import { Cron } from 'croner'
import { runMeteoAlertCheck } from './tasks/meteo-alerts'
import { runPretempCheck } from './tasks/pretemp'
import { runEstofexCheck } from './tasks/estofex'
+import { runRiverLevelCheck } from './tasks/river-levels'
+import { runFloodPrediction } from './tasks/flood-prediction'
+import { runFloodCalibration } from './tasks/flood-calibration'
import logger from './logger'
const jobs: Cron[] = []
@@ -35,6 +38,10 @@ export const startScheduler = () => {
schedule('meteo-alerts', '*/5 * * * *', runMeteoAlertCheck)
schedule('pretemp', '*/5 * * * *', runPretempCheck)
schedule('estofex', '*/5 * * * *', runEstofexCheck)
+ schedule('river-levels', '*/5 * * * *', runRiverLevelCheck)
+ schedule('flood-prediction', '*/5 * * * *', runFloodPrediction)
+ // Re-learn link models daily (cheap, reads accumulated history); also run on demand after a backfill.
+ schedule('flood-calibration', '15 3 * * *', runFloodCalibration)
logger.info({ count: jobs.length }, 'Scheduler started')
}
diff --git a/src/server.ts b/src/server.ts
index bc01335..20292e8 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -4,6 +4,8 @@ import i18next from 'i18next'
import italian from './resources/locales/it.json'
import { registerTestMessageRoutes } from './routes/test-message'
import { registerForecastReportsRoutes } from './routes/forecast-reports'
+import { registerRiversRoutes } from './routes/rivers'
+import { registerRiverLinksRoutes } from './routes/river-links'
import logger from './logger'
const translations = {
@@ -45,6 +47,8 @@ export const startServer = async (): Promise => {
registerTestMessageRoutes(app)
registerMeteoAlertsRoutes(app)
registerForecastReportsRoutes(app)
+ registerRiversRoutes(app)
+ registerRiverLinksRoutes(app)
await fastify.listen({
host: '127.0.0.1',
diff --git a/src/services/arpae-archive.ts b/src/services/arpae-archive.ts
new file mode 100644
index 0000000..62e5a81
--- /dev/null
+++ b/src/services/arpae-archive.ts
@@ -0,0 +1,129 @@
+import zlib from 'zlib'
+import readline from 'readline'
+import { http } from './http'
+import logger from '../logger'
+
+// ARPAE-SIMC open-data historical archive: one gzipped JSONL file per month, each line a station's
+// observation at one timestamp. Hydrometric level is the measurement block with timerange[0] === 254
+// carrying B13215 — the same signature as the live Allerta variable 254,0,0/1,-,-,-/B13215.
+const ARCHIVE_BASE_URL = 'https://dati-simc.arpae.it/opendata/osservati/meteo/storico'
+
+const HYDRO_TIMERANGE = 254
+const LEVEL_VARIABLE = 'B13215'
+
+export interface ArchiveTarget {
+ riverId: number
+ soglia1: number | null
+ soglia2: number | null
+ soglia3: number | null
+}
+
+export interface ParsedArchiveReading {
+ river_id: number
+ value: number
+ measured_at: string
+ soglia1_above: boolean | null
+ soglia2_above: boolean | null
+ soglia3_above: boolean | null
+}
+
+interface ArchiveBlock {
+ timerange?: number[]
+ vars?: Record
+}
+
+interface ArchiveLine {
+ lat?: number
+ lon?: number
+ date?: string
+ data?: ArchiveBlock[]
+}
+
+// Stations are matched on the integer (lat, lon) coordinates, which the archive and the Allerta
+// sensor list share (degrees × 1e5).
+export const coordKey = (lat: number, lon: number): string => `${lat}:${lon}`
+
+const aboveOrNull = (value: number, threshold: number | null): boolean | null =>
+ threshold == null ? null : value > threshold
+
+/**
+ * Parses one archive JSONL line into a river_levels reading, or null when the line is not one of the
+ * target stations, carries no hydrometric level, or is malformed. Pure — unit-testable without IO.
+ */
+export const parseArchiveLine = (line: string, index: Map): ParsedArchiveReading | null => {
+ let parsed: ArchiveLine
+ try {
+ parsed = JSON.parse(line)
+ } catch {
+ return null
+ }
+
+ if (typeof parsed.lat !== 'number' || typeof parsed.lon !== 'number' || typeof parsed.date !== 'string') {
+ return null
+ }
+
+ const target = index.get(coordKey(parsed.lat, parsed.lon))
+ if (!target || !Array.isArray(parsed.data)) {
+ return null
+ }
+
+ const block = parsed.data.find(
+ (b) => Array.isArray(b.timerange) && b.timerange[0] === HYDRO_TIMERANGE && b.vars?.[LEVEL_VARIABLE] != null
+ )
+ const raw = block?.vars?.[LEVEL_VARIABLE]?.v
+ const value = typeof raw === 'number' ? raw : Number(raw)
+
+ if (raw == null || !Number.isFinite(value)) {
+ return null
+ }
+
+ return {
+ river_id: target.riverId,
+ value,
+ measured_at: new Date(parsed.date).toISOString(),
+ soglia1_above: aboveOrNull(value, target.soglia1),
+ soglia2_above: aboveOrNull(value, target.soglia2),
+ soglia3_above: aboveOrNull(value, target.soglia3),
+ }
+}
+
+export const archiveUrlForMonth = (year: number, month: number): string =>
+ `${ARCHIVE_BASE_URL}/${year}-${String(month).padStart(2, '0')}.json.gz`
+
+/**
+ * Streams one monthly archive file, returning the readings that match the target stations. Holds
+ * only the matched rows in memory (a few stations × ~2,976 readings/month), parsing line by line.
+ */
+export const fetchArchiveMonth = async (
+ year: number,
+ month: number,
+ index: Map
+): Promise => {
+ const url = archiveUrlForMonth(year, month)
+ const readings: ParsedArchiveReading[] = []
+
+ // Archive files are ~20 MB; override the default 15s client timeout so the download/stream has
+ // room to complete on a slow connection.
+ const response = await http.get(url, { responseType: 'stream', timeout: 180_000 })
+ const lines = readline.createInterface({
+ input: (response.data as NodeJS.ReadableStream).pipe(zlib.createGunzip()),
+ crlfDelay: Infinity,
+ })
+
+ try {
+ for await (const line of lines) {
+ if (line.length === 0) {
+ continue
+ }
+ const reading = parseArchiveLine(line, index)
+ if (reading) {
+ readings.push(reading)
+ }
+ }
+ } catch (error) {
+ logger.error({ err: error, url }, 'Failed while streaming ARPAE archive month')
+ throw error
+ }
+
+ return readings
+}
diff --git a/src/services/river-sensors.ts b/src/services/river-sensors.ts
new file mode 100644
index 0000000..462e1d4
--- /dev/null
+++ b/src/services/river-sensors.ts
@@ -0,0 +1,79 @@
+import { http } from './http'
+import logger from '../logger'
+
+export interface RiverSensorTimePoint {
+ t: number
+ v: number
+}
+
+export interface RawSensorStation {
+ idstazione: string
+ nomestaz: string
+ lon: string
+ lat: string
+ value: number | null
+ soglia1: number | null
+ soglia2: number | null
+ soglia3: number | null
+}
+
+const VARIABILE = '254,0,0/1,-,-,-/B13215'
+const TIME_SERIES_BASE_URL = 'https://allertameteo.regione.emilia-romagna.it/o/api/allerta/get-time-series/'
+const SENSOR_VALUES_BASE_URL =
+ 'https://allertameteo.regione.emilia-romagna.it/o/api/allerta/get-sensor-values-no-time'
+
+export const getRiverSensorTimeSeries = async (stationId: string): Promise => {
+ const url = `${TIME_SERIES_BASE_URL}?stazione=${encodeURIComponent(stationId)}&variabile=${VARIABILE}`
+
+ try {
+ const response = await http.get(url).then((res) => res.data)
+
+ if (!Array.isArray(response)) {
+ return []
+ }
+
+ return response.filter(
+ (point): point is RiverSensorTimePoint =>
+ point != null && typeof point.t === 'number' && typeof point.v === 'number'
+ )
+ } catch (error) {
+ logger.error({ err: error, stationId }, 'Failed to retrieve river sensor time series')
+ throw error
+ }
+}
+
+export const getLatestRiverSensorValue = async (stationId: string): Promise => {
+ const series = await getRiverSensorTimeSeries(stationId)
+
+ if (series.length === 0) {
+ return undefined
+ }
+
+ return series.reduce((latest, point) => (point.t > latest.t ? point : latest), series[0])
+}
+
+/**
+ * Fetches the full station list (one call, all hydrometric stations) used for discovery and
+ * nearest-station lookup. Note: the per-station `value` is unreliable (often null) for this
+ * variable, so this endpoint is NOT a substitute for the time-series readings — it is metadata
+ * (name, coordinates, official thresholds) only. The leading `{ time }` element is dropped.
+ */
+export const getSensorStations = async (): Promise => {
+ const url = `${SENSOR_VALUES_BASE_URL}?variabile=${VARIABILE}`
+
+ try {
+ const response = await http.get(url).then((res) => res.data)
+
+ if (!Array.isArray(response)) {
+ return []
+ }
+
+ return response.filter(
+ (entry): entry is RawSensorStation =>
+ entry != null && typeof (entry as RawSensorStation).idstazione === 'string'
+ )
+ } catch (error) {
+ logger.error({ err: error }, 'Failed to retrieve river sensor station list')
+ throw error
+ }
+}
diff --git a/src/tasks/flood-backfill.ts b/src/tasks/flood-backfill.ts
new file mode 100644
index 0000000..f365fbf
--- /dev/null
+++ b/src/tasks/flood-backfill.ts
@@ -0,0 +1,118 @@
+import { getRivers } from '../models/river'
+import { bulkInsertRiverLevels } from '../models/river-level'
+import { getSensorStations } from '../services/river-sensors'
+import { ArchiveTarget, coordKey, fetchArchiveMonth } from '../services/arpae-archive'
+import logger from '../logger'
+
+const log = logger.child({ task: 'flood-backfill' })
+
+const INSERT_BATCH_SIZE = 5000
+
+export interface FloodBackfillSummary {
+ months: number
+ fetched: number
+ inserted: number
+ targets: number
+}
+
+interface YearMonth {
+ year: number
+ month: number
+}
+
+const toNumberOrNull = (value: number | null): number | null => {
+ if (value == null) {
+ return null
+ }
+ const n = Number(value)
+ return Number.isFinite(n) ? n : null
+}
+
+export const expandMonthRange = (from: string, to: string): YearMonth[] => {
+ const [fy, fm] = from.split('-').map(Number)
+ const [ty, tm] = to.split('-').map(Number)
+
+ if (![fy, fm, ty, tm].every(Number.isFinite) || fm < 1 || fm > 12 || tm < 1 || tm > 12) {
+ throw new Error(`Invalid month range: ${from}..${to}`)
+ }
+
+ const months: YearMonth[] = []
+ let year = fy
+ let month = fm
+ while (year < ty || (year === ty && month <= tm)) {
+ months.push({ year, month })
+ month += 1
+ if (month > 12) {
+ month = 1
+ year += 1
+ }
+ }
+
+ return months
+}
+
+/**
+ * Builds a coordinate -> river index from the registered rivers and the live Allerta sensor list.
+ * Both the archive and the sensor list encode coordinates as the same degrees×1e5 integers, so the
+ * raw integer strings are used as the join key.
+ */
+const buildCoordIndex = async (): Promise