fix: keep Mol database layers explicit on map
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-14 11:01:36 +02:00
parent 1ba479b50b
commit aae6315981
9 changed files with 190 additions and 18 deletions
+8
View File
@@ -7,6 +7,14 @@
# Changelog
## Sprint 183 Mol map source clarity and live AI validation (2026-07-14)
- Added an explicit Database/Analysis result map-content mode so an automatically loaded detection result can no longer mask a newly selected persisted municipality layer.
- Kept the Map workspace database-first and made dataset selection switch back to the selected PostGIS layer without discarding available detection, segmentation or change-analysis results.
- Restored the premium UI's viewport status row so visible/total feature counts and the 1,000-feature truncation warning remain readable.
- Rebuilt and deployed the all-in-one Tower runtime, then passed PostGIS 3.6 connectivity, schema/index, single-head migration, frontend proxy and browser runtime checks.
- Ran the bounded Mol-center raster -> tiles -> configured YOLO -> persisted Detection -> GRB QA -> export chain against the live runtime: 36 detections, 17 matches, precision 0.4722, recall 0.0455, F1 0.0829 and mean IoU 0.5958. These honest metrics confirm the workflow while keeping the current model below production-quality acceptance.
## Sprint 182 Municipality viewport delivery and bounded AI handoff (2026-07-14)
- Replaced full-file loading for vector datasets above 5,000 features with debounced, zoom-aware requests to the existing persisted PostGIS bbox-selection endpoint.
@@ -0,0 +1,17 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_map_source_mode_keeps_database_layers_distinct_from_analysis_results() -> None:
app = (ROOT / "frontend/src/App.tsx").read_text(encoding="utf-8")
workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8")
assert "const [mapContentMode, setMapContentMode]" in app
assert "mapContentMode === 'analysis' && analysisMapLayerAvailable" in app
assert "setMapContentMode('dataset')" in app
assert 'aria-label="Map content source"' in workspace
assert "onSetMapContentMode('dataset')" in workspace
assert "onSetMapContentMode('analysis')" in workspace
assert "disabled={!analysisLayerAvailable}" in workspace
+46
View File
@@ -7510,3 +7510,49 @@ Open:
guard, suppressed the incorrect empty-layer state and loaded an actual
viewport slice with `2 visible of 36,941 total features`. No browser warnings
or errors were recorded.
# Sprint 183 - Mol map source clarity and live AI validation
## Runtime evidence
- Deployed commit `1ba479b` to the all-in-one Tower runtime at
`http://192.168.10.150:1202`. The deployment rebuilt the updated
`postgres:16-bookworm` GIS runtime and installed the configured CPU AI image.
- Live migration smoke passed against PostGIS `3.6` with required runtime
schema objects, geometry indexes and the single Alembic head
`202606120900`. `/health` reported service, backend version and database as
healthy.
- Reused project `d74c1f87-29c0-4c67-adfc-560764f2b80e` and created bounded
area `0a38dd7a-d55a-4453-ae59-db8579ed8170` for
`Mol Centrum - AI analysezone 500m`.
- Persisted raster dataset `e9a292e2-57d2-4a85-9df6-32580ba6ea98` and GRB
reference dataset `bfc83882-2acc-470d-baec-ee828b4428f8` through the
canonical upload and vector persistence paths.
- Ran local model asset
`geointel-building-yolov8s-smallbld-minpx3-img640-ft30-pt` against the
persisted tile manifest. Analysis run
`97c204cc-bc75-4139-aab5-25397dd0d87e` persisted `36` detections.
- Quality check `1edb81ec-6799-41c1-ae25-9145f52407bd` persisted `17` matches,
`19` false positives and `357` false negatives. Persisted metrics were
precision `0.472222`, recall `0.045455`, F1 `0.082927` and mean IoU
`0.595803`; the workflow works, but these figures do not justify production
model acceptance.
- Detection export `d18946c4-b7c5-4262-83ad-6c406c6aa59c` was persisted.
## Map-source correction
- Live browser review exposed that the automatically selected detection run
could mask an explicitly selected database layer and leave the header at
`36 features`. Added a Database/Analysis result source mode and made
database selection return to Database mode.
- Local browser verification then selected the complete
`mol_grb_gbg_buildings.geojson` dataset, rendered a real `1,000`-feature
PostGIS viewport slice over the OpenStreetMap road basemap, displayed the
truncation/zoom guidance, switched to the 36-feature detection overlay and
back without state loss, and recorded no console errors.
## Next pass
- Clip detection QA reference populations to actual raster/tile coverage and
add box-to-building-footprint matching diagnostics before any further model
training or promotion decision.
+3
View File
@@ -21,6 +21,9 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Make Mol the primary workbench, AOI and operator-sample context while preserving broader Kempen coverage.
- [x] Add a Mol multi-zone operational pack with independent positive holdouts, background control and persisted map-ready AOIs.
- [x] Execute the Mol pack on live Tower/PostGIS with real orthophotos, GRB references, configured YOLO, persisted QA/QC and a zero-building background control.
- [x] Persist and stream the complete official Mol municipality boundary plus all 36,941 clipped GRB buildings through the Map workspace.
- [x] Run a bounded Mol-center raster, tile, configured-YOLO, persisted detection, GRB QA and export workflow on live Tower/PostGIS.
- [x] Separate persisted database layers from available analysis-result overlays with an explicit Map content mode.
- [x] Persist combined Mol operator evidence under the Unraid storage mount so reports survive all-in-one container replacement.
- [x] Visually review Mol Postel and Donk false-positive/false-negative evidence, classify the dominant error modes and only then decide whether another model-training pass is justified.
- [ ] Clip detection QA populations to persisted raster/tile coverage and add box-to-footprint matching diagnostics before reconsidering model training.
+2
View File
@@ -22,6 +22,8 @@ Operational GIS testing is now available directly in the Map workspace. Users ca
Large vector layers use viewport delivery instead of downloading one unbounded GeoJSON file. Datasets above 5,000 features load their persisted PostGIS geometries from the existing bbox-selection endpoint at zoom level 14 or closer, with a 1,000-feature cap per view. The Map workspace shows visible versus total feature counts and asks the operator to zoom further when the response is truncated. Small layers, AI result overlays, selections and QA evidence keep their existing complete-FeatureCollection behavior.
The Map workspace keeps database layers and analysis-result overlays explicit. `Map content: Database` is the default and is selected automatically whenever an operator chooses a persisted database layer. `Analysis result` is enabled only when a detection, segmentation or change-result GeoJSON is available. Switching modes changes only the rendered source; it does not delete persisted analysis results or dataset state.
QA/QC and Exports follow the same calmer density model. QA/QC keeps metric evidence, feature ids and raw findings available but compresses provenance and history surfaces so review starts from the selected check and map evidence actions. Exports uses denser handoff cards, latest-artifact cards and history filters so artifact creation and download paths are easier to scan.
When project data loads and no dataset is selected yet, the workbench auto-opens the first ready vector dataset. This gives Data, Map and Exports an immediately usable default context while preserving explicit user selection once the user picks another dataset.
+24 -14
View File
@@ -56,11 +56,11 @@ const workspaceNavGroups: Array<{ label: string; keys: WorkspaceKey[] }> = [
function App(): JSX.Element {
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('overview')
const [inspectorOpen, setInspectorOpen] = useState(false)
const [mapContentMode, setMapContentMode] = useState<'dataset' | 'analysis'>('dataset')
useEffect(() => {
window.scrollTo({ top: 0, left: 0 })
setInspectorOpen(false)
}, [activeWorkspace])
const {
projects,
selectedProject,
@@ -83,6 +83,10 @@ function App(): JSX.Element {
setProjectForm,
setAreaForm,
} = useProjectWorkspace()
const selectProject = (projectId: string) => {
setMapContentMode('dataset')
setSelectedProjectId(projectId)
}
const {
selectedDatasetId,
selectedDataset,
@@ -351,7 +355,8 @@ function App(): JSX.Element {
featureCount: selectedDatasetSummary?.feature_count ?? selectedDataset?.feature_count ?? null,
isVectorDatasetType,
})
const analysisMapLayerActive = Boolean(changeDetectionResult?.geojson || segmentationGeoJson || detectionGeoJson)
const analysisMapLayerAvailable = Boolean(changeDetectionResult?.geojson || segmentationGeoJson || detectionGeoJson)
const analysisMapLayerActive = mapContentMode === 'analysis' && analysisMapLayerAvailable
const viewportVectorLayerActive = viewportVectorLayer.enabled && !analysisMapLayerActive
const datasetMapContent = viewportVectorLayer.enabled ? viewportVectorLayer.data : datasetContent
const useRasterTileManifestForSegmentation = () => {
@@ -410,9 +415,9 @@ function App(): JSX.Element {
setSelectedMapFeature,
} = useMapWorkspaceState({
areas,
changeDetectionGeoJson: changeDetectionResult?.geojson ?? null,
segmentationGeoJson,
detectionGeoJson,
changeDetectionGeoJson: analysisMapLayerActive ? changeDetectionResult?.geojson ?? null : null,
segmentationGeoJson: analysisMapLayerActive ? segmentationGeoJson : null,
detectionGeoJson: analysisMapLayerActive ? detectionGeoJson : null,
datasetContent: datasetMapContent,
selectedDataset,
datasetLayerActive: Boolean(selectedDataset && isVectorDatasetType(selectedDataset.dataset_type)),
@@ -479,7 +484,7 @@ function App(): JSX.Element {
loadSegmentationRuns,
loadQualityChecks,
loadExports,
setSelectedProjectId,
setSelectedProjectId: selectProject,
setSelectedDatasetId,
setSelectedMapAreaId,
setQaCandidateDatasetId,
@@ -521,28 +526,28 @@ function App(): JSX.Element {
const selectedArea = areas.find((area) => area.id === selectedMapAreaId) ?? null
const activeWorkspaceItem = workspaceNavItems.find((item) => item.key === activeWorkspace) ?? workspaceNavItems[0]
const mapLayerSourceLabel = useMemo(() => {
if (changeDetectionResult?.geojson) {
if (analysisMapLayerActive && changeDetectionResult?.geojson) {
return 'Change detection'
}
if (segmentationGeoJson) {
if (analysisMapLayerActive && segmentationGeoJson) {
return 'Segmentation run'
}
if (detectionGeoJson) {
if (analysisMapLayerActive && detectionGeoJson) {
return 'Detection run'
}
if ((datasetMapContent || viewportVectorLayer.enabled) && selectedDataset) {
return `${selectedDataset.dataset_type} dataset`
}
return 'No active vector or result layer'
}, [changeDetectionResult?.geojson, datasetMapContent, detectionGeoJson, segmentationGeoJson, selectedDataset, viewportVectorLayer.enabled])
}, [analysisMapLayerActive, changeDetectionResult?.geojson, datasetMapContent, detectionGeoJson, segmentationGeoJson, selectedDataset, viewportVectorLayer.enabled])
const mapLayerProvenance = useMemo(() => {
if (changeDetectionResult?.geojson) {
if (analysisMapLayerActive && changeDetectionResult?.geojson) {
return `source ${changeSourceDatasetId || 'n/a'} -> target ${changeTargetDatasetId || 'n/a'}`
}
if (segmentationGeoJson) {
if (analysisMapLayerActive && segmentationGeoJson) {
return selectedSegmentationRunId ? `analysis run ${selectedSegmentationRunId}` : 'segmentation results loaded'
}
if (detectionGeoJson) {
if (analysisMapLayerActive && detectionGeoJson) {
return selectedDetectionRunId ? `analysis run ${selectedDetectionRunId}` : 'detection results loaded'
}
if ((datasetMapContent || viewportVectorLayer.enabled) && selectedDataset) {
@@ -550,6 +555,7 @@ function App(): JSX.Element {
}
return 'Open a dataset, detection run, segmentation run or change result to draw it here.'
}, [
analysisMapLayerActive,
changeDetectionResult?.geojson,
changeSourceDatasetId,
changeTargetDatasetId,
@@ -565,6 +571,7 @@ function App(): JSX.Element {
if (selectedProjectId) {
loadDatasetDetails(selectedProjectId, dataset)
}
setMapContentMode('dataset')
setMapLayerVisible(true)
setActiveWorkspace('map')
}
@@ -867,7 +874,7 @@ function App(): JSX.Element {
demoWorkflowMessage={demoWorkflowMessage}
onCreateProject={createProject}
onUpdateProjectForm={setProjectForm}
onSelectProject={setSelectedProjectId}
onSelectProject={selectProject}
onLoadDemoWorkflow={loadDemoWorkflow}
/>
@@ -925,6 +932,8 @@ function App(): JSX.Element {
viewportVectorStatus={viewportVectorLayerActive ? viewportVectorLayer.statusMessage : null}
viewportVectorTone={viewportVectorLayer.error ? 'error' : viewportVectorLayer.truncated ? 'warning' : viewportVectorLayer.loading || viewportVectorLayer.zoomRequired ? 'pending' : 'ready'}
fitMapDataOnChange={!viewportVectorLayerActive}
mapContentMode={analysisMapLayerActive ? 'analysis' : 'dataset'}
analysisLayerAvailable={analysisMapLayerAvailable}
selectedMapFeature={selectedMapFeature}
mapSelectionBbox={mapSelectionBbox}
mapSelectionResult={mapSelectionResult}
@@ -952,6 +961,7 @@ function App(): JSX.Element {
onSetAreaLayerOpacity={setAreaLayerOpacity}
onSetMapLayerVisible={setMapLayerVisible}
onSetMapLayerOpacity={setMapLayerOpacity}
onSetMapContentMode={setMapContentMode}
onSelectMapFeature={setSelectedMapFeature}
onMapViewportChange={viewportVectorLayer.setViewport}
onSetMapSelectionBbox={setMapSelectionBbox}
@@ -193,6 +193,8 @@ interface MapWorkspaceProps {
viewportVectorStatus: string | null
viewportVectorTone: 'ready' | 'pending' | 'warning' | 'error'
fitMapDataOnChange: boolean
mapContentMode: 'dataset' | 'analysis'
analysisLayerAvailable: boolean
selectedMapFeature: GeoJSON.Feature | null
selectedFeature?: GeoJSON.Feature | null
mapSelectionBbox: VectorSelectionBBox | null
@@ -220,6 +222,7 @@ interface MapWorkspaceProps {
onSetAreaLayerOpacity: (opacity: number) => void
onSetMapLayerVisible: (visible: boolean) => void
onSetMapLayerOpacity: (opacity: number) => void
onSetMapContentMode: (mode: 'dataset' | 'analysis') => void
onSelectMapFeature: (feature: GeoJSON.Feature | null) => void
onMapViewportChange: (viewport: MapViewportState) => void
onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void
@@ -256,6 +259,8 @@ export function MapWorkspace({
viewportVectorStatus,
viewportVectorTone,
fitMapDataOnChange,
mapContentMode,
analysisLayerAvailable,
selectedMapFeature,
selectedFeature = selectedMapFeature,
mapSelectionBbox,
@@ -283,6 +288,7 @@ export function MapWorkspace({
onSetAreaLayerOpacity,
onSetMapLayerVisible,
onSetMapLayerOpacity,
onSetMapContentMode,
onSelectMapFeature,
onMapViewportChange,
onSetMapSelectionBbox,
@@ -508,6 +514,26 @@ export function MapWorkspace({
</div>
) : null}
<div className="map-toolbar">
<div className="map-layer-mode" aria-label="Map content mode">
<span>Map content</span>
<div role="group" aria-label="Map content source">
<button
type="button"
aria-pressed={mapContentMode === 'dataset'}
onClick={() => onSetMapContentMode('dataset')}
>
Database
</button>
<button
type="button"
aria-pressed={mapContentMode === 'analysis'}
disabled={!analysisLayerAvailable}
onClick={() => onSetMapContentMode('analysis')}
>
Analysis result
</button>
</div>
</div>
<label>
Database layer
<select
+50 -1
View File
@@ -4969,7 +4969,7 @@ section {
}
.map-workspace-shell .map-toolbar {
grid-template-columns: minmax(13rem, 1.25fr) minmax(10rem, 1fr) repeat(2, minmax(8rem, 0.85fr)) minmax(11rem, 1fr);
grid-template-columns: minmax(10rem, 0.9fr) minmax(13rem, 1.25fr) minmax(10rem, 1fr) repeat(2, minmax(8rem, 0.85fr)) minmax(11rem, 1fr);
gap: 0.5rem;
}
@@ -4977,6 +4977,55 @@ section {
margin-top: 0;
}
.map-layer-mode {
display: grid;
align-content: start;
gap: 0.34rem;
min-width: 0;
}
.map-layer-mode > span {
color: var(--muted);
font-size: 0.7rem;
font-weight: 700;
}
.map-layer-mode > div {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
overflow: hidden;
border: 1px solid var(--line-strong);
border-radius: 6px;
background: var(--panel-soft);
}
.map-layer-mode button {
min-width: 0;
min-height: 2.2rem;
border: 0;
border-radius: 0;
padding: 0.36rem 0.42rem;
background: transparent;
color: var(--muted);
font-size: 0.68rem;
font-weight: 750;
line-height: 1.15;
}
.map-layer-mode button + button {
border-left: 1px solid var(--line-strong);
}
.map-layer-mode button[aria-pressed='true'] {
background: var(--accent-strong);
color: #ffffff;
}
.map-layer-mode button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.layer-control-card,
.map-status {
border-radius: 6px;
+14 -3
View File
@@ -936,7 +936,7 @@ details.ai-lab-model-surface > summary strong {
.map-workspace-shell .map-toolbar {
display: grid;
grid-template-columns: minmax(13rem, 1.3fr) minmax(10rem, 0.9fr) minmax(8rem, 0.65fr) minmax(8rem, 0.65fr) minmax(10rem, 0.9fr);
grid-template-columns: minmax(10rem, 0.85fr) minmax(13rem, 1.3fr) minmax(10rem, 0.9fr) minmax(8rem, 0.65fr) minmax(8rem, 0.65fr);
gap: 0.6rem;
align-items: end;
}
@@ -956,7 +956,10 @@ details.ai-lab-model-surface > summary strong {
.map-status {
display: grid;
min-height: 3.9rem;
grid-column: 1 / -1;
grid-template-columns: minmax(13rem, 1.35fr) repeat(3, minmax(10rem, 1fr));
gap: 0.25rem 0.85rem;
min-height: auto;
align-content: center;
border-left: 3px solid var(--accent);
border-radius: 4px;
@@ -969,7 +972,15 @@ details.ai-lab-model-surface > summary strong {
}
.map-status span {
display: none;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.map-status .viewport-vector-status {
grid-column: 1 / -1;
white-space: normal;
}
.basemap-policy-notice {