diff --git a/frontend/src/components/detection/DetectionLab.tsx b/frontend/src/components/detection/DetectionLab.tsx
index b135855d..91a133a1 100644
--- a/frontend/src/components/detection/DetectionLab.tsx
+++ b/frontend/src/components/detection/DetectionLab.tsx
@@ -949,6 +949,26 @@ export function DetectionLab({
{detectionQaResult.box_to_footprint_diagnostics.possible_box_to_footprint_mismatch_count} mogelijke vormafwijkingen. Precisie, herkenningsgraad en F1 hierboven blijven gebaseerd op de strikte geometrische overlap.
+ {detectionQaResult.box_to_footprint_diagnostics.interpretation ? (
+ {detectionQaResult.box_to_footprint_diagnostics.interpretation}
+ ) : null}
+
+ ) : null}
+ {detectionQaResult.precision_recall_curve ? (
+
+
Drempelonafhankelijke kwaliteit
+
+ AP {formatNullableNumber(detectionQaResult.precision_recall_curve.average_precision, 3)} · beste F1{' '}
+ {formatNullableNumber(detectionQaResult.precision_recall_curve.best_f1, 3)}
+
+
+ {detectionQaResult.precision_recall_curve.best_f1_threshold === null
+ ? 'Geen kandidaten om een werkpunt uit af te leiden.'
+ : `De beste F1 ligt bij drempel ${formatNullableNumber(
+ detectionQaResult.precision_recall_curve.best_f1_threshold,
+ 2,
+ )}. Precisie en herkenningsgraad hierboven gelden alleen voor de gekozen drempel; AP vat de volledige curve samen en maakt vergelijking tussen modellen mogelijk.`}
+
) : null}
diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx
index 859a6f26..11e709b0 100644
--- a/frontend/src/components/map/MapWorkspace.tsx
+++ b/frontend/src/components/map/MapWorkspace.tsx
@@ -2957,6 +2957,9 @@ export function MapWorkspace({
{analysisMode === 'current' && activeSelectionResult?.truncated ? (
De telling is volledig; op de kaart en in de tabel worden maximaal {activeSelectionResult.limit.toLocaleString('nl-BE')} objecten getoond.
) : null}
+ {analysisMode === 'current' && activeSelectionResult?.summary?.selection_edge_warning ? (
+ {activeSelectionResult.summary.selection_edge_warning}
+ ) : null}
{analysisMode === 'current' && activeSelectionResult?.summary?.warning ? (
{activeSelectionResult.summary.warning}
) : null}
diff --git a/frontend/src/lib/bathymetryRaster.ts b/frontend/src/lib/bathymetryRaster.ts
index 7beca57c..39fc303f 100644
--- a/frontend/src/lib/bathymetryRaster.ts
+++ b/frontend/src/lib/bathymetryRaster.ts
@@ -19,7 +19,10 @@ export function bathymetryRasterSelectionToMapSelection(
...result.summary,
feature_count: result.valid_cell_count,
is_estimate: false,
- warning: result.limitation_message,
+ // A selection finer than one source cell was widened to the cells it
+ // touches; the result then covers more ground than was drawn.
+ warning: [result.cell_selection_warning, result.limitation_message].filter(Boolean).join(' '),
+ selection_edge_warning: result.cell_selection_warning ?? null,
metrics: result.summary.metrics.map((metric) => ({
...metric,
is_estimate: false,
diff --git a/frontend/src/lib/floodHazardSelection.ts b/frontend/src/lib/floodHazardSelection.ts
index 567c2b56..a3945abf 100644
--- a/frontend/src/lib/floodHazardSelection.ts
+++ b/frontend/src/lib/floodHazardSelection.ts
@@ -12,9 +12,15 @@ export function floodHazardSelectionToMapSelection(result: FloodHazardSelectionR
summary: {
...result.summary,
feature_count: result.inundated_cell_count,
- is_estimate: false,
- warning: result.limitation_message,
- metrics: result.summary.metrics.map((metric) => ({ ...metric, is_estimate: false })),
+ // Coverage is not certain when the model does not span the selection, or
+ // when the selection was widened to whole cells.
+ is_estimate: Boolean(result.coverage_warning),
+ warning: [result.coverage_warning, result.limitation_message].filter(Boolean).join(' '),
+ selection_edge_warning: result.coverage_warning ?? null,
+ metrics: result.summary.metrics.map((metric) => ({
+ ...metric,
+ is_estimate: Boolean(result.coverage_warning),
+ })),
},
}
}
diff --git a/frontend/src/lib/rasterSelectionWarnings.test.ts b/frontend/src/lib/rasterSelectionWarnings.test.ts
new file mode 100644
index 00000000..2b7af37f
--- /dev/null
+++ b/frontend/src/lib/rasterSelectionWarnings.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from 'vitest'
+
+import { floodHazardSelectionToMapSelection } from './floodHazardSelection'
+import { thematicRasterSelectionToMapSelection } from './thematicRaster'
+import type { FloodHazardSelectionResponse, ThematicRasterSelectionResponse } from '../types'
+
+/**
+ * Raster analyses answer over whole cells. When a selection is finer than one
+ * source cell, or when the flood model does not span it, the value describes a
+ * different area than the operator drew. The adapters must carry that statement
+ * into the map panel; without it the number reads as an exact answer.
+ */
+
+const BBOX = { min_x: 5.0, min_y: 51.1, max_x: 5.2, max_y: 51.3, crs: 'EPSG:4326' }
+
+function floodResponse(overrides: Partial = {}): FloodHazardSelectionResponse {
+ return {
+ dataset_id: 'dataset-1',
+ dataset_ids: ['dataset-1'],
+ partition_count: 1,
+ product_key: 'fluviaal_current_t100',
+ mechanism: 'fluviaal',
+ climate_context: 'current',
+ probability_class: 'grote kans',
+ return_period_years: 100,
+ selection_bbox: BBOX,
+ selected_cell_count: 400,
+ inundated_cell_count: 200,
+ inundated_fraction: 1,
+ resolution_m: 5,
+ summary: {
+ metric_label: 'Gemodelleerd overstroomd oppervlak',
+ metric_value: 0.5,
+ metric_unit: 'ha',
+ aggregation_method: 'positive_depth_cells_times_cell_area',
+ primary_metric_key: 'modelled_inundated_area_ha',
+ metrics: [],
+ },
+ unsupported_metrics: [],
+ limitation_message: 'Scenariodiepte is geen bathymetrie.',
+ generated_at: '2026-01-01T00:00:00Z',
+ ...overrides,
+ } as FloodHazardSelectionResponse
+}
+
+function thematicResponse(
+ overrides: Partial = {},
+): ThematicRasterSelectionResponse {
+ return {
+ dataset_id: 'dataset-2',
+ product_key: 'population_density',
+ selection_bbox: BBOX,
+ selected_cell_count: 1,
+ valid_cell_count: 1,
+ coverage_ratio: 1,
+ resolution_m: 100,
+ summary: {
+ metric_label: 'Inwoners',
+ metric_value: 42,
+ metric_unit: 'inwoners',
+ aggregation_method: 'sum_valid_source_cells',
+ primary_metric_key: 'population',
+ metrics: [],
+ },
+ limitation_message: 'Rasterbron op 100 m.',
+ generated_at: '2026-01-01T00:00:00Z',
+ ...overrides,
+ } as ThematicRasterSelectionResponse
+}
+
+describe('flood hazard selection adapter', () => {
+ it('surfaces a partial model coverage warning and marks the result an estimate', () => {
+ const mapped = floodHazardSelectionToMapSelection(
+ floodResponse({ coverage_warning: 'Het VMM-model dekt 50.0% van deze selectie.' }),
+ )
+
+ expect(mapped.summary?.selection_edge_warning).toContain('50.0%')
+ expect(mapped.summary?.warning).toContain('50.0%')
+ expect(mapped.summary?.warning).toContain('Scenariodiepte is geen bathymetrie.')
+ expect(mapped.summary?.is_estimate).toBe(true)
+ })
+
+ it('leaves a fully modelled selection exact', () => {
+ const mapped = floodHazardSelectionToMapSelection(floodResponse())
+
+ expect(mapped.summary?.selection_edge_warning).toBeNull()
+ expect(mapped.summary?.is_estimate).toBe(false)
+ expect(mapped.summary?.warning).toBe('Scenariodiepte is geen bathymetrie.')
+ })
+})
+
+describe('thematic raster selection adapter', () => {
+ it('reports that a sub-cell selection was widened to whole cells', () => {
+ const mapped = thematicRasterSelectionToMapSelection(
+ thematicResponse({
+ cell_selection_warning: 'De selectie is kleiner dan één rastercel van deze bron.',
+ }),
+ )
+
+ expect(mapped.summary?.selection_edge_warning).toContain('rastercel')
+ expect(mapped.summary?.warning).toContain('rastercel')
+ expect(mapped.summary?.warning).toContain('Rasterbron op 100 m.')
+ })
+
+ it('keeps a normal selection free of an invented caveat', () => {
+ const mapped = thematicRasterSelectionToMapSelection(thematicResponse())
+
+ expect(mapped.summary?.selection_edge_warning).toBeNull()
+ expect(mapped.summary?.warning).toBe('Rasterbron op 100 m.')
+ })
+})
diff --git a/frontend/src/lib/terrainSelection.ts b/frontend/src/lib/terrainSelection.ts
index 7a4188e8..fc81b685 100644
--- a/frontend/src/lib/terrainSelection.ts
+++ b/frontend/src/lib/terrainSelection.ts
@@ -13,7 +13,10 @@ export function terrainSelectionToMapSelection(result: TerrainSelectionResponse)
...result.summary,
feature_count: result.sample_count,
is_estimate: false,
- warning: result.limitation_message,
+ // A selection finer than one source cell was widened to the cells it
+ // touches; the result then covers more ground than was drawn.
+ warning: [result.cell_selection_warning, result.limitation_message].filter(Boolean).join(' '),
+ selection_edge_warning: result.cell_selection_warning ?? null,
metrics: result.summary.metrics.map((metric) => ({
...metric,
is_estimate: false,
diff --git a/frontend/src/lib/thematicRaster.ts b/frontend/src/lib/thematicRaster.ts
index 2e83b325..f222e717 100644
--- a/frontend/src/lib/thematicRaster.ts
+++ b/frontend/src/lib/thematicRaster.ts
@@ -21,7 +21,10 @@ export function thematicRasterSelectionToMapSelection(result: ThematicRasterSele
...result.summary,
feature_count: result.valid_cell_count,
is_estimate: true,
- warning: result.limitation_message,
+ // A selection finer than one source cell was widened to the cells it
+ // touches; the result then covers more ground than was drawn.
+ warning: [result.cell_selection_warning, result.limitation_message].filter(Boolean).join(' '),
+ selection_edge_warning: result.cell_selection_warning ?? null,
metrics: result.summary.metrics.map((metric) => ({
...metric,
is_estimate: metric.is_estimate ?? true,
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 52e60d2a..7293d7ca 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -505,6 +505,8 @@ export interface TerrainSelectionResponse {
sample_count: number
slope_sample_count: number
coverage_ratio: number
+ /** Set when the selection was finer than one source cell and widened. */
+ cell_selection_warning?: string | null
resolution_m: number
vertical_reference: string
summary: {
@@ -558,8 +560,14 @@ export interface FloodHazardSelectionResponse {
selection_bbox: VectorSelectionBBox
selection_area_id?: string | null
selected_cell_count: number
+ /** Cells the flood model actually covers inside the selection. */
+ valid_cell_count?: number
+ no_data_cell_count?: number
+ data_coverage_ratio?: number
inundated_cell_count: number
- inundated_fraction: number
+ /** Share of the *modelled* cells; null when nothing was modelled. */
+ inundated_fraction: number | null
+ coverage_warning?: string | null
resolution_m: number
summary: {
metric_label: string
@@ -633,6 +641,8 @@ export interface BathymetryRasterSelectionResponse {
selected_cell_count: number
valid_cell_count: number
coverage_ratio: number
+ /** Set when the selection was finer than one source cell and widened. */
+ cell_selection_warning?: string | null
resolution_m: number
vertical_reference: 'mDNG'
survey_period: string
@@ -690,6 +700,8 @@ export interface ThematicRasterSelectionResponse {
selected_cell_count: number
valid_cell_count: number
coverage_ratio: number
+ /** Set when the selection was finer than one source cell and widened. */
+ cell_selection_warning?: string | null
resolution_m: number
observation_year: number
summary: {
@@ -749,7 +761,14 @@ export interface VectorSelectionSummary {
metric_unit: string
aggregation_method: string
primary_metric_key?: string | null
+ /**
+ * Whole features touching the selection. Area and length metrics clip to the
+ * selection, so these two fields say how far the populations diverge.
+ */
feature_count: number
+ fully_covered_feature_count?: number | null
+ partially_covered_feature_count?: number | null
+ selection_edge_warning?: string | null
is_estimate: boolean
warning?: string | null
metrics?: VectorSelectionMetric[]
@@ -1435,6 +1454,9 @@ export interface DetectionQaResult {
canonical_method: string
diagnostic_method: string
iou_threshold: number
+ /** Whether the candidates are detector boxes or true footprint polygons. */
+ candidate_geometry_mode?: string
+ interpretation?: string
strict_matches: number
envelope_matches: number
possible_box_to_footprint_mismatch_count: number
@@ -1444,7 +1466,37 @@ export interface DetectionQaResult {
envelope_recall?: number | null
envelope_f1_score?: number | null
envelope_mean_iou?: number | null
+ envelope_precision_recall_curve?: PrecisionRecallCurve
}
+ precision_recall_curve?: PrecisionRecallCurve
+}
+
+/**
+ * Threshold-independent view of a detection run: precision and recall at every
+ * confidence value that occurs, so two models can be compared without both
+ * being read at one arbitrary cut.
+ */
+export interface PrecisionRecallCurve {
+ iou_threshold: number
+ reference_count: number
+ candidate_count: number
+ average_precision: number
+ best_f1: number
+ best_f1_threshold: number | null
+ best_f1_precision?: number | null
+ best_f1_recall?: number | null
+ points: PrecisionRecallPoint[]
+}
+
+export interface PrecisionRecallPoint {
+ confidence_threshold: number
+ candidate_count: number
+ true_positives: number
+ false_positives: number
+ false_negatives: number
+ precision: number
+ recall: number
+ f1_score: number
}
export type SegmentationModelCapability = DetectionModelCapability