Skip to content

Commit 6725dba

Browse files
committed
Merge #98: EFFIS è pubblico — corretto il dialetto WFS del client
2 parents 17b09f7 + 63204a4 commit 6725dba

8 files changed

Lines changed: 386 additions & 43 deletions

File tree

.env.example

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,9 @@ LLM__LLAMACPP_BASE_URL=http://127.0.0.1:8091
174174
#LIMEN_FWI_AOI=
175175
#LIMEN_FWI_DAYS=60
176176

177-
# Backtest incendio contro i perimetri di area bruciata EFFIS. Nota: l'endpoint
178-
# pubblico EFFIS risponde 403 senza accreditamento, quindi senza ingest il
179-
# report esce vuoto e lo dichiara.
177+
# Backtest incendio contro i perimetri di area bruciata EFFIS. Il servizio è
178+
# pubblico e non richiede credenziali: se il report esce vuoto va eseguito
179+
# l'ingest, non cercato un accreditamento.
180180
#LIMEN_BACKTEST_WILDFIRE_AOI=
181181
#LIMEN_BACKTEST_WILDFIRE_START=
182182
#LIMEN_BACKTEST_WILDFIRE_END=

CLAUDE.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@
9090
| Geodata image carries no data | The Docker image bundles Python + GDAL/PROJ + tippecanoe + the code. Datasets land in the named PostGIS volume at first `limen geodata init`. Verify with `docker image inspect` — image size ≪ data size. |
9191
| Geodata never in critical path | The operational API reads pre-computed numeric per-cell features. `limen geodata export-features` ships those across with one upsert per cell. The MCP server is for agents; nothing in the hourly scoring path waits on it. |
9292
| Geodata is self-contained | `geodata/` is a uv workspace member designed to be extracted into a standalone repo with one `mv`. Nothing in `geodata.*` imports from `limen.*`; Prompt-2 parsers are duplicated in `geodata/parsers.py`. |
93+
| EFFIS is MapServer, not GeoServer | `maps.effis.emergency.copernicus.eu/gwis` is a **MapServer** CGI, and every GeoServer habit fails differently: `/gwis/ows` hangs (use `/gwis`), a wrong `typeNames` closes the connection mid-response instead of 404ing, `CQL_FILTER` is silently ignored, and `bbox` + `filter` together kill the connection. The layer is `nrt.ba.poly` with `initialdate`/`area`; dates are filtered **client-side** after a bbox request, which is cheap because one AOI's whole history is a single response (Basilicata: 515 perimeters 2012-2026, 305 KB). A sync that returns zero is a client bug, not missing credentials — that mistake cost this project a year of an empty `fire_perimeters` and three issues blocked on an accreditation nobody needed. |
94+
| A hit rate without a base rate is unreadable | `backtest-wildfire` reports both, plus their ratio. A model that calls every summer day dangerous scores a perfect hit rate while discriminating nothing, and the base rate is what exposes it. Measure it over the **fire season only**, never the spin-up days: including them dropped Basilicata's 2025 base from 43 % to 35 % and inflated the discrimination from 1.82 to 2.27 for free. |
9395
| Geodata URLs are official | The manifest schema refuses any URL outside `https://idrogeo.isprambiente.it/` by construction. To add a dataset, edit `datasets.yaml` (single source of truth) — no code change required. |
9496
| MCP refresh is admin-only | The `refresh` tool requires `MCP_ADMIN_TOKEN`. Env var **unset** = refresh disabled (fail-closed). |
9597
| `quality-local` never on a synchronous path | Colibrì/GLM-5.2 generate in *tens of minutes* (measured: 40 s for 3 tokens). Every `LLM__MODELS__*` role is synchronous — HTTP request, MCP tool call, or a scheduler tick shorter than the response — so mapping one there stalls the hourly sweep silently instead of just being slow. Enforced by `SLOW_GENERATION_MODELS` + a `model_validator` on `LLMSettings`: the process **refuses to start**. See `docs/inference.md`. |
@@ -286,9 +288,11 @@ extension points already:
286288
`landuse_code` (CORINE) and `slope_deg` (DEM) are unpopulated in most
287289
deployments, so the terrain term is constant and the wildfire map is
288290
effectively the FWI map — which is what EFFIS publishes, but not yet
289-
territory-modulated risk. And the EFFIS perimeter endpoint answers **403**
290-
without accreditation, so `backtest-wildfire` has no ground truth until
291-
someone ingests it; the report says so instead of reporting zeros.
291+
territory-modulated risk. The EFFIS perimeter service, on the other hand,
292+
**needs no credentials** — the earlier conclusion that it required CEMS
293+
accreditation was wrong, and the fix was in our own client. Measured on
294+
Basilicata against real perimeters: hit rate 79 % against a 43 % base rate
295+
in 2025, 100 % against 28 % in 2024.
292296
- Flood (#63-#64) still has no YAML and no engine.
293297
- V2 ML scoring engine (drop-in replacement of
294298
`MultiFactorScoringEngine` consuming the same `CellFeatureBundle`).

docs/HANDOFF.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,12 @@ Versione: implementazione completa, in fase di test. Feature mergiate di recente
9797
lo dichiara. `limen fwi-backfill` ricostruisce la storia dall'archivio
9898
ERA5 e va eseguito prima di mettere la mappa davanti a un operatore.
9999

100-
**Backtest senza ground truth**: l'endpoint pubblico EFFIS risponde **403**
101-
senza accreditamento, quindi `fire_perimeters` resta vuota e il report lo
102-
dichiara invece di stampare zeri. La logica di valutazione è verificata con
103-
perimetri sintetici in `tests/integration/test_backtest_wildfire.py`.
100+
**Backtest su ground truth vero**: EFFIS è pubblico e non richiede
101+
credenziali. Per un anno il client ha creduto il contrario perché parlava
102+
dialetto GeoServer a un servizio MapServer — vedi l'invariante «EFFIS is
103+
MapServer» in CLAUDE.md. Misurato su Basilicata con perimetri reali: hit
104+
rate **79 %** contro un tasso di base del **43 %** nel 2025 (1.82 volte),
105+
**100 %** contro **28 %** nel 2024 (3.61 volte), preavviso medio ~55 h.
104106

105107
- **Fase 1 hazard-agnostic COMPLETA** (2026-09-05, epic #57, PR #88 #89 #91 #92
106108
#93 + #94). L'architettura è multi-rischio nella struttura: `hazard_type`

src/limen/cli/backtest_wildfire.py

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,15 @@
1212
exactly as the operational step does. Forcing the two through one function
1313
would make both harder to read than either is alone.
1414
15-
Truth is ``fire_perimeters`` — the EFFIS burnt areas Limen already ingests.
16-
The public EFFIS endpoint now answers 403 without accreditation, so an
17-
un-ingested deployment gets an honest empty report rather than a crash.
15+
Truth is ``fire_perimeters`` — the EFFIS burnt areas Limen ingests. The
16+
service is public and needs no credentials; an un-ingested deployment still
17+
gets an honest empty report rather than a crash.
18+
19+
The report pairs the hit rate with the **base rate**: the share of days a
20+
burnt cell was in alert anyway. Without it the hit rate is unreadable,
21+
because a model that calls every summer day dangerous maximises it while
22+
discriminating nothing. Measured on Basilicata against real perimeters: 79 %
23+
against a 43 % base in 2025, 100 % against 28 % in 2024.
1824
1925
Env knobs:
2026
@@ -88,6 +94,12 @@ class WildfireBacktestMetrics:
8894
hit_rate: float
8995
far: float
9096
mean_lead_hours: float
97+
#: Quota di giornate della finestra in cui una cella poi bruciata era
98+
#: comunque in allerta. È il metro dell'hit rate: un modello che dice
99+
#: "pericolo alto" tutti i giorni d'estate ottiene un hit rate altissimo
100+
#: senza discriminare nulla, e senza questo numero il report lo
101+
#: presenterebbe come bravura.
102+
base_rate: float
91103
report_path: Path | None
92104

93105

@@ -218,6 +230,7 @@ def evaluate(
218230
days: list[date],
219231
thresholds: WildfireThresholds,
220232
alert_level: RiskLevel,
233+
season: tuple[date, date] | None = None,
221234
) -> WildfireBacktestMetrics:
222235
"""Score every burnt cell across the window and measure the warning.
223236
@@ -233,6 +246,27 @@ def evaluate(
233246
horizon_days = int(_LEAD_MAX_HOURS // 24)
234247
scored_days = set(days)
235248

249+
def alerted(cell_id: str, sf: StaticFactors, fw: FireWeatherState, day: date) -> bool:
250+
scored = engine.score(
251+
CellFeatureBundle(
252+
aoi_id=aoi_id,
253+
cell_id=cell_id,
254+
static=sf,
255+
dynamic=DynamicInputs(
256+
valuation_time=datetime.combine(day, time(12), UTC),
257+
fire_weather=fw,
258+
),
259+
)
260+
)
261+
return _at_least(scored.level, alert_level)
262+
263+
# Il tasso di base si misura sulla **stagione degli incendi**, non sui
264+
# giorni di spin-up che la precedono: quelli sono primavera, il pericolo è
265+
# basso, e includerli abbasserebbe il metro gonfiando la discriminazione.
266+
first, last = season or (days[0], days[-1])
267+
base_days = 0
268+
base_alerted = 0
269+
236270
hits = 0
237271
misses = 0
238272
warned_cells = 0
@@ -251,23 +285,24 @@ def evaluate(
251285
for d in range(horizon_days, -1, -1)
252286
if (fire_date - timedelta(days=d)) in scored_days
253287
]
288+
# Il metro: quanto spesso questa cella sarebbe stata in allerta in un
289+
# giorno qualunque della finestra, non solo prima del suo incendio.
290+
for day in days:
291+
if not first <= day <= last:
292+
continue
293+
fw = chain.get(day)
294+
if fw is None:
295+
continue
296+
base_days += 1
297+
if alerted(cell_id, sf, fw, day):
298+
base_alerted += 1
299+
254300
earliest: date | None = None
255301
for day in window:
256302
fw = chain.get(day)
257303
if fw is None:
258304
continue
259-
scored = engine.score(
260-
CellFeatureBundle(
261-
aoi_id=aoi_id,
262-
cell_id=cell_id,
263-
static=sf,
264-
dynamic=DynamicInputs(
265-
valuation_time=datetime.combine(day, time(12), UTC),
266-
fire_weather=fw,
267-
),
268-
)
269-
)
270-
if _at_least(scored.level, alert_level):
305+
if alerted(cell_id, sf, fw, day):
271306
earliest = day
272307
break
273308
if earliest is None:
@@ -295,10 +330,16 @@ def evaluate(
295330
hit_rate=hit_rate,
296331
far=far,
297332
mean_lead_hours=mean_lead,
333+
base_rate=base_alerted / base_days if base_days else 0.0,
298334
report_path=None,
299335
)
300336

301337

338+
def _lift(metrics: WildfireBacktestMetrics) -> float:
339+
"""Quante volte l'hit rate supera il tasso di base. 0 se non misurabile."""
340+
return metrics.hit_rate / metrics.base_rate if metrics.base_rate > 0 else 0.0
341+
342+
302343
def write_report(
303344
metrics: WildfireBacktestMetrics, *, start: date, end: date, alert_level: RiskLevel
304345
) -> Path:
@@ -320,9 +361,16 @@ def write_report(
320361
"## Metriche §2.5",
321362
"",
322363
f"- **Hit rate**: {metrics.hit_rate:.2%}",
364+
f"- **Tasso di base**: {metrics.base_rate:.2%} "
365+
f"(quota di giornate in allerta a prescindere dagli incendi)",
366+
f"- **Discriminazione**: {_lift(metrics):.2f} volte il tasso di base",
323367
"- **FAR**: non misurato (vedi nota)",
324368
f"- **Preavviso medio**: {metrics.mean_lead_hours:.1f} h",
325369
"",
370+
"> L'hit rate da solo non dice nulla: un modello che dichiara pericolo",
371+
"> alto tutti i giorni d'estate lo massimizza senza discriminare. Il",
372+
"> confronto con il tasso di base è ciò che separa la bravura dalla",
373+
"> frequenza — sotto 1.0 il modello è peggio del caso.",
326374
"> Il replay guarda solo le celle che hanno bruciato, e solo",
327375
f"> l'orizzonte di {_LEAD_MAX_HOURS:.0f} h prima di ciascun incendio. Hit rate e",
328376
"> preavviso sono quindi misurati; il **FAR no**: un falso allarme è",
@@ -334,9 +382,10 @@ def write_report(
334382
if metrics.truth_fires == 0:
335383
lines.insert(
336384
5,
337-
"> **Nessun perimetro EFFIS nella finestra.** L'endpoint pubblico "
338-
"risponde 403 senza accreditamento: senza ingest non c'è ground "
339-
"truth e le metriche sotto sono vuote per costruzione, non zero.",
385+
"> **Nessun perimetro EFFIS nella finestra.** Il servizio è "
386+
"pubblico e non richiede credenziali: se `fire_perimeters` è vuota "
387+
"va eseguito l'ingest. Le metriche sotto sono vuote per "
388+
"costruzione, non zero.",
340389
)
341390
out.write_text("\n".join(lines) + "\n", encoding="utf-8")
342391
return out
@@ -364,6 +413,7 @@ async def backtest_aoi(
364413
hit_rate=0.0,
365414
far=0.0,
366415
mean_lead_hours=0.0,
416+
base_rate=0.0,
367417
report_path=None,
368418
)
369419
path = write_report(metrics, start=start, end=end, alert_level=alert_level)
@@ -409,6 +459,7 @@ async def backtest_aoi(
409459
days=days,
410460
thresholds=thresholds,
411461
alert_level=alert_level,
462+
season=(start, end),
412463
)
413464
path = write_report(metrics, start=start, end=end, alert_level=alert_level)
414465
log.info(
@@ -418,6 +469,8 @@ async def backtest_aoi(
418469
hit_rate=round(metrics.hit_rate, 4),
419470
far=round(metrics.far, 4),
420471
mean_lead_hours=round(metrics.mean_lead_hours, 1),
472+
base_rate=round(metrics.base_rate, 4),
473+
lift=round(_lift(metrics), 2),
421474
report=str(path),
422475
)
423476
return replace(metrics, report_path=path)

src/limen/integrations/effis/fire_client.py

Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,26 @@
4141

4242
# Default WFS endpoint and typeName. Override at construction time if
4343
# Copernicus moves endpoints.
44-
DEFAULT_WFS_URL = "https://maps.effis.emergency.copernicus.eu/gwis/ows"
45-
DEFAULT_TYPENAME = "effis:ba.fires"
44+
#
45+
# The endpoint is **MapServer**, not GeoServer, and that shapes every choice
46+
# below. Verified against the live service:
47+
#
48+
# * the base path is ``/gwis``, not ``/gwis/ows`` — the latter hangs;
49+
# * the layer is ``nrt.ba.poly``. ``effis:ba.fires`` does not exist, and a
50+
# request for it makes MapServer close the connection mid-response, which
51+
# surfaces as a transport error rather than a 404;
52+
# * ``CQL_FILTER`` is a GeoServer vendor parameter. MapServer ignores it, so
53+
# a date range expressed that way silently filtered nothing;
54+
# * ``bbox`` and ``filter`` are mutually exclusive in WFS (and combining them
55+
# here kills the connection), so the bbox goes on the wire and the dates are
56+
# applied locally. Cheap in practice: one AOI's whole history is a single
57+
# response — Basilicata is 515 perimeters across 2012-2026, 305 KB.
58+
DEFAULT_WFS_URL = "https://maps.effis.emergency.copernicus.eu/gwis"
59+
DEFAULT_TYPENAME = "nrt.ba.poly"
60+
61+
#: Server-side cap. Above the largest per-AOI history we measured, so the
62+
#: local date filter never sees a truncated set.
63+
DEFAULT_MAX_FEATURES = 5000
4664

4765
_DEGRADATION_EXC: tuple[type[BaseException], ...] = (
4866
httpx.HTTPError,
@@ -52,6 +70,30 @@
5270
)
5371

5472

73+
def _feature_date(feat: dict[str, Any]) -> date | None:
74+
"""The day a fire started, from whichever field this layer carries."""
75+
props = feat.get("properties") or {}
76+
for key in ("initialdate", "firedate", "FIREDATE", "fire_date"):
77+
raw = props.get(key)
78+
if raw:
79+
try:
80+
return date.fromisoformat(str(raw)[:10])
81+
except ValueError:
82+
return None
83+
return None
84+
85+
86+
def _within(feat: dict[str, Any], start: date, end: date) -> bool:
87+
"""Keep a feature whose start date falls in the window.
88+
89+
A feature with no readable date is **kept**: the sync stores it with a
90+
NULL date, which a backtest ignores, and dropping it here would lose a
91+
perimeter that is still real.
92+
"""
93+
when = _feature_date(feat)
94+
return when is None or start <= when <= end
95+
96+
5597
class EffisHttpClient:
5698
"""Concrete :class:`EffisClient` Protocol implementation."""
5799

@@ -61,10 +103,12 @@ def __init__(
61103
wfs_url: str = DEFAULT_WFS_URL,
62104
typename: str = DEFAULT_TYPENAME,
63105
http_client: httpx.AsyncClient | None = None,
106+
max_features: int = DEFAULT_MAX_FEATURES,
64107
) -> None:
65108
self._wfs_url = wfs_url
66109
self._typename = typename
67110
self._http = http_client
111+
self._max_features = max_features
68112

69113
async def _client(self) -> httpx.AsyncClient:
70114
return self._http if self._http is not None else await SharedHttpClient.get()
@@ -82,15 +126,15 @@ async def fetch_perimeters(
82126
"""
83127
params: dict[str, Any] = {
84128
"service": "WFS",
85-
"version": "2.0.0",
129+
"version": "1.1.0",
86130
"request": "GetFeature",
87-
"typeNames": self._typename,
88-
"outputFormat": "application/json",
89-
"srsName": "EPSG:4326",
90-
"bbox": f"{bbox[0]},{bbox[1]},{bbox[2]},{bbox[3]},EPSG:4326",
91-
"CQL_FILTER": (
92-
f"firedate >= '{start.isoformat()}' AND firedate <= '{end.isoformat()}'"
93-
),
131+
# Lowercase keys: MapServer's WFS 1.1.0 does not accept the
132+
# camelCase spellings GeoServer tolerates.
133+
"typename": self._typename,
134+
"outputformat": "geojson",
135+
"srsname": "EPSG:4326",
136+
"bbox": f"{bbox[0]},{bbox[1]},{bbox[2]},{bbox[3]}",
137+
"maxfeatures": str(self._max_features),
94138
}
95139
log.info(
96140
"effis.perimeters.fetch",
@@ -120,8 +164,23 @@ async def fetch_perimeters(
120164
log.warning("effis.perimeters.bad_payload", status=resp.status_code)
121165
return []
122166
features = list(payload.get("features") or [])
123-
log.info("effis.perimeters.fetched", count=len(features))
124-
return features
167+
if len(features) >= self._max_features:
168+
# The local date filter below would then be working on a truncated
169+
# set, and the gap would look like "no fires that summer".
170+
log.warning(
171+
"effis.perimeters.truncated",
172+
count=len(features),
173+
max_features=self._max_features,
174+
)
175+
in_window = [f for f in features if _within(f, start, end)]
176+
log.info(
177+
"effis.perimeters.fetched",
178+
fetched=len(features),
179+
in_window=len(in_window),
180+
start=start.isoformat(),
181+
end=end.isoformat(),
182+
)
183+
return in_window
125184

126185
async def fetch_perimeters_bulk(
127186
self,

src/limen/integrations/effis/sync_job.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,16 +61,32 @@ def _parse_feature(feat: dict[str, Any]) -> FirePerimeter | None:
6161
log.warning("effis.feature.skip", reason="non-polygon geometry", feat_id=feat_id)
6262
return None
6363

64-
fire_date_str = props.get("firedate") or props.get("FIREDATE") or props.get("fire_date")
64+
# `initialdate` è il campo del layer `nrt.ba.poly`; gli altri restano per
65+
# il fallback bulk, che è un altro dataset con un'altra intestazione.
66+
fire_date_str = (
67+
props.get("initialdate")
68+
or props.get("firedate")
69+
or props.get("FIREDATE")
70+
or props.get("fire_date")
71+
)
6572
fire_date: date | None = None
6673
if fire_date_str:
6774
try:
6875
fire_date = date.fromisoformat(str(fire_date_str)[:10])
6976
except ValueError:
7077
log.warning("effis.feature.bad_date", value=fire_date_str, feat_id=feat_id)
7178

72-
area_ha_raw = props.get("area_ha") or props.get("AREA_HA") or props.get("AREA")
73-
area_ha = float(area_ha_raw) if area_ha_raw is not None else None
79+
# `area` sul WFS, già in ettari; `area_ha` sui file bulk. Arriva come
80+
# stringa, quindi la conversione è esplicita e tollera un valore illeggibile.
81+
area_raw = (
82+
props.get("area") or props.get("area_ha") or props.get("AREA_HA") or props.get("AREA")
83+
)
84+
area_ha: float | None = None
85+
if area_raw is not None:
86+
try:
87+
area_ha = float(area_raw)
88+
except (TypeError, ValueError):
89+
log.warning("effis.feature.bad_area", value=area_raw, feat_id=feat_id)
7490

7591
return FirePerimeter(
7692
id=str(feat_id),

0 commit comments

Comments
 (0)