diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eb3d9d8..bb8db91e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ # Changelog +## Sprint 200 Operational time-series handoff (2026-07-15) + +- Removed the dead-end Evolution state that appeared when the current building + theme had only one regional snapshot while real historical series existed. +- Evolution now opens the first available persisted series automatically and + loads its latest snapshot on the map. +- Theme cards distinguish genuine multi-snapshot series from current-only + sources, including observation counts and year ranges. +- Confirmed the deployed PostGIS temporal path with a real Statbel 2021-2025 + comparison; no synthetic values or parallel frontend calculations were added. + ## Sprint 199 Reviewed accuracy expansion (2026-07-15) - Added six leakage-free training AOIs in Arendonk, Dessel, Meerhout, Laakdal, diff --git a/backend/tests/test_sprint200_temporal_explorer_handoff.py b/backend/tests/test_sprint200_temporal_explorer_handoff.py new file mode 100644 index 00000000..824a982b --- /dev/null +++ b/backend/tests/test_sprint200_temporal_explorer_handoff.py @@ -0,0 +1,40 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def read(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def test_evolution_mode_falls_back_to_an_available_series() -> None: + workspace = read("frontend/src/components/map/MapWorkspace.tsx") + + assert "themeTemporalSeriesMap" in workspace + assert "availableEvolutionThemes[0]" in workspace + assert "activeTemporalSeriesGroups.length > 0" in workspace + assert "setActiveThemeId(fallbackTheme.id)" in workspace + assert "onOpenDatasetInMap(fallbackDataset)" in workspace + + +def test_evolution_theme_catalog_distinguishes_history_from_current_only_data() -> None: + workspace = read("frontend/src/components/map/MapWorkspace.tsx") + + assert "analysisMode === 'current' || evolutionAvailable" in workspace + assert "meetmomenten" in workspace + assert "Tijdreeks" in workspace + assert "Alleen huidige toestand" in workspace + assert "Alleen huidig" in workspace + assert "Geen tijdreeks beschikbaar" not in workspace + + +def test_regional_time_series_remain_real_persisted_sources() -> None: + regional_operator = read("scripts/provision_regional_timeseries.py") + temporal_service = read("backend/app/services/temporal_analysis_service.py") + + assert "provision_mol_population_history.py" in regional_operator + assert "provision_official_landuse_timeseries.py" in regional_operator + assert "TemporalAnalysisService._get_temporal_dataset" in temporal_service + assert "VectorFeatureService.summarize_features_by_bbox" in temporal_service + assert "object_changes=object_changes" in temporal_service diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 718bad61..89c3c9ae 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -8293,3 +8293,43 @@ Next: - Convert the two confirmed Postel-bos challenger errors into a complete, leakage-free hard-negative training sample, add independent empty controls, then train a new inactive candidate through the same fail-closed gate. + +## Sprint 200 - Operational time-series handoff (2026-07-15) + +Implemented: +- Reproduced the `Geen tijdreeks beschikbaar` dead end in the deployed regional + map: Evolution retained the current-only GRB building snapshot even though + persisted population and forest series were available. +- Built one temporal-series catalog for every end-user theme from ready + persisted datasets. Entering Evolution now retains a comparable theme or + automatically opens the first available series and its latest snapshot. +- Theme cards now expose observation count and year range for real series. + Single-snapshot sources remain visible as `Alleen huidige toestand` but are + disabled for comparison instead of opening empty controls. +- Kept every comparison behind the existing temporal API and PostGIS + `vector_features` aggregation. No browser-side metrics, inferred snapshots + or synthetic historical values were introduced. +- Updated the top-level Detection documentation values while touching the + frontend handoff documentation so the active profile no longer shows an + older superseded benchmark. + +Validation evidence: +- A live regional Statbel comparison for a Mol bbox returned 87,868.45 + area-weighted inhabitants in 2021 and 91,497.15 in 2025: +3,628.70 or + +4.13%. The API correctly marked the result as an estimate and suppressed + unsupported object lineage. +- Focused temporal/regional/frontend contract tests passed: 21 tests. +- Full readiness passed 601 backend tests, backend compilation, 88 documented + API routes, one Alembic head, frontend typecheck/build and shell gates. + +Known limitation: +- The complete regional project currently has real 2021-2025 population and + 2013-2025 modern forest series. Buildings, water, roads and parcels each + have one current GRB snapshot at regional scope. The official 1778/1873/1969 + historical building, forest, water and road series is persisted for Mol but + has not yet been partitioned and imported for all 28 municipalities. + +Next: +- Generalize the audited historical-land-use operator to the approved regional + scope, partition source retrieval by municipality and provision the three + official editions without merging their methodology into current GRB. diff --git a/docs/TODO.md b/docs/TODO.md index 2b280ef5..fad1733b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -15,6 +15,8 @@ - [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. - [x] Extend official population and land-use time series from Mol to the approved 28-municipality regional scope. +- [x] Make Evolution automatically open an available regional series and distinguish historical themes from current-only snapshots. +- [ ] Extend the official 1778/1873/1969 historical buildings, water and roads series from Mol to the approved regional scope with partitioned source audits. - [x] Connect a drawn rectangle to bounded official orthophoto acquisition, local configured-YOLO detection and persisted GRB QA. 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/README.md b/frontend/README.md index 275a6077..438675ac 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -6,7 +6,7 @@ The persisted `Kempen Regional Workbench` is the automatic operational data cont The user-facing shell is task based: `Kaart`, `Bronnen`, `Kwaliteit`, `Beeldanalyse`, `Downloads`, `Status` and `Beheer`. Internal benchmark projects, raw dataset metadata, provider capabilities, model registry details and QA evidence remain accessible through labelled advanced disclosures instead of competing with the normal workflow. -Detection defaults to the configured local YOLO asset and automatically selects an available raster and active model asset where possible. The model registry and preflight remain honest when PyTorch, Ultralytics, a local model file or a tile manifest is unavailable. The active building profile is operational but remains review-required: its recorded coverage-aware benchmark is approximately precision 0.590, recall 0.577 and F1 0.582 over seven positive AOIs, with zero detections in the empty-background control. Another training pass is intentionally blocked until the generated false-positive and false-negative review decisions are completed. +Detection defaults to the configured local YOLO asset and automatically selects an available raster and active model asset where possible. The model registry and preflight remain honest when PyTorch, Ultralytics, a local model file or a tile manifest is unavailable. The active building profile is operational but remains review-required: its current coverage-aligned benchmark is approximately precision 0.614, recall 0.606 and F1 0.607 over seven positive AOIs, with zero detections in all three pure-empty controls. A reviewed challenger remains inactive because it produced two detections in empty Postel forest. Map-driven building analysis uses the documented footprint-IoU `0.25` and distinguishes model candidates from verified buildings. It shows persisted @@ -20,6 +20,10 @@ current, while `Evolution` lets the operator compare an earlier and later snapshot from the same series over a drawn rectangle. Results show units, absolute/percentage change, estimate status and source limitations. Added/removed/modified overlays only appear for stable source identities. +When Evolution is opened from a current-only theme, the explorer automatically +switches to the first available persisted series. Theme cards then show either +the number and range of historical observations or `Alleen huidige toestand`, +so a single GRB snapshot can no longer strand the workflow in an empty state. When a theme has multiple valid methodologies, a compact series selector keeps them explicit. Forest therefore defaults to the official modern 2013-2025 10 m series, while the separate 1778-1969 historical map series remains diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index 684cb337..36b52cfd 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -613,10 +613,15 @@ export function MapWorkspace({ const activeScopeProject = projects.find((project) => project.id === selectedProjectId) ?? null const activeScopeLabel = activeScopeProject ? operationalScopeProjectLabel(activeScopeProject) : 'Werkgebied' const municipalityAreaCount = areas.filter((area) => /^Gemeente\s/i.test(area.name)).length - const activeTemporalSeriesGroups = useMemo( - () => listThemeTemporalSeries(availableMapDatasets, activeTheme), - [activeTheme, availableMapDatasets], + const themeTemporalSeriesMap = useMemo( + () => + Object.fromEntries( + DATA_THEMES.map((theme) => [theme.id, listThemeTemporalSeries(availableMapDatasets, theme)]), + ) as Record, + [availableMapDatasets], ) + const activeTemporalSeriesGroups = themeTemporalSeriesMap[activeTheme.id] + const availableEvolutionThemes = DATA_THEMES.filter((theme) => themeTemporalSeriesMap[theme.id].length > 0) const activeTemporalSeriesGroup = activeTemporalSeriesGroups.find((group) => group.key === selectedTemporalSeriesKey) ?? activeTemporalSeriesGroups[0] const activeTemporalSeries = activeTemporalSeriesGroup?.items ?? EMPTY_TEMPORAL_SERIES @@ -808,7 +813,10 @@ export function MapWorkspace({ } const selectDataTheme = (theme: DataTheme) => { - const dataset = themeDatasetMap[theme.id] + const temporalGroup = themeTemporalSeriesMap[theme.id][0] + const dataset = analysisMode === 'evolution' + ? temporalGroup?.items[temporalGroup.items.length - 1] ?? null + : themeDatasetMap[theme.id] if (!dataset) { return } @@ -820,6 +828,16 @@ export function MapWorkspace({ const setExplorerMode = (mode: 'current' | 'evolution') => { setAnalysisMode(mode) clearTemporalComparison() + if (mode !== 'evolution' || activeTemporalSeriesGroups.length > 0) { + return + } + const fallbackTheme = availableEvolutionThemes[0] + const fallbackGroup = fallbackTheme ? themeTemporalSeriesMap[fallbackTheme.id][0] : null + const fallbackDataset = fallbackGroup?.items[fallbackGroup.items.length - 1] + if (fallbackTheme && fallbackDataset) { + setActiveThemeId(fallbackTheme.id) + onOpenDatasetInMap(fallbackDataset) + } } const loadAllThemeResults = async (bbox: VectorSelectionBBox, areaId?: string) => { @@ -985,11 +1003,19 @@ export function MapWorkspace({
{DATA_THEMES.map((theme) => { const dataset = themeDatasetMap[theme.id] + const temporalGroups = themeTemporalSeriesMap[theme.id] + const temporalGroup = temporalGroups[0] + const evolutionAvailable = temporalGroups.length > 0 + const available = Boolean(dataset) && (analysisMode === 'current' || evolutionAvailable) const active = activeThemeId === theme.id + const firstObservation = temporalGroup?.items[0]?.observed_at + const lastObservation = temporalGroup?.items[temporalGroup.items.length - 1]?.observed_at + const firstYear = firstObservation ? new Date(firstObservation).getUTCFullYear() : null + const lastYear = lastObservation ? new Date(lastObservation).getUTCFullYear() : null return ( ) })} @@ -1012,7 +1052,7 @@ export function MapWorkspace({ {analysisOverlayActive ? mapLayerLabel : analysisMode === 'evolution' - ? activeTemporalSeriesGroup?.label ?? 'Geen tijdreeks beschikbaar' + ? activeTemporalSeriesGroup?.label ?? 'Nog geen historische reeks ingeladen' : activeThemeDataset ? getDatasetDisplayName(activeThemeDataset) : 'Geen databron beschikbaar'} @@ -1021,7 +1061,7 @@ export function MapWorkspace({ : analysisMode === 'evolution' ? activeTemporalSeries.length >= 2 ? `${activeTemporalSeries.length} officiële meetmomenten · ${formatObservationDate(activeTemporalSeries[0].observed_at)} tot ${formatObservationDate(activeTemporalSeries[activeTemporalSeries.length - 1].observed_at)}` - : 'Minstens twee expliciet gedateerde snapshots zijn vereist.' + : 'Voor dit thema is nog geen tweede officieel meetmoment beschikbaar.' : activeThemeDataset ? `${getDatasetSourceDisplayName(activeThemeDataset)} · ${formatObservationDate(activeThemeDataset.observed_at)}` : activeTheme.description}