surface the new analysis caveats in the workbench

The backend now says when a result covers a different area than was drawn, or
when a score belongs to one confidence cut only. None of that helps an
operator while it stays in the response body.

- the raster selection adapters carry the model-coverage and widened-cell
  warnings into the map panel and mark the result an estimate when either
  applies, so an existing warning slot renders them;
- the map workspace shows the selection-edge disclosure next to the object
  count;
- the detection panel shows average precision and the F1-optimal threshold
  beside the single-threshold figures, and the box-versus-footprint
  interpretation when candidates are detector boxes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jens
2026-08-22 14:33:54 +02:00
co-authored by Claude Opus 5
parent 23d6e0372b
commit b146b2143d
8 changed files with 208 additions and 7 deletions
@@ -949,6 +949,26 @@ export function DetectionLab({
<p>
{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.
</p>
{detectionQaResult.box_to_footprint_diagnostics.interpretation ? (
<p>{detectionQaResult.box_to_footprint_diagnostics.interpretation}</p>
) : null}
</div>
) : null}
{detectionQaResult.precision_recall_curve ? (
<div className="detection-qa-diagnostic">
<span>Drempelonafhankelijke kwaliteit</span>
<strong>
AP {formatNullableNumber(detectionQaResult.precision_recall_curve.average_precision, 3)} · beste F1{' '}
{formatNullableNumber(detectionQaResult.precision_recall_curve.best_f1, 3)}
</strong>
<p>
{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.`}
</p>
</div>
) : null}
</div>
@@ -2957,6 +2957,9 @@ export function MapWorkspace({
{analysisMode === 'current' && activeSelectionResult?.truncated ? (
<p className="geo-data-notice">De telling is volledig; op de kaart en in de tabel worden maximaal {activeSelectionResult.limit.toLocaleString('nl-BE')} objecten getoond.</p>
) : null}
{analysisMode === 'current' && activeSelectionResult?.summary?.selection_edge_warning ? (
<p className="geo-data-notice">{activeSelectionResult.summary.selection_edge_warning}</p>
) : null}
{analysisMode === 'current' && activeSelectionResult?.summary?.warning ? (
<p className="geo-data-notice">{activeSelectionResult.summary.warning}</p>
) : null}
+4 -1
View File
@@ -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,
+9 -3
View File
@@ -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),
})),
},
}
}
@@ -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> = {}): 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> = {},
): 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.')
})
})
+4 -1
View File
@@ -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,
+4 -1
View File
@@ -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,
+53 -1
View File
@@ -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