fix: make regional time series discoverable
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-15 05:55:12 +02:00
parent ecc9f77732
commit 3ff2d07f3c
6 changed files with 147 additions and 10 deletions
+11
View File
@@ -7,6 +7,17 @@
# Changelog # 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) ## Sprint 199 Reviewed accuracy expansion (2026-07-15)
- Added six leakage-free training AOIs in Arendonk, Dessel, Meerhout, Laakdal, - Added six leakage-free training AOIs in Arendonk, Dessel, Meerhout, Laakdal,
@@ -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
+40
View File
@@ -8293,3 +8293,43 @@ Next:
- Convert the two confirmed Postel-bos challenger errors into a complete, - Convert the two confirmed Postel-bos challenger errors into a complete,
leakage-free hard-negative training sample, add independent empty controls, leakage-free hard-negative training sample, add independent empty controls,
then train a new inactive candidate through the same fail-closed gate. 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.
+2
View File
@@ -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] 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] 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] 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. - [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`. 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`.
+5 -1
View File
@@ -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. 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 Map-driven building analysis uses the documented footprint-IoU `0.25` and
distinguishes model candidates from verified buildings. It shows persisted 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, snapshot from the same series over a drawn rectangle. Results show units,
absolute/percentage change, estimate status and source limitations. absolute/percentage change, estimate status and source limitations.
Added/removed/modified overlays only appear for stable source identities. 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 When a theme has multiple valid methodologies, a compact series selector keeps
them explicit. Forest therefore defaults to the official modern 2013-2025 them explicit. Forest therefore defaults to the official modern 2013-2025
10 m series, while the separate 1778-1969 historical map series remains 10 m series, while the separate 1778-1969 historical map series remains
+49 -9
View File
@@ -613,10 +613,15 @@ export function MapWorkspace({
const activeScopeProject = projects.find((project) => project.id === selectedProjectId) ?? null const activeScopeProject = projects.find((project) => project.id === selectedProjectId) ?? null
const activeScopeLabel = activeScopeProject ? operationalScopeProjectLabel(activeScopeProject) : 'Werkgebied' const activeScopeLabel = activeScopeProject ? operationalScopeProjectLabel(activeScopeProject) : 'Werkgebied'
const municipalityAreaCount = areas.filter((area) => /^Gemeente\s/i.test(area.name)).length const municipalityAreaCount = areas.filter((area) => /^Gemeente\s/i.test(area.name)).length
const activeTemporalSeriesGroups = useMemo( const themeTemporalSeriesMap = useMemo(
() => listThemeTemporalSeries(availableMapDatasets, activeTheme), () =>
[activeTheme, availableMapDatasets], Object.fromEntries(
DATA_THEMES.map((theme) => [theme.id, listThemeTemporalSeries(availableMapDatasets, theme)]),
) as Record<DataThemeId, TemporalSeriesGroup[]>,
[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) const activeTemporalSeriesGroup = activeTemporalSeriesGroups.find((group) => group.key === selectedTemporalSeriesKey)
?? activeTemporalSeriesGroups[0] ?? activeTemporalSeriesGroups[0]
const activeTemporalSeries = activeTemporalSeriesGroup?.items ?? EMPTY_TEMPORAL_SERIES const activeTemporalSeries = activeTemporalSeriesGroup?.items ?? EMPTY_TEMPORAL_SERIES
@@ -808,7 +813,10 @@ export function MapWorkspace({
} }
const selectDataTheme = (theme: DataTheme) => { 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) { if (!dataset) {
return return
} }
@@ -820,6 +828,16 @@ export function MapWorkspace({
const setExplorerMode = (mode: 'current' | 'evolution') => { const setExplorerMode = (mode: 'current' | 'evolution') => {
setAnalysisMode(mode) setAnalysisMode(mode)
clearTemporalComparison() 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) => { const loadAllThemeResults = async (bbox: VectorSelectionBBox, areaId?: string) => {
@@ -985,11 +1003,19 @@ export function MapWorkspace({
<div className="geo-theme-list"> <div className="geo-theme-list">
{DATA_THEMES.map((theme) => { {DATA_THEMES.map((theme) => {
const dataset = themeDatasetMap[theme.id] 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 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 ( return (
<button <button
className={active ? 'geo-theme-option geo-theme-option-active' : 'geo-theme-option'} className={active ? 'geo-theme-option geo-theme-option-active' : 'geo-theme-option'}
disabled={!dataset} disabled={!available}
key={theme.id} key={theme.id}
type="button" type="button"
onClick={() => selectDataTheme(theme)} onClick={() => selectDataTheme(theme)}
@@ -998,9 +1024,23 @@ export function MapWorkspace({
<span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" /> <span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" />
<span> <span>
<strong>{theme.label}</strong> <strong>{theme.label}</strong>
<small>{dataset ? `${(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} objecten beschikbaar` : 'Bron nog niet ingeladen'}</small> <small>
{analysisMode === 'evolution'
? evolutionAvailable
? `${temporalGroup.items.length} meetmomenten${firstYear && lastYear ? ` · ${firstYear}-${lastYear}` : ''}`
: dataset
? 'Alleen huidige toestand'
: 'Bron nog niet ingeladen'
: dataset
? `${(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} objecten beschikbaar`
: 'Bron nog niet ingeladen'}
</small>
</span> </span>
<i>{dataset ? 'Beschikbaar' : 'Ontbreekt'}</i> <i>
{analysisMode === 'evolution'
? evolutionAvailable ? 'Tijdreeks' : dataset ? 'Alleen huidig' : 'Ontbreekt'
: dataset ? 'Beschikbaar' : 'Ontbreekt'}
</i>
</button> </button>
) )
})} })}
@@ -1012,7 +1052,7 @@ export function MapWorkspace({
{analysisOverlayActive {analysisOverlayActive
? mapLayerLabel ? mapLayerLabel
: analysisMode === 'evolution' : analysisMode === 'evolution'
? activeTemporalSeriesGroup?.label ?? 'Geen tijdreeks beschikbaar' ? activeTemporalSeriesGroup?.label ?? 'Nog geen historische reeks ingeladen'
: activeThemeDataset ? getDatasetDisplayName(activeThemeDataset) : 'Geen databron beschikbaar'} : activeThemeDataset ? getDatasetDisplayName(activeThemeDataset) : 'Geen databron beschikbaar'}
</strong> </strong>
<small> <small>
@@ -1021,7 +1061,7 @@ export function MapWorkspace({
: analysisMode === 'evolution' : analysisMode === 'evolution'
? activeTemporalSeries.length >= 2 ? activeTemporalSeries.length >= 2
? `${activeTemporalSeries.length} officiële meetmomenten · ${formatObservationDate(activeTemporalSeries[0].observed_at)} tot ${formatObservationDate(activeTemporalSeries[activeTemporalSeries.length - 1].observed_at)}` ? `${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 : activeThemeDataset
? `${getDatasetSourceDisplayName(activeThemeDataset)} · ${formatObservationDate(activeThemeDataset.observed_at)}` ? `${getDatasetSourceDisplayName(activeThemeDataset)} · ${formatObservationDate(activeThemeDataset.observed_at)}`
: activeTheme.description} : activeTheme.description}