diff --git a/CHANGELOG.md b/CHANGELOG.md index a2b83bd7..07ed5615 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,9 @@ - Preserved separate Mol/regional series keys and honest partial-sector population and 10 m forest-area limitations. - Replaced internal provider identifiers with readable source labels in the primary map. - Added a provenance-gated full-Area query path for official pre-clipped datasets, avoiding redundant intersection of every feature against the same detailed municipal/regional boundary while preserving exact rectangle selection behavior. -- Added focused scope filtering, command construction, boundary resolution, multi-NIS provenance, packaging and frontend-label tests. +- Reduced a live complete-Kempen population/forest analysis from roughly 92 seconds to 2.8 seconds of API work while retaining the exact official totals. +- Grouped dated population and forest snapshots into one current source plus an optional historical disclosure, translated remaining user-facing status/download/model-evaluation text and made municipality selection explicitly optional. +- Added focused scope filtering, command construction, boundary resolution, multi-NIS provenance, full-Area correctness, temporal-language and frontend-label tests; the full readiness gate now passes with 571 tests. ## Sprint 193 End-user regional workbench simplification (2026-07-14) diff --git a/backend/app/services/temporal_analysis_service.py b/backend/app/services/temporal_analysis_service.py index e5a8e5c5..ea5819c7 100644 --- a/backend/app/services/temporal_analysis_service.py +++ b/backend/app/services/temporal_analysis_service.py @@ -200,7 +200,7 @@ class TemporalAnalysisService: return ( TemporalObjectChanges(available=False), {"type": "FeatureCollection", "features": []}, - ["Object-level changes are unavailable because the source does not guarantee stable feature identifiers."], + ["Wijzigingen van individuele objecten kunnen voor deze bron niet betrouwbaar worden gevolgd."], ) normalized_bbox = VectorFeatureService._normalize_selection_bbox(bbox) diff --git a/backend/tests/test_sprint133_detection_threshold_calibration_ux.py b/backend/tests/test_sprint133_detection_threshold_calibration_ux.py index 9a1057ff..f0a3e8c4 100644 --- a/backend/tests/test_sprint133_detection_threshold_calibration_ux.py +++ b/backend/tests/test_sprint133_detection_threshold_calibration_ux.py @@ -15,19 +15,19 @@ def test_detection_lab_exposes_persisted_threshold_calibration_comparison() -> N assert "qualityChecks: QualityCheckRead[]" in source assert "buildCalibrationRows(detectionRuns, qualityChecks)" in source - assert "Calibration comparison" in source - assert "Compare persisted detection runs by confidence threshold" in source - assert "Best F1 candidate" in source - assert "Best precision candidate" in source - assert "Lowest false-positive pressure" in source - assert "Threshold" in source - assert "Precision" in source + assert "Kalibraties vergelijken" in source + assert "Vergelijk bewaarde analyseruns per zekerheidsdrempel" in source + assert "Beste F1-score" in source + assert "Beste precisie" in source + assert "Minste foutieve meldingen" in source + assert "Drempel" in source + assert "Precisie" in source assert "Recall" in source assert "F1" in source - assert "False positives" in source - assert "False negatives" in source - assert "Promote only after checking evidence across AOIs" in source - assert "No calibration comparison available yet" in source + assert "Fout positief" in source + assert "Fout negatief" in source + assert "Keur pas goed nadat meerdere gebieden" in source + assert "Nog geen kalibratievergelijking beschikbaar" in source assert "metricValue(check, 'f1')" in source assert "metricValue(check, 'precision')" in source assert "metricValue(check, 'recall')" in source diff --git a/backend/tests/test_sprint175_detection_review_hardening.py b/backend/tests/test_sprint175_detection_review_hardening.py index 6680e11c..ebc89be8 100644 --- a/backend/tests/test_sprint175_detection_review_hardening.py +++ b/backend/tests/test_sprint175_detection_review_hardening.py @@ -100,8 +100,8 @@ def test_detection_results_table_uses_bounded_local_pagination() -> None: assert "visibleDetectionItems" in lab assert "detectionItems.slice" in lab assert "Detection result pagination" in lab - assert "Previous detection results page" in lab - assert "Next detection results page" in lab + assert "Vorige resultatenpagina" in lab + assert "Volgende resultatenpagina" in lab assert "formatSourceTilePath(detection.source_tile_path)" in lab assert 'title={detection.source_tile_path ?? undefined}' in lab assert "pagination-toolbar" in styles diff --git a/backend/tests/test_sprint187_temporal_map_foundation.py b/backend/tests/test_sprint187_temporal_map_foundation.py index e6d7dda5..2adbab6b 100644 --- a/backend/tests/test_sprint187_temporal_map_foundation.py +++ b/backend/tests/test_sprint187_temporal_map_foundation.py @@ -274,6 +274,26 @@ def test_temporal_compare_returns_delta_and_canonical_change_payload(monkeypatch assert result.geojson["type"] == "FeatureCollection" +def test_unstable_temporal_identity_returns_clear_end_user_warning() -> None: + project_id = uuid4() + earlier = temporal_dataset(project_id=project_id, observed_year=2021) + later = temporal_dataset(project_id=project_id, observed_year=2025) + earlier.source_metadata["identity_stable"] = False + later.source_metadata["identity_stable"] = False + + changes, geojson, warnings = TemporalAnalysisService._compare_identity_features( + SimpleNamespace(), + earlier=earlier, + later=later, + bbox={"min_x": 5.0, "min_y": 51.1, "max_x": 5.2, "max_y": 51.3}, + preview_limit=100, + ) + + assert changes.available is False + assert geojson == {"type": "FeatureCollection", "features": []} + assert warnings == ["Wijzigingen van individuele objecten kunnen voor deze bron niet betrouwbaar worden gevolgd."] + + def test_temporal_frontend_and_official_operator_contracts_exist() -> None: workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8") temporal_api = (ROOT / "frontend/src/services/api/temporal.ts").read_text(encoding="utf-8") diff --git a/backend/tests/test_sprint189_kempen_scope.py b/backend/tests/test_sprint189_kempen_scope.py index 57cf1e65..dbfd8c2b 100644 --- a/backend/tests/test_sprint189_kempen_scope.py +++ b/backend/tests/test_sprint189_kempen_scope.py @@ -131,7 +131,7 @@ def test_kempen_scope_operator_is_packaged_and_exposed_in_map_flow() -> None: assert "Kempen (28 gemeenten)" in workspace assert 'aria-label="Regio"' not in workspace assert 'aria-label="Ingeladen regiobereik"' in workspace - assert "Gemeente of volledige regio" in workspace + assert "Snel naar een gemeente (optioneel)" in workspace assert "projects={projects}" in app map_props = app.split("", maxsplit=1)[0] assert "onSelectProject={selectProject}" not in map_props diff --git a/backend/tests/test_sprint193_end_user_workbench.py b/backend/tests/test_sprint193_end_user_workbench.py index 23c6a362..79d4e0b6 100644 --- a/backend/tests/test_sprint193_end_user_workbench.py +++ b/backend/tests/test_sprint193_end_user_workbench.py @@ -17,7 +17,7 @@ def test_regional_workspace_is_automatic_and_map_has_one_scope_selector() -> Non assert regional_check < municipality_check assert 'aria-label="Regio"' not in map_workspace assert 'aria-label="Ingeladen regiobereik"' in map_workspace - assert "Gemeente of volledige regio" in map_workspace + assert "Snel naar een gemeente (optioneel)" in map_workspace assert 'aria-label="Werkgebied"' in map_workspace diff --git a/backend/tests/test_sprint194_regional_timeseries.py b/backend/tests/test_sprint194_regional_timeseries.py index 617f85a9..e97fab02 100644 --- a/backend/tests/test_sprint194_regional_timeseries.py +++ b/backend/tests/test_sprint194_regional_timeseries.py @@ -184,11 +184,22 @@ def test_regional_timeseries_operator_is_packaged_and_release_checked() -> None: def test_end_user_dataset_sources_are_human_readable() -> None: display = (ROOT / "frontend/src/lib/datasetDisplay.ts").read_text(encoding="utf-8") workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8") + catalog = (ROOT / "frontend/src/components/datasets/DatasetPanel.tsx").read_text(encoding="utf-8") + status = (ROOT / "frontend/src/components/WorkbenchStatusStrip.tsx").read_text(encoding="utf-8") + detection = (ROOT / "frontend/src/components/detection/DetectionLab.tsx").read_text(encoding="utf-8") + exports = (ROOT / "frontend/src/components/exports/ExportCenter.tsx").read_text(encoding="utf-8") assert "department_omgeving_land_use: 'Departement Omgeving'" in display assert "statbel: 'Statbel'" in display assert "getDatasetSourceDisplayName(activeThemeDataset)" in workspace assert "dataset ? getDatasetSourceDisplayName(dataset)" in workspace + assert "Snel naar een gemeente (optioneel)" in workspace + assert "latestDatasetBySeries" in catalog + assert "Historische meetmomenten" in catalog + assert "getDatasetSourceDisplayName(dataset)" in catalog + assert "statusLabel(item.state)" in status + assert "Technische modelevaluatie" in detection + assert "Nog geen downloads gemaakt." in exports def test_full_area_fast_path_requires_matching_area_and_clipped_operator_provenance() -> None: diff --git a/backend/tests/test_sprint22_workbench_status_strip.py b/backend/tests/test_sprint22_workbench_status_strip.py index ba10dae1..2617abbb 100644 --- a/backend/tests/test_sprint22_workbench_status_strip.py +++ b/backend/tests/test_sprint22_workbench_status_strip.py @@ -17,7 +17,7 @@ def test_frontend_wires_v1_workbench_status_strip() -> None: assert "activeLayerFeatureCount={mapFeatureCount}" in app assert "selectedAreaHasGeometry={Boolean(areaFeatureCollection)}" in app assert "Platformstatus" in component - assert "Workbench status:" in component + assert "Status werkruimte:" in component assert "Kaartwerkruimte is gebruiksklaar" in component assert "workbench-status-strip" in css assert "status-tile-ready" in css diff --git a/backend/tests/test_sprint88_ai_lab_density.py b/backend/tests/test_sprint88_ai_lab_density.py index 61322724..04439f38 100644 --- a/backend/tests/test_sprint88_ai_lab_density.py +++ b/backend/tests/test_sprint88_ai_lab_density.py @@ -20,7 +20,7 @@ def test_detection_lab_exposes_structured_surfaces() -> None: assert 'className="ai-lab-results-surface"' in lab assert 'aria-label="Detection results"' in lab assert 'className="ai-lab-qa-surface"' in lab - assert 'aria-label="Detection QA controls and results"' in lab + assert 'aria-label="Kwaliteitscontrole gebouwdetectie"' in lab def test_segmentation_lab_exposes_structured_surfaces() -> None: @@ -53,7 +53,7 @@ def test_ai_lab_preserves_existing_detection_and_segmentation_controls() -> None assert "onRunQa" in detection assert "detectionTileManifestPath" in detection assert "{detectionItems.length} objecten geladen" in detection - assert "Compare detections to reference" in detection + assert "Vergelijk met referentielaag" in detection assert "onRunSegmentation" in segmentation assert "onLoadResults" in segmentation diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 6575a374..8dfe522a 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -1,3 +1,29 @@ +## Sprint 194 Regional official time series and full-Area performance (2026-07-14) + +Changed: +- Generalized the explicit Statbel operator to the approved 28-municipality scope and added one coordinator for official 2021-2025 population plus 2013-2025 forest snapshots. +- Added municipality-partitioned official 10 m WCS retrieval after the upstream response-size limit rejected a single full-region request; source partitions are resumable and mosaicked locally before exact region clipping. +- Added a provenance-gated full-Area selection path for trusted pre-clipped operator datasets. Free rectangles, unrelated Areas and ordinary uploads continue to execute the normal PostGIS intersection path. +- Grouped older dated snapshots behind `Historische meetmomenten`, replaced raw provider/type labels in the primary catalog and translated the remaining visible temporal, download, platform-status and detection-evaluation language. + +Live regional evidence: +- Population datasets: five exact dated snapshots, 3,317 persisted `vector_features`; 2021 `489,927` inhabitants and 2025 `506,473`, a measured increase of `16,546` (`3.377%`). +- Forest datasets: five exact dated snapshots, 273,669 persisted `vector_features`; 2013 `27,659.976 ha` and 2025 `27,410.687 ha`, a source-resolution change of `-249.289 ha` (`-0.9013%`). +- All ten snapshots have one immutable dataset version and zero invalid, empty, non-4326 or source-ID-missing geometries. A repeated synchronization reused the same ten Dataset IDs without duplicates. +- Complete-Kempen current analysis returns 506,473 inhabitants, 27,410.69 ha forest, 466,078 buildings, 88,332 water features, 84,504 roads and 415,288 parcels over 1,399.25 km2. + +Performance and runtime evidence: +- Population full-Area API selection completed in `1.738 s`; forest completed in `1.083 s`. The browser rendered the full six-theme analysis and 2021-2025 comparison within the 3.5-second verification window, replacing the previous roughly 92-second forest-heavy workflow. +- Tower health, PostGIS 3.6, required schema objects, Alembic head `202607140001`, frontend, API proxy and icon checks passed after deployment. +- PyTorch `2.13.0+cpu`, Ultralytics `8.4.95` and the active local model file load successfully. The catalog exposes 24 local assets; the active seven-AOI profile remains review-required at F1 `0.5825` and was not blindly retrained. + +Tested: +- `bash scripts/run_readiness_check.sh` passed with `571 passed`, backend compile, one Alembic head, frontend typecheck/build and live-smoke syntax validation. +- Focused regional, temporal and end-user contract checks passed. The browser confirmed the complete regional default, six current themes, five population moments, exact temporal totals and no required project/region selection step. + +Next: +- Complete the 48 false-positive and 48 false-negative operator review decisions, then use those decisions to build a justified next training corpus and expose the existing raster-to-tiles-to-detection workflow as one guided end-user action. + ## Sprint 193 End-user regional workbench simplification (2026-07-14) Changed: diff --git a/docs/TODO.md b/docs/TODO.md index 9392f0f0..dfd0fb4c 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -14,7 +14,7 @@ - [x] Make the complete regional workspace the automatic data context and use municipality/full-region Areas as one spatial filter instead of requiring a region dropdown. - [x] Reduce end-user noise by moving technical projects, source metadata, QA evidence, provider internals and model diagnostics behind explicit advanced disclosures. - [x] Default Detection Lab to the configured local YOLO asset and present measured model quality and control requirements honestly. -- [ ] Extend official population and land-use time series from Mol to the approved 28-municipality regional scope. +- [x] Extend official population and land-use time series from Mol to the approved 28-municipality regional scope. This file now starts with the current implementation status. Older preparation/backlog sections are preserved below as historical planning context and should not be treated as the live sprint board without checking `docs/CODEX_EXECUTION_LOG.md`. diff --git a/frontend/src/components/WorkbenchStatusStrip.tsx b/frontend/src/components/WorkbenchStatusStrip.tsx index 3e0867bb..2bbea338 100644 --- a/frontend/src/components/WorkbenchStatusStrip.tsx +++ b/frontend/src/components/WorkbenchStatusStrip.tsx @@ -1,4 +1,5 @@ import type { AreaRead, DatasetCreateResponse, ExportRead, ProjectRead, QualityCheckRead } from '../types' +import { REGIONAL_WORKSPACE_LABEL, REGIONAL_WORKSPACE_PROJECT_NAME } from '../config/primaryFocus' interface StatusItem { key: string @@ -26,6 +27,17 @@ function countReferenceDatasets(datasets: DatasetCreateResponse[]): number { return datasets.filter((dataset) => dataset.dataset_role === 'reference').length } +function statusLabel(state: StatusItem['state']): string { + if (state === 'ready') return 'gereed' + if (state === 'warning') return 'aandacht' + return 'nog open' +} + +function projectLabel(project: ProjectRead | null): string { + if (!project) return 'Geen werkruimte' + return project.name === REGIONAL_WORKSPACE_PROJECT_NAME ? REGIONAL_WORKSPACE_LABEL : project.name +} + function nextAction(items: StatusItem[]): string { if (!items.some((item) => item.key === 'project' && item.state === 'ready')) { return 'De regionale werkruimte kon niet worden geopend.' @@ -65,7 +77,7 @@ export function WorkbenchStatusStrip({ { key: 'project', label: 'Werkruimte', - value: selectedProject ? selectedProject.name : 'Geen werkruimte', + value: projectLabel(selectedProject), detail: selectedProject ? selectedProject.region : 'Niet geladen', state: selectedProject ? 'ready' : 'waiting', }, @@ -114,12 +126,12 @@ export function WorkbenchStatusStrip({

Platformstatus

- Workbench status: + Status werkruimte: {coreReady ? 'Kaartwerkruimte is gebruiksklaar' : 'Kaartwerkruimte vraagt aandacht'}

{nextAction(items)}

-
+
{readyCount}/{items.length} onderdelen gereed
@@ -129,7 +141,7 @@ export function WorkbenchStatusStrip({
{item.label} - {item.state} + {statusLabel(item.state)}
{item.value}

{item.detail}

diff --git a/frontend/src/components/datasets/DatasetPanel.tsx b/frontend/src/components/datasets/DatasetPanel.tsx index fdd538ce..2fd62ef0 100644 --- a/frontend/src/components/datasets/DatasetPanel.tsx +++ b/frontend/src/components/datasets/DatasetPanel.tsx @@ -1,6 +1,6 @@ import type { Dispatch, FormEvent, SetStateAction } from 'react' import type { AreaRead, DatasetCreateResponse } from '../../types' -import { getDatasetDisplayName } from '../../lib/datasetDisplay' +import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay' interface DatasetFormState { datasetType: string @@ -63,6 +63,9 @@ function normalizeDatasetRole(dataset: DatasetCreateResponse): 'reference' | 'ca if (dataset.dataset_role === 'reference') { return 'reference' } + if (dataset.dataset_role === 'source') { + return 'source' + } if (dataset.dataset_role === 'derived' || isVectorDatasetType(dataset.dataset_type)) { return 'candidate' } @@ -76,7 +79,7 @@ function datasetRoleLabel(role: string): string { if (role === 'candidate') { return 'Analyse-resultaat' } - return 'Eigen bron' + return 'Basisbron' } function datasetActionHint(dataset: DatasetCreateResponse, role: string): string { @@ -92,6 +95,19 @@ function datasetActionHint(dataset: DatasetCreateResponse, role: string): string return 'Ingeladen gegevensbron voor verdere analyse.' } +function formatObservationDate(value: string | null | undefined): string { + if (!value) return 'huidige toestand' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return new Intl.DateTimeFormat('nl-BE', { day: 'numeric', month: 'short', year: 'numeric' }).format(date) +} + +function datasetKindLabel(dataset: DatasetCreateResponse): string { + if (dataset.dataset_type === 'raster') return 'Luchtbeeld' + if (isVectorDatasetType(dataset.dataset_type)) return 'Kaartlaag' + return 'Gegevensbron' +} + export function DatasetPanel({ selectedProjectId, selectedDatasetId, @@ -107,8 +123,24 @@ export function DatasetPanel({ onOpenDatasetInMap, onOpenDatasetExport, }: DatasetPanelProps) { - const readyDatasets = datasets.filter((dataset) => dataset.status === 'ready').length const selectedDataset = datasets.find((dataset) => dataset.id === selectedDatasetId) + const latestDatasetBySeries = new Map() + datasets.forEach((dataset) => { + if (!dataset.temporal_series_key) return + const current = latestDatasetBySeries.get(dataset.temporal_series_key) + const currentTimestamp = current?.observed_at ? Date.parse(current.observed_at) : Number.NEGATIVE_INFINITY + const candidateTimestamp = dataset.observed_at ? Date.parse(dataset.observed_at) : Number.NEGATIVE_INFINITY + if (!current || candidateTimestamp > currentTimestamp) { + latestDatasetBySeries.set(dataset.temporal_series_key, dataset) + } + }) + const primaryDatasets = datasets.filter( + (dataset) => !dataset.temporal_series_key || latestDatasetBySeries.get(dataset.temporal_series_key)?.id === dataset.id, + ) + const historicalDatasets = datasets.filter( + (dataset) => dataset.temporal_series_key && latestDatasetBySeries.get(dataset.temporal_series_key)?.id !== dataset.id, + ) + const readyDatasets = primaryDatasets.filter((dataset) => dataset.status === 'ready').length const roleSummaries = [ { key: 'selected', @@ -120,21 +152,21 @@ export function DatasetPanel({ { key: 'reference', label: 'Referentie', - count: datasets.filter((dataset) => normalizeDatasetRole(dataset) === 'reference').length, + count: primaryDatasets.filter((dataset) => normalizeDatasetRole(dataset) === 'reference').length, hint: 'Officieel', className: 'dataset-role-reference', }, { key: 'candidate', label: 'Resultaat', - count: datasets.filter((dataset) => normalizeDatasetRole(dataset) === 'candidate').length, + count: primaryDatasets.filter((dataset) => normalizeDatasetRole(dataset) === 'candidate').length, hint: 'Afgeleid', className: 'dataset-role-candidate', }, { key: 'source', - label: 'Eigen bron', - count: datasets.filter((dataset) => normalizeDatasetRole(dataset) === 'source').length, + label: 'Basisbron', + count: primaryDatasets.filter((dataset) => normalizeDatasetRole(dataset) === 'source').length, hint: 'Ingeladen', className: 'dataset-role-source', }, @@ -147,7 +179,7 @@ export function DatasetPanel({

Ingeladen gegevens

Bronnen

- {readyDatasets}/{datasets.length} beschikbaar + {readyDatasets}/{primaryDatasets.length} bronnen beschikbaar
@@ -165,17 +197,17 @@ export function DatasetPanel({ value={datasetForm.datasetType} onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, datasetType: event.target.value }))} > - - - + + +