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