Skip to content

Commit da40793

Browse files
committed
feat: enhance flood calibration and prediction with new river readings and tests
1 parent d13e47d commit da40793

7 files changed

Lines changed: 357 additions & 10 deletions

File tree

src/models/river-level.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,21 @@ export const getRiverLevelsSince = async (riverId: number, since: string): Promi
4949
return database.query<RiverLevel>(query, [riverId, since])
5050
}
5151

52+
export interface RiverReading {
53+
value: number
54+
measured_at: string
55+
}
56+
57+
/**
58+
* Lightweight reading fetch for calibration: only the two columns the model needs, ordered in time.
59+
* Keeps memory low on small hosts when scanning years of history (vs SELECT * full rows).
60+
*/
61+
export const getRiverReadingsSince = async (riverId: number, since: string): Promise<RiverReading[]> => {
62+
const query = `SELECT value, measured_at FROM ${tableName} WHERE river_id = $1 AND measured_at >= $2 ORDER BY measured_at ASC`
63+
64+
return database.query<RiverReading>(query, [riverId, since])
65+
}
66+
5267
export type BackfillRiverLevel = Omit<CreatableRiverLevel, never>
5368

5469
/**

src/tasks/flood-calibration.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { getRiverById } from '../models/river'
2-
import { getRiverLevelsSince } from '../models/river-level'
2+
import { getRiverReadingsSince } from '../models/river-level'
33
import { getRiverLinks, updateRiverLinkModel } from '../models/river-link'
44
import { calibrateLink, isLinkActive, toReadings } from '../utilities/flood-prediction'
55
import logger from '../logger'
66

77
const log = logger.child({ task: 'flood-calibration' })
88

9-
const EPOCH_START = '1970-01-01T00:00:00.000Z'
9+
// Bound how far back calibration reads, so memory stays modest on small hosts and the model
10+
// reflects recent channel behaviour (~6 years still spans many flood seasons).
11+
const CALIBRATION_LOOKBACK_DAYS = 6 * 365
12+
const DAY_MS = 24 * 60 * 60 * 1000
1013

1114
export interface FloodCalibrationSummary {
1215
calibrated: number
@@ -31,6 +34,7 @@ const thresholdFor = (
3134
export const runFloodCalibration = async (): Promise<FloodCalibrationSummary> => {
3235
const links = await getRiverLinks()
3336
const summary: FloodCalibrationSummary = { calibrated: 0, active: 0, skipped: 0 }
37+
const since = new Date(Date.now() - CALIBRATION_LOOKBACK_DAYS * DAY_MS).toISOString()
3438

3539
for (const link of links) {
3640
try {
@@ -51,8 +55,8 @@ export const runFloodCalibration = async (): Promise<FloodCalibrationSummary> =>
5155
}
5256

5357
const [downstreamRows, upstreamRows] = await Promise.all([
54-
getRiverLevelsSince(link.downstream_river_id, EPOCH_START),
55-
getRiverLevelsSince(link.upstream_river_id, EPOCH_START),
58+
getRiverReadingsSince(link.downstream_river_id, since),
59+
getRiverReadingsSince(link.upstream_river_id, since),
5660
])
5761

5862
const model = calibrateLink(toReadings(upstreamRows), toReadings(downstreamRows), threshold)

src/utilities/flood-prediction.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,17 @@ export interface FloodModelOptions {
4747
minLeadMinutes: number
4848
minSamples: number
4949
precursorPercentile: number
50+
minSeparationMinutes: number
5051
}
5152

5253
export const DEFAULT_FLOOD_OPTIONS: FloodModelOptions = {
5354
maxLookbackMinutes: 48 * 60,
5455
minLeadMinutes: 15,
5556
minSamples: 3,
5657
precursorPercentile: 0.25,
58+
// Treat brief dips back under the threshold as part of the same flood, so one flood is one
59+
// event (not many micro-events when the level hugs the threshold).
60+
minSeparationMinutes: 6 * 60,
5761
}
5862

5963
const MINUTE_MS = 60_000
@@ -83,22 +87,37 @@ export const percentile = (values: number[], p: number): number => {
8387
* Splits a level series into maximal runs strictly above `threshold`. Each run yields one event
8488
* with its onset (first sample above), peak time and peak value.
8589
*/
86-
export const detectExceedanceEvents = (series: Reading[], threshold: number): ExceedanceEvent[] => {
90+
export const detectExceedanceEvents = (
91+
series: Reading[],
92+
threshold: number,
93+
minSeparationMinutes = 0
94+
): ExceedanceEvent[] => {
8795
const sorted = sortByTime(series)
96+
const separationMs = minSeparationMinutes * MINUTE_MS
8897
const events: ExceedanceEvent[] = []
8998
let current: ExceedanceEvent | null = null
99+
let belowSince: number | null = null
90100

91101
for (const point of sorted) {
92102
if (point.value > threshold) {
103+
belowSince = null
93104
if (!current) {
94105
current = { onsetAt: point.measuredAt, peakAt: point.measuredAt, peakValue: point.value }
95106
} else if (point.value > current.peakValue) {
96107
current.peakValue = point.value
97108
current.peakAt = point.measuredAt
98109
}
99110
} else if (current) {
100-
events.push(current)
101-
current = null
111+
// Only close the event once the level has stayed below the threshold for at least the
112+
// separation window; shorter dips are absorbed into the same flood.
113+
if (belowSince === null) {
114+
belowSince = point.measuredAt
115+
}
116+
if (point.measuredAt - belowSince >= separationMs) {
117+
events.push(current)
118+
current = null
119+
belowSince = null
120+
}
102121
}
103122
}
104123

@@ -147,7 +166,7 @@ export const calibrateLink = (
147166
threshold: number,
148167
options: FloodModelOptions = DEFAULT_FLOOD_OPTIONS
149168
): LinkModel => {
150-
const events = detectExceedanceEvents(downstream, threshold)
169+
const events = detectExceedanceEvents(downstream, threshold, options.minSeparationMinutes)
151170
const observations = events
152171
.map((event) => findPrecursor(upstream, event, options))
153172
.filter((obs): obs is PrecursorObservation => obs !== null)

src/utilities/telegram.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ export const sendFloodPredictionMessage = async (
103103
const textMessage = `🌊⚠️ Possibile piena in arrivo
104104
Monte: ${upstream.river_name}${upstream.station_name}: ${payload.upstreamValue} m
105105
Storicamente questo livello a monte ha preceduto il superamento della soglia ${payload.targetThreshold} a ${downstream.river_name}${downstream.station_name}.
106-
Arrivo stimato: tra ~${formatLeadTime(payload.leadTimeMinutes)} (≈ ${eta})`
106+
Arrivo stimato: tra ~${formatLeadTime(payload.leadTimeMinutes)} (≈ ${eta})
107+
ℹ️ Stima statistica, non una previsione ufficiale: verificare sempre le fonti ufficiali (allertameteo.regione.emilia-romagna.it, Protezione Civile).`
107108

108109
await sendTelegramMessage(config.chat_id, textMessage)
109110
}

tests/tasks/flood-calibration.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { expect } from 'chai'
2+
import { afterEach, beforeEach, describe, it } from 'mocha'
3+
import sinon, { SinonStub } from 'sinon'
4+
import * as riverModel from '../../src/models/river'
5+
import * as riverLevelModel from '../../src/models/river-level'
6+
import * as riverLinkModel from '../../src/models/river-link'
7+
import { River } from '../../src/models/river'
8+
import { RiverLink } from '../../src/models/river-link'
9+
import { runFloodCalibration } from '../../src/tasks/flood-calibration'
10+
11+
const T0 = new Date('2026-01-01T00:00:00.000Z').getTime()
12+
const MIN = 60_000
13+
const DAY = 24 * 60 * MIN
14+
15+
const iso = (ms: number) => new Date(ms).toISOString()
16+
17+
const river = (id: number, soglia1: number | null): River => ({
18+
id,
19+
station_id: String(id),
20+
river_name: 'Idice',
21+
station_name: `st-${id}`,
22+
soglia1,
23+
soglia2: null,
24+
soglia3: null,
25+
created_on: iso(T0),
26+
updated_on: iso(T0),
27+
})
28+
29+
const link = (overrides: Partial<RiverLink> = {}): RiverLink => ({
30+
id: 1,
31+
upstream_river_id: 10,
32+
downstream_river_id: 20,
33+
target_threshold: 1,
34+
lead_time_minutes: null,
35+
precursor_level: null,
36+
sample_size: 0,
37+
model_json: null,
38+
last_calibrated_on: null,
39+
created_on: iso(T0),
40+
updated_on: iso(T0),
41+
...overrides,
42+
})
43+
44+
// Three downstream exceedances (>10) 3 days apart, each preceded by an upstream peak.
45+
const buildPair = (dayOffset: number, leadMin: number, upstreamPeak: number) => {
46+
const onset = T0 + dayOffset * DAY
47+
return {
48+
downstream: [
49+
{ value: 9, measured_at: iso(onset - 30 * MIN) },
50+
{ value: 11, measured_at: iso(onset) },
51+
{ value: 12, measured_at: iso(onset + 30 * MIN) },
52+
{ value: 8, measured_at: iso(onset + 60 * MIN) },
53+
],
54+
upstream: [
55+
{ value: upstreamPeak - 2, measured_at: iso(onset - (leadMin + 60) * MIN) },
56+
{ value: upstreamPeak, measured_at: iso(onset - leadMin * MIN) },
57+
{ value: upstreamPeak - 1, measured_at: iso(onset - (leadMin - 30) * MIN) },
58+
],
59+
}
60+
}
61+
62+
const e = [buildPair(2, 120, 5), buildPair(5, 150, 6), buildPair(8, 135, 5.5)]
63+
const downstreamRows = e.flatMap((p) => p.downstream)
64+
const upstreamRows = e.flatMap((p) => p.upstream)
65+
66+
describe('tests/tasks/flood-calibration', () => {
67+
let getLinks: SinonStub
68+
let getRiver: SinonStub
69+
let getLevels: SinonStub
70+
let update: SinonStub
71+
72+
beforeEach(() => {
73+
getLinks = sinon.stub(riverLinkModel, 'getRiverLinks')
74+
getRiver = sinon.stub(riverModel, 'getRiverById')
75+
getLevels = sinon.stub(riverLevelModel, 'getRiverReadingsSince')
76+
update = sinon.stub(riverLinkModel, 'updateRiverLinkModel').resolves({} as never)
77+
})
78+
79+
afterEach(() => sinon.restore())
80+
81+
it('learns and persists an active model from historical events', async () => {
82+
getLinks.resolves([link()])
83+
getRiver.withArgs(20).resolves(river(20, 10))
84+
getRiver.withArgs(10).resolves(river(10, 5))
85+
getLevels.withArgs(20).resolves(downstreamRows)
86+
getLevels.withArgs(10).resolves(upstreamRows)
87+
88+
const summary = await runFloodCalibration()
89+
90+
expect(update.calledOnce).to.equal(true)
91+
const patch = update.firstCall.args[1]
92+
expect(patch.sample_size).to.equal(3)
93+
expect(patch.lead_time_minutes).to.equal(135)
94+
expect(patch.precursor_level).to.be.closeTo(5.25, 1e-9)
95+
expect(summary).to.deep.equal({ calibrated: 1, active: 1, skipped: 0 })
96+
})
97+
98+
it('skips a link whose downstream soglia is not set', async () => {
99+
getLinks.resolves([link()])
100+
getRiver.withArgs(20).resolves(river(20, null))
101+
getRiver.withArgs(10).resolves(river(10, 5))
102+
103+
const summary = await runFloodCalibration()
104+
105+
expect(update.called).to.equal(false)
106+
expect(summary).to.deep.equal({ calibrated: 0, active: 0, skipped: 1 })
107+
})
108+
109+
it('skips a link with a missing river', async () => {
110+
getLinks.resolves([link()])
111+
getRiver.withArgs(20).resolves(undefined)
112+
getRiver.withArgs(10).resolves(river(10, 5))
113+
114+
const summary = await runFloodCalibration()
115+
116+
expect(summary.skipped).to.equal(1)
117+
expect(update.called).to.equal(false)
118+
})
119+
120+
it('isolates a per-link failure and continues', async () => {
121+
getLinks.resolves([link({ id: 1 }), link({ id: 2 })])
122+
getRiver.withArgs(20).resolves(river(20, 10))
123+
getRiver.withArgs(10).resolves(river(10, 5))
124+
getLevels.withArgs(20).rejects(new Error('db down'))
125+
126+
const summary = await runFloodCalibration()
127+
128+
expect(summary.skipped).to.equal(2)
129+
expect(summary.calibrated).to.equal(0)
130+
})
131+
132+
it('persists an inactive model when there are too few events', async () => {
133+
getLinks.resolves([link()])
134+
getRiver.withArgs(20).resolves(river(20, 10))
135+
getRiver.withArgs(10).resolves(river(10, 5))
136+
getLevels.withArgs(20).resolves(e[0].downstream) // single event only
137+
getLevels.withArgs(10).resolves(e[0].upstream)
138+
139+
const summary = await runFloodCalibration()
140+
141+
const patch = update.firstCall.args[1]
142+
expect(patch.sample_size).to.equal(1)
143+
expect(summary).to.deep.equal({ calibrated: 1, active: 0, skipped: 0 })
144+
})
145+
})

0 commit comments

Comments
 (0)