From 9e9d974b9b377ace6484eb2dd1958b485f689d9f Mon Sep 17 00:00:00 2001 From: Caleb Robinson Date: Fri, 21 Aug 2026 06:31:03 +0000 Subject: [PATCH 01/10] feat(ui,api): edit model predictions and save versioned results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every model row in both workflows gains an Edit button that opens a new screen showing all predicted building footprints. An analyst can reclassify buildings and, for trained-inference models, re-threshold on damage_pct_0m, then save the result as a new version of the prediction geopackage. The button stays disabled until a full prediction set exists: inferenceStatus == "Processed" for a trained model, and a non-zero predictedBuildingCount for the embedding workflow. That count is new — "Clear labels" also writes a geopackage and sets gpkgUrl, so the old !!gpkgUrl check could not tell a completed pass from a cleared one. Reads go through PMTiles plus a columnar attribute sidecar, prepared by a queued job that runs in the training image because tippecanoe only exists there. Nothing before this could deliver a full prediction set to the browser: the GeoJSON route returns a random sample capped at 2000, and vector tiles existed only for the embedding workflow. The threshold slider recolours from the sidecar in the browser, so it costs no server round-trip. Writes never touch the raw output. The client sends a threshold and a sparse override list; the server re-reads the source geopackage, applies threshold then overrides, and writes a new numbered version. Model.gpkgUrl still points at the raw prediction, and each version is recorded in Model.editedPredictions. Row order is preserved exactly, because two downstream consumers join predictions to Overture ids positionally; an explicit overture_id column is now written alongside. The two producers disagree on schema — trained inference writes a continuous damage_pct_0m in the raster CRS, while the embedding endpoint writes layer "predictions" with a degenerate 0/1 copy — so a normalising reader hides the difference and thresholding is offered only where it is meaningful. Also fixes a latent conflict: the Interactive Labeler registered its own pmtiles protocol handler, and addProtocol keeps one handler per scheme, so a second registration from the editor would have silently broken its tiles. Both now share a singleton. Specs and an ADR for versioned derived artifacts are included, and the stale API reference is brought up to date. --- api/hastefuncapi/function_app.py | 613 +++++- api/hastefuncqueues/function_app.py | 185 +- docker/data-init/upload_data.py | 1 + docker/docker-compose.yml | 2 + docs/api/hastefuncapi.md | 303 +++ hastelib/logs/embedding_friendly.log | 2 + hastelib/logs/prediction_tiles_friendly.log | 13 + hastelib/pyproject.toml | 1 + hastelib/src/hastegeo/core/config.py | 33 +- .../src/hastegeo/core/models/predictions.py | 111 ++ hastelib/src/hastegeo/core/models/projects.py | 75 +- .../core/processors/prediction_edits.py | 422 +++++ .../core/processors/prediction_tiles.py | 757 ++++++++ .../src/hastegeo/core/utils/predictions.py | 237 +++ .../workflows/prepare_prediction_tiles.py | 660 +++++++ hastelib/tests/core/models/__init__.py | 2 + .../models/test_prediction_wire_models.py | 164 ++ .../core/processors/test_prediction_edits.py | 592 ++++++ .../core/processors/test_prediction_tiles.py | 359 ++++ .../test_prediction_tiles_request.py | 244 +++ hastelib/tests/core/utils/test_predictions.py | 331 ++++ .../test_prepare_prediction_tiles.py | 536 ++++++ local.settings.example.jsonc | 1 + ...-versioned-derived-prediction-artifacts.md | 128 ++ spec/features/prediction-editing/README.md | 113 ++ .../features/prediction-editing/data-model.md | 327 ++++ spec/features/prediction-editing/design.md | 528 ++++++ .../prediction-editing/impact-analysis.md | 122 ++ spec/features/prediction-editing/plan.md | 136 ++ spec/features/prediction-editing/rollout.md | 125 ++ spec/features/prediction-editing/test-plan.md | 159 ++ .../prediction-editing/user-stories.md | 269 +++ ui/src/Components/AppBody.jsx | 5 + .../InteractiveLabeler/InteractiveLabeler.jsx | 15 +- .../PredictionEditor/PredictionEditor.jsx | 1675 +++++++++++++++++ .../PredictionEditorRightPanel.jsx | 585 ++++++ .../PredictionEditor/predictionClassify.js | 402 ++++ .../predictionClassify.test.js | 615 ++++++ .../PredictionEditor/predictionPrep.js | 272 +++ .../ProjectManagement/EmbeddingModelRow.jsx | 41 + .../ProjectManagement/ModelResultsButton.jsx | 29 + ui/src/Components/keyboardShortcuts.js | 26 + ui/src/util/pmtiles.js | 86 + 43 files changed, 11281 insertions(+), 21 deletions(-) create mode 100644 hastelib/logs/embedding_friendly.log create mode 100644 hastelib/logs/prediction_tiles_friendly.log create mode 100644 hastelib/src/hastegeo/core/models/predictions.py create mode 100644 hastelib/src/hastegeo/core/processors/prediction_edits.py create mode 100644 hastelib/src/hastegeo/core/processors/prediction_tiles.py create mode 100644 hastelib/src/hastegeo/core/utils/predictions.py create mode 100644 hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py create mode 100644 hastelib/tests/core/models/__init__.py create mode 100644 hastelib/tests/core/models/test_prediction_wire_models.py create mode 100644 hastelib/tests/core/processors/test_prediction_edits.py create mode 100644 hastelib/tests/core/processors/test_prediction_tiles.py create mode 100644 hastelib/tests/core/processors/test_prediction_tiles_request.py create mode 100644 hastelib/tests/core/utils/test_predictions.py create mode 100644 hastelib/tests/workflows/test_prepare_prediction_tiles.py create mode 100644 spec/architecture/decisions/0005-versioned-derived-prediction-artifacts.md create mode 100644 spec/features/prediction-editing/README.md create mode 100644 spec/features/prediction-editing/data-model.md create mode 100644 spec/features/prediction-editing/design.md create mode 100644 spec/features/prediction-editing/impact-analysis.md create mode 100644 spec/features/prediction-editing/plan.md create mode 100644 spec/features/prediction-editing/rollout.md create mode 100644 spec/features/prediction-editing/test-plan.md create mode 100644 spec/features/prediction-editing/user-stories.md create mode 100644 ui/src/Components/PredictionEditor/PredictionEditor.jsx create mode 100644 ui/src/Components/PredictionEditor/PredictionEditorRightPanel.jsx create mode 100644 ui/src/Components/PredictionEditor/predictionClassify.js create mode 100644 ui/src/Components/PredictionEditor/predictionClassify.test.js create mode 100644 ui/src/Components/PredictionEditor/predictionPrep.js create mode 100644 ui/src/util/pmtiles.js diff --git a/api/hastefuncapi/function_app.py b/api/hastefuncapi/function_app.py index 707cd6c3..c2da2620 100644 --- a/api/hastefuncapi/function_app.py +++ b/api/hastefuncapi/function_app.py @@ -12,10 +12,17 @@ import azure.functions as func # type: ignore import requests # type: ignore +from azure.core.exceptions import ResourceNotFoundError # type: ignore from hastegeo.core.config import Config from hastegeo.core.models.admin import AdminConfig +from hastegeo.core.models.predictions import ( + PREDICTION_EDIT_DEFAULT_THRESHOLD, + EditedPredictionsRequest, + PreparePredictionTilesRequest, +) from hastegeo.core.models.projects import ( BuildingValidation, + EditedPredictionVersion, ImageLayer, LabelProject, Model, @@ -1374,13 +1381,29 @@ async def GetLayerModelsDetails(req: func.HttpRequest) -> func.HttpResponse: "sidecar": "featuresSidecarUrl", "geojson": "embeddingsGeoJSONUrl", "gpkg": "gpkgUrl", + # Prediction editor: the model's columnar attribute sidecar + # (ArtifactTypes.PREDICTION_ATTRS), recorded on the Model by the + # prediction-tiles job. + "prediction_attrs": "predictionAttrsUrl", +} +# Kinds whose blob URL lives on the ImageLayer rather than the Model. +# Footprint tiles (ArtifactTypes.LAYER_FOOTPRINT_PMTILES) are geometry +# only and are shared by every model trained on that layer, so they are +# built and stored once per layer. These kinds also require imageLayerId. +_LAYER_ARTIFACT_URL_FIELDS = { + "footprint_pmtiles": "footprintPmtilesUrl", } _MODEL_ARTIFACT_CONTENT_TYPES = { "pmtiles": "application/octet-stream", "sidecar": "application/octet-stream", "geojson": "application/geo+json", "gpkg": "application/geopackage+sqlite3", + "prediction_attrs": "application/json", + "footprint_pmtiles": "application/vnd.pmtiles", } +_MODEL_ARTIFACT_KINDS = sorted( + set(_MODEL_ARTIFACT_URL_FIELDS) | set(_LAYER_ARTIFACT_URL_FIELDS) +) @app.route( @@ -1405,6 +1428,13 @@ async def GetModelArtifact(req: func.HttpRequest) -> func.HttpResponse: predictions GeoPackage saved by ``PutBuildingPredictions``, served as a downloadable attachment. Example: ``GET /api/GetModelArtifact?projectId=&modelId=&kind=gpkg``. + + The prediction editor adds two more kinds: ``prediction_attrs`` (the + model's columnar prediction attribute sidecar, ``application/json``) + and ``footprint_pmtiles`` (the image layer's footprint tiles, + ``application/vnd.pmtiles``). ``footprint_pmtiles`` lives on the + ImageLayer; pass ``imageLayerId`` to select it explicitly, otherwise + the model's own image layer is used. """ try: project_id = _require_guid_param(req, "projectId") @@ -1413,14 +1443,13 @@ async def GetModelArtifact(req: func.HttpRequest) -> func.HttpResponse: return _bad_request(str(e)) kind = (req.params.get("kind") or "").lower() - url_field = _MODEL_ARTIFACT_URL_FIELDS.get(kind) + layer_url_field = _LAYER_ARTIFACT_URL_FIELDS.get(kind) + url_field = _MODEL_ARTIFACT_URL_FIELDS.get(kind) or layer_url_field if url_field is None: - return _bad_request( - f"kind must be one of {sorted(_MODEL_ARTIFACT_URL_FIELDS)}" - ) + return _bad_request(f"kind must be one of {_MODEL_ARTIFACT_KINDS}") try: - model = await asyncio.to_thread( + document = await asyncio.to_thread( MetadataProcessor( data_type=config.get_metadata_types().MODEL.value, partition_key=project_id, @@ -1436,7 +1465,35 @@ async def GetModelArtifact(req: func.HttpRequest) -> func.HttpResponse: ) return func.HttpResponse("Error loading model.", status_code=500) - blob_url = (model or {}).get(url_field) or "" + if layer_url_field is not None: + # Layer-scoped artifact: take an explicit imageLayerId when the + # caller passes one, otherwise the model's own layer — a client + # holding a modelId always means that model's footprints. + image_layer_id = req.params.get("imageLayerId") or ( + document or {} + ).get("imageLayerId") + if not image_layer_id or not _GUID_RE.match(str(image_layer_id)): + return _bad_request("Invalid or missing parameter: imageLayerId") + try: + document = await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().IMAGELAYER.value, + partition_key=project_id, + ).load, + image_layer_id, + ) + except FileNotFoundError: + return func.HttpResponse("Image layer not found.", status_code=404) + except Exception as e: + logger.error( + f"GetModelArtifact image layer load failed: {e}\n" + f"{traceback.format_exc()}" + ) + return func.HttpResponse( + "Error loading image layer.", status_code=500 + ) + + blob_url = (document or {}).get(url_field) or "" if not blob_url: return func.HttpResponse( "Artifact not available for this model.", status_code=404 @@ -2757,6 +2814,13 @@ def _store_and_url(): gpkg_url = await asyncio.to_thread(_store_and_url) model_data["gpkgUrl"] = gpkg_url + # gpkgUrl alone is an ambiguous completion signal: the labeler's + # "Clear labels" action PUTs predictions: [], which still writes a + # valid (all-zero) GeoPackage and still sets gpkgUrl, so a cleared + # model would look identical to a completed one. Persist the count + # and timestamp so consumers can tell the two apart. + model_data["predictedBuildingCount"] = len(predictions) + model_data["predictedAt"] = MetadataUtils.get_timestamp() await asyncio.to_thread( MetadataProcessor( data_type=config.get_metadata_types().MODEL.value, @@ -2796,6 +2860,543 @@ def _store_and_url(): pass +# ── Prediction editing ────────────────────────────────────────────────── +# The request wire contracts (EditedPredictionsRequest, +# PreparePredictionTilesRequest) live in +# hastegeo.core.models.predictions; the decisions they feed (class +# derivation, thresholding, versioning, blob writes, queueing) live in +# hastegeo.core.processors.prediction_edits / prediction_tiles. Only the +# HTTP plumbing belongs here. + + +def _invalid_body(error: ValidationError) -> func.HttpResponse: + """Render a body ValidationError as a 400 the editor can display.""" + errors = error.errors() + first = errors[0] if errors else {} + location = ".".join(str(part) for part in first.get("loc", ())) + detail = first.get("msg", "invalid value") + if location: + detail = f"{location}: {detail}" + logger.warning(f"Rejected request body: {detail}") + return func.HttpResponse( + f"Invalid request body. {detail}", status_code=400 + ) + + +def _edited_versions(model_data: dict) -> list: + """Model.editedPredictions, newest version first.""" + return sorted( + model_data.get("editedPredictions") or [], + key=lambda entry: entry.get("version") or 0, + reverse=True, + ) + + +@app.route( + route="GetPredictionEditSession", + auth_level=AUTH_LEVEL, + methods=["GET"], +) +async def GetPredictionEditSession( + req: func.HttpRequest, +) -> func.HttpResponse: + """Return everything the prediction editor needs to open a session. + + Query params: projectId, imageLayerId, modelId. + + Response:: + + { + "modelId": "5557", + "flavor": "inference" | "embedding", + "supportsThreshold": true, + "defaultThreshold": 0.0, + "buildingCount": 125430, + "tilesReady": true, + "attrsReady": true, + "predictionTilesStatus": "Processed", + "predictionTilesStatusMessage": "", + "versions": [ EditedPredictionVersion, ... ] + } + + ``flavor`` tells the UI whether the damage threshold slider is + meaningful: the embedding producer writes a degenerate 0.0/1.0 copy of + ``damaged``, so thresholding it does nothing. ``tilesReady`` and + ``attrsReady`` report whether the footprint PMTiles and the prediction + attribute sidecar have been built yet — building them needs tippecanoe + and therefore runs as a queued job, never inline in this handler. This + route is read-only: when either flag is false the UI asks for the work + with ``PutPreparePredictionTilesQueueMessage`` and then polls here, + using ``predictionTilesStatus`` to tell "queued/running" apart from + "failed" (and to surface the job's progress message). + """ + logger.info( + "GetPredictionEditSession HTTP trigger function processed a request." + ) + # Imported lazily: both modules import fiona at module scope, which is + # only present in the geospatial images (same pattern as the assessment + # and footprint routes). + from hastegeo.core.processors.prediction_tiles import needs_preparation + from hastegeo.core.utils.predictions import read_predictions + + try: + project_id = _require_guid_param(req, "projectId") + image_layer_id = _require_guid_param(req, "imageLayerId") + model_id = _require_short_int_id_param(req, "modelId") + except ValueError as e: + return _bad_request(str(e)) + + gpkg_path = None + try: + model_data = await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().MODEL.value, + partition_key=project_id, + ).load, + model_id, + ) + image_layer_data = await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().IMAGELAYER.value, + partition_key=project_id, + ).load, + image_layer_id, + ) + + gpkg_url = model_data.get("gpkgUrl") + if not gpkg_url: + return func.HttpResponse( + "No predictions available for this model.", status_code=404 + ) + + gpkg_path = await download_blob_to_tempfile(gpkg_url, suffix=".gpkg") + predictions = await asyncio.to_thread(read_predictions, gpkg_path) + + model = Model(**model_data) + needs_pmtiles, needs_attrs = needs_preparation( + model, ImageLayer(**image_layer_data) + ) + + session = { + "modelId": model_id, + "flavor": predictions.flavor, + "supportsThreshold": predictions.supports_threshold, + "defaultThreshold": PREDICTION_EDIT_DEFAULT_THRESHOLD, + "buildingCount": len(predictions), + "tilesReady": not needs_pmtiles, + "attrsReady": not needs_attrs, + # Preparation job state, so the UI can distinguish "queued" + # from "failed" while polling instead of spinning forever. + "predictionTilesStatus": model.predictionTilesStatus, + "predictionTilesStatusMessage": ( + model.predictionTilesStatusMessage or "" + ), + "versions": _edited_versions(model_data), + } + return func.HttpResponse( + json.dumps(session), + status_code=200, + mimetype="application/json", + ) + + except FileNotFoundError: + return func.HttpResponse( + "Model or image layer not found.", status_code=404 + ) + except ResourceNotFoundError: + logger.warning( + f"GetPredictionEditSession missing prediction blob for model " + f"{model_id}" + ) + return func.HttpResponse( + "Prediction GeoPackage not found.", status_code=404 + ) + except Exception as e: + logger.error( + f"Error in GetPredictionEditSession: {e}\n" + f"{traceback.format_exc()}", + stack_info=True, + ) + return func.HttpResponse( + "Error loading prediction edit session.", status_code=500 + ) + finally: + if gpkg_path and os.path.exists(gpkg_path): + try: + os.unlink(gpkg_path) + except OSError: + pass + + +@app.route( + route="PutPreparePredictionTilesQueueMessage", + auth_level=AUTH_LEVEL, + methods=["PUT"], +) +async def PutPreparePredictionTilesQueueMessage( + req: func.HttpRequest, +) -> func.HttpResponse: + """Queue the prediction editor's tile/sidecar preparation job. + + Body:: + + { + "projectId": "...", + "imageLayerId": "...", + "modelId": "5557", + "force": false + } + + Returns + ``{ modelId, queued, tilesReady, attrsReady, status, statusMessage }`` + — the state the editor polls ``GetPredictionEditSession`` for while + it waits. + + Building the layer's footprint PMTiles and the model's prediction + attribute sidecar needs ``tippecanoe``, which ships only in the + training image, so the work is always a queued job; this route only + requests it. Nothing is enqueued when both artifacts already exist + (``queued: false``) unless ``force`` is set — used after predictions + are regenerated, which leaves stale artifacts behind. + """ + logger.info( + "PutPreparePredictionTilesQueueMessage HTTP trigger function " + "processed a request." + ) + # Lazy import, matching GetPredictionEditSession: the prediction + # modules pull in the geospatial stack at module scope. + from hastegeo.core.processors.prediction_tiles import ( + PredictionTilesUnavailableError, + request_preparation, + ) + + try: + body = req.get_json() + except ValueError as e: + logger.warning( + f"PutPreparePredictionTilesQueueMessage invalid JSON: {e}" + ) + return func.HttpResponse( + "Invalid JSON in request body.", status_code=400 + ) + + try: + prep_request = PreparePredictionTilesRequest.model_validate(body) + except ValidationError as e: + return _invalid_body(e) + + project_id = prep_request.projectId + model_id = prep_request.modelId + try: + model_data = await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().MODEL.value, + partition_key=project_id, + ).load, + model_id, + ) + image_layer_data = await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().IMAGELAYER.value, + partition_key=project_id, + ).load, + prep_request.imageLayerId, + ) + + model = Model(**model_data) + try: + result = await asyncio.to_thread( + request_preparation, + model, + ImageLayer(**image_layer_data), + force=prep_request.force, + ) + except PredictionTilesUnavailableError as e: + # Raw inputs missing: nothing to prepare from (yet). + logger.warning( + f"PutPreparePredictionTilesQueueMessage cannot prepare " + f"model {model_id}: {e}" + ) + return func.HttpResponse( + "No predictions or building footprints available to " + "prepare for this model.", + status_code=404, + ) + + await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().MODEL.value, + partition_key=project_id, + ).save, + model_id, + model.dict(), + ) + + return func.HttpResponse( + json.dumps(result), + status_code=200, + mimetype="application/json", + ) + + except FileNotFoundError: + return func.HttpResponse( + "Model or image layer not found.", status_code=404 + ) + except Exception as e: + logger.error( + f"Error in PutPreparePredictionTilesQueueMessage: {e}\n" + f"{traceback.format_exc()}", + stack_info=True, + ) + return func.HttpResponse( + "Error queueing prediction tile preparation.", status_code=500 + ) + + +@app.route( + route="PutEditedPredictions", + auth_level=AUTH_LEVEL, + methods=["PUT"], +) +async def PutEditedPredictions(req: func.HttpRequest) -> func.HttpResponse: + """Save analyst edits as a NEW versioned prediction GeoPackage. + + Body:: + + { + "projectId": "...", + "imageLayerId": "...", + "modelId": "5557", + "threshold": 0.0, + "unknownThreshold": 0.0, + "overrides": [ {"id": 12, "class": "Damaged"}, ... ] + } + + Returns ``{ version, gpkgUrl, editedCount }``. + + ``Model.gpkgUrl`` is never rewritten: it holds the RAW model output + that this and every future edit derives from. Each save appends an + ``EditedPredictionVersion`` to ``Model.editedPredictions`` instead. + """ + logger.info( + "PutEditedPredictions HTTP trigger function processed a request." + ) + # Lazy import: prediction_edits pulls in fiona at module scope. + from hastegeo.core.processors.prediction_edits import ( + apply_edits, + next_version, + store_edited_version, + ) + + try: + body = req.get_json() + except ValueError as e: + logger.warning(f"PutEditedPredictions invalid JSON: {e}") + return func.HttpResponse( + "Invalid JSON in request body.", status_code=400 + ) + + try: + edit_request = EditedPredictionsRequest.model_validate(body) + except ValidationError as e: + return _invalid_body(e) + + project_id = edit_request.projectId + model_id = edit_request.modelId + principal = _decode_client_principal(req) + created_by = ( + principal.get("userDetails") or principal.get("userId") + if principal + else None + ) + + src_path = None + footprints_path = None + edited_path = None + try: + model_data = await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().MODEL.value, + partition_key=project_id, + ).load, + model_id, + ) + image_layer_data = await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().IMAGELAYER.value, + partition_key=project_id, + ).load, + edit_request.imageLayerId, + ) + + source_gpkg_url = model_data.get("gpkgUrl") + if not source_gpkg_url: + return func.HttpResponse( + "No predictions available for this model.", status_code=404 + ) + footprints_url = image_layer_data.get("buildingFootprintsUrl") + if not footprints_url: + return func.HttpResponse( + "No building footprints available for this image layer.", + status_code=404, + ) + + src_path = await download_blob_to_tempfile( + source_gpkg_url, suffix=".gpkg" + ) + footprints_path = await download_blob_to_tempfile( + footprints_url, suffix=".gpkg" + ) + fd, edited_path = tempfile.mkstemp(suffix=".gpkg") + os.close(fd) + + overrides = { + override.rowIndex: override.editedClass + for override in edit_request.overrides + } + try: + summary = await asyncio.to_thread( + apply_edits, + src_path, + edited_path, + threshold=edit_request.threshold, + unknown_threshold=edit_request.unknownThreshold, + overrides=overrides, + footprints_path=footprints_path, + ) + except ValueError as e: + # The prediction → footprint join is positional, so a row + # count mismatch is unprocessable rather than a server fault. + logger.error(f"PutEditedPredictions could not apply edits: {e}") + return func.HttpResponse( + "Predictions and building footprints do not line up row " + "for row; the edit was not saved.", + status_code=422, + ) + + version = next_version(model_data) + edited_gpkg_url = await asyncio.to_thread( + store_edited_version, + project_id, + model_id, + version, + edited_path, + ) + + entry = EditedPredictionVersion( + version=version, + gpkgUrl=edited_gpkg_url, + createdAt=MetadataUtils.get_timestamp(), + createdBy=created_by, + threshold=edit_request.threshold, + unknownThreshold=edit_request.unknownThreshold, + editedCount=summary.overrides_applied, + sourceGpkgUrl=source_gpkg_url, + ) + # Append only. model_data["gpkgUrl"] is deliberately untouched: + # overwriting it would destroy the source of every future edit. + model_data["editedPredictions"] = list( + model_data.get("editedPredictions") or [] + ) + [entry.model_dump()] + await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().MODEL.value, + partition_key=project_id, + ).save, + model_id, + model_data, + ) + + return func.HttpResponse( + json.dumps( + { + "version": version, + "gpkgUrl": edited_gpkg_url, + "editedCount": summary.overrides_applied, + } + ), + status_code=200, + mimetype="application/json", + ) + + except FileNotFoundError: + return func.HttpResponse( + "Model or image layer not found.", status_code=404 + ) + except ResourceNotFoundError: + logger.warning( + f"PutEditedPredictions missing source blob for model {model_id}" + ) + return func.HttpResponse( + "Source prediction GeoPackage or footprints not found.", + status_code=404, + ) + except Exception as e: + logger.error( + f"Error in PutEditedPredictions: {e}\n{traceback.format_exc()}", + stack_info=True, + ) + return func.HttpResponse( + "Error saving edited predictions.", status_code=500 + ) + finally: + for path in (src_path, footprints_path, edited_path): + if path and os.path.exists(path): + try: + os.unlink(path) + except OSError: + pass + + +@app.route( + route="GetEditedPredictionVersions", + auth_level=AUTH_LEVEL, + methods=["GET"], +) +async def GetEditedPredictionVersions( + req: func.HttpRequest, +) -> func.HttpResponse: + """List a model's saved edited-prediction versions, newest first. + + Query params: projectId, modelId. Returns ``{"versions": [...]}`` — + an empty list when the model has never been edited. + """ + logger.info( + "GetEditedPredictionVersions HTTP trigger function processed a " + "request." + ) + try: + project_id = _require_guid_param(req, "projectId") + model_id = _require_short_int_id_param(req, "modelId") + except ValueError as e: + return _bad_request(str(e)) + + try: + model_data = await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().MODEL.value, + partition_key=project_id, + ).load, + model_id, + ) + return func.HttpResponse( + json.dumps({"versions": _edited_versions(model_data)}), + status_code=200, + mimetype="application/json", + ) + except FileNotFoundError: + return func.HttpResponse("Model not found.", status_code=404) + except Exception as e: + logger.error( + f"Error in GetEditedPredictionVersions: {e}\n" + f"{traceback.format_exc()}", + stack_info=True, + ) + return func.HttpResponse( + "Error loading edited prediction versions.", status_code=500 + ) + + @app.route( route="GenerateProjectStats", auth_level=AUTH_LEVEL, diff --git a/api/hastefuncqueues/function_app.py b/api/hastefuncqueues/function_app.py index 07cef137..e245d560 100644 --- a/api/hastefuncqueues/function_app.py +++ b/api/hastefuncqueues/function_app.py @@ -27,6 +27,10 @@ ) from hastegeo.core.processors.labels import LabelTaskGenerator from hastegeo.core.processors.metadata import MetadataProcessor +from hastegeo.core.processors.prediction_tiles import ( + PredictionTilesPostprocessor, + needs_preparation, +) from hastegeo.core.processors.publishing import PublishingProcessor from hastegeo.core.processors.stats import StatsPostProcessor from hastegeo.core.processors.train import TrainPostprocessor @@ -601,6 +605,183 @@ async def GetRunEmbeddingQueueMessage(msg: func.QueueMessage) -> None: ) +@app.function_name(name="PreparePredictionTilesQueueTrigger") +@app.queue_trigger( + arg_name="msg", + queue_name=config.get_queue_config()["prediction_edit_prep_queue_name"], + connection="AzureWebJobsStorage", +) +async def GetPreparePredictionTilesQueueMessage( + msg: func.QueueMessage, +) -> None: + """Build the prediction editor's footprint tiles + attribute sidecar. + + Message schema (identifiers only):: + + {"projectId", "imageLayerId", "modelId", "sourceGpkgUrl", + "sourceFootprintsUrl", "force"} + + The authoritative job state is read from metadata, so a fresh + request and the postprocessor's own poll messages take the same + path. Drives the PredictionTilesPostprocessor state machine (submit + -> poll -> finalize). The work runs as a task in the training + docker image because tippecanoe only ships there. On completion the + model gets its attribute-sidecar URL and the image layer gets the + shared footprint PMTiles URL, so both documents are persisted. + """ + logger.info( + "PreparePredictionTilesQueueTrigger function processed a message: " + f'{msg.get_body().decode("utf-8")}' + ) + model_data = None + try: + payload = json.loads(msg.get_body().decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("Queue message must be a JSON object") + project_id = payload.get("projectId") + model_id = payload.get("modelId") + force = bool(payload.get("force", False)) + if not project_id or not model_id: + raise ValueError( + "Queue message requires projectId and modelId, got: " + f"{sorted(payload.keys())}" + ) + + try: + existing_model = await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().MODEL.value, + partition_key=project_id, + ).load, + model_id, + ) + except FileNotFoundError: + existing_model = None + + if not existing_model: + logger.info( + f"Model {model_id} not found, likely deleted, " + "skipping prediction tile preparation." + ) + return + + # Metadata is authoritative: the message only routes the work. + model_data = Model(**existing_model) + image_layer_id = payload.get("imageLayerId") or model_data.imageLayerId + if not image_layer_id: + raise ValueError( + f"Model {model_id} has no imageLayerId; cannot locate " + "the building footprints to tile." + ) + + image_layer_record = await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().IMAGELAYER.value, + partition_key=project_id, + ).load, + image_layer_id, + ) + image_layer = ImageLayer(**image_layer_record) + previous_pmtiles_url = image_layer.footprintPmtilesUrl + + statuses = config.get_status_types() + if model_data.predictionTilesStatus != statuses.IN_PROGRESS.value: + needs_pmtiles, needs_attrs = needs_preparation( + model_data, image_layer + ) + if not force and not needs_pmtiles and not needs_attrs: + logger.info( + f"Prediction tiles for model {model_id} are already " + "available; nothing to do." + ) + model_data.predictionTilesStatus = statuses.COMPLETED.value + await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().MODEL.value, + partition_key=project_id, + ).save, + model_id, + model_data.dict(), + ) + return + model_data.predictionTilesStatus = statuses.PENDING.value + + processor = PredictionTilesPostprocessor(model_data, image_layer) + output = await asyncio.to_thread(processor.process) + + await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().MODEL.value, + partition_key=project_id, + ).save, + model_id, + output.dict(), + ) + + # Footprint tiles belong to the layer, not the model. Re-read the + # layer before writing so a concurrent imagery update isn't lost. + new_pmtiles_url = processor.image_layer.footprintPmtilesUrl + if new_pmtiles_url and new_pmtiles_url != previous_pmtiles_url: + latest_layer = await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().IMAGELAYER.value, + partition_key=project_id, + ).load, + image_layer_id, + ) + latest_layer["footprintPmtilesUrl"] = new_pmtiles_url + await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().IMAGELAYER.value, + partition_key=project_id, + ).save, + image_layer_id, + latest_layer, + ) + except ValidationError as e: + logger.error( + f"PreparePredictionTilesQueueTrigger: Validation error: {e}\n" + f"{traceback.format_exc()}" + ) + except ValueError as e: + logger.error( + "PreparePredictionTilesQueueTrigger: Invalid queue message: " + f"{e}\n{traceback.format_exc()}" + ) + except Exception as e: + logger.error( + "PreparePredictionTilesQueueTrigger: Error processing queue " + f"message: {e}\n{traceback.format_exc()}", + stack_info=True, + ) + if model_data is not None: + try: + model_data.predictionTilesStatus = ( + config.get_status_types().FAILED.value + ) + model_data.predictionTilesStatusMessage = ( + MetadataUtils.append_status_message( + model_data.predictionTilesStatusMessage, + "Prediction tile job failed: " + f"{describe_exception(e)}", + ) + ) + await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().MODEL.value, + partition_key=model_data.projectId, + ).save, + model_data.modelId, + model_data.dict(), + ) + except Exception as inner_e: + logger.error( + "PreparePredictionTilesQueueTrigger: Error saving " + f"failed status: {inner_e}\n{traceback.format_exc()}", + stack_info=True, + ) + + @app.function_name(name="GetRunInferenceQueueTrigger") @app.queue_trigger( arg_name="msg", @@ -1013,7 +1194,9 @@ async def GetPublishDatasetQueueMessage(msg: func.QueueMessage) -> None: message = PublishQueueMessage( **json.loads(msg.get_body().decode("utf-8")) ) - await asyncio.to_thread(PublishingProcessor(config=config).run_step, message) + await asyncio.to_thread( + PublishingProcessor(config=config).run_step, message + ) except Exception as error: logger.error( "PublishDatasetQueueTrigger failed with %s", diff --git a/docker/data-init/upload_data.py b/docker/data-init/upload_data.py index 76cbc26a..9b2f42c9 100644 --- a/docker/data-init/upload_data.py +++ b/docker/data-init/upload_data.py @@ -46,6 +46,7 @@ def create_queues(): "local-zip-queue", "local-inference-queue", "local-embedding-queue", + "local-prediction-edit-prep-queue", "local-image-queue-poison", "local-embedding-queue-poison" ] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 923dce16..fff7553c 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -106,6 +106,7 @@ services: ZIP_QUEUE_NAME: "local-zip-queue" INFERENCE_QUEUE_NAME: "local-inference-queue" EMBEDDING_QUEUE_NAME: "local-embedding-queue" + PREDICTION_EDIT_PREP_QUEUE_NAME: "local-prediction-edit-prep-queue" PUBLISH_QUEUE_NAME: "local-publish-queue" PUBLISHING_ENABLED: "true" PC_PROVIDER_ENABLED: "false" @@ -154,6 +155,7 @@ services: ZIP_QUEUE_NAME: "local-zip-queue" INFERENCE_QUEUE_NAME: "local-inference-queue" EMBEDDING_QUEUE_NAME: "local-embedding-queue" + PREDICTION_EDIT_PREP_QUEUE_NAME: "local-prediction-edit-prep-queue" PUBLISH_QUEUE_NAME: "local-publish-queue" PUBLISHING_ENABLED: "true" PC_PROVIDER_ENABLED: "false" diff --git a/docs/api/hastefuncapi.md b/docs/api/hastefuncapi.md index 2c207c75..5d8dca1e 100644 --- a/docs/api/hastefuncapi.md +++ b/docs/api/hastefuncapi.md @@ -50,9 +50,294 @@ All functions are defined in `function_app.py` as a single Azure Functions app. | PUT | `PutRunModelQueueMessage` | Queue a model training run. | | PUT | `PutCancelModelQueueMessage` | Cancel a queued or running training **or inference** job for a model. | | PUT | `PutRunInferenceQueueMessage` | Queue an inference run. | +| PUT | `PutRunEmbeddingQueueMessage` | Queue a building-embedding job for the building labeling workflow. Creates a `modelType="embedding"` model; needs no labels, only the layer's imagery and cached footprints. | | DELETE | `DeleteModel` | Delete a model. Requires `projectId` and `modelId`. | | GET | `GetVisualizerResults` | Visualizer data with imagery layers and TiTiler tile URLs with colormaps. Requires `projectId`, `imageLayerId`, and `modelId`. | | PUT | `PutArtifactsZipQueueMessage` | Queue a job to zip model artifacts for download. | +| GET | `GetModelArtifact` | Stream a model artifact (or an image layer's footprint tiles) through the function app instead of a direct blob SAS URL, honoring HTTP `Range`. See [Model artifacts](#model-artifacts). | + +### Building Labeling (Interactive Labeler) + +The building labeling workflow trains a small model **in the browser** from +building embeddings, so these endpoints move whole-layer data rather than +per-request samples. + +| Method | Route | Description | +|--------|-------|-------------| +| GET | `GetBuildingEmbeddingsGeoJSON` | Full building-embeddings GeoJSON (footprints plus `f_*` feature columns, one row per footprint in row-index order). Unlike `GetBuildingFootprintsGeoJSON` this does **not** sample. Requires `projectId` and `modelId`. | +| GET | `GetInteractiveLabels` | The model-scoped labels of the interactive labeler. Separate store from the layer-scoped Building Validation labels. Requires `projectId` and `modelId`. Returns `{"labels": {...}}`. | +| PUT | `PutInteractiveLabels` | Save (replace) the interactive labeler's labels. Body: `{ projectId, imageLayerId, modelId, labels }`. | +| PUT | `PutBuildingPredictions` | Persist the in-browser model's per-building predictions as a GeoPackage and point the embedding model's `gpkgUrl` at it. See below. | + +#### `PUT PutBuildingPredictions` + +Joins the browser's `damaged` (0/1) calls onto the layer's cached building +footprints **by row index** and writes a predictions GeoPackage with the schema +the reports expect (`id`, `damaged`, `damage_pct_0m`, `unknown_pct`, `area`). + +**Request:** + +```json +{ + "projectId": "string — required", + "imageLayerId": "string — required", + "modelId": "string — required", + "predictions": [ { "id": 0, "damaged": 1, "unknown": 0.0 } ] +} +``` + +**Response (200):** `{ "gpkgUrl": "https://...", "count": 2 }` + +The endpoint also persists `predictedBuildingCount` and `predictedAt` on the +model. Those two fields — not `gpkgUrl` — are the unambiguous "this model has +predictions" signal: the labeler's **Clear labels** action PUTs +`predictions: []`, which still writes a valid (all-zero) GeoPackage and still +sets `gpkgUrl`, so a cleared model is indistinguishable from a completed one by +`gpkgUrl` alone. + +### Prediction Editing + +Lets an analyst review a model's building-damage predictions, retune the +thresholds, hand-correct individual buildings, and save the result as a **new +versioned GeoPackage**. See `spec/features/prediction-editing/` and +[ADR-0005](../../spec/architecture/decisions/0005-versioned-derived-prediction-artifacts.md). + +> `Model.gpkgUrl` always points at the RAW model output and is never rewritten +> by these endpoints — it is the source every future edit derives from. Saves +> append to `Model.editedPredictions` instead. + +Typical call order: `GetPredictionEditSession` → +`PutPreparePredictionTilesQueueMessage` when it reports `tilesReady: false` or +`attrsReady: false` → poll `GetPredictionEditSession` until both are true → +fetch `footprint_pmtiles` and `prediction_attrs` through `GetModelArtifact` → +`PutEditedPredictions` on save. + +| Method | Route | Description | +|--------|-------|-------------| +| GET | `GetPredictionEditSession` | Everything the editor needs to open a session: prediction flavor, threshold support, building count, artifact readiness, preparation status, and existing versions. | +| PUT | `PutPreparePredictionTilesQueueMessage` | Queue the job that builds the layer's footprint PMTiles and the model's prediction attribute sidecar. | +| PUT | `PutEditedPredictions` | Apply thresholds plus analyst overrides to the raw predictions and store the result as the next numbered version. | +| GET | `GetEditedPredictionVersions` | List a model's saved edited-prediction versions, newest first. | + +#### `GET GetPredictionEditSession` + +**Query params:** `projectId` (GUID), `imageLayerId` (GUID), `modelId`. + +**Response (200):** + +```json +{ + "modelId": "5557", + "flavor": "inference", + "supportsThreshold": true, + "defaultThreshold": 0.0, + "buildingCount": 125430, + "tilesReady": true, + "attrsReady": true, + "predictionTilesStatus": "Processed", + "predictionTilesStatusMessage": "", + "versions": [ + { + "version": 1, + "gpkgUrl": "https://.../edited_predictions_5557_v1.gpkg", + "createdAt": "2026-08-21T05:10:48.123456+00:00", + "createdBy": "analyst@example.com", + "threshold": 0.5, + "unknownThreshold": 0.0, + "editedCount": 53, + "sourceGpkgUrl": "https://.../raw.gpkg" + } + ] +} +``` + +- `flavor` / `supportsThreshold` are read from the model's raw prediction + GeoPackage. Trained inference writes a continuous `damage_pct_0m`, so + thresholding is meaningful (`"inference"`, `supportsThreshold: true`). The + interactive labeler writes a degenerate 0.0/1.0 copy of `damaged` + (`"embedding"`, `supportsThreshold: false`) — the UI hides the slider. +- `defaultThreshold` is `0.0` for both flavors. Comparisons downstream are + strictly greater-than, so `0.0` reproduces exactly what each producer already + stored (the inference GeoPackage derives `damaged` from `damage_pct_0m > 0`). +- `tilesReady` / `attrsReady` report whether the layer's footprint PMTiles and + the model's prediction attribute sidecar exist yet. Building them needs + `tippecanoe`, which ships only in the training image, so they are produced by + a queued job — never inline in this handler. **This route is read-only:** when + either flag is false the UI requests the work with + [`PutPreparePredictionTilesQueueMessage`](#put-putpreparepredictiontilesqueuemessage) + and then polls here. +- `predictionTilesStatus` is `Model.predictionTilesStatus` — `Queued`, + `InProgress`, `Processed`, `Failed`, or `null` when preparation has never been + requested. It lets the UI tell "still building" from "failed" instead of + polling forever; `predictionTilesStatusMessage` carries the job's appended + progress/failure lines (empty string when there are none). +- `versions` is `Model.editedPredictions` sorted by version descending. + +| Code | Condition | +|------|-----------| +| 400 | Missing or malformed `projectId`, `imageLayerId`, or `modelId` | +| 404 | Model or image layer not found, or the model has no raw prediction GeoPackage | +| 500 | Metadata or storage failure | + +#### `PUT PutPreparePredictionTilesQueueMessage` + +Queues the preparation job behind the editor's read path: the layer's +geometry-only footprint PMTiles (`ImageLayer.footprintPmtilesUrl`, shared by +every model on the layer) and the model's columnar prediction attribute sidecar +(`Model.predictionAttrsUrl`). Both are produced by +`hastegeo.workflows.prepare_prediction_tiles`, which shells out to +`tippecanoe` — present only in the training image — so this route never does +the work inline, it only asks for it. The +`prediction-edit-prep-queue` trigger in `hastefuncqueues` runs the job through +the training pool and writes the two URLs back. + +**Request:** + +```json +{ + "projectId": "string — required, GUID", + "imageLayerId": "string — required, GUID", + "modelId": "string — required", + "force": false +} +``` + +- `force` (default `false`) rebuilds both artifacts even when they already + exist, and re-queues even when a job is already in flight. Use it after + predictions are regenerated, which leaves stale artifacts behind. + +**Response (200):** + +```json +{ + "modelId": "5557", + "queued": true, + "tilesReady": false, + "attrsReady": false, + "status": "Queued", + "statusMessage": "\n2026-08-21T05:10:48.123456+00:00: Queued for prediction tile preparation" +} +``` + +- `queued` says whether a message was actually put on the queue. It is `false` + — with `status: "Processed"` — when both artifacts already exist, so opening + the editor on a prepared model costs no Batch task. It is also `false` when a + job for this model is already `Queued`/`InProgress`, so a double-click or a + retry cannot submit a duplicate job. +- `tilesReady` / `attrsReady` describe the state **at request time**; they flip + to `true` once the queued job finishes. Poll + [`GetPredictionEditSession`](#get-getpredictioneditsession) (or this route) + until then. +- `status` / `statusMessage` are `Model.predictionTilesStatus` and + `Model.predictionTilesStatusMessage`, persisted on the model so every poller + sees the same state. + +| Code | Condition | +|------|-----------| +| 400 | Invalid JSON, non-GUID `projectId`/`imageLayerId`, non-numeric `modelId`, or non-boolean `force` | +| 404 | Model or image layer not found; model has no raw prediction GeoPackage (`gpkgUrl`); layer has no cached building footprints — in either case there is nothing to tile | +| 500 | Metadata or queue failure | + +Requesting preparation is idempotent: a repeat call while the job runs returns +the current state without enqueueing, and a repeat call on a prepared model +leaves the model document unchanged. + +#### `PUT PutEditedPredictions` + +**Request:** + +```json +{ + "projectId": "string — required, GUID", + "imageLayerId": "string — required, GUID", + "modelId": "string — required", + "threshold": 0.5, + "unknownThreshold": 0.0, + "overrides": [ { "id": 12, "class": "Damaged" } ] +} +``` + +- `threshold` / `unknownThreshold` are **fractions in `[0.0, 1.0]`**, not + percentages, and both default to `0.0`. +- `overrides[].id` is the zero-based **row index** of the building in the + prediction GeoPackage (the positional join key used throughout the pipeline). + It must be a non-negative integer and may appear at most once. +- `overrides[].class` is one of `Damaged`, `NotDamaged`, `Unknown`. +- Derivation precedence per building: an explicit override wins, else + `unknown > unknownThreshold` → `Unknown`, else `damage > threshold` → + `Damaged`, else `NotDamaged`. + +**Response (200):** + +```json +{ + "version": 2, + "gpkgUrl": "https://.../edited_predictions_5557_v2.gpkg", + "editedCount": 53 +} +``` + +The stored GeoPackage keeps every source column and row **in source order** +(the downstream join is positional) and adds `edited_class`, `edit_threshold`, +and `overture_id`; `damaged` is rewritten to agree with the final class. The +new `EditedPredictionVersion` is appended to `Model.editedPredictions` with +`createdAt` (UTC ISO-8601) and `createdBy` (from the Static Web Apps client +principal when present). + +| Code | Condition | +|------|-----------| +| 400 | Invalid JSON, non-GUID `projectId`/`imageLayerId`, threshold outside `[0,1]`, unknown class, negative or duplicate override id | +| 404 | Model or image layer not found, no raw prediction GeoPackage, or no cached building footprints for the layer | +| 422 | Predictions and footprints do not line up row for row (the positional join would be corrupt) | +| 500 | Blob, metadata, or geospatial write failure | + +Override ids outside `[0, buildingCount)` are ignored and logged rather than +rejected, so a stale editor session cannot fail an otherwise valid save. + +#### `GET GetEditedPredictionVersions` + +**Query params:** `projectId` (GUID), `modelId`. + +**Response (200):** `{ "versions": [ ...same shape as above... ] }`, newest +version first; `[]` when the model has never been edited. Returns 400 on +malformed parameters and 404 when the model does not exist. + +### Model artifacts + +#### `GET GetModelArtifact` + +Streams a large browser artifact through the function app using managed +identity. A direct `*.blob.core.windows.net` SAS URL only works from IPs on the +storage firewall allowlist, so the browser fetches same-origin `/api` and the +function app does the blob I/O server-side. `Range` is honored (`206` partial +content, `416` when unsatisfiable) so `pmtiles.js` can do partial reads. + +**Query params:** `projectId` (GUID), `modelId`, `kind`, plus optional +`imageLayerId` (GUID) for layer-scoped kinds. + +| `kind` | Source field | Content type | Notes | +|--------|--------------|--------------|-------| +| `pmtiles` | `Model.pmtilesUrl` | `application/octet-stream` | Interactive labeler footprint tiles. | +| `sidecar` | `Model.featuresSidecarUrl` | `application/octet-stream` | Per-building embedding vectors. | +| `geojson` | `Model.embeddingsGeoJSONUrl` | `application/geo+json` | | +| `gpkg` | `Model.gpkgUrl` | `application/geopackage+sqlite3` | Served as a download attachment. | +| `prediction_attrs` | `Model.predictionAttrsUrl` | `application/json` | Columnar prediction attribute sidecar for the prediction editor. | +| `footprint_pmtiles` | `ImageLayer.footprintPmtilesUrl` | `application/vnd.pmtiles` | Geometry-only footprint tiles, shared by every model on that layer. Pass `imageLayerId` to select the layer explicitly; it defaults to the model's own image layer. | + +The sidecar payload is columnar and index-aligned with the prediction +GeoPackage rows: + +```json +{ "n": 3, "ids": [0, 1, 2], "overtureIds": ["08b...", "08c...", "08d..."], + "damage": [0.0, 0.42, 0.8], "unknown": [0.0, 0.2, 0.0], "damaged": [0, 1, 1] } +``` + +| Code | Condition | +|------|-----------| +| 400 | Missing/malformed `projectId`, `modelId`, `kind`, or an `imageLayerId` that is neither supplied nor resolvable from the model | +| 404 | Model or image layer not found, or the artifact is not available yet | +| 416 | Requested range starts past the end of the artifact | +| 502 | Blob read failure | ### Model Catalog @@ -74,6 +359,24 @@ These endpoints use `FUNCTION`-level auth regardless of development mode (intend | GET | `GetValidationReport` | Validation accuracy report: confusion matrix, accuracy, precision, recall, F1. Crosses inference results with user-supplied labels. | | GET | `GetAssessmentReport` | Full damage assessment: precision/recall/AP against labels, plus a finite-population estimate with 95% CI for damaged building count. Supports `threshold` and `minAreaM2` query params. | +### Data Publishing + +Publish HASTE artifacts to external catalogs. All publishing routes require an +authenticated caller; mutations additionally require the `contributors` or +`administrators` role, and every response uses the shared publishing error +envelope (`{"error": {"code": ..., "message": ...}}`). See +`spec/features/data-publishing/`. + +| Method | Route | Description | +|--------|-------|-------------| +| GET | `GetPublishingProviders` | Publishing providers registered and currently available. | +| GET | `GetPublishDatasetOptions` | Publishable artifacts and target options for a project. Requires `projectId`. | +| GET | `GetPublishedDatasets` | Published datasets, filterable by project. | +| GET | `GetPublishedDataset` | A single published dataset by id. | +| PUT | `PutPublishDatasetQueueMessage` | Queue a publish job for an artifact. | +| PUT | `PutRetryPublishedDatasetQueueMessage` | Re-queue a failed publish job. | +| DELETE | `DeletePublishedDataset` | Withdraw/delete a published dataset. | + ### Users & Admin | Method | Route | Description | diff --git a/hastelib/logs/embedding_friendly.log b/hastelib/logs/embedding_friendly.log new file mode 100644 index 00000000..5c84327f --- /dev/null +++ b/hastelib/logs/embedding_friendly.log @@ -0,0 +1,2 @@ +2026-08-21T06:30:44.609284+00:00|Wrote features sidecar -> tmp_gr1pt5q.bin (1 buildings × 3 dims, 0.0 MB) +2026-08-21T06:30:44.630216+00:00|Wrote features sidecar -> tmp5eexs6rz.bin (3 buildings × 2 dims, 0.0 MB) diff --git a/hastelib/logs/prediction_tiles_friendly.log b/hastelib/logs/prediction_tiles_friendly.log new file mode 100644 index 00000000..65bca58c --- /dev/null +++ b/hastelib/logs/prediction_tiles_friendly.log @@ -0,0 +1,13 @@ +2026-08-21T06:30:41.952755+00:00|Reusing existing footprint vector tiles +2026-08-21T06:30:41.952924+00:00|Building prediction attributes +2026-08-21T06:30:41.981414+00:00|Wrote prediction attributes for 3 buildings -> prediction_attrs_model-1.json +2026-08-21T06:30:41.989955+00:00|Finalizing outputs +2026-08-21T06:30:42.051844+00:00|Building footprint vector tiles +2026-08-21T06:30:42.085935+00:00|Running tippecanoe -> footprints_layer-1.pmtiles +2026-08-21T06:30:42.086400+00:00|Built footprint tiles for 3 buildings +2026-08-21T06:30:42.086561+00:00|Building prediction attributes +2026-08-21T06:30:42.119555+00:00|Wrote prediction attributes for 3 buildings -> prediction_attrs_model-1.json +2026-08-21T06:30:42.128656+00:00|Finalizing outputs +2026-08-21T06:30:42.224809+00:00|Wrote prediction attributes for 2 buildings -> attrs.json +2026-08-21T06:30:42.780228+00:00|Running tippecanoe -> tiles.pmtiles +2026-08-21T06:30:42.850567+00:00|Running tippecanoe -> tiles.pmtiles diff --git a/hastelib/pyproject.toml b/hastelib/pyproject.toml index cedc8e1a..03e462c9 100644 --- a/hastelib/pyproject.toml +++ b/hastelib/pyproject.toml @@ -82,6 +82,7 @@ Source = "https://github.com/microsoft/haste" [project.scripts] prepare-imagery = "hastegeo.workflows.prepare_imagery:main" +prepare-prediction-tiles = "hastegeo.workflows.prepare_prediction_tiles:main" zip-artifacts = "hastegeo.workflows.zip_artifacts:main" [tool.hatch.version] diff --git a/hastelib/src/hastegeo/core/config.py b/hastelib/src/hastegeo/core/config.py index fc4472f0..8890d6ff 100644 --- a/hastelib/src/hastegeo/core/config.py +++ b/hastelib/src/hastegeo/core/config.py @@ -93,6 +93,8 @@ class ArtifactTypes(Enum): - ${projectId}: Unique project identifier - ${imageLayerId}: Unique image layer identifier - ${modelName}: Model identifier/name + - ${modelId}: Unique model identifier + - ${version}: Monotonic version number of an edited artifact Artifact Categories: - PRE_EVENT_*: Pre-disaster imagery and derivatives @@ -107,6 +109,13 @@ class ArtifactTypes(Enum): - INFERENCE_*: Model inference outputs - MODEL_*: Model artifacts and checkpoints - VISUALIZER: Visualization-ready outputs + - EDITED_PREDICTIONS_GPKG: Analyst-edited copy of a model's raw + prediction GeoPackage. Immutable per version, so the raw + prediction referenced by ``Model.gpkgUrl`` is never overwritten. + - PREDICTION_ATTRS: Compact per-building prediction attribute + payload served alongside the footprint vector tiles. + - LAYER_FOOTPRINT_PMTILES: PMTiles archive of an image layer's + building footprints (geometry + id only). """ PRE_EVENT_RAW = Template( @@ -153,6 +162,14 @@ class ArtifactTypes(Enum): BUILDING_PMTILES = Template("building_pmtiles_${modelName}") BUILDING_FEATURES_SIDECAR = Template("building_features_${modelName}") BUILDING_PREDICTIONS_GPKG = Template("building_predictions_${modelName}") + # Prediction editing workflow: each save writes a NEW versioned + # GeoPackage derived from the raw model prediction GeoPackage, plus the + # attribute payload and footprint tiles the editor renders against. + EDITED_PREDICTIONS_GPKG = Template( + "edited_predictions_${modelId}_v${version}" + ) + PREDICTION_ATTRS = Template("prediction_attrs_${modelId}") + LAYER_FOOTPRINT_PMTILES = Template("footprints_${imageLayerId}") class InviteConfig(NamedTuple): @@ -321,18 +338,21 @@ def get_queue_config(): "publish_queue_name": os.getenv( "PUBLISH_QUEUE_NAME", "publish-queue" ), + # Prediction editing: footprint PMTiles + per-model attribute + # sidecar generation. Runs in the training container because + # tippecanoe only ships in that image. + "prediction_edit_prep_queue_name": os.getenv( + "PREDICTION_EDIT_PREP_QUEUE_NAME", + "prediction-edit-prep-queue", + ), } @staticmethod def get_publishing_config(): """Get publishing feature and provider configuration.""" return { - "publishing_enabled": _get_bool_env( - "PUBLISHING_ENABLED", True - ), - "pc_provider_enabled": _get_bool_env( - "PC_PROVIDER_ENABLED", False - ), + "publishing_enabled": _get_bool_env("PUBLISHING_ENABLED", True), + "pc_provider_enabled": _get_bool_env("PC_PROVIDER_ENABLED", False), "max_total_bytes": _get_bounded_int_env( "PUBLISH_MAX_TOTAL_BYTES", 5 * 1024**3, 1 ), @@ -405,6 +425,7 @@ class DataTypes(Enum): EXPERIMENT_CONFIG = "experiment_config" IMAGERY_CONFIG = "imageryprep_config" EMBEDDING_CONFIG = "embedding_config" + PREDICTION_TILES_CONFIG = "prediction_tiles_config" PROCESSED_IMAGERY = "processed_imagery_post_event_cog" RAW_IMAGERY = "raw_imagery" PREVIEW_RAW_IMAGERY = "preview_raw_imagery" diff --git a/hastelib/src/hastegeo/core/models/predictions.py b/hastelib/src/hastegeo/core/models/predictions.py new file mode 100644 index 00000000..f498af60 --- /dev/null +++ b/hastelib/src/hastegeo/core/models/predictions.py @@ -0,0 +1,111 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""HTTP wire contracts for the prediction editor. + +These models describe the *request bodies* the ``hastefuncapi`` routes +accept — they exist so a malformed body is rejected at the HTTP boundary +with a 400 instead of reaching the geospatial code. They deliberately +carry no behavior beyond validation: every decision they feed (class +derivation, thresholding, versioning, blob writes, queueing) lives in +``hastegeo.core.processors.prediction_edits`` and +``hastegeo.core.processors.prediction_tiles``. + +They live here rather than in ``function_app.py`` because +``api/hastefuncapi/function_app.py`` must contain only thin HTTP +wrappers (see ``AGENTS.md``), and next to each other rather than in +``projects.py`` because that module holds *persisted document schemas* +(``Project``/``ImageLayer``/``Model``/``EditedPredictionVersion``) while +these are transport-only shapes — the same split ``publishing.py`` +already makes with ``PublishRequest`` versus ``PublishedDataset``. +""" +from typing import List + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from ..utils.assessment import DAMAGED, NOT_DAMAGED, UNKNOWN + +# Strict allowlist patterns, kept identical to the ones the API layer +# applies to query-string parameters (``_GUID_RE`` / +# ``_SHORT_INT_ID_RE`` in ``function_app.py``): bounded length and +# character set to defend against injection, path traversal, and +# oversized inputs. modelId is a MetadataUtils.generate_short_int_id() +# value (currently 4 zero-padded digits, e.g. "5557"), not a GUID. +GUID_PATTERN = ( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}" + r"-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" +) +SHORT_INT_ID_PATTERN = r"^[0-9]{1,8}$" + +# The only classes an analyst may assign to a building. +PREDICTION_EDIT_CLASSES = (DAMAGED, NOT_DAMAGED, UNKNOWN) + +# Where the editor's damage slider starts. 0.0 reproduces what each +# producer already stored: the inference GeoPackage derives its own +# ``damaged`` column from ``damage_pct_0m > 0``, and the embedding +# producer only ever writes 0.0/1.0. Comparisons downstream are strictly +# greater-than, so 0.0 keeps pristine buildings out of the damaged +# bucket. +PREDICTION_EDIT_DEFAULT_THRESHOLD = 0.0 + + +class PredictionOverrideRequest(BaseModel): + """One analyst class override: ``{"id": , "class": ...}``. + + ``id`` is the zero-based row index of the building in the prediction + GeoPackage — the join key the whole prediction pipeline uses. + """ + + model_config = ConfigDict(populate_by_name=True) + + rowIndex: int = Field(alias="id", ge=0) + editedClass: str = Field(alias="class") + + @field_validator("editedClass") + @classmethod + def _known_class(cls, value: str) -> str: + if value not in PREDICTION_EDIT_CLASSES: + raise ValueError( + "must be one of " + ", ".join(PREDICTION_EDIT_CLASSES) + ) + return value + + +class EditedPredictionsRequest(BaseModel): + """Validated body of a ``PutEditedPredictions`` request.""" + + projectId: str = Field(pattern=GUID_PATTERN) + imageLayerId: str = Field(pattern=GUID_PATTERN) + modelId: str = Field(pattern=SHORT_INT_ID_PATTERN) + threshold: float = Field( + default=PREDICTION_EDIT_DEFAULT_THRESHOLD, ge=0.0, le=1.0 + ) + unknownThreshold: float = Field(default=0.0, ge=0.0, le=1.0) + overrides: List[PredictionOverrideRequest] = Field(default_factory=list) + + @field_validator("overrides") + @classmethod + def _reject_duplicate_ids( + cls, value: List[PredictionOverrideRequest] + ) -> List[PredictionOverrideRequest]: + """Two classes for one building is a client bug, not a merge.""" + seen: set[int] = set() + for override in value: + if override.rowIndex in seen: + raise ValueError(f"duplicate id {override.rowIndex}") + seen.add(override.rowIndex) + return value + + +class PreparePredictionTilesRequest(BaseModel): + """Validated body of a ``PutPreparePredictionTilesQueueMessage``. + + Asks for the footprint PMTiles of ``imageLayerId`` and the prediction + attribute sidecar of ``modelId`` to be built. ``force`` rebuilds them + even when both already exist — used after predictions are + regenerated, which leaves stale artifacts behind. + """ + + projectId: str = Field(pattern=GUID_PATTERN) + imageLayerId: str = Field(pattern=GUID_PATTERN) + modelId: str = Field(pattern=SHORT_INT_ID_PATTERN) + force: bool = Field(default=False) diff --git a/hastelib/src/hastegeo/core/models/projects.py b/hastelib/src/hastegeo/core/models/projects.py index afdb0ee7..f21c9b90 100644 --- a/hastelib/src/hastegeo/core/models/projects.py +++ b/hastelib/src/hastegeo/core/models/projects.py @@ -342,6 +342,51 @@ class ModelRequest(BaseModel): status: Optional[str] = Field(default=None) +class EditedPredictionVersion(BaseModel): + """ + Represents one saved revision of analyst-edited model predictions. + + Every save of the prediction editor writes a NEW GeoPackage derived + from the model's raw prediction GeoPackage; the raw file referenced by + ``Model.gpkgUrl`` is never modified. Versions are append-only so an + analyst can always trace a published layer back to the exact edit that + produced it. + + Args: + version: Monotonically increasing revision number, starting at 1 + gpkgUrl: URL to the edited GeoPackage for this version + createdAt: ISO formatted timestamp when the version was saved + createdBy: Identifier of the user who saved the version + threshold: Damage fraction (0.0-1.0) above which a building was + classified as damaged when the version was derived + unknownThreshold: Unknown/cloud fraction (0.0-1.0) above which a + building was classified as unknown + editedCount: Number of buildings whose class was explicitly + overridden by the analyst in this version + sourceGpkgUrl: URL to the GeoPackage this version was derived from + + Example: + ```python + version = EditedPredictionVersion( + version=1, + gpkgUrl="https://.../edited_predictions_model_123_v1.gpkg", + createdAt="2026-08-21T05:10:48Z", + threshold=0.1, + editedCount=42, + ) + ``` + """ + + version: int + gpkgUrl: str + createdAt: str + createdBy: Optional[str] = None + threshold: Optional[float] = None + unknownThreshold: Optional[float] = None + editedCount: int = 0 + sourceGpkgUrl: Optional[str] = None + + class Model(BaseModel): """ Represents a machine learning model configuration and state in the HASTE system. @@ -386,7 +431,13 @@ class Model(BaseModel): currentInferenceTaskId: Current inference task identifier inferenceStatusMessage: Detailed inference status message predictedDamageLayerUrl: URL to predicted damage layer output - gpkgUrl: URL to GeoPackage output file with predictions + gpkgUrl: URL to GeoPackage output file with predictions. This is + the RAW model output and is never overwritten by editing. + editedPredictions: Append-only list of analyst-edited revisions + derived from ``gpkgUrl``, newest version last + predictedBuildingCount: Number of buildings carried by the raw + prediction GeoPackage + predictedAt: ISO formatted timestamp when predictions were written labelsUrl: URL to labels file used for training dependsOn: Dependency tuple specifying parent resource type and ID @@ -440,6 +491,13 @@ class Model(BaseModel): inferenceStatusMessage: Optional[str] = Field(default="") predictedDamageLayerUrl: Optional[str] = Field(default=None) gpkgUrl: Optional[str] = Field(default=None) + # Analyst-edited revisions of gpkgUrl. Appended to by the prediction + # editor; gpkgUrl itself always stays the raw model output. + editedPredictions: Optional[List[EditedPredictionVersion]] = Field( + default_factory=list + ) + predictedBuildingCount: Optional[int] = Field(default=None) + predictedAt: Optional[str] = Field(default=None) labelsUrl: Optional[str] = Field(default=None) # ── Building labeling workflow (embedding sub-row) ────────────────── # A Model with modelType="embedding" represents a building-embedding @@ -461,6 +519,16 @@ class Model(BaseModel): # once at session start and looks vectors up by id; the PMTiles # archive itself only carries id + overture_id (no f_* columns). featuresSidecarUrl: Optional[str] = Field(default=None) + # ── Prediction editing (footprint PMTiles + attribute sidecar) ────── + # The per-model columnar JSON sidecar (id -> damage/unknown/damaged) + # that the prediction editor fetches once per session. The matching + # geometry-only vector tiles live on the ImageLayer + # (``footprintPmtilesUrl``) because they are shared by every model + # trained on that layer. + predictionAttrsUrl: Optional[str] = Field(default=None) + predictionTilesJob: Optional[TrainingJob] = Field(default=None) + predictionTilesStatus: Optional[str] = Field(default=None) + predictionTilesStatusMessage: Optional[str] = Field(default="") dependsOn: Optional[tuple[str, str]] = Field( default=("ImageLayer", "imageLayerId") ) @@ -695,6 +763,10 @@ class ImageLayer(BaseModel): post-event mosaic, i.e. the imagery's actual AOI excluding nodata. Populated by the imageryprep workflow; surfaced as a downloadable artifact in the UI. + footprintPmtilesUrl: URL to the PMTiles archive of this layer's + building footprints (geometry + Overture id only). Rendered by + the prediction editor, which joins prediction attributes onto + the tiles client-side instead of shipping a GeoJSON payload. dependsOn: Dependency tuple specifying parent resource type and ID Example: @@ -772,6 +844,7 @@ class ImageLayer(BaseModel): # Catalog "clip to area" flow. clipBbox: Optional[list[float]] = Field(default=None) validAreaMaskUrl: Optional[str] = Field(default=None) + footprintPmtilesUrl: Optional[str] = Field(default=None) dependsOn: Optional[tuple[str, str]] = Field( default=("Project", "projectId") ) diff --git a/hastelib/src/hastegeo/core/processors/prediction_edits.py b/hastelib/src/hastegeo/core/processors/prediction_edits.py new file mode 100644 index 00000000..9596d806 --- /dev/null +++ b/hastelib/src/hastegeo/core/processors/prediction_edits.py @@ -0,0 +1,422 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Apply analyst edits to model predictions and version the result. + +An analyst reviews a model's building-damage predictions, retunes the +damage/unknown thresholds and hand-corrects individual buildings. Saving +that review must never mutate the raw model output: :func:`apply_edits` +derives a NEW GeoPackage from the raw one and +:func:`store_edited_version` stores it under its own version, leaving +``Model.gpkgUrl`` pointing at the raw predictions forever. + +The derived file is a strict superset of the source — every original +column is kept, ``damaged`` is rewritten to agree with the analyst's +call, and ``edited_class`` / ``edit_threshold`` / ``overture_id`` are +appended. **Row order is preserved exactly**, because the prediction → +footprint join downstream is positional (see +``hastegeo.core.utils.assessment.build_assessment_inputs_from_gpkgs``). +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union + +import fiona +from fiona.model import Feature + +from ..config import ArtifactTypes, Config +from ..models.projects import Model +from ..utils.assessment import DAMAGED, NOT_DAMAGED, UNKNOWN +from ..utils.gdal_security import harden_gdal +from ..utils.logs import Logger +from ..utils.predictions import DAMAGED_FIELD, PredictionRow, read_predictions + +if TYPE_CHECKING: + from .artifacts import ArtifactProcessor + +# Harden GDAL/OGR drivers before any fiona read/write of a user-supplied +# vector file (GDAL CVE compensating control — +# docs/known-vulnerabilities.md Root Cause C). +harden_gdal() + +logger = Logger.get_logger(__name__) + +EDITED_CLASS_FIELD = "edited_class" +EDIT_THRESHOLD_FIELD = "edit_threshold" +OVERTURE_ID_FIELD = "overture_id" + +VALID_EDIT_CLASSES = (DAMAGED, NOT_DAMAGED, UNKNOWN) + +GPKG_DRIVER = "GPKG" + + +@dataclass +class EditSummary: + """Outcome of one :func:`apply_edits` run. + + Attributes: + total_rows: Number of rows written, always equal to the number of + rows in the source GeoPackage. + counts: Row count per final class, keyed by ``"Damaged"``, + ``"NotDamaged"`` and ``"Unknown"``. + overrides_applied: Number of analyst overrides that matched a row + and were applied. + """ + + total_rows: int = 0 + counts: Dict[str, int] = field( + default_factory=lambda: {DAMAGED: 0, NOT_DAMAGED: 0, UNKNOWN: 0} + ) + overrides_applied: int = 0 + + @property + def damaged(self) -> int: + return self.counts.get(DAMAGED, 0) + + @property + def not_damaged(self) -> int: + return self.counts.get(NOT_DAMAGED, 0) + + @property + def unknown(self) -> int: + return self.counts.get(UNKNOWN, 0) + + def to_dict(self) -> Dict[str, Any]: + """Render as a JSON-serialisable dict for API responses.""" + return { + "totalRows": self.total_rows, + "counts": dict(self.counts), + "overridesApplied": self.overrides_applied, + } + + +def _validate_threshold(name: str, value: float) -> float: + """Validate a damage/unknown threshold as a fraction in [0, 1]. + + ``damage_pct_0m`` and ``unknown_pct`` are fractions despite their + names, so a caller passing a 0-100 percentage would silently + misclassify every building. Fail loudly instead. + """ + try: + threshold = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be a number, got {value!r}") from exc + if not 0.0 <= threshold <= 1.0: + raise ValueError( + f"{name} must be a fraction between 0.0 and 1.0 " + f"(damage values are fractions, not percentages), " + f"got {threshold}" + ) + return threshold + + +def _normalize_overrides( + overrides: Optional[Dict[int, str]] +) -> Dict[int, str]: + """Validate analyst overrides and coerce their keys to row indices. + + Raises: + ValueError: on a non-integer row index or an unrecognised class. + """ + if not overrides: + return {} + + normalized: Dict[int, str] = {} + for raw_index, raw_class in overrides.items(): + try: + row_index = int(raw_index) + except (TypeError, ValueError) as exc: + raise ValueError( + f"Override row index must be an integer, got {raw_index!r}" + ) from exc + if raw_class not in VALID_EDIT_CLASSES: + raise ValueError( + f"Invalid override class {raw_class!r} for row {row_index}. " + f"Valid classes are: {', '.join(VALID_EDIT_CLASSES)}" + ) + normalized[row_index] = raw_class + return normalized + + +def derive_class( + row: PredictionRow, + *, + threshold: float, + unknown_threshold: float, + overrides: Dict[int, str], +) -> str: + """Return the final class for one row, honouring edit precedence. + + An explicit analyst override always wins; otherwise unknown coverage + takes precedence over damage, and both comparisons are strictly + greater-than so a threshold of 0.0 keeps pristine buildings out of + the damaged bucket. + """ + override = overrides.get(row.row_index) + if override is not None: + return override + if row.unknown_fraction > unknown_threshold: + return UNKNOWN + if row.damage_fraction > threshold: + return DAMAGED + return NOT_DAMAGED + + +def _output_schema(schema: Dict[str, Any]) -> Dict[str, Any]: + """Extend a source schema with the edit columns, preserving order.""" + properties = dict(schema["properties"]) + properties.setdefault(DAMAGED_FIELD, "int") + properties[EDITED_CLASS_FIELD] = "str" + properties[EDIT_THRESHOLD_FIELD] = "float" + properties[OVERTURE_ID_FIELD] = "str" + return {"geometry": schema["geometry"], "properties": properties} + + +def apply_edits( + src_gpkg: str, + dst_gpkg: str, + *, + threshold: float, + unknown_threshold: float = 0.0, + overrides: Dict[int, str], + footprints_path: Optional[str] = None, +) -> EditSummary: + """Write an edited copy of a prediction GeoPackage. + + Args: + src_gpkg: Path to the raw (or previously edited) prediction + GeoPackage. Never modified. + dst_gpkg: Path the edited GeoPackage is written to; replaced if + it already exists. + threshold: Damage fraction in [0, 1]; a building is damaged when + its damage fraction is strictly greater. + unknown_threshold: Unknown/cloud fraction in [0, 1]; a building + is unknown when its unknown fraction is strictly greater. + overrides: Analyst class overrides keyed by row index. Values + must be one of ``Damaged``, ``NotDamaged``, ``Unknown``. + footprints_path: Optional footprints GeoPackage used to resolve + Overture ids positionally. + + Returns: + An :class:`EditSummary` describing what was written. + + Raises: + ValueError: on an invalid threshold, an invalid override, or a + footprints/predictions row-count mismatch. + """ + threshold = _validate_threshold("threshold", threshold) + unknown_threshold = _validate_threshold( + "unknown_threshold", unknown_threshold + ) + normalized_overrides = _normalize_overrides(overrides) + + predictions = read_predictions(src_gpkg, footprints_path=footprints_path) + if not predictions.supports_threshold: + logger.warning( + "Prediction file %s is the '%s' flavor: its damage values are " + "binary, so the %s threshold only separates 0.0 from 1.0.", + src_gpkg, + predictions.flavor, + threshold, + ) + + rows: List[PredictionRow] = predictions.rows + summary = EditSummary() + + dst_dir = os.path.dirname(dst_gpkg) + if dst_dir: + os.makedirs(dst_dir, exist_ok=True) + if os.path.exists(dst_gpkg): + os.remove(dst_gpkg) + + try: + with fiona.open(src_gpkg, layer=predictions.layer_name) as src: + schema = _output_schema(src.schema) + with fiona.open( + dst_gpkg, + "w", + driver=GPKG_DRIVER, + crs=src.crs, + schema=schema, + layer=predictions.layer_name, + ) as dst: + for row_index, feature in enumerate(src): + if row_index >= len(rows): + raise ValueError( + f"Prediction GeoPackage {src_gpkg} yielded more " + "rows on the second pass than on the first; " + "refusing to write a misaligned output." + ) + row = rows[row_index] + final_class = derive_class( + row, + threshold=threshold, + unknown_threshold=unknown_threshold, + overrides=normalized_overrides, + ) + if row_index in normalized_overrides: + summary.overrides_applied += 1 + summary.counts[final_class] = ( + summary.counts.get(final_class, 0) + 1 + ) + + properties = dict(feature["properties"]) + properties[DAMAGED_FIELD] = ( + 1 if final_class == DAMAGED else 0 + ) + properties[EDITED_CLASS_FIELD] = final_class + properties[EDIT_THRESHOLD_FIELD] = threshold + properties[OVERTURE_ID_FIELD] = row.overture_id or "" + dst.write( + Feature( + geometry=feature.geometry, + properties=properties, + ) + ) + summary.total_rows += 1 + except Exception: + # Never leave a half-written GeoPackage behind: a truncated file + # would still look like a valid edited version to the caller. + if os.path.exists(dst_gpkg): + os.remove(dst_gpkg) + raise + + if summary.total_rows != len(rows): + raise ValueError( + f"Wrote {summary.total_rows} rows but read {len(rows)} from " + f"{src_gpkg}; refusing to return a misaligned output." + ) + + unmatched = len(normalized_overrides) - summary.overrides_applied + if unmatched: + logger.warning( + "%d override(s) referenced row indices outside %s (%d rows) " + "and were ignored.", + unmatched, + src_gpkg, + summary.total_rows, + ) + logger.info( + "Wrote %d edited predictions to %s (%s)", + summary.total_rows, + dst_gpkg, + summary.counts, + ) + return summary + + +def _version_of(entry: Union[Model, Dict[str, Any]]) -> Optional[int]: + """Extract the version number from a dict or Pydantic entry.""" + value = ( + entry.get("version") + if isinstance(entry, dict) + else getattr(entry, "version", None) + ) + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + logger.warning( + "Ignoring non-integer edited-prediction version %r", value + ) + return None + + +def next_version(model_doc: Union[Model, Dict[str, Any]]) -> int: + """Return the next edited-prediction version for a model. + + Args: + model_doc: A ``Model`` instance or its dict representation. + + Returns: + ``1`` when the model has no edited predictions yet, otherwise one + past the highest existing version (versions are append-only, so + deleting a revision must not let a new one reuse its number). + """ + if isinstance(model_doc, dict): + entries = model_doc.get("editedPredictions") or [] + else: + entries = getattr(model_doc, "editedPredictions", None) or [] + + versions = [ + version + for version in (_version_of(entry) for entry in entries) + if version is not None + ] + if not versions: + return 1 + return max(versions) + 1 + + +def edited_version_artifact_name(model_id: str, version: int) -> str: + """Return the artifact name for one edited-prediction version.""" + return ( + ArtifactTypes.EDITED_PREDICTIONS_GPKG.value.substitute( + modelId=model_id, + version=version, + ) + + ".gpkg" + ) + + +def store_edited_version( + project_id: str, + model_id: str, + version: int, + local_gpkg_path: str, + *, + processor: Optional["ArtifactProcessor"] = None, + config: Optional[Config] = None, +) -> str: + """Store an edited GeoPackage as a new version and return its URL. + + The artifact name embeds the version, so each save lands on its own + blob and never overwrites a previous revision — or the raw model + prediction referenced by ``Model.gpkgUrl``. + + Args: + project_id: Project the model belongs to; used as the storage + partition key. + model_id: Model whose predictions were edited. + version: Version number from :func:`next_version`. + local_gpkg_path: Path to the GeoPackage written by + :func:`apply_edits`. + processor: Optional pre-built ``ArtifactProcessor`` (used by + tests and by callers that already hold one). + config: Optional ``Config`` used when building the processor. + + Returns: + The download URL of the stored artifact. + + Raises: + FileNotFoundError: if ``local_gpkg_path`` does not exist. + """ + if not os.path.exists(local_gpkg_path): + raise FileNotFoundError( + f"Edited GeoPackage not found: {local_gpkg_path}" + ) + + if processor is None: + # Imported lazily so the pure-geospatial half of this module stays + # importable without the queue/runner stack ArtifactProcessor pulls + # in (Batch tasks import apply_edits alone). + from .artifacts import ArtifactProcessor as _ArtifactProcessor + + processor = _ArtifactProcessor(project_id, config=config) + + artifact_name = edited_version_artifact_name(model_id, version) + processor.store_artifact( + artifact_name=artifact_name, src_path=local_gpkg_path + ) + url = processor.get_download_url(identifier=artifact_name) + logger.info( + "Stored edited predictions v%s for model %s at %s", + version, + model_id, + artifact_name, + ) + return url diff --git a/hastelib/src/hastegeo/core/processors/prediction_tiles.py b/hastelib/src/hastegeo/core/processors/prediction_tiles.py new file mode 100644 index 00000000..bbb95ae0 --- /dev/null +++ b/hastelib/src/hastegeo/core/processors/prediction_tiles.py @@ -0,0 +1,757 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Prediction-tiles job: queue + execute the prediction editor's data prep. + +The prediction editor needs every predicted building footprint of an +image layer in the browser. That means two derived artifacts: + +* the layer's footprint PMTiles (built once per image layer, reused by + every model on it), and +* the model's columnar prediction attribute sidecar. + +Both are produced by ``hastegeo.workflows.prepare_prediction_tiles``, +which shells out to ``tippecanoe``. tippecanoe ships ONLY in the +training docker image (``docker/training/env/env.yml``), so this work +can never run inline in the Azure Functions app: the preprocessor drops +a message on the prediction-tiles queue and the postprocessor submits a +task to the training image through the unified runner, exactly like +``processors/embedding.py`` does for the embedding workflow. + +State lives in ``Model.predictionTilesStatus`` rather than +``Model.status`` so preparing tiles never disturbs the model's own +train/inference lifecycle (same separation the zip flow uses with +``ModelArtifacts.zipStatus``). + +Config JSON handed to the workflow:: + + { + "project_id": "...", + "image_layer_id": "...", + "model_id": "...", + "output_dir": "outputs", + "files": { + "footprints": "inputs/.gpkg", + "predictions": "inputs/.gpkg", + "pmtiles": "footprints_.pmtiles", + "attrs": "prediction_attrs_.json" + }, + "tiles": {"build_pmtiles": true}, + "store_artifacts": true + } + +Queue message (``prediction-edit-prep-queue``):: + + { + "projectId": "...", + "imageLayerId": "...", + "modelId": "...", + "sourceGpkgUrl": "...", + "sourceFootprintsUrl": "...", + "force": false + } + +The trigger treats the message as a *request* and reads the authoritative +state from metadata, so the postprocessor's own poll re-queues (which +carry the full model document) are handled by the same code path. + +:func:`request_preparation` is the HTTP-side entry point (used by +``PutPreparePredictionTilesQueueMessage``): it decides whether anything +still has to be built and enqueues at most one job per model. +""" + +from __future__ import annotations + +import json +import os +from typing import Any, Dict, List, Optional, Tuple + +from ..config import ArtifactTypes, Config +from ..data_layer.unified import UnifiedDataLayer +from ..models.projects import ImageLayer, Model, TrainingJob +from ..runners.unified_runner import UnifiedRunner +from ..utils.data import extract_from_url +from ..utils.logs import Logger +from ..utils.metadata import MetadataUtils +from ..utils.queues import AzureQueueHandler + +# Do not prefix with '$'. Replaced at runtime with the task working dir. +BATCH_JOB_WORKDIR = "AZ_BATCH_TASK_WORKING_DIR" +PREDICTION_TILES_PREFIX = "ptl" +MANIFEST_FILENAME = "prediction_tiles_manifest.json" +FRIENDLY_LOG_FILENAME = "prediction_tiles_friendly.log" + + +def pmtiles_artifact_name(image_layer_id: str) -> str: + """Artifact name for a layer's footprint PMTiles archive.""" + return ( + ArtifactTypes.LAYER_FOOTPRINT_PMTILES.value.substitute( + imageLayerId=image_layer_id + ) + + ".pmtiles" + ) + + +def attrs_artifact_name(model_id: str) -> str: + """Artifact name for a model's prediction attribute sidecar.""" + return ( + ArtifactTypes.PREDICTION_ATTRS.value.substitute(modelId=model_id) + + ".json" + ) + + +def build_prep_message( + project_id: str, + image_layer_id: str, + model_id: str, + source_gpkg_url: Optional[str] = None, + source_footprints_url: Optional[str] = None, + force: bool = False, +) -> Dict[str, Any]: + """Build a ``prediction-edit-prep-queue`` message payload. + + The message only carries identifiers; the queue trigger reads the + authoritative state from metadata so that re-queued poll messages + and fresh requests take the same code path. + """ + return { + "projectId": project_id, + "imageLayerId": image_layer_id, + "modelId": model_id, + "sourceGpkgUrl": source_gpkg_url or "", + "sourceFootprintsUrl": source_footprints_url or "", + "force": bool(force), + } + + +def enqueue_prediction_tiles( + project_id: str, + image_layer_id: str, + model_id: str, + source_gpkg_url: Optional[str] = None, + source_footprints_url: Optional[str] = None, + force: bool = False, + config: Optional[Config] = None, +) -> Dict[str, Any]: + """Put a preparation request on the prediction-edit prep queue. + + Convenience seam for the HTTP layer, which must never run + ``tippecanoe`` inline. Returns the enqueued message. + """ + if config is None: + config = Config() + message = build_prep_message( + project_id=project_id, + image_layer_id=image_layer_id, + model_id=model_id, + source_gpkg_url=source_gpkg_url, + source_footprints_url=source_footprints_url, + force=force, + ) + queue_client = AzureQueueHandler( + config.queue_config["queue_connection_string"], + config.queue_config["prediction_edit_prep_queue_name"], + config.queue_config["queue_account_url"], + ) + queue_client.put_message(json.dumps(message), visibility_timeout=0) + return message + + +def needs_preparation( + model: Model, image_layer: ImageLayer +) -> Tuple[bool, bool]: + """Decide what still has to be built for this model/layer pair. + + Returns: + ``(needs_pmtiles, needs_attrs)``. Footprint tiles are shared by + every model on a layer, so they are only built when the layer + has no ``footprintPmtilesUrl`` yet. + """ + needs_pmtiles = not bool(image_layer.footprintPmtilesUrl) + needs_attrs = not bool(model.predictionAttrsUrl) + return needs_pmtiles, needs_attrs + + +class PredictionTilesUnavailableError(ValueError): + """Nothing can be prepared for this model/layer pair as it stands. + + Raised when the raw inputs the job would tile do not exist yet (the + model has no prediction GeoPackage, or the layer has no cached + building footprints). The HTTP layer maps this to a 404: it is a + missing prerequisite, not a malformed request. + """ + + +def request_preparation( + model: Model, + image_layer: ImageLayer, + force: bool = False, + config: Optional[Config] = None, +) -> Dict[str, Any]: + """Decide whether to enqueue preparation, and do it if so. + + The API seam behind ``PutPreparePredictionTilesQueueMessage``. All of + the decision-making lives here so the HTTP handler stays a thin + wrapper: it loads the two documents, calls this, persists ``model`` + and serializes the returned dict. + + This is the *request* half of :class:`PredictionTilesPreprocessor` + (which is driven from the queue side and returns only the model); + the HTTP caller additionally needs to know whether a job was + actually queued and what the UI should poll for. + + Args: + model: The model whose attribute sidecar is needed. Mutated in + place — the caller persists it. + image_layer: The model's image layer, which owns the shared + footprint PMTiles. + force: Rebuild even when both artifacts already exist, or when a + job is already in flight. Used after predictions are + regenerated, which leaves stale artifacts behind. + config: Optional config override (tests inject a fake). + + Returns: + ``{"modelId", "queued", "tilesReady", "attrsReady", "status", + "statusMessage"}``. ``tilesReady``/``attrsReady`` describe the + state *now*, so a caller that polls sees them flip to ``True`` + once the queued job finishes. + + Raises: + PredictionTilesUnavailableError: when the model has no raw + prediction GeoPackage or the layer has no cached building + footprints, i.e. there is nothing to tile. + """ + if config is None: + config = Config() + statuses = config.get_status_types() + logger = Logger.get_logger(__name__) + + if not model.gpkgUrl: + raise PredictionTilesUnavailableError( + f"Model {model.modelId} has no prediction GeoPackage " + "(gpkgUrl); run inference before preparing prediction tiles." + ) + if not image_layer.buildingFootprintsUrl: + raise PredictionTilesUnavailableError( + f"Image layer {image_layer.imageLayerId} has no cached " + "building footprints; prediction tiles cannot be built " + "without them." + ) + + needs_pmtiles, needs_attrs = needs_preparation(model, image_layer) + in_flight = model.predictionTilesStatus in ( + statuses.PENDING.value, + statuses.IN_PROGRESS.value, + ) + + def _state(queued: bool) -> Dict[str, Any]: + return { + "modelId": model.modelId, + "queued": queued, + "tilesReady": not needs_pmtiles, + "attrsReady": not needs_attrs, + "status": model.predictionTilesStatus, + "statusMessage": model.predictionTilesStatusMessage or "", + } + + if not force and not needs_pmtiles and not needs_attrs: + # Both artifacts exist: record that and skip the queue rather + # than pay for a redundant Batch task. Only the transition is + # recorded — an editor that re-opens a prepared model must not + # grow the status message a line at a time. + if model.predictionTilesStatus != statuses.COMPLETED.value: + model.predictionTilesStatus = statuses.COMPLETED.value + model.predictionTilesStatusMessage = ( + MetadataUtils.append_status_message( + model.predictionTilesStatusMessage, + "Prediction tiles already available", + ) + ) + return _state(False) + + if not force and in_flight: + # A job is already queued or running for this model. Re-queueing + # would submit a second Batch task for the same artifacts; the + # caller just polls the status it already has. + logger.info( + "Prediction tiles for model %s already %s; not re-queueing", + model.modelId, + model.predictionTilesStatus, + ) + return _state(False) + + model.predictionTilesStatus = statuses.PENDING.value + model.predictionTilesStatusMessage = MetadataUtils.append_status_message( + "", "Queued for prediction tile preparation" + ) + enqueue_prediction_tiles( + project_id=model.projectId, + image_layer_id=image_layer.imageLayerId, + model_id=model.modelId, + source_gpkg_url=model.gpkgUrl, + source_footprints_url=image_layer.buildingFootprintsUrl, + force=force, + config=config, + ) + logger.info( + "Queued prediction tiles for model %s (pmtiles=%s, attrs=%s, " + "force=%s)", + model.modelId, + needs_pmtiles or force, + needs_attrs or force, + force, + ) + return _state(True) + + +class PredictionTilesPreprocessor: + """Validate a request and enqueue the prediction-tiles job.""" + + def __init__( + self, + model: Model, + image_layer: Optional[ImageLayer] = None, + config: Optional[Config] = None, + ) -> None: + if config is None: + config = Config() + self.config = config + self.model_data = model + self.image_layer = image_layer + self.logger = Logger.get_logger(__name__) + self.queue_client = AzureQueueHandler( + config.queue_config["queue_connection_string"], + config.queue_config["prediction_edit_prep_queue_name"], + config.queue_config["queue_account_url"], + ) + + def queue_for_processing(self, force: bool = False) -> Model: + """Queue the model for tile/sidecar preparation. + + Args: + force: Rebuild even when both artifacts already exist (used + after predictions are regenerated). + + Returns: + The updated model. When nothing has to be built the model is + marked COMPLETED and no message is enqueued. + + Raises: + ValueError: when the model has no prediction GeoPackage or + the layer has no cached building footprints — without + either of those there is nothing to tile. + """ + if self.image_layer is None: + raise ValueError( + "PredictionTilesPreprocessor requires the model's image " + "layer to decide whether footprint tiles are needed." + ) + if not self.model_data.gpkgUrl: + raise ValueError( + f"Model {self.model_data.modelId} has no prediction " + "GeoPackage (gpkgUrl); run inference before preparing " + "prediction tiles." + ) + if not self.image_layer.buildingFootprintsUrl: + raise ValueError( + f"Image layer {self.image_layer.imageLayerId} has no " + "cached building footprints; prediction tiles cannot be " + "built without them." + ) + + needs_pmtiles, needs_attrs = needs_preparation( + self.model_data, self.image_layer + ) + if not force and not needs_pmtiles and not needs_attrs: + self.model_data.predictionTilesStatus = ( + self.config.get_status_types().COMPLETED.value + ) + self.model_data.predictionTilesStatusMessage = ( + MetadataUtils.append_status_message( + self.model_data.predictionTilesStatusMessage, + "Prediction tiles already available", + ) + ) + return self.model_data + + self.model_data.predictionTilesStatus = ( + self.config.get_status_types().PENDING.value + ) + self.model_data.predictionTilesStatusMessage = ( + MetadataUtils.append_status_message( + "", "Queued for prediction tile preparation" + ) + ) + self.queue_client.put_message( + json.dumps( + build_prep_message( + project_id=self.model_data.projectId, + image_layer_id=self.image_layer.imageLayerId, + model_id=self.model_data.modelId, + source_gpkg_url=self.model_data.gpkgUrl, + source_footprints_url=( + self.image_layer.buildingFootprintsUrl + ), + force=force, + ) + ), + visibility_timeout=0, + ) + self.logger.info( + "Queued prediction tiles for model %s (pmtiles=%s, attrs=%s)", + self.model_data.modelId, + needs_pmtiles or force, + needs_attrs or force, + ) + return self.model_data + + +class PredictionTilesPostprocessor: + """Submit, poll and finalize the prediction-tiles Batch task.""" + + def __init__( + self, + model: Model, + image_layer: ImageLayer, + config: Optional[Config] = None, + ) -> None: + if config is None: + config = Config() + self.config = config + self.model_data = model + self.image_layer = image_layer + self.storage = UnifiedDataLayer( + storage_type=config.storage_type, + partition_key=model.projectId, + **config.storage_config, + ) + self.logger = Logger.get_logger(__name__) + self.runner = UnifiedRunner( + runner_type=config.runner_type, + config=self.config, + pool_id=self.config.get_azure_batch_config()["training_pool_id"], + candidate_pool_ids=self.config.get_azure_batch_config()[ + "training_pool_ids" + ], + ) + self.queue_client = AzureQueueHandler( + config.queue_config["queue_connection_string"], + config.queue_config["prediction_edit_prep_queue_name"], + config.queue_config["queue_account_url"], + ) + + def _poll_message(self) -> str: + """Message that brings this model back for another status poll.""" + footprints_url = self.image_layer.buildingFootprintsUrl + return json.dumps( + build_prep_message( + project_id=self.model_data.projectId, + image_layer_id=self.image_layer.imageLayerId, + model_id=self.model_data.modelId, + source_gpkg_url=self.model_data.gpkgUrl, + source_footprints_url=footprints_url, + ) + ) + + def process(self) -> Model: + """Advance the job state machine by one step. + + The caller persists both ``self.model_data`` and + ``self.image_layer``: the footprint tiles belong to the layer, + the attribute sidecar to the model. + """ + self.logger.info( + "%s.process: model %s prediction tiles status %s", + self.__class__.__name__, + self.model_data.modelId, + self.model_data.predictionTilesStatus, + ) + statuses = self.config.get_status_types() + + if self.model_data.predictionTilesStatus == statuses.PENDING.value: + self._update_progress("Submitting prediction tile job") + self.model_data = self._execute_job() + + elif ( + self.model_data.predictionTilesStatus == statuses.IN_PROGRESS.value + ): + job = self.model_data.predictionTilesJob + if job is None: + self.model_data.predictionTilesStatus = statuses.FAILED.value + self._update_progress( + "Prediction tile job reference is missing; cannot " + "poll for completion" + ) + return self.model_data + + task_status = self.runner.get_task_status( + job_id=job.jobId, task_id=job.taskId + ) + self.logger.info( + "Task status for prediction tiles of model %s is %s", + self.model_data.modelId, + task_status, + ) + + if task_status == statuses.COMPLETED.value: + job.status = task_status + job.completedDate = MetadataUtils.get_timestamp() + try: + self._update_results_from_job() + self.model_data.predictionTilesStatus = task_status + except Exception as error: + self.logger.error( + "Error finalizing prediction tiles for model " + f"{self.model_data.modelId}: {error}", + stack_info=True, + ) + self.model_data.predictionTilesStatus = ( + statuses.FAILED.value + ) + job.status = statuses.FAILED.value + self._update_progress( + f"Prediction tile job failed: {error}" + ) + self._replay_friendly_logs() + self.runner.cleanup_task(job_id=job.jobId, task_id=job.taskId) + + elif task_status == statuses.FAILED.value: + self.model_data.predictionTilesStatus = task_status + job.status = task_status + job.completedDate = MetadataUtils.get_timestamp() + self._replay_friendly_logs() + self._update_progress("Prediction tile job failed") + self.runner.cleanup_task(job_id=job.jobId, task_id=job.taskId) + else: + self.model_data.predictionTilesStatus = task_status + job.status = task_status + self.queue_client.put_message(self._poll_message()) + + return self.model_data + + # ── submission ──────────────────────────────────────────────────── + def _execute_job(self) -> Model: + statuses = self.config.get_status_types() + try: + input_files = self._create_job_config() + config_path = input_files["config"]["file_path"] + command = ( + f'"mkdir -p ${BATCH_JOB_WORKDIR} ' + f"&& cd ${BATCH_JOB_WORKDIR} " + "&& python -m hastegeo.workflows.prepare_prediction_tiles " + f'--config ${BATCH_JOB_WORKDIR}/{config_path}"' + ) + job_id = self.config.get_azure_batch_config()[ + "training_batch_job_id" + ][:64] + task_id = ( + f"{PREDICTION_TILES_PREFIX}-{MetadataUtils.generate_id()}" + ) + output_prefix = ( + f"{MetadataUtils.hash_string(self.model_data.projectId)}" + f"/{task_id}" + ) + job_id, task_id = self.runner.add_task( + job_id=job_id, + task_id=task_id, + output_prefix=output_prefix, + resource_files_for_upload=input_files, + file_pattern=f"${BATCH_JOB_WORKDIR}/outputs/*.*", + command=command, + image_name=self.config.get_azure_batch_config()[ + "docker_image" + ], + ) + self.model_data.predictionTilesJob = TrainingJob( + jobId=job_id, + taskId=task_id, + modelId=self.model_data.modelId, + projectId=self.model_data.projectId, + status=statuses.IN_PROGRESS.value, + creationDate=MetadataUtils.get_timestamp(), + ) + self.model_data.predictionTilesStatus = statuses.IN_PROGRESS.value + self._update_progress( + f"Prediction tiles submitted with task id {task_id}" + ) + self.queue_client.put_message(self._poll_message()) + except Exception as error: + self.logger.error( + "Error submitting prediction tiles for model " + f"{self.model_data.modelId}: {error}", + stack_info=True, + ) + self.model_data.predictionTilesStatus = statuses.FAILED.value + self._update_progress(f"Prediction tile job failed: {error}") + return self.model_data + + def _create_job_config(self) -> Dict[str, Dict[str, str]]: + """Write the workflow config and describe the task input files.""" + filename_pattern = ( + rf"{MetadataUtils.hash_string(self.model_data.projectId)}/(.*)\?+" + ) + plain_url_pattern = r"(.*)\?+" + + footprints_url = self.image_layer.buildingFootprintsUrl + predictions_url = self.model_data.gpkgUrl + if not footprints_url: + raise ValueError("Image layer has no building footprints.") + if not predictions_url: + raise ValueError("Model has no prediction GeoPackage.") + + footprints_fn = ( + f"inputs/{extract_from_url(footprints_url, filename_pattern)}" + ) + predictions_fn = ( + f"inputs/{extract_from_url(predictions_url, filename_pattern)}" + ) + + needs_pmtiles, _ = needs_preparation(self.model_data, self.image_layer) + pmtiles_name = pmtiles_artifact_name(self.model_data.imageLayerId) + attrs_name = attrs_artifact_name(self.model_data.modelId) + + workflow_config: Dict[str, Any] = { + "project_id": self.model_data.projectId, + "image_layer_id": self.model_data.imageLayerId, + "model_id": self.model_data.modelId, + "output_dir": "outputs", + # Relative to the task working dir: the command cd's into + # $AZ_BATCH_TASK_WORKING_DIR before running the workflow. + "files": { + "footprints": footprints_fn, + "predictions": predictions_fn, + "pmtiles": pmtiles_name, + "attrs": attrs_name, + }, + "tiles": {"build_pmtiles": needs_pmtiles}, + "store_artifacts": True, + } + self.storage.save( + identifier=self.model_data.modelId, + data=workflow_config, + data_type=( + self.config.get_metadata_types().PREDICTION_TILES_CONFIG.value + ), + data_format="json", + ) + config_filepath = self.storage.get_file_remote_path( + self.model_data.modelId, + self.config.get_metadata_types().PREDICTION_TILES_CONFIG.value, + data_format="json", + ) + config_fn = ( + f"inputs/{extract_from_url(config_filepath, filename_pattern)}" + ) + + return { + "config": { + "http_url": extract_from_url( + config_filepath, plain_url_pattern + ), + "file_path": config_fn, + }, + "footprints": { + "http_url": extract_from_url( + footprints_url, plain_url_pattern + ), + "file_path": footprints_fn, + }, + "predictions": { + "http_url": extract_from_url( + predictions_url, plain_url_pattern + ), + "file_path": predictions_fn, + }, + } + + # ── finalization ────────────────────────────────────────────────── + def _update_results_from_job(self) -> None: + """Persist artifact URLs and counts from the task manifest.""" + job = self.model_data.predictionTilesJob + content = self.runner.get_filecontent_from_task( + job_id=job.jobId, + task_id=job.taskId, + filename=MANIFEST_FILENAME, + ) + if not content: + raise FileNotFoundError( + "Prediction tiles manifest not found for model " + f"{self.model_data.modelId}" + ) + manifest = json.loads(content) + + attrs_url = manifest.get("attrs_url") or self._artifact_url( + manifest.get("attrs_filename", "") + ) + if not attrs_url: + raise ValueError( + "Prediction tiles manifest carries no attribute sidecar " + f"for model {self.model_data.modelId}" + ) + self.model_data.predictionAttrsUrl = attrs_url + + if manifest.get("pmtiles_built"): + pmtiles_url = manifest.get("pmtiles_url") or self._artifact_url( + manifest.get("pmtiles_filename", "") + ) + if not pmtiles_url: + raise ValueError( + "Prediction tiles manifest reports tiles were built " + "but carries no PMTiles URL for image layer " + f"{self.model_data.imageLayerId}" + ) + self.image_layer.footprintPmtilesUrl = pmtiles_url + + self.model_data.predictedBuildingCount = int( + manifest.get("building_count", 0) + ) + self.model_data.predictedAt = MetadataUtils.get_timestamp() + self._update_progress( + "Prepared prediction attributes for " + f"{self.model_data.predictedBuildingCount} buildings" + ) + + def _artifact_url(self, filename: str) -> str: + """Resolve a task output filename to a downloadable URL.""" + if not filename: + return "" + return self.storage.get_file_remote_path( + identifier=filename, + extra_partition_keys=f"{self.model_data.predictionTilesJob.taskId}", + data_format=os.path.splitext(filename)[1].strip("."), + ) + + def _replay_friendly_logs(self) -> None: + for timestamp, message in self._get_friendly_logs(): + if message not in ( + self.model_data.predictionTilesStatusMessage or "" + ): + self._update_progress(message, timestamp=timestamp) + + def _get_friendly_logs(self) -> List[Tuple[str, str]]: + job = self.model_data.predictionTilesJob + content = self.runner.get_filecontent_from_task( + job_id=job.jobId, + task_id=job.taskId, + filename=FRIENDLY_LOG_FILENAME, + ) + logs: List[Tuple[str, str]] = [] + if content: + for record in content.splitlines(): + if not record: + continue + parts = record.split("|", 1) + if len(parts) == 2: + logs.append((parts[0], parts[1])) + return logs + + def _update_progress( + self, message: str, timestamp: Optional[str] = None + ) -> None: + self.model_data.predictionTilesStatusMessage = ( + MetadataUtils.append_status_message( + self.model_data.predictionTilesStatusMessage, + message, + timestamp=timestamp, + ) + ) diff --git a/hastelib/src/hastegeo/core/utils/predictions.py b/hastelib/src/hastegeo/core/utils/predictions.py new file mode 100644 index 00000000..824fe669 --- /dev/null +++ b/hastelib/src/hastegeo/core/utils/predictions.py @@ -0,0 +1,237 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Normalising reader for HASTE building-damage prediction GeoPackages. + +Two producers write prediction GeoPackages and they do not agree on +layout, so every consumer that hand-rolled its own reader ended up +encoding one producer's quirks: + +* **inference** — ``docker/training/code/merge_with_building_footprints.py`` + writes the default layer with ``id`` (sequential row index), + ``damage_pct_0m`` / ``damage_pct_10m`` / ``damage_pct_20m`` (damage + *fractions* in [0, 1], despite the ``pct`` name), ``damaged`` + (``damage_pct_0m > 0``) and ``unknown_pct``. Thresholding this flavor + is meaningful because the damage fraction is continuous. +* **embedding** — the interactive building labeler writes a + ``predictions`` layer with ``id``, ``damaged``, ``damage_pct_0m`` + (a degenerate 0.0/1.0 copy of ``damaged``), ``unknown_pct`` and + ``area``. Thresholding this flavor is meaningless. + +:func:`read_predictions` hides that difference behind +:class:`PredictionSet`, so callers only branch on +:attr:`PredictionSet.supports_threshold` when the distinction genuinely +matters. + +Neither producer stores the Overture building id: predictions join to the +image layer's footprints GeoPackage **by row order**, matching +``hastegeo.core.utils.assessment.build_assessment_inputs_from_gpkgs``. +Pass ``footprints_path`` to resolve those ids. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, List, Optional + +import fiona + +from .gdal_security import harden_gdal + +# Harden GDAL/OGR drivers before any fiona read of a user-supplied vector +# file (GDAL CVE compensating control — docs/known-vulnerabilities.md +# Root Cause C). +harden_gdal() + +logger = logging.getLogger(__name__) + +INFERENCE_FLAVOR = "inference" +EMBEDDING_FLAVOR = "embedding" + +DAMAGE_FIELD = "damage_pct_0m" +UNKNOWN_FIELD = "unknown_pct" +DAMAGED_FIELD = "damaged" +AREA_FIELD = "area" +EMBEDDING_LAYER_NAME = "predictions" + +# Overture ids live only in the footprints GeoPackage +# (hastegeo.core.utils.footprints writes them to this column). +FOOTPRINT_ID_FIELD = "id" + + +@dataclass +class PredictionRow: + """One building's prediction, normalised across both producers. + + Attributes: + row_index: Zero-based position of the row in the GeoPackage. This + is the join key for the positional footprint join and for + analyst edits — it is the row's *position*, not the value of + its ``id`` column (the two coincide for both producers). + overture_id: Overture building id, resolved from the footprints + GeoPackage; ``None`` when no footprints file was supplied. + damage_fraction: Predicted damage fraction in [0, 1]. + damaged: Producer's own binary damage call (0 or 1). + unknown_fraction: Cloud/unknown cover fraction in [0, 1]. + """ + + row_index: int + overture_id: Optional[str] + damage_fraction: float + damaged: int + unknown_fraction: float + + +@dataclass +class PredictionSet: + """All predictions from one GeoPackage plus its provenance. + + Attributes: + rows: Prediction rows in file order. Downstream consumers join + positionally, so this order must never be rearranged. + flavor: ``"inference"`` or ``"embedding"``. + supports_threshold: ``True`` only when the damage fraction is + continuous and thresholding it is meaningful. + layer_name: Layer the predictions were read from. + crs: CRS of the source layer, as returned by fiona. + """ + + rows: List[PredictionRow] = field(default_factory=list) + flavor: str = INFERENCE_FLAVOR + supports_threshold: bool = True + layer_name: Optional[str] = None + crs: Any = None + + def __len__(self) -> int: + return len(self.rows) + + +def _as_float(value: Any) -> float: + """Coerce a GeoPackage property to a float, treating NULL as 0.0.""" + if value is None: + return 0.0 + return float(value) + + +def _as_int(value: Any) -> int: + """Coerce a GeoPackage property to an int, treating NULL as 0.""" + if value is None: + return 0 + return int(value) + + +def _detect_flavor( + layer_name: Optional[str], + field_names: List[str], + damage_values: List[float], +) -> str: + """Classify which producer wrote a prediction layer. + + A ``predictions`` layer carrying an ``area`` column is the + interactive labeler's output. Failing that, a ``damage_pct_0m`` + column that only ever holds 0.0 or 1.0 is the labeler's degenerate + copy of ``damaged`` rather than a real damage fraction. + """ + if layer_name == EMBEDDING_LAYER_NAME and AREA_FIELD in field_names: + return EMBEDDING_FLAVOR + if damage_values and all(v in (0.0, 1.0) for v in damage_values): + return EMBEDDING_FLAVOR + return INFERENCE_FLAVOR + + +def read_footprint_ids(footprints_path: str) -> List[str]: + """Read Overture building ids from a footprints GeoPackage, in order. + + Raises: + ValueError: if the layer has no ``id`` column. + """ + with fiona.open(footprints_path) as src: + if FOOTPRINT_ID_FIELD not in src.schema["properties"]: + raise ValueError( + "Footprints GeoPackage is missing the " + f"'{FOOTPRINT_ID_FIELD}' column: {footprints_path}" + ) + return [ + str(feature["properties"][FOOTPRINT_ID_FIELD]) for feature in src + ] + + +def read_predictions( + gpkg_path: str, + footprints_path: Optional[str] = None, +) -> PredictionSet: + """Read a prediction GeoPackage written by either producer. + + Args: + gpkg_path: Path to the prediction GeoPackage. + footprints_path: Optional path to the image layer's footprints + GeoPackage. When given, Overture ids are attached to each row + by position, mirroring the positional join in + ``hastegeo.core.utils.assessment``. + + Returns: + A :class:`PredictionSet` whose rows are in file order. + + Raises: + ValueError: if the GeoPackage has no layers, or if the footprints + file has a different row count than the predictions (which + would silently corrupt the positional join). + """ + layers = fiona.listlayers(gpkg_path) + if not layers: + raise ValueError(f"Prediction GeoPackage has no layers: {gpkg_path}") + layer_name = ( + EMBEDDING_LAYER_NAME if EMBEDDING_LAYER_NAME in layers else layers[0] + ) + + overture_ids: Optional[List[str]] = None + if footprints_path: + overture_ids = read_footprint_ids(footprints_path) + + rows: List[PredictionRow] = [] + damage_values: List[float] = [] + with fiona.open(gpkg_path, layer=layer_name) as src: + crs = src.crs + field_names = list(src.schema["properties"].keys()) + for row_index, feature in enumerate(src): + props = feature["properties"] + damage = _as_float(props.get(DAMAGE_FIELD)) + damage_values.append(damage) + rows.append( + PredictionRow( + row_index=row_index, + overture_id=None, + damage_fraction=damage, + damaged=_as_int(props.get(DAMAGED_FIELD)), + unknown_fraction=_as_float(props.get(UNKNOWN_FIELD)), + ) + ) + + if overture_ids is not None: + if len(overture_ids) != len(rows): + raise ValueError( + "Footprints/predictions row count mismatch: " + f"{len(overture_ids)} footprints in {footprints_path} vs " + f"{len(rows)} predictions in {gpkg_path}. The prediction " + "join is positional, so these files must line up row for " + "row." + ) + for row in rows: + row.overture_id = overture_ids[row.row_index] + + flavor = _detect_flavor(layer_name, field_names, damage_values) + logger.info( + "Read %d predictions from %s (layer=%s, flavor=%s)", + len(rows), + gpkg_path, + layer_name, + flavor, + ) + return PredictionSet( + rows=rows, + flavor=flavor, + supports_threshold=flavor == INFERENCE_FLAVOR, + layer_name=layer_name, + crs=crs, + ) diff --git a/hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py b/hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py new file mode 100644 index 00000000..291fc52c --- /dev/null +++ b/hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py @@ -0,0 +1,660 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Prepare the vector-tile + attribute payload the prediction editor needs. + +The prediction editor has to render EVERY predicted building footprint of +an image layer in the browser, with its damage attributes. Two artifacts +make that possible and this workflow builds both: + +1. **Footprint PMTiles** (``ArtifactTypes.LAYER_FOOTPRINT_PMTILES``) — a + geometry-only vector-tile archive of the image layer's cached + building-footprints GeoPackage, reprojected to EPSG:4326. Built once + per image layer and reused by every model trained on that layer, so + the workflow skips this step when the layer already has one. +2. **Prediction attribute sidecar** (``ArtifactTypes.PREDICTION_ATTRS``) + — a compact columnar JSON payload of the model's per-building damage + values, fetched once per editing session and indexed by the same + integer id that is baked into the tiles. + +CRITICAL — row-order invariant: + Predictions join to the layer's ``buildingFootprintsUrl`` GeoPackage + **by row index** (``hastegeo.core.utils.assessment``). Both artifacts + key on that row index: the tiles carry ``id = 0..N-1`` (promoted to + the MVT feature id via ``--use-attribute-for-id=id`` so the browser + can drive per-building colouring through map feature-state) and the + sidecar arrays are ordered by the very same index. Rows are never + dropped or reordered, and a footprint/prediction count mismatch is a + hard error rather than a silent misalignment. + +CRITICAL — where this runs: + ``tippecanoe`` ships only in the training docker image + (``docker/training/env/env.yml``). This module must therefore run as + a queued Batch/local-runner task inside that container — never + inline in an Azure Functions HTTP handler. See + ``hastegeo.core.processors.prediction_tiles``. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +import fiona +import geopandas as gpd +from hastegeo.core.config import ArtifactTypes, Config, StorageType +from hastegeo.core.utils.gdal_security import harden_gdal +from hastegeo.core.utils.logs import Logger as HasteLogger +from hastegeo.core.utils.predictions import PredictionSet, read_predictions + +WORKDIR = os.getenv("WORKDIR", ".") +LOG_DIR = os.path.join(WORKDIR, "logs") +LOG_FILE = "prediction_tiles_verbose.log" +FRIENDLY_LOG_FILE = "prediction_tiles_friendly.log" +os.makedirs(LOG_DIR, exist_ok=True) + +logger = HasteLogger.get_logger( + "prepare_prediction_tiles", log_dir=LOG_DIR, log_file=LOG_FILE +) +logger.info("Executing %s", __file__) + +# Harden GDAL/OGR before reading any user-reachable vector file (GDAL CVE +# compensating control — docs/known-vulnerabilities.md Root Cause C). +harden_gdal() + +TIPPECANOE_BIN = "tippecanoe" +TILE_LAYER_NAME = "buildings" +# Buildings are invisible below z10 and the editor works at z<=15; tiles +# above that are produced by the map SDK via overzoom, on which +# queryRenderedFeatures + setFeatureState keep working. Same window as +# the interactive labeler's tiles (workflows/embed_buildings.py). +DEFAULT_MIN_ZOOM = 10 +DEFAULT_MAX_ZOOM = 15 +# Tiles carry only these two attributes; every damage value rides in the +# sidecar instead, which keeps a dense urban tile small. +TILE_ID_FIELD = "id" +TILE_OVERTURE_ID_FIELD = "overture_id" +TILING_CRS = "EPSG:4326" +# Damage/unknown values are fractions in [0, 1]; six decimals is well +# below any threshold a user can set and keeps the payload compact. +VALUE_PRECISION = 6 + +MANIFEST_FILENAME = "prediction_tiles_manifest.json" +DEFAULT_OUTPUT_DIR = "outputs" + + +class TippecanoeNotFoundError(RuntimeError): + """Raised when the tippecanoe binary is unavailable.""" + + +class TippecanoeError(RuntimeError): + """Raised when tippecanoe runs but exits non-zero.""" + + +class FootprintPredictionMismatchError(ValueError): + """Raised when predictions and footprints do not line up row for row.""" + + +def log_progress(message: str) -> None: + """Append a friendly progress line consumed by the postprocessor.""" + logger.info(message) + log_file = os.path.join(LOG_DIR, FRIENDLY_LOG_FILE) + with open(log_file, "a") as handle: + handle.write(f"{datetime.now(timezone.utc).isoformat()}|{message}\n") + + +# --------------------------------------------------------------------------- +# Footprint PMTiles +# --------------------------------------------------------------------------- +def require_tippecanoe() -> str: + """Return the tippecanoe executable path or fail with guidance. + + Raises: + TippecanoeNotFoundError: when the binary is not on PATH. The + message names the one image that ships it so the failure is + actionable instead of a bare ``FileNotFoundError`` traceback + from ``subprocess``. + """ + binary = shutil.which(TIPPECANOE_BIN) + if binary: + return binary + raise TippecanoeNotFoundError( + "tippecanoe was not found on PATH, so the footprint vector tiles " + "cannot be built. tippecanoe ships only in the HASTE training " + "image (docker/training/env/env.yml); run this workflow as a " + "queued task in that container " + "(hastegeo.core.processors.prediction_tiles), never inline in " + "the Azure Functions app." + ) + + +def footprints_to_tiling_geojson( + footprints_path: str, geojson_path: str +) -> int: + """Write a tiling-ready EPSG:4326 GeoJSON of the footprints. + + Emits exactly two attributes per feature: an integer ``id`` equal to + the footprint's row index (the positional join key) and the Overture + string id as ``overture_id``. + + Args: + footprints_path: Building-footprints GeoPackage for the layer. + geojson_path: Destination GeoJSON path. + + Returns: + Number of footprints written. + + Raises: + ValueError: if the GeoPackage is empty, has no CRS, or lacks the + Overture ``id`` column. + """ + footprints = gpd.read_file(footprints_path) + if len(footprints) == 0: + raise ValueError( + f"Building footprints GeoPackage is empty: {footprints_path}" + ) + if footprints.crs is None: + raise ValueError( + "Building footprints GeoPackage has no CRS, refusing to tile " + f"unreferenced geometry: {footprints_path}" + ) + if TILE_ID_FIELD not in footprints.columns: + raise ValueError( + "Building footprints GeoPackage has no 'id' column (expected " + f"the Overture id): {footprints_path}" + ) + + # Tiles are always geographic; reproject only when needed so an + # already-4326 layer keeps its exact coordinates. + if footprints.crs.to_epsg() != 4326: + logger.info( + "Reprojecting footprints from %s to %s for tiling", + footprints.crs, + TILING_CRS, + ) + footprints = footprints.to_crs(TILING_CRS) + + tiles_gdf = gpd.GeoDataFrame( + { + # Row index -> integer feature id. MUST stay 0..N-1 in native + # footprint order to match the positional prediction join. + TILE_ID_FIELD: range(len(footprints)), + TILE_OVERTURE_ID_FIELD: footprints[TILE_ID_FIELD].astype(str), + }, + geometry=footprints.geometry.values, + crs=TILING_CRS, + ) + tiles_gdf.to_file(geojson_path, driver="GeoJSON") + logger.info( + "Wrote %d footprints to %s for tiling", + len(tiles_gdf), + geojson_path, + ) + return len(tiles_gdf) + + +def run_tippecanoe( + geojson_path: str, + pmtiles_path: str, + minimum_zoom: int = DEFAULT_MIN_ZOOM, + maximum_zoom: int = DEFAULT_MAX_ZOOM, +) -> None: + """Convert a footprints GeoJSON to PMTiles via tippecanoe. + + Mirrors ``workflows/embed_buildings.write_pmtiles``: ``id`` becomes + the native MVT feature id (``--use-attribute-for-id=id``) so the + browser can colour buildings through map feature-state, and only + ``id`` + ``overture_id`` survive into the tiles. + + Raises: + TippecanoeNotFoundError: when the binary is missing. + TippecanoeError: when tippecanoe exits non-zero. + """ + binary = require_tippecanoe() + cmd = [ + binary, + "-o", + pmtiles_path, + "-l", + TILE_LAYER_NAME, + # Bake the row-index id into each tile as the MVT feature id. + # Azure Maps' VectorTileSource does not honor client-side + # promoteId, so the id has to be native. + f"--use-attribute-for-id={TILE_ID_FIELD}", + # Keep only the join key and the Overture id — damage values + # travel in the sidecar. + "-y", + TILE_ID_FIELD, + "-y", + TILE_OVERTURE_ID_FIELD, + "--force", + f"--minimum-zoom={int(minimum_zoom)}", + f"--maximum-zoom={int(maximum_zoom)}", + # Geometry-only features are tiny; lifting the 500 KB per-tile + # cap guarantees no footprint is silently dropped. + "--no-tile-size-limit", + # Last-resort safety valve. + "--drop-densest-as-needed", + geojson_path, + ] + log_progress(f"Running tippecanoe -> {os.path.basename(pmtiles_path)}") + try: + subprocess.run(cmd, check=True) + except subprocess.CalledProcessError as exc: + raise TippecanoeError( + f"tippecanoe failed with exit code {exc.returncode} while " + f"building {os.path.basename(pmtiles_path)}. Command: " + f"{' '.join(cmd)}" + ) from exc + + +def build_footprint_pmtiles( + footprints_path: str, + pmtiles_path: str, + minimum_zoom: int = DEFAULT_MIN_ZOOM, + maximum_zoom: int = DEFAULT_MAX_ZOOM, + geojson_path: Optional[str] = None, +) -> int: + """Build the layer's footprint PMTiles archive. + + Returns: + Number of footprints tiled. + """ + # Fail before the (potentially slow) reprojection when the binary is + # missing, so the error the user sees is the actionable one. + require_tippecanoe() + geojson_path = geojson_path or os.path.join( + os.path.dirname(pmtiles_path) or ".", "footprints_4326.geojson" + ) + count = footprints_to_tiling_geojson(footprints_path, geojson_path) + run_tippecanoe( + geojson_path, + pmtiles_path, + minimum_zoom=minimum_zoom, + maximum_zoom=maximum_zoom, + ) + log_progress(f"Built footprint tiles for {count} buildings") + return count + + +# --------------------------------------------------------------------------- +# Prediction attribute sidecar +# --------------------------------------------------------------------------- +def count_features(path: str, layer: Optional[str] = None) -> int: + """Count features in a vector file without loading its geometry.""" + if layer is None: + layers = fiona.listlayers(path) + if not layers: + raise ValueError(f"Vector file has no layers: {path}") + layer = layers[0] + with fiona.open(path, layer=layer) as src: + return len(src) + + +def _prediction_layer(predictions_path: str) -> str: + """Return the layer a prediction GeoPackage stores its rows in.""" + layers = fiona.listlayers(predictions_path) + if not layers: + raise ValueError( + f"Prediction GeoPackage has no layers: {predictions_path}" + ) + # The embedding flavor writes a named "predictions" layer; the + # trained-inference flavor uses the default (first) layer. + return "predictions" if "predictions" in layers else layers[0] + + +def _assert_row_counts_match( + predictions_path: str, footprints_path: str +) -> int: + """Fail loudly when predictions and footprints do not line up. + + The prediction -> footprint join is positional, so a count mismatch + would silently attach every damage value to the wrong building. + + Returns: + The (shared) row count. + + Raises: + FootprintPredictionMismatchError: on any mismatch. + """ + footprint_count = count_features(footprints_path) + prediction_count = count_features( + predictions_path, layer=_prediction_layer(predictions_path) + ) + if footprint_count != prediction_count: + raise FootprintPredictionMismatchError( + "Prediction/footprint row count mismatch: " + f"{prediction_count} predictions in {predictions_path} vs " + f"{footprint_count} footprints in {footprints_path}. The " + "prediction-to-footprint join is positional, so both files " + "must have the same number of rows in the same order." + ) + return footprint_count + + +def build_prediction_attrs( + predictions_path: str, footprints_path: str +) -> Dict[str, Any]: + """Build the columnar attribute payload for one model. + + Args: + predictions_path: Prediction GeoPackage (either flavor — the + trained-inference merge output or the embedding labeler's + ``predictions`` layer). + footprints_path: The image layer's building-footprints + GeoPackage, used to resolve Overture ids positionally. + + Returns: + ``{"n", "ids", "overtureIds", "damage", "unknown", "damaged"}`` + with every array the same length and ordered by row index. + + Raises: + FootprintPredictionMismatchError: when the two files disagree on + row count. + ValueError: when the prediction row indices are not the + contiguous range ``0..n-1``. + """ + expected_count = _assert_row_counts_match( + predictions_path, footprints_path + ) + predictions: PredictionSet = read_predictions( + predictions_path, footprints_path=footprints_path + ) + rows = sorted(predictions.rows, key=lambda row: row.row_index) + + ids: List[int] = [int(row.row_index) for row in rows] + if ids != list(range(expected_count)): + raise ValueError( + f"Prediction GeoPackage {predictions_path} does not carry a " + f"contiguous 0..{expected_count - 1} row index; the editor " + "indexes the sidecar arrays by tile feature id, so gaps or " + "duplicates would mislabel buildings." + ) + + payload: Dict[str, Any] = { + "n": expected_count, + "ids": ids, + "overtureIds": [ + "" if row.overture_id is None else str(row.overture_id) + for row in rows + ], + "damage": [ + round(float(row.damage_fraction), VALUE_PRECISION) for row in rows + ], + "unknown": [ + round(float(row.unknown_fraction), VALUE_PRECISION) for row in rows + ], + "damaged": [int(row.damaged) for row in rows], + } + + lengths = { + key: len(value) + for key, value in payload.items() + if isinstance(value, list) + } + if set(lengths.values()) != {expected_count}: + raise ValueError( + "Prediction attribute arrays have inconsistent lengths " + f"{lengths}; expected {expected_count} for every column." + ) + return payload + + +def write_prediction_attrs( + predictions_path: str, footprints_path: str, attrs_path: str +) -> Dict[str, Any]: + """Build and write the attribute sidecar; return the payload.""" + payload = build_prediction_attrs(predictions_path, footprints_path) + with open(attrs_path, "w") as handle: + json.dump(payload, handle, separators=(",", ":")) + log_progress( + f"Wrote prediction attributes for {payload['n']} buildings -> " + f"{os.path.basename(attrs_path)}" + ) + return payload + + +# --------------------------------------------------------------------------- +# Artifact storage +# --------------------------------------------------------------------------- +def artifact_storage_available(config: Config) -> bool: + """Report whether this process can reach artifact storage. + + Azure Batch tasks are not given storage credentials by default (the + runner uploads ``outputs/`` for them), while the local docker runner + does pass them through. Probing up front lets the workflow store the + artifacts itself when it can and fall back to the runner's upload + otherwise, instead of dying on a misleading credential error. + """ + storage_config = config.artifact_storage_config or {} + if config.artifact_storage_type == StorageType.BLOB.value: + return bool( + storage_config.get("connection_string") + or storage_config.get("account_url") + ) + if config.artifact_storage_type == StorageType.LOCAL.value: + return bool(storage_config.get("directory")) + return False + + +def store_artifacts( + project_id: str, + artifacts: Dict[str, str], + config: Optional[Config] = None, +) -> Dict[str, str]: + """Store artifacts through the artifact-storage façade. + + Args: + project_id: Storage partition key. + artifacts: ``{artifact_name: local_path}``. + config: Optional config override. + + Returns: + ``{artifact_name: download_url}`` for everything stored. + """ + from hastegeo.core.processors.artifacts import ArtifactProcessor + + config = config or Config() + processor = ArtifactProcessor(partition_key=project_id, config=config) + urls: Dict[str, str] = {} + for artifact_name, local_path in artifacts.items(): + if not os.path.exists(local_path): + raise FileNotFoundError( + f"Artifact {artifact_name} not found at {local_path}" + ) + processor.store_artifact( + artifact_name=artifact_name, src_path=local_path + ) + urls[artifact_name] = processor.get_download_url( + identifier=artifact_name + ) + logger.info("Stored artifact %s", artifact_name) + return urls + + +# --------------------------------------------------------------------------- +# CLI / workflow entrypoint +# --------------------------------------------------------------------------- +def default_pmtiles_name(image_layer_id: str) -> str: + """Artifact filename of a layer's footprint PMTiles archive.""" + return ( + ArtifactTypes.LAYER_FOOTPRINT_PMTILES.value.substitute( + imageLayerId=image_layer_id + ) + + ".pmtiles" + ) + + +def default_attrs_name(model_id: str) -> str: + """Artifact filename of a model's prediction attribute sidecar.""" + return ( + ArtifactTypes.PREDICTION_ATTRS.value.substitute(modelId=model_id) + + ".json" + ) + + +def run(config: Dict[str, Any], output_dir: str) -> Dict[str, Any]: + """Run the workflow described by ``config`` and return its manifest. + + Args: + config: Parsed workflow config (see the module docstring of + ``hastegeo.core.processors.prediction_tiles`` for the shape + the processor writes). + output_dir: Directory the artifacts are written to. The runner + uploads everything in here after the task completes. + + Returns: + The manifest dict, which is also written to ``output_dir``. + """ + files: Dict[str, Any] = config.get("files", {}) + tiles_config: Dict[str, Any] = config.get("tiles", {}) + project_id = config.get("project_id") + image_layer_id = config.get("image_layer_id") + model_id = config.get("model_id") + if not project_id or not image_layer_id or not model_id: + raise ValueError( + "Config must set project_id, image_layer_id and model_id." + ) + + footprints_path = files.get("footprints") + predictions_path = files.get("predictions") + if not footprints_path or not os.path.exists(footprints_path): + raise FileNotFoundError( + f"Building footprints not found: {footprints_path}" + ) + if not predictions_path or not os.path.exists(predictions_path): + raise FileNotFoundError( + f"Prediction GeoPackage not found: {predictions_path}" + ) + + pmtiles_name = os.path.basename( + files.get("pmtiles") or default_pmtiles_name(image_layer_id) + ) + attrs_name = os.path.basename( + files.get("attrs") or default_attrs_name(model_id) + ) + build_pmtiles = bool(tiles_config.get("build_pmtiles", True)) + + manifest: Dict[str, Any] = { + "project_id": project_id, + "image_layer_id": image_layer_id, + "model_id": model_id, + "pmtiles_filename": "", + "pmtiles_built": False, + "pmtiles_url": None, + "attrs_filename": attrs_name, + "attrs_url": None, + "building_count": 0, + "prediction_flavor": "", + "supports_threshold": False, + } + + to_store: Dict[str, str] = {} + + if build_pmtiles: + log_progress("Building footprint vector tiles") + pmtiles_path = os.path.join(output_dir, pmtiles_name) + build_footprint_pmtiles( + footprints_path, + pmtiles_path, + minimum_zoom=int( + tiles_config.get("minimum_zoom", DEFAULT_MIN_ZOOM) + ), + maximum_zoom=int( + tiles_config.get("maximum_zoom", DEFAULT_MAX_ZOOM) + ), + geojson_path=os.path.join(output_dir, "footprints_4326.geojson"), + ) + manifest["pmtiles_filename"] = pmtiles_name + manifest["pmtiles_built"] = True + to_store[pmtiles_name] = pmtiles_path + else: + log_progress("Reusing existing footprint vector tiles") + + log_progress("Building prediction attributes") + attrs_path = os.path.join(output_dir, attrs_name) + payload = write_prediction_attrs( + predictions_path, footprints_path, attrs_path + ) + predictions = read_predictions(predictions_path) + manifest["building_count"] = int(payload["n"]) + manifest["prediction_flavor"] = predictions.flavor + manifest["supports_threshold"] = bool(predictions.supports_threshold) + to_store[attrs_name] = attrs_path + + if config.get("store_artifacts", True): + haste_config = Config() + if artifact_storage_available(haste_config): + urls = store_artifacts(project_id, to_store, config=haste_config) + manifest["pmtiles_url"] = urls.get(pmtiles_name) + manifest["attrs_url"] = urls.get(attrs_name) + else: + # Not an error: on Azure Batch the runner uploads outputs/ + # and the postprocessor resolves the URLs from the task's + # output prefix instead. + logger.warning( + "Artifact storage is not configured in this container; " + "leaving upload to the runner and URL resolution to the " + "postprocessor." + ) + + # The GeoJSON is only tippecanoe's input; drop it so the runner does + # not upload a second full copy of every footprint. + stray_geojson = os.path.join(output_dir, "footprints_4326.geojson") + if os.path.exists(stray_geojson): + os.remove(stray_geojson) + + log_progress("Finalizing outputs") + with open(os.path.join(output_dir, MANIFEST_FILENAME), "w") as handle: + json.dump(manifest, handle, indent=4) + return manifest + + +def main() -> None: + """CLI entrypoint: ``prepare-prediction-tiles --config ``.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--config", type=str, required=True, help="Path to config JSON file" + ) + args = parser.parse_args() + + if not os.path.exists(args.config): + logger.error("No config file found at location %s", args.config) + sys.exit(1) + + with open(args.config) as handle: + config = json.load(handle) + if not config: + logger.error("Config file is empty") + sys.exit(1) + + output_dir = os.path.join( + WORKDIR, config.get("output_dir", DEFAULT_OUTPUT_DIR) + ) + os.makedirs(output_dir, exist_ok=True) + + try: + run(config, output_dir) + logger.info("Prediction tile preparation completed successfully.") + except TippecanoeNotFoundError as exc: + logger.error("%s", exc) + log_progress(f"Prediction tile preparation failed: {exc}") + raise + except Exception as exc: + logger.error("Error during prediction tile preparation", exc_info=True) + log_progress(f"Error during prediction tile preparation: {exc}") + raise + + +if __name__ == "__main__": + try: + main() + except Exception as error: # pragma: no cover - CLI guard + logger.error(f"Error during main execution: {error}", exc_info=True) + sys.exit(1) diff --git a/hastelib/tests/core/models/__init__.py b/hastelib/tests/core/models/__init__.py new file mode 100644 index 00000000..5b7f7a92 --- /dev/null +++ b/hastelib/tests/core/models/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/hastelib/tests/core/models/test_prediction_wire_models.py b/hastelib/tests/core/models/test_prediction_wire_models.py new file mode 100644 index 00000000..7f20c1dc --- /dev/null +++ b/hastelib/tests/core/models/test_prediction_wire_models.py @@ -0,0 +1,164 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Unit tests for the prediction-editing HTTP wire contracts. + +These models are the API layer's input boundary: a malformed editor +request must fail here with a validation error rather than reach the +geospatial code. The tests pin the exact allowlists and bounds the +routes rely on. +""" + +import unittest + +from hastegeo.core.models.predictions import ( + PREDICTION_EDIT_CLASSES, + PREDICTION_EDIT_DEFAULT_THRESHOLD, + EditedPredictionsRequest, + PredictionOverrideRequest, + PreparePredictionTilesRequest, +) +from hastegeo.core.utils.assessment import DAMAGED, NOT_DAMAGED, UNKNOWN +from pydantic import ValidationError + +GUID = "8ee4b0ea-3f24-4c05-b8c1-9ec2f8d5b6a1" +OTHER_GUID = "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" + + +def _edit_body(**overrides) -> dict: + body = { + "projectId": GUID, + "imageLayerId": OTHER_GUID, + "modelId": "5557", + } + body.update(overrides) + return body + + +class TestPredictionOverrideRequest(unittest.TestCase): + def test_accepts_the_wire_aliases(self): + override = PredictionOverrideRequest.model_validate( + {"id": 12, "class": DAMAGED} + ) + self.assertEqual(override.rowIndex, 12) + self.assertEqual(override.editedClass, DAMAGED) + + def test_rejects_negative_row_index(self): + with self.assertRaises(ValidationError): + PredictionOverrideRequest.model_validate( + {"id": -1, "class": DAMAGED} + ) + + def test_rejects_unknown_class(self): + with self.assertRaises(ValidationError): + PredictionOverrideRequest.model_validate( + {"id": 0, "class": "Destroyed"} + ) + + def test_allowed_classes_come_from_the_assessment_utility(self): + self.assertEqual( + PREDICTION_EDIT_CLASSES, (DAMAGED, NOT_DAMAGED, UNKNOWN) + ) + + +class TestEditedPredictionsRequest(unittest.TestCase): + def test_defaults(self): + request = EditedPredictionsRequest.model_validate(_edit_body()) + self.assertEqual(request.threshold, PREDICTION_EDIT_DEFAULT_THRESHOLD) + self.assertEqual(request.unknownThreshold, 0.0) + self.assertEqual(request.overrides, []) + + def test_rejects_non_guid_ids(self): + for field in ("projectId", "imageLayerId"): + with self.subTest(field=field): + with self.assertRaises(ValidationError): + EditedPredictionsRequest.model_validate( + _edit_body(**{field: "not-a-guid"}) + ) + + def test_rejects_non_numeric_model_id(self): + with self.assertRaises(ValidationError): + EditedPredictionsRequest.model_validate( + _edit_body(modelId="../secrets") + ) + + def test_rejects_thresholds_outside_the_unit_interval(self): + for field in ("threshold", "unknownThreshold"): + for value in (-0.01, 1.01): + with self.subTest(field=field, value=value): + with self.assertRaises(ValidationError): + EditedPredictionsRequest.model_validate( + _edit_body(**{field: value}) + ) + + def test_rejects_duplicate_override_ids(self): + with self.assertRaises(ValidationError): + EditedPredictionsRequest.model_validate( + _edit_body( + overrides=[ + {"id": 3, "class": DAMAGED}, + {"id": 3, "class": NOT_DAMAGED}, + ] + ) + ) + + def test_accepts_distinct_override_ids(self): + request = EditedPredictionsRequest.model_validate( + _edit_body( + threshold=0.25, + unknownThreshold=1.0, + overrides=[ + {"id": 3, "class": DAMAGED}, + {"id": 4, "class": UNKNOWN}, + ], + ) + ) + self.assertEqual( + [override.rowIndex for override in request.overrides], [3, 4] + ) + self.assertEqual(request.threshold, 0.25) + + +class TestPreparePredictionTilesRequest(unittest.TestCase): + def test_force_defaults_to_false(self): + request = PreparePredictionTilesRequest.model_validate( + { + "projectId": GUID, + "imageLayerId": OTHER_GUID, + "modelId": "5557", + } + ) + self.assertFalse(request.force) + + def test_accepts_force(self): + request = PreparePredictionTilesRequest.model_validate( + { + "projectId": GUID, + "imageLayerId": OTHER_GUID, + "modelId": "5557", + "force": True, + } + ) + self.assertTrue(request.force) + + def test_rejects_non_guid_ids(self): + for field in ("projectId", "imageLayerId"): + with self.subTest(field=field): + payload = { + "projectId": GUID, + "imageLayerId": OTHER_GUID, + "modelId": "5557", + } + payload[field] = "1234" + with self.assertRaises(ValidationError): + PreparePredictionTilesRequest.model_validate(payload) + + def test_rejects_missing_model_id(self): + with self.assertRaises(ValidationError): + PreparePredictionTilesRequest.model_validate( + {"projectId": GUID, "imageLayerId": OTHER_GUID} + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/hastelib/tests/core/processors/test_prediction_edits.py b/hastelib/tests/core/processors/test_prediction_edits.py new file mode 100644 index 00000000..5e980f74 --- /dev/null +++ b/hastelib/tests/core/processors/test_prediction_edits.py @@ -0,0 +1,592 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for hastegeo.core.processors.prediction_edits. + +The edited GeoPackage feeds a downstream POSITIONAL join against the +layer footprints, so row-order preservation is the headline guarantee +under test here. +""" + +import os +import shutil +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +import fiona +import geopandas as gpd +from fiona.crs import CRS +from fiona.model import Feature +from hastegeo.core.models.projects import EditedPredictionVersion, Model +from hastegeo.core.processors.prediction_edits import ( + EDIT_THRESHOLD_FIELD, + EDITED_CLASS_FIELD, + OVERTURE_ID_FIELD, + EditSummary, + apply_edits, + edited_version_artifact_name, + next_version, + store_edited_version, +) +from shapely.geometry import Polygon + +DAMAGED = "Damaged" +NOT_DAMAGED = "NotDamaged" +UNKNOWN = "Unknown" + +INFERENCE_SCHEMA = { + "geometry": "MultiPolygon", + "properties": { + "id": "int", + "damage_pct_0m": "float", + "damage_pct_10m": "float", + "damage_pct_20m": "float", + "damaged": "int", + "unknown_pct": "float", + }, +} + + +def square(index: int) -> Polygon: + x = float(index) + return Polygon([(x, 0), (x + 1, 0), (x + 1, 1), (x, 1)]) + + +def multipolygon_mapping(index: int) -> dict: + x = float(index) + ring = [(x, 0.0), (x + 1, 0.0), (x + 1, 1.0), (x, 1.0), (x, 0.0)] + return {"type": "MultiPolygon", "coordinates": [[ring]]} + + +def write_inference_gpkg( + path: str, + damage_values: list, + unknown_values: list = None, + epsg: int = 32610, +) -> str: + """Write a GeoPackage shaped like merge_with_building_footprints.py.""" + unknown_values = unknown_values or [0.0] * len(damage_values) + with fiona.open( + path, + "w", + driver="GPKG", + crs=CRS.from_epsg(epsg), + schema=INFERENCE_SCHEMA, + ) as dst: + for index, damage in enumerate(damage_values): + dst.write( + Feature.from_dict( + **{ + "type": "Feature", + "geometry": multipolygon_mapping(index), + "properties": { + "id": index, + "damage_pct_0m": damage, + "damage_pct_10m": damage / 2, + "damage_pct_20m": damage / 4, + "damaged": 1 if damage > 0 else 0, + "unknown_pct": unknown_values[index], + }, + } + ) + ) + return path + + +def write_embedding_gpkg(path: str, damaged_values: list) -> str: + """Write a GeoPackage shaped like the interactive labeler's output.""" + frame = gpd.GeoDataFrame( + { + "id": list(range(len(damaged_values))), + "damaged": damaged_values, + "damage_pct_0m": [float(d) for d in damaged_values], + "unknown_pct": [0.0] * len(damaged_values), + "area": [100.0] * len(damaged_values), + "geometry": [square(i) for i in range(len(damaged_values))], + }, + crs="EPSG:4326", + ) + frame.to_file(path, layer="predictions", driver="GPKG") + return path + + +def write_footprints_gpkg(path: str, ids: list) -> str: + frame = gpd.GeoDataFrame( + { + "id": ids, + "geometry": [square(i) for i in range(len(ids))], + }, + crs="EPSG:4326", + ) + frame.to_file(path, driver="GPKG") + return path + + +def read_rows(path: str, layer: str = None) -> list: + with fiona.open(path, layer=layer) as src: + return [dict(feature["properties"]) for feature in src] + + +class EditFixtureMixin(unittest.TestCase): + def setUp(self): + self.tmp_dir = tempfile.mkdtemp(prefix="haste-prediction-edits-") + + def tearDown(self): + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def path(self, name: str) -> str: + return os.path.join(self.tmp_dir, name) + + +class TestClassDerivation(EditFixtureMixin): + def test_threshold_is_strictly_greater_than(self): + src = write_inference_gpkg( + self.path("src.gpkg"), [0.0, 0.5, 0.5000001, 0.9] + ) + dst = self.path("dst.gpkg") + + summary = apply_edits( + src, dst, threshold=0.5, unknown_threshold=0.0, overrides={} + ) + + classes = [row[EDITED_CLASS_FIELD] for row in read_rows(dst)] + self.assertEqual(classes, [NOT_DAMAGED, NOT_DAMAGED, DAMAGED, DAMAGED]) + self.assertEqual(summary.damaged, 2) + self.assertEqual(summary.not_damaged, 2) + self.assertEqual(summary.unknown, 0) + + def test_zero_threshold_keeps_pristine_buildings_undamaged(self): + src = write_inference_gpkg(self.path("src.gpkg"), [0.0, 0.0001]) + dst = self.path("dst.gpkg") + + apply_edits(src, dst, threshold=0.0, overrides={}) + + classes = [row[EDITED_CLASS_FIELD] for row in read_rows(dst)] + self.assertEqual(classes, [NOT_DAMAGED, DAMAGED]) + + def test_unknown_takes_precedence_over_damaged(self): + src = write_inference_gpkg( + self.path("src.gpkg"), + [0.9, 0.9, 0.1], + unknown_values=[0.6, 0.0, 0.6], + ) + dst = self.path("dst.gpkg") + + summary = apply_edits( + src, dst, threshold=0.5, unknown_threshold=0.5, overrides={} + ) + + classes = [row[EDITED_CLASS_FIELD] for row in read_rows(dst)] + self.assertEqual(classes, [UNKNOWN, DAMAGED, UNKNOWN]) + self.assertEqual(summary.unknown, 2) + + def test_unknown_threshold_is_strictly_greater_than(self): + src = write_inference_gpkg( + self.path("src.gpkg"), + [0.9, 0.9], + unknown_values=[0.5, 0.51], + ) + dst = self.path("dst.gpkg") + + apply_edits( + src, dst, threshold=0.5, unknown_threshold=0.5, overrides={} + ) + + classes = [row[EDITED_CLASS_FIELD] for row in read_rows(dst)] + self.assertEqual(classes, [DAMAGED, UNKNOWN]) + + def test_overrides_win_over_derived_classes(self): + src = write_inference_gpkg( + self.path("src.gpkg"), + [0.9, 0.1, 0.1], + unknown_values=[0.0, 0.0, 0.9], + ) + dst = self.path("dst.gpkg") + + summary = apply_edits( + src, + dst, + threshold=0.5, + unknown_threshold=0.5, + overrides={0: NOT_DAMAGED, 1: DAMAGED, 2: NOT_DAMAGED}, + ) + + classes = [row[EDITED_CLASS_FIELD] for row in read_rows(dst)] + self.assertEqual(classes, [NOT_DAMAGED, DAMAGED, NOT_DAMAGED]) + self.assertEqual(summary.overrides_applied, 3) + + def test_string_override_keys_are_accepted(self): + src = write_inference_gpkg(self.path("src.gpkg"), [0.0, 0.0]) + dst = self.path("dst.gpkg") + + summary = apply_edits( + src, dst, threshold=0.5, overrides={"1": DAMAGED} + ) + + classes = [row[EDITED_CLASS_FIELD] for row in read_rows(dst)] + self.assertEqual(classes, [NOT_DAMAGED, DAMAGED]) + self.assertEqual(summary.overrides_applied, 1) + + def test_out_of_range_override_is_not_counted(self): + src = write_inference_gpkg(self.path("src.gpkg"), [0.0, 0.0]) + dst = self.path("dst.gpkg") + + summary = apply_edits(src, dst, threshold=0.5, overrides={99: DAMAGED}) + + self.assertEqual(summary.overrides_applied, 0) + self.assertEqual(summary.total_rows, 2) + + +class TestRowOrderPreservation(EditFixtureMixin): + """The downstream footprint join is positional — order is the API.""" + + def test_output_row_order_matches_input(self): + damage = [0.9, 0.0, 0.42, 0.1, 0.75, 0.0, 1.0] + src = write_inference_gpkg(self.path("src.gpkg"), damage) + dst = self.path("dst.gpkg") + + apply_edits( + src, + dst, + threshold=0.5, + overrides={1: DAMAGED, 4: NOT_DAMAGED}, + ) + + with fiona.open(src) as source: + src_rows = [ + ( + feature["properties"]["id"], + feature["properties"]["damage_pct_0m"], + feature["geometry"]["coordinates"][0][0][0], + ) + for feature in source + ] + with fiona.open(dst) as edited: + dst_rows = [ + ( + feature["properties"]["id"], + feature["properties"]["damage_pct_0m"], + feature["geometry"]["coordinates"][0][0][0], + ) + for feature in edited + ] + + self.assertEqual(dst_rows, src_rows) + self.assertEqual([row[0] for row in dst_rows], list(range(7))) + + def test_row_order_preserved_for_embedding_flavor(self): + src = write_embedding_gpkg(self.path("src.gpkg"), [1, 0, 0, 1, 1]) + dst = self.path("dst.gpkg") + + apply_edits(src, dst, threshold=0.5, overrides={}) + + src_ids = [row["id"] for row in read_rows(src, layer="predictions")] + dst_ids = [row["id"] for row in read_rows(dst, layer="predictions")] + self.assertEqual(dst_ids, src_ids) + + +class TestOutputSchema(EditFixtureMixin): + def test_all_source_columns_are_preserved(self): + src = write_inference_gpkg(self.path("src.gpkg"), [0.8, 0.2]) + dst = self.path("dst.gpkg") + + apply_edits(src, dst, threshold=0.5, overrides={}) + + with fiona.open(src) as source: + source_fields = list(source.schema["properties"].keys()) + with fiona.open(dst) as edited: + edited_fields = list(edited.schema["properties"].keys()) + + self.assertEqual(edited_fields[: len(source_fields)], source_fields) + self.assertEqual( + edited_fields[len(source_fields) :], + [EDITED_CLASS_FIELD, EDIT_THRESHOLD_FIELD, OVERTURE_ID_FIELD], + ) + + rows = read_rows(dst) + self.assertEqual([row["damage_pct_10m"] for row in rows], [0.4, 0.1]) + self.assertEqual([row["damage_pct_20m"] for row in rows], [0.2, 0.05]) + + def test_damaged_column_follows_final_class(self): + src = write_inference_gpkg( + self.path("src.gpkg"), + [0.9, 0.9, 0.1], + unknown_values=[0.0, 0.9, 0.0], + ) + dst = self.path("dst.gpkg") + + apply_edits( + src, dst, threshold=0.5, unknown_threshold=0.5, overrides={} + ) + + rows = read_rows(dst) + self.assertEqual([row["damaged"] for row in rows], [1, 0, 0]) + self.assertEqual( + [row[EDITED_CLASS_FIELD] for row in rows], + [DAMAGED, UNKNOWN, NOT_DAMAGED], + ) + + def test_edit_threshold_is_recorded_on_every_row(self): + src = write_inference_gpkg(self.path("src.gpkg"), [0.1, 0.9]) + dst = self.path("dst.gpkg") + + apply_edits(src, dst, threshold=0.35, overrides={}) + + rows = read_rows(dst) + self.assertEqual( + [row[EDIT_THRESHOLD_FIELD] for row in rows], [0.35, 0.35] + ) + + def test_crs_and_layer_name_are_preserved(self): + src = write_inference_gpkg( + self.path("src.gpkg"), [0.1, 0.9], epsg=32610 + ) + dst = self.path("dst.gpkg") + + apply_edits(src, dst, threshold=0.5, overrides={}) + + self.assertEqual(fiona.listlayers(dst), ["src"]) + with fiona.open(dst) as edited: + self.assertEqual(edited.crs.to_epsg(), 32610) + + def test_source_file_is_not_modified(self): + src = write_inference_gpkg(self.path("src.gpkg"), [0.1, 0.9]) + before = read_rows(src) + + apply_edits( + src, self.path("dst.gpkg"), threshold=0.5, overrides={0: DAMAGED} + ) + + self.assertEqual(read_rows(src), before) + + def test_existing_output_is_replaced(self): + src = write_inference_gpkg(self.path("src.gpkg"), [0.9, 0.9, 0.9]) + dst = write_inference_gpkg(self.path("dst.gpkg"), [0.0]) + + summary = apply_edits(src, dst, threshold=0.5, overrides={}) + + self.assertEqual(summary.total_rows, 3) + self.assertEqual(len(read_rows(dst)), 3) + + +class TestOvertureIds(EditFixtureMixin): + def test_overture_ids_are_written(self): + src = write_inference_gpkg(self.path("src.gpkg"), [0.9, 0.1, 0.5]) + footprints = write_footprints_gpkg( + self.path("fp.gpkg"), ["ovt-a", "ovt-b", "ovt-c"] + ) + dst = self.path("dst.gpkg") + + apply_edits( + src, + dst, + threshold=0.5, + overrides={}, + footprints_path=footprints, + ) + + rows = read_rows(dst) + self.assertEqual( + [row[OVERTURE_ID_FIELD] for row in rows], + ["ovt-a", "ovt-b", "ovt-c"], + ) + + def test_overture_id_is_empty_without_footprints(self): + src = write_inference_gpkg(self.path("src.gpkg"), [0.9, 0.1]) + dst = self.path("dst.gpkg") + + apply_edits(src, dst, threshold=0.5, overrides={}) + + rows = read_rows(dst) + self.assertEqual([row[OVERTURE_ID_FIELD] for row in rows], ["", ""]) + + def test_footprint_length_mismatch_raises(self): + src = write_inference_gpkg(self.path("src.gpkg"), [0.9, 0.1, 0.5]) + footprints = write_footprints_gpkg( + self.path("fp.gpkg"), ["ovt-a", "ovt-b"] + ) + dst = self.path("dst.gpkg") + + with self.assertRaises(ValueError) as ctx: + apply_edits( + src, + dst, + threshold=0.5, + overrides={}, + footprints_path=footprints, + ) + + self.assertIn("mismatch", str(ctx.exception)) + self.assertFalse(os.path.exists(dst)) + + +class TestValidation(EditFixtureMixin): + def test_invalid_override_class_raises(self): + src = write_inference_gpkg(self.path("src.gpkg"), [0.9, 0.1]) + + with self.assertRaises(ValueError) as ctx: + apply_edits( + src, + self.path("dst.gpkg"), + threshold=0.5, + overrides={0: "destroyed"}, + ) + + message = str(ctx.exception) + self.assertIn("destroyed", message) + self.assertIn("Damaged", message) + self.assertFalse(os.path.exists(self.path("dst.gpkg"))) + + def test_invalid_override_row_index_raises(self): + src = write_inference_gpkg(self.path("src.gpkg"), [0.9, 0.1]) + + with self.assertRaises(ValueError) as ctx: + apply_edits( + src, + self.path("dst.gpkg"), + threshold=0.5, + overrides={"not-an-index": DAMAGED}, + ) + + self.assertIn("integer", str(ctx.exception)) + + def test_percentage_threshold_raises(self): + src = write_inference_gpkg(self.path("src.gpkg"), [0.9, 0.1]) + + with self.assertRaises(ValueError) as ctx: + apply_edits(src, self.path("dst.gpkg"), threshold=50, overrides={}) + + self.assertIn("fraction", str(ctx.exception)) + + def test_negative_unknown_threshold_raises(self): + src = write_inference_gpkg(self.path("src.gpkg"), [0.9, 0.1]) + + with self.assertRaises(ValueError): + apply_edits( + src, + self.path("dst.gpkg"), + threshold=0.5, + unknown_threshold=-0.1, + overrides={}, + ) + + @patch("hastegeo.core.processors.prediction_edits.derive_class") + def test_partial_output_is_removed_on_failure(self, derive): + derive.side_effect = RuntimeError("boom") + src = write_inference_gpkg(self.path("src.gpkg"), [0.9, 0.1]) + dst = self.path("dst.gpkg") + + with self.assertRaises(RuntimeError): + apply_edits(src, dst, threshold=0.5, overrides={}) + + self.assertFalse(os.path.exists(dst)) + + +class TestEditSummary(unittest.TestCase): + def test_to_dict(self): + summary = EditSummary( + total_rows=3, + counts={DAMAGED: 1, NOT_DAMAGED: 1, UNKNOWN: 1}, + overrides_applied=2, + ) + + self.assertEqual( + summary.to_dict(), + { + "totalRows": 3, + "counts": {DAMAGED: 1, NOT_DAMAGED: 1, UNKNOWN: 1}, + "overridesApplied": 2, + }, + ) + + +class TestNextVersion(unittest.TestCase): + def test_empty_model_starts_at_one(self): + self.assertEqual(next_version(Model(modelId="m1")), 1) + self.assertEqual(next_version({}), 1) + self.assertEqual(next_version({"editedPredictions": None}), 1) + + def test_single_existing_version(self): + model = Model( + modelId="m1", + editedPredictions=[ + EditedPredictionVersion( + version=1, + gpkgUrl="https://example/v1.gpkg", + createdAt="2026-08-21T00:00:00Z", + ) + ], + ) + + self.assertEqual(next_version(model), 2) + + def test_multiple_existing_versions_use_the_maximum(self): + model_doc = { + "editedPredictions": [ + {"version": 1, "gpkgUrl": "v1"}, + {"version": 3, "gpkgUrl": "v3"}, + {"version": 2, "gpkgUrl": "v2"}, + ] + } + + self.assertEqual(next_version(model_doc), 4) + + def test_non_integer_versions_are_ignored(self): + model_doc = { + "editedPredictions": [ + {"version": 1}, + {"version": None}, + {"gpkgUrl": "no-version"}, + ] + } + + self.assertEqual(next_version(model_doc), 2) + + +class TestStoreEditedVersion(EditFixtureMixin): + def test_artifact_name_embeds_model_and_version(self): + self.assertEqual( + edited_version_artifact_name("model-123", 4), + "edited_predictions_model-123_v4.gpkg", + ) + + def test_stores_and_returns_download_url(self): + gpkg = write_inference_gpkg(self.path("edited.gpkg"), [0.9]) + processor = MagicMock() + processor.get_download_url.return_value = "https://example/v2.gpkg" + + url = store_edited_version( + "project-1", + "model-123", + 2, + gpkg, + processor=processor, + ) + + self.assertEqual(url, "https://example/v2.gpkg") + processor.store_artifact.assert_called_once_with( + artifact_name="edited_predictions_model-123_v2.gpkg", + src_path=gpkg, + ) + processor.get_download_url.assert_called_once_with( + identifier="edited_predictions_model-123_v2.gpkg" + ) + + def test_missing_local_file_raises(self): + processor = MagicMock() + + with self.assertRaises(FileNotFoundError): + store_edited_version( + "project-1", + "model-123", + 1, + self.path("missing.gpkg"), + processor=processor, + ) + + processor.store_artifact.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/hastelib/tests/core/processors/test_prediction_tiles.py b/hastelib/tests/core/processors/test_prediction_tiles.py new file mode 100644 index 00000000..bb83523f --- /dev/null +++ b/hastelib/tests/core/processors/test_prediction_tiles.py @@ -0,0 +1,359 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Unit tests for the prediction-tiles processor. + +The processor is pure orchestration: it decides whether the footprint +tiles and/or the attribute sidecar still have to be built, submits the +job to the *training* container through the unified runner (tippecanoe +ships only there), and writes the resulting URLs back onto the model and +its image layer. Storage, runner and queue are mocked — no Azure, no +Batch and no tippecanoe are touched. +""" + +import json +import unittest +from unittest.mock import patch + +from hastegeo.core.config import Config +from hastegeo.core.models.projects import ImageLayer, Model, TrainingJob + +STATUSES = Config.get_status_types() + + +def _model(**overrides) -> Model: + data = { + "modelId": "model-1", + "projectId": "proj-1", + "imageLayerId": "layer-1", + "name": "test model", + "gpkgUrl": "https://acct.blob/c/hash/predicted_damage_m.gpkg?sas", + } + data.update(overrides) + return Model(**data) + + +def _layer(**overrides) -> ImageLayer: + data = { + "imageLayerId": "layer-1", + "projectId": "proj-1", + "buildingFootprintsUrl": ( + "https://acct.blob/c/hash/building_footprints_p_l.gpkg?sas" + ), + } + data.update(overrides) + return ImageLayer(**data) + + +def _build_preprocessor(model: Model, layer: ImageLayer): + with patch( + "hastegeo.core.processors.prediction_tiles.AzureQueueHandler", + autospec=True, + ): + from hastegeo.core.processors.prediction_tiles import ( + PredictionTilesPreprocessor, + ) + + return PredictionTilesPreprocessor(model, layer) + + +def _build_postprocessor(model: Model, layer: ImageLayer): + with patch( + "hastegeo.core.processors.prediction_tiles.UnifiedDataLayer", + autospec=True, + ), patch( + "hastegeo.core.processors.prediction_tiles.UnifiedRunner", + autospec=True, + ), patch( + "hastegeo.core.processors.prediction_tiles.AzureQueueHandler", + autospec=True, + ): + from hastegeo.core.processors.prediction_tiles import ( + PredictionTilesPostprocessor, + ) + + return PredictionTilesPostprocessor(model, layer) + + +class TestNeedsPreparation(unittest.TestCase): + def test_everything_needed_when_nothing_exists(self): + from hastegeo.core.processors.prediction_tiles import needs_preparation + + needs_pmtiles, needs_attrs = needs_preparation(_model(), _layer()) + self.assertTrue(needs_pmtiles) + self.assertTrue(needs_attrs) + + def test_tiles_are_reused_across_models_on_a_layer(self): + from hastegeo.core.processors.prediction_tiles import needs_preparation + + layer = _layer(footprintPmtilesUrl="https://acct/tiles.pmtiles") + needs_pmtiles, needs_attrs = needs_preparation(_model(), layer) + self.assertFalse(needs_pmtiles) + self.assertTrue(needs_attrs) + + +class TestPreprocessor(unittest.TestCase): + def test_enqueues_when_work_is_outstanding(self): + model = _model() + preprocessor = _build_preprocessor(model, _layer()) + + output = preprocessor.queue_for_processing() + + self.assertEqual(output.predictionTilesStatus, STATUSES.PENDING.value) + preprocessor.queue_client.put_message.assert_called_once() + payload = json.loads( + preprocessor.queue_client.put_message.call_args.args[0] + ) + self.assertEqual(payload["modelId"], "model-1") + # Documented prediction-edit-prep-queue message schema. + self.assertEqual( + set(payload), + { + "projectId", + "imageLayerId", + "modelId", + "sourceGpkgUrl", + "sourceFootprintsUrl", + "force", + }, + ) + self.assertEqual(payload["imageLayerId"], "layer-1") + self.assertEqual(payload["sourceGpkgUrl"], _model().gpkgUrl) + self.assertEqual( + payload["sourceFootprintsUrl"], + _layer().buildingFootprintsUrl, + ) + self.assertFalse(payload["force"]) + + def test_enqueue_helper_uses_the_prep_queue(self): + from hastegeo.core.processors import prediction_tiles + + with patch.object( + prediction_tiles, "AzureQueueHandler", autospec=True + ) as handler: + message = prediction_tiles.enqueue_prediction_tiles( + project_id="proj-1", + image_layer_id="layer-1", + model_id="model-1", + force=True, + ) + + queue_name = Config().queue_config["prediction_edit_prep_queue_name"] + self.assertEqual(handler.call_args.args[1], queue_name) + self.assertTrue(message["force"]) + handler.return_value.put_message.assert_called_once() + + def test_skips_when_both_artifacts_exist(self): + model = _model(predictionAttrsUrl="https://acct/attrs.json") + layer = _layer(footprintPmtilesUrl="https://acct/tiles.pmtiles") + preprocessor = _build_preprocessor(model, layer) + + output = preprocessor.queue_for_processing() + + self.assertEqual( + output.predictionTilesStatus, STATUSES.COMPLETED.value + ) + preprocessor.queue_client.put_message.assert_not_called() + + def test_force_rebuilds_existing_artifacts(self): + model = _model(predictionAttrsUrl="https://acct/attrs.json") + layer = _layer(footprintPmtilesUrl="https://acct/tiles.pmtiles") + preprocessor = _build_preprocessor(model, layer) + + output = preprocessor.queue_for_processing(force=True) + + self.assertEqual(output.predictionTilesStatus, STATUSES.PENDING.value) + preprocessor.queue_client.put_message.assert_called_once() + + def test_requires_predictions(self): + preprocessor = _build_preprocessor(_model(gpkgUrl=None), _layer()) + with self.assertRaises(ValueError): + preprocessor.queue_for_processing() + + def test_requires_building_footprints(self): + preprocessor = _build_preprocessor( + _model(), _layer(buildingFootprintsUrl=None) + ) + with self.assertRaises(ValueError): + preprocessor.queue_for_processing() + + +class TestPostprocessorSubmission(unittest.TestCase): + def test_submits_to_the_training_image(self): + model = _model( + predictionTilesStatus=STATUSES.PENDING.value, + ) + processor = _build_postprocessor(model, _layer()) + processor.runner.add_task.return_value = ("job-1", "ptl-abc") + processor.storage.get_file_remote_path.return_value = ( + "https://acct.blob/c/hash/prediction_tiles_config_m.json?sas" + ) + + output = processor.process() + + self.assertEqual( + output.predictionTilesStatus, STATUSES.IN_PROGRESS.value + ) + self.assertEqual(output.predictionTilesJob.taskId, "ptl-abc") + kwargs = processor.runner.add_task.call_args.kwargs + self.assertIn( + "python -m hastegeo.workflows.prepare_prediction_tiles", + kwargs["command"], + ) + # The training image is the only one carrying tippecanoe. + self.assertEqual( + kwargs["image_name"], + Config().get_azure_batch_config()["docker_image"], + ) + self.assertIn("footprints", kwargs["resource_files_for_upload"]) + self.assertIn("predictions", kwargs["resource_files_for_upload"]) + # Re-enqueued so the next poll advances the state machine. + processor.queue_client.put_message.assert_called_once() + + def test_submission_failure_marks_the_model_failed(self): + model = _model(predictionTilesStatus=STATUSES.PENDING.value) + processor = _build_postprocessor(model, _layer()) + processor.storage.save.side_effect = RuntimeError("storage down") + + output = processor.process() + + self.assertEqual(output.predictionTilesStatus, STATUSES.FAILED.value) + self.assertIn("failed", output.predictionTilesStatusMessage.lower()) + + def test_config_skips_tiles_when_the_layer_has_them(self): + model = _model(predictionTilesStatus=STATUSES.PENDING.value) + layer = _layer(footprintPmtilesUrl="https://acct/tiles.pmtiles") + processor = _build_postprocessor(model, layer) + processor.runner.add_task.return_value = ("job-1", "ptl-abc") + processor.storage.get_file_remote_path.return_value = ( + "https://acct.blob/c/hash/prediction_tiles_config_m.json?sas" + ) + + processor.process() + + workflow_config = processor.storage.save.call_args.kwargs["data"] + self.assertFalse(workflow_config["tiles"]["build_pmtiles"]) + self.assertEqual( + workflow_config["files"]["attrs"], + "prediction_attrs_model-1.json", + ) + self.assertEqual( + workflow_config["files"]["pmtiles"], + "footprints_layer-1.pmtiles", + ) + + +class TestPostprocessorCompletion(unittest.TestCase): + def _completed_processor(self, manifest: dict): + model = _model( + predictionTilesStatus=STATUSES.IN_PROGRESS.value, + predictionTilesJob=TrainingJob( + jobId="job-1", + taskId="ptl-abc", + modelId="model-1", + projectId="proj-1", + status=STATUSES.IN_PROGRESS.value, + ), + ) + processor = _build_postprocessor(model, _layer()) + processor.runner.get_task_status.return_value = ( + STATUSES.COMPLETED.value + ) + + def _file_content(job_id, task_id, filename): + if filename.endswith(".json"): + return json.dumps(manifest) + return "2026-08-21T00:00:00+00:00|Building prediction attributes" + + processor.runner.get_filecontent_from_task.side_effect = _file_content + return processor + + def test_persists_urls_counts_and_timestamp(self): + processor = self._completed_processor( + { + "pmtiles_built": True, + "pmtiles_filename": "footprints_layer-1.pmtiles", + "pmtiles_url": "https://acct/footprints_layer-1.pmtiles", + "attrs_filename": "prediction_attrs_model-1.json", + "attrs_url": "https://acct/prediction_attrs_model-1.json", + "building_count": 1234, + } + ) + + output = processor.process() + + self.assertEqual( + output.predictionTilesStatus, STATUSES.COMPLETED.value + ) + self.assertEqual( + output.predictionAttrsUrl, + "https://acct/prediction_attrs_model-1.json", + ) + self.assertEqual(output.predictedBuildingCount, 1234) + self.assertTrue(output.predictedAt) + # Tiles belong to the layer, not the model. + self.assertEqual( + processor.image_layer.footprintPmtilesUrl, + "https://acct/footprints_layer-1.pmtiles", + ) + processor.runner.cleanup_task.assert_called_once() + + def test_resolves_urls_from_task_outputs_when_manifest_has_none(self): + processor = self._completed_processor( + { + "pmtiles_built": False, + "pmtiles_filename": "", + "pmtiles_url": None, + "attrs_filename": "prediction_attrs_model-1.json", + "attrs_url": None, + "building_count": 7, + } + ) + processor.storage.get_file_remote_path.return_value = ( + "https://acct/hash/ptl-abc/prediction_attrs_model-1.json?sas" + ) + + output = processor.process() + + self.assertEqual( + output.predictionAttrsUrl, + "https://acct/hash/ptl-abc/prediction_attrs_model-1.json?sas", + ) + # No tiles were built, so the layer keeps its (empty) value. + self.assertIsNone(processor.image_layer.footprintPmtilesUrl) + + def test_missing_manifest_fails_the_job(self): + processor = self._completed_processor({}) + processor.runner.get_filecontent_from_task.side_effect = None + processor.runner.get_filecontent_from_task.return_value = None + + output = processor.process() + + self.assertEqual(output.predictionTilesStatus, STATUSES.FAILED.value) + + def test_running_task_is_requeued(self): + processor = self._completed_processor({}) + processor.runner.get_task_status.return_value = ( + STATUSES.IN_PROGRESS.value + ) + + output = processor.process() + + self.assertEqual( + output.predictionTilesStatus, STATUSES.IN_PROGRESS.value + ) + processor.queue_client.put_message.assert_called_once() + processor.runner.cleanup_task.assert_not_called() + + def test_failed_task_is_reported(self): + processor = self._completed_processor({}) + processor.runner.get_task_status.return_value = STATUSES.FAILED.value + + output = processor.process() + + self.assertEqual(output.predictionTilesStatus, STATUSES.FAILED.value) + processor.runner.cleanup_task.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/hastelib/tests/core/processors/test_prediction_tiles_request.py b/hastelib/tests/core/processors/test_prediction_tiles_request.py new file mode 100644 index 00000000..c7d8fcfc --- /dev/null +++ b/hastelib/tests/core/processors/test_prediction_tiles_request.py @@ -0,0 +1,244 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Unit tests for the prediction-tiles HTTP request seam. + +``request_preparation`` is what ``PutPreparePredictionTilesQueueMessage`` +delegates to: it decides whether the footprint PMTiles and/or the +attribute sidecar still have to be built, enqueues at most one job, and +reports the state the editor polls for. The queue is mocked — no Azure +and no tippecanoe are touched. +""" + +import json +import unittest +from unittest.mock import patch + +from hastegeo.core.config import Config +from hastegeo.core.models.projects import ImageLayer, Model + +STATUSES = Config.get_status_types() + +PMTILES_URL = "https://acct.blob/c/hash/footprints_layer-1.pmtiles?sas" +ATTRS_URL = "https://acct.blob/c/hash/prediction_attrs_model-1.json?sas" + + +def _model(**overrides) -> Model: + data = { + "modelId": "model-1", + "projectId": "proj-1", + "imageLayerId": "layer-1", + "name": "test model", + "gpkgUrl": "https://acct.blob/c/hash/predicted_damage_m.gpkg?sas", + } + data.update(overrides) + return Model(**data) + + +def _layer(**overrides) -> ImageLayer: + data = { + "imageLayerId": "layer-1", + "projectId": "proj-1", + "buildingFootprintsUrl": ( + "https://acct.blob/c/hash/building_footprints_p_l.gpkg?sas" + ), + } + data.update(overrides) + return ImageLayer(**data) + + +def _request(model: Model, layer: ImageLayer, force: bool = False): + """Call the seam with the prep queue mocked out. + + Returns ``(result, queue_handler_mock)`` so callers can assert both + on the response payload and on what was (not) enqueued. + """ + from hastegeo.core.processors import prediction_tiles + + with patch.object( + prediction_tiles, "AzureQueueHandler", autospec=True + ) as handler: + result = prediction_tiles.request_preparation( + model, layer, force=force + ) + return result, handler + + +class TestRequestPreparation(unittest.TestCase): + def test_queues_when_nothing_is_prepared(self): + model = _model() + + result, handler = _request(model, _layer()) + + self.assertTrue(result["queued"]) + self.assertFalse(result["tilesReady"]) + self.assertFalse(result["attrsReady"]) + self.assertEqual(result["modelId"], "model-1") + self.assertEqual(result["status"], STATUSES.PENDING.value) + self.assertIn( + "Queued for prediction tile preparation", result["statusMessage"] + ) + # The model is mutated in place for the caller to persist. + self.assertEqual(model.predictionTilesStatus, STATUSES.PENDING.value) + + handler.return_value.put_message.assert_called_once() + payload = json.loads( + handler.return_value.put_message.call_args.args[0] + ) + self.assertEqual( + set(payload), + { + "projectId", + "imageLayerId", + "modelId", + "sourceGpkgUrl", + "sourceFootprintsUrl", + "force", + }, + ) + self.assertEqual(payload["projectId"], "proj-1") + self.assertEqual(payload["imageLayerId"], "layer-1") + self.assertEqual(payload["modelId"], "model-1") + self.assertEqual(payload["sourceGpkgUrl"], _model().gpkgUrl) + self.assertEqual( + payload["sourceFootprintsUrl"], _layer().buildingFootprintsUrl + ) + self.assertFalse(payload["force"]) + + def test_uses_the_prediction_edit_prep_queue(self): + _, handler = _request(_model(), _layer()) + + queue_name = Config().queue_config["prediction_edit_prep_queue_name"] + self.assertEqual(handler.call_args.args[1], queue_name) + + def test_skips_when_both_artifacts_exist(self): + model = _model(predictionAttrsUrl=ATTRS_URL) + layer = _layer(footprintPmtilesUrl=PMTILES_URL) + + result, handler = _request(model, layer) + + self.assertFalse(result["queued"]) + self.assertTrue(result["tilesReady"]) + self.assertTrue(result["attrsReady"]) + self.assertEqual(result["status"], STATUSES.COMPLETED.value) + handler.return_value.put_message.assert_not_called() + + def test_repeat_request_on_a_ready_model_is_a_no_op(self): + # The editor may ask on every open; the status message must not + # grow a line per visit. + model = _model( + predictionAttrsUrl=ATTRS_URL, + predictionTilesStatus=STATUSES.COMPLETED.value, + predictionTilesStatusMessage=( + "\n2026-01-01: Prediction tiles already available" + ), + ) + layer = _layer(footprintPmtilesUrl=PMTILES_URL) + + result, handler = _request(model, layer) + + self.assertFalse(result["queued"]) + self.assertEqual( + model.predictionTilesStatusMessage, + "\n2026-01-01: Prediction tiles already available", + ) + handler.return_value.put_message.assert_not_called() + + def test_force_rebuilds_ready_artifacts(self): + model = _model(predictionAttrsUrl=ATTRS_URL) + layer = _layer(footprintPmtilesUrl=PMTILES_URL) + + result, handler = _request(model, layer, force=True) + + self.assertTrue(result["queued"]) + self.assertEqual(result["status"], STATUSES.PENDING.value) + handler.return_value.put_message.assert_called_once() + payload = json.loads( + handler.return_value.put_message.call_args.args[0] + ) + self.assertTrue(payload["force"]) + + def test_queues_when_only_the_sidecar_is_missing(self): + # Footprint tiles are shared by every model on a layer, so a + # second model on a prepared layer still needs its own sidecar. + layer = _layer(footprintPmtilesUrl=PMTILES_URL) + + result, handler = _request(_model(), layer) + + self.assertTrue(result["queued"]) + self.assertTrue(result["tilesReady"]) + self.assertFalse(result["attrsReady"]) + handler.return_value.put_message.assert_called_once() + + def test_does_not_requeue_an_in_flight_job(self): + # A second PUT while the Batch task runs must not submit a + # duplicate job; the caller just polls the status it gets back. + model = _model( + predictionTilesStatus=STATUSES.IN_PROGRESS.value, + predictionTilesStatusMessage="\n2026-01-01: Submitted", + ) + + result, handler = _request(model, _layer()) + + self.assertFalse(result["queued"]) + self.assertEqual(result["status"], STATUSES.IN_PROGRESS.value) + self.assertIn("Submitted", result["statusMessage"]) + handler.return_value.put_message.assert_not_called() + + def test_does_not_requeue_a_pending_job(self): + model = _model(predictionTilesStatus=STATUSES.PENDING.value) + + result, handler = _request(model, _layer()) + + self.assertFalse(result["queued"]) + self.assertEqual(result["status"], STATUSES.PENDING.value) + handler.return_value.put_message.assert_not_called() + + def test_force_overrides_an_in_flight_job(self): + model = _model(predictionTilesStatus=STATUSES.IN_PROGRESS.value) + + result, handler = _request(model, _layer(), force=True) + + self.assertTrue(result["queued"]) + self.assertEqual(result["status"], STATUSES.PENDING.value) + handler.return_value.put_message.assert_called_once() + + def test_retries_a_failed_job(self): + model = _model(predictionTilesStatus=STATUSES.FAILED.value) + + result, handler = _request(model, _layer()) + + self.assertTrue(result["queued"]) + self.assertEqual(result["status"], STATUSES.PENDING.value) + handler.return_value.put_message.assert_called_once() + + def test_requires_predictions(self): + from hastegeo.core.processors.prediction_tiles import ( + PredictionTilesUnavailableError, + ) + + with self.assertRaises(PredictionTilesUnavailableError): + _request(_model(gpkgUrl=None), _layer()) + + def test_requires_building_footprints(self): + from hastegeo.core.processors.prediction_tiles import ( + PredictionTilesUnavailableError, + ) + + with self.assertRaises(PredictionTilesUnavailableError): + _request(_model(), _layer(buildingFootprintsUrl=None)) + + def test_unavailable_error_is_a_value_error(self): + # The HTTP layer maps it to 404; keeping it a ValueError means an + # older caller's `except ValueError` still behaves. + from hastegeo.core.processors.prediction_tiles import ( + PredictionTilesUnavailableError, + ) + + self.assertTrue( + issubclass(PredictionTilesUnavailableError, ValueError) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/hastelib/tests/core/utils/test_predictions.py b/hastelib/tests/core/utils/test_predictions.py new file mode 100644 index 00000000..077ac557 --- /dev/null +++ b/hastelib/tests/core/utils/test_predictions.py @@ -0,0 +1,331 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for hastegeo.core.utils.predictions. + +Builds tiny synthetic GeoPackages for both prediction producers (the +trained-inference merge script and the interactive building labeler) and +checks that the reader normalises them identically. +""" + +import os +import shutil +import tempfile +import unittest +from unittest.mock import patch + +import fiona +import geopandas as gpd +from fiona.crs import CRS +from fiona.model import Feature +from hastegeo.core.utils.predictions import ( + EMBEDDING_FLAVOR, + INFERENCE_FLAVOR, + read_footprint_ids, + read_predictions, +) +from shapely.geometry import Polygon + +INFERENCE_SCHEMA = { + "geometry": "MultiPolygon", + "properties": { + "id": "int", + "damage_pct_0m": "float", + "damage_pct_10m": "float", + "damage_pct_20m": "float", + "damaged": "int", + "unknown_pct": "float", + }, +} + + +def square(index: int) -> Polygon: + """Return a unit square offset along x so rows stay distinguishable.""" + x = float(index) + return Polygon([(x, 0), (x + 1, 0), (x + 1, 1), (x, 1)]) + + +def multipolygon_mapping(index: int) -> dict: + x = float(index) + ring = [(x, 0.0), (x + 1, 0.0), (x + 1, 1.0), (x, 1.0), (x, 0.0)] + return {"type": "MultiPolygon", "coordinates": [[ring]]} + + +def write_inference_gpkg( + path: str, + damage_values: list, + unknown_values: list = None, + layer: str = None, + epsg: int = 32610, +) -> str: + """Write a GeoPackage shaped like merge_with_building_footprints.py.""" + unknown_values = unknown_values or [0.0] * len(damage_values) + with fiona.open( + path, + "w", + driver="GPKG", + crs=CRS.from_epsg(epsg), + schema=INFERENCE_SCHEMA, + layer=layer, + ) as dst: + for index, damage in enumerate(damage_values): + dst.write( + Feature.from_dict( + **{ + "type": "Feature", + "geometry": multipolygon_mapping(index), + "properties": { + "id": index, + "damage_pct_0m": damage, + "damage_pct_10m": damage, + "damage_pct_20m": damage, + "damaged": 1 if damage > 0 else 0, + "unknown_pct": unknown_values[index], + }, + } + ) + ) + return path + + +def write_embedding_gpkg( + path: str, + damaged_values: list, + unknown_values: list = None, + epsg: int = 4326, +) -> str: + """Write a GeoPackage shaped like the interactive labeler's output.""" + unknown_values = unknown_values or [0.0] * len(damaged_values) + frame = gpd.GeoDataFrame( + { + "id": list(range(len(damaged_values))), + "damaged": damaged_values, + "damage_pct_0m": [float(d) for d in damaged_values], + "unknown_pct": unknown_values, + "area": [100.0] * len(damaged_values), + "geometry": [square(i) for i in range(len(damaged_values))], + }, + crs=f"EPSG:{epsg}", + ) + frame.to_file(path, layer="predictions", driver="GPKG") + return path + + +def write_footprints_gpkg(path: str, ids: list, epsg: int = 4326) -> str: + """Write a footprints GeoPackage carrying Overture string ids.""" + frame = gpd.GeoDataFrame( + { + "id": ids, + "subtype": ["residential"] * len(ids), + "class": ["house"] * len(ids), + "geometry": [square(i) for i in range(len(ids))], + }, + crs=f"EPSG:{epsg}", + ) + frame.to_file(path, driver="GPKG") + return path + + +class PredictionFixtureMixin(unittest.TestCase): + """Temp-dir lifecycle shared by the prediction reader tests.""" + + def setUp(self): + self.tmp_dir = tempfile.mkdtemp(prefix="haste-predictions-") + + def tearDown(self): + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def path(self, name: str) -> str: + return os.path.join(self.tmp_dir, name) + + +class TestFlavorDetection(PredictionFixtureMixin): + def test_inference_flavor(self): + path = write_inference_gpkg( + self.path("inference.gpkg"), [0.0, 0.25, 0.9, 1.0] + ) + + predictions = read_predictions(path) + + self.assertEqual(predictions.flavor, INFERENCE_FLAVOR) + self.assertTrue(predictions.supports_threshold) + self.assertEqual(predictions.layer_name, "inference") + self.assertEqual(len(predictions), 4) + + def test_embedding_flavor_from_layer_and_area(self): + path = write_embedding_gpkg( + self.path("embedding.gpkg"), [0, 1, 1, 0, 1] + ) + + predictions = read_predictions(path) + + self.assertEqual(predictions.flavor, EMBEDDING_FLAVOR) + self.assertFalse(predictions.supports_threshold) + self.assertEqual(predictions.layer_name, "predictions") + + def test_embedding_flavor_from_degenerate_damage_values(self): + # Inference-shaped layer (no area column, different layer name) + # whose damage fractions are only ever 0.0/1.0 is still the + # labeler's degenerate output. + path = write_inference_gpkg( + self.path("degenerate.gpkg"), [0.0, 1.0, 1.0, 0.0] + ) + + predictions = read_predictions(path) + + self.assertEqual(predictions.flavor, EMBEDDING_FLAVOR) + self.assertFalse(predictions.supports_threshold) + + def test_single_fractional_value_keeps_inference_flavor(self): + path = write_inference_gpkg( + self.path("mostly-binary.gpkg"), [0.0, 1.0, 0.5, 1.0] + ) + + predictions = read_predictions(path) + + self.assertEqual(predictions.flavor, INFERENCE_FLAVOR) + + def test_empty_layer_defaults_to_inference(self): + path = write_inference_gpkg(self.path("empty.gpkg"), []) + + predictions = read_predictions(path) + + self.assertEqual(predictions.rows, []) + self.assertEqual(predictions.flavor, INFERENCE_FLAVOR) + + +class TestReadPredictions(PredictionFixtureMixin): + def test_rows_follow_file_order(self): + damage = [0.9, 0.0, 0.4, 0.1, 0.75] + path = write_inference_gpkg(self.path("ordered.gpkg"), damage) + + predictions = read_predictions(path) + + self.assertEqual( + [row.row_index for row in predictions.rows], list(range(5)) + ) + self.assertEqual( + [row.damage_fraction for row in predictions.rows], damage + ) + + def test_normalises_both_producers_to_same_fields(self): + inference = read_predictions( + write_inference_gpkg(self.path("a.gpkg"), [0.0, 0.6]) + ) + embedding = read_predictions( + write_embedding_gpkg(self.path("b.gpkg"), [0, 1]) + ) + + self.assertEqual( + [row.damaged for row in inference.rows], + [row.damaged for row in embedding.rows], + ) + self.assertEqual( + [row.unknown_fraction for row in embedding.rows], [0.0, 0.0] + ) + + def test_unknown_fraction_is_read(self): + path = write_inference_gpkg( + self.path("unknown.gpkg"), + [0.0, 0.5, 0.5], + unknown_values=[0.0, 0.3, 1.0], + ) + + predictions = read_predictions(path) + + self.assertEqual( + [row.unknown_fraction for row in predictions.rows], + [0.0, 0.3, 1.0], + ) + + def test_crs_is_preserved(self): + path = write_inference_gpkg( + self.path("crs.gpkg"), [0.0, 0.5], epsg=32610 + ) + + predictions = read_predictions(path) + + self.assertEqual(predictions.crs.to_epsg(), 32610) + + def test_missing_footprints_leaves_ids_unset(self): + path = write_inference_gpkg(self.path("noids.gpkg"), [0.0, 0.5]) + + predictions = read_predictions(path) + + self.assertTrue( + all(row.overture_id is None for row in predictions.rows) + ) + + @patch("hastegeo.core.utils.predictions.fiona.listlayers", return_value=[]) + def test_no_layers_raises(self, _listlayers): + with self.assertRaises(ValueError) as ctx: + read_predictions(self.path("layerless.gpkg")) + + self.assertIn("no layers", str(ctx.exception)) + + +class TestOvertureIdResolution(PredictionFixtureMixin): + def test_ids_resolved_positionally(self): + overture_ids = ["ovt-a", "ovt-b", "ovt-c"] + predictions_path = write_inference_gpkg( + self.path("preds.gpkg"), [0.0, 0.5, 1.0] + ) + footprints_path = write_footprints_gpkg( + self.path("footprints.gpkg"), overture_ids + ) + + predictions = read_predictions( + predictions_path, footprints_path=footprints_path + ) + + self.assertEqual( + [row.overture_id for row in predictions.rows], overture_ids + ) + + def test_ids_resolved_for_embedding_flavor(self): + overture_ids = ["ovt-1", "ovt-2"] + predictions_path = write_embedding_gpkg(self.path("emb.gpkg"), [1, 0]) + footprints_path = write_footprints_gpkg( + self.path("fp.gpkg"), overture_ids + ) + + predictions = read_predictions( + predictions_path, footprints_path=footprints_path + ) + + self.assertEqual( + [row.overture_id for row in predictions.rows], overture_ids + ) + + def test_length_mismatch_raises(self): + predictions_path = write_inference_gpkg( + self.path("preds.gpkg"), [0.0, 0.5, 1.0] + ) + footprints_path = write_footprints_gpkg( + self.path("footprints.gpkg"), ["ovt-a", "ovt-b"] + ) + + with self.assertRaises(ValueError) as ctx: + read_predictions(predictions_path, footprints_path=footprints_path) + + message = str(ctx.exception) + self.assertIn("2 footprints", message) + self.assertIn("3 predictions", message) + self.assertIn("positional", message) + + def test_footprints_without_id_column_raises(self): + path = self.path("bad-footprints.gpkg") + frame = gpd.GeoDataFrame( + {"name": ["a", "b"], "geometry": [square(0), square(1)]}, + crs="EPSG:4326", + ) + frame.to_file(path, driver="GPKG") + + with self.assertRaises(ValueError) as ctx: + read_footprint_ids(path) + + self.assertIn("'id' column", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/hastelib/tests/workflows/test_prepare_prediction_tiles.py b/hastelib/tests/workflows/test_prepare_prediction_tiles.py new file mode 100644 index 00000000..4e9c9232 --- /dev/null +++ b/hastelib/tests/workflows/test_prepare_prediction_tiles.py @@ -0,0 +1,536 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for the prediction-tiles data-preparation workflow. + +The workflow has two halves: + +* the **attribute sidecar** builder, which is pure geospatial I/O and is + exercised here against small synthetic GeoPackages written to a temp + dir — in BOTH prediction flavors (trained-inference merge output and + the interactive labeler's ``predictions`` layer); +* the **PMTiles** builder, which shells out to ``tippecanoe``. That + binary ships only in the training docker image, so every test here + either mocks the subprocess call or asserts on the actionable error + raised when the binary is missing. Nothing in this file requires + tippecanoe to be installed. +""" + +import json +import os +import shutil +import subprocess +import tempfile +import unittest +from unittest import mock + +import fiona +from hastegeo.workflows import prepare_prediction_tiles as ppt +from shapely.geometry import Polygon, mapping + +FOOTPRINT_SCHEMA = { + "geometry": "Polygon", + "properties": {"id": "str", "subtype": "str", "class": "str"}, +} +# merge_with_building_footprints.py's output schema (trained inference). +TRAINED_SCHEMA = { + "geometry": "Polygon", + "properties": { + "id": "int", + "damage_pct_0m": "float", + "damage_pct_10m": "float", + "damage_pct_20m": "float", + "damaged": "int", + "unknown_pct": "float", + }, +} +# PutBuildingPredictions' output schema (interactive labeler). +EMBEDDING_SCHEMA = { + "geometry": "Polygon", + "properties": { + "id": "int", + "damaged": "int", + "damage_pct_0m": "float", + "unknown_pct": "float", + "area": "float", + }, +} + + +def _square(index: int) -> Polygon: + """A 1e-4 degree square offset by ``index`` so squares never overlap.""" + x = -122.0 + index * 0.001 + y = 47.0 + index * 0.001 + return Polygon( + [(x, y), (x + 0.0001, y), (x + 0.0001, y + 0.0001), (x, y + 0.0001)] + ) + + +def write_footprints(path: str, count: int, crs: str = "EPSG:4326") -> None: + """Write a synthetic Overture-shaped footprints GeoPackage.""" + with fiona.open( + path, "w", driver="GPKG", crs=crs, schema=FOOTPRINT_SCHEMA + ) as dst: + for index in range(count): + dst.write( + { + "geometry": mapping(_square(index)), + "properties": { + "id": f"overture-{index}", + "subtype": "residential", + "class": "house", + }, + } + ) + + +def write_trained_predictions( + path: str, damages: list, unknowns: list, crs: str = "EPSG:32610" +) -> None: + """Write a trained-inference prediction GeoPackage (default layer). + + Written in a projected CRS on purpose: the real merge script writes + in the raster CRS, not EPSG:4326. + """ + with fiona.open( + path, "w", driver="GPKG", crs=crs, schema=TRAINED_SCHEMA + ) as dst: + for index, damage in enumerate(damages): + dst.write( + { + "geometry": mapping(_square(index)), + "properties": { + "id": index, + "damage_pct_0m": damage, + "damage_pct_10m": damage, + "damage_pct_20m": damage, + "damaged": 1 if damage > 0 else 0, + "unknown_pct": unknowns[index], + }, + } + ) + + +def write_embedding_predictions( + path: str, damaged_flags: list, unknowns: list +) -> None: + """Write an interactive-labeler prediction GeoPackage.""" + with fiona.open( + path, + "w", + driver="GPKG", + crs="EPSG:4326", + schema=EMBEDDING_SCHEMA, + layer="predictions", + ) as dst: + for index, flag in enumerate(damaged_flags): + dst.write( + { + "geometry": mapping(_square(index)), + "properties": { + "id": index, + "damaged": int(flag), + "damage_pct_0m": float(flag), + "unknown_pct": unknowns[index], + "area": 100.0, + }, + } + ) + + +class TestBuildPredictionAttrs(unittest.TestCase): + """The columnar sidecar, against both prediction GPKG flavors.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix="haste-pred-tiles-") + self.addCleanup(shutil.rmtree, self.tmpdir, True) + self.footprints = os.path.join(self.tmpdir, "footprints.gpkg") + self.predictions = os.path.join(self.tmpdir, "predictions.gpkg") + + def test_trained_flavor_columns_align(self): + damages = [0.0, 0.25, 0.9, 1.0] + unknowns = [0.0, 0.1, 0.0, 0.5] + write_footprints(self.footprints, len(damages)) + write_trained_predictions(self.predictions, damages, unknowns) + + payload = ppt.build_prediction_attrs(self.predictions, self.footprints) + + self.assertEqual(payload["n"], 4) + self.assertEqual(payload["ids"], [0, 1, 2, 3]) + self.assertEqual(payload["damage"], damages) + self.assertEqual(payload["unknown"], unknowns) + self.assertEqual(payload["damaged"], [0, 1, 1, 1]) + self.assertEqual( + payload["overtureIds"], + ["overture-0", "overture-1", "overture-2", "overture-3"], + ) + + def test_embedding_flavor_columns_align(self): + flags = [1, 0, 1] + unknowns = [0.0, 0.0, 0.25] + write_footprints(self.footprints, len(flags)) + write_embedding_predictions(self.predictions, flags, unknowns) + + payload = ppt.build_prediction_attrs(self.predictions, self.footprints) + + self.assertEqual(payload["n"], 3) + self.assertEqual(payload["ids"], [0, 1, 2]) + # damage_pct_0m is a degenerate 0.0/1.0 copy of `damaged` here. + self.assertEqual(payload["damage"], [1.0, 0.0, 1.0]) + self.assertEqual(payload["damaged"], flags) + self.assertEqual(payload["unknown"], unknowns) + self.assertEqual( + payload["overtureIds"], + ["overture-0", "overture-1", "overture-2"], + ) + + def test_all_arrays_have_equal_length(self): + damages = [0.1, 0.2, 0.3, 0.4, 0.5] + unknowns = [0.0] * 5 + write_footprints(self.footprints, len(damages)) + write_trained_predictions(self.predictions, damages, unknowns) + + payload = ppt.build_prediction_attrs(self.predictions, self.footprints) + + lengths = { + key: len(payload[key]) + for key in ("ids", "overtureIds", "damage", "unknown", "damaged") + } + self.assertEqual(set(lengths.values()), {payload["n"]}) + + def test_rows_are_ordered_by_row_index(self): + # A strictly increasing damage ramp makes any reordering visible. + damages = [i / 10.0 for i in range(10)] + unknowns = [0.0] * 10 + write_footprints(self.footprints, len(damages)) + write_trained_predictions(self.predictions, damages, unknowns) + + payload = ppt.build_prediction_attrs(self.predictions, self.footprints) + + self.assertEqual(payload["ids"], sorted(payload["ids"])) + self.assertEqual(payload["damage"], damages) + for index, overture_id in enumerate(payload["overtureIds"]): + self.assertEqual(overture_id, f"overture-{index}") + + def test_overture_ids_follow_footprint_row_order(self): + # Footprint ids deliberately NOT sorted lexicographically, so a + # sort-by-id bug would be caught. + with fiona.open( + self.footprints, + "w", + driver="GPKG", + crs="EPSG:4326", + schema=FOOTPRINT_SCHEMA, + ) as dst: + for index, oid in enumerate(["zeta", "alpha", "mu"]): + dst.write( + { + "geometry": mapping(_square(index)), + "properties": { + "id": oid, + "subtype": "residential", + "class": "house", + }, + } + ) + write_trained_predictions( + self.predictions, [0.0, 0.5, 1.0], [0.0, 0.0, 0.0] + ) + + payload = ppt.build_prediction_attrs(self.predictions, self.footprints) + + self.assertEqual(payload["overtureIds"], ["zeta", "alpha", "mu"]) + self.assertEqual(payload["damage"], [0.0, 0.5, 1.0]) + + def test_length_mismatch_raises_clear_error(self): + write_footprints(self.footprints, 5) + write_trained_predictions( + self.predictions, [0.0, 1.0, 0.5], [0.0, 0.0, 0.0] + ) + + with self.assertRaises(ppt.FootprintPredictionMismatchError) as caught: + ppt.build_prediction_attrs(self.predictions, self.footprints) + + message = str(caught.exception) + self.assertIn("3 predictions", message) + self.assertIn("5 footprints", message) + self.assertIn("positional", message) + + def test_length_mismatch_is_a_value_error(self): + # Callers (and the queue trigger) catch ValueError; keep that + # contract even though the subclass carries the detail. + write_footprints(self.footprints, 2) + write_embedding_predictions(self.predictions, [1], [0.0]) + + with self.assertRaises(ValueError): + ppt.build_prediction_attrs(self.predictions, self.footprints) + + def test_write_prediction_attrs_round_trips_json(self): + damages = [0.0, 0.75] + unknowns = [0.125, 0.0] + write_footprints(self.footprints, len(damages)) + write_trained_predictions(self.predictions, damages, unknowns) + attrs_path = os.path.join(self.tmpdir, "attrs.json") + + payload = ppt.write_prediction_attrs( + self.predictions, self.footprints, attrs_path + ) + + with open(attrs_path) as handle: + on_disk = json.load(handle) + self.assertEqual(on_disk, payload) + self.assertEqual( + sorted(on_disk), + ["damage", "damaged", "ids", "n", "overtureIds", "unknown"], + ) + + +class TestFootprintTiling(unittest.TestCase): + """Tiling inputs: CRS handling, id emission, tippecanoe invocation.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix="haste-pred-tiles-") + self.addCleanup(shutil.rmtree, self.tmpdir, True) + self.footprints = os.path.join(self.tmpdir, "footprints.gpkg") + self.geojson = os.path.join(self.tmpdir, "footprints_4326.geojson") + + def test_geojson_carries_row_index_and_overture_id(self): + write_footprints(self.footprints, 3) + + count = ppt.footprints_to_tiling_geojson(self.footprints, self.geojson) + + self.assertEqual(count, 3) + with open(self.geojson) as handle: + collection = json.load(handle) + properties = [f["properties"] for f in collection["features"]] + self.assertEqual([p["id"] for p in properties], [0, 1, 2]) + self.assertEqual( + [p["overture_id"] for p in properties], + ["overture-0", "overture-1", "overture-2"], + ) + # Damage values must NOT ride in the tiles. + for props in properties: + self.assertEqual(set(props), {"id", "overture_id"}) + + def test_projected_footprints_are_reprojected_to_4326(self): + # UTM 10N coordinates; tiles must always come out geographic. + with fiona.open( + self.footprints, + "w", + driver="GPKG", + crs="EPSG:32610", + schema=FOOTPRINT_SCHEMA, + ) as dst: + for index in range(2): + dst.write( + { + "geometry": mapping( + Polygon( + [ + (500000 + index * 10, 5200000), + (500010 + index * 10, 5200000), + (500010 + index * 10, 5200010), + (500000 + index * 10, 5200010), + ] + ) + ), + "properties": { + "id": f"overture-{index}", + "subtype": "residential", + "class": "house", + }, + } + ) + + ppt.footprints_to_tiling_geojson(self.footprints, self.geojson) + + with fiona.open(self.geojson) as src: + self.assertEqual(src.crs.to_epsg(), 4326) + longitudes = [ + feature["geometry"]["coordinates"][0][0][0] for feature in src + ] + for longitude in longitudes: + self.assertTrue(-180.0 <= longitude <= 180.0) + + def test_footprints_without_crs_are_rejected(self): + with fiona.open( + self.footprints, + "w", + driver="GPKG", + crs=None, + schema=FOOTPRINT_SCHEMA, + ) as dst: + dst.write( + { + "geometry": mapping(_square(0)), + "properties": { + "id": "overture-0", + "subtype": None, + "class": None, + }, + } + ) + + with self.assertRaises(ValueError) as caught: + ppt.footprints_to_tiling_geojson(self.footprints, self.geojson) + self.assertIn("no CRS", str(caught.exception)) + + def test_missing_tippecanoe_raises_actionable_error(self): + with mock.patch.object(ppt.shutil, "which", return_value=None): + with self.assertRaises(ppt.TippecanoeNotFoundError) as caught: + ppt.require_tippecanoe() + message = str(caught.exception) + self.assertIn("tippecanoe", message) + self.assertIn("training image", message) + + def test_build_footprint_pmtiles_fails_fast_without_binary(self): + write_footprints(self.footprints, 2) + pmtiles = os.path.join(self.tmpdir, "tiles.pmtiles") + with mock.patch.object(ppt.shutil, "which", return_value=None): + with self.assertRaises(ppt.TippecanoeNotFoundError): + ppt.build_footprint_pmtiles(self.footprints, pmtiles) + # Fails before doing any work. + self.assertFalse(os.path.exists(pmtiles)) + + def test_tippecanoe_command_preserves_feature_id_promotion(self): + pmtiles = os.path.join(self.tmpdir, "tiles.pmtiles") + with mock.patch.object( + ppt.shutil, "which", return_value="/usr/bin/tippecanoe" + ): + with mock.patch.object(ppt.subprocess, "run") as runner: + ppt.run_tippecanoe(self.geojson, pmtiles) + + cmd = runner.call_args.args[0] + self.assertIn("--use-attribute-for-id=id", cmd) + self.assertEqual(cmd[cmd.index("-o") + 1], pmtiles) + self.assertEqual(cmd[cmd.index("-l") + 1], "buildings") + # Only id + overture_id survive into the tiles. + keep = [cmd[i + 1] for i, arg in enumerate(cmd) if arg == "-y"] + self.assertEqual(keep, ["id", "overture_id"]) + self.assertIn("--minimum-zoom=10", cmd) + self.assertIn("--maximum-zoom=15", cmd) + self.assertIn("--no-tile-size-limit", cmd) + self.assertTrue(runner.call_args.kwargs["check"]) + + def test_tippecanoe_failure_is_wrapped(self): + pmtiles = os.path.join(self.tmpdir, "tiles.pmtiles") + error = subprocess.CalledProcessError(3, ["tippecanoe"]) + with mock.patch.object( + ppt.shutil, "which", return_value="/usr/bin/tippecanoe" + ): + with mock.patch.object(ppt.subprocess, "run", side_effect=error): + with self.assertRaises(ppt.TippecanoeError) as caught: + ppt.run_tippecanoe(self.geojson, pmtiles) + self.assertIn("exit code 3", str(caught.exception)) + + +class TestArtifactNaming(unittest.TestCase): + def test_names_come_from_artifact_templates(self): + self.assertEqual( + ppt.default_pmtiles_name("layer-1"), "footprints_layer-1.pmtiles" + ) + self.assertEqual( + ppt.default_attrs_name("model-1"), + "prediction_attrs_model-1.json", + ) + + +class TestWorkflowRun(unittest.TestCase): + """End-to-end ``run()`` with tippecanoe and storage mocked out.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix="haste-pred-tiles-") + self.addCleanup(shutil.rmtree, self.tmpdir, True) + self.output_dir = os.path.join(self.tmpdir, "outputs") + os.makedirs(self.output_dir) + self.footprints = os.path.join(self.tmpdir, "footprints.gpkg") + self.predictions = os.path.join(self.tmpdir, "predictions.gpkg") + write_footprints(self.footprints, 3) + write_trained_predictions( + self.predictions, [0.0, 0.5, 1.0], [0.0, 0.0, 0.1] + ) + + def _config(self, build_pmtiles: bool) -> dict: + return { + "project_id": "proj-1", + "image_layer_id": "layer-1", + "model_id": "model-1", + "files": { + "footprints": self.footprints, + "predictions": self.predictions, + }, + "tiles": {"build_pmtiles": build_pmtiles}, + "store_artifacts": False, + } + + def test_run_builds_tiles_and_sidecar(self): + def fake_run(cmd, check=False): + # Stand in for tippecanoe: touch the -o target. + with open(cmd[cmd.index("-o") + 1], "wb") as handle: + handle.write(b"PMTiles") + return mock.Mock(returncode=0) + + with mock.patch.object( + ppt.shutil, "which", return_value="/usr/bin/tippecanoe" + ): + with mock.patch.object( + ppt.subprocess, "run", side_effect=fake_run + ): + manifest = ppt.run(self._config(True), self.output_dir) + + self.assertTrue(manifest["pmtiles_built"]) + self.assertEqual( + manifest["pmtiles_filename"], "footprints_layer-1.pmtiles" + ) + self.assertEqual( + manifest["attrs_filename"], "prediction_attrs_model-1.json" + ) + self.assertEqual(manifest["building_count"], 3) + self.assertTrue(manifest["supports_threshold"]) + self.assertTrue( + os.path.exists( + os.path.join(self.output_dir, manifest["attrs_filename"]) + ) + ) + self.assertTrue( + os.path.exists( + os.path.join(self.output_dir, manifest["pmtiles_filename"]) + ) + ) + # The tiling GeoJSON is scratch and must not be uploaded. + self.assertFalse( + os.path.exists( + os.path.join(self.output_dir, "footprints_4326.geojson") + ) + ) + with open( + os.path.join(self.output_dir, ppt.MANIFEST_FILENAME) + ) as handle: + self.assertEqual(json.load(handle), manifest) + + def test_run_skips_tiles_when_layer_already_has_them(self): + with mock.patch.object(ppt.subprocess, "run") as runner: + manifest = ppt.run(self._config(False), self.output_dir) + + runner.assert_not_called() + self.assertFalse(manifest["pmtiles_built"]) + self.assertEqual(manifest["pmtiles_filename"], "") + self.assertEqual(manifest["building_count"], 3) + + def test_run_requires_identifiers(self): + config = self._config(False) + config.pop("model_id") + with self.assertRaises(ValueError): + ppt.run(config, self.output_dir) + + def test_run_reports_missing_inputs(self): + config = self._config(False) + config["files"]["predictions"] = os.path.join( + self.tmpdir, "does-not-exist.gpkg" + ) + with self.assertRaises(FileNotFoundError): + ppt.run(config, self.output_dir) + + +if __name__ == "__main__": + unittest.main() diff --git a/local.settings.example.jsonc b/local.settings.example.jsonc index ba32276d..b2d8611f 100644 --- a/local.settings.example.jsonc +++ b/local.settings.example.jsonc @@ -46,6 +46,7 @@ "STATS_QUEUE_NAME": "my-local-stats-queue", "ZIP_QUEUE_NAME": "my-local-zip-queue", "EMBEDDING_QUEUE_NAME": "my-local-embedding-queue", + "PREDICTION_EDIT_PREP_QUEUE_NAME": "my-local-prediction-edit-prep-queue", // ===== REQUIRED: Docker Images ===== "AZURE_BATCH_DOCKER_IMAGE": ".azurecr.io/hastetraining:", diff --git a/spec/architecture/decisions/0005-versioned-derived-prediction-artifacts.md b/spec/architecture/decisions/0005-versioned-derived-prediction-artifacts.md new file mode 100644 index 00000000..6d2999e7 --- /dev/null +++ b/spec/architecture/decisions/0005-versioned-derived-prediction-artifacts.md @@ -0,0 +1,128 @@ +# ADR-0005: Introduce Versioned Derived Prediction Artifacts + +**Status:** proposed +**Date:** 2026-08-21 +**Deciders:** HASTE engineering team + +**Contents:** [Context](#context) · [Options Considered](#options-considered) · [Decision](#decision) · [Consequences](#consequences) + +## Context + +Prediction editing needs analysts to save corrected building-level prediction +outputs without losing the raw model result. HASTE does not currently have +artifact versioning: blob writes through `store_artifact` use `overwrite=True`, +and `Model.gpkgUrl` is the single pointer to the raw prediction GeoPackage +(`hastelib/src/hastegeo/core/artifact_storage/azure_blob_artifact_storage.py:255`, +`hastelib/src/hastegeo/core/models/projects.py:440`). Overwriting that pointer +or blob would remove the provenance needed to compare model output with analyst +edits. + +The feature spec at `spec/features/prediction-editing/` introduces edited +prediction GeoPackages as derived artifacts. Each save must produce a new +version (`edit_v1`, `edit_v2`, …) that is listable and downloadable, while +assessment reports, validation reports, publishing, and the visualizer continue +to read the raw model output until later specs opt in. + +## Options Considered + +### Option A: Overwrite `Model.gpkgUrl` in place + +- **Pros:** Smallest data-model change; all current consumers would immediately + see analyst edits without new parameters. +- **Cons:** Destroys the raw model output, loses auditability, makes it hard to + compare model vs analyst decisions, and is unsafe because artifact storage + already overwrites same-named blobs. +- **Impact on HASTE components:** Minimal code change, but high behavioral risk + across reports, validation, publishing, and downloads. + +### Option B: Use Azure Blob snapshots for edited outputs + +- **Pros:** Keeps physical versions close to the source blob; relies on Azure + Storage features rather than new metadata structures. +- **Cons:** Couples HASTE semantics to one storage backend, snapshots are not a + clear user-facing version history, SAS/download flows become harder to reason + about, and the metadata store still needs to know which snapshot is an edited + prediction. +- **Impact on HASTE components:** Requires storage-layer snapshot support and + new API logic to list and authorize snapshots; weak fit for local/Azurite and + any future non-Blob artifact storage. + +### Option C: Introduce a generic artifact registry + +- **Pros:** Solves versioning for all artifact types, can model provenance and + lifecycle uniformly, and could support future publishing/report selection. +- **Cons:** Large architecture change for a focused editing feature; requires + new metadata schemas, migrations, APIs, UI patterns, and rollout planning + beyond the current scope. +- **Impact on HASTE components:** Broad changes across `hastelib`, API, UI, + storage, and downstream consumers; higher schedule and migration risk. + +### Option D: Store a numbered edited-version list on the Model document (Chosen) + +- **Pros:** Preserves raw `Model.gpkgUrl`, gives analysts a simple version + history, uses unique blob artifact names, avoids new containers, and keeps + downstream consumers unchanged in v1. +- **Cons:** Model documents grow with each save; version history is scoped to + prediction editing rather than a reusable artifact registry; the current + implementation does not yet protect concurrent saves when assigning the next + number. +- **Impact on HASTE components:** Adds optional Model fields, new artifact-type + templates, small API additions, and UI version-list rendering. + +## Decision + +Adopt **Option D: a numbered edited-version list on the Model document**. + +Each prediction-edit save writes a new immutable-by-convention blob named from +`EDITED_PREDICTIONS_GPKG = Template("edited_predictions_${modelId}_v${version}")` +and appends one `EditedPredictionVersion` entry to `Model.editedPredictions`. +The displayed version names are `edit_v1`, `edit_v2`, and so on, derived from +the numeric `version` field. The raw prediction remains in `Model.gpkgUrl` and +must not be mutated by the edit flow. + +`EditedPredictionVersion` stores `version`, `gpkgUrl`, `createdAt`, `createdBy`, +`threshold`, `unknownThreshold`, `editedCount`, and `sourceGpkgUrl`. The API +allocates the next version from the current Model document, writes the blob under +that versioned artifact name, and appends metadata. Existing downstream +consumers continue to use the raw prediction pointer unless a future ADR/spec +introduces active-version selection. + +The implemented v1 does **not** include the proposed 409 conflict response for +simultaneous saves. `next_version` plus metadata save is currently a +read-modify-write without optimistic concurrency, so a follow-up must add ETag, +lease, or retry-safe allocation before multi-analyst collision safety is +guaranteed. The separate `PutPreparePredictionTilesQueueMessage` route affects +only PMTiles/attribute preparation; it does not change this artifact-versioning +decision. + +### Components Affected + +| Component | Path | Change | +|---|---|---| +| Model metadata | `hastelib/src/hastegeo/core/models/projects.py` | Add `EditedPredictionVersion` and `Model.editedPredictions`; preserve raw `gpkgUrl`. | +| Artifact naming | `hastelib/src/hastegeo/core/config.py` | Add `EDITED_PREDICTIONS_GPKG` template. | +| Prediction editing processor | `hastelib/src/hastegeo/core/processors/prediction_edits.py` | Allocate versions, write edited GeoPackages, and append metadata. | +| REST API | `api/hastefuncapi/function_app.py` | Add save/list endpoints that expose edited versions without changing existing report endpoints. | +| React UI | `ui/src/Components/PredictionEditor/` | Show version history in the editor. | + +### Azure Services Affected + +| Service | Change | +|---|---| +| Cosmos DB | Existing Model documents gain an optional embedded version list. | +| Blob Storage | Stores one edited GeoPackage blob per version. | +| Azure Functions | New HTTP save/list operations read and update Model metadata. | + +## Consequences + +- **Easier:** Analysts can save multiple reviewed outputs; engineers can reason + about raw vs edited provenance; rollback does not require restoring raw blobs. +- **Harder:** A Model document can grow over time, and concurrent saves still + need protection around version allocation. +- **New constraints:** The edit flow must never write edited data to + `Model.gpkgUrl`; every edited artifact name must include the assigned version; + downstream consumers need explicit future work before they can use edits. +- **Impact on Docker Compose local dev stack:** No new storage service; local + Azurite must hold additional edited GeoPackage blobs. +- **Impact on CI/CD workflows:** No workflow change expected unless additional + automated test jobs are added later. diff --git a/spec/features/prediction-editing/README.md b/spec/features/prediction-editing/README.md new file mode 100644 index 00000000..9accadad --- /dev/null +++ b/spec/features/prediction-editing/README.md @@ -0,0 +1,113 @@ +# Feature: Prediction Editing + +**Status:** draft +**Author:** HASTE engineering team +**Date:** 2026-08-21 +**Target Release:** TBD +**Priority:** P1 +**Work Item:** — + +**Contents:** [Summary](#summary) · [Motivation](#motivation) · [Success Criteria](#success-criteria) · [HASTE Components Affected](#haste-components-affected) · [Related Specs](#related-specs) · [Document Index](#document-index) · [Decision Log](#decision-log) + +## Summary + +Add an **Edit** action to every model result row in both prediction workflows. +The action opens a full-footprint editor where analysts can inspect model +predictions, manually reclassify buildings, and, for trained-inference models +only, adjust the damage-percent threshold that drives the default classes. Each +save produces a new, numbered edited prediction GeoPackage (`edit_v1`, +`edit_v2`, …) as a derived artifact; the raw model output remains unchanged. + +The browser receives the full prediction set through footprint PMTiles plus a +columnar JSON attribute sidecar. If footprint tiles or prediction attributes do +not exist yet, the editor calls a separate prep PUT route that creates them +through an asynchronous queued job rather than inside the session GET handler. + +## Motivation + +- Disaster analysts need a fast way to correct false positives, false negatives, + and ambiguous buildings before handing outputs to response partners. +- The current outputs are either model-generated GeoPackages or sampled browser + views. `GetBuildingFootprintsGeoJSON` returns a random sample capped at 2,000 + features, so it cannot support complete editing (`api/hastefuncapi/function_app.py:3626`, + `api/hastefuncapi/function_app.py:3645-3663`, + `api/hastefuncapi/function_app.py:3697`). +- The report pipeline has a read-only `threshold` parameter with default `0.1`, + but no UI sends it today (`api/hastefuncapi/function_app.py:4313-4322`, + `hastelib/src/hastegeo/core/utils/assessment.py:150-160`). Analysts need a + visible threshold control for workflows where the score is meaningful. +- HASTE currently has no artifact versioning: `store_artifact` overwrites blobs, + and `Model.gpkgUrl` is the only raw prediction pointer + (`hastelib/src/hastegeo/core/artifact_storage/azure_blob_artifact_storage.py:255`, + `hastelib/src/hastegeo/core/models/projects.py:440`). Edited outputs must + therefore be separate derived artifacts. + +## Success Criteria + +- [ ] Every trained-inference model row shows an **Edit** button that is enabled + only when `model.inferenceStatus === "Processed" && model.gpkgUrl`. +- [ ] Every embedding-model row shows an **Edit** button that is enabled only + when `model.gpkgUrl && model.predictedBuildingCount > 0`. +- [ ] Opening the editor loads all predicted footprints through PMTiles and the + prediction attribute sidecar; missing PMTiles or attributes are requested + through the explicit prep PUT route and generated by a queued job. +- [ ] Analysts can click individual buildings and ctrl+drag box-select groups + to set `Damaged`, `NotDamaged`, or `Unknown` overrides. +- [ ] Trained-inference models show a live threshold slider using + `damage_pct_0m`; embedding models do not show the slider because their + `damage_pct_0m` values are only a 0.0/1.0 copy of `damaged`. +- [ ] Saving creates `edit_v1`, `edit_v2`, … without mutating `Model.gpkgUrl` or + the raw model output. +- [ ] The written edited GeoPackage preserves source row order exactly and adds + `edited_class`, `edit_threshold`, and `overture_id` columns. +- [ ] Edited versions are listable through the API and right-panel history; the + API returns each `gpkgUrl`, while a dedicated one-click UI download action + remains a follow-up. Assessment reports, validation reports, publishing, + and the visualizer continue to consume raw outputs until a follow-up spec + changes them. + +## HASTE Components Affected + +| Component | Impact | +|---|---| +| `hastelib/src/hastegeo/core/models/` | add `EditedPredictionVersion`; add `Model.editedPredictions`, `Model.predictedBuildingCount`, `Model.predictedAt`, `Model.predictionAttrsUrl`, `Model.predictionTilesJob`, `Model.predictionTilesStatus`, `Model.predictionTilesStatusMessage`, and `ImageLayer.footprintPmtilesUrl`; add transport-only prediction wire models in `models/predictions.py` | +| `hastelib/src/hastegeo/core/config.py` | add artifact templates for edited prediction GeoPackages, prediction attributes, and layer footprint PMTiles | +| `hastelib/src/hastegeo/core/processors/` | `prediction_edits.py` applies edits and stores versions; `prediction_tiles.py` queues and finalizes prep work | +| `hastelib/src/hastegeo/core/utils/predictions.py` | normalize trained-inference and embedding prediction GeoPackages | +| `hastelib/src/hastegeo/workflows/` | queued tile/sidecar preparation workflow that runs where `tippecanoe` is available | +| `api/hastefuncapi/` | new side-effect-free session, explicit prep PUT, save, and version endpoints; extend `GetModelArtifact` with `footprint_pmtiles` and `prediction_attrs` | +| `api/hastefuncqueues/` | new queued handler for missing footprint PMTiles and attribute sidecar creation | +| `ui/src/Components/` | edit buttons in both model-row workflows; new `/edit-predictions/:projectId/:imageLayerId/:modelId` `PredictionEditor` screen | +| `ui/src/util/pmtiles.js` | shared PMTiles protocol singleton for Azure Maps screens | +| `.github/workflows/` | no expected dependency change; CI should enforce tests and no-regression UI lint baseline | + +## Related Specs + +| Spec | Relationship | +|---|---| +| [data-publishing](../data-publishing/) | related — edited versions are downloadable artifacts now and may become publishable datasets in a later spec | +| [open-data-catalog](../open-data-catalog/) | related — shares Azure Maps/TiTiler geospatial UI patterns and the queue-first approach for heavy geospatial work | +| [ADR-0005: Introduce Versioned Derived Prediction Artifacts](../../architecture/decisions/0005-versioned-derived-prediction-artifacts.md) | records the artifact-versioning decision for edited prediction GeoPackages | + +## Document Index + +| Document | Purpose | Status | +|---|---|---| +| [plan.md](plan.md) | Execution plan, milestones, phases | draft | +| [impact-analysis.md](impact-analysis.md) | Risk, dependencies, blast radius | draft | +| [user-stories.md](user-stories.md) | User stories & acceptance criteria | draft | +| [design.md](design.md) | Technical design & API contracts | draft | +| [data-model.md](data-model.md) | Cosmos DB / Blob / Data Lake schema changes | draft | +| [test-plan.md](test-plan.md) | Test strategy & coverage matrix | draft | +| [rollout.md](rollout.md) | Rollout strategy, flags, rollback | draft | + +## Decision Log + +| Date | Decision | Rationale | +|---|---|---| +| 2026-08-21 | Support both trained-inference and embedding workflows | Analysts need one editing entry point regardless of how predictions were produced. | +| 2026-08-21 | Show the threshold slider only for trained-inference models | Trained inference writes continuous `damage_pct_0m`; embedding predictions write a degenerate 0.0/1.0 copy of `damaged` (`docker/training/code/merge_with_building_footprints.py:221-231`, `api/hastefuncapi/function_app.py:2638-2786`). | +| 2026-08-21 | Store saves as numbered derived artifacts (`edit_v1`, `edit_v2`, …) | HASTE has no generic artifact versioning today, and overwriting `Model.gpkgUrl` would clobber the raw model output. | +| 2026-08-21 | Use PMTiles plus a columnar JSON attribute sidecar for the full browser dataset | Existing full-attribute APIs do not exist, and the sampled GeoJSON route is capped at 2,000 features. | +| 2026-08-21 | Keep `GetPredictionEditSession` read-only and queue prep through `PutPreparePredictionTilesQueueMessage` | `tippecanoe` is installed in the training image only, so HTTP handlers must not generate tiles inline; a separate PUT keeps GET side-effect-free (`docker/training/env/env.yml:11`, `hastelib/src/hastegeo/workflows/embed_buildings.py:712-763`). | +| 2026-08-21 | Keep downstream report, validation, publishing, and visualizer consumption out of scope | v1 produces and exposes edited versions for download only; consumers switch in later specs. | diff --git a/spec/features/prediction-editing/data-model.md b/spec/features/prediction-editing/data-model.md new file mode 100644 index 00000000..7e47d28c --- /dev/null +++ b/spec/features/prediction-editing/data-model.md @@ -0,0 +1,327 @@ +# Data Model: Prediction Editing + +**Contents:** [Cosmos DB Changes](#cosmos-db-changes) · [Blob Storage Changes](#blob-storage-changes) · [Data Lake Changes](#data-lake-changes) · [Queue Storage Changes](#queue-storage-changes) · [Azure Batch Changes](#azure-batch-changes) · [Data Flow](#data-flow) · [Migration Plan](#migration-plan) · [Data Volume Estimates](#data-volume-estimates) · [Caching Strategy](#caching-strategy) + +## Cosmos DB Changes + +### New Containers + +No new Cosmos containers. Prediction edit metadata is embedded in the existing +Model and ImageLayer metadata documents so reads remain local to the project. + +| Container | Partition Key | Description | +|---|---|---| +| — | — | No new container. | + +### Modified Containers + +| Container | Change | Migration Needed? | +|---|---|---| +| Model metadata | Add `editedPredictions`, `predictedBuildingCount`, `predictedAt`, `predictionAttrsUrl`, `predictionTilesJob`, `predictionTilesStatus`, and `predictionTilesStatusMessage` | no — nullable/defaulted fields are backward-compatible | +| ImageLayer metadata | Add `footprintPmtilesUrl` | no — nullable/defaulted field is backward-compatible | + +### New Document Schema + +**Container:** existing Model metadata document +**Partition key:** `projectId` + +`EditedPredictionVersion` is embedded as a list entry on `Model`: + +```python +class EditedPredictionVersion(BaseModel): + version: int + gpkgUrl: str + createdAt: str + createdBy: Optional[str] + threshold: Optional[float] + unknownThreshold: Optional[float] + editedCount: int + sourceGpkgUrl: Optional[str] +``` + +Serialized example: + +```json +{ + "editedPredictions": [ + { + "version": 1, + "gpkgUrl": "https://storage/.../edited_predictions_12345_v1.gpkg", + "createdAt": "2026-08-21T05:10:48Z", + "createdBy": "analyst@example.com", + "threshold": 0.1, + "unknownThreshold": 0.0, + "editedCount": 53, + "sourceGpkgUrl": "https://storage/.../raw_predictions.gpkg" + } + ] +} +``` + +**RU estimate:** One point read of the Model, one point read of the ImageLayer, +and one Model upsert per save. The embedded list is expected to be small; if +version history grows beyond Cosmos document limits, promote it to a dedicated +registry in a follow-up ADR. + +### Modified Document Schema + +| Container | Field | Before | After | Notes | +|---|---|---|---|---| +| Model metadata | `gpkgUrl` | optional string holding the prediction GeoPackage url | unchanged | Remains the raw prediction pointer; writing edited versions here would clobber the source (`hastelib/src/hastegeo/core/models/projects.py:440`). | +| Model metadata | `editedPredictions` | absent | `Optional[List[EditedPredictionVersion]]`, default empty list | Append-only numbered history: `edit_v1`, `edit_v2`, … | +| Model metadata | `predictedBuildingCount` | absent | `Optional[int]` | Positive count gates embedding editing; avoids treating an empty prediction write as editable. | +| Model metadata | `predictedAt` | absent | `Optional[str]` ISO 8601 timestamp | Set when embedding predictions are written or prep validates the raw prediction set. | +| Model metadata | `predictionAttrsUrl` | absent | `Optional[str]` | URL to the per-model columnar prediction attribute JSON sidecar. | +| Model metadata | `predictionTilesJob` | absent | `Optional[TrainingJob]` | Batch/local runner job metadata for the queued prep workflow. | +| Model metadata | `predictionTilesStatus` | absent | `Optional[str]` | Prep status using HASTE status values: `Queued`, `InProgress`, `Processed`, `Failed`, `Cancelled`. | +| Model metadata | `predictionTilesStatusMessage` | absent | `Optional[str]`, default `""` | User-visible appended progress/failure messages for prep polling. | +| ImageLayer metadata | `footprintPmtilesUrl` | absent | `Optional[str]` | Layer-level PMTiles for all footprints used by prediction editing. | + +### Transport-Only Wire Models + +`PredictionOverrideRequest`, `EditedPredictionsRequest`, and +`PreparePredictionTilesRequest` live in +`hastelib/src/hastegeo/core/models/predictions.py`. They validate HTTP request +bodies for `PutEditedPredictions` and `PutPreparePredictionTilesQueueMessage` +but are not persisted in Cosmos DB. + +They deliberately do not live in `function_app.py`, which remains a thin HTTP +wrapper, or in `projects.py`, which holds persisted document schemas. This +mirrors the publishing split between `PublishRequest` transport models and the +persisted `PublishedDataset` schema in `publishing.py`. + +`store_artifact` currently uploads with `overwrite=True`, so version identity +comes from unique artifact names instead of mutating a blob in place +(`hastelib/src/hastegeo/core/artifact_storage/azure_blob_artifact_storage.py:255`). + +--- + +## Blob Storage Changes + +### New Containers + +No new blob container. Use the existing artifact storage container and project +partitioning used by `ArtifactProcessor`. + +| Container | Access Level | Naming Convention | Content Type | +|---|---|---|---| +| existing artifacts container | private | existing project/model namespace | GeoPackage / PMTiles / JSON | + +### Modified Containers + +| Container | Change | Description | +|---|---|---| +| existing artifacts container | add edited prediction GeoPackage blobs | One immutable blob per numbered edit version. | +| existing artifacts container | add prediction attribute sidecar blobs | Columnar JSON sidecar for full prediction attributes. | +| existing artifacts container | add layer footprint PMTiles blobs | Layer-level PMTiles shared by models for an image layer. | + +### Blob Path Conventions + +Artifact names are added to `ArtifactTypes`: + +```python +EDITED_PREDICTIONS_GPKG = Template("edited_predictions_${modelId}_v${version}") +PREDICTION_ATTRS = Template("prediction_attrs_${modelId}") +LAYER_FOOTPRINT_PMTILES = Template("footprints_${imageLayerId}") +``` + +Logical layout: + +```text +{artifact-container}/ + {projectId}/ + {modelId}/ + edited_predictions_{modelId}_v1.gpkg + edited_predictions_{modelId}_v2.gpkg + prediction_attrs_{modelId}.json + {imageLayerId}/ + footprints_{imageLayerId}.pmtiles +``` + +The exact physical namespace should follow `ArtifactProcessor` conventions, but +artifact names must match the templates above. Edited GeoPackages are immutable +by convention; a later save always writes the next version. + +#### Edited prediction GeoPackage schema + +The source schemas differ by producer. Trained inference writes continuous +fractions and a default layer name; embedding writes layer `"predictions"`, an +`area` column, and `damage_pct_0m` as a 0.0/1.0 copy of `damaged` +(`docker/training/code/merge_with_building_footprints.py:221-231`, +`api/hastefuncapi/function_app.py:2638-2786`). The edited output must normalize +the minimum columns below while preserving any safe source columns that do not +conflict. + +| Column | Type | Required | Description | +|---|---|---|---| +| `id` | int | yes | Source row index; must remain in original order. | +| `damage_pct_0m` | float | yes | Damage fraction in `[0,1]`; continuous for trained inference, degenerate 0.0/1.0 for embedding. | +| `damage_pct_10m` | float | trained source only | Preserve when present. | +| `damage_pct_20m` | float | trained source only | Preserve when present. | +| `unknown_pct` | float | yes | Unknown fraction; default to `0.0` when absent. | +| `damaged` | int | yes | Rewritten to `1` only when final class is `Damaged`; otherwise `0`. | +| `area` | float | embedding source only | Preserve when present. | +| `edited_class` | string | yes | `Damaged`, `NotDamaged`, or `Unknown` after overrides and thresholds. | +| `edit_threshold` | float | yes | Threshold used for this save; still written for embedding for provenance. | +| `overture_id` | string | yes | Explicit Overture building id copied from source footprints by row order. | +| `geometry` | geometry | yes | Original prediction geometry and CRS. | + +The existing trained output computes `damaged` as `damage_pct_0m > 0` +(`docker/training/code/merge_with_building_footprints.py:254`). Edited outputs +replace that rule with the documented thresholded final class. + +#### Attribute sidecar schema + +The prediction attribute sidecar is JSON and is streamed by +`GetModelArtifact?kind=prediction_attrs` as `application/json`: + +```json +{ + "n": 3, + "ids": [0, 1, 2], + "overtureIds": ["08b...", "08c...", "08d..."], + "damage": [0.0, 0.42, 0.8], + "unknown": [0.0, 0.2, 0.0], + "damaged": [0, 1, 1] +} +``` + +All arrays must have length `n` and must be ordered exactly like the prediction +GeoPackage rows. This order matters because current report logic joins +predictions to Overture ids positionally, not by id +(`hastelib/src/hastegeo/core/utils/assessment.py:376-395`, +`api/hastefuncapi/function_app.py:4116-4133`). + +--- + +## Data Lake Changes + +### New Filesystems / Paths + +No Data Lake filesystem changes are required for v1. Edited versions are Blob +artifacts only. + +| Filesystem | Path Pattern | Data Format | Description | +|---|---|---|---| +| — | — | — | No Data Lake change. | + +--- + +## Queue Storage Changes + +### New Queues + +| Queue Name | Message Schema | Producer | Consumer | +|---|---|---|---| +| `prediction-edit-prep-queue` | See [design.md](design.md#queue-prediction-edit-prep-queue) | `hastefuncapi` `PutPreparePredictionTilesQueueMessage` | `hastefuncqueues` prediction-edit-prep trigger | + +The queue is for PMTiles and sidecar preparation only. Saving edited +GeoPackages remains an API-driven write in v1. + +`infra/modules/functions.bicep` does not add explicit app-setting parity for +this queue in the current implementation. `Config` supplies the +`prediction-edit-prep-queue` default, the Functions host can create the queue, +and editing Bicep without regenerating `infra/main.json` would create infra +drift. + +--- + +## Azure Batch Changes + +### Pool Configuration + +| Setting | Value | Notes | +|---|---|---| +| VM SKU | existing training/CPU-capable pool | No GPU requirement; uses the training image because it includes `tippecanoe`. | +| Pool size | existing autoscale | Prep is bursty and should not require a dedicated pool in v1. | +| Container image | `docker/training/` | `tippecanoe` is present only in the training conda env (`docker/training/env/env.yml:11`). | + +--- + +## Data Flow + +### Write Path + +```text +UI save overrides + thresholds + → hastefuncapi PutEditedPredictions + → hastegeo.core.processors.prediction_edits.apply_edits + → hastegeo.core.processors.prediction_edits.store_edited_version + → Blob Storage edited_predictions_{modelId}_v{version}.gpkg + → Cosmos Model.editedPredictions append +``` + +Prep write path: + +```text +UI opens editor + → hastefuncapi GetPredictionEditSession + → hastefuncapi PutPreparePredictionTilesQueueMessage when missing + → Queue Storage prediction-edit-prep-queue + → hastefuncqueues + → training image workflow builds PMTiles + sidecar + → Blob Storage footprints_{imageLayerId}.pmtiles + prediction_attrs_{modelId}.json + → Cosmos ImageLayer.footprintPmtilesUrl + Model.predictionAttrsUrl/predictedBuildingCount/predictedAt/predictionTilesStatus +``` + +### Read Path + +```text +UI PredictionEditor page + → hastefuncapi GetPredictionEditSession (metadata and readiness) + → hastefuncapi GetModelArtifact?kind=footprint_pmtiles + → hastefuncapi GetModelArtifact?kind=prediction_attrs + → Azure Maps PMTiles + in-memory sidecar rendering + → hastefuncapi GetEditedPredictionVersions (history) +``` + +--- + +## Migration Plan + +### Forward Migration + +1. Deploy Pydantic schema changes with nullable/defaulted fields. +2. Deploy new artifact types and `GetModelArtifact` kinds. +3. Deploy queue worker support for PMTiles and sidecar creation. +4. Deploy API routes and the explicit prep PUT route. +5. Deploy UI route and edit buttons. +6. Enable the feature in dev/test and backfill `predictedBuildingCount` through + `PutBuildingPredictions` for embedding models or prep completion for raw + prediction GeoPackages. + +Existing trained models can use `inferenceStatus === "Processed" && gpkgUrl`. +Existing embedding models with only `gpkgUrl` should remain disabled until a +positive `predictedBuildingCount` is set, because `PutBuildingPredictions` can +write empty predictions while still setting `gpkgUrl` +(`api/hastefuncapi/function_app.py:2638-2786`, +`ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:86`). + +### Backward Migration + +1. Revert API and UI deployments if needed. +2. Stop or drain the prediction-edit prep queue if workers are failing. +3. Leave `editedPredictions`, `predictedBuildingCount`, `predictedAt`, + `predictionAttrsUrl`, `predictionTilesJob`, `predictionTilesStatus`, + `predictionTilesStatusMessage`, and `footprintPmtilesUrl` fields in place; + old code ignores unknown optional fields. +4. Leave edited GeoPackage, PMTiles, and sidecar blobs in storage unless a + cleanup script is explicitly approved. + +## Data Volume Estimates + +| Entity / Container | Initial Size | Growth Rate | Retention | +|---|---|---|---| +| `Model.editedPredictions` list | 0-20 small objects per model | one entry per save | same as model metadata | +| Edited GeoPackage blobs | roughly source prediction GPKG size per version | one blob per save | same as project artifacts | +| Prediction attribute sidecar | one compact JSON array set per model | regenerated when source predictions change | replaceable derived cache | +| Footprint PMTiles | one geometry-only archive per image layer | generated once per layer | replaceable derived cache | + +## Caching Strategy + +| Data | Cache Layer | TTL | Invalidation | +|---|---|---|---| +| `GetPredictionEditSession` metadata | Browser state | until model refresh or route leave | Reload after save or prep completion. | +| `prediction_attrs` sidecar | Browser memory | current editor session | Refetch when `predictedAt` or source `gpkgUrl` changes. | +| `footprint_pmtiles` | Browser memory / HTTP cache | current editor session; cacheable by blob version/url | Regenerate when source footprints change. | +| Edited version list | Browser state | current editor session | Refresh after `PutEditedPredictions` succeeds. | diff --git a/spec/features/prediction-editing/design.md b/spec/features/prediction-editing/design.md new file mode 100644 index 00000000..a20b6031 --- /dev/null +++ b/spec/features/prediction-editing/design.md @@ -0,0 +1,528 @@ +# Technical Design: Prediction Editing + +**Contents:** [Overview](#overview) · [Architecture](#architecture) · [API Design](#api-design) · [Behavior & Logic](#behavior--logic) · [Configuration](#configuration) · [Observability](#observability) · [Open Questions](#open-questions) + +## Overview + +Prediction editing adds a dedicated React screen for full-building prediction +review. The screen reads footprint geometry from PMTiles, reads prediction +attributes from a columnar JSON sidecar, lets an analyst set class overrides, +and saves each edit as a new derived GeoPackage version. Reference the HASTE +architecture in `spec/architecture/overview.md`; this design keeps Azure +Functions as thin HTTP wrappers and moves data manipulation into `hastegeo`. + +The raw prediction GeoPackage remains immutable. Existing downstream consumers +continue to use the raw `Model.gpkgUrl`; edited versions are produced, listed, +and downloadable only. + +## Architecture + +### Component Diagram + +``` +┌──────────────────────────────┐ +│ React UI │ +│ Model row Edit button │ +│ PredictionEditor page │ +│ Azure Maps + PMTiles │ +└──────────────┬───────────────┘ + │ GET session / PUT prep / attrs / tiles + ▼ +┌──────────────────────────────┐ metadata ┌────────────────────┐ +│ hastefuncapi │◀─────────────────▶│ Cosmos metadata │ +│ GetPredictionEditSession │ │ Project/Layer/Model │ +│ PutPreparePredictionTiles... │ +│ PutEditedPredictions │ └────────────────────┘ +│ GetEditedPredictionVersions │ +│ GetModelArtifact kinds │ +└───────┬───────────────┬──────┘ + │ SAS/download │ queue after explicit PUT prep request + ▼ ▼ +┌──────────────────┐ ┌────────────────────────────┐ +│ Blob Storage │ │ hastefuncqueues │ +│ raw GPKG │ │ prediction-edit-prep queue │ +│ edited GPKG vN │ └─────────────┬──────────────┘ +│ PMTiles + attrs │ │ run training image workflow +└──────────────────┘ ▼ + ┌────────────────────────────┐ + │ hastegeo workflow │ + │ fiona/geopandas + │ + │ tippecanoe PMTiles │ + └────────────────────────────┘ +``` + +### New Components + +| Component | Path | Responsibility | Technology | +|---|---|---|---| +| Prediction edit engine | `hastelib/src/hastegeo/core/processors/prediction_edits.py` | Apply overrides and thresholds, derive final classes, allocate the next version, and store edited GeoPackages | Python / Fiona | +| Prediction schema utilities | `hastelib/src/hastegeo/core/utils/predictions.py` | Normalize trained-inference vs embedding GeoPackage schemas, preserve row order, and resolve Overture ids positionally | Python / Fiona | +| Prediction HTTP wire models | `hastelib/src/hastegeo/core/models/predictions.py` | Transport-only Pydantic request bodies for save and prep routes; kept out of persisted project schemas | Python / Pydantic | +| Prediction edit models | `hastelib/src/hastegeo/core/models/projects.py` | `EditedPredictionVersion`; new optional `Model` and `ImageLayer` fields | Python / Pydantic | +| Prediction edit prep workflow | `hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py` | Build footprint PMTiles and prediction attribute sidecar from the raw prediction GeoPackage and layer footprints | Python / tippecanoe | +| Prediction tiles job processor | `hastelib/src/hastegeo/core/processors/prediction_tiles.py` | Decide whether tiles/sidecar are missing, submit the workflow to the training image through `UnifiedRunner`, persist artifact URLs | Python | +| Queue trigger | `api/hastefuncqueues/function_app.py` | Consume prediction-edit-prep messages and invoke the workflow through the existing runner pattern | Azure Functions | +| Prediction edit page | `ui/src/Components/PredictionEditor/PredictionEditor.jsx` | Full-screen editor with Azure Maps, PMTiles, filters, traversal, overrides, threshold slider, and save action | React / Fluent UI / Azure Maps | +| Prediction edit helpers | `ui/src/Components/PredictionEditor/predictionClassify.js`, `ui/src/Components/PredictionEditor/predictionPrep.js` | Class derivation, sidecar loading, counts, selection state, request shaping, and prep polling decisions | JavaScript | +| Shared PMTiles protocol | `ui/src/util/pmtiles.js` | Single process-wide PMTiles protocol instance and in-memory source used by Azure Maps screens | JavaScript / PMTiles | + +### Modified Components + +| Component | Path | Change Description | +|---|---|---| +| Artifact types | `hastelib/src/hastegeo/core/config.py` | Add `EDITED_PREDICTIONS_GPKG`, `PREDICTION_ATTRS`, and `LAYER_FOOTPRINT_PMTILES` templates | +| Model schema | `hastelib/src/hastegeo/core/models/projects.py` | Add `editedPredictions`, `predictedBuildingCount`, `predictedAt`, `predictionAttrsUrl`, `predictionTilesJob`, `predictionTilesStatus`, and `predictionTilesStatusMessage`; keep `gpkgUrl` as the raw prediction pointer | +| Image layer schema | `hastelib/src/hastegeo/core/models/projects.py` | Add `footprintPmtilesUrl` for layer-level footprint tiles | +| API module | `api/hastefuncapi/function_app.py` | Add four thin prediction-editing endpoints and extend `GetModelArtifact` artifact-kind dispatch | +| Trained model row | `ui/src/Components/ProjectManagement/ModelResultsButton.jsx` | Add **Edit** button enabled when `inferenceStatus === "Processed" && gpkgUrl` | +| Embedding model row | `ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx` | Add **Edit** button enabled when `gpkgUrl && predictedBuildingCount > 0` | +| App routing | `ui/src/Components/AppBody.jsx` | Register `/edit-predictions/:projectId/:imageLayerId/:modelId` | +| Existing editor references | `ui/src/Components/BuildingValidation/BuildingValidation.jsx`, `ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx`, `ui/src/util/pmtiles.js` | Reuse interaction patterns: filters, prev/next traversal, PMTiles in-memory source, feature-state coloring, and box-select; share the PMTiles protocol singleton | + +## API Design + +The route names follow the current Azure Functions convention in +`function_app.py`. Endpoints use `func.AuthLevel.FUNCTION` and must delegate +non-HTTP logic to `hastegeo`. + +### hastefuncapi Endpoints + +#### `GET /api/GetPredictionEditSession` + +**Auth:** `func.AuthLevel.FUNCTION` + +**Description:** Return everything the UI needs to decide whether the editor can +load. The endpoint uses `projectId` to load the image layer and model, +distinguishes trained inference from embedding predictions by reading the raw +GeoPackage, and reports whether the PMTiles and attribute sidecar already exist. +It is side-effect-free: it does not enqueue preparation work. When preparation +is missing, the UI calls `PutPreparePredictionTilesQueueMessage` and then polls +this endpoint. + +**Query parameters:** + +| Name | Type | Required | Description | +|---|---|---|---| +| `projectId` | string | yes | Project metadata partition key. | +| `imageLayerId` | string | yes | Image layer that owns the source building footprints. | +| `modelId` | string | yes | Model whose raw `gpkgUrl` supplies predictions. | + +**Response (200):** + +```json +{ + "modelId": "12345", + "flavor": "inference", + "supportsThreshold": true, + "defaultThreshold": 0.0, + "buildingCount": 125430, + "tilesReady": true, + "attrsReady": true, + "predictionTilesStatus": "Processed", + "predictionTilesStatusMessage": "", + "versions": [ + { + "version": 1, + "gpkgUrl": "https://...", + "createdAt": "2026-08-21T05:10:48Z", + "createdBy": "analyst@example.com", + "threshold": 0.1, + "unknownThreshold": 0.0, + "editedCount": 53, + "sourceGpkgUrl": "https://...raw.gpkg" + } + ] +} +``` + +For embedding models, `flavor` is `"embedding"` and `supportsThreshold` is +`false`; the UI must hide the threshold slider. + +**Error Responses:** + +| Code | Condition | +|---|---| +| 400 | Missing or malformed `projectId`, `imageLayerId`, or `modelId` | +| 404 | Model, image layer, or raw prediction GeoPackage not found | +| 500 | Storage or metadata failure | + +#### `PUT /api/PutPreparePredictionTilesQueueMessage` + +**Auth:** `func.AuthLevel.FUNCTION` + +**Description:** Queue the job that builds the layer footprint PMTiles and the +model prediction attribute sidecar. This route is the only HTTP endpoint that +requests prediction-edit preparation; `GetPredictionEditSession` remains +read-only. + +**Request:** + +```json +{ + "projectId": "string — required", + "imageLayerId": "string — required", + "modelId": "string — required", + "force": "bool — optional; default false" +} +``` + +**Response (200):** + +```json +{ + "modelId": "12345", + "queued": true, + "tilesReady": false, + "attrsReady": false, + "status": "Queued", + "statusMessage": "\n2026-08-21T05:10:48+00:00: Queued for prediction tile preparation" +} +``` + +**Semantics:** + +- When both artifacts are already ready and `force` is false, the response has + `queued: false`, `tilesReady: true`, `attrsReady: true`, and nothing is + enqueued. +- When `Model.predictionTilesStatus` is already `Queued` or `InProgress` and + `force` is false, the response has `queued: false` and no duplicate message is + enqueued. +- Otherwise the model status is set to `Queued` and exactly one message is put + on `prediction-edit-prep-queue`. +- `force: true` rebuilds even when artifacts exist or a previous job is in + flight. + +**Error Responses:** + +| Code | Condition | +|---|---| +| 400 | Invalid JSON or validation failure for `projectId`, `imageLayerId`, `modelId`, or `force` | +| 404 | Model or image layer not found; no raw `Model.gpkgUrl`; or no `ImageLayer.buildingFootprintsUrl` to prepare from | +| 500 | Metadata or queue failure | + +#### `PUT /api/PutEditedPredictions` + +**Auth:** `func.AuthLevel.FUNCTION` + +**Description:** Apply a threshold and explicit user overrides to the source +prediction GeoPackage, write a new edited GeoPackage, upload it under the next +numbered version, and append an `EditedPredictionVersion` entry to the `Model`. +The endpoint is synchronous in v1, but all geospatial work must live in +`hastegeo`. + +**Request:** + +```json +{ + "projectId": "string — required", + "imageLayerId": "string — required", + "modelId": "string — required", + "threshold": "number — optional; default 0.0", + "unknownThreshold": "number — optional; default 0.0", + "overrides": [ + { "id": "integer row id", "class": "Damaged | NotDamaged | Unknown" } + ] +} +``` + +**Response (200):** + +```json +{ + "version": 2, + "gpkgUrl": "https://.../edited_predictions_12345_v2.gpkg", + "editedCount": 53 +} +``` + +**Error Responses:** + +| Code | Condition | +|---|---| +| 400 | Invalid JSON, threshold outside `[0,1]`, unknown threshold outside `[0,1]`, invalid class, duplicate override ids | +| 404 | Model, image layer, raw predictions, or source footprints not found | +| 422 | Source prediction and footprint GeoPackages do not line up row for row | +| 500 | Blob, metadata, or geospatial write failure | + +Override ids outside the source row range are ignored and logged rather than +rejected. The response `editedCount` counts only overrides that matched a row. + +#### `GET /api/GetEditedPredictionVersions` + +**Auth:** `func.AuthLevel.FUNCTION` + +**Query parameters:** + +| Name | Type | Required | Description | +|---|---|---|---| +| `projectId` | string | yes | Project metadata partition key. | +| `modelId` | string | yes | Model id. | + +**Response (200):** + +```json +{ + "versions": [ + { + "version": 1, + "gpkgUrl": "https://...", + "createdAt": "2026-08-21T05:10:48Z", + "createdBy": "analyst@example.com", + "threshold": 0.1, + "unknownThreshold": 0.0, + "editedCount": 53, + "sourceGpkgUrl": "https://...raw.gpkg" + } + ] +} +``` + +**Error Responses:** + +| Code | Condition | +|---|---| +| 400 | Missing or malformed `projectId` or `modelId` | +| 404 | Model not found | +| 500 | Metadata read failure | + +#### `GET /api/GetModelArtifact` (modified) + +**Auth:** `func.AuthLevel.FUNCTION` + +Adds two `kind` values: + +| Kind | Required params | Returns | +|---|---|---| +| `footprint_pmtiles` | `projectId`, `imageLayerId`, `modelId` | Streamed bytes for `footprints_${imageLayerId}.pmtiles` | +| `prediction_attrs` | `projectId`, `modelId` | JSON sidecar for `prediction_attrs_${modelId}` | + +The sidecar response uses the columnar format below. Arrays must be the same +length and order as the source prediction GeoPackage rows. + +```json +{ + "n": 3, + "ids": [0, 1, 2], + "overtureIds": ["08b...", "08c...", "08d..."], + "damage": [0.0, 0.42, 0.8], + "unknown": [0.0, 0.2, 0.0], + "damaged": [0, 1, 1] +} +``` + +### Queue Messages (hastefuncqueues) + +#### Queue: `prediction-edit-prep-queue` + +**Message Schema:** + +```json +{ + "projectId": "string", + "imageLayerId": "string", + "modelId": "string", + "sourceGpkgUrl": "string", + "sourceFootprintsUrl": "string", + "force": false +} +``` + +**Trigger behavior:** The worker downloads the source footprints and raw +prediction GeoPackage, validates equal row count and positional row order, +writes or refreshes `footprints_${imageLayerId}.pmtiles` when missing, writes +`prediction_attrs_${modelId}` from prediction columns, uploads both artifacts, +and updates `ImageLayer.footprintPmtilesUrl`, `Model.predictionAttrsUrl`, +`Model.predictedBuildingCount`, `Model.predictedAt`, +`Model.predictionTilesJob`, `Model.predictionTilesStatus`, and +`Model.predictionTilesStatusMessage`. + +Tile creation must run in the queued worker because `tippecanoe` is installed in +the training image only (`docker/training/env/env.yml:11`). Existing PMTiles +creation in `embed_buildings.py` is the invocation pattern to mirror +(`hastelib/src/hastegeo/workflows/embed_buildings.py:712-763`). + +### Internal Interfaces (hastegeo) + +| Module | Function/Class | Signature | Description | +|---|---|---|---| +| `core/models/projects.py` | `EditedPredictionVersion` | `BaseModel` | Embedded version metadata on `Model`; see [data-model.md](data-model.md#modified-document-schema). | +| `core/models/predictions.py` | `PredictionOverrideRequest`, `EditedPredictionsRequest`, `PreparePredictionTilesRequest` | `BaseModel` | Transport-only HTTP request bodies; mirrors the `PublishRequest` / `PublishedDataset` split by keeping wire contracts out of persisted `projects.py` schemas. | +| `core/utils/predictions.py` | `read_predictions` | `(path: str, footprints_path: Optional[str] = None) -> PredictionSet` | Detects `inference` vs `embedding`, normalizes row attributes, and resolves Overture ids by positional row order. | +| `core/processors/prediction_edits.py` | `apply_edits` | `(src_gpkg: str, dst_gpkg: str, threshold: float, unknown_threshold: float, overrides: dict[int, str], footprints_path: Optional[str]) -> EditSummary` | Applies class derivation, preserves row order, and writes the edited GeoPackage. | +| `core/processors/prediction_edits.py` | `derive_class`, `next_version`, `store_edited_version` | helper functions | Compute final class, allocate the next version number, and store `edited_predictions_${modelId}_v${version}.gpkg`. | +| `core/processors/prediction_tiles.py` | `needs_preparation`, `request_preparation` | `(model: Model, image_layer: ImageLayer, force: bool = False) -> dict` | Decide whether PMTiles/sidecar artifacts are ready and enqueue at most one prep message for the explicit PUT route. | +| `core/processors/prediction_tiles.py` | `PredictionTilesPostprocessor` | class | Submit, poll, and finalize the queued training-image workflow. | +| `hastegeo/workflows/prepare_prediction_tiles.py` | `run` | `(config: dict, output_dir: str) -> dict` | Builds footprint PMTiles and the prediction attribute JSON sidecar. | +| `api/hastefuncapi/function_app.py` | `GetModelArtifact` | HTTP route | Adds `footprint_pmtiles` and `prediction_attrs` kinds. | + +## Behavior & Logic + +### Core Flow + +1. Analyst sees an **Edit** button in each model row. +2. For trained inference, the button is enabled only when + `model.inferenceStatus === "Processed" && model.gpkgUrl`. +3. For embedding, the button is enabled only when + `model.gpkgUrl && model.predictedBuildingCount > 0` to avoid the current + ambiguity where an empty prediction save can still set `gpkgUrl`. +4. The UI navigates to + `/edit-predictions/:projectId/:imageLayerId/:modelId`. +5. The screen calls `GetPredictionEditSession`. +6. If `tilesReady` or `attrsReady` is false, the screen calls + `PutPreparePredictionTilesQueueMessage`; that route enqueues + `prediction-edit-prep-queue` unless artifacts are already ready or a job is + already in flight. The screen shows a preparation state and polls the + session endpoint. +7. Once ready, the UI fetches `footprint_pmtiles` and `prediction_attrs` through + `GetModelArtifact`. +8. Azure Maps displays PMTiles footprints. Feature-state coloring is computed + from the sidecar, current threshold, unknown threshold, and explicit + overrides. +9. The analyst clicks or ctrl+drag box-selects buildings, filters by + `Damaged`, `NotDamaged`, `Unknown`, or `edited`, and uses prev/next traversal. +10. On save, the UI calls `PutEditedPredictions` with the threshold, + unknown threshold, and only explicit overrides. +11. The backend writes `edited_predictions_${modelId}_v${version}.gpkg`, appends + version metadata, and returns `{ version, gpkgUrl, editedCount }`. +12. The UI refreshes the version list. Raw `Model.gpkgUrl` remains unchanged. + +### Existing implementation constraints + +- Trained inference writes `id`, `damage_pct_0m`, `damage_pct_10m`, + `damage_pct_20m`, `damaged`, and `unknown_pct` in the raster CRS with the + default layer name (`docker/training/code/merge_with_building_footprints.py:221-231`). + The `damaged` column is currently hard-coded as `damage_pct_0m > 0` + (`docker/training/code/merge_with_building_footprints.py:254`). +- The embedding workflow writes predictions through `PutBuildingPredictions`. + It uses layer name `"predictions"`, adds `area`, and sets `damage_pct_0m` to a + 0.0/1.0 copy of `damaged`, which makes thresholding meaningless for embedding + models (`api/hastefuncapi/function_app.py:2638-2786`). +- The prediction-to-Overture join is positional row order, not an id. Both the + assessment utility and the API build Overture ids by reading the footprints in + order and indexing with the prediction row id + (`hastelib/src/hastegeo/core/utils/assessment.py:376-395`, + `api/hastefuncapi/function_app.py:4116-4133`). Edited GeoPackages must keep + row order exactly and also write an explicit `overture_id` column. +- `GetBuildingFootprintsGeoJSON` is only a sampled preview path, capped at 2,000 + features (`api/hastefuncapi/function_app.py:3626`, + `api/hastefuncapi/function_app.py:3645-3663`). Prediction editing requires a + complete PMTiles + sidecar data path. +- PMTiles currently exist only for the embedding workflow through + `ArtifactTypes.BUILDING_PMTILES` and the embedding processor + (`hastelib/src/hastegeo/core/config.py:153`, + `hastelib/src/hastegeo/core/processors/embedding.py:239`). Trained models + need the new `LAYER_FOOTPRINT_PMTILES` artifact path. +- Existing row gating differs by workflow: trained results key off processed + inference state, while embedding rows treat any `gpkgUrl` as predictions + (`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:43-46`, + `ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:86`). This feature + adds `predictedBuildingCount` and `predictedAt` to remove ambiguity. + +### Class derivation rule + +The editor supports exactly three classes. Recompute each row at save time using +source prediction values plus explicit user overrides: + +```text +final_class = override if the row was explicitly overridden by the user, else + "Unknown" if unknown_fraction > unknown_threshold (default 0.0), else + "Damaged" if damage_fraction > threshold, else + "NotDamaged" +``` + +The written `damaged` integer column is `1` when +`final_class == "Damaged"`; otherwise it is `0`. The edited GeoPackage also +writes `edited_class` (string), `edit_threshold` (float), and `overture_id` +(string). Row order must be preserved exactly from the source prediction +GeoPackage. + +### UI behavior + +- The screen uses Azure Maps with PMTiles loaded through the existing in-memory + protocol pattern from `InteractiveLabeler.jsx`. +- Styling uses Fluent UI `makeStyles` and `tokens` so the editor works in dark + mode. Hard-coded hex colors are not allowed for semantic UI colors. +- Feature-state colors update live when overrides or thresholds change; the + source PMTiles are not regenerated in the browser. +- The right panel shows counts for `Damaged`, `NotDamaged`, `Unknown`, and + `edited`, plus filters and prev/next traversal modeled on + `BuildingValidation.jsx`. +- The threshold slider appears only when `supportsThreshold` is true. It shows + how many buildings would flip relative to the current saved/default state. +- Embedding models can still be manually reclassified, but do not display the + slider. + +### Edge Cases + +| Case | Expected Behavior | +|---|---| +| Missing raw `Model.gpkgUrl` | Edit button disabled; direct session request returns 404. | +| Trained inference processed but no `gpkgUrl` | Edit button disabled because the full prediction GeoPackage is unavailable. | +| Embedding `gpkgUrl` exists but `predictedBuildingCount` is `0` or missing | Edit button disabled; a direct session request still reads the raw GeoPackage if present, so UI gating is the protection against empty embedding saves. | +| PMTiles or sidecar missing | Session endpoint returns `tilesReady: false` or `attrsReady: false`; UI calls `PutPreparePredictionTilesQueueMessage` and then polls with a preparation message. | +| Source prediction and footprint row counts differ | Save returns 422; prep records a failed `predictionTilesStatus` with a row-count message; no edited version is appended. | +| Duplicate override ids | PUT returns 400; client must de-duplicate before retrying. | +| Override id outside source range | Save succeeds; the override is ignored and not counted in `editedCount`. | +| Concurrent saves | Known limitation: backend uses `next_version` plus a metadata save without optimistic concurrency, so concurrent saves can collide instead of returning 409. | +| Invalid thresholds | PUT returns 400 for values outside `[0,1]`. | +| Very large layers | UI avoids GeoJSON; prep/save still read whole GeoPackages and must expose progress/failure logs. | + +### Error Handling + +| Error Condition | Response | Recovery | +|---|---|---| +| Prep queue enqueue fails | `PutPreparePredictionTilesQueueMessage` returns 500 | Retry the prep request; the route is idempotent by readiness/status. | +| PMTiles generation fails | Session continues to report not ready with status details in logs | Queue retry/dead-letter; user can retry opening the editor. | +| Attribute sidecar missing or invalid | UI blocks editing and reports a load failure | Regenerate prep artifacts with `force: true`. | +| Blob upload timeout on edited GeoPackage | `PutEditedPredictions` returns 500 | Retry save; if a blob exists without model metadata, next version allocation must not reuse it. | +| Metadata conflict appending version | Not detected in the current implementation | Follow up with ETag/lease-based optimistic concurrency before relying on multi-analyst collision safety. | + +### Known limitations / follow-ups + +- `PutEditedPredictions` does not implement the 409 conflict response that the + original draft proposed. `next_version` plus `MetadataProcessor.save` is a + read-modify-write sequence with no ETag, lease, or retry-safe compare step. +- API-level integration tests for the prediction-editing routes are not present; + `api/hastefuncapi/tests/` contains only `test_publishing_routes.py`. Current + automated coverage is at the processor, workflow, wire-model, and UI helper + level. +- `infra/modules/functions.bicep` does not include an explicit app-setting row + for `PREDICTION_EDIT_PREP_QUEUE_NAME`. This was intentionally skipped because + `Config` has a default, the Functions host can create the queue, and changing + the Bicep without regenerating `infra/main.json` would introduce infra drift. +- No browser or Playwright validation exists for the editor screen; this repo + currently has no Playwright configuration. + +## Configuration + +| Config Key | Type | Default | Where Set | Description | +|---|---|---|---|---| +| `prediction_edit_prep_queue_name` | string | `prediction-edit-prep-queue` | `local.settings.json` / App Settings | Queue used to generate missing PMTiles and sidecars. | + +No feature flag is implemented in the current branch; the API routes and UI +entry points are present when the branch is deployed. No new third-party +dependency is required. PMTiles support already exists in the UI, and +`tippecanoe` already exists in the training image. + +## Observability + +- **Logs:** Log session readiness, queued prep requests, source schema flavor, + row-count validation, version allocation, edit counts, and final artifact urls + without logging SAS tokens. +- **Metrics:** Track session readiness failures, prep duration, save duration, + edited GeoPackage size, and edited counts. +- **Queue depth:** Monitor `prediction-edit-prep-queue` depth and dead-letter + count. +- **Storage:** Alert on failed uploads for PMTiles, sidecars, and edited + GeoPackages. +- **UI errors:** Surface load, sidecar parse, and save errors in the right panel + with retry actions. + +## Open Questions + +- [ ] Should edit application move to an async queue if production layers exceed + Azure Functions request-timeout or memory budgets? +- [ ] Should edited-version saves use Cosmos ETags, blob leases, or another + optimistic-concurrency mechanism to prevent concurrent version collisions? +- [ ] Should a future downstream-consumption spec choose a single active edited + version, or let each report/publish call accept a version id? diff --git a/spec/features/prediction-editing/impact-analysis.md b/spec/features/prediction-editing/impact-analysis.md new file mode 100644 index 00000000..82a54229 --- /dev/null +++ b/spec/features/prediction-editing/impact-analysis.md @@ -0,0 +1,122 @@ +# Impact Analysis: Prediction Editing + +**Contents:** [Scope of Change](#scope-of-change) · [Azure Service Impact](#azure-service-impact) · [Dependency Analysis](#dependency-analysis) · [Risk Assessment](#risk-assessment) · [Performance Impact](#performance-impact) · [Security Impact](#security-impact) · [Compliance & Data Impact](#compliance--data-impact) · [Rollback Assessment](#rollback-assessment) + +## Scope of Change + +### HASTE Components Affected + +| Component | Path | Type of Change | Severity | +|---|---|---|---| +| Core library | `hastelib/src/hastegeo/core/models/`, `hastelib/src/hastegeo/core/processors/`, `hastelib/src/hastegeo/core/utils/`, `hastelib/src/hastegeo/core/config.py` | modified / new | high | +| REST API | `api/hastefuncapi/function_app.py` | new endpoints; modified artifact dispatch | medium | +| Queue workers | `api/hastefuncqueues/function_app.py` | new prep trigger | medium | +| React UI | `ui/src/Components/...` | new route/editor; modified model rows | high | +| Docker config | `docker/training/` | no new package expected; uses existing `tippecanoe` in training env | low | +| CI/CD / infra | `.github/workflows/...`, `infra/modules/functions.bicep` | no workflow change; explicit Bicep app-setting parity for the prep queue was skipped to avoid `infra/main.json` drift | low | + +## Azure Service Impact + +| Service | Change | New Cost Impact | +|---|---|---| +| Cosmos DB | Model and ImageLayer documents gain optional fields; Model appends small version records | low RU increase per session/save | +| Blob Storage | Stores PMTiles, sidecars, and one edited GeoPackage per save | proportional to footprint count and version count | +| Queue Storage | Adds prep messages for missing PMTiles/sidecars | low; bursty when editors first open layers | +| Azure Functions | Adds three HTTP routes and one queue trigger | low to medium CPU/memory during save and metadata reads | +| Azure Batch | Reuses existing runner/training image path for `tippecanoe` prep | low; CPU-bound tile jobs may occupy existing nodes | +| Static Web Apps | Adds one route and larger client-side editing workflow | low hosting impact; browser memory is the main concern | + +## Dependency Analysis + +### Upstream Dependencies (things this feature needs) + +| Dependency | Type | Status | Risk if Unavailable | +|---|---|---|---| +| Raw prediction GeoPackage (`Model.gpkgUrl`) | artifact | available after prediction | Editor cannot open or save. | +| Source building footprints (`ImageLayer.buildingFootprintsUrl`) | artifact | available after imagery prep | Cannot derive `overture_id` or validate row-order mapping. | +| `tippecanoe` in training image | container tool | available only in training env | PMTiles cannot be generated from Functions inline (`docker/training/env/env.yml:11`). | +| PMTiles JS support | UI dependency | already present | Editor map cannot stream full geometry efficiently. | +| Azure Maps | UI mapping | available in app | Editor loses primary visual interaction surface. | + +### Downstream Impact (things affected by this feature) + +| Consumer | How Affected | Breaking? | Migration Needed? | +|---|---|---|---| +| `hastefuncapi` callers | New endpoints and artifact kinds; existing endpoints unchanged | no | no | +| React model rows | New Edit action and stricter embedding edit gating | no | no | +| Existing Cosmos documents | Optional fields absent until touched/backfilled | no | no blocking migration | +| Assessment report | Not changed; continues using raw `Model.gpkgUrl` | no | follow-up spec required to consume edits | +| Validation report | Not changed; continues using raw `Model.gpkgUrl` | no | follow-up spec required to consume edits | +| Data publishing | Not changed; edited versions not publishable in v1 | no | follow-up spec required | +| Visualizer | Not changed; edited versions not shown in v1 | no | follow-up spec required | + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | Owner | +|---|---|---|---|---| +| Positional row-order invariant breaks Overture id mapping | medium | high | Assert row count and row order in prep/save tests; never sort or spatial-join edited output; write explicit `overture_id` for audit. | `gis` | +| Editor default threshold and `GetAssessmentReport` default differ | medium | medium | Document the current split: editor defaults to `0.0` to reproduce raw stored predictions, while reports still default to `0.1`; add product follow-up if this confuses users. | `backend-dev` | +| Large layers exceed memory in tile prep or edit application | medium | high | Keep browser geometry in PMTiles; measure whole-GPKG reads; add performance tests; move save to async if needed. | `backend-dev`, `gis` | +| HTTP handler tries to run `tippecanoe` inline | low | high | Keep PMTiles generation in `prediction-edit-prep-queue`; test absence of inline generation path. | `backend-dev` | +| Embedding `gpkgUrl` is treated as a full prediction set after Clear labels | high | medium | Gate on `predictedBuildingCount > 0` and set `predictedAt` only after non-empty predictions. | `ui`, `backend-dev` | +| Edited artifact overwrites raw output | low | high | Never write to `Model.gpkgUrl`; use `EDITED_PREDICTIONS_GPKG` with version in the name and append metadata. | `backend-dev` | +| UI hard-coded colors fail dark mode | medium | medium | Require `makeStyles` + Fluent tokens; add UI review checklist item. | `ui` | +| UI lint remains red because of existing ESLint 9 flat-config mismatch | high | medium | Treat CI gate as no regression from baseline; record baseline failure and require targeted UI tests. | `ui-validation` | +| Concurrent edited-version saves collide | medium | medium | Current implementation has no 409/ETag conflict handling; add optimistic concurrency before relying on simultaneous multi-analyst saves. | `backend-dev` | + +## Performance Impact + +- **API latency:** `GetPredictionEditSession` is read-only and does not enqueue, + but it downloads the raw prediction GeoPackage to detect flavor and count + rows. `PutPreparePredictionTilesQueueMessage` performs the queue request. + `PutEditedPredictions` reads and writes a full GeoPackage in v1, so large + layers may approach function timeout or memory limits. +- **Queue throughput:** New prep jobs are CPU and I/O bound. They should be + idempotent and skip PMTiles or sidecar generation when artifacts already + exist. +- **Tile serving:** The editor uses static PMTiles artifacts, not TiTiler for + vector tiles. Tile serving load shifts to Blob/download bandwidth. +- **Batch compute:** No GPU is needed. Existing training-image jobs may consume + CPU on the current runner pool while generating PMTiles. +- **Storage I/O:** Each editor open may download PMTiles and sidecar data; each + save writes a full edited GeoPackage. + +## Security Impact + +- [x] New API endpoints exposed? Use existing `func.AuthLevel.FUNCTION` and SWA + auth pattern. +- [x] New data classification handled? Edited predictions are derived disaster + assessment geospatial data, same sensitivity as raw model outputs. +- [ ] MSAL/Entra ID auth changes? None expected. +- [ ] New secrets or connection strings required? None expected. +- [ ] CORS configuration changes in SWA? None expected. +- [ ] New federated credentials needed? None expected. + +## Compliance & Data Impact + +- [x] Geospatial data sovereignty concerns? Same as raw project artifacts; + edited versions must stay in the project storage boundary. +- [x] Partner data sharing agreements affected? No external sharing in v1; + downloads are existing-authenticated artifact access. +- [x] New data retention requirements? Versioned edited GeoPackages increase + retained derived artifacts; retention follows project artifact retention. +- [x] Audit logging for new operations? Save logs should include project, + model, version, editor identity when available, and edited count. +- [ ] Component Governance scan implications? None unless implementation adds + dependencies; current design reuses existing packages. + +## Rollback Assessment + +- **Reversibility:** fully reversible for runtime behavior by disabling feature + flags; persisted optional metadata and blobs can remain safely. +- **Cosmos data:** Old code ignores optional `editedPredictions`, + `predictedBuildingCount`, `predictedAt`, `predictionAttrsUrl`, + `predictionTilesJob`, `predictionTilesStatus`, + `predictionTilesStatusMessage`, and `footprintPmtilesUrl`. Cleanup is + optional, not required for rollback. +- **Blob data:** Edited GeoPackages, sidecars, and PMTiles are additive derived + artifacts. They can be deleted by approved maintenance tooling if needed. +- **API:** New endpoints and artifact kinds are backward-compatible. Existing + endpoint contracts are unchanged. +- **Estimated rollback time:** Immediate feature-flag disable; less than 30 + minutes to redeploy a reverted UI/API if required. diff --git a/spec/features/prediction-editing/plan.md b/spec/features/prediction-editing/plan.md new file mode 100644 index 00000000..94a49962 --- /dev/null +++ b/spec/features/prediction-editing/plan.md @@ -0,0 +1,136 @@ +# Execution Plan: Prediction Editing + +**Contents:** [Phases](#phases) · [Milestones](#milestones) · [Agent Summary](#agent-summary) · [Resource Requirements](#resource-requirements) · [Open Questions](#open-questions) + +## Phases + +### Phase 1: Core Library — implemented + +**Goal:** Implement core models, artifact naming, schema normalization, and +versioned edit writing in `hastelib/src/hastegeo/`. + +| Task | Agent | Dependencies | Story Ref | Status | +|---|---|---|---|---| +| Add `EditedPredictionVersion`, `Model.editedPredictions`, `Model.predictedBuildingCount`, `Model.predictedAt`, `Model.predictionAttrsUrl`, `Model.predictionTilesJob`, `Model.predictionTilesStatus`, `Model.predictionTilesStatusMessage`, and `ImageLayer.footprintPmtilesUrl` | `backend-dev` | — | US-002, US-004 | complete | +| Add transport-only wire models in `hastelib/src/hastegeo/core/models/predictions.py` | `backend-dev` | model fields | US-002, US-004 | complete | +| Add `EDITED_PREDICTIONS_GPKG`, `PREDICTION_ATTRS`, and `LAYER_FOOTPRINT_PMTILES` artifact types | `backend-dev` | — | US-002, US-004 | complete | +| Implement prediction schema detection for trained inference vs embedding outputs in `core/utils/predictions.py` | `backend-dev`, `gis` | model fields | US-002 | complete | +| Implement row-order validation and Overture id extraction from source footprints | `gis` | schema detection | US-002, US-004 | complete | +| Implement class derivation and edited GeoPackage writer in `core/processors/prediction_edits.py` | `backend-dev`, `gis` | row-order validation | US-004 | complete | +| Write unit tests for schema detection, class derivation, version allocation, and row-order preservation | `backend-dev`, `gis` | all above | US-002, US-004 | complete | + +> **Agent column:** Use HASTE agent names (`backend-dev`, `gis`, `ui`, `security`). See [user-stories.md](user-stories.md#agent-assignment-map) for the full agent→story mapping. + +**Exit Criteria:** +- [x] `hastelib` unit tests cover both producer schemas and row-order preservation. +- [x] Edited GeoPackage generation works independently of the API layer. +- [x] Raw `Model.gpkgUrl` remains unchanged after saves. + +### Phase 2: API Layer — implemented with known test gaps + +**Goal:** Expose prediction editing through thin `hastefuncapi` routes and a +queued preparation worker. + +| Task | Agent | Dependencies | Story Ref | Status | +|---|---|---|---|---| +| Add side-effect-free `GetPredictionEditSession` route | `backend-dev` | Phase 1 models | US-002 | complete | +| Add `PutPreparePredictionTilesQueueMessage` route for explicit prep queue requests | `backend-dev` | `core/processors/prediction_tiles.py` | US-002 | complete | +| Add `PutEditedPredictions` route | `backend-dev` | edited GeoPackage writer | US-004 | complete | +| Add `GetEditedPredictionVersions` route | `backend-dev` | Phase 1 models | US-005 | complete | +| Extend `GetModelArtifact` with `footprint_pmtiles` and `prediction_attrs` kinds | `backend-dev` | artifact types | US-002, US-005 | complete | +| Add `workflows/prepare_prediction_tiles.py` prep workflow (footprint PMTiles + attribute sidecar) | `gis` | Phase 1 prediction reader | US-002 | complete | +| Add `core/processors/prediction_tiles.py` runner orchestration | `gis` | prep workflow | US-002 | complete | +| Add `prediction-edit-prep-queue` trigger in `hastefuncqueues` | `backend-dev`, `gis` | prep workflow | US-002 | complete | +| Add API integration tests for validation, readiness, save, and version-list responses | `backend-dev` | routes | US-002, US-004, US-005 | not-started | +| Add `infra/modules/functions.bicep` app-setting parity for the new queue | `backend-dev` | queue config | US-002 | skipped — `Config` has a default and changing Bicep without regenerating `infra/main.json` would create infra drift | + +**Exit Criteria:** +- [x] Endpoints are implemented as Azure Functions routes. +- [x] Missing PMTiles/sidecars are generated by the queue worker, not inline in HTTP. +- [x] `PutEditedPredictions` returns `version`, `gpkgUrl`, and `editedCount` for both producer schemas. +- [ ] Docker Compose local stack can exercise session prep and save. +- [ ] API-level integration tests exist for the new routes. + +### Phase 3: UI — implemented with validation gaps + +**Goal:** Surface the editor in React using Azure Maps, PMTiles, Fluent UI, and +existing HASTE interaction patterns. + +| Task | Agent | Dependencies | Story Ref | Status | +|---|---|---|---|---| +| Add **Edit** action to trained model rows with `inferenceStatus === "Processed" && gpkgUrl` gating | `ui` | API route contract | US-001 | complete | +| Add **Edit** action to embedding rows with `gpkgUrl && predictedBuildingCount > 0` gating | `ui` | model field | US-001 | complete | +| Register `/edit-predictions/:projectId/:imageLayerId/:modelId` in `AppBody.jsx` | `ui` | route component | US-001 | complete | +| Build `PredictionEditor` map with PMTiles in-memory source and feature-state coloring | `ui` | session and artifact APIs | US-003 | complete | +| Build right panel filters, counts, edited filter, and prev/next traversal | `ui` | map selection state | US-003 | complete | +| Add threshold slider for trained-inference only and live flip counts | `ui` | sidecar class derivation | US-003 | complete | +| Add save-as-new-version action and version-history display | `ui` | `PutEditedPredictions`, versions API | US-004, US-005 | complete | +| Add one-click edited-version download action in the right panel | `ui` | version history display | US-005 | not-started | +| Add shared PMTiles protocol singleton in `ui/src/util/pmtiles.js` and use it from editor screens | `ui` | PMTiles map sources | US-002, US-003 | complete | +| Add plain Node unit tests for `predictionClassify.js` and `predictionPrep.js` | `ui` | UI helpers | US-002, US-003, US-004 | complete | +| Add browser/Playwright coverage for gating, threshold visibility, selection, and save flow | `ui-validation` | UI implementation | US-001, US-003, US-005 | not-started — no Playwright config exists | + +**Exit Criteria:** +- [x] Feature is accessible from both model-row workflows. +- [ ] Editor works with PMTiles and sidecar data in local SWA dev. +- [x] UI uses `makeStyles` and Fluent tokens; no hard-coded semantic hex colors. +- [ ] UI validation shows no regression from the current lint baseline. +- [ ] Browser/Playwright validation exists for the editor screen. + +### Phase 4: Integration & Deployment — TBD + +**Goal:** Validate end-to-end behavior and prepare safe rollout. + +| Task | Agent | Dependencies | Story Ref | Status | +|---|---|---|---|---| +| Run end-to-end Docker Compose scenario for trained-inference predictions | `backend-dev`, `ui`, `gis` | Phases 1-3 | US-001, US-002, US-003, US-004 | not-started | +| Run end-to-end Docker Compose scenario for embedding predictions | `backend-dev`, `ui`, `gis` | Phases 1-3 | US-001, US-002, US-003, US-004 | not-started | +| Verify versioned downloads and raw `Model.gpkgUrl` immutability | `backend-dev` | Phases 1-3 | US-004, US-005 | not-started | +| Verify Azure monitoring and queue dead-letter visibility | `backend-dev` | Phase 2 | US-002 | not-started | +| Update end-user docs only after behavior is implemented | `ui` | Feature complete | US-001-US-005 | not-started | + +**Exit Criteria:** +- [ ] Docker Compose validates both workflows. +- [ ] Targeted backend tests pass. +- [ ] Targeted UI helper tests pass; Playwright coverage is added or explicitly waived. +- [ ] CI passes or has a documented no-regression exception for the known UI lint baseline. + +## Milestones + +| Milestone | Date | Deliverable | +|---|---|---| +| Spec approved | TBD | Draft spec and ADR reviewed. | +| Core library done | TBD | Models, artifact types, class derivation, and GeoPackage writer merged. | +| Prep/API done | TBD | Session, save, version list, artifact retrieval, and queue prep working. | +| UI editor done | TBD | Route, map, filters, threshold, overrides, and version list working. | +| Release | TBD | Feature promoted after dev/test validation. | + +## Agent Summary + +| Agent | Tasks Owned | Phases | +|---|---|---| +| `backend-dev` | 16 | 1, 2, 4 | +| `gis` | 6 | 1, 2, 4 | +| `ui` | 12 | 3, 4 | +| `ui-validation` | 1 | 3 | +| `security` | 0 | —; no new dependency is expected | + +## Resource Requirements + +- **Agents:** `backend-dev`, `gis`, `ui`, `backend-validation`, and + `ui-validation`. +- **Azure services:** Existing Cosmos metadata store, Blob Storage, Queue + Storage, Azure Functions, and existing Batch/runner capacity. +- **GPU compute:** None required for editing or tile generation. The training + container is used because it contains `tippecanoe`, not because GPU is needed. +- **External data:** None beyond existing project imagery, footprints, and model + prediction artifacts. + +## Open Questions + +- [x] Confirm the concrete queue config key name before implementation. + Resolved: `prediction_edit_prep_queue_name` in `Config.get_queue_config()` + (env `PREDICTION_EDIT_PREP_QUEUE_NAME`, default `prediction-edit-prep-queue`, + `local-prediction-edit-prep-queue` in the Docker Compose stack). +- [ ] Decide whether high-volume saves need an async save path after measuring + real production layer sizes. diff --git a/spec/features/prediction-editing/rollout.md b/spec/features/prediction-editing/rollout.md new file mode 100644 index 00000000..d07bbb81 --- /dev/null +++ b/spec/features/prediction-editing/rollout.md @@ -0,0 +1,125 @@ +# Rollout Plan: Prediction Editing + +**Contents:** [Rollout Strategy](#rollout-strategy) · [Deployment Targets](#deployment-targets) · [Feature Flags](#feature-flags) · [Rollout Phases](#rollout-phases) · [Rollback Plan](#rollback-plan) · [Monitoring & Alerting](#monitoring--alerting) · [Communication Plan](#communication-plan) · [Post-Rollout Checklist](#post-rollout-checklist) + +## Rollout Strategy + +**Type:** phased by environment deployment +**Target date:** TBD + +The current implementation does not include API or UI feature flags. Start with +internal dev/test deployments and test projects, then promote to production +after both trained-inference and embedding workflows produce edited versions +without mutating raw outputs. Add feature flags as a follow-up if rollout needs +a runtime kill switch. + +## Deployment Targets + +| Component | Deployment Method | Target | +|---|---|---| +| `hastelib` | pip install / Docker rebuild | All Function Apps and queue workers | +| `hastefuncapi` | GitHub Actions `deploy-apps.yml` | Azure Functions | +| `hastefuncqueues` | GitHub Actions `deploy-apps.yml` | Azure Functions | +| React UI | GitHub Actions `deploy-apps.yml` | Azure Static Web Apps | + +## Feature Flags + +| Flag Name | Location | Default | Description | Kill Switch? | +|---|---|---|---|---| +| — | — | — | No prediction-editing feature flags are implemented in the current branch. | no | + +## Rollout Phases + +### Phase 1: Dev1 Environment — TBD + +- **Target:** SWA `dev1` environment +- **Duration:** one sprint or until both workflows pass E2E validation +- **Deployment:** + 1. Deploy the branch to dev1. + 2. Verify trained-inference and embedding edit flows against test projects. +- **Success criteria:** + - [ ] `GetPredictionEditSession` reports correct readiness for both workflows without enqueueing. + - [ ] `PutPreparePredictionTilesQueueMessage` queues missing PMTiles and sidecars. + - [ ] Queue workers generate missing PMTiles and sidecars. + - [ ] UI renders the editor, class filters, selection, and threshold behavior. + - [ ] Saving creates `edit_v1` without changing raw `Model.gpkgUrl`. +- **Rollback trigger:** Any raw artifact mutation, repeated prep queue failures, + or browser crashes on representative layers. + +### Phase 2: Testing Environment — TBD + +- **Target:** SWA `testing` environment +- **Duration:** one response exercise or agreed analyst validation window +- **Success criteria:** + - [ ] Analysts can complete and download edited versions for trained models. + - [ ] Analysts can complete and download edited versions for embedding models. + - [ ] The documented editor/report threshold default split is accepted by testers. + - [ ] Memory and duration metrics stay within accepted bounds. + - [ ] No regression from baseline UI lint behavior. +- **Rollback trigger:** Save failures above the agreed threshold, invalid row-order + output, or editor performance that blocks analyst use. + +### Phase 3: Production — TBD + +- **Target:** Production SWA + Function Apps +- **Federated credentials:** `fed-cred-main.json` (GitHub Actions OIDC) +- **Success criteria:** + - [ ] Error rate and queue depth remain stable after production deployment. + - [ ] First production edited version downloads and validates row count/order. + - [ ] Analyst feedback confirms the editor is usable in dark and light themes. +- **Kill-switch follow-up:** If production requires runtime disablement, add the + missing API/UI feature flags before broad enablement. + +## Rollback Plan + +| Step | Action | Owner | ETA | +|---|---|---|---| +| 1 | Redeploy the previous UI build to remove Edit entry points | `ui` | <1 hour | +| 2 | Redeploy the previous API build if direct prediction-editing calls must fail closed | `backend-dev` | <1 hour | +| 3 | Stop or drain `prediction-edit-prep-queue` if workers are failing | `backend-dev` | <30 min | +| 4 | Verify raw `Model.gpkgUrl` and existing reports still work | `backend-validation` | <1 hour | + +**Cosmos data rollback required?** no — new fields are optional and backward-compatible. +**Blob artifacts cleanup needed?** no for functional rollback — edited GeoPackages, +PMTiles, and sidecars are additive derived artifacts. Cleanup can run later if +storage cost requires it. + +## Monitoring & Alerting + +### Key Metrics to Watch + +| Metric | Source | Baseline | Alert Threshold | +|---|---|---|---| +| Prediction edit session error rate | Azure Functions metrics / Application Insights | new metric | >5% 5xx over 15 minutes | +| `PutEditedPredictions` duration and memory | Application Insights | new metric | p95 near function timeout or memory ceiling | +| Prep queue depth | Azure Queue Storage metrics | 0 when idle | sustained growth for 30 minutes | +| Prep job failures | queue worker logs / Batch task status | 0 | any repeated failure for same model | +| Edited artifact upload failures | Blob SDK logs | 0 | any production failure | +| Browser-side editor errors | UI telemetry / support reports | 0 | repeated sidecar parse or map-load failures | + +### Alerts to Configure + +| Alert | Condition | Severity | Notify | +|---|---|---|---| +| Prep queue stalled | `prediction-edit-prep-queue` depth rising and no completions for 30 minutes | P2 | Engineering on-call | +| Save failures | `PutEditedPredictions` 5xx rate >5% over 15 minutes | P2 | Engineering on-call | +| Row-order validation failure | Any 422 row-count/order failure in production | P1 | Backend + GIS leads | +| Blob upload failures | Edited GeoPackage upload errors >0 for production saves | P2 | Engineering on-call | + +## Communication Plan + +| Audience | Channel | When | Message | +|---|---|---|---| +| Engineering team | GitHub PR / Teams | Before dev1 deployment | Prediction editing has no runtime flags in this branch; verify both workflows and artifact immutability before promotion. | +| Disaster analysts | Release notes / Teams | Before testing enablement | Edit completed predictions, save numbered versions, and download them; reports still use raw outputs. | +| Partners | Release notes | At production enablement | Edited prediction GeoPackages may be shared as downloadable derived files; downstream reports are unchanged. | + +## Post-Rollout Checklist + +- [ ] Decide whether to add runtime feature flags before broad production use. +- [ ] Temporary rollout monitoring removed or converted to normal dashboards. +- [ ] End-user docs updated with edit workflow and out-of-scope downstream behavior. +- [ ] GitHub Pages docs rebuilt (`docs-deploy.yml`) if public docs changed. +- [ ] Docker Compose stack verified after release. +- [ ] `CHANGELOG.md` updated. +- [ ] Follow-up spec opened for downstream consumption of edited versions. diff --git a/spec/features/prediction-editing/test-plan.md b/spec/features/prediction-editing/test-plan.md new file mode 100644 index 00000000..486252a6 --- /dev/null +++ b/spec/features/prediction-editing/test-plan.md @@ -0,0 +1,159 @@ +# Test Plan: Prediction Editing + +**Contents:** [Test Strategy](#test-strategy) · [Test Scenarios](#test-scenarios) · [Test Data Requirements](#test-data-requirements) · [Coverage Matrix](#coverage-matrix) · [Environment Requirements](#environment-requirements) · [Sign-off Criteria](#sign-off-criteria) + +## Test Strategy + +| Level | Scope | Tool/Framework | Coverage Target | +|---|---|---|---| +| Unit | `hastegeo` schema detection, class derivation, sidecar generation, row-order preservation, version allocation | pytest / unittest (`hastelib/tests/`) | all core rules and both producer schemas | +| Integration | Prediction edit HTTP endpoints and artifact retrieval | pytest + Azure Functions test harness | success and negative responses; not implemented in current branch | +| Queue | PMTiles and sidecar prep worker | pytest / Docker Compose worker test | idempotent generation and failure handling | +| UI | Edit buttons, route, map state, filters, threshold slider, save flow | Plain Node unit tests for helpers today; browser/Playwright follow-up | critical analyst flows | +| E2E | Full stack with trained and embedding predictions | Docker Compose + manual verification; Playwright unavailable today | one successful version save per workflow | +| Performance | Large layer prep/save/browser memory | custom scripts with representative GeoPackages | no timeout/memory regression beyond agreed thresholds | + +## Test Scenarios + +### Unit Tests (`hastelib/tests/`) + +| ID | Module | Scenario | Input | Expected Output | Story Ref | +|---|---|---|---|---|---| +| UT-001 | `hastegeo/core/models/projects.py` | Model defaults | Model without new fields | `editedPredictions` behaves as empty list; prediction count/timestamps/sidecar/job/status fields nullable/defaulted | US-004 | +| UT-002 | `hastegeo/core/config.py` | Artifact template rendering | `modelId=123`, `version=2`, `imageLayerId=abc` | `edited_predictions_123_v2`, `prediction_attrs_123`, `footprints_abc` | US-002, US-004 | +| UT-003 | `hastegeo/core/utils/predictions.py` | Trained schema detection | GPKG with `damage_pct_0m`, `damage_pct_10m`, `damage_pct_20m`, `damaged`, `unknown_pct` | `flavor="inference"`, `supportsThreshold=true` | US-002 | +| UT-004 | `hastegeo/core/utils/predictions.py` | Embedding schema detection | GPKG layer `predictions` with `area`, `damaged`, degenerate `damage_pct_0m` | `flavor="embedding"`, `supportsThreshold=false` | US-002 | +| UT-005 | `hastegeo/core/processors/prediction_edits.py` | Class derivation without override | damage `0.2`, unknown `0.0`, threshold `0.1` | `Damaged`, `damaged=1` | US-003, US-004 | +| UT-006 | `hastegeo/core/processors/prediction_edits.py` | Unknown wins before damage | damage `0.8`, unknown `0.3`, unknownThreshold `0.0` | `Unknown`, `damaged=0` | US-003, US-004 | +| UT-007 | `hastegeo/core/processors/prediction_edits.py` | Override wins over thresholds | override `NotDamaged`, damage `0.9` | `NotDamaged`, `damaged=0` | US-003, US-004 | +| UT-008 | `hastegeo/core/processors/prediction_edits.py` | Row-order invariant | Footprints ids `[a,b,c]`; predictions rows `[0,1,2]` | Edited rows remain `[0,1,2]` with `overture_id` `[a,b,c]` | US-004 | +| UT-009 | `hastegeo/core/processors/prediction_edits.py` | Row-count mismatch | Footprints 3 rows; predictions 2 rows | Raises validation error; no version metadata appended | US-002, US-004 | +| UT-010 | `hastegeo/core/processors/prediction_edits.py` | Version allocation | Existing versions `[1,2]` | Next artifact uses version `3` | US-004 | +| UT-011 | `hastegeo/workflows/prepare_prediction_tiles.py` | Sidecar shape | Three prediction rows | JSON has `n=3` and same-length `ids`, `overtureIds`, `damage`, `unknown`, `damaged` arrays | US-002 | +| UT-012 | `hastegeo/core/models/predictions.py` | Wire request validation | Save/prep request bodies | Invalid IDs, thresholds, classes, duplicate override IDs rejected before processors run | US-002, US-004 | +| UT-013 | `hastegeo/core/processors/prediction_tiles.py` | Prep request idempotency | Ready, missing, in-flight, and forced model/layer states | Returns `{modelId, queued, tilesReady, attrsReady, status, statusMessage}` and enqueues at most one message | US-002 | + +### API Integration Tests + +No prediction-editing API integration tests are implemented in the current +branch. `api/hastefuncapi/tests/` contains only `test_publishing_routes.py`; the +cases below remain follow-up coverage. + +| ID | Endpoint | Method | Scenario | Preconditions | Expected Response | Story Ref | +|---|---|---|---|---|---|---| +| IT-001 | `/api/GetPredictionEditSession` | GET | Ready trained model | Processed inference model with raw GPKG, PMTiles, sidecar | 200 with `flavor="inference"`, `supportsThreshold=true`, `defaultThreshold=0.0`, readiness flags, and prep status fields | US-002 | +| IT-002 | `/api/GetPredictionEditSession` | GET | Ready embedding model | Embedding model with `gpkgUrl` and `predictedBuildingCount>0` | 200 with `flavor="embedding"`, `supportsThreshold=false` | US-002 | +| IT-003 | `/api/GetPredictionEditSession` | GET | Missing prep artifacts | Raw GPKG exists, PMTiles/sidecar absent | 200 with readiness false and no queued message | US-002 | +| IT-004 | `/api/PutPreparePredictionTilesQueueMessage` | PUT | Queue missing prep | Raw GPKG and building footprints exist; artifacts missing | 200 with `queued=true`, `status="Queued"`, and exactly one queue message | US-002 | +| IT-005 | `/api/PutPreparePredictionTilesQueueMessage` | PUT | Ready no-op | PMTiles and sidecar already exist; `force=false` | 200 with `queued=false`, `tilesReady=true`, `attrsReady=true`, no queue message | US-002 | +| IT-006 | `/api/PutPreparePredictionTilesQueueMessage` | PUT | In-flight no-op | `predictionTilesStatus` is `Queued` or `InProgress`; `force=false` | 200 with `queued=false`, current status, no duplicate queue message | US-002 | +| IT-007 | `/api/PutPreparePredictionTilesQueueMessage` | PUT | Missing source inputs | No `gpkgUrl` or no `buildingFootprintsUrl` | 404 | US-002 | +| IT-008 | `/api/PutEditedPredictions` | PUT | Save first edit | Valid thresholds and overrides | 200 with `version=1`, `gpkgUrl`, `editedCount`; Model gets one version | US-004 | +| IT-009 | `/api/PutEditedPredictions` | PUT | Invalid threshold | `threshold=2` | 400 | US-004 | +| IT-010 | `/api/PutEditedPredictions` | PUT | Override out of range | `id >= buildingCount` | 200; unmatched override ignored and not counted | US-004 | +| IT-011 | `/api/GetEditedPredictionVersions` | GET | Existing versions | Model has versions | 200 with version metadata list, newest first | US-005 | +| IT-012 | `/api/GetModelArtifact` | GET | Fetch new artifact kinds | Prepared PMTiles and sidecar | 200 for `footprint_pmtiles` and JSON `prediction_attrs` | US-002, US-005 | +| IT-013 | `/api/GetPredictionEditSession` | GET | Missing model | Unknown `modelId` | 404 | US-002 | + +### Queue Worker Tests + +| ID | Queue | Scenario | Message | Expected Side Effect | Story Ref | +|---|---|---|---|---|---| +| QT-001 | `prediction-edit-prep-queue` | Build missing PMTiles and sidecar | valid project/layer/model/source urls | PMTiles and sidecar blobs uploaded; metadata fields updated | US-002 | +| QT-002 | `prediction-edit-prep-queue` | Idempotent no-op | artifacts already exist and `force=false` | No duplicate work; metadata remains consistent | US-002 | +| QT-003 | `prediction-edit-prep-queue` | Force rebuild | artifacts exist and `force=true` | Artifacts regenerated and metadata timestamp refreshed | US-002 | +| QT-004 | `prediction-edit-prep-queue` | Malformed message | missing `modelId` | Worker logs validation error and dead-letters/fails without partial metadata | US-002 | +| QT-005 | `prediction-edit-prep-queue` | Row-count mismatch | predictions and footprints lengths differ | Prep fails; no `predictedAt` update | US-002 | + +### UI Component Tests + +The current branch includes plain Node tests for `predictionClassify.js` and +`predictionPrep.js`. It does not include a React Testing Library, Vitest, or +Playwright harness for browser rendering. + +| ID | Component | Scenario | User Action | Expected Behavior | Story Ref | +|---|---|---|---|---|---| +| UI-001 | `ModelResultsButton.jsx` | Trained edit gating | Render model variations | Enabled only when `inferenceStatus === "Processed" && gpkgUrl` | US-001 | +| UI-002 | `EmbeddingModelRow.jsx` | Embedding edit gating | Render model variations | Enabled only when `gpkgUrl && predictedBuildingCount > 0` | US-001 | +| UI-003 | `PredictionEditor.jsx` / `predictionPrep.js` | Prep pending | Load session with `tilesReady=false` | Calls `PutPreparePredictionTilesQueueMessage`, shows preparation state, and polls session | US-002 | +| UI-004 | `PredictionEditor.jsx` / `predictionClassify.js` | Trained threshold | Load `supportsThreshold=true`; move slider | Slider visible; colors and flip counts update | US-003 | +| UI-005 | `PredictionEditor.jsx` | Embedding no threshold | Load `supportsThreshold=false` | Slider hidden; manual overrides available | US-003 | +| UI-006 | `PredictionEditor.jsx` | Click classify | Click footprint and choose class | Feature color and counts update via feature-state | US-003 | +| UI-007 | `PredictionEditor.jsx` | Box-select classify | Ctrl+drag selection and choose class | All selected features update | US-003 | +| UI-008 | `PredictionEditor.jsx` | Save version | Click Save as new version | PUT body includes thresholds and overrides; version list refreshes | US-004, US-005 | +| UI-009 | `PredictionEditorRightPanel.jsx` | Version history | Save or load existing versions | History displays version, timestamp, threshold, editor, and edited count; one-click download remains follow-up | US-005 | +| UI-010 | `PredictionEditor.jsx` | Dark mode | Render in dark theme | Styles use Fluent tokens and remain legible | US-003 | +| UI-011 | `ui/src/util/pmtiles.js` | Shared protocol singleton | Render multiple PMTiles screens | Both screens share one `pmtiles://` protocol instance | US-002, US-003 | + +### End-to-End Tests (Docker Compose) + +| ID | User Flow | Steps | Expected Outcome | Story Ref | +|---|---|---|---|---| +| E2E-001 | Trained prediction edit | 1. Start Docker Compose 2. Use a processed trained model with `gpkgUrl` 3. Open Edit 4. Wait for prep 5. Change threshold and override one building 6. Save | `edit_v1` GeoPackage downloads; raw `Model.gpkgUrl` unchanged | US-001-US-005 | +| E2E-002 | Embedding prediction edit | 1. Start Docker Compose 2. Use an embedding model with non-empty predictions 3. Open Edit 4. Confirm no threshold slider 5. Override one building 6. Save | `edit_v1` GeoPackage downloads with expected class columns | US-001-US-005 | +| E2E-003 | Empty embedding predictions | 1. Save empty embedding predictions 2. Return to project management | Edit button remains disabled because `predictedBuildingCount` is not positive | US-001 | + +### Edge Case & Negative Tests + +| ID | Scenario | Input | Expected Behavior | +|---|---|---|---| +| NEG-001 | Unauthenticated API request | No function key / invalid auth context | 401 or existing platform auth failure | +| NEG-002 | Non-existent project ID | Random GUID | 404 | +| NEG-003 | Invalid class | override class `Destroyed` | 400 | +| NEG-004 | Duplicate override ids | two overrides for id `7` | 400 or deterministic client-side collapse before request | +| NEG-005 | Missing raw GPKG | Model lacks `gpkgUrl` | 404 from session; button disabled in UI | +| EDGE-001 | Very large layer | Representative large GeoPackage | Prep/save complete within agreed memory/time budget or produce actionable error | +| EDGE-002 | Concurrent saves | Parallel PUT requests | Known gap: current implementation can allocate the same next version; add optimistic concurrency follow-up | +| EDGE-003 | Threshold default split | Session default vs report default | Editor session remains `0.0`; assessment report default remains `0.1`; product decision is documented | +| EDGE-004 | UI lint baseline | Current repo-wide ESLint 9 flat-config failure | Validation records no regression from baseline, not necessarily clean lint | + +### Performance Tests + +| ID | Scenario | Load Profile | Target Metric | Threshold | +|---|---|---|---|---| +| PERF-001 | Session readiness | 50 concurrent session requests that read raw prediction GeoPackages for flavor/count | p99 latency | threshold TBD after representative GPKG measurement | +| PERF-002 | PMTiles/sidecar prep | One dense urban layer | job duration and peak memory | fit existing worker/Batch limits; no OOM | +| PERF-003 | Save edited version | GeoPackage at 95th percentile building count | function duration and peak memory | complete below platform timeout or trigger async-save follow-up | +| PERF-004 | Browser editing | PMTiles + sidecar for dense layer | Chrome heap and interaction latency | no tab crash; pan/selection remains usable | + +## Test Data Requirements + +| Dataset | Description | Source | Sensitive? | +|---|---|---|---| +| Trained inference sample GeoPackage | Includes continuous `damage_pct_0m`, `damage_pct_10m`, `damage_pct_20m`, `damaged`, `unknown_pct` | Synthetic or sanitized existing fixture | no | +| Embedding prediction sample GeoPackage | Layer `predictions`, `area`, `damaged`, degenerate `damage_pct_0m` | Synthetic or sanitized existing fixture | no | +| Source footprints GeoPackage | Ordered Overture ids matching prediction rows | Synthetic | no | +| Large dense footprint set | Stress PMTiles, sidecar, and save memory | Synthetic | no | +| Model/ImageLayer metadata fixtures | Raw and edited model documents | Synthetic | no | + +## Coverage Matrix + +| User Story | Unit | API Integration | Queue | UI | E2E | Performance | +|---|---|---|---|---|---|---| +| US-001 | — | — | — | UI-001, UI-002 | E2E-001, E2E-002, E2E-003 | — | +| US-002 | UT-003, UT-004, UT-008, UT-009, UT-011, UT-012, UT-013 | IT-001-IT-007, IT-012, IT-013 | QT-001-QT-005 | UI-003, UI-011 | E2E-001, E2E-002 | PERF-001, PERF-002 | +| US-003 | UT-005, UT-006, UT-007 | — | — | UI-004-UI-007, UI-010, UI-011 | E2E-001, E2E-002 | PERF-004 | +| US-004 | UT-001, UT-002, UT-005-UT-010, UT-012 | IT-008-IT-010 | — | UI-008 | E2E-001, E2E-002 | PERF-003 | +| US-005 | UT-001 | IT-011, IT-012 | — | UI-008, UI-009 | E2E-001, E2E-002 | — | + +## Environment Requirements + +| Environment | Purpose | Config | +|---|---|---| +| Local (Docker Compose) | Developer testing of UI, API, queue, Azurite artifacts | `docker/docker-compose.yml`; no prediction-editing feature flags implemented | +| CI (GitHub Actions) | Automated backend and UI tests | Existing secret scan/deploy workflows plus targeted tests | +| Dev1 SWA | Integration testing with realistic project data | Feature flags enabled for internal testers | +| Testing SWA | Pre-production validation | Feature flags enabled after dev1 sign-off | + +## Sign-off Criteria + +- [ ] All P0 stories have E2E coverage for trained and embedding workflows. +- [ ] Row-order preservation is asserted in unit tests; API integration coverage remains a follow-up. +- [ ] `hastelib` targeted tests pass for prediction editing. +- [ ] API integration tests are added and pass for session, prep, save, version list, and artifact retrieval. +- [ ] UI helper tests pass; browser/Playwright tests are added for gating, + threshold visibility, selection, save, version history, and dark mode. +- [ ] Performance tests establish safe limits or document a follow-up async-save + requirement. +- [ ] UI lint validation records no regression from the known repo-wide ESLint 9 + flat-config baseline. diff --git a/spec/features/prediction-editing/user-stories.md b/spec/features/prediction-editing/user-stories.md new file mode 100644 index 00000000..da1a4150 --- /dev/null +++ b/spec/features/prediction-editing/user-stories.md @@ -0,0 +1,269 @@ +# User Stories: Prediction Editing + +**Contents:** [Personas](#personas) · [Stories](#stories) · [Agent Assignment Map](#agent-assignment-map) · [Story Map](#story-map) · [Out of Scope](#out-of-scope) + +## Personas + +| Persona | Description | Key Goals | +|---|---|---| +| Disaster Analyst | Domain expert who reviews building-level damage predictions during response | Correct model outputs quickly and preserve provenance | +| ML Engineer | Builds and evaluates trained and embedding-based prediction workflows | Keep raw model outputs immutable while comparing edited versions | +| External Partner | Collaborator who receives HASTE-generated files | Download a clear edited deliverable without needing editor access | + +--- + +## Stories + +### US-001: Open the Prediction Editor from Any Completed Prediction Workflow + +**As a** Disaster Analyst, +**I want to** open an Edit screen from both trained-inference and embedding model rows, +**So that** I can correct predictions without caring which workflow produced them. + +**Priority:** P0 +**Estimate:** M +**Component(s):** `ui/src/Components/ProjectManagement/ModelResultsButton.jsx`, `ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx`, `ui/src/Components/AppBody.jsx` + +**Acceptance Criteria:** + +```gherkin +Given a trained-inference model with inferenceStatus "Processed" and a non-empty gpkgUrl +When I view the model row +Then the Edit button is enabled and navigates to /edit-predictions/:projectId/:imageLayerId/:modelId +``` + +```gherkin +Given an embedding model with a non-empty gpkgUrl and predictedBuildingCount greater than 0 +When I view the embedding model row +Then the Edit button is enabled and navigates to /edit-predictions/:projectId/:imageLayerId/:modelId +``` + +```gherkin +Given a trained model without processed inference or without gpkgUrl +When I view the model row +Then the Edit button is disabled +``` + +```gherkin +Given an embedding model whose gpkgUrl was set by an empty prediction write +When predictedBuildingCount is 0 or missing +Then the Edit button is disabled +``` + +**UI Wireframe:** The Edit button appears beside existing result actions on each +model row and opens a full-screen editor route. + +**Notes:** Current trained gating uses `inferenceStatus === "Processed"`; current +embedding gating only checks `!!model.gpkgUrl`, which is ambiguous +(`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:43-46`, +`ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:86`). + +--- + +### US-002: Prepare a Complete Footprint Editing Session + +**As a** Disaster Analyst, +**I want to** load all predicted building footprints, not a sample, +**So that** edits cover the complete model output. + +**Priority:** P0 +**Estimate:** L +**Component(s):** `api/hastefuncapi`, `api/hastefuncqueues`, `hastelib`, `docker/training` + +**Acceptance Criteria:** + +```gherkin +Given a model with a raw prediction GeoPackage and existing footprint PMTiles and prediction attributes +When the UI calls GetPredictionEditSession +Then the response includes tilesReady true, attrsReady true, buildingCount, flavor, supportsThreshold, defaultThreshold, predictionTilesStatus, predictionTilesStatusMessage, and versions +``` + +```gherkin +Given the raw prediction GeoPackage exists but PMTiles or attributes are missing +When the UI calls GetPredictionEditSession +Then the response is side-effect-free and returns tilesReady false or attrsReady false without enqueueing work or running tippecanoe inline +``` + +```gherkin +Given the raw prediction GeoPackage exists but PMTiles or attributes are missing +When the UI calls PutPreparePredictionTilesQueueMessage with projectId, imageLayerId, modelId, and optional force +Then the API returns modelId, queued, tilesReady, attrsReady, status, and statusMessage, and enqueues exactly one prediction-edit-prep message unless artifacts are already ready or a job is already Queued/InProgress +``` + +```gherkin +Given source footprints and predictions have different row counts +When the prep worker validates the session inputs +Then it fails the prep job and records a user-visible readiness error +``` + +**UI Wireframe:** Preparation state with spinner, retry, and a short explanation +that full editor tiles are being generated. + +**Notes:** `GetBuildingFootprintsGeoJSON` is a random sample capped at 2,000 +features and must not be used for editing (`api/hastefuncapi/function_app.py:3626`, +`api/hastefuncapi/function_app.py:3645-3663`). `tippecanoe` is available only in +the training image (`docker/training/env/env.yml:11`). + +--- + +### US-003: Reclassify Buildings and Re-threshold Trained Predictions + +**As a** Disaster Analyst, +**I want to** reclassify individual or selected groups of buildings and adjust a threshold where valid, +**So that** I can produce a corrected damage layer that reflects expert review. + +**Priority:** P0 +**Estimate:** L +**Component(s):** `ui/src/Components/PredictionEditor/`, `ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx`, `ui/src/Components/BuildingValidation/BuildingValidation.jsx` + +**Acceptance Criteria:** + +```gherkin +Given the editor loaded a trained-inference model +When I move the threshold slider +Then footprint colors update live from the sidecar and the panel shows how many buildings would flip +``` + +```gherkin +Given the editor loaded an embedding model +When I view the right panel +Then no threshold slider is shown and I can still set explicit Damaged, NotDamaged, or Unknown overrides +``` + +```gherkin +Given visible footprints on the map +When I click a building or ctrl+drag a selection box +Then selected buildings can be assigned Damaged, NotDamaged, or Unknown and the edited count updates +``` + +**UI Wireframe:** Azure Maps canvas on the left, right panel with class filters, +counts, prev/next traversal, threshold controls when supported, version history, +and Save as new version. + +**Notes:** Use PMTiles in-memory loading, feature-state coloring, and box-select +patterns from `InteractiveLabeler.jsx`; use filter/traversal patterns from +`BuildingValidation.jsx`. Use Fluent `makeStyles` and `tokens` for dark mode. + +--- + +### US-004: Save an Edited Prediction GeoPackage as a New Version + +**As a** Disaster Analyst, +**I want to** save corrections as `edit_v1`, `edit_v2`, and later numbered versions, +**So that** the original model output remains auditable and recoverable. + +**Priority:** P0 +**Estimate:** L +**Component(s):** `api/hastefuncapi`, `hastelib/src/hastegeo/core/models/`, `hastelib/src/hastegeo/core/processors/`, Blob Storage + +**Acceptance Criteria:** + +```gherkin +Given a loaded prediction edit session and a set of overrides +When I save with threshold 0.1 and unknownThreshold 0.0 +Then PutEditedPredictions returns version, gpkgUrl, and editedCount and the Model document appends one EditedPredictionVersion entry +``` + +```gherkin +Given a source prediction GeoPackage with N rows +When an edited GeoPackage is written +Then the edited file has N rows in the exact same order, preserves the source geometry, writes overture_id, edited_class, and edit_threshold, and sets damaged to 1 only for final_class Damaged +``` + +**UI Wireframe:** Save button opens a confirmation state, then displays the new +version in the right panel history. + +**Notes:** Existing storage overwrites same-named artifacts, so the versioned +artifact name is the immutability boundary +(`hastelib/src/hastegeo/core/artifact_storage/azure_blob_artifact_storage.py:255`). +The current implementation does not implement optimistic concurrency or a 409 +conflict response; concurrent saves can collide and need a follow-up fix. + +--- + +### US-005: List and Download Edited Versions + +**As an** External Partner, +**I want to** download a named edited prediction version, +**So that** I can consume the analyst-reviewed file while HASTE keeps raw outputs separate. + +**Priority:** P1 +**Estimate:** M +**Component(s):** `api/hastefuncapi`, `ui/src/Components/PredictionEditor/`, `hastelib/src/hastegeo/core/models/` + +**Acceptance Criteria:** + +```gherkin +Given a model with editedPredictions entries +When the UI calls GetEditedPredictionVersions +Then it receives the versions sorted by version number or creation time and can display the threshold, editor, edited count, and gpkgUrl for each version +``` + +```gherkin +Given I download edit_v2 +When the browser requests the gpkgUrl +Then the downloaded file is the edited GeoPackage for version 2 and the raw Model.gpkgUrl is unchanged +``` + +**UI Wireframe:** Version history list in the right panel. The API returns each +version's `gpkgUrl`; a dedicated one-click UI download action is a follow-up in +the current branch. + +**Notes:** Assessment report, validation report, publishing, and visualizer use +of edited versions is out of scope for this feature. + +--- + +## Agent Assignment Map + +Every user story must be assigned to one or more HASTE agents. The **implementing agent** writes the code; the **validating agent** verifies correctness against acceptance criteria. See [Agent Architecture](../../architecture/overview.md#agent-architecture) for full agent descriptions. + +### Available Agents + +| Agent | Scope | Touches Code? | +|---|---|---| +| `backend-dev` | Python backend, API, processors, data layers, runners | Yes | +| `gis` | Satellite imagery, GDAL/rasterio, vector tiles, GeoPackage handling, damage assessment | Yes | +| `ui` | React/FluentUI/Azure Maps/MSAL, frontend only | Yes | +| `backend-validation` | Validates backend code against specs, conventions, tests | No (validates only) | +| `ui-validation` | Validates frontend changes against expected behavior | No (validates only) | + +### Story → Agent Mapping + +| Story | Implementing Agent(s) | Validating Agent(s) | Notes | +|---|---|---|---| +| US-001 | `ui` | `ui-validation` | UI route and button gating only. | +| US-002 | `backend-dev`, `gis` | `backend-validation` | Queue/API ownership is backend; PMTiles, GeoPackage, CRS, and row-order checks require GIS review. | +| US-003 | `ui` | `ui-validation` | UI editor behavior; GIS should be consulted for class semantics but does not own UI code. | +| US-004 | `backend-dev`, `gis` | `backend-validation` | Version metadata plus GeoPackage read/write and row-order invariant. | +| US-005 | `backend-dev`, `ui` | `backend-validation`, `ui-validation` | API version list and UI download history. | + +### Agent Workflow Per Phase + +| Phase | Lead Agent | Supporting Agents | Validation | +|---|---|---|---| +| Phase 1 — Data Model & Artifact Contract | `backend-dev` | `gis` | `backend-validation` | +| Phase 2 — Prep Workflow & API | `backend-dev` | `gis` | `backend-validation` | +| Phase 3 — UI Editor | `ui` | `gis` | `ui-validation` | +| Phase 4 — Integration | `backend-dev` | `ui`, `gis` | `backend-validation`, `ui-validation` | + +## Story Map + +| Priority | Story | Phase | Implementing Agent | Component | +|---|---|---|---|---| +| P0 | US-001 | Phase 3 — UI Editor | `ui` | `ui/src/Components/` | +| P0 | US-002 | Phase 2 — Prep Workflow & API | `backend-dev`, `gis` | `hastelib`, `hastefuncapi`, `hastefuncqueues` | +| P0 | US-003 | Phase 3 — UI Editor | `ui` | `ui/src/Components/PredictionEditor/` | +| P0 | US-004 | Phase 1/2 — Data Model & API | `backend-dev`, `gis` | `hastelib`, Blob Storage, `hastefuncapi` | +| P1 | US-005 | Phase 4 — Integration | `backend-dev`, `ui` | `hastefuncapi`, `ui/src/Components/` | + +## Out of Scope + +Stories explicitly excluded from this feature: + +- [ ] Use edited versions in assessment reports. +- [ ] Use edited versions in validation reports. +- [ ] Publish edited versions through the data-publishing workflow. +- [ ] Show edited versions in the general visualizer. +- [ ] Add collaborative real-time editing, locking, or audit diff playback. +- [ ] Introduce a generic artifact registry beyond the Model-level edited version list. diff --git a/ui/src/Components/AppBody.jsx b/ui/src/Components/AppBody.jsx index 369f250b..29e23b18 100644 --- a/ui/src/Components/AppBody.jsx +++ b/ui/src/Components/AppBody.jsx @@ -12,6 +12,7 @@ import Home from "./Home"; import LabelingTool from "./LabelingTool/LabelingTool"; import BuildingValidation from "./BuildingValidation/BuildingValidation"; import InteractiveLabeler from "./InteractiveLabeler/InteractiveLabeler"; +import PredictionEditor from "./PredictionEditor/PredictionEditor"; import Visualizer from "./Visualizer/Visualizer"; import ModelCatalog from "./ModelCatalog"; import PublishedDatasets from "./PublishedDatasets"; @@ -70,6 +71,10 @@ const AppBody = ({ setModalComponent }) => { path="/interactive-label/:projectId/:imageLayerId/:modelId" element={} /> + } + /> } diff --git a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx index e5a20338..976b21ce 100644 --- a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx +++ b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx @@ -34,8 +34,9 @@ import { tokens, } from "@fluentui/react-components"; import { FluentIcon } from "../../util/icons"; -import { PMTiles, Protocol } from "pmtiles"; +import { PMTiles } from "pmtiles"; import { apiGet, apiPut, buildUrl } from "../../util/api"; +import { getPmtilesProtocol } from "../../util/pmtiles.js"; import { getAzureMapsAuthOptions, isAzureMapsPlaceholder, @@ -75,12 +76,12 @@ import { // VectorTileSource configured with `url: "pmtiles://"` will route // through pmtiles' byte-range-aware reader. Atlas v3 exposes the Mapbox-GL // style `addProtocol` hook (see AZURE_MAPS_INTERACTIVE_LABELER.md §2). -const _pmtilesProtocol = new Protocol(); -if (typeof window !== "undefined" && window.atlas) { - // The bound `.tile` member is what addProtocol expects. Re-registering the - // same scheme is idempotent in atlas. - window.atlas.addProtocol("pmtiles", _pmtilesProtocol.tile); -} +// +// The Protocol instance is shared process-wide (util/pmtiles.js): atlas keeps +// one handler per scheme, so a second screen registering its own instance +// would take over every tile request and fail to resolve the archives added +// here. +const _pmtilesProtocol = getPmtilesProtocol(); // Tippecanoe writes the buildings layer with `-l buildings`. The // VectorTileSource references this layer name to draw the polygons. diff --git a/ui/src/Components/PredictionEditor/PredictionEditor.jsx b/ui/src/Components/PredictionEditor/PredictionEditor.jsx new file mode 100644 index 00000000..ea0eeabc --- /dev/null +++ b/ui/src/Components/PredictionEditor/PredictionEditor.jsx @@ -0,0 +1,1675 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Prediction Editor — review and edit a model's building-damage predictions, +// then save the result as a new version. +// +// Footprints stream from the model's PMTiles archive (kind=footprint_pmtiles) +// so the editor never downloads every polygon up front. The per-building +// scores come from a small JSON sidecar (kind=prediction_attrs) that is held +// in a ref; each building's class is derived in the browser from those scores +// plus the current thresholds, with any user edit taking precedence +// (predictionClassify.js owns that logic and is unit-tested). Colouring is +// applied as feature-state on the internal Mapbox-GL map keyed by the integer +// feature id, which is why moving the threshold slider recolours instantly +// with no server round-trip. +// +// Saving PUTs the thresholds plus the sparse override list to +// PutEditedPredictions, which writes a brand-new version — nothing is +// destructive. +// +// Both artifacts are produced by a queued job, so a model nobody has opened +// before arrives here unprepared. The editor enqueues that job itself +// (PutPreparePredictionTilesQueueMessage) and then polls the session until +// the artifacts exist, rather than telling the user to come back later — see +// the preparation effect below. The decisions behind that wait live in +// predictionPrep.js so they are unit-testable. +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Button, + MessageBar, + MessageBarBody, + MessageBarTitle, + ProgressBar, + Spinner, + Text, + makeStyles, + tokens, +} from "@fluentui/react-components"; +import { PMTiles } from "pmtiles"; +import { FluentIcon } from "../../util/icons"; +import { apiGet, apiPut, buildUrl } from "../../util/api"; +import { + getPmtilesProtocol, + InMemoryPMTilesSource, + fetchArtifactBuffer, +} from "../../util/pmtiles.js"; +import { + getAzureMapsAuthOptions, + isAzureMapsPlaceholder, +} from "../../util/azureMapsAuth"; +import { AppContext } from "../../AppContext.jsx"; +import { useTheme } from "../../util/ThemeContext.jsx"; +import { shouldIgnoreShortcut } from "../keyboardShortcuts.js"; +import PredictionEditorRightPanel from "./PredictionEditorRightPanel.jsx"; +import { + CLASS_DAMAGED, + CLASS_NOT_DAMAGED, + CLASS_UNKNOWN, + FILTER_ALL, + buildSavePayload, + classifyAll, + clearOverride, + countClassChanges, + cycleClass, + filterIndices, + indexById, + matchesFilter, + nextIndexInList, + normalizeAttrs, + setOverrideEntries, + setOverrides, +} from "./predictionClassify.js"; +import { + MAX_PREP_POLL_ATTEMPTS, + PREP_PHASE_FAILED, + PREP_PHASE_REQUESTING, + PREP_PHASE_TIMED_OUT, + PREP_POLL_INTERVAL_MS, + applyPrepResponse, + buildPrepRequest, + describeOutstandingArtifacts, + evaluatePrepState, + isPrepReady, + nextPollAttempt, + prepStateAfterPollError, + prepStatusLabel, + shouldPollPrep, +} from "./predictionPrep.js"; +import "../../assets/css/drawingToolbar.css"; + +// Tippecanoe writes the buildings layer with `-l buildings`; every feature +// carries the integer `id` used for feature-state. +const PMTILES_SOURCE_LAYER = "buildings"; +const SOURCE_ID = "predictionBuildings"; +const FILL_LAYER_ID = "predictionFill"; +const LINE_LAYER_ID = "predictionOutline"; + +// Paint expressions compare numbers, so each class gets a code. +const CLASS_CODES = { + [CLASS_DAMAGED]: 1, + [CLASS_NOT_DAMAGED]: 2, + [CLASS_UNKNOWN]: 3, +}; + +// The map's colours come from the active Fluent theme rather than a hardcoded +// palette: `tokens.x` is the string "var(--x)", which the renderer cannot +// parse, so we resolve the custom property against a live element inside the +// FluentProvider subtree and hand the renderer the concrete value. Switching +// light/dark (or the brand palette) re-resolves them — see the theme effect. +const MAP_COLOR_TOKENS = { + damaged: tokens.colorStatusDangerBackground3, + notDamaged: tokens.colorStatusSuccessBackground3, + unknown: tokens.colorNeutralForeground3, + pending: tokens.colorNeutralBackground5, + outline: tokens.colorNeutralStrokeAccessible, + edited: tokens.colorBrandStroke1, + selected: tokens.colorNeutralForeground1, +}; + +// Last-resort values, used only if a custom property cannot be resolved (the +// renderer needs *some* parseable colour or the layer fails to paint). Named +// CSS colours, deliberately not theme-specific hex codes. +const FALLBACK_COLORS = { + damaged: "firebrick", + notDamaged: "seagreen", + unknown: "dimgray", + pending: "lightgray", + outline: "steelblue", + edited: "royalblue", + selected: "white", +}; + +function resolveThemeColors(element) { + const style = element ? window.getComputedStyle(element) : null; + const colors = {}; + for (const [key, tokenValue] of Object.entries(MAP_COLOR_TOKENS)) { + const match = /var\((--[^,)]+)/.exec(String(tokenValue)); + const resolved = + match && style ? style.getPropertyValue(match[1]).trim() : ""; + colors[key] = resolved || FALLBACK_COLORS[key]; + } + return colors; +} + +function fillColorExpression(colors) { + return [ + "case", + ["==", ["feature-state", "cls"], CLASS_CODES[CLASS_DAMAGED]], + colors.damaged, + ["==", ["feature-state", "cls"], CLASS_CODES[CLASS_NOT_DAMAGED]], + colors.notDamaged, + ["==", ["feature-state", "cls"], CLASS_CODES[CLASS_UNKNOWN]], + colors.unknown, + colors.pending, + ]; +} + +// Buildings filtered out stay on screen as context, but faint. +const FILL_OPACITY_EXPRESSION = [ + "case", + ["==", ["feature-state", "dim"], true], + 0.1, + 0.55, +]; + +function strokeColorExpression(colors) { + return [ + "case", + ["==", ["feature-state", "selected"], true], + colors.selected, + ["==", ["feature-state", "edited"], true], + colors.edited, + colors.outline, + ]; +} + +const STROKE_WIDTH_EXPRESSION = [ + "case", + ["==", ["feature-state", "selected"], true], + 4, + ["==", ["feature-state", "edited"], true], + 2.5, + 1, +]; + +// atlas.Map has no public setFeatureState; the renderer underneath (a +// Mapbox-GL fork) does. Same duck-typed scan the Interactive Labeler uses. +function findGlMap(atlasMap) { + const direct = [atlasMap.map, atlasMap._map, atlasMap.gl, atlasMap._gl]; + for (const candidate of direct) { + if (candidate && typeof candidate.setFeatureState === "function") { + return candidate; + } + } + for (const key of Object.keys(atlasMap)) { + const value = atlasMap[key]; + if (value && typeof value === "object" && typeof value.setFeatureState === "function") { + return value; + } + } + return null; +} + +// Average of the first ring's vertices — good enough to centre the camera on +// a building, and far cheaper than a real centroid. +function featureCentroid(geometry) { + if (!geometry) return null; + const ring = + geometry.type === "Polygon" + ? geometry.coordinates?.[0] + : geometry.type === "MultiPolygon" + ? geometry.coordinates?.[0]?.[0] + : null; + if (!Array.isArray(ring) || ring.length === 0) return null; + let lng = 0; + let lat = 0; + for (const position of ring) { + lng += position[0]; + lat += position[1]; + } + return [lng / ring.length, lat / ring.length]; +} + +const useStyles = makeStyles({ + root: { + display: "flex", + flexGrow: 1, + minHeight: 0, + position: "relative", + isolation: "isolate", + overflow: "hidden", + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground2, + }, + map: { + flexGrow: 1, + minHeight: 0, + }, + messageCard: { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + zIndex: 1000, + boxSizing: "border-box", + width: "min(520px, calc(100% - 32px))", + padding: tokens.spacingHorizontalXXL, + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalS, + textAlign: "center", + alignItems: "center", + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusMedium, + boxShadow: tokens.shadow16, + }, + messageBody: { + color: tokens.colorNeutralForeground2, + fontSize: tokens.fontSizeBase300, + lineHeight: tokens.lineHeightBase300, + }, + messageDetail: { + color: tokens.colorNeutralForeground3, + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase200, + wordBreak: "break-word", + }, + // Preparation card: status line, indeterminate progress, and actions. All + // colours come from Fluent tokens so the card is readable in either theme. + messageActions: { + marginTop: tokens.spacingVerticalS, + display: "flex", + flexWrap: "wrap", + justifyContent: "center", + gap: tokens.spacingHorizontalS, + }, + messageBar: { + width: "100%", + textAlign: "left", + }, + prepProgress: { + width: "100%", + }, + prepStatusRow: { + display: "flex", + flexWrap: "wrap", + alignItems: "center", + justifyContent: "center", + gap: tokens.spacingHorizontalXS, + color: tokens.colorNeutralForeground2, + fontSize: tokens.fontSizeBase300, + lineHeight: tokens.lineHeightBase300, + }, + prepStatusValue: { + padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`, + borderRadius: tokens.borderRadiusCircular, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground4, + fontWeight: tokens.fontWeightSemibold, + }, + legend: { + position: "absolute", + right: "calc(clamp(300px, 25vw, 360px) + 20px)", + bottom: "10px", + zIndex: 900, + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalS}`, + borderRadius: tokens.borderRadiusMedium, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, + boxShadow: tokens.shadow8, + fontSize: tokens.fontSizeBase100, + lineHeight: tokens.lineHeightBase200, + pointerEvents: "none", + "@media (max-width: 700px)": { + right: "8px", + bottom: "calc(55% + 18px)", + }, + }, + legendRow: { + display: "flex", + alignItems: "center", + gap: tokens.spacingHorizontalXS, + }, + legendSwatch: { + width: "12px", + height: "12px", + borderRadius: tokens.borderRadiusSmall, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, + }, + // Legend swatches read the same tokens the map palette resolves at + // runtime, so the two can never drift apart. + legendDamaged: { + backgroundColor: tokens.colorStatusDangerBackground3, + }, + legendNotDamaged: { + backgroundColor: tokens.colorStatusSuccessBackground3, + }, + legendUnknown: { + backgroundColor: tokens.colorNeutralForeground3, + }, + legendTitle: { + marginBottom: tokens.spacingVerticalXXS, + fontWeight: tokens.fontWeightSemibold, + }, + selectBox: { + position: "absolute", + display: "none", + zIndex: 900, + pointerEvents: "none", + border: `${tokens.strokeWidthThick} dashed ${tokens.colorBrandStroke1}`, + backgroundColor: tokens.colorBrandBackground2, + opacity: 0.4, + }, + mapHint: { + position: "absolute", + bottom: "6px", + left: "50%", + transform: "translateX(-50%)", + zIndex: 900, + padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalM}`, + borderRadius: tokens.borderRadiusMedium, + color: tokens.colorNeutralForeground2, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, + boxShadow: tokens.shadow4, + fontSize: tokens.fontSizeBase100, + whiteSpace: "nowrap", + pointerEvents: "none", + "@media (max-width: 700px)": { + display: "none", + }, + }, +}); + +// Load phases rendered explicitly, so a not-yet-built artifact shows an +// explanation instead of an empty map. PHASE_PREPARING covers the whole +// enqueue-and-wait cycle; the detail inside it (queued / running / failed / +// gave up) lives in `prepState`. +const PHASE_LOADING = "loading"; +const PHASE_READY = "ready"; +const PHASE_PREPARING = "preparing"; +const PHASE_EMPTY = "empty"; +const PHASE_ERROR = "error"; + +const PredictionEditor = () => { + const styles = useStyles(); + const { projectId, imageLayerId, modelId } = useParams(); + const navigate = useNavigate(); + const { setIsLoading, setDialog } = useContext(AppContext); + const { isDark, palette } = useTheme(); + + // ── Refs ────────────────────────────────────────────────────────────────── + const rootRef = useRef(null); + const mapContainerRef = useRef(null); + const mapRef = useRef(null); + const glMapRef = useRef(null); + const fillLayerRef = useRef(null); + const lineLayerRef = useRef(null); + const internalLayerIdsRef = useRef([]); + // The prediction sidecar, held in a ref: the arrays never change after load + // and can be large, so they stay out of React state. `attrsVersion` below + // is what tells the render tree they arrived. + const attrsRef = useRef(null); + const indexByIdRef = useRef(new Map()); + // The renderer renames our source internally; the first rendered feature + // tells us what it actually calls it, which is the id feature-state writes + // must use for buildings that are not part of a query result. + const primarySourceIdRef = useRef(SOURCE_ID); + const hydrateTimerRef = useRef(null); + // Set by Prev/Next only: clicking a footprint should not yank the camera. + const pendingPanRef = useRef(false); + // Mirrors of state that long-lived map handlers read (a handler registered + // in the map's "ready" callback closes over the first render's values). + const classesRef = useRef([]); + const editedRef = useRef([]); + const overridesRef = useRef({}); + const filterRef = useRef(FILTER_ALL); + const clickActionRef = useRef("cycle"); + const colorsRef = useRef(FALLBACK_COLORS); + // id -> [lng, lat], harvested from rendered footprints. The sidecar carries + // no geometry, so this is the only way Prev/Next knows where to pan. + const centroidsRef = useRef(new Map()); + const selectedIdRef = useRef(null); + const boxRef = useRef(null); + const boxCleanupRef = useRef(null); + // Guards every setState that happens after an await, so nothing writes to a + // torn-down component (and, with it, no timer outlives the editor). + const mountedRef = useRef(true); + // Incremented every time the route params change. Async work captures the id it + // started under and drops its results if a newer run has taken over, so a + // fast model switch cannot have the old model's session clobber the new + // one's (the component stays mounted across that switch, so mountedRef + // alone would not catch it). + const initRunRef = useRef(0); + // Latest session, for the async prep helpers: they run outside the render + // that produced `session` and must not merge into a stale copy. + const sessionRef = useRef(null); + + // ── State ───────────────────────────────────────────────────────────────── + const [phase, setPhase] = useState(PHASE_LOADING); + const [errorMessage, setErrorMessage] = useState(""); + const [session, setSession] = useState(null); + const [attrsVersion, setAttrsVersion] = useState(0); + // Azure Maps builds its source/layers inside the async "ready" handler, + // which fires AFTER createMap() resolves. Mirroring readiness in state (and + // depending on it below) is what makes the styling effects re-run once the + // layers actually exist — the refs alone never trigger a render. + const [isSourceReady, setIsSourceReady] = useState(false); + + const [threshold, setThreshold] = useState(0.5); + const [unknownThreshold, setUnknownThreshold] = useState(0); + // What the current thresholds are compared against for the "N buildings + // would change class" readout: the model default at first, then whatever + // was last saved. + const [baseline, setBaseline] = useState({ + threshold: 0.5, + unknownThreshold: 0, + }); + const [overrides, setOverridesState] = useState({}); + const [classification, setClassification] = useState(null); + const [changeCount, setChangeCount] = useState(0); + const [filter, setFilter] = useState(FILTER_ALL); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [clickAction, setClickAction] = useState("cycle"); + + const [versions, setVersions] = useState([]); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(""); + const [savedResult, setSavedResult] = useState(null); + + // Preparation wait-state, shaped by predictionPrep.js: + // { phase, status, statusMessage, attempt, error }. Null once the artifacts + // are in hand (or when they were ready from the start). + const [prepState, setPrepState] = useState(null); + // Bumped to hand the artifact + map load to its own effect. Doing the load + // in an effect rather than inline guarantees React has already committed + // the render that mounts the map container, so mapContainerRef.current is + // a real element — the editor may reach this point from the preparing card, + // where the container was not in the DOM at all. + const [loadToken, setLoadToken] = useState(0); + + // ── Ref mirrors ─────────────────────────────────────────────────────────── + useEffect(() => { + overridesRef.current = overrides; + }, [overrides]); + useEffect(() => { + filterRef.current = filter; + }, [filter]); + useEffect(() => { + clickActionRef.current = clickAction; + }, [clickAction]); + useEffect(() => { + sessionRef.current = session; + }, [session]); + + // Mount flag. Set in an effect (not just at ref creation) so a remount — + // React StrictMode double-invokes effects in development — flips it back on. + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // The session URL is stable for a route; both the initial load and every + // poll go through it. + const sessionEndpoint = useMemo( + () => + `GetPredictionEditSession?projectId=${encodeURIComponent(projectId)}` + + `&imageLayerId=${encodeURIComponent(imageLayerId)}` + + `&modelId=${encodeURIComponent(modelId)}`, + [projectId, imageLayerId, modelId] + ); + + // Adopt a freshly fetched session: keep the version history in sync and + // hand the object to the prep helpers through the ref. + const adoptSession = useCallback((editSession) => { + sessionRef.current = editSession; + setSession(editSession); + if (Array.isArray(editSession?.versions)) setVersions(editSession.versions); + }, []); + + // True when the component is gone, or when a newer route run has taken + // over. Every async continuation checks this before touching state. + const isStale = useCallback( + (runId) => !mountedRef.current || runId !== initRunRef.current, + [] + ); + + // Artifacts exist: show the map container (PHASE_LOADING) and let the load + // effect do the fetching, one committed render later. + const startArtifactLoad = useCallback(() => { + setPrepState(null); + setPhase(PHASE_LOADING); + setLoadToken((token) => token + 1); + }, []); + + // ── Preparation ─────────────────────────────────────────────────────────── + // Enqueue the job that builds the PMTiles archive and the score sidecar. + // Called once on open when the artifacts are missing, and again (with + // force) from the Retry action after a terminal failure. Without this the + // editor would just sit on "still being prepared" forever, because nothing + // else in the app ever queues that job. + const requestPreparation = useCallback( + async (force = false) => { + const runId = initRunRef.current; + setPhase(PHASE_PREPARING); + setPrepState({ + phase: PREP_PHASE_REQUESTING, + status: "", + statusMessage: "", + attempt: 0, + error: "", + }); + try { + const response = await apiPut( + "PutPreparePredictionTilesQueueMessage", + buildPrepRequest({ projectId, imageLayerId, modelId, force }) + ); + if (isStale(runId)) return; + // The response carries the same readiness flags as the session, so a + // job that had already finished opens the editor immediately instead + // of waiting out a poll interval. + const merged = applyPrepResponse(sessionRef.current, response); + adoptSession(merged); + const decision = evaluatePrepState(merged, 0, MAX_PREP_POLL_ATTEMPTS); + if (decision.ready) { + startArtifactLoad(); + return; + } + setPrepState(decision); + } catch (error) { + if (isStale(runId)) return; + console.error("Could not queue prediction tile preparation:", error); + setPrepState({ + phase: PREP_PHASE_FAILED, + status: "", + statusMessage: "", + attempt: 0, + error: + error?.message || + "The preparation job could not be queued. Try again.", + }); + } + }, + [projectId, imageLayerId, modelId, adoptSession, startArtifactLoad, isStale] + ); + + // ── Load: session -> (prepare) -> attributes -> map ─────────────────────── + useEffect(() => { + let cancelled = false; + const runId = initRunRef.current + 1; + initRunRef.current = runId; + + const init = async () => { + setIsLoading(true, "Loading Prediction Editor"); + // Route params can change without remounting; start from a clean slate. + setPhase(PHASE_LOADING); + setPrepState(null); + setIsSourceReady(false); + setOverridesState({}); + setClassification(null); + setSelectedIndex(-1); + setSavedResult(null); + setSaveError(""); + setVersions([]); + attrsRef.current = null; + centroidsRef.current = new Map(); + selectedIdRef.current = null; + try { + const editSession = await apiGet(sessionEndpoint); + if (cancelled || isStale(runId)) return; + adoptSession(editSession); + + // Start from the model's own operating point so the first paint + // matches what the rest of the app already shows for this model. + const startThreshold = + typeof editSession?.defaultThreshold === "number" && + Number.isFinite(editSession.defaultThreshold) + ? editSession.defaultThreshold + : 0.5; + setThreshold(startThreshold); + setUnknownThreshold(0); + setBaseline({ threshold: startThreshold, unknownThreshold: 0 }); + + if (!(Number(editSession?.buildingCount) > 0)) { + setPhase(PHASE_EMPTY); + return; + } + if (isPrepReady(editSession)) { + startArtifactLoad(); + return; + } + // Nothing else queues this job, so the editor does it — once, without + // force, then waits on the poll effect below. + await requestPreparation(false); + } catch (error) { + if (cancelled || isStale(runId)) return; + console.error("Error initializing the prediction editor:", error); + setErrorMessage( + error?.message || "The prediction editor could not be loaded." + ); + setPhase(PHASE_ERROR); + } finally { + // Release the app-wide spinner unless a newer run has taken it over + // (that run turns it off itself). Safe after unmount: the flag lives + // in AppContext, above this component, so an editor torn down + // mid-load cannot leave the whole app behind an overlay. + if (runId === initRunRef.current) setIsLoading(false); + } + }; + + init(); + + return () => { + cancelled = true; + teardownMap(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId, imageLayerId, modelId]); + + // Poll the session while the prep job runs, and open the editor the moment + // both artifacts land — no page reload. + // + // Each pass schedules exactly ONE timeout and then re-runs off the state it + // wrote, so there is never more than one timer in flight and never two + // overlapping requests. The cleanup clears that timer, which is what stops + // polling on unmount and on a route change; `mountedRef` covers the request + // that is already in the air when the component goes away. + useEffect(() => { + if (!shouldPollPrep(prepState?.phase)) return undefined; + let cancelled = false; + const timer = window.setTimeout(async () => { + const runId = initRunRef.current; + try { + const editSession = await apiGet(sessionEndpoint); + if (cancelled || isStale(runId)) return; + adoptSession(editSession); + const decision = evaluatePrepState( + editSession, + nextPollAttempt(prepState.attempt), + MAX_PREP_POLL_ATTEMPTS + ); + if (decision.ready) { + startArtifactLoad(); + return; + } + setPrepState(decision); + } catch (error) { + if (cancelled || isStale(runId)) return; + // A blip in the API must not abandon a healthy job; keep the last + // known status and count the attempt against the cap. + setPrepState((previous) => + prepStateAfterPollError( + previous, + error?.message, + MAX_PREP_POLL_ATTEMPTS + ) + ); + } + }, PREP_POLL_INTERVAL_MS); + + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [prepState, sessionEndpoint, adoptSession, startArtifactLoad, isStale]); + + // ── Load the artifacts and build the map ────────────────────────────────── + // Runs only when startArtifactLoad() bumps the token, i.e. after a render + // in which the map container is mounted. + useEffect(() => { + if (!loadToken) return undefined; + let cancelled = false; + const runId = initRunRef.current; + + const load = async () => { + setIsLoading(true, "Loading predictions"); + setIsSourceReady(false); + try { + const attrs = await loadAttributes(); + if (cancelled || isStale(runId)) return; + attrsRef.current = attrs; + indexByIdRef.current = indexById(attrs); + setAttrsVersion((version) => version + 1); + + if (!window.atlas) { + throw new Error( + "The Azure Maps control did not load, so footprints cannot be shown." + ); + } + await createMap(); + if (cancelled || isStale(runId)) { + // The editor went away (or moved to another model) while the + // archive was downloading; the map this call just built would + // otherwise never be disposed. + teardownMap(); + return; + } + setPhase(PHASE_READY); + } catch (error) { + if (cancelled || isStale(runId)) return; + console.error("Error initializing the prediction editor:", error); + setErrorMessage( + error?.message || "The prediction editor could not be loaded." + ); + setPhase(PHASE_ERROR); + } finally { + // Same rule as the init effect: whoever still owns the run clears the + // app-wide spinner. + if (runId === initRunRef.current) setIsLoading(false); + } + }; + + load(); + + return () => { + cancelled = true; + teardownMap(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loadToken]); + + // Single owner of map teardown, called from both load effects' cleanups: + // whichever runs first nulls the refs, so a double call is a no-op. + function teardownMap() { + // Read at teardown on purpose: the box-select listeners are registered + // well after the effect that owns them runs. + if (boxCleanupRef.current) { + boxCleanupRef.current(); + boxCleanupRef.current = null; + } + if (hydrateTimerRef.current) { + clearTimeout(hydrateTimerRef.current); + hydrateTimerRef.current = null; + } + if (mapRef.current) { + mapRef.current.dispose(); + mapRef.current = null; + } + glMapRef.current = null; + fillLayerRef.current = null; + lineLayerRef.current = null; + } + + async function loadAttributes() { + // Streamed through the same-origin API proxy (managed identity server + // side) so remote analysts behind the storage firewall can read it. + const url = buildUrl( + `GetModelArtifact?projectId=${encodeURIComponent(projectId)}` + + `&modelId=${encodeURIComponent(modelId)}&kind=prediction_attrs` + ); + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to load prediction attributes (HTTP ${response.status}).` + ); + } + const attrs = normalizeAttrs(await response.json()); + if (attrs.n === 0) { + throw new Error("The prediction attributes file contains no buildings."); + } + return attrs; + } + + async function createMap() { + const protocol = getPmtilesProtocol(); + const archiveUrl = buildUrl( + `GetModelArtifact?projectId=${encodeURIComponent(projectId)}` + + `&modelId=${encodeURIComponent(modelId)}&kind=footprint_pmtiles` + ); + + // Download the whole archive once and serve pmtiles.js from memory: the + // SWA /api proxy in front of the function app does not honour range + // requests. + const buffer = await fetchArtifactBuffer(archiveUrl); + const archive = new PMTiles(new InMemoryPMTilesSource(archiveUrl, buffer)); + if (protocol) protocol.add(archive); + const header = await archive.getHeader(); + + let initialCamera = { center: [0, 0], zoom: 3 }; + if (header) { + const hasCenter = header.centerLon != null && header.centerLat != null; + const centerLon = hasCenter + ? header.centerLon + : (header.minLon + header.maxLon) / 2; + const centerLat = hasCenter + ? header.centerLat + : (header.minLat + header.maxLat) / 2; + initialCamera = { + center: [centerLon, centerLat], + zoom: header.centerZoom || Math.max(10, (header.maxZoom || 14) - 1), + }; + } + + const map = new window.atlas.Map(mapContainerRef.current, { + ...initialCamera, + maxPitch: 0, + pitch: 0, + style: isAzureMapsPlaceholder ? "blank" : "satellite", + language: "en-US", + authOptions: getAzureMapsAuthOptions(), + }); + + map.events.add("ready", () => { + map.setUserInteraction({ + dragRotateInteraction: false, + scrollZoomInteraction: true, + pinchZoomInteraction: true, + pinchRotateInteraction: false, + }); + map.controls.add(new window.atlas.control.ZoomControl(), { + position: "bottom-left", + }); + + const source = new window.atlas.source.VectorTileSource(SOURCE_ID, { + type: "vector", + url: `pmtiles://${archiveUrl}`, + // Azure Maps ignores promoteId, but the tiles already carry native + // integer feature ids (tippecanoe --use-attribute-for-id=id), which + // is what setFeatureState needs. + promoteId: { [PMTILES_SOURCE_LAYER]: "id" }, + }); + map.sources.add(source); + + const paint = colorsRef.current; + const fillLayer = new window.atlas.layer.PolygonLayer( + SOURCE_ID, + FILL_LAYER_ID, + { + sourceLayer: PMTILES_SOURCE_LAYER, + fillColor: fillColorExpression(paint), + fillOpacity: FILL_OPACITY_EXPRESSION, + } + ); + map.layers.add(fillLayer); + fillLayerRef.current = fillLayer; + + const lineLayer = new window.atlas.layer.LineLayer( + SOURCE_ID, + LINE_LAYER_ID, + { + sourceLayer: PMTILES_SOURCE_LAYER, + strokeColor: strokeColorExpression(paint), + strokeWidth: STROKE_WIDTH_EXPRESSION, + } + ); + map.layers.add(lineLayer); + lineLayerRef.current = lineLayer; + + const glMap = findGlMap(map); + glMapRef.current = glMap; + if (glMap && typeof glMap.getStyle === "function") { + // Azure Maps renames our source/layer internally; discover the ids + // the renderer actually uses so queryRenderedFeatures can target them. + try { + const style = glMap.getStyle(); + const sourceIds = Object.keys(style.sources || {}); + const ours = [ + SOURCE_ID, + ...sourceIds.filter((s) => s === SOURCE_ID || /predict|build/i.test(s)), + ...sourceIds, + ]; + internalLayerIdsRef.current = (style.layers || []) + .filter( + (layer) => + layer.type === "fill" && + (ours.includes(layer.source) || /predict|build/i.test(layer.id)) + ) + .map((layer) => layer.id); + } catch (error) { + console.warn("glMap.getStyle() failed:", error); + } + } + + map.events.add("click", fillLayer, (event) => { + // Ctrl+click starts a box-select drag; don't also toggle a class. + if ( + event.originalEvent && + (event.originalEvent.ctrlKey || event.originalEvent.metaKey) + ) { + return; + } + const feature = featureAtEvent(map, event); + if (feature) handleFeatureClick(feature.id); + }); + map.events.add("contextmenu", fillLayer, (event) => { + const feature = featureAtEvent(map, event); + if (feature) handleClearOverrideForId(feature.id); + return false; + }); + map.getCanvasContainer().style.cursor = "pointer"; + setupBoxSelect(map); + + const hydrate = () => scheduleHydrate(); + map.events.add("moveend", hydrate); + map.events.add("sourcedata", (event) => { + if (event && event.isSourceLoaded) hydrate(); + }); + hydrateViewport(); + setIsSourceReady(true); + }); + + mapRef.current = map; + } + + // ── Renderer helpers (all read refs so map handlers stay valid) ─────────── + function featureAtEvent(map, event) { + const glMap = glMapRef.current; + if (!glMap) return null; + let pixel = event.pixel; + if (!pixel && event.position) { + const pixels = map.positionsToPixels([event.position]); + pixel = pixels && pixels[0]; + } + if (!pixel) return null; + try { + const layerIds = internalLayerIdsRef.current; + const rendered = glMap.queryRenderedFeatures( + pixel, + layerIds && layerIds.length ? { layers: layerIds } : undefined + ); + const feature = rendered && rendered[0]; + if (!feature || feature.id == null) return null; + return { id: feature.id, source: feature.source }; + } catch (error) { + console.warn("queryRenderedFeatures failed:", error); + return null; + } + } + + function renderedFeatures(box) { + const glMap = glMapRef.current; + if (!glMap) return []; + const layerIds = internalLayerIdsRef.current; + try { + return ( + glMap.queryRenderedFeatures( + box, + layerIds && layerIds.length ? { layers: layerIds } : undefined + ) || [] + ); + } catch (error) { + console.warn("queryRenderedFeatures (viewport) failed:", error); + return []; + } + } + + function writeFeatureState(sourceId, id, state) { + const glMap = glMapRef.current; + if (!glMap) return; + try { + glMap.setFeatureState( + { + source: sourceId || primarySourceIdRef.current || SOURCE_ID, + sourceLayer: PMTILES_SOURCE_LAYER, + id, + }, + state + ); + } catch (error) { + console.warn("feature-state write failed:", error); + } + } + + // Tile loads and camera moves arrive in bursts; coalesce them so a pan + // costs one queryRenderedFeatures pass rather than a dozen. + function scheduleHydrate() { + if (hydrateTimerRef.current) return; + hydrateTimerRef.current = setTimeout(() => { + hydrateTimerRef.current = null; + hydrateViewport(); + }, 120); + } + + // Paint every footprint currently on screen from the cached classification, + // and remember where each one is so Prev/Next can pan to it. Called on every + // viewport settle and whenever the classification changes. + function hydrateViewport() { + const features = renderedFeatures(undefined); + if (features.length === 0) return; + const classes = classesRef.current; + const edited = editedRef.current; + const byId = indexByIdRef.current; + const activeFilter = filterRef.current; + const selectedId = selectedIdRef.current; + for (const feature of features) { + const id = feature.id; + if (id == null) continue; + if (feature.source) primarySourceIdRef.current = feature.source; + const index = byId.get(id); + if (index === undefined) continue; + if (!centroidsRef.current.has(id)) { + const centroid = featureCentroid(feature.geometry); + if (centroid) centroidsRef.current.set(id, centroid); + } + const cls = classes[index]; + writeFeatureState(feature.source, id, { + cls: CLASS_CODES[cls] || 0, + dim: !matchesFilter(cls, edited[index], activeFilter), + edited: !!edited[index], + selected: selectedId === id, + }); + } + if (mapRef.current && mapRef.current.triggerRepaint) { + mapRef.current.triggerRepaint(); + } + } + + // ── Editing ─────────────────────────────────────────────────────────────── + function handleFeatureClick(id) { + const index = indexByIdRef.current.get(id); + if (index === undefined) return; + setSelectedIndex(index); + const action = clickActionRef.current; + const cls = + action === "cycle" ? cycleClass(classesRef.current[index]) : action; + setOverridesState((previous) => setOverrides(previous, [id], cls)); + } + + function handleClearOverrideForId(id) { + const index = indexByIdRef.current.get(id); + if (index === undefined) return; + setSelectedIndex(index); + setOverridesState((previous) => clearOverride(previous, id)); + } + + function applyClickActionToIds(ids) { + if (ids.length === 0) return; + const action = clickActionRef.current; + if (action !== "cycle") { + setOverridesState((previous) => setOverrides(previous, ids, action)); + return; + } + // Cycle mode over a box: advance each building from its own class. + const classes = classesRef.current; + const byId = indexByIdRef.current; + const entries = ids + .map((id) => { + const index = byId.get(id); + if (index === undefined) return null; + return { id, class: cycleClass(classes[index]) }; + }) + .filter(Boolean); + setOverridesState((previous) => setOverrideEntries(previous, entries)); + } + + function setClassForSelected(cls) { + const attrs = attrsRef.current; + if (!attrs || selectedIndex < 0 || selectedIndex >= attrs.n) return; + const id = attrs.ids[selectedIndex]; + setOverridesState((previous) => setOverrides(previous, [id], cls)); + } + + function clearSelectedOverride() { + const attrs = attrsRef.current; + if (!attrs || selectedIndex < 0 || selectedIndex >= attrs.n) return; + setOverridesState((previous) => + clearOverride(previous, attrs.ids[selectedIndex]) + ); + } + + function clearAllOverrides() { + setOverridesState({}); + } + + // ── Ctrl+drag box-select ────────────────────────────────────────────────── + function setupBoxSelect(map) { + const canvas = map.getCanvasContainer(); + let origin = null; + + const onDown = (event) => { + if (!event.ctrlKey && !event.metaKey) return; + event.preventDefault(); + event.stopPropagation(); + map.setUserInteraction({ dragPanInteraction: false }); + const rect = canvas.getBoundingClientRect(); + origin = { x: event.clientX - rect.left, y: event.clientY - rect.top }; + const box = boxRef.current; + if (box) { + box.style.display = "block"; + box.style.left = `${origin.x}px`; + box.style.top = `${origin.y}px`; + box.style.width = "0px"; + box.style.height = "0px"; + } + }; + + const onMove = (event) => { + if (!origin) return; + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const box = boxRef.current; + if (box) { + box.style.left = `${Math.min(origin.x, x)}px`; + box.style.top = `${Math.min(origin.y, y)}px`; + box.style.width = `${Math.abs(x - origin.x)}px`; + box.style.height = `${Math.abs(y - origin.y)}px`; + } + }; + + const onUp = (event) => { + if (!origin) return; + const rect = canvas.getBoundingClientRect(); + const x1 = Math.min(origin.x, event.clientX - rect.left); + const y1 = Math.min(origin.y, event.clientY - rect.top); + const x2 = Math.max(origin.x, event.clientX - rect.left); + const y2 = Math.max(origin.y, event.clientY - rect.top); + origin = null; + if (boxRef.current) boxRef.current.style.display = "none"; + map.setUserInteraction({ dragPanInteraction: true }); + if (x2 - x1 < 4 || y2 - y1 < 4) return; + + const features = renderedFeatures([ + [x1, y1], + [x2, y2], + ]); + const ids = [ + ...new Set(features.filter((f) => f.id != null).map((f) => f.id)), + ]; + applyClickActionToIds(ids); + }; + + canvas.addEventListener("mousedown", onDown); + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + boxCleanupRef.current = () => { + canvas.removeEventListener("mousedown", onDown); + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + } + + // ── Classification ──────────────────────────────────────────────────────── + // Recomputed whenever the thresholds or the user's edits change. The map is + // repainted from the result in the effect below, so the slider recolours + // without touching the server. + useEffect(() => { + const attrs = attrsRef.current; + if (!attrs) return; + const result = classifyAll(attrs, { + threshold, + unknownThreshold, + overrides, + }); + classesRef.current = result.classes; + editedRef.current = result.edited; + setClassification(result); + setChangeCount( + countClassChanges( + attrs, + baseline, + { threshold, unknownThreshold }, + overrides + ) + ); + }, [attrsVersion, threshold, unknownThreshold, overrides, baseline]); + + // Repaint on-screen footprints. isSourceReady is in the deps because the + // layers are created inside the map's async "ready" handler — reading the + // layer refs during render would see nulls and never re-run. + useEffect(() => { + if (!isSourceReady || !classification) return; + hydrateViewport(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [classification, filter, isSourceReady]); + + // Resolve the map palette from the active Fluent theme, and re-apply it when + // the user flips light/dark or changes the brand palette. The resolved + // values live in a ref because only the renderer consumes them — the legend + // uses the same tokens through makeStyles. + useEffect(() => { + const resolved = resolveThemeColors(rootRef.current); + colorsRef.current = resolved; + if (fillLayerRef.current) { + fillLayerRef.current.setOptions({ + fillColor: fillColorExpression(resolved), + }); + } + if (lineLayerRef.current) { + lineLayerRef.current.setOptions({ + strokeColor: strokeColorExpression(resolved), + }); + } + }, [isDark, palette, isSourceReady]); + + // ── Selection ───────────────────────────────────────────────────────────── + const filteredIndices = useMemo( + () => (classification ? filterIndices(classification, filter) : []), + [classification, filter] + ); + + // Changing the filter can strand the current selection outside the visible + // set, so snap it to the first match as part of the same event rather than + // in an effect (which would cost an extra render pass). + const handleFilterChange = useCallback( + (nextFilter) => { + setFilter(nextFilter); + if (!classification) return; + const nextIndices = filterIndices(classification, nextFilter); + if (nextIndices.length === 0) return; + setSelectedIndex((current) => + current >= 0 && !nextIndices.includes(current) + ? nextIndices[0] + : current + ); + }, + [classification] + ); + + // Highlight the selected footprint, and pan to it when the selection came + // from Prev/Next. Buildings whose tile has never rendered have no cached + // centroid, so there is nowhere to pan yet. + useEffect(() => { + if (!isSourceReady) return; + const attrs = attrsRef.current; + const previousId = selectedIdRef.current; + const nextId = + attrs && selectedIndex >= 0 && selectedIndex < attrs.n + ? attrs.ids[selectedIndex] + : null; + if (previousId != null && previousId !== nextId) { + writeFeatureState(null, previousId, { selected: false }); + } + selectedIdRef.current = nextId; + if (nextId == null) return; + writeFeatureState(null, nextId, { selected: true }); + const shouldPan = pendingPanRef.current; + pendingPanRef.current = false; + const centroid = centroidsRef.current.get(nextId); + if (shouldPan && centroid && mapRef.current) { + const camera = mapRef.current.getCamera(); + mapRef.current.setCamera({ + center: centroid, + zoom: Math.max(camera?.zoom || 0, 17.5), + duration: 500, + }); + } + }, [selectedIndex, isSourceReady]); + + function navigateInFilter(direction) { + if (filteredIndices.length === 0) return; + const attrs = attrsRef.current; + // Prefer buildings we can actually pan to; fall back to the plain next + // one so navigation never stalls. + const hasLocation = (index) => { + const id = attrs?.ids?.[index]; + return id != null && centroidsRef.current.has(id); + }; + const next = nextIndexInList( + filteredIndices, + selectedIndex, + direction, + hasLocation + ); + if (next === null) return; + pendingPanRef.current = true; + setSelectedIndex(next); + } + + // ── Save ────────────────────────────────────────────────────────────────── + async function refreshVersions() { + try { + const data = await apiGet( + `GetEditedPredictionVersions?projectId=${encodeURIComponent(projectId)}` + + `&modelId=${encodeURIComponent(modelId)}` + ); + if (Array.isArray(data?.versions)) setVersions(data.versions); + } catch (error) { + console.warn("Could not refresh edited prediction versions:", error); + } + } + + async function handleSave() { + setIsSaving(true); + setSaveError(""); + try { + const payload = buildSavePayload({ + projectId, + imageLayerId, + modelId, + threshold, + unknownThreshold, + overrides, + }); + const result = await apiPut("PutEditedPredictions", payload); + // apiPut surfaces a conflict as the bare status code. + if (result === 409) { + throw new Error( + "Another version is being written for this model. Try saving again in a moment." + ); + } + if (!result || result.version == null) { + throw new Error("The server did not return a new version number."); + } + setSavedResult(result); + // Subsequent slider moves are now measured against what was saved. + setBaseline({ threshold, unknownThreshold }); + await refreshVersions(); + setDialog( + "Saved", + `Version ${result.version} saved with ${ + result.editedCount ?? payload.overrides.length + } edited buildings.` + ); + } catch (error) { + const message = + error?.message || "Failed to save the edited predictions."; + setSaveError(message); + setDialog("Save failed", message); + } finally { + setIsSaving(false); + } + } + + // ── Keyboard shortcuts ──────────────────────────────────────────────────── + // 1/2/3 set the selected building's class (and become the click action, so + // the next click paints the same class); arrows walk the filtered set. + useEffect(() => { + if (phase !== PHASE_READY) return undefined; + const classByKey = { + 1: CLASS_DAMAGED, + 2: CLASS_NOT_DAMAGED, + 3: CLASS_UNKNOWN, + }; + function onKeyDown(event) { + if (shouldIgnoreShortcut(event)) return; + if (event.ctrlKey || event.altKey || event.metaKey) return; + const cls = classByKey[event.key]; + if (cls) { + setClickAction(cls); + setClassForSelected(cls); + return; + } + if (event.key === "ArrowLeft") { + event.preventDefault(); + navigateInFilter(-1); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + navigateInFilter(1); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [phase, selectedIndex, filteredIndices]); + + // ── Derived view data ───────────────────────────────────────────────────── + const currentBuilding = useMemo(() => { + const attrs = attrsRef.current; + if ( + !attrs || + !classification || + selectedIndex < 0 || + selectedIndex >= attrs.n + ) { + return null; + } + const id = attrs.ids[selectedIndex]; + return { + id, + overtureId: attrs.overtureIds[selectedIndex], + damage: attrs.damage[selectedIndex], + unknown: attrs.unknown[selectedIndex], + cls: classification.classes[selectedIndex], + edited: classification.edited[selectedIndex], + }; + // attrsVersion re-runs this once the sidecar lands. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [classification, selectedIndex, attrsVersion]); + + const legendItems = [ + { key: CLASS_DAMAGED, label: "Damaged", swatch: styles.legendDamaged }, + { + key: CLASS_NOT_DAMAGED, + label: "Not Damaged", + swatch: styles.legendNotDamaged, + }, + { key: CLASS_UNKNOWN, label: "Unknown", swatch: styles.legendUnknown }, + ]; + + // ── Preparation card ────────────────────────────────────────────────────── + // One card, three outcomes: waiting (live status + indeterminate progress), + // terminally failed (error + forced retry), or gave up after the attempt + // cap (check back later + a manual re-check). + function renderPreparationCard() { + const prep = prepState || {}; + const statusLabel = prepStatusLabel(prep.status); + const outstanding = describeOutstandingArtifacts(session); + const expected = + Number(session?.buildingCount) > 0 + ? `${Number(session.buildingCount).toLocaleString()} buildings are expected.` + : ""; + + if (prep.phase === PREP_PHASE_FAILED) { + return ( + <> + Preparing predictions failed + + + {statusLabel} + {prep.statusMessage || + prep.error || + "The job that builds the editable footprint tiles did not finish."} + + +
+ Retrying queues the preparation job again from scratch. Nothing + already saved is affected. +
+
+ + +
+ + ); + } + + if (prep.phase === PREP_PHASE_TIMED_OUT) { + return ( + <> + Still preparing predictions +
+ This is taking longer than expected, so this page stopped checking. + The job is still running in the background — check back later, or + check now. +
+
+ Last known status + {statusLabel} +
+ {prep.statusMessage ? ( +
{prep.statusMessage}
+ ) : null} + {prep.error ? ( + + {prep.error} + + ) : null} +
+ + +
+ + ); + } + + // Requesting or waiting: the live view. + const isRequesting = prep.phase === PREP_PHASE_REQUESTING; + return ( + <> + Preparing predictions for editing + + {/* Live region scoped to the text that actually changes — announcing + the whole card would re-read the buttons on every poll. */} +
+ Status + + {isRequesting ? "Queuing" : statusLabel} + +
+ {prep.statusMessage ? ( +
{prep.statusMessage}
+ ) : null} +
+ {outstanding || + "The editable footprint tiles and prediction scores are being generated."} +
+ {prep.error ? ( + + + {prep.error} Still retrying in the background. + + + ) : null} +
+ This usually takes a few minutes. The map opens on its own when the + data is ready — no need to reload.{expected ? ` ${expected}` : ""} +
+
+ {prep.attempt > 0 + ? `Checked ${prep.attempt} ${ + prep.attempt === 1 ? "time" : "times" + }, every ${Math.round(PREP_POLL_INTERVAL_MS / 1000)} seconds.` + : "Waiting for the first status update."} +
+ + ); + } + + const showMap = phase === PHASE_LOADING || phase === PHASE_READY; + + return ( +
+
+ +
+ + {showMap && ( +
+ )} + + {phase === PHASE_LOADING && ( +
+ +
+ Streaming building footprints and prediction scores. +
+
+ )} + + {phase === PHASE_PREPARING && ( +
{renderPreparationCard()}
+ )} + + {phase === PHASE_EMPTY && ( +
+ No predicted buildings +
+ This model has no building predictions to edit. Run inference (or, + for an embedding model, predict all buildings in the Interactive + Labeler) and then come back. +
+
+ )} + + {phase === PHASE_ERROR && ( +
+ Prediction editor unavailable +
{errorMessage}
+
+ {/* Only offered once a session has loaded: the model exists, so a + missing or half-written artifact is worth rebuilding. When the + session itself failed there is nothing to prepare. */} + {session ? ( + + ) : null} + +
+
+ )} + + {phase === PHASE_READY && classification && ( + <> +
+
Current class
+ {legendItems.map((item) => ( +
+ + {item.label} +
+ ))} +
+
+ Click a footprint to change it · Ctrl+drag to box-select + · right-click to undo an edit +
+ { + setClickAction(cls); + setClassForSelected(cls); + }} + onClearOverride={clearSelectedOverride} + onClearAllEdits={clearAllOverrides} + onPrev={() => navigateInFilter(-1)} + onNext={() => navigateInFilter(1)} + threshold={threshold} + setThreshold={setThreshold} + unknownThreshold={unknownThreshold} + setUnknownThreshold={setUnknownThreshold} + baseline={baseline} + changeCount={changeCount} + onSave={handleSave} + isSaving={isSaving} + saveError={saveError} + savedResult={savedResult} + versions={versions} + /> + + )} + + {/* Box-select rectangle (Ctrl+drag) */} + {showMap &&
} +
+ ); +}; + +export default PredictionEditor; diff --git a/ui/src/Components/PredictionEditor/PredictionEditorRightPanel.jsx b/ui/src/Components/PredictionEditor/PredictionEditorRightPanel.jsx new file mode 100644 index 00000000..dae04bbc --- /dev/null +++ b/ui/src/Components/PredictionEditor/PredictionEditorRightPanel.jsx @@ -0,0 +1,585 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Right-hand control panel for the Prediction Editor: class counts, the +// filter + prev/next traversal over the filtered set, the threshold sliders +// (only for models that support them), the live "would change class" +// readout, the save action, and the saved-version history. +// +// Layout and interaction mirror BuildingValidationRightPanel so the two +// review screens feel like the same tool. Every colour comes from Fluent +// tokens, so the panel follows the light/dark theme. +import PropTypes from "prop-types"; +import { + Button, + Divider, + Dropdown, + Field, + MessageBar, + MessageBarBody, + MessageBarTitle, + Option, + Radio, + RadioGroup, + Slider, + Text, + makeStyles, + tokens, +} from "@fluentui/react-components"; +import KeyboardShortcutHelp from "../KeyboardShortcutHelp"; +import { PREDICTION_EDITOR_SHORTCUTS } from "../keyboardShortcuts"; +import { + CLASS_DAMAGED, + CLASS_LABELS, + CLASS_NOT_DAMAGED, + CLASS_UNKNOWN, + FILTER_ALL, + FILTER_LABELS, + FILTER_VALUES, + sortVersionsDescending, + toPercentLabel, +} from "./predictionClassify"; + +const CLASS_ORDER = [CLASS_DAMAGED, CLASS_NOT_DAMAGED, CLASS_UNKNOWN]; + +// Keyboard hints shown on the class buttons, matching PREDICTION_EDITOR_SHORTCUTS. +const CLASS_HOTKEYS = { + [CLASS_DAMAGED]: "1", + [CLASS_NOT_DAMAGED]: "2", + [CLASS_UNKNOWN]: "3", +}; + +// Fluent's Slider is integer-friendly; thresholds are fractions in [0, 1], so +// the control works in whole percent and converts on the way in and out. +const toPercent = (fraction) => Math.round((Number(fraction) || 0) * 100); +const fromPercent = (percent) => (Number(percent) || 0) / 100; + +function formatDate(value) { + if (!value) return "—"; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return String(value); + return parsed.toLocaleString(); +} + +const useStyles = makeStyles({ + panel: { + position: "absolute", + top: "10px", + right: "10px", + bottom: "10px", + zIndex: 1000, + boxSizing: "border-box", + width: "clamp(300px, 25vw, 360px)", + maxWidth: "calc(100% - 20px)", + padding: tokens.spacingHorizontalL, + display: "flex", + flexDirection: "column", + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusMedium, + boxShadow: tokens.shadow16, + "@media (max-width: 700px)": { + top: "auto", + right: "8px", + bottom: "8px", + left: "8px", + width: "auto", + maxWidth: "none", + maxHeight: "min(55%, 520px)", + padding: tokens.spacingHorizontalM, + zIndex: 25, + }, + }, + scroll: { + flex: 1, + minHeight: 0, + overflowX: "hidden", + overflowY: "auto", + overscrollBehavior: "contain", + scrollbarGutter: "stable", + paddingRight: tokens.spacingHorizontalS, + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalM, + touchAction: "pan-y", + }, + header: { + paddingBottom: tokens.spacingVerticalS, + marginBottom: tokens.spacingVerticalS, + borderBottom: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, + }, + subtle: { + color: tokens.colorNeutralForeground3, + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase200, + }, + countRow: { + display: "flex", + alignItems: "baseline", + justifyContent: "space-between", + fontSize: tokens.fontSizeBase300, + }, + countValue: { + fontWeight: tokens.fontWeightSemibold, + }, + swatch: { + display: "inline-block", + width: "10px", + height: "10px", + marginRight: tokens.spacingHorizontalXS, + borderRadius: tokens.borderRadiusSmall, + }, + damagedSwatch: { + backgroundColor: tokens.colorStatusDangerBackground3, + }, + notDamagedSwatch: { + backgroundColor: tokens.colorStatusSuccessBackground3, + }, + unknownSwatch: { + backgroundColor: tokens.colorNeutralForeground3, + }, + card: { + padding: `${tokens.spacingVerticalSNudge} ${tokens.spacingHorizontalS}`, + borderRadius: tokens.borderRadiusMedium, + backgroundColor: tokens.colorNeutralBackground2, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase200, + wordBreak: "break-word", + }, + cardTitle: { + fontWeight: tokens.fontWeightSemibold, + marginBottom: tokens.spacingVerticalXXS, + }, + editedBadge: { + display: "inline-block", + marginTop: tokens.spacingVerticalXXS, + padding: `0 ${tokens.spacingHorizontalXS}`, + borderRadius: tokens.borderRadiusSmall, + color: tokens.colorNeutralForegroundOnBrand, + backgroundColor: tokens.colorBrandBackground, + fontSize: tokens.fontSizeBase100, + fontWeight: tokens.fontWeightSemibold, + }, + buttonColumn: { + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalXS, + }, + buttonRow: { + display: "flex", + gap: tokens.spacingHorizontalS, + }, + grow: { + flexGrow: 1, + }, + stackedField: { + marginTop: tokens.spacingVerticalS, + }, + sliderValue: { + display: "flex", + justifyContent: "space-between", + fontSize: tokens.fontSizeBase200, + }, + changeReadout: { + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + borderRadius: tokens.borderRadiusMedium, + backgroundColor: tokens.colorNeutralBackground3, + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase200, + }, + changeHighlight: { + fontWeight: tokens.fontWeightSemibold, + color: tokens.colorBrandForeground1, + }, + versionList: { + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalXS, + maxHeight: "160px", + overflowY: "auto", + }, + versionRow: { + padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`, + borderRadius: tokens.borderRadiusMedium, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, + fontSize: tokens.fontSizeBase100, + lineHeight: tokens.lineHeightBase200, + }, + versionTitle: { + fontWeight: tokens.fontWeightSemibold, + }, + actions: { + paddingTop: tokens.spacingVerticalS, + marginTop: tokens.spacingVerticalS, + borderTop: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalXS, + }, +}); + +const PredictionEditorRightPanel = ({ + session, + counts, + total, + editedCount, + filter, + setFilter, + filteredIndices, + selectedIndex, + currentBuilding, + clickAction, + setClickAction, + onSetClass, + onClearOverride, + onClearAllEdits, + onPrev, + onNext, + threshold, + setThreshold, + unknownThreshold, + setUnknownThreshold, + baseline, + changeCount, + onSave, + isSaving, + saveError, + savedResult, + versions, +}) => { + const styles = useStyles(); + + const swatchClass = { + [CLASS_DAMAGED]: styles.damagedSwatch, + [CLASS_NOT_DAMAGED]: styles.notDamagedSwatch, + [CLASS_UNKNOWN]: styles.unknownSwatch, + }; + + // Position within the filtered subset, so Prev/Next reads honestly when a + // filter is narrowing the set. + const filterPosition = filteredIndices.indexOf(selectedIndex); + const positionLabel = + filteredIndices.length === 0 + ? "No buildings match this filter" + : filterPosition >= 0 + ? `Building ${filterPosition + 1} of ${filteredIndices.length}${ + filter === FILTER_ALL ? "" : " (filtered)" + }` + : `${filteredIndices.length} buildings match — press Next to start`; + + const orderedVersions = sortVersionsDescending(versions); + const thresholdChanged = + toPercent(threshold) !== toPercent(baseline?.threshold) || + toPercent(unknownThreshold) !== toPercent(baseline?.unknownThreshold); + + return ( +
+
+ + Edit predictions + +
+ {total.toLocaleString()} buildings + {session?.flavor ? ` · ${session.flavor} model` : ""} +
+
+ +
+ {/* Counts */} +
+ {CLASS_ORDER.map((cls) => ( +
+ + + {CLASS_LABELS[cls]} + + + {(counts?.[cls] || 0).toLocaleString()} + +
+ ))} +
+ Edited by hand + + {editedCount.toLocaleString()} + +
+
+ + + + {/* Thresholds — only models that expose a score support these. */} + {session?.supportsThreshold && ( +
+ + + setThreshold(fromPercent(data.value)) + } + /> + +
+ More damaged + Fewer damaged +
+ + + setUnknownThreshold(fromPercent(data.value)) + } + /> + +
+ + {changeCount.toLocaleString()} + {" "} + {changeCount === 1 ? "building would" : "buildings would"} change + class + {thresholdChanged + ? ` versus ${toPercentLabel(baseline?.threshold)} / ${toPercentLabel( + baseline?.unknownThreshold + )}.` + : " — thresholds are unchanged."} +
+
+ )} + + {!session?.supportsThreshold && ( +
+ This model does not expose a tunable score, so classes come from + its own decisions plus your edits. +
+ )} + + + + {/* Filter + traversal */} + + + data.optionValue && setFilter(data.optionValue) + } + > + {FILTER_VALUES.map((value) => ( + + ))} + + + +
+
{positionLabel}
+ {currentBuilding ? ( + <> +
ID: {String(currentBuilding.id)}
+ {currentBuilding.overtureId && ( +
+ Overture: {String(currentBuilding.overtureId)} +
+ )} +
+ Damage score: {toPercentLabel(currentBuilding.damage, 1)} + {" · "} + Unknown: {toPercentLabel(currentBuilding.unknown, 1)} +
+
+ Class:{" "} + + {CLASS_LABELS[currentBuilding.cls] || currentBuilding.cls} + +
+ {currentBuilding.edited && ( + Edited + )} + + ) : ( +
+ Click a footprint, or use Next, to select a building. +
+ )} +
+ +
+ + +
+ + {/* Editing */} +
+
Set the selected building to:
+ {CLASS_ORDER.map((cls) => ( + + ))} + +
+ + + setClickAction(data.value)} + > + + {CLASS_ORDER.map((cls) => ( + + ))} + + + + + + + + {/* Saved versions */} +
+
Saved versions
+ {orderedVersions.length === 0 ? ( +
+ No edited versions yet. Saving creates version 1 — the model’s + own predictions are never overwritten. +
+ ) : ( +
+ {orderedVersions.map((version) => ( +
+
+ Version {version.version} +
+
+ {formatDate(version.createdAt)} + {version.createdBy ? ` · ${version.createdBy}` : ""} +
+
+ Threshold {toPercentLabel(version.threshold)} ·{" "} + {(version.editedCount || 0).toLocaleString()} edited +
+
+ ))} +
+ )} +
+ + +
+ +
+ {saveError && ( + + + Save failed + {saveError} + + + )} + {!saveError && savedResult && ( + + + Version {savedResult.version} saved + {(savedResult.editedCount || 0).toLocaleString()} edited buildings. + + + )} + +
+
+ ); +}; + +PredictionEditorRightPanel.propTypes = { + session: PropTypes.shape({ + flavor: PropTypes.string, + supportsThreshold: PropTypes.bool, + buildingCount: PropTypes.number, + }), + counts: PropTypes.object.isRequired, + total: PropTypes.number.isRequired, + editedCount: PropTypes.number.isRequired, + filter: PropTypes.string.isRequired, + setFilter: PropTypes.func.isRequired, + filteredIndices: PropTypes.arrayOf(PropTypes.number).isRequired, + selectedIndex: PropTypes.number.isRequired, + currentBuilding: PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + overtureId: PropTypes.string, + damage: PropTypes.number, + unknown: PropTypes.number, + cls: PropTypes.string, + edited: PropTypes.bool, + }), + clickAction: PropTypes.string.isRequired, + setClickAction: PropTypes.func.isRequired, + onSetClass: PropTypes.func.isRequired, + onClearOverride: PropTypes.func.isRequired, + onClearAllEdits: PropTypes.func.isRequired, + onPrev: PropTypes.func.isRequired, + onNext: PropTypes.func.isRequired, + threshold: PropTypes.number.isRequired, + setThreshold: PropTypes.func.isRequired, + unknownThreshold: PropTypes.number.isRequired, + setUnknownThreshold: PropTypes.func.isRequired, + baseline: PropTypes.shape({ + threshold: PropTypes.number, + unknownThreshold: PropTypes.number, + }).isRequired, + changeCount: PropTypes.number.isRequired, + onSave: PropTypes.func.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.string, + savedResult: PropTypes.shape({ + version: PropTypes.number, + gpkgUrl: PropTypes.string, + editedCount: PropTypes.number, + }), + versions: PropTypes.array.isRequired, +}; + +export default PredictionEditorRightPanel; diff --git a/ui/src/Components/PredictionEditor/predictionClassify.js b/ui/src/Components/PredictionEditor/predictionClassify.js new file mode 100644 index 00000000..442ce2e3 --- /dev/null +++ b/ui/src/Components/PredictionEditor/predictionClassify.js @@ -0,0 +1,402 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Pure classification helpers for the Prediction Editor. +// +// Everything the editor needs to decide "what class is this building right +// now?" lives here as plain functions over plain data: no React, no Azure +// Maps, no fetch. The component keeps the attribute arrays in a ref and the +// override map in state, then calls into this module — which makes the +// interesting logic (threshold derivation, override merging, filtering, and +// the "how many buildings would flip" counter) unit-testable with +// `node --test`. +// +// IMPORTANT: `damage` and `unknown` are FRACTIONS in [0, 1] as produced by +// the model, NOT 0-100 percentages. All threshold arithmetic here stays in +// [0, 1]; percentages are a display concern (see toPercentLabel). + +export const CLASS_DAMAGED = "Damaged"; +export const CLASS_NOT_DAMAGED = "NotDamaged"; +export const CLASS_UNKNOWN = "Unknown"; + +// Cycle order used when the user clicks a footprint in "cycle" mode. +export const PREDICTION_CLASSES = [ + CLASS_DAMAGED, + CLASS_NOT_DAMAGED, + CLASS_UNKNOWN, +]; + +export const FILTER_ALL = "all"; +export const FILTER_EDITED = "edited"; + +// Order matters — this drives the right panel's filter dropdown. +export const FILTER_VALUES = [ + FILTER_ALL, + CLASS_DAMAGED, + CLASS_NOT_DAMAGED, + CLASS_UNKNOWN, + FILTER_EDITED, +]; + +export const FILTER_LABELS = { + [FILTER_ALL]: "All buildings", + [CLASS_DAMAGED]: "Damaged only", + [CLASS_NOT_DAMAGED]: "Not Damaged only", + [CLASS_UNKNOWN]: "Unknown only", + [FILTER_EDITED]: "Edited only", +}; + +export const CLASS_LABELS = { + [CLASS_DAMAGED]: "Damaged", + [CLASS_NOT_DAMAGED]: "Not Damaged", + [CLASS_UNKNOWN]: "Unknown", +}; + +// Non-finite scores (missing rows, NaN placeholders) are treated as 0 so a +// corrupt entry degrades to "NotDamaged" rather than throwing mid-render. +function num(value, fallback = 0) { + return typeof value === "number" && Number.isFinite(value) + ? value + : fallback; +} + +/** True when `value` is one of the three prediction classes. */ +export function isPredictionClass(value) { + return PREDICTION_CLASSES.indexOf(value) !== -1; +} + +/** + * The model's class for one building, before any user edit. + * + * Unknown when unknown > unknownThreshold + * Damaged when damage > threshold + * NotDamaged otherwise + * + * Both comparisons are strict so the default unknownThreshold of 0 means + * "any non-zero unknown score wins", and a threshold of 1 can never produce + * a Damaged building. + */ +export function deriveClass(damage, unknown, threshold, unknownThreshold = 0) { + if (num(unknown) > num(unknownThreshold)) return CLASS_UNKNOWN; + if (num(damage) > num(threshold)) return CLASS_DAMAGED; + return CLASS_NOT_DAMAGED; +} + +/** + * Coerce the raw prediction_attrs sidecar into the shape the editor uses. + * Missing arrays become empty ones and `n` is clamped to the shortest array + * actually present, so a truncated sidecar can't index past its data. + */ +export function normalizeAttrs(raw) { + const ids = Array.isArray(raw?.ids) ? raw.ids : []; + const overtureIds = Array.isArray(raw?.overtureIds) ? raw.overtureIds : []; + const damage = Array.isArray(raw?.damage) ? raw.damage : []; + const unknown = Array.isArray(raw?.unknown) ? raw.unknown : []; + const damaged = Array.isArray(raw?.damaged) ? raw.damaged : []; + const declared = num(raw?.n, ids.length); + const n = Math.max(0, Math.min(declared, ids.length)); + return { n, ids, overtureIds, damage, unknown, damaged }; +} + +/** Map of building id -> row index, for turning a clicked feature id into attrs. */ +export function indexById(attrs) { + const map = new Map(); + const n = attrs?.n || 0; + for (let i = 0; i < n; i++) { + map.set(attrs.ids[i], i); + } + return map; +} + +// ── Override map ──────────────────────────────────────────────────────────── +// Overrides are a sparse plain object keyed by building id. It is stored in +// React state, so every mutator below returns a NEW object rather than +// editing in place. + +/** The user's class for `id`, or null when they haven't edited it. */ +export function getOverride(overrides, id) { + if (!overrides || id == null) return null; + const value = overrides[id]; + return isPredictionClass(value) ? value : null; +} + +/** Set one override. Invalid classes are ignored (returns the input map). */ +export function setOverride(overrides, id, cls) { + return setOverrides(overrides, [id], cls); +} + +/** Set many overrides at once — the ctrl+drag box-select path. */ +export function setOverrides(overrides, ids, cls) { + if (!isPredictionClass(cls) || !Array.isArray(ids) || ids.length === 0) { + return overrides || {}; + } + const next = { ...(overrides || {}) }; + let changed = false; + for (const id of ids) { + if (id == null) continue; + if (next[id] === cls) continue; + next[id] = cls; + changed = true; + } + return changed ? next : overrides || {}; +} + +/** + * Apply a batch of per-building classes in one immutable update: + * `entries` is `[{ id, class }]`. Box-selecting in cycle mode gives every + * building a different target class, and merging them one at a time would + * copy the whole map per building. + */ +export function setOverrideEntries(overrides, entries) { + if (!Array.isArray(entries) || entries.length === 0) return overrides || {}; + const next = { ...(overrides || {}) }; + let changed = false; + for (const entry of entries) { + const id = entry?.id; + const cls = entry?.class; + if (id == null || !isPredictionClass(cls)) continue; + if (next[id] === cls) continue; + next[id] = cls; + changed = true; + } + return changed ? next : overrides || {}; +} + +/** Drop one override so the building falls back to its derived class. */ +export function clearOverride(overrides, id) { + return clearOverrides(overrides, [id]); +} +/** Drop many overrides at once. */ +export function clearOverrides(overrides, ids) { + if (!overrides || !Array.isArray(ids) || ids.length === 0) { + return overrides || {}; + } + const next = { ...overrides }; + let changed = false; + for (const id of ids) { + if (id == null) continue; + if (Object.prototype.hasOwnProperty.call(next, id)) { + delete next[id]; + changed = true; + } + } + return changed ? next : overrides; +} + +/** How many buildings the user has edited. */ +export function countOverrides(overrides) { + return Object.keys(overrides || {}).length; +} + +/** + * The sparse override list sent to PutEditedPredictions: + * `[{ id, class }]`, numerically sorted so repeated saves of the same edits + * produce a byte-identical payload. + */ +export function toOverrideList(overrides) { + return Object.keys(overrides || {}) + .filter((key) => isPredictionClass(overrides[key])) + .map((key) => ({ id: Number(key), class: overrides[key] })) + .sort((a, b) => a.id - b.id); +} + +/** The exact PUT body for PutEditedPredictions. */ +export function buildSavePayload({ + projectId, + imageLayerId, + modelId, + threshold, + unknownThreshold, + overrides, +}) { + return { + projectId, + imageLayerId, + modelId, + threshold: num(threshold), + unknownThreshold: num(unknownThreshold), + overrides: toOverrideList(overrides), + }; +} + +// ── Classification over the whole layer ───────────────────────────────────── + +/** The current class of row `index`: the user's edit if any, else derived. */ +export function resolveClassAt(attrs, index, options) { + const { threshold = 0.5, unknownThreshold = 0, overrides = null } = + options || {}; + const override = getOverride(overrides, attrs?.ids?.[index]); + if (override) return override; + return deriveClass( + attrs?.damage?.[index], + attrs?.unknown?.[index], + threshold, + unknownThreshold + ); +} + +/** + * Classify every building once. Returns parallel arrays (cheap to index from + * the map's feature-state writer) plus the per-class counts the right panel + * shows. `edited[i]` is true when the class came from a user override. + */ +export function classifyAll(attrs, options) { + const { threshold = 0.5, unknownThreshold = 0, overrides = null } = + options || {}; + const n = attrs?.n || 0; + const classes = new Array(n); + const edited = new Array(n); + const counts = { + [CLASS_DAMAGED]: 0, + [CLASS_NOT_DAMAGED]: 0, + [CLASS_UNKNOWN]: 0, + }; + let editedCount = 0; + for (let i = 0; i < n; i++) { + const override = getOverride(overrides, attrs.ids[i]); + const cls = + override || + deriveClass( + attrs.damage[i], + attrs.unknown[i], + threshold, + unknownThreshold + ); + classes[i] = cls; + edited[i] = override != null; + if (override != null) editedCount++; + counts[cls] = (counts[cls] || 0) + 1; + } + return { classes, edited, counts, editedCount, total: n }; +} + +/** Filter predicate shared by the map dimming and the traversal list. */ +export function matchesFilter(cls, isEdited, filter) { + if (!filter || filter === FILTER_ALL) return true; + if (filter === FILTER_EDITED) return !!isEdited; + return cls === filter; +} + +/** Row indices that pass `filter`, in ascending order. */ +export function filterIndices(classification, filter) { + const classes = classification?.classes || []; + const edited = classification?.edited || []; + if (!filter || filter === FILTER_ALL) { + return classes.map((_cls, i) => i); + } + const out = []; + for (let i = 0; i < classes.length; i++) { + if (matchesFilter(classes[i], edited[i], filter)) out.push(i); + } + return out; +} + +/** + * How many buildings would change class when moving from one threshold + * setting to another. Buildings the user has explicitly edited are excluded: + * their class is pinned by the override, so a slider move can never flip + * them. This is what drives the live "N buildings would change class" + * readout — no server round-trip involved. + */ +export function countClassChanges(attrs, baseline, candidate, overrides = null) { + const n = attrs?.n || 0; + const fromT = num(baseline?.threshold, 0.5); + const fromU = num(baseline?.unknownThreshold, 0); + const toT = num(candidate?.threshold, 0.5); + const toU = num(candidate?.unknownThreshold, 0); + if (fromT === toT && fromU === toU) return 0; + let changed = 0; + for (let i = 0; i < n; i++) { + if (getOverride(overrides, attrs.ids[i])) continue; + const before = deriveClass(attrs.damage[i], attrs.unknown[i], fromT, fromU); + const after = deriveClass(attrs.damage[i], attrs.unknown[i], toT, toU); + if (before !== after) changed++; + } + return changed; +} + +/** The next class in the cycle order (used by plain left-click). */ +export function cycleClass(cls) { + const pos = PREDICTION_CLASSES.indexOf(cls); + if (pos === -1) return PREDICTION_CLASSES[0]; + return PREDICTION_CLASSES[(pos + 1) % PREDICTION_CLASSES.length]; +} + +// ── Traversal ─────────────────────────────────────────────────────────────── + +// Position of the last entry strictly below `value` (-1 when there is none). +function positionBefore(list, value) { + let pos = -1; + for (let i = 0; i < list.length; i++) { + if (list[i] < value) pos = i; + else break; + } + return pos; +} + +// Position of the first entry strictly above `value` (list.length when none). +function positionAfter(list, value) { + for (let i = 0; i < list.length; i++) { + if (list[i] > value) return i; + } + return list.length; +} + +/** + * Walk an ascending list of row indices cyclically. + * + * `isCandidate` is optional: when supplied, the walk returns the first entry + * that satisfies it (the editor uses this to prefer buildings whose geometry + * has already streamed in, so Next actually pans somewhere). If nothing + * satisfies the predicate we fall back to the plain next entry rather than + * refusing to move. Returns null for an empty list. + */ +export function nextIndexInList( + list, + fromIndex, + direction = 1, + isCandidate = null +) { + if (!Array.isArray(list) || list.length === 0) return null; + const step = direction < 0 ? -1 : 1; + const n = list.length; + let start = list.indexOf(fromIndex); + if (start === -1) { + // The selection isn't in the filtered list (it was filtered out, or + // nothing is selected yet). Start from the insertion point so the first + // step lands on the nearest neighbour in the direction of travel. + start = step > 0 ? positionBefore(list, fromIndex) : positionAfter(list, fromIndex); + } + let fallback = null; + for (let k = 1; k <= n; k++) { + const pos = (((start + k * step) % n) + n) % n; + const value = list[pos]; + if (fallback === null) fallback = value; + if (!isCandidate || isCandidate(value)) return value; + } + return fallback; +} + +// ── Version helpers ───────────────────────────────────────────────────────── + +/** The highest-numbered saved version, or null when there are none. */ +export function latestVersion(versions) { + if (!Array.isArray(versions) || versions.length === 0) return null; + return versions.reduce((best, v) => + num(v?.version, -Infinity) > num(best?.version, -Infinity) ? v : best + ); +} + +/** Versions newest-first, for the right panel's history list. */ +export function sortVersionsDescending(versions) { + if (!Array.isArray(versions)) return []; + return [...versions].sort((a, b) => num(b?.version) - num(a?.version)); +} + +// ── Display helpers ───────────────────────────────────────────────────────── + +/** Render a [0, 1] fraction as a percentage string. */ +export function toPercentLabel(fraction, digits = 0) { + if (typeof fraction !== "number" || !Number.isFinite(fraction)) return "—"; + return `${(fraction * 100).toFixed(digits)}%`; +} diff --git a/ui/src/Components/PredictionEditor/predictionClassify.test.js b/ui/src/Components/PredictionEditor/predictionClassify.test.js new file mode 100644 index 00000000..387f51c7 --- /dev/null +++ b/ui/src/Components/PredictionEditor/predictionClassify.test.js @@ -0,0 +1,615 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Run with: node --test src/Components/PredictionEditor/predictionClassify.test.js + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + CLASS_DAMAGED, + CLASS_NOT_DAMAGED, + CLASS_UNKNOWN, + FILTER_ALL, + FILTER_EDITED, + buildSavePayload, + classifyAll, + clearOverride, + countClassChanges, + countOverrides, + cycleClass, + deriveClass, + filterIndices, + getOverride, + indexById, + latestVersion, + matchesFilter, + nextIndexInList, + normalizeAttrs, + resolveClassAt, + setOverride, + setOverrideEntries, + setOverrides, + sortVersionsDescending, + toOverrideList, + toPercentLabel, +} from "./predictionClassify.js"; +import { + MAX_PREP_POLL_ATTEMPTS, + PREP_PHASE_FAILED, + PREP_PHASE_READY, + PREP_PHASE_TIMED_OUT, + PREP_PHASE_WAITING, + PREP_POLL_INTERVAL_MS, + PREP_STATUS_CANCELLED, + PREP_STATUS_FAILED, + PREP_STATUS_IN_PROGRESS, + PREP_STATUS_QUEUED, + applyPrepResponse, + buildPrepRequest, + describeOutstandingArtifacts, + evaluatePrepState, + isPrepReady, + isTerminalPrepStatus, + nextPollAttempt, + normalizePrepStatus, + prepStateAfterPollError, + prepStatusLabel, + shouldPollPrep, +} from "./predictionPrep.js"; + +// Five buildings covering every interesting corner: below/at/above the +// threshold, and one with a non-zero unknown score. +function sampleAttrs() { + return normalizeAttrs({ + n: 5, + ids: [10, 11, 12, 13, 14], + overtureIds: ["a", "b", "c", "d", "e"], + damage: [0.05, 0.5, 0.51, 0.9, 0.8], + unknown: [0, 0, 0, 0, 0.4], + damaged: [0, 0, 1, 1, 0], + }); +} + +test("deriveClass thresholds on fractions, not percentages", () => { + assert.equal(deriveClass(0.9, 0, 0.5), CLASS_DAMAGED); + assert.equal(deriveClass(0.1, 0, 0.5), CLASS_NOT_DAMAGED); + // Strictly greater: a score exactly at the threshold is not damaged. + assert.equal(deriveClass(0.5, 0, 0.5), CLASS_NOT_DAMAGED); + // A 0-100 style value would be nonsense here; 90 > 0.5 is still damaged, + // which is exactly why the caller must pass fractions. + assert.equal(deriveClass(0.5001, 0, 0.5), CLASS_DAMAGED); +}); + +test("deriveClass gives the unknown score priority over damage", () => { + // Default unknownThreshold of 0: any positive unknown wins. + assert.equal(deriveClass(0.99, 0.01, 0.5), CLASS_UNKNOWN); + assert.equal(deriveClass(0.99, 0, 0.5), CLASS_DAMAGED); + // Raising the unknown threshold lets the damage score through again. + assert.equal(deriveClass(0.99, 0.4, 0.5, 0.5), CLASS_DAMAGED); + assert.equal(deriveClass(0.99, 0.6, 0.5, 0.5), CLASS_UNKNOWN); +}); + +test("deriveClass treats non-finite scores as zero", () => { + assert.equal(deriveClass(NaN, undefined, 0.5), CLASS_NOT_DAMAGED); + assert.equal(deriveClass(null, null, 0.5), CLASS_NOT_DAMAGED); +}); + +test("normalizeAttrs clamps n to the data actually present", () => { + const attrs = normalizeAttrs({ n: 99, ids: [1, 2] }); + assert.equal(attrs.n, 2); + assert.deepEqual(attrs.damage, []); + + const empty = normalizeAttrs(undefined); + assert.equal(empty.n, 0); + assert.deepEqual(empty.ids, []); +}); + +test("indexById maps feature ids back to attribute rows", () => { + const map = indexById(sampleAttrs()); + assert.equal(map.get(10), 0); + assert.equal(map.get(14), 4); + assert.equal(map.get(999), undefined); +}); + +test("override merging is immutable and drops no-op writes", () => { + const empty = {}; + const one = setOverride(empty, 12, CLASS_UNKNOWN); + assert.deepEqual(empty, {}, "input map must not be mutated"); + assert.equal(getOverride(one, 12), CLASS_UNKNOWN); + + // Writing the same value again returns the identical object so React can + // skip the re-render. + assert.equal(setOverride(one, 12, CLASS_UNKNOWN), one); + + // Invalid classes are ignored. + assert.equal(setOverride(one, 13, "Rubble"), one); + assert.equal(getOverride(one, 13), null); + + const many = setOverrides(one, [10, 11], CLASS_DAMAGED); + assert.equal(countOverrides(many), 3); + + const cleared = clearOverride(many, 12); + assert.equal(getOverride(cleared, 12), null); + assert.equal(countOverrides(cleared), 2); + // Clearing something that was never set changes nothing. + assert.equal(clearOverride(cleared, 999), cleared); +}); + +test("setOverrideEntries merges a mixed batch in one pass", () => { + const start = setOverride({}, 10, CLASS_DAMAGED); + const merged = setOverrideEntries(start, [ + { id: 10, class: CLASS_NOT_DAMAGED }, + { id: 11, class: CLASS_UNKNOWN }, + { id: 12, class: "Nonsense" }, + { id: null, class: CLASS_DAMAGED }, + ]); + assert.equal(getOverride(merged, 10), CLASS_NOT_DAMAGED); + assert.equal(getOverride(merged, 11), CLASS_UNKNOWN); + assert.equal(getOverride(merged, 12), null); + assert.equal(countOverrides(merged), 2); + assert.deepEqual(start, { 10: CLASS_DAMAGED }, "input map must not change"); + // Nothing to apply -> same object back. + assert.equal(setOverrideEntries(merged, []), merged); +}); + +test("resolveClassAt prefers the user override over the derived class", () => { + const attrs = sampleAttrs(); + const overrides = setOverride({}, 13, CLASS_NOT_DAMAGED); + assert.equal( + resolveClassAt(attrs, 3, { threshold: 0.5 }), + CLASS_DAMAGED, + "derived without an override" + ); + assert.equal( + resolveClassAt(attrs, 3, { threshold: 0.5, overrides }), + CLASS_NOT_DAMAGED + ); +}); + +test("classifyAll counts every class and tracks edits", () => { + const attrs = sampleAttrs(); + const result = classifyAll(attrs, { threshold: 0.5, unknownThreshold: 0 }); + + assert.deepEqual(result.classes, [ + CLASS_NOT_DAMAGED, // 0.05 + CLASS_NOT_DAMAGED, // 0.5 is not > 0.5 + CLASS_DAMAGED, // 0.51 + CLASS_DAMAGED, // 0.9 + CLASS_UNKNOWN, // unknown 0.4 > 0 + ]); + assert.deepEqual(result.counts, { + [CLASS_DAMAGED]: 2, + [CLASS_NOT_DAMAGED]: 2, + [CLASS_UNKNOWN]: 1, + }); + assert.equal(result.editedCount, 0); + assert.equal(result.total, 5); + + const edited = classifyAll(attrs, { + threshold: 0.5, + overrides: setOverride({}, 10, CLASS_DAMAGED), + }); + assert.equal(edited.classes[0], CLASS_DAMAGED); + assert.equal(edited.edited[0], true); + assert.equal(edited.edited[1], false); + assert.equal(edited.editedCount, 1); + assert.equal(edited.counts[CLASS_DAMAGED], 3); +}); + +test("lowering the threshold reclassifies without touching the data", () => { + const attrs = sampleAttrs(); + const strict = classifyAll(attrs, { threshold: 0.95 }); + assert.equal(strict.counts[CLASS_DAMAGED], 0); + + const loose = classifyAll(attrs, { threshold: 0.01 }); + // Building 4 still has a non-zero unknown score, so it stays Unknown. + assert.equal(loose.counts[CLASS_DAMAGED], 4); + assert.equal(loose.counts[CLASS_UNKNOWN], 1); +}); + +test("filter predicate covers class filters, edited, and all", () => { + assert.equal(matchesFilter(CLASS_DAMAGED, false, FILTER_ALL), true); + assert.equal(matchesFilter(CLASS_DAMAGED, false, CLASS_DAMAGED), true); + assert.equal(matchesFilter(CLASS_DAMAGED, false, CLASS_UNKNOWN), false); + assert.equal(matchesFilter(CLASS_DAMAGED, false, FILTER_EDITED), false); + assert.equal(matchesFilter(CLASS_DAMAGED, true, FILTER_EDITED), true); + assert.equal(matchesFilter(CLASS_UNKNOWN, false, undefined), true); +}); + +test("filterIndices returns the rows the panel traverses", () => { + const attrs = sampleAttrs(); + const classification = classifyAll(attrs, { + threshold: 0.5, + overrides: setOverride({}, 11, CLASS_UNKNOWN), + }); + + assert.deepEqual(filterIndices(classification, FILTER_ALL), [0, 1, 2, 3, 4]); + assert.deepEqual(filterIndices(classification, CLASS_DAMAGED), [2, 3]); + assert.deepEqual(filterIndices(classification, CLASS_UNKNOWN), [1, 4]); + assert.deepEqual(filterIndices(classification, FILTER_EDITED), [1]); +}); + +test("countClassChanges reports how many buildings a slider move flips", () => { + const attrs = sampleAttrs(); + const base = { threshold: 0.5, unknownThreshold: 0 }; + + assert.equal(countClassChanges(attrs, base, base), 0); + + // 0.5 -> 0.85 demotes 0.51 and 0.8 (0.8 is Unknown, so it doesn't count) + // leaving only building index 2. + assert.equal( + countClassChanges(attrs, base, { threshold: 0.85, unknownThreshold: 0 }), + 1 + ); + + // 0.5 -> 0.01 promotes 0.05 and 0.5. + assert.equal( + countClassChanges(attrs, base, { threshold: 0.01, unknownThreshold: 0 }), + 2 + ); + + // Raising the unknown threshold releases building 4 back to Damaged. + assert.equal( + countClassChanges(attrs, base, { threshold: 0.5, unknownThreshold: 0.5 }), + 1 + ); +}); + +test("countClassChanges ignores buildings the user pinned", () => { + const attrs = sampleAttrs(); + const base = { threshold: 0.5, unknownThreshold: 0 }; + const candidate = { threshold: 0.01, unknownThreshold: 0 }; + + assert.equal(countClassChanges(attrs, base, candidate), 2); + // Pin one of the two that would have flipped. + const overrides = setOverride({}, 10, CLASS_NOT_DAMAGED); + assert.equal(countClassChanges(attrs, base, candidate, overrides), 1); +}); + +test("cycleClass walks Damaged -> NotDamaged -> Unknown -> Damaged", () => { + assert.equal(cycleClass(CLASS_DAMAGED), CLASS_NOT_DAMAGED); + assert.equal(cycleClass(CLASS_NOT_DAMAGED), CLASS_UNKNOWN); + assert.equal(cycleClass(CLASS_UNKNOWN), CLASS_DAMAGED); + assert.equal(cycleClass(undefined), CLASS_DAMAGED); +}); + +test("nextIndexInList wraps in both directions", () => { + const list = [2, 5, 9]; + assert.equal(nextIndexInList(list, 2, 1), 5); + assert.equal(nextIndexInList(list, 9, 1), 2); + assert.equal(nextIndexInList(list, 2, -1), 9); + assert.equal(nextIndexInList(list, 5, -1), 2); + assert.equal(nextIndexInList([], 0, 1), null); +}); + +test("nextIndexInList handles a selection outside the filtered set", () => { + const list = [2, 5, 9]; + // Nothing selected yet. + assert.equal(nextIndexInList(list, -1, 1), 2); + assert.equal(nextIndexInList(list, -1, -1), 9); + // Selection filtered out: step to the nearest neighbour in the direction + // of travel. + assert.equal(nextIndexInList(list, 6, 1), 9); + assert.equal(nextIndexInList(list, 6, -1), 5); + assert.equal(nextIndexInList(list, 12, 1), 2); +}); + +test("nextIndexInList prefers candidates but never refuses to move", () => { + const list = [2, 5, 9]; + const located = new Set([9]); + // Skips 5 because its geometry hasn't streamed in yet. + assert.equal(nextIndexInList(list, 2, 1, (i) => located.has(i)), 9); + // Nothing qualifies -> plain next entry. + assert.equal(nextIndexInList(list, 2, 1, () => false), 5); +}); + +test("toOverrideList produces the sparse, sorted payload list", () => { + const overrides = setOverrides({}, [14, 10, 12], CLASS_DAMAGED); + assert.deepEqual(toOverrideList(overrides), [ + { id: 10, class: CLASS_DAMAGED }, + { id: 12, class: CLASS_DAMAGED }, + { id: 14, class: CLASS_DAMAGED }, + ]); + assert.deepEqual(toOverrideList({}), []); + assert.deepEqual(toOverrideList(null), []); +}); + +test("buildSavePayload matches the PutEditedPredictions contract", () => { + const payload = buildSavePayload({ + projectId: "p1", + imageLayerId: "l1", + modelId: "m1", + threshold: 0.42, + unknownThreshold: 0.1, + overrides: setOverride({}, 7, CLASS_UNKNOWN), + }); + assert.deepEqual(payload, { + projectId: "p1", + imageLayerId: "l1", + modelId: "m1", + threshold: 0.42, + unknownThreshold: 0.1, + overrides: [{ id: 7, class: CLASS_UNKNOWN }], + }); +}); + +test("version helpers order the history newest-first", () => { + const versions = [ + { version: 1, threshold: 0.5 }, + { version: 3, threshold: 0.7 }, + { version: 2, threshold: 0.6 }, + ]; + assert.equal(latestVersion(versions).version, 3); + assert.deepEqual( + sortVersionsDescending(versions).map((v) => v.version), + [3, 2, 1] + ); + assert.equal(latestVersion([]), null); + assert.equal(latestVersion(undefined), null); +}); + +test("toPercentLabel renders fractions as percentages", () => { + assert.equal(toPercentLabel(0.5), "50%"); + assert.equal(toPercentLabel(0.517, 1), "51.7%"); + assert.equal(toPercentLabel(undefined), "—"); +}); + +// ── Preparation / polling state (predictionPrep.js) ───────────────────────── +// The editor cannot draw anything until the queued prep job has written the +// footprint PMTiles and the score sidecar, so the "trigger it, then wait" +// decisions are pure functions and get tested here rather than inside the +// component. + +test("normalizePrepStatus accepts the repo status vocabulary defensively", () => { + assert.equal(normalizePrepStatus("Queued"), PREP_STATUS_QUEUED); + assert.equal(normalizePrepStatus("InProgress"), PREP_STATUS_IN_PROGRESS); + // Case and stray whitespace must not break the state machine. + assert.equal(normalizePrepStatus(" inprogress "), PREP_STATUS_IN_PROGRESS); + // Anything unknown collapses to "" — treated as "not terminal, keep going". + assert.equal(normalizePrepStatus("Exploded"), ""); + assert.equal(normalizePrepStatus(undefined), ""); + assert.equal(normalizePrepStatus(null), ""); + assert.equal(normalizePrepStatus(42), ""); +}); + +test("only Failed and Cancelled are terminal", () => { + assert.equal(isTerminalPrepStatus(PREP_STATUS_FAILED), true); + assert.equal(isTerminalPrepStatus(PREP_STATUS_CANCELLED), true); + assert.equal(isTerminalPrepStatus(PREP_STATUS_QUEUED), false); + assert.equal(isTerminalPrepStatus(PREP_STATUS_IN_PROGRESS), false); + assert.equal(isTerminalPrepStatus("Processed"), false); + assert.equal(isTerminalPrepStatus(undefined), false); +}); + +test("isPrepReady requires both artifacts and never assumes readiness", () => { + assert.equal(isPrepReady({ tilesReady: true, attrsReady: true }), true); + assert.equal(isPrepReady({ tilesReady: true, attrsReady: false }), false); + assert.equal(isPrepReady({ tilesReady: false, attrsReady: true }), false); + // A backend that omits the flags entirely means "not ready". + assert.equal(isPrepReady({}), false); + assert.equal(isPrepReady(null), false); + // Truthy-but-not-true values are not readiness either. + assert.equal(isPrepReady({ tilesReady: 1, attrsReady: "yes" }), false); +}); + +test("evaluatePrepState keeps polling while the job is queued or running", () => { + const queued = evaluatePrepState( + { tilesReady: false, attrsReady: false, predictionTilesStatus: "Queued" }, + 0 + ); + assert.equal(queued.phase, PREP_PHASE_WAITING); + assert.equal(queued.shouldPoll, true); + assert.equal(queued.ready, false); + assert.equal(queued.status, PREP_STATUS_QUEUED); + assert.equal(shouldPollPrep(queued.phase), true); + + // Unknown/missing status is not an error: keep waiting. + const unknown = evaluatePrepState({ tilesReady: false, attrsReady: false }, 3); + assert.equal(unknown.phase, PREP_PHASE_WAITING); + assert.equal(unknown.status, ""); + assert.equal(unknown.attempt, 3); + + // Half-prepared is still not ready. + const half = evaluatePrepState( + { tilesReady: true, attrsReady: false, predictionTilesStatus: "InProgress" }, + 1 + ); + assert.equal(half.phase, PREP_PHASE_WAITING); +}); + +test("evaluatePrepState stops as soon as both artifacts exist", () => { + const ready = evaluatePrepState( + { tilesReady: true, attrsReady: true, predictionTilesStatus: "Processed" }, + 7 + ); + assert.equal(ready.phase, PREP_PHASE_READY); + assert.equal(ready.ready, true); + assert.equal(ready.shouldPoll, false); + assert.equal(shouldPollPrep(ready.phase), false); +}); + +test("existing artifacts win over a stale Failed status", () => { + // A previous run failed, but the files are on disk: the editor can open, so + // it must open rather than showing an error. + const result = evaluatePrepState( + { tilesReady: true, attrsReady: true, predictionTilesStatus: "Failed" }, + 2 + ); + assert.equal(result.phase, PREP_PHASE_READY); + assert.equal(result.shouldPoll, false); +}); + +test("evaluatePrepState treats Failed and Cancelled as terminal", () => { + for (const status of [PREP_STATUS_FAILED, PREP_STATUS_CANCELLED]) { + const result = evaluatePrepState( + { + tilesReady: false, + attrsReady: false, + predictionTilesStatus: status, + predictionTilesStatusMessage: "tippecanoe exited 1", + }, + 4 + ); + assert.equal(result.phase, PREP_PHASE_FAILED); + assert.equal(result.shouldPoll, false); + assert.equal(result.status, status); + assert.equal(result.statusMessage, "tippecanoe exited 1"); + } +}); + +test("evaluatePrepState gives up at the attempt cap", () => { + const running = { tilesReady: false, attrsReady: false, predictionTilesStatus: "InProgress" }; + const justUnder = evaluatePrepState(running, MAX_PREP_POLL_ATTEMPTS - 1); + assert.equal(justUnder.phase, PREP_PHASE_WAITING); + + const atCap = evaluatePrepState(running, MAX_PREP_POLL_ATTEMPTS); + assert.equal(atCap.phase, PREP_PHASE_TIMED_OUT); + assert.equal(atCap.shouldPoll, false); + + // A tighter cap (used by the test below) short-circuits the same way. + assert.equal(evaluatePrepState(running, 3, 3).phase, PREP_PHASE_TIMED_OUT); +}); + +test("evaluatePrepState reads the status off an enqueue response too", () => { + // PutPreparePredictionTilesQueueMessage returns `status`, the session + // returns `predictionTilesStatus`; both must be understood. + const result = evaluatePrepState( + { tilesReady: false, attrsReady: false, status: "Failed" }, + 0 + ); + assert.equal(result.phase, PREP_PHASE_FAILED); +}); + +test("nextPollAttempt counts up and repairs junk input", () => { + assert.equal(nextPollAttempt(0), 1); + assert.equal(nextPollAttempt(1), 2); + assert.equal(nextPollAttempt(undefined), 1); + assert.equal(nextPollAttempt(-5), 1); + assert.equal(nextPollAttempt(NaN), 1); + assert.equal(nextPollAttempt(2.7), 3); +}); + +test("a transient poll error keeps waiting until the cap", () => { + const current = { + phase: PREP_PHASE_WAITING, + status: PREP_STATUS_IN_PROGRESS, + statusMessage: "reprojecting", + attempt: 1, + }; + const blip = prepStateAfterPollError(current, "Error fetching.", 5); + assert.equal(blip.phase, PREP_PHASE_WAITING); + assert.equal(blip.shouldPoll, true); + assert.equal(blip.attempt, 2); + // The last known good status survives the blip so the card doesn't reset. + assert.equal(blip.status, PREP_STATUS_IN_PROGRESS); + assert.equal(blip.statusMessage, "reprojecting"); + assert.equal(blip.error, "Error fetching."); + + // Repeated failures still terminate at the cap. + const exhausted = prepStateAfterPollError({ ...current, attempt: 4 }, "", 5); + assert.equal(exhausted.phase, PREP_PHASE_TIMED_OUT); + assert.equal(exhausted.shouldPoll, false); + assert.equal(exhausted.error, "The preparation status could not be read."); +}); + +test("a polling run always terminates within the cap", () => { + // Drive the state machine the way the component does — evaluate, count, + // repeat — against a job that never finishes, and prove it stops. + const stuck = { tilesReady: false, attrsReady: false, predictionTilesStatus: "InProgress" }; + const cap = 8; + let state = evaluatePrepState(stuck, 0, cap); + let polls = 0; + while (shouldPollPrep(state.phase)) { + polls++; + assert.ok(polls <= cap, "must not poll past the cap"); + state = evaluatePrepState(stuck, nextPollAttempt(state.attempt), cap); + } + assert.equal(state.phase, PREP_PHASE_TIMED_OUT); + assert.equal(polls, cap); + + // The same loop against a job that finishes on the third poll stops early. + let finished = evaluatePrepState(stuck, 0, cap); + let ticks = 0; + while (shouldPollPrep(finished.phase)) { + ticks++; + const session = ticks < 3 ? stuck : { tilesReady: true, attrsReady: true }; + finished = evaluatePrepState(session, nextPollAttempt(finished.attempt), cap); + } + assert.equal(finished.phase, PREP_PHASE_READY); + assert.equal(ticks, 3); +}); + +test("the poll interval and cap are a sane bounded wait", () => { + assert.equal(PREP_POLL_INTERVAL_MS, 5000); + // 360 x 5s = 30 minutes: generous for a container-runner job, finite for a + // forgotten tab. + assert.equal((MAX_PREP_POLL_ATTEMPTS * PREP_POLL_INTERVAL_MS) / 60000, 30); +}); + +test("buildPrepRequest only sends force when retrying", () => { + assert.deepEqual( + buildPrepRequest({ projectId: "p1", imageLayerId: "l1", modelId: "m1" }), + { projectId: "p1", imageLayerId: "l1", modelId: "m1" } + ); + assert.deepEqual( + buildPrepRequest({ + projectId: "p1", + imageLayerId: "l1", + modelId: "m1", + force: true, + }), + { projectId: "p1", imageLayerId: "l1", modelId: "m1", force: true } + ); +}); + +test("applyPrepResponse folds the enqueue reply into the session", () => { + const session = { + buildingCount: 10, + tilesReady: false, + attrsReady: false, + predictionTilesStatus: "Failed", + }; + const merged = applyPrepResponse(session, { + queued: true, + tilesReady: false, + attrsReady: true, + status: "Queued", + }); + assert.equal(merged.predictionTilesStatus, PREP_STATUS_QUEUED); + assert.equal(merged.attrsReady, true); + assert.equal(merged.tilesReady, false); + assert.equal(merged.buildingCount, 10, "unrelated fields survive"); + assert.deepEqual(session.predictionTilesStatus, "Failed", "input unchanged"); + + // apiPut hands back a bare 409 for a conflict, and a dead endpoint can give + // us anything at all — keep the session we already have. + assert.equal(applyPrepResponse(session, 409), session); + assert.equal(applyPrepResponse(session, null), session); + assert.equal(applyPrepResponse(session, "nope"), session); + assert.equal(applyPrepResponse(session, []), session); + assert.deepEqual(applyPrepResponse(null, null), {}); +}); + +test("prep card copy reports what is outstanding", () => { + assert.equal( + describeOutstandingArtifacts({ tilesReady: false, attrsReady: false }), + "Still generating footprint tiles and per-building prediction scores." + ); + assert.equal( + describeOutstandingArtifacts({ tilesReady: true, attrsReady: false }), + "Still generating per-building prediction scores." + ); + assert.equal( + describeOutstandingArtifacts({ tilesReady: true, attrsReady: true }), + "" + ); + + assert.equal(prepStatusLabel(PREP_STATUS_QUEUED), "Queued"); + assert.equal(prepStatusLabel(PREP_STATUS_IN_PROGRESS), "In progress"); + assert.equal(prepStatusLabel(PREP_STATUS_FAILED), "Failed"); + assert.equal(prepStatusLabel(PREP_STATUS_CANCELLED), "Cancelled"); + // Unknown/missing must still render something sensible. + assert.equal(prepStatusLabel(undefined), "Starting"); + assert.equal(prepStatusLabel("Weird"), "Starting"); +}); diff --git a/ui/src/Components/PredictionEditor/predictionPrep.js b/ui/src/Components/PredictionEditor/predictionPrep.js new file mode 100644 index 00000000..472aad54 --- /dev/null +++ b/ui/src/Components/PredictionEditor/predictionPrep.js @@ -0,0 +1,272 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Pure preparation/polling helpers for the Prediction Editor. +// +// The editor needs two derived artifacts before it can draw anything: the +// footprint PMTiles archive (kind=footprint_pmtiles) and the per-building +// score sidecar (kind=prediction_attrs). Both are produced by a queued job — +// they are never built inline in an HTTP handler because tippecanoe has to +// run — so opening the editor for a model nobody has prepared yet means: +// +// 1. PUT PutPreparePredictionTilesQueueMessage to enqueue the job, then +// 2. poll GetPredictionEditSession until `tilesReady && attrsReady`. +// +// Everything about "should we still be polling, have we given up, is this +// failure terminal, which attempt are we on" lives here as plain functions +// over plain data: no React, no timers, no fetch. The component owns the +// setTimeout; this module owns the decisions, so they can be unit-tested with +// `node --test` (see predictionClassify.test.js). +// +// Status values follow the repo-wide vocabulary (hastegeo StatusTypes): +// Queued / InProgress / Processed / Failed / Cancelled. Anything else — a +// missing field, a null, a value from a newer backend — is treated as +// "unknown but not terminal", which degrades to "keep waiting" rather than +// showing the user a spurious error. + +export const PREP_STATUS_QUEUED = "Queued"; +export const PREP_STATUS_IN_PROGRESS = "InProgress"; +export const PREP_STATUS_PROCESSED = "Processed"; +export const PREP_STATUS_FAILED = "Failed"; +export const PREP_STATUS_CANCELLED = "Cancelled"; + +const KNOWN_STATUSES = [ + PREP_STATUS_QUEUED, + PREP_STATUS_IN_PROGRESS, + PREP_STATUS_PROCESSED, + PREP_STATUS_FAILED, + PREP_STATUS_CANCELLED, +]; + +// Only these two mean "this job is never finishing on its own". +const TERMINAL_STATUSES = [PREP_STATUS_FAILED, PREP_STATUS_CANCELLED]; + +// ── Wait-state machine ────────────────────────────────────────────────────── +// REQUESTING the enqueue PUT is in flight; nothing to poll yet. +// WAITING the job is queued/running; poll again after the interval. +// READY both artifacts exist; the editor can load them. +// FAILED terminal (Failed/Cancelled, or the enqueue call itself blew up); +// stop polling and offer a forced retry. +// TIMED_OUT the attempt cap was reached; stop polling and tell the user to +// check back later. +export const PREP_PHASE_REQUESTING = "requesting"; +export const PREP_PHASE_WAITING = "waiting"; +export const PREP_PHASE_READY = "ready"; +export const PREP_PHASE_FAILED = "failed"; +export const PREP_PHASE_TIMED_OUT = "timedOut"; + +// How long to wait between GetPredictionEditSession polls. +// +// 5s is the same cadence PublishedDatasets.jsx already uses for its active +// publishing jobs, so the app has one polling rhythm rather than several. It +// is short enough that a job which finishes while the user is watching lights +// up the map within a few seconds (the prep job takes minutes, not +// milliseconds, so anything faster only adds load), and long enough that a +// single parked tab costs the function app 12 cheap metadata reads a minute +// instead of hundreds. +export const PREP_POLL_INTERVAL_MS = 5000; + +// Give up after this many polls: 360 x 5s = 30 minutes of waiting. +// +// The job has to acquire a container runner before tippecanoe even starts, so +// several minutes is normal and a large layer can legitimately take much +// longer than that; 30 minutes is generous enough that we never abandon a +// healthy job, while still guaranteeing a stuck or dead-lettered one cannot +// leave a forgotten tab polling until the browser is closed. +export const MAX_PREP_POLL_ATTEMPTS = 360; + +/** + * Canonical form of a status string, or "" when it is missing/unrecognized. + * Matching is case-insensitive and whitespace-tolerant so a backend that + * writes "queued" or " InProgress " still lines up. + */ +export function normalizePrepStatus(value) { + if (typeof value !== "string") return ""; + const trimmed = value.trim(); + if (trimmed === "") return ""; + const match = KNOWN_STATUSES.find( + (status) => status.toLowerCase() === trimmed.toLowerCase() + ); + return match || ""; +} + +/** True for the statuses a job never recovers from without a new request. */ +export function isTerminalPrepStatus(value) { + return TERMINAL_STATUSES.indexOf(normalizePrepStatus(value)) !== -1; +} + +/** + * True once BOTH artifacts exist. Deliberately strict about `true`: a missing + * flag from an older backend means "not ready", never "assume ready". + */ +export function isPrepReady(session) { + return session?.tilesReady === true && session?.attrsReady === true; +} + +/** Human sentence naming what is still outstanding, for the waiting card. */ +export function describeOutstandingArtifacts(session) { + const missing = []; + if (session?.tilesReady !== true) missing.push("footprint tiles"); + if (session?.attrsReady !== true) { + missing.push("per-building prediction scores"); + } + if (missing.length === 0) return ""; + return `Still generating ${missing.join(" and ")}.`; +} + +/** Label for the status chip; unknown/missing reads as "Starting". */ +export function prepStatusLabel(value) { + switch (normalizePrepStatus(value)) { + case PREP_STATUS_QUEUED: + return "Queued"; + case PREP_STATUS_IN_PROGRESS: + return "In progress"; + case PREP_STATUS_PROCESSED: + return "Finishing up"; + case PREP_STATUS_FAILED: + return "Failed"; + case PREP_STATUS_CANCELLED: + return "Cancelled"; + default: + return "Starting"; + } +} + +/** The attempt counter after one more poll. Junk input restarts at 1. */ +export function nextPollAttempt(attempt) { + const current = + typeof attempt === "number" && Number.isFinite(attempt) && attempt > 0 + ? Math.floor(attempt) + : 0; + return current + 1; +} + +/** True only in the one phase that schedules another poll. */ +export function shouldPollPrep(phase) { + return phase === PREP_PHASE_WAITING; +} + +/** + * Decide what the editor should do having just observed `session` after + * `attempt` polls (0 when the observation came from the enqueue response + * rather than a poll). + * + * Readiness is checked FIRST and on purpose: if the artifacts are on disk it + * does not matter that some earlier run left a Failed status behind — the + * editor can open, so it opens. + */ +export function evaluatePrepState( + session, + attempt = 0, + maxAttempts = MAX_PREP_POLL_ATTEMPTS +) { + const status = normalizePrepStatus( + session?.predictionTilesStatus ?? session?.status + ); + const statusMessage = + typeof session?.predictionTilesStatusMessage === "string" + ? session.predictionTilesStatusMessage + : ""; + const base = { status, statusMessage, attempt, error: "" }; + + if (isPrepReady(session)) { + return { ...base, phase: PREP_PHASE_READY, shouldPoll: false, ready: true }; + } + if (isTerminalPrepStatus(status)) { + return { ...base, phase: PREP_PHASE_FAILED, shouldPoll: false, ready: false }; + } + if (attempt >= maxAttempts) { + return { + ...base, + phase: PREP_PHASE_TIMED_OUT, + shouldPoll: false, + ready: false, + }; + } + return { ...base, phase: PREP_PHASE_WAITING, shouldPoll: true, ready: false }; +} + +/** + * Next wait-state after a poll request itself failed (network blip, a 502 + * from the proxy, ...). A transient error must not kill a healthy wait, so we + * keep the last known status, count the attempt, and carry the message for + * display — until the cap turns it into a give-up. + */ +export function prepStateAfterPollError( + current, + message, + maxAttempts = MAX_PREP_POLL_ATTEMPTS +) { + const attempt = nextPollAttempt(current?.attempt); + const error = + typeof message === "string" && message.trim() !== "" + ? message.trim() + : "The preparation status could not be read."; + const carried = { + status: normalizePrepStatus(current?.status), + statusMessage: + typeof current?.statusMessage === "string" ? current.statusMessage : "", + attempt, + error, + }; + if (attempt >= maxAttempts) { + return { + ...carried, + phase: PREP_PHASE_TIMED_OUT, + shouldPoll: false, + ready: false, + }; + } + return { + ...carried, + phase: PREP_PHASE_WAITING, + shouldPoll: true, + ready: false, + }; +} + +/** The exact PUT body for PutPreparePredictionTilesQueueMessage. */ +export function buildPrepRequest({ + projectId, + imageLayerId, + modelId, + force = false, +}) { + const body = { projectId, imageLayerId, modelId }; + // `force` is optional in the contract: only send it when re-queuing a job + // that already failed, so a routine open can never stomp a running job. + if (force) body.force = true; + return body; +} + +/** + * Fold the enqueue response back into the session object so the readiness + * flags and status the editor renders come from one place. + * + * apiPut surfaces a 409 as the bare status code, and any non-object response + * is ignored — in both cases the caller keeps waiting on the session it + * already has rather than crashing on `undefined.tilesReady`. + */ +export function applyPrepResponse(session, response) { + const base = session || {}; + if (!response || typeof response !== "object" || Array.isArray(response)) { + return base; + } + const next = { ...base }; + if (typeof response.tilesReady === "boolean") { + next.tilesReady = response.tilesReady; + } + if (typeof response.attrsReady === "boolean") { + next.attrsReady = response.attrsReady; + } + const status = normalizePrepStatus( + response.predictionTilesStatus ?? response.status + ); + if (status) next.predictionTilesStatus = status; + const message = + response.predictionTilesStatusMessage ?? response.statusMessage; + if (typeof message === "string") { + next.predictionTilesStatusMessage = message; + } + return next; +} diff --git a/ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx b/ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx index b50db7fd..a9f59c83 100644 --- a/ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx +++ b/ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx @@ -84,6 +84,21 @@ const EmbeddingModelRow = ({ const isProcessed = model.status === "Processed"; const hasPredictions = !!model.gpkgUrl; + // A GeoPackage on its own is not enough to edit: "Clear labels" also writes + // one, with zero predicted buildings in it. predictedBuildingCount is what + // tells us there is actually something to review. + const canEditPredictions = + hasPredictions && (model.predictedBuildingCount ?? 0) > 0; + const editTooltip = canEditPredictions + ? "Review and edit this model's predictions, then save them as a new version" + : "Predict buildings in the Interactive Labeler before editing predictions"; + + function handleEditPredictions() { + if (!canEditPredictions) return; + navigate( + `/edit-predictions/${projectId}/${imageLayerId}/${model.modelId}` + ); + } const createdDate = model.creationDate ? `${model.creationDate.substring(0, 10)} ${model.creationDate.substring( 11, @@ -336,6 +351,21 @@ const EmbeddingModelRow = ({ + + +
@@ -453,6 +483,17 @@ const EmbeddingModelRow = ({ + + + + + + {model.artifacts && model.artifacts.zipStatusMessage && ( `, where is the same + * string the source's `getKey()` returns. + */ +export function getPmtilesProtocol() { + if (typeof window === "undefined") return null; + if (!protocolInstance) { + protocolInstance = new Protocol(); + } + if ( + !isRegistered && + window.atlas && + typeof window.atlas.addProtocol === "function" + ) { + // The bound `.tile` member is what addProtocol expects. + window.atlas.addProtocol("pmtiles", protocolInstance.tile); + isRegistered = true; + } + return protocolInstance; +} + +/** + * pmtiles.js reads an archive through a `Source` (getKey + getBytes). Its + * default FetchSource issues HTTP Range requests, but these screens are + * served behind an Azure Static Web App whose /api proxy does NOT honor byte + * serving: a ranged GET comes back as a full 200, so pmtiles throws "Server + * returned no content-length header or content-length exceeding request." + * Downloading the whole archive once and satisfying every range read from + * that in-memory buffer sidesteps the problem. + * + * `getKey()` must equal the string used in the `pmtiles://` source URL + * so Protocol.add()'s lookup matches. + */ +export class InMemoryPMTilesSource { + constructor(key, arrayBuffer) { + this._key = key; + this._buf = arrayBuffer; + } + getKey() { + return this._key; + } + async getBytes(offset, length) { + // ArrayBuffer.slice clamps to the buffer end, which is what pmtiles + // expects for the initial 16 KB header read on a smaller archive. + return { data: this._buf.slice(offset, offset + length) }; + } +} + +/** + * Download an entire artifact through the same-origin API proxy as raw bytes. + * Used for the PMTiles archive so it can be read fully in memory (see + * InMemoryPMTilesSource) rather than via unsupported range requests. + */ +export async function fetchArtifactBuffer(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to fetch PMTiles archive (HTTP ${response.status}).` + ); + } + return response.arrayBuffer(); +} From 2e47b576367ea40d6858bc58c7c8f068ba2addf8 Mon Sep 17 00:00:00 2001 From: Caleb Robinson Date: Fri, 21 Aug 2026 16:25:12 +0000 Subject: [PATCH 02/10] fix(gis): reuse existing PMTiles and drop the queue dep from tiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preparing an embedding model for editing failed, and would have wasted a multi-gigabyte container job even once it worked. Reuse the tiles we already have. The embedding workflow tiles the same footprints from the same PMTiles archive, keyed on the same integer row-index id, and records it as Model.pmtilesUrl. needs_preparation only looked at ImageLayer.footprintPmtilesUrl, so it rebuilt a byte-for-byte equivalent archive. Preparation now resolves the model's own archive first, and GetModelArtifact serves footprint_pmtiles from it, which also means that kind no longer needs an imageLayerId for embedding models. Stop importing the queue SDK inside the training image. The workflow stored its outputs through ArtifactProcessor, which also drives zip jobs and therefore imports azure.storage.queue — a package the training image does not install, so the job died with ModuleNotFoundError after all the real work was done. It now talks to UnifiedArtifactStorage directly, constructed exactly as ArtifactProcessor does; putting bytes in blob storage is all this workflow ever needed. Verified against a real embedding model in the local stack: preparation reports tilesReady immediately, the job reaches Processed, and the sidecar serves 1496 buildings with every column the same length. --- api/hastefuncapi/function_app.py | 68 ++++++++++++------- .../core/processors/prediction_tiles.py | 18 ++++- .../workflows/prepare_prediction_tiles.py | 18 +++-- .../core/processors/test_prediction_tiles.py | 26 +++++++ 4 files changed, 97 insertions(+), 33 deletions(-) diff --git a/api/hastefuncapi/function_app.py b/api/hastefuncapi/function_app.py index c2da2620..52a9862d 100644 --- a/api/hastefuncapi/function_app.py +++ b/api/hastefuncapi/function_app.py @@ -1466,32 +1466,48 @@ async def GetModelArtifact(req: func.HttpRequest) -> func.HttpResponse: return func.HttpResponse("Error loading model.", status_code=500) if layer_url_field is not None: - # Layer-scoped artifact: take an explicit imageLayerId when the - # caller passes one, otherwise the model's own layer — a client - # holding a modelId always means that model's footprints. - image_layer_id = req.params.get("imageLayerId") or ( - document or {} - ).get("imageLayerId") - if not image_layer_id or not _GUID_RE.match(str(image_layer_id)): - return _bad_request("Invalid or missing parameter: imageLayerId") - try: - document = await asyncio.to_thread( - MetadataProcessor( - data_type=config.get_metadata_types().IMAGELAYER.value, - partition_key=project_id, - ).load, - image_layer_id, - ) - except FileNotFoundError: - return func.HttpResponse("Image layer not found.", status_code=404) - except Exception as e: - logger.error( - f"GetModelArtifact image layer load failed: {e}\n" - f"{traceback.format_exc()}" - ) - return func.HttpResponse( - "Error loading image layer.", status_code=500 - ) + model_document = document or {} + # The embedding workflow already tiles these footprints from the + # same archive, keyed on the same row-index id, so reuse the + # model's own PMTiles rather than making the caller wait for an + # identical layer-scoped rebuild. + reused_url = "" + if kind == "footprint_pmtiles": + reused_url = model_document.get("pmtilesUrl") or "" + if reused_url: + document = {url_field: reused_url} + else: + # Layer-scoped artifact: take an explicit imageLayerId when + # the caller passes one, otherwise the model's own layer — a + # client holding a modelId always means that model's + # footprints. + image_layer_id = req.params.get( + "imageLayerId" + ) or model_document.get("imageLayerId") + if not image_layer_id or not _GUID_RE.match(str(image_layer_id)): + return _bad_request( + "Invalid or missing parameter: imageLayerId" + ) + try: + document = await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().IMAGELAYER.value, + partition_key=project_id, + ).load, + image_layer_id, + ) + except FileNotFoundError: + return func.HttpResponse( + "Image layer not found.", status_code=404 + ) + except Exception as e: + logger.error( + f"GetModelArtifact image layer load failed: {e}\n" + f"{traceback.format_exc()}" + ) + return func.HttpResponse( + "Error loading image layer.", status_code=500 + ) blob_url = (document or {}).get(url_field) or "" if not blob_url: diff --git a/hastelib/src/hastegeo/core/processors/prediction_tiles.py b/hastelib/src/hastegeo/core/processors/prediction_tiles.py index bbb95ae0..088d675e 100644 --- a/hastelib/src/hastegeo/core/processors/prediction_tiles.py +++ b/hastelib/src/hastegeo/core/processors/prediction_tiles.py @@ -157,6 +157,18 @@ def enqueue_prediction_tiles( return message +def resolve_tiles_url(model: Model, image_layer: ImageLayer) -> Optional[str]: + """Return the PMTiles archive the editor should read, if any. + + The embedding workflow already tiles the same footprints from the + same PMTiles archive, keyed on the same integer row-index ``id`` + (see ``workflows/embed_buildings.py``), so those tiles are reused + rather than rebuilt. Only trained-inference models need a layer + level archive built for them. + """ + return model.pmtilesUrl or image_layer.footprintPmtilesUrl + + def needs_preparation( model: Model, image_layer: ImageLayer ) -> Tuple[bool, bool]: @@ -164,10 +176,10 @@ def needs_preparation( Returns: ``(needs_pmtiles, needs_attrs)``. Footprint tiles are shared by - every model on a layer, so they are only built when the layer - has no ``footprintPmtilesUrl`` yet. + every model on a layer, so they are only built when neither the + model nor the layer already has a usable archive. """ - needs_pmtiles = not bool(image_layer.footprintPmtilesUrl) + needs_pmtiles = not bool(resolve_tiles_url(model, image_layer)) needs_attrs = not bool(model.predictionAttrsUrl) return needs_pmtiles, needs_attrs diff --git a/hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py b/hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py index 291fc52c..5f153a36 100644 --- a/hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py +++ b/hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py @@ -458,20 +458,30 @@ def store_artifacts( Returns: ``{artifact_name: download_url}`` for everything stored. """ - from hastegeo.core.processors.artifacts import ArtifactProcessor + # Use the storage layer directly rather than ArtifactProcessor: that + # processor also drives zip jobs and therefore imports the queue SDK, + # which the training image does not install. This workflow only ever + # needs to put bytes in blob storage. + from hastegeo.core.artifact_storage.unified_artifact_storage import ( + UnifiedArtifactStorage, + ) config = config or Config() - processor = ArtifactProcessor(partition_key=project_id, config=config) + storage = UnifiedArtifactStorage( + storage_type=config.artifact_storage_type, + partition_key=project_id, + **config.artifact_storage_config, + ) urls: Dict[str, str] = {} for artifact_name, local_path in artifacts.items(): if not os.path.exists(local_path): raise FileNotFoundError( f"Artifact {artifact_name} not found at {local_path}" ) - processor.store_artifact( + storage.store_artifact( artifact_name=artifact_name, src_path=local_path ) - urls[artifact_name] = processor.get_download_url( + urls[artifact_name] = storage.get_download_url( identifier=artifact_name ) logger.info("Stored artifact %s", artifact_name) diff --git a/hastelib/tests/core/processors/test_prediction_tiles.py b/hastelib/tests/core/processors/test_prediction_tiles.py index bb83523f..5bd98ece 100644 --- a/hastelib/tests/core/processors/test_prediction_tiles.py +++ b/hastelib/tests/core/processors/test_prediction_tiles.py @@ -91,6 +91,32 @@ def test_tiles_are_reused_across_models_on_a_layer(self): self.assertFalse(needs_pmtiles) self.assertTrue(needs_attrs) + def test_embedding_model_pmtiles_are_reused(self): + """The embedding workflow already tiles the same footprints. + + Rebuilding them would spawn a multi-gigabyte container job to + produce a byte-for-byte equivalent archive. + """ + from hastegeo.core.processors.prediction_tiles import needs_preparation + + model = _model(pmtilesUrl="https://acct/buildings_5553.pmtiles") + needs_pmtiles, needs_attrs = needs_preparation(model, _layer()) + self.assertFalse(needs_pmtiles) + self.assertTrue(needs_attrs) + + def test_resolve_tiles_url_prefers_the_model_archive(self): + from hastegeo.core.processors.prediction_tiles import resolve_tiles_url + + model = _model(pmtilesUrl="https://acct/model.pmtiles") + layer = _layer(footprintPmtilesUrl="https://acct/layer.pmtiles") + self.assertEqual( + resolve_tiles_url(model, layer), "https://acct/model.pmtiles" + ) + self.assertEqual( + resolve_tiles_url(_model(), layer), "https://acct/layer.pmtiles" + ) + self.assertIsNone(resolve_tiles_url(_model(), _layer())) + class TestPreprocessor(unittest.TestCase): def test_enqueues_when_work_is_outstanding(self): From c4a5a86c2145be62a5a587a54495192c4234a472 Mon Sep 17 00:00:00 2001 From: Caleb Robinson Date: Fri, 21 Aug 2026 17:04:00 +0000 Subject: [PATCH 03/10] feat: tile footprints at layer creation and add an editor swipe map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the layer's footprint PMTiles as part of image-layer creation, so a trained-inference model opens straight into the editor instead of waiting on a tiling job. Tippecanoe lives only in the training image — not in imageryprep and not in the Functions app — so the tiling itself stays a queued job; imagery prep simply enqueues it once the footprints are cached. That needed a layer-only mode for the prep job. modelId and the predictions URL are now optional: without them the job builds the PMTiles, skips the sidecar, and records status on the image layer rather than the model. The message schema is unchanged, so the queue contract stays backward compatible, and the on-demand path still tiles older layers when someone opens the editor. Layer-time status writes patch only the tiling fields, because imagery prep is writing the same document around the same time. Enqueue failures are logged and swallowed: tiles are an optimisation, and the editor can still build them on demand. Add a swipe comparison to the editor: pre-event against post-event, or basemap against post-event when the layer has no pre-event imagery. atlas.SwipeMap reveals the PRIMARY map on the left and clips the SECONDARY, so the comparison map is primary and the editor map is secondary — moving the divider left uncovers more of the editing map. A recent PR shipped that description inverted, so a test pins the wording. Editing is wired to both panes and feature-state and paint expressions are mirrored across renderers; otherwise half the map is inert and the far side draws every footprint unlabeled. A/S/D move the divider, consistent with the other map views. Verified in the local stack: a layer-only job runs tippecanoe for real and lands footprintPmtilesUrl, and the tiles serve as valid PMTiles. --- api/hastefuncqueues/function_app.py | 242 ++++-- docs/api/hastefuncapi.md | 7 + hastelib/src/hastegeo/core/models/projects.py | 18 + .../src/hastegeo/core/processors/imagery.py | 58 ++ .../core/processors/prediction_tiles.py | 368 ++++++--- .../workflows/prepare_prediction_tiles.py | 80 +- .../test_imagery_footprint_tiles.py | 226 ++++++ .../processors/test_prediction_tiles_layer.py | 349 +++++++++ .../test_prepare_prediction_tiles.py | 129 ++++ .../features/prediction-editing/data-model.md | 20 +- spec/features/prediction-editing/design.md | 52 +- spec/features/prediction-editing/plan.md | 3 + spec/features/prediction-editing/test-plan.md | 5 +- .../PredictionEditor/PredictionEditor.jsx | 697 ++++++++++++++++-- .../PredictionEditorRightPanel.jsx | 36 +- .../predictionClassify.test.js | 139 ++++ .../PredictionEditor/predictionSwipe.js | 117 +++ ui/src/Components/keyboardShortcuts.js | 9 + 18 files changed, 2288 insertions(+), 267 deletions(-) create mode 100644 hastelib/tests/core/processors/test_imagery_footprint_tiles.py create mode 100644 hastelib/tests/core/processors/test_prediction_tiles_layer.py create mode 100644 ui/src/Components/PredictionEditor/predictionSwipe.js diff --git a/api/hastefuncqueues/function_app.py b/api/hastefuncqueues/function_app.py index e245d560..ef765162 100644 --- a/api/hastefuncqueues/function_app.py +++ b/api/hastefuncqueues/function_app.py @@ -5,6 +5,7 @@ import json import os import traceback +from typing import Optional import azure.functions as func # type: ignore from hastegeo.core.config import Config @@ -605,48 +606,133 @@ async def GetRunEmbeddingQueueMessage(msg: func.QueueMessage) -> None: ) -@app.function_name(name="PreparePredictionTilesQueueTrigger") -@app.queue_trigger( - arg_name="msg", - queue_name=config.get_queue_config()["prediction_edit_prep_queue_name"], - connection="AzureWebJobsStorage", -) -async def GetPreparePredictionTilesQueueMessage( - msg: func.QueueMessage, +async def _save_layer_footprint_tile_state( + project_id: str, image_layer: ImageLayer ) -> None: - """Build the prediction editor's footprint tiles + attribute sidecar. + """Persist only the footprint-tiling fields of an image layer. - Message schema (identifiers only):: + A layer-only tiling job runs alongside (and just after) imagery + preprocessing, which owns the rest of the document. Re-reading the + layer and patching just these four fields keeps the tiling job from + clobbering a concurrent imagery update. + """ + metadata = MetadataProcessor( + data_type=config.get_metadata_types().IMAGELAYER.value, + partition_key=project_id, + ) + image_layer_id = image_layer.imageLayerId + latest_layer = await asyncio.to_thread(metadata.load, image_layer_id) + job = image_layer.footprintTilesJob + latest_layer.update( + { + "footprintPmtilesUrl": image_layer.footprintPmtilesUrl, + "footprintTilesStatus": image_layer.footprintTilesStatus, + "footprintTilesStatusMessage": ( + image_layer.footprintTilesStatusMessage + ), + "footprintTilesJob": job.dict() if job else None, + } + ) + await asyncio.to_thread(metadata.save, image_layer_id, latest_layer) - {"projectId", "imageLayerId", "modelId", "sourceGpkgUrl", - "sourceFootprintsUrl", "force"} - The authoritative job state is read from metadata, so a fresh - request and the postprocessor's own poll messages take the same - path. Drives the PredictionTilesPostprocessor state machine (submit - -> poll -> finalize). The work runs as a task in the training - docker image because tippecanoe only ships there. On completion the - model gets its attribute-sidecar URL and the image layer gets the - shared footprint PMTiles URL, so both documents are persisted. +async def _prepare_layer_footprint_tiles( + project_id: str, image_layer_id: str, force: bool +) -> None: + """Build an image layer's shared footprint PMTiles (no model). + + The layer-only half of the prep queue: imagery preprocessing asks + for this as soon as a layer's building footprints are cached, so the + prediction editor finds the tiles already built. No model document is + read or written — the job's state lives on the layer + (``footprintTilesStatus``/``footprintTilesJob``). """ - logger.info( - "PreparePredictionTilesQueueTrigger function processed a message: " - f'{msg.get_body().decode("utf-8")}' - ) - model_data = None + image_layer = None try: - payload = json.loads(msg.get_body().decode("utf-8")) - if not isinstance(payload, dict): - raise ValueError("Queue message must be a JSON object") - project_id = payload.get("projectId") - model_id = payload.get("modelId") - force = bool(payload.get("force", False)) - if not project_id or not model_id: - raise ValueError( - "Queue message requires projectId and modelId, got: " - f"{sorted(payload.keys())}" + try: + layer_record = await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().IMAGELAYER.value, + partition_key=project_id, + ).load, + image_layer_id, + ) + except FileNotFoundError: + layer_record = None + + if not layer_record: + logger.info( + f"Image layer {image_layer_id} not found, likely deleted, " + "skipping footprint tile preparation." ) + return + + # Metadata is authoritative: the message only routes the work. + image_layer = ImageLayer(**layer_record) + statuses = config.get_status_types() + if image_layer.footprintTilesStatus != statuses.IN_PROGRESS.value: + if not image_layer.buildingFootprintsUrl: + logger.info( + f"Image layer {image_layer_id} has no cached building " + "footprints; nothing to tile." + ) + return + if not force and image_layer.footprintPmtilesUrl: + logger.info( + f"Footprint tiles for image layer {image_layer_id} are " + "already available; nothing to do." + ) + image_layer.footprintTilesStatus = statuses.COMPLETED.value + await _save_layer_footprint_tile_state(project_id, image_layer) + return + image_layer.footprintTilesStatus = statuses.PENDING.value + + processor = PredictionTilesPostprocessor(None, image_layer) + output = await asyncio.to_thread(processor.process) + await _save_layer_footprint_tile_state(project_id, output) + except Exception as e: + logger.error( + "PreparePredictionTilesQueueTrigger: Error preparing footprint " + f"tiles for image layer {image_layer_id}: {e}\n" + f"{traceback.format_exc()}", + stack_info=True, + ) + if image_layer is not None: + try: + image_layer.footprintTilesStatus = ( + config.get_status_types().FAILED.value + ) + image_layer.footprintTilesStatusMessage = ( + MetadataUtils.append_status_message( + image_layer.footprintTilesStatusMessage, + "Footprint tile job failed: " + f"{describe_exception(e)}", + ) + ) + await _save_layer_footprint_tile_state(project_id, image_layer) + except Exception as inner_e: + logger.error( + "PreparePredictionTilesQueueTrigger: Error saving " + f"failed status: {inner_e}\n{traceback.format_exc()}", + stack_info=True, + ) + +async def _prepare_model_prediction_tiles( + project_id: str, + image_layer_id: Optional[str], + model_id: str, + force: bool, +) -> None: + """Build a model's attribute sidecar (+ the layer's tiles if absent). + + Drives the PredictionTilesPostprocessor state machine (submit -> + poll -> finalize) for one model. On completion the model gets its + attribute-sidecar URL and the image layer gets the shared footprint + PMTiles URL, so both documents are persisted. + """ + model_data = None + try: try: existing_model = await asyncio.to_thread( MetadataProcessor( @@ -667,7 +753,7 @@ async def GetPreparePredictionTilesQueueMessage( # Metadata is authoritative: the message only routes the work. model_data = Model(**existing_model) - image_layer_id = payload.get("imageLayerId") or model_data.imageLayerId + image_layer_id = image_layer_id or model_data.imageLayerId if not image_layer_id: raise ValueError( f"Model {model_id} has no imageLayerId; cannot locate " @@ -738,20 +824,10 @@ async def GetPreparePredictionTilesQueueMessage( image_layer_id, latest_layer, ) - except ValidationError as e: - logger.error( - f"PreparePredictionTilesQueueTrigger: Validation error: {e}\n" - f"{traceback.format_exc()}" - ) - except ValueError as e: - logger.error( - "PreparePredictionTilesQueueTrigger: Invalid queue message: " - f"{e}\n{traceback.format_exc()}" - ) except Exception as e: logger.error( - "PreparePredictionTilesQueueTrigger: Error processing queue " - f"message: {e}\n{traceback.format_exc()}", + "PreparePredictionTilesQueueTrigger: Error preparing prediction " + f"tiles for model {model_id}: {e}\n{traceback.format_exc()}", stack_info=True, ) if model_data is not None: @@ -782,6 +858,78 @@ async def GetPreparePredictionTilesQueueMessage( ) +@app.function_name(name="PreparePredictionTilesQueueTrigger") +@app.queue_trigger( + arg_name="msg", + queue_name=config.get_queue_config()["prediction_edit_prep_queue_name"], + connection="AzureWebJobsStorage", +) +async def GetPreparePredictionTilesQueueMessage( + msg: func.QueueMessage, +) -> None: + """Build the prediction editor's footprint tiles + attribute sidecar. + + Message schema (identifiers only):: + + {"projectId", "imageLayerId", "modelId", "sourceGpkgUrl", + "sourceFootprintsUrl", "force"} + + An empty/absent ``modelId`` selects **layer-only** preparation: build + the image layer's shared footprint PMTiles and nothing else. Imagery + preprocessing queues that as soon as a layer's footprints are cached + so the editor never has to wait for tiling. With a ``modelId`` the + message is **model-scoped**: build the model's attribute sidecar, + plus the layer's tiles when they are still missing. + + The authoritative job state is read from metadata, so a fresh + request and the postprocessor's own poll messages take the same + path. The work runs as a task in the training docker image because + tippecanoe only ships there. + """ + logger.info( + "PreparePredictionTilesQueueTrigger function processed a message: " + f'{msg.get_body().decode("utf-8")}' + ) + try: + payload = json.loads(msg.get_body().decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("Queue message must be a JSON object") + project_id = payload.get("projectId") + model_id = payload.get("modelId") + image_layer_id = payload.get("imageLayerId") + force = bool(payload.get("force", False)) + if not project_id or not (model_id or image_layer_id): + raise ValueError( + "Queue message requires projectId plus modelId or " + f"imageLayerId, got: {sorted(payload.keys())}" + ) + + if model_id: + await _prepare_model_prediction_tiles( + project_id, image_layer_id, model_id, force + ) + else: + await _prepare_layer_footprint_tiles( + project_id, image_layer_id, force + ) + except ValidationError as e: + logger.error( + f"PreparePredictionTilesQueueTrigger: Validation error: {e}\n" + f"{traceback.format_exc()}" + ) + except ValueError as e: + logger.error( + "PreparePredictionTilesQueueTrigger: Invalid queue message: " + f"{e}\n{traceback.format_exc()}" + ) + except Exception as e: + logger.error( + "PreparePredictionTilesQueueTrigger: Error processing queue " + f"message: {e}\n{traceback.format_exc()}", + stack_info=True, + ) + + @app.function_name(name="GetRunInferenceQueueTrigger") @app.queue_trigger( arg_name="msg", diff --git a/docs/api/hastefuncapi.md b/docs/api/hastefuncapi.md index 5d8dca1e..9edc6a43 100644 --- a/docs/api/hastefuncapi.md +++ b/docs/api/hastefuncapi.md @@ -191,6 +191,13 @@ the work inline, it only asks for it. The `prediction-edit-prep-queue` trigger in `hastefuncqueues` runs the job through the training pool and writes the two URLs back. +The footprint PMTiles are normally already there: imagery preprocessing queues +a layer-only preparation job (same queue, empty `modelId`) as soon as an image +layer's building footprints are cached, so this route usually only has to build +the per-model sidecar. Image layers created before that behaviour existed — +or whose layer-time enqueue failed, which is deliberately non-fatal — get their +tiles built here on demand instead. + **Request:** ```json diff --git a/hastelib/src/hastegeo/core/models/projects.py b/hastelib/src/hastegeo/core/models/projects.py index f21c9b90..1c9423d7 100644 --- a/hastelib/src/hastegeo/core/models/projects.py +++ b/hastelib/src/hastegeo/core/models/projects.py @@ -767,6 +767,15 @@ class ImageLayer(BaseModel): building footprints (geometry + Overture id only). Rendered by the prediction editor, which joins prediction attributes onto the tiles client-side instead of shipping a GeoJSON payload. + footprintTilesJob: Job reference for the layer-scoped tiling task + that builds ``footprintPmtilesUrl``. Kicked off automatically + once imagery prep caches the footprints, so the tiles already + exist by the time anyone opens the prediction editor. + footprintTilesStatus: Status of that layer-scoped tiling job. + Deliberately separate from ``status`` so tiling never disturbs + the imagery-preprocessing lifecycle — tiles are an + optimisation and a layer without them still works. + footprintTilesStatusMessage: Progress/error log of the tiling job. dependsOn: Dependency tuple specifying parent resource type and ID Example: @@ -844,7 +853,16 @@ class ImageLayer(BaseModel): # Catalog "clip to area" flow. clipBbox: Optional[list[float]] = Field(default=None) validAreaMaskUrl: Optional[str] = Field(default=None) + # ── Prediction editing (shared footprint vector tiles) ────────────── + # Built once per layer — at layer-creation time when footprints are + # cached, or on demand the first time the prediction editor opens an + # older layer — and reused by every model trained on the layer. The + # tiling job runs in the training image (only tippecanoe carrier), so + # it carries its own status fields rather than reusing ``status``. footprintPmtilesUrl: Optional[str] = Field(default=None) + footprintTilesJob: Optional[TrainingJob] = Field(default=None) + footprintTilesStatus: Optional[str] = Field(default=None) + footprintTilesStatusMessage: Optional[str] = Field(default="") dependsOn: Optional[tuple[str, str]] = Field( default=("Project", "projectId") ) diff --git a/hastelib/src/hastegeo/core/processors/imagery.py b/hastelib/src/hastegeo/core/processors/imagery.py index bc46172a..94f81eff 100644 --- a/hastelib/src/hastegeo/core/processors/imagery.py +++ b/hastelib/src/hastegeo/core/processors/imagery.py @@ -14,6 +14,10 @@ from ..utils.logs import Logger from ..utils.metadata import MetadataUtils from ..utils.queues import AzureQueueHandler +from .prediction_tiles import ( + enqueue_prediction_tiles, + layer_needs_footprint_tiles, +) BATCH_JOB_WORKDIR = "AZ_BATCH_TASK_WORKING_DIR" IMAGERY_PREFIX = "img" @@ -242,6 +246,16 @@ def process(self): self.image_data.statusMessage ) + # Pre-build the layer's footprint vector tiles now that + # the footprints exist, so the prediction editor opens + # instantly later. Skipped when _update_results_from_job + # flipped the layer to FAILED (no usable footprints). + if ( + self.image_data.status + == self.config.get_status_types().COMPLETED.value + ): + self._enqueue_footprint_tiles() + # Cleanup the task on the runner self.runner.cleanup_task( job_id=self.image_data.preprocessJob.jobId, @@ -382,6 +396,50 @@ def _execute_image_preprocess(self): ) return self.image_data + def _enqueue_footprint_tiles(self) -> None: + """Queue the layer's footprint PMTiles build (best effort). + + Building the tiles at layer-creation time means the prediction + editor never has to wait for a per-model tiling job: the archive + is shared by every model on the layer. The work itself still runs + as a queued task in the training image, which is the only one + carrying ``tippecanoe``. + + Deliberately non-fatal: tiles are an optimisation. If the queue + is unreachable the layer is still perfectly usable, and the + editor's own preparation path (``request_preparation``) rebuilds + them on demand — the same path older layers take. + """ + if not layer_needs_footprint_tiles(self.image_data): + self.logger.info( + "Not queueing footprint tiles for image layer %s " + "(footprints=%s, tiles=%s)", + self.image_data.imageLayerId, + bool(self.image_data.buildingFootprintsUrl), + bool(self.image_data.footprintPmtilesUrl), + ) + return + try: + enqueue_prediction_tiles( + project_id=self.image_data.projectId, + image_layer_id=self.image_data.imageLayerId, + source_footprints_url=self.image_data.buildingFootprintsUrl, + config=self.config, + ) + self.logger.info( + "Queued footprint tiles for image layer %s", + self.image_data.imageLayerId, + ) + except Exception as e: + self.logger.warning( + "Could not queue footprint tiles for image layer %s: %s. " + "Imagery preprocessing is unaffected; the tiles will be " + "built on demand when the prediction editor first opens " + "this layer.", + self.image_data.imageLayerId, + e, + ) + def _read_task_output(self, filename: str) -> Optional[str]: """Return the text of a task output file, or ``None``. diff --git a/hastelib/src/hastegeo/core/processors/prediction_tiles.py b/hastelib/src/hastegeo/core/processors/prediction_tiles.py index 088d675e..62f56514 100644 --- a/hastelib/src/hastegeo/core/processors/prediction_tiles.py +++ b/hastelib/src/hastegeo/core/processors/prediction_tiles.py @@ -23,7 +23,27 @@ train/inference lifecycle (same separation the zip flow uses with ``ModelArtifacts.zipStatus``). -Config JSON handed to the workflow:: +Two scopes share this machinery: + +* **model-scoped** — the historic path: build the layer's PMTiles when + they are still missing *and* the model's attribute sidecar. State + lives on the ``Model``. +* **layer-only** — no ``modelId``: build just the shared footprint + PMTiles for an image layer, skipping the sidecar entirely. Kicked off + by ``processors/imagery.py`` as soon as a layer's footprints are + cached, so the tiles are already there by the time the first model is + edited. State lives on the ``ImageLayer`` + (``footprintTilesStatus``/``footprintTilesJob``) — there is no model + to write to. + +Both scopes write the same deterministic artifact name +(``footprints_${imageLayerId}.pmtiles``), so a model-scoped job that +starts while a layer-only job is still running simply repeats the tiling +and overwrites the archive with an identical one — wasteful in a narrow +window, never inconsistent. + +Config JSON handed to the workflow (``model_id``/``files.predictions``/ +``files.attrs`` are omitted in layer-only mode):: { "project_id": "...", @@ -51,6 +71,9 @@ "force": false } +An empty ``modelId`` (and, with it, an empty ``sourceGpkgUrl``) selects +the layer-only mode. + The trigger treats the message as a *request* and reads the authoritative state from metadata, so the postprocessor's own poll re-queues (which carry the full model document) are handled by the same code path. @@ -64,7 +87,7 @@ import json import os -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, Union from ..config import ArtifactTypes, Config from ..data_layer.unified import UnifiedDataLayer @@ -81,6 +104,11 @@ MANIFEST_FILENAME = "prediction_tiles_manifest.json" FRIENDLY_LOG_FILENAME = "prediction_tiles_friendly.log" +# The document a prediction-tiles job records its state on: the Model in +# model-scoped mode, the ImageLayer when only the shared footprint tiles +# are being built. +PredictionTilesTarget = Union[Model, ImageLayer] + def pmtiles_artifact_name(image_layer_id: str) -> str: """Artifact name for a layer's footprint PMTiles archive.""" @@ -103,7 +131,7 @@ def attrs_artifact_name(model_id: str) -> str: def build_prep_message( project_id: str, image_layer_id: str, - model_id: str, + model_id: Optional[str] = None, source_gpkg_url: Optional[str] = None, source_footprints_url: Optional[str] = None, force: bool = False, @@ -113,11 +141,23 @@ def build_prep_message( The message only carries identifiers; the queue trigger reads the authoritative state from metadata so that re-queued poll messages and fresh requests take the same code path. + + Args: + project_id: Owning project. + image_layer_id: Layer whose footprints get tiled. + model_id: Model whose attribute sidecar is needed. Omit (or pass + ``None``) to request the layer's footprint PMTiles alone — + the mode imagery prep uses at layer-creation time, when no + model exists yet. + source_gpkg_url: The model's prediction GeoPackage. Meaningless + (and empty) in layer-only mode. + source_footprints_url: The layer's cached footprints GeoPackage. + force: Rebuild even when the artifacts already exist. """ return { "projectId": project_id, "imageLayerId": image_layer_id, - "modelId": model_id, + "modelId": model_id or "", "sourceGpkgUrl": source_gpkg_url or "", "sourceFootprintsUrl": source_footprints_url or "", "force": bool(force), @@ -127,7 +167,7 @@ def build_prep_message( def enqueue_prediction_tiles( project_id: str, image_layer_id: str, - model_id: str, + model_id: Optional[str] = None, source_gpkg_url: Optional[str] = None, source_footprints_url: Optional[str] = None, force: bool = False, @@ -135,8 +175,10 @@ def enqueue_prediction_tiles( ) -> Dict[str, Any]: """Put a preparation request on the prediction-edit prep queue. - Convenience seam for the HTTP layer, which must never run - ``tippecanoe`` inline. Returns the enqueued message. + Convenience seam for the HTTP layer and for imagery prep, neither of + which may run ``tippecanoe`` inline. Omitting ``model_id`` requests + the layer's shared footprint PMTiles only. Returns the enqueued + message. """ if config is None: config = Config() @@ -184,6 +226,18 @@ def needs_preparation( return needs_pmtiles, needs_attrs +def layer_needs_footprint_tiles(image_layer: ImageLayer) -> bool: + """Report whether a layer-only tiling job is worth queueing. + + Used by imagery prep, which has no model in hand: the tiles can only + be built once the footprints GeoPackage is cached, and there is no + point rebuilding an archive the layer already has. + """ + return bool(image_layer.buildingFootprintsUrl) and not bool( + image_layer.footprintPmtilesUrl + ) + + class PredictionTilesUnavailableError(ValueError): """Nothing can be prepared for this model/layer pair as it stands. @@ -419,22 +473,43 @@ def queue_for_processing(self, force: bool = False) -> Model: class PredictionTilesPostprocessor: - """Submit, poll and finalize the prediction-tiles Batch task.""" + """Submit, poll and finalize the prediction-tiles Batch task. + + Runs in one of two scopes: + + * **model-scoped** (``model`` given): builds the attribute sidecar, + plus the layer's PMTiles when they are still missing. Job state + lives on the ``Model``. + * **layer-only** (``model is None``): builds just the layer's shared + footprint PMTiles, which is what imagery prep asks for at + layer-creation time. Job state lives on the ``ImageLayer`` and no + model document is read or written. + """ def __init__( self, - model: Model, + model: Optional[Model], image_layer: ImageLayer, config: Optional[Config] = None, ) -> None: if config is None: config = Config() + if image_layer is None: + raise ValueError( + "PredictionTilesPostprocessor requires the image layer " + "that owns the footprint tiles." + ) self.config = config self.model_data = model self.image_layer = image_layer + # No model -> layer-only mode: footprint tiles alone, no sidecar. + self.layer_only = model is None + self.project_id = ( + image_layer.projectId if self.layer_only else model.projectId + ) self.storage = UnifiedDataLayer( storage_type=config.storage_type, - partition_key=model.projectId, + partition_key=self.project_id, **config.storage_config, ) self.logger = Logger.get_logger(__name__) @@ -452,56 +527,115 @@ def __init__( config.queue_config["queue_account_url"], ) + # ── scope-aware state accessors ─────────────────────────────────── + # The job's status/reference/log live on whichever document owns the + # work, so every state transition below goes through these instead of + # touching a specific document. + @property + def target(self) -> PredictionTilesTarget: + """The document this job's state is recorded on.""" + return self.image_layer if self.layer_only else self.model_data + + @property + def target_id(self) -> str: + """Identifier of that document, for logs and task naming.""" + if self.layer_only: + return self.image_layer.imageLayerId + return self.model_data.modelId + + @property + def status(self) -> Optional[str]: + if self.layer_only: + return self.image_layer.footprintTilesStatus + return self.model_data.predictionTilesStatus + + @status.setter + def status(self, value: str) -> None: + if self.layer_only: + self.image_layer.footprintTilesStatus = value + else: + self.model_data.predictionTilesStatus = value + + @property + def job(self) -> Optional[TrainingJob]: + if self.layer_only: + return self.image_layer.footprintTilesJob + return self.model_data.predictionTilesJob + + @job.setter + def job(self, value: TrainingJob) -> None: + if self.layer_only: + self.image_layer.footprintTilesJob = value + else: + self.model_data.predictionTilesJob = value + + @property + def status_message(self) -> str: + if self.layer_only: + return self.image_layer.footprintTilesStatusMessage or "" + return self.model_data.predictionTilesStatusMessage or "" + + @status_message.setter + def status_message(self, value: str) -> None: + if self.layer_only: + self.image_layer.footprintTilesStatusMessage = value + else: + self.model_data.predictionTilesStatusMessage = value + def _poll_message(self) -> str: - """Message that brings this model back for another status poll.""" + """Message that brings this job back for another status poll.""" footprints_url = self.image_layer.buildingFootprintsUrl return json.dumps( build_prep_message( - project_id=self.model_data.projectId, + project_id=self.project_id, image_layer_id=self.image_layer.imageLayerId, - model_id=self.model_data.modelId, - source_gpkg_url=self.model_data.gpkgUrl, + model_id=None if self.layer_only else self.model_data.modelId, + source_gpkg_url=( + None if self.layer_only else self.model_data.gpkgUrl + ), source_footprints_url=footprints_url, ) ) - def process(self) -> Model: + def process(self) -> PredictionTilesTarget: """Advance the job state machine by one step. - The caller persists both ``self.model_data`` and - ``self.image_layer``: the footprint tiles belong to the layer, - the attribute sidecar to the model. + Returns: + The document that owns this job's state — the ``Model`` in + model-scoped mode, the ``ImageLayer`` in layer-only mode. In + model-scoped mode the caller persists ``self.image_layer`` + too: the footprint tiles belong to the layer, the attribute + sidecar to the model. """ self.logger.info( - "%s.process: model %s prediction tiles status %s", + "%s.process: %s %s prediction tiles status %s", self.__class__.__name__, - self.model_data.modelId, - self.model_data.predictionTilesStatus, + "image layer" if self.layer_only else "model", + self.target_id, + self.status, ) statuses = self.config.get_status_types() - if self.model_data.predictionTilesStatus == statuses.PENDING.value: + if self.status == statuses.PENDING.value: self._update_progress("Submitting prediction tile job") - self.model_data = self._execute_job() + self._execute_job() - elif ( - self.model_data.predictionTilesStatus == statuses.IN_PROGRESS.value - ): - job = self.model_data.predictionTilesJob + elif self.status == statuses.IN_PROGRESS.value: + job = self.job if job is None: - self.model_data.predictionTilesStatus = statuses.FAILED.value + self.status = statuses.FAILED.value self._update_progress( "Prediction tile job reference is missing; cannot " "poll for completion" ) - return self.model_data + return self.target task_status = self.runner.get_task_status( job_id=job.jobId, task_id=job.taskId ) self.logger.info( - "Task status for prediction tiles of model %s is %s", - self.model_data.modelId, + "Task status for prediction tiles of %s is %s", + self.target_id, task_status, ) @@ -510,16 +644,14 @@ def process(self) -> Model: job.completedDate = MetadataUtils.get_timestamp() try: self._update_results_from_job() - self.model_data.predictionTilesStatus = task_status + self.status = task_status except Exception as error: self.logger.error( - "Error finalizing prediction tiles for model " - f"{self.model_data.modelId}: {error}", + "Error finalizing prediction tiles for " + f"{self.target_id}: {error}", stack_info=True, ) - self.model_data.predictionTilesStatus = ( - statuses.FAILED.value - ) + self.status = statuses.FAILED.value job.status = statuses.FAILED.value self._update_progress( f"Prediction tile job failed: {error}" @@ -528,21 +660,21 @@ def process(self) -> Model: self.runner.cleanup_task(job_id=job.jobId, task_id=job.taskId) elif task_status == statuses.FAILED.value: - self.model_data.predictionTilesStatus = task_status + self.status = task_status job.status = task_status job.completedDate = MetadataUtils.get_timestamp() self._replay_friendly_logs() self._update_progress("Prediction tile job failed") self.runner.cleanup_task(job_id=job.jobId, task_id=job.taskId) else: - self.model_data.predictionTilesStatus = task_status + self.status = task_status job.status = task_status self.queue_client.put_message(self._poll_message()) - return self.model_data + return self.target # ── submission ──────────────────────────────────────────────────── - def _execute_job(self) -> Model: + def _execute_job(self) -> PredictionTilesTarget: statuses = self.config.get_status_types() try: input_files = self._create_job_config() @@ -560,8 +692,7 @@ def _execute_job(self) -> Model: f"{PREDICTION_TILES_PREFIX}-{MetadataUtils.generate_id()}" ) output_prefix = ( - f"{MetadataUtils.hash_string(self.model_data.projectId)}" - f"/{task_id}" + f"{MetadataUtils.hash_string(self.project_id)}" f"/{task_id}" ) job_id, task_id = self.runner.add_task( job_id=job_id, @@ -574,72 +705,88 @@ def _execute_job(self) -> Model: "docker_image" ], ) - self.model_data.predictionTilesJob = TrainingJob( + self.job = TrainingJob( jobId=job_id, taskId=task_id, - modelId=self.model_data.modelId, - projectId=self.model_data.projectId, + modelId=None if self.layer_only else self.model_data.modelId, + projectId=self.project_id, status=statuses.IN_PROGRESS.value, creationDate=MetadataUtils.get_timestamp(), ) - self.model_data.predictionTilesStatus = statuses.IN_PROGRESS.value + self.status = statuses.IN_PROGRESS.value self._update_progress( f"Prediction tiles submitted with task id {task_id}" ) self.queue_client.put_message(self._poll_message()) except Exception as error: self.logger.error( - "Error submitting prediction tiles for model " - f"{self.model_data.modelId}: {error}", + "Error submitting prediction tiles for " + f"{self.target_id}: {error}", stack_info=True, ) - self.model_data.predictionTilesStatus = statuses.FAILED.value + self.status = statuses.FAILED.value self._update_progress(f"Prediction tile job failed: {error}") - return self.model_data + return self.target def _create_job_config(self) -> Dict[str, Dict[str, str]]: - """Write the workflow config and describe the task input files.""" + """Write the workflow config and describe the task input files. + + In layer-only mode neither the prediction GeoPackage nor the + sidecar is referenced: the task tiles the footprints and stops. + """ filename_pattern = ( - rf"{MetadataUtils.hash_string(self.model_data.projectId)}/(.*)\?+" + rf"{MetadataUtils.hash_string(self.project_id)}/(.*)\?+" ) plain_url_pattern = r"(.*)\?+" footprints_url = self.image_layer.buildingFootprintsUrl - predictions_url = self.model_data.gpkgUrl if not footprints_url: raise ValueError("Image layer has no building footprints.") - if not predictions_url: - raise ValueError("Model has no prediction GeoPackage.") - footprints_fn = ( f"inputs/{extract_from_url(footprints_url, filename_pattern)}" ) - predictions_fn = ( - f"inputs/{extract_from_url(predictions_url, filename_pattern)}" - ) - - needs_pmtiles, _ = needs_preparation(self.model_data, self.image_layer) - pmtiles_name = pmtiles_artifact_name(self.model_data.imageLayerId) - attrs_name = attrs_artifact_name(self.model_data.modelId) + image_layer_id = self.image_layer.imageLayerId + pmtiles_name = pmtiles_artifact_name(image_layer_id) + files: Dict[str, str] = { + "footprints": footprints_fn, + "pmtiles": pmtiles_name, + } workflow_config: Dict[str, Any] = { - "project_id": self.model_data.projectId, - "image_layer_id": self.model_data.imageLayerId, - "model_id": self.model_data.modelId, + "project_id": self.project_id, + "image_layer_id": image_layer_id, "output_dir": "outputs", # Relative to the task working dir: the command cd's into # $AZ_BATCH_TASK_WORKING_DIR before running the workflow. - "files": { - "footprints": footprints_fn, - "predictions": predictions_fn, - "pmtiles": pmtiles_name, - "attrs": attrs_name, - }, - "tiles": {"build_pmtiles": needs_pmtiles}, + "files": files, "store_artifacts": True, } + + predictions_url = None + predictions_fn = "" + if self.layer_only: + # Nothing to reuse and nothing to join: the whole point of + # this job is to produce the layer's archive. + workflow_config["tiles"] = {"build_pmtiles": True} + config_identifier = image_layer_id + else: + predictions_url = self.model_data.gpkgUrl + if not predictions_url: + raise ValueError("Model has no prediction GeoPackage.") + predictions_fn = ( + f"inputs/{extract_from_url(predictions_url, filename_pattern)}" + ) + needs_pmtiles, _ = needs_preparation( + self.model_data, self.image_layer + ) + files["predictions"] = predictions_fn + files["attrs"] = attrs_artifact_name(self.model_data.modelId) + workflow_config["model_id"] = self.model_data.modelId + workflow_config["tiles"] = {"build_pmtiles": needs_pmtiles} + config_identifier = self.model_data.modelId + self.storage.save( - identifier=self.model_data.modelId, + identifier=config_identifier, data=workflow_config, data_type=( self.config.get_metadata_types().PREDICTION_TILES_CONFIG.value @@ -647,7 +794,7 @@ def _create_job_config(self) -> Dict[str, Dict[str, str]]: data_format="json", ) config_filepath = self.storage.get_file_remote_path( - self.model_data.modelId, + config_identifier, self.config.get_metadata_types().PREDICTION_TILES_CONFIG.value, data_format="json", ) @@ -655,7 +802,7 @@ def _create_job_config(self) -> Dict[str, Dict[str, str]]: f"inputs/{extract_from_url(config_filepath, filename_pattern)}" ) - return { + input_files: Dict[str, Dict[str, str]] = { "config": { "http_url": extract_from_url( config_filepath, plain_url_pattern @@ -668,18 +815,20 @@ def _create_job_config(self) -> Dict[str, Dict[str, str]]: ), "file_path": footprints_fn, }, - "predictions": { + } + if predictions_url: + input_files["predictions"] = { "http_url": extract_from_url( predictions_url, plain_url_pattern ), "file_path": predictions_fn, - }, - } + } + return input_files # ── finalization ────────────────────────────────────────────────── def _update_results_from_job(self) -> None: """Persist artifact URLs and counts from the task manifest.""" - job = self.model_data.predictionTilesJob + job = self.job content = self.runner.get_filecontent_from_task( job_id=job.jobId, task_id=job.taskId, @@ -687,21 +836,10 @@ def _update_results_from_job(self) -> None: ) if not content: raise FileNotFoundError( - "Prediction tiles manifest not found for model " - f"{self.model_data.modelId}" + "Prediction tiles manifest not found for " f"{self.target_id}" ) manifest = json.loads(content) - attrs_url = manifest.get("attrs_url") or self._artifact_url( - manifest.get("attrs_filename", "") - ) - if not attrs_url: - raise ValueError( - "Prediction tiles manifest carries no attribute sidecar " - f"for model {self.model_data.modelId}" - ) - self.model_data.predictionAttrsUrl = attrs_url - if manifest.get("pmtiles_built"): pmtiles_url = manifest.get("pmtiles_url") or self._artifact_url( manifest.get("pmtiles_filename", "") @@ -710,10 +848,34 @@ def _update_results_from_job(self) -> None: raise ValueError( "Prediction tiles manifest reports tiles were built " "but carries no PMTiles URL for image layer " - f"{self.model_data.imageLayerId}" + f"{self.image_layer.imageLayerId}" ) self.image_layer.footprintPmtilesUrl = pmtiles_url + if self.layer_only: + # No sidecar, no model document: the layer's tiles are the + # entire deliverable, so a manifest without them is a failure. + if not self.image_layer.footprintPmtilesUrl: + raise ValueError( + "Layer-only prediction tile job produced no PMTiles " + f"for image layer {self.image_layer.imageLayerId}" + ) + self._update_progress( + "Prepared footprint tiles for " + f"{int(manifest.get('building_count', 0))} buildings" + ) + return + + attrs_url = manifest.get("attrs_url") or self._artifact_url( + manifest.get("attrs_filename", "") + ) + if not attrs_url: + raise ValueError( + "Prediction tiles manifest carries no attribute sidecar " + f"for model {self.model_data.modelId}" + ) + self.model_data.predictionAttrsUrl = attrs_url + self.model_data.predictedBuildingCount = int( manifest.get("building_count", 0) ) @@ -729,19 +891,17 @@ def _artifact_url(self, filename: str) -> str: return "" return self.storage.get_file_remote_path( identifier=filename, - extra_partition_keys=f"{self.model_data.predictionTilesJob.taskId}", + extra_partition_keys=f"{self.job.taskId}", data_format=os.path.splitext(filename)[1].strip("."), ) def _replay_friendly_logs(self) -> None: for timestamp, message in self._get_friendly_logs(): - if message not in ( - self.model_data.predictionTilesStatusMessage or "" - ): + if message not in self.status_message: self._update_progress(message, timestamp=timestamp) def _get_friendly_logs(self) -> List[Tuple[str, str]]: - job = self.model_data.predictionTilesJob + job = self.job content = self.runner.get_filecontent_from_task( job_id=job.jobId, task_id=job.taskId, @@ -760,10 +920,8 @@ def _get_friendly_logs(self) -> List[Tuple[str, str]]: def _update_progress( self, message: str, timestamp: Optional[str] = None ) -> None: - self.model_data.predictionTilesStatusMessage = ( - MetadataUtils.append_status_message( - self.model_data.predictionTilesStatusMessage, - message, - timestamp=timestamp, - ) + self.status_message = MetadataUtils.append_status_message( + self.status_message, + message, + timestamp=timestamp, ) diff --git a/hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py b/hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py index 5f153a36..0bd44413 100644 --- a/hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py +++ b/hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py @@ -17,6 +17,16 @@ values, fetched once per editing session and indexed by the same integer id that is baked into the tiles. +Two modes, selected by the presence of ``model_id`` in the config: + +* **model-scoped** (``model_id`` set): build the sidecar, and the + PMTiles too unless ``tiles.build_pmtiles`` says the layer already has + them. +* **layer-only** (no ``model_id``): build the footprint PMTiles alone + and skip the sidecar entirely. Imagery prep requests this as soon as + a layer's footprints are cached, so the tiles exist before any model + is trained — there are no predictions to join yet. + CRITICAL — row-order invariant: Predictions join to the layer's ``buildingFootprintsUrl`` GeoPackage **by row index** (``hastegeo.core.utils.assessment``). Both artifacts @@ -515,21 +525,35 @@ def run(config: Dict[str, Any], output_dir: str) -> Dict[str, Any]: Args: config: Parsed workflow config (see the module docstring of ``hastegeo.core.processors.prediction_tiles`` for the shape - the processor writes). + the processor writes). Omitting ``model_id`` selects + layer-only mode: footprint PMTiles, no sidecar. output_dir: Directory the artifacts are written to. The runner uploads everything in here after the task completes. Returns: The manifest dict, which is also written to ``output_dir``. + + Raises: + ValueError: when the identifiers are missing, or when a + layer-only config asks for no tiles (nothing to do). + FileNotFoundError: when a declared input file is absent. """ files: Dict[str, Any] = config.get("files", {}) tiles_config: Dict[str, Any] = config.get("tiles", {}) project_id = config.get("project_id") image_layer_id = config.get("image_layer_id") - model_id = config.get("model_id") - if not project_id or not image_layer_id or not model_id: + # No model -> layer-only: build the shared footprint tiles only. + model_id = config.get("model_id") or None + if not project_id or not image_layer_id: + raise ValueError("Config must set project_id and image_layer_id.") + + build_pmtiles = bool(tiles_config.get("build_pmtiles", True)) + if model_id is None and not build_pmtiles: raise ValueError( - "Config must set project_id, image_layer_id and model_id." + "Config has no model_id and does not ask for PMTiles, so " + "there is nothing to build. Set model_id to build the " + "prediction attribute sidecar, or tiles.build_pmtiles to " + "build the layer's footprint tiles." ) footprints_path = files.get("footprints") @@ -538,7 +562,9 @@ def run(config: Dict[str, Any], output_dir: str) -> Dict[str, Any]: raise FileNotFoundError( f"Building footprints not found: {footprints_path}" ) - if not predictions_path or not os.path.exists(predictions_path): + if model_id is not None and ( + not predictions_path or not os.path.exists(predictions_path) + ): raise FileNotFoundError( f"Prediction GeoPackage not found: {predictions_path}" ) @@ -546,15 +572,18 @@ def run(config: Dict[str, Any], output_dir: str) -> Dict[str, Any]: pmtiles_name = os.path.basename( files.get("pmtiles") or default_pmtiles_name(image_layer_id) ) - attrs_name = os.path.basename( - files.get("attrs") or default_attrs_name(model_id) + attrs_name = ( + "" + if model_id is None + else os.path.basename( + files.get("attrs") or default_attrs_name(model_id) + ) ) - build_pmtiles = bool(tiles_config.get("build_pmtiles", True)) manifest: Dict[str, Any] = { "project_id": project_id, "image_layer_id": image_layer_id, - "model_id": model_id, + "model_id": model_id or "", "pmtiles_filename": "", "pmtiles_built": False, "pmtiles_url": None, @@ -570,7 +599,7 @@ def run(config: Dict[str, Any], output_dir: str) -> Dict[str, Any]: if build_pmtiles: log_progress("Building footprint vector tiles") pmtiles_path = os.path.join(output_dir, pmtiles_name) - build_footprint_pmtiles( + tiled_count = build_footprint_pmtiles( footprints_path, pmtiles_path, minimum_zoom=int( @@ -583,27 +612,36 @@ def run(config: Dict[str, Any], output_dir: str) -> Dict[str, Any]: ) manifest["pmtiles_filename"] = pmtiles_name manifest["pmtiles_built"] = True + # Without a sidecar this is the only building count available; + # the model-scoped branch overwrites it with the sidecar's own. + manifest["building_count"] = int(tiled_count) to_store[pmtiles_name] = pmtiles_path else: log_progress("Reusing existing footprint vector tiles") - log_progress("Building prediction attributes") - attrs_path = os.path.join(output_dir, attrs_name) - payload = write_prediction_attrs( - predictions_path, footprints_path, attrs_path - ) - predictions = read_predictions(predictions_path) - manifest["building_count"] = int(payload["n"]) - manifest["prediction_flavor"] = predictions.flavor - manifest["supports_threshold"] = bool(predictions.supports_threshold) - to_store[attrs_name] = attrs_path + if model_id is not None: + log_progress("Building prediction attributes") + attrs_path = os.path.join(output_dir, attrs_name) + payload = write_prediction_attrs( + predictions_path, footprints_path, attrs_path + ) + predictions = read_predictions(predictions_path) + manifest["building_count"] = int(payload["n"]) + manifest["prediction_flavor"] = predictions.flavor + manifest["supports_threshold"] = bool(predictions.supports_threshold) + to_store[attrs_name] = attrs_path + else: + log_progress("No model requested; skipping prediction attributes") if config.get("store_artifacts", True): haste_config = Config() if artifact_storage_available(haste_config): urls = store_artifacts(project_id, to_store, config=haste_config) manifest["pmtiles_url"] = urls.get(pmtiles_name) - manifest["attrs_url"] = urls.get(attrs_name) + # Empty in layer-only mode: no sidecar was built or stored. + manifest["attrs_url"] = ( + urls.get(attrs_name) if attrs_name else None + ) else: # Not an error: on Azure Batch the runner uploads outputs/ # and the postprocessor resolves the URLs from the task's diff --git a/hastelib/tests/core/processors/test_imagery_footprint_tiles.py b/hastelib/tests/core/processors/test_imagery_footprint_tiles.py new file mode 100644 index 00000000..a731863e --- /dev/null +++ b/hastelib/tests/core/processors/test_imagery_footprint_tiles.py @@ -0,0 +1,226 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Unit tests for footprint-tile scheduling at image-layer creation. + +Building footprints are cached by the imageryprep workflow; the vector +tiles the prediction editor renders are built from them by a queued job +in the *training* image (the only one carrying tippecanoe). Kicking that +job off as soon as the layer completes means the editor never has to +wait for tiling later. + +The rule this pins down: + +* enqueue exactly once when the layer completes with footprints and no + tiles yet; +* never enqueue when the tiles already exist, when there are no + footprints, or when the footprint step reported an error; +* a queue failure is logged and swallowed — tiling is an optimisation, + and the editor's own prep path rebuilds tiles on demand. + +Runner, storage and queue are mocked; nothing here touches Azure. +""" + +import json +import unittest +from unittest.mock import patch + +from hastegeo.core.config import Config +from hastegeo.core.models.projects import ImageLayer, ImageryPreprocessJob + +STATUSES = Config.get_status_types() +FOOTPRINTS_FN = "building_footprints_proj-1_layer-9.gpkg" +FOOTPRINTS_URL = f"https://acct.blob/c/hash/img-123/{FOOTPRINTS_FN}?sas" + + +def _manifest(**overrides) -> dict: + manifest = { + "preview_pre_event_filenames": [], + "preview_post_event_filenames": [], + "pre_event_mosaic_filename": "", + "pre_event_processed_filename": "", + "post_event_mosaic_filename": "", + "post_event_processed_filename": "", + "normalization_means": [1.0], + "normalization_stds": [2.0], + "building_footprints_filename": FOOTPRINTS_FN, + "building_footprints_error": "", + "valid_area_mask_filename": "", + "valid_area_mask_error": "", + } + manifest.update(overrides) + return manifest + + +def _layer(**overrides) -> ImageLayer: + data = { + "imageLayerId": "layer-9", + "projectId": "proj-1", + "status": STATUSES.IN_PROGRESS.value, + "preEventImageryUrls": ["https://example/pre.tif"], + "postEventImageryUrls": ["https://example/post.tif"], + "sourceTypePreEvent": "url", + "sourceTypePostEvent": "url", + "totalSteps": 4, + "preprocessJob": ImageryPreprocessJob( + jobId="job-1", + taskId="img-123", + imageLayerId="layer-9", + projectId="proj-1", + status=STATUSES.IN_PROGRESS.value, + ), + } + data.update(overrides) + return ImageLayer(**data) + + +def _build_processor(image_data: ImageLayer): + with patch( + "hastegeo.core.processors.imagery.UnifiedDataLayer", autospec=True + ), patch( + "hastegeo.core.processors.imagery.UnifiedRunner", autospec=True + ), patch( + "hastegeo.core.processors.imagery.AzureQueueHandler", autospec=True + ): + from hastegeo.core.processors.imagery import ImageryPostProcessor + + processor = ImageryPostProcessor(image_data=image_data) + + processor.runner.get_task_status.return_value = STATUSES.COMPLETED.value + processor.storage.get_file_remote_path.return_value = FOOTPRINTS_URL + return processor + + +def _complete(processor, manifest: dict): + """Run process() through the COMPLETED branch with ``manifest``.""" + + def _file_content(job_id, task_id, filename): + if filename.endswith(".json"): + return json.dumps(manifest) + return "" + + processor.runner.get_filecontent_from_task.side_effect = _file_content + with patch( + "hastegeo.core.processors.imagery.enqueue_prediction_tiles", + autospec=True, + ) as enqueue: + output = processor.process() + return output, enqueue + + +class TestFootprintTilesAreQueuedAtLayerCreation(unittest.TestCase): + def test_enqueued_once_when_footprints_exist_and_tiles_do_not(self): + processor = _build_processor(_layer()) + + output, enqueue = _complete(processor, _manifest()) + + self.assertEqual(output.status, STATUSES.COMPLETED.value) + self.assertEqual(output.buildingFootprintsUrl, FOOTPRINTS_URL) + enqueue.assert_called_once() + kwargs = enqueue.call_args.kwargs + self.assertEqual(kwargs["project_id"], "proj-1") + self.assertEqual(kwargs["image_layer_id"], "layer-9") + self.assertEqual(kwargs["source_footprints_url"], FOOTPRINTS_URL) + # No model exists yet: this is a layer-only request. + self.assertNotIn("model_id", kwargs) + self.assertNotIn("source_gpkg_url", kwargs) + self.assertIs(kwargs["config"], processor.config) + + def test_not_enqueued_when_the_layer_already_has_tiles(self): + layer = _layer(footprintPmtilesUrl="https://acct/tiles.pmtiles") + processor = _build_processor(layer) + + _, enqueue = _complete(processor, _manifest()) + + enqueue.assert_not_called() + + def test_not_enqueued_when_the_footprint_step_failed(self): + processor = _build_processor(_layer()) + + output, enqueue = _complete( + processor, + _manifest( + building_footprints_filename="", + building_footprints_error="Overture download failed", + ), + ) + + enqueue.assert_not_called() + self.assertEqual(output.status, STATUSES.FAILED.value) + + def test_not_enqueued_without_footprints(self): + processor = _build_processor(_layer()) + + _, enqueue = _complete( + processor, _manifest(building_footprints_filename="") + ) + + enqueue.assert_not_called() + + def test_not_enqueued_when_the_preprocess_task_failed(self): + processor = _build_processor(_layer()) + processor.runner.get_task_status.return_value = STATUSES.FAILED.value + processor.runner.get_filecontent_from_task.return_value = "" + + with patch( + "hastegeo.core.processors.imagery.enqueue_prediction_tiles", + autospec=True, + ) as enqueue: + output = processor.process() + + enqueue.assert_not_called() + self.assertEqual(output.status, STATUSES.FAILED.value) + + def test_enqueue_failure_does_not_fail_imagery_prep(self): + """Tiles are an optimisation; the layer must still complete.""" + processor = _build_processor(_layer()) + + def _file_content(job_id, task_id, filename): + if filename.endswith(".json"): + return json.dumps(_manifest()) + return "" + + processor.runner.get_filecontent_from_task.side_effect = _file_content + with patch( + "hastegeo.core.processors.imagery.enqueue_prediction_tiles", + autospec=True, + side_effect=RuntimeError("queue unreachable"), + ) as enqueue: + output = processor.process() + + enqueue.assert_called_once() + self.assertEqual(output.status, STATUSES.COMPLETED.value) + self.assertEqual(output.buildingFootprintsUrl, FOOTPRINTS_URL) + # The task is still cleaned up: the failure is fully contained. + processor.runner.cleanup_task.assert_called_once() + + +class TestEnqueueGuard(unittest.TestCase): + """Direct tests of the guard, independent of the state machine.""" + + def _enqueue(self, layer: ImageLayer): + processor = _build_processor(layer) + with patch( + "hastegeo.core.processors.imagery.enqueue_prediction_tiles", + autospec=True, + ) as enqueue: + processor._enqueue_footprint_tiles() + return enqueue + + def test_queues_for_a_layer_with_fresh_footprints(self): + layer = _layer(buildingFootprintsUrl=FOOTPRINTS_URL) + self._enqueue(layer).assert_called_once() + + def test_skips_a_layer_without_footprints(self): + self._enqueue(_layer()).assert_not_called() + + def test_skips_a_layer_that_is_already_tiled(self): + layer = _layer( + buildingFootprintsUrl=FOOTPRINTS_URL, + footprintPmtilesUrl="https://acct/tiles.pmtiles", + ) + self._enqueue(layer).assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/hastelib/tests/core/processors/test_prediction_tiles_layer.py b/hastelib/tests/core/processors/test_prediction_tiles_layer.py new file mode 100644 index 00000000..4f48e3b5 --- /dev/null +++ b/hastelib/tests/core/processors/test_prediction_tiles_layer.py @@ -0,0 +1,349 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Unit tests for layer-only (model-less) prediction-tile preparation. + +Footprint PMTiles are shared by every model on an image layer, so they +are built once — at layer-creation time, straight after imagery prep +caches the building footprints, when no model exists yet. In that mode +the job must build the tiles, skip the attribute sidecar entirely, and +keep all of its state on the ``ImageLayer``: there is no model document +to write to. + +Storage, runner and queue are mocked; tippecanoe is never invoked here +(it only exists inside the training container the job is submitted to). +""" + +import json +import unittest +from unittest.mock import patch + +from hastegeo.core.config import Config +from hastegeo.core.models.projects import ImageLayer, Model, TrainingJob + +STATUSES = Config.get_status_types() +FOOTPRINTS_URL = "https://acct.blob/c/hash/building_footprints_p_l.gpkg?sas" + + +def _layer(**overrides) -> ImageLayer: + data = { + "imageLayerId": "layer-1", + "projectId": "proj-1", + "buildingFootprintsUrl": FOOTPRINTS_URL, + } + data.update(overrides) + return ImageLayer(**data) + + +def _build_postprocessor(layer: ImageLayer): + """Layer-only postprocessor: no model, mocked Azure dependencies.""" + with patch( + "hastegeo.core.processors.prediction_tiles.UnifiedDataLayer", + autospec=True, + ), patch( + "hastegeo.core.processors.prediction_tiles.UnifiedRunner", + autospec=True, + ), patch( + "hastegeo.core.processors.prediction_tiles.AzureQueueHandler", + autospec=True, + ): + from hastegeo.core.processors.prediction_tiles import ( + PredictionTilesPostprocessor, + ) + + return PredictionTilesPostprocessor(None, layer) + + +class TestLayerNeedsFootprintTiles(unittest.TestCase): + def test_needed_once_footprints_exist(self): + from hastegeo.core.processors.prediction_tiles import ( + layer_needs_footprint_tiles, + ) + + self.assertTrue(layer_needs_footprint_tiles(_layer())) + + def test_not_needed_without_footprints(self): + from hastegeo.core.processors.prediction_tiles import ( + layer_needs_footprint_tiles, + ) + + layer = _layer(buildingFootprintsUrl=None) + self.assertFalse(layer_needs_footprint_tiles(layer)) + + def test_not_needed_when_tiles_already_exist(self): + from hastegeo.core.processors.prediction_tiles import ( + layer_needs_footprint_tiles, + ) + + layer = _layer(footprintPmtilesUrl="https://acct/tiles.pmtiles") + self.assertFalse(layer_needs_footprint_tiles(layer)) + + +class TestLayerOnlyEnqueue(unittest.TestCase): + def test_message_omits_the_model(self): + from hastegeo.core.processors import prediction_tiles + + with patch.object( + prediction_tiles, "AzureQueueHandler", autospec=True + ) as handler: + message = prediction_tiles.enqueue_prediction_tiles( + project_id="proj-1", + image_layer_id="layer-1", + source_footprints_url=FOOTPRINTS_URL, + ) + + queue_name = Config().queue_config["prediction_edit_prep_queue_name"] + self.assertEqual(handler.call_args.args[1], queue_name) + handler.return_value.put_message.assert_called_once() + # Same documented schema; an empty modelId selects layer-only. + self.assertEqual( + set(message), + { + "projectId", + "imageLayerId", + "modelId", + "sourceGpkgUrl", + "sourceFootprintsUrl", + "force", + }, + ) + self.assertEqual(message["modelId"], "") + self.assertEqual(message["sourceGpkgUrl"], "") + self.assertEqual(message["imageLayerId"], "layer-1") + self.assertEqual(message["sourceFootprintsUrl"], FOOTPRINTS_URL) + + +class TestLayerOnlySubmission(unittest.TestCase): + def _submit(self, layer: ImageLayer): + processor = _build_postprocessor(layer) + processor.runner.add_task.return_value = ("job-1", "ptl-abc") + processor.storage.get_file_remote_path.return_value = ( + "https://acct.blob/c/hash/prediction_tiles_config_l.json?sas" + ) + return processor, processor.process() + + def test_state_lives_on_the_image_layer(self): + layer = _layer(footprintTilesStatus=STATUSES.PENDING.value) + processor, output = self._submit(layer) + + # The layer is the returned document — there is no model at all. + self.assertIs(output, layer) + self.assertIsNone(processor.model_data) + self.assertEqual( + output.footprintTilesStatus, STATUSES.IN_PROGRESS.value + ) + self.assertEqual(output.footprintTilesJob.taskId, "ptl-abc") + self.assertIsNone(output.footprintTilesJob.modelId) + self.assertIn("ptl-abc", output.footprintTilesStatusMessage) + + def test_workflow_config_skips_the_sidecar(self): + layer = _layer(footprintTilesStatus=STATUSES.PENDING.value) + processor, _ = self._submit(layer) + + workflow_config = processor.storage.save.call_args.kwargs["data"] + self.assertNotIn("model_id", workflow_config) + self.assertTrue(workflow_config["tiles"]["build_pmtiles"]) + self.assertEqual( + workflow_config["files"]["pmtiles"], "footprints_layer-1.pmtiles" + ) + self.assertNotIn("attrs", workflow_config["files"]) + self.assertNotIn("predictions", workflow_config["files"]) + # The config is stored under the layer, not under some model. + self.assertEqual( + processor.storage.save.call_args.kwargs["identifier"], "layer-1" + ) + + def test_only_the_footprints_are_staged_for_the_task(self): + layer = _layer(footprintTilesStatus=STATUSES.PENDING.value) + processor, _ = self._submit(layer) + + kwargs = processor.runner.add_task.call_args.kwargs + resource_files = kwargs["resource_files_for_upload"] + self.assertEqual(set(resource_files), {"config", "footprints"}) + self.assertIn( + "python -m hastegeo.workflows.prepare_prediction_tiles", + kwargs["command"], + ) + # tippecanoe only ships in the training image. + self.assertEqual( + kwargs["image_name"], + Config().get_azure_batch_config()["docker_image"], + ) + + def test_poll_message_stays_layer_only(self): + layer = _layer(footprintTilesStatus=STATUSES.PENDING.value) + processor, _ = self._submit(layer) + + payload = json.loads( + processor.queue_client.put_message.call_args.args[0] + ) + self.assertEqual(payload["modelId"], "") + self.assertEqual(payload["sourceGpkgUrl"], "") + self.assertEqual(payload["imageLayerId"], "layer-1") + + def test_submission_failure_marks_the_layer_failed(self): + layer = _layer(footprintTilesStatus=STATUSES.PENDING.value) + processor = _build_postprocessor(layer) + processor.storage.save.side_effect = RuntimeError("storage down") + + output = processor.process() + + self.assertEqual(output.footprintTilesStatus, STATUSES.FAILED.value) + self.assertIn("failed", output.footprintTilesStatusMessage.lower()) + + def test_missing_footprints_fail_the_job(self): + layer = _layer( + buildingFootprintsUrl=None, + footprintTilesStatus=STATUSES.PENDING.value, + ) + processor = _build_postprocessor(layer) + + output = processor.process() + + self.assertEqual(output.footprintTilesStatus, STATUSES.FAILED.value) + processor.runner.add_task.assert_not_called() + + +class TestLayerOnlyCompletion(unittest.TestCase): + def _completed_processor(self, manifest: dict): + layer = _layer( + footprintTilesStatus=STATUSES.IN_PROGRESS.value, + footprintTilesJob=TrainingJob( + jobId="job-1", + taskId="ptl-abc", + projectId="proj-1", + status=STATUSES.IN_PROGRESS.value, + ), + ) + processor = _build_postprocessor(layer) + processor.runner.get_task_status.return_value = ( + STATUSES.COMPLETED.value + ) + + def _file_content(job_id, task_id, filename): + if filename.endswith(".json"): + return json.dumps(manifest) + return "2026-08-21T00:00:00+00:00|Building footprint vector tiles" + + processor.runner.get_filecontent_from_task.side_effect = _file_content + return processor + + def test_tiles_url_lands_on_the_layer(self): + processor = self._completed_processor( + { + "model_id": "", + "pmtiles_built": True, + "pmtiles_filename": "footprints_layer-1.pmtiles", + "pmtiles_url": "https://acct/footprints_layer-1.pmtiles", + "attrs_filename": "", + "attrs_url": None, + "building_count": 4242, + } + ) + + output = processor.process() + + self.assertEqual(output.footprintTilesStatus, STATUSES.COMPLETED.value) + self.assertEqual( + output.footprintPmtilesUrl, + "https://acct/footprints_layer-1.pmtiles", + ) + self.assertIn("4242 buildings", output.footprintTilesStatusMessage) + processor.runner.cleanup_task.assert_called_once() + + def test_no_model_document_is_touched(self): + """A missing sidecar is expected here, not a failure.""" + processor = self._completed_processor( + { + "model_id": "", + "pmtiles_built": True, + "pmtiles_filename": "footprints_layer-1.pmtiles", + "pmtiles_url": "https://acct/footprints_layer-1.pmtiles", + "attrs_filename": "", + "attrs_url": None, + "building_count": 3, + } + ) + + output = processor.process() + + self.assertIsNone(processor.model_data) + self.assertIsInstance(output, ImageLayer) + self.assertNotIsInstance(output, Model) + + def test_url_is_resolved_from_task_outputs_when_absent(self): + processor = self._completed_processor( + { + "pmtiles_built": True, + "pmtiles_filename": "footprints_layer-1.pmtiles", + "pmtiles_url": None, + "attrs_filename": "", + "building_count": 9, + } + ) + processor.storage.get_file_remote_path.return_value = ( + "https://acct/hash/ptl-abc/footprints_layer-1.pmtiles?sas" + ) + + output = processor.process() + + self.assertEqual( + output.footprintPmtilesUrl, + "https://acct/hash/ptl-abc/footprints_layer-1.pmtiles?sas", + ) + kwargs = processor.storage.get_file_remote_path.call_args.kwargs + self.assertEqual(kwargs["extra_partition_keys"], "ptl-abc") + + def test_a_manifest_without_tiles_fails_the_job(self): + """Tiles are the whole deliverable in layer-only mode.""" + processor = self._completed_processor( + { + "pmtiles_built": False, + "pmtiles_filename": "", + "pmtiles_url": None, + "attrs_filename": "", + "building_count": 0, + } + ) + + output = processor.process() + + self.assertEqual(output.footprintTilesStatus, STATUSES.FAILED.value) + self.assertIsNone(output.footprintPmtilesUrl) + + def test_running_task_is_requeued(self): + processor = self._completed_processor({}) + processor.runner.get_task_status.return_value = ( + STATUSES.IN_PROGRESS.value + ) + + output = processor.process() + + self.assertEqual( + output.footprintTilesStatus, STATUSES.IN_PROGRESS.value + ) + processor.queue_client.put_message.assert_called_once() + processor.runner.cleanup_task.assert_not_called() + + def test_failed_task_is_reported_on_the_layer(self): + processor = self._completed_processor({}) + processor.runner.get_task_status.return_value = STATUSES.FAILED.value + + output = processor.process() + + self.assertEqual(output.footprintTilesStatus, STATUSES.FAILED.value) + self.assertIn("failed", output.footprintTilesStatusMessage.lower()) + processor.runner.cleanup_task.assert_called_once() + + def test_missing_job_reference_fails_cleanly(self): + layer = _layer(footprintTilesStatus=STATUSES.IN_PROGRESS.value) + processor = _build_postprocessor(layer) + + output = processor.process() + + self.assertEqual(output.footprintTilesStatus, STATUSES.FAILED.value) + processor.runner.get_task_status.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/hastelib/tests/workflows/test_prepare_prediction_tiles.py b/hastelib/tests/workflows/test_prepare_prediction_tiles.py index 4e9c9232..4d2ca507 100644 --- a/hastelib/tests/workflows/test_prepare_prediction_tiles.py +++ b/hastelib/tests/workflows/test_prepare_prediction_tiles.py @@ -518,6 +518,14 @@ def test_run_skips_tiles_when_layer_already_has_them(self): self.assertEqual(manifest["building_count"], 3) def test_run_requires_identifiers(self): + config = self._config(False) + config.pop("project_id") + with self.assertRaises(ValueError): + ppt.run(config, self.output_dir) + + def test_run_without_a_model_or_tiles_has_nothing_to_do(self): + # Dropping model_id selects layer-only mode, which only makes + # sense when tiles are actually being built. config = self._config(False) config.pop("model_id") with self.assertRaises(ValueError): @@ -532,5 +540,126 @@ def test_run_reports_missing_inputs(self): ppt.run(config, self.output_dir) +class TestLayerOnlyWorkflowRun(unittest.TestCase): + """``run()`` without a ``model_id``: footprint tiles, no sidecar. + + This is the mode imagery prep asks for at layer-creation time. There + are no predictions yet — only the layer's cached footprints — so the + workflow must not go looking for a prediction GeoPackage. + """ + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix="haste-layer-tiles-") + self.addCleanup(shutil.rmtree, self.tmpdir, True) + self.output_dir = os.path.join(self.tmpdir, "outputs") + os.makedirs(self.output_dir) + self.footprints = os.path.join(self.tmpdir, "footprints.gpkg") + write_footprints(self.footprints, 3) + + def _config(self, **overrides) -> dict: + config = { + "project_id": "proj-1", + "image_layer_id": "layer-1", + "files": {"footprints": self.footprints}, + "tiles": {"build_pmtiles": True}, + "store_artifacts": False, + } + config.update(overrides) + return config + + def _run(self, config: dict) -> dict: + def fake_run(cmd, check=False): + # Stand in for tippecanoe: touch the -o target. + with open(cmd[cmd.index("-o") + 1], "wb") as handle: + handle.write(b"PMTiles") + return mock.Mock(returncode=0) + + with mock.patch.object( + ppt.shutil, "which", return_value="/usr/bin/tippecanoe" + ): + with mock.patch.object( + ppt.subprocess, "run", side_effect=fake_run + ): + return ppt.run(config, self.output_dir) + + def test_builds_tiles_and_skips_the_sidecar(self): + manifest = self._run(self._config()) + + self.assertTrue(manifest["pmtiles_built"]) + self.assertEqual( + manifest["pmtiles_filename"], "footprints_layer-1.pmtiles" + ) + self.assertTrue( + os.path.exists( + os.path.join(self.output_dir, manifest["pmtiles_filename"]) + ) + ) + # No model -> no sidecar, and nothing that implies one. + self.assertEqual(manifest["model_id"], "") + self.assertEqual(manifest["attrs_filename"], "") + self.assertIsNone(manifest["attrs_url"]) + self.assertEqual(manifest["prediction_flavor"], "") + self.assertFalse(manifest["supports_threshold"]) + self.assertEqual( + [ + name + for name in os.listdir(self.output_dir) + if name.endswith(".json") + ], + [ppt.MANIFEST_FILENAME], + ) + + def test_building_count_comes_from_the_tiled_footprints(self): + manifest = self._run(self._config()) + + self.assertEqual(manifest["building_count"], 3) + + def test_missing_predictions_are_not_an_error(self): + """The layer has footprints long before any model exists.""" + config = self._config() + config["files"]["predictions"] = os.path.join( + self.tmpdir, "does-not-exist.gpkg" + ) + + manifest = self._run(config) + + self.assertTrue(manifest["pmtiles_built"]) + self.assertEqual(manifest["attrs_filename"], "") + + def test_missing_footprints_still_fail(self): + config = self._config() + config["files"]["footprints"] = os.path.join( + self.tmpdir, "does-not-exist.gpkg" + ) + with self.assertRaises(FileNotFoundError): + ppt.run(config, self.output_dir) + + def test_nothing_to_build_is_rejected(self): + """No model and no tiles requested is a malformed config.""" + config = self._config(tiles={"build_pmtiles": False}) + with self.assertRaises(ValueError): + ppt.run(config, self.output_dir) + + def test_image_layer_id_is_still_required(self): + config = self._config() + config.pop("image_layer_id") + with self.assertRaises(ValueError): + ppt.run(config, self.output_dir) + + def test_manifest_is_written_for_the_postprocessor(self): + manifest = self._run(self._config()) + + with open( + os.path.join(self.output_dir, ppt.MANIFEST_FILENAME) + ) as handle: + self.assertEqual(json.load(handle), manifest) + # The tiling GeoJSON is scratch and must not be uploaded. + self.assertFalse( + os.path.exists( + os.path.join(self.output_dir, "footprints_4326.geojson") + ) + ) + + if __name__ == "__main__": unittest.main() diff --git a/spec/features/prediction-editing/data-model.md b/spec/features/prediction-editing/data-model.md index 7e47d28c..701f0921 100644 --- a/spec/features/prediction-editing/data-model.md +++ b/spec/features/prediction-editing/data-model.md @@ -18,7 +18,7 @@ Model and ImageLayer metadata documents so reads remain local to the project. | Container | Change | Migration Needed? | |---|---|---| | Model metadata | Add `editedPredictions`, `predictedBuildingCount`, `predictedAt`, `predictionAttrsUrl`, `predictionTilesJob`, `predictionTilesStatus`, and `predictionTilesStatusMessage` | no — nullable/defaulted fields are backward-compatible | -| ImageLayer metadata | Add `footprintPmtilesUrl` | no — nullable/defaulted field is backward-compatible | +| ImageLayer metadata | Add `footprintPmtilesUrl`, `footprintTilesJob`, `footprintTilesStatus`, and `footprintTilesStatusMessage` | no — nullable/defaulted fields are backward-compatible | ### New Document Schema @@ -75,7 +75,10 @@ registry in a follow-up ADR. | Model metadata | `predictionTilesJob` | absent | `Optional[TrainingJob]` | Batch/local runner job metadata for the queued prep workflow. | | Model metadata | `predictionTilesStatus` | absent | `Optional[str]` | Prep status using HASTE status values: `Queued`, `InProgress`, `Processed`, `Failed`, `Cancelled`. | | Model metadata | `predictionTilesStatusMessage` | absent | `Optional[str]`, default `""` | User-visible appended progress/failure messages for prep polling. | -| ImageLayer metadata | `footprintPmtilesUrl` | absent | `Optional[str]` | Layer-level PMTiles for all footprints used by prediction editing. | +| ImageLayer metadata | `footprintPmtilesUrl` | absent | `Optional[str]` | Layer-level PMTiles for all footprints used by prediction editing. Normally written by the layer-only tiling job queued at image-layer creation; still written by the model-scoped prep job for layers created before that existed. | +| ImageLayer metadata | `footprintTilesJob` | absent | `Optional[TrainingJob]` | Batch/local runner job metadata for the layer-only tiling job. Separate from `Model.predictionTilesJob` because the job has no model. | +| ImageLayer metadata | `footprintTilesStatus` | absent | `Optional[str]` | Status of that job using HASTE status values. Deliberately not `ImageLayer.status`: tiling is an optimisation and must never affect the imagery-preprocessing lifecycle. | +| ImageLayer metadata | `footprintTilesStatusMessage` | absent | `Optional[str]`, default `""` | Appended progress/failure messages for the layer-only tiling job. | ### Transport-Only Wire Models @@ -264,6 +267,19 @@ UI opens editor → Cosmos ImageLayer.footprintPmtilesUrl + Model.predictionAttrsUrl/predictedBuildingCount/predictedAt/predictionTilesStatus ``` +Layer-time prep write path (no model; runs at image-layer creation so the +tiles already exist by the time anyone opens the editor): + +```text +imageryprep workflow caches building footprints + → hastegeo.core.processors.imagery.ImageryPostProcessor completes the layer + → Queue Storage prediction-edit-prep-queue (message with empty modelId) + → hastefuncqueues + → training image workflow builds PMTiles only (no sidecar) + → Blob Storage footprints_{imageLayerId}.pmtiles + → Cosmos ImageLayer.footprintPmtilesUrl/footprintTilesStatus/footprintTilesJob +``` + ### Read Path ```text diff --git a/spec/features/prediction-editing/design.md b/spec/features/prediction-editing/design.md index a20b6031..6d935507 100644 --- a/spec/features/prediction-editing/design.md +++ b/spec/features/prediction-editing/design.md @@ -320,21 +320,40 @@ length and order as the source prediction GeoPackage rows. { "projectId": "string", "imageLayerId": "string", - "modelId": "string", - "sourceGpkgUrl": "string", + "modelId": "string — empty selects layer-only preparation", + "sourceGpkgUrl": "string — empty in layer-only mode", "sourceFootprintsUrl": "string", "force": false } ``` -**Trigger behavior:** The worker downloads the source footprints and raw -prediction GeoPackage, validates equal row count and positional row order, -writes or refreshes `footprints_${imageLayerId}.pmtiles` when missing, writes -`prediction_attrs_${modelId}` from prediction columns, uploads both artifacts, -and updates `ImageLayer.footprintPmtilesUrl`, `Model.predictionAttrsUrl`, -`Model.predictedBuildingCount`, `Model.predictedAt`, -`Model.predictionTilesJob`, `Model.predictionTilesStatus`, and -`Model.predictionTilesStatusMessage`. +**Trigger behavior (model-scoped, `modelId` set):** The worker downloads the +source footprints and raw prediction GeoPackage, validates equal row count and +positional row order, writes or refreshes `footprints_${imageLayerId}.pmtiles` +when missing, writes `prediction_attrs_${modelId}` from prediction columns, +uploads both artifacts, and updates `ImageLayer.footprintPmtilesUrl`, +`Model.predictionAttrsUrl`, `Model.predictedBuildingCount`, +`Model.predictedAt`, `Model.predictionTilesJob`, +`Model.predictionTilesStatus`, and `Model.predictionTilesStatusMessage`. + +**Trigger behavior (layer-only, `modelId` empty):** The worker downloads the +source footprints only, writes `footprints_${imageLayerId}.pmtiles`, and +updates `ImageLayer.footprintPmtilesUrl`, `ImageLayer.footprintTilesJob`, +`ImageLayer.footprintTilesStatus`, and +`ImageLayer.footprintTilesStatusMessage`. No sidecar is built and no model +document is read or written — there is usually no model yet. Only the tiling +fields of the layer are patched on save, so a concurrent imagery-preprocessing +write is never clobbered. + +`ImageryPostProcessor` enqueues the layer-only message as soon as an image +layer completes with cached building footprints and no +`footprintPmtilesUrl`, so the editor normally finds the tiles already built. +That enqueue is best effort: a queue failure is logged and imagery +preprocessing still succeeds, because `PutPreparePredictionTilesQueueMessage` +rebuilds the tiles on demand (the path layers created before this change take). +Both jobs write the same deterministic artifact name, so an editor opened while +a layer-time job is still running merely repeats the tiling rather than +corrupting anything. Tile creation must run in the queued worker because `tippecanoe` is installed in the training image only (`docker/training/env/env.yml:11`). Existing PMTiles @@ -351,8 +370,11 @@ creation in `embed_buildings.py` is the invocation pattern to mirror | `core/processors/prediction_edits.py` | `apply_edits` | `(src_gpkg: str, dst_gpkg: str, threshold: float, unknown_threshold: float, overrides: dict[int, str], footprints_path: Optional[str]) -> EditSummary` | Applies class derivation, preserves row order, and writes the edited GeoPackage. | | `core/processors/prediction_edits.py` | `derive_class`, `next_version`, `store_edited_version` | helper functions | Compute final class, allocate the next version number, and store `edited_predictions_${modelId}_v${version}.gpkg`. | | `core/processors/prediction_tiles.py` | `needs_preparation`, `request_preparation` | `(model: Model, image_layer: ImageLayer, force: bool = False) -> dict` | Decide whether PMTiles/sidecar artifacts are ready and enqueue at most one prep message for the explicit PUT route. | -| `core/processors/prediction_tiles.py` | `PredictionTilesPostprocessor` | class | Submit, poll, and finalize the queued training-image workflow. | -| `hastegeo/workflows/prepare_prediction_tiles.py` | `run` | `(config: dict, output_dir: str) -> dict` | Builds footprint PMTiles and the prediction attribute JSON sidecar. | +| `core/processors/prediction_tiles.py` | `layer_needs_footprint_tiles` | `(image_layer: ImageLayer) -> bool` | Guard used by imagery prep: tiles are worth queueing only once footprints are cached and while the layer has no archive. | +| `core/processors/prediction_tiles.py` | `enqueue_prediction_tiles` | `(project_id: str, image_layer_id: str, model_id: Optional[str] = None, ...) -> dict` | Put one prep request on the queue. Omitting `model_id` requests the layer's footprint PMTiles alone. | +| `core/processors/prediction_tiles.py` | `PredictionTilesPostprocessor` | `(model: Optional[Model], image_layer: ImageLayer)` | Submit, poll, and finalize the queued training-image workflow. `model=None` runs layer-only and keeps all job state on the `ImageLayer`. | +| `core/processors/imagery.py` | `ImageryPostProcessor._enqueue_footprint_tiles` | `() -> None` | Best-effort layer-only enqueue once a completed layer has footprints and no tiles; never raises into imagery prep. | +| `hastegeo/workflows/prepare_prediction_tiles.py` | `run` | `(config: dict, output_dir: str) -> dict` | Builds footprint PMTiles and, when `config["model_id"]` is set, the prediction attribute JSON sidecar. | | `api/hastefuncapi/function_app.py` | `GetModelArtifact` | HTTP route | Adds `footprint_pmtiles` and `prediction_attrs` kinds. | ## Behavior & Logic @@ -372,7 +394,11 @@ creation in `embed_buildings.py` is the invocation pattern to mirror `PutPreparePredictionTilesQueueMessage`; that route enqueues `prediction-edit-prep-queue` unless artifacts are already ready or a job is already in flight. The screen shows a preparation state and polls the - session endpoint. + session endpoint. `tilesReady` is normally already true: the layer's + footprint PMTiles are built by a layer-only job queued when the image + layer was created, so only the per-model sidecar is usually outstanding. + Layers created before that behaviour existed fall back to this on-demand + path unchanged. 7. Once ready, the UI fetches `footprint_pmtiles` and `prediction_attrs` through `GetModelArtifact`. 8. Azure Maps displays PMTiles footprints. Feature-state coloring is computed diff --git a/spec/features/prediction-editing/plan.md b/spec/features/prediction-editing/plan.md index 94a49962..13ec19b5 100644 --- a/spec/features/prediction-editing/plan.md +++ b/spec/features/prediction-editing/plan.md @@ -41,12 +41,14 @@ queued preparation worker. | Add `workflows/prepare_prediction_tiles.py` prep workflow (footprint PMTiles + attribute sidecar) | `gis` | Phase 1 prediction reader | US-002 | complete | | Add `core/processors/prediction_tiles.py` runner orchestration | `gis` | prep workflow | US-002 | complete | | Add `prediction-edit-prep-queue` trigger in `hastefuncqueues` | `backend-dev`, `gis` | prep workflow | US-002 | complete | +| Build the layer's footprint PMTiles at image-layer creation (layer-only prep mode; `ImageLayer.footprintTiles*` fields; best-effort enqueue from `ImageryPostProcessor`) | `gis` | prep workflow, queue trigger | US-002 | complete | | Add API integration tests for validation, readiness, save, and version-list responses | `backend-dev` | routes | US-002, US-004, US-005 | not-started | | Add `infra/modules/functions.bicep` app-setting parity for the new queue | `backend-dev` | queue config | US-002 | skipped — `Config` has a default and changing Bicep without regenerating `infra/main.json` would create infra drift | **Exit Criteria:** - [x] Endpoints are implemented as Azure Functions routes. - [x] Missing PMTiles/sidecars are generated by the queue worker, not inline in HTTP. +- [x] Footprint PMTiles are built once per image layer at layer-creation time; the on-demand path still covers pre-existing layers. - [x] `PutEditedPredictions` returns `version`, `gpkgUrl`, and `editedCount` for both producer schemas. - [ ] Docker Compose local stack can exercise session prep and save. - [ ] API-level integration tests exist for the new routes. @@ -68,6 +70,7 @@ existing HASTE interaction patterns. | Add one-click edited-version download action in the right panel | `ui` | version history display | US-005 | not-started | | Add shared PMTiles protocol singleton in `ui/src/util/pmtiles.js` and use it from editor screens | `ui` | PMTiles map sources | US-002, US-003 | complete | | Add plain Node unit tests for `predictionClassify.js` and `predictionPrep.js` | `ui` | UI helpers | US-002, US-003, US-004 | complete | +| Add swipe comparison map (pre-vs-post, falling back to basemap-vs-post) with dual-pane editing, mirrored feature-state and `A`/`S`/`D` divider keys | `ui` | Editor map | US-003 | complete | | Add browser/Playwright coverage for gating, threshold visibility, selection, and save flow | `ui-validation` | UI implementation | US-001, US-003, US-005 | not-started — no Playwright config exists | **Exit Criteria:** diff --git a/spec/features/prediction-editing/test-plan.md b/spec/features/prediction-editing/test-plan.md index 486252a6..b854787b 100644 --- a/spec/features/prediction-editing/test-plan.md +++ b/spec/features/prediction-editing/test-plan.md @@ -62,8 +62,11 @@ cases below remain follow-up coverage. | QT-001 | `prediction-edit-prep-queue` | Build missing PMTiles and sidecar | valid project/layer/model/source urls | PMTiles and sidecar blobs uploaded; metadata fields updated | US-002 | | QT-002 | `prediction-edit-prep-queue` | Idempotent no-op | artifacts already exist and `force=false` | No duplicate work; metadata remains consistent | US-002 | | QT-003 | `prediction-edit-prep-queue` | Force rebuild | artifacts exist and `force=true` | Artifacts regenerated and metadata timestamp refreshed | US-002 | -| QT-004 | `prediction-edit-prep-queue` | Malformed message | missing `modelId` | Worker logs validation error and dead-letters/fails without partial metadata | US-002 | +| QT-004 | `prediction-edit-prep-queue` | Malformed message | neither `modelId` nor `imageLayerId` | Worker logs validation error and dead-letters/fails without partial metadata | US-002 | | QT-005 | `prediction-edit-prep-queue` | Row-count mismatch | predictions and footprints lengths differ | Prep fails; no `predictedAt` update | US-002 | +| QT-006 | `prediction-edit-prep-queue` | Layer-only prep | empty `modelId`, layer with footprints | PMTiles blob uploaded; only `ImageLayer.footprintPmtilesUrl`/`footprintTiles*` written; no sidecar and no model document touched | US-002 | +| QT-007 | `prediction-edit-prep-queue` | Layer-only no-op | empty `modelId`, layer already has `footprintPmtilesUrl`, `force=false` | No job submitted; layer marked `Processed` | US-002 | +| QT-008 | imagery prep (`ImageryPostProcessor`) | Layer-time scheduling | layer completes with cached footprints and no tiles | Exactly one layer-only message enqueued; none when tiles exist or the footprint step errored; enqueue failure never fails imagery prep | US-002 | ### UI Component Tests diff --git a/ui/src/Components/PredictionEditor/PredictionEditor.jsx b/ui/src/Components/PredictionEditor/PredictionEditor.jsx index ea0eeabc..afa2cef5 100644 --- a/ui/src/Components/PredictionEditor/PredictionEditor.jsx +++ b/ui/src/Components/PredictionEditor/PredictionEditor.jsx @@ -18,6 +18,15 @@ // PutEditedPredictions, which writes a brand-new version — nothing is // destructive. // +// The optional swipe view (see the swipe effect near the bottom) puts a +// second Azure Maps instance behind this one and hands both to +// atlas.SwipeMap so the analyst can compare imagery while reclassifying: +// pre-event vs post-event when the layer has pre-event tiles, basemap vs +// post-event when it does not. Both panes draw the same footprints, share +// every feature-state write, and accept the same edit gestures — SwipeMap +// clips the editor map, so clicks on the uncovered side land on the other +// map and would otherwise do nothing. +// // Both artifacts are produced by a queued job, so a model nobody has opened // before arrives here unprepared. The editor enqueues that job itself // (PutPreparePredictionTilesQueueMessage) and then polls the session until @@ -40,6 +49,8 @@ import { import { PMTiles } from "pmtiles"; import { FluentIcon } from "../../util/icons"; import { apiGet, apiPut, buildUrl } from "../../util/api"; +import { toBrowserTitilerUrl } from "../../util/blobUrl"; +import { loadImagery } from "../LabelingTool/LabelingToolHelper"; import { getPmtilesProtocol, InMemoryPMTilesSource, @@ -87,6 +98,14 @@ import { prepStatusLabel, shouldPollPrep, } from "./predictionPrep.js"; +import { + dividerPositionForKey, + isSwipeAvailable, + resolveSwipeMode, + swipeComparisonTileUrl, + swipeLeftPaneLabel, + swipeRightPaneLabel, +} from "./predictionSwipe.js"; import "../../assets/css/drawingToolbar.css"; // Tippecanoe writes the buildings layer with `-l buildings`; every feature @@ -96,6 +115,14 @@ const SOURCE_ID = "predictionBuildings"; const FILL_LAYER_ID = "predictionFill"; const LINE_LAYER_ID = "predictionOutline"; +// The swipe comparison map is a second Azure Maps instance with its own +// renderer, so it declares its own copy of the same PMTiles archive. Ids are +// distinct from the editor map's purely for clarity in the debugger — the two +// styles never meet. +const SWIPE_SOURCE_ID = "predictionSwipeBuildings"; +const SWIPE_FILL_LAYER_ID = "predictionSwipeFill"; +const SWIPE_LINE_LAYER_ID = "predictionSwipeOutline"; + // Paint expressions compare numbers, so each class gets a code. const CLASS_CODES = { [CLASS_DAMAGED]: 1, @@ -202,6 +229,48 @@ function findGlMap(atlasMap) { return null; } +// Azure Maps renames our source/layer inside the renderer's style, so the ids +// queryRenderedFeatures needs have to be discovered rather than assumed. Used +// for the editor map and, identically, for the swipe comparison map. +function discoverFillLayerIds(glMap, fallbackLayerIds) { + if (!glMap || typeof glMap.getStyle !== "function") return fallbackLayerIds; + try { + const style = glMap.getStyle(); + const sourceIds = Object.keys(style.sources || {}); + const ours = [...fallbackLayerIds, ...sourceIds]; + const discovered = (style.layers || []) + .filter( + (layer) => + layer.type === "fill" && + (ours.includes(layer.source) || /predict|build/i.test(layer.id)) + ) + .map((layer) => layer.id); + return discovered.length > 0 ? discovered : fallbackLayerIds; + } catch (error) { + console.warn("glMap.getStyle() failed:", error); + return fallbackLayerIds; + } +} + +// The name the renderer gave our vector source, which is the id every +// setFeatureState call has to use. The editor map learns this from its first +// rendered feature; the comparison map has no such feature yet when its +// layers are built, so it reads the style instead. +function discoverVectorSourceId(glMap, preferredId) { + if (!glMap || typeof glMap.getStyle !== "function") return preferredId; + try { + const sources = glMap.getStyle().sources || {}; + if (sources[preferredId]) return preferredId; + const match = Object.keys(sources).find( + (id) => sources[id]?.type === "vector" && /predict|build/i.test(id) + ); + return match || preferredId; + } catch (error) { + console.warn("glMap.getStyle() failed:", error); + return preferredId; + } +} + // Average of the first ring's vertices — good enough to centre the camera on // a building, and far cheaper than a real centroid. function featureCentroid(geometry) { @@ -233,10 +302,53 @@ const useStyles = makeStyles({ color: tokens.colorNeutralForeground1, backgroundColor: tokens.colorNeutralBackground2, }, - map: { + // Both map panes are absolutely positioned inside this wrapper and fill it + // exactly: the swipe comparison map (SwipeMap PRIMARY) sits behind, the + // editor map (SECONDARY, clipped to the right of the divider) on top. The + // wrapper is also the positioning context for the box-select rectangle and + // the pane badges, so their pixel offsets match the map canvases. + mapArea: { + position: "relative", flexGrow: 1, minHeight: 0, }, + mapPane: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + mapPaneHidden: { + display: "none", + }, + mapBadge: { + position: "absolute", + top: "10px", + zIndex: 900, + padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`, + borderRadius: tokens.borderRadiusMedium, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, + boxShadow: tokens.shadow4, + fontSize: tokens.fontSizeBase200, + fontWeight: tokens.fontWeightSemibold, + whiteSpace: "nowrap", + // Never intercept a divider drag or a footprint click. + pointerEvents: "none", + }, + // The Back button control (10px inset, 104px wide, 4px padding) owns the + // top-left corner, so the left badge starts clear of it. + mapBadgeLeft: { + left: "132px", + }, + mapBadgeRight: { + right: "calc(clamp(300px, 25vw, 360px) + 20px)", + "@media (max-width: 700px)": { + right: "10px", + }, + }, messageCard: { position: "absolute", top: "50%", @@ -427,6 +539,30 @@ const PredictionEditor = () => { const selectedIdRef = useRef(null); const boxRef = useRef(null); const boxCleanupRef = useRef(null); + // ── Swipe comparison map ────────────────────────────────────────────────── + // atlas.SwipeMap reveals its SECONDARY on the RIGHT of the divider and its + // PRIMARY on the LEFT, so the comparison map (pre-event imagery, or just + // the basemap) is built as the PRIMARY and the editor map is adopted as the + // SECONDARY. mapAreaRef is the wrapper both panes fill — its width is what + // the A/S/D divider shortcuts measure against. + const mapAreaRef = useRef(null); + const swipeMapContainerRef = useRef(null); + const swipeMapRef = useRef(null); + // The comparison map's own renderer: feature-state and paint are + // per-renderer, so every write aimed at the editor map is mirrored here or + // the left pane draws every footprint in the "pending" colour. + const swipeGlMapRef = useRef(null); + const swipeControlRef = useRef(null); + const swipeFillLayerRef = useRef(null); + const swipeLineLayerRef = useRef(null); + const swipeSourceIdRef = useRef(SWIPE_SOURCE_ID); + const swipeLayerIdsRef = useRef([SWIPE_FILL_LAYER_ID]); + const swipeBoxCleanupRef = useRef(null); + // The layer's imagery URLs (GetLayerLabelingToolData) and the PMTiles + // archive URL, cached so the comparison map can draw the same imagery and + // the same footprints without refetching anything. + const imageryRef = useRef(null); + const archiveUrlRef = useRef(""); // Guards every setState that happens after an await, so nothing writes to a // torn-down component (and, with it, no timer outlives the editor). const mountedRef = useRef(true); @@ -450,6 +586,20 @@ const PredictionEditor = () => { // depending on it below) is what makes the styling effects re-run once the // layers actually exist — the refs alone never trigger a render. const [isSourceReady, setIsSourceReady] = useState(false); + // Same rule for the swipe comparison map: its layers exist only once its + // own async "ready" has fired, so the paint effect depends on this flag + // rather than on swipeMapRef.current. + const [isSwipeReady, setIsSwipeReady] = useState(false); + // The layer's imagery block, in state because the render tree decides from + // it whether to offer a swipe (and which comparison to name). + const [imagery, setImagery] = useState(null); + // Swipe defaults OFF. The editor's bread-and-butter gesture is a wide + // ctrl+drag box-select over the whole map, and a second Azure Maps instance + // plus a second PMTiles renderer is not free — so the analyst opts in when + // they actually want to compare imagery. (Editing is wired to BOTH panes + // regardless, so turning it on never makes half the map inert, which is the + // regression the Interactive Labeler hit by defaulting swipe on.) + const [swipeOn, setSwipeOn] = useState(false); const [threshold, setThreshold] = useState(0.5); const [unknownThreshold, setUnknownThreshold] = useState(0); @@ -483,6 +633,15 @@ const PredictionEditor = () => { // where the container was not in the DOM at all. const [loadToken, setLoadToken] = useState(0); + // Which comparison the swipe offers, derived from the imagery the layer + // actually has (pure, unit-tested in predictionSwipe.js). Pre-vs-post is + // never offered without pre-event tiles, and no swipe at all is offered + // without post-event tiles — there would be nothing to compare. + const swipeMode = useMemo(() => resolveSwipeMode(imagery), [imagery]); + const swipeAvailable = isSwipeAvailable(swipeMode); + // The comparison pane is only really up once its map has finished loading. + const isSwipeActive = swipeOn && swipeAvailable; + // ── Ref mirrors ─────────────────────────────────────────────────────────── useEffect(() => { overridesRef.current = overrides; @@ -602,6 +761,7 @@ const PredictionEditor = () => { setPhase(PHASE_LOADING); setPrepState(null); setIsSourceReady(false); + setImagery(null); setOverridesState({}); setClassification(null); setSelectedIndex(-1); @@ -741,6 +901,9 @@ const PredictionEditor = () => { teardownMap(); return; } + // Publishing the imagery block is what lets the panel decide whether + // to offer a swipe, and which comparison to name. + setImagery(imageryRef.current); setPhase(PHASE_READY); } catch (error) { if (cancelled || isStale(runId)) return; @@ -813,6 +976,24 @@ const PredictionEditor = () => { `GetModelArtifact?projectId=${encodeURIComponent(projectId)}` + `&modelId=${encodeURIComponent(modelId)}&kind=footprint_pmtiles` ); + // Cached so the swipe comparison map can point a source at the very same + // `pmtiles://` key and reuse the archive already in memory. + archiveUrlRef.current = archiveUrl; + + // The layer's imagery, so the editor draws footprints over the post-event + // scene the model actually scored (rather than a generic basemap) and the + // swipe view knows whether pre-event tiles exist. Imagery is optional: + // a failure here must not stop the editor from opening. + let layerData = null; + try { + layerData = await apiGet( + `GetLayerLabelingToolData?projectId=${encodeURIComponent(projectId)}` + + `&imageLayerId=${encodeURIComponent(imageLayerId)}` + ); + } catch (error) { + console.warn("Could not fetch layer imagery:", error); + } + imageryRef.current = layerData?.imagery || null; // Download the whole archive once and serve pmtiles.js from memory: the // SWA /api proxy in front of the function app does not honour range @@ -857,6 +1038,25 @@ const PredictionEditor = () => { position: "bottom-left", }); + // Post-event imagery under the footprints. This is the pane the swipe + // view compares against, and on its own it already puts every footprint + // over the scene the model scored. toBrowserTitilerUrl returns "" when + // it cannot map the tile template to something this browser can reach, + // in which case the basemap alone is a better answer than a layer of + // failing tiles. + const postUrl = toBrowserTitilerUrl( + layerData?.imagery?.postEventTileUrl || "" + ); + if (postUrl) { + loadImagery( + postUrl, + map, + { current: null }, + "predictionPostEventImagery", + true + ); + } + const source = new window.atlas.source.VectorTileSource(SOURCE_ID, { type: "vector", url: `pmtiles://${archiveUrl}`, @@ -894,28 +1094,9 @@ const PredictionEditor = () => { const glMap = findGlMap(map); glMapRef.current = glMap; - if (glMap && typeof glMap.getStyle === "function") { - // Azure Maps renames our source/layer internally; discover the ids - // the renderer actually uses so queryRenderedFeatures can target them. - try { - const style = glMap.getStyle(); - const sourceIds = Object.keys(style.sources || {}); - const ours = [ - SOURCE_ID, - ...sourceIds.filter((s) => s === SOURCE_ID || /predict|build/i.test(s)), - ...sourceIds, - ]; - internalLayerIdsRef.current = (style.layers || []) - .filter( - (layer) => - layer.type === "fill" && - (ours.includes(layer.source) || /predict|build/i.test(layer.id)) - ) - .map((layer) => layer.id); - } catch (error) { - console.warn("glMap.getStyle() failed:", error); - } - } + internalLayerIdsRef.current = discoverFillLayerIds(glMap, [ + FILL_LAYER_ID, + ]); map.events.add("click", fillLayer, (event) => { // Ctrl+click starts a box-select drag; don't also toggle a class. @@ -934,7 +1115,12 @@ const PredictionEditor = () => { return false; }); map.getCanvasContainer().style.cursor = "pointer"; - setupBoxSelect(map); + setupBoxSelect( + map, + () => glMapRef.current, + () => internalLayerIdsRef.current, + boxCleanupRef + ); const hydrate = () => scheduleHydrate(); map.events.add("moveend", hydrate); @@ -949,8 +1135,30 @@ const PredictionEditor = () => { } // ── Renderer helpers (all read refs so map handlers stay valid) ─────────── - function featureAtEvent(map, event) { - const glMap = glMapRef.current; + // Every renderer currently drawing footprints: the editor map, plus the + // swipe comparison map while it is up. Feature-state is per-renderer, so a + // write that skipped the second one would leave the far side of the divider + // painting every building in the "pending" colour. + function footprintRenderers() { + const renderers = []; + if (glMapRef.current) { + renderers.push({ + map: mapRef.current, + gl: glMapRef.current, + sourceId: primarySourceIdRef.current || SOURCE_ID, + }); + } + if (swipeGlMapRef.current) { + renderers.push({ + map: swipeMapRef.current, + gl: swipeGlMapRef.current, + sourceId: swipeSourceIdRef.current || SWIPE_SOURCE_ID, + }); + } + return renderers; + } + + function featureAtEventOn(map, glMap, layerIds, event) { if (!glMap) return null; let pixel = event.pixel; if (!pixel && event.position) { @@ -959,7 +1167,6 @@ const PredictionEditor = () => { } if (!pixel) return null; try { - const layerIds = internalLayerIdsRef.current; const rendered = glMap.queryRenderedFeatures( pixel, layerIds && layerIds.length ? { layers: layerIds } : undefined @@ -973,10 +1180,17 @@ const PredictionEditor = () => { } } - function renderedFeatures(box) { - const glMap = glMapRef.current; + function featureAtEvent(map, event) { + return featureAtEventOn( + map, + glMapRef.current, + internalLayerIdsRef.current, + event + ); + } + + function renderedFeaturesOn(glMap, layerIds, box) { if (!glMap) return []; - const layerIds = internalLayerIdsRef.current; try { return ( glMap.queryRenderedFeatures( @@ -990,20 +1204,38 @@ const PredictionEditor = () => { } } - function writeFeatureState(sourceId, id, state) { - const glMap = glMapRef.current; - if (!glMap) return; - try { - glMap.setFeatureState( - { - source: sourceId || primarySourceIdRef.current || SOURCE_ID, - sourceLayer: PMTILES_SOURCE_LAYER, - id, - }, - state - ); - } catch (error) { - console.warn("feature-state write failed:", error); + function renderedFeatures(box) { + return renderedFeaturesOn( + glMapRef.current, + internalLayerIdsRef.current, + box + ); + } + + // One class change, written to every renderer that draws the building. Each + // renderer names the source differently, so each gets its own cached id. + function writeFeatureState(id, state) { + for (const renderer of footprintRenderers()) { + try { + renderer.gl.setFeatureState( + { + source: renderer.sourceId, + sourceLayer: PMTILES_SOURCE_LAYER, + id, + }, + state + ); + } catch (error) { + console.warn("feature-state write failed:", error); + } + } + } + + function repaintFootprints() { + for (const renderer of footprintRenderers()) { + if (renderer.map && renderer.map.triggerRepaint) { + renderer.map.triggerRepaint(); + } } } @@ -1020,6 +1252,10 @@ const PredictionEditor = () => { // Paint every footprint currently on screen from the cached classification, // and remember where each one is so Prev/Next can pan to it. Called on every // viewport settle and whenever the classification changes. + // + // The two panes share a camera, so the editor map's rendered features are + // the authoritative list of what is on screen; writeFeatureState fans each + // building's state out to the comparison map's renderer as well. function hydrateViewport() { const features = renderedFeatures(undefined); if (features.length === 0) return; @@ -1039,16 +1275,14 @@ const PredictionEditor = () => { if (centroid) centroidsRef.current.set(id, centroid); } const cls = classes[index]; - writeFeatureState(feature.source, id, { + writeFeatureState(id, { cls: CLASS_CODES[cls] || 0, dim: !matchesFilter(cls, edited[index], activeFilter), edited: !!edited[index], selected: selectedId === id, }); } - if (mapRef.current && mapRef.current.triggerRepaint) { - mapRef.current.triggerRepaint(); - } + repaintFootprints(); } // ── Editing ─────────────────────────────────────────────────────────────── @@ -1109,7 +1343,11 @@ const PredictionEditor = () => { } // ── Ctrl+drag box-select ────────────────────────────────────────────────── - function setupBoxSelect(map) { + // Parameterised by pane: the swipe comparison map wires its own copy so a + // drag that starts on the uncovered (left) half selects buildings too. The + // two canvases are the same size and in the same place, so both can share + // the single box rectangle without any coordinate translation. + function setupBoxSelect(map, glGetter, layerIdsGetter, cleanupRef) { const canvas = map.getCanvasContainer(); let origin = null; @@ -1156,7 +1394,7 @@ const PredictionEditor = () => { map.setUserInteraction({ dragPanInteraction: true }); if (x2 - x1 < 4 || y2 - y1 < 4) return; - const features = renderedFeatures([ + const features = renderedFeaturesOn(glGetter(), layerIdsGetter(), [ [x1, y1], [x2, y2], ]); @@ -1169,7 +1407,7 @@ const PredictionEditor = () => { canvas.addEventListener("mousedown", onDown); document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); - boxCleanupRef.current = () => { + cleanupRef.current = () => { canvas.removeEventListener("mousedown", onDown); document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); @@ -1203,31 +1441,36 @@ const PredictionEditor = () => { // Repaint on-screen footprints. isSourceReady is in the deps because the // layers are created inside the map's async "ready" handler — reading the - // layer refs during render would see nulls and never re-run. + // layer refs during render would see nulls and never re-run. isSwipeReady + // is there for the same reason on the comparison pane: its renderer starts + // with no feature-state at all, so it has to be hydrated the moment it + // appears. useEffect(() => { if (!isSourceReady || !classification) return; hydrateViewport(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [classification, filter, isSourceReady]); + }, [classification, filter, isSourceReady, isSwipeReady]); // Resolve the map palette from the active Fluent theme, and re-apply it when // the user flips light/dark or changes the brand palette. The resolved // values live in a ref because only the renderer consumes them — the legend // uses the same tokens through makeStyles. + // + // Paint expressions are per-renderer too, so the comparison pane's layers + // get exactly the same ones; isSwipeReady is in the deps because those + // layers only exist once that map's async "ready" has fired. useEffect(() => { const resolved = resolveThemeColors(rootRef.current); colorsRef.current = resolved; - if (fillLayerRef.current) { - fillLayerRef.current.setOptions({ - fillColor: fillColorExpression(resolved), - }); + const fillColor = fillColorExpression(resolved); + const strokeColor = strokeColorExpression(resolved); + for (const layer of [fillLayerRef.current, swipeFillLayerRef.current]) { + if (layer) layer.setOptions({ fillColor }); } - if (lineLayerRef.current) { - lineLayerRef.current.setOptions({ - strokeColor: strokeColorExpression(resolved), - }); + for (const layer of [lineLayerRef.current, swipeLineLayerRef.current]) { + if (layer) layer.setOptions({ strokeColor }); } - }, [isDark, palette, isSourceReady]); + }, [isDark, palette, isSourceReady, isSwipeReady]); // ── Selection ───────────────────────────────────────────────────────────── const filteredIndices = useMemo( @@ -1265,11 +1508,15 @@ const PredictionEditor = () => { ? attrs.ids[selectedIndex] : null; if (previousId != null && previousId !== nextId) { - writeFeatureState(null, previousId, { selected: false }); + writeFeatureState(previousId, { selected: false }); } selectedIdRef.current = nextId; - if (nextId == null) return; - writeFeatureState(null, nextId, { selected: true }); + if (nextId == null) { + repaintFootprints(); + return; + } + writeFeatureState(nextId, { selected: true }); + repaintFootprints(); const shouldPan = pendingPanRef.current; pendingPanRef.current = false; const centroid = centroidsRef.current.get(nextId); @@ -1281,7 +1528,11 @@ const PredictionEditor = () => { duration: 500, }); } - }, [selectedIndex, isSourceReady]); + // writeFeatureState / repaintFootprints only read refs (the renderers and + // their source ids), so they are stable for the life of the component and + // are deliberately not dependencies. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedIndex, isSourceReady, isSwipeReady]); function navigateInFilter(direction) { if (filteredIndices.length === 0) return; @@ -1303,6 +1554,250 @@ const PredictionEditor = () => { setSelectedIndex(next); } + // ── Swipe comparison map ────────────────────────────────────────────────── + // Built on demand from the global atlas.SwipeMap that index.html loads from + // /assets/js/azure-maps-swipe-map.min.js — NOT an npm import. Mirrors the + // Interactive Labeler's hardened implementation. + // + // atlas.SwipeMap always shows its PRIMARY on the LEFT of the divider and + // clips its SECONDARY to reveal it on the RIGHT, so: + // • PRIMARY = a freshly built comparison map (pre-event imagery, or the + // plain basemap), created in swipeMapContainerRef — the + // FIRST/behind pane; and + // • SECONDARY = the existing editor map (post-event imagery + footprints + // + editing), which sits in the SECOND/on-top pane, so its + // clipped right half reveals the comparison map on the left. + // Consequently the divider moving LEFT uncovers more of the post-event + // (editing) map, and moving RIGHT uncovers more of the comparison map. + // + // The editor map is only ADOPTED: SwipeMap adds 'move'/'resize' handlers and + // an inline clip to its container, nothing else, so an already-"ready" map + // adopts cleanly and its own handlers survive. SwipeMap also syncs BOTH + // cameras on every 'move' internally — adding our own camera-sync handler + // here would double-update them and make panning stutter, so we do not. + useEffect(() => { + if (!isSourceReady || !swipeOn || !swipeAvailable) return undefined; + const editorMap = mapRef.current; + const container = swipeMapContainerRef.current; + // Captured up front: by teardown time the ref may already point elsewhere, + // but this node is stable for the effect's lifetime. + const editorContainer = mapContainerRef.current; + if (!editorMap || !container || !window.atlas || !window.atlas.SwipeMap) { + return undefined; + } + // The map's "ready" is async and can land after this effect is cleaned up + // (a fast toggle off, or an unmount) — by which point the map is disposed. + let isDisposed = false; + + // Seed the comparison map with the editor's current camera so the two + // start aligned before SwipeMap takes over the synchronisation. + const camera = editorMap.getCamera(); + const compareMap = new window.atlas.Map(container, { + center: camera.center, + zoom: camera.zoom, + bearing: camera.bearing || 0, + pitch: 0, + maxPitch: 0, + // Same rule as the editor map: "satellite" is the real basemap, while + // local docker dev (no Azure Maps subscription) uses "blank" so the + // control still fires "ready" without a valid token. + style: isAzureMapsPlaceholder ? "blank" : "satellite", + language: "en-US", + authOptions: getAzureMapsAuthOptions(), + }); + swipeMapRef.current = compareMap; + + compareMap.events.add("ready", () => { + if (isDisposed) return; + compareMap.setUserInteraction({ + dragRotateInteraction: false, + scrollZoomInteraction: true, + pinchZoomInteraction: true, + pinchRotateInteraction: false, + }); + + // Pre-event imagery on the comparison pane. In basemap mode there is no + // overlay at all — the map's own basemap IS the comparison. + const compareUrl = toBrowserTitilerUrl( + swipeComparisonTileUrl(imageryRef.current, swipeMode) + ); + if (compareUrl) { + loadImagery( + compareUrl, + compareMap, + { current: null }, + "predictionSwipeComparisonImagery", + true + ); + } + + // The same footprints, from the same in-memory archive: SwipeMap clips + // a whole map, so a single set of footprint layers could only ever + // appear on one side of the divider. + if (archiveUrlRef.current) { + try { + compareMap.sources.add( + new window.atlas.source.VectorTileSource(SWIPE_SOURCE_ID, { + type: "vector", + url: `pmtiles://${archiveUrlRef.current}`, + promoteId: { [PMTILES_SOURCE_LAYER]: "id" }, + }) + ); + const paint = colorsRef.current; + const swipeFillLayer = new window.atlas.layer.PolygonLayer( + SWIPE_SOURCE_ID, + SWIPE_FILL_LAYER_ID, + { + sourceLayer: PMTILES_SOURCE_LAYER, + fillColor: fillColorExpression(paint), + fillOpacity: FILL_OPACITY_EXPRESSION, + } + ); + compareMap.layers.add(swipeFillLayer); + swipeFillLayerRef.current = swipeFillLayer; + + const swipeLineLayer = new window.atlas.layer.LineLayer( + SWIPE_SOURCE_ID, + SWIPE_LINE_LAYER_ID, + { + sourceLayer: PMTILES_SOURCE_LAYER, + strokeColor: strokeColorExpression(paint), + strokeWidth: STROKE_WIDTH_EXPRESSION, + } + ); + compareMap.layers.add(swipeLineLayer); + swipeLineLayerRef.current = swipeLineLayer; + + const swipeGlMap = findGlMap(compareMap); + swipeGlMapRef.current = swipeGlMap; + swipeLayerIdsRef.current = discoverFillLayerIds(swipeGlMap, [ + SWIPE_FILL_LAYER_ID, + ]); + swipeSourceIdRef.current = discoverVectorSourceId( + swipeGlMap, + SWIPE_SOURCE_ID + ); + + // Without these the whole uncovered half of the map would be inert: + // the editor map is clipped there, so its own handlers never see + // those clicks. Same edit path, same box-select, same undo. + compareMap.events.add("click", swipeFillLayer, (event) => { + if ( + event.originalEvent && + (event.originalEvent.ctrlKey || event.originalEvent.metaKey) + ) { + return; + } + const feature = featureAtEventOn( + compareMap, + swipeGlMapRef.current, + swipeLayerIdsRef.current, + event + ); + if (feature) handleFeatureClick(feature.id); + }); + compareMap.events.add("contextmenu", swipeFillLayer, (event) => { + const feature = featureAtEventOn( + compareMap, + swipeGlMapRef.current, + swipeLayerIdsRef.current, + event + ); + if (feature) handleClearOverrideForId(feature.id); + return false; + }); + compareMap.getCanvasContainer().style.cursor = "pointer"; + setupBoxSelect( + compareMap, + () => swipeGlMapRef.current, + () => swipeLayerIdsRef.current, + swipeBoxCleanupRef + ); + + // This renderer starts with empty feature-state, and its tiles + // arrive on their own schedule, so re-hydrate as they land. + compareMap.events.add("sourcedata", (event) => { + if (event && event.isSourceLoaded) scheduleHydrate(); + }); + } catch (error) { + console.warn("Swipe comparison footprints failed:", error); + } + } + + try { + swipeControlRef.current = new window.atlas.SwipeMap( + compareMap, + editorMap + ); + } catch (error) { + console.warn("atlas.SwipeMap init failed:", error); + } + + // Paint what is already on screen into the new renderer, then let the + // effects above take over (isSwipeReady is their trigger). + hydrateViewport(); + if (mountedRef.current && !isDisposed) setIsSwipeReady(true); + }); + + return () => { + isDisposed = true; + // Detach the comparison pane's document-level drag listeners before its + // map goes away, or box-select keeps firing against a dead renderer. + if (swipeBoxCleanupRef.current) { + swipeBoxCleanupRef.current(); + swipeBoxCleanupRef.current = null; + } + swipeGlMapRef.current = null; + swipeFillLayerRef.current = null; + swipeLineLayerRef.current = null; + swipeLayerIdsRef.current = [SWIPE_FILL_LAYER_ID]; + swipeSourceIdRef.current = SWIPE_SOURCE_ID; + // Order matters: SwipeMap.dispose() removes the divider handle it + // appended to the PRIMARY container and detaches the 'move'/'resize' + // handlers from BOTH maps, so it has to go before the map it decorates. + if (swipeControlRef.current) { + try { + if (typeof swipeControlRef.current.dispose === "function") { + swipeControlRef.current.dispose(); + } + } catch (error) { + console.warn("atlas.SwipeMap dispose failed:", error); + } + swipeControlRef.current = null; + } + if (swipeMapRef.current) { + try { + swipeMapRef.current.dispose(); + } catch (error) { + console.warn("swipe comparison map dispose failed:", error); + } + swipeMapRef.current = null; + } + // SwipeMap.dispose() does NOT clear the inline `clip` it set on the + // SECONDARY (editor) map's container. Left behind, the editor stays + // stuck at half width. Clear it on both the element getMapContainer() + // reports and the div handed to the Map constructor, since which one + // that is varies across Atlas builds. The editor map may already be + // disposed when this runs on unmount, hence the try/catch. + try { + if (editorMap && typeof editorMap.getMapContainer === "function") { + editorMap.getMapContainer().style.clip = ""; + } + } catch (error) { + console.warn("clearing the editor map clip failed:", error); + } + if (editorContainer) editorContainer.style.clip = ""; + // Leave the comparison pane's container as clean as we found it. + container.style.clip = ""; + container.innerHTML = ""; + if (mountedRef.current) setIsSwipeReady(false); + }; + // The map helpers are stable for the life of the component and are + // deliberately not dependencies: including them would rebuild the + // comparison map on every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSourceReady, swipeOn, swipeAvailable, swipeMode]); + // ── Save ────────────────────────────────────────────────────────────────── async function refreshVersions() { try { @@ -1360,7 +1855,8 @@ const PredictionEditor = () => { // ── Keyboard shortcuts ──────────────────────────────────────────────────── // 1/2/3 set the selected building's class (and become the click action, so - // the next click paints the same class); arrows walk the filtered set. + // the next click paints the same class); arrows walk the filtered set; with + // the swipe view up, A/S/D snap the divider. useEffect(() => { if (phase !== PHASE_READY) return undefined; const classByKey = { @@ -1377,6 +1873,22 @@ const PredictionEditor = () => { setClassForSelected(cls); return; } + if (swipeControlRef.current) { + // sliderPosition is in pixels from the left edge of the map area. + // A = hard left (the whole post-event/editing map shows), S = centre, + // D = hard right (the whole comparison map shows). SwipeMap clamps to + // [0, width] itself. + const width = mapAreaRef.current?.getBoundingClientRect().width; + const position = dividerPositionForKey(event.key, width); + if (position !== null) { + try { + swipeControlRef.current.setOptions({ sliderPosition: position }); + } catch (error) { + console.warn("swipe setOptions (sliderPosition) failed:", error); + } + return; + } + } if (event.key === "ArrowLeft") { event.preventDefault(); navigateInFilter(-1); @@ -1558,12 +2070,44 @@ const PredictionEditor = () => {
+ {/* Map area. Both panes fill this wrapper exactly and overlap: the + comparison map (SwipeMap PRIMARY, revealed LEFT of the divider) has + to sit FIRST/behind, and the editor map (SECONDARY, clipped so it is + revealed RIGHT of the divider) SECOND/on-top. The divider handle + SwipeMap appends into the primary's container carries its own + z-index and still paints above both. The comparison pane's container + stays mounted but hidden while swipe is off, so the effect always + has a container to build into. */} {showMap && ( -
+
+
+
+ {/* Box-select rectangle (Ctrl+drag). Inside the map area so its + offsets line up with either canvas. */} +
+ {isSwipeActive && ( + <> +
+ {swipeLeftPaneLabel(swipeMode)} +
+
+ {swipeRightPaneLabel(swipeMode)} +
+ + )} +
)} {phase === PHASE_LOADING && ( @@ -1630,6 +2174,9 @@ const PredictionEditor = () => {
Click a footprint to change it · Ctrl+drag to box-select · right-click to undo an edit + {isSwipeActive + ? " · A / S / D move the swipe divider" + : ""}
{ setUnknownThreshold={setUnknownThreshold} baseline={baseline} changeCount={changeCount} + swipeMode={swipeMode} + swipeOn={swipeOn} + onSwipeChange={setSwipeOn} onSave={handleSave} isSaving={isSaving} saveError={saveError} @@ -1665,9 +2215,6 @@ const PredictionEditor = () => { /> )} - - {/* Box-select rectangle (Ctrl+drag) */} - {showMap &&
}
); }; diff --git a/ui/src/Components/PredictionEditor/PredictionEditorRightPanel.jsx b/ui/src/Components/PredictionEditor/PredictionEditorRightPanel.jsx index dae04bbc..5600a421 100644 --- a/ui/src/Components/PredictionEditor/PredictionEditorRightPanel.jsx +++ b/ui/src/Components/PredictionEditor/PredictionEditorRightPanel.jsx @@ -2,9 +2,10 @@ // Licensed under the MIT License. // // Right-hand control panel for the Prediction Editor: class counts, the -// filter + prev/next traversal over the filtered set, the threshold sliders -// (only for models that support them), the live "would change class" -// readout, the save action, and the saved-version history. +// swipe imagery-comparison toggle, the filter + prev/next traversal over the +// filtered set, the threshold sliders (only for models that support them), +// the live "would change class" readout, the save action, and the +// saved-version history. // // Layout and interaction mirror BuildingValidationRightPanel so the two // review screens feel like the same tool. Every colour comes from Fluent @@ -22,6 +23,7 @@ import { Radio, RadioGroup, Slider, + Switch, Text, makeStyles, tokens, @@ -39,6 +41,12 @@ import { sortVersionsDescending, toPercentLabel, } from "./predictionClassify"; +import { + SWIPE_MODE_NONE, + isSwipeAvailable, + swipeModeHint, + swipeToggleLabel, +} from "./predictionSwipe"; const CLASS_ORDER = [CLASS_DAMAGED, CLASS_NOT_DAMAGED, CLASS_UNKNOWN]; @@ -243,6 +251,9 @@ const PredictionEditorRightPanel = ({ setUnknownThreshold, baseline, changeCount, + swipeMode = SWIPE_MODE_NONE, + swipeOn = false, + onSwipeChange, onSave, isSaving, saveError, @@ -270,6 +281,7 @@ const PredictionEditorRightPanel = ({ : `${filteredIndices.length} buildings match — press Next to start`; const orderedVersions = sortVersionsDescending(versions); + const swipeAvailable = isSwipeAvailable(swipeMode); const thresholdChanged = toPercent(threshold) !== toPercent(baseline?.threshold) || toPercent(unknownThreshold) !== toPercent(baseline?.unknownThreshold); @@ -310,6 +322,21 @@ const PredictionEditorRightPanel = ({ + {/* Imagery comparison. The mode is decided by the layer's imagery, so + pre-vs-post is simply not on offer when there are no pre-event + tiles — the label always names the comparison being shown. */} +
+ onSwipeChange?.(data.checked)} + /> +
{swipeModeHint(swipeMode)}
+
+ + + {/* Thresholds — only models that expose a score support these. */} {session?.supportsThreshold && (
@@ -571,6 +598,9 @@ PredictionEditorRightPanel.propTypes = { unknownThreshold: PropTypes.number, }).isRequired, changeCount: PropTypes.number.isRequired, + swipeMode: PropTypes.string, + swipeOn: PropTypes.bool, + onSwipeChange: PropTypes.func, onSave: PropTypes.func.isRequired, isSaving: PropTypes.bool.isRequired, saveError: PropTypes.string, diff --git a/ui/src/Components/PredictionEditor/predictionClassify.test.js b/ui/src/Components/PredictionEditor/predictionClassify.test.js index 387f51c7..acbdb1de 100644 --- a/ui/src/Components/PredictionEditor/predictionClassify.test.js +++ b/ui/src/Components/PredictionEditor/predictionClassify.test.js @@ -57,6 +57,19 @@ import { prepStatusLabel, shouldPollPrep, } from "./predictionPrep.js"; +import { + SWIPE_MODE_BASEMAP_POST, + SWIPE_MODE_NONE, + SWIPE_MODE_PRE_POST, + dividerPositionForKey, + isSwipeAvailable, + resolveSwipeMode, + swipeComparisonTileUrl, + swipeLeftPaneLabel, + swipeModeHint, + swipeRightPaneLabel, + swipeToggleLabel, +} from "./predictionSwipe.js"; // Five buildings covering every interesting corner: below/at/above the // threshold, and one with a non-zero unknown score. @@ -613,3 +626,129 @@ test("prep card copy reports what is outstanding", () => { assert.equal(prepStatusLabel(undefined), "Starting"); assert.equal(prepStatusLabel("Weird"), "Starting"); }); + +// ── Swipe comparison map (predictionSwipe.js) ─────────────────────────────── + +test("swipe mode follows the imagery the layer actually has", () => { + // Pre-event tiles present: compare pre against post. + assert.equal( + resolveSwipeMode({ + preEventTileUrl: "https://x/pre/{z}/{x}/{y}.png", + postEventTileUrl: "https://x/post/{z}/{x}/{y}.png", + }), + SWIPE_MODE_PRE_POST + ); + + // No pre-event tiles: the basemap stands in on the comparison pane. + assert.equal( + resolveSwipeMode({ postEventTileUrl: "https://x/post/{z}/{x}/{y}.png" }), + SWIPE_MODE_BASEMAP_POST + ); + assert.equal( + resolveSwipeMode({ + preEventTileUrl: " ", + postEventTileUrl: "https://x/post/{z}/{x}/{y}.png", + }), + SWIPE_MODE_BASEMAP_POST, + "a blank pre URL is not pre-event imagery" + ); + + // Without post-event imagery there is nothing to compare against, so the + // editor must not offer a swipe at all. + assert.equal(resolveSwipeMode(null), SWIPE_MODE_NONE); + assert.equal(resolveSwipeMode(undefined), SWIPE_MODE_NONE); + assert.equal(resolveSwipeMode({}), SWIPE_MODE_NONE); + assert.equal( + resolveSwipeMode({ preEventTileUrl: "https://x/pre/{z}/{x}/{y}.png" }), + SWIPE_MODE_NONE + ); + + assert.equal(isSwipeAvailable(SWIPE_MODE_PRE_POST), true); + assert.equal(isSwipeAvailable(SWIPE_MODE_BASEMAP_POST), true); + assert.equal(isSwipeAvailable(SWIPE_MODE_NONE), false); + assert.equal(isSwipeAvailable(undefined), false); +}); + +test("only the pre/post mode overlays imagery on the comparison pane", () => { + const imagery = { + preEventTileUrl: " https://x/pre/{z}/{x}/{y}.png ", + postEventTileUrl: "https://x/post/{z}/{x}/{y}.png", + }; + assert.equal( + swipeComparisonTileUrl(imagery, SWIPE_MODE_PRE_POST), + "https://x/pre/{z}/{x}/{y}.png" + ); + // Basemap mode draws the map's own basemap — no tile layer on top. + assert.equal(swipeComparisonTileUrl(imagery, SWIPE_MODE_BASEMAP_POST), ""); + assert.equal(swipeComparisonTileUrl(imagery, SWIPE_MODE_NONE), ""); + assert.equal(swipeComparisonTileUrl(null, SWIPE_MODE_PRE_POST), ""); +}); + +test("swipe labels name the comparison the analyst is getting", () => { + assert.equal( + swipeToggleLabel(SWIPE_MODE_PRE_POST), + "Swipe: pre-event vs post-event" + ); + assert.equal( + swipeToggleLabel(SWIPE_MODE_BASEMAP_POST), + "Swipe: basemap vs post-event" + ); + assert.equal( + swipeToggleLabel(SWIPE_MODE_NONE), + "Swipe comparison unavailable" + ); + + assert.equal(swipeLeftPaneLabel(SWIPE_MODE_PRE_POST), "Pre-event imagery"); + assert.equal(swipeLeftPaneLabel(SWIPE_MODE_BASEMAP_POST), "Basemap"); + assert.equal(swipeLeftPaneLabel(SWIPE_MODE_NONE), ""); + assert.equal( + swipeRightPaneLabel(SWIPE_MODE_PRE_POST), + "Post-event imagery" + ); + assert.equal( + swipeRightPaneLabel(SWIPE_MODE_BASEMAP_POST), + "Post-event imagery" + ); + assert.equal(swipeRightPaneLabel(SWIPE_MODE_NONE), ""); +}); + +test("swipe hint describes the divider directions the right way round", () => { + // The comparison map is the SwipeMap PRIMARY and sits LEFT of the divider, + // so dragging LEFT uncovers MORE post-event imagery. A previous PR shipped + // this backwards; pin it down. + const hint = swipeModeHint(SWIPE_MODE_PRE_POST); + assert.match(hint, /Pre-event imagery sits left of the divider/); + assert.match(hint, /left for more post-event/); + assert.match(hint, /right for more pre-event imagery/); + assert.match(hint, /Editing works on both sides/); + assert.match( + swipeModeHint(SWIPE_MODE_BASEMAP_POST), + /Basemap sits left of the divider/ + ); + assert.match( + swipeModeHint(SWIPE_MODE_NONE), + /no post-event imagery to compare against/ + ); +}); + +test("A / S / D snap the divider left / centre / right", () => { + assert.equal(dividerPositionForKey("a", 800), 0); + assert.equal(dividerPositionForKey("s", 800), 400); + assert.equal(dividerPositionForKey("d", 800), 800); + // Shift-held (or caps-locked) keys are the same shortcut. + assert.equal(dividerPositionForKey("A", 800), 0); + assert.equal(dividerPositionForKey("S", 800), 400); + assert.equal(dividerPositionForKey("D", 800), 800); + + // Anything else is not ours to handle. + for (const key of ["1", "w", "ArrowLeft", " ", "", null, undefined, 5]) { + assert.equal(dividerPositionForKey(key, 800), null); + } + + // No usable width yet (map area not laid out): do nothing rather than + // silently park the divider at 0. + for (const width of [0, -10, NaN, Infinity, null, undefined, "wide"]) { + assert.equal(dividerPositionForKey("s", width), null); + } + assert.equal(dividerPositionForKey("s", "800"), 400, "numeric strings work"); +}); diff --git a/ui/src/Components/PredictionEditor/predictionSwipe.js b/ui/src/Components/PredictionEditor/predictionSwipe.js new file mode 100644 index 00000000..d45eec06 --- /dev/null +++ b/ui/src/Components/PredictionEditor/predictionSwipe.js @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Pure decision logic for the Prediction Editor's swipe comparison map. +// +// Nothing here touches the DOM, React, or Azure Maps, so every rule the +// editor relies on — which comparison the analyst gets, what the panes are +// called, and where a keyboard shortcut puts the divider — is unit-testable +// in predictionClassify.test.js. +// +// Geometry note that the copy in this file depends on (and that a previous +// PR shipped backwards): atlas.SwipeMap shows its PRIMARY map on the LEFT of +// the divider and clips its SECONDARY to reveal it on the RIGHT. The editor +// wires the comparison map (pre-event imagery, or the plain basemap) as the +// PRIMARY and the editable post-event map as the SECONDARY. So: +// +// divider fully LEFT -> the post-event (editing) map fills the view +// divider fully RIGHT -> the comparison (pre-event / basemap) map fills it + +// Pre-event imagery on the left, post-event imagery on the right. +export const SWIPE_MODE_PRE_POST = "prePost"; +// The layer has no pre-event imagery: compare the basemap against post-event. +export const SWIPE_MODE_BASEMAP_POST = "basemapPost"; +// Nothing worth comparing (no post-event imagery), so no swipe is offered. +export const SWIPE_MODE_NONE = "none"; + +// Keys that snap the divider, in left → centre → right order. +export const SWIPE_DIVIDER_KEYS = ["a", "s", "d"]; + +function cleanUrl(value) { + return typeof value === "string" ? value.trim() : ""; +} + +/** + * Which comparison applies for a layer's imagery block, as returned by + * GetLayerLabelingToolData (`layerData.imagery`). + * + * The post-event tiles are what the editable map draws its footprints over, + * so without them there is no meaningful comparison and the toggle is not + * offered at all. Pre-event tiles, when present, replace the basemap on the + * comparison pane. + */ +export function resolveSwipeMode(imagery) { + const post = cleanUrl(imagery?.postEventTileUrl); + if (!post) return SWIPE_MODE_NONE; + return cleanUrl(imagery?.preEventTileUrl) + ? SWIPE_MODE_PRE_POST + : SWIPE_MODE_BASEMAP_POST; +} + +/** True when the editor should show the swipe toggle. */ +export function isSwipeAvailable(mode) { + return mode === SWIPE_MODE_PRE_POST || mode === SWIPE_MODE_BASEMAP_POST; +} + +/** + * The tile URL the comparison pane should draw, or "" when it should just + * show its own basemap. Only the pre/post mode has an imagery overlay. + */ +export function swipeComparisonTileUrl(imagery, mode) { + return mode === SWIPE_MODE_PRE_POST ? cleanUrl(imagery?.preEventTileUrl) : ""; +} + +/** Toggle label — the user must be able to see which comparison they get. */ +export function swipeToggleLabel(mode) { + if (mode === SWIPE_MODE_PRE_POST) return "Swipe: pre-event vs post-event"; + if (mode === SWIPE_MODE_BASEMAP_POST) return "Swipe: basemap vs post-event"; + return "Swipe comparison unavailable"; +} + +/** Badge over the left (comparison) pane. */ +export function swipeLeftPaneLabel(mode) { + if (mode === SWIPE_MODE_PRE_POST) return "Pre-event imagery"; + if (mode === SWIPE_MODE_BASEMAP_POST) return "Basemap"; + return ""; +} + +/** Badge over the right (editable, post-event) pane. */ +export function swipeRightPaneLabel(mode) { + return isSwipeAvailable(mode) ? "Post-event imagery" : ""; +} + +/** + * One-line explanation under the toggle. Direction matters: the comparison + * map is the PRIMARY and sits LEFT of the divider, so dragging the divider + * left uncovers MORE post-event imagery and dragging it right uncovers more + * of the comparison pane. + */ +export function swipeModeHint(mode) { + if (!isSwipeAvailable(mode)) { + return "This layer has no post-event imagery to compare against."; + } + const left = swipeLeftPaneLabel(mode).toLowerCase(); + return ( + `${swipeLeftPaneLabel(mode)} sits left of the divider, post-event ` + + `imagery right of it. Drag the divider left for more post-event, ` + + `right for more ${left}. Editing works on both sides.` + ); +} + +/** + * Where the divider goes for a snap key, in pixels from the left edge of the + * map area: A = hard left, S = centre, D = hard right. Returns null for any + * other key, or when the map area has no usable width yet (atlas.SwipeMap + * clamps to [0, width], so handing it a bad number would silently park the + * divider at 0). + */ +export function dividerPositionForKey(key, width) { + if (typeof key !== "string") return null; + const normalized = key.toLowerCase(); + if (!SWIPE_DIVIDER_KEYS.includes(normalized)) return null; + const usableWidth = Number(width); + if (!Number.isFinite(usableWidth) || usableWidth <= 0) return null; + if (normalized === "a") return 0; + if (normalized === "s") return usableWidth / 2; + return usableWidth; +} diff --git a/ui/src/Components/keyboardShortcuts.js b/ui/src/Components/keyboardShortcuts.js index 5a9ef37a..76c71104 100644 --- a/ui/src/Components/keyboardShortcuts.js +++ b/ui/src/Components/keyboardShortcuts.js @@ -78,6 +78,15 @@ export const PREDICTION_EDITOR_SHORTCUTS = [ keys: ["Right-click"], description: "Undo an edit — back to the model's class", }, + { + // Direction matters: the comparison map (pre-event imagery, or the + // basemap) is the swipe PRIMARY and sits LEFT of the divider, so moving + // the divider left uncovers MORE of the post-event map and moving it + // right uncovers more of the comparison map. + keys: ["A", "S", "D"], + description: + "With Swipe on: snap the divider left / centre / right — left uncovers more post-event imagery, right more of the pre-event (or basemap) pane", + }, ]; // Input types that are not free-text entry. Focus can legitimately sit on From 03fa68496ad53bd8d2bd00c4a97d4fcdf7e53648 Mon Sep 17 00:00:00 2001 From: Caleb Robinson Date: Sat, 22 Aug 2026 22:40:17 +0000 Subject: [PATCH 04/10] feat: make results vector-first and fold editing into View Results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review asked for prediction editing to be a mode inside the existing View Results page rather than a screen of its own. Doing that surfaced why the two workflows had never really shared a results story. The viewer was raster-only: a pre-coloured visualizer COG plus a raw predictions raster, both produced solely by the train/infer workflow. The embedding workflow makes no rasters at all, so pointing it at the viewer would have shown an empty map — and no code path anywhere rendered per-building predictions, for either workflow. So the viewer now draws predicted building footprints as vectors from the footprint tiles and the attribute sidecar, artifacts both workflows already produce, and the rasters become optional layers that simply do not appear when a model has none. That is what lets both workflows produce mostly the same results and be handled the same way. Editing rides on that same layer. A pencil beside Back, or E, turns the footprints interactive: click to set a class, ctrl+drag to box-select, right-click to clear an override back to the derived class, with the threshold slider shown only where re-thresholding means anything — the embedding workflow's damage fraction is a degenerate 0/1 copy. Leaving edit mode restores the read-only view and confirms before discarding unsaved work. The standalone screen, its route and both model-row Edit buttons are gone, and with them the logic that existed only to disable those buttons. The embedding row gains the View Results it never had. Three places disagreed about whether a model has results, so there is now one rule in hastegeo, surfaced as predictionsReady, and publishing uses it too rather than keeping a fourth copy. Reads resolve their source through resolve_prediction_source, so an analyst's edits finally reach the reports instead of being written and then ignored; the newest version wins and an explicit version can be requested, with no mutable "active version" pointer, which ADR-0005 deliberately avoided. Guards the null rasters this makes possible: four raster loaders dereferenced fields that are now absent for embedding models, and one older payload shape handed them a tile template with an empty url that would have quietly served 404s rather than failing. --- api/hastefuncapi/function_app.py | 283 ++- docs/api/hastefuncapi.md | 141 +- .../src/hastegeo/core/models/visualizer.py | 61 +- .../hastegeo/core/processors/visualizer.py | 336 +++ .../src/hastegeo/core/publishing/source.py | 13 +- .../hastegeo/core/utils/model_readiness.py | 237 ++ .../src/hastegeo/core/utils/predictions.py | 166 +- .../processors/test_visualizer_payload.py | 422 ++++ .../tests/core/utils/test_model_readiness.py | 233 ++ .../core/utils/test_prediction_source.py | 193 ++ ...-versioned-derived-prediction-artifacts.md | 125 +- spec/features/prediction-editing/README.md | 173 +- .../features/prediction-editing/data-model.md | 138 +- spec/features/prediction-editing/design.md | 564 +++-- .../prediction-editing/impact-analysis.md | 98 +- spec/features/prediction-editing/plan.md | 111 +- spec/features/prediction-editing/rollout.md | 89 +- spec/features/prediction-editing/test-plan.md | 151 +- .../prediction-editing/user-stories.md | 295 ++- ui/src/Components/AppBody.jsx | 5 - .../PredictionEditor/PredictionEditor.jsx | 2222 ----------------- .../predictionClassify.test.js | 754 ------ .../ProjectManagement/EmbeddingModelRow.jsx | 119 +- .../ProjectManagement/ModelResultsButton.jsx | 89 +- ui/src/Components/Visualizer/InfoPanel.jsx | 142 +- ui/src/Components/Visualizer/Labels.jsx | 149 +- .../PredictionEditPanel.jsx} | 130 +- .../Visualizer/PredictionStatusNote.jsx | 175 ++ ui/src/Components/Visualizer/Visualizer.jsx | 653 ++++- .../VisualizerInformationMobile.jsx | 58 +- .../predictionClassify.js | 0 .../Visualizer/predictionClassify.test.js | 1522 +++++++++++ .../Visualizer/predictionFootprintMap.js | 284 +++ .../predictionPrep.js | 0 .../Visualizer/predictionResults.js | 494 ++++ .../Visualizer/usePredictionArtifacts.js | 462 ++++ .../Visualizer/usePredictionFootprints.js | 936 +++++++ .../visualizerSwipe.js} | 80 +- ui/src/Components/keyboardShortcuts.js | 30 +- 39 files changed, 7931 insertions(+), 4202 deletions(-) create mode 100644 hastelib/src/hastegeo/core/processors/visualizer.py create mode 100644 hastelib/src/hastegeo/core/utils/model_readiness.py create mode 100644 hastelib/tests/core/processors/test_visualizer_payload.py create mode 100644 hastelib/tests/core/utils/test_model_readiness.py create mode 100644 hastelib/tests/core/utils/test_prediction_source.py delete mode 100644 ui/src/Components/PredictionEditor/PredictionEditor.jsx delete mode 100644 ui/src/Components/PredictionEditor/predictionClassify.test.js rename ui/src/Components/{PredictionEditor/PredictionEditorRightPanel.jsx => Visualizer/PredictionEditPanel.jsx} (83%) create mode 100644 ui/src/Components/Visualizer/PredictionStatusNote.jsx rename ui/src/Components/{PredictionEditor => Visualizer}/predictionClassify.js (100%) create mode 100644 ui/src/Components/Visualizer/predictionClassify.test.js create mode 100644 ui/src/Components/Visualizer/predictionFootprintMap.js rename ui/src/Components/{PredictionEditor => Visualizer}/predictionPrep.js (100%) create mode 100644 ui/src/Components/Visualizer/predictionResults.js create mode 100644 ui/src/Components/Visualizer/usePredictionArtifacts.js create mode 100644 ui/src/Components/Visualizer/usePredictionFootprints.js rename ui/src/Components/{PredictionEditor/predictionSwipe.js => Visualizer/visualizerSwipe.js} (50%) diff --git a/api/hastefuncapi/function_app.py b/api/hastefuncapi/function_app.py index 52a9862d..81553e3e 100644 --- a/api/hastefuncapi/function_app.py +++ b/api/hastefuncapi/function_app.py @@ -11,7 +11,6 @@ import traceback import azure.functions as func # type: ignore -import requests # type: ignore from azure.core.exceptions import ResourceNotFoundError # type: ignore from hastegeo.core.config import Config from hastegeo.core.models.admin import AdminConfig @@ -43,7 +42,6 @@ ) from hastegeo.core.models.training import CatalogModel from hastegeo.core.models.users import User -from hastegeo.core.models.visualizer import Imagery, Visualizer from hastegeo.core.processors.artifacts import ArtifactProcessor from hastegeo.core.processors.assessment import AssessmentReportProcessor from hastegeo.core.processors.embedding import EmbeddingPreprocessor @@ -87,6 +85,7 @@ from hastegeo.core.utils.data import convert_json_to_geojson, filter_roles from hastegeo.core.utils.logs import Logger from hastegeo.core.utils.metadata import MetadataUtils +from hastegeo.core.utils.model_readiness import annotate_predictions_ready from hastegeo.core.utils.source_types import normalize_source_type from hastegeo.core.utils.url_allowlist import ( validate_clip_bbox, @@ -134,6 +133,9 @@ # to leave room for the field to grow without ever admitting an unbounded # string into log lines or blob paths. _SHORT_INT_ID_RE = re.compile(r"^[0-9]{1,8}$") +# Edited-prediction version numbers (Model.editedPredictions[].version). +# Bounded like the other id patterns; 0 means "the raw model output". +_VERSION_RE = re.compile(r"^[0-9]{1,6}$") _PUBLISH_ASSESSMENT_MAX_TOTAL_BYTES = 512 * 1024**2 @@ -162,6 +164,23 @@ def _require_short_int_id_param(req: func.HttpRequest, name: str) -> str: return value +def _optional_version_param( + req: func.HttpRequest, name: str = "version" +) -> int | None: + """Return an optional edited-prediction version, or raise ValueError. + + Absent or empty selects the default source (the newest analyst edit, + falling back to the raw model output). ``0`` explicitly selects the + raw model output even when edits exist. + """ + value = (req.params.get(name) or "").strip() + if not value: + return None + if not _VERSION_RE.match(value): + raise ValueError(f"Invalid format for parameter: {name}") + return int(value) + + def _require_email_param(req: func.HttpRequest, name: str) -> str: """Return a request parameter validated as an email address, or raise ValueError.""" value = req.params.get(name) @@ -774,6 +793,9 @@ async def GetProjectDetails(req: func.HttpRequest) -> func.HttpResponse: key=lambda x: x["creationDate"], reverse=True ) for model in match_models: + # Server-derived "this model has results" flag, so + # trained and embedding rows gate on one rule. + annotate_predictions_ready(model) try: artifacts = await asyncio.to_thread( MetadataProcessor( @@ -1247,6 +1269,11 @@ async def GetLayerDetailView(req: func.HttpRequest) -> func.HttpResponse: for model in models if model["imageLayerId"] == image_layer_id ] + # Server-derived "this model has results" flag (see + # hastegeo.core.utils.model_readiness) — the same value + # GetLayerModelsDetails and GetProjectDetails return. + for model in match_models: + annotate_predictions_ready(model) image_layer["models"] = match_models image_layer["modelCount"] = len(match_models) return func.HttpResponse(json.dumps(image_layer), status_code=200) @@ -1360,6 +1387,10 @@ async def GetLayerModelsDetails(req: func.HttpRequest) -> func.HttpResponse: for model in models if model["imageLayerId"] == image_layer_id ] + # Server-derived "this model has results" flag, so the model rows + # do not each re-derive it from a different set of fields. + for model in match_models: + annotate_predictions_ready(model) return func.HttpResponse(json.dumps(match_models), status_code=200) @@ -2273,9 +2304,41 @@ async def GetUserById(req: func.HttpRequest) -> func.HttpResponse: methods=["GET"], ) async def GetVisualizerResults(req: func.HttpRequest) -> func.HttpResponse: + """Return everything the results viewer needs for ONE model. + + Workflow-agnostic and vector-first. Both workflows get the building + footprint tiles plus the model's prediction attribute sidecar (as + API-relative ``GetModelArtifact`` routes); only the trained-inference + workflow additionally gets the two raster layers, because only it + writes COGs. ``predictedDamageLayer``/``predictionsLayer`` are + therefore ``null`` for an embedding model rather than tile templates + over a URL that does not exist. + + Query params: + projectId (GUID), imageLayerId (GUID), modelId (short int id), + version (int, optional): edited-prediction version to read. + Omit for the newest edit (falling back to the raw model + output); pass ``0`` to force the raw model output. + + The payload assembly lives in + :func:`hastegeo.core.processors.visualizer.build_visualizer_results`. + """ logger.info( "GetVisualizerResults HTTP trigger function processed a request." ) + # Lazy imports: the prediction reader and the payload builder pull in + # fiona at module scope (same pattern as GetPredictionEditSession). + from hastegeo.core.processors.visualizer import ( + PredictionInfo, + build_visualizer_results, + ) + from hastegeo.core.utils.predictions import ( + PredictionVersionNotFoundError, + describe_prediction_source, + read_predictions, + ) + + gpkg_path = None try: try: project_id = _require_guid_param(req, "projectId") @@ -2284,6 +2347,7 @@ async def GetVisualizerResults(req: func.HttpRequest) -> func.HttpResponse: # (currently "0000"-"9999"), not a UUID — so the GUID validator # rejected every real value. See _require_short_int_id_param. model_id = _require_short_int_id_param(req, "modelId") + version = _optional_version_param(req) except ValueError as ve: return _bad_request(f"GetVisualizerResults: {ve}") @@ -2329,111 +2393,61 @@ async def GetVisualizerResults(req: func.HttpRequest) -> func.HttpResponse: **match_label_projects[0] if match_label_projects else {} ) - titiler_ep = config.titiler_endpoint - # URL needs to include SAS token for the image to be accessible - # Also needs to be urlencoded so that SAS is not mangled - pre_event_image_URL = ( - requests.utils.quote( - image_layer.preEventProcessedImageryUrl, safe="" - ) - if image_layer.preEventImageryUrls - else "" - ) - post_disaster_image_URL = requests.utils.quote( - image_layer.postEventProcessedImageryUrl, safe="" - ) - predicted_damage_layer_URL = ( - requests.utils.quote(model_data.predictedDamageLayerUrl, safe="") - if model_data.predictedDamageLayerUrl - else "" - ) - # The inference workflow always produces a `_predictions.tif` next to - # `_visualizer.tif`, sharing the same container SAS. Derive its URL by - # swapping the suffix rather than persisting a separate model field. - predictions_url_raw = ( - model_data.predictedDamageLayerUrl.replace( - "_visualizer.tif", "_predictions.tif" + # Analyst edits win over the raw model output unless the caller + # pins a version (ADR-0005: newest-wins plus explicit override, + # no mutable "active version" pointer on the model). + try: + source = describe_prediction_source(model_data, version=version) + except PredictionVersionNotFoundError as e: + logger.warning(f"GetVisualizerResults: {e}") + return func.HttpResponse( + "Requested prediction version not found.", status_code=404 ) - if model_data.predictedDamageLayerUrl - else None - ) - predictions_layer_URL = ( - requests.utils.quote(predictions_url_raw, safe="") - if predictions_url_raw - else "" - ) - # TiTiler colormap overrides the embedded TIFF palette (whose alpha=0 entry - # is silently dropped by TIFF). Maps pixel values 0/1 -> transparent, - # 2 -> green, 3 -> red, matching the inference.py palette. - predictions_colormap = requests.utils.quote( - json.dumps( - { - "0": [0, 0, 0, 0], - "1": [0, 0, 0, 0], - "2": [0, 255, 0, 255], - "3": [255, 0, 0, 255], - } - ), - safe="", - ) - visualizer = Visualizer( - projectId=project_id, - imageLayerId=image_layer_id, - modelId=model_id, - projectName=project.name, - studyArea=label_project.features, - eventDate=project.eventDate, - # NOTE: predictedDamageImageryDownloadUrl will be a screenshot for pre-release, could be something else in the future - preDisasterImagery=Imagery( - # If no image is uploaded, then the base Azure Map will be displayed in the pre section - url=( - f"{titiler_ep}cog/tiles/WebMercatorQuad/{{z}}/{{x}}/{{y}}?scale=1&url={pre_event_image_URL}" - if pre_event_image_URL - else "" - ), - bounds=( - label_project.features[0].bbox - if label_project.features - else None - ), - ), - postDisasterImagery=Imagery( - url=f"{titiler_ep}cog/tiles/WebMercatorQuad/{{z}}/{{x}}/{{y}}?scale=1&url={post_disaster_image_URL}", - bounds=( - label_project.features[0].bbox - if label_project.features - else None - ), - ), - predictedDamageLayer=Imagery( - url=f"{titiler_ep}cog/tiles/WebMercatorQuad/{{z}}/{{x}}/{{y}}?scale=1&url={predicted_damage_layer_URL}", - bounds=( - label_project.features[0].bbox - if label_project.features - else None - ), - ), - predictionsLayer=Imagery( - url=( - f"{titiler_ep}cog/tiles/WebMercatorQuad/{{z}}/{{x}}/{{y}}?scale=1&url={predictions_layer_URL}&colormap={predictions_colormap}" - if predictions_layer_URL - else "" - ), - bounds=( - label_project.features[0].bbox - if label_project.features - else None - ), - ), - sourceTypePreEvent=image_layer.sourceTypePreEvent, - sourceTypePostEvent=image_layer.sourceTypePostEvent, - imageryCaptureDatePreEvent=image_layer.imageryCaptureDatePreEvent, - imageryCaptureDatePostEvent=image_layer.imageryCaptureDatePostEvent, + predictions_info = PredictionInfo(version=source.version) + if source.url: + # Read the selected GeoPackage for its flavor: the embedding + # producer's damage fraction is a degenerate 0/1 copy of + # `damaged`, so the UI must not offer re-thresholding there. + # Best effort — a viewer that cannot read the file still gets + # its imagery, its rasters and its readiness state. + try: + gpkg_path = await download_blob_to_tempfile( + source.url, suffix=".gpkg" + ) + predictions = await asyncio.to_thread( + read_predictions, gpkg_path + ) + predictions_info = PredictionInfo( + version=source.version, + flavor=predictions.flavor, + supports_threshold=predictions.supports_threshold, + building_count=len(predictions), + ) + except Exception as e: + logger.warning( + f"GetVisualizerResults could not read predictions for " + f"model {model_id}: {e}" + ) + + visualizer = build_visualizer_results( + project=project, + image_layer=image_layer, + model=model_data, + titiler_endpoint=config.titiler_endpoint, + study_area=label_project.features, + predictions=predictions_info, ) return func.HttpResponse( - json.dumps(visualizer.dict()), status_code=200 + json.dumps(visualizer.dict()), + status_code=200, + mimetype="application/json", + ) + except FileNotFoundError as e: + logger.warning(f"GetVisualizerResults source not found: {e}") + return func.HttpResponse( + "Model, project or image layer not found.", status_code=404 ) except Exception as e: logger.error( @@ -2443,6 +2457,12 @@ async def GetVisualizerResults(req: func.HttpRequest) -> func.HttpResponse: return func.HttpResponse( "Error loading visualizer results.", status_code=500 ) + finally: + if gpkg_path and os.path.exists(gpkg_path): + try: + os.unlink(gpkg_path) + except OSError: + pass @app.route( @@ -2901,11 +2921,10 @@ def _invalid_body(error: ValidationError) -> func.HttpResponse: def _edited_versions(model_data: dict) -> list: """Model.editedPredictions, newest version first.""" - return sorted( - model_data.get("editedPredictions") or [], - key=lambda entry: entry.get("version") or 0, - reverse=True, - ) + # Lazy import: hastegeo.core.utils.predictions pulls in fiona. + from hastegeo.core.utils.predictions import edited_prediction_versions + + return edited_prediction_versions(model_data) @app.route( @@ -4741,6 +4760,9 @@ async def GetValidationReport(req: func.HttpRequest) -> func.HttpResponse: projectId (str): Parent project identifier. imageLayerId (str): Image layer identifier. modelId (str): Model identifier whose inference results to use. + version (int, optional): Edited-prediction version to report on. + Omit to use the newest analyst edit (falling back to the raw + model output); pass ``0`` to force the raw model output. Returns JSON: { @@ -4762,6 +4784,13 @@ async def GetValidationReport(req: func.HttpRequest) -> func.HttpResponse: logger.info( "GetValidationReport HTTP trigger function processed a request." ) + # Lazy import: hastegeo.core.utils.predictions pulls in fiona at + # module scope (same pattern as GetPredictionEditSession). + from hastegeo.core.utils.predictions import ( + PredictionVersionNotFoundError, + resolve_prediction_source, + ) + try: project_id = req.params.get("projectId") image_layer_id = req.params.get("imageLayerId") @@ -4772,6 +4801,10 @@ async def GetValidationReport(req: func.HttpRequest) -> func.HttpResponse: "projectId, imageLayerId and modelId are required.", status_code=400, ) + try: + version = _optional_version_param(req) + except ValueError as ve: + return _bad_request(f"GetValidationReport: {ve}") # ── 1. Load model (modelType picks the label store; gpkgUrl needed) ──── model_data = await asyncio.to_thread( @@ -4782,7 +4815,18 @@ async def GetValidationReport(req: func.HttpRequest) -> func.HttpResponse: model_id, ) - gpkg_url = model_data.get("gpkgUrl") + # Report on the analyst-edited predictions when there are any, so + # corrections made in the editor reach the metrics instead of + # stopping at the saved GeoPackage. + try: + gpkg_url = resolve_prediction_source(model_data, version=version) + except PredictionVersionNotFoundError as e: + logger.warning(f"GetValidationReport: {e}") + return func.HttpResponse( + json.dumps({"error": str(e)}), + status_code=404, + mimetype="application/json", + ) if not gpkg_url: return func.HttpResponse( json.dumps( @@ -5048,6 +5092,9 @@ async def GetAssessmentReport(req: func.HttpRequest) -> func.HttpResponse: building is called damaged (default 0.1, same as the CLI). minAreaM2 (float, optional): Minimum footprint area in m² for the population extrapolation (default 50). + version (int, optional): Edited-prediction version to assess. + Omit to use the newest analyst edit (falling back to the raw + model output); pass ``0`` to force the raw model output. """ logger.info( "GetAssessmentReport HTTP trigger function processed a request." @@ -5057,6 +5104,10 @@ async def GetAssessmentReport(req: func.HttpRequest) -> func.HttpResponse: build_assessment_inputs_from_gpkgs, compute_assessment_report, ) + from hastegeo.core.utils.predictions import ( + PredictionVersionNotFoundError, + resolve_prediction_source, + ) project_id = req.params.get("projectId") image_layer_id = req.params.get("imageLayerId") @@ -5091,6 +5142,10 @@ async def GetAssessmentReport(req: func.HttpRequest) -> func.HttpResponse: return func.HttpResponse( "minAreaM2 must be >= 0.", status_code=400 ) + try: + version = _optional_version_param(req) + except ValueError as ve: + return _bad_request(f"GetAssessmentReport: {ve}") # Load model + image layer to get the two blob URLs we need. model_data = await asyncio.to_thread( @@ -5100,7 +5155,17 @@ async def GetAssessmentReport(req: func.HttpRequest) -> func.HttpResponse: ).load, model_id, ) - gpkg_url = model_data.get("gpkgUrl") + # Assess the analyst-edited predictions when there are any, so + # the damage counts reflect the corrections that were saved. + try: + gpkg_url = resolve_prediction_source(model_data, version=version) + except PredictionVersionNotFoundError as e: + logger.warning(f"GetAssessmentReport: {e}") + return func.HttpResponse( + json.dumps({"error": str(e)}), + status_code=404, + mimetype="application/json", + ) if not gpkg_url: return func.HttpResponse( json.dumps( diff --git a/docs/api/hastefuncapi.md b/docs/api/hastefuncapi.md index 9edc6a43..8ac2756c 100644 --- a/docs/api/hastefuncapi.md +++ b/docs/api/hastefuncapi.md @@ -52,10 +52,116 @@ All functions are defined in `function_app.py` as a single Azure Functions app. | PUT | `PutRunInferenceQueueMessage` | Queue an inference run. | | PUT | `PutRunEmbeddingQueueMessage` | Queue a building-embedding job for the building labeling workflow. Creates a `modelType="embedding"` model; needs no labels, only the layer's imagery and cached footprints. | | DELETE | `DeleteModel` | Delete a model. Requires `projectId` and `modelId`. | -| GET | `GetVisualizerResults` | Visualizer data with imagery layers and TiTiler tile URLs with colormaps. Requires `projectId`, `imageLayerId`, and `modelId`. | +| GET | `GetVisualizerResults` | Results-viewer data for one model: imagery tile URLs, the vector prediction artifacts, readiness, and (inference models only) the raster prediction layers. Requires `projectId`, `imageLayerId`, and `modelId`. See [below](#get-getvisualizerresults). | | PUT | `PutArtifactsZipQueueMessage` | Queue a job to zip model artifacts for download. | | GET | `GetModelArtifact` | Stream a model artifact (or an image layer's footprint tiles) through the function app instead of a direct blob SAS URL, honoring HTTP `Range`. See [Model artifacts](#model-artifacts). | +#### `predictionsReady` on model payloads + +Every endpoint that returns model objects — `GetProjectDetails`, +`GetLayerDetailView` and `GetLayerModelsDetails` — adds a server-derived +boolean **`predictionsReady`** to each model. It is the single answer to "does +this model have results worth opening", so trained and embedding rows stop +re-deriving it from different fields. + +The rule lives in `hastegeo.core.utils.model_readiness` and is the same one +data publishing uses to decide whether a model is publishable: + +| `modelType` | Ready when | +|-------------|-----------| +| `embedding` | `status == "Processed"` **and** `gpkgUrl` is set **and** `predictedBuildingCount > 0` (a model predating that counter falls back to `gpkgUrl` alone; `0` means the analyst cleared their labels, so it is *not* ready) | +| anything else (trained inference) | `inferenceStatus == "Processed"` **and** `gpkgUrl` or `predictedDamageLayerUrl` is set | + +`predictionsReady` is derived on every read and is never persisted — it does +not exist in metadata storage and must not be sent in a `PutModel` body. + +#### `GET GetVisualizerResults` + +Everything the results viewer needs for one model, for **both** workflows. +Vector-first: the building footprint tiles plus the model's prediction +attribute sidecar are returned for every model, and the two TiTiler raster +layers only when the model actually wrote COGs (trained inference). + +**Query params:** `projectId` (GUID), `imageLayerId` (GUID), `modelId`, and +optional `version` (integer). Omit `version` for the newest analyst edit, +falling back to the raw model output; pass `0` to force the raw model output. +See [Prediction Editing](#prediction-editing). + +**Response (200):** + +```json +{ + "projectId": "…", "imageLayerId": "…", "modelId": "5557", + "projectName": "Hurricane X", "eventDate": "2025-10-04", + "studyArea": [ { "type": "Feature", "bbox": [ … ], … } ], + "predictedDamageImageryDownloadUrl": "", + + "preDisasterImagery": { "url": "https://titiler…/{z}/{x}/{y}?…", "bounds": [ … ], "tms": false, "attribution": "AI For Good Lab", "minZoom": 12, "maxNativeZoom": 20, "maxZoom": 21 }, + "postDisasterImagery": { "url": "https://titiler…/{z}/{x}/{y}?…", "bounds": [ … ], … }, + "predictedDamageLayer": { "url": "https://titiler…", "bounds": [ … ], … }, + "predictionsLayer": { "url": "https://titiler…&colormap=…", "bounds": [ … ], … }, + + "footprintTilesUrl": "GetModelArtifact?projectId=…&modelId=5557&kind=footprint_pmtiles&imageLayerId=…", + "predictionAttrsUrl": "GetModelArtifact?projectId=…&modelId=5557&kind=prediction_attrs", + "flavor": "inference", + "supportsThreshold": true, + "buildingCount": 125430, + "predictionVersion": null, + "predictionVersions": [ { "version": 1, "gpkgUrl": "…", "createdAt": "…", "createdBy": "…", "threshold": 0.5, "unknownThreshold": 0.0, "editedCount": 53, "sourceGpkgUrl": "…" } ], + "predictionsReady": true, + "predictionsReadiness": { + "ready": true, "reason": "ready", "detail": "", + "workflow": "inference", "status": "Processed", + "tilesReady": true, "attrsReady": true, + "predictionTilesStatus": "Processed", "predictionTilesStatusMessage": "" + }, + + "sourceTypePreEvent": "…", "sourceTypePostEvent": "…", + "imageryCaptureDatePreEvent": "…", "imageryCaptureDatePostEvent": "…" +} +``` + +- **`predictedDamageLayer` / `predictionsLayer` are nullable.** They are + `null` for every embedding model (that workflow writes no rasters) and for an + inference model that has not produced them yet. `predictionsLayer` is derived + from `predictedDamageLayerUrl` by swapping `_visualizer.tif` for + `_predictions.tif`, and is `null` when that suffix is absent instead of a URL + that 404s every tile. Callers must null-check both before reading `.url`. + All other previously existing fields keep their old names, types and URL + formats. +- **`footprintTilesUrl` / `predictionAttrsUrl` are API-relative + `GetModelArtifact` routes**, not blob URLs — pass them through the UI's + `buildUrl()`. Serving them through + [`GetModelArtifact`](#get-getmodelartifact) keeps auth, managed identity and + `Range` support. For an embedding model `footprint_pmtiles` resolves to the + model's own `pmtilesUrl` (same `resolve_tiles_url` seam the prediction editor + uses); otherwise it is the layer's shared `footprintPmtilesUrl`. Either field + is `null` when that artifact has not been built yet. +- **`flavor` / `supportsThreshold` / `buildingCount`** come from reading the + selected prediction GeoPackage, exactly as in + [`GetPredictionEditSession`](#get-getpredictioneditsession). Re-thresholding + is meaningless for `"embedding"` (`supportsThreshold: false`), whose + `damage_pct_0m` is a degenerate 0/1 copy of `damaged`. All three are `null` + when the file could not be read; the rest of the payload is still returned. +- **`predictionVersion`** is the edited version that was read (`null` = raw + model output); **`predictionVersions`** is `Model.editedPredictions`, newest + first, so the viewer can offer a version switch without a second round trip. +- **`predictionsReady`** here is stricter than the model-payload flag above: it + additionally requires both browser artifacts to exist, because that is what + the viewer actually needs to draw. `predictionsReadiness.reason` is one of + `ready`, `not_processed`, `no_predictions`, `no_buildings` or `preparing`, + with a human-readable `detail`; `preparing` means the model has predictions + but the prediction-tiles job has not finished, so the UI should show a "still + preparing" state (and may request the work with + [`PutPreparePredictionTilesQueueMessage`](#put-putpreparepredictiontilesqueuemessage)) + rather than an empty map. + +| Code | Condition | +|------|-----------| +| 400 | Missing or malformed `projectId`, `imageLayerId`, `modelId`, or `version` | +| 404 | Model, project or image layer not found, or the requested `version` does not exist | +| 500 | Metadata or storage failure | + ### Building Labeling (Interactive Labeler) The building labeling workflow trains a small model **in the browser** from @@ -105,6 +211,11 @@ versioned GeoPackage**. See `spec/features/prediction-editing/` and > `Model.gpkgUrl` always points at the RAW model output and is never rewritten > by these endpoints — it is the source every future edit derives from. Saves > append to `Model.editedPredictions` instead. +> +> Readers (`GetVisualizerResults`, `GetValidationReport`, +> `GetAssessmentReport`) default to the **newest** saved version and accept a +> `version` query param to pin one — see +> [Reading edited predictions](#reading-edited-predictions-version). Typical call order: `GetPredictionEditSession` → `PutPreparePredictionTilesQueueMessage` when it reports `tilesReady: false` or @@ -363,8 +474,32 @@ These endpoints use `FUNCTION`-level auth regardless of development mode (intend | GET | `GetBuildingFootprintsGeoJSON` | Random sample of building footprints as a GeoJSON FeatureCollection. `sample` param controls count (1–2000, default 200). | | GET | `GetBuildingValidation` | Existing building validation labels for a layer (Damaged / NotDamaged / Unknown). | | PUT | `PutBuildingValidation` | Save (replace) building validation labels for a layer. | -| GET | `GetValidationReport` | Validation accuracy report: confusion matrix, accuracy, precision, recall, F1. Crosses inference results with user-supplied labels. | -| GET | `GetAssessmentReport` | Full damage assessment: precision/recall/AP against labels, plus a finite-population estimate with 95% CI for damaged building count. Supports `threshold` and `minAreaM2` query params. | +| GET | `GetValidationReport` | Validation accuracy report: confusion matrix, accuracy, precision, recall, F1. Crosses inference results with user-supplied labels. Supports the `version` query param. | +| GET | `GetAssessmentReport` | Full damage assessment: precision/recall/AP against labels, plus a finite-population estimate with 95% CI for damaged building count. Supports `threshold`, `minAreaM2` and `version` query params. | + +#### Reading edited predictions (`version`) + +`GetValidationReport`, `GetAssessmentReport` and +[`GetVisualizerResults`](#get-getvisualizerresults) all resolve which +prediction GeoPackage to read through +`hastegeo.core.utils.predictions.resolve_prediction_source`: + +| `version` | Source read | +|-----------|-------------| +| omitted (or empty) | the **newest** entry in `Model.editedPredictions`, falling back to `Model.gpkgUrl` when the model has never been edited | +| `0` | `Model.gpkgUrl` — the raw model output, even when edits exist | +| `N` | the `Model.editedPredictions` entry whose `version == N` | + +An unknown `N` returns **404** with `{"error": "..."}`; a non-numeric value +returns **400**. There is deliberately no mutable "active version" pointer on +the model — newest-wins plus an explicit override is the design in +[ADR-0005](../../spec/architecture/decisions/0005-versioned-derived-prediction-artifacts.md). + +Note that an edited GeoPackage overrides `damaged` (and adds `edited_class`, +`edit_threshold`, `overture_id`) but preserves the producer's original +`damage_pct_0m`. `GetValidationReport` reads `damaged`, so analyst corrections +change its metrics directly; `GetAssessmentReport` thresholds `damage_pct_0m`, +so per-building overrides do not move its threshold-based counts. ### Data Publishing diff --git a/hastelib/src/hastegeo/core/models/visualizer.py b/hastelib/src/hastegeo/core/models/visualizer.py index 3b3b5976..0d9e01d4 100644 --- a/hastelib/src/hastegeo/core/models/visualizer.py +++ b/hastelib/src/hastegeo/core/models/visualizer.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Optional +from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field @@ -12,7 +12,34 @@ class Imagery(BaseModel): minZoom: int = Field(default=12) maxNativeZoom: int = Field(default=20) maxZoom: int = Field(default=21) - bounds: list = Field(default_factory=list) + # Optional because a project with no study-area polygon has no bbox + # to bound the tiles with; the map then falls back to its own view. + bounds: Optional[list] = Field(default_factory=list) + + +class PredictionsReadiness(BaseModel): + """Why the viewer can (or cannot) draw this model's predictions yet. + + ``ready`` is the vector path's readiness: the model has predictions + *and* the two browser artifacts (footprint PMTiles + attribute + sidecar) exist. When it is ``False`` the UI shows ``detail`` — a + "still preparing" or "not processed" message — instead of an empty + map. + """ + + ready: bool = Field(default=False) + # Machine-readable code the UI branches on: "ready", "not_processed", + # "no_predictions", "no_buildings" or "preparing". + reason: str = Field(default="") + detail: str = Field(default="") + # "embedding" or "inference". + workflow: str = Field(default="") + # The workflow-relevant model status that was checked. + status: Optional[str] = Field(default=None) + tilesReady: bool = Field(default=False) + attrsReady: bool = Field(default=False) + predictionTilesStatus: Optional[str] = Field(default=None) + predictionTilesStatusMessage: str = Field(default="") class Visualizer(BaseModel): @@ -25,8 +52,34 @@ class Visualizer(BaseModel): predictedDamageImageryDownloadUrl: str = Field(default="") preDisasterImagery: Imagery = Field(default_factory=Imagery) postDisasterImagery: Imagery = Field(default_factory=Imagery) - predictedDamageLayer: Imagery = Field(default_factory=Imagery) - predictionsLayer: Imagery = Field(default_factory=Imagery) + # ── Raster prediction layers (trained-inference workflow only) ────── + # Both are TiTiler tile templates over COGs the inference job wrote. + # The embedding workflow produces no rasters at all, so these are + # None there — an empty tile URL would just 404 every tile request. + predictedDamageLayer: Optional[Imagery] = Field(default=None) + predictionsLayer: Optional[Imagery] = Field(default=None) + # ── Vector prediction layer (both workflows) ──────────────────────── + # API-relative GetModelArtifact routes, not blob URLs: the artifacts + # stream through the function app (auth + Range + managed identity), + # and the UI turns them into absolute URLs with its own buildUrl(). + footprintTilesUrl: Optional[str] = Field(default=None) + predictionAttrsUrl: Optional[str] = Field(default=None) + # Prediction flavor, from hastegeo.core.utils.predictions. The + # embedding producer's damage fraction is a degenerate 0/1 copy of + # `damaged`, so re-thresholding it is meaningless there. + flavor: Optional[str] = Field(default=None) + supportsThreshold: Optional[bool] = Field(default=None) + buildingCount: Optional[int] = Field(default=None) + # Which prediction GeoPackage was read: an analyst-edited version + # number, or None for the raw model output. + predictionVersion: Optional[int] = Field(default=None) + # Model.editedPredictions, newest version first, so the viewer can + # offer a version switch without a second round trip. + predictionVersions: List[Dict[str, Any]] = Field(default_factory=list) + predictionsReady: bool = Field(default=False) + predictionsReadiness: PredictionsReadiness = Field( + default_factory=PredictionsReadiness + ) sourceTypePreEvent: Optional[str] = Field(default=None) sourceTypePostEvent: Optional[str] = Field(default=None) imageryCaptureDatePreEvent: Optional[str] = Field(default=None) diff --git a/hastelib/src/hastegeo/core/processors/visualizer.py b/hastelib/src/hastegeo/core/processors/visualizer.py new file mode 100644 index 00000000..7d30eb5c --- /dev/null +++ b/hastelib/src/hastegeo/core/processors/visualizer.py @@ -0,0 +1,336 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Assemble the results-viewer payload for BOTH prediction workflows. + +The viewer used to be raster-only: it handed back TiTiler tile templates +over the two COGs the inference job writes (``_visualizer.tif`` and +``_predictions.tif``). The embedding workflow produces no rasters at +all — the interactive labeler posts per-building calls straight to a +GeoPackage — so an embedding model had nothing to show and, in practice, +no viewer entry point. + +Both workflows *do* have vector artifacts, and they are the same two in +both cases: + +* the building footprints as PMTiles — the layer's shared archive + (``ImageLayer.footprintPmtilesUrl``) or, for an embedding model, the + archive it already tiled for the labeler (``Model.pmtilesUrl``); + :func:`~hastegeo.core.processors.prediction_tiles.resolve_tiles_url` + is the seam that picks between them, and +* the model's columnar prediction attribute sidecar + (``Model.predictionAttrsUrl``), keyed by the same row-index id the + tiles carry. + +So this module builds a *vector-first* payload: the two artifacts always +(as API-relative ``GetModelArtifact`` routes, never raw blob SAS URLs), +the rasters only when the model actually has them, and a readiness block +so the UI can say "still preparing" instead of drawing an empty map. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Sequence +from urllib.parse import quote, urlencode + +from ..config import Config +from ..models.projects import ImageLayer, Model, Project +from ..models.visualizer import Imagery, PredictionsReadiness, Visualizer +from ..utils.model_readiness import REASON_READY, prediction_readiness +from ..utils.predictions import edited_prediction_versions +from .prediction_tiles import resolve_tiles_url + +# The inference workflow writes a pre-coloured visualizer COG next to the +# raw prediction COG, sharing one container SAS. The raw one has never +# been stored on the Model, so it is still derived by swapping the +# suffix — but only when the suffix is actually there, so a differently +# named layer yields no layer instead of a URL that 404s every tile. +VISUALIZER_COG_SUFFIX = "_visualizer.tif" +PREDICTIONS_COG_SUFFIX = "_predictions.tif" + +# TiTiler colormap overrides the embedded TIFF palette (whose alpha=0 +# entry is silently dropped by TIFF). Maps pixel values 0/1 -> +# transparent, 2 -> green, 3 -> red, matching the inference.py palette. +PREDICTIONS_COLORMAP: Dict[str, List[int]] = { + "0": [0, 0, 0, 0], + "1": [0, 0, 0, 0], + "2": [0, 255, 0, 255], + "3": [255, 0, 0, 255], +} + +TILE_PATH = "cog/tiles/WebMercatorQuad/{z}/{x}/{y}" + +#: Artifacts are served through this route rather than as blob URLs: it +#: streams with Range support, managed identity and the app's auth, so +#: analysts outside the storage firewall allowlist can still read them. +MODEL_ARTIFACT_ROUTE = "GetModelArtifact" +FOOTPRINT_TILES_KIND = "footprint_pmtiles" +PREDICTION_ATTRS_KIND = "prediction_attrs" + +#: Extra readiness reason: the model has predictions, but the browser +#: artifacts are still being built by the prediction-tiles job. +REASON_PREPARING = "preparing" + + +@dataclass +class PredictionInfo: + """What the caller learned by opening the selected prediction file. + + Reading a GeoPackage is I/O, so the HTTP layer does it (it already + downloads blobs) and passes the result in; this module stays a pure + payload assembler that tests can drive with plain data. + + Attributes: + version: Edited version that was read, ``None`` for the raw + model output. + flavor: ``"inference"`` or ``"embedding"``, from + :func:`~hastegeo.core.utils.predictions.read_predictions`. + supports_threshold: Whether re-thresholding the damage fraction + is meaningful for this flavor. + building_count: Rows in the prediction file. + """ + + version: Optional[int] = None + flavor: Optional[str] = None + supports_threshold: Optional[bool] = None + building_count: Optional[int] = None + + +def model_artifact_url( + project_id: str, + model_id: str, + kind: str, + image_layer_id: Optional[str] = None, +) -> str: + """Build the API-relative ``GetModelArtifact`` route for one artifact. + + Relative on purpose: the function app does not know the client's API + base URL (or its APIM subscription key), and the UI already funnels + every call through its own ``buildUrl()``. + """ + params = [ + ("projectId", project_id or ""), + ("modelId", model_id or ""), + ("kind", kind), + ] + if image_layer_id: + params.append(("imageLayerId", image_layer_id)) + return f"{MODEL_ARTIFACT_ROUTE}?{urlencode(params)}" + + +def _tile_url( + titiler_endpoint: str, + blob_url: str, + colormap: Optional[Dict[str, List[int]]] = None, +) -> str: + """Build a TiTiler XYZ template for one COG. + + The blob URL carries a SAS token, so it must be percent-encoded in + full or TiTiler receives a mangled query string. + """ + url = ( + f"{titiler_endpoint}{TILE_PATH}?scale=1&" + f"url={quote(blob_url, safe='')}" + ) + if colormap: + url += "&colormap=" + quote(json.dumps(colormap), safe="") + return url + + +def _study_area_bounds(study_area: Optional[Sequence[Any]]) -> Optional[list]: + """Bounding box of the first study-area feature, if there is one.""" + if not study_area: + return None + feature = study_area[0] + if isinstance(feature, dict): + return feature.get("bbox") + return getattr(feature, "bbox", None) + + +def raster_layer_urls(model: Model) -> Dict[str, Optional[str]]: + """Return the model's two prediction COG URLs, if it has any. + + Only the trained-inference workflow writes rasters, and only the + visualizer COG is stored on the Model; the raw prediction COG sits + next to it under a sibling name. + """ + visualizer_url = model.predictedDamageLayerUrl or "" + if not visualizer_url: + return {"visualizer": None, "predictions": None} + predictions_url: Optional[str] = None + if VISUALIZER_COG_SUFFIX in visualizer_url: + predictions_url = visualizer_url.replace( + VISUALIZER_COG_SUFFIX, PREDICTIONS_COG_SUFFIX + ) + return {"visualizer": visualizer_url, "predictions": predictions_url} + + +def visualizer_readiness( + model: Model, + image_layer: ImageLayer, + config: Optional[Config] = None, +) -> PredictionsReadiness: + """Report whether the viewer can draw this model's predictions. + + Two independent things have to be true, and the UI needs to tell + them apart: the model must actually have predictions (workflow-aware + rule in :mod:`hastegeo.core.utils.model_readiness`), and the two + browser artifacts must have been built by the prediction-tiles job. + The second is transient — the UI shows a "still preparing" state and + polls — while the first usually is not. + """ + base = prediction_readiness(model, config=config) + tiles_ready = bool(resolve_tiles_url(model, image_layer)) + attrs_ready = bool(model.predictionAttrsUrl) + + reason = base.reason + detail = base.detail + if base.ready and not (tiles_ready and attrs_ready): + reason = REASON_PREPARING + detail = ( + "Preparing this model's building predictions for display. " + "This runs once per model and can take a few minutes." + ) + elif base.ready: + reason = REASON_READY + detail = "" + + return PredictionsReadiness( + ready=base.ready and tiles_ready and attrs_ready, + reason=reason, + detail=detail, + workflow=base.workflow, + status=base.status, + tilesReady=tiles_ready, + attrsReady=attrs_ready, + predictionTilesStatus=model.predictionTilesStatus, + predictionTilesStatusMessage=( + model.predictionTilesStatusMessage or "" + ), + ) + + +def build_visualizer_results( + project: Project, + image_layer: ImageLayer, + model: Model, + titiler_endpoint: str, + study_area: Optional[Sequence[Any]] = None, + predictions: Optional[PredictionInfo] = None, + config: Optional[Config] = None, +) -> Visualizer: + """Assemble the ``GetVisualizerResults`` payload. + + Args: + project: Owning project (name and event date). + image_layer: The layer whose imagery is shown, and which owns + the shared footprint PMTiles. + model: The model whose predictions are shown. + titiler_endpoint: TiTiler base URL, trailing slash included. + study_area: Label-project features drawn as the study area; the + first feature's bbox also bounds the tile layers. + predictions: What the caller read from the prediction + GeoPackage. ``None`` when it could not be read — the payload + then simply carries no flavor. + config: Optional config override (tests inject a fake). + + Returns: + A :class:`~hastegeo.core.models.visualizer.Visualizer`. Raster + fields are ``None`` for any model without prediction COGs (every + embedding model, and any inference model that has not produced + them yet), so the map never gets a URL that 404s every tile. + """ + info = predictions or PredictionInfo() + bounds = _study_area_bounds(study_area) + rasters = raster_layer_urls(model) + readiness = visualizer_readiness(model, image_layer, config=config) + + pre_event_url = ( + image_layer.preEventProcessedImageryUrl + if image_layer.preEventImageryUrls + else "" + ) or "" + post_event_url = image_layer.postEventProcessedImageryUrl or "" + + project_id = image_layer.projectId or project.projectId or "" + model_id = model.modelId or "" + # Only advertise the vector artifacts that exist. GetModelArtifact + # reuses an embedding model's own PMTiles for the footprint_pmtiles + # kind, which is exactly what resolve_tiles_url decides here. + footprint_tiles_url = ( + model_artifact_url( + project_id, + model_id, + FOOTPRINT_TILES_KIND, + image_layer_id=image_layer.imageLayerId, + ) + if readiness.tilesReady + else None + ) + prediction_attrs_url = ( + model_artifact_url(project_id, model_id, PREDICTION_ATTRS_KIND) + if readiness.attrsReady + else None + ) + + return Visualizer( + projectId=project_id, + imageLayerId=image_layer.imageLayerId or "", + modelId=model_id, + projectName=project.name or "", + studyArea=list(study_area or []), + eventDate=project.eventDate, + preDisasterImagery=Imagery( + # With no pre-event imagery the viewer falls back to the + # base Azure Map in the "pre" pane. + url=( + _tile_url(titiler_endpoint, pre_event_url) + if pre_event_url + else "" + ), + bounds=bounds, + ), + postDisasterImagery=Imagery( + url=( + _tile_url(titiler_endpoint, post_event_url) + if post_event_url + else "" + ), + bounds=bounds, + ), + predictedDamageLayer=( + Imagery( + url=_tile_url(titiler_endpoint, rasters["visualizer"]), + bounds=bounds, + ) + if rasters["visualizer"] + else None + ), + predictionsLayer=( + Imagery( + url=_tile_url( + titiler_endpoint, + rasters["predictions"], + colormap=PREDICTIONS_COLORMAP, + ), + bounds=bounds, + ) + if rasters["predictions"] + else None + ), + footprintTilesUrl=footprint_tiles_url, + predictionAttrsUrl=prediction_attrs_url, + flavor=info.flavor, + supportsThreshold=info.supports_threshold, + buildingCount=info.building_count, + predictionVersion=info.version, + predictionVersions=edited_prediction_versions(model), + predictionsReady=readiness.ready, + predictionsReadiness=readiness, + sourceTypePreEvent=image_layer.sourceTypePreEvent, + sourceTypePostEvent=image_layer.sourceTypePostEvent, + imageryCaptureDatePreEvent=image_layer.imageryCaptureDatePreEvent, + imageryCaptureDatePostEvent=image_layer.imageryCaptureDatePostEvent, + ) diff --git a/hastelib/src/hastegeo/core/publishing/source.py b/hastelib/src/hastegeo/core/publishing/source.py index fcab8637..5a9e8ee1 100644 --- a/hastelib/src/hastegeo/core/publishing/source.py +++ b/hastelib/src/hastegeo/core/publishing/source.py @@ -13,6 +13,7 @@ ) from ..processors.metadata import MetadataProcessor from ..utils.metadata import MetadataUtils +from ..utils.model_readiness import model_is_complete from .open_data import validate_source_refs @@ -146,14 +147,12 @@ def _load_source( raise PublishingSourceNotFoundError( "Model does not belong to the requested project and image layer" ) - completed = self.config.get_status_types().COMPLETED.value # Embedding models signal completion via `status`; trained/inference - # models via `inferenceStatus`. Gate on the field that actually applies. - if model.modelType == "embedding": - is_complete = model.status == completed - else: - is_complete = model.inferenceStatus == completed - if not is_complete: + # models via `inferenceStatus`. `model_is_complete` owns that rule + # for every consumer (it is also what the API's `predictionsReady` + # flag is built on), so publishing eligibility cannot drift from + # what the UI shows as "has results". + if not model_is_complete(model, config=self.config): raise PublishingSourceNotEligibleError( "Model must be Processed before publishing" ) diff --git a/hastelib/src/hastegeo/core/utils/model_readiness.py b/hastelib/src/hastegeo/core/utils/model_readiness.py new file mode 100644 index 00000000..b4807a6b --- /dev/null +++ b/hastelib/src/hastegeo/core/utils/model_readiness.py @@ -0,0 +1,237 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""One rule for "does this model have predictions a reader can use?". + +HASTE finishes a model two different ways, and the two record completion +on *different* fields: + +* **trained inference** (label -> train -> infer) runs on Azure Batch and + reports through ``Model.inferenceStatus``; its predictions land in + ``Model.gpkgUrl`` (plus the ``_visualizer.tif`` COG in + ``Model.predictedDamageLayerUrl``). +* **embedding** (``Model.modelType == "embedding"``) never runs + inference: the interactive labeler trains in the browser and posts its + per-building calls to ``PutBuildingPredictions``. Completion shows up + on ``Model.status``, and the unambiguous "has predictions" signal is + ``Model.predictedBuildingCount`` — clearing the labels re-writes a + valid all-zero GeoPackage, so ``gpkgUrl`` alone cannot tell a cleared + model from a finished one. + +Every consumer that re-derived this rule locally got a slightly +different answer (the results button, the embedding row, and +``publishing/source.py`` each had their own). This module holds the one +rule they all defer to; the API surfaces it as ``predictionsReady`` on +the model payloads the UI already fetches. + +The functions accept either a :class:`~hastegeo.core.models.projects.Model` +or the raw metadata ``dict`` the storage layer returns, because the API +handles both shapes. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Mapping, MutableMapping, Optional, Union + +from ..config import Config + +#: ``Model.modelType`` value of the browser-labeling workflow. +EMBEDDING_MODEL_TYPE = "embedding" + +#: Which workflow produced (or will produce) a model's predictions. +WORKFLOW_EMBEDDING = "embedding" +WORKFLOW_INFERENCE = "inference" + +#: Machine-readable ``reason`` codes. The UI branches on these; the +#: matching ``detail`` string is what it shows the analyst. +REASON_READY = "ready" +REASON_NOT_PROCESSED = "not_processed" +REASON_NO_PREDICTIONS = "no_predictions" +REASON_NO_BUILDINGS = "no_buildings" + +#: A Model instance or the raw metadata dict for one. +ModelLike = Union[Mapping[str, Any], Any] + + +def model_field(model: ModelLike, name: str) -> Any: + """Read one field from a Model instance or a raw metadata dict. + + Returns ``None`` when the field is absent, so callers can tell + "never set" from "set to zero/empty". + """ + if isinstance(model, Mapping): + return model.get(name) + return getattr(model, name, None) + + +def model_workflow(model: ModelLike) -> str: + """Classify a model as ``"embedding"`` or ``"inference"``.""" + model_type = model_field(model, "modelType") or "trained" + if str(model_type).lower() == EMBEDDING_MODEL_TYPE: + return WORKFLOW_EMBEDDING + return WORKFLOW_INFERENCE + + +def completion_status(model: ModelLike) -> Optional[str]: + """Return the status value that decides completion for this model. + + Embedding models signal completion on ``status``; trained models on + ``inferenceStatus``. Gating on the wrong one is why the two + workflows used to disagree about which models had results. + """ + if model_workflow(model) == WORKFLOW_EMBEDDING: + return model_field(model, "status") + return model_field(model, "inferenceStatus") + + +def model_is_complete( + model: ModelLike, config: Optional[Config] = None +) -> bool: + """Report whether the model's producing job finished successfully. + + This is the publishing eligibility rule + (``hastegeo.core.publishing.source``), lifted out so the readiness + helper and the publisher cannot drift apart. It says nothing about + whether any artifact was actually written — see + :func:`prediction_readiness` for that. + """ + statuses = (config or Config).get_status_types() + return completion_status(model) == statuses.COMPLETED.value + + +@dataclass(frozen=True) +class PredictionReadiness: + """Whether a model's predictions can be read, and why not if not. + + Attributes: + ready: ``True`` only when a reader can load predictions now. + reason: One of the ``REASON_*`` codes, for UI branching. + detail: Human-readable sentence for the "not ready" state. + workflow: ``"embedding"`` or ``"inference"``. + status: The workflow-relevant status value that was checked. + """ + + ready: bool + reason: str + detail: str + workflow: str + status: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Serialize for an HTTP payload.""" + return { + "ready": self.ready, + "reason": self.reason, + "detail": self.detail, + "workflow": self.workflow, + "status": self.status, + } + + +def prediction_readiness( + model: ModelLike, config: Optional[Config] = None +) -> PredictionReadiness: + """Decide whether this model has per-building predictions to read. + + Both workflows must clear two bars: the producing job finished + (:func:`model_is_complete`), and it left an artifact behind. + + The artifact check differs per workflow on purpose: + + * embedding — ``gpkgUrl`` plus a positive ``predictedBuildingCount``. + A count of ``0`` means the analyst cleared the labels, which still + writes a valid GeoPackage. A count of ``None`` predates the field, + so those models fall back to "a GeoPackage exists" rather than + being wrongly reported as empty. + * inference — ``gpkgUrl`` or the ``_visualizer.tif`` COG in + ``predictedDamageLayerUrl``; older models have only the raster. + """ + workflow = model_workflow(model) + status = completion_status(model) + + if not model_is_complete(model, config=config): + return PredictionReadiness( + ready=False, + reason=REASON_NOT_PROCESSED, + detail=( + "This model has not finished processing yet." + if workflow == WORKFLOW_EMBEDDING + else "Inference has not finished for this model yet." + ), + workflow=workflow, + status=status, + ) + + gpkg_url = model_field(model, "gpkgUrl") or "" + + if workflow == WORKFLOW_EMBEDDING: + if not gpkg_url: + return PredictionReadiness( + ready=False, + reason=REASON_NO_PREDICTIONS, + detail=( + "No predictions have been saved from the interactive " + "labeler for this model." + ), + workflow=workflow, + status=status, + ) + building_count = model_field(model, "predictedBuildingCount") + if building_count is not None and int(building_count) <= 0: + return PredictionReadiness( + ready=False, + reason=REASON_NO_BUILDINGS, + detail=( + "The saved predictions for this model contain no " + "buildings; label some buildings and save again." + ), + workflow=workflow, + status=status, + ) + return PredictionReadiness( + ready=True, + reason=REASON_READY, + detail="", + workflow=workflow, + status=status, + ) + + raster_url = model_field(model, "predictedDamageLayerUrl") or "" + if not gpkg_url and not raster_url: + return PredictionReadiness( + ready=False, + reason=REASON_NO_PREDICTIONS, + detail="Inference produced no prediction outputs.", + workflow=workflow, + status=status, + ) + return PredictionReadiness( + ready=True, + reason=REASON_READY, + detail="", + workflow=workflow, + status=status, + ) + + +def predictions_ready( + model: ModelLike, config: Optional[Config] = None +) -> bool: + """Boolean shorthand for :func:`prediction_readiness`.""" + return prediction_readiness(model, config=config).ready + + +def annotate_predictions_ready( + model_data: MutableMapping[str, Any], config: Optional[Config] = None +) -> MutableMapping[str, Any]: + """Stamp ``predictionsReady`` onto a model payload, in place. + + The API calls this on every model document it hands the UI so the + rows never have to re-derive readiness client-side. Returns the same + mapping for use in a comprehension. + """ + model_data["predictionsReady"] = predictions_ready( + model_data, config=config + ) + return model_data diff --git a/hastelib/src/hastegeo/core/utils/predictions.py b/hastelib/src/hastegeo/core/utils/predictions.py index 824fe669..e81ae712 100644 --- a/hastelib/src/hastegeo/core/utils/predictions.py +++ b/hastelib/src/hastegeo/core/utils/predictions.py @@ -27,17 +27,22 @@ image layer's footprints GeoPackage **by row order**, matching ``hastegeo.core.utils.assessment.build_assessment_inputs_from_gpkgs``. Pass ``footprints_path`` to resolve those ids. + +:func:`resolve_prediction_source` picks *which* GeoPackage a reader +should open: the newest analyst-edited version when there is one, the +raw model output otherwise. """ from __future__ import annotations import logging from dataclasses import dataclass, field -from typing import Any, List, Optional +from typing import Any, Dict, List, Mapping, Optional import fiona from .gdal_security import harden_gdal +from .model_readiness import ModelLike, model_field # Harden GDAL/OGR drivers before any fiona read of a user-supplied vector # file (GDAL CVE compensating control — docs/known-vulnerabilities.md @@ -59,6 +64,10 @@ # (hastegeo.core.utils.footprints writes them to this column). FOOTPRINT_ID_FIELD = "id" +#: ``version=0`` explicitly selects the raw model output, so a caller can +#: ask for "what the model actually predicted" even after edits exist. +RAW_PREDICTION_VERSION = 0 + @dataclass class PredictionRow: @@ -235,3 +244,158 @@ def read_predictions( layer_name=layer_name, crs=crs, ) + + +class PredictionVersionNotFoundError(ValueError): + """Raised when a requested edited prediction version does not exist. + + The HTTP layer maps this to a 404: the caller asked for a specific + revision of a model's predictions and there is no such revision. + """ + + +@dataclass +class PredictionSource: + """The prediction GeoPackage a reader should open, plus provenance. + + Attributes: + url: Blob URL of the GeoPackage. Empty when the model has no + predictions at all. + version: Edited-version number, or ``None`` for the raw model + output in ``Model.gpkgUrl``. + created_at: When the edited version was saved (``None`` for raw). + created_by: Who saved the edited version (``None`` for raw). + edited_count: Buildings the analyst overrode in this version. + """ + + url: str = "" + version: Optional[int] = None + created_at: Optional[str] = None + created_by: Optional[str] = None + edited_count: int = 0 + + @property + def is_edited(self) -> bool: + """``True`` when this is an analyst-edited version, not the raw.""" + return self.version is not None + + def to_dict(self) -> Dict[str, Any]: + """Serialize for an HTTP payload.""" + return { + "url": self.url, + "version": self.version, + "createdAt": self.created_at, + "createdBy": self.created_by, + "editedCount": self.edited_count, + "isEdited": self.is_edited, + } + + +def _entry_to_dict(entry: Any) -> Dict[str, Any]: + """Normalise one ``editedPredictions`` entry to a plain dict. + + Model documents come off the metadata store as dicts, but a + :class:`~hastegeo.core.models.projects.Model` instance carries + ``EditedPredictionVersion`` objects. Callers (and ``json.dumps``) + want the same shape either way. + """ + if isinstance(entry, Mapping): + return dict(entry) + dump = getattr(entry, "model_dump", None) + if callable(dump): + return dump() + return dict(getattr(entry, "__dict__", {}) or {}) + + +def _entry_version(entry: Mapping[str, Any]) -> int: + """Version number of an entry, treating a missing one as 0.""" + try: + return int(entry.get("version") or 0) + except (TypeError, ValueError): + return 0 + + +def edited_prediction_versions(model: ModelLike) -> List[Dict[str, Any]]: + """Return ``Model.editedPredictions`` as dicts, newest version first. + + Ordering is by the numeric ``version`` field rather than list order: + the list is append-only today, but readers must not depend on that. + """ + entries = model_field(model, "editedPredictions") or [] + return sorted( + (_entry_to_dict(entry) for entry in entries), + key=_entry_version, + reverse=True, + ) + + +def describe_prediction_source( + model: ModelLike, version: Optional[int] = None +) -> PredictionSource: + """Resolve which prediction GeoPackage to read, with its provenance. + + Newest-wins by default: analyst edits are what a reader should see, + and ADR-0005 deliberately did not add a mutable "active version" + pointer to the model, so "newest edit, else raw" *is* the selection + rule. Pass ``version`` to pin a specific revision (``0`` selects the + raw model output explicitly). + + Args: + model: A Model instance or its raw metadata dict. + version: Edited version number to pin, ``0`` for the raw model + output, or ``None`` for "newest edit, else raw". + + Returns: + A :class:`PredictionSource`. Its ``url`` is empty only when the + model has no predictions at all — callers surface that as a 404. + + Raises: + PredictionVersionNotFoundError: when ``version`` is given but no + such edited version exists. + """ + raw_url = model_field(model, "gpkgUrl") or "" + entries = [ + entry + for entry in edited_prediction_versions(model) + if entry.get("gpkgUrl") + ] + + if version is None: + if not entries: + return PredictionSource(url=raw_url) + entry = entries[0] + else: + requested = int(version) + if requested == RAW_PREDICTION_VERSION: + return PredictionSource(url=raw_url) + entry = next( + (e for e in entries if _entry_version(e) == requested), None + ) + if entry is None: + available = [_entry_version(e) for e in entries] + raise PredictionVersionNotFoundError( + f"Edited prediction version {requested} does not exist " + f"for model {model_field(model, 'modelId')}. Available " + f"versions: {available} (0 selects the raw model output)." + ) + + return PredictionSource( + url=str(entry.get("gpkgUrl") or ""), + version=_entry_version(entry), + created_at=entry.get("createdAt"), + created_by=entry.get("createdBy"), + edited_count=int(entry.get("editedCount") or 0), + ) + + +def resolve_prediction_source( + model: ModelLike, version: Optional[int] = None +) -> str: + """Return the URL of the prediction GeoPackage a reader should open. + + Thin wrapper over :func:`describe_prediction_source` for the common + case where the caller only needs the URL. Reports, publishing and + the results viewer all go through this so analyst edits reach every + reader instead of stopping at the editor. + """ + return describe_prediction_source(model, version=version).url diff --git a/hastelib/tests/core/processors/test_visualizer_payload.py b/hastelib/tests/core/processors/test_visualizer_payload.py new file mode 100644 index 00000000..57c49eb8 --- /dev/null +++ b/hastelib/tests/core/processors/test_visualizer_payload.py @@ -0,0 +1,422 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Unit tests for the results-viewer payload builder. + +The viewer has to serve BOTH workflows from one payload: + +* **trained inference** writes two COGs, so it keeps the raster tile + layers it always had, and +* **embedding** writes no rasters at all, so those fields must be + ``None`` rather than TiTiler templates over a URL that does not exist. + +Both get the vector artifacts (footprint PMTiles + attribute sidecar) as +API-relative ``GetModelArtifact`` routes, plus a readiness block so the +UI can show "still preparing" instead of an empty map. + +No I/O happens here: ``build_visualizer_results`` is a pure assembler +and the prediction flavor is passed in by the HTTP layer. +""" + +import unittest +from urllib.parse import parse_qs, urlparse + +from hastegeo.core.config import Config +from hastegeo.core.models.projects import ImageLayer, Model, Project +from hastegeo.core.processors.visualizer import ( + REASON_PREPARING, + PredictionInfo, + build_visualizer_results, + model_artifact_url, + raster_layer_urls, +) +from hastegeo.core.utils.model_readiness import ( + REASON_NOT_PROCESSED, + REASON_READY, +) + +STATUSES = Config.get_status_types() +PROCESSED = STATUSES.COMPLETED.value + +TITILER = "https://titiler.example.net/" +PROJECT_ID = "11111111-1111-1111-1111-111111111111" +LAYER_ID = "22222222-2222-2222-2222-222222222222" + +PRE_URL = "https://acct.blob/c/hash/pre_cog.tif?sas=a&b=c" +POST_URL = "https://acct.blob/c/hash/post_cog.tif?sas=a&b=c" +VISUALIZER_URL = "https://acct.blob/c/hash/5557_visualizer.tif?sas=a&b=c" +PREDICTIONS_URL = "https://acct.blob/c/hash/5557_predictions.tif?sas=a&b=c" +GPKG_URL = "https://acct.blob/c/hash/predicted_damage_m.gpkg?sas" +LAYER_PMTILES = "https://acct.blob/c/hash/footprints_layer.pmtiles?sas" +MODEL_PMTILES = "https://acct.blob/c/hash/buildings_5558.pmtiles?sas" +ATTRS_URL = "https://acct.blob/c/hash/prediction_attrs_5557.json?sas" + +BBOX = [-1.0, -2.0, 3.0, 4.0] + + +class _Feature: + """Stand-in for a LabelProject feature (only ``bbox`` is read).""" + + def __init__(self, bbox): + self.bbox = bbox + + +def _project(**overrides) -> Project: + data = { + "projectId": PROJECT_ID, + "name": "Hurricane Test", + "eventDate": "2026-01-02T00:00:00Z", + } + data.update(overrides) + return Project(**data) + + +def _layer(**overrides) -> ImageLayer: + data = { + "imageLayerId": LAYER_ID, + "projectId": PROJECT_ID, + "preEventImageryUrls": ["https://acct.blob/c/raw/pre.tif"], + "preEventProcessedImageryUrl": PRE_URL, + "postEventProcessedImageryUrl": POST_URL, + "buildingFootprintsUrl": "https://acct.blob/c/hash/fp.gpkg?sas", + "footprintPmtilesUrl": LAYER_PMTILES, + "sourceTypePreEvent": "Maxar", + "sourceTypePostEvent": "Maxar", + "imageryCaptureDatePreEvent": "2026-01-01", + "imageryCaptureDatePostEvent": "2026-01-03", + } + data.update(overrides) + return ImageLayer(**data) + + +def _trained_model(**overrides) -> Model: + data = { + "modelId": "5557", + "projectId": PROJECT_ID, + "imageLayerId": LAYER_ID, + "modelType": "trained", + "status": PROCESSED, + "inferenceStatus": PROCESSED, + "gpkgUrl": GPKG_URL, + "predictedDamageLayerUrl": VISUALIZER_URL, + "predictionAttrsUrl": ATTRS_URL, + "predictionTilesStatus": PROCESSED, + } + data.update(overrides) + return Model(**data) + + +def _embedding_model(**overrides) -> Model: + data = { + "modelId": "5558", + "projectId": PROJECT_ID, + "imageLayerId": LAYER_ID, + "modelType": "embedding", + "status": PROCESSED, + "gpkgUrl": GPKG_URL, + "predictedBuildingCount": 1200, + # The embedding workflow tiles the same footprints for the + # labeler, so the editor/viewer reuses that archive. + "pmtilesUrl": MODEL_PMTILES, + "predictionAttrsUrl": ATTRS_URL, + "predictionTilesStatus": PROCESSED, + } + data.update(overrides) + return Model(**data) + + +def _build(model: Model, layer: ImageLayer = None, **kwargs): + return build_visualizer_results( + project=_project(), + image_layer=layer or _layer(), + model=model, + titiler_endpoint=TITILER, + study_area=[_Feature(BBOX)], + **kwargs, + ) + + +class TestTrainedInferencePayload(unittest.TestCase): + """Workflow A must keep exactly the shape the viewer already uses.""" + + def test_raster_layers_are_present(self): + visualizer = _build(_trained_model()) + + self.assertIsNotNone(visualizer.predictedDamageLayer) + self.assertIsNotNone(visualizer.predictionsLayer) + + def test_predicted_damage_layer_points_at_the_visualizer_cog(self): + visualizer = _build(_trained_model()) + + url = visualizer.predictedDamageLayer.url + self.assertTrue(url.startswith(f"{TITILER}cog/tiles/")) + self.assertIn("{z}/{x}/{y}", url) + # The SAS-bearing blob URL must be fully percent-encoded. + self.assertNotIn(VISUALIZER_URL, url) + query = parse_qs(urlparse(url).query) + self.assertEqual(query["url"], [VISUALIZER_URL]) + + def test_predictions_layer_derives_the_sibling_cog_with_a_colormap(self): + visualizer = _build(_trained_model()) + + query = parse_qs(urlparse(visualizer.predictionsLayer.url).query) + self.assertEqual(query["url"], [PREDICTIONS_URL]) + self.assertIn("colormap", query) + + def test_layers_are_bounded_by_the_study_area(self): + visualizer = _build(_trained_model()) + + self.assertEqual(visualizer.predictedDamageLayer.bounds, BBOX) + self.assertEqual(visualizer.preDisasterImagery.bounds, BBOX) + self.assertEqual(visualizer.postDisasterImagery.bounds, BBOX) + + def test_imagery_and_metadata_are_carried_through(self): + visualizer = _build(_trained_model()) + + self.assertEqual(visualizer.projectId, PROJECT_ID) + self.assertEqual(visualizer.imageLayerId, LAYER_ID) + self.assertEqual(visualizer.modelId, "5557") + self.assertEqual(visualizer.projectName, "Hurricane Test") + self.assertEqual(visualizer.eventDate, "2026-01-02T00:00:00Z") + self.assertEqual(visualizer.sourceTypePostEvent, "Maxar") + self.assertEqual(visualizer.imageryCaptureDatePreEvent, "2026-01-01") + self.assertIn("url=", visualizer.postDisasterImagery.url) + + def test_pre_event_imagery_is_empty_without_uploads(self): + # No pre-event upload: the viewer falls back to the base map. + visualizer = _build( + _trained_model(), layer=_layer(preEventImageryUrls=[]) + ) + + self.assertEqual(visualizer.preDisasterImagery.url, "") + + def test_flavor_comes_from_the_prediction_reader(self): + visualizer = _build( + _trained_model(), + predictions=PredictionInfo( + flavor="inference", + supports_threshold=True, + building_count=1234, + ), + ) + + self.assertEqual(visualizer.flavor, "inference") + self.assertTrue(visualizer.supportsThreshold) + self.assertEqual(visualizer.buildingCount, 1234) + + def test_payload_is_json_serializable(self): + import json + + payload = _build( + _trained_model(), predictions=PredictionInfo(flavor="inference") + ).dict() + payload["studyArea"] = [] + + self.assertIn("predictedDamageLayer", json.loads(json.dumps(payload))) + + +class TestEmbeddingPayload(unittest.TestCase): + """Workflow B has no rasters — and must still be usable.""" + + def test_raster_layers_are_absent(self): + visualizer = _build(_embedding_model()) + + self.assertIsNone(visualizer.predictedDamageLayer) + self.assertIsNone(visualizer.predictionsLayer) + + def test_vector_artifacts_are_served_through_the_api(self): + visualizer = _build(_embedding_model()) + + self.assertIsNotNone(visualizer.footprintTilesUrl) + self.assertIsNotNone(visualizer.predictionAttrsUrl) + for url in ( + visualizer.footprintTilesUrl, + visualizer.predictionAttrsUrl, + ): + # API-relative route, never a raw blob SAS URL. + self.assertTrue(url.startswith("GetModelArtifact?")) + self.assertNotIn("blob", url) + + def test_footprint_tiles_reuse_the_models_own_archive(self): + # No layer-level PMTiles at all: resolve_tiles_url still finds + # the embedding model's own archive, so the viewer is ready. + visualizer = _build( + _embedding_model(), layer=_layer(footprintPmtilesUrl=None) + ) + + self.assertTrue(visualizer.predictionsReady) + self.assertIsNotNone(visualizer.footprintTilesUrl) + + def test_embedding_flavor_disables_thresholding(self): + visualizer = _build( + _embedding_model(), + predictions=PredictionInfo( + flavor="embedding", + supports_threshold=False, + building_count=1200, + ), + ) + + self.assertEqual(visualizer.flavor, "embedding") + self.assertFalse(visualizer.supportsThreshold) + + def test_ready_payload_is_returned_for_an_embedding_model(self): + visualizer = _build(_embedding_model()) + + self.assertTrue(visualizer.predictionsReady) + self.assertEqual(visualizer.predictionsReadiness.workflow, "embedding") + self.assertEqual(visualizer.predictionsReadiness.reason, REASON_READY) + + +class TestArtifactRoutes(unittest.TestCase): + def test_route_carries_the_ids_and_kind(self): + url = model_artifact_url( + PROJECT_ID, "5557", "footprint_pmtiles", image_layer_id=LAYER_ID + ) + + query = parse_qs(urlparse(url).query) + self.assertEqual(query["projectId"], [PROJECT_ID]) + self.assertEqual(query["modelId"], ["5557"]) + self.assertEqual(query["kind"], ["footprint_pmtiles"]) + self.assertEqual(query["imageLayerId"], [LAYER_ID]) + + def test_attrs_route_is_model_scoped_only(self): + visualizer = _build(_trained_model()) + + query = parse_qs(urlparse(visualizer.predictionAttrsUrl).query) + self.assertEqual(query["kind"], ["prediction_attrs"]) + self.assertNotIn("imageLayerId", query) + + +class TestRasterUrlDerivation(unittest.TestCase): + def test_sibling_predictions_cog_is_derived(self): + urls = raster_layer_urls(_trained_model()) + + self.assertEqual(urls["visualizer"], VISUALIZER_URL) + self.assertEqual(urls["predictions"], PREDICTIONS_URL) + + def test_no_rasters_without_a_visualizer_cog(self): + urls = raster_layer_urls(_embedding_model()) + + self.assertIsNone(urls["visualizer"]) + self.assertIsNone(urls["predictions"]) + + def test_unexpected_name_yields_no_predictions_layer(self): + # The raw prediction COG is derived by name, so an unfamiliar + # name must produce no layer rather than a URL that 404s. + model = _trained_model( + predictedDamageLayerUrl="https://acct.blob/c/hash/odd.tif?sas" + ) + + urls = raster_layer_urls(model) + + self.assertIsNotNone(urls["visualizer"]) + self.assertIsNone(urls["predictions"]) + self.assertIsNone(_build(model).predictionsLayer) + + +class TestReadiness(unittest.TestCase): + def test_not_ready_while_inference_runs(self): + visualizer = _build(_trained_model(inferenceStatus="InProgress")) + + self.assertFalse(visualizer.predictionsReady) + self.assertEqual( + visualizer.predictionsReadiness.reason, REASON_NOT_PROCESSED + ) + self.assertTrue(visualizer.predictionsReadiness.detail) + + def test_preparing_when_the_sidecar_is_missing(self): + visualizer = _build( + _trained_model( + predictionAttrsUrl=None, + predictionTilesStatus="Queued", + predictionTilesStatusMessage="\nQueued for preparation", + ) + ) + + readiness = visualizer.predictionsReadiness + self.assertFalse(visualizer.predictionsReady) + self.assertEqual(readiness.reason, REASON_PREPARING) + self.assertTrue(readiness.tilesReady) + self.assertFalse(readiness.attrsReady) + self.assertEqual(readiness.predictionTilesStatus, "Queued") + self.assertIn("Queued", readiness.predictionTilesStatusMessage) + # The artifact URL is withheld until the artifact exists. + self.assertIsNone(visualizer.predictionAttrsUrl) + + def test_preparing_when_the_tiles_are_missing(self): + visualizer = _build( + _trained_model(), layer=_layer(footprintPmtilesUrl=None) + ) + + readiness = visualizer.predictionsReadiness + self.assertFalse(visualizer.predictionsReady) + self.assertEqual(readiness.reason, REASON_PREPARING) + self.assertFalse(readiness.tilesReady) + self.assertIsNone(visualizer.footprintTilesUrl) + + def test_rasters_survive_an_unprepared_vector_layer(self): + # A classic model whose tiles are not built yet still shows its + # rasters; only the vector layer waits. + visualizer = _build( + _trained_model(), layer=_layer(footprintPmtilesUrl=None) + ) + + self.assertIsNotNone(visualizer.predictedDamageLayer) + + +class TestVersionSelection(unittest.TestCase): + def _edited(self, version: int) -> dict: + return { + "version": version, + "gpkgUrl": f"https://acct.blob/c/hash/edited_v{version}.gpkg", + "createdAt": "2026-08-21T05:10:48+00:00", + "editedCount": 5, + } + + def test_raw_source_reports_no_version(self): + visualizer = _build(_trained_model()) + + self.assertIsNone(visualizer.predictionVersion) + self.assertEqual(visualizer.predictionVersions, []) + + def test_selected_version_and_history_are_reported(self): + model = _trained_model( + editedPredictions=[self._edited(1), self._edited(2)] + ) + + visualizer = _build(model, predictions=PredictionInfo(version=2)) + + self.assertEqual(visualizer.predictionVersion, 2) + self.assertEqual( + [v["version"] for v in visualizer.predictionVersions], [2, 1] + ) + + +class TestStudyArea(unittest.TestCase): + def test_missing_study_area_leaves_bounds_unset(self): + visualizer = build_visualizer_results( + project=_project(), + image_layer=_layer(), + model=_trained_model(), + titiler_endpoint=TITILER, + study_area=None, + ) + + self.assertEqual(visualizer.studyArea, []) + self.assertIsNone(visualizer.postDisasterImagery.bounds) + + def test_dict_features_are_supported(self): + visualizer = build_visualizer_results( + project=_project(), + image_layer=_layer(), + model=_trained_model(), + titiler_endpoint=TITILER, + study_area=[{"bbox": BBOX}], + ) + + self.assertEqual(visualizer.postDisasterImagery.bounds, BBOX) + + +if __name__ == "__main__": + unittest.main() diff --git a/hastelib/tests/core/utils/test_model_readiness.py b/hastelib/tests/core/utils/test_model_readiness.py new file mode 100644 index 00000000..3af79e25 --- /dev/null +++ b/hastelib/tests/core/utils/test_model_readiness.py @@ -0,0 +1,233 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Unit tests for the shared "does this model have results?" rule. + +Three consumers used to answer this question three different ways (the +trained-model Results button, the embedding row, and the publishing +source resolver). ``hastegeo.core.utils.model_readiness`` is the one +rule they all defer to, surfaced to the UI as ``predictionsReady``, so +these tests pin the behaviour for BOTH workflows — including the states +where a model looks finished but has nothing to read. +""" + +import unittest + +from hastegeo.core.config import Config +from hastegeo.core.models.projects import Model +from hastegeo.core.utils.model_readiness import ( + REASON_NO_BUILDINGS, + REASON_NO_PREDICTIONS, + REASON_NOT_PROCESSED, + REASON_READY, + WORKFLOW_EMBEDDING, + WORKFLOW_INFERENCE, + annotate_predictions_ready, + model_is_complete, + model_workflow, + prediction_readiness, + predictions_ready, +) + +STATUSES = Config.get_status_types() +PROCESSED = STATUSES.COMPLETED.value + +GPKG_URL = "https://acct.blob/c/hash/predicted_damage_m.gpkg?sas" +RASTER_URL = "https://acct.blob/c/hash/m_visualizer.tif?sas" + + +def _trained(**overrides) -> dict: + """A trained-inference model document that finished inference.""" + data = { + "modelId": "5557", + "projectId": "proj-1", + "imageLayerId": "layer-1", + "modelType": "trained", + "status": PROCESSED, + "inferenceStatus": PROCESSED, + "gpkgUrl": GPKG_URL, + "predictedDamageLayerUrl": RASTER_URL, + } + data.update(overrides) + return data + + +def _embedding(**overrides) -> dict: + """An embedding model document with saved building predictions.""" + data = { + "modelId": "5558", + "projectId": "proj-1", + "imageLayerId": "layer-1", + "modelType": "embedding", + "status": PROCESSED, + "gpkgUrl": GPKG_URL, + "predictedBuildingCount": 1200, + } + data.update(overrides) + return data + + +class TestModelWorkflow(unittest.TestCase): + def test_embedding_model_type_selects_embedding_workflow(self): + self.assertEqual(model_workflow(_embedding()), WORKFLOW_EMBEDDING) + + def test_default_model_type_selects_inference_workflow(self): + self.assertEqual(model_workflow({}), WORKFLOW_INFERENCE) + self.assertEqual(model_workflow(_trained()), WORKFLOW_INFERENCE) + + +class TestModelIsComplete(unittest.TestCase): + """The publishing eligibility rule, now shared.""" + + def test_trained_model_gates_on_inference_status(self): + self.assertTrue(model_is_complete(_trained())) + self.assertFalse( + model_is_complete(_trained(inferenceStatus="InProgress")) + ) + + def test_trained_model_ignores_its_training_status(self): + # Training finished but inference has not: not complete, even + # though `status` says Processed. + self.assertFalse(model_is_complete(_trained(inferenceStatus="Queued"))) + + def test_embedding_model_gates_on_status(self): + self.assertTrue(model_is_complete(_embedding())) + self.assertFalse(model_is_complete(_embedding(status="Failed"))) + + def test_embedding_model_ignores_missing_inference_status(self): + # Embedding models never run inference, so inferenceStatus is + # None; gating on it is what used to hide them from the viewer. + model = _embedding() + self.assertIsNone(model.get("inferenceStatus")) + self.assertTrue(model_is_complete(model)) + + +class TestTrainedWorkflowReadiness(unittest.TestCase): + def test_ready_with_predictions(self): + readiness = prediction_readiness(_trained()) + + self.assertTrue(readiness.ready) + self.assertEqual(readiness.reason, REASON_READY) + self.assertEqual(readiness.workflow, WORKFLOW_INFERENCE) + self.assertEqual(readiness.status, PROCESSED) + self.assertEqual(readiness.detail, "") + + def test_ready_with_only_the_raster(self): + # Models predating the GeoPackage output still have the COG. + readiness = prediction_readiness(_trained(gpkgUrl=None)) + + self.assertTrue(readiness.ready) + + def test_not_ready_while_inference_runs(self): + readiness = prediction_readiness( + _trained(inferenceStatus="InProgress") + ) + + self.assertFalse(readiness.ready) + self.assertEqual(readiness.reason, REASON_NOT_PROCESSED) + self.assertEqual(readiness.status, "InProgress") + self.assertIn("Inference", readiness.detail) + + def test_not_ready_when_inference_failed(self): + readiness = prediction_readiness(_trained(inferenceStatus="Failed")) + + self.assertFalse(readiness.ready) + self.assertEqual(readiness.reason, REASON_NOT_PROCESSED) + + def test_not_ready_without_any_output(self): + readiness = prediction_readiness( + _trained(gpkgUrl=None, predictedDamageLayerUrl=None) + ) + + self.assertFalse(readiness.ready) + self.assertEqual(readiness.reason, REASON_NO_PREDICTIONS) + + +class TestEmbeddingWorkflowReadiness(unittest.TestCase): + def test_ready_with_saved_predictions(self): + readiness = prediction_readiness(_embedding()) + + self.assertTrue(readiness.ready) + self.assertEqual(readiness.reason, REASON_READY) + self.assertEqual(readiness.workflow, WORKFLOW_EMBEDDING) + + def test_not_ready_while_embedding_runs(self): + readiness = prediction_readiness(_embedding(status="InProgress")) + + self.assertFalse(readiness.ready) + self.assertEqual(readiness.reason, REASON_NOT_PROCESSED) + + def test_not_ready_without_saved_predictions(self): + readiness = prediction_readiness( + _embedding(gpkgUrl=None, predictedBuildingCount=None) + ) + + self.assertFalse(readiness.ready) + self.assertEqual(readiness.reason, REASON_NO_PREDICTIONS) + + def test_not_ready_after_labels_are_cleared(self): + # "Clear labels" PUTs an empty prediction list, which still + # writes a valid all-zero GeoPackage and still sets gpkgUrl. + readiness = prediction_readiness(_embedding(predictedBuildingCount=0)) + + self.assertFalse(readiness.ready) + self.assertEqual(readiness.reason, REASON_NO_BUILDINGS) + + def test_ready_for_models_predating_the_building_count(self): + # No count recorded at all: fall back to "a GeoPackage exists" + # rather than reporting an old model as empty. + readiness = prediction_readiness( + _embedding(predictedBuildingCount=None) + ) + + self.assertTrue(readiness.ready) + + +class TestReadinessInputShapes(unittest.TestCase): + def test_accepts_a_model_instance(self): + self.assertTrue(predictions_ready(Model(**_trained()))) + self.assertTrue(predictions_ready(Model(**_embedding()))) + + def test_model_instance_and_dict_agree(self): + for document in (_trained(inferenceStatus="Queued"), _embedding()): + self.assertEqual( + predictions_ready(document), + predictions_ready(Model(**document)), + ) + + def test_readiness_serializes_for_a_payload(self): + payload = prediction_readiness(_embedding(status="Queued")).to_dict() + + self.assertEqual( + set(payload), + {"ready", "reason", "detail", "workflow", "status"}, + ) + self.assertFalse(payload["ready"]) + + +class TestAnnotatePredictionsReady(unittest.TestCase): + def test_stamps_the_flag_in_place(self): + document = _trained() + + result = annotate_predictions_ready(document) + + self.assertIs(result, document) + self.assertTrue(document["predictionsReady"]) + + def test_stamps_false_without_results(self): + document = _embedding(predictedBuildingCount=0) + + annotate_predictions_ready(document) + + self.assertFalse(document["predictionsReady"]) + + def test_flag_is_always_a_bool(self): + # The UI falls back to its own rule only when the field is + # absent, so it must never be None/undefined. + document = annotate_predictions_ready({}) + + self.assertIsInstance(document["predictionsReady"], bool) + + +if __name__ == "__main__": + unittest.main() diff --git a/hastelib/tests/core/utils/test_prediction_source.py b/hastelib/tests/core/utils/test_prediction_source.py new file mode 100644 index 00000000..397b6c4d --- /dev/null +++ b/hastelib/tests/core/utils/test_prediction_source.py @@ -0,0 +1,193 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Unit tests for prediction-source resolution (raw vs analyst edits). + +``Model.gpkgUrl`` is the RAW model output and is never rewritten; every +save of the prediction editor appends a new numbered entry to +``Model.editedPredictions`` (ADR-0005). Readers therefore need one rule +for "which GeoPackage should I open?", and ADR-0005 deliberately did not +add a mutable "active version" pointer: newest edit wins, with an +explicit ``version`` override. + +No GeoPackages are read here — this is pure metadata selection. +""" + +import unittest + +from hastegeo.core.models.projects import Model +from hastegeo.core.utils.predictions import ( + PredictionVersionNotFoundError, + describe_prediction_source, + edited_prediction_versions, + resolve_prediction_source, +) + +RAW_URL = "https://acct.blob/c/hash/predicted_damage_m.gpkg?sas" + + +def _edit(version: int, **overrides) -> dict: + entry = { + "version": version, + "gpkgUrl": f"https://acct.blob/c/hash/edited_predictions_5557_v" + f"{version}.gpkg?sas", + "createdAt": f"2026-08-2{version}T05:10:48+00:00", + "createdBy": "analyst@example.com", + "threshold": 0.5, + "unknownThreshold": 0.0, + "editedCount": version * 10, + "sourceGpkgUrl": RAW_URL, + } + entry.update(overrides) + return entry + + +def _model(*edits, **overrides) -> dict: + data = { + "modelId": "5557", + "projectId": "proj-1", + "imageLayerId": "layer-1", + "gpkgUrl": RAW_URL, + "editedPredictions": list(edits), + } + data.update(overrides) + return data + + +class TestResolveWithoutEdits(unittest.TestCase): + def test_falls_back_to_the_raw_model_output(self): + self.assertEqual(resolve_prediction_source(_model()), RAW_URL) + + def test_missing_edited_list_is_treated_as_no_edits(self): + model = _model() + del model["editedPredictions"] + + self.assertEqual(resolve_prediction_source(model), RAW_URL) + + def test_source_is_flagged_as_not_edited(self): + source = describe_prediction_source(_model()) + + self.assertIsNone(source.version) + self.assertFalse(source.is_edited) + self.assertEqual(source.edited_count, 0) + + def test_model_without_predictions_resolves_to_empty(self): + # Callers surface the empty URL as a 404 rather than a crash. + self.assertEqual(resolve_prediction_source(_model(gpkgUrl=None)), "") + + +class TestResolveWithEdits(unittest.TestCase): + def test_single_edit_wins_over_the_raw_output(self): + source = describe_prediction_source(_model(_edit(1))) + + self.assertEqual(source.url, _edit(1)["gpkgUrl"]) + self.assertEqual(source.version, 1) + self.assertTrue(source.is_edited) + self.assertEqual(source.created_by, "analyst@example.com") + self.assertEqual(source.edited_count, 10) + + def test_newest_of_several_edits_wins(self): + model = _model(_edit(1), _edit(2), _edit(3)) + + self.assertEqual(resolve_prediction_source(model), _edit(3)["gpkgUrl"]) + + def test_newest_is_by_version_number_not_list_order(self): + # The list is append-only today, but readers must not rely on it. + model = _model(_edit(3), _edit(1), _edit(2)) + + source = describe_prediction_source(model) + + self.assertEqual(source.version, 3) + + def test_entries_without_a_url_are_skipped(self): + model = _model(_edit(1), _edit(2, gpkgUrl="")) + + source = describe_prediction_source(model) + + self.assertEqual(source.version, 1) + + def test_raw_output_is_never_mutated(self): + model = _model(_edit(1), _edit(2)) + + resolve_prediction_source(model) + + self.assertEqual(model["gpkgUrl"], RAW_URL) + + +class TestExplicitVersion(unittest.TestCase): + def test_pins_an_older_version(self): + model = _model(_edit(1), _edit(2), _edit(3)) + + source = describe_prediction_source(model, version=2) + + self.assertEqual(source.version, 2) + self.assertEqual(source.url, _edit(2)["gpkgUrl"]) + + def test_version_zero_selects_the_raw_model_output(self): + model = _model(_edit(1), _edit(2)) + + source = describe_prediction_source(model, version=0) + + self.assertEqual(source.url, RAW_URL) + self.assertIsNone(source.version) + self.assertFalse(source.is_edited) + + def test_missing_version_raises(self): + model = _model(_edit(1), _edit(2)) + + with self.assertRaises(PredictionVersionNotFoundError) as ctx: + describe_prediction_source(model, version=7) + + message = str(ctx.exception) + self.assertIn("7", message) + self.assertIn("5557", message) + self.assertIn("[2, 1]", message) + + def test_missing_version_raises_when_there_are_no_edits(self): + with self.assertRaises(PredictionVersionNotFoundError): + resolve_prediction_source(_model(), version=1) + + def test_version_not_found_is_a_value_error(self): + # The HTTP layer catches it specifically for a 404; keeping it a + # ValueError means a generic handler still rejects the request. + self.assertTrue(issubclass(PredictionVersionNotFoundError, ValueError)) + + +class TestVersionListing(unittest.TestCase): + def test_newest_first(self): + model = _model(_edit(1), _edit(3), _edit(2)) + + versions = edited_prediction_versions(model) + + self.assertEqual([v["version"] for v in versions], [3, 2, 1]) + + def test_empty_without_edits(self): + self.assertEqual(edited_prediction_versions(_model()), []) + + def test_entries_are_json_serializable_dicts(self): + # A Model instance carries EditedPredictionVersion objects; the + # API has to hand plain dicts to json.dumps either way. + versions = edited_prediction_versions(Model(**_model(_edit(1)))) + + self.assertIsInstance(versions[0], dict) + self.assertEqual(versions[0]["version"], 1) + self.assertEqual(versions[0]["editedCount"], 10) + + +class TestModelInstanceInput(unittest.TestCase): + def test_resolves_from_a_model_instance(self): + model = Model(**_model(_edit(1), _edit(2))) + + self.assertEqual(resolve_prediction_source(model), _edit(2)["gpkgUrl"]) + + def test_model_instance_and_dict_agree(self): + document = _model(_edit(1), _edit(2)) + + self.assertEqual( + resolve_prediction_source(document), + resolve_prediction_source(Model(**document)), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/spec/architecture/decisions/0005-versioned-derived-prediction-artifacts.md b/spec/architecture/decisions/0005-versioned-derived-prediction-artifacts.md index 6d2999e7..a8f99d0c 100644 --- a/spec/architecture/decisions/0005-versioned-derived-prediction-artifacts.md +++ b/spec/architecture/decisions/0005-versioned-derived-prediction-artifacts.md @@ -9,19 +9,29 @@ ## Context Prediction editing needs analysts to save corrected building-level prediction -outputs without losing the raw model result. HASTE does not currently have -artifact versioning: blob writes through `store_artifact` use `overwrite=True`, -and `Model.gpkgUrl` is the single pointer to the raw prediction GeoPackage -(`hastelib/src/hastegeo/core/artifact_storage/azure_blob_artifact_storage.py:255`, -`hastelib/src/hastegeo/core/models/projects.py:440`). Overwriting that pointer -or blob would remove the provenance needed to compare model output with analyst -edits. - -The feature spec at `spec/features/prediction-editing/` introduces edited -prediction GeoPackages as derived artifacts. Each save must produce a new -version (`edit_v1`, `edit_v2`, …) that is listable and downloadable, while -assessment reports, validation reports, publishing, and the visualizer continue -to read the raw model output until later specs opt in. +outputs without losing the raw model result. HASTE's raw prediction pointer is +`Model.gpkgUrl`; overwriting that pointer or blob would remove the provenance +needed to compare model output with analyst edits and would be especially risky +because artifact writes can overwrite same-named blobs in the storage layer. + +The implemented prediction-editing design writes edited GeoPackages as derived +artifacts and records a numbered metadata entry for each save. The raw +`Model.gpkgUrl` remains the producer output. There is no stored mutable +"current edited version" pointer. Instead, readers that support edited versions +call `resolve_prediction_source(model, version=None)`: omitted `version` resolves +to the newest edited artifact when one exists, `version=0` forces the raw output, +and an explicit positive version resolves that exact edited artifact +(`hastelib/src/hastegeo/core/utils/predictions.py:332-401`). + +That resolver is now used by `GetVisualizerResults`, `GetValidationReport`, and +`GetAssessmentReport`, each with an optional `version` query parameter. The +visualizer payload reports which version is on the map and lists available +versions, but the current UI version history is read-only: choosing an older +version in the panel does not refetch the map yet +(`api/hastefuncapi/function_app.py:2296-2435`, +`api/hastefuncapi/function_app.py:4607-4688`, +`api/hastefuncapi/function_app.py:4929-5027`, +`ui/src/Components/Visualizer/PredictionEditPanel.jsx:513-550`). ## Options Considered @@ -30,10 +40,10 @@ to read the raw model output until later specs opt in. - **Pros:** Smallest data-model change; all current consumers would immediately see analyst edits without new parameters. - **Cons:** Destroys the raw model output, loses auditability, makes it hard to - compare model vs analyst decisions, and is unsafe because artifact storage - already overwrites same-named blobs. + compare model vs analyst decisions, and is unsafe because artifact storage can + overwrite same-named blobs. - **Impact on HASTE components:** Minimal code change, but high behavioral risk - across reports, validation, publishing, and downloads. + across reports, validation, publishing, downloads, and visualizer rendering. ### Option B: Use Azure Blob snapshots for edited outputs @@ -60,18 +70,30 @@ to read the raw model output until later specs opt in. ### Option D: Store a numbered edited-version list on the Model document (Chosen) - **Pros:** Preserves raw `Model.gpkgUrl`, gives analysts a simple version - history, uses unique blob artifact names, avoids new containers, and keeps - downstream consumers unchanged in v1. + history, uses unique blob artifact names, avoids new containers, and lets + readers select raw/newest/explicit versions without a mutable active pointer. - **Cons:** Model documents grow with each save; version history is scoped to prediction editing rather than a reusable artifact registry; the current implementation does not yet protect concurrent saves when assigning the next number. - **Impact on HASTE components:** Adds optional Model fields, new artifact-type - templates, small API additions, and UI version-list rendering. + templates, API additions, a source resolver, report/visualizer version + support, and UI version-list rendering. + +### Option E: Store an `activeEditedPredictionVersion` pointer + +- **Pros:** Lets users switch the default edited version without changing every + reader URL. +- **Cons:** Introduces mutable global state on the Model document; report and + visualizer results could change after a pointer update even when callers did + not ask for a different artifact; races and audit semantics become harder. +- **Impact on HASTE components:** Requires write APIs and UI for switching the + active pointer, plus stronger concurrency controls. This is not implemented. ## Decision -Adopt **Option D: a numbered edited-version list on the Model document**. +Adopt **Option D: a numbered edited-version list on the Model document** and +reject a mutable active-version pointer. Each prediction-edit save writes a new immutable-by-convention blob named from `EDITED_PREDICTIONS_GPKG = Template("edited_predictions_${modelId}_v${version}")` @@ -83,46 +105,65 @@ must not be mutated by the edit flow. `EditedPredictionVersion` stores `version`, `gpkgUrl`, `createdAt`, `createdBy`, `threshold`, `unknownThreshold`, `editedCount`, and `sourceGpkgUrl`. The API allocates the next version from the current Model document, writes the blob under -that versioned artifact name, and appends metadata. Existing downstream -consumers continue to use the raw prediction pointer unless a future ADR/spec -introduces active-version selection. - -The implemented v1 does **not** include the proposed 409 conflict response for -simultaneous saves. `next_version` plus metadata save is currently a -read-modify-write without optimistic concurrency, so a follow-up must add ETag, -lease, or retry-safe allocation before multi-analyst collision safety is -guaranteed. The separate `PutPreparePredictionTilesQueueMessage` route affects -only PMTiles/attribute preparation; it does not change this artifact-versioning -decision. +that versioned artifact name, and appends metadata. The implemented v1 does +**not** include the proposed 409 conflict response for simultaneous saves: +`next_version` plus metadata save is currently a read-modify-write without +optimistic concurrency, so a follow-up must add ETag, lease, or retry-safe +allocation before multi-analyst collision safety is guaranteed. + +Readers use `resolve_prediction_source` rather than a persisted active pointer. +By default, `GetVisualizerResults`, `GetValidationReport`, and +`GetAssessmentReport` use the newest edited version when one exists. Callers can +request `version=0` for the raw model output or `version=N` for an explicit +edited artifact; the public API contract documents these query parameters and +the visualizer response fields (`docs/api/hastefuncapi.md:78-157`, +`docs/api/hastefuncapi.md:480-502`). + +The separate `PutPreparePredictionTilesQueueMessage` route affects only PMTiles +and prediction-attribute preparation; it does not change this artifact-versioning +decision. PMTiles and sidecars are derived artifacts used by the vector-first +results viewer, while edited GeoPackage versions remain the durable analyst +outputs. ### Components Affected | Component | Path | Change | |---|---|---| | Model metadata | `hastelib/src/hastegeo/core/models/projects.py` | Add `EditedPredictionVersion` and `Model.editedPredictions`; preserve raw `gpkgUrl`. | -| Artifact naming | `hastelib/src/hastegeo/core/config.py` | Add `EDITED_PREDICTIONS_GPKG` template. | +| Artifact naming | `hastelib/src/hastegeo/core/config.py` | Add `EDITED_PREDICTIONS_GPKG` and prediction prep artifact templates. | | Prediction editing processor | `hastelib/src/hastegeo/core/processors/prediction_edits.py` | Allocate versions, write edited GeoPackages, and append metadata. | -| REST API | `api/hastefuncapi/function_app.py` | Add save/list endpoints that expose edited versions without changing existing report endpoints. | -| React UI | `ui/src/Components/PredictionEditor/` | Show version history in the editor. | +| Prediction source resolver | `hastelib/src/hastegeo/core/utils/predictions.py` | Resolve newest edited, raw, or explicit edited source without a mutable pointer. | +| REST API | `api/hastefuncapi/function_app.py` | Add save/list/prep endpoints; update visualizer, validation, and assessment readers to accept `version`. | +| React UI | `ui/src/Components/Visualizer/` | Render vector-first results and edit mode on the existing Visualizer page; show read-only version history. | ### Azure Services Affected | Service | Change | |---|---| -| Cosmos DB | Existing Model documents gain an optional embedded version list. | -| Blob Storage | Stores one edited GeoPackage blob per version. | -| Azure Functions | New HTTP save/list operations read and update Model metadata. | +| Cosmos DB | Existing Model documents gain an optional embedded version list and optional prep/readiness metadata. | +| Blob Storage | Stores one edited GeoPackage blob per version plus PMTiles and sidecar derived artifacts. | +| Azure Functions | New HTTP save/list/prep operations read and update Model metadata; existing visualizer/report operations resolve versions. | +| Azure Queue / Batch | Prep messages and jobs generate PMTiles and prediction attribute sidecars; edited versioning itself remains HTTP + Blob/Cosmos. | ## Consequences - **Easier:** Analysts can save multiple reviewed outputs; engineers can reason - about raw vs edited provenance; rollback does not require restoring raw blobs. -- **Harder:** A Model document can grow over time, and concurrent saves still - need protection around version allocation. + about raw vs edited provenance; rollback does not require restoring raw blobs; + visualizer, validation, and assessment callers can choose raw/newest/explicit + sources with the same `version` contract. +- **Harder:** A Model document can grow over time, concurrent saves still need + protection around version allocation, and the UI does not yet provide wired + version switching even though the payload lists available versions. - **New constraints:** The edit flow must never write edited data to `Model.gpkgUrl`; every edited artifact name must include the assigned version; - downstream consumers need explicit future work before they can use edits. + source selection must go through `resolve_prediction_source`; readers must use + `version=0` when they need the raw producer output. +- **Known semantic gap:** `GetValidationReport` reads edited `damaged`, so edits + move its metrics. `GetAssessmentReport` opens the selected GeoPackage but + still thresholds the producer's preserved `damage_pct_0m`, so per-building + overrides do not move assessment counts until a follow-up decision changes the + assessment contract. - **Impact on Docker Compose local dev stack:** No new storage service; local - Azurite must hold additional edited GeoPackage blobs. + Azurite must hold additional edited GeoPackage blobs, PMTiles, and sidecars. - **Impact on CI/CD workflows:** No workflow change expected unless additional automated test jobs are added later. diff --git a/spec/features/prediction-editing/README.md b/spec/features/prediction-editing/README.md index 9accadad..41f02fd6 100644 --- a/spec/features/prediction-editing/README.md +++ b/spec/features/prediction-editing/README.md @@ -11,83 +11,136 @@ ## Summary -Add an **Edit** action to every model result row in both prediction workflows. -The action opens a full-footprint editor where analysts can inspect model -predictions, manually reclassify buildings, and, for trained-inference models -only, adjust the damage-percent threshold that drives the default classes. Each -save produces a new, numbered edited prediction GeoPackage (`edit_v1`, -`edit_v2`, …) as a derived artifact; the raw model output remains unchanged. - -The browser receives the full prediction set through footprint PMTiles plus a -columnar JSON attribute sidecar. If footprint tiles or prediction attributes do -not exist yet, the editor calls a separate prep PUT route that creates them -through an asynchronous queued job rather than inside the session GET handler. +Prediction editing is now a **mode inside the existing View Results page**, not +a standalone screen. Analysts open `/visualizer/:projectId/:imageLayerId/:modelId` +from the Results menu, then enter edit mode with the pencil next to Back or the +`E` shortcut; Done or `E` exits, with a discard-confirmation dialog for unsaved +edits (`ui/src/Components/AppBody.jsx:73-75`, +`ui/src/Components/Visualizer/Labels.jsx:8-12`, +`ui/src/Components/Visualizer/Labels.jsx:117-128`, +`ui/src/Components/Visualizer/Visualizer.jsx:457-605`). + +The View Results page is vector-first for both prediction workflows. It draws +predicted building footprints from footprint PMTiles plus the prediction +attribute sidecar, artifacts both trained inference and embedding models can +provide; trained-inference rasters remain optional overlays and are nullable in +the payload (`hastelib/src/hastegeo/core/processors/visualizer.py:4-29`, +`hastelib/src/hastegeo/core/processors/visualizer.py:278-331`, +`hastelib/src/hastegeo/core/models/visualizer.py:45-82`). The embedding model +row now exposes View Results as the first Results menu action, so the embedding +workflow has a working results-viewer entry point +(`ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:116-130`). + +Each save still creates a new, numbered edited prediction GeoPackage (`edit_v1`, +`edit_v2`, …) as a derived artifact. The raw model output remains in +`Model.gpkgUrl`, while `GetVisualizerResults`, `GetValidationReport`, and +`GetAssessmentReport` default to the newest saved edit and accept an optional +`version` query parameter; `version=0` forces the raw output +(`hastelib/src/hastegeo/core/utils/predictions.py:332-401`, +`api/hastefuncapi/function_app.py:2386-2435`, +`api/hastefuncapi/function_app.py:4677-4688`, +`api/hastefuncapi/function_app.py:5017-5027`). ## Motivation - Disaster analysts need a fast way to correct false positives, false negatives, and ambiguous buildings before handing outputs to response partners. -- The current outputs are either model-generated GeoPackages or sampled browser - views. `GetBuildingFootprintsGeoJSON` returns a random sample capped at 2,000 - features, so it cannot support complete editing (`api/hastefuncapi/function_app.py:3626`, - `api/hastefuncapi/function_app.py:3645-3663`, - `api/hastefuncapi/function_app.py:3697`). -- The report pipeline has a read-only `threshold` parameter with default `0.1`, - but no UI sends it today (`api/hastefuncapi/function_app.py:4313-4322`, - `hastelib/src/hastegeo/core/utils/assessment.py:150-160`). Analysts need a - visible threshold control for workflows where the score is meaningful. -- HASTE currently has no artifact versioning: `store_artifact` overwrites blobs, - and `Model.gpkgUrl` is the only raw prediction pointer - (`hastelib/src/hastegeo/core/artifact_storage/azure_blob_artifact_storage.py:255`, - `hastelib/src/hastegeo/core/models/projects.py:440`). Edited outputs must - therefore be separate derived artifacts. +- The previous raster-only viewer could draw only the `_visualizer.tif` and + `_predictions.tif` COGs produced by trained inference. Embedding predictions + produce no raster, so vector PMTiles plus the attribute sidecar are now the + shared results path (`hastelib/src/hastegeo/core/processors/visualizer.py:4-29`, + `ui/src/Components/Visualizer/Visualizer.jsx:13-28`). +- Three call sites previously answered "does this model have results" from + different fields. `hastegeo.core.utils.model_readiness` is now the single + server-side rule, exposed as `predictionsReady` on model payloads and reused + by publishing (`hastelib/src/hastegeo/core/utils/model_readiness.py:4-25`, + `api/hastefuncapi/function_app.py:785-788`, + `api/hastefuncapi/function_app.py:1262-1266`, + `api/hastefuncapi/function_app.py:1380-1383`, + `hastelib/src/hastegeo/core/publishing/source.py:116-124`). +- `GetBuildingFootprintsGeoJSON` remains a sampled preview path, not an editing + data path. Editing requires the complete footprint PMTiles and sidecar route + (`api/hastefuncapi/function_app.py:1400-1424`, + `api/hastefuncapi/function_app.py:1453-1458`). +- HASTE still has no generic artifact versioning: edited outputs must be + numbered derived artifacts rather than overwriting raw model outputs + (`hastelib/src/hastegeo/core/models/projects.py:343-385`, + `hastelib/src/hastegeo/core/processors/prediction_edits.py:1-19`, + `hastelib/src/hastegeo/core/processors/prediction_edits.py:329-422`). ## Success Criteria -- [ ] Every trained-inference model row shows an **Edit** button that is enabled - only when `model.inferenceStatus === "Processed" && model.gpkgUrl`. -- [ ] Every embedding-model row shows an **Edit** button that is enabled only - when `model.gpkgUrl && model.predictedBuildingCount > 0`. -- [ ] Opening the editor loads all predicted footprints through PMTiles and the +- [ ] Trained and embedding model rows expose **View** as the Results menu entry + point, enabled from server-derived `predictionsReady` with legacy + fallbacks; there are no model-row Edit buttons or `/edit-predictions/...` + route (`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:87-110`, + `ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:85-130`, + `ui/src/Components/AppBody.jsx:73-75`). +- [ ] `GetVisualizerResults` returns the vector artifacts and readiness for both + workflows, while `predictedDamageLayer` and `predictionsLayer` are nullable + trained-inference-only overlays; the full response shape remains aligned + with `docs/api/hastefuncapi.md` (`docs/api/hastefuncapi.md:78-157`). +- [ ] Opening View Results for an embedding model renders a usable 200 payload + and predicted footprints rather than an empty raster-only page + (`hastelib/tests/core/processors/test_visualizer_payload.py:222-268`). +- [ ] The results page loads all predicted footprints through PMTiles and the prediction attribute sidecar; missing PMTiles or attributes are requested - through the explicit prep PUT route and generated by a queued job. -- [ ] Analysts can click individual buildings and ctrl+drag box-select groups - to set `Damaged`, `NotDamaged`, or `Unknown` overrides. -- [ ] Trained-inference models show a live threshold slider using - `damage_pct_0m`; embedding models do not show the slider because their - `damage_pct_0m` values are only a 0.0/1.0 copy of `damaged`. + through the explicit prep PUT route and generated by a queued job, not by + the GET handler (`ui/src/Components/Visualizer/usePredictionArtifacts.js:4-24`, + `ui/src/Components/Visualizer/usePredictionArtifacts.js:224-299`, + `hastelib/src/hastegeo/core/processors/prediction_tiles.py:251-370`). +- [ ] Analysts can enter edit mode with the pencil or `E`, click individual + buildings, ctrl+drag box-select groups, set `Damaged`, `NotDamaged`, or + `Unknown`, and leave through Done/`E` with unsaved-edits confirmation + (`ui/src/Components/Visualizer/Visualizer.jsx:496-605`, + `ui/src/Components/Visualizer/usePredictionFootprints.js:313-376`, + `ui/src/Components/keyboardShortcuts.js:60-80`). +- [ ] Trained-inference models show live damage/unknown threshold sliders using + `damage_pct_0m`; embedding models hide the sliders because their + `damage_pct_0m` values are a degenerate 0.0/1.0 copy of `damaged` + (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:346-397`, + `api/hastefuncapi/function_app.py:2738-2815`). - [ ] Saving creates `edit_v1`, `edit_v2`, … without mutating `Model.gpkgUrl` or - the raw model output. -- [ ] The written edited GeoPackage preserves source row order exactly and adds - `edited_class`, `edit_threshold`, and `overture_id` columns. -- [ ] Edited versions are listable through the API and right-panel history; the - API returns each `gpkgUrl`, while a dedicated one-click UI download action - remains a follow-up. Assessment reports, validation reports, publishing, - and the visualizer continue to consume raw outputs until a follow-up spec - changes them. + the raw model output; the written edited GeoPackage preserves row order + and adds `edited_class`, `edit_threshold`, and `overture_id` + (`api/hastefuncapi/function_app.py:3181-3345`, + `hastelib/src/hastegeo/core/processors/prediction_edits.py:226-308`). +- [ ] Saved versions are visible in the edit panel and the payload reports which + version is on the map. Version switching in the UI is **not** wired yet: + the history rows are read-only and the visualizer fetch does not append a + `version` parameter (`ui/src/Components/Visualizer/Visualizer.jsx:213-223`, + `ui/src/Components/Visualizer/PredictionEditPanel.jsx:513-550`). +- [ ] Validation and assessment/report readers can see edited versions through + `resolve_prediction_source`; `version=0` forces raw. The known asymmetry is + documented: validation reads edited `damaged`, while assessment thresholds + the preserved `damage_pct_0m` and therefore ignores per-building overrides + for its threshold-based counts (`api/hastefuncapi/function_app.py:4808-4827`, + `hastelib/src/hastegeo/core/utils/assessment.py:150-190`, + `docs/api/hastefuncapi.md:480-502`). ## HASTE Components Affected | Component | Impact | |---|---| -| `hastelib/src/hastegeo/core/models/` | add `EditedPredictionVersion`; add `Model.editedPredictions`, `Model.predictedBuildingCount`, `Model.predictedAt`, `Model.predictionAttrsUrl`, `Model.predictionTilesJob`, `Model.predictionTilesStatus`, `Model.predictionTilesStatusMessage`, and `ImageLayer.footprintPmtilesUrl`; add transport-only prediction wire models in `models/predictions.py` | -| `hastelib/src/hastegeo/core/config.py` | add artifact templates for edited prediction GeoPackages, prediction attributes, and layer footprint PMTiles | -| `hastelib/src/hastegeo/core/processors/` | `prediction_edits.py` applies edits and stores versions; `prediction_tiles.py` queues and finalizes prep work | -| `hastelib/src/hastegeo/core/utils/predictions.py` | normalize trained-inference and embedding prediction GeoPackages | -| `hastelib/src/hastegeo/workflows/` | queued tile/sidecar preparation workflow that runs where `tippecanoe` is available | -| `api/hastefuncapi/` | new side-effect-free session, explicit prep PUT, save, and version endpoints; extend `GetModelArtifact` with `footprint_pmtiles` and `prediction_attrs` | -| `api/hastefuncqueues/` | new queued handler for missing footprint PMTiles and attribute sidecar creation | -| `ui/src/Components/` | edit buttons in both model-row workflows; new `/edit-predictions/:projectId/:imageLayerId/:modelId` `PredictionEditor` screen | -| `ui/src/util/pmtiles.js` | shared PMTiles protocol singleton for Azure Maps screens | +| `hastelib/src/hastegeo/core/models/` | add `EditedPredictionVersion`; add `Model.editedPredictions`, `Model.predictedBuildingCount`, `Model.predictedAt`, `Model.predictionAttrsUrl`, `Model.predictionTilesJob`, `Model.predictionTilesStatus`, `Model.predictionTilesStatusMessage`, and `ImageLayer.footprintPmtilesUrl`; add visualizer payload fields for vector artifacts, readiness, flavor, building count, and versions (`hastelib/src/hastegeo/core/models/projects.py:343-505`, `hastelib/src/hastegeo/core/models/projects.py:520-529`, `hastelib/src/hastegeo/core/models/projects.py:842-851`, `hastelib/src/hastegeo/core/models/visualizer.py:45-82`) | +| `hastelib/src/hastegeo/core/config.py` | add artifact templates for edited prediction GeoPackages, prediction attributes, and layer footprint PMTiles; add the prediction-edit prep queue config (`hastelib/src/hastegeo/core/config.py:112-118`, `hastelib/src/hastegeo/core/config.py:165-172`, `hastelib/src/hastegeo/core/config.py:341-347`) | +| `hastelib/src/hastegeo/core/processors/` | `prediction_edits.py` applies edits and stores versions; `prediction_tiles.py` queues/finalizes prep; `visualizer.py` assembles the vector-first results payload (`hastelib/src/hastegeo/core/processors/prediction_edits.py:1-19`, `hastelib/src/hastegeo/core/processors/prediction_tiles.py:4-84`, `hastelib/src/hastegeo/core/processors/visualizer.py:215-336`) | +| `hastelib/src/hastegeo/core/utils/` | `predictions.py` normalizes both prediction GeoPackage flavors and resolves raw vs edited versions; `model_readiness.py` owns the single results-readiness rule (`hastelib/src/hastegeo/core/utils/predictions.py:4-34`, `hastelib/src/hastegeo/core/utils/predictions.py:318-401`, `hastelib/src/hastegeo/core/utils/model_readiness.py:132-237`) | +| `hastelib/src/hastegeo/workflows/` | queued tile/sidecar preparation workflow that runs where `tippecanoe` is available (`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:4-46`, `hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:322-416`) | +| `api/hastefuncapi/` | prediction edit session/prep/save/version endpoints; vector-first `GetVisualizerResults`; `version` support in visualizer, validation, and assessment reports; `GetModelArtifact` serves `footprint_pmtiles` and `prediction_attrs` (`api/hastefuncapi/function_app.py:1400-1510`, `api/hastefuncapi/function_app.py:2296-2435`, `api/hastefuncapi/function_app.py:2920-3420`, `api/hastefuncapi/function_app.py:4607-4688`, `api/hastefuncapi/function_app.py:4929-5027`) | +| `api/hastefuncqueues/` | prediction-edit prep queue trigger supports model-scoped and layer-only preparation (`api/hastefuncqueues/function_app.py:861-914`) | +| `ui/src/Components/ProjectManagement/` | Results menu View action gates on `predictionsReady`; embedding row gets View Results; standalone Edit buttons are removed (`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:87-110`, `ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:85-130`) | +| `ui/src/Components/Visualizer/` | existing View Results page owns vector-footprint loading, status notes, edit mode, edit panel, save flow, version display, and keyboard shortcuts (`ui/src/Components/Visualizer/Visualizer.jsx:166-199`, `ui/src/Components/Visualizer/Visualizer.jsx:873-921`, `ui/src/Components/Visualizer/usePredictionArtifacts.js:177-221`, `ui/src/Components/Visualizer/usePredictionFootprints.js:838-902`) | +| `ui/src/util/pmtiles.js` | shared PMTiles protocol and in-memory source used by the visualizer's vector artifacts (`ui/src/Components/Visualizer/usePredictionArtifacts.js:25-32`, `ui/src/Components/Visualizer/usePredictionArtifacts.js:201-212`) | | `.github/workflows/` | no expected dependency change; CI should enforce tests and no-regression UI lint baseline | ## Related Specs | Spec | Relationship | |---|---| -| [data-publishing](../data-publishing/) | related — edited versions are downloadable artifacts now and may become publishable datasets in a later spec | +| [data-publishing](../data-publishing/) | related — edited versions are saved artifacts but are not publishable datasets in this feature | | [open-data-catalog](../open-data-catalog/) | related — shares Azure Maps/TiTiler geospatial UI patterns and the queue-first approach for heavy geospatial work | -| [ADR-0005: Introduce Versioned Derived Prediction Artifacts](../../architecture/decisions/0005-versioned-derived-prediction-artifacts.md) | records the artifact-versioning decision for edited prediction GeoPackages | +| [ADR-0005: Introduce Versioned Derived Prediction Artifacts](../../architecture/decisions/0005-versioned-derived-prediction-artifacts.md) | records the artifact-versioning decision for edited prediction GeoPackages and the no-mutable-pointer reader rule | ## Document Index @@ -105,9 +158,11 @@ through an asynchronous queued job rather than inside the session GET handler. | Date | Decision | Rationale | |---|---|---| -| 2026-08-21 | Support both trained-inference and embedding workflows | Analysts need one editing entry point regardless of how predictions were produced. | -| 2026-08-21 | Show the threshold slider only for trained-inference models | Trained inference writes continuous `damage_pct_0m`; embedding predictions write a degenerate 0.0/1.0 copy of `damaged` (`docker/training/code/merge_with_building_footprints.py:221-231`, `api/hastefuncapi/function_app.py:2638-2786`). | +| 2026-08-21 | Support both trained-inference and embedding workflows | Analysts need one review/edit entry point regardless of how predictions were produced. | | 2026-08-21 | Store saves as numbered derived artifacts (`edit_v1`, `edit_v2`, …) | HASTE has no generic artifact versioning today, and overwriting `Model.gpkgUrl` would clobber the raw model output. | | 2026-08-21 | Use PMTiles plus a columnar JSON attribute sidecar for the full browser dataset | Existing full-attribute APIs do not exist, and the sampled GeoJSON route is capped at 2,000 features. | -| 2026-08-21 | Keep `GetPredictionEditSession` read-only and queue prep through `PutPreparePredictionTilesQueueMessage` | `tippecanoe` is installed in the training image only, so HTTP handlers must not generate tiles inline; a separate PUT keeps GET side-effect-free (`docker/training/env/env.yml:11`, `hastelib/src/hastegeo/workflows/embed_buildings.py:712-763`). | -| 2026-08-21 | Keep downstream report, validation, publishing, and visualizer consumption out of scope | v1 produces and exposes edited versions for download only; consumers switch in later specs. | +| 2026-08-21 | Keep `GetPredictionEditSession` read-only and queue prep through `PutPreparePredictionTilesQueueMessage` | `tippecanoe` is installed in the training image only, so HTTP handlers must not generate tiles inline (`hastelib/src/hastegeo/core/processors/prediction_tiles.py:13-19`, `hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:40-45`). | +| 2026-08-22 | Fold prediction editing into the existing View Results page | The implementation removed the standalone `/edit-predictions/...` route and uses the visualizer pencil/`E` affordance instead (`ui/src/Components/AppBody.jsx:73-75`, `ui/src/Components/Visualizer/Labels.jsx:117-128`). | +| 2026-08-22 | Make the results viewer vector-first and treat rasters as optional trained-inference overlays | Embedding models produce no rasters but can provide the same footprint PMTiles and sidecar as trained models (`hastelib/src/hastegeo/core/processors/visualizer.py:4-29`, `hastelib/src/hastegeo/core/models/visualizer.py:55-82`). | +| 2026-08-22 | Centralize model results readiness server-side | `predictionsReady` now comes from `model_readiness.py` and is stamped onto model payloads instead of being derived differently in each UI/publishing call site (`hastelib/src/hastegeo/core/utils/model_readiness.py:132-237`). | +| 2026-08-22 | Let readers default to the newest edited prediction version, with explicit `version` override and `version=0` raw | The no-mutable-pointer ADR still holds, while edited versions now reach visualizer, validation, and assessment readers (`hastelib/src/hastegeo/core/utils/predictions.py:332-401`, `docs/api/hastefuncapi.md:480-502`). | diff --git a/spec/features/prediction-editing/data-model.md b/spec/features/prediction-editing/data-model.md index 701f0921..f93546b9 100644 --- a/spec/features/prediction-editing/data-model.md +++ b/spec/features/prediction-editing/data-model.md @@ -8,6 +8,9 @@ No new Cosmos containers. Prediction edit metadata is embedded in the existing Model and ImageLayer metadata documents so reads remain local to the project. +`predictionsReady` is derived on reads and is not persisted +(`docs/api/hastefuncapi.md:59-76`, +`hastelib/src/hastegeo/core/utils/model_readiness.py:225-237`). | Container | Partition Key | Description | |---|---|---| @@ -17,8 +20,8 @@ Model and ImageLayer metadata documents so reads remain local to the project. | Container | Change | Migration Needed? | |---|---|---| -| Model metadata | Add `editedPredictions`, `predictedBuildingCount`, `predictedAt`, `predictionAttrsUrl`, `predictionTilesJob`, `predictionTilesStatus`, and `predictionTilesStatusMessage` | no — nullable/defaulted fields are backward-compatible | -| ImageLayer metadata | Add `footprintPmtilesUrl`, `footprintTilesJob`, `footprintTilesStatus`, and `footprintTilesStatusMessage` | no — nullable/defaulted fields are backward-compatible | +| Model metadata | Add `editedPredictions`, `predictedBuildingCount`, `predictedAt`, `predictionAttrsUrl`, `predictionTilesJob`, `predictionTilesStatus`, and `predictionTilesStatusMessage` | no — nullable/defaulted fields are backward-compatible (`hastelib/src/hastegeo/core/models/projects.py:491-529`) | +| ImageLayer metadata | Add `footprintPmtilesUrl`, `footprintTilesJob`, `footprintTilesStatus`, and `footprintTilesStatusMessage` | no — nullable/defaulted fields are backward-compatible (`hastelib/src/hastegeo/core/models/projects.py:842-851`) | ### New Document Schema @@ -58,6 +61,10 @@ Serialized example: } ``` +The fields and raw-output invariant are implemented in the Model schema and the +save handler (`hastelib/src/hastegeo/core/models/projects.py:343-385`, +`api/hastefuncapi/function_app.py:3311-3325`). + **RU estimate:** One point read of the Model, one point read of the ImageLayer, and one Model upsert per save. The embedded list is expected to be small; if version history grows beyond Cosmos document limits, promote it to a dedicated @@ -67,17 +74,17 @@ registry in a follow-up ADR. | Container | Field | Before | After | Notes | |---|---|---|---|---| -| Model metadata | `gpkgUrl` | optional string holding the prediction GeoPackage url | unchanged | Remains the raw prediction pointer; writing edited versions here would clobber the source (`hastelib/src/hastegeo/core/models/projects.py:440`). | -| Model metadata | `editedPredictions` | absent | `Optional[List[EditedPredictionVersion]]`, default empty list | Append-only numbered history: `edit_v1`, `edit_v2`, … | -| Model metadata | `predictedBuildingCount` | absent | `Optional[int]` | Positive count gates embedding editing; avoids treating an empty prediction write as editable. | +| Model metadata | `gpkgUrl` | optional string holding the prediction GeoPackage URL | unchanged | Remains the raw prediction pointer; writing edited versions here would clobber the source (`hastelib/src/hastegeo/core/models/projects.py:431-438`). | +| Model metadata | `editedPredictions` | absent | `Optional[List[EditedPredictionVersion]]`, default empty list | Append-only numbered history: `edit_v1`, `edit_v2`, … (`hastelib/src/hastegeo/core/models/projects.py:492-496`). | +| Model metadata | `predictedBuildingCount` | absent | `Optional[int]` | Positive count gates embedding readiness; `0` means the analyst cleared labels and should not show results as ready (`hastelib/src/hastegeo/core/utils/model_readiness.py:168-198`). | | Model metadata | `predictedAt` | absent | `Optional[str]` ISO 8601 timestamp | Set when embedding predictions are written or prep validates the raw prediction set. | -| Model metadata | `predictionAttrsUrl` | absent | `Optional[str]` | URL to the per-model columnar prediction attribute JSON sidecar. | +| Model metadata | `predictionAttrsUrl` | absent | `Optional[str]` | URL to the per-model columnar prediction attribute JSON sidecar (`hastelib/src/hastegeo/core/models/projects.py:520-526`). | | Model metadata | `predictionTilesJob` | absent | `Optional[TrainingJob]` | Batch/local runner job metadata for the queued prep workflow. | | Model metadata | `predictionTilesStatus` | absent | `Optional[str]` | Prep status using HASTE status values: `Queued`, `InProgress`, `Processed`, `Failed`, `Cancelled`. | | Model metadata | `predictionTilesStatusMessage` | absent | `Optional[str]`, default `""` | User-visible appended progress/failure messages for prep polling. | -| ImageLayer metadata | `footprintPmtilesUrl` | absent | `Optional[str]` | Layer-level PMTiles for all footprints used by prediction editing. Normally written by the layer-only tiling job queued at image-layer creation; still written by the model-scoped prep job for layers created before that existed. | +| ImageLayer metadata | `footprintPmtilesUrl` | absent | `Optional[str]` | Layer-level PMTiles for all footprints used by results viewing/editing. Normally written by the layer-only tiling job queued at image-layer creation; still written by the model-scoped prep job for older layers. | | ImageLayer metadata | `footprintTilesJob` | absent | `Optional[TrainingJob]` | Batch/local runner job metadata for the layer-only tiling job. Separate from `Model.predictionTilesJob` because the job has no model. | -| ImageLayer metadata | `footprintTilesStatus` | absent | `Optional[str]` | Status of that job using HASTE status values. Deliberately not `ImageLayer.status`: tiling is an optimisation and must never affect the imagery-preprocessing lifecycle. | +| ImageLayer metadata | `footprintTilesStatus` | absent | `Optional[str]` | Status of that job using HASTE status values. Deliberately not `ImageLayer.status`: tiling is an optimisation and must never affect imagery preprocessing. | | ImageLayer metadata | `footprintTilesStatusMessage` | absent | `Optional[str]`, default `""` | Appended progress/failure messages for the layer-only tiling job. | ### Transport-Only Wire Models @@ -93,9 +100,11 @@ wrapper, or in `projects.py`, which holds persisted document schemas. This mirrors the publishing split between `PublishRequest` transport models and the persisted `PublishedDataset` schema in `publishing.py`. -`store_artifact` currently uploads with `overwrite=True`, so version identity -comes from unique artifact names instead of mutating a blob in place -(`hastelib/src/hastegeo/core/artifact_storage/azure_blob_artifact_storage.py:255`). +`store_artifact` uploads by artifact name, so version identity comes from unique +artifact names instead of mutating the raw model pointer. The edit writer and API +handler never write edited output to `Model.gpkgUrl` +(`hastelib/src/hastegeo/core/processors/prediction_edits.py:355-422`, +`api/hastefuncapi/function_app.py:3202-3205`). --- @@ -114,13 +123,13 @@ partitioning used by `ArtifactProcessor`. | Container | Change | Description | |---|---|---| -| existing artifacts container | add edited prediction GeoPackage blobs | One immutable blob per numbered edit version. | +| existing artifacts container | add edited prediction GeoPackage blobs | One immutable-by-convention blob per numbered edit version. | | existing artifacts container | add prediction attribute sidecar blobs | Columnar JSON sidecar for full prediction attributes. | | existing artifacts container | add layer footprint PMTiles blobs | Layer-level PMTiles shared by models for an image layer. | ### Blob Path Conventions -Artifact names are added to `ArtifactTypes`: +Artifact names are added to `ArtifactTypes` (`hastelib/src/hastegeo/core/config.py:165-172`): ```python EDITED_PREDICTIONS_GPKG = Template("edited_predictions_${modelId}_v${version}") @@ -141,24 +150,24 @@ Logical layout: footprints_{imageLayerId}.pmtiles ``` -The exact physical namespace should follow `ArtifactProcessor` conventions, but +The exact physical namespace follows `ArtifactProcessor` conventions, but artifact names must match the templates above. Edited GeoPackages are immutable -by convention; a later save always writes the next version. +by convention; a later save writes the next version. #### Edited prediction GeoPackage schema The source schemas differ by producer. Trained inference writes continuous fractions and a default layer name; embedding writes layer `"predictions"`, an `area` column, and `damage_pct_0m` as a 0.0/1.0 copy of `damaged` -(`docker/training/code/merge_with_building_footprints.py:221-231`, -`api/hastefuncapi/function_app.py:2638-2786`). The edited output must normalize -the minimum columns below while preserving any safe source columns that do not +(`docker/training/code/merge_with_building_footprints.py:221-258`, +`api/hastefuncapi/function_app.py:2738-2815`). The edited output normalizes the +minimum columns below while preserving any safe source columns that do not conflict. | Column | Type | Required | Description | |---|---|---|---| | `id` | int | yes | Source row index; must remain in original order. | -| `damage_pct_0m` | float | yes | Damage fraction in `[0,1]`; continuous for trained inference, degenerate 0.0/1.0 for embedding. | +| `damage_pct_0m` | float | yes | Damage fraction in `[0,1]`; continuous for trained inference, degenerate 0.0/1.0 for embedding. Preserved from the producer even when `damaged` is overridden. | | `damage_pct_10m` | float | trained source only | Preserve when present. | | `damage_pct_20m` | float | trained source only | Preserve when present. | | `unknown_pct` | float | yes | Unknown fraction; default to `0.0` when absent. | @@ -169,9 +178,8 @@ conflict. | `overture_id` | string | yes | Explicit Overture building id copied from source footprints by row order. | | `geometry` | geometry | yes | Original prediction geometry and CRS. | -The existing trained output computes `damaged` as `damage_pct_0m > 0` -(`docker/training/code/merge_with_building_footprints.py:254`). Edited outputs -replace that rule with the documented thresholded final class. +The edited writer implements the rewrite/add-column behavior and preserves row +order (`hastelib/src/hastegeo/core/processors/prediction_edits.py:246-279`). #### Attribute sidecar schema @@ -190,10 +198,10 @@ The prediction attribute sidecar is JSON and is streamed by ``` All arrays must have length `n` and must be ordered exactly like the prediction -GeoPackage rows. This order matters because current report logic joins -predictions to Overture ids positionally, not by id -(`hastelib/src/hastegeo/core/utils/assessment.py:376-395`, -`api/hastefuncapi/function_app.py:4116-4133`). +GeoPackage rows (`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:351-416`). +This order matters because current report logic joins predictions to Overture ids +positionally, not by id (`hastelib/src/hastegeo/core/utils/assessment.py:368-395`, +`api/hastefuncapi/function_app.py:4808-4827`). --- @@ -216,7 +224,7 @@ artifacts only. | Queue Name | Message Schema | Producer | Consumer | |---|---|---|---| -| `prediction-edit-prep-queue` | See [design.md](design.md#queue-prediction-edit-prep-queue) | `hastefuncapi` `PutPreparePredictionTilesQueueMessage` | `hastefuncqueues` prediction-edit-prep trigger | +| `prediction-edit-prep-queue` | See [design.md](design.md#queue-prediction-edit-prep-queue) | `hastefuncapi` `PutPreparePredictionTilesQueueMessage` and `ImageryPostProcessor` layer-only enqueue | `hastefuncqueues` prediction-edit-prep trigger | The queue is for PMTiles and sidecar preparation only. Saving edited GeoPackages remains an API-driven write in v1. @@ -225,7 +233,7 @@ GeoPackages remains an API-driven write in v1. this queue in the current implementation. `Config` supplies the `prediction-edit-prep-queue` default, the Functions host can create the queue, and editing Bicep without regenerating `infra/main.json` would create infra -drift. +drift (`hastelib/src/hastegeo/core/config.py:341-347`). --- @@ -237,7 +245,7 @@ drift. |---|---|---| | VM SKU | existing training/CPU-capable pool | No GPU requirement; uses the training image because it includes `tippecanoe`. | | Pool size | existing autoscale | Prep is bursty and should not require a dedicated pool in v1. | -| Container image | `docker/training/` | `tippecanoe` is present only in the training conda env (`docker/training/env/env.yml:11`). | +| Container image | `docker/training/` | The prep workflow must run where `tippecanoe` is available, not inline in the Function App (`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:40-45`). | --- @@ -245,8 +253,10 @@ drift. ### Write Path +Edit save path: + ```text -UI save overrides + thresholds +Visualizer edit mode save overrides + thresholds → hastefuncapi PutEditedPredictions → hastegeo.core.processors.prediction_edits.apply_edits → hastegeo.core.processors.prediction_edits.store_edited_version @@ -257,8 +267,9 @@ UI save overrides + thresholds Prep write path: ```text -UI opens editor - → hastefuncapi GetPredictionEditSession +Visualizer opens / loads predictions + → hastefuncapi GetVisualizerResults reports readiness and artifact routes + → hastefuncapi GetPredictionEditSession when artifacts are missing or edit mode opens → hastefuncapi PutPreparePredictionTilesQueueMessage when missing → Queue Storage prediction-edit-prep-queue → hastefuncqueues @@ -267,8 +278,8 @@ UI opens editor → Cosmos ImageLayer.footprintPmtilesUrl + Model.predictionAttrsUrl/predictedBuildingCount/predictedAt/predictionTilesStatus ``` -Layer-time prep write path (no model; runs at image-layer creation so the -tiles already exist by the time anyone opens the editor): +Layer-time prep write path (no model; runs at image-layer creation so the tiles +already exist by the time anyone opens View Results): ```text imageryprep workflow caches building footprints @@ -282,13 +293,26 @@ imageryprep workflow caches building footprints ### Read Path +Visualizer/read-only and edit-mode path: + ```text -UI PredictionEditor page - → hastefuncapi GetPredictionEditSession (metadata and readiness) +UI View Results page + → hastefuncapi GetVisualizerResults (imagery, nullable rasters, vector artifact routes, readiness, versions) → hastefuncapi GetModelArtifact?kind=footprint_pmtiles → hastefuncapi GetModelArtifact?kind=prediction_attrs → Azure Maps PMTiles + in-memory sidecar rendering - → hastefuncapi GetEditedPredictionVersions (history) + → pencil / E enters edit mode + → hastefuncapi GetPredictionEditSession (lazy flavor/readiness/history refresh) + → hastefuncapi GetEditedPredictionVersions after save +``` + +Report/readers path: + +```text +GetVisualizerResults / GetValidationReport / GetAssessmentReport + → hastegeo.core.utils.predictions.resolve_prediction_source(model, version) + → newest edited GeoPackage by default, raw Model.gpkgUrl for version=0, + or the requested edited version for version=N ``` --- @@ -301,27 +325,34 @@ UI PredictionEditor page 2. Deploy new artifact types and `GetModelArtifact` kinds. 3. Deploy queue worker support for PMTiles and sidecar creation. 4. Deploy API routes and the explicit prep PUT route. -5. Deploy UI route and edit buttons. -6. Enable the feature in dev/test and backfill `predictedBuildingCount` through +5. Deploy vector-first `GetVisualizerResults`, `predictionsReady` on model + payloads, and `version` support in visualizer/validation/assessment readers. +6. Deploy Visualizer edit mode and Results menu changes; do not add a standalone + edit route. +7. Enable the feature in dev/test and backfill `predictedBuildingCount` through `PutBuildingPredictions` for embedding models or prep completion for raw prediction GeoPackages. -Existing trained models can use `inferenceStatus === "Processed" && gpkgUrl`. -Existing embedding models with only `gpkgUrl` should remain disabled until a -positive `predictedBuildingCount` is set, because `PutBuildingPredictions` can -write empty predictions while still setting `gpkgUrl` -(`api/hastefuncapi/function_app.py:2638-2786`, -`ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:86`). +Existing trained models can use the server readiness fallback for processed +inference artifacts. Existing embedding models with `gpkgUrl` but no +`predictedBuildingCount` fall back to `gpkgUrl` for backward compatibility; +`predictedBuildingCount == 0` is explicitly not ready because Clear labels can +write an empty predictions GeoPackage while still setting `gpkgUrl` +(`hastelib/src/hastegeo/core/utils/model_readiness.py:142-146`, +`hastelib/src/hastegeo/core/utils/model_readiness.py:168-198`). ### Backward Migration -1. Revert API and UI deployments if needed. -2. Stop or drain the prediction-edit prep queue if workers are failing. -3. Leave `editedPredictions`, `predictedBuildingCount`, `predictedAt`, +1. Revert UI deployment to remove Visualizer edit mode and the embedding View + Results entry point if needed. +2. Revert API deployment if direct prediction-editing calls or newest-edited + report defaults must be disabled. +3. Stop or drain the prediction-edit prep queue if workers are failing. +4. Leave `editedPredictions`, `predictedBuildingCount`, `predictedAt`, `predictionAttrsUrl`, `predictionTilesJob`, `predictionTilesStatus`, `predictionTilesStatusMessage`, and `footprintPmtilesUrl` fields in place; old code ignores unknown optional fields. -4. Leave edited GeoPackage, PMTiles, and sidecar blobs in storage unless a +5. Leave edited GeoPackage, PMTiles, and sidecar blobs in storage unless a cleanup script is explicitly approved. ## Data Volume Estimates @@ -337,7 +368,8 @@ write empty predictions while still setting `gpkgUrl` | Data | Cache Layer | TTL | Invalidation | |---|---|---|---| -| `GetPredictionEditSession` metadata | Browser state | until model refresh or route leave | Reload after save or prep completion. | -| `prediction_attrs` sidecar | Browser memory | current editor session | Refetch when `predictedAt` or source `gpkgUrl` changes. | -| `footprint_pmtiles` | Browser memory / HTTP cache | current editor session; cacheable by blob version/url | Regenerate when source footprints change. | -| Edited version list | Browser state | current editor session | Refresh after `PutEditedPredictions` succeeds. | +| `GetVisualizerResults` payload | Browser route state | current View Results load | Refetch on route change or manual reload; UI version switching is not wired. | +| `GetPredictionEditSession` metadata | Browser state | until model refresh or route leave | Loaded lazily on edit/prep and refreshed during prep polling or after save. | +| `prediction_attrs` sidecar | Browser memory | current visualizer session | Refetch when `predictedAt` or source `gpkgUrl` changes. | +| `footprint_pmtiles` | Browser memory / HTTP cache | current visualizer session; cacheable by blob version/url | Regenerate when source footprints change. | +| Edited version list | Browser state | current visualizer session | Seeded by `GetVisualizerResults`, refreshed after `PutEditedPredictions` succeeds. | diff --git a/spec/features/prediction-editing/design.md b/spec/features/prediction-editing/design.md index 6d935507..de1f181b 100644 --- a/spec/features/prediction-editing/design.md +++ b/spec/features/prediction-editing/design.md @@ -4,100 +4,164 @@ ## Overview -Prediction editing adds a dedicated React screen for full-building prediction -review. The screen reads footprint geometry from PMTiles, reads prediction -attributes from a columnar JSON sidecar, lets an analyst set class overrides, -and saves each edit as a new derived GeoPackage version. Reference the HASTE -architecture in `spec/architecture/overview.md`; this design keeps Azure -Functions as thin HTTP wrappers and moves data manipulation into `hastegeo`. - -The raw prediction GeoPackage remains immutable. Existing downstream consumers -continue to use the raw `Model.gpkgUrl`; edited versions are produced, listed, -and downloadable only. +Prediction editing is a mode of the existing **View Results** page. The +visualizer already owns the two-map swipe view, raster overlays, imagery +metadata, and results URL; edit mode adds the predicted-footprint vector layer, +right-side edit panel, save action, and version history without navigating away +from `/visualizer/:projectId/:imageLayerId/:modelId` +(`ui/src/Components/AppBody.jsx:73-75`, +`ui/src/Components/Visualizer/Visualizer.jsx:13-28`, +`ui/src/Components/Visualizer/Labels.jsx:117-128`). + +The results viewer is vector-first. Both trained inference and embedding models +can render predicted building footprints from the layer/model PMTiles plus the +model's columnar prediction attribute sidecar. The trained-inference rasters +remain optional overlays; embedding models return `null` for those fields because +they do not write COGs (`hastelib/src/hastegeo/core/processors/visualizer.py:4-29`, +`hastelib/src/hastegeo/core/processors/visualizer.py:303-331`, +`hastelib/src/hastegeo/core/models/visualizer.py:55-82`). + +The raw prediction GeoPackage remains immutable. Each edit save appends a new +`EditedPredictionVersion` and writes a versioned GeoPackage, while readers use +`resolve_prediction_source` to select the newest edit by default or an explicit +`version` (`0` selects raw). This keeps the ADR's no-mutable-pointer decision +while making edits visible to the visualizer, validation report, and assessment +report (`hastelib/src/hastegeo/core/utils/predictions.py:332-401`, +`api/hastefuncapi/function_app.py:2386-2435`, +`api/hastefuncapi/function_app.py:4677-4688`, +`api/hastefuncapi/function_app.py:5017-5027`). ## Architecture ### Component Diagram ``` -┌──────────────────────────────┐ -│ React UI │ -│ Model row Edit button │ -│ PredictionEditor page │ -│ Azure Maps + PMTiles │ -└──────────────┬───────────────┘ - │ GET session / PUT prep / attrs / tiles - ▼ -┌──────────────────────────────┐ metadata ┌────────────────────┐ -│ hastefuncapi │◀─────────────────▶│ Cosmos metadata │ -│ GetPredictionEditSession │ │ Project/Layer/Model │ -│ PutPreparePredictionTiles... │ -│ PutEditedPredictions │ └────────────────────┘ -│ GetEditedPredictionVersions │ -│ GetModelArtifact kinds │ -└───────┬───────────────┬──────┘ - │ SAS/download │ queue after explicit PUT prep request - ▼ ▼ -┌──────────────────┐ ┌────────────────────────────┐ -│ Blob Storage │ │ hastefuncqueues │ -│ raw GPKG │ │ prediction-edit-prep queue │ -│ edited GPKG vN │ └─────────────┬──────────────┘ -│ PMTiles + attrs │ │ run training image workflow -└──────────────────┘ ▼ - ┌────────────────────────────┐ - │ hastegeo workflow │ - │ fiona/geopandas + │ - │ tippecanoe PMTiles │ - └────────────────────────────┘ +┌────────────────────────────────────────────┐ +│ React UI │ +│ Results menu → /visualizer/... │ +│ Visualizer + Labels pencil / E shortcut │ +│ PredictionEditPanel + vector footprints │ +└──────────────────┬─────────────────────────┘ + │ GET visualizer / GET session / PUT prep / artifacts / PUT save + ▼ +┌────────────────────────────────────────────┐ metadata ┌────────────────────┐ +│ hastefuncapi │◀─────────────────▶│ Cosmos metadata │ +│ GetVisualizerResults (vector-first) │ │ Project/Layer/Model │ +│ GetPredictionEditSession │ └────────────────────┘ +│ PutPreparePredictionTilesQueueMessage │ +│ PutEditedPredictions │ +│ GetEditedPredictionVersions │ +│ GetModelArtifact kinds │ +│ GetValidationReport / GetAssessmentReport │ +└──────────────┬─────────────────┬───────────┘ + │ stream artifacts │ queue after explicit prep request + ▼ ▼ +┌──────────────────┐ ┌────────────────────────────┐ +│ Blob Storage │ │ hastefuncqueues │ +│ raw GPKG │ │ prediction-edit-prep queue │ +│ edited GPKG vN │ └─────────────┬──────────────┘ +│ PMTiles + attrs │ │ run training image workflow +└──────────────────┘ ▼ + ┌────────────────────────────┐ + │ hastegeo workflow │ + │ fiona/geopandas + │ + │ tippecanoe PMTiles │ + └────────────────────────────┘ ``` ### New Components | Component | Path | Responsibility | Technology | |---|---|---|---| -| Prediction edit engine | `hastelib/src/hastegeo/core/processors/prediction_edits.py` | Apply overrides and thresholds, derive final classes, allocate the next version, and store edited GeoPackages | Python / Fiona | -| Prediction schema utilities | `hastelib/src/hastegeo/core/utils/predictions.py` | Normalize trained-inference vs embedding GeoPackage schemas, preserve row order, and resolve Overture ids positionally | Python / Fiona | +| Prediction edit engine | `hastelib/src/hastegeo/core/processors/prediction_edits.py` | Apply overrides and thresholds, derive final classes, allocate the next version, and store edited GeoPackages (`hastelib/src/hastegeo/core/processors/prediction_edits.py:1-19`, `hastelib/src/hastegeo/core/processors/prediction_edits.py:226-308`) | Python / Fiona | +| Prediction schema and source utilities | `hastelib/src/hastegeo/core/utils/predictions.py` | Normalize trained-inference vs embedding GeoPackage schemas, preserve row order, resolve Overture ids positionally, and choose raw/newest/explicit edited sources (`hastelib/src/hastegeo/core/utils/predictions.py:4-34`, `hastelib/src/hastegeo/core/utils/predictions.py:318-401`) | Python / Fiona | +| Model readiness utility | `hastelib/src/hastegeo/core/utils/model_readiness.py` | Single server-side readiness rule for model rows, visualizer readiness, and publishing completion (`hastelib/src/hastegeo/core/utils/model_readiness.py:4-25`, `hastelib/src/hastegeo/core/utils/model_readiness.py:132-237`) | Python | +| Visualizer payload builder | `hastelib/src/hastegeo/core/processors/visualizer.py` | Build the vector-first `GetVisualizerResults` payload and nullable raster layers for both workflows (`hastelib/src/hastegeo/core/processors/visualizer.py:215-336`) | Python | | Prediction HTTP wire models | `hastelib/src/hastegeo/core/models/predictions.py` | Transport-only Pydantic request bodies for save and prep routes; kept out of persisted project schemas | Python / Pydantic | -| Prediction edit models | `hastelib/src/hastegeo/core/models/projects.py` | `EditedPredictionVersion`; new optional `Model` and `ImageLayer` fields | Python / Pydantic | -| Prediction edit prep workflow | `hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py` | Build footprint PMTiles and prediction attribute sidecar from the raw prediction GeoPackage and layer footprints | Python / tippecanoe | -| Prediction tiles job processor | `hastelib/src/hastegeo/core/processors/prediction_tiles.py` | Decide whether tiles/sidecar are missing, submit the workflow to the training image through `UnifiedRunner`, persist artifact URLs | Python | -| Queue trigger | `api/hastefuncqueues/function_app.py` | Consume prediction-edit-prep messages and invoke the workflow through the existing runner pattern | Azure Functions | -| Prediction edit page | `ui/src/Components/PredictionEditor/PredictionEditor.jsx` | Full-screen editor with Azure Maps, PMTiles, filters, traversal, overrides, threshold slider, and save action | React / Fluent UI / Azure Maps | -| Prediction edit helpers | `ui/src/Components/PredictionEditor/predictionClassify.js`, `ui/src/Components/PredictionEditor/predictionPrep.js` | Class derivation, sidecar loading, counts, selection state, request shaping, and prep polling decisions | JavaScript | -| Shared PMTiles protocol | `ui/src/util/pmtiles.js` | Single process-wide PMTiles protocol instance and in-memory source used by Azure Maps screens | JavaScript / PMTiles | +| Prediction edit models | `hastelib/src/hastegeo/core/models/projects.py` | `EditedPredictionVersion`; new optional `Model` and `ImageLayer` fields (`hastelib/src/hastegeo/core/models/projects.py:343-505`, `hastelib/src/hastegeo/core/models/projects.py:520-529`, `hastelib/src/hastegeo/core/models/projects.py:842-851`) | Python / Pydantic | +| Prediction edit prep workflow | `hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py` | Build footprint PMTiles and prediction attribute sidecar from the raw prediction GeoPackage and layer footprints (`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:4-46`, `hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:322-416`) | Python / tippecanoe | +| Prediction tiles job processor | `hastelib/src/hastegeo/core/processors/prediction_tiles.py` | Decide whether tiles/sidecar are missing, submit the workflow to the training image through `UnifiedRunner`, persist artifact URLs (`hastelib/src/hastegeo/core/processors/prediction_tiles.py:251-370`, `hastelib/src/hastegeo/core/processors/prediction_tiles.py:475-560`) | Python | +| Queue trigger | `api/hastefuncqueues/function_app.py` | Consume prediction-edit-prep messages and invoke model-scoped or layer-only preparation through the existing runner pattern (`api/hastefuncqueues/function_app.py:861-914`) | Azure Functions | +| Visualizer edit affordance | `ui/src/Components/Visualizer/Labels.jsx` | Pencil/Done button next to Back; disabled-state tooltip (`ui/src/Components/Visualizer/Labels.jsx:8-12`, `ui/src/Components/Visualizer/Labels.jsx:117-128`) | React / Fluent UI | +| Visualizer edit mode | `ui/src/Components/Visualizer/Visualizer.jsx` | Enters/leaves edit mode, hides conflicting rasters while editing, handles unsaved discard dialog, keyboard shortcuts, and edit panel render (`ui/src/Components/Visualizer/Visualizer.jsx:457-605`, `ui/src/Components/Visualizer/Visualizer.jsx:873-921`) | React / Azure Maps | +| Prediction artifact hook | `ui/src/Components/Visualizer/usePredictionArtifacts.js` | Load vector artifacts, request prep, poll readiness, cache versions, and expose active version (`ui/src/Components/Visualizer/usePredictionArtifacts.js:4-24`, `ui/src/Components/Visualizer/usePredictionArtifacts.js:177-221`, `ui/src/Components/Visualizer/usePredictionArtifacts.js:377-459`) | React / PMTiles | +| Prediction footprint hook | `ui/src/Components/Visualizer/usePredictionFootprints.js` | Add footprint layers to both swipe panes, apply feature-state coloring, selection, overrides, save, and discard (`ui/src/Components/Visualizer/usePredictionFootprints.js:4-29`, `ui/src/Components/Visualizer/usePredictionFootprints.js:313-376`, `ui/src/Components/Visualizer/usePredictionFootprints.js:838-902`) | React / Azure Maps | +| Prediction edit panel | `ui/src/Components/Visualizer/PredictionEditPanel.jsx` | Counts, filters, traversal, threshold sliders when supported, save button, Done button, keyboard help, and read-only saved-version history (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:4-16`, `ui/src/Components/Visualizer/PredictionEditPanel.jsx:300-585`) | React / Fluent UI | +| Results decision helpers | `ui/src/Components/Visualizer/predictionResults.js`, `predictionClassify.js`, `predictionPrep.js`, `predictionFootprintMap.js`, `visualizerSwipe.js` | Pure helper logic for payload interpretation, classification, prep polling, map paint expressions, and swipe hints; covered by Node tests (`ui/src/Components/Visualizer/predictionResults.js:20-25`, `ui/src/Components/Visualizer/predictionResults.js:96-103`, `ui/src/Components/Visualizer/predictionResults.js:320-385`) | JavaScript | ### Modified Components | Component | Path | Change Description | |---|---|---| -| Artifact types | `hastelib/src/hastegeo/core/config.py` | Add `EDITED_PREDICTIONS_GPKG`, `PREDICTION_ATTRS`, and `LAYER_FOOTPRINT_PMTILES` templates | -| Model schema | `hastelib/src/hastegeo/core/models/projects.py` | Add `editedPredictions`, `predictedBuildingCount`, `predictedAt`, `predictionAttrsUrl`, `predictionTilesJob`, `predictionTilesStatus`, and `predictionTilesStatusMessage`; keep `gpkgUrl` as the raw prediction pointer | -| Image layer schema | `hastelib/src/hastegeo/core/models/projects.py` | Add `footprintPmtilesUrl` for layer-level footprint tiles | -| API module | `api/hastefuncapi/function_app.py` | Add four thin prediction-editing endpoints and extend `GetModelArtifact` artifact-kind dispatch | -| Trained model row | `ui/src/Components/ProjectManagement/ModelResultsButton.jsx` | Add **Edit** button enabled when `inferenceStatus === "Processed" && gpkgUrl` | -| Embedding model row | `ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx` | Add **Edit** button enabled when `gpkgUrl && predictedBuildingCount > 0` | -| App routing | `ui/src/Components/AppBody.jsx` | Register `/edit-predictions/:projectId/:imageLayerId/:modelId` | -| Existing editor references | `ui/src/Components/BuildingValidation/BuildingValidation.jsx`, `ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx`, `ui/src/util/pmtiles.js` | Reuse interaction patterns: filters, prev/next traversal, PMTiles in-memory source, feature-state coloring, and box-select; share the PMTiles protocol singleton | +| Artifact types | `hastelib/src/hastegeo/core/config.py` | Add `EDITED_PREDICTIONS_GPKG`, `PREDICTION_ATTRS`, and `LAYER_FOOTPRINT_PMTILES` templates; queue config defaults to `prediction-edit-prep-queue` (`hastelib/src/hastegeo/core/config.py:165-172`, `hastelib/src/hastegeo/core/config.py:341-347`) | +| Model schema | `hastelib/src/hastegeo/core/models/projects.py` | Add edited-version, predicted-building, sidecar, and prep-status fields while keeping `gpkgUrl` as the raw prediction pointer (`hastelib/src/hastegeo/core/models/projects.py:431-438`, `hastelib/src/hastegeo/core/models/projects.py:491-529`) | +| Image layer schema | `hastelib/src/hastegeo/core/models/projects.py` | Add `footprintPmtilesUrl` and layer-only tiling status fields (`hastelib/src/hastegeo/core/models/projects.py:758-770`, `hastelib/src/hastegeo/core/models/projects.py:842-851`) | +| API module | `api/hastefuncapi/function_app.py` | Adds prediction-editing endpoints; extends `GetModelArtifact`; updates `GetVisualizerResults`, `GetValidationReport`, and `GetAssessmentReport`; stamps `predictionsReady` on model payloads (`api/hastefuncapi/function_app.py:1400-1510`, `api/hastefuncapi/function_app.py:2296-2435`, `api/hastefuncapi/function_app.py:2920-3420`, `api/hastefuncapi/function_app.py:4607-4688`, `api/hastefuncapi/function_app.py:4929-5027`) | +| Trained model row | `ui/src/Components/ProjectManagement/ModelResultsButton.jsx` | Uses `predictionsReady` to enable View Results and removes the standalone Edit action (`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:87-110`) | +| Embedding model row | `ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx` | Adds View Results as the first Results menu item and removes the standalone Edit action (`ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:85-130`) | +| App routing | `ui/src/Components/AppBody.jsx` | Keeps `/visualizer/:projectId/:imageLayerId/:modelId`; no `/edit-predictions/...` route is registered (`ui/src/Components/AppBody.jsx:73-75`) | +| Existing editor references | `ui/src/Components/Visualizer/`, `ui/src/util/pmtiles.js` | Visualizer now owns PMTiles loading, feature-state coloring, filters, prev/next traversal, box-select, keyboard shortcuts, and shared PMTiles protocol (`ui/src/Components/Visualizer/usePredictionArtifacts.js:25-32`, `ui/src/Components/Visualizer/usePredictionFootprints.js:313-376`, `ui/src/Components/keyboardShortcuts.js:60-80`) | ## API Design The route names follow the current Azure Functions convention in -`function_app.py`. Endpoints use `func.AuthLevel.FUNCTION` and must delegate -non-HTTP logic to `hastegeo`. +`function_app.py`. Endpoints use `func.AuthLevel.FUNCTION` and delegate non-HTTP +logic to `hastegeo`. + +### Model payloads: `predictionsReady` + +Every endpoint that returns model objects (`GetProjectDetails`, +`GetLayerDetailView`, and `GetLayerModelsDetails`) stamps a derived +`predictionsReady` boolean in memory. It is not persisted and should not be sent +back in a `PutModel` body (`api/hastefuncapi/function_app.py:785-788`, +`api/hastefuncapi/function_app.py:1262-1266`, +`api/hastefuncapi/function_app.py:1380-1383`). The exact readiness rule is +specified in the API docs and implemented in `model_readiness.py` +(`docs/api/hastefuncapi.md:59-76`, +`hastelib/src/hastegeo/core/utils/model_readiness.py:132-237`). ### hastefuncapi Endpoints +#### `GET /api/GetVisualizerResults` + +**Auth:** `func.AuthLevel.FUNCTION` + +**Description:** Return everything the View Results page needs for one model. +This is the primary read path for both workflows and the data source for the +vector footprint layer. The full response shape is documented in +`docs/api/hastefuncapi.md`; keep that API reference as the contract rather than +restating a divergent schema here (`docs/api/hastefuncapi.md:78-157`). + +**Key semantics:** + +- `footprintTilesUrl` and `predictionAttrsUrl` are API-relative + `GetModelArtifact` routes, not blob URLs. +- `predictedDamageLayer` and `predictionsLayer` are nullable. They are normally + present only for trained-inference models with prediction COGs. +- `predictionsReady` in this payload is stricter than the model-row flag because + it also requires browser artifacts to exist; `predictionsReadiness` explains + `ready`, `not_processed`, `no_predictions`, `no_buildings`, or `preparing`. +- `flavor`, `supportsThreshold`, and `buildingCount` come from reading the + selected prediction GeoPackage. If the file cannot be read, the payload still + returns imagery and readiness with those fields null. +- `predictionVersion` reports the edited version on the map (`null` for raw), + and `predictionVersions` returns `Model.editedPredictions` newest first. +- Optional `version` follows the shared reader contract: omit for newest edit, + `0` for raw, or `N` for a specific edited version + (`api/hastefuncapi/function_app.py:157-171`, + `api/hastefuncapi/function_app.py:2386-2435`). + #### `GET /api/GetPredictionEditSession` **Auth:** `func.AuthLevel.FUNCTION` -**Description:** Return everything the UI needs to decide whether the editor can -load. The endpoint uses `projectId` to load the image layer and model, -distinguishes trained inference from embedding predictions by reading the raw -GeoPackage, and reports whether the PMTiles and attribute sidecar already exist. -It is side-effect-free: it does not enqueue preparation work. When preparation -is missing, the UI calls `PutPreparePredictionTilesQueueMessage` and then polls -this endpoint. +**Description:** Return the additional data edit mode needs when it opens. The +endpoint uses `projectId` to load the image layer and model, distinguishes +trained inference from embedding predictions by reading the selected raw +GeoPackage, and reports whether PMTiles and the sidecar already exist. It is +side-effect-free: it does not enqueue preparation work. When preparation is +missing, the UI calls `PutPreparePredictionTilesQueueMessage` and then polls +this endpoint (`api/hastefuncapi/function_app.py:2920-3025`). **Query parameters:** @@ -107,36 +171,11 @@ this endpoint. | `imageLayerId` | string | yes | Image layer that owns the source building footprints. | | `modelId` | string | yes | Model whose raw `gpkgUrl` supplies predictions. | -**Response (200):** - -```json -{ - "modelId": "12345", - "flavor": "inference", - "supportsThreshold": true, - "defaultThreshold": 0.0, - "buildingCount": 125430, - "tilesReady": true, - "attrsReady": true, - "predictionTilesStatus": "Processed", - "predictionTilesStatusMessage": "", - "versions": [ - { - "version": 1, - "gpkgUrl": "https://...", - "createdAt": "2026-08-21T05:10:48Z", - "createdBy": "analyst@example.com", - "threshold": 0.1, - "unknownThreshold": 0.0, - "editedCount": 53, - "sourceGpkgUrl": "https://...raw.gpkg" - } - ] -} -``` - -For embedding models, `flavor` is `"embedding"` and `supportsThreshold` is -`false`; the UI must hide the threshold slider. +**Response (200):** `modelId`, `flavor`, `supportsThreshold`, +`defaultThreshold`, `buildingCount`, `tilesReady`, `attrsReady`, +`predictionTilesStatus`, `predictionTilesStatusMessage`, and `versions`. +Embedding models return `flavor="embedding"` and `supportsThreshold=false`, so +the UI hides threshold sliders (`api/hastefuncapi/function_app.py:3005-3025`). **Error Responses:** @@ -166,18 +205,8 @@ read-only. } ``` -**Response (200):** - -```json -{ - "modelId": "12345", - "queued": true, - "tilesReady": false, - "attrsReady": false, - "status": "Queued", - "statusMessage": "\n2026-08-21T05:10:48+00:00: Queued for prediction tile preparation" -} -``` +**Response (200):** `modelId`, `queued`, `tilesReady`, `attrsReady`, `status`, +and `statusMessage`, matching `request_preparation` (`hastelib/src/hastegeo/core/processors/prediction_tiles.py:251-370`). **Semantics:** @@ -204,11 +233,11 @@ read-only. **Auth:** `func.AuthLevel.FUNCTION` -**Description:** Apply a threshold and explicit user overrides to the source +**Description:** Apply a threshold and explicit user overrides to the raw source prediction GeoPackage, write a new edited GeoPackage, upload it under the next numbered version, and append an `EditedPredictionVersion` entry to the `Model`. -The endpoint is synchronous in v1, but all geospatial work must live in -`hastegeo`. +The endpoint is synchronous in v1, but all geospatial work lives in `hastegeo` +(`api/hastefuncapi/function_app.py:3181-3345`). **Request:** @@ -225,15 +254,7 @@ The endpoint is synchronous in v1, but all geospatial work must live in } ``` -**Response (200):** - -```json -{ - "version": 2, - "gpkgUrl": "https://.../edited_predictions_12345_v2.gpkg", - "editedCount": 53 -} -``` +**Response (200):** `version`, `gpkgUrl`, and `editedCount`. **Error Responses:** @@ -245,7 +266,8 @@ The endpoint is synchronous in v1, but all geospatial work must live in | 500 | Blob, metadata, or geospatial write failure | Override ids outside the source row range are ignored and logged rather than -rejected. The response `editedCount` counts only overrides that matched a row. +rejected. The response `editedCount` counts only overrides that matched a row +(`hastelib/src/hastegeo/core/processors/prediction_edits.py:293-308`). #### `GET /api/GetEditedPredictionVersions` @@ -258,24 +280,10 @@ rejected. The response `editedCount` counts only overrides that matched a row. | `projectId` | string | yes | Project metadata partition key. | | `modelId` | string | yes | Model id. | -**Response (200):** - -```json -{ - "versions": [ - { - "version": 1, - "gpkgUrl": "https://...", - "createdAt": "2026-08-21T05:10:48Z", - "createdBy": "analyst@example.com", - "threshold": 0.1, - "unknownThreshold": 0.0, - "editedCount": 53, - "sourceGpkgUrl": "https://...raw.gpkg" - } - ] -} -``` +**Response (200):** `{"versions": [EditedPredictionVersion, ...]}`, newest +first. The same helper backs the visualizer payload and the edit session +(`api/hastefuncapi/function_app.py:2912-2917`, +`api/hastefuncapi/function_app.py:3376-3410`). **Error Responses:** @@ -289,15 +297,17 @@ rejected. The response `editedCount` counts only overrides that matched a row. **Auth:** `func.AuthLevel.FUNCTION` -Adds two `kind` values: +Adds two `kind` values. The route streams bytes through the Function App so auth, +managed identity, and HTTP `Range` support remain central (`api/hastefuncapi/function_app.py:1430-1458`). | Kind | Required params | Returns | |---|---|---| -| `footprint_pmtiles` | `projectId`, `imageLayerId`, `modelId` | Streamed bytes for `footprints_${imageLayerId}.pmtiles` | -| `prediction_attrs` | `projectId`, `modelId` | JSON sidecar for `prediction_attrs_${modelId}` | +| `footprint_pmtiles` | `projectId`, `imageLayerId`, `modelId` | Streamed bytes for the layer PMTiles, or the embedding model's own `pmtilesUrl` when available (`api/hastefuncapi/function_app.py:1489-1507`) | +| `prediction_attrs` | `projectId`, `modelId` | JSON sidecar for `prediction_attrs_${modelId}` (`api/hastefuncapi/function_app.py:1400-1424`) | The sidecar response uses the columnar format below. Arrays must be the same -length and order as the source prediction GeoPackage rows. +length and order as the source prediction GeoPackage rows +(`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:351-416`). ```json { @@ -310,6 +320,24 @@ length and order as the source prediction GeoPackage rows. } ``` +#### `GET /api/GetValidationReport`, `GET /api/GetAssessmentReport` (modified) + +Both report endpoints accept optional `version` with the same semantics as +`GetVisualizerResults`: omitted = newest edit or raw fallback, `0` = raw, and +`N` = a specific edited version. Unknown `N` returns 404 and malformed values +return 400 (`api/hastefuncapi/function_app.py:4607-4688`, +`api/hastefuncapi/function_app.py:4929-5027`, +`docs/api/hastefuncapi.md:480-502`). + +Important asymmetry: edited GeoPackages rewrite `damaged` but preserve the +producer's original `damage_pct_0m`. `GetValidationReport` builds metrics from +`damaged`, so analyst overrides move validation metrics. `GetAssessmentReport` +feeds `damage_pct_0m` into `compute_assessment_report`, so per-building overrides +do not move its threshold-based damaged counts until a follow-up changes the +assessment data model (`api/hastefuncapi/function_app.py:4808-4827`, +`api/hastefuncapi/function_app.py:5080-5103`, +`hastelib/src/hastegeo/core/utils/assessment.py:150-190`). + ### Queue Messages (hastefuncqueues) #### Queue: `prediction-edit-prep-queue` @@ -332,47 +360,38 @@ source footprints and raw prediction GeoPackage, validates equal row count and positional row order, writes or refreshes `footprints_${imageLayerId}.pmtiles` when missing, writes `prediction_attrs_${modelId}` from prediction columns, uploads both artifacts, and updates `ImageLayer.footprintPmtilesUrl`, -`Model.predictionAttrsUrl`, `Model.predictedBuildingCount`, -`Model.predictedAt`, `Model.predictionTilesJob`, -`Model.predictionTilesStatus`, and `Model.predictionTilesStatusMessage`. +`Model.predictionAttrsUrl`, `Model.predictedBuildingCount`, `Model.predictedAt`, +`Model.predictionTilesJob`, `Model.predictionTilesStatus`, and +`Model.predictionTilesStatusMessage` (`api/hastefuncqueues/function_app.py:721-827`). **Trigger behavior (layer-only, `modelId` empty):** The worker downloads the -source footprints only, writes `footprints_${imageLayerId}.pmtiles`, and -updates `ImageLayer.footprintPmtilesUrl`, `ImageLayer.footprintTilesJob`, -`ImageLayer.footprintTilesStatus`, and -`ImageLayer.footprintTilesStatusMessage`. No sidecar is built and no model -document is read or written — there is usually no model yet. Only the tiling -fields of the layer are patched on save, so a concurrent imagery-preprocessing -write is never clobbered. - -`ImageryPostProcessor` enqueues the layer-only message as soon as an image -layer completes with cached building footprints and no -`footprintPmtilesUrl`, so the editor normally finds the tiles already built. -That enqueue is best effort: a queue failure is logged and imagery -preprocessing still succeeds, because `PutPreparePredictionTilesQueueMessage` -rebuilds the tiles on demand (the path layers created before this change take). -Both jobs write the same deterministic artifact name, so an editor opened while -a layer-time job is still running merely repeats the tiling rather than -corrupting anything. - -Tile creation must run in the queued worker because `tippecanoe` is installed in -the training image only (`docker/training/env/env.yml:11`). Existing PMTiles -creation in `embed_buildings.py` is the invocation pattern to mirror -(`hastelib/src/hastegeo/workflows/embed_buildings.py:712-763`). +source footprints only, writes `footprints_${imageLayerId}.pmtiles`, and updates +`ImageLayer.footprintPmtilesUrl`, `ImageLayer.footprintTilesJob`, +`ImageLayer.footprintTilesStatus`, and `ImageLayer.footprintTilesStatusMessage`. +No sidecar is built and no model document is read or written (`api/hastefuncqueues/function_app.py:639-719`, +`api/hastefuncqueues/function_app.py:877-914`). + +`ImageryPostProcessor` enqueues the layer-only message as soon as an image layer +completes with cached building footprints and no `footprintPmtilesUrl`. That +enqueue is best effort: a queue failure is logged and imagery preprocessing +still succeeds, because the visualizer/edit preparation path rebuilds tiles on +demand (`hastelib/src/hastegeo/core/processors/imagery.py:249-257`, +`hastelib/src/hastegeo/core/processors/imagery.py:399-441`). ### Internal Interfaces (hastegeo) | Module | Function/Class | Signature | Description | |---|---|---|---| | `core/models/projects.py` | `EditedPredictionVersion` | `BaseModel` | Embedded version metadata on `Model`; see [data-model.md](data-model.md#modified-document-schema). | -| `core/models/predictions.py` | `PredictionOverrideRequest`, `EditedPredictionsRequest`, `PreparePredictionTilesRequest` | `BaseModel` | Transport-only HTTP request bodies; mirrors the `PublishRequest` / `PublishedDataset` split by keeping wire contracts out of persisted `projects.py` schemas. | +| `core/models/predictions.py` | `PredictionOverrideRequest`, `EditedPredictionsRequest`, `PreparePredictionTilesRequest` | `BaseModel` | Transport-only HTTP request bodies. | +| `core/utils/model_readiness.py` | `prediction_readiness`, `predictions_ready`, `annotate_predictions_ready` | `(model, config=None) -> PredictionReadiness/bool/dict` | Single model-readiness rule for UI payloads and publishing (`hastelib/src/hastegeo/core/utils/model_readiness.py:132-237`). | | `core/utils/predictions.py` | `read_predictions` | `(path: str, footprints_path: Optional[str] = None) -> PredictionSet` | Detects `inference` vs `embedding`, normalizes row attributes, and resolves Overture ids by positional row order. | -| `core/processors/prediction_edits.py` | `apply_edits` | `(src_gpkg: str, dst_gpkg: str, threshold: float, unknown_threshold: float, overrides: dict[int, str], footprints_path: Optional[str]) -> EditSummary` | Applies class derivation, preserves row order, and writes the edited GeoPackage. | +| `core/utils/predictions.py` | `resolve_prediction_source`, `describe_prediction_source`, `edited_prediction_versions` | `(model, version=None) -> str/PredictionSource/list` | Implements newest-wins, `version=0` raw, and explicit edited version selection (`hastelib/src/hastegeo/core/utils/predictions.py:318-401`). | +| `core/processors/visualizer.py` | `build_visualizer_results`, `visualizer_readiness`, `raster_layer_urls` | pure payload helpers | Assemble vector-first `GetVisualizerResults` payload and nullable raster layers (`hastelib/src/hastegeo/core/processors/visualizer.py:152-336`). | +| `core/processors/prediction_edits.py` | `apply_edits` | `(src_gpkg, dst_gpkg, threshold, unknown_threshold, overrides, footprints_path=None) -> EditSummary` | Applies class derivation, preserves row order, and writes the edited GeoPackage. | | `core/processors/prediction_edits.py` | `derive_class`, `next_version`, `store_edited_version` | helper functions | Compute final class, allocate the next version number, and store `edited_predictions_${modelId}_v${version}.gpkg`. | -| `core/processors/prediction_tiles.py` | `needs_preparation`, `request_preparation` | `(model: Model, image_layer: ImageLayer, force: bool = False) -> dict` | Decide whether PMTiles/sidecar artifacts are ready and enqueue at most one prep message for the explicit PUT route. | -| `core/processors/prediction_tiles.py` | `layer_needs_footprint_tiles` | `(image_layer: ImageLayer) -> bool` | Guard used by imagery prep: tiles are worth queueing only once footprints are cached and while the layer has no archive. | -| `core/processors/prediction_tiles.py` | `enqueue_prediction_tiles` | `(project_id: str, image_layer_id: str, model_id: Optional[str] = None, ...) -> dict` | Put one prep request on the queue. Omitting `model_id` requests the layer's footprint PMTiles alone. | -| `core/processors/prediction_tiles.py` | `PredictionTilesPostprocessor` | `(model: Optional[Model], image_layer: ImageLayer)` | Submit, poll, and finalize the queued training-image workflow. `model=None` runs layer-only and keeps all job state on the `ImageLayer`. | +| `core/processors/prediction_tiles.py` | `needs_preparation`, `request_preparation`, `resolve_tiles_url` | `(model, image_layer, force=False) -> flags/response` | Decide whether PMTiles/sidecar artifacts are ready and enqueue at most one prep message for the explicit PUT route. | +| `core/processors/prediction_tiles.py` | `layer_needs_footprint_tiles`, `enqueue_prediction_tiles`, `PredictionTilesPostprocessor` | helper / postprocessor | Queue and run model-scoped or layer-only tile prep. | | `core/processors/imagery.py` | `ImageryPostProcessor._enqueue_footprint_tiles` | `() -> None` | Best-effort layer-only enqueue once a completed layer has footprints and no tiles; never raises into imagery prep. | | `hastegeo/workflows/prepare_prediction_tiles.py` | `run` | `(config: dict, output_dir: str) -> dict` | Builds footprint PMTiles and, when `config["model_id"]` is set, the prediction attribute JSON sidecar. | | `api/hastefuncapi/function_app.py` | `GetModelArtifact` | HTTP route | Adds `footprint_pmtiles` and `prediction_attrs` kinds. | @@ -381,68 +400,63 @@ creation in `embed_buildings.py` is the invocation pattern to mirror ### Core Flow -1. Analyst sees an **Edit** button in each model row. -2. For trained inference, the button is enabled only when - `model.inferenceStatus === "Processed" && model.gpkgUrl`. -3. For embedding, the button is enabled only when - `model.gpkgUrl && model.predictedBuildingCount > 0` to avoid the current - ambiguity where an empty prediction save can still set `gpkgUrl`. -4. The UI navigates to - `/edit-predictions/:projectId/:imageLayerId/:modelId`. -5. The screen calls `GetPredictionEditSession`. -6. If `tilesReady` or `attrsReady` is false, the screen calls - `PutPreparePredictionTilesQueueMessage`; that route enqueues - `prediction-edit-prep-queue` unless artifacts are already ready or a job is - already in flight. The screen shows a preparation state and polls the - session endpoint. `tilesReady` is normally already true: the layer's - footprint PMTiles are built by a layer-only job queued when the image - layer was created, so only the per-model sidecar is usually outstanding. - Layers created before that behaviour existed fall back to this on-demand - path unchanged. -7. Once ready, the UI fetches `footprint_pmtiles` and `prediction_attrs` through - `GetModelArtifact`. -8. Azure Maps displays PMTiles footprints. Feature-state coloring is computed - from the sidecar, current threshold, unknown threshold, and explicit +1. Analyst opens a model's **Results** menu and selects **View**. Trained rows + and embedding rows both navigate to `/visualizer/:projectId/:imageLayerId/:modelId`. +2. The View item is enabled from server-derived `predictionsReady`, with + client-side legacy fallbacks for models saved before the field existed. +3. `Visualizer` calls `GetVisualizerResults` without a `version` parameter, so + the newest edited version is selected by default when edits exist + (`ui/src/Components/Visualizer/Visualizer.jsx:213-223`). +4. The payload supplies imagery, nullable raster overlays, vector artifact URLs, + readiness, flavor, threshold support, building count, active version, and + version history. +5. `usePredictionArtifacts` fetches `prediction_attrs` and `footprint_pmtiles` + through `GetModelArtifact`. If the payload or artifact response says they are + missing, it lazily reads `GetPredictionEditSession`, calls + `PutPreparePredictionTilesQueueMessage`, and polls the session endpoint. +6. `usePredictionFootprints` adds the PMTiles source/layers to both swipe panes, + then colors features from sidecar attributes, current thresholds, and manual overrides. -9. The analyst clicks or ctrl+drag box-selects buildings, filters by - `Damaged`, `NotDamaged`, `Unknown`, or `edited`, and uses prev/next traversal. -10. On save, the UI calls `PutEditedPredictions` with the threshold, - unknown threshold, and only explicit overrides. -11. The backend writes `edited_predictions_${modelId}_v${version}.gpkg`, appends +7. The analyst clicks the pencil next to Back or presses `E` to enter edit mode. + Rasters are hidden while editing and restored when edit mode exits. +8. The analyst clicks or ctrl+drag box-selects buildings, filters by `Damaged`, + `NotDamaged`, `Unknown`, or `edited`, and uses prev/next traversal. Keys + `1`, `2`, and `3` set the selected building's class in edit mode. +9. On save, the UI calls `PutEditedPredictions` with threshold, + unknownThreshold, and only explicit overrides. +10. The backend writes `edited_predictions_${modelId}_v${version}.gpkg`, appends version metadata, and returns `{ version, gpkgUrl, editedCount }`. -12. The UI refreshes the version list. Raw `Model.gpkgUrl` remains unchanged. +11. The UI refreshes the version list and resets the unsaved baseline. Raw + `Model.gpkgUrl` remains unchanged. +12. `GetVisualizerResults`, `GetValidationReport`, and `GetAssessmentReport` + use the newest edit on later calls unless the caller pins `version` or passes + `version=0`. ### Existing implementation constraints - Trained inference writes `id`, `damage_pct_0m`, `damage_pct_10m`, `damage_pct_20m`, `damaged`, and `unknown_pct` in the raster CRS with the - default layer name (`docker/training/code/merge_with_building_footprints.py:221-231`). - The `damaged` column is currently hard-coded as `damage_pct_0m > 0` - (`docker/training/code/merge_with_building_footprints.py:254`). -- The embedding workflow writes predictions through `PutBuildingPredictions`. - It uses layer name `"predictions"`, adds `area`, and sets `damage_pct_0m` to a + default layer name. It sets `damaged` to `1` when `damage_pct_0m > 0` + (`docker/training/code/merge_with_building_footprints.py:221-258`). +- The classic writer can skip footprints outside raster bounds before writing + predictions, so the positional join can silently lose rows before this feature + ever sees the GeoPackage (`docker/training/code/merge_with_building_footprints.py:151-190`). +- The embedding workflow writes predictions through `PutBuildingPredictions`. It + uses layer name `"predictions"`, adds `area`, and sets `damage_pct_0m` to a 0.0/1.0 copy of `damaged`, which makes thresholding meaningless for embedding - models (`api/hastefuncapi/function_app.py:2638-2786`). -- The prediction-to-Overture join is positional row order, not an id. Both the - assessment utility and the API build Overture ids by reading the footprints in - order and indexing with the prediction row id - (`hastelib/src/hastegeo/core/utils/assessment.py:376-395`, - `api/hastefuncapi/function_app.py:4116-4133`). Edited GeoPackages must keep - row order exactly and also write an explicit `overture_id` column. -- `GetBuildingFootprintsGeoJSON` is only a sampled preview path, capped at 2,000 - features (`api/hastefuncapi/function_app.py:3626`, - `api/hastefuncapi/function_app.py:3645-3663`). Prediction editing requires a - complete PMTiles + sidecar data path. -- PMTiles currently exist only for the embedding workflow through - `ArtifactTypes.BUILDING_PMTILES` and the embedding processor - (`hastelib/src/hastegeo/core/config.py:153`, - `hastelib/src/hastegeo/core/processors/embedding.py:239`). Trained models - need the new `LAYER_FOOTPRINT_PMTILES` artifact path. -- Existing row gating differs by workflow: trained results key off processed - inference state, while embedding rows treat any `gpkgUrl` as predictions - (`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:43-46`, - `ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:86`). This feature - adds `predictedBuildingCount` and `predictedAt` to remove ambiguity. + models (`api/hastefuncapi/function_app.py:2738-2815`). +- Neither producer writes an explicit `overture_id` column. Current reports join + prediction rows to Overture ids by reading the footprints in order and indexing + with the prediction row id (`hastelib/src/hastegeo/core/utils/assessment.py:368-395`, + `api/hastefuncapi/function_app.py:4808-4827`). Edited GeoPackages must keep + row order exactly and add `overture_id` for auditability. +- `GetBuildingFootprintsGeoJSON` remains only a sampled preview path; prediction + editing uses complete PMTiles + sidecar data through `GetModelArtifact`. +- PMTiles existed for the embedding workflow through the embedding model's own + archive. The viewer reuses that archive when available and otherwise uses the + layer-scoped `ImageLayer.footprintPmtilesUrl` + (`hastelib/src/hastegeo/core/processors/prediction_tiles.py:202-226`, + `api/hastefuncapi/function_app.py:1489-1507`). ### Class derivation rule @@ -460,70 +474,98 @@ The written `damaged` integer column is `1` when `final_class == "Damaged"`; otherwise it is `0`. The edited GeoPackage also writes `edited_class` (string), `edit_threshold` (float), and `overture_id` (string). Row order must be preserved exactly from the source prediction -GeoPackage. +GeoPackage (`hastelib/src/hastegeo/core/processors/prediction_edits.py:246-279`). ### UI behavior -- The screen uses Azure Maps with PMTiles loaded through the existing in-memory - protocol pattern from `InteractiveLabeler.jsx`. +- The screen is the existing Visualizer route; there is no standalone + `PredictionEditor` directory or `/edit-predictions/...` route in the current + implementation (`ui/src/Components/AppBody.jsx:73-75`). +- The screen uses Azure Maps with PMTiles loaded through the shared in-memory + protocol pattern (`ui/src/Components/Visualizer/usePredictionArtifacts.js:201-212`). - Styling uses Fluent UI `makeStyles` and `tokens` so the editor works in dark - mode. Hard-coded hex colors are not allowed for semantic UI colors. + mode. Hard-coded hex colors are not allowed for semantic UI colors + (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:72-80`, + `ui/src/Components/Visualizer/predictionFootprintMap.js:70-95`). - Feature-state colors update live when overrides or thresholds change; the source PMTiles are not regenerated in the browser. - The right panel shows counts for `Damaged`, `NotDamaged`, `Unknown`, and - `edited`, plus filters and prev/next traversal modeled on - `BuildingValidation.jsx`. -- The threshold slider appears only when `supportsThreshold` is true. It shows - how many buildings would flip relative to the current saved/default state. -- Embedding models can still be manually reclassified, but do not display the - slider. + `edited`, plus filters, prev/next traversal, click-action mode, threshold + controls when supported, saved-version history, Save as new version, and Done + editing. +- The threshold slider appears only when `supportsThreshold` is true. Embedding + models can still be manually reclassified, but do not display the slider + (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:346-397`). +- The saved-version history is read-only in this branch. It shows which version + is currently on the map but does not refetch when a row is selected + (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:513-550`, + `ui/src/Components/Visualizer/Visualizer.jsx:213-223`). ### Edge Cases | Case | Expected Behavior | |---|---| -| Missing raw `Model.gpkgUrl` | Edit button disabled; direct session request returns 404. | -| Trained inference processed but no `gpkgUrl` | Edit button disabled because the full prediction GeoPackage is unavailable. | -| Embedding `gpkgUrl` exists but `predictedBuildingCount` is `0` or missing | Edit button disabled; a direct session request still reads the raw GeoPackage if present, so UI gating is the protection against empty embedding saves. | -| PMTiles or sidecar missing | Session endpoint returns `tilesReady: false` or `attrsReady: false`; UI calls `PutPreparePredictionTilesQueueMessage` and then polls with a preparation message. | +| Missing raw `Model.gpkgUrl` | View/edit remains unavailable; direct session request returns 404. | +| Trained inference processed but no `gpkgUrl` and no `predictedDamageLayerUrl` | `predictionsReady` is false; results View is disabled or the visualizer explains there are no predictions. | +| Embedding `gpkgUrl` exists but `predictedBuildingCount` is `0` | `predictionsReady` is false with reason `no_buildings`; the visualizer should not queue a prep job that can never produce buildings (`hastelib/src/hastegeo/core/utils/model_readiness.py:168-198`). | +| Embedding model predates `predictedBuildingCount` | Falls back to `gpkgUrl` so older successful models remain viewable (`hastelib/src/hastegeo/core/utils/model_readiness.py:142-146`). | +| PMTiles or sidecar missing | `predictionsReadiness.reason` is `preparing`; UI requests prep and polls until artifacts are available. | | Source prediction and footprint row counts differ | Save returns 422; prep records a failed `predictionTilesStatus` with a row-count message; no edited version is appended. | | Duplicate override ids | PUT returns 400; client must de-duplicate before retrying. | | Override id outside source range | Save succeeds; the override is ignored and not counted in `editedCount`. | | Concurrent saves | Known limitation: backend uses `next_version` plus a metadata save without optimistic concurrency, so concurrent saves can collide instead of returning 409. | | Invalid thresholds | PUT returns 400 for values outside `[0,1]`. | | Very large layers | UI avoids GeoJSON; prep/save still read whole GeoPackages and must expose progress/failure logs. | +| User exits edit mode with unsaved edits | Visualizer shows a discard-confirmation dialog and either discards or keeps editing (`ui/src/Components/Visualizer/Visualizer.jsx:502-528`). | +| User wants to inspect older edits | API supports `version=N`, but UI selection is not wired; use the API directly or wait for follow-up UI work. | ### Error Handling | Error Condition | Response | Recovery | |---|---|---| | Prep queue enqueue fails | `PutPreparePredictionTilesQueueMessage` returns 500 | Retry the prep request; the route is idempotent by readiness/status. | -| PMTiles generation fails | Session continues to report not ready with status details in logs | Queue retry/dead-letter; user can retry opening the editor. | +| PMTiles generation fails | Visualizer status note reports not ready with status details; session continues to report failure | Queue retry/dead-letter; user can retry with force from the status note. | | Attribute sidecar missing or invalid | UI blocks editing and reports a load failure | Regenerate prep artifacts with `force: true`. | | Blob upload timeout on edited GeoPackage | `PutEditedPredictions` returns 500 | Retry save; if a blob exists without model metadata, next version allocation must not reuse it. | | Metadata conflict appending version | Not detected in the current implementation | Follow up with ETag/lease-based optimistic concurrency before relying on multi-analyst collision safety. | +| Unknown explicit prediction version | Reader returns 404 | Refresh version history or use `version=0` for raw. | +| Malformed prediction version | Reader returns 400 | Fix the query parameter. | ### Known limitations / follow-ups +- UI version switching is not wired. The history is read-only; `predictionVersion` + reports what is on the map, but selecting another version does not refetch + (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:513-550`, + `ui/src/Components/Visualizer/Visualizer.jsx:213-223`). +- Edited GeoPackages override `damaged` but preserve the producer's + `damage_pct_0m`. `GetValidationReport` reads `damaged`, so edits move its + metrics; `GetAssessmentReport` thresholds `damage_pct_0m`, so per-building + overrides do not move threshold-based counts (`api/hastefuncapi/function_app.py:4808-4827`, + `api/hastefuncapi/function_app.py:5080-5103`). - `PutEditedPredictions` does not implement the 409 conflict response that the original draft proposed. `next_version` plus `MetadataProcessor.save` is a read-modify-write sequence with no ETag, lease, or retry-safe compare step. -- API-level integration tests for the prediction-editing routes are not present; - `api/hastefuncapi/tests/` contains only `test_publishing_routes.py`. Current - automated coverage is at the processor, workflow, wire-model, and UI helper - level. +- API-level integration tests for the rewritten handlers are not present. + Current automated coverage is at the processor, workflow, wire-model, and UI + helper level. +- No browser or Playwright validation exists for the viewer or edit mode; this + repo currently has no Playwright configuration or dependency (`ui/package.json:6-15`, + `ui/package.json:62-75`). +- Two pre-existing correctness risks remain out of scope: the classic workflow + can drop footprint rows before writing predictions, and neither producer writes + `overture_id` in the raw prediction GeoPackage (`docker/training/code/merge_with_building_footprints.py:151-190`, + `docker/training/code/merge_with_building_footprints.py:221-258`, + `api/hastefuncapi/function_app.py:2738-2815`). - `infra/modules/functions.bicep` does not include an explicit app-setting row for `PREDICTION_EDIT_PREP_QUEUE_NAME`. This was intentionally skipped because `Config` has a default, the Functions host can create the queue, and changing the Bicep without regenerating `infra/main.json` would introduce infra drift. -- No browser or Playwright validation exists for the editor screen; this repo - currently has no Playwright configuration. ## Configuration | Config Key | Type | Default | Where Set | Description | |---|---|---|---|---| -| `prediction_edit_prep_queue_name` | string | `prediction-edit-prep-queue` | `local.settings.json` / App Settings | Queue used to generate missing PMTiles and sidecars. | +| `prediction_edit_prep_queue_name` | string | `prediction-edit-prep-queue` | `local.settings.json` / App Settings / `Config.get_queue_config()` | Queue used to generate missing PMTiles and sidecars (`hastelib/src/hastegeo/core/config.py:341-347`). | No feature flag is implemented in the current branch; the API routes and UI entry points are present when the branch is deployed. No new third-party @@ -532,17 +574,17 @@ dependency is required. PMTiles support already exists in the UI, and ## Observability -- **Logs:** Log session readiness, queued prep requests, source schema flavor, - row-count validation, version allocation, edit counts, and final artifact urls - without logging SAS tokens. -- **Metrics:** Track session readiness failures, prep duration, save duration, - edited GeoPackage size, and edited counts. +- **Logs:** Log model/readiness decisions, visualizer version selection, queued + prep requests, source schema flavor, row-count validation, version allocation, + edit counts, and final artifact URLs without logging SAS tokens. +- **Metrics:** Track `GetVisualizerResults` readiness failures, prep duration, + save duration, edited GeoPackage size, and edited counts. - **Queue depth:** Monitor `prediction-edit-prep-queue` depth and dead-letter count. - **Storage:** Alert on failed uploads for PMTiles, sidecars, and edited GeoPackages. -- **UI errors:** Surface load, sidecar parse, and save errors in the right panel - with retry actions. +- **UI errors:** Surface load, sidecar parse, prep timeout, and save errors in + the status note or edit panel with retry actions. ## Open Questions @@ -550,5 +592,11 @@ dependency is required. PMTiles support already exists in the UI, and Azure Functions request-timeout or memory budgets? - [ ] Should edited-version saves use Cosmos ETags, blob leases, or another optimistic-concurrency mechanism to prevent concurrent version collisions? -- [ ] Should a future downstream-consumption spec choose a single active edited - version, or let each report/publish call accept a version id? +- [ ] Should the UI implement version switching by refetching + `GetVisualizerResults?version=N`, by adding a dedicated version-selection + endpoint, or by keeping the history read-only? +- [ ] Should assessment reports use edited `damaged`, persist edited + `damage_pct_0m`, expose override-aware counts separately, or keep the + current threshold-only interpretation? +- [ ] Should raw prediction producers add explicit `overture_id` and stop + relying on positional joins before this feature is broadly rolled out? diff --git a/spec/features/prediction-editing/impact-analysis.md b/spec/features/prediction-editing/impact-analysis.md index 82a54229..8845a2fa 100644 --- a/spec/features/prediction-editing/impact-analysis.md +++ b/spec/features/prediction-editing/impact-analysis.md @@ -8,10 +8,10 @@ | Component | Path | Type of Change | Severity | |---|---|---|---| -| Core library | `hastelib/src/hastegeo/core/models/`, `hastelib/src/hastegeo/core/processors/`, `hastelib/src/hastegeo/core/utils/`, `hastelib/src/hastegeo/core/config.py` | modified / new | high | -| REST API | `api/hastefuncapi/function_app.py` | new endpoints; modified artifact dispatch | medium | -| Queue workers | `api/hastefuncqueues/function_app.py` | new prep trigger | medium | -| React UI | `ui/src/Components/...` | new route/editor; modified model rows | high | +| Core library | `hastelib/src/hastegeo/core/models/`, `hastelib/src/hastegeo/core/processors/`, `hastelib/src/hastegeo/core/utils/`, `hastelib/src/hastegeo/core/config.py` | modified / new; adds version metadata, vector-results payload assembly, readiness, prep, source resolution, and edit writer | high | +| REST API | `api/hastefuncapi/function_app.py` | new edit/prep/version endpoints; vector-first `GetVisualizerResults`; `version` support in visualizer/validation/assessment readers; modified artifact dispatch | high | +| Queue workers | `api/hastefuncqueues/function_app.py` | new prep trigger with model-scoped and layer-only modes | medium | +| React UI | `ui/src/Components/ProjectManagement/`, `ui/src/Components/Visualizer/` | Results menu gating, embedding View Results entry, vector-first viewer, edit mode, and removal of standalone Edit route/screen | high | | Docker config | `docker/training/` | no new package expected; uses existing `tippecanoe` in training env | low | | CI/CD / infra | `.github/workflows/...`, `infra/modules/functions.bicep` | no workflow change; explicit Bicep app-setting parity for the prep queue was skipped to avoid `infra/main.json` drift | low | @@ -19,12 +19,12 @@ | Service | Change | New Cost Impact | |---|---|---| -| Cosmos DB | Model and ImageLayer documents gain optional fields; Model appends small version records | low RU increase per session/save | +| Cosmos DB | Model and ImageLayer documents gain optional fields; Model appends small version records; model reads derive `predictionsReady` in memory | low RU increase per session/save | | Blob Storage | Stores PMTiles, sidecars, and one edited GeoPackage per save | proportional to footprint count and version count | -| Queue Storage | Adds prep messages for missing PMTiles/sidecars | low; bursty when editors first open layers | -| Azure Functions | Adds three HTTP routes and one queue trigger | low to medium CPU/memory during save and metadata reads | +| Queue Storage | Adds prep messages for missing PMTiles/sidecars and layer-only footprint tiling | low; bursty when results are first opened for older layers | +| Azure Functions | Adds edit/prep/version routes and expands visualizer/report readers | low to medium CPU/memory during GeoPackage reads and saves | | Azure Batch | Reuses existing runner/training image path for `tippecanoe` prep | low; CPU-bound tile jobs may occupy existing nodes | -| Static Web Apps | Adds one route and larger client-side editing workflow | low hosting impact; browser memory is the main concern | +| Static Web Apps | View Results now downloads and renders vector footprint artifacts; edit mode runs in the existing route | low hosting impact; browser memory is the main concern | ## Dependency Analysis @@ -32,54 +32,67 @@ | Dependency | Type | Status | Risk if Unavailable | |---|---|---|---| -| Raw prediction GeoPackage (`Model.gpkgUrl`) | artifact | available after prediction | Editor cannot open or save. | -| Source building footprints (`ImageLayer.buildingFootprintsUrl`) | artifact | available after imagery prep | Cannot derive `overture_id` or validate row-order mapping. | -| `tippecanoe` in training image | container tool | available only in training env | PMTiles cannot be generated from Functions inline (`docker/training/env/env.yml:11`). | -| PMTiles JS support | UI dependency | already present | Editor map cannot stream full geometry efficiently. | -| Azure Maps | UI mapping | available in app | Editor loses primary visual interaction surface. | +| Raw prediction GeoPackage (`Model.gpkgUrl`) | artifact | available after trained inference or embedding predictions | Edit session cannot open; save cannot derive a version. | +| Vector readiness flag (`predictionsReady`) | API-derived field | returned by model payload endpoints | UI falls back to legacy checks, but stale clients can diverge until refreshed. | +| Source building footprints (`ImageLayer.buildingFootprintsUrl`) | artifact | available after imagery prep | Cannot derive `overture_id`, build layer PMTiles, or validate row-order mapping. | +| Layer/model PMTiles (`footprintPmtilesUrl` or embedding `pmtilesUrl`) | artifact | generated at layer creation or on demand | Results page shows a preparing state and queues prep; without it no vector layer draws. | +| Prediction attribute sidecar (`Model.predictionAttrsUrl`) | artifact | generated on demand per model | Results page can show imagery but not predicted footprints or edit mode. | +| `tippecanoe` in training image | container tool | available only in training env | PMTiles cannot be generated from Functions inline (`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:40-45`). | +| PMTiles JS support | UI dependency | already present | Visualizer cannot stream full geometry efficiently. | +| Azure Maps | UI mapping | available in app | Results viewer loses primary visual interaction surface. | ### Downstream Impact (things affected by this feature) | Consumer | How Affected | Breaking? | Migration Needed? | |---|---|---|---| -| `hastefuncapi` callers | New endpoints and artifact kinds; existing endpoints unchanged | no | no | -| React model rows | New Edit action and stricter embedding edit gating | no | no | -| Existing Cosmos documents | Optional fields absent until touched/backfilled | no | no blocking migration | -| Assessment report | Not changed; continues using raw `Model.gpkgUrl` | no | follow-up spec required to consume edits | -| Validation report | Not changed; continues using raw `Model.gpkgUrl` | no | follow-up spec required to consume edits | -| Data publishing | Not changed; edited versions not publishable in v1 | no | follow-up spec required | -| Visualizer | Not changed; edited versions not shown in v1 | no | follow-up spec required | +| `hastefuncapi` callers | New endpoints and artifact kinds; `GetVisualizerResults` adds vector fields and nullable raster fields | low risk | callers must null-check `predictedDamageLayer` and `predictionsLayer` (`docs/api/hastefuncapi.md:124-131`) | +| React model rows | Results View gating now uses `predictionsReady`; embedding rows gain View Results; standalone Edit buttons are gone | no | no data migration | +| Existing Cosmos documents | Optional fields absent until touched/backfilled; derived `predictionsReady` not persisted | no | no blocking migration | +| Visualizer | Changed from raster-first to vector-first; embedding workflow now has a usable entry point | yes for code path | existing route remains `/visualizer/...` (`ui/src/Components/AppBody.jsx:73-75`) | +| Validation report | Defaults to newest edited version and supports `version`; reads edited `damaged` | behavioral change | document raw access with `version=0` (`docs/api/hastefuncapi.md:480-502`) | +| Assessment report | Defaults to newest edited version and supports `version`; still thresholds preserved `damage_pct_0m` | behavioral nuance | product follow-up required for override-aware counts | +| Data publishing | Uses unified completion/readiness rule for eligibility but does not publish edited versions | no | follow-up spec required | ## Risk Assessment | Risk | Likelihood | Impact | Mitigation | Owner | |---|---|---|---|---| | Positional row-order invariant breaks Overture id mapping | medium | high | Assert row count and row order in prep/save tests; never sort or spatial-join edited output; write explicit `overture_id` for audit. | `gis` | -| Editor default threshold and `GetAssessmentReport` default differ | medium | medium | Document the current split: editor defaults to `0.0` to reproduce raw stored predictions, while reports still default to `0.1`; add product follow-up if this confuses users. | `backend-dev` | -| Large layers exceed memory in tile prep or edit application | medium | high | Keep browser geometry in PMTiles; measure whole-GPKG reads; add performance tests; move save to async if needed. | `backend-dev`, `gis` | +| Classic inference can silently drop footprint rows before prediction output | medium | high | Capture as a follow-up: the current writer skips out-of-bounds geometries and writes only `valid_building_geoms`, which can invalidate the positional join (`docker/training/code/merge_with_building_footprints.py:151-190`, `docker/training/code/merge_with_building_footprints.py:239-258`). | `gis` | +| Raw prediction GeoPackages lack `overture_id` | high | medium | Edited outputs add `overture_id`; open a producer-side follow-up so raw outputs do not rely solely on row order (`api/hastefuncapi/function_app.py:2738-2815`). | `gis`, `backend-dev` | +| Editor default threshold and `GetAssessmentReport` default differ | medium | medium | Document the current split: editor defaults to `0.0` to reproduce raw stored predictions, while `GetAssessmentReport` still defaults to threshold `0.1`; add product follow-up if this confuses users. | `backend-dev` | +| Edited `damaged` moves validation metrics but assessment thresholds preserved `damage_pct_0m` | high | medium | Document the asymmetry and decide whether assessment should consume overrides differently (`api/hastefuncapi/function_app.py:4808-4827`, `hastelib/src/hastegeo/core/utils/assessment.py:187-190`). | `backend-dev`, `gis` | +| Large layers exceed memory in tile prep, artifact loading, or edit application | medium | high | Keep browser geometry in PMTiles; measure whole-GPKG reads; add performance tests; move save to async if needed. | `backend-dev`, `gis`, `ui` | | HTTP handler tries to run `tippecanoe` inline | low | high | Keep PMTiles generation in `prediction-edit-prep-queue`; test absence of inline generation path. | `backend-dev` | -| Embedding `gpkgUrl` is treated as a full prediction set after Clear labels | high | medium | Gate on `predictedBuildingCount > 0` and set `predictedAt` only after non-empty predictions. | `ui`, `backend-dev` | +| Embedding `gpkgUrl` is treated as a full prediction set after Clear labels | medium | medium | Gate on server-derived `predictionsReady`; `predictedBuildingCount == 0` returns `no_buildings` (`hastelib/src/hastegeo/core/utils/model_readiness.py:168-198`). | `backend-dev`, `ui` | | Edited artifact overwrites raw output | low | high | Never write to `Model.gpkgUrl`; use `EDITED_PREDICTIONS_GPKG` with version in the name and append metadata. | `backend-dev` | +| UI version history appears selectable but does not switch versions | medium | low | Label active version clearly; document read-only history and add version-switching follow-up (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:513-550`). | `ui` | | UI hard-coded colors fail dark mode | medium | medium | Require `makeStyles` + Fluent tokens; add UI review checklist item. | `ui` | -| UI lint remains red because of existing ESLint 9 flat-config mismatch | high | medium | Treat CI gate as no regression from baseline; record baseline failure and require targeted UI tests. | `ui-validation` | +| UI lint remains red because of existing ESLint 9 flat-config mismatch | high | medium | Treat CI gate as no regression from baseline; record baseline failure and require targeted UI helper tests. | `ui-validation` | | Concurrent edited-version saves collide | medium | medium | Current implementation has no 409/ETag conflict handling; add optimistic concurrency before relying on simultaneous multi-analyst saves. | `backend-dev` | +| Lack of browser/Playwright coverage misses visualizer regressions | high | medium | Add Playwright or explicitly waive with manual evidence; current repo has no Playwright config or dependency (`ui/package.json:6-15`, `ui/package.json:62-75`). | `ui-validation` | ## Performance Impact +- **Visualizer latency:** `GetVisualizerResults` may read the selected GeoPackage + to populate `flavor`, `supportsThreshold`, and `buildingCount`. If that read + fails, imagery/readiness still return (`api/hastefuncapi/function_app.py:2397-2423`). - **API latency:** `GetPredictionEditSession` is read-only and does not enqueue, - but it downloads the raw prediction GeoPackage to detect flavor and count - rows. `PutPreparePredictionTilesQueueMessage` performs the queue request. + but it downloads the raw prediction GeoPackage to detect flavor and count rows. + `PutPreparePredictionTilesQueueMessage` performs the queue request. `PutEditedPredictions` reads and writes a full GeoPackage in v1, so large layers may approach function timeout or memory limits. - **Queue throughput:** New prep jobs are CPU and I/O bound. They should be - idempotent and skip PMTiles or sidecar generation when artifacts already - exist. -- **Tile serving:** The editor uses static PMTiles artifacts, not TiTiler for - vector tiles. Tile serving load shifts to Blob/download bandwidth. + idempotent and skip PMTiles or sidecar generation when artifacts already exist. +- **Tile serving:** The visualizer uses static PMTiles artifacts, not TiTiler for + vector tiles. Tile serving load shifts to Function App streaming and + Blob/download bandwidth. +- **Browser memory:** The UI downloads the PMTiles archive and sidecar once per + visualizer route (`ui/src/Components/Visualizer/usePredictionArtifacts.js:177-221`). - **Batch compute:** No GPU is needed. Existing training-image jobs may consume CPU on the current runner pool while generating PMTiles. -- **Storage I/O:** Each editor open may download PMTiles and sidecar data; each - save writes a full edited GeoPackage. +- **Storage I/O:** Each first results open may download PMTiles and sidecar data; + each save writes a full edited GeoPackage. ## Security Impact @@ -87,6 +100,9 @@ auth pattern. - [x] New data classification handled? Edited predictions are derived disaster assessment geospatial data, same sensitivity as raw model outputs. +- [x] Artifact access constrained? PMTiles and sidecars are streamed through + `GetModelArtifact`, preserving server-side auth and managed identity + rather than exposing raw blob URLs (`api/hastefuncapi/function_app.py:1435-1458`). - [ ] MSAL/Entra ID auth changes? None expected. - [ ] New secrets or connection strings required? None expected. - [ ] CORS configuration changes in SWA? None expected. @@ -96,8 +112,8 @@ - [x] Geospatial data sovereignty concerns? Same as raw project artifacts; edited versions must stay in the project storage boundary. -- [x] Partner data sharing agreements affected? No external sharing in v1; - downloads are existing-authenticated artifact access. +- [x] Partner data sharing agreements affected? No external sharing automation + in v1; downloads are existing-authenticated artifact access. - [x] New data retention requirements? Versioned edited GeoPackages increase retained derived artifacts; retention follows project artifact retention. - [x] Audit logging for new operations? Save logs should include project, @@ -107,8 +123,8 @@ ## Rollback Assessment -- **Reversibility:** fully reversible for runtime behavior by disabling feature - flags; persisted optional metadata and blobs can remain safely. +- **Reversibility:** runtime behavior is reversible by redeploying the previous + UI/API. The current implementation has no feature flag kill switch. - **Cosmos data:** Old code ignores optional `editedPredictions`, `predictedBuildingCount`, `predictedAt`, `predictionAttrsUrl`, `predictionTilesJob`, `predictionTilesStatus`, @@ -116,7 +132,11 @@ optional, not required for rollback. - **Blob data:** Edited GeoPackages, sidecars, and PMTiles are additive derived artifacts. They can be deleted by approved maintenance tooling if needed. -- **API:** New endpoints and artifact kinds are backward-compatible. Existing - endpoint contracts are unchanged. -- **Estimated rollback time:** Immediate feature-flag disable; less than 30 +- **API:** New endpoints and artifact kinds are additive. `GetVisualizerResults` + now returns nullable raster layers; reverting API restores the old raster-only + contract if an external caller cannot tolerate nulls. +- **Reports:** If newest-edited defaults cause issues, callers can use + `version=0` as an immediate raw-output workaround while API rollback is + evaluated. +- **Estimated rollback time:** Immediate previous-build redeploy; less than 30 minutes to redeploy a reverted UI/API if required. diff --git a/spec/features/prediction-editing/plan.md b/spec/features/prediction-editing/plan.md index 13ec19b5..b06b619d 100644 --- a/spec/features/prediction-editing/plan.md +++ b/spec/features/prediction-editing/plan.md @@ -6,18 +6,21 @@ ### Phase 1: Core Library — implemented -**Goal:** Implement core models, artifact naming, schema normalization, and -versioned edit writing in `hastelib/src/hastegeo/`. +**Goal:** Implement core models, artifact naming, schema normalization, +versioned edit writing, readiness, and reader source selection in +`hastelib/src/hastegeo/`. | Task | Agent | Dependencies | Story Ref | Status | |---|---|---|---|---| -| Add `EditedPredictionVersion`, `Model.editedPredictions`, `Model.predictedBuildingCount`, `Model.predictedAt`, `Model.predictionAttrsUrl`, `Model.predictionTilesJob`, `Model.predictionTilesStatus`, `Model.predictionTilesStatusMessage`, and `ImageLayer.footprintPmtilesUrl` | `backend-dev` | — | US-002, US-004 | complete | +| Add `EditedPredictionVersion`, `Model.editedPredictions`, `Model.predictedBuildingCount`, `Model.predictedAt`, `Model.predictionAttrsUrl`, `Model.predictionTilesJob`, `Model.predictionTilesStatus`, `Model.predictionTilesStatusMessage`, and `ImageLayer.footprintPmtilesUrl` | `backend-dev` | — | US-002, US-004 | complete (`hastelib/src/hastegeo/core/models/projects.py:343-505`, `hastelib/src/hastegeo/core/models/projects.py:520-529`, `hastelib/src/hastegeo/core/models/projects.py:842-851`) | | Add transport-only wire models in `hastelib/src/hastegeo/core/models/predictions.py` | `backend-dev` | model fields | US-002, US-004 | complete | -| Add `EDITED_PREDICTIONS_GPKG`, `PREDICTION_ATTRS`, and `LAYER_FOOTPRINT_PMTILES` artifact types | `backend-dev` | — | US-002, US-004 | complete | -| Implement prediction schema detection for trained inference vs embedding outputs in `core/utils/predictions.py` | `backend-dev`, `gis` | model fields | US-002 | complete | -| Implement row-order validation and Overture id extraction from source footprints | `gis` | schema detection | US-002, US-004 | complete | -| Implement class derivation and edited GeoPackage writer in `core/processors/prediction_edits.py` | `backend-dev`, `gis` | row-order validation | US-004 | complete | -| Write unit tests for schema detection, class derivation, version allocation, and row-order preservation | `backend-dev`, `gis` | all above | US-002, US-004 | complete | +| Add `EDITED_PREDICTIONS_GPKG`, `PREDICTION_ATTRS`, and `LAYER_FOOTPRINT_PMTILES` artifact types | `backend-dev` | — | US-002, US-004 | complete (`hastelib/src/hastegeo/core/config.py:165-172`) | +| Implement prediction schema detection for trained inference vs embedding outputs in `core/utils/predictions.py` | `backend-dev`, `gis` | model fields | US-002 | complete (`hastelib/src/hastegeo/core/utils/predictions.py:4-34`) | +| Implement row-order validation and Overture id extraction from source footprints | `gis` | schema detection | US-002, US-004 | complete (`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:322-416`) | +| Implement class derivation and edited GeoPackage writer in `core/processors/prediction_edits.py` | `backend-dev`, `gis` | row-order validation | US-004 | complete (`hastelib/src/hastegeo/core/processors/prediction_edits.py:226-308`) | +| Add one server-derived readiness rule in `core/utils/model_readiness.py` | `backend-dev` | model fields | US-001, US-002 | complete (`hastelib/src/hastegeo/core/utils/model_readiness.py:132-237`) | +| Add `resolve_prediction_source(model, version=None)` next to `read_predictions` | `backend-dev` | `Model.editedPredictions` | US-006 | complete (`hastelib/src/hastegeo/core/utils/predictions.py:332-401`) | +| Write unit tests for schema detection, class derivation, version allocation, row-order preservation, readiness, source resolution, and visualizer payload assembly | `backend-dev`, `gis` | all above | US-001, US-002, US-004, US-006 | complete (`hastelib/tests/core/utils/test_model_readiness.py:148-229`, `hastelib/tests/core/utils/test_prediction_source.py:89-188`, `hastelib/tests/core/processors/test_visualizer_payload.py:222-392`) | > **Agent column:** Use HASTE agent names (`backend-dev`, `gis`, `ui`, `security`). See [user-stories.md](user-stories.md#agent-assignment-map) for the full agent→story mapping. @@ -25,24 +28,29 @@ versioned edit writing in `hastelib/src/hastegeo/`. - [x] `hastelib` unit tests cover both producer schemas and row-order preservation. - [x] Edited GeoPackage generation works independently of the API layer. - [x] Raw `Model.gpkgUrl` remains unchanged after saves. +- [x] Server readiness and raw-vs-edited source selection are pure helpers with targeted tests. ### Phase 2: API Layer — implemented with known test gaps -**Goal:** Expose prediction editing through thin `hastefuncapi` routes and a -queued preparation worker. +**Goal:** Expose prediction editing and vector-first results through thin +`hastefuncapi` routes and a queued preparation worker. | Task | Agent | Dependencies | Story Ref | Status | |---|---|---|---|---| -| Add side-effect-free `GetPredictionEditSession` route | `backend-dev` | Phase 1 models | US-002 | complete | +| Add side-effect-free `GetPredictionEditSession` route | `backend-dev` | Phase 1 models | US-002 | complete (`api/hastefuncapi/function_app.py:2920-3025`) | | Add `PutPreparePredictionTilesQueueMessage` route for explicit prep queue requests | `backend-dev` | `core/processors/prediction_tiles.py` | US-002 | complete | -| Add `PutEditedPredictions` route | `backend-dev` | edited GeoPackage writer | US-004 | complete | -| Add `GetEditedPredictionVersions` route | `backend-dev` | Phase 1 models | US-005 | complete | -| Extend `GetModelArtifact` with `footprint_pmtiles` and `prediction_attrs` kinds | `backend-dev` | artifact types | US-002, US-005 | complete | -| Add `workflows/prepare_prediction_tiles.py` prep workflow (footprint PMTiles + attribute sidecar) | `gis` | Phase 1 prediction reader | US-002 | complete | -| Add `core/processors/prediction_tiles.py` runner orchestration | `gis` | prep workflow | US-002 | complete | -| Add `prediction-edit-prep-queue` trigger in `hastefuncqueues` | `backend-dev`, `gis` | prep workflow | US-002 | complete | -| Build the layer's footprint PMTiles at image-layer creation (layer-only prep mode; `ImageLayer.footprintTiles*` fields; best-effort enqueue from `ImageryPostProcessor`) | `gis` | prep workflow, queue trigger | US-002 | complete | -| Add API integration tests for validation, readiness, save, and version-list responses | `backend-dev` | routes | US-002, US-004, US-005 | not-started | +| Add `PutEditedPredictions` route | `backend-dev` | edited GeoPackage writer | US-004 | complete (`api/hastefuncapi/function_app.py:3181-3345`) | +| Add `GetEditedPredictionVersions` route | `backend-dev` | Phase 1 models | US-005 | complete (`api/hastefuncapi/function_app.py:3376-3410`) | +| Extend `GetModelArtifact` with `footprint_pmtiles` and `prediction_attrs` kinds | `backend-dev` | artifact types | US-002, US-005 | complete (`api/hastefuncapi/function_app.py:1400-1510`) | +| Add `workflows/prepare_prediction_tiles.py` prep workflow (footprint PMTiles + attribute sidecar) | `gis` | Phase 1 prediction reader | US-002 | complete (`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:4-46`, `hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:322-416`) | +| Add `core/processors/prediction_tiles.py` runner orchestration | `gis` | prep workflow | US-002 | complete (`hastelib/src/hastegeo/core/processors/prediction_tiles.py:4-84`, `hastelib/src/hastegeo/core/processors/prediction_tiles.py:475-560`) | +| Add `prediction-edit-prep-queue` trigger in `hastefuncqueues` | `backend-dev`, `gis` | prep workflow | US-002 | complete (`api/hastefuncqueues/function_app.py:861-914`) | +| Build the layer's footprint PMTiles at image-layer creation (layer-only prep mode; `ImageLayer.footprintTiles*` fields; best-effort enqueue from `ImageryPostProcessor`) | `gis` | prep workflow, queue trigger | US-002 | complete (`hastelib/src/hastegeo/core/processors/imagery.py:249-257`, `hastelib/src/hastegeo/core/processors/imagery.py:399-441`) | +| Make `GetVisualizerResults` workflow-agnostic and vector-first (footprint tiles + attrs sidecar as `GetModelArtifact` routes, `predictionsReady`/readiness detail, `flavor`/`supportsThreshold`, nullable raster layers); payload assembly in `core/processors/visualizer.py` | `backend-dev` | `core/processors/prediction_tiles.py`, prediction reader | US-001, US-002, US-006 | complete (`api/hastefuncapi/function_app.py:2296-2435`, `hastelib/src/hastegeo/core/processors/visualizer.py:215-336`) | +| Surface `predictionsReady` on `GetLayerModelsDetails`, `GetProjectDetails`, and `GetLayerDetailView`; reuse the same completion rule in `core/publishing/source.py` | `backend-dev` | `core/utils/model_readiness.py` | US-001 | complete (`api/hastefuncapi/function_app.py:785-788`, `api/hastefuncapi/function_app.py:1262-1266`, `api/hastefuncapi/function_app.py:1380-1383`, `hastelib/src/hastegeo/core/publishing/source.py:116-124`) | +| Adopt `resolve_prediction_source(model, version=None)` and optional `version` query param in `GetVisualizerResults`, `GetValidationReport`, and `GetAssessmentReport` | `backend-dev` | `Model.editedPredictions` | US-006 | complete (`api/hastefuncapi/function_app.py:2386-2435`, `api/hastefuncapi/function_app.py:4677-4688`, `api/hastefuncapi/function_app.py:5017-5027`) | +| Document the full `GetVisualizerResults` shape and reader `version` behavior in the API docs | `backend-dev` | route implementation | US-002, US-006 | complete (`docs/api/hastefuncapi.md:78-157`, `docs/api/hastefuncapi.md:480-502`) | +| Add API integration tests for visualizer payloads, validation, readiness, save, and version-list responses | `backend-dev` | routes | US-002, US-004, US-005, US-006 | not-started | | Add `infra/modules/functions.bicep` app-setting parity for the new queue | `backend-dev` | queue config | US-002 | skipped — `Config` has a default and changing Bicep without regenerating `infra/main.json` would create infra drift | **Exit Criteria:** @@ -50,35 +58,40 @@ queued preparation worker. - [x] Missing PMTiles/sidecars are generated by the queue worker, not inline in HTTP. - [x] Footprint PMTiles are built once per image layer at layer-creation time; the on-demand path still covers pre-existing layers. - [x] `PutEditedPredictions` returns `version`, `gpkgUrl`, and `editedCount` for both producer schemas. +- [x] Readers default to the newest edited version and accept an explicit `version` override (no mutable "active version" pointer — see ADR-0005). +- [x] `GetVisualizerResults` returns a usable 200 payload for an embedding model, with the raster fields nullable rather than broken. - [ ] Docker Compose local stack can exercise session prep and save. -- [ ] API-level integration tests exist for the new routes. +- [ ] API-level integration tests exist for the new and modified routes. ### Phase 3: UI — implemented with validation gaps -**Goal:** Surface the editor in React using Azure Maps, PMTiles, Fluent UI, and -existing HASTE interaction patterns. +**Goal:** Use the existing View Results page as the prediction review and edit +surface with Azure Maps, PMTiles, Fluent UI, and existing HASTE interaction +patterns. | Task | Agent | Dependencies | Story Ref | Status | |---|---|---|---|---| -| Add **Edit** action to trained model rows with `inferenceStatus === "Processed" && gpkgUrl` gating | `ui` | API route contract | US-001 | complete | -| Add **Edit** action to embedding rows with `gpkgUrl && predictedBuildingCount > 0` gating | `ui` | model field | US-001 | complete | -| Register `/edit-predictions/:projectId/:imageLayerId/:modelId` in `AppBody.jsx` | `ui` | route component | US-001 | complete | -| Build `PredictionEditor` map with PMTiles in-memory source and feature-state coloring | `ui` | session and artifact APIs | US-003 | complete | -| Build right panel filters, counts, edited filter, and prev/next traversal | `ui` | map selection state | US-003 | complete | -| Add threshold slider for trained-inference only and live flip counts | `ui` | sidecar class derivation | US-003 | complete | -| Add save-as-new-version action and version-history display | `ui` | `PutEditedPredictions`, versions API | US-004, US-005 | complete | +| Remove the standalone `/edit-predictions/:projectId/:imageLayerId/:modelId` route and `PredictionEditor` screen; keep only `/visualizer/:projectId/:imageLayerId/:modelId` | `ui` | route component removal | US-001 | complete (`ui/src/Components/AppBody.jsx:73-75`) | +| Remove standalone model-row Edit buttons; make trained model Results → View use `predictionsReady` with a processed-inference fallback | `ui` | API model payload flag | US-001 | complete (`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:87-110`) | +| Add embedding View Results as the first Results menu item, gated by `predictionsReady` with a legacy `gpkgUrl` fallback | `ui` | API model payload flag | US-001 | complete (`ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:85-130`) | +| Add vector-first predicted-footprint rendering to the Visualizer through `usePredictionArtifacts`, `usePredictionFootprints`, and `predictionFootprintMap.js` | `ui` | `GetVisualizerResults`, `GetModelArtifact` | US-002, US-003 | complete (`ui/src/Components/Visualizer/Visualizer.jsx:166-199`, `ui/src/Components/Visualizer/usePredictionArtifacts.js:177-221`, `ui/src/Components/Visualizer/usePredictionFootprints.js:4-29`) | +| Add status-note handling for loading/preparing/empty/unavailable predicted buildings | `ui` | readiness contract | US-002 | complete (`ui/src/Components/Visualizer/PredictionStatusNote.jsx`, `ui/src/Components/Visualizer/predictionResults.js:320-385`) | +| Add pencil/Done affordance next to Back, `E` shortcut, and unsaved-edits discard confirmation | `ui` | vector footprint readiness | US-001, US-003 | complete (`ui/src/Components/Visualizer/Labels.jsx:117-128`, `ui/src/Components/Visualizer/Visualizer.jsx:496-605`, `ui/src/Components/keyboardShortcuts.js:7-17`) | +| Move the former editor right panel into `Visualizer/PredictionEditPanel.jsx` with filters, counts, edited filter, prev/next traversal, class controls, threshold sliders, save, and read-only version history | `ui` | map selection state | US-003, US-005 | complete (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:4-16`, `ui/src/Components/Visualizer/PredictionEditPanel.jsx:300-585`) | +| Add save-as-new-version action that calls `PutEditedPredictions`, refreshes versions, and resets the unsaved baseline | `ui` | `PutEditedPredictions`, versions API | US-004, US-005 | complete (`ui/src/Components/Visualizer/usePredictionFootprints.js:838-902`, `ui/src/Components/Visualizer/usePredictionArtifacts.js:159-168`) | +| Add active-version readout from `predictionVersion`/`predictionVersions` | `ui` | vector-first payload | US-005, US-006 | complete (`ui/src/Components/Visualizer/predictionResults.js:174-180`, `ui/src/Components/Visualizer/predictionResults.js:231-249`, `ui/src/Components/Visualizer/PredictionEditPanel.jsx:513-550`) | +| Wire version-history row selection to refetch `GetVisualizerResults?version=N` | `ui` | active-version UI design | US-005, US-006 | not-started — history is read-only and `getVisualizerResults` sends no `version` param (`ui/src/Components/Visualizer/Visualizer.jsx:213-223`) | | Add one-click edited-version download action in the right panel | `ui` | version history display | US-005 | not-started | -| Add shared PMTiles protocol singleton in `ui/src/util/pmtiles.js` and use it from editor screens | `ui` | PMTiles map sources | US-002, US-003 | complete | -| Add plain Node unit tests for `predictionClassify.js` and `predictionPrep.js` | `ui` | UI helpers | US-002, US-003, US-004 | complete | -| Add swipe comparison map (pre-vs-post, falling back to basemap-vs-post) with dual-pane editing, mirrored feature-state and `A`/`S`/`D` divider keys | `ui` | Editor map | US-003 | complete | -| Add browser/Playwright coverage for gating, threshold visibility, selection, and save flow | `ui-validation` | UI implementation | US-001, US-003, US-005 | not-started — no Playwright config exists | +| Add shared PMTiles protocol singleton in `ui/src/util/pmtiles.js` and use it from Visualizer artifact loading | `ui` | PMTiles map sources | US-002, US-003 | complete (`ui/src/Components/Visualizer/usePredictionArtifacts.js:25-32`, `ui/src/Components/Visualizer/usePredictionArtifacts.js:201-212`) | +| Add plain Node unit tests for `predictionClassify.js`, `predictionResults.js`, `predictionPrep.js`, `predictionFootprintMap.js`, and `visualizerSwipe.js` behavior | `ui` | UI helpers | US-001-US-006 | complete (`ui/src/Components/Visualizer/predictionClassify.test.js:388-407`, `ui/src/Components/Visualizer/predictionClassify.test.js:958-1030`, `ui/src/Components/Visualizer/predictionClassify.test.js:1112-1243`) | +| Add browser/Playwright coverage for View Results gating, vector loading, threshold visibility, selection, save flow, and version history | `ui-validation` | UI implementation | US-001, US-003, US-005 | not-started — this repo has no Playwright config or dependency (`ui/package.json:6-15`, `ui/package.json:62-75`) | **Exit Criteria:** -- [x] Feature is accessible from both model-row workflows. -- [ ] Editor works with PMTiles and sidecar data in local SWA dev. -- [x] UI uses `makeStyles` and Fluent tokens; no hard-coded semantic hex colors. +- [x] Feature is accessible from both model-row workflows through View Results. +- [ ] Edit mode works with PMTiles and sidecar data in local SWA dev. +- [x] UI uses `makeStyles` and Fluent tokens; no hard-coded semantic hex colors in the edit panel/map helpers. - [ ] UI validation shows no regression from the current lint baseline. -- [ ] Browser/Playwright validation exists for the editor screen. +- [ ] Browser/Playwright validation exists for the Visualizer edit mode or is explicitly waived. ### Phase 4: Integration & Deployment — TBD @@ -86,35 +99,37 @@ existing HASTE interaction patterns. | Task | Agent | Dependencies | Story Ref | Status | |---|---|---|---|---| -| Run end-to-end Docker Compose scenario for trained-inference predictions | `backend-dev`, `ui`, `gis` | Phases 1-3 | US-001, US-002, US-003, US-004 | not-started | -| Run end-to-end Docker Compose scenario for embedding predictions | `backend-dev`, `ui`, `gis` | Phases 1-3 | US-001, US-002, US-003, US-004 | not-started | +| Run end-to-end Docker Compose scenario for trained-inference View Results and edit mode | `backend-dev`, `ui`, `gis` | Phases 1-3 | US-001, US-002, US-003, US-004, US-006 | not-started | +| Run end-to-end Docker Compose scenario for embedding View Results and edit mode | `backend-dev`, `ui`, `gis` | Phases 1-3 | US-001, US-002, US-003, US-004, US-006 | not-started | | Verify versioned downloads and raw `Model.gpkgUrl` immutability | `backend-dev` | Phases 1-3 | US-004, US-005 | not-started | +| Verify `GetValidationReport`, `GetAssessmentReport`, and `GetVisualizerResults` default/newest, explicit version, and `version=0` raw behavior | `backend-dev`, `gis` | Phase 2 | US-006 | not-started | | Verify Azure monitoring and queue dead-letter visibility | `backend-dev` | Phase 2 | US-002 | not-started | -| Update end-user docs only after behavior is implemented | `ui` | Feature complete | US-001-US-005 | not-started | +| Update end-user docs only after behavior is implemented and validated | `ui` | Feature complete | US-001-US-006 | not-started | **Exit Criteria:** - [ ] Docker Compose validates both workflows. - [ ] Targeted backend tests pass. - [ ] Targeted UI helper tests pass; Playwright coverage is added or explicitly waived. - [ ] CI passes or has a documented no-regression exception for the known UI lint baseline. +- [ ] Known follow-ups are triaged: UI version switching, API integration tests, browser validation, concurrent-save conflict handling, assessment/report semantics, and producer-side Overture ids. ## Milestones | Milestone | Date | Deliverable | |---|---|---| | Spec approved | TBD | Draft spec and ADR reviewed. | -| Core library done | TBD | Models, artifact types, class derivation, and GeoPackage writer merged. | -| Prep/API done | TBD | Session, save, version list, artifact retrieval, and queue prep working. | -| UI editor done | TBD | Route, map, filters, threshold, overrides, and version list working. | +| Core library done | TBD | Models, artifact types, class derivation, readiness, source resolution, and GeoPackage writer merged. | +| Prep/API done | TBD | Session, save, version list, artifact retrieval, vector-first visualizer, report version params, and queue prep working. | +| Results edit mode done | TBD | View Results entry, vector map, filters, threshold, overrides, version list, and save flow working. | | Release | TBD | Feature promoted after dev/test validation. | ## Agent Summary | Agent | Tasks Owned | Phases | |---|---|---| -| `backend-dev` | 16 | 1, 2, 4 | -| `gis` | 6 | 1, 2, 4 | -| `ui` | 12 | 3, 4 | +| `backend-dev` | 21 | 1, 2, 4 | +| `gis` | 8 | 1, 2, 4 | +| `ui` | 15 | 3, 4 | | `ui-validation` | 1 | 3 | | `security` | 0 | —; no new dependency is expected | @@ -137,3 +152,7 @@ existing HASTE interaction patterns. `local-prediction-edit-prep-queue` in the Docker Compose stack). - [ ] Decide whether high-volume saves need an async save path after measuring real production layer sizes. +- [ ] Decide whether UI version switching should refetch `GetVisualizerResults?version=N`, add a separate version-selection endpoint, or stay read-only. +- [ ] Decide how assessment counts should incorporate per-building overrides when edited GeoPackages preserve the producer's original `damage_pct_0m`. +- [ ] Add optimistic concurrency for simultaneous saves before supporting multi-analyst editing of the same model. +- [ ] Fix or explicitly mitigate the pre-existing positional-join risks: classic inference can drop footprint rows before writing predictions, and neither producer writes an explicit `overture_id` column (`docker/training/code/merge_with_building_footprints.py:151-190`, `docker/training/code/merge_with_building_footprints.py:221-258`, `api/hastefuncapi/function_app.py:2738-2815`). diff --git a/spec/features/prediction-editing/rollout.md b/spec/features/prediction-editing/rollout.md index d07bbb81..d4bd0f8d 100644 --- a/spec/features/prediction-editing/rollout.md +++ b/spec/features/prediction-editing/rollout.md @@ -9,9 +9,10 @@ The current implementation does not include API or UI feature flags. Start with internal dev/test deployments and test projects, then promote to production -after both trained-inference and embedding workflows produce edited versions -without mutating raw outputs. Add feature flags as a follow-up if rollout needs -a runtime kill switch. +after both trained-inference and embedding workflows can open View Results, +render vector footprints, enter Visualizer edit mode, save edited versions, and +read the expected version from visualizer, validation, and assessment readers. +Add feature flags as a follow-up if rollout needs a runtime kill switch. ## Deployment Targets @@ -36,28 +37,50 @@ a runtime kill switch. - **Duration:** one sprint or until both workflows pass E2E validation - **Deployment:** 1. Deploy the branch to dev1. - 2. Verify trained-inference and embedding edit flows against test projects. + 2. Verify trained-inference and embedding View Results flows against test + projects. + 3. Verify edit mode is entered from the existing `/visualizer/...` route by + the pencil affordance and `E` shortcut, not a standalone editor route + (`ui/src/Components/AppBody.jsx:73-75`, + `ui/src/Components/Visualizer/Labels.jsx:117-128`). - **Success criteria:** - - [ ] `GetPredictionEditSession` reports correct readiness for both workflows without enqueueing. - - [ ] `PutPreparePredictionTilesQueueMessage` queues missing PMTiles and sidecars. + - [ ] Server-derived `predictionsReady` enables Results consistently for + trained and embedding models. + - [ ] `GetVisualizerResults` returns the vector-first payload documented for + PMTiles, prediction attributes, readiness, version metadata, flavor, + and nullable classic rasters (`docs/api/hastefuncapi.md:78-157`). + - [ ] `PutPreparePredictionTilesQueueMessage` queues missing PMTiles and + sidecars only when needed. - [ ] Queue workers generate missing PMTiles and sidecars. - - [ ] UI renders the editor, class filters, selection, and threshold behavior. + - [ ] UI renders vectors, filters, selection, threshold behavior when + supported, and edit-mode entry/exit. - [ ] Saving creates `edit_v1` without changing raw `Model.gpkgUrl`. + - [ ] Validation and assessment endpoints accept `version`; default behavior + selects the newest edited version while `version=0` selects raw. - **Rollback trigger:** Any raw artifact mutation, repeated prep queue failures, - or browser crashes on representative layers. + failed Visualizer payload contract, report reader regression, or browser + crashes on representative layers. ### Phase 2: Testing Environment — TBD - **Target:** SWA `testing` environment - **Duration:** one response exercise or agreed analyst validation window - **Success criteria:** - - [ ] Analysts can complete and download edited versions for trained models. - - [ ] Analysts can complete and download edited versions for embedding models. - - [ ] The documented editor/report threshold default split is accepted by testers. + - [ ] Analysts can open View Results and save edited versions for trained + models. + - [ ] Analysts can open View Results and save edited versions for embedding + models. + - [ ] Analysts understand that version history is read-only in the UI: the + payload reports which version is mapped, but selecting another version + does not refetch in this branch. + - [ ] The documented split is accepted: validation metrics read edited + `damaged`, while assessment counts still threshold the producer's + preserved `damage_pct_0m`. - [ ] Memory and duration metrics stay within accepted bounds. - [ ] No regression from baseline UI lint behavior. -- **Rollback trigger:** Save failures above the agreed threshold, invalid row-order - output, or editor performance that blocks analyst use. +- **Rollback trigger:** Save failures above the agreed threshold, invalid + row-order output, confusing report semantics that block analyst use, or editor + performance that blocks analyst workflows. ### Phase 3: Production — TBD @@ -65,7 +88,13 @@ a runtime kill switch. - **Federated credentials:** `fed-cred-main.json` (GitHub Actions OIDC) - **Success criteria:** - [ ] Error rate and queue depth remain stable after production deployment. + - [ ] First production trained and embedding View Results sessions render + vector footprints. - [ ] First production edited version downloads and validates row count/order. + - [ ] Validation report default/`version=0` behavior is verified on the first + edited production model. + - [ ] Assessment report asymmetry is visible in release notes and support + guidance. - [ ] Analyst feedback confirms the editor is usable in dark and light themes. - **Kill-switch follow-up:** If production requires runtime disablement, add the missing API/UI feature flags before broad enablement. @@ -74,10 +103,11 @@ a runtime kill switch. | Step | Action | Owner | ETA | |---|---|---|---| -| 1 | Redeploy the previous UI build to remove Edit entry points | `ui` | <1 hour | -| 2 | Redeploy the previous API build if direct prediction-editing calls must fail closed | `backend-dev` | <1 hour | +| 1 | Redeploy the previous UI build to remove Visualizer edit-mode affordances and embedding View Results entry points | `ui` | <1 hour | +| 2 | Redeploy the previous API build if vector-first visualizer or prediction-editing calls must fail closed | `backend-dev` | <1 hour | | 3 | Stop or drain `prediction-edit-prep-queue` if workers are failing | `backend-dev` | <30 min | -| 4 | Verify raw `Model.gpkgUrl` and existing reports still work | `backend-validation` | <1 hour | +| 4 | Verify raw `Model.gpkgUrl`, classic raster results, and existing reports still work | `backend-validation` | <1 hour | +| 5 | Tell analysts that edited versions saved before rollback remain derived artifacts but may not be selected by the reverted UI/API | `orchestrator` | <1 hour | **Cosmos data rollback required?** no — new fields are optional and backward-compatible. **Blob artifacts cleanup needed?** no for functional rollback — edited GeoPackages, @@ -90,36 +120,49 @@ storage cost requires it. | Metric | Source | Baseline | Alert Threshold | |---|---|---|---| +| `GetVisualizerResults` error rate | Azure Functions metrics / Application Insights | existing route with new payload | >5% 5xx over 15 minutes | | Prediction edit session error rate | Azure Functions metrics / Application Insights | new metric | >5% 5xx over 15 minutes | | `PutEditedPredictions` duration and memory | Application Insights | new metric | p95 near function timeout or memory ceiling | | Prep queue depth | Azure Queue Storage metrics | 0 when idle | sustained growth for 30 minutes | -| Prep job failures | queue worker logs / Batch task status | 0 | any repeated failure for same model | +| Prep job failures | queue worker logs / Batch task status | 0 | any repeated failure for same model or layer | | Edited artifact upload failures | Blob SDK logs | 0 | any production failure | -| Browser-side editor errors | UI telemetry / support reports | 0 | repeated sidecar parse or map-load failures | +| Validation/assessment report failures with `version` | Application Insights | new metric | repeated 4xx/5xx for valid version requests | +| Browser-side Visualizer errors | UI telemetry / support reports | 0 | repeated sidecar parse, PMTiles, or map-load failures | ### Alerts to Configure | Alert | Condition | Severity | Notify | |---|---|---|---| +| Visualizer failures | `GetVisualizerResults` 5xx rate >5% over 15 minutes | P2 | Engineering on-call | | Prep queue stalled | `prediction-edit-prep-queue` depth rising and no completions for 30 minutes | P2 | Engineering on-call | | Save failures | `PutEditedPredictions` 5xx rate >5% over 15 minutes | P2 | Engineering on-call | | Row-order validation failure | Any 422 row-count/order failure in production | P1 | Backend + GIS leads | | Blob upload failures | Edited GeoPackage upload errors >0 for production saves | P2 | Engineering on-call | +| Report version regression | Valid `GetValidationReport` or `GetAssessmentReport` version requests fail repeatedly | P2 | Backend on-call | ## Communication Plan | Audience | Channel | When | Message | |---|---|---|---| -| Engineering team | GitHub PR / Teams | Before dev1 deployment | Prediction editing has no runtime flags in this branch; verify both workflows and artifact immutability before promotion. | -| Disaster analysts | Release notes / Teams | Before testing enablement | Edit completed predictions, save numbered versions, and download them; reports still use raw outputs. | -| Partners | Release notes | At production enablement | Edited prediction GeoPackages may be shared as downloadable derived files; downstream reports are unchanged. | +| Engineering team | GitHub PR / Teams | Before dev1 deployment | Prediction editing has no runtime flags in this branch; verify vector-first View Results, readiness, report `version`, and raw artifact immutability before promotion. | +| Disaster analysts | Release notes / Teams | Before testing enablement | Open View Results for trained or embedding models, use the pencil or `E` to edit predictions in place, save numbered versions, and download them. Version switching in the UI is not wired yet. | +| Product / data science | Design review | Before testing sign-off | Validation reads edited `damaged`; assessment still thresholds preserved `damage_pct_0m`, so manual overrides do not move assessment counts until a follow-up decision. | +| Partners | Release notes | At production enablement | Edited prediction GeoPackages may be shared as downloadable derived files; use `version=0` for raw report inputs and the default/newest version for edited report inputs. | ## Post-Rollout Checklist - [ ] Decide whether to add runtime feature flags before broad production use. +- [ ] Decide whether UI version switching should refetch visualizer/report data. +- [ ] Decide whether `GetAssessmentReport` should count manual per-building + overrides instead of only thresholding `damage_pct_0m`. +- [ ] Open follow-up issues for concurrent-save 409/ETag handling, API + integration tests, Playwright/browser validation, classic row-loss risk, + and raw `overture_id` producer columns. - [ ] Temporary rollout monitoring removed or converted to normal dashboards. -- [ ] End-user docs updated with edit workflow and out-of-scope downstream behavior. +- [ ] End-user docs updated with Visualizer edit-mode workflow and versioned + report behavior. - [ ] GitHub Pages docs rebuilt (`docs-deploy.yml`) if public docs changed. - [ ] Docker Compose stack verified after release. - [ ] `CHANGELOG.md` updated. -- [ ] Follow-up spec opened for downstream consumption of edited versions. +- [ ] Follow-up spec opened for publishing/downstream consumption if edited + versions need active selection outside the current readers. diff --git a/spec/features/prediction-editing/test-plan.md b/spec/features/prediction-editing/test-plan.md index b854787b..b0211e60 100644 --- a/spec/features/prediction-editing/test-plan.md +++ b/spec/features/prediction-editing/test-plan.md @@ -6,11 +6,11 @@ | Level | Scope | Tool/Framework | Coverage Target | |---|---|---|---| -| Unit | `hastegeo` schema detection, class derivation, sidecar generation, row-order preservation, version allocation | pytest / unittest (`hastelib/tests/`) | all core rules and both producer schemas | -| Integration | Prediction edit HTTP endpoints and artifact retrieval | pytest + Azure Functions test harness | success and negative responses; not implemented in current branch | -| Queue | PMTiles and sidecar prep worker | pytest / Docker Compose worker test | idempotent generation and failure handling | -| UI | Edit buttons, route, map state, filters, threshold slider, save flow | Plain Node unit tests for helpers today; browser/Playwright follow-up | critical analyst flows | -| E2E | Full stack with trained and embedding predictions | Docker Compose + manual verification; Playwright unavailable today | one successful version save per workflow | +| Unit | `hastegeo` readiness, source resolution, schema detection, class derivation, sidecar generation, row-order preservation, and version allocation | pytest / unittest (`hastelib/tests/`) | all core rules, both producer schemas, and raw/newest/explicit version selection | +| Integration | `GetVisualizerResults`, prediction edit/prep/version HTTP endpoints, artifact retrieval, and report `version` query handling | pytest + Azure Functions test harness | success and negative responses; API-level tests for the rewritten handler are not implemented in the current branch | +| Queue | PMTiles and sidecar prep worker | pytest / Docker Compose worker test | idempotent generation, layer-only prep, model prep, and failure handling | +| UI | Existing Visualizer route, vector layer loading, edit-mode entry/exit, keyboard shortcut, discard confirmation, save flow, and read-only version history | Plain Node unit tests for helper modules today; browser/Playwright follow-up | critical analyst flows without a standalone editor screen | +| E2E | Full stack with trained and embedding predictions | Docker Compose + manual verification; Playwright unavailable today | View Results works for both workflows and at least one edited version can be saved | | Performance | Large layer prep/save/browser memory | custom scripts with representative GeoPackages | no timeout/memory regression beyond agreed thresholds | ## Test Scenarios @@ -19,41 +19,50 @@ | ID | Module | Scenario | Input | Expected Output | Story Ref | |---|---|---|---|---|---| -| UT-001 | `hastegeo/core/models/projects.py` | Model defaults | Model without new fields | `editedPredictions` behaves as empty list; prediction count/timestamps/sidecar/job/status fields nullable/defaulted | US-004 | +| UT-001 | `hastegeo/core/models/projects.py` | Model defaults | Model without optional prediction fields | `editedPredictions` behaves as empty list; prediction count/timestamps/sidecar/job/status fields nullable/defaulted | US-004, US-005 | | UT-002 | `hastegeo/core/config.py` | Artifact template rendering | `modelId=123`, `version=2`, `imageLayerId=abc` | `edited_predictions_123_v2`, `prediction_attrs_123`, `footprints_abc` | US-002, US-004 | -| UT-003 | `hastegeo/core/utils/predictions.py` | Trained schema detection | GPKG with `damage_pct_0m`, `damage_pct_10m`, `damage_pct_20m`, `damaged`, `unknown_pct` | `flavor="inference"`, `supportsThreshold=true` | US-002 | -| UT-004 | `hastegeo/core/utils/predictions.py` | Embedding schema detection | GPKG layer `predictions` with `area`, `damaged`, degenerate `damage_pct_0m` | `flavor="embedding"`, `supportsThreshold=false` | US-002 | -| UT-005 | `hastegeo/core/processors/prediction_edits.py` | Class derivation without override | damage `0.2`, unknown `0.0`, threshold `0.1` | `Damaged`, `damaged=1` | US-003, US-004 | -| UT-006 | `hastegeo/core/processors/prediction_edits.py` | Unknown wins before damage | damage `0.8`, unknown `0.3`, unknownThreshold `0.0` | `Unknown`, `damaged=0` | US-003, US-004 | -| UT-007 | `hastegeo/core/processors/prediction_edits.py` | Override wins over thresholds | override `NotDamaged`, damage `0.9` | `NotDamaged`, `damaged=0` | US-003, US-004 | -| UT-008 | `hastegeo/core/processors/prediction_edits.py` | Row-order invariant | Footprints ids `[a,b,c]`; predictions rows `[0,1,2]` | Edited rows remain `[0,1,2]` with `overture_id` `[a,b,c]` | US-004 | -| UT-009 | `hastegeo/core/processors/prediction_edits.py` | Row-count mismatch | Footprints 3 rows; predictions 2 rows | Raises validation error; no version metadata appended | US-002, US-004 | -| UT-010 | `hastegeo/core/processors/prediction_edits.py` | Version allocation | Existing versions `[1,2]` | Next artifact uses version `3` | US-004 | -| UT-011 | `hastegeo/workflows/prepare_prediction_tiles.py` | Sidecar shape | Three prediction rows | JSON has `n=3` and same-length `ids`, `overtureIds`, `damage`, `unknown`, `damaged` arrays | US-002 | -| UT-012 | `hastegeo/core/models/predictions.py` | Wire request validation | Save/prep request bodies | Invalid IDs, thresholds, classes, duplicate override IDs rejected before processors run | US-002, US-004 | -| UT-013 | `hastegeo/core/processors/prediction_tiles.py` | Prep request idempotency | Ready, missing, in-flight, and forced model/layer states | Returns `{modelId, queued, tilesReady, attrsReady, status, statusMessage}` and enqueues at most one message | US-002 | +| UT-003 | `hastegeo/core/utils/model_readiness.py` | Unified model-row readiness | Inference, embedding, empty, clear-label, and missing-artifact model states | One `predictionsReady` result and reason contract is applied across model payloads and publishing (`hastelib/src/hastegeo/core/utils/model_readiness.py:132-237`) | US-001, US-002 | +| UT-004 | `hastegeo/core/utils/predictions.py` | Source resolution | No `version`, `version=0`, explicit edited version, missing version | Defaults to newest edited version; `version=0` returns raw output; explicit version returns that edit or raises not found (`hastelib/src/hastegeo/core/utils/predictions.py:332-401`) | US-006 | +| UT-005 | `hastegeo/core/utils/predictions.py` | Trained schema detection | GPKG with `damage_pct_0m`, `damage_pct_10m`, `damage_pct_20m`, `damaged`, `unknown_pct` | `flavor="inference"`, `supportsThreshold=true` | US-002, US-003 | +| UT-006 | `hastegeo/core/utils/predictions.py` | Embedding schema detection | GPKG layer `predictions` with `area`, `damaged`, degenerate `damage_pct_0m` | `flavor="embedding"`, `supportsThreshold=false` | US-002, US-003 | +| UT-007 | `hastegeo/core/processors/prediction_edits.py` | Class derivation without override | damage `0.2`, unknown `0.0`, threshold `0.1` | `Damaged`, `damaged=1` | US-003, US-004 | +| UT-008 | `hastegeo/core/processors/prediction_edits.py` | Unknown wins before damage | damage `0.8`, unknown `0.3`, unknownThreshold `0.0` | `Unknown`, `damaged=0` | US-003, US-004 | +| UT-009 | `hastegeo/core/processors/prediction_edits.py` | Override wins over thresholds | override `NotDamaged`, damage `0.9` | `NotDamaged`, `damaged=0` | US-003, US-004 | +| UT-010 | `hastegeo/core/processors/prediction_edits.py` | Row-order invariant | Footprints ids `[a,b,c]`; predictions rows `[0,1,2]` | Edited rows remain `[0,1,2]` with `overture_id` `[a,b,c]` | US-004 | +| UT-011 | `hastegeo/core/processors/prediction_edits.py` | Row-count mismatch | Footprints 3 rows; predictions 2 rows | Raises validation error; no version metadata appended | US-002, US-004 | +| UT-012 | `hastegeo/core/processors/prediction_edits.py` | Version allocation | Existing versions `[1,2]` | Next artifact uses version `3`; concurrent-save conflict is a known follow-up, not expected here | US-004, US-005 | +| UT-013 | `hastegeo/workflows/prepare_prediction_tiles.py` | Sidecar shape | Three prediction rows | JSON has `n=3` and same-length `ids`, `overtureIds`, `damage`, `unknown`, `damaged` arrays | US-002 | +| UT-014 | `hastegeo/core/models/predictions.py` | Wire request validation | Save/prep request bodies | Invalid IDs, thresholds, classes, duplicate override IDs rejected before processors run | US-002, US-004 | +| UT-015 | `hastegeo/core/processors/prediction_tiles.py` | Prep request idempotency | Ready, missing, in-flight, forced model, and layer-only states | Returns `{modelId, queued, tilesReady, attrsReady, status, statusMessage}` and enqueues at most one message | US-002 | ### API Integration Tests No prediction-editing API integration tests are implemented in the current -branch. `api/hastefuncapi/tests/` contains only `test_publishing_routes.py`; the -cases below remain follow-up coverage. +branch. `api/hastefuncapi/tests/` contains only publishing-route coverage; the +cases below remain follow-up coverage for the rewritten handlers. | ID | Endpoint | Method | Scenario | Preconditions | Expected Response | Story Ref | |---|---|---|---|---|---|---| -| IT-001 | `/api/GetPredictionEditSession` | GET | Ready trained model | Processed inference model with raw GPKG, PMTiles, sidecar | 200 with `flavor="inference"`, `supportsThreshold=true`, `defaultThreshold=0.0`, readiness flags, and prep status fields | US-002 | -| IT-002 | `/api/GetPredictionEditSession` | GET | Ready embedding model | Embedding model with `gpkgUrl` and `predictedBuildingCount>0` | 200 with `flavor="embedding"`, `supportsThreshold=false` | US-002 | -| IT-003 | `/api/GetPredictionEditSession` | GET | Missing prep artifacts | Raw GPKG exists, PMTiles/sidecar absent | 200 with readiness false and no queued message | US-002 | -| IT-004 | `/api/PutPreparePredictionTilesQueueMessage` | PUT | Queue missing prep | Raw GPKG and building footprints exist; artifacts missing | 200 with `queued=true`, `status="Queued"`, and exactly one queue message | US-002 | -| IT-005 | `/api/PutPreparePredictionTilesQueueMessage` | PUT | Ready no-op | PMTiles and sidecar already exist; `force=false` | 200 with `queued=false`, `tilesReady=true`, `attrsReady=true`, no queue message | US-002 | -| IT-006 | `/api/PutPreparePredictionTilesQueueMessage` | PUT | In-flight no-op | `predictionTilesStatus` is `Queued` or `InProgress`; `force=false` | 200 with `queued=false`, current status, no duplicate queue message | US-002 | -| IT-007 | `/api/PutPreparePredictionTilesQueueMessage` | PUT | Missing source inputs | No `gpkgUrl` or no `buildingFootprintsUrl` | 404 | US-002 | -| IT-008 | `/api/PutEditedPredictions` | PUT | Save first edit | Valid thresholds and overrides | 200 with `version=1`, `gpkgUrl`, `editedCount`; Model gets one version | US-004 | -| IT-009 | `/api/PutEditedPredictions` | PUT | Invalid threshold | `threshold=2` | 400 | US-004 | -| IT-010 | `/api/PutEditedPredictions` | PUT | Override out of range | `id >= buildingCount` | 200; unmatched override ignored and not counted | US-004 | -| IT-011 | `/api/GetEditedPredictionVersions` | GET | Existing versions | Model has versions | 200 with version metadata list, newest first | US-005 | -| IT-012 | `/api/GetModelArtifact` | GET | Fetch new artifact kinds | Prepared PMTiles and sidecar | 200 for `footprint_pmtiles` and JSON `prediction_attrs` | US-002, US-005 | -| IT-013 | `/api/GetPredictionEditSession` | GET | Missing model | Unknown `modelId` | 404 | US-002 | +| IT-001 | `/api/GetVisualizerResults` | GET | Ready trained model | Processed inference model with raw GPKG, footprint PMTiles, and sidecar | 200 with `footprintTilesUrl`, `predictionAttrsUrl`, readiness object, `flavor="inference"`, `supportsThreshold=true`, `predictionVersion`, `predictionVersions`, and nullable raster fields as documented (`docs/api/hastefuncapi.md:78-157`) | US-002, US-006 | +| IT-002 | `/api/GetVisualizerResults` | GET | Ready embedding model | Embedding model with `gpkgUrl`, PMTiles, sidecar, and `predictedBuildingCount>0` | 200 with vector fields, `flavor="embedding"`, `supportsThreshold=false`, and no required classic rasters | US-001, US-002 | +| IT-003 | `/api/GetVisualizerResults` | GET | Explicit raw version | Model with edited versions; query `version=0` | Payload reports raw source version and raw building count/readiness | US-006 | +| IT-004 | `/api/GetVisualizerResults` | GET | Explicit edited version | Model with version `2`; query `version=2` | Payload reports `predictionVersion=2` and selects the edited GeoPackage | US-006 | +| IT-005 | `/api/GetVisualizerResults` | GET | Missing prep artifacts | Raw GPKG exists, PMTiles/sidecar absent | 200 with readiness false, null vector URLs as applicable, and `predictionsReadiness` reason | US-002 | +| IT-006 | `/api/GetPredictionEditSession` | GET | Ready trained model | Processed inference model with raw GPKG, PMTiles, sidecar | 200 with `flavor="inference"`, `supportsThreshold=true`, `defaultThreshold=0.0`, readiness flags, and prep status fields | US-002, US-003 | +| IT-007 | `/api/GetPredictionEditSession` | GET | Ready embedding model | Embedding model with `gpkgUrl` and `predictedBuildingCount>0` | 200 with `flavor="embedding"`, `supportsThreshold=false` | US-002, US-003 | +| IT-008 | `/api/GetPredictionEditSession` | GET | Missing prep artifacts | Raw GPKG exists, PMTiles/sidecar absent | 200 with readiness false and no queued message | US-002 | +| IT-009 | `/api/PutPreparePredictionTilesQueueMessage` | PUT | Queue missing prep | Raw GPKG and building footprints exist; artifacts missing | 200 with `queued=true`, `status="Queued"`, and exactly one queue message | US-002 | +| IT-010 | `/api/PutPreparePredictionTilesQueueMessage` | PUT | Ready no-op | PMTiles and sidecar already exist; `force=false` | 200 with `queued=false`, `tilesReady=true`, `attrsReady=true`, no queue message | US-002 | +| IT-011 | `/api/PutPreparePredictionTilesQueueMessage` | PUT | In-flight no-op | `predictionTilesStatus` is `Queued` or `InProgress`; `force=false` | 200 with `queued=false`, current status, no duplicate queue message | US-002 | +| IT-012 | `/api/PutPreparePredictionTilesQueueMessage` | PUT | Missing source inputs | No `gpkgUrl` or no `buildingFootprintsUrl` | 404 | US-002 | +| IT-013 | `/api/PutEditedPredictions` | PUT | Save first edit | Valid thresholds and overrides from Visualizer edit mode | 200 with `version=1`, `gpkgUrl`, `editedCount`; Model gets one version | US-004 | +| IT-014 | `/api/PutEditedPredictions` | PUT | Invalid threshold | `threshold=2` | 400 | US-004 | +| IT-015 | `/api/PutEditedPredictions` | PUT | Override out of range | `id >= buildingCount` | 200; unmatched override ignored and not counted | US-004 | +| IT-016 | `/api/GetEditedPredictionVersions` | GET | Existing versions | Model has versions | 200 with version metadata list, newest first | US-005 | +| IT-017 | `/api/GetModelArtifact` | GET | Fetch new artifact kinds | Prepared PMTiles and sidecar | 200 for `footprint_pmtiles` and JSON `prediction_attrs` | US-002, US-005 | +| IT-018 | `/api/GetValidationReport` | GET | Edited version selected by default | Model has edited version whose `damaged` differs from raw | Default response reflects newest edit; `version=0` restores raw (`api/hastefuncapi/function_app.py:4607-4688`) | US-006 | +| IT-019 | `/api/GetAssessmentReport` | GET | Edited version selected by default | Model has edited version whose `damaged` differs but `damage_pct_0m` is preserved | Default reader opens newest edit, but thresholded counts remain tied to `damage_pct_0m`; this asymmetry is documented (`api/hastefuncapi/function_app.py:4929-5027`) | US-006 | +| IT-020 | `/api/GetPredictionEditSession` | GET | Missing model | Unknown `modelId` | 404 | US-002 | ### Queue Worker Tests @@ -62,7 +71,7 @@ cases below remain follow-up coverage. | QT-001 | `prediction-edit-prep-queue` | Build missing PMTiles and sidecar | valid project/layer/model/source urls | PMTiles and sidecar blobs uploaded; metadata fields updated | US-002 | | QT-002 | `prediction-edit-prep-queue` | Idempotent no-op | artifacts already exist and `force=false` | No duplicate work; metadata remains consistent | US-002 | | QT-003 | `prediction-edit-prep-queue` | Force rebuild | artifacts exist and `force=true` | Artifacts regenerated and metadata timestamp refreshed | US-002 | -| QT-004 | `prediction-edit-prep-queue` | Malformed message | neither `modelId` nor `imageLayerId` | Worker logs validation error and dead-letters/fails without partial metadata | US-002 | +| QT-004 | `prediction-edit-prep-queue` | Malformed message | neither `modelId` nor `imageLayerId` | Worker logs validation error and fails without partial metadata | US-002 | | QT-005 | `prediction-edit-prep-queue` | Row-count mismatch | predictions and footprints lengths differ | Prep fails; no `predictedAt` update | US-002 | | QT-006 | `prediction-edit-prep-queue` | Layer-only prep | empty `modelId`, layer with footprints | PMTiles blob uploaded; only `ImageLayer.footprintPmtilesUrl`/`footprintTiles*` written; no sidecar and no model document touched | US-002 | | QT-007 | `prediction-edit-prep-queue` | Layer-only no-op | empty `modelId`, layer already has `footprintPmtilesUrl`, `force=false` | No job submitted; layer marked `Processed` | US-002 | @@ -70,31 +79,36 @@ cases below remain follow-up coverage. ### UI Component Tests -The current branch includes plain Node tests for `predictionClassify.js` and -`predictionPrep.js`. It does not include a React Testing Library, Vitest, or -Playwright harness for browser rendering. +The current branch includes plain Node helper tests, but it does not include a +React Testing Library, Vitest, or Playwright harness for browser rendering. UI +coverage below is therefore a required follow-up before release sign-off. | ID | Component | Scenario | User Action | Expected Behavior | Story Ref | |---|---|---|---|---|---| -| UI-001 | `ModelResultsButton.jsx` | Trained edit gating | Render model variations | Enabled only when `inferenceStatus === "Processed" && gpkgUrl` | US-001 | -| UI-002 | `EmbeddingModelRow.jsx` | Embedding edit gating | Render model variations | Enabled only when `gpkgUrl && predictedBuildingCount > 0` | US-001 | -| UI-003 | `PredictionEditor.jsx` / `predictionPrep.js` | Prep pending | Load session with `tilesReady=false` | Calls `PutPreparePredictionTilesQueueMessage`, shows preparation state, and polls session | US-002 | -| UI-004 | `PredictionEditor.jsx` / `predictionClassify.js` | Trained threshold | Load `supportsThreshold=true`; move slider | Slider visible; colors and flip counts update | US-003 | -| UI-005 | `PredictionEditor.jsx` | Embedding no threshold | Load `supportsThreshold=false` | Slider hidden; manual overrides available | US-003 | -| UI-006 | `PredictionEditor.jsx` | Click classify | Click footprint and choose class | Feature color and counts update via feature-state | US-003 | -| UI-007 | `PredictionEditor.jsx` | Box-select classify | Ctrl+drag selection and choose class | All selected features update | US-003 | -| UI-008 | `PredictionEditor.jsx` | Save version | Click Save as new version | PUT body includes thresholds and overrides; version list refreshes | US-004, US-005 | -| UI-009 | `PredictionEditorRightPanel.jsx` | Version history | Save or load existing versions | History displays version, timestamp, threshold, editor, and edited count; one-click download remains follow-up | US-005 | -| UI-010 | `PredictionEditor.jsx` | Dark mode | Render in dark theme | Styles use Fluent tokens and remain legible | US-003 | -| UI-011 | `ui/src/util/pmtiles.js` | Shared protocol singleton | Render multiple PMTiles screens | Both screens share one `pmtiles://` protocol instance | US-002, US-003 | +| UI-001 | `ModelResultsButton.jsx` | Trained results gating | Render model variations | Results menu follows server-derived `predictionsReady` with legacy fallback (`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:87-110`) | US-001 | +| UI-002 | `EmbeddingModelRow.jsx` | Embedding View Results | Open Results menu for ready and unready embedding models | First menu item navigates to `/visualizer/...` only when `predictionsReady` is true (`ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:85-130`) | US-001 | +| UI-003 | `Visualizer.jsx` | Vector-first load | Open `/visualizer/:projectId/:imageLayerId/:modelId` | Fetches visualizer payload and loads footprint PMTiles plus prediction attrs before edit mode (`ui/src/Components/Visualizer/Visualizer.jsx:457-605`) | US-002 | +| UI-004 | `Labels.jsx` / `Visualizer.jsx` | Enter edit mode | Click pencil next to Back or press `E` | Existing visualizer switches to edit mode; no route change or standalone screen (`ui/src/Components/Visualizer/Labels.jsx:117-128`, `ui/src/Components/Visualizer/Visualizer.jsx:873-921`) | US-003 | +| UI-005 | `Visualizer.jsx` | Leave clean edit mode | Click Done or press `E` with no unsaved edits | Edit controls disappear; vectors remain visible on the View Results page | US-003 | +| UI-006 | `Visualizer.jsx` | Discard confirmation | Press `E`, Back, or Done with unsaved edits | Confirmation dialog appears; cancel keeps edits; discard exits mode | US-003 | +| UI-007 | `PredictionEditPanel.jsx` / `predictionResults.js` | Trained threshold | Load `supportsThreshold=true`; move slider | Slider visible; colors and flip counts update | US-003 | +| UI-008 | `PredictionEditPanel.jsx` | Embedding no threshold | Load `supportsThreshold=false` | Slider hidden; manual overrides available | US-003 | +| UI-009 | `Visualizer.jsx` | Click classify | Click footprint and choose class | Feature color and counts update via vector state | US-003 | +| UI-010 | `Visualizer.jsx` | Box-select classify | Ctrl+drag selection and choose class | All selected features update | US-003 | +| UI-011 | `PredictionEditPanel.jsx` | Save version | Click Save | PUT body includes thresholds and overrides; version list refreshes; raw route stays on `/visualizer/...` | US-004, US-005 | +| UI-012 | `PredictionEditPanel.jsx` | Version history read-only | Load existing versions or save a version | History displays version, timestamp, threshold, editor, edited count, and which version is mapped; selecting another version does not refetch in this branch (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:513-550`) | US-005 | +| UI-013 | `Visualizer.jsx` | Dark mode | Render in dark theme | Styles use Fluent tokens and remain legible | US-003 | +| UI-014 | `ui/src/util/pmtiles.js` | Shared protocol singleton | Render multiple PMTiles screens | Both screens share one `pmtiles://` protocol instance | US-002, US-003 | ### End-to-End Tests (Docker Compose) | ID | User Flow | Steps | Expected Outcome | Story Ref | |---|---|---|---|---| -| E2E-001 | Trained prediction edit | 1. Start Docker Compose 2. Use a processed trained model with `gpkgUrl` 3. Open Edit 4. Wait for prep 5. Change threshold and override one building 6. Save | `edit_v1` GeoPackage downloads; raw `Model.gpkgUrl` unchanged | US-001-US-005 | -| E2E-002 | Embedding prediction edit | 1. Start Docker Compose 2. Use an embedding model with non-empty predictions 3. Open Edit 4. Confirm no threshold slider 5. Override one building 6. Save | `edit_v1` GeoPackage downloads with expected class columns | US-001-US-005 | -| E2E-003 | Empty embedding predictions | 1. Save empty embedding predictions 2. Return to project management | Edit button remains disabled because `predictedBuildingCount` is not positive | US-001 | +| E2E-001 | Trained View Results and edit mode | 1. Start Docker Compose 2. Use a processed trained model with `predictionsReady=true` 3. Open Results → View Results 4. Confirm vector footprints render 5. Enter edit mode with pencil or `E` 6. Change threshold/override one building 7. Save | `edit_v1` GeoPackage downloads; raw `Model.gpkgUrl` unchanged; visualizer payload defaults to newest edit after refresh | US-001-US-006 | +| E2E-002 | Embedding View Results and edit mode | 1. Start Docker Compose 2. Use an embedding model with non-empty predictions 3. Open Results → View Results 4. Confirm vector footprints render and no threshold slider 5. Override one building 6. Save | `edit_v1` GeoPackage downloads with expected class columns; embedding View Results uses `/visualizer/...` | US-001-US-006 | +| E2E-003 | Empty embedding predictions | 1. Save empty embedding predictions 2. Return to project management | Results View remains disabled because server-derived `predictionsReady` is false with `no_buildings` readiness reason | US-001, US-002 | +| E2E-004 | Unsaved edit discard | 1. Enter edit mode 2. Modify one building 3. Press `E` or Done 4. Cancel and then discard | Dialog protects unsaved edits; discard returns to normal visualizer mode | US-003 | +| E2E-005 | Report reader versions | 1. Save edited version 2. Request validation and assessment reports with default, `version=0`, and explicit version | Validation metrics follow edited `damaged`; assessment opens the requested GeoPackage but counts still threshold `damage_pct_0m` | US-006 | ### Edge Case & Negative Tests @@ -104,17 +118,20 @@ Playwright harness for browser rendering. | NEG-002 | Non-existent project ID | Random GUID | 404 | | NEG-003 | Invalid class | override class `Destroyed` | 400 | | NEG-004 | Duplicate override ids | two overrides for id `7` | 400 or deterministic client-side collapse before request | -| NEG-005 | Missing raw GPKG | Model lacks `gpkgUrl` | 404 from session; button disabled in UI | +| NEG-005 | Missing raw GPKG | Model lacks `gpkgUrl` | 404 from edit session; Results disabled in UI through readiness | | EDGE-001 | Very large layer | Representative large GeoPackage | Prep/save complete within agreed memory/time budget or produce actionable error | | EDGE-002 | Concurrent saves | Parallel PUT requests | Known gap: current implementation can allocate the same next version; add optimistic concurrency follow-up | | EDGE-003 | Threshold default split | Session default vs report default | Editor session remains `0.0`; assessment report default remains `0.1`; product decision is documented | | EDGE-004 | UI lint baseline | Current repo-wide ESLint 9 flat-config failure | Validation records no regression from baseline, not necessarily clean lint | +| EDGE-005 | Version switching | User clicks an older version in the history | Known gap: history is read-only; payload reports current version but selection does not refetch | +| EDGE-006 | Classic footprint row loss | Prediction GPKG has fewer rows than source footprints | Prep/save should fail loudly; producer-side fix remains a follow-up | +| EDGE-007 | Raw Overture id absence | Raw prediction GeoPackage has no explicit `overture_id` | Prep/save relies on positional join today; explicit producer column remains a follow-up | ### Performance Tests | ID | Scenario | Load Profile | Target Metric | Threshold | |---|---|---|---|---| -| PERF-001 | Session readiness | 50 concurrent session requests that read raw prediction GeoPackages for flavor/count | p99 latency | threshold TBD after representative GPKG measurement | +| PERF-001 | Visualizer payload readiness | 50 concurrent `GetVisualizerResults` requests that read selected prediction GeoPackages for flavor/count | p99 latency | threshold TBD after representative GPKG measurement | | PERF-002 | PMTiles/sidecar prep | One dense urban layer | job duration and peak memory | fit existing worker/Batch limits; no OOM | | PERF-003 | Save edited version | GeoPackage at 95th percentile building count | function duration and peak memory | complete below platform timeout or trigger async-save follow-up | | PERF-004 | Browser editing | PMTiles + sidecar for dense layer | Chrome heap and interaction latency | no tab crash; pan/selection remains usable | @@ -126,18 +143,22 @@ Playwright harness for browser rendering. | Trained inference sample GeoPackage | Includes continuous `damage_pct_0m`, `damage_pct_10m`, `damage_pct_20m`, `damaged`, `unknown_pct` | Synthetic or sanitized existing fixture | no | | Embedding prediction sample GeoPackage | Layer `predictions`, `area`, `damaged`, degenerate `damage_pct_0m` | Synthetic or sanitized existing fixture | no | | Source footprints GeoPackage | Ordered Overture ids matching prediction rows | Synthetic | no | +| Layer footprint PMTiles | Building geometry artifact independent of a model | Synthetic or generated by prep worker | no | +| Prediction attribute sidecar | Model-scoped arrays matching PMTiles feature ids | Synthetic or generated by prep worker | no | +| Edited prediction GeoPackages | Raw plus `edit_v1` and `edit_v2` documents | Synthetic | no | | Large dense footprint set | Stress PMTiles, sidecar, and save memory | Synthetic | no | -| Model/ImageLayer metadata fixtures | Raw and edited model documents | Synthetic | no | +| Model/ImageLayer metadata fixtures | Raw, unready, ready, and edited model documents | Synthetic | no | ## Coverage Matrix | User Story | Unit | API Integration | Queue | UI | E2E | Performance | |---|---|---|---|---|---|---| -| US-001 | — | — | — | UI-001, UI-002 | E2E-001, E2E-002, E2E-003 | — | -| US-002 | UT-003, UT-004, UT-008, UT-009, UT-011, UT-012, UT-013 | IT-001-IT-007, IT-012, IT-013 | QT-001-QT-005 | UI-003, UI-011 | E2E-001, E2E-002 | PERF-001, PERF-002 | -| US-003 | UT-005, UT-006, UT-007 | — | — | UI-004-UI-007, UI-010, UI-011 | E2E-001, E2E-002 | PERF-004 | -| US-004 | UT-001, UT-002, UT-005-UT-010, UT-012 | IT-008-IT-010 | — | UI-008 | E2E-001, E2E-002 | PERF-003 | -| US-005 | UT-001 | IT-011, IT-012 | — | UI-008, UI-009 | E2E-001, E2E-002 | — | +| US-001 | UT-003 | IT-002 | — | UI-001, UI-002 | E2E-001, E2E-002, E2E-003 | — | +| US-002 | UT-002, UT-003, UT-005, UT-006, UT-010, UT-011, UT-013, UT-014, UT-015 | IT-001-IT-012, IT-017, IT-020 | QT-001-QT-008 | UI-003, UI-014 | E2E-001, E2E-002, E2E-003 | PERF-001, PERF-002 | +| US-003 | UT-005-UT-009 | — | — | UI-004-UI-010, UI-013, UI-014 | E2E-001, E2E-002, E2E-004 | PERF-004 | +| US-004 | UT-001, UT-002, UT-007-UT-012, UT-014 | IT-013-IT-015 | — | UI-011 | E2E-001, E2E-002 | PERF-003 | +| US-005 | UT-001 | IT-016, IT-017 | — | UI-011, UI-012 | E2E-001, E2E-002 | — | +| US-006 | UT-004 | IT-001, IT-003, IT-004, IT-018, IT-019 | — | UI-012 | E2E-001, E2E-005 | — | ## Environment Requirements @@ -145,17 +166,19 @@ Playwright harness for browser rendering. |---|---|---| | Local (Docker Compose) | Developer testing of UI, API, queue, Azurite artifacts | `docker/docker-compose.yml`; no prediction-editing feature flags implemented | | CI (GitHub Actions) | Automated backend and UI tests | Existing secret scan/deploy workflows plus targeted tests | -| Dev1 SWA | Integration testing with realistic project data | Feature flags enabled for internal testers | -| Testing SWA | Pre-production validation | Feature flags enabled after dev1 sign-off | +| Dev1 SWA | Integration testing with realistic project data | Internal testers use existing route and auth; no runtime feature flag | +| Testing SWA | Pre-production validation | Promote after dev1 sign-off; no runtime feature flag | ## Sign-off Criteria - [ ] All P0 stories have E2E coverage for trained and embedding workflows. -- [ ] Row-order preservation is asserted in unit tests; API integration coverage remains a follow-up. -- [ ] `hastelib` targeted tests pass for prediction editing. -- [ ] API integration tests are added and pass for session, prep, save, version list, and artifact retrieval. +- [ ] Row-order preservation is asserted in unit tests; producer-side row loss and missing raw `overture_id` are tracked as follow-ups. +- [ ] `hastelib` targeted tests pass for readiness, source resolution, prep, and edit processors. +- [ ] API integration tests are added and pass for visualizer payloads, session, prep, save, version list, artifact retrieval, validation report, and assessment report version handling. - [ ] UI helper tests pass; browser/Playwright tests are added for gating, - threshold visibility, selection, save, version history, and dark mode. + vector-first rendering, edit-mode entry/exit, discard confirmation, + threshold visibility, selection, save, read-only version history, and dark + mode. - [ ] Performance tests establish safe limits or document a follow-up async-save requirement. - [ ] UI lint validation records no regression from the known repo-wide ESLint 9 diff --git a/spec/features/prediction-editing/user-stories.md b/spec/features/prediction-editing/user-stories.md index da1a4150..1e7a223b 100644 --- a/spec/features/prediction-editing/user-stories.md +++ b/spec/features/prediction-editing/user-stories.md @@ -14,80 +14,97 @@ ## Stories -### US-001: Open the Prediction Editor from Any Completed Prediction Workflow +### US-001: Open Results and Enter Edit Mode from Any Completed Prediction Workflow **As a** Disaster Analyst, -**I want to** open an Edit screen from both trained-inference and embedding model rows, -**So that** I can correct predictions without caring which workflow produced them. +**I want to** open View Results from both trained-inference and embedding model rows and enter edit mode there, +**So that** I can review and correct predictions without leaving the results map. **Priority:** P0 **Estimate:** M -**Component(s):** `ui/src/Components/ProjectManagement/ModelResultsButton.jsx`, `ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx`, `ui/src/Components/AppBody.jsx` +**Component(s):** `ui/src/Components/ProjectManagement/ModelResultsButton.jsx`, `ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx`, `ui/src/Components/AppBody.jsx`, `ui/src/Components/Visualizer/Labels.jsx`, `ui/src/Components/Visualizer/Visualizer.jsx` **Acceptance Criteria:** ```gherkin -Given a trained-inference model with inferenceStatus "Processed" and a non-empty gpkgUrl -When I view the model row -Then the Edit button is enabled and navigates to /edit-predictions/:projectId/:imageLayerId/:modelId +Given a trained-inference model with server-derived predictionsReady true +When I open the Results menu +Then the View item is enabled and navigates to /visualizer/:projectId/:imageLayerId/:modelId +And there is no standalone Edit button on the model row ``` ```gherkin -Given an embedding model with a non-empty gpkgUrl and predictedBuildingCount greater than 0 -When I view the embedding model row -Then the Edit button is enabled and navigates to /edit-predictions/:projectId/:imageLayerId/:modelId +Given an embedding model with server-derived predictionsReady true +When I open the embedding Results menu +Then View is the first menu item and navigates to /visualizer/:projectId/:imageLayerId/:modelId +And there is no standalone Edit button on the embedding row ``` ```gherkin -Given a trained model without processed inference or without gpkgUrl -When I view the model row -Then the Edit button is disabled +Given the View Results page has loaded predicted footprints and attributes +When I click the pencil next to Back or press E +Then the same /visualizer page enters prediction edit mode +And the edit panel replaces the read-only overlay controls ``` ```gherkin -Given an embedding model whose gpkgUrl was set by an empty prediction write -When predictedBuildingCount is 0 or missing -Then the Edit button is disabled +Given I am in edit mode with unsaved edits +When I click Done or press E +Then HASTE asks me to discard unsaved edits before leaving edit mode ``` -**UI Wireframe:** The Edit button appears beside existing result actions on each -model row and opens a full-screen editor route. +```gherkin +Given the model is not ready, has no predictions, has no predicted buildings, or is still preparing vector artifacts +When I view the Results menu or the visualizer edit affordance +Then the disabled state explains why editing cannot open yet +``` + +**UI Wireframe:** The Results menu opens the existing View Results route. A +pencil/Done button sits beside Back on the visualizer; edit mode overlays a +right-side edit panel on the same swipe map. -**Notes:** Current trained gating uses `inferenceStatus === "Processed"`; current -embedding gating only checks `!!model.gpkgUrl`, which is ambiguous -(`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:43-46`, -`ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:86`). +**Notes:** `AppBody.jsx` registers `/visualizer/...` and no +`/edit-predictions/...` route (`ui/src/Components/AppBody.jsx:73-75`). The +trained row and embedding row both navigate to `/visualizer/...` from View +(`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:87-110`, +`ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:116-130`). The +pencil affordance and `E` shortcut are wired in `Labels.jsx` and `Visualizer.jsx` +(`ui/src/Components/Visualizer/Labels.jsx:117-128`, +`ui/src/Components/Visualizer/Visualizer.jsx:496-605`). --- -### US-002: Prepare a Complete Footprint Editing Session +### US-002: Prepare a Complete Footprint Results/Edit Session **As a** Disaster Analyst, **I want to** load all predicted building footprints, not a sample, -**So that** edits cover the complete model output. +**So that** both viewing and editing cover the complete model output. **Priority:** P0 **Estimate:** L -**Component(s):** `api/hastefuncapi`, `api/hastefuncqueues`, `hastelib`, `docker/training` +**Component(s):** `api/hastefuncapi`, `api/hastefuncqueues`, `hastelib`, `docker/training`, `ui/src/Components/Visualizer/` **Acceptance Criteria:** ```gherkin -Given a model with a raw prediction GeoPackage and existing footprint PMTiles and prediction attributes -When the UI calls GetPredictionEditSession -Then the response includes tilesReady true, attrsReady true, buildingCount, flavor, supportsThreshold, defaultThreshold, predictionTilesStatus, predictionTilesStatusMessage, and versions +Given GetVisualizerResults returns footprintTilesUrl and predictionAttrsUrl +When the UI loads the results page +Then it fetches the PMTiles archive and prediction attribute sidecar through GetModelArtifact routes +And it renders predicted buildings as vectors for either workflow ``` ```gherkin -Given the raw prediction GeoPackage exists but PMTiles or attributes are missing -When the UI calls GetPredictionEditSession -Then the response is side-effect-free and returns tilesReady false or attrsReady false without enqueueing work or running tippecanoe inline +Given the model has predictions but PMTiles or attributes are missing +When GetVisualizerResults reports predictionsReadiness.reason "preparing" or the artifact request returns 404 +Then the UI calls GetPredictionEditSession and PutPreparePredictionTilesQueueMessage +And it polls GetPredictionEditSession until tilesReady and attrsReady are true ``` ```gherkin -Given the raw prediction GeoPackage exists but PMTiles or attributes are missing -When the UI calls PutPreparePredictionTilesQueueMessage with projectId, imageLayerId, modelId, and optional force -Then the API returns modelId, queued, tilesReady, attrsReady, status, and statusMessage, and enqueues exactly one prediction-edit-prep message unless artifacts are already ready or a job is already Queued/InProgress +Given GetPredictionEditSession is called for a raw prediction GeoPackage +When the raw GeoPackage can be read +Then the response includes tilesReady, attrsReady, buildingCount, flavor, supportsThreshold, defaultThreshold, predictionTilesStatus, predictionTilesStatusMessage, and versions +And the GET does not enqueue work or run tippecanoe inline ``` ```gherkin @@ -96,13 +113,20 @@ When the prep worker validates the session inputs Then it fails the prep job and records a user-visible readiness error ``` -**UI Wireframe:** Preparation state with spinner, retry, and a short explanation -that full editor tiles are being generated. - -**Notes:** `GetBuildingFootprintsGeoJSON` is a random sample capped at 2,000 -features and must not be used for editing (`api/hastefuncapi/function_app.py:3626`, -`api/hastefuncapi/function_app.py:3645-3663`). `tippecanoe` is available only in -the training image (`docker/training/env/env.yml:11`). +**UI Wireframe:** Results page status note with spinner/retry while predicted +buildings are prepared; once ready, the same vector footprint layer is visible +in read-only and edit modes. + +**Notes:** `GetModelArtifact` streams `footprint_pmtiles` and `prediction_attrs` +through the API (`api/hastefuncapi/function_app.py:1400-1424`, +`api/hastefuncapi/function_app.py:1453-1458`). The visualizer artifact hook owns +loading, queueing, and polling (`ui/src/Components/Visualizer/usePredictionArtifacts.js:4-24`, +`ui/src/Components/Visualizer/usePredictionArtifacts.js:224-299`, +`ui/src/Components/Visualizer/usePredictionArtifacts.js:377-459`). The prep job +runs in the queue/training-image path because `tippecanoe` is not an HTTP-handler +concern (`hastelib/src/hastegeo/core/processors/prediction_tiles.py:13-19`, +`api/hastefuncqueues/function_app.py:861-914`, +`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:40-45`). --- @@ -114,35 +138,46 @@ the training image (`docker/training/env/env.yml:11`). **Priority:** P0 **Estimate:** L -**Component(s):** `ui/src/Components/PredictionEditor/`, `ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx`, `ui/src/Components/BuildingValidation/BuildingValidation.jsx` +**Component(s):** `ui/src/Components/Visualizer/PredictionEditPanel.jsx`, `ui/src/Components/Visualizer/usePredictionFootprints.js`, `ui/src/Components/Visualizer/predictionClassify.js`, `ui/src/Components/Visualizer/predictionFootprintMap.js` **Acceptance Criteria:** ```gherkin -Given the editor loaded a trained-inference model -When I move the threshold slider -Then footprint colors update live from the sidecar and the panel shows how many buildings would flip +Given edit mode loaded a trained-inference model +When I move the damage or unknown threshold slider +Then footprint colors update live from the sidecar and the panel shows how many buildings would change class ``` ```gherkin -Given the editor loaded an embedding model -When I view the right panel -Then no threshold slider is shown and I can still set explicit Damaged, NotDamaged, or Unknown overrides +Given edit mode loaded an embedding model +When I view the edit panel +Then no threshold slider is shown +And I can still set explicit Damaged, NotDamaged, or Unknown overrides ``` ```gherkin -Given visible footprints on the map +Given visible predicted footprints on the map When I click a building or ctrl+drag a selection box -Then selected buildings can be assigned Damaged, NotDamaged, or Unknown and the edited count updates +Then selected buildings can be assigned Damaged, NotDamaged, or Unknown +And the edited count updates ``` -**UI Wireframe:** Azure Maps canvas on the left, right panel with class filters, -counts, prev/next traversal, threshold controls when supported, version history, -and Save as new version. +```gherkin +Given the swipe map is visible +When I edit footprints on either side of the divider +Then feature-state coloring and selection stay mirrored between the two panes +``` + +**UI Wireframe:** Azure Maps swipe canvas underneath a right panel with class +counts, filters, prev/next traversal, threshold controls when supported, saved +version history, Save as new version, and Done editing. -**Notes:** Use PMTiles in-memory loading, feature-state coloring, and box-select -patterns from `InteractiveLabeler.jsx`; use filter/traversal patterns from -`BuildingValidation.jsx`. Use Fluent `makeStyles` and `tokens` for dark mode. +**Notes:** The edit panel lives in the Visualizer directory and is rendered only +when `isEditMode` is true (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:4-16`, +`ui/src/Components/Visualizer/Visualizer.jsx:873-921`). Map classification is +browser-side feature-state over PMTiles, so threshold moves do not need a server +round trip (`ui/src/Components/Visualizer/usePredictionFootprints.js:4-29`, +`ui/src/Components/Visualizer/predictionFootprintMap.js:4-18`). --- @@ -154,14 +189,16 @@ patterns from `InteractiveLabeler.jsx`; use filter/traversal patterns from **Priority:** P0 **Estimate:** L -**Component(s):** `api/hastefuncapi`, `hastelib/src/hastegeo/core/models/`, `hastelib/src/hastegeo/core/processors/`, Blob Storage +**Component(s):** `api/hastefuncapi`, `hastelib/src/hastegeo/core/models/`, `hastelib/src/hastegeo/core/processors/`, Blob Storage, `ui/src/Components/Visualizer/usePredictionFootprints.js` **Acceptance Criteria:** ```gherkin -Given a loaded prediction edit session and a set of overrides +Given a loaded prediction edit mode session and a set of overrides When I save with threshold 0.1 and unknownThreshold 0.0 -Then PutEditedPredictions returns version, gpkgUrl, and editedCount and the Model document appends one EditedPredictionVersion entry +Then PutEditedPredictions returns version, gpkgUrl, and editedCount +And the Model document appends one EditedPredictionVersion entry +And Model.gpkgUrl remains the raw prediction pointer ``` ```gherkin @@ -170,47 +207,114 @@ When an edited GeoPackage is written Then the edited file has N rows in the exact same order, preserves the source geometry, writes overture_id, edited_class, and edit_threshold, and sets damaged to 1 only for final_class Damaged ``` -**UI Wireframe:** Save button opens a confirmation state, then displays the new -version in the right panel history. +```gherkin +Given a save has succeeded +When the edit panel refreshes versions +Then the saved version appears in the history and the saved baseline becomes the new unsaved-edits baseline +``` + +**UI Wireframe:** Save button displays success/failure in the edit panel. The +saved version appears in the right-panel history; the rows are informational in +this branch. -**Notes:** Existing storage overwrites same-named artifacts, so the versioned -artifact name is the immutability boundary -(`hastelib/src/hastegeo/core/artifact_storage/azure_blob_artifact_storage.py:255`). +**Notes:** The save path builds the sparse `PutEditedPredictions` payload in the +visualizer hook (`ui/src/Components/Visualizer/usePredictionFootprints.js:838-887`). +The API appends metadata without touching `gpkgUrl` (`api/hastefuncapi/function_app.py:3181-3345`). The current implementation does not implement optimistic concurrency or a 409 conflict response; concurrent saves can collide and need a follow-up fix. --- -### US-005: List and Download Edited Versions +### US-005: Show Edited Version History Without Switching Versions in the UI **As an** External Partner, -**I want to** download a named edited prediction version, -**So that** I can consume the analyst-reviewed file while HASTE keeps raw outputs separate. +**I want to** identify saved edited prediction versions, +**So that** I can request or download the correct analyst-reviewed file while HASTE keeps raw outputs separate. **Priority:** P1 **Estimate:** M -**Component(s):** `api/hastefuncapi`, `ui/src/Components/PredictionEditor/`, `hastelib/src/hastegeo/core/models/` +**Component(s):** `api/hastefuncapi`, `ui/src/Components/Visualizer/PredictionEditPanel.jsx`, `hastelib/src/hastegeo/core/models/` **Acceptance Criteria:** ```gherkin Given a model with editedPredictions entries -When the UI calls GetEditedPredictionVersions -Then it receives the versions sorted by version number or creation time and can display the threshold, editor, edited count, and gpkgUrl for each version +When GetVisualizerResults or GetPredictionEditSession returns +Then the payload includes predictionVersions or versions sorted newest first +And the edit panel displays version, timestamp, threshold, editor, and edited count +``` + +```gherkin +Given I view the Saved versions list in edit mode +When I click or focus a version row +Then the row does not refetch the map or switch the served version in the current branch +And the active version badge only reports the version already on the map +``` + +```gherkin +Given I need a specific edited GeoPackage +When I call GetEditedPredictionVersions or inspect the visualizer payload +Then the gpkgUrl for each edited version is available while raw Model.gpkgUrl is unchanged +``` + +**UI Wireframe:** Version history list in the edit panel. The active version gets +an "On the map" badge; rows are read-only until a follow-up wires selection to a +`GetVisualizerResults?version=N` refetch. + +**Notes:** `PredictionEditPanel` renders history without an `onClick`/selection +handler (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:513-550`). The +visualizer fetch currently omits `version`, so UI version switching is not wired +(`ui/src/Components/Visualizer/Visualizer.jsx:213-223`). + +--- + +### US-006: Read Edited Versions in Results and Reports + +**As an** ML Engineer, +**I want to** use the same raw-or-edited prediction source selection in every reader, +**So that** visual results and validation/report metrics reflect saved analyst edits consistently where their data model allows it. + +**Priority:** P0 +**Estimate:** M +**Component(s):** `api/hastefuncapi`, `hastelib/src/hastegeo/core/utils/predictions.py`, `hastelib/src/hastegeo/core/processors/visualizer.py`, `docs/api/hastefuncapi.md` + +**Acceptance Criteria:** + +```gherkin +Given a model has editedPredictions versions 1 and 2 +When GetVisualizerResults, GetValidationReport, or GetAssessmentReport is called without version +Then the reader uses version 2 +``` + +```gherkin +Given a model has editedPredictions versions 1 and 2 +When a reader is called with version=0 +Then the reader uses raw Model.gpkgUrl +``` + +```gherkin +Given a model has editedPredictions versions 1 and 2 +When a reader is called with version=1 +Then the reader uses version 1 +And an unknown numeric version returns 404 while a malformed version returns 400 ``` ```gherkin -Given I download edit_v2 -When the browser requests the gpkgUrl -Then the downloaded file is the edited GeoPackage for version 2 and the raw Model.gpkgUrl is unchanged +Given an edited GeoPackage changes damaged but preserves damage_pct_0m +When GetValidationReport computes metrics +Then the explicit edits affect validation because it reads damaged +But GetAssessmentReport threshold-based counts continue to derive from damage_pct_0m until a follow-up resolves that product decision ``` -**UI Wireframe:** Version history list in the right panel. The API returns each -version's `gpkgUrl`; a dedicated one-click UI download action is a follow-up in -the current branch. +**UI Wireframe:** The results map displays the served version in the edit panel; +UI controls for switching versions remain a follow-up. -**Notes:** Assessment report, validation report, publishing, and visualizer use -of edited versions is out of scope for this feature. +**Notes:** `resolve_prediction_source` implements newest-wins, explicit version, +and `version=0` raw selection (`hastelib/src/hastegeo/core/utils/predictions.py:332-401`). +The three readers call it (`api/hastefuncapi/function_app.py:2386-2435`, +`api/hastefuncapi/function_app.py:4677-4688`, +`api/hastefuncapi/function_app.py:5017-5027`). The API docs capture the full +reader contract and the validation/assessment asymmetry (`docs/api/hastefuncapi.md:480-502`). --- @@ -232,38 +336,41 @@ Every user story must be assigned to one or more HASTE agents. The **implementin | Story | Implementing Agent(s) | Validating Agent(s) | Notes | |---|---|---|---| -| US-001 | `ui` | `ui-validation` | UI route and button gating only. | -| US-002 | `backend-dev`, `gis` | `backend-validation` | Queue/API ownership is backend; PMTiles, GeoPackage, CRS, and row-order checks require GIS review. | -| US-003 | `ui` | `ui-validation` | UI editor behavior; GIS should be consulted for class semantics but does not own UI code. | -| US-004 | `backend-dev`, `gis` | `backend-validation` | Version metadata plus GeoPackage read/write and row-order invariant. | -| US-005 | `backend-dev`, `ui` | `backend-validation`, `ui-validation` | API version list and UI download history. | +| US-001 | `ui`, `backend-dev` | `ui-validation`, `backend-validation` | UI entry point uses server-derived `predictionsReady`; no standalone route. | +| US-002 | `backend-dev`, `gis`, `ui` | `backend-validation`, `ui-validation` | Queue/API ownership is backend; PMTiles, GeoPackage, CRS, and row-order checks require GIS review; visualizer owns artifact loading. | +| US-003 | `ui` | `ui-validation` | UI edit-mode behavior; GIS should be consulted for class semantics but does not own UI code. | +| US-004 | `backend-dev`, `gis`, `ui` | `backend-validation`, `ui-validation` | Version metadata plus GeoPackage read/write and row-order invariant; UI save wiring. | +| US-005 | `backend-dev`, `ui` | `backend-validation`, `ui-validation` | API version list and read-only UI history; version switching remains a follow-up. | +| US-006 | `backend-dev`, `gis` | `backend-validation` | Raw-vs-edited source resolution across visualizer, validation, and assessment; assessment semantics need GIS/product follow-up. | ### Agent Workflow Per Phase | Phase | Lead Agent | Supporting Agents | Validation | |---|---|---|---| | Phase 1 — Data Model & Artifact Contract | `backend-dev` | `gis` | `backend-validation` | -| Phase 2 — Prep Workflow & API | `backend-dev` | `gis` | `backend-validation` | -| Phase 3 — UI Editor | `ui` | `gis` | `ui-validation` | +| Phase 2 — Prep Workflow, Readiness & API | `backend-dev` | `gis` | `backend-validation` | +| Phase 3 — Results Viewer Edit Mode | `ui` | `backend-dev`, `gis` | `ui-validation`, `backend-validation` | | Phase 4 — Integration | `backend-dev` | `ui`, `gis` | `backend-validation`, `ui-validation` | ## Story Map | Priority | Story | Phase | Implementing Agent | Component | |---|---|---|---|---| -| P0 | US-001 | Phase 3 — UI Editor | `ui` | `ui/src/Components/` | -| P0 | US-002 | Phase 2 — Prep Workflow & API | `backend-dev`, `gis` | `hastelib`, `hastefuncapi`, `hastefuncqueues` | -| P0 | US-003 | Phase 3 — UI Editor | `ui` | `ui/src/Components/PredictionEditor/` | -| P0 | US-004 | Phase 1/2 — Data Model & API | `backend-dev`, `gis` | `hastelib`, Blob Storage, `hastefuncapi` | -| P1 | US-005 | Phase 4 — Integration | `backend-dev`, `ui` | `hastefuncapi`, `ui/src/Components/` | +| P0 | US-001 | Phase 2/3 — Readiness & UI Entry | `backend-dev`, `ui` | model payloads, `ui/src/Components/ProjectManagement/`, `ui/src/Components/Visualizer/` | +| P0 | US-002 | Phase 2/3 — Prep Workflow & Vector Viewer | `backend-dev`, `gis`, `ui` | `hastelib`, `hastefuncapi`, `hastefuncqueues`, `ui/src/Components/Visualizer/` | +| P0 | US-003 | Phase 3 — Results Viewer Edit Mode | `ui` | `ui/src/Components/Visualizer/` | +| P0 | US-004 | Phase 1/2/3 — Data Model, API & UI Save | `backend-dev`, `gis`, `ui` | `hastelib`, Blob Storage, `hastefuncapi`, Visualizer hooks | +| P1 | US-005 | Phase 3/4 — Version History | `backend-dev`, `ui` | `hastefuncapi`, `ui/src/Components/Visualizer/` | +| P0 | US-006 | Phase 2/4 — Reader Integration | `backend-dev`, `gis` | `hastefuncapi`, `hastelib/src/hastegeo/core/utils/predictions.py` | ## Out of Scope Stories explicitly excluded from this feature: -- [ ] Use edited versions in assessment reports. -- [ ] Use edited versions in validation reports. - [ ] Publish edited versions through the data-publishing workflow. -- [ ] Show edited versions in the general visualizer. -- [ ] Add collaborative real-time editing, locking, or audit diff playback. +- [ ] Switch served prediction versions from the UI; history is read-only in the current branch. +- [ ] Add a dedicated one-click edited-version download button in the edit panel. +- [ ] Add collaborative real-time editing, locking, 409 conflict handling, or audit diff playback. - [ ] Introduce a generic artifact registry beyond the Model-level edited version list. +- [ ] Resolve the assessment-report asymmetry where edited `damaged` changes validation metrics but preserved `damage_pct_0m` drives threshold-based assessment counts. +- [ ] Fix the pre-existing positional-join risks in the classic prediction writer or add explicit producer-side `overture_id` columns. diff --git a/ui/src/Components/AppBody.jsx b/ui/src/Components/AppBody.jsx index 29e23b18..369f250b 100644 --- a/ui/src/Components/AppBody.jsx +++ b/ui/src/Components/AppBody.jsx @@ -12,7 +12,6 @@ import Home from "./Home"; import LabelingTool from "./LabelingTool/LabelingTool"; import BuildingValidation from "./BuildingValidation/BuildingValidation"; import InteractiveLabeler from "./InteractiveLabeler/InteractiveLabeler"; -import PredictionEditor from "./PredictionEditor/PredictionEditor"; import Visualizer from "./Visualizer/Visualizer"; import ModelCatalog from "./ModelCatalog"; import PublishedDatasets from "./PublishedDatasets"; @@ -71,10 +70,6 @@ const AppBody = ({ setModalComponent }) => { path="/interactive-label/:projectId/:imageLayerId/:modelId" element={} /> - } - /> } diff --git a/ui/src/Components/PredictionEditor/PredictionEditor.jsx b/ui/src/Components/PredictionEditor/PredictionEditor.jsx deleted file mode 100644 index afa2cef5..00000000 --- a/ui/src/Components/PredictionEditor/PredictionEditor.jsx +++ /dev/null @@ -1,2222 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -// -// Prediction Editor — review and edit a model's building-damage predictions, -// then save the result as a new version. -// -// Footprints stream from the model's PMTiles archive (kind=footprint_pmtiles) -// so the editor never downloads every polygon up front. The per-building -// scores come from a small JSON sidecar (kind=prediction_attrs) that is held -// in a ref; each building's class is derived in the browser from those scores -// plus the current thresholds, with any user edit taking precedence -// (predictionClassify.js owns that logic and is unit-tested). Colouring is -// applied as feature-state on the internal Mapbox-GL map keyed by the integer -// feature id, which is why moving the threshold slider recolours instantly -// with no server round-trip. -// -// Saving PUTs the thresholds plus the sparse override list to -// PutEditedPredictions, which writes a brand-new version — nothing is -// destructive. -// -// The optional swipe view (see the swipe effect near the bottom) puts a -// second Azure Maps instance behind this one and hands both to -// atlas.SwipeMap so the analyst can compare imagery while reclassifying: -// pre-event vs post-event when the layer has pre-event tiles, basemap vs -// post-event when it does not. Both panes draw the same footprints, share -// every feature-state write, and accept the same edit gestures — SwipeMap -// clips the editor map, so clicks on the uncovered side land on the other -// map and would otherwise do nothing. -// -// Both artifacts are produced by a queued job, so a model nobody has opened -// before arrives here unprepared. The editor enqueues that job itself -// (PutPreparePredictionTilesQueueMessage) and then polls the session until -// the artifacts exist, rather than telling the user to come back later — see -// the preparation effect below. The decisions behind that wait live in -// predictionPrep.js so they are unit-testable. -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; -import { - Button, - MessageBar, - MessageBarBody, - MessageBarTitle, - ProgressBar, - Spinner, - Text, - makeStyles, - tokens, -} from "@fluentui/react-components"; -import { PMTiles } from "pmtiles"; -import { FluentIcon } from "../../util/icons"; -import { apiGet, apiPut, buildUrl } from "../../util/api"; -import { toBrowserTitilerUrl } from "../../util/blobUrl"; -import { loadImagery } from "../LabelingTool/LabelingToolHelper"; -import { - getPmtilesProtocol, - InMemoryPMTilesSource, - fetchArtifactBuffer, -} from "../../util/pmtiles.js"; -import { - getAzureMapsAuthOptions, - isAzureMapsPlaceholder, -} from "../../util/azureMapsAuth"; -import { AppContext } from "../../AppContext.jsx"; -import { useTheme } from "../../util/ThemeContext.jsx"; -import { shouldIgnoreShortcut } from "../keyboardShortcuts.js"; -import PredictionEditorRightPanel from "./PredictionEditorRightPanel.jsx"; -import { - CLASS_DAMAGED, - CLASS_NOT_DAMAGED, - CLASS_UNKNOWN, - FILTER_ALL, - buildSavePayload, - classifyAll, - clearOverride, - countClassChanges, - cycleClass, - filterIndices, - indexById, - matchesFilter, - nextIndexInList, - normalizeAttrs, - setOverrideEntries, - setOverrides, -} from "./predictionClassify.js"; -import { - MAX_PREP_POLL_ATTEMPTS, - PREP_PHASE_FAILED, - PREP_PHASE_REQUESTING, - PREP_PHASE_TIMED_OUT, - PREP_POLL_INTERVAL_MS, - applyPrepResponse, - buildPrepRequest, - describeOutstandingArtifacts, - evaluatePrepState, - isPrepReady, - nextPollAttempt, - prepStateAfterPollError, - prepStatusLabel, - shouldPollPrep, -} from "./predictionPrep.js"; -import { - dividerPositionForKey, - isSwipeAvailable, - resolveSwipeMode, - swipeComparisonTileUrl, - swipeLeftPaneLabel, - swipeRightPaneLabel, -} from "./predictionSwipe.js"; -import "../../assets/css/drawingToolbar.css"; - -// Tippecanoe writes the buildings layer with `-l buildings`; every feature -// carries the integer `id` used for feature-state. -const PMTILES_SOURCE_LAYER = "buildings"; -const SOURCE_ID = "predictionBuildings"; -const FILL_LAYER_ID = "predictionFill"; -const LINE_LAYER_ID = "predictionOutline"; - -// The swipe comparison map is a second Azure Maps instance with its own -// renderer, so it declares its own copy of the same PMTiles archive. Ids are -// distinct from the editor map's purely for clarity in the debugger — the two -// styles never meet. -const SWIPE_SOURCE_ID = "predictionSwipeBuildings"; -const SWIPE_FILL_LAYER_ID = "predictionSwipeFill"; -const SWIPE_LINE_LAYER_ID = "predictionSwipeOutline"; - -// Paint expressions compare numbers, so each class gets a code. -const CLASS_CODES = { - [CLASS_DAMAGED]: 1, - [CLASS_NOT_DAMAGED]: 2, - [CLASS_UNKNOWN]: 3, -}; - -// The map's colours come from the active Fluent theme rather than a hardcoded -// palette: `tokens.x` is the string "var(--x)", which the renderer cannot -// parse, so we resolve the custom property against a live element inside the -// FluentProvider subtree and hand the renderer the concrete value. Switching -// light/dark (or the brand palette) re-resolves them — see the theme effect. -const MAP_COLOR_TOKENS = { - damaged: tokens.colorStatusDangerBackground3, - notDamaged: tokens.colorStatusSuccessBackground3, - unknown: tokens.colorNeutralForeground3, - pending: tokens.colorNeutralBackground5, - outline: tokens.colorNeutralStrokeAccessible, - edited: tokens.colorBrandStroke1, - selected: tokens.colorNeutralForeground1, -}; - -// Last-resort values, used only if a custom property cannot be resolved (the -// renderer needs *some* parseable colour or the layer fails to paint). Named -// CSS colours, deliberately not theme-specific hex codes. -const FALLBACK_COLORS = { - damaged: "firebrick", - notDamaged: "seagreen", - unknown: "dimgray", - pending: "lightgray", - outline: "steelblue", - edited: "royalblue", - selected: "white", -}; - -function resolveThemeColors(element) { - const style = element ? window.getComputedStyle(element) : null; - const colors = {}; - for (const [key, tokenValue] of Object.entries(MAP_COLOR_TOKENS)) { - const match = /var\((--[^,)]+)/.exec(String(tokenValue)); - const resolved = - match && style ? style.getPropertyValue(match[1]).trim() : ""; - colors[key] = resolved || FALLBACK_COLORS[key]; - } - return colors; -} - -function fillColorExpression(colors) { - return [ - "case", - ["==", ["feature-state", "cls"], CLASS_CODES[CLASS_DAMAGED]], - colors.damaged, - ["==", ["feature-state", "cls"], CLASS_CODES[CLASS_NOT_DAMAGED]], - colors.notDamaged, - ["==", ["feature-state", "cls"], CLASS_CODES[CLASS_UNKNOWN]], - colors.unknown, - colors.pending, - ]; -} - -// Buildings filtered out stay on screen as context, but faint. -const FILL_OPACITY_EXPRESSION = [ - "case", - ["==", ["feature-state", "dim"], true], - 0.1, - 0.55, -]; - -function strokeColorExpression(colors) { - return [ - "case", - ["==", ["feature-state", "selected"], true], - colors.selected, - ["==", ["feature-state", "edited"], true], - colors.edited, - colors.outline, - ]; -} - -const STROKE_WIDTH_EXPRESSION = [ - "case", - ["==", ["feature-state", "selected"], true], - 4, - ["==", ["feature-state", "edited"], true], - 2.5, - 1, -]; - -// atlas.Map has no public setFeatureState; the renderer underneath (a -// Mapbox-GL fork) does. Same duck-typed scan the Interactive Labeler uses. -function findGlMap(atlasMap) { - const direct = [atlasMap.map, atlasMap._map, atlasMap.gl, atlasMap._gl]; - for (const candidate of direct) { - if (candidate && typeof candidate.setFeatureState === "function") { - return candidate; - } - } - for (const key of Object.keys(atlasMap)) { - const value = atlasMap[key]; - if (value && typeof value === "object" && typeof value.setFeatureState === "function") { - return value; - } - } - return null; -} - -// Azure Maps renames our source/layer inside the renderer's style, so the ids -// queryRenderedFeatures needs have to be discovered rather than assumed. Used -// for the editor map and, identically, for the swipe comparison map. -function discoverFillLayerIds(glMap, fallbackLayerIds) { - if (!glMap || typeof glMap.getStyle !== "function") return fallbackLayerIds; - try { - const style = glMap.getStyle(); - const sourceIds = Object.keys(style.sources || {}); - const ours = [...fallbackLayerIds, ...sourceIds]; - const discovered = (style.layers || []) - .filter( - (layer) => - layer.type === "fill" && - (ours.includes(layer.source) || /predict|build/i.test(layer.id)) - ) - .map((layer) => layer.id); - return discovered.length > 0 ? discovered : fallbackLayerIds; - } catch (error) { - console.warn("glMap.getStyle() failed:", error); - return fallbackLayerIds; - } -} - -// The name the renderer gave our vector source, which is the id every -// setFeatureState call has to use. The editor map learns this from its first -// rendered feature; the comparison map has no such feature yet when its -// layers are built, so it reads the style instead. -function discoverVectorSourceId(glMap, preferredId) { - if (!glMap || typeof glMap.getStyle !== "function") return preferredId; - try { - const sources = glMap.getStyle().sources || {}; - if (sources[preferredId]) return preferredId; - const match = Object.keys(sources).find( - (id) => sources[id]?.type === "vector" && /predict|build/i.test(id) - ); - return match || preferredId; - } catch (error) { - console.warn("glMap.getStyle() failed:", error); - return preferredId; - } -} - -// Average of the first ring's vertices — good enough to centre the camera on -// a building, and far cheaper than a real centroid. -function featureCentroid(geometry) { - if (!geometry) return null; - const ring = - geometry.type === "Polygon" - ? geometry.coordinates?.[0] - : geometry.type === "MultiPolygon" - ? geometry.coordinates?.[0]?.[0] - : null; - if (!Array.isArray(ring) || ring.length === 0) return null; - let lng = 0; - let lat = 0; - for (const position of ring) { - lng += position[0]; - lat += position[1]; - } - return [lng / ring.length, lat / ring.length]; -} - -const useStyles = makeStyles({ - root: { - display: "flex", - flexGrow: 1, - minHeight: 0, - position: "relative", - isolation: "isolate", - overflow: "hidden", - color: tokens.colorNeutralForeground1, - backgroundColor: tokens.colorNeutralBackground2, - }, - // Both map panes are absolutely positioned inside this wrapper and fill it - // exactly: the swipe comparison map (SwipeMap PRIMARY) sits behind, the - // editor map (SECONDARY, clipped to the right of the divider) on top. The - // wrapper is also the positioning context for the box-select rectangle and - // the pane badges, so their pixel offsets match the map canvases. - mapArea: { - position: "relative", - flexGrow: 1, - minHeight: 0, - }, - mapPane: { - position: "absolute", - top: 0, - right: 0, - bottom: 0, - left: 0, - }, - mapPaneHidden: { - display: "none", - }, - mapBadge: { - position: "absolute", - top: "10px", - zIndex: 900, - padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`, - borderRadius: tokens.borderRadiusMedium, - color: tokens.colorNeutralForeground1, - backgroundColor: tokens.colorNeutralBackground1, - border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, - boxShadow: tokens.shadow4, - fontSize: tokens.fontSizeBase200, - fontWeight: tokens.fontWeightSemibold, - whiteSpace: "nowrap", - // Never intercept a divider drag or a footprint click. - pointerEvents: "none", - }, - // The Back button control (10px inset, 104px wide, 4px padding) owns the - // top-left corner, so the left badge starts clear of it. - mapBadgeLeft: { - left: "132px", - }, - mapBadgeRight: { - right: "calc(clamp(300px, 25vw, 360px) + 20px)", - "@media (max-width: 700px)": { - right: "10px", - }, - }, - messageCard: { - position: "absolute", - top: "50%", - left: "50%", - transform: "translate(-50%, -50%)", - zIndex: 1000, - boxSizing: "border-box", - width: "min(520px, calc(100% - 32px))", - padding: tokens.spacingHorizontalXXL, - display: "flex", - flexDirection: "column", - gap: tokens.spacingVerticalS, - textAlign: "center", - alignItems: "center", - color: tokens.colorNeutralForeground1, - backgroundColor: tokens.colorNeutralBackground1, - border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, - borderRadius: tokens.borderRadiusMedium, - boxShadow: tokens.shadow16, - }, - messageBody: { - color: tokens.colorNeutralForeground2, - fontSize: tokens.fontSizeBase300, - lineHeight: tokens.lineHeightBase300, - }, - messageDetail: { - color: tokens.colorNeutralForeground3, - fontSize: tokens.fontSizeBase200, - lineHeight: tokens.lineHeightBase200, - wordBreak: "break-word", - }, - // Preparation card: status line, indeterminate progress, and actions. All - // colours come from Fluent tokens so the card is readable in either theme. - messageActions: { - marginTop: tokens.spacingVerticalS, - display: "flex", - flexWrap: "wrap", - justifyContent: "center", - gap: tokens.spacingHorizontalS, - }, - messageBar: { - width: "100%", - textAlign: "left", - }, - prepProgress: { - width: "100%", - }, - prepStatusRow: { - display: "flex", - flexWrap: "wrap", - alignItems: "center", - justifyContent: "center", - gap: tokens.spacingHorizontalXS, - color: tokens.colorNeutralForeground2, - fontSize: tokens.fontSizeBase300, - lineHeight: tokens.lineHeightBase300, - }, - prepStatusValue: { - padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`, - borderRadius: tokens.borderRadiusCircular, - color: tokens.colorNeutralForeground1, - backgroundColor: tokens.colorNeutralBackground4, - fontWeight: tokens.fontWeightSemibold, - }, - legend: { - position: "absolute", - right: "calc(clamp(300px, 25vw, 360px) + 20px)", - bottom: "10px", - zIndex: 900, - padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalS}`, - borderRadius: tokens.borderRadiusMedium, - color: tokens.colorNeutralForeground1, - backgroundColor: tokens.colorNeutralBackground1, - border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, - boxShadow: tokens.shadow8, - fontSize: tokens.fontSizeBase100, - lineHeight: tokens.lineHeightBase200, - pointerEvents: "none", - "@media (max-width: 700px)": { - right: "8px", - bottom: "calc(55% + 18px)", - }, - }, - legendRow: { - display: "flex", - alignItems: "center", - gap: tokens.spacingHorizontalXS, - }, - legendSwatch: { - width: "12px", - height: "12px", - borderRadius: tokens.borderRadiusSmall, - border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, - }, - // Legend swatches read the same tokens the map palette resolves at - // runtime, so the two can never drift apart. - legendDamaged: { - backgroundColor: tokens.colorStatusDangerBackground3, - }, - legendNotDamaged: { - backgroundColor: tokens.colorStatusSuccessBackground3, - }, - legendUnknown: { - backgroundColor: tokens.colorNeutralForeground3, - }, - legendTitle: { - marginBottom: tokens.spacingVerticalXXS, - fontWeight: tokens.fontWeightSemibold, - }, - selectBox: { - position: "absolute", - display: "none", - zIndex: 900, - pointerEvents: "none", - border: `${tokens.strokeWidthThick} dashed ${tokens.colorBrandStroke1}`, - backgroundColor: tokens.colorBrandBackground2, - opacity: 0.4, - }, - mapHint: { - position: "absolute", - bottom: "6px", - left: "50%", - transform: "translateX(-50%)", - zIndex: 900, - padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalM}`, - borderRadius: tokens.borderRadiusMedium, - color: tokens.colorNeutralForeground2, - backgroundColor: tokens.colorNeutralBackground1, - border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, - boxShadow: tokens.shadow4, - fontSize: tokens.fontSizeBase100, - whiteSpace: "nowrap", - pointerEvents: "none", - "@media (max-width: 700px)": { - display: "none", - }, - }, -}); - -// Load phases rendered explicitly, so a not-yet-built artifact shows an -// explanation instead of an empty map. PHASE_PREPARING covers the whole -// enqueue-and-wait cycle; the detail inside it (queued / running / failed / -// gave up) lives in `prepState`. -const PHASE_LOADING = "loading"; -const PHASE_READY = "ready"; -const PHASE_PREPARING = "preparing"; -const PHASE_EMPTY = "empty"; -const PHASE_ERROR = "error"; - -const PredictionEditor = () => { - const styles = useStyles(); - const { projectId, imageLayerId, modelId } = useParams(); - const navigate = useNavigate(); - const { setIsLoading, setDialog } = useContext(AppContext); - const { isDark, palette } = useTheme(); - - // ── Refs ────────────────────────────────────────────────────────────────── - const rootRef = useRef(null); - const mapContainerRef = useRef(null); - const mapRef = useRef(null); - const glMapRef = useRef(null); - const fillLayerRef = useRef(null); - const lineLayerRef = useRef(null); - const internalLayerIdsRef = useRef([]); - // The prediction sidecar, held in a ref: the arrays never change after load - // and can be large, so they stay out of React state. `attrsVersion` below - // is what tells the render tree they arrived. - const attrsRef = useRef(null); - const indexByIdRef = useRef(new Map()); - // The renderer renames our source internally; the first rendered feature - // tells us what it actually calls it, which is the id feature-state writes - // must use for buildings that are not part of a query result. - const primarySourceIdRef = useRef(SOURCE_ID); - const hydrateTimerRef = useRef(null); - // Set by Prev/Next only: clicking a footprint should not yank the camera. - const pendingPanRef = useRef(false); - // Mirrors of state that long-lived map handlers read (a handler registered - // in the map's "ready" callback closes over the first render's values). - const classesRef = useRef([]); - const editedRef = useRef([]); - const overridesRef = useRef({}); - const filterRef = useRef(FILTER_ALL); - const clickActionRef = useRef("cycle"); - const colorsRef = useRef(FALLBACK_COLORS); - // id -> [lng, lat], harvested from rendered footprints. The sidecar carries - // no geometry, so this is the only way Prev/Next knows where to pan. - const centroidsRef = useRef(new Map()); - const selectedIdRef = useRef(null); - const boxRef = useRef(null); - const boxCleanupRef = useRef(null); - // ── Swipe comparison map ────────────────────────────────────────────────── - // atlas.SwipeMap reveals its SECONDARY on the RIGHT of the divider and its - // PRIMARY on the LEFT, so the comparison map (pre-event imagery, or just - // the basemap) is built as the PRIMARY and the editor map is adopted as the - // SECONDARY. mapAreaRef is the wrapper both panes fill — its width is what - // the A/S/D divider shortcuts measure against. - const mapAreaRef = useRef(null); - const swipeMapContainerRef = useRef(null); - const swipeMapRef = useRef(null); - // The comparison map's own renderer: feature-state and paint are - // per-renderer, so every write aimed at the editor map is mirrored here or - // the left pane draws every footprint in the "pending" colour. - const swipeGlMapRef = useRef(null); - const swipeControlRef = useRef(null); - const swipeFillLayerRef = useRef(null); - const swipeLineLayerRef = useRef(null); - const swipeSourceIdRef = useRef(SWIPE_SOURCE_ID); - const swipeLayerIdsRef = useRef([SWIPE_FILL_LAYER_ID]); - const swipeBoxCleanupRef = useRef(null); - // The layer's imagery URLs (GetLayerLabelingToolData) and the PMTiles - // archive URL, cached so the comparison map can draw the same imagery and - // the same footprints without refetching anything. - const imageryRef = useRef(null); - const archiveUrlRef = useRef(""); - // Guards every setState that happens after an await, so nothing writes to a - // torn-down component (and, with it, no timer outlives the editor). - const mountedRef = useRef(true); - // Incremented every time the route params change. Async work captures the id it - // started under and drops its results if a newer run has taken over, so a - // fast model switch cannot have the old model's session clobber the new - // one's (the component stays mounted across that switch, so mountedRef - // alone would not catch it). - const initRunRef = useRef(0); - // Latest session, for the async prep helpers: they run outside the render - // that produced `session` and must not merge into a stale copy. - const sessionRef = useRef(null); - - // ── State ───────────────────────────────────────────────────────────────── - const [phase, setPhase] = useState(PHASE_LOADING); - const [errorMessage, setErrorMessage] = useState(""); - const [session, setSession] = useState(null); - const [attrsVersion, setAttrsVersion] = useState(0); - // Azure Maps builds its source/layers inside the async "ready" handler, - // which fires AFTER createMap() resolves. Mirroring readiness in state (and - // depending on it below) is what makes the styling effects re-run once the - // layers actually exist — the refs alone never trigger a render. - const [isSourceReady, setIsSourceReady] = useState(false); - // Same rule for the swipe comparison map: its layers exist only once its - // own async "ready" has fired, so the paint effect depends on this flag - // rather than on swipeMapRef.current. - const [isSwipeReady, setIsSwipeReady] = useState(false); - // The layer's imagery block, in state because the render tree decides from - // it whether to offer a swipe (and which comparison to name). - const [imagery, setImagery] = useState(null); - // Swipe defaults OFF. The editor's bread-and-butter gesture is a wide - // ctrl+drag box-select over the whole map, and a second Azure Maps instance - // plus a second PMTiles renderer is not free — so the analyst opts in when - // they actually want to compare imagery. (Editing is wired to BOTH panes - // regardless, so turning it on never makes half the map inert, which is the - // regression the Interactive Labeler hit by defaulting swipe on.) - const [swipeOn, setSwipeOn] = useState(false); - - const [threshold, setThreshold] = useState(0.5); - const [unknownThreshold, setUnknownThreshold] = useState(0); - // What the current thresholds are compared against for the "N buildings - // would change class" readout: the model default at first, then whatever - // was last saved. - const [baseline, setBaseline] = useState({ - threshold: 0.5, - unknownThreshold: 0, - }); - const [overrides, setOverridesState] = useState({}); - const [classification, setClassification] = useState(null); - const [changeCount, setChangeCount] = useState(0); - const [filter, setFilter] = useState(FILTER_ALL); - const [selectedIndex, setSelectedIndex] = useState(-1); - const [clickAction, setClickAction] = useState("cycle"); - - const [versions, setVersions] = useState([]); - const [isSaving, setIsSaving] = useState(false); - const [saveError, setSaveError] = useState(""); - const [savedResult, setSavedResult] = useState(null); - - // Preparation wait-state, shaped by predictionPrep.js: - // { phase, status, statusMessage, attempt, error }. Null once the artifacts - // are in hand (or when they were ready from the start). - const [prepState, setPrepState] = useState(null); - // Bumped to hand the artifact + map load to its own effect. Doing the load - // in an effect rather than inline guarantees React has already committed - // the render that mounts the map container, so mapContainerRef.current is - // a real element — the editor may reach this point from the preparing card, - // where the container was not in the DOM at all. - const [loadToken, setLoadToken] = useState(0); - - // Which comparison the swipe offers, derived from the imagery the layer - // actually has (pure, unit-tested in predictionSwipe.js). Pre-vs-post is - // never offered without pre-event tiles, and no swipe at all is offered - // without post-event tiles — there would be nothing to compare. - const swipeMode = useMemo(() => resolveSwipeMode(imagery), [imagery]); - const swipeAvailable = isSwipeAvailable(swipeMode); - // The comparison pane is only really up once its map has finished loading. - const isSwipeActive = swipeOn && swipeAvailable; - - // ── Ref mirrors ─────────────────────────────────────────────────────────── - useEffect(() => { - overridesRef.current = overrides; - }, [overrides]); - useEffect(() => { - filterRef.current = filter; - }, [filter]); - useEffect(() => { - clickActionRef.current = clickAction; - }, [clickAction]); - useEffect(() => { - sessionRef.current = session; - }, [session]); - - // Mount flag. Set in an effect (not just at ref creation) so a remount — - // React StrictMode double-invokes effects in development — flips it back on. - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); - - // The session URL is stable for a route; both the initial load and every - // poll go through it. - const sessionEndpoint = useMemo( - () => - `GetPredictionEditSession?projectId=${encodeURIComponent(projectId)}` + - `&imageLayerId=${encodeURIComponent(imageLayerId)}` + - `&modelId=${encodeURIComponent(modelId)}`, - [projectId, imageLayerId, modelId] - ); - - // Adopt a freshly fetched session: keep the version history in sync and - // hand the object to the prep helpers through the ref. - const adoptSession = useCallback((editSession) => { - sessionRef.current = editSession; - setSession(editSession); - if (Array.isArray(editSession?.versions)) setVersions(editSession.versions); - }, []); - - // True when the component is gone, or when a newer route run has taken - // over. Every async continuation checks this before touching state. - const isStale = useCallback( - (runId) => !mountedRef.current || runId !== initRunRef.current, - [] - ); - - // Artifacts exist: show the map container (PHASE_LOADING) and let the load - // effect do the fetching, one committed render later. - const startArtifactLoad = useCallback(() => { - setPrepState(null); - setPhase(PHASE_LOADING); - setLoadToken((token) => token + 1); - }, []); - - // ── Preparation ─────────────────────────────────────────────────────────── - // Enqueue the job that builds the PMTiles archive and the score sidecar. - // Called once on open when the artifacts are missing, and again (with - // force) from the Retry action after a terminal failure. Without this the - // editor would just sit on "still being prepared" forever, because nothing - // else in the app ever queues that job. - const requestPreparation = useCallback( - async (force = false) => { - const runId = initRunRef.current; - setPhase(PHASE_PREPARING); - setPrepState({ - phase: PREP_PHASE_REQUESTING, - status: "", - statusMessage: "", - attempt: 0, - error: "", - }); - try { - const response = await apiPut( - "PutPreparePredictionTilesQueueMessage", - buildPrepRequest({ projectId, imageLayerId, modelId, force }) - ); - if (isStale(runId)) return; - // The response carries the same readiness flags as the session, so a - // job that had already finished opens the editor immediately instead - // of waiting out a poll interval. - const merged = applyPrepResponse(sessionRef.current, response); - adoptSession(merged); - const decision = evaluatePrepState(merged, 0, MAX_PREP_POLL_ATTEMPTS); - if (decision.ready) { - startArtifactLoad(); - return; - } - setPrepState(decision); - } catch (error) { - if (isStale(runId)) return; - console.error("Could not queue prediction tile preparation:", error); - setPrepState({ - phase: PREP_PHASE_FAILED, - status: "", - statusMessage: "", - attempt: 0, - error: - error?.message || - "The preparation job could not be queued. Try again.", - }); - } - }, - [projectId, imageLayerId, modelId, adoptSession, startArtifactLoad, isStale] - ); - - // ── Load: session -> (prepare) -> attributes -> map ─────────────────────── - useEffect(() => { - let cancelled = false; - const runId = initRunRef.current + 1; - initRunRef.current = runId; - - const init = async () => { - setIsLoading(true, "Loading Prediction Editor"); - // Route params can change without remounting; start from a clean slate. - setPhase(PHASE_LOADING); - setPrepState(null); - setIsSourceReady(false); - setImagery(null); - setOverridesState({}); - setClassification(null); - setSelectedIndex(-1); - setSavedResult(null); - setSaveError(""); - setVersions([]); - attrsRef.current = null; - centroidsRef.current = new Map(); - selectedIdRef.current = null; - try { - const editSession = await apiGet(sessionEndpoint); - if (cancelled || isStale(runId)) return; - adoptSession(editSession); - - // Start from the model's own operating point so the first paint - // matches what the rest of the app already shows for this model. - const startThreshold = - typeof editSession?.defaultThreshold === "number" && - Number.isFinite(editSession.defaultThreshold) - ? editSession.defaultThreshold - : 0.5; - setThreshold(startThreshold); - setUnknownThreshold(0); - setBaseline({ threshold: startThreshold, unknownThreshold: 0 }); - - if (!(Number(editSession?.buildingCount) > 0)) { - setPhase(PHASE_EMPTY); - return; - } - if (isPrepReady(editSession)) { - startArtifactLoad(); - return; - } - // Nothing else queues this job, so the editor does it — once, without - // force, then waits on the poll effect below. - await requestPreparation(false); - } catch (error) { - if (cancelled || isStale(runId)) return; - console.error("Error initializing the prediction editor:", error); - setErrorMessage( - error?.message || "The prediction editor could not be loaded." - ); - setPhase(PHASE_ERROR); - } finally { - // Release the app-wide spinner unless a newer run has taken it over - // (that run turns it off itself). Safe after unmount: the flag lives - // in AppContext, above this component, so an editor torn down - // mid-load cannot leave the whole app behind an overlay. - if (runId === initRunRef.current) setIsLoading(false); - } - }; - - init(); - - return () => { - cancelled = true; - teardownMap(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId, imageLayerId, modelId]); - - // Poll the session while the prep job runs, and open the editor the moment - // both artifacts land — no page reload. - // - // Each pass schedules exactly ONE timeout and then re-runs off the state it - // wrote, so there is never more than one timer in flight and never two - // overlapping requests. The cleanup clears that timer, which is what stops - // polling on unmount and on a route change; `mountedRef` covers the request - // that is already in the air when the component goes away. - useEffect(() => { - if (!shouldPollPrep(prepState?.phase)) return undefined; - let cancelled = false; - const timer = window.setTimeout(async () => { - const runId = initRunRef.current; - try { - const editSession = await apiGet(sessionEndpoint); - if (cancelled || isStale(runId)) return; - adoptSession(editSession); - const decision = evaluatePrepState( - editSession, - nextPollAttempt(prepState.attempt), - MAX_PREP_POLL_ATTEMPTS - ); - if (decision.ready) { - startArtifactLoad(); - return; - } - setPrepState(decision); - } catch (error) { - if (cancelled || isStale(runId)) return; - // A blip in the API must not abandon a healthy job; keep the last - // known status and count the attempt against the cap. - setPrepState((previous) => - prepStateAfterPollError( - previous, - error?.message, - MAX_PREP_POLL_ATTEMPTS - ) - ); - } - }, PREP_POLL_INTERVAL_MS); - - return () => { - cancelled = true; - window.clearTimeout(timer); - }; - }, [prepState, sessionEndpoint, adoptSession, startArtifactLoad, isStale]); - - // ── Load the artifacts and build the map ────────────────────────────────── - // Runs only when startArtifactLoad() bumps the token, i.e. after a render - // in which the map container is mounted. - useEffect(() => { - if (!loadToken) return undefined; - let cancelled = false; - const runId = initRunRef.current; - - const load = async () => { - setIsLoading(true, "Loading predictions"); - setIsSourceReady(false); - try { - const attrs = await loadAttributes(); - if (cancelled || isStale(runId)) return; - attrsRef.current = attrs; - indexByIdRef.current = indexById(attrs); - setAttrsVersion((version) => version + 1); - - if (!window.atlas) { - throw new Error( - "The Azure Maps control did not load, so footprints cannot be shown." - ); - } - await createMap(); - if (cancelled || isStale(runId)) { - // The editor went away (or moved to another model) while the - // archive was downloading; the map this call just built would - // otherwise never be disposed. - teardownMap(); - return; - } - // Publishing the imagery block is what lets the panel decide whether - // to offer a swipe, and which comparison to name. - setImagery(imageryRef.current); - setPhase(PHASE_READY); - } catch (error) { - if (cancelled || isStale(runId)) return; - console.error("Error initializing the prediction editor:", error); - setErrorMessage( - error?.message || "The prediction editor could not be loaded." - ); - setPhase(PHASE_ERROR); - } finally { - // Same rule as the init effect: whoever still owns the run clears the - // app-wide spinner. - if (runId === initRunRef.current) setIsLoading(false); - } - }; - - load(); - - return () => { - cancelled = true; - teardownMap(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loadToken]); - - // Single owner of map teardown, called from both load effects' cleanups: - // whichever runs first nulls the refs, so a double call is a no-op. - function teardownMap() { - // Read at teardown on purpose: the box-select listeners are registered - // well after the effect that owns them runs. - if (boxCleanupRef.current) { - boxCleanupRef.current(); - boxCleanupRef.current = null; - } - if (hydrateTimerRef.current) { - clearTimeout(hydrateTimerRef.current); - hydrateTimerRef.current = null; - } - if (mapRef.current) { - mapRef.current.dispose(); - mapRef.current = null; - } - glMapRef.current = null; - fillLayerRef.current = null; - lineLayerRef.current = null; - } - - async function loadAttributes() { - // Streamed through the same-origin API proxy (managed identity server - // side) so remote analysts behind the storage firewall can read it. - const url = buildUrl( - `GetModelArtifact?projectId=${encodeURIComponent(projectId)}` + - `&modelId=${encodeURIComponent(modelId)}&kind=prediction_attrs` - ); - const response = await fetch(url); - if (!response.ok) { - throw new Error( - `Failed to load prediction attributes (HTTP ${response.status}).` - ); - } - const attrs = normalizeAttrs(await response.json()); - if (attrs.n === 0) { - throw new Error("The prediction attributes file contains no buildings."); - } - return attrs; - } - - async function createMap() { - const protocol = getPmtilesProtocol(); - const archiveUrl = buildUrl( - `GetModelArtifact?projectId=${encodeURIComponent(projectId)}` + - `&modelId=${encodeURIComponent(modelId)}&kind=footprint_pmtiles` - ); - // Cached so the swipe comparison map can point a source at the very same - // `pmtiles://` key and reuse the archive already in memory. - archiveUrlRef.current = archiveUrl; - - // The layer's imagery, so the editor draws footprints over the post-event - // scene the model actually scored (rather than a generic basemap) and the - // swipe view knows whether pre-event tiles exist. Imagery is optional: - // a failure here must not stop the editor from opening. - let layerData = null; - try { - layerData = await apiGet( - `GetLayerLabelingToolData?projectId=${encodeURIComponent(projectId)}` + - `&imageLayerId=${encodeURIComponent(imageLayerId)}` - ); - } catch (error) { - console.warn("Could not fetch layer imagery:", error); - } - imageryRef.current = layerData?.imagery || null; - - // Download the whole archive once and serve pmtiles.js from memory: the - // SWA /api proxy in front of the function app does not honour range - // requests. - const buffer = await fetchArtifactBuffer(archiveUrl); - const archive = new PMTiles(new InMemoryPMTilesSource(archiveUrl, buffer)); - if (protocol) protocol.add(archive); - const header = await archive.getHeader(); - - let initialCamera = { center: [0, 0], zoom: 3 }; - if (header) { - const hasCenter = header.centerLon != null && header.centerLat != null; - const centerLon = hasCenter - ? header.centerLon - : (header.minLon + header.maxLon) / 2; - const centerLat = hasCenter - ? header.centerLat - : (header.minLat + header.maxLat) / 2; - initialCamera = { - center: [centerLon, centerLat], - zoom: header.centerZoom || Math.max(10, (header.maxZoom || 14) - 1), - }; - } - - const map = new window.atlas.Map(mapContainerRef.current, { - ...initialCamera, - maxPitch: 0, - pitch: 0, - style: isAzureMapsPlaceholder ? "blank" : "satellite", - language: "en-US", - authOptions: getAzureMapsAuthOptions(), - }); - - map.events.add("ready", () => { - map.setUserInteraction({ - dragRotateInteraction: false, - scrollZoomInteraction: true, - pinchZoomInteraction: true, - pinchRotateInteraction: false, - }); - map.controls.add(new window.atlas.control.ZoomControl(), { - position: "bottom-left", - }); - - // Post-event imagery under the footprints. This is the pane the swipe - // view compares against, and on its own it already puts every footprint - // over the scene the model scored. toBrowserTitilerUrl returns "" when - // it cannot map the tile template to something this browser can reach, - // in which case the basemap alone is a better answer than a layer of - // failing tiles. - const postUrl = toBrowserTitilerUrl( - layerData?.imagery?.postEventTileUrl || "" - ); - if (postUrl) { - loadImagery( - postUrl, - map, - { current: null }, - "predictionPostEventImagery", - true - ); - } - - const source = new window.atlas.source.VectorTileSource(SOURCE_ID, { - type: "vector", - url: `pmtiles://${archiveUrl}`, - // Azure Maps ignores promoteId, but the tiles already carry native - // integer feature ids (tippecanoe --use-attribute-for-id=id), which - // is what setFeatureState needs. - promoteId: { [PMTILES_SOURCE_LAYER]: "id" }, - }); - map.sources.add(source); - - const paint = colorsRef.current; - const fillLayer = new window.atlas.layer.PolygonLayer( - SOURCE_ID, - FILL_LAYER_ID, - { - sourceLayer: PMTILES_SOURCE_LAYER, - fillColor: fillColorExpression(paint), - fillOpacity: FILL_OPACITY_EXPRESSION, - } - ); - map.layers.add(fillLayer); - fillLayerRef.current = fillLayer; - - const lineLayer = new window.atlas.layer.LineLayer( - SOURCE_ID, - LINE_LAYER_ID, - { - sourceLayer: PMTILES_SOURCE_LAYER, - strokeColor: strokeColorExpression(paint), - strokeWidth: STROKE_WIDTH_EXPRESSION, - } - ); - map.layers.add(lineLayer); - lineLayerRef.current = lineLayer; - - const glMap = findGlMap(map); - glMapRef.current = glMap; - internalLayerIdsRef.current = discoverFillLayerIds(glMap, [ - FILL_LAYER_ID, - ]); - - map.events.add("click", fillLayer, (event) => { - // Ctrl+click starts a box-select drag; don't also toggle a class. - if ( - event.originalEvent && - (event.originalEvent.ctrlKey || event.originalEvent.metaKey) - ) { - return; - } - const feature = featureAtEvent(map, event); - if (feature) handleFeatureClick(feature.id); - }); - map.events.add("contextmenu", fillLayer, (event) => { - const feature = featureAtEvent(map, event); - if (feature) handleClearOverrideForId(feature.id); - return false; - }); - map.getCanvasContainer().style.cursor = "pointer"; - setupBoxSelect( - map, - () => glMapRef.current, - () => internalLayerIdsRef.current, - boxCleanupRef - ); - - const hydrate = () => scheduleHydrate(); - map.events.add("moveend", hydrate); - map.events.add("sourcedata", (event) => { - if (event && event.isSourceLoaded) hydrate(); - }); - hydrateViewport(); - setIsSourceReady(true); - }); - - mapRef.current = map; - } - - // ── Renderer helpers (all read refs so map handlers stay valid) ─────────── - // Every renderer currently drawing footprints: the editor map, plus the - // swipe comparison map while it is up. Feature-state is per-renderer, so a - // write that skipped the second one would leave the far side of the divider - // painting every building in the "pending" colour. - function footprintRenderers() { - const renderers = []; - if (glMapRef.current) { - renderers.push({ - map: mapRef.current, - gl: glMapRef.current, - sourceId: primarySourceIdRef.current || SOURCE_ID, - }); - } - if (swipeGlMapRef.current) { - renderers.push({ - map: swipeMapRef.current, - gl: swipeGlMapRef.current, - sourceId: swipeSourceIdRef.current || SWIPE_SOURCE_ID, - }); - } - return renderers; - } - - function featureAtEventOn(map, glMap, layerIds, event) { - if (!glMap) return null; - let pixel = event.pixel; - if (!pixel && event.position) { - const pixels = map.positionsToPixels([event.position]); - pixel = pixels && pixels[0]; - } - if (!pixel) return null; - try { - const rendered = glMap.queryRenderedFeatures( - pixel, - layerIds && layerIds.length ? { layers: layerIds } : undefined - ); - const feature = rendered && rendered[0]; - if (!feature || feature.id == null) return null; - return { id: feature.id, source: feature.source }; - } catch (error) { - console.warn("queryRenderedFeatures failed:", error); - return null; - } - } - - function featureAtEvent(map, event) { - return featureAtEventOn( - map, - glMapRef.current, - internalLayerIdsRef.current, - event - ); - } - - function renderedFeaturesOn(glMap, layerIds, box) { - if (!glMap) return []; - try { - return ( - glMap.queryRenderedFeatures( - box, - layerIds && layerIds.length ? { layers: layerIds } : undefined - ) || [] - ); - } catch (error) { - console.warn("queryRenderedFeatures (viewport) failed:", error); - return []; - } - } - - function renderedFeatures(box) { - return renderedFeaturesOn( - glMapRef.current, - internalLayerIdsRef.current, - box - ); - } - - // One class change, written to every renderer that draws the building. Each - // renderer names the source differently, so each gets its own cached id. - function writeFeatureState(id, state) { - for (const renderer of footprintRenderers()) { - try { - renderer.gl.setFeatureState( - { - source: renderer.sourceId, - sourceLayer: PMTILES_SOURCE_LAYER, - id, - }, - state - ); - } catch (error) { - console.warn("feature-state write failed:", error); - } - } - } - - function repaintFootprints() { - for (const renderer of footprintRenderers()) { - if (renderer.map && renderer.map.triggerRepaint) { - renderer.map.triggerRepaint(); - } - } - } - - // Tile loads and camera moves arrive in bursts; coalesce them so a pan - // costs one queryRenderedFeatures pass rather than a dozen. - function scheduleHydrate() { - if (hydrateTimerRef.current) return; - hydrateTimerRef.current = setTimeout(() => { - hydrateTimerRef.current = null; - hydrateViewport(); - }, 120); - } - - // Paint every footprint currently on screen from the cached classification, - // and remember where each one is so Prev/Next can pan to it. Called on every - // viewport settle and whenever the classification changes. - // - // The two panes share a camera, so the editor map's rendered features are - // the authoritative list of what is on screen; writeFeatureState fans each - // building's state out to the comparison map's renderer as well. - function hydrateViewport() { - const features = renderedFeatures(undefined); - if (features.length === 0) return; - const classes = classesRef.current; - const edited = editedRef.current; - const byId = indexByIdRef.current; - const activeFilter = filterRef.current; - const selectedId = selectedIdRef.current; - for (const feature of features) { - const id = feature.id; - if (id == null) continue; - if (feature.source) primarySourceIdRef.current = feature.source; - const index = byId.get(id); - if (index === undefined) continue; - if (!centroidsRef.current.has(id)) { - const centroid = featureCentroid(feature.geometry); - if (centroid) centroidsRef.current.set(id, centroid); - } - const cls = classes[index]; - writeFeatureState(id, { - cls: CLASS_CODES[cls] || 0, - dim: !matchesFilter(cls, edited[index], activeFilter), - edited: !!edited[index], - selected: selectedId === id, - }); - } - repaintFootprints(); - } - - // ── Editing ─────────────────────────────────────────────────────────────── - function handleFeatureClick(id) { - const index = indexByIdRef.current.get(id); - if (index === undefined) return; - setSelectedIndex(index); - const action = clickActionRef.current; - const cls = - action === "cycle" ? cycleClass(classesRef.current[index]) : action; - setOverridesState((previous) => setOverrides(previous, [id], cls)); - } - - function handleClearOverrideForId(id) { - const index = indexByIdRef.current.get(id); - if (index === undefined) return; - setSelectedIndex(index); - setOverridesState((previous) => clearOverride(previous, id)); - } - - function applyClickActionToIds(ids) { - if (ids.length === 0) return; - const action = clickActionRef.current; - if (action !== "cycle") { - setOverridesState((previous) => setOverrides(previous, ids, action)); - return; - } - // Cycle mode over a box: advance each building from its own class. - const classes = classesRef.current; - const byId = indexByIdRef.current; - const entries = ids - .map((id) => { - const index = byId.get(id); - if (index === undefined) return null; - return { id, class: cycleClass(classes[index]) }; - }) - .filter(Boolean); - setOverridesState((previous) => setOverrideEntries(previous, entries)); - } - - function setClassForSelected(cls) { - const attrs = attrsRef.current; - if (!attrs || selectedIndex < 0 || selectedIndex >= attrs.n) return; - const id = attrs.ids[selectedIndex]; - setOverridesState((previous) => setOverrides(previous, [id], cls)); - } - - function clearSelectedOverride() { - const attrs = attrsRef.current; - if (!attrs || selectedIndex < 0 || selectedIndex >= attrs.n) return; - setOverridesState((previous) => - clearOverride(previous, attrs.ids[selectedIndex]) - ); - } - - function clearAllOverrides() { - setOverridesState({}); - } - - // ── Ctrl+drag box-select ────────────────────────────────────────────────── - // Parameterised by pane: the swipe comparison map wires its own copy so a - // drag that starts on the uncovered (left) half selects buildings too. The - // two canvases are the same size and in the same place, so both can share - // the single box rectangle without any coordinate translation. - function setupBoxSelect(map, glGetter, layerIdsGetter, cleanupRef) { - const canvas = map.getCanvasContainer(); - let origin = null; - - const onDown = (event) => { - if (!event.ctrlKey && !event.metaKey) return; - event.preventDefault(); - event.stopPropagation(); - map.setUserInteraction({ dragPanInteraction: false }); - const rect = canvas.getBoundingClientRect(); - origin = { x: event.clientX - rect.left, y: event.clientY - rect.top }; - const box = boxRef.current; - if (box) { - box.style.display = "block"; - box.style.left = `${origin.x}px`; - box.style.top = `${origin.y}px`; - box.style.width = "0px"; - box.style.height = "0px"; - } - }; - - const onMove = (event) => { - if (!origin) return; - const rect = canvas.getBoundingClientRect(); - const x = event.clientX - rect.left; - const y = event.clientY - rect.top; - const box = boxRef.current; - if (box) { - box.style.left = `${Math.min(origin.x, x)}px`; - box.style.top = `${Math.min(origin.y, y)}px`; - box.style.width = `${Math.abs(x - origin.x)}px`; - box.style.height = `${Math.abs(y - origin.y)}px`; - } - }; - - const onUp = (event) => { - if (!origin) return; - const rect = canvas.getBoundingClientRect(); - const x1 = Math.min(origin.x, event.clientX - rect.left); - const y1 = Math.min(origin.y, event.clientY - rect.top); - const x2 = Math.max(origin.x, event.clientX - rect.left); - const y2 = Math.max(origin.y, event.clientY - rect.top); - origin = null; - if (boxRef.current) boxRef.current.style.display = "none"; - map.setUserInteraction({ dragPanInteraction: true }); - if (x2 - x1 < 4 || y2 - y1 < 4) return; - - const features = renderedFeaturesOn(glGetter(), layerIdsGetter(), [ - [x1, y1], - [x2, y2], - ]); - const ids = [ - ...new Set(features.filter((f) => f.id != null).map((f) => f.id)), - ]; - applyClickActionToIds(ids); - }; - - canvas.addEventListener("mousedown", onDown); - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); - cleanupRef.current = () => { - canvas.removeEventListener("mousedown", onDown); - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - }; - } - - // ── Classification ──────────────────────────────────────────────────────── - // Recomputed whenever the thresholds or the user's edits change. The map is - // repainted from the result in the effect below, so the slider recolours - // without touching the server. - useEffect(() => { - const attrs = attrsRef.current; - if (!attrs) return; - const result = classifyAll(attrs, { - threshold, - unknownThreshold, - overrides, - }); - classesRef.current = result.classes; - editedRef.current = result.edited; - setClassification(result); - setChangeCount( - countClassChanges( - attrs, - baseline, - { threshold, unknownThreshold }, - overrides - ) - ); - }, [attrsVersion, threshold, unknownThreshold, overrides, baseline]); - - // Repaint on-screen footprints. isSourceReady is in the deps because the - // layers are created inside the map's async "ready" handler — reading the - // layer refs during render would see nulls and never re-run. isSwipeReady - // is there for the same reason on the comparison pane: its renderer starts - // with no feature-state at all, so it has to be hydrated the moment it - // appears. - useEffect(() => { - if (!isSourceReady || !classification) return; - hydrateViewport(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [classification, filter, isSourceReady, isSwipeReady]); - - // Resolve the map palette from the active Fluent theme, and re-apply it when - // the user flips light/dark or changes the brand palette. The resolved - // values live in a ref because only the renderer consumes them — the legend - // uses the same tokens through makeStyles. - // - // Paint expressions are per-renderer too, so the comparison pane's layers - // get exactly the same ones; isSwipeReady is in the deps because those - // layers only exist once that map's async "ready" has fired. - useEffect(() => { - const resolved = resolveThemeColors(rootRef.current); - colorsRef.current = resolved; - const fillColor = fillColorExpression(resolved); - const strokeColor = strokeColorExpression(resolved); - for (const layer of [fillLayerRef.current, swipeFillLayerRef.current]) { - if (layer) layer.setOptions({ fillColor }); - } - for (const layer of [lineLayerRef.current, swipeLineLayerRef.current]) { - if (layer) layer.setOptions({ strokeColor }); - } - }, [isDark, palette, isSourceReady, isSwipeReady]); - - // ── Selection ───────────────────────────────────────────────────────────── - const filteredIndices = useMemo( - () => (classification ? filterIndices(classification, filter) : []), - [classification, filter] - ); - - // Changing the filter can strand the current selection outside the visible - // set, so snap it to the first match as part of the same event rather than - // in an effect (which would cost an extra render pass). - const handleFilterChange = useCallback( - (nextFilter) => { - setFilter(nextFilter); - if (!classification) return; - const nextIndices = filterIndices(classification, nextFilter); - if (nextIndices.length === 0) return; - setSelectedIndex((current) => - current >= 0 && !nextIndices.includes(current) - ? nextIndices[0] - : current - ); - }, - [classification] - ); - - // Highlight the selected footprint, and pan to it when the selection came - // from Prev/Next. Buildings whose tile has never rendered have no cached - // centroid, so there is nowhere to pan yet. - useEffect(() => { - if (!isSourceReady) return; - const attrs = attrsRef.current; - const previousId = selectedIdRef.current; - const nextId = - attrs && selectedIndex >= 0 && selectedIndex < attrs.n - ? attrs.ids[selectedIndex] - : null; - if (previousId != null && previousId !== nextId) { - writeFeatureState(previousId, { selected: false }); - } - selectedIdRef.current = nextId; - if (nextId == null) { - repaintFootprints(); - return; - } - writeFeatureState(nextId, { selected: true }); - repaintFootprints(); - const shouldPan = pendingPanRef.current; - pendingPanRef.current = false; - const centroid = centroidsRef.current.get(nextId); - if (shouldPan && centroid && mapRef.current) { - const camera = mapRef.current.getCamera(); - mapRef.current.setCamera({ - center: centroid, - zoom: Math.max(camera?.zoom || 0, 17.5), - duration: 500, - }); - } - // writeFeatureState / repaintFootprints only read refs (the renderers and - // their source ids), so they are stable for the life of the component and - // are deliberately not dependencies. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedIndex, isSourceReady, isSwipeReady]); - - function navigateInFilter(direction) { - if (filteredIndices.length === 0) return; - const attrs = attrsRef.current; - // Prefer buildings we can actually pan to; fall back to the plain next - // one so navigation never stalls. - const hasLocation = (index) => { - const id = attrs?.ids?.[index]; - return id != null && centroidsRef.current.has(id); - }; - const next = nextIndexInList( - filteredIndices, - selectedIndex, - direction, - hasLocation - ); - if (next === null) return; - pendingPanRef.current = true; - setSelectedIndex(next); - } - - // ── Swipe comparison map ────────────────────────────────────────────────── - // Built on demand from the global atlas.SwipeMap that index.html loads from - // /assets/js/azure-maps-swipe-map.min.js — NOT an npm import. Mirrors the - // Interactive Labeler's hardened implementation. - // - // atlas.SwipeMap always shows its PRIMARY on the LEFT of the divider and - // clips its SECONDARY to reveal it on the RIGHT, so: - // • PRIMARY = a freshly built comparison map (pre-event imagery, or the - // plain basemap), created in swipeMapContainerRef — the - // FIRST/behind pane; and - // • SECONDARY = the existing editor map (post-event imagery + footprints - // + editing), which sits in the SECOND/on-top pane, so its - // clipped right half reveals the comparison map on the left. - // Consequently the divider moving LEFT uncovers more of the post-event - // (editing) map, and moving RIGHT uncovers more of the comparison map. - // - // The editor map is only ADOPTED: SwipeMap adds 'move'/'resize' handlers and - // an inline clip to its container, nothing else, so an already-"ready" map - // adopts cleanly and its own handlers survive. SwipeMap also syncs BOTH - // cameras on every 'move' internally — adding our own camera-sync handler - // here would double-update them and make panning stutter, so we do not. - useEffect(() => { - if (!isSourceReady || !swipeOn || !swipeAvailable) return undefined; - const editorMap = mapRef.current; - const container = swipeMapContainerRef.current; - // Captured up front: by teardown time the ref may already point elsewhere, - // but this node is stable for the effect's lifetime. - const editorContainer = mapContainerRef.current; - if (!editorMap || !container || !window.atlas || !window.atlas.SwipeMap) { - return undefined; - } - // The map's "ready" is async and can land after this effect is cleaned up - // (a fast toggle off, or an unmount) — by which point the map is disposed. - let isDisposed = false; - - // Seed the comparison map with the editor's current camera so the two - // start aligned before SwipeMap takes over the synchronisation. - const camera = editorMap.getCamera(); - const compareMap = new window.atlas.Map(container, { - center: camera.center, - zoom: camera.zoom, - bearing: camera.bearing || 0, - pitch: 0, - maxPitch: 0, - // Same rule as the editor map: "satellite" is the real basemap, while - // local docker dev (no Azure Maps subscription) uses "blank" so the - // control still fires "ready" without a valid token. - style: isAzureMapsPlaceholder ? "blank" : "satellite", - language: "en-US", - authOptions: getAzureMapsAuthOptions(), - }); - swipeMapRef.current = compareMap; - - compareMap.events.add("ready", () => { - if (isDisposed) return; - compareMap.setUserInteraction({ - dragRotateInteraction: false, - scrollZoomInteraction: true, - pinchZoomInteraction: true, - pinchRotateInteraction: false, - }); - - // Pre-event imagery on the comparison pane. In basemap mode there is no - // overlay at all — the map's own basemap IS the comparison. - const compareUrl = toBrowserTitilerUrl( - swipeComparisonTileUrl(imageryRef.current, swipeMode) - ); - if (compareUrl) { - loadImagery( - compareUrl, - compareMap, - { current: null }, - "predictionSwipeComparisonImagery", - true - ); - } - - // The same footprints, from the same in-memory archive: SwipeMap clips - // a whole map, so a single set of footprint layers could only ever - // appear on one side of the divider. - if (archiveUrlRef.current) { - try { - compareMap.sources.add( - new window.atlas.source.VectorTileSource(SWIPE_SOURCE_ID, { - type: "vector", - url: `pmtiles://${archiveUrlRef.current}`, - promoteId: { [PMTILES_SOURCE_LAYER]: "id" }, - }) - ); - const paint = colorsRef.current; - const swipeFillLayer = new window.atlas.layer.PolygonLayer( - SWIPE_SOURCE_ID, - SWIPE_FILL_LAYER_ID, - { - sourceLayer: PMTILES_SOURCE_LAYER, - fillColor: fillColorExpression(paint), - fillOpacity: FILL_OPACITY_EXPRESSION, - } - ); - compareMap.layers.add(swipeFillLayer); - swipeFillLayerRef.current = swipeFillLayer; - - const swipeLineLayer = new window.atlas.layer.LineLayer( - SWIPE_SOURCE_ID, - SWIPE_LINE_LAYER_ID, - { - sourceLayer: PMTILES_SOURCE_LAYER, - strokeColor: strokeColorExpression(paint), - strokeWidth: STROKE_WIDTH_EXPRESSION, - } - ); - compareMap.layers.add(swipeLineLayer); - swipeLineLayerRef.current = swipeLineLayer; - - const swipeGlMap = findGlMap(compareMap); - swipeGlMapRef.current = swipeGlMap; - swipeLayerIdsRef.current = discoverFillLayerIds(swipeGlMap, [ - SWIPE_FILL_LAYER_ID, - ]); - swipeSourceIdRef.current = discoverVectorSourceId( - swipeGlMap, - SWIPE_SOURCE_ID - ); - - // Without these the whole uncovered half of the map would be inert: - // the editor map is clipped there, so its own handlers never see - // those clicks. Same edit path, same box-select, same undo. - compareMap.events.add("click", swipeFillLayer, (event) => { - if ( - event.originalEvent && - (event.originalEvent.ctrlKey || event.originalEvent.metaKey) - ) { - return; - } - const feature = featureAtEventOn( - compareMap, - swipeGlMapRef.current, - swipeLayerIdsRef.current, - event - ); - if (feature) handleFeatureClick(feature.id); - }); - compareMap.events.add("contextmenu", swipeFillLayer, (event) => { - const feature = featureAtEventOn( - compareMap, - swipeGlMapRef.current, - swipeLayerIdsRef.current, - event - ); - if (feature) handleClearOverrideForId(feature.id); - return false; - }); - compareMap.getCanvasContainer().style.cursor = "pointer"; - setupBoxSelect( - compareMap, - () => swipeGlMapRef.current, - () => swipeLayerIdsRef.current, - swipeBoxCleanupRef - ); - - // This renderer starts with empty feature-state, and its tiles - // arrive on their own schedule, so re-hydrate as they land. - compareMap.events.add("sourcedata", (event) => { - if (event && event.isSourceLoaded) scheduleHydrate(); - }); - } catch (error) { - console.warn("Swipe comparison footprints failed:", error); - } - } - - try { - swipeControlRef.current = new window.atlas.SwipeMap( - compareMap, - editorMap - ); - } catch (error) { - console.warn("atlas.SwipeMap init failed:", error); - } - - // Paint what is already on screen into the new renderer, then let the - // effects above take over (isSwipeReady is their trigger). - hydrateViewport(); - if (mountedRef.current && !isDisposed) setIsSwipeReady(true); - }); - - return () => { - isDisposed = true; - // Detach the comparison pane's document-level drag listeners before its - // map goes away, or box-select keeps firing against a dead renderer. - if (swipeBoxCleanupRef.current) { - swipeBoxCleanupRef.current(); - swipeBoxCleanupRef.current = null; - } - swipeGlMapRef.current = null; - swipeFillLayerRef.current = null; - swipeLineLayerRef.current = null; - swipeLayerIdsRef.current = [SWIPE_FILL_LAYER_ID]; - swipeSourceIdRef.current = SWIPE_SOURCE_ID; - // Order matters: SwipeMap.dispose() removes the divider handle it - // appended to the PRIMARY container and detaches the 'move'/'resize' - // handlers from BOTH maps, so it has to go before the map it decorates. - if (swipeControlRef.current) { - try { - if (typeof swipeControlRef.current.dispose === "function") { - swipeControlRef.current.dispose(); - } - } catch (error) { - console.warn("atlas.SwipeMap dispose failed:", error); - } - swipeControlRef.current = null; - } - if (swipeMapRef.current) { - try { - swipeMapRef.current.dispose(); - } catch (error) { - console.warn("swipe comparison map dispose failed:", error); - } - swipeMapRef.current = null; - } - // SwipeMap.dispose() does NOT clear the inline `clip` it set on the - // SECONDARY (editor) map's container. Left behind, the editor stays - // stuck at half width. Clear it on both the element getMapContainer() - // reports and the div handed to the Map constructor, since which one - // that is varies across Atlas builds. The editor map may already be - // disposed when this runs on unmount, hence the try/catch. - try { - if (editorMap && typeof editorMap.getMapContainer === "function") { - editorMap.getMapContainer().style.clip = ""; - } - } catch (error) { - console.warn("clearing the editor map clip failed:", error); - } - if (editorContainer) editorContainer.style.clip = ""; - // Leave the comparison pane's container as clean as we found it. - container.style.clip = ""; - container.innerHTML = ""; - if (mountedRef.current) setIsSwipeReady(false); - }; - // The map helpers are stable for the life of the component and are - // deliberately not dependencies: including them would rebuild the - // comparison map on every render. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isSourceReady, swipeOn, swipeAvailable, swipeMode]); - - // ── Save ────────────────────────────────────────────────────────────────── - async function refreshVersions() { - try { - const data = await apiGet( - `GetEditedPredictionVersions?projectId=${encodeURIComponent(projectId)}` + - `&modelId=${encodeURIComponent(modelId)}` - ); - if (Array.isArray(data?.versions)) setVersions(data.versions); - } catch (error) { - console.warn("Could not refresh edited prediction versions:", error); - } - } - - async function handleSave() { - setIsSaving(true); - setSaveError(""); - try { - const payload = buildSavePayload({ - projectId, - imageLayerId, - modelId, - threshold, - unknownThreshold, - overrides, - }); - const result = await apiPut("PutEditedPredictions", payload); - // apiPut surfaces a conflict as the bare status code. - if (result === 409) { - throw new Error( - "Another version is being written for this model. Try saving again in a moment." - ); - } - if (!result || result.version == null) { - throw new Error("The server did not return a new version number."); - } - setSavedResult(result); - // Subsequent slider moves are now measured against what was saved. - setBaseline({ threshold, unknownThreshold }); - await refreshVersions(); - setDialog( - "Saved", - `Version ${result.version} saved with ${ - result.editedCount ?? payload.overrides.length - } edited buildings.` - ); - } catch (error) { - const message = - error?.message || "Failed to save the edited predictions."; - setSaveError(message); - setDialog("Save failed", message); - } finally { - setIsSaving(false); - } - } - - // ── Keyboard shortcuts ──────────────────────────────────────────────────── - // 1/2/3 set the selected building's class (and become the click action, so - // the next click paints the same class); arrows walk the filtered set; with - // the swipe view up, A/S/D snap the divider. - useEffect(() => { - if (phase !== PHASE_READY) return undefined; - const classByKey = { - 1: CLASS_DAMAGED, - 2: CLASS_NOT_DAMAGED, - 3: CLASS_UNKNOWN, - }; - function onKeyDown(event) { - if (shouldIgnoreShortcut(event)) return; - if (event.ctrlKey || event.altKey || event.metaKey) return; - const cls = classByKey[event.key]; - if (cls) { - setClickAction(cls); - setClassForSelected(cls); - return; - } - if (swipeControlRef.current) { - // sliderPosition is in pixels from the left edge of the map area. - // A = hard left (the whole post-event/editing map shows), S = centre, - // D = hard right (the whole comparison map shows). SwipeMap clamps to - // [0, width] itself. - const width = mapAreaRef.current?.getBoundingClientRect().width; - const position = dividerPositionForKey(event.key, width); - if (position !== null) { - try { - swipeControlRef.current.setOptions({ sliderPosition: position }); - } catch (error) { - console.warn("swipe setOptions (sliderPosition) failed:", error); - } - return; - } - } - if (event.key === "ArrowLeft") { - event.preventDefault(); - navigateInFilter(-1); - } else if (event.key === "ArrowRight") { - event.preventDefault(); - navigateInFilter(1); - } - } - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [phase, selectedIndex, filteredIndices]); - - // ── Derived view data ───────────────────────────────────────────────────── - const currentBuilding = useMemo(() => { - const attrs = attrsRef.current; - if ( - !attrs || - !classification || - selectedIndex < 0 || - selectedIndex >= attrs.n - ) { - return null; - } - const id = attrs.ids[selectedIndex]; - return { - id, - overtureId: attrs.overtureIds[selectedIndex], - damage: attrs.damage[selectedIndex], - unknown: attrs.unknown[selectedIndex], - cls: classification.classes[selectedIndex], - edited: classification.edited[selectedIndex], - }; - // attrsVersion re-runs this once the sidecar lands. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [classification, selectedIndex, attrsVersion]); - - const legendItems = [ - { key: CLASS_DAMAGED, label: "Damaged", swatch: styles.legendDamaged }, - { - key: CLASS_NOT_DAMAGED, - label: "Not Damaged", - swatch: styles.legendNotDamaged, - }, - { key: CLASS_UNKNOWN, label: "Unknown", swatch: styles.legendUnknown }, - ]; - - // ── Preparation card ────────────────────────────────────────────────────── - // One card, three outcomes: waiting (live status + indeterminate progress), - // terminally failed (error + forced retry), or gave up after the attempt - // cap (check back later + a manual re-check). - function renderPreparationCard() { - const prep = prepState || {}; - const statusLabel = prepStatusLabel(prep.status); - const outstanding = describeOutstandingArtifacts(session); - const expected = - Number(session?.buildingCount) > 0 - ? `${Number(session.buildingCount).toLocaleString()} buildings are expected.` - : ""; - - if (prep.phase === PREP_PHASE_FAILED) { - return ( - <> - Preparing predictions failed - - - {statusLabel} - {prep.statusMessage || - prep.error || - "The job that builds the editable footprint tiles did not finish."} - - -
- Retrying queues the preparation job again from scratch. Nothing - already saved is affected. -
-
- - -
- - ); - } - - if (prep.phase === PREP_PHASE_TIMED_OUT) { - return ( - <> - Still preparing predictions -
- This is taking longer than expected, so this page stopped checking. - The job is still running in the background — check back later, or - check now. -
-
- Last known status - {statusLabel} -
- {prep.statusMessage ? ( -
{prep.statusMessage}
- ) : null} - {prep.error ? ( - - {prep.error} - - ) : null} -
- - -
- - ); - } - - // Requesting or waiting: the live view. - const isRequesting = prep.phase === PREP_PHASE_REQUESTING; - return ( - <> - Preparing predictions for editing - - {/* Live region scoped to the text that actually changes — announcing - the whole card would re-read the buttons on every poll. */} -
- Status - - {isRequesting ? "Queuing" : statusLabel} - -
- {prep.statusMessage ? ( -
{prep.statusMessage}
- ) : null} -
- {outstanding || - "The editable footprint tiles and prediction scores are being generated."} -
- {prep.error ? ( - - - {prep.error} Still retrying in the background. - - - ) : null} -
- This usually takes a few minutes. The map opens on its own when the - data is ready — no need to reload.{expected ? ` ${expected}` : ""} -
-
- {prep.attempt > 0 - ? `Checked ${prep.attempt} ${ - prep.attempt === 1 ? "time" : "times" - }, every ${Math.round(PREP_POLL_INTERVAL_MS / 1000)} seconds.` - : "Waiting for the first status update."} -
- - ); - } - - const showMap = phase === PHASE_LOADING || phase === PHASE_READY; - - return ( -
-
- -
- - {/* Map area. Both panes fill this wrapper exactly and overlap: the - comparison map (SwipeMap PRIMARY, revealed LEFT of the divider) has - to sit FIRST/behind, and the editor map (SECONDARY, clipped so it is - revealed RIGHT of the divider) SECOND/on-top. The divider handle - SwipeMap appends into the primary's container carries its own - z-index and still paints above both. The comparison pane's container - stays mounted but hidden while swipe is off, so the effect always - has a container to build into. */} - {showMap && ( -
-
-
- {/* Box-select rectangle (Ctrl+drag). Inside the map area so its - offsets line up with either canvas. */} -
- {isSwipeActive && ( - <> -
- {swipeLeftPaneLabel(swipeMode)} -
-
- {swipeRightPaneLabel(swipeMode)} -
- - )} -
- )} - - {phase === PHASE_LOADING && ( -
- -
- Streaming building footprints and prediction scores. -
-
- )} - - {phase === PHASE_PREPARING && ( -
{renderPreparationCard()}
- )} - - {phase === PHASE_EMPTY && ( -
- No predicted buildings -
- This model has no building predictions to edit. Run inference (or, - for an embedding model, predict all buildings in the Interactive - Labeler) and then come back. -
-
- )} - - {phase === PHASE_ERROR && ( -
- Prediction editor unavailable -
{errorMessage}
-
- {/* Only offered once a session has loaded: the model exists, so a - missing or half-written artifact is worth rebuilding. When the - session itself failed there is nothing to prepare. */} - {session ? ( - - ) : null} - -
-
- )} - - {phase === PHASE_READY && classification && ( - <> -
-
Current class
- {legendItems.map((item) => ( -
- - {item.label} -
- ))} -
-
- Click a footprint to change it · Ctrl+drag to box-select - · right-click to undo an edit - {isSwipeActive - ? " · A / S / D move the swipe divider" - : ""} -
- { - setClickAction(cls); - setClassForSelected(cls); - }} - onClearOverride={clearSelectedOverride} - onClearAllEdits={clearAllOverrides} - onPrev={() => navigateInFilter(-1)} - onNext={() => navigateInFilter(1)} - threshold={threshold} - setThreshold={setThreshold} - unknownThreshold={unknownThreshold} - setUnknownThreshold={setUnknownThreshold} - baseline={baseline} - changeCount={changeCount} - swipeMode={swipeMode} - swipeOn={swipeOn} - onSwipeChange={setSwipeOn} - onSave={handleSave} - isSaving={isSaving} - saveError={saveError} - savedResult={savedResult} - versions={versions} - /> - - )} -
- ); -}; - -export default PredictionEditor; diff --git a/ui/src/Components/PredictionEditor/predictionClassify.test.js b/ui/src/Components/PredictionEditor/predictionClassify.test.js deleted file mode 100644 index acbdb1de..00000000 --- a/ui/src/Components/PredictionEditor/predictionClassify.test.js +++ /dev/null @@ -1,754 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -// -// Run with: node --test src/Components/PredictionEditor/predictionClassify.test.js - -import test from "node:test"; -import assert from "node:assert/strict"; - -import { - CLASS_DAMAGED, - CLASS_NOT_DAMAGED, - CLASS_UNKNOWN, - FILTER_ALL, - FILTER_EDITED, - buildSavePayload, - classifyAll, - clearOverride, - countClassChanges, - countOverrides, - cycleClass, - deriveClass, - filterIndices, - getOverride, - indexById, - latestVersion, - matchesFilter, - nextIndexInList, - normalizeAttrs, - resolveClassAt, - setOverride, - setOverrideEntries, - setOverrides, - sortVersionsDescending, - toOverrideList, - toPercentLabel, -} from "./predictionClassify.js"; -import { - MAX_PREP_POLL_ATTEMPTS, - PREP_PHASE_FAILED, - PREP_PHASE_READY, - PREP_PHASE_TIMED_OUT, - PREP_PHASE_WAITING, - PREP_POLL_INTERVAL_MS, - PREP_STATUS_CANCELLED, - PREP_STATUS_FAILED, - PREP_STATUS_IN_PROGRESS, - PREP_STATUS_QUEUED, - applyPrepResponse, - buildPrepRequest, - describeOutstandingArtifacts, - evaluatePrepState, - isPrepReady, - isTerminalPrepStatus, - nextPollAttempt, - normalizePrepStatus, - prepStateAfterPollError, - prepStatusLabel, - shouldPollPrep, -} from "./predictionPrep.js"; -import { - SWIPE_MODE_BASEMAP_POST, - SWIPE_MODE_NONE, - SWIPE_MODE_PRE_POST, - dividerPositionForKey, - isSwipeAvailable, - resolveSwipeMode, - swipeComparisonTileUrl, - swipeLeftPaneLabel, - swipeModeHint, - swipeRightPaneLabel, - swipeToggleLabel, -} from "./predictionSwipe.js"; - -// Five buildings covering every interesting corner: below/at/above the -// threshold, and one with a non-zero unknown score. -function sampleAttrs() { - return normalizeAttrs({ - n: 5, - ids: [10, 11, 12, 13, 14], - overtureIds: ["a", "b", "c", "d", "e"], - damage: [0.05, 0.5, 0.51, 0.9, 0.8], - unknown: [0, 0, 0, 0, 0.4], - damaged: [0, 0, 1, 1, 0], - }); -} - -test("deriveClass thresholds on fractions, not percentages", () => { - assert.equal(deriveClass(0.9, 0, 0.5), CLASS_DAMAGED); - assert.equal(deriveClass(0.1, 0, 0.5), CLASS_NOT_DAMAGED); - // Strictly greater: a score exactly at the threshold is not damaged. - assert.equal(deriveClass(0.5, 0, 0.5), CLASS_NOT_DAMAGED); - // A 0-100 style value would be nonsense here; 90 > 0.5 is still damaged, - // which is exactly why the caller must pass fractions. - assert.equal(deriveClass(0.5001, 0, 0.5), CLASS_DAMAGED); -}); - -test("deriveClass gives the unknown score priority over damage", () => { - // Default unknownThreshold of 0: any positive unknown wins. - assert.equal(deriveClass(0.99, 0.01, 0.5), CLASS_UNKNOWN); - assert.equal(deriveClass(0.99, 0, 0.5), CLASS_DAMAGED); - // Raising the unknown threshold lets the damage score through again. - assert.equal(deriveClass(0.99, 0.4, 0.5, 0.5), CLASS_DAMAGED); - assert.equal(deriveClass(0.99, 0.6, 0.5, 0.5), CLASS_UNKNOWN); -}); - -test("deriveClass treats non-finite scores as zero", () => { - assert.equal(deriveClass(NaN, undefined, 0.5), CLASS_NOT_DAMAGED); - assert.equal(deriveClass(null, null, 0.5), CLASS_NOT_DAMAGED); -}); - -test("normalizeAttrs clamps n to the data actually present", () => { - const attrs = normalizeAttrs({ n: 99, ids: [1, 2] }); - assert.equal(attrs.n, 2); - assert.deepEqual(attrs.damage, []); - - const empty = normalizeAttrs(undefined); - assert.equal(empty.n, 0); - assert.deepEqual(empty.ids, []); -}); - -test("indexById maps feature ids back to attribute rows", () => { - const map = indexById(sampleAttrs()); - assert.equal(map.get(10), 0); - assert.equal(map.get(14), 4); - assert.equal(map.get(999), undefined); -}); - -test("override merging is immutable and drops no-op writes", () => { - const empty = {}; - const one = setOverride(empty, 12, CLASS_UNKNOWN); - assert.deepEqual(empty, {}, "input map must not be mutated"); - assert.equal(getOverride(one, 12), CLASS_UNKNOWN); - - // Writing the same value again returns the identical object so React can - // skip the re-render. - assert.equal(setOverride(one, 12, CLASS_UNKNOWN), one); - - // Invalid classes are ignored. - assert.equal(setOverride(one, 13, "Rubble"), one); - assert.equal(getOverride(one, 13), null); - - const many = setOverrides(one, [10, 11], CLASS_DAMAGED); - assert.equal(countOverrides(many), 3); - - const cleared = clearOverride(many, 12); - assert.equal(getOverride(cleared, 12), null); - assert.equal(countOverrides(cleared), 2); - // Clearing something that was never set changes nothing. - assert.equal(clearOverride(cleared, 999), cleared); -}); - -test("setOverrideEntries merges a mixed batch in one pass", () => { - const start = setOverride({}, 10, CLASS_DAMAGED); - const merged = setOverrideEntries(start, [ - { id: 10, class: CLASS_NOT_DAMAGED }, - { id: 11, class: CLASS_UNKNOWN }, - { id: 12, class: "Nonsense" }, - { id: null, class: CLASS_DAMAGED }, - ]); - assert.equal(getOverride(merged, 10), CLASS_NOT_DAMAGED); - assert.equal(getOverride(merged, 11), CLASS_UNKNOWN); - assert.equal(getOverride(merged, 12), null); - assert.equal(countOverrides(merged), 2); - assert.deepEqual(start, { 10: CLASS_DAMAGED }, "input map must not change"); - // Nothing to apply -> same object back. - assert.equal(setOverrideEntries(merged, []), merged); -}); - -test("resolveClassAt prefers the user override over the derived class", () => { - const attrs = sampleAttrs(); - const overrides = setOverride({}, 13, CLASS_NOT_DAMAGED); - assert.equal( - resolveClassAt(attrs, 3, { threshold: 0.5 }), - CLASS_DAMAGED, - "derived without an override" - ); - assert.equal( - resolveClassAt(attrs, 3, { threshold: 0.5, overrides }), - CLASS_NOT_DAMAGED - ); -}); - -test("classifyAll counts every class and tracks edits", () => { - const attrs = sampleAttrs(); - const result = classifyAll(attrs, { threshold: 0.5, unknownThreshold: 0 }); - - assert.deepEqual(result.classes, [ - CLASS_NOT_DAMAGED, // 0.05 - CLASS_NOT_DAMAGED, // 0.5 is not > 0.5 - CLASS_DAMAGED, // 0.51 - CLASS_DAMAGED, // 0.9 - CLASS_UNKNOWN, // unknown 0.4 > 0 - ]); - assert.deepEqual(result.counts, { - [CLASS_DAMAGED]: 2, - [CLASS_NOT_DAMAGED]: 2, - [CLASS_UNKNOWN]: 1, - }); - assert.equal(result.editedCount, 0); - assert.equal(result.total, 5); - - const edited = classifyAll(attrs, { - threshold: 0.5, - overrides: setOverride({}, 10, CLASS_DAMAGED), - }); - assert.equal(edited.classes[0], CLASS_DAMAGED); - assert.equal(edited.edited[0], true); - assert.equal(edited.edited[1], false); - assert.equal(edited.editedCount, 1); - assert.equal(edited.counts[CLASS_DAMAGED], 3); -}); - -test("lowering the threshold reclassifies without touching the data", () => { - const attrs = sampleAttrs(); - const strict = classifyAll(attrs, { threshold: 0.95 }); - assert.equal(strict.counts[CLASS_DAMAGED], 0); - - const loose = classifyAll(attrs, { threshold: 0.01 }); - // Building 4 still has a non-zero unknown score, so it stays Unknown. - assert.equal(loose.counts[CLASS_DAMAGED], 4); - assert.equal(loose.counts[CLASS_UNKNOWN], 1); -}); - -test("filter predicate covers class filters, edited, and all", () => { - assert.equal(matchesFilter(CLASS_DAMAGED, false, FILTER_ALL), true); - assert.equal(matchesFilter(CLASS_DAMAGED, false, CLASS_DAMAGED), true); - assert.equal(matchesFilter(CLASS_DAMAGED, false, CLASS_UNKNOWN), false); - assert.equal(matchesFilter(CLASS_DAMAGED, false, FILTER_EDITED), false); - assert.equal(matchesFilter(CLASS_DAMAGED, true, FILTER_EDITED), true); - assert.equal(matchesFilter(CLASS_UNKNOWN, false, undefined), true); -}); - -test("filterIndices returns the rows the panel traverses", () => { - const attrs = sampleAttrs(); - const classification = classifyAll(attrs, { - threshold: 0.5, - overrides: setOverride({}, 11, CLASS_UNKNOWN), - }); - - assert.deepEqual(filterIndices(classification, FILTER_ALL), [0, 1, 2, 3, 4]); - assert.deepEqual(filterIndices(classification, CLASS_DAMAGED), [2, 3]); - assert.deepEqual(filterIndices(classification, CLASS_UNKNOWN), [1, 4]); - assert.deepEqual(filterIndices(classification, FILTER_EDITED), [1]); -}); - -test("countClassChanges reports how many buildings a slider move flips", () => { - const attrs = sampleAttrs(); - const base = { threshold: 0.5, unknownThreshold: 0 }; - - assert.equal(countClassChanges(attrs, base, base), 0); - - // 0.5 -> 0.85 demotes 0.51 and 0.8 (0.8 is Unknown, so it doesn't count) - // leaving only building index 2. - assert.equal( - countClassChanges(attrs, base, { threshold: 0.85, unknownThreshold: 0 }), - 1 - ); - - // 0.5 -> 0.01 promotes 0.05 and 0.5. - assert.equal( - countClassChanges(attrs, base, { threshold: 0.01, unknownThreshold: 0 }), - 2 - ); - - // Raising the unknown threshold releases building 4 back to Damaged. - assert.equal( - countClassChanges(attrs, base, { threshold: 0.5, unknownThreshold: 0.5 }), - 1 - ); -}); - -test("countClassChanges ignores buildings the user pinned", () => { - const attrs = sampleAttrs(); - const base = { threshold: 0.5, unknownThreshold: 0 }; - const candidate = { threshold: 0.01, unknownThreshold: 0 }; - - assert.equal(countClassChanges(attrs, base, candidate), 2); - // Pin one of the two that would have flipped. - const overrides = setOverride({}, 10, CLASS_NOT_DAMAGED); - assert.equal(countClassChanges(attrs, base, candidate, overrides), 1); -}); - -test("cycleClass walks Damaged -> NotDamaged -> Unknown -> Damaged", () => { - assert.equal(cycleClass(CLASS_DAMAGED), CLASS_NOT_DAMAGED); - assert.equal(cycleClass(CLASS_NOT_DAMAGED), CLASS_UNKNOWN); - assert.equal(cycleClass(CLASS_UNKNOWN), CLASS_DAMAGED); - assert.equal(cycleClass(undefined), CLASS_DAMAGED); -}); - -test("nextIndexInList wraps in both directions", () => { - const list = [2, 5, 9]; - assert.equal(nextIndexInList(list, 2, 1), 5); - assert.equal(nextIndexInList(list, 9, 1), 2); - assert.equal(nextIndexInList(list, 2, -1), 9); - assert.equal(nextIndexInList(list, 5, -1), 2); - assert.equal(nextIndexInList([], 0, 1), null); -}); - -test("nextIndexInList handles a selection outside the filtered set", () => { - const list = [2, 5, 9]; - // Nothing selected yet. - assert.equal(nextIndexInList(list, -1, 1), 2); - assert.equal(nextIndexInList(list, -1, -1), 9); - // Selection filtered out: step to the nearest neighbour in the direction - // of travel. - assert.equal(nextIndexInList(list, 6, 1), 9); - assert.equal(nextIndexInList(list, 6, -1), 5); - assert.equal(nextIndexInList(list, 12, 1), 2); -}); - -test("nextIndexInList prefers candidates but never refuses to move", () => { - const list = [2, 5, 9]; - const located = new Set([9]); - // Skips 5 because its geometry hasn't streamed in yet. - assert.equal(nextIndexInList(list, 2, 1, (i) => located.has(i)), 9); - // Nothing qualifies -> plain next entry. - assert.equal(nextIndexInList(list, 2, 1, () => false), 5); -}); - -test("toOverrideList produces the sparse, sorted payload list", () => { - const overrides = setOverrides({}, [14, 10, 12], CLASS_DAMAGED); - assert.deepEqual(toOverrideList(overrides), [ - { id: 10, class: CLASS_DAMAGED }, - { id: 12, class: CLASS_DAMAGED }, - { id: 14, class: CLASS_DAMAGED }, - ]); - assert.deepEqual(toOverrideList({}), []); - assert.deepEqual(toOverrideList(null), []); -}); - -test("buildSavePayload matches the PutEditedPredictions contract", () => { - const payload = buildSavePayload({ - projectId: "p1", - imageLayerId: "l1", - modelId: "m1", - threshold: 0.42, - unknownThreshold: 0.1, - overrides: setOverride({}, 7, CLASS_UNKNOWN), - }); - assert.deepEqual(payload, { - projectId: "p1", - imageLayerId: "l1", - modelId: "m1", - threshold: 0.42, - unknownThreshold: 0.1, - overrides: [{ id: 7, class: CLASS_UNKNOWN }], - }); -}); - -test("version helpers order the history newest-first", () => { - const versions = [ - { version: 1, threshold: 0.5 }, - { version: 3, threshold: 0.7 }, - { version: 2, threshold: 0.6 }, - ]; - assert.equal(latestVersion(versions).version, 3); - assert.deepEqual( - sortVersionsDescending(versions).map((v) => v.version), - [3, 2, 1] - ); - assert.equal(latestVersion([]), null); - assert.equal(latestVersion(undefined), null); -}); - -test("toPercentLabel renders fractions as percentages", () => { - assert.equal(toPercentLabel(0.5), "50%"); - assert.equal(toPercentLabel(0.517, 1), "51.7%"); - assert.equal(toPercentLabel(undefined), "—"); -}); - -// ── Preparation / polling state (predictionPrep.js) ───────────────────────── -// The editor cannot draw anything until the queued prep job has written the -// footprint PMTiles and the score sidecar, so the "trigger it, then wait" -// decisions are pure functions and get tested here rather than inside the -// component. - -test("normalizePrepStatus accepts the repo status vocabulary defensively", () => { - assert.equal(normalizePrepStatus("Queued"), PREP_STATUS_QUEUED); - assert.equal(normalizePrepStatus("InProgress"), PREP_STATUS_IN_PROGRESS); - // Case and stray whitespace must not break the state machine. - assert.equal(normalizePrepStatus(" inprogress "), PREP_STATUS_IN_PROGRESS); - // Anything unknown collapses to "" — treated as "not terminal, keep going". - assert.equal(normalizePrepStatus("Exploded"), ""); - assert.equal(normalizePrepStatus(undefined), ""); - assert.equal(normalizePrepStatus(null), ""); - assert.equal(normalizePrepStatus(42), ""); -}); - -test("only Failed and Cancelled are terminal", () => { - assert.equal(isTerminalPrepStatus(PREP_STATUS_FAILED), true); - assert.equal(isTerminalPrepStatus(PREP_STATUS_CANCELLED), true); - assert.equal(isTerminalPrepStatus(PREP_STATUS_QUEUED), false); - assert.equal(isTerminalPrepStatus(PREP_STATUS_IN_PROGRESS), false); - assert.equal(isTerminalPrepStatus("Processed"), false); - assert.equal(isTerminalPrepStatus(undefined), false); -}); - -test("isPrepReady requires both artifacts and never assumes readiness", () => { - assert.equal(isPrepReady({ tilesReady: true, attrsReady: true }), true); - assert.equal(isPrepReady({ tilesReady: true, attrsReady: false }), false); - assert.equal(isPrepReady({ tilesReady: false, attrsReady: true }), false); - // A backend that omits the flags entirely means "not ready". - assert.equal(isPrepReady({}), false); - assert.equal(isPrepReady(null), false); - // Truthy-but-not-true values are not readiness either. - assert.equal(isPrepReady({ tilesReady: 1, attrsReady: "yes" }), false); -}); - -test("evaluatePrepState keeps polling while the job is queued or running", () => { - const queued = evaluatePrepState( - { tilesReady: false, attrsReady: false, predictionTilesStatus: "Queued" }, - 0 - ); - assert.equal(queued.phase, PREP_PHASE_WAITING); - assert.equal(queued.shouldPoll, true); - assert.equal(queued.ready, false); - assert.equal(queued.status, PREP_STATUS_QUEUED); - assert.equal(shouldPollPrep(queued.phase), true); - - // Unknown/missing status is not an error: keep waiting. - const unknown = evaluatePrepState({ tilesReady: false, attrsReady: false }, 3); - assert.equal(unknown.phase, PREP_PHASE_WAITING); - assert.equal(unknown.status, ""); - assert.equal(unknown.attempt, 3); - - // Half-prepared is still not ready. - const half = evaluatePrepState( - { tilesReady: true, attrsReady: false, predictionTilesStatus: "InProgress" }, - 1 - ); - assert.equal(half.phase, PREP_PHASE_WAITING); -}); - -test("evaluatePrepState stops as soon as both artifacts exist", () => { - const ready = evaluatePrepState( - { tilesReady: true, attrsReady: true, predictionTilesStatus: "Processed" }, - 7 - ); - assert.equal(ready.phase, PREP_PHASE_READY); - assert.equal(ready.ready, true); - assert.equal(ready.shouldPoll, false); - assert.equal(shouldPollPrep(ready.phase), false); -}); - -test("existing artifacts win over a stale Failed status", () => { - // A previous run failed, but the files are on disk: the editor can open, so - // it must open rather than showing an error. - const result = evaluatePrepState( - { tilesReady: true, attrsReady: true, predictionTilesStatus: "Failed" }, - 2 - ); - assert.equal(result.phase, PREP_PHASE_READY); - assert.equal(result.shouldPoll, false); -}); - -test("evaluatePrepState treats Failed and Cancelled as terminal", () => { - for (const status of [PREP_STATUS_FAILED, PREP_STATUS_CANCELLED]) { - const result = evaluatePrepState( - { - tilesReady: false, - attrsReady: false, - predictionTilesStatus: status, - predictionTilesStatusMessage: "tippecanoe exited 1", - }, - 4 - ); - assert.equal(result.phase, PREP_PHASE_FAILED); - assert.equal(result.shouldPoll, false); - assert.equal(result.status, status); - assert.equal(result.statusMessage, "tippecanoe exited 1"); - } -}); - -test("evaluatePrepState gives up at the attempt cap", () => { - const running = { tilesReady: false, attrsReady: false, predictionTilesStatus: "InProgress" }; - const justUnder = evaluatePrepState(running, MAX_PREP_POLL_ATTEMPTS - 1); - assert.equal(justUnder.phase, PREP_PHASE_WAITING); - - const atCap = evaluatePrepState(running, MAX_PREP_POLL_ATTEMPTS); - assert.equal(atCap.phase, PREP_PHASE_TIMED_OUT); - assert.equal(atCap.shouldPoll, false); - - // A tighter cap (used by the test below) short-circuits the same way. - assert.equal(evaluatePrepState(running, 3, 3).phase, PREP_PHASE_TIMED_OUT); -}); - -test("evaluatePrepState reads the status off an enqueue response too", () => { - // PutPreparePredictionTilesQueueMessage returns `status`, the session - // returns `predictionTilesStatus`; both must be understood. - const result = evaluatePrepState( - { tilesReady: false, attrsReady: false, status: "Failed" }, - 0 - ); - assert.equal(result.phase, PREP_PHASE_FAILED); -}); - -test("nextPollAttempt counts up and repairs junk input", () => { - assert.equal(nextPollAttempt(0), 1); - assert.equal(nextPollAttempt(1), 2); - assert.equal(nextPollAttempt(undefined), 1); - assert.equal(nextPollAttempt(-5), 1); - assert.equal(nextPollAttempt(NaN), 1); - assert.equal(nextPollAttempt(2.7), 3); -}); - -test("a transient poll error keeps waiting until the cap", () => { - const current = { - phase: PREP_PHASE_WAITING, - status: PREP_STATUS_IN_PROGRESS, - statusMessage: "reprojecting", - attempt: 1, - }; - const blip = prepStateAfterPollError(current, "Error fetching.", 5); - assert.equal(blip.phase, PREP_PHASE_WAITING); - assert.equal(blip.shouldPoll, true); - assert.equal(blip.attempt, 2); - // The last known good status survives the blip so the card doesn't reset. - assert.equal(blip.status, PREP_STATUS_IN_PROGRESS); - assert.equal(blip.statusMessage, "reprojecting"); - assert.equal(blip.error, "Error fetching."); - - // Repeated failures still terminate at the cap. - const exhausted = prepStateAfterPollError({ ...current, attempt: 4 }, "", 5); - assert.equal(exhausted.phase, PREP_PHASE_TIMED_OUT); - assert.equal(exhausted.shouldPoll, false); - assert.equal(exhausted.error, "The preparation status could not be read."); -}); - -test("a polling run always terminates within the cap", () => { - // Drive the state machine the way the component does — evaluate, count, - // repeat — against a job that never finishes, and prove it stops. - const stuck = { tilesReady: false, attrsReady: false, predictionTilesStatus: "InProgress" }; - const cap = 8; - let state = evaluatePrepState(stuck, 0, cap); - let polls = 0; - while (shouldPollPrep(state.phase)) { - polls++; - assert.ok(polls <= cap, "must not poll past the cap"); - state = evaluatePrepState(stuck, nextPollAttempt(state.attempt), cap); - } - assert.equal(state.phase, PREP_PHASE_TIMED_OUT); - assert.equal(polls, cap); - - // The same loop against a job that finishes on the third poll stops early. - let finished = evaluatePrepState(stuck, 0, cap); - let ticks = 0; - while (shouldPollPrep(finished.phase)) { - ticks++; - const session = ticks < 3 ? stuck : { tilesReady: true, attrsReady: true }; - finished = evaluatePrepState(session, nextPollAttempt(finished.attempt), cap); - } - assert.equal(finished.phase, PREP_PHASE_READY); - assert.equal(ticks, 3); -}); - -test("the poll interval and cap are a sane bounded wait", () => { - assert.equal(PREP_POLL_INTERVAL_MS, 5000); - // 360 x 5s = 30 minutes: generous for a container-runner job, finite for a - // forgotten tab. - assert.equal((MAX_PREP_POLL_ATTEMPTS * PREP_POLL_INTERVAL_MS) / 60000, 30); -}); - -test("buildPrepRequest only sends force when retrying", () => { - assert.deepEqual( - buildPrepRequest({ projectId: "p1", imageLayerId: "l1", modelId: "m1" }), - { projectId: "p1", imageLayerId: "l1", modelId: "m1" } - ); - assert.deepEqual( - buildPrepRequest({ - projectId: "p1", - imageLayerId: "l1", - modelId: "m1", - force: true, - }), - { projectId: "p1", imageLayerId: "l1", modelId: "m1", force: true } - ); -}); - -test("applyPrepResponse folds the enqueue reply into the session", () => { - const session = { - buildingCount: 10, - tilesReady: false, - attrsReady: false, - predictionTilesStatus: "Failed", - }; - const merged = applyPrepResponse(session, { - queued: true, - tilesReady: false, - attrsReady: true, - status: "Queued", - }); - assert.equal(merged.predictionTilesStatus, PREP_STATUS_QUEUED); - assert.equal(merged.attrsReady, true); - assert.equal(merged.tilesReady, false); - assert.equal(merged.buildingCount, 10, "unrelated fields survive"); - assert.deepEqual(session.predictionTilesStatus, "Failed", "input unchanged"); - - // apiPut hands back a bare 409 for a conflict, and a dead endpoint can give - // us anything at all — keep the session we already have. - assert.equal(applyPrepResponse(session, 409), session); - assert.equal(applyPrepResponse(session, null), session); - assert.equal(applyPrepResponse(session, "nope"), session); - assert.equal(applyPrepResponse(session, []), session); - assert.deepEqual(applyPrepResponse(null, null), {}); -}); - -test("prep card copy reports what is outstanding", () => { - assert.equal( - describeOutstandingArtifacts({ tilesReady: false, attrsReady: false }), - "Still generating footprint tiles and per-building prediction scores." - ); - assert.equal( - describeOutstandingArtifacts({ tilesReady: true, attrsReady: false }), - "Still generating per-building prediction scores." - ); - assert.equal( - describeOutstandingArtifacts({ tilesReady: true, attrsReady: true }), - "" - ); - - assert.equal(prepStatusLabel(PREP_STATUS_QUEUED), "Queued"); - assert.equal(prepStatusLabel(PREP_STATUS_IN_PROGRESS), "In progress"); - assert.equal(prepStatusLabel(PREP_STATUS_FAILED), "Failed"); - assert.equal(prepStatusLabel(PREP_STATUS_CANCELLED), "Cancelled"); - // Unknown/missing must still render something sensible. - assert.equal(prepStatusLabel(undefined), "Starting"); - assert.equal(prepStatusLabel("Weird"), "Starting"); -}); - -// ── Swipe comparison map (predictionSwipe.js) ─────────────────────────────── - -test("swipe mode follows the imagery the layer actually has", () => { - // Pre-event tiles present: compare pre against post. - assert.equal( - resolveSwipeMode({ - preEventTileUrl: "https://x/pre/{z}/{x}/{y}.png", - postEventTileUrl: "https://x/post/{z}/{x}/{y}.png", - }), - SWIPE_MODE_PRE_POST - ); - - // No pre-event tiles: the basemap stands in on the comparison pane. - assert.equal( - resolveSwipeMode({ postEventTileUrl: "https://x/post/{z}/{x}/{y}.png" }), - SWIPE_MODE_BASEMAP_POST - ); - assert.equal( - resolveSwipeMode({ - preEventTileUrl: " ", - postEventTileUrl: "https://x/post/{z}/{x}/{y}.png", - }), - SWIPE_MODE_BASEMAP_POST, - "a blank pre URL is not pre-event imagery" - ); - - // Without post-event imagery there is nothing to compare against, so the - // editor must not offer a swipe at all. - assert.equal(resolveSwipeMode(null), SWIPE_MODE_NONE); - assert.equal(resolveSwipeMode(undefined), SWIPE_MODE_NONE); - assert.equal(resolveSwipeMode({}), SWIPE_MODE_NONE); - assert.equal( - resolveSwipeMode({ preEventTileUrl: "https://x/pre/{z}/{x}/{y}.png" }), - SWIPE_MODE_NONE - ); - - assert.equal(isSwipeAvailable(SWIPE_MODE_PRE_POST), true); - assert.equal(isSwipeAvailable(SWIPE_MODE_BASEMAP_POST), true); - assert.equal(isSwipeAvailable(SWIPE_MODE_NONE), false); - assert.equal(isSwipeAvailable(undefined), false); -}); - -test("only the pre/post mode overlays imagery on the comparison pane", () => { - const imagery = { - preEventTileUrl: " https://x/pre/{z}/{x}/{y}.png ", - postEventTileUrl: "https://x/post/{z}/{x}/{y}.png", - }; - assert.equal( - swipeComparisonTileUrl(imagery, SWIPE_MODE_PRE_POST), - "https://x/pre/{z}/{x}/{y}.png" - ); - // Basemap mode draws the map's own basemap — no tile layer on top. - assert.equal(swipeComparisonTileUrl(imagery, SWIPE_MODE_BASEMAP_POST), ""); - assert.equal(swipeComparisonTileUrl(imagery, SWIPE_MODE_NONE), ""); - assert.equal(swipeComparisonTileUrl(null, SWIPE_MODE_PRE_POST), ""); -}); - -test("swipe labels name the comparison the analyst is getting", () => { - assert.equal( - swipeToggleLabel(SWIPE_MODE_PRE_POST), - "Swipe: pre-event vs post-event" - ); - assert.equal( - swipeToggleLabel(SWIPE_MODE_BASEMAP_POST), - "Swipe: basemap vs post-event" - ); - assert.equal( - swipeToggleLabel(SWIPE_MODE_NONE), - "Swipe comparison unavailable" - ); - - assert.equal(swipeLeftPaneLabel(SWIPE_MODE_PRE_POST), "Pre-event imagery"); - assert.equal(swipeLeftPaneLabel(SWIPE_MODE_BASEMAP_POST), "Basemap"); - assert.equal(swipeLeftPaneLabel(SWIPE_MODE_NONE), ""); - assert.equal( - swipeRightPaneLabel(SWIPE_MODE_PRE_POST), - "Post-event imagery" - ); - assert.equal( - swipeRightPaneLabel(SWIPE_MODE_BASEMAP_POST), - "Post-event imagery" - ); - assert.equal(swipeRightPaneLabel(SWIPE_MODE_NONE), ""); -}); - -test("swipe hint describes the divider directions the right way round", () => { - // The comparison map is the SwipeMap PRIMARY and sits LEFT of the divider, - // so dragging LEFT uncovers MORE post-event imagery. A previous PR shipped - // this backwards; pin it down. - const hint = swipeModeHint(SWIPE_MODE_PRE_POST); - assert.match(hint, /Pre-event imagery sits left of the divider/); - assert.match(hint, /left for more post-event/); - assert.match(hint, /right for more pre-event imagery/); - assert.match(hint, /Editing works on both sides/); - assert.match( - swipeModeHint(SWIPE_MODE_BASEMAP_POST), - /Basemap sits left of the divider/ - ); - assert.match( - swipeModeHint(SWIPE_MODE_NONE), - /no post-event imagery to compare against/ - ); -}); - -test("A / S / D snap the divider left / centre / right", () => { - assert.equal(dividerPositionForKey("a", 800), 0); - assert.equal(dividerPositionForKey("s", 800), 400); - assert.equal(dividerPositionForKey("d", 800), 800); - // Shift-held (or caps-locked) keys are the same shortcut. - assert.equal(dividerPositionForKey("A", 800), 0); - assert.equal(dividerPositionForKey("S", 800), 400); - assert.equal(dividerPositionForKey("D", 800), 800); - - // Anything else is not ours to handle. - for (const key of ["1", "w", "ArrowLeft", " ", "", null, undefined, 5]) { - assert.equal(dividerPositionForKey(key, 800), null); - } - - // No usable width yet (map area not laid out): do nothing rather than - // silently park the divider at 0. - for (const width of [0, -10, NaN, Infinity, null, undefined, "wide"]) { - assert.equal(dividerPositionForKey("s", width), null); - } - assert.equal(dividerPositionForKey("s", "800"), 400, "numeric strings work"); -}); diff --git a/ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx b/ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx index a9f59c83..651c3c74 100644 --- a/ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx +++ b/ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx @@ -84,21 +84,13 @@ const EmbeddingModelRow = ({ const isProcessed = model.status === "Processed"; const hasPredictions = !!model.gpkgUrl; - // A GeoPackage on its own is not enough to edit: "Clear labels" also writes - // one, with zero predicted buildings in it. predictedBuildingCount is what - // tells us there is actually something to review. - const canEditPredictions = - hasPredictions && (model.predictedBuildingCount ?? 0) > 0; - const editTooltip = canEditPredictions - ? "Review and edit this model's predictions, then save them as a new version" - : "Predict buildings in the Interactive Labeler before editing predictions"; + // Viewing results opens the visualizer, which is also where predictions are + // reviewed and edited. `predictionsReady` is the server-derived readiness + // flag; models saved before it existed fall back to "a GeoPackage exists". + const canViewResults = model.predictionsReady ?? hasPredictions; + const viewResultsTooltip = + "Predict buildings in the Interactive Labeler before viewing results"; - function handleEditPredictions() { - if (!canEditPredictions) return; - navigate( - `/edit-predictions/${projectId}/${imageLayerId}/${model.modelId}` - ); - } const createdDate = model.creationDate ? `${model.creationDate.substring(0, 10)} ${model.creationDate.substring( 11, @@ -123,6 +115,20 @@ const EmbeddingModelRow = ({ const resultsMenu = { items: [ + { + // Same destination and ordering as the standard workflow's Results + // menu (ModelResultsButton): View first, downloads/reports after. + key: "viewResults", + text: "View", + icon: , + disabled: !canViewResults, + tooltip: viewResultsTooltip, + onClick: () => { + navigate( + `/visualizer/${projectId}/${imageLayerId}/${model.modelId}` + ); + }, + }, { key: "downloadGeopackage", text: "Download Geopackage (.gpkg)", @@ -176,6 +182,35 @@ const EmbeddingModelRow = ({ ], }; + // Rendered identically by the mobile and desktop layouts below. Disabled + // Fluent menu items stay hoverable/focusable, so a tooltip can explain why + // an action isn't available yet. + const renderResultsMenuItems = () => + resultsMenu.items.map((mi) => { + const menuItem = ( + + {mi.text} + + ); + return mi.disabled && mi.tooltip ? ( + + {menuItem} + + ) : ( + menuItem + ); + }); + const moreMenuOptions = { items: [ { @@ -331,41 +366,15 @@ const EmbeddingModelRow = ({ appearance="primary" id={"embeddingResults" + index} className="dashboard-button ms-2" - disabled={!hasPredictions} + disabled={!(hasPredictions || canViewResults)} > Results - - {resultsMenu.items.map((mi) => ( - - {mi.text} - - ))} - + {renderResultsMenuItems()} - - -
@@ -463,37 +472,15 @@ const EmbeddingModelRow = ({ appearance="primary" id={"embeddingResults" + index} className="dashboard-button" - disabled={!hasPredictions} + disabled={!(hasPredictions || canViewResults)} > Results - - {resultsMenu.items.map((mi) => ( - - {mi.text} - - ))} - + {renderResultsMenuItems()} - - - - {resultsMenuOptions(model).items.map((mi) => ( - - {mi.text} - - ))} + {resultsMenuOptions(model).items.map((mi) => { + const menuItem = ( + + {mi.text} + + ); + // Disabled Fluent menu items stay hoverable/focusable, so a + // tooltip can explain why the action isn't available yet. + return mi.disabled && mi.tooltip ? ( + + {menuItem} + + ) : ( + menuItem + ); + })} - - - {model.artifacts && model.artifacts.zipStatusMessage && ( option.key === "predictedDamageLayer" + ); + const footprintOption = layerOptions.find( + (option) => option.key === "footprints" + ); + const showFootprintLegend = !!footprintOption && !footprintOption.disabled; + + const footprintLegend = [ + { label: "Predicted damaged", className: styles.damagedSwatch }, + { label: "Predicted not damaged", className: styles.notDamagedSwatch }, + { label: "Unknown / uncertain", className: styles.unknownSwatch }, + { label: "Not yet classified", className: styles.pendingSwatch }, + ]; + return ( <>
- - togglePredictedDamageLayerVisibility( - "predictedDamageLayer", - data.checked - ) - } - /> - - togglePredictedDamageLayerVisibility( - "predictionsLayer", - data.checked - ) - } - /> + {layerOptions.map((option) => { + const checkbox = ( + + onLayerVisibilityChange(option.key, data.checked) + } + /> + ); + // A disabled Fluent control swallows pointer events, so the + // tooltip goes on a wrapper rather than the checkbox. + return option.disabled ? ( + +
{checkbox}
+
+ ) : ( + checkbox + ); + })}
@@ -121,18 +169,34 @@ const InfoPanel = ({ Legend -
- {DAMAGE_LEGEND.map((item) => ( -
-
- ))} -
+ {hasDamageRaster && ( +
+ {DAMAGE_LEGEND.map((item) => ( +
+
+ ))} +
+ )} + + {showFootprintLegend && ( +
+ {footprintLegend.map((item) => ( +
+
+ ))} +
+ )} + + +
+ {/* PRE DISASTER */} +
Pre disaster imagery @@ -102,52 +145,63 @@ const Labels = ({ {convertPreOrPostEventImagerySource( - visualizerResults.preDisasterImagery.url, visualizerResults.sourceTypePreEvent + visualizerResults.preDisasterImagery?.url, + visualizerResults.sourceTypePreEvent )}
{/* POST DISASTER */} - -
- - Post disaster imagery - - - {convertPreOrPostEventImageryDate( - visualizerResults.imageryCaptureDatePostEvent - )} - - - {convertPreOrPostEventImagerySource( - visualizerResults.postDisasterImagery.url, visualizerResults.sourceTypePostEvent - )} - -
+ {/* Top-right, where the edit panel goes: it steps aside in edit mode. */} + + {!isEditMode && ( +
+ + Post disaster imagery + + + {convertPreOrPostEventImageryDate( + visualizerResults.imageryCaptureDatePostEvent + )} + + + {convertPreOrPostEventImagerySource( + visualizerResults.postDisasterImagery?.url, + visualizerResults.sourceTypePostEvent + )} + +
+ )} {/* CONTROLS AND AND INFOPANEL */} - - - - + {/* Both are read-only views of the layers; the edit panel replaces + them so the two never fight for the same corner. */} + + {!isEditMode && ( + <> + + + + + )} )} @@ -155,11 +209,18 @@ const Labels = ({ }; Labels.propTypes = { - togglePredictedDamageLayerVisibility: PropType.func.isRequired, resetMapPosition: PropType.func.isRequired, visualizerResults: PropType.object.isRequired, setSwipeStateMobile: PropType.func.isRequired, swipeStateMobile: PropType.string.isRequired, + layerOptions: PropType.array.isRequired, + layerVisibility: PropType.object.isRequired, + onLayerVisibilityChange: PropType.func.isRequired, + isEditMode: PropType.bool.isRequired, + canEdit: PropType.bool.isRequired, + editTooltip: PropType.string.isRequired, + onToggleEditMode: PropType.func.isRequired, + navigationControlsClassName: PropType.string, }; export default Labels; diff --git a/ui/src/Components/PredictionEditor/PredictionEditorRightPanel.jsx b/ui/src/Components/Visualizer/PredictionEditPanel.jsx similarity index 83% rename from ui/src/Components/PredictionEditor/PredictionEditorRightPanel.jsx rename to ui/src/Components/Visualizer/PredictionEditPanel.jsx index 5600a421..bce630a2 100644 --- a/ui/src/Components/PredictionEditor/PredictionEditorRightPanel.jsx +++ b/ui/src/Components/Visualizer/PredictionEditPanel.jsx @@ -1,14 +1,18 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. // -// Right-hand control panel for the Prediction Editor: class counts, the -// swipe imagery-comparison toggle, the filter + prev/next traversal over the -// filtered set, the threshold sliders (only for models that support them), -// the live "would change class" readout, the save action, and the -// saved-version history. +// Edit-mode control panel for the results page: class counts, the filter + +// prev/next traversal over the filtered set, the threshold sliders (only for +// models that support them), the live "would change class" readout, the save +// action, and the saved-version history. // -// Layout and interaction mirror BuildingValidationRightPanel so the two -// review screens feel like the same tool. Every colour comes from Fluent +// This is the overlay the pencil affordance opens over the results view. It +// was the standalone Prediction Editor's right panel and is deliberately +// unchanged in look: the same review controls, now on the map the analyst was +// already looking at. The swipe toggle is gone because the results page +// always has the swipe map up; the hint under the header names the divider +// instead. Layout and interaction mirror BuildingValidationRightPanel so the +// review screens feel like one tool, and every colour comes from Fluent // tokens, so the panel follows the light/dark theme. import PropTypes from "prop-types"; import { @@ -16,6 +20,7 @@ import { Divider, Dropdown, Field, + Badge, MessageBar, MessageBarBody, MessageBarTitle, @@ -23,13 +28,13 @@ import { Radio, RadioGroup, Slider, - Switch, Text, makeStyles, tokens, } from "@fluentui/react-components"; +import { FluentIcon } from "../../util/icons"; import KeyboardShortcutHelp from "../KeyboardShortcutHelp"; -import { PREDICTION_EDITOR_SHORTCUTS } from "../keyboardShortcuts"; +import { PREDICTION_EDIT_SHORTCUTS } from "../keyboardShortcuts"; import { CLASS_DAMAGED, CLASS_LABELS, @@ -41,16 +46,11 @@ import { sortVersionsDescending, toPercentLabel, } from "./predictionClassify"; -import { - SWIPE_MODE_NONE, - isSwipeAvailable, - swipeModeHint, - swipeToggleLabel, -} from "./predictionSwipe"; +import { describeServedVersion } from "./predictionResults"; const CLASS_ORDER = [CLASS_DAMAGED, CLASS_NOT_DAMAGED, CLASS_UNKNOWN]; -// Keyboard hints shown on the class buttons, matching PREDICTION_EDITOR_SHORTCUTS. +// Keyboard hints shown on the class buttons, matching PREDICTION_EDIT_SHORTCUTS. const CLASS_HOTKEYS = { [CLASS_DAMAGED]: "1", [CLASS_NOT_DAMAGED]: "2", @@ -215,6 +215,18 @@ const useStyles = makeStyles({ fontSize: tokens.fontSizeBase100, lineHeight: tokens.lineHeightBase200, }, + // The version the map is drawing right now, so the history never leaves the + // analyst guessing which one they are editing on top of. + servedVersionRow: { + borderColor: tokens.colorBrandStroke1, + backgroundColor: tokens.colorNeutralBackground1Selected, + }, + versionHeader: { + display: "flex", + alignItems: "baseline", + justifyContent: "space-between", + gap: tokens.spacingHorizontalXS, + }, versionTitle: { fontWeight: tokens.fontWeightSemibold, }, @@ -228,8 +240,9 @@ const useStyles = makeStyles({ }, }); -const PredictionEditorRightPanel = ({ - session, +const PredictionEditPanel = ({ + flavor = "", + supportsThreshold = true, counts, total, editedCount, @@ -251,14 +264,14 @@ const PredictionEditorRightPanel = ({ setUnknownThreshold, baseline, changeCount, - swipeMode = SWIPE_MODE_NONE, - swipeOn = false, - onSwipeChange, + swipeHint = "", + onExit, onSave, isSaving, saveError, savedResult, versions, + activeVersion = null, }) => { const styles = useStyles(); @@ -281,7 +294,6 @@ const PredictionEditorRightPanel = ({ : `${filteredIndices.length} buildings match — press Next to start`; const orderedVersions = sortVersionsDescending(versions); - const swipeAvailable = isSwipeAvailable(swipeMode); const thresholdChanged = toPercent(threshold) !== toPercent(baseline?.threshold) || toPercent(unknownThreshold) !== toPercent(baseline?.unknownThreshold); @@ -294,7 +306,10 @@ const PredictionEditorRightPanel = ({
{total.toLocaleString()} buildings - {session?.flavor ? ` · ${session.flavor} model` : ""} + {flavor ? ` · ${flavor} model` : ""} +
+
+ {describeServedVersion(activeVersion)}
@@ -322,23 +337,14 @@ const PredictionEditorRightPanel = ({ - {/* Imagery comparison. The mode is decided by the layer's imagery, so - pre-vs-post is simply not on offer when there are no pre-event - tiles — the label always names the comparison being shown. */} -
- onSwipeChange?.(data.checked)} - /> -
{swipeModeHint(swipeMode)}
-
+ {/* The results page always has the swipe map up, so there is nothing + to switch on here — only the divider's directions to explain. */} + {swipeHint ?
{swipeHint}
: null} - + {swipeHint ? : null} - {/* Thresholds — only models that expose a score support these. */} - {session?.supportsThreshold && ( + {/* Thresholds — only models that expose a real score support these. */} + {supportsThreshold && (
)} - {!session?.supportsThreshold && ( + {!supportsThreshold && (
This model does not expose a tunable score, so classes come from its own decisions plus your edits. @@ -515,9 +521,23 @@ const PredictionEditorRightPanel = ({ ) : (
{orderedVersions.map((version) => ( -
-
- Version {version.version} +
+
+ + Version {version.version} + + {version.version === activeVersion && ( + + On the map + + )}
{formatDate(version.createdAt)} @@ -533,7 +553,7 @@ const PredictionEditorRightPanel = ({ )}
- +
@@ -556,17 +576,21 @@ const PredictionEditorRightPanel = ({ +
); }; -PredictionEditorRightPanel.propTypes = { - session: PropTypes.shape({ - flavor: PropTypes.string, - supportsThreshold: PropTypes.bool, - buildingCount: PropTypes.number, - }), +PredictionEditPanel.propTypes = { + flavor: PropTypes.string, + supportsThreshold: PropTypes.bool, counts: PropTypes.object.isRequired, total: PropTypes.number.isRequired, editedCount: PropTypes.number.isRequired, @@ -598,9 +622,8 @@ PredictionEditorRightPanel.propTypes = { unknownThreshold: PropTypes.number, }).isRequired, changeCount: PropTypes.number.isRequired, - swipeMode: PropTypes.string, - swipeOn: PropTypes.bool, - onSwipeChange: PropTypes.func, + swipeHint: PropTypes.string, + onExit: PropTypes.func.isRequired, onSave: PropTypes.func.isRequired, isSaving: PropTypes.bool.isRequired, saveError: PropTypes.string, @@ -610,6 +633,7 @@ PredictionEditorRightPanel.propTypes = { editedCount: PropTypes.number, }), versions: PropTypes.array.isRequired, + activeVersion: PropTypes.number, }; -export default PredictionEditorRightPanel; +export default PredictionEditPanel; diff --git a/ui/src/Components/Visualizer/PredictionStatusNote.jsx b/ui/src/Components/Visualizer/PredictionStatusNote.jsx new file mode 100644 index 00000000..b2dc56f3 --- /dev/null +++ b/ui/src/Components/Visualizer/PredictionStatusNote.jsx @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// The results page's note about its predicted-building layer. +// +// An empty map is the worst possible answer to "where are the predictions?", +// and it is the answer an embedding model gives by default: it ships no +// raster at all, so if the footprint tiles are not built yet there is +// literally nothing on screen. This card says which of those it is — +// loading, still being prepared by the tiling job, nothing to show, or +// broken — and, when the job has given up, offers to queue it again. +// +// All copy comes from predictionResults.js / predictionPrep.js so the wording +// is decided in pure, unit-tested code rather than in JSX. +import PropTypes from "prop-types"; +import { + Button, + MessageBar, + MessageBarActions, + MessageBarBody, + MessageBarTitle, + ProgressBar, + makeStyles, + tokens, +} from "@fluentui/react-components"; +import { FluentIcon } from "../../util/icons"; +import { + FOOTPRINTS_LOADING, + FOOTPRINTS_PREPARING, + describeFootprintStatus, +} from "./predictionResults.js"; +import { + PREP_PHASE_FAILED, + PREP_PHASE_TIMED_OUT, + describeOutstandingArtifacts, + prepStatusLabel, +} from "./predictionPrep.js"; + +const useStyles = makeStyles({ + // Centred under the app header, clear of the pre/post imagery blocks in the + // corners and below the edit panel's stacking level. + root: { + position: "absolute", + top: "66px", + left: "50%", + transform: "translateX(-50%)", + zIndex: 900, + boxSizing: "border-box", + width: "min(560px, calc(100% - 32px))", + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalXS, + padding: tokens.spacingHorizontalM, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusMedium, + boxShadow: tokens.shadow16, + }, + detail: { + color: tokens.colorNeutralForeground3, + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase200, + }, + statusRow: { + display: "flex", + flexWrap: "wrap", + alignItems: "center", + gap: tokens.spacingHorizontalXS, + color: tokens.colorNeutralForeground2, + fontSize: tokens.fontSizeBase200, + }, + statusValue: { + padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`, + borderRadius: tokens.borderRadiusCircular, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground4, + fontWeight: tokens.fontWeightSemibold, + }, +}); + +const PredictionStatusNote = ({ + status, + prepState = null, + session = null, + error = "", + detail = "", + onRetry, + onDismiss, +}) => { + const styles = useStyles(); + // A load failure carries its own reason; otherwise the tiling job's own + // status message, then the server's readiness explanation, beat our generic + // copy — they know which workflow the model came from. + const message = describeFootprintStatus(status, { + detail: error || prepState?.statusMessage || detail, + }); + if (!message) return null; + + const isPreparing = status === FOOTPRINTS_PREPARING; + const isWaiting = + isPreparing && + prepState?.phase !== PREP_PHASE_FAILED && + prepState?.phase !== PREP_PHASE_TIMED_OUT; + const canRetry = + typeof onRetry === "function" && + (prepState?.phase === PREP_PHASE_FAILED || + prepState?.phase === PREP_PHASE_TIMED_OUT); + const outstanding = isPreparing ? describeOutstandingArtifacts(session) : ""; + + return ( +
+ + + {message.title} + {message.body} + + {(canRetry || typeof onDismiss === "function") && ( + } + onClick={onDismiss} + /> + ) : undefined + } + > + {canRetry && ( + + )} + + )} + + + {(isWaiting || status === FOOTPRINTS_LOADING) && ( + + )} + + {isPreparing && ( +
+ Status + + {prepStatusLabel(prepState?.status)} + + {outstanding ? {outstanding} : null} +
+ )} + + {prepState?.error ? ( +
{prepState.error}
+ ) : null} +
+ ); +}; + +PredictionStatusNote.propTypes = { + status: PropTypes.string.isRequired, + prepState: PropTypes.shape({ + phase: PropTypes.string, + status: PropTypes.string, + statusMessage: PropTypes.string, + attempt: PropTypes.number, + error: PropTypes.string, + }), + session: PropTypes.object, + error: PropTypes.string, + detail: PropTypes.string, + onRetry: PropTypes.func, + onDismiss: PropTypes.func, +}; + +export default PredictionStatusNote; diff --git a/ui/src/Components/Visualizer/Visualizer.jsx b/ui/src/Components/Visualizer/Visualizer.jsx index 6b79cfd6..cd80a5fe 100644 --- a/ui/src/Components/Visualizer/Visualizer.jsx +++ b/ui/src/Components/Visualizer/Visualizer.jsx @@ -1,34 +1,158 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +// +// The results page: one model's predictions over the imagery it was run on. +// +// Two Azure Maps instances are handed to atlas.SwipeMap — the PRIMARY carries +// the pre-event imagery and is revealed LEFT of the divider, the SECONDARY +// carries the post-event imagery and is clipped so it is revealed RIGHT of it. +// Moving the divider left therefore uncovers more of the post-event map. +// SwipeMap syncs both cameras internally on every "move", so this file +// deliberately adds no camera-sync handler of its own. +// +// What gets drawn on top of the imagery depends on the workflow that produced +// the model, and both workflows end up in the same place: +// +// • the inference workflow ships pre-coloured rasters (`_visualizer.tif` +// and the raw `_predictions.tif`), while +// • the embedding workflow ships no raster at all. +// +// So the layer that makes this page work for either is the vector one: +// per-building predicted footprints, streamed from the layer's PMTiles +// archive and coloured in the browser from the per-building score sidecar +// (usePredictionArtifacts + usePredictionFootprints). The raster checkboxes +// only appear for the models that actually have those rasters. +// +// The pencil next to Back turns this same view into an editor — same maps, +// same footprints, now clickable — instead of sending the analyst to a +// separate screen. See handleToggleEditMode. + // Dependencies -import { useEffect, useRef, useState, useContext } from "react"; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { apiGet } from "../../util/api"; import { useParams } from "react-router-dom"; import Labels from "./Labels"; import { AppContext } from "../../AppContext"; import PropType from "prop-types"; +import { makeStyles, tokens } from "@fluentui/react-components"; import { convertDateToString } from "../../util/conversion"; import VisualizerImageryControls from "./VisualizerImageryControls" import "../../assets/css/visualizer.css"; import { getAzureMapsAuthOptions } from "../../util/azureMapsAuth"; import { shouldIgnoreShortcut } from "../keyboardShortcuts"; +import { useTheme } from "../../util/ThemeContext.jsx"; +import PredictionEditPanel from "./PredictionEditPanel"; +import PredictionStatusNote from "./PredictionStatusNote"; +import usePredictionArtifacts from "./usePredictionArtifacts"; +import usePredictionFootprints from "./usePredictionFootprints"; +import { + CLASS_DAMAGED, + CLASS_NOT_DAMAGED, + CLASS_UNKNOWN, +} from "./predictionClassify"; +import { + FOOTPRINTS_READY, + canEditFootprints, + describeEditAvailability, + describeUnsavedEdits, + hasRasterLayer, + resolveModelFlavor, + resolveSupportsThreshold, + visualizerLayerOptions, +} from "./predictionResults"; +import { + dividerPositionForKey, + resolveSwipeMode, + swipeModeHint, +} from "./visualizerSwipe"; + +// 1 / 2 / 3 set the selected building's class in edit mode, matching +// PREDICTION_EDIT_SHORTCUTS. +const CLASS_BY_KEY = { + 1: CLASS_DAMAGED, + 2: CLASS_NOT_DAMAGED, + 3: CLASS_UNKNOWN, +}; +const useStyles = makeStyles({ + // Back and the edit affordance sit side by side; stacked, the second button + // would run into the pre-event imagery block just below them. + navigationControls: { + flexDirection: "row", + }, + // Ctrl+drag box-select rectangle. Absolutely positioned inside the + // visualizer container, which shares its top-left corner with both map + // canvases, so the drag offsets need no translation. + selectBox: { + position: "absolute", + display: "none", + zIndex: 900, + pointerEvents: "none", + border: `${tokens.strokeWidthThick} dashed ${tokens.colorBrandStroke1}`, + backgroundColor: tokens.colorBrandBackground2, + opacity: 0.4, + }, + editHint: { + position: "absolute", + bottom: "6px", + left: "50%", + transform: "translateX(-50%)", + zIndex: 900, + padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalM}`, + borderRadius: tokens.borderRadiusMedium, + color: tokens.colorNeutralForeground2, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, + boxShadow: tokens.shadow4, + fontSize: tokens.fontSizeBase100, + whiteSpace: "nowrap", + pointerEvents: "none", + "@media (max-width: 900px)": { + display: "none", + }, + }, +}); const Visualizer = ({ setModalComponent }) => { - Visualizer.propTypes = { - setModalComponent: PropType.func.isRequired, - }; - - // Constants const { projectId, imageLayerId, modelId } = useParams(); + const styles = useStyles(); const [globalVisualizerResults, setGlobalVisualizerResults] = useState({}); - const { setIsLoading, updateAppParams, appParams } = useContext(AppContext); + const { setIsLoading, updateAppParams, appParams, setDialog } = + useContext(AppContext); + const { isDark, palette } = useTheme(); + // The container elements and the Map objects that end up inside them are + // kept apart: the footprint layer needs the maps, the box-select needs the + // DOM, and SwipeMap leaves state on both. + const containerRef = useRef(null); + const primaryContainerRef = useRef(null); + const secondaryContainerRef = useRef(null); + const selectionBoxRef = useRef(null); const primaryMapRef = useRef(null); const secondaryMapRef = useRef(null); const swipeMapRef = useRef(null); const zoomControlRef = useRef(null); + // Both maps build their layers inside an async "ready" handler that fires + // after the constructor returns, so readiness is counted there and mirrored + // into state — the refs alone would never re-run the dependent effects. + const readyCountRef = useRef(0); + const [mapsReady, setMapsReady] = useState(false); const [swipeStateMobile, setSwipeStateMobile] = useState("post"); + const [isEditMode, setIsEditMode] = useState(false); + // The status the analyst last dismissed a note for. Stored rather than a + // plain boolean so a NEW status (preparing -> failed, say) shows up again + // without an effect that resets state behind their back. + const [dismissedNoteStatus, setDismissedNoteStatus] = useState(""); + // Layer checkboxes are controlled from here so edit mode can hide the + // pre-coloured damage raster (it fights with the vector classes underneath) + // and put it back on the way out, with the checkboxes telling the truth + // throughout. + const [layerVisibility, setLayerVisibility] = useState({ + predictedDamageLayer: true, + predictionsLayer: false, + footprints: true, + }); + const rasterVisibilityBeforeEditRef = useRef(null); const [imageryValues, setImageryValues] = useState({ opacity: 1, @@ -37,6 +161,55 @@ const Visualizer = ({ setModalComponent }) => { saturation: 0, }); + const resultsReady = !!globalVisualizerResults.projectName; + + // ── Predicted building footprints ──────────────────────────────────────── + // The artifacts (PMTiles + score sidecar) and the map layers they feed are + // owned by two hooks so this component stays about the page rather than the + // renderer. Both are safe to call before anything has loaded. + const artifacts = usePredictionArtifacts({ + projectId, + imageLayerId, + modelId, + results: globalVisualizerResults, + resultsReady, + }); + + const mapRefs = useMemo(() => [primaryMapRef, secondaryMapRef], []); + + const footprints = usePredictionFootprints({ + projectId, + imageLayerId, + modelId, + mapRefs, + mapsReady, + archiveKey: artifacts.archiveKey, + attrs: artifacts.attrs, + indexByIdRef: artifacts.indexByIdRef, + isEditMode, + themeHostRef: containerRef, + selectionBoxRef, + isDark, + palette, + defaultThreshold: artifacts.session?.defaultThreshold, + onSaved: artifacts.refreshVersions, + }); + + const footprintStatus = artifacts.status; + const canEdit = canEditFootprints(footprintStatus) && footprints.layersReady; + const layerOptions = useMemo( + () => + visualizerLayerOptions({ + results: globalVisualizerResults, + footprintStatus, + }), + [globalVisualizerResults, footprintStatus] + ); + const swipeMode = useMemo( + () => resolveSwipeMode(globalVisualizerResults), + [globalVisualizerResults] + ); + // Visualizer data fetching function async function getVisualizerResults() { setIsLoading(true); @@ -50,16 +223,23 @@ const Visualizer = ({ setModalComponent }) => { ) .then((response) => { setIsLoading(false); - console.log(response); return response; - }) .catch((error) => { + setIsLoading(false); console.error("Error fetching visualizer results:", error); throw error; }); } + // The width the swipe divider is measured against. The container is the + // element both map canvases fill, so it beats window.innerWidth whenever the + // page is not full-bleed. + const swipeAreaWidth = useCallback( + () => containerRef.current?.getBoundingClientRect().width || window.innerWidth, + [] + ); + useEffect(() => { if (swipeMapRef.current) { if (swipeStateMobile === "post") { @@ -68,19 +248,18 @@ const Visualizer = ({ setModalComponent }) => { }); } else { swipeMapRef.current.setOptions({ - sliderPosition: window.innerWidth, + sliderPosition: swipeAreaWidth(), }); } } - }, [swipeStateMobile]); - + }, [swipeStateMobile, swipeAreaWidth]); function checkResponsiveness() { const bootstrapBreakpoint = appParams.bootstrapBreakpoint; if (bootstrapBreakpoint < 4) { if (swipeMapRef.current) { swipeMapRef.current.setOptions({ - sliderPosition: swipeStateMobile === "post" ? 0 : window.innerWidth, + sliderPosition: swipeStateMobile === "post" ? 0 : swipeAreaWidth(), }); } @@ -95,7 +274,7 @@ const Visualizer = ({ setModalComponent }) => { } else { if (swipeMapRef.current) { swipeMapRef.current.setOptions({ - sliderPosition: window.innerWidth / 2 + sliderPosition: swipeAreaWidth() / 2 }); } @@ -114,7 +293,6 @@ const Visualizer = ({ setModalComponent }) => { swipeMapElement.classList.remove('d-none'); } } - } useEffect(() => { @@ -123,38 +301,49 @@ const Visualizer = ({ setModalComponent }) => { }, [appParams.bootstrapBreakpoint]); useEffect(() => { + let cancelled = false; + const initializeMaps = async () => { if (window.atlas) { - - // Create zoom control reference, so it can be referenced when deleting and resetting regarding responsiveness + // Create zoom control reference, so it can be referenced when deleting + // and resetting regarding responsiveness zoomControlRef.current = new window.atlas.control.ZoomControl(); - var visualizerResults = await getVisualizerResults(); + const visualizerResults = await getVisualizerResults(); + if (cancelled) return; updateAppParams({ visualizerTitle: convertToVisualizerTitle(visualizerResults), }); - var authOptions = getAzureMapsAuthOptions(); + const authOptions = getAzureMapsAuthOptions(); // PRE EVENT MAP SETUP - const primaryMap = new window.atlas.Map(primaryMapRef.current, { + const primaryMap = new window.atlas.Map(primaryContainerRef.current, { style: "satellite", authOptions: authOptions, }); // POST EVENT MAP SETUP - const secondaryMap = new window.atlas.Map(secondaryMapRef.current, { + const secondaryMap = new window.atlas.Map(secondaryContainerRef.current, { style: "satellite", authOptions: authOptions, }); - // SwipeMap object to enable swipe functionality + // SwipeMap object to enable swipe functionality. Primary is revealed + // left of the divider, secondary is clipped and revealed right of it. swipeMapRef.current = new window.atlas.SwipeMap( primaryMap, secondaryMap ); + // Both panes have to be up before the footprint layer can be added to + // them; whichever "ready" lands second flips the flag. + const markReady = () => { + readyCountRef.current += 1; + if (readyCountRef.current >= 2 && !cancelled) setMapsReady(true); + }; + // Primary map event listeners primaryMap.events.add("ready", async function () { // Avoid map rotation @@ -166,18 +355,13 @@ const Visualizer = ({ setModalComponent }) => { "preDisasterImagery" ); - loadPredictedDamageLayer( - primaryMap, - visualizerResults.predictedDamageLayer, - ); + loadPredictedDamageLayer(primaryMap, visualizerResults.predictedDamageLayer); - loadPredictionsLayer( - primaryMap, - visualizerResults.predictionsLayer, - ); + loadPredictionsLayer(primaryMap, visualizerResults.predictionsLayer); await loadStudyArea(primaryMap, visualizerResults.studyArea); + markReady(); }); // Secondary map event listeners @@ -191,17 +375,13 @@ const Visualizer = ({ setModalComponent }) => { "postDisasterImagery" ); - loadPredictedDamageLayer( - secondaryMap, - visualizerResults.predictedDamageLayer - ); + loadPredictedDamageLayer(secondaryMap, visualizerResults.predictedDamageLayer); - loadPredictionsLayer( - secondaryMap, - visualizerResults.predictionsLayer - ); + loadPredictionsLayer(secondaryMap, visualizerResults.predictionsLayer); loadStudyArea(secondaryMap, visualizerResults.studyArea); + + markReady(); }); // Assign maps to refs @@ -217,45 +397,212 @@ const Visualizer = ({ setModalComponent }) => { // Call the async function inside the effect initializeMaps(); - window.addEventListener("keydown", handleKeyboardShortcuts); - //On component dismount + // On component dismount return () => { + cancelled = true; setModalComponent(null); updateAppParams({ visualizerTitle: "" }); - window.removeEventListener("keydown", handleKeyboardShortcuts); + teardownMaps(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const handleKeyboardShortcuts = (event) => { - if (shouldIgnoreShortcut(event)) return; - if (event.ctrlKey || event.altKey || event.metaKey) { + // Dispose both maps and the swipe control. Ordering matters: SwipeMap + // appended its divider to the primary's container and attached handlers to + // both maps, so it goes first — and it does NOT clear the inline `clip` it + // left on the secondary, which has to be wiped by hand or the element is + // handed back to the DOM permanently cropped. + function teardownMaps() { + if (swipeMapRef.current) { + try { + if (typeof swipeMapRef.current.dispose === "function") { + swipeMapRef.current.dispose(); + } + } catch (error) { + console.warn("atlas.SwipeMap dispose failed:", error); + } + swipeMapRef.current = null; + } + + const panes = [ + [primaryMapRef, primaryContainerRef], + [secondaryMapRef, secondaryContainerRef], + ]; + for (const [mapRef, containerElementRef] of panes) { + const map = mapRef.current; + if (map) { + try { + if (typeof map.getMapContainer === "function") { + map.getMapContainer().style.clip = ""; + } + } catch (error) { + console.warn("clearing the map clip failed:", error); + } + try { + map.dispose(); + } catch (error) { + console.warn("map dispose failed:", error); + } + } + if (containerElementRef.current) { + containerElementRef.current.style.clip = ""; + } + mapRef.current = null; + } + readyCountRef.current = 0; + zoomControlRef.current = null; + } + + // ── Edit mode ──────────────────────────────────────────────────────────── + const enterEditMode = useCallback(async () => { + // The session carries the model's flavour, whether its score can be + // re-thresholded, and the saved version history. It is fetched lazily — + // the API reads the GeoPackage to answer — so a plain results view never + // pays for it. + setIsLoading(true, "Preparing prediction editing"); + try { + await artifacts.ensureSession(); + } catch (error) { + // Editing still works without it: the thresholds simply start from + // their defaults and the version list stays empty until the first save. + console.warn("Could not load the prediction edit session:", error); + } finally { + setIsLoading(false); + } + rasterVisibilityBeforeEditRef.current = { + predictedDamageLayer: layerVisibility.predictedDamageLayer, + predictionsLayer: layerVisibility.predictionsLayer, + }; + setLayerVisibility((previous) => ({ + ...previous, + predictedDamageLayer: false, + predictionsLayer: false, + footprints: true, + })); + setIsEditMode(true); + }, [artifacts, layerVisibility, setIsLoading]); + + const leaveEditMode = useCallback(() => { + footprints.discardEdits(); + setIsEditMode(false); + const restore = rasterVisibilityBeforeEditRef.current; + rasterVisibilityBeforeEditRef.current = null; + if (restore) { + setLayerVisibility((previous) => ({ ...previous, ...restore })); + } + }, [footprints]); + + const handleToggleEditMode = useCallback(() => { + if (!isEditMode) { + if (!canEdit) return; + enterEditMode(); return; } - if (!swipeMapRef.current) { + if (!footprints.isDirty) { + leaveEditMode(); return; } - switch (event.key.toLowerCase()) { - case "a": - swipeMapRef.current.setOptions({ - sliderPosition: 1, - }); - break; - case "s": - swipeMapRef.current.setOptions({ - sliderPosition: window.innerWidth / 2, - }); - break; - case "d": - swipeMapRef.current.setOptions({ - sliderPosition: window.innerWidth - 1, - }); - break; - default: - break; + // Nothing is written until "Save as new version", so leaving with edits + // pending throws them away — say so before it happens. + setDialog( + "Discard unsaved edits?", + describeUnsavedEdits(footprints.overrides, footprints.baseline), + [ + { + type: "primary", + key: "discard", + text: "Discard edits", + onClick: () => { + setDialog(); + leaveEditMode(); + }, + }, + { + type: "default", + key: "keep", + text: "Keep editing", + onClick: () => setDialog(), + }, + ] + ); + }, [ + isEditMode, + canEdit, + enterEditMode, + leaveEditMode, + footprints.isDirty, + footprints.overrides, + footprints.baseline, + setDialog, + ]); + + const handleSave = useCallback(async () => { + try { + const result = await footprints.save(); + setDialog( + "Edits saved", + `Version ${result.version} saved with ${result.editedCount ?? 0} edited building${ + result.editedCount === 1 ? "" : "s" + }.` + ); + } catch (error) { + setDialog( + "Save failed", + error?.message || "Failed to save the edited predictions." + ); } - }; + }, [footprints, setDialog]); + + // ── Keyboard ───────────────────────────────────────────────────────────── + // Bound in its own effect so the handler always sees the current mode and + // selection; a listener registered once on mount would close over the first + // render's values. Focus guarding is the shared shouldIgnoreShortcut. + useEffect(() => { + const onKeyDown = (event) => { + if (shouldIgnoreShortcut(event)) return; + if (event.ctrlKey || event.altKey || event.metaKey) return; + + if (isEditMode) { + const cls = CLASS_BY_KEY[event.key]; + if (cls) { + footprints.setClickAction(cls); + footprints.setClassForSelected(cls); + return; + } + if (event.key === "ArrowLeft") { + event.preventDefault(); + footprints.navigateInFilter(-1); + return; + } + if (event.key === "ArrowRight") { + event.preventDefault(); + footprints.navigateInFilter(1); + return; + } + } + + if (event.key.toLowerCase() === "e") { + handleToggleEditMode(); + return; + } + + // A / S / D snap the divider. The position is in pixels from the left + // edge of the map area: A = hard left (the post-event map fills the + // view), S = centre, D = hard right (the pre-event map fills it). + if (!swipeMapRef.current) return; + const position = dividerPositionForKey(event.key, swipeAreaWidth()); + if (position === null) return; + try { + swipeMapRef.current.setOptions({ sliderPosition: position }); + } catch (error) { + console.warn("swipe setOptions(sliderPosition) failed:", error); + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [isEditMode, footprints, handleToggleEditMode, swipeAreaWidth]); // Avoid map rotation and set camera bearing to 0 function avoidRotation(map) { @@ -273,19 +620,23 @@ const Visualizer = ({ setModalComponent }) => { // Load study area on map async function loadStudyArea(map, studyArea) { + // A layer with no label project has no study area: the maps still work, + // there is simply no outline to draw and nothing to fly to. + if (!Array.isArray(studyArea) || studyArea.length === 0) return; + // Create Data Source - var dataSource = new window.atlas.source.DataSource(); + const dataSource = new window.atlas.source.DataSource(); map.sources.add(dataSource); // Add data - var geoJsonData = { + const geoJsonData = { type: "FeatureCollection", features: studyArea, }; dataSource.add(geoJsonData); // Create linelayer to define workspace - var lineLayer = new window.atlas.layer.LineLayer(dataSource, null, { + const lineLayer = new window.atlas.layer.LineLayer(dataSource, null, { strokeColor: "#FFFFFF", strokeWidth: 2, }); @@ -296,6 +647,10 @@ const Visualizer = ({ setModalComponent }) => { // Reset map position to study area function resetMapPosition(studyArea, duration = 700) { + if (!primaryMapRef.current) return; + if (!Array.isArray(studyArea) || !studyArea[0]?.bbox) return; + // atlas.SwipeMap keeps both cameras in sync, so moving the primary moves + // the secondary with it. primaryMapRef.current.setCamera({ bounds: studyArea[0].bbox, type: "fly", @@ -304,10 +659,12 @@ const Visualizer = ({ setModalComponent }) => { }); } - // Adds a layer with pre or post disaster imagery + // Adds a layer with pre or post disaster imagery. Without a usable tile URL + // (no pre-event imagery, or an embedding model whose imagery was never + // processed into a COG) the Azure Maps basemap stands in, so the pane is + // never blank. async function loadPreOrPostDisasterLayer(map, disasterLayer, customId) { - - if (!disasterLayer || disasterLayer.url != "") { + if (hasRasterLayer(disasterLayer)) { const layer = new window.atlas.layer.TileLayer({ tileUrl: disasterLayer.url, minZoom: 1, @@ -319,10 +676,9 @@ const Visualizer = ({ setModalComponent }) => { map.layers.add(layer); } else { - const tempTileUrlPath = `https://atlas.microsoft.com/map/tile?api-version=2.1&tilesetId=microsoft.imagery&zoom={z}&x={x}&y={y}`; - var imagery = new window.atlas.layer.TileLayer({ + const imagery = new window.atlas.layer.TileLayer({ tileUrl: tempTileUrlPath, tileSize: 512, }); @@ -336,8 +692,12 @@ const Visualizer = ({ setModalComponent }) => { } } - // Adds a layer with predicted damage + // Adds the pre-coloured predicted damage raster. Embedding models have no + // such raster — their predictions are the vector footprints — so nothing is + // added and the InfoPanel offers no checkbox for it. function loadPredictedDamageLayer(map, predictedDamageLayer) { + if (!hasRasterLayer(predictedDamageLayer)) return; + const layer = new window.atlas.layer.TileLayer({ tileUrl: predictedDamageLayer.url, minZoom: 1, @@ -350,12 +710,11 @@ const Visualizer = ({ setModalComponent }) => { map.layers.add(layer); } - // Adds a layer with the raw model predictions (rendered via TiTiler colormap). - // Hidden by default; toggle from the InfoPanel. + // Adds the raw per-pixel prediction raster, hidden until it is checked in + // the InfoPanel. function loadPredictionsLayer(map, predictionsLayer) { - if (!predictionsLayer || !predictionsLayer.url) { - return; - } + if (!hasRasterLayer(predictionsLayer)) return; + const layer = new window.atlas.layer.TileLayer({ tileUrl: predictionsLayer.url, minZoom: 1, @@ -371,22 +730,37 @@ const Visualizer = ({ setModalComponent }) => { // Get layer by customId function getLayerById(currentMap, customId) { + if (!currentMap.current || !currentMap.current.layers) return null; const layers = currentMap.current.layers.getLayers(); return layers.find((layer) => layer.customId === customId); } - // Toggles visibility of predicted damage layer - function togglePredictedDamageLayerVisibility(customId, isVisible) { - const layer = getLayerById(primaryMapRef, customId); - if (layer) { - layer.setOptions({ visible: isVisible }); + // Toggles one raster layer on both maps. The vector footprints are not here: + // they belong to usePredictionFootprints, which owns both panes' copies. + const applyRasterVisibility = useCallback((customId, isVisible) => { + for (const mapRef of [primaryMapRef, secondaryMapRef]) { + const layer = getLayerById(mapRef, customId); + if (layer) layer.setOptions({ visible: isVisible }); } + }, []); - const layer2 = getLayerById(secondaryMapRef, customId); - if (layer2) { - layer2.setOptions({ visible: isVisible }); - } - } + useEffect(() => { + if (!mapsReady) return; + applyRasterVisibility( + "predictedDamageLayer", + layerVisibility.predictedDamageLayer + ); + applyRasterVisibility("predictionsLayer", layerVisibility.predictionsLayer); + }, [mapsReady, layerVisibility, applyRasterVisibility]); + + const setFootprintsVisible = footprints.setIsVisible; + useEffect(() => { + setFootprintsVisible(layerVisibility.footprints); + }, [layerVisibility.footprints, setFootprintsVisible]); + + const handleLayerVisibilityChange = useCallback((key, isVisible) => { + setLayerVisibility((previous) => ({ ...previous, [key]: isVisible })); + }, []); // Convert date to string for visualizer title function convertToVisualizerTitle(response) { @@ -399,9 +773,8 @@ const Visualizer = ({ setModalComponent }) => { const updateImageryProperties = (key, value) => { try { - - var preImageryRef = getLayerById(primaryMapRef, "preDisasterImagery"); - var postImageryRef = getLayerById(secondaryMapRef, "postDisasterImagery"); + const preImageryRef = getLayerById(primaryMapRef, "preDisasterImagery"); + const postImageryRef = getLayerById(secondaryMapRef, "postDisasterImagery"); preImageryRef.setOptions({ [key]: value, @@ -415,7 +788,6 @@ const Visualizer = ({ setModalComponent }) => { ...imageryValues, [key]: value, }); - } catch (error) { console.error("Error updating imagery values:", error); } @@ -423,9 +795,8 @@ const Visualizer = ({ setModalComponent }) => { const resetImageryProperties = () => { try { - - var preImageryRef = getLayerById(primaryMapRef, "preDisasterImagery"); - var postImageryRef = getLayerById(secondaryMapRef, "postDisasterImagery"); + const preImageryRef = getLayerById(primaryMapRef, "preDisasterImagery"); + const postImageryRef = getLayerById(secondaryMapRef, "postDisasterImagery"); preImageryRef.setOptions({ opacity: 1, @@ -452,20 +823,32 @@ const Visualizer = ({ setModalComponent }) => { } }; + const classification = footprints.classification; + const showStatusNote = + resultsReady && + footprintStatus !== FOOTPRINTS_READY && + dismissedNoteStatus !== footprintStatus; return ( -
-
-
+
+
+
+ {/* Ctrl+drag selection rectangle, shared by both panes. */} +
{ imageryValues={imageryValues} visualizerResults={globalVisualizerResults} /> + + {showStatusNote && ( + setDismissedNoteStatus(footprintStatus)} + /> + )} + + {isEditMode && classification && ( + <> +
+ Click a footprint to set its class · Ctrl+drag to box-select + · right-click to undo an edit · A / S / D move the + swipe divider +
+ { + footprints.setClickAction(cls); + footprints.setClassForSelected(cls); + }} + onClearOverride={footprints.clearSelectedOverride} + onClearAllEdits={footprints.clearAllOverrides} + onPrev={() => footprints.navigateInFilter(-1)} + onNext={() => footprints.navigateInFilter(1)} + threshold={footprints.threshold} + setThreshold={footprints.setThreshold} + unknownThreshold={footprints.unknownThreshold} + setUnknownThreshold={footprints.setUnknownThreshold} + baseline={footprints.baseline} + changeCount={footprints.changeCount} + swipeHint={swipeModeHint(swipeMode)} + onExit={handleToggleEditMode} + onSave={handleSave} + isSaving={footprints.isSaving} + saveError={footprints.saveError} + savedResult={footprints.savedResult} + versions={artifacts.versions} + activeVersion={artifacts.activeVersion} + /> + + )}
); }; +Visualizer.propTypes = { + setModalComponent: PropType.func.isRequired, +}; + export default Visualizer; diff --git a/ui/src/Components/Visualizer/VisualizerInformationMobile.jsx b/ui/src/Components/Visualizer/VisualizerInformationMobile.jsx index 45efa7c7..376f90d3 100644 --- a/ui/src/Components/Visualizer/VisualizerInformationMobile.jsx +++ b/ui/src/Components/Visualizer/VisualizerInformationMobile.jsx @@ -1,5 +1,11 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +// +// The small-screen version of the results page overlay: imagery details for +// whichever side of the swipe is showing, plus the same layer toggles the +// InfoPanel offers on the desktop layout. The layer rows come from the same +// pure visualizerLayerOptions() list, so a model with no damage raster shows +// no checkbox for one here either. import { Checkbox, Button, @@ -20,7 +26,9 @@ const VisualizerInformationMobile = ({ convertPreOrPostEventImagerySource, setSwipeStateMobile, swipeStateMobile, - togglePredictedDamageLayerVisibility, + layerOptions, + layerVisibility, + onLayerVisibilityChange, surfaceClassName, }) => { const [panelVisibility, setPanelVisibility] = useState("d-none"); @@ -78,7 +86,8 @@ const VisualizerInformationMobile = ({ {convertPreOrPostEventImagerySource( - visualizerResults.preDisasterImagery.url, visualizerResults.sourceTypePreEvent + visualizerResults.preDisasterImagery?.url, + visualizerResults.sourceTypePreEvent )} @@ -94,34 +103,25 @@ const VisualizerInformationMobile = ({ {convertPreOrPostEventImagerySource( - visualizerResults.postDisasterImagery.url, visualizerResults.sourceTypePostEvent + visualizerResults.postDisasterImagery?.url, + visualizerResults.sourceTypePostEvent )} )}
- - - togglePredictedDamageLayerVisibility( - "predictedDamageLayer", - data.checked - ) - } - /> - - togglePredictedDamageLayerVisibility( - "predictionsLayer", - data.checked - ) - } - /> + {layerOptions.map((option) => ( + + onLayerVisibilityChange(option.key, data.checked) + } + /> + ))}
@@ -137,7 +137,15 @@ VisualizerInformationMobile.propTypes = { convertPreOrPostEventImagerySource: PropType.func.isRequired, setSwipeStateMobile: PropType.func.isRequired, swipeStateMobile: PropType.string.isRequired, - togglePredictedDamageLayerVisibility: PropType.func.isRequired, + layerOptions: PropType.arrayOf( + PropType.shape({ + key: PropType.string.isRequired, + label: PropType.string.isRequired, + disabled: PropType.bool, + }) + ).isRequired, + layerVisibility: PropType.object.isRequired, + onLayerVisibilityChange: PropType.func.isRequired, surfaceClassName: PropType.string.isRequired, }; diff --git a/ui/src/Components/PredictionEditor/predictionClassify.js b/ui/src/Components/Visualizer/predictionClassify.js similarity index 100% rename from ui/src/Components/PredictionEditor/predictionClassify.js rename to ui/src/Components/Visualizer/predictionClassify.js diff --git a/ui/src/Components/Visualizer/predictionClassify.test.js b/ui/src/Components/Visualizer/predictionClassify.test.js new file mode 100644 index 00000000..34ec05a0 --- /dev/null +++ b/ui/src/Components/Visualizer/predictionClassify.test.js @@ -0,0 +1,1522 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Run with: node --test src/Components/Visualizer/predictionClassify.test.js +// +// Covers every pure module behind the results page's predicted-building +// layer: class derivation and overrides (predictionClassify.js), the +// preparation-job state machine (predictionPrep.js), the swipe divider +// (visualizerSwipe.js), the layer/status decisions the page renders from +// (predictionResults.js) and the renderer helpers that paint the footprints +// (predictionFootprintMap.js). None of them may import React, Azure Maps or +// FluentUI — this file is run by plain `node --test`. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + CLASS_DAMAGED, + CLASS_NOT_DAMAGED, + CLASS_UNKNOWN, + FILTER_ALL, + FILTER_EDITED, + buildSavePayload, + classifyAll, + clearOverride, + countClassChanges, + countOverrides, + cycleClass, + deriveClass, + filterIndices, + getOverride, + indexById, + latestVersion, + matchesFilter, + nextIndexInList, + normalizeAttrs, + resolveClassAt, + setOverride, + setOverrideEntries, + setOverrides, + sortVersionsDescending, + toOverrideList, + toPercentLabel, +} from "./predictionClassify.js"; +import { + MAX_PREP_POLL_ATTEMPTS, + PREP_PHASE_FAILED, + PREP_PHASE_READY, + PREP_PHASE_TIMED_OUT, + PREP_PHASE_WAITING, + PREP_POLL_INTERVAL_MS, + PREP_STATUS_CANCELLED, + PREP_STATUS_FAILED, + PREP_STATUS_IN_PROGRESS, + PREP_STATUS_QUEUED, + applyPrepResponse, + buildPrepRequest, + describeOutstandingArtifacts, + evaluatePrepState, + isPrepReady, + isTerminalPrepStatus, + nextPollAttempt, + normalizePrepStatus, + prepStateAfterPollError, + prepStatusLabel, + shouldPollPrep, +} from "./predictionPrep.js"; +import { + SWIPE_MODE_BASEMAP_POST, + SWIPE_MODE_NONE, + SWIPE_MODE_PRE_POST, + dividerPositionForKey, + isSwipeAvailable, + resolveSwipeMode, + swipeLeftPaneLabel, + swipeModeHint, + swipeRightPaneLabel, +} from "./visualizerSwipe.js"; +import { + FLAVOR_EMBEDDING, + FLAVOR_INFERENCE, + FOOTPRINTS_EMPTY, + FOOTPRINTS_LOADING, + FOOTPRINTS_PREPARING, + FOOTPRINTS_READY, + FOOTPRINTS_UNAVAILABLE, + buildArtifactUrl, + canEditFootprints, + countUnsavedOverrides, + describeEditAvailability, + describeFootprintStatus, + describeServedVersion, + describeUnsavedEdits, + hasAnyRasterLayer, + hasRasterLayer, + hasUnsavedEdits, + rasterLayerAvailability, + resolveActiveVersion, + resolveFootprintStatus, + resolveModelFlavor, + resolvePredictionArtifacts, + resolveInitialBuildingCount, + resolveInitialVersions, + resolvePredictionsReady, + resolveReadinessDetail, + resolveReadinessReason, + shouldRequestPreparation, + statusForReadinessReason, + resolveSupportsThreshold, + sameOverrides, + visualizerLayerOptions, +} from "./predictionResults.js"; +import { + CLASS_CODES, + FALLBACK_COLORS, + FILL_OPACITY_EXPRESSION, + PMTILES_SOURCE_LAYER, + classCode, + discoverFillLayerIds, + discoverVectorSourceId, + featureCentroid, + fillColorExpression, + findGlMap, + footprintFeatureState, + normalizeSelectionBox, + resolveMapColors, + strokeColorExpression, + themeColorLookup, +} from "./predictionFootprintMap.js"; + +// Five buildings covering every interesting corner: below/at/above the +// threshold, and one with a non-zero unknown score. +function sampleAttrs() { + return normalizeAttrs({ + n: 5, + ids: [10, 11, 12, 13, 14], + overtureIds: ["a", "b", "c", "d", "e"], + damage: [0.05, 0.5, 0.51, 0.9, 0.8], + unknown: [0, 0, 0, 0, 0.4], + damaged: [0, 0, 1, 1, 0], + }); +} + +test("deriveClass thresholds on fractions, not percentages", () => { + assert.equal(deriveClass(0.9, 0, 0.5), CLASS_DAMAGED); + assert.equal(deriveClass(0.1, 0, 0.5), CLASS_NOT_DAMAGED); + // Strictly greater: a score exactly at the threshold is not damaged. + assert.equal(deriveClass(0.5, 0, 0.5), CLASS_NOT_DAMAGED); + // A 0-100 style value would be nonsense here; 90 > 0.5 is still damaged, + // which is exactly why the caller must pass fractions. + assert.equal(deriveClass(0.5001, 0, 0.5), CLASS_DAMAGED); +}); + +test("deriveClass gives the unknown score priority over damage", () => { + // Default unknownThreshold of 0: any positive unknown wins. + assert.equal(deriveClass(0.99, 0.01, 0.5), CLASS_UNKNOWN); + assert.equal(deriveClass(0.99, 0, 0.5), CLASS_DAMAGED); + // Raising the unknown threshold lets the damage score through again. + assert.equal(deriveClass(0.99, 0.4, 0.5, 0.5), CLASS_DAMAGED); + assert.equal(deriveClass(0.99, 0.6, 0.5, 0.5), CLASS_UNKNOWN); +}); + +test("deriveClass treats non-finite scores as zero", () => { + assert.equal(deriveClass(NaN, undefined, 0.5), CLASS_NOT_DAMAGED); + assert.equal(deriveClass(null, null, 0.5), CLASS_NOT_DAMAGED); +}); + +test("normalizeAttrs clamps n to the data actually present", () => { + const attrs = normalizeAttrs({ n: 99, ids: [1, 2] }); + assert.equal(attrs.n, 2); + assert.deepEqual(attrs.damage, []); + + const empty = normalizeAttrs(undefined); + assert.equal(empty.n, 0); + assert.deepEqual(empty.ids, []); +}); + +test("indexById maps feature ids back to attribute rows", () => { + const map = indexById(sampleAttrs()); + assert.equal(map.get(10), 0); + assert.equal(map.get(14), 4); + assert.equal(map.get(999), undefined); +}); + +test("override merging is immutable and drops no-op writes", () => { + const empty = {}; + const one = setOverride(empty, 12, CLASS_UNKNOWN); + assert.deepEqual(empty, {}, "input map must not be mutated"); + assert.equal(getOverride(one, 12), CLASS_UNKNOWN); + + // Writing the same value again returns the identical object so React can + // skip the re-render. + assert.equal(setOverride(one, 12, CLASS_UNKNOWN), one); + + // Invalid classes are ignored. + assert.equal(setOverride(one, 13, "Rubble"), one); + assert.equal(getOverride(one, 13), null); + + const many = setOverrides(one, [10, 11], CLASS_DAMAGED); + assert.equal(countOverrides(many), 3); + + const cleared = clearOverride(many, 12); + assert.equal(getOverride(cleared, 12), null); + assert.equal(countOverrides(cleared), 2); + // Clearing something that was never set changes nothing. + assert.equal(clearOverride(cleared, 999), cleared); +}); + +test("setOverrideEntries merges a mixed batch in one pass", () => { + const start = setOverride({}, 10, CLASS_DAMAGED); + const merged = setOverrideEntries(start, [ + { id: 10, class: CLASS_NOT_DAMAGED }, + { id: 11, class: CLASS_UNKNOWN }, + { id: 12, class: "Nonsense" }, + { id: null, class: CLASS_DAMAGED }, + ]); + assert.equal(getOverride(merged, 10), CLASS_NOT_DAMAGED); + assert.equal(getOverride(merged, 11), CLASS_UNKNOWN); + assert.equal(getOverride(merged, 12), null); + assert.equal(countOverrides(merged), 2); + assert.deepEqual(start, { 10: CLASS_DAMAGED }, "input map must not change"); + // Nothing to apply -> same object back. + assert.equal(setOverrideEntries(merged, []), merged); +}); + +test("resolveClassAt prefers the user override over the derived class", () => { + const attrs = sampleAttrs(); + const overrides = setOverride({}, 13, CLASS_NOT_DAMAGED); + assert.equal( + resolveClassAt(attrs, 3, { threshold: 0.5 }), + CLASS_DAMAGED, + "derived without an override" + ); + assert.equal( + resolveClassAt(attrs, 3, { threshold: 0.5, overrides }), + CLASS_NOT_DAMAGED + ); +}); + +test("classifyAll counts every class and tracks edits", () => { + const attrs = sampleAttrs(); + const result = classifyAll(attrs, { threshold: 0.5, unknownThreshold: 0 }); + + assert.deepEqual(result.classes, [ + CLASS_NOT_DAMAGED, // 0.05 + CLASS_NOT_DAMAGED, // 0.5 is not > 0.5 + CLASS_DAMAGED, // 0.51 + CLASS_DAMAGED, // 0.9 + CLASS_UNKNOWN, // unknown 0.4 > 0 + ]); + assert.deepEqual(result.counts, { + [CLASS_DAMAGED]: 2, + [CLASS_NOT_DAMAGED]: 2, + [CLASS_UNKNOWN]: 1, + }); + assert.equal(result.editedCount, 0); + assert.equal(result.total, 5); + + const edited = classifyAll(attrs, { + threshold: 0.5, + overrides: setOverride({}, 10, CLASS_DAMAGED), + }); + assert.equal(edited.classes[0], CLASS_DAMAGED); + assert.equal(edited.edited[0], true); + assert.equal(edited.edited[1], false); + assert.equal(edited.editedCount, 1); + assert.equal(edited.counts[CLASS_DAMAGED], 3); +}); + +test("lowering the threshold reclassifies without touching the data", () => { + const attrs = sampleAttrs(); + const strict = classifyAll(attrs, { threshold: 0.95 }); + assert.equal(strict.counts[CLASS_DAMAGED], 0); + + const loose = classifyAll(attrs, { threshold: 0.01 }); + // Building 4 still has a non-zero unknown score, so it stays Unknown. + assert.equal(loose.counts[CLASS_DAMAGED], 4); + assert.equal(loose.counts[CLASS_UNKNOWN], 1); +}); + +test("filter predicate covers class filters, edited, and all", () => { + assert.equal(matchesFilter(CLASS_DAMAGED, false, FILTER_ALL), true); + assert.equal(matchesFilter(CLASS_DAMAGED, false, CLASS_DAMAGED), true); + assert.equal(matchesFilter(CLASS_DAMAGED, false, CLASS_UNKNOWN), false); + assert.equal(matchesFilter(CLASS_DAMAGED, false, FILTER_EDITED), false); + assert.equal(matchesFilter(CLASS_DAMAGED, true, FILTER_EDITED), true); + assert.equal(matchesFilter(CLASS_UNKNOWN, false, undefined), true); +}); + +test("filterIndices returns the rows the panel traverses", () => { + const attrs = sampleAttrs(); + const classification = classifyAll(attrs, { + threshold: 0.5, + overrides: setOverride({}, 11, CLASS_UNKNOWN), + }); + + assert.deepEqual(filterIndices(classification, FILTER_ALL), [0, 1, 2, 3, 4]); + assert.deepEqual(filterIndices(classification, CLASS_DAMAGED), [2, 3]); + assert.deepEqual(filterIndices(classification, CLASS_UNKNOWN), [1, 4]); + assert.deepEqual(filterIndices(classification, FILTER_EDITED), [1]); +}); + +test("countClassChanges reports how many buildings a slider move flips", () => { + const attrs = sampleAttrs(); + const base = { threshold: 0.5, unknownThreshold: 0 }; + + assert.equal(countClassChanges(attrs, base, base), 0); + + // 0.5 -> 0.85 demotes 0.51 and 0.8 (0.8 is Unknown, so it doesn't count) + // leaving only building index 2. + assert.equal( + countClassChanges(attrs, base, { threshold: 0.85, unknownThreshold: 0 }), + 1 + ); + + // 0.5 -> 0.01 promotes 0.05 and 0.5. + assert.equal( + countClassChanges(attrs, base, { threshold: 0.01, unknownThreshold: 0 }), + 2 + ); + + // Raising the unknown threshold releases building 4 back to Damaged. + assert.equal( + countClassChanges(attrs, base, { threshold: 0.5, unknownThreshold: 0.5 }), + 1 + ); +}); + +test("countClassChanges ignores buildings the user pinned", () => { + const attrs = sampleAttrs(); + const base = { threshold: 0.5, unknownThreshold: 0 }; + const candidate = { threshold: 0.01, unknownThreshold: 0 }; + + assert.equal(countClassChanges(attrs, base, candidate), 2); + // Pin one of the two that would have flipped. + const overrides = setOverride({}, 10, CLASS_NOT_DAMAGED); + assert.equal(countClassChanges(attrs, base, candidate, overrides), 1); +}); + +test("cycleClass walks Damaged -> NotDamaged -> Unknown -> Damaged", () => { + assert.equal(cycleClass(CLASS_DAMAGED), CLASS_NOT_DAMAGED); + assert.equal(cycleClass(CLASS_NOT_DAMAGED), CLASS_UNKNOWN); + assert.equal(cycleClass(CLASS_UNKNOWN), CLASS_DAMAGED); + assert.equal(cycleClass(undefined), CLASS_DAMAGED); +}); + +test("nextIndexInList wraps in both directions", () => { + const list = [2, 5, 9]; + assert.equal(nextIndexInList(list, 2, 1), 5); + assert.equal(nextIndexInList(list, 9, 1), 2); + assert.equal(nextIndexInList(list, 2, -1), 9); + assert.equal(nextIndexInList(list, 5, -1), 2); + assert.equal(nextIndexInList([], 0, 1), null); +}); + +test("nextIndexInList handles a selection outside the filtered set", () => { + const list = [2, 5, 9]; + // Nothing selected yet. + assert.equal(nextIndexInList(list, -1, 1), 2); + assert.equal(nextIndexInList(list, -1, -1), 9); + // Selection filtered out: step to the nearest neighbour in the direction + // of travel. + assert.equal(nextIndexInList(list, 6, 1), 9); + assert.equal(nextIndexInList(list, 6, -1), 5); + assert.equal(nextIndexInList(list, 12, 1), 2); +}); + +test("nextIndexInList prefers candidates but never refuses to move", () => { + const list = [2, 5, 9]; + const located = new Set([9]); + // Skips 5 because its geometry hasn't streamed in yet. + assert.equal(nextIndexInList(list, 2, 1, (i) => located.has(i)), 9); + // Nothing qualifies -> plain next entry. + assert.equal(nextIndexInList(list, 2, 1, () => false), 5); +}); + +test("toOverrideList produces the sparse, sorted payload list", () => { + const overrides = setOverrides({}, [14, 10, 12], CLASS_DAMAGED); + assert.deepEqual(toOverrideList(overrides), [ + { id: 10, class: CLASS_DAMAGED }, + { id: 12, class: CLASS_DAMAGED }, + { id: 14, class: CLASS_DAMAGED }, + ]); + assert.deepEqual(toOverrideList({}), []); + assert.deepEqual(toOverrideList(null), []); +}); + +test("buildSavePayload matches the PutEditedPredictions contract", () => { + const payload = buildSavePayload({ + projectId: "p1", + imageLayerId: "l1", + modelId: "m1", + threshold: 0.42, + unknownThreshold: 0.1, + overrides: setOverride({}, 7, CLASS_UNKNOWN), + }); + assert.deepEqual(payload, { + projectId: "p1", + imageLayerId: "l1", + modelId: "m1", + threshold: 0.42, + unknownThreshold: 0.1, + overrides: [{ id: 7, class: CLASS_UNKNOWN }], + }); +}); + +test("version helpers order the history newest-first", () => { + const versions = [ + { version: 1, threshold: 0.5 }, + { version: 3, threshold: 0.7 }, + { version: 2, threshold: 0.6 }, + ]; + assert.equal(latestVersion(versions).version, 3); + assert.deepEqual( + sortVersionsDescending(versions).map((v) => v.version), + [3, 2, 1] + ); + assert.equal(latestVersion([]), null); + assert.equal(latestVersion(undefined), null); +}); + +test("toPercentLabel renders fractions as percentages", () => { + assert.equal(toPercentLabel(0.5), "50%"); + assert.equal(toPercentLabel(0.517, 1), "51.7%"); + assert.equal(toPercentLabel(undefined), "—"); +}); + +// ── Preparation / polling state (predictionPrep.js) ───────────────────────── +// The editor cannot draw anything until the queued prep job has written the +// footprint PMTiles and the score sidecar, so the "trigger it, then wait" +// decisions are pure functions and get tested here rather than inside the +// component. + +test("normalizePrepStatus accepts the repo status vocabulary defensively", () => { + assert.equal(normalizePrepStatus("Queued"), PREP_STATUS_QUEUED); + assert.equal(normalizePrepStatus("InProgress"), PREP_STATUS_IN_PROGRESS); + // Case and stray whitespace must not break the state machine. + assert.equal(normalizePrepStatus(" inprogress "), PREP_STATUS_IN_PROGRESS); + // Anything unknown collapses to "" — treated as "not terminal, keep going". + assert.equal(normalizePrepStatus("Exploded"), ""); + assert.equal(normalizePrepStatus(undefined), ""); + assert.equal(normalizePrepStatus(null), ""); + assert.equal(normalizePrepStatus(42), ""); +}); + +test("only Failed and Cancelled are terminal", () => { + assert.equal(isTerminalPrepStatus(PREP_STATUS_FAILED), true); + assert.equal(isTerminalPrepStatus(PREP_STATUS_CANCELLED), true); + assert.equal(isTerminalPrepStatus(PREP_STATUS_QUEUED), false); + assert.equal(isTerminalPrepStatus(PREP_STATUS_IN_PROGRESS), false); + assert.equal(isTerminalPrepStatus("Processed"), false); + assert.equal(isTerminalPrepStatus(undefined), false); +}); + +test("isPrepReady requires both artifacts and never assumes readiness", () => { + assert.equal(isPrepReady({ tilesReady: true, attrsReady: true }), true); + assert.equal(isPrepReady({ tilesReady: true, attrsReady: false }), false); + assert.equal(isPrepReady({ tilesReady: false, attrsReady: true }), false); + // A backend that omits the flags entirely means "not ready". + assert.equal(isPrepReady({}), false); + assert.equal(isPrepReady(null), false); + // Truthy-but-not-true values are not readiness either. + assert.equal(isPrepReady({ tilesReady: 1, attrsReady: "yes" }), false); +}); + +test("evaluatePrepState keeps polling while the job is queued or running", () => { + const queued = evaluatePrepState( + { tilesReady: false, attrsReady: false, predictionTilesStatus: "Queued" }, + 0 + ); + assert.equal(queued.phase, PREP_PHASE_WAITING); + assert.equal(queued.shouldPoll, true); + assert.equal(queued.ready, false); + assert.equal(queued.status, PREP_STATUS_QUEUED); + assert.equal(shouldPollPrep(queued.phase), true); + + // Unknown/missing status is not an error: keep waiting. + const unknown = evaluatePrepState({ tilesReady: false, attrsReady: false }, 3); + assert.equal(unknown.phase, PREP_PHASE_WAITING); + assert.equal(unknown.status, ""); + assert.equal(unknown.attempt, 3); + + // Half-prepared is still not ready. + const half = evaluatePrepState( + { tilesReady: true, attrsReady: false, predictionTilesStatus: "InProgress" }, + 1 + ); + assert.equal(half.phase, PREP_PHASE_WAITING); +}); + +test("evaluatePrepState stops as soon as both artifacts exist", () => { + const ready = evaluatePrepState( + { tilesReady: true, attrsReady: true, predictionTilesStatus: "Processed" }, + 7 + ); + assert.equal(ready.phase, PREP_PHASE_READY); + assert.equal(ready.ready, true); + assert.equal(ready.shouldPoll, false); + assert.equal(shouldPollPrep(ready.phase), false); +}); + +test("existing artifacts win over a stale Failed status", () => { + // A previous run failed, but the files are on disk: the editor can open, so + // it must open rather than showing an error. + const result = evaluatePrepState( + { tilesReady: true, attrsReady: true, predictionTilesStatus: "Failed" }, + 2 + ); + assert.equal(result.phase, PREP_PHASE_READY); + assert.equal(result.shouldPoll, false); +}); + +test("evaluatePrepState treats Failed and Cancelled as terminal", () => { + for (const status of [PREP_STATUS_FAILED, PREP_STATUS_CANCELLED]) { + const result = evaluatePrepState( + { + tilesReady: false, + attrsReady: false, + predictionTilesStatus: status, + predictionTilesStatusMessage: "tippecanoe exited 1", + }, + 4 + ); + assert.equal(result.phase, PREP_PHASE_FAILED); + assert.equal(result.shouldPoll, false); + assert.equal(result.status, status); + assert.equal(result.statusMessage, "tippecanoe exited 1"); + } +}); + +test("evaluatePrepState gives up at the attempt cap", () => { + const running = { tilesReady: false, attrsReady: false, predictionTilesStatus: "InProgress" }; + const justUnder = evaluatePrepState(running, MAX_PREP_POLL_ATTEMPTS - 1); + assert.equal(justUnder.phase, PREP_PHASE_WAITING); + + const atCap = evaluatePrepState(running, MAX_PREP_POLL_ATTEMPTS); + assert.equal(atCap.phase, PREP_PHASE_TIMED_OUT); + assert.equal(atCap.shouldPoll, false); + + // A tighter cap (used by the test below) short-circuits the same way. + assert.equal(evaluatePrepState(running, 3, 3).phase, PREP_PHASE_TIMED_OUT); +}); + +test("evaluatePrepState reads the status off an enqueue response too", () => { + // PutPreparePredictionTilesQueueMessage returns `status`, the session + // returns `predictionTilesStatus`; both must be understood. + const result = evaluatePrepState( + { tilesReady: false, attrsReady: false, status: "Failed" }, + 0 + ); + assert.equal(result.phase, PREP_PHASE_FAILED); +}); + +test("nextPollAttempt counts up and repairs junk input", () => { + assert.equal(nextPollAttempt(0), 1); + assert.equal(nextPollAttempt(1), 2); + assert.equal(nextPollAttempt(undefined), 1); + assert.equal(nextPollAttempt(-5), 1); + assert.equal(nextPollAttempt(NaN), 1); + assert.equal(nextPollAttempt(2.7), 3); +}); + +test("a transient poll error keeps waiting until the cap", () => { + const current = { + phase: PREP_PHASE_WAITING, + status: PREP_STATUS_IN_PROGRESS, + statusMessage: "reprojecting", + attempt: 1, + }; + const blip = prepStateAfterPollError(current, "Error fetching.", 5); + assert.equal(blip.phase, PREP_PHASE_WAITING); + assert.equal(blip.shouldPoll, true); + assert.equal(blip.attempt, 2); + // The last known good status survives the blip so the card doesn't reset. + assert.equal(blip.status, PREP_STATUS_IN_PROGRESS); + assert.equal(blip.statusMessage, "reprojecting"); + assert.equal(blip.error, "Error fetching."); + + // Repeated failures still terminate at the cap. + const exhausted = prepStateAfterPollError({ ...current, attempt: 4 }, "", 5); + assert.equal(exhausted.phase, PREP_PHASE_TIMED_OUT); + assert.equal(exhausted.shouldPoll, false); + assert.equal(exhausted.error, "The preparation status could not be read."); +}); + +test("a polling run always terminates within the cap", () => { + // Drive the state machine the way the component does — evaluate, count, + // repeat — against a job that never finishes, and prove it stops. + const stuck = { tilesReady: false, attrsReady: false, predictionTilesStatus: "InProgress" }; + const cap = 8; + let state = evaluatePrepState(stuck, 0, cap); + let polls = 0; + while (shouldPollPrep(state.phase)) { + polls++; + assert.ok(polls <= cap, "must not poll past the cap"); + state = evaluatePrepState(stuck, nextPollAttempt(state.attempt), cap); + } + assert.equal(state.phase, PREP_PHASE_TIMED_OUT); + assert.equal(polls, cap); + + // The same loop against a job that finishes on the third poll stops early. + let finished = evaluatePrepState(stuck, 0, cap); + let ticks = 0; + while (shouldPollPrep(finished.phase)) { + ticks++; + const session = ticks < 3 ? stuck : { tilesReady: true, attrsReady: true }; + finished = evaluatePrepState(session, nextPollAttempt(finished.attempt), cap); + } + assert.equal(finished.phase, PREP_PHASE_READY); + assert.equal(ticks, 3); +}); + +test("the poll interval and cap are a sane bounded wait", () => { + assert.equal(PREP_POLL_INTERVAL_MS, 5000); + // 360 x 5s = 30 minutes: generous for a container-runner job, finite for a + // forgotten tab. + assert.equal((MAX_PREP_POLL_ATTEMPTS * PREP_POLL_INTERVAL_MS) / 60000, 30); +}); + +test("buildPrepRequest only sends force when retrying", () => { + assert.deepEqual( + buildPrepRequest({ projectId: "p1", imageLayerId: "l1", modelId: "m1" }), + { projectId: "p1", imageLayerId: "l1", modelId: "m1" } + ); + assert.deepEqual( + buildPrepRequest({ + projectId: "p1", + imageLayerId: "l1", + modelId: "m1", + force: true, + }), + { projectId: "p1", imageLayerId: "l1", modelId: "m1", force: true } + ); +}); + +test("applyPrepResponse folds the enqueue reply into the session", () => { + const session = { + buildingCount: 10, + tilesReady: false, + attrsReady: false, + predictionTilesStatus: "Failed", + }; + const merged = applyPrepResponse(session, { + queued: true, + tilesReady: false, + attrsReady: true, + status: "Queued", + }); + assert.equal(merged.predictionTilesStatus, PREP_STATUS_QUEUED); + assert.equal(merged.attrsReady, true); + assert.equal(merged.tilesReady, false); + assert.equal(merged.buildingCount, 10, "unrelated fields survive"); + assert.deepEqual(session.predictionTilesStatus, "Failed", "input unchanged"); + + // apiPut hands back a bare 409 for a conflict, and a dead endpoint can give + // us anything at all — keep the session we already have. + assert.equal(applyPrepResponse(session, 409), session); + assert.equal(applyPrepResponse(session, null), session); + assert.equal(applyPrepResponse(session, "nope"), session); + assert.equal(applyPrepResponse(session, []), session); + assert.deepEqual(applyPrepResponse(null, null), {}); +}); + +test("prep card copy reports what is outstanding", () => { + assert.equal( + describeOutstandingArtifacts({ tilesReady: false, attrsReady: false }), + "Still generating footprint tiles and per-building prediction scores." + ); + assert.equal( + describeOutstandingArtifacts({ tilesReady: true, attrsReady: false }), + "Still generating per-building prediction scores." + ); + assert.equal( + describeOutstandingArtifacts({ tilesReady: true, attrsReady: true }), + "" + ); + + assert.equal(prepStatusLabel(PREP_STATUS_QUEUED), "Queued"); + assert.equal(prepStatusLabel(PREP_STATUS_IN_PROGRESS), "In progress"); + assert.equal(prepStatusLabel(PREP_STATUS_FAILED), "Failed"); + assert.equal(prepStatusLabel(PREP_STATUS_CANCELLED), "Cancelled"); + // Unknown/missing must still render something sensible. + assert.equal(prepStatusLabel(undefined), "Starting"); + assert.equal(prepStatusLabel("Weird"), "Starting"); +}); + +// ── Swipe comparison map (visualizerSwipe.js) ─────────────────────────────── + +test("swipe mode follows the imagery the layer actually has", () => { + // Pre-event tiles present: compare pre against post. + assert.equal( + resolveSwipeMode({ + preEventTileUrl: "https://x/pre/{z}/{x}/{y}.png", + postEventTileUrl: "https://x/post/{z}/{x}/{y}.png", + }), + SWIPE_MODE_PRE_POST + ); + + // No pre-event tiles: the basemap stands in on the comparison pane. + assert.equal( + resolveSwipeMode({ postEventTileUrl: "https://x/post/{z}/{x}/{y}.png" }), + SWIPE_MODE_BASEMAP_POST + ); + assert.equal( + resolveSwipeMode({ + preEventTileUrl: " ", + postEventTileUrl: "https://x/post/{z}/{x}/{y}.png", + }), + SWIPE_MODE_BASEMAP_POST, + "a blank pre URL is not pre-event imagery" + ); + + // Without post-event imagery there is nothing to compare against, so the + // editor must not offer a swipe at all. + assert.equal(resolveSwipeMode(null), SWIPE_MODE_NONE); + assert.equal(resolveSwipeMode(undefined), SWIPE_MODE_NONE); + assert.equal(resolveSwipeMode({}), SWIPE_MODE_NONE); + assert.equal( + resolveSwipeMode({ preEventTileUrl: "https://x/pre/{z}/{x}/{y}.png" }), + SWIPE_MODE_NONE + ); + + assert.equal(isSwipeAvailable(SWIPE_MODE_PRE_POST), true); + assert.equal(isSwipeAvailable(SWIPE_MODE_BASEMAP_POST), true); + assert.equal(isSwipeAvailable(SWIPE_MODE_NONE), false); + assert.equal(isSwipeAvailable(undefined), false); +}); + +test("swipe mode also reads the results payload's imagery blocks", () => { + // The results page is handed GetVisualizerResults, not the labeling tool's + // flat tile URLs, so both shapes have to resolve the same way. + assert.equal( + resolveSwipeMode({ + preDisasterImagery: { url: "https://x/pre/{z}/{x}/{y}.png" }, + postDisasterImagery: { url: "https://x/post/{z}/{x}/{y}.png" }, + }), + SWIPE_MODE_PRE_POST + ); + assert.equal( + resolveSwipeMode({ + preDisasterImagery: { url: "" }, + postDisasterImagery: { url: "https://x/post/{z}/{x}/{y}.png" }, + }), + SWIPE_MODE_BASEMAP_POST + ); + // An embedding model has no processed imagery at all: no comparison. + assert.equal( + resolveSwipeMode({ preDisasterImagery: null, postDisasterImagery: null }), + SWIPE_MODE_NONE + ); +}); + +test("swipe labels name the comparison the analyst is getting", () => { + assert.equal(swipeLeftPaneLabel(SWIPE_MODE_PRE_POST), "Pre-event imagery"); + assert.equal(swipeLeftPaneLabel(SWIPE_MODE_BASEMAP_POST), "Basemap"); + assert.equal(swipeLeftPaneLabel(SWIPE_MODE_NONE), ""); + assert.equal( + swipeRightPaneLabel(SWIPE_MODE_PRE_POST), + "Post-event imagery" + ); + assert.equal( + swipeRightPaneLabel(SWIPE_MODE_BASEMAP_POST), + "Post-event imagery" + ); + assert.equal(swipeRightPaneLabel(SWIPE_MODE_NONE), ""); +}); + +test("swipe hint describes the divider directions the right way round", () => { + // The comparison map is the SwipeMap PRIMARY and sits LEFT of the divider, + // so dragging LEFT uncovers MORE post-event imagery. A previous PR shipped + // this backwards; pin it down. + const hint = swipeModeHint(SWIPE_MODE_PRE_POST); + assert.match(hint, /Pre-event imagery sits left of the divider/); + assert.match(hint, /left for more post-event/); + assert.match(hint, /right for more pre-event imagery/); + assert.match(hint, /Editing works on both sides/); + assert.match( + swipeModeHint(SWIPE_MODE_BASEMAP_POST), + /Basemap sits left of the divider/ + ); + assert.match( + swipeModeHint(SWIPE_MODE_NONE), + /no post-event imagery to compare against/ + ); +}); + +test("A / S / D snap the divider left / centre / right", () => { + assert.equal(dividerPositionForKey("a", 800), 0); + assert.equal(dividerPositionForKey("s", 800), 400); + assert.equal(dividerPositionForKey("d", 800), 800); + // Shift-held (or caps-locked) keys are the same shortcut. + assert.equal(dividerPositionForKey("A", 800), 0); + assert.equal(dividerPositionForKey("S", 800), 400); + assert.equal(dividerPositionForKey("D", 800), 800); + + // Anything else is not ours to handle. + for (const key of ["1", "w", "ArrowLeft", " ", "", null, undefined, 5]) { + assert.equal(dividerPositionForKey(key, 800), null); + } + + // No usable width yet (map area not laid out): do nothing rather than + // silently park the divider at 0. + for (const width of [0, -10, NaN, Infinity, null, undefined, "wide"]) { + assert.equal(dividerPositionForKey("s", width), null); + } + assert.equal(dividerPositionForKey("s", "800"), 400, "numeric strings work"); +}); + +// ── Results-page layer decisions (predictionResults.js) ───────────────────── + +test("a raster only counts as present when it can actually be fetched", () => { + assert.equal( + hasRasterLayer({ url: "https://titiler/cog/tiles/{z}/{x}/{y}?url=abfs://x" }), + true + ); + + // An embedding model has no COG, but GetVisualizerResults still builds its + // TiTiler template by interpolation, so the layer arrives with an EMPTY + // `url=` parameter. Requesting those tiles can only 404. + assert.equal( + hasRasterLayer({ url: "https://titiler/cog/tiles/{z}/{x}/{y}?scale=1&url=" }), + false + ); + assert.equal( + hasRasterLayer({ url: "https://titiler/cog/tiles/{z}/{x}/{y}?url=&scale=1" }), + false + ); + + assert.equal(hasRasterLayer(null), false); + assert.equal(hasRasterLayer(undefined), false); + assert.equal(hasRasterLayer({}), false); + assert.equal(hasRasterLayer({ url: "" }), false); + assert.equal(hasRasterLayer({ url: " " }), false); + assert.equal(hasRasterLayer({ url: 42 }), false); +}); + +test("raster availability drives what the results page can offer", () => { + const inference = { + predictedDamageLayer: { url: "https://titiler/a?url=abfs://v.tif" }, + predictionsLayer: { url: "https://titiler/b?url=abfs://p.tif" }, + }; + assert.deepEqual(rasterLayerAvailability(inference), { + predictedDamageLayer: true, + predictionsLayer: true, + }); + assert.equal(hasAnyRasterLayer(inference), true); + + // The embedding workflow: no rasters at all, which is exactly why the page + // needs the vector footprints. + const embedding = { predictedDamageLayer: null, predictionsLayer: null }; + assert.deepEqual(rasterLayerAvailability(embedding), { + predictedDamageLayer: false, + predictionsLayer: false, + }); + assert.equal(hasAnyRasterLayer(embedding), false); + assert.equal(hasAnyRasterLayer(null), false); +}); + +test("artifact URLs prefer the server's own and fall back to the standard route", () => { + const ids = { projectId: "p1", imageLayerId: "l1", modelId: "m1" }; + assert.equal( + buildArtifactUrl({ ...ids, kind: "footprint_pmtiles" }), + "GetModelArtifact?projectId=p1&imageLayerId=l1&modelId=m1&kind=footprint_pmtiles" + ); + // imageLayerId is optional in the contract; leave it out rather than send + // an empty one. + assert.equal( + buildArtifactUrl({ projectId: "p1", modelId: "m1", kind: "prediction_attrs" }), + "GetModelArtifact?projectId=p1&modelId=m1&kind=prediction_attrs" + ); + // Ids are escaped, never concatenated raw. + assert.match( + buildArtifactUrl({ projectId: "a b&c", modelId: "m", kind: "k" }), + /projectId=a\+b%26c/ + ); + + // The vector-first payload wins when it is there... + assert.deepEqual( + resolvePredictionArtifacts( + { + footprintTilesUrl: "GetModelArtifact?x=1&kind=footprint_pmtiles", + predictionAttrsUrl: "GetModelArtifact?x=1&kind=prediction_attrs", + }, + ids + ), + { + footprintTilesUrl: "GetModelArtifact?x=1&kind=footprint_pmtiles", + predictionAttrsUrl: "GetModelArtifact?x=1&kind=prediction_attrs", + } + ); + // ...and today's payload, which has neither field, still resolves. + assert.deepEqual(resolvePredictionArtifacts({}, ids), { + footprintTilesUrl: + "GetModelArtifact?projectId=p1&imageLayerId=l1&modelId=m1&kind=footprint_pmtiles", + predictionAttrsUrl: + "GetModelArtifact?projectId=p1&imageLayerId=l1&modelId=m1&kind=prediction_attrs", + }); +}); + +test("flavor and threshold support come from the session first", () => { + assert.equal( + resolveModelFlavor({ session: { flavor: FLAVOR_EMBEDDING }, results: { flavor: FLAVOR_INFERENCE } }), + FLAVOR_EMBEDDING + ); + assert.equal( + resolveModelFlavor({ results: { flavor: FLAVOR_INFERENCE } }), + FLAVOR_INFERENCE + ); + assert.equal(resolveModelFlavor({}), ""); + assert.equal(resolveModelFlavor(), ""); + + // An embedding model's damage_pct_0m is a degenerate 0/1 copy of the class, + // so re-thresholding it is meaningless and the slider must not appear. + assert.equal( + resolveSupportsThreshold({ session: { flavor: FLAVOR_EMBEDDING } }), + false + ); + assert.equal( + resolveSupportsThreshold({ results: { flavor: FLAVOR_EMBEDDING } }), + false + ); + // An explicit flag always wins over the flavour guess. + assert.equal( + resolveSupportsThreshold({ + session: { flavor: FLAVOR_EMBEDDING, supportsThreshold: true }, + }), + true + ); + assert.equal( + resolveSupportsThreshold({ results: { supportsThreshold: false } }), + false + ); + // Nothing said anything: assume the slider works, because hiding it would + // silently remove the feature from every inference model. + assert.equal(resolveSupportsThreshold({}), true); + assert.equal(resolveSupportsThreshold(), true); +}); + +test("readiness prefers the session's per-artifact flags", () => { + assert.equal( + resolvePredictionsReady({ + session: { tilesReady: true, attrsReady: true }, + results: { predictionsReady: false }, + }), + true + ); + assert.equal( + resolvePredictionsReady({ session: { tilesReady: true, attrsReady: false } }), + false + ); + assert.equal(resolvePredictionsReady({ results: { predictionsReady: true } }), true); + assert.equal(resolvePredictionsReady({ results: { predictionsReady: false } }), false); + // Today's payload says nothing: "unknown", not "not ready". + assert.equal(resolvePredictionsReady({ results: {} }), null); + assert.equal(resolvePredictionsReady(), null); +}); + +test("what the results payload already knows is not thrown away", () => { + // Zero is a real answer — it is the difference between "still preparing" + // and "there is nothing to prepare" — so it must survive. + assert.equal(resolveInitialBuildingCount({ buildingCount: 0 }), 0); + assert.equal(resolveInitialBuildingCount({ buildingCount: 1234 }), 1234); + assert.equal(resolveInitialBuildingCount({ buildingCount: "42" }), 42); + assert.equal(resolveInitialBuildingCount({}), null); + assert.equal(resolveInitialBuildingCount({ buildingCount: null }), null); + assert.equal(resolveInitialBuildingCount({ buildingCount: "" }), null); + assert.equal(resolveInitialBuildingCount({ buildingCount: "many" }), null); + assert.equal(resolveInitialBuildingCount({ buildingCount: -3 }), null); + assert.equal(resolveInitialBuildingCount(), null); + + // The saved versions ride along with the results, so the history is + // populated before any edit session is fetched. + const versions = [{ version: 2 }, { version: 1 }]; + assert.deepEqual( + resolveInitialVersions({ predictionVersions: versions }), + versions + ); + assert.deepEqual(resolveInitialVersions({}), []); + assert.deepEqual(resolveInitialVersions({ predictionVersions: "2" }), []); + assert.deepEqual(resolveInitialVersions(), []); + + // The server explains a non-ready layer better than we can: it knows which + // workflow the model came from. + assert.equal( + resolveReadinessDetail({ + predictionsReadiness: { ready: false, detail: "Preparing tiles." }, + }), + "Preparing tiles." + ); + assert.equal(resolveReadinessDetail({ predictionsReadiness: {} }), ""); + assert.equal(resolveReadinessDetail({}), ""); + assert.equal(resolveReadinessDetail(), ""); +}); + +test("footprint status never reports an empty map as ready", () => { + // A model with zero predicted buildings is EMPTY whatever else is true — + // no job will ever produce footprints for it. + assert.equal( + resolveFootprintStatus({ loaded: true, buildingCount: 0 }), + FOOTPRINTS_EMPTY + ); + assert.equal( + resolveFootprintStatus({ error: "boom", buildingCount: 0 }), + FOOTPRINTS_EMPTY + ); + + assert.equal( + resolveFootprintStatus({ loaded: true, error: "boom" }), + FOOTPRINTS_UNAVAILABLE + ); + assert.equal(resolveFootprintStatus({ loaded: true }), FOOTPRINTS_READY); + assert.equal( + resolveFootprintStatus({ loading: true, ready: false }), + FOOTPRINTS_PREPARING, + "a known-missing artifact is preparing, not loading" + ); + assert.equal(resolveFootprintStatus({ loading: true }), FOOTPRINTS_LOADING); + assert.equal(resolveFootprintStatus({}), FOOTPRINTS_LOADING); + assert.equal(resolveFootprintStatus(), FOOTPRINTS_LOADING); + + // Only a fully loaded vector layer can be edited. + assert.equal(canEditFootprints(FOOTPRINTS_READY), true); + for (const status of [ + FOOTPRINTS_LOADING, + FOOTPRINTS_PREPARING, + FOOTPRINTS_EMPTY, + FOOTPRINTS_UNAVAILABLE, + undefined, + ]) { + assert.equal(canEditFootprints(status), false); + } +}); + +test("the server's readiness reason separates 'not yet' from 'never'", () => { + // Only "preparing" is something a tiling job can fix. The others must not + // be dressed up as "nearly there" — the user has to go and do something + // else, and the server's `detail` already says what. + assert.equal( + statusForReadinessReason("preparing"), + FOOTPRINTS_PREPARING + ); + assert.equal( + statusForReadinessReason("not_processed"), + FOOTPRINTS_UNAVAILABLE + ); + assert.equal( + statusForReadinessReason("no_predictions"), + FOOTPRINTS_UNAVAILABLE + ); + assert.equal(statusForReadinessReason("no_buildings"), FOOTPRINTS_EMPTY); + // "ready" says nothing on its own: the payload was written before the + // browser tried to download anything. + assert.equal(statusForReadinessReason("ready"), null); + assert.equal(statusForReadinessReason(""), null); + assert.equal(statusForReadinessReason(), null); + assert.equal(statusForReadinessReason("something new"), null); + + assert.equal( + resolveReadinessReason({ predictionsReadiness: { reason: "preparing" } }), + "preparing" + ); + assert.equal(resolveReadinessReason({ predictionsReadiness: {} }), ""); + assert.equal(resolveReadinessReason(), ""); + + // A job is queued unless the server has ruled one out. An unknown reason + // still queues — that is the pre-contract behaviour, and it is harmless. + assert.equal(shouldRequestPreparation("preparing"), true); + assert.equal(shouldRequestPreparation("ready"), true); + assert.equal(shouldRequestPreparation(""), true); + assert.equal(shouldRequestPreparation(), true); + assert.equal(shouldRequestPreparation("not_processed"), false); + assert.equal(shouldRequestPreparation("no_predictions"), false); + assert.equal(shouldRequestPreparation("no_buildings"), false); +}); + +test("a declared reason outranks a guess, but never a loaded map", () => { + // Without the reason, a missing artifact reads as "preparing" forever on a + // model that was never processed. + assert.equal( + resolveFootprintStatus({ ready: false, reason: "not_processed" }), + FOOTPRINTS_UNAVAILABLE + ); + assert.equal( + resolveFootprintStatus({ ready: false, reason: "no_buildings" }), + FOOTPRINTS_EMPTY + ); + assert.equal( + resolveFootprintStatus({ loading: true, reason: "preparing" }), + FOOTPRINTS_PREPARING + ); + // Footprints actually on the map beat a stale payload. + assert.equal( + resolveFootprintStatus({ loaded: true, reason: "not_processed" }), + FOOTPRINTS_READY + ); + // As does a real load failure, which carries a more specific message. + assert.equal( + resolveFootprintStatus({ error: "boom", reason: "preparing" }), + FOOTPRINTS_UNAVAILABLE + ); + // An unrecognised reason falls through to the artifact flags. + assert.equal( + resolveFootprintStatus({ ready: false, reason: "ready" }), + FOOTPRINTS_PREPARING + ); + assert.equal( + resolveFootprintStatus({ loading: true, reason: "brand new reason" }), + FOOTPRINTS_LOADING + ); +}); + +test("the map always says which version it is drawing", () => { + assert.equal(resolveActiveVersion({ predictionVersion: 3 }), 3); + assert.equal(resolveActiveVersion({ predictionVersion: "2" }), 2); + // version=0 forces the model's raw output, so it is not a version. + assert.equal(resolveActiveVersion({ predictionVersion: 0 }), null); + assert.equal(resolveActiveVersion({ predictionVersion: null }), null); + assert.equal(resolveActiveVersion({ predictionVersion: "" }), null); + assert.equal(resolveActiveVersion({ predictionVersion: "latest" }), null); + assert.equal(resolveActiveVersion({}), null); + assert.equal(resolveActiveVersion(), null); + + assert.match(describeServedVersion(3), /version 3/); + assert.match(describeServedVersion(null), /model/i); + assert.match(describeServedVersion(), /model/i); +}); + +test("every non-ready status explains itself", () => { + assert.equal(describeFootprintStatus(FOOTPRINTS_READY), null); + + for (const status of [ + FOOTPRINTS_LOADING, + FOOTPRINTS_PREPARING, + FOOTPRINTS_EMPTY, + FOOTPRINTS_UNAVAILABLE, + ]) { + const message = describeFootprintStatus(status); + assert.ok(message.title.length > 0, `${status} needs a title`); + assert.ok(message.body.length > 0, `${status} needs a body`); + assert.ok(["info", "warning", "error"].includes(message.intent)); + } + + // The job's own status message beats the generic copy when we have one. + assert.equal( + describeFootprintStatus(FOOTPRINTS_PREPARING, { detail: "Tiling 12%" }).body, + "Tiling 12%" + ); + assert.equal( + describeFootprintStatus(FOOTPRINTS_UNAVAILABLE, { detail: "HTTP 500" }).body, + "HTTP 500" + ); + + // The pencil's tooltip has to say why it is disabled. + assert.match(describeEditAvailability(FOOTPRINTS_READY), /Edit/); + assert.match(describeEditAvailability(FOOTPRINTS_EMPTY), /no per-building/); + assert.match(describeEditAvailability(FOOTPRINTS_PREPARING), /preparing/); + assert.match(describeEditAvailability(FOOTPRINTS_UNAVAILABLE), /could not be loaded/); + assert.match(describeEditAvailability(FOOTPRINTS_LOADING), /Loading/); +}); + +test("the layer list never offers a toggle for a layer that is not there", () => { + const inference = visualizerLayerOptions({ + results: { + predictedDamageLayer: { url: "https://titiler/a?url=abfs://v.tif" }, + predictionsLayer: { url: "https://titiler/b?url=abfs://p.tif" }, + }, + footprintStatus: FOOTPRINTS_READY, + }); + assert.deepEqual( + inference.map((option) => option.key), + ["predictedDamageLayer", "predictionsLayer", "footprints"] + ); + assert.ok(inference.every((option) => option.disabled === false)); + + // The embedding workflow: footprints are the only layer there is. + const embedding = visualizerLayerOptions({ + results: { predictedDamageLayer: null, predictionsLayer: null }, + footprintStatus: FOOTPRINTS_READY, + }); + assert.deepEqual( + embedding.map((option) => option.key), + ["footprints"] + ); + + // Footprints are always listed, but cannot be toggled before they exist. + const preparing = visualizerLayerOptions({ + results: {}, + footprintStatus: FOOTPRINTS_PREPARING, + }); + assert.deepEqual(preparing.map((option) => option.key), ["footprints"]); + assert.equal(preparing[0].disabled, true); + assert.equal(visualizerLayerOptions().length, 1); + assert.ok(visualizerLayerOptions().every((option) => option.label.length > 0)); +}); + +test("unsaved edits are measured against the last saved version", () => { + assert.equal(sameOverrides({}, {}), true); + assert.equal(sameOverrides(null, undefined), true); + assert.equal(sameOverrides({ 1: CLASS_DAMAGED }, { 1: CLASS_DAMAGED }), true); + assert.equal(sameOverrides({ 1: CLASS_DAMAGED }, { 1: CLASS_UNKNOWN }), false); + assert.equal(sameOverrides({ 1: CLASS_DAMAGED }, {}), false); + assert.equal(sameOverrides({}, { 1: CLASS_DAMAGED }), false); + + // No baseline yet: any override at all is unsaved work. + assert.equal(hasUnsavedEdits({ overrides: {} }), false); + assert.equal(hasUnsavedEdits({ overrides: { 3: CLASS_DAMAGED } }), true); + + // Saving establishes a baseline, so the edits it wrote stop counting. + const baseline = { + threshold: 0.5, + unknownThreshold: 0.5, + overrides: { 3: CLASS_DAMAGED }, + }; + assert.equal( + hasUnsavedEdits({ + overrides: { 3: CLASS_DAMAGED }, + threshold: 0.5, + unknownThreshold: 0.5, + baseline, + }), + false + ); + assert.equal( + hasUnsavedEdits({ + overrides: { 3: CLASS_DAMAGED, 4: CLASS_UNKNOWN }, + threshold: 0.5, + unknownThreshold: 0.5, + baseline, + }), + true + ); + // Moving a threshold is unsaved work too — it reclassifies every building. + assert.equal( + hasUnsavedEdits({ + overrides: { 3: CLASS_DAMAGED }, + threshold: 0.6, + unknownThreshold: 0.5, + baseline, + }), + true + ); + assert.equal( + hasUnsavedEdits({ + overrides: { 3: CLASS_DAMAGED }, + threshold: 0.5, + unknownThreshold: 0.25, + baseline, + }), + true + ); + + // Added, changed and removed overrides all count once each. + assert.equal(countUnsavedOverrides({ 3: CLASS_DAMAGED }, baseline), 0); + assert.equal( + countUnsavedOverrides({ 3: CLASS_UNKNOWN }, baseline), + 1, + "changed" + ); + assert.equal( + countUnsavedOverrides({ 3: CLASS_DAMAGED, 4: CLASS_DAMAGED }, baseline), + 1, + "added" + ); + assert.equal(countUnsavedOverrides({}, baseline), 1, "removed"); + assert.equal(countUnsavedOverrides({ 1: CLASS_DAMAGED }, null), 1); + assert.equal(countUnsavedOverrides(null, null), 0); + + assert.match(describeUnsavedEdits({ 1: CLASS_DAMAGED }, null), /^1 building /); + assert.match( + describeUnsavedEdits({ 1: CLASS_DAMAGED, 2: CLASS_UNKNOWN }, null), + /^2 buildings / + ); + // Thresholds moved but nothing clicked: still worth warning about. + assert.match(describeUnsavedEdits({}, null), /threshold changes/); +}); + +// ── Footprint renderer helpers (predictionFootprintMap.js) ────────────────── + +test("class codes are stable and unknown classes never collide with them", () => { + assert.equal(PMTILES_SOURCE_LAYER, "buildings"); + assert.equal(classCode(CLASS_DAMAGED), CLASS_CODES[CLASS_DAMAGED]); + assert.equal(classCode(CLASS_NOT_DAMAGED), CLASS_CODES[CLASS_NOT_DAMAGED]); + assert.equal(classCode(CLASS_UNKNOWN), CLASS_CODES[CLASS_UNKNOWN]); + const codes = Object.values(CLASS_CODES); + assert.equal(new Set(codes).size, codes.length, "codes must be distinct"); + assert.ok(codes.every((code) => code > 0), "0 is reserved for unclassified"); + + // A footprint whose tile arrived before its scores did. + assert.equal(classCode(undefined), 0); + assert.equal(classCode("something-else"), 0); + + assert.deepEqual(footprintFeatureState({ cls: CLASS_DAMAGED }), { + cls: CLASS_CODES[CLASS_DAMAGED], + dim: false, + edited: false, + selected: false, + }); + assert.deepEqual( + footprintFeatureState({ + cls: CLASS_UNKNOWN, + dim: 1, + edited: "yes", + selected: true, + }), + { + cls: CLASS_CODES[CLASS_UNKNOWN], + dim: true, + edited: true, + selected: true, + } + ); + assert.deepEqual(footprintFeatureState(), { + cls: 0, + dim: false, + edited: false, + selected: false, + }); +}); + +test("paint expressions key off feature-state so recolouring needs no reload", () => { + const colors = { + damaged: "#111111", + notDamaged: "#222222", + unknown: "#333333", + pending: "#444444", + outline: "#555555", + edited: "#666666", + selected: "#777777", + }; + + const fill = fillColorExpression(colors); + assert.equal(fill[0], "case"); + assert.deepEqual(fill[1], [ + "==", + ["feature-state", "cls"], + CLASS_CODES[CLASS_DAMAGED], + ]); + assert.equal(fill[2], colors.damaged); + assert.equal(fill[4], colors.notDamaged); + assert.equal(fill[6], colors.unknown); + assert.equal(fill.at(-1), colors.pending, "unclassified is the default arm"); + + const stroke = strokeColorExpression(colors); + assert.equal(stroke[0], "case"); + assert.equal(stroke[2], colors.selected, "selection outranks edited"); + assert.equal(stroke[4], colors.edited); + assert.equal(stroke.at(-1), colors.outline); + + // Never hand the renderer an undefined colour: it drops the whole layer. + assert.deepEqual(fillColorExpression(null), fillColorExpression(FALLBACK_COLORS)); + assert.deepEqual( + strokeColorExpression(undefined), + strokeColorExpression(FALLBACK_COLORS) + ); + + // Filtered-out buildings stay on screen as context, but faint. + assert.equal(FILL_OPACITY_EXPRESSION[0], "case"); + assert.ok(FILL_OPACITY_EXPRESSION[2] < FILL_OPACITY_EXPRESSION[3]); +}); + +test("map colours come from the theme, with a parseable last resort", () => { + const tokenMap = { + damaged: "var(--colorStatusDangerBackground3)", + notDamaged: "var(--colorStatusSuccessBackground3)", + unknown: "var(--colorNeutralForeground3)", + pending: "var(--colorNeutralBackground5)", + outline: "var(--colorNeutralStrokeAccessible)", + edited: "var(--colorBrandStroke1)", + selected: "var(--colorNeutralForeground1)", + }; + const resolved = resolveMapColors(tokenMap, (name) => ` value-for${name} `); + assert.equal(resolved.damaged, "value-for--colorStatusDangerBackground3"); + assert.equal(resolved.selected, "value-for--colorNeutralForeground1"); + + // A token the theme cannot answer for, a lookup that throws, and no lookup + // at all must all still paint something. + assert.deepEqual(resolveMapColors(tokenMap, () => ""), FALLBACK_COLORS); + assert.deepEqual( + resolveMapColors(tokenMap, () => { + throw new Error("no such property"); + }), + FALLBACK_COLORS + ); + assert.deepEqual(resolveMapColors(tokenMap, null), FALLBACK_COLORS); + assert.deepEqual(resolveMapColors(null, () => "#abcdef"), FALLBACK_COLORS); + // A v9 token is "var(--x)"; handing the raw string to the renderer would + // fail, so anything that is not a var() reference falls back too. + assert.deepEqual( + resolveMapColors({ damaged: "#123456" }, () => "#abcdef").damaged, + FALLBACK_COLORS.damaged + ); + + // Outside a browser there is no computed style to read. + assert.equal(themeColorLookup(null)("--x"), ""); + assert.equal(themeColorLookup({})("--x"), ""); +}); + +test("the renderer under atlas.Map is duck-typed, never assumed", () => { + const gl = { setFeatureState() {} }; + assert.equal(findGlMap({ map: gl }), gl); + assert.equal(findGlMap({ _map: gl }), gl); + assert.equal(findGlMap({ gl }), gl); + assert.equal(findGlMap({ _gl: gl }), gl); + // A build that renamed the property is still found by scanning. + assert.equal(findGlMap({ somethingElse: gl }), gl); + + assert.equal(findGlMap(null), null); + assert.equal(findGlMap(undefined), null); + assert.equal(findGlMap("map"), null); + assert.equal(findGlMap({ map: {} }), null); +}); + +test("source and layer ids are discovered because Azure Maps renames them", () => { + const glMap = { + getStyle: () => ({ + sources: { + "vectorTiles-0": { type: "vector" }, + "predictedBuildings-3": { type: "vector" }, + basemap: { type: "raster" }, + }, + layers: [ + { id: "basemapFill", type: "fill", source: "basemap" }, + { id: "predictedBuildingsFill-3", type: "fill", source: "predictedBuildings-3" }, + { id: "unrelatedLine", type: "line", source: "predictedBuildings-3" }, + ], + }), + }; + + assert.deepEqual( + discoverFillLayerIds( + glMap, + ["visualizerPrimaryFootprintFill"], + ["visualizerPrimaryBuildings"] + ), + ["predictedBuildingsFill-3"], + "the basemap's own fill layers are not ours to click" + ); + // Nothing to discover: keep the ids we asked for. + assert.deepEqual( + discoverFillLayerIds({ getStyle: () => ({}) }, ["fallbackFill"]), + ["fallbackFill"] + ); + assert.deepEqual(discoverFillLayerIds(null, ["fallbackFill"]), ["fallbackFill"]); + assert.deepEqual(discoverFillLayerIds(null, null), []); + // A renderer that throws must not take the layer down with it. + assert.deepEqual( + discoverFillLayerIds( + { + getStyle: () => { + throw new Error("style not loaded"); + }, + }, + ["fallbackFill"] + ), + ["fallbackFill"] + ); + + assert.equal( + discoverVectorSourceId(glMap, "visualizerPrimaryBuildings"), + "predictedBuildings-3" + ); + // Our own id survived: use it. + assert.equal( + discoverVectorSourceId( + { getStyle: () => ({ sources: { mySource: { type: "vector" } } }) }, + "mySource" + ), + "mySource" + ); + assert.equal(discoverVectorSourceId(null, "mySource"), "mySource"); + assert.equal( + discoverVectorSourceId( + { + getStyle: () => { + throw new Error("style not loaded"); + }, + }, + "mySource" + ), + "mySource" + ); +}); + +test("centroids and drag rectangles survive the shapes the map hands over", () => { + assert.deepEqual( + featureCentroid({ + type: "Polygon", + coordinates: [[[0, 0], [0, 2], [2, 2], [2, 0]]], + }), + [1, 1] + ); + assert.deepEqual( + featureCentroid({ + type: "MultiPolygon", + coordinates: [[[[0, 0], [0, 4], [4, 4], [4, 0]]]], + }), + [2, 2] + ); + assert.equal(featureCentroid(null), null); + assert.equal(featureCentroid({ type: "Point", coordinates: [1, 2] }), null); + assert.equal(featureCentroid({ type: "Polygon", coordinates: [[]] }), null); + assert.equal(featureCentroid({ type: "Polygon", coordinates: [[[0]]] }), null); + + // Dragged up-and-left: the box still has its top-left corner first. + assert.deepEqual( + normalizeSelectionBox({ x: 100, y: 100 }, { x: 20, y: 30 }), + { x1: 20, y1: 30, x2: 100, y2: 100 } + ); + assert.deepEqual( + normalizeSelectionBox({ x: 20, y: 30 }, { x: 100, y: 100 }), + { x1: 20, y1: 30, x2: 100, y2: 100 } + ); + // Too small to be deliberate: that was a click, not a box-select. + assert.equal(normalizeSelectionBox({ x: 10, y: 10 }, { x: 12, y: 40 }), null); + assert.equal(normalizeSelectionBox({ x: 10, y: 10 }, { x: 40, y: 12 }), null); + assert.deepEqual( + normalizeSelectionBox({ x: 10, y: 10 }, { x: 12, y: 12 }, 1), + { x1: 10, y1: 10, x2: 12, y2: 12 } + ); + assert.equal(normalizeSelectionBox(null, { x: 1, y: 1 }), null); + assert.equal(normalizeSelectionBox({ x: 1, y: 1 }, null), null); +}); diff --git a/ui/src/Components/Visualizer/predictionFootprintMap.js b/ui/src/Components/Visualizer/predictionFootprintMap.js new file mode 100644 index 00000000..07872b0a --- /dev/null +++ b/ui/src/Components/Visualizer/predictionFootprintMap.js @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Renderer-facing helpers for the predicted-footprint layer. +// +// The results page paints one polygon per predicted building and colours it +// from the browser-side classification (predictionClassify.js) via +// feature-state, so moving the threshold slider recolours instantly with no +// server round-trip. Everything in this module is a plain function over plain +// data — the paint expressions, the class codes they compare against, the +// theme-colour resolution and the duck-typed lookups needed to reach the +// renderer underneath atlas.Map — so it is unit-tested in +// predictionClassify.test.js and the hooks stay pure wiring. +// +// Nothing here imports Azure Maps or touches the DOM at module scope; the two +// browser-only helpers (themeColorLookup, findGlMap) only read what they are +// handed. + +import { + CLASS_DAMAGED, + CLASS_NOT_DAMAGED, + CLASS_UNKNOWN, +} from "./predictionClassify.js"; + +// Tippecanoe writes the buildings layer with `-l buildings`; every feature +// carries the integer `id` (the attribute sidecar's row index) that +// feature-state colouring keys on. +export const PMTILES_SOURCE_LAYER = "buildings"; + +// Paint expressions compare numbers, so each class gets a code. 0 means "not +// classified yet" — a footprint whose tile arrived before its scores did. +export const CLASS_CODES = { + [CLASS_DAMAGED]: 1, + [CLASS_NOT_DAMAGED]: 2, + [CLASS_UNKNOWN]: 3, +}; + +/** The paint code for a class, or 0 when it is unknown to us. */ +export function classCode(cls) { + return CLASS_CODES[cls] || 0; +} + +// The roles the footprint layer paints. The caller supplies the Fluent token +// for each one (this module deliberately does NOT import +// @fluentui/react-components: it is imported by `node --test`, which cannot +// load the React bundle). +export const COLOR_KEYS = [ + "damaged", + "notDamaged", + "unknown", + "pending", + "outline", + "edited", + "selected", +]; + +// Last-resort values, used only if a custom property cannot be resolved (the +// renderer needs *some* parseable colour or the layer fails to paint). Named +// CSS colours, deliberately not theme-specific hex codes. +export const FALLBACK_COLORS = { + damaged: "firebrick", + notDamaged: "seagreen", + unknown: "dimgray", + pending: "lightgray", + outline: "steelblue", + edited: "royalblue", + selected: "white", +}; + +/** + * Resolve the map palette. + * + * The map's colours come from the active Fluent theme rather than a hardcoded + * palette: a v9 token is the string "var(--x)", which the renderer cannot + * parse, so `tokenMap` (role -> token) is unwrapped to its custom property + * name and handed to `lookup(name)`, which reads it off a live element inside + * the FluentProvider subtree. Anything the lookup cannot answer falls back to + * a named CSS colour, so the layer always paints. + */ +export function resolveMapColors(tokenMap, lookup) { + const colors = {}; + for (const key of COLOR_KEYS) { + const match = /var\((--[^,)]+)/.exec(String(tokenMap?.[key] ?? "")); + let resolved = ""; + if (match && typeof lookup === "function") { + try { + resolved = String(lookup(match[1]) || "").trim(); + } catch { + resolved = ""; + } + } + colors[key] = resolved || FALLBACK_COLORS[key]; + } + return colors; +} + +/** + * A lookup backed by an element's computed style — the browser half of + * resolveMapColors. `element` must live inside the FluentProvider subtree so + * the theme's custom properties are in scope. + */ +export function themeColorLookup(element) { + const style = + element && typeof window !== "undefined" && window.getComputedStyle + ? window.getComputedStyle(element) + : null; + return (name) => (style ? style.getPropertyValue(name) : ""); +} + +export function fillColorExpression(colors) { + const paint = colors || FALLBACK_COLORS; + return [ + "case", + ["==", ["feature-state", "cls"], CLASS_CODES[CLASS_DAMAGED]], + paint.damaged, + ["==", ["feature-state", "cls"], CLASS_CODES[CLASS_NOT_DAMAGED]], + paint.notDamaged, + ["==", ["feature-state", "cls"], CLASS_CODES[CLASS_UNKNOWN]], + paint.unknown, + paint.pending, + ]; +} + +// Buildings filtered out stay on screen as context, but faint. +export const FILL_OPACITY_EXPRESSION = [ + "case", + ["==", ["feature-state", "dim"], true], + 0.1, + 0.55, +]; + +export function strokeColorExpression(colors) { + const paint = colors || FALLBACK_COLORS; + return [ + "case", + ["==", ["feature-state", "selected"], true], + paint.selected, + ["==", ["feature-state", "edited"], true], + paint.edited, + paint.outline, + ]; +} + +export const STROKE_WIDTH_EXPRESSION = [ + "case", + ["==", ["feature-state", "selected"], true], + 4, + ["==", ["feature-state", "edited"], true], + 2.5, + 1, +]; + +/** + * The feature-state one footprint should carry: its class code, whether the + * current filter dims it, whether the user edited it and whether it is the + * selected building. Written to every renderer that draws the footprints. + */ +export function footprintFeatureState({ + cls, + edited = false, + selected = false, + dim = false, +} = {}) { + return { + cls: classCode(cls), + dim: !!dim, + edited: !!edited, + selected: !!selected, + }; +} + +/** + * atlas.Map has no public setFeatureState; the renderer underneath (a + * Mapbox-GL fork) does. Same duck-typed scan the Interactive Labeler uses. + */ +export function findGlMap(atlasMap) { + if (!atlasMap || typeof atlasMap !== "object") return null; + const direct = [atlasMap.map, atlasMap._map, atlasMap.gl, atlasMap._gl]; + for (const candidate of direct) { + if (candidate && typeof candidate.setFeatureState === "function") { + return candidate; + } + } + for (const key of Object.keys(atlasMap)) { + const value = atlasMap[key]; + if ( + value && + typeof value === "object" && + typeof value.setFeatureState === "function" + ) { + return value; + } + } + return null; +} + +/** + * Azure Maps renames our source/layer inside the renderer's style, so the ids + * queryRenderedFeatures needs have to be discovered rather than assumed. Used + * identically for both panes of the swipe map. + * + * Only OUR sources are considered: matching every fill layer in the style + * would include the basemap's own, and then a click anywhere on the map would + * "hit" a building. + */ +export function discoverFillLayerIds(glMap, fallbackLayerIds, sourceIds) { + const fallback = Array.isArray(fallbackLayerIds) ? fallbackLayerIds : []; + const preferredSources = Array.isArray(sourceIds) ? sourceIds : []; + if (!glMap || typeof glMap.getStyle !== "function") return fallback; + try { + const style = glMap.getStyle() || {}; + const ourSources = Object.keys(style.sources || {}).filter( + (id) => preferredSources.includes(id) || /predict|build/i.test(id) + ); + const discovered = (style.layers || []) + .filter( + (layer) => + layer.type === "fill" && + (ourSources.includes(layer.source) || fallback.includes(layer.id)) + ) + .map((layer) => layer.id); + return discovered.length > 0 ? discovered : fallback; + } catch (error) { + console.warn("glMap.getStyle() failed:", error); + return fallback; + } +} + +/** + * The name the renderer gave our vector source, which is the id every + * setFeatureState call has to use. + */ +export function discoverVectorSourceId(glMap, preferredId) { + if (!glMap || typeof glMap.getStyle !== "function") return preferredId; + try { + const sources = (glMap.getStyle() || {}).sources || {}; + if (sources[preferredId]) return preferredId; + const match = Object.keys(sources).find( + (id) => sources[id]?.type === "vector" && /predict|build/i.test(id) + ); + return match || preferredId; + } catch (error) { + console.warn("glMap.getStyle() failed:", error); + return preferredId; + } +} + +/** + * Average of the first ring's vertices — good enough to centre the camera on + * a building, and far cheaper than a real centroid. + */ +export function featureCentroid(geometry) { + if (!geometry) return null; + const ring = + geometry.type === "Polygon" + ? geometry.coordinates?.[0] + : geometry.type === "MultiPolygon" + ? geometry.coordinates?.[0]?.[0] + : null; + if (!Array.isArray(ring) || ring.length === 0) return null; + let lng = 0; + let lat = 0; + for (const position of ring) { + if (!Array.isArray(position) || position.length < 2) return null; + lng += position[0]; + lat += position[1]; + } + return [lng / ring.length, lat / ring.length]; +} + +/** + * The pixel rectangle a drag described, normalised so x1/y1 is the top-left + * corner whichever way the pointer travelled. Returns null for a rectangle + * too small to be a deliberate box-select (that is a click, not a drag). + */ +export function normalizeSelectionBox(origin, current, minimumSize = 4) { + if (!origin || !current) return null; + const x1 = Math.min(origin.x, current.x); + const y1 = Math.min(origin.y, current.y); + const x2 = Math.max(origin.x, current.x); + const y2 = Math.max(origin.y, current.y); + if (x2 - x1 < minimumSize || y2 - y1 < minimumSize) return null; + return { x1, y1, x2, y2 }; +} diff --git a/ui/src/Components/PredictionEditor/predictionPrep.js b/ui/src/Components/Visualizer/predictionPrep.js similarity index 100% rename from ui/src/Components/PredictionEditor/predictionPrep.js rename to ui/src/Components/Visualizer/predictionPrep.js diff --git a/ui/src/Components/Visualizer/predictionResults.js b/ui/src/Components/Visualizer/predictionResults.js new file mode 100644 index 00000000..3f55c180 --- /dev/null +++ b/ui/src/Components/Visualizer/predictionResults.js @@ -0,0 +1,494 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Pure decision logic for the results view's prediction layer. +// +// The results page (Visualizer) draws one model's predicted building +// footprints as vectors and, in edit mode, lets an analyst reclassify them. +// Both HASTE workflows land here: +// +// • inference models ship pre-coloured rasters (`_visualizer.tif` and +// `_predictions.tif`) *plus* per-building predictions, and +// • embedding models ship no raster at all — the vector footprints are the +// only thing there is to draw. +// +// So every "is this layer actually there?" question has to be answered from +// the payload rather than assumed, which is what this module does. Nothing +// here touches React, the DOM, Azure Maps or fetch, so the rules are +// unit-tested in predictionClassify.test.js. +// +// GetVisualizerResults is being made vector-first in parallel with this UI: +// it gains `footprintTilesUrl`, `predictionAttrsUrl`, `predictionsReady`, +// `flavor` and `supportsThreshold`, and returns null rasters for embedding +// models. Every reader below treats those fields as OPTIONAL and falls back +// to what today's payload can tell us, so the page works against either +// version of the API. + +// ── Raster availability ───────────────────────────────────────────────────── + +// GetVisualizerResults builds its TiTiler template by string interpolation, +// so a model with no COG still yields a syntactically fine tile URL whose +// `url=` parameter is empty. Requesting those tiles can only ever fail, so an +// empty `url=` means "this raster does not exist" exactly like a null layer. +const EMPTY_TITILER_URL = /[?&]url=(?:&|$)/; + +/** True when a layer block from GetVisualizerResults can actually be drawn. */ +export function hasRasterLayer(layer) { + const url = typeof layer?.url === "string" ? layer.url.trim() : ""; + if (url === "") return false; + return !EMPTY_TITILER_URL.test(url); +} + +/** + * Which raster overlays this model has, keyed by the customId the map layers + * are registered under. The InfoPanel checkboxes are driven from this so an + * embedding model never offers a toggle for a layer that was never added. + */ +export function rasterLayerAvailability(results) { + return { + predictedDamageLayer: hasRasterLayer(results?.predictedDamageLayer), + predictionsLayer: hasRasterLayer(results?.predictionsLayer), + }; +} + +/** True when the model has no raster overlays at all (the embedding case). */ +export function hasAnyRasterLayer(results) { + const available = rasterLayerAvailability(results); + return available.predictedDamageLayer || available.predictionsLayer; +} + +// ── Artifact endpoints ────────────────────────────────────────────────────── + +function cleanString(value) { + return typeof value === "string" ? value.trim() : ""; +} + +/** + * The API-relative GetModelArtifact endpoint for one artifact `kind`. + * + * `imageLayerId` is optional in the contract (the route falls back to the + * model's own layer) but is always sent when known: footprint tiles are + * layer-scoped, and being explicit costs nothing. + */ +export function buildArtifactUrl({ + projectId, + imageLayerId, + modelId, + kind, +} = {}) { + const params = new URLSearchParams(); + params.set("projectId", String(projectId ?? "")); + if (cleanString(imageLayerId)) { + params.set("imageLayerId", String(imageLayerId)); + } + params.set("modelId", String(modelId ?? "")); + params.set("kind", String(kind ?? "")); + return `GetModelArtifact?${params.toString()}`; +} + +/** + * Where to fetch the footprint PMTiles archive and the score sidecar. + * + * Prefers whatever the server handed back so the API stays free to move the + * artifacts, and reconstructs the standard endpoints when those fields are + * missing (the pre-vector-first payload). + */ +export function resolvePredictionArtifacts(results, ids = {}) { + const footprintTilesUrl = + cleanString(results?.footprintTilesUrl) || + buildArtifactUrl({ ...ids, kind: "footprint_pmtiles" }); + const predictionAttrsUrl = + cleanString(results?.predictionAttrsUrl) || + buildArtifactUrl({ ...ids, kind: "prediction_attrs" }); + return { footprintTilesUrl, predictionAttrsUrl }; +} + +// ── Model shape ───────────────────────────────────────────────────────────── + +export const FLAVOR_INFERENCE = "inference"; +export const FLAVOR_EMBEDDING = "embedding"; + +/** + * Which workflow produced these predictions. The edit session is the + * authority (it reads the GeoPackage); the results payload is used when no + * session has been fetched yet, and "" means "not known yet". + */ +export function resolveModelFlavor({ results, session } = {}) { + const fromSession = cleanString(session?.flavor); + if (fromSession) return fromSession; + return cleanString(results?.flavor); +} + +/** + * Whether re-thresholding this model's scores means anything. + * + * The embedding producer writes `damage_pct_0m` as a degenerate 0/1 copy of + * the predicted class, so a slider over it would just move buildings between + * "all damaged" and "none damaged". When nothing says otherwise we assume the + * slider IS meaningful: hiding it on an inference model silently removes the + * feature, while showing it on an embedding model is merely useless. + */ +export function resolveSupportsThreshold({ results, session } = {}) { + if (typeof session?.supportsThreshold === "boolean") { + return session.supportsThreshold; + } + if (typeof results?.supportsThreshold === "boolean") { + return results.supportsThreshold; + } + return resolveModelFlavor({ results, session }) !== FLAVOR_EMBEDDING; +} + +/** + * Are the vector artifacts built? `null` when nothing has said either way. + * + * The session's per-artifact flags win when present because they are what the + * preparation poll refreshes; the results payload only reports readiness as + * it was when the page loaded. + */ +export function resolvePredictionsReady({ results, session } = {}) { + if ( + typeof session?.tilesReady === "boolean" && + typeof session?.attrsReady === "boolean" + ) { + return session.tilesReady && session.attrsReady; + } + if (typeof results?.predictionsReady === "boolean") { + return results.predictionsReady; + } + return null; +} + +/** + * How many buildings this model predicted, when the payload already says. + * Zero is a real answer — it is what makes the difference between "still + * preparing" and "there is nothing to prepare" — so it must survive. + */ +export function resolveInitialBuildingCount(results) { + const raw = results?.buildingCount; + if (raw === null || raw === undefined || raw === "") return null; + const count = Number(raw); + return Number.isFinite(count) && count >= 0 ? count : null; +} + +/** + * Saved edited-prediction versions the results payload already carries, so + * the version history is populated before any edit session is fetched. + */ +export function resolveInitialVersions(results) { + return Array.isArray(results?.predictionVersions) + ? results.predictionVersions + : []; +} + +/** + * The server's own explanation for a layer that is not ready — "not + * processed", "still preparing", and so on. Preferred over our generic copy + * because it knows which workflow the model came from. + */ +export function resolveReadinessDetail(results) { + return cleanString(results?.predictionsReadiness?.detail); +} + +// Why the server says the vector artifacts are not ready. `preparing` is the +// only one a tiling job can fix; the rest need the user to go and do +// something else entirely, so they must not be dressed up as "nearly there". +export const READINESS_READY = "ready"; +export const READINESS_PREPARING = "preparing"; +export const READINESS_NOT_PROCESSED = "not_processed"; +export const READINESS_NO_PREDICTIONS = "no_predictions"; +export const READINESS_NO_BUILDINGS = "no_buildings"; + +export function resolveReadinessReason(results) { + return cleanString(results?.predictionsReadiness?.reason); +} + +/** + * Whether it is worth queueing a tile-preparation job. + * + * Only when the server has not ruled it out. An unrecognised or absent + * reason still queues — that is the pre-contract behaviour and the job is + * harmless — but a model that was never processed, has no predictions, or + * has no buildings can never produce artifacts, so asking would just spin. + */ +export function shouldRequestPreparation(reason) { + switch (cleanString(reason)) { + case READINESS_NOT_PROCESSED: + case READINESS_NO_PREDICTIONS: + case READINESS_NO_BUILDINGS: + return false; + default: + return true; + } +} + +/** + * The footprint status a server-declared reason implies, or null when it + * implies nothing on its own. Defined with the statuses it returns, below. + */ + +// ── Edited versions ───────────────────────────────────────────────────────── + +/** + * Which edited version the payload was served from, or null for the model's + * raw output. Zero is the raw output too — `GetVisualizerResults?version=0` + * forces it — so it normalises to null rather than surviving as a falsy + * number that reads like a real version. + */ +export function resolveActiveVersion(results) { + const raw = results?.predictionVersion; + if (raw === null || raw === undefined || raw === "") return null; + const version = Number(raw); + if (!Number.isFinite(version) || version <= 0) return null; + return version; +} + +/** One line naming what the map is currently showing. */ +export function describeServedVersion(activeVersion) { + return activeVersion + ? `Showing edited version ${activeVersion}.` + : "Showing the model's own predictions."; +} + +// ── Footprint layer status ────────────────────────────────────────────────── + +// LOADING artifacts are being fetched (or we do not know yet). +// PREPARING the tiling job has not produced them yet; we poll. +// READY footprints are on the map. +// EMPTY this model predicted no buildings at all. +// UNAVAILABLE something failed, or there is nothing to fetch. +export const FOOTPRINTS_LOADING = "loading"; +export const FOOTPRINTS_PREPARING = "preparing"; +export const FOOTPRINTS_READY = "ready"; +export const FOOTPRINTS_EMPTY = "empty"; +export const FOOTPRINTS_UNAVAILABLE = "unavailable"; + +/** + * The footprint status a server-declared readiness reason implies, or null + * when it implies nothing on its own. `ready` is deliberately null: the + * payload was written before the browser tried to download anything, so it + * cannot say whether the layer is actually on the map yet. + */ +export function statusForReadinessReason(reason) { + switch (cleanString(reason)) { + case READINESS_NO_BUILDINGS: + return FOOTPRINTS_EMPTY; + case READINESS_NOT_PROCESSED: + case READINESS_NO_PREDICTIONS: + return FOOTPRINTS_UNAVAILABLE; + case READINESS_PREPARING: + return FOOTPRINTS_PREPARING; + default: + return null; + } +} + +/** + * What the results page should show for the footprint layer, given what it + * has learned so far. Ordering is deliberate: + * + * 1. a model with zero predicted buildings is EMPTY whatever else is true — + * no job will ever produce footprints for it; + * 2. a hard failure is UNAVAILABLE, so the user gets the reason instead of + * an empty map; + * 3. footprints actually on the map are READY even if a stale payload + * still says otherwise; + * 4. the server's own reason then decides — it distinguishes "preparing" + * (a job will fix it) from "never processed" (nothing will); + * 5. artifacts known to be missing mean PREPARING (with or without a job + * already queued), never "ready but blank". + */ +export function resolveFootprintStatus({ + loaded = false, + loading = false, + error = "", + ready = null, + buildingCount = null, + reason = "", +} = {}) { + if (typeof buildingCount === "number" && buildingCount === 0) { + return FOOTPRINTS_EMPTY; + } + if (cleanString(error)) return FOOTPRINTS_UNAVAILABLE; + if (loaded) return FOOTPRINTS_READY; + const declared = statusForReadinessReason(reason); + if (declared) return declared; + if (ready === false) return FOOTPRINTS_PREPARING; + if (loading) return FOOTPRINTS_LOADING; + return FOOTPRINTS_LOADING; +} + +/** Only a fully loaded vector layer can be edited. */ +export function canEditFootprints(status) { + return status === FOOTPRINTS_READY; +} + +/** + * Copy for the status note the results page shows instead of leaving the map + * silently empty. Returns null when there is nothing to say (READY). + * + * `intent` maps onto the Fluent MessageBar intents. + */ +export function describeFootprintStatus(status, context = {}) { + const detail = cleanString(context.detail); + switch (status) { + case FOOTPRINTS_LOADING: + return { + intent: "info", + title: "Loading predicted buildings", + body: + detail || + "Streaming building footprints and per-building prediction scores.", + }; + case FOOTPRINTS_PREPARING: + return { + intent: "info", + title: "Predicted buildings are still being prepared", + body: + detail || + "The editable footprint tiles and prediction scores are being " + + "generated. They appear here on their own — no need to reload.", + }; + case FOOTPRINTS_EMPTY: + return { + intent: "warning", + title: "No predicted buildings", + body: + detail || + "This model has no per-building predictions. Run inference, or " + + "predict all buildings in the Interactive Labeler, and come back.", + }; + case FOOTPRINTS_UNAVAILABLE: + return { + intent: "error", + title: "Predicted buildings unavailable", + body: detail || "The building footprints could not be loaded.", + }; + default: + return null; + } +} + +/** Tooltip for the edit affordance, explaining any disabled state. */ +export function describeEditAvailability(status) { + if (canEditFootprints(status)) { + return "Edit these predictions and save them as a new version"; + } + if (status === FOOTPRINTS_EMPTY) { + return "This model has no per-building predictions to edit"; + } + if (status === FOOTPRINTS_PREPARING) { + return "Editing opens once the predicted buildings finish preparing"; + } + if (status === FOOTPRINTS_UNAVAILABLE) { + return "The predicted buildings could not be loaded, so they cannot be edited"; + } + return "Loading predicted buildings…"; +} + +// ── Layer list ────────────────────────────────────────────────────────────── + +/** + * The layer toggles the results page should offer, in draw order. + * + * A checkbox for a layer that was never added to the map is worse than no + * checkbox at all, so the rasters are listed only when this model actually has + * them — for an embedding model that leaves just the footprints. The footprint + * row is always listed (it is the whole point of the page) but is disabled + * until the vectors are on the map, which doubles as a hint that something is + * still coming. + */ +export function visualizerLayerOptions({ results, footprintStatus } = {}) { + const rasters = rasterLayerAvailability(results); + const options = []; + if (rasters.predictedDamageLayer) { + options.push({ + key: "predictedDamageLayer", + label: "Predicted building damage", + disabled: false, + }); + } + if (rasters.predictionsLayer) { + options.push({ + key: "predictionsLayer", + label: "Raw model output", + disabled: false, + }); + } + options.push({ + key: "footprints", + label: "Predicted building footprints", + disabled: footprintStatus !== FOOTPRINTS_READY, + }); + return options; +} + +// ── Unsaved work ──────────────────────────────────────────────────────────── + +const EPSILON = 1e-9; + +function sameNumber(a, b) { + const left = typeof a === "number" && Number.isFinite(a) ? a : 0; + const right = typeof b === "number" && Number.isFinite(b) ? b : 0; + return Math.abs(left - right) < EPSILON; +} + +/** True when two override maps hold exactly the same per-building classes. */ +export function sameOverrides(left, right) { + const a = left || {}; + const b = right || {}; + const keys = Object.keys(a); + if (keys.length !== Object.keys(b).length) return false; + return keys.every((key) => a[key] === b[key]); +} + +/** + * True when leaving edit mode would throw work away: overrides that differ + * from the last saved set, or thresholds moved away from the baseline that + * save established. + * + * Saving updates the baseline (thresholds AND overrides), so edits that were + * just written to a new version stop counting as unsaved — otherwise the page + * would warn about discarding work it had already stored. + */ +export function hasUnsavedEdits({ + overrides, + threshold, + unknownThreshold, + baseline, +} = {}) { + if (!baseline) return !!overrides && Object.keys(overrides).length > 0; + if (!sameOverrides(overrides, baseline.overrides)) return true; + return ( + !sameNumber(threshold, baseline.threshold) || + !sameNumber(unknownThreshold, baseline.unknownThreshold) + ); +} + +/** How many overrides differ from the last saved set. */ +export function countUnsavedOverrides(overrides, baseline) { + const current = overrides || {}; + const saved = baseline?.overrides || {}; + let count = 0; + for (const key of Object.keys(current)) { + if (current[key] !== saved[key]) count++; + } + for (const key of Object.keys(saved)) { + if (!(key in current)) count++; + } + return count; +} + +/** Confirmation copy for discarding unsaved edits. */ +export function describeUnsavedEdits(overrides, baseline) { + const count = countUnsavedOverrides(overrides, baseline); + if (count === 0) { + return ( + "The threshold changes you made have not been saved. " + + "Leaving edit mode discards them." + ); + } + return ( + `${count.toLocaleString()} ${count === 1 ? "building" : "buildings"} ` + + "changed by hand have not been saved. Leaving edit mode discards them." + ); +} diff --git a/ui/src/Components/Visualizer/usePredictionArtifacts.js b/ui/src/Components/Visualizer/usePredictionArtifacts.js new file mode 100644 index 00000000..cc2f86b9 --- /dev/null +++ b/ui/src/Components/Visualizer/usePredictionArtifacts.js @@ -0,0 +1,462 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Loads the two artifacts the results page needs to draw predicted building +// footprints as vectors: +// +// • the footprint PMTiles archive (kind=footprint_pmtiles), and +// • the per-building score sidecar (kind=prediction_attrs). +// +// Both are produced by a queued job — tippecanoe cannot run inside an HTTP +// handler — so a model nobody has opened before arrives here unprepared. This +// hook enqueues that job itself and then polls GetPredictionEditSession until +// the artifacts exist, rather than telling the user to come back later. The +// decisions behind that wait are pure and unit-tested (predictionPrep.js), as +// is the status the page renders from it (predictionResults.js). +// +// The hook deliberately does NOT touch the map: it hands back the archive key +// and the attribute arrays, and usePredictionFootprints turns those into +// layers on both panes of the swipe map. +// +// It also owns the edit *session* (flavor, threshold support, saved version +// history), fetched lazily: reading it costs the API a GeoPackage read, so a +// plain results view that finds its artifacts on the first try never asks for +// one. Entering edit mode does. +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { PMTiles } from "pmtiles"; +import { apiGet, apiPut, buildUrl } from "../../util/api"; +import { + fetchArtifactBuffer, + getPmtilesProtocol, + InMemoryPMTilesSource, +} from "../../util/pmtiles.js"; +import { indexById, normalizeAttrs } from "./predictionClassify.js"; +import { + FOOTPRINTS_EMPTY, + resolveActiveVersion, + resolveFootprintStatus, + resolveInitialBuildingCount, + resolveInitialVersions, + resolvePredictionArtifacts, + resolvePredictionsReady, + resolveReadinessDetail, + resolveReadinessReason, + shouldRequestPreparation, +} from "./predictionResults.js"; +import { + MAX_PREP_POLL_ATTEMPTS, + PREP_PHASE_FAILED, + PREP_PHASE_REQUESTING, + PREP_POLL_INTERVAL_MS, + applyPrepResponse, + buildPrepRequest, + evaluatePrepState, + isPrepReady, + nextPollAttempt, + prepStateAfterPollError, + shouldPollPrep, +} from "./predictionPrep.js"; + +const usePredictionArtifacts = ({ + projectId, + imageLayerId, + modelId, + results, + resultsReady, +}) => { + // The id -> row index map is read inside long-lived map handlers, which + // close over the render that registered them, so it lives in a ref. The + // arrays themselves are handed out as a value: anything derived from them + // during render (the selected building, the counts) must not read a ref. + const indexByIdRef = useRef(new Map()); + // Guards every setState that happens after an await. + const mountedRef = useRef(true); + // Bumped whenever the route params change: async work captures the id it + // started under and drops its result if a newer run has taken over. + const runRef = useRef(0); + // Latest session for the async prep helpers, which run outside the render + // that produced `session`. + const sessionRef = useRef(null); + const sessionPromiseRef = useRef(null); + + const [attrs, setAttrs] = useState(null); + const [archiveKey, setArchiveKey] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isLoaded, setIsLoaded] = useState(false); + const [error, setError] = useState(""); + const [session, setSession] = useState(null); + const [versions, setVersions] = useState([]); + const [prepState, setPrepState] = useState(null); + const [buildingCount, setBuildingCount] = useState(null); + + const artifactUrls = useMemo( + () => + resolvePredictionArtifacts(results, { + projectId, + imageLayerId, + modelId, + }), + [results, projectId, imageLayerId, modelId] + ); + + const sessionEndpoint = useMemo( + () => + `GetPredictionEditSession?projectId=${encodeURIComponent(projectId)}` + + `&imageLayerId=${encodeURIComponent(imageLayerId)}` + + `&modelId=${encodeURIComponent(modelId)}`, + [projectId, imageLayerId, modelId] + ); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const isStale = useCallback( + (runId) => !mountedRef.current || runId !== runRef.current, + [] + ); + + const adoptSession = useCallback((editSession) => { + sessionRef.current = editSession; + setSession(editSession); + if (Array.isArray(editSession?.versions)) setVersions(editSession.versions); + if (Number.isFinite(Number(editSession?.buildingCount))) { + setBuildingCount(Number(editSession.buildingCount)); + } + }, []); + + /** + * The edit session, fetched at most once per route (and shared by every + * caller that asks while the request is in flight). + */ + const ensureSession = useCallback( + async ({ refresh = false } = {}) => { + if (!refresh && sessionRef.current) return sessionRef.current; + if (!refresh && sessionPromiseRef.current) { + return sessionPromiseRef.current; + } + const runId = runRef.current; + const promise = apiGet(sessionEndpoint) + .then((editSession) => { + if (isStale(runId)) return editSession; + adoptSession(editSession); + return editSession; + }) + .finally(() => { + if (sessionPromiseRef.current === promise) { + sessionPromiseRef.current = null; + } + }); + sessionPromiseRef.current = promise; + return promise; + }, + [sessionEndpoint, adoptSession, isStale] + ); + + const refreshVersions = useCallback(async () => { + try { + const data = await apiGet( + `GetEditedPredictionVersions?projectId=${encodeURIComponent( + projectId + )}&modelId=${encodeURIComponent(modelId)}` + ); + if (mountedRef.current && Array.isArray(data?.versions)) { + setVersions(data.versions); + } + } catch (versionError) { + console.warn( + "Could not refresh edited prediction versions:", + versionError + ); + } + }, [projectId, modelId]); + + // ── Artifact download ───────────────────────────────────────────────────── + const loadArtifacts = useCallback( + async (runId) => { + setIsLoading(true); + setError(""); + // Streamed through the same-origin API proxy (managed identity server + // side) so analysts behind the storage firewall can read them. + const attrsUrl = buildUrl(artifactUrls.predictionAttrsUrl); + const response = await fetch(attrsUrl); + if (!response.ok) { + const notFound = response.status === 404; + const failure = new Error( + `Failed to load prediction attributes (HTTP ${response.status}).` + ); + failure.missing = notFound; + throw failure; + } + const loadedAttrs = normalizeAttrs(await response.json()); + if (isStale(runId)) return false; + if (loadedAttrs.n === 0) { + setBuildingCount(0); + return false; + } + + // Download the whole archive once and serve pmtiles.js from memory: the + // SWA /api proxy in front of the function app does not honour range + // requests. + const archiveUrl = buildUrl(artifactUrls.footprintTilesUrl); + const protocol = getPmtilesProtocol(); + const buffer = await fetchArtifactBuffer(archiveUrl); + if (isStale(runId)) return false; + const archive = new PMTiles( + new InMemoryPMTilesSource(archiveUrl, buffer) + ); + if (protocol) protocol.add(archive); + + indexByIdRef.current = indexById(loadedAttrs); + setBuildingCount(loadedAttrs.n); + setAttrs(loadedAttrs); + setArchiveKey(archiveUrl); + setIsLoaded(true); + setIsLoading(false); + return true; + }, + [artifactUrls, isStale] + ); + + // ── Preparation ─────────────────────────────────────────────────────────── + // Nothing else in the app queues the job that builds these artifacts, so the + // results page does it — once, without force, and again (with force) from + // the Retry action after a terminal failure. + const requestPreparation = useCallback( + async (force = false) => { + const runId = runRef.current; + setPrepState({ + phase: PREP_PHASE_REQUESTING, + status: "", + statusMessage: "", + attempt: 0, + error: "", + }); + try { + const response = await apiPut( + "PutPreparePredictionTilesQueueMessage", + buildPrepRequest({ projectId, imageLayerId, modelId, force }) + ); + if (isStale(runId)) return; + // The response carries the same readiness flags as the session, so a + // job that had already finished starts the load immediately instead of + // waiting out a poll interval. + const merged = applyPrepResponse(sessionRef.current, response); + adoptSession(merged); + const decision = evaluatePrepState(merged, 0, MAX_PREP_POLL_ATTEMPTS); + setPrepState(decision.ready ? null : decision); + } catch (prepError) { + if (isStale(runId)) return; + console.error("Could not queue prediction tile preparation:", prepError); + setPrepState({ + phase: PREP_PHASE_FAILED, + status: "", + statusMessage: "", + attempt: 0, + error: + prepError?.message || + "The preparation job could not be queued. Try again.", + }); + } + }, + [projectId, imageLayerId, modelId, adoptSession, isStale] + ); + + // Artifacts are missing: find out why (an empty model never gets any) and, + // unless there is nothing to build, get the job moving. + const beginPreparation = useCallback( + async (runId, reason = "") => { + setIsLoading(false); + // The server already ruled a job out — never processed, no predictions, + // no buildings. Queueing one would poll forever against a job that can + // never run, so show its explanation instead. + if (!shouldRequestPreparation(reason)) return; + let editSession = null; + try { + editSession = await ensureSession({ refresh: true }); + } catch (sessionError) { + if (isStale(runId)) return; + console.error("Could not read the prediction edit session:", sessionError); + setError( + "The predicted buildings could not be read for this model." + ); + return; + } + if (isStale(runId)) return; + if (Number(editSession?.buildingCount) === 0) { + setBuildingCount(0); + return; + } + if (isPrepReady(editSession)) { + // The artifacts exist after all (a race with the job finishing); + // try the download again rather than sitting on a preparing note. + try { + await loadArtifacts(runId); + } catch (retryError) { + if (isStale(runId)) return; + setError( + retryError?.message || + "The predicted building footprints could not be loaded." + ); + } + return; + } + await requestPreparation(false); + }, + [ensureSession, isStale, loadArtifacts, requestPreparation] + ); + + // ── Load ────────────────────────────────────────────────────────────────── + useEffect(() => { + if (!resultsReady) return undefined; + const runId = runRef.current + 1; + runRef.current = runId; + + // Route params can change without remounting; start from a clean slate — + // but keep whatever the results payload already told us, so a + // vector-first API answers "how many buildings?" and "which versions + // exist?" without a single extra request. + setAttrs(null); + indexByIdRef.current = new Map(); + sessionRef.current = null; + sessionPromiseRef.current = null; + setSession(null); + setVersions(resolveInitialVersions(results)); + setPrepState(null); + setArchiveKey(""); + setIsLoaded(false); + setError(""); + const initialBuildingCount = resolveInitialBuildingCount(results); + setBuildingCount(initialBuildingCount); + + const load = async () => { + // Nothing was predicted: no job will ever produce footprints, so do not + // ask for artifacts that cannot exist. + if (initialBuildingCount === 0) return; + const reason = resolveReadinessReason(results); + // A payload that already says "not ready" saves us a doomed download. + if (resolvePredictionsReady({ results }) === false) { + await beginPreparation(runId, reason); + return; + } + try { + await loadArtifacts(runId); + } catch (loadError) { + if (isStale(runId)) return; + setIsLoading(false); + if (loadError?.missing) { + // Not built yet (or not built for this model): the session says + // which, and preparation takes it from there. + await beginPreparation(runId, reason); + return; + } + console.error("Could not load predicted building footprints:", loadError); + setError( + loadError?.message || + "The predicted building footprints could not be loaded." + ); + } + }; + + load(); + + return () => { + // Nothing to dispose here: the map layers belong to + // usePredictionFootprints, and the in-flight requests drop their + // results through isStale(). + runRef.current += 1; + }; + // `results` is only read for its readiness flag and artifact URLs, both of + // which are folded into artifactUrls / resultsReady. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId, imageLayerId, modelId, resultsReady]); + + // ── Preparation polling ─────────────────────────────────────────────────── + // Each pass schedules exactly ONE timeout and then re-runs off the state it + // wrote, so there is never more than one timer in flight. The cleanup clears + // it, which is what stops polling on unmount and on a route change. + useEffect(() => { + if (!shouldPollPrep(prepState?.phase)) return undefined; + let cancelled = false; + const timer = window.setTimeout(async () => { + const runId = runRef.current; + try { + const editSession = await apiGet(sessionEndpoint); + if (cancelled || isStale(runId)) return; + adoptSession(editSession); + const decision = evaluatePrepState( + editSession, + nextPollAttempt(prepState.attempt), + MAX_PREP_POLL_ATTEMPTS + ); + if (decision.ready) { + setPrepState(null); + try { + await loadArtifacts(runId); + } catch (loadError) { + if (cancelled || isStale(runId)) return; + setError( + loadError?.message || + "The predicted building footprints could not be loaded." + ); + } + return; + } + setPrepState(decision); + } catch (pollError) { + if (cancelled || isStale(runId)) return; + // A blip in the API must not abandon a healthy job: keep the last + // known status and count the attempt against the cap. + setPrepState((previous) => + prepStateAfterPollError( + previous, + pollError?.message, + MAX_PREP_POLL_ATTEMPTS + ) + ); + } + }, PREP_POLL_INTERVAL_MS); + + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [prepState, sessionEndpoint, adoptSession, isStale, loadArtifacts]); + + const status = useMemo( + () => + resolveFootprintStatus({ + loaded: isLoaded, + loading: isLoading, + error, + ready: prepState ? false : resolvePredictionsReady({ results, session }), + buildingCount, + // Only trusted while a job is not already running: once one is, the + // poll knows more than the reason the page loaded with. + reason: prepState ? "" : resolveReadinessReason(results), + }), + [isLoaded, isLoading, error, prepState, results, session, buildingCount] + ); + + return { + status, + isEmpty: status === FOOTPRINTS_EMPTY, + error, + readinessDetail: resolveReadinessDetail(results), + activeVersion: resolveActiveVersion(results), + attrs, + indexByIdRef, + archiveKey, + session, + ensureSession, + versions, + refreshVersions, + prepState, + requestPreparation, + }; +}; + +export default usePredictionArtifacts; diff --git a/ui/src/Components/Visualizer/usePredictionFootprints.js b/ui/src/Components/Visualizer/usePredictionFootprints.js new file mode 100644 index 00000000..f6623d0f --- /dev/null +++ b/ui/src/Components/Visualizer/usePredictionFootprints.js @@ -0,0 +1,936 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Draws one model's predicted building footprints on the results page and, +// in edit mode, makes them editable. +// +// This is the layer both HASTE workflows share: the inference workflow also +// ships pre-coloured rasters, but an embedding model has nothing else to +// show, so per-building vectors are what makes the results page work for +// either. Footprints stream from the layer's PMTiles archive (never a +// download of every polygon), the per-building scores come from the small +// JSON sidecar held in a ref, and each building's class is derived in the +// browser from those scores plus the current thresholds, with any user edit +// taking precedence — predictionClassify.js owns that logic and is +// unit-tested. Colouring is applied as feature-state on the renderer beneath +// atlas.Map, keyed by the integer feature id, which is why moving the +// threshold slider recolours instantly with no server round-trip. +// +// THE SWIPE MAP IS ALWAYS UP on the results page. atlas.SwipeMap clips the +// SECONDARY (post-event) map to reveal the PRIMARY (pre-event) one on the +// left of the divider, so a click on the uncovered left half never reaches +// the post-event map. Both panes therefore get their own copy of the source, +// their own layers, their own interaction handlers, and a mirror of every +// feature-state write and paint expression — otherwise half the map is inert +// and the far side draws every footprint in the "not classified" colour. +// +// Saving PUTs the thresholds plus the sparse override list to +// PutEditedPredictions, which writes a brand-new version — nothing is +// destructive. +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { tokens } from "@fluentui/react-components"; +import { apiPut } from "../../util/api"; +import { + FILTER_ALL, + buildSavePayload, + classifyAll, + clearOverride, + countClassChanges, + cycleClass, + filterIndices, + matchesFilter, + nextIndexInList, + setOverrideEntries, + setOverrides, +} from "./predictionClassify.js"; +import { + FALLBACK_COLORS, + FILL_OPACITY_EXPRESSION, + PMTILES_SOURCE_LAYER, + STROKE_WIDTH_EXPRESSION, + discoverFillLayerIds, + discoverVectorSourceId, + featureCentroid, + fillColorExpression, + findGlMap, + footprintFeatureState, + normalizeSelectionBox, + resolveMapColors, + strokeColorExpression, + themeColorLookup, +} from "./predictionFootprintMap.js"; +import { hasUnsavedEdits } from "./predictionResults.js"; + +// Each pane needs its own source and layer ids: the two Azure Maps instances +// have separate styles and never meet, and distinct ids keep the debugger +// honest about which renderer is which. +const PANE_IDS = [ + { + key: "primary", + sourceId: "visualizerPrimaryBuildings", + fillLayerId: "visualizerPrimaryFootprintFill", + lineLayerId: "visualizerPrimaryFootprintOutline", + }, + { + key: "secondary", + sourceId: "visualizerSecondaryBuildings", + fillLayerId: "visualizerSecondaryFootprintFill", + lineLayerId: "visualizerSecondaryFootprintOutline", + }, +]; + +// `tokens.x` is the string "var(--x)"; resolveMapColors unwraps it and reads +// the concrete value off a live element inside the FluentProvider subtree, so +// the map follows the light/dark theme instead of a hardcoded palette. +const MAP_COLOR_TOKENS = { + damaged: tokens.colorStatusDangerBackground3, + notDamaged: tokens.colorStatusSuccessBackground3, + unknown: tokens.colorNeutralForeground3, + pending: tokens.colorNeutralBackground5, + outline: tokens.colorNeutralStrokeAccessible, + edited: tokens.colorBrandStroke1, + selected: tokens.colorNeutralForeground1, +}; + +const DEFAULT_THRESHOLD = 0.5; + +// Shared empty object so the derived baseline keeps a stable identity and the +// effects that depend on it do not re-run every render. +const EMPTY_OVERRIDES = {}; + +const usePredictionFootprints = ({ + projectId, + imageLayerId, + modelId, + mapRefs, + mapsReady, + archiveKey, + attrs, + indexByIdRef, + isEditMode, + themeHostRef, + selectionBoxRef, + isDark, + palette, + defaultThreshold, + onSaved, +}) => { + // ── Refs the long-lived map handlers read ──────────────────────────────── + // A handler registered when the layers are built closes over that render's + // values, so everything it needs is mirrored into a ref. + const panesRef = useRef([]); + const classesRef = useRef([]); + const editedRef = useRef([]); + const filterRef = useRef(FILTER_ALL); + const clickActionRef = useRef("cycle"); + const editModeRef = useRef(false); + const colorsRef = useRef(FALLBACK_COLORS); + const selectedIdRef = useRef(null); + // id -> [lng, lat], harvested from rendered footprints. The sidecar carries + // no geometry, so this is the only way Prev/Next knows where to pan. + const centroidsRef = useRef(new Map()); + // pane key -> the id the renderer gave our vector source. + const sourceIdsRef = useRef({}); + const hydrateTimerRef = useRef(null); + // Set by Prev/Next only: clicking a footprint must not yank the camera. + const pendingPanRef = useRef(false); + const mountedRef = useRef(true); + + // ── State ──────────────────────────────────────────────────────────────── + // Azure Maps builds layers inside its async "ready" handler, so readiness + // is mirrored in state: the paint and hydrate effects depend on this flag + // because refs alone never trigger a render. + const [layersReady, setLayersReady] = useState(false); + const [isVisible, setIsVisible] = useState(true); + // The thresholds are DERIVED, not synced. The model's own operating point + // arrives late (with the edit session) and a save moves the goalposts + // again, so holding either in state would mean copying a prop into state + // and then racing to keep it correct. Instead: null means "whatever the + // model says", and only a deliberate move by the analyst is stored. + const [thresholdOverride, setThresholdOverride] = useState(null); + const [unknownThresholdOverride, setUnknownThresholdOverride] = + useState(null); + // Set by save(): from then on, "unsaved" and the "would change class" + // readout are measured against the version that was written. + const [savedBaseline, setSavedBaseline] = useState(null); + const [overrides, setOverridesState] = useState({}); + const [filter, setFilter] = useState(FILTER_ALL); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [clickAction, setClickAction] = useState("cycle"); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(""); + const [savedResult, setSavedResult] = useState(null); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // ── Ref mirrors ────────────────────────────────────────────────────────── + useEffect(() => { + filterRef.current = filter; + }, [filter]); + useEffect(() => { + clickActionRef.current = clickAction; + }, [clickAction]); + useEffect(() => { + editModeRef.current = isEditMode; + }, [isEditMode]); + + // The model's own operating point, so the first paint matches what the rest + // of the app already shows for this model. + const modelThreshold = Number.isFinite(Number(defaultThreshold)) + ? Number(defaultThreshold) + : DEFAULT_THRESHOLD; + const threshold = thresholdOverride ?? modelThreshold; + const unknownThreshold = unknownThresholdOverride ?? 0; + + // What unsaved work is measured against: the model's operating point until + // something has been saved, and the saved version afterwards. + const baseline = useMemo( + () => + savedBaseline ?? { + threshold: modelThreshold, + unknownThreshold: 0, + overrides: EMPTY_OVERRIDES, + }, + [savedBaseline, modelThreshold] + ); + + // ── Renderer helpers (read refs so map handlers stay valid) ────────────── + // Azure Maps renames our vector source inside the renderer's style, and the + // real name only shows up on the first rendered feature. It is learned once + // per pane and kept here rather than written back onto the pane object, + // which the panes' handlers treat as read-only. + const rememberSourceId = useCallback((pane, source) => { + if (!source || sourceIdsRef.current[pane.key] === source) return; + sourceIdsRef.current = { ...sourceIdsRef.current, [pane.key]: source }; + }, []); + + const writeFeatureState = useCallback((id, state) => { + for (const pane of panesRef.current) { + if (!pane.glMap) continue; + try { + pane.glMap.setFeatureState( + { + source: sourceIdsRef.current[pane.key] || pane.sourceId, + sourceLayer: PMTILES_SOURCE_LAYER, + id, + }, + state + ); + } catch (error) { + console.warn("feature-state write failed:", error); + } + } + }, []); + + const repaintFootprints = useCallback(() => { + for (const pane of panesRef.current) { + if (pane.map && typeof pane.map.triggerRepaint === "function") { + pane.map.triggerRepaint(); + } + } + }, []); + + const queryPane = useCallback((pane, box) => { + if (!pane?.glMap) return []; + try { + return ( + pane.glMap.queryRenderedFeatures( + box, + pane.layerIds && pane.layerIds.length + ? { layers: pane.layerIds } + : undefined + ) || [] + ); + } catch (error) { + console.warn("queryRenderedFeatures failed:", error); + return []; + } + }, []); + + // Paint every footprint currently on screen from the cached classification, + // and remember where each one is so Prev/Next can pan to it. Both panes are + // queried: they draw the same buildings but load their tiles independently, + // so neither is authoritative on its own. + const hydrateViewport = useCallback(() => { + const classes = classesRef.current; + const edited = editedRef.current; + const byId = indexByIdRef.current; + const activeFilter = filterRef.current; + const selectedId = selectedIdRef.current; + const seen = new Set(); + let wrote = false; + for (const pane of panesRef.current) { + for (const feature of queryPane(pane, undefined)) { + const id = feature.id; + if (id == null || seen.has(id)) continue; + seen.add(id); + // The first rendered feature tells us what the renderer actually + // calls our source, which is the id every feature-state write needs. + rememberSourceId(pane, feature.source); + const index = byId.get(id); + if (index === undefined) continue; + if (!centroidsRef.current.has(id)) { + const centroid = featureCentroid(feature.geometry); + if (centroid) centroidsRef.current.set(id, centroid); + } + const cls = classes[index]; + writeFeatureState( + id, + footprintFeatureState({ + cls, + dim: !matchesFilter(cls, edited[index], activeFilter), + edited: !!edited[index], + selected: selectedId === id, + }) + ); + wrote = true; + } + } + if (wrote) repaintFootprints(); + }, [ + indexByIdRef, + queryPane, + rememberSourceId, + repaintFootprints, + writeFeatureState, + ]); + + // Tile loads and camera moves arrive in bursts; coalesce them so a pan + // costs one queryRenderedFeatures pass rather than a dozen. + const scheduleHydrate = useCallback(() => { + if (hydrateTimerRef.current) return; + hydrateTimerRef.current = window.setTimeout(() => { + hydrateTimerRef.current = null; + hydrateViewport(); + }, 120); + }, [hydrateViewport]); + + // ── Editing ────────────────────────────────────────────────────────────── + const handleFeatureClick = useCallback( + (id) => { + const index = indexByIdRef.current.get(id); + if (index === undefined) return; + setSelectedIndex(index); + const action = clickActionRef.current; + const cls = + action === "cycle" ? cycleClass(classesRef.current[index]) : action; + setOverridesState((previous) => setOverrides(previous, [id], cls)); + }, + [indexByIdRef] + ); + + const handleClearOverrideForId = useCallback( + (id) => { + const index = indexByIdRef.current.get(id); + if (index === undefined) return; + setSelectedIndex(index); + setOverridesState((previous) => clearOverride(previous, id)); + }, + [indexByIdRef] + ); + + const applyClickActionToIds = useCallback( + (ids) => { + if (ids.length === 0) return; + const action = clickActionRef.current; + if (action !== "cycle") { + setOverridesState((previous) => setOverrides(previous, ids, action)); + return; + } + // Cycle mode over a box: advance each building from its own class. + const classes = classesRef.current; + const byId = indexByIdRef.current; + const entries = ids + .map((id) => { + const index = byId.get(id); + if (index === undefined) return null; + return { id, class: cycleClass(classes[index]) }; + }) + .filter(Boolean); + setOverridesState((previous) => setOverrideEntries(previous, entries)); + }, + [indexByIdRef] + ); + + const setClassForSelected = useCallback( + (cls) => { + if (!attrs || selectedIndex < 0 || selectedIndex >= attrs.n) return; + const id = attrs.ids[selectedIndex]; + setOverridesState((previous) => setOverrides(previous, [id], cls)); + }, + [attrs, selectedIndex] + ); + + const clearSelectedOverride = useCallback(() => { + if (!attrs || selectedIndex < 0 || selectedIndex >= attrs.n) return; + setOverridesState((previous) => + clearOverride(previous, attrs.ids[selectedIndex]) + ); + }, [attrs, selectedIndex]); + + const clearAllOverrides = useCallback(() => setOverridesState({}), []); + + // The feature under an event, on one pane's renderer. + function featureAtEvent(pane, event) { + if (!pane.glMap) return null; + let pixel = event.pixel; + if (!pixel && event.position) { + const pixels = pane.map.positionsToPixels([event.position]); + pixel = pixels && pixels[0]; + } + if (!pixel) return null; + try { + const rendered = pane.glMap.queryRenderedFeatures( + pixel, + pane.layerIds && pane.layerIds.length + ? { layers: pane.layerIds } + : undefined + ); + const feature = rendered && rendered[0]; + if (!feature || feature.id == null) return null; + rememberSourceId(pane, feature.source); + return { id: feature.id, source: feature.source }; + } catch (error) { + console.warn("queryRenderedFeatures failed:", error); + return null; + } + } + + // Ctrl+drag box-select, wired per pane: a drag that starts on the uncovered + // side of the divider has to select buildings too. Both canvases are the + // same size and in the same place, so they share one rectangle element with + // no coordinate translation. + function attachBoxSelect(pane) { + const canvas = pane.map.getCanvasContainer(); + let origin = null; + + const onDown = (event) => { + if (!editModeRef.current) return; + if (!event.ctrlKey && !event.metaKey) return; + event.preventDefault(); + event.stopPropagation(); + pane.map.setUserInteraction({ dragPanInteraction: false }); + const rect = canvas.getBoundingClientRect(); + origin = { x: event.clientX - rect.left, y: event.clientY - rect.top }; + const box = selectionBoxRef?.current; + if (box) { + box.style.display = "block"; + box.style.left = `${origin.x}px`; + box.style.top = `${origin.y}px`; + box.style.width = "0px"; + box.style.height = "0px"; + } + }; + + const onMove = (event) => { + if (!origin) return; + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const box = selectionBoxRef?.current; + if (box) { + box.style.left = `${Math.min(origin.x, x)}px`; + box.style.top = `${Math.min(origin.y, y)}px`; + box.style.width = `${Math.abs(x - origin.x)}px`; + box.style.height = `${Math.abs(y - origin.y)}px`; + } + }; + + const onUp = (event) => { + if (!origin) return; + const rect = canvas.getBoundingClientRect(); + const selection = normalizeSelectionBox(origin, { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }); + origin = null; + if (selectionBoxRef?.current) { + selectionBoxRef.current.style.display = "none"; + } + pane.map.setUserInteraction({ dragPanInteraction: true }); + if (!selection) return; + const features = queryPane(pane, [ + [selection.x1, selection.y1], + [selection.x2, selection.y2], + ]); + const ids = [ + ...new Set( + features.filter((feature) => feature.id != null).map((f) => f.id) + ), + ]; + applyClickActionToIds(ids); + }; + + canvas.addEventListener("mousedown", onDown); + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + return () => { + canvas.removeEventListener("mousedown", onDown); + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + } + + // ── Layers ─────────────────────────────────────────────────────────────── + // Built once the maps are ready and the archive is in memory, on BOTH panes. + // Everything the handlers need comes from refs, so this effect never re-runs + // for an edit-mode toggle or a class change. + useEffect(() => { + if (!mapsReady || !archiveKey || !window.atlas) return undefined; + const maps = (mapRefs || []) + .map((ref) => ref?.current) + .filter((map) => map && typeof map.layers?.add === "function"); + if (maps.length === 0) return undefined; + + const panes = []; + const paint = colorsRef.current; + // A rebuild (new archive, or a remount) invalidates whatever the previous + // renderer called our source. + sourceIdsRef.current = {}; + + maps.forEach((map, position) => { + const ids = PANE_IDS[position] || PANE_IDS[PANE_IDS.length - 1]; + try { + const source = new window.atlas.source.VectorTileSource(ids.sourceId, { + type: "vector", + url: `pmtiles://${archiveKey}`, + // Azure Maps ignores promoteId, but the tiles already carry native + // integer feature ids (tippecanoe --use-attribute-for-id=id), which + // is what setFeatureState needs. + promoteId: { [PMTILES_SOURCE_LAYER]: "id" }, + }); + map.sources.add(source); + + const fillLayer = new window.atlas.layer.PolygonLayer( + ids.sourceId, + ids.fillLayerId, + { + sourceLayer: PMTILES_SOURCE_LAYER, + fillColor: fillColorExpression(paint), + fillOpacity: FILL_OPACITY_EXPRESSION, + visible: true, + } + ); + map.layers.add(fillLayer); + + const lineLayer = new window.atlas.layer.LineLayer( + ids.sourceId, + ids.lineLayerId, + { + sourceLayer: PMTILES_SOURCE_LAYER, + strokeColor: strokeColorExpression(paint), + strokeWidth: STROKE_WIDTH_EXPRESSION, + visible: true, + } + ); + map.layers.add(lineLayer); + + const glMap = findGlMap(map); + const pane = { + key: ids.key, + map, + source, + fillLayer, + lineLayer, + glMap, + layerIds: discoverFillLayerIds(glMap, [ids.fillLayerId], [ids.sourceId]), + sourceId: discoverVectorSourceId(glMap, ids.sourceId), + handlers: [], + detachBox: null, + }; + + // Interaction handlers are attached ONCE and bail out unless edit mode + // is on. Adding and removing them on every toggle is how listeners get + // orphaned; a ref check cannot leak. + const onClick = (event) => { + if (!editModeRef.current) return; + // Ctrl+click starts a box-select drag; don't also set a class. + if ( + event.originalEvent && + (event.originalEvent.ctrlKey || event.originalEvent.metaKey) + ) { + return; + } + const feature = featureAtEvent(pane, event); + if (feature) handleFeatureClick(feature.id); + }; + const onContextMenu = (event) => { + if (!editModeRef.current) return; + // The browser's own menu over a map you are editing is never what + // the analyst wanted. + if (event?.originalEvent?.preventDefault) { + event.originalEvent.preventDefault(); + } + const feature = featureAtEvent(pane, event); + if (feature) handleClearOverrideForId(feature.id); + return false; + }; + const onHydrate = () => scheduleHydrate(); + const onSourceData = (event) => { + if (event && event.isSourceLoaded) scheduleHydrate(); + }; + map.events.add("click", fillLayer, onClick); + map.events.add("contextmenu", fillLayer, onContextMenu); + map.events.add("moveend", onHydrate); + map.events.add("sourcedata", onSourceData); + pane.handlers = [ + ["click", fillLayer, onClick], + ["contextmenu", fillLayer, onContextMenu], + ["moveend", null, onHydrate], + ["sourcedata", null, onSourceData], + ]; + pane.detachBox = attachBoxSelect(pane); + panes.push(pane); + } catch (error) { + console.warn("Could not add the footprint layer to a map:", error); + } + }); + + panesRef.current = panes; + setLayersReady(panes.length > 0); + hydrateViewport(); + + return () => { + for (const pane of panes) { + if (pane.detachBox) pane.detachBox(); + for (const [name, target, handler] of pane.handlers) { + try { + if (target) pane.map.events.remove(name, target, handler); + else pane.map.events.remove(name, handler); + } catch (error) { + console.warn("Could not detach a footprint handler:", error); + } + } + try { + pane.map.layers.remove(pane.fillLayer); + pane.map.layers.remove(pane.lineLayer); + pane.map.sources.remove(pane.source); + } catch (error) { + console.warn("Could not remove the footprint layer:", error); + } + try { + pane.map.getCanvasContainer().style.cursor = ""; + } catch { + // The map may already be disposed; nothing to restore. + } + } + if (hydrateTimerRef.current) { + window.clearTimeout(hydrateTimerRef.current); + hydrateTimerRef.current = null; + } + panesRef.current = []; + sourceIdsRef.current = {}; + if (mountedRef.current) setLayersReady(false); + }; + // The callbacks below are stable (useCallback over refs); listing them + // would not change when this effect runs, and featureAtEvent / + // attachBoxSelect are plain closures defined in this module scope. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mapsReady, archiveKey]); + + // ── Classification ─────────────────────────────────────────────────────── + // Every building's class is a pure function of the scores, the thresholds + // and the analyst's overrides, so it is derived rather than stored — that + // is what makes the threshold slider recolour the map with no server round + // trip and no state to keep in sync. + const classification = useMemo( + () => + attrs + ? classifyAll(attrs, { threshold, unknownThreshold, overrides }) + : null, + [attrs, threshold, unknownThreshold, overrides] + ); + + // "N buildings would change class", measured against the operating point + // the current view was saved (or shipped) with. + const changeCount = useMemo( + () => + attrs + ? countClassChanges( + attrs, + baseline, + { threshold, unknownThreshold }, + overrides + ) + : 0, + [attrs, baseline, threshold, unknownThreshold, overrides] + ); + + // Mirror the classification for the map handlers (which close over the + // render that registered them) and repaint what is on screen. layersReady + // is in the deps because the layers are created inside the maps' async + // "ready" path — reading the pane refs during render would see an empty + // list and never re-run. + useEffect(() => { + classesRef.current = classification?.classes || []; + editedRef.current = classification?.edited || []; + if (!layersReady || !classification) return; + hydrateViewport(); + }, [classification, filter, layersReady, hydrateViewport]); + + // Resolve the map palette from the active Fluent theme, and re-apply it when + // the user flips light/dark or changes the brand palette. Paint expressions + // are per-renderer, so both panes get their own copy. + useEffect(() => { + const resolved = resolveMapColors( + MAP_COLOR_TOKENS, + themeColorLookup(themeHostRef?.current) + ); + colorsRef.current = resolved; + const fillColor = fillColorExpression(resolved); + const strokeColor = strokeColorExpression(resolved); + for (const pane of panesRef.current) { + try { + pane.fillLayer.setOptions({ fillColor }); + pane.lineLayer.setOptions({ strokeColor }); + } catch (error) { + console.warn("Could not restyle the footprint layer:", error); + } + } + }, [isDark, palette, layersReady, themeHostRef]); + + // Visibility, driven by the InfoPanel checkbox. Edit mode forces the layer + // on: editing footprints nobody can see is not a state worth supporting. + useEffect(() => { + const visible = isEditMode || isVisible; + for (const pane of panesRef.current) { + try { + pane.fillLayer.setOptions({ visible }); + pane.lineLayer.setOptions({ visible }); + } catch (error) { + console.warn("Could not toggle the footprint layer:", error); + } + } + }, [isVisible, isEditMode, layersReady]); + + // The pointer cursor is the only interaction affordance that changes with + // edit mode; the handlers themselves are always attached and check the ref. + useEffect(() => { + for (const pane of panesRef.current) { + try { + pane.map.getCanvasContainer().style.cursor = isEditMode + ? "pointer" + : ""; + } catch (error) { + console.warn("Could not set the map cursor:", error); + } + } + }, [isEditMode, layersReady]); + + // ── Selection ──────────────────────────────────────────────────────────── + const filteredIndices = useMemo( + () => (classification ? filterIndices(classification, filter) : []), + [classification, filter] + ); + + // Changing the filter can strand the selection outside the visible set, so + // snap it to the first match as part of the same event rather than in an + // effect (which would cost an extra render pass). + const handleFilterChange = useCallback( + (nextFilter) => { + setFilter(nextFilter); + if (!classification) return; + const nextIndices = filterIndices(classification, nextFilter); + if (nextIndices.length === 0) return; + setSelectedIndex((current) => + current >= 0 && !nextIndices.includes(current) + ? nextIndices[0] + : current + ); + }, + [classification] + ); + + // Highlight the selected footprint, and pan to it when the selection came + // from Prev/Next. Buildings whose tile has never rendered have no cached + // centroid, so there is nowhere to pan yet. + useEffect(() => { + if (!layersReady) return; + const previousId = selectedIdRef.current; + const nextId = + attrs && selectedIndex >= 0 && selectedIndex < attrs.n + ? attrs.ids[selectedIndex] + : null; + if (previousId != null && previousId !== nextId) { + writeFeatureState(previousId, { selected: false }); + } + selectedIdRef.current = nextId; + if (nextId == null) { + repaintFootprints(); + return; + } + writeFeatureState(nextId, { selected: true }); + repaintFootprints(); + const shouldPan = pendingPanRef.current; + pendingPanRef.current = false; + const centroid = centroidsRef.current.get(nextId); + const pane = panesRef.current[0]; + if (shouldPan && centroid && pane?.map) { + // Both panes share a camera through atlas.SwipeMap, so moving one moves + // the other — adding a second setCamera here would double-update them. + const camera = pane.map.getCamera(); + pane.map.setCamera({ + center: centroid, + zoom: Math.max(camera?.zoom || 0, 17.5), + duration: 500, + }); + } + }, [ + selectedIndex, + layersReady, + attrs, + repaintFootprints, + writeFeatureState, + ]); + + const navigateInFilter = useCallback( + (direction) => { + if (filteredIndices.length === 0) return; + // Prefer buildings we can actually pan to; fall back to the plain next + // one so navigation never stalls. + const hasLocation = (index) => { + const id = attrs?.ids?.[index]; + return id != null && centroidsRef.current.has(id); + }; + const next = nextIndexInList( + filteredIndices, + selectedIndex, + direction, + hasLocation + ); + if (next === null) return; + pendingPanRef.current = true; + setSelectedIndex(next); + }, + [attrs, filteredIndices, selectedIndex] + ); + + // ── Derived view data ──────────────────────────────────────────────────── + const currentBuilding = useMemo(() => { + if ( + !attrs || + !classification || + selectedIndex < 0 || + selectedIndex >= attrs.n + ) { + return null; + } + return { + id: attrs.ids[selectedIndex], + overtureId: attrs.overtureIds[selectedIndex], + damage: attrs.damage[selectedIndex], + unknown: attrs.unknown[selectedIndex], + cls: classification.classes[selectedIndex], + edited: classification.edited[selectedIndex], + }; + }, [attrs, classification, selectedIndex]); + + const isDirty = useMemo( + () => hasUnsavedEdits({ overrides, threshold, unknownThreshold, baseline }), + [overrides, threshold, unknownThreshold, baseline] + ); + + // ── Save ───────────────────────────────────────────────────────────────── + const save = useCallback(async () => { + setIsSaving(true); + setSaveError(""); + try { + const payload = buildSavePayload({ + projectId, + imageLayerId, + modelId, + threshold, + unknownThreshold, + overrides, + }); + const result = await apiPut("PutEditedPredictions", payload); + // apiPut surfaces a conflict as the bare status code. + if (result === 409) { + throw new Error( + "Another version is being written for this model. Try saving again in a moment." + ); + } + if (!result || result.version == null) { + throw new Error("The server did not return a new version number."); + } + if (!mountedRef.current) return result; + setSavedResult(result); + // Everything just written is now the thing later edits are measured + // against, so the page stops calling saved work "unsaved". + setSavedBaseline({ + threshold, + unknownThreshold, + overrides: { ...overrides }, + }); + if (typeof onSaved === "function") await onSaved(result); + return result; + } catch (error) { + const message = + error?.message || "Failed to save the edited predictions."; + if (mountedRef.current) setSaveError(message); + throw error; + } finally { + if (mountedRef.current) setIsSaving(false); + } + }, [ + projectId, + imageLayerId, + modelId, + threshold, + unknownThreshold, + overrides, + onSaved, + ]); + + // Throw away every unsaved edit — used when the analyst confirms leaving + // edit mode. "Unsaved" means "since the last save", so this rewinds to the + // baseline rather than to nothing: edits that were already written to a + // version must survive, or the page would immediately consider itself dirty + // again. + const discardEdits = useCallback(() => { + setOverridesState({ ...(baseline.overrides || {}) }); + setThresholdOverride(baseline.threshold); + setUnknownThresholdOverride(baseline.unknownThreshold); + setSelectedIndex(-1); + setFilter(FILTER_ALL); + setSaveError(""); + }, [baseline]); + + return { + layersReady, + isVisible, + setIsVisible, + classification, + filter, + setFilter: handleFilterChange, + filteredIndices, + selectedIndex, + currentBuilding, + clickAction, + setClickAction, + setClassForSelected, + clearSelectedOverride, + clearAllOverrides, + navigateInFilter, + threshold, + setThreshold: setThresholdOverride, + unknownThreshold, + setUnknownThreshold: setUnknownThresholdOverride, + baseline, + changeCount, + overrides, + isDirty, + save, + discardEdits, + isSaving, + saveError, + savedResult, + }; +}; + +export default usePredictionFootprints; diff --git a/ui/src/Components/PredictionEditor/predictionSwipe.js b/ui/src/Components/Visualizer/visualizerSwipe.js similarity index 50% rename from ui/src/Components/PredictionEditor/predictionSwipe.js rename to ui/src/Components/Visualizer/visualizerSwipe.js index d45eec06..c217e4c4 100644 --- a/ui/src/Components/PredictionEditor/predictionSwipe.js +++ b/ui/src/Components/Visualizer/visualizerSwipe.js @@ -1,21 +1,23 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. // -// Pure decision logic for the Prediction Editor's swipe comparison map. +// Pure decision logic for the results view's swipe map. // -// Nothing here touches the DOM, React, or Azure Maps, so every rule the -// editor relies on — which comparison the analyst gets, what the panes are -// called, and where a keyboard shortcut puts the divider — is unit-testable -// in predictionClassify.test.js. +// Nothing here touches the DOM, React, or Azure Maps, so the rules the +// results page relies on — which comparison the analyst is looking at, what +// the panes are called, and where a keyboard shortcut puts the divider — are +// unit-testable in predictionClassify.test.js. // -// Geometry note that the copy in this file depends on (and that a previous -// PR shipped backwards): atlas.SwipeMap shows its PRIMARY map on the LEFT of -// the divider and clips its SECONDARY to reveal it on the RIGHT. The editor -// wires the comparison map (pre-event imagery, or the plain basemap) as the -// PRIMARY and the editable post-event map as the SECONDARY. So: +// Geometry note that the copy in this file depends on (and that a previous PR +// shipped backwards): atlas.SwipeMap shows its PRIMARY map on the LEFT of the +// divider and clips its SECONDARY to reveal it on the RIGHT. The results page +// wires the pre-event map (or the plain basemap, when the layer has no +// pre-event imagery) as the PRIMARY and the post-event map — the one carrying +// the predicted damage raster and the editable footprints — as the SECONDARY. +// So: // -// divider fully LEFT -> the post-event (editing) map fills the view -// divider fully RIGHT -> the comparison (pre-event / basemap) map fills it +// divider fully LEFT -> the post-event map fills the view +// divider fully RIGHT -> the pre-event / basemap map fills it // Pre-event imagery on the left, post-event imagery on the right. export const SWIPE_MODE_PRE_POST = "prePost"; @@ -32,59 +34,49 @@ function cleanUrl(value) { } /** - * Which comparison applies for a layer's imagery block, as returned by - * GetLayerLabelingToolData (`layerData.imagery`). + * Which comparison the results page is showing, from the imagery it has. * - * The post-event tiles are what the editable map draws its footprints over, - * so without them there is no meaningful comparison and the toggle is not - * offered at all. Pre-event tiles, when present, replace the basemap on the - * comparison pane. + * The post-event tiles are what the predictions are drawn over, so without + * them there is no meaningful comparison at all. Pre-event tiles, when + * present, replace the basemap on the left-hand pane. + * + * Accepts either shape the app hands around: `{ preEventTileUrl, + * postEventTileUrl }` from GetLayerLabelingToolData, or the results payload's + * `{ preDisasterImagery, postDisasterImagery }` blocks. */ export function resolveSwipeMode(imagery) { - const post = cleanUrl(imagery?.postEventTileUrl); + const post = cleanUrl( + imagery?.postEventTileUrl ?? imagery?.postDisasterImagery?.url + ); if (!post) return SWIPE_MODE_NONE; - return cleanUrl(imagery?.preEventTileUrl) - ? SWIPE_MODE_PRE_POST - : SWIPE_MODE_BASEMAP_POST; + const pre = cleanUrl( + imagery?.preEventTileUrl ?? imagery?.preDisasterImagery?.url + ); + return pre ? SWIPE_MODE_PRE_POST : SWIPE_MODE_BASEMAP_POST; } -/** True when the editor should show the swipe toggle. */ +/** True when there are two panes worth comparing. */ export function isSwipeAvailable(mode) { return mode === SWIPE_MODE_PRE_POST || mode === SWIPE_MODE_BASEMAP_POST; } -/** - * The tile URL the comparison pane should draw, or "" when it should just - * show its own basemap. Only the pre/post mode has an imagery overlay. - */ -export function swipeComparisonTileUrl(imagery, mode) { - return mode === SWIPE_MODE_PRE_POST ? cleanUrl(imagery?.preEventTileUrl) : ""; -} - -/** Toggle label — the user must be able to see which comparison they get. */ -export function swipeToggleLabel(mode) { - if (mode === SWIPE_MODE_PRE_POST) return "Swipe: pre-event vs post-event"; - if (mode === SWIPE_MODE_BASEMAP_POST) return "Swipe: basemap vs post-event"; - return "Swipe comparison unavailable"; -} - -/** Badge over the left (comparison) pane. */ +/** Badge/label for the left (pre-event or basemap) pane. */ export function swipeLeftPaneLabel(mode) { if (mode === SWIPE_MODE_PRE_POST) return "Pre-event imagery"; if (mode === SWIPE_MODE_BASEMAP_POST) return "Basemap"; return ""; } -/** Badge over the right (editable, post-event) pane. */ +/** Badge/label for the right (post-event, editable) pane. */ export function swipeRightPaneLabel(mode) { return isSwipeAvailable(mode) ? "Post-event imagery" : ""; } /** - * One-line explanation under the toggle. Direction matters: the comparison - * map is the PRIMARY and sits LEFT of the divider, so dragging the divider - * left uncovers MORE post-event imagery and dragging it right uncovers more - * of the comparison pane. + * One-line explanation of the divider, shown in edit mode. Direction matters: + * the pre-event (or basemap) map is the PRIMARY and sits LEFT of the divider, + * so dragging the divider left uncovers MORE post-event imagery and dragging + * it right uncovers more of the pre-event pane. */ export function swipeModeHint(mode) { if (!isSwipeAvailable(mode)) { diff --git a/ui/src/Components/keyboardShortcuts.js b/ui/src/Components/keyboardShortcuts.js index 76c71104..1ed945ed 100644 --- a/ui/src/Components/keyboardShortcuts.js +++ b/ui/src/Components/keyboardShortcuts.js @@ -1,10 +1,18 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +// Results view (Visualizer), read-only. The swipe map is always up: the +// pre-event map is the SwipeMap PRIMARY and sits left of the divider, so +// moving the divider left uncovers more of the post-event map. export const VISUALIZER_SHORTCUTS = [ { keys: ["A", "S", "D"], - description: "Move the swipe divider left / split / right", + description: + "Move the swipe divider left / split / right — left uncovers more post-event imagery", + }, + { + keys: ["E"], + description: "Enter or leave prediction edit mode", }, ]; @@ -54,8 +62,10 @@ export const BUILDING_VALIDATION_SHORTCUTS = [ }, ]; -// Prediction Editor (edit a model's predictions and save a new version). -export const PREDICTION_EDITOR_SHORTCUTS = [ +// Results view, edit mode (reclassify a model's predictions and save a new +// version). Everything the read-only view binds still works, so A/S/D and E +// are repeated here rather than replaced. +export const PREDICTION_EDIT_SHORTCUTS = [ { keys: ["1", "2", "3"], description: @@ -79,13 +89,17 @@ export const PREDICTION_EDITOR_SHORTCUTS = [ description: "Undo an edit — back to the model's class", }, { - // Direction matters: the comparison map (pre-event imagery, or the - // basemap) is the swipe PRIMARY and sits LEFT of the divider, so moving - // the divider left uncovers MORE of the post-event map and moving it - // right uncovers more of the comparison map. + // Direction matters: the pre-event map (or the basemap) is the swipe + // PRIMARY and sits LEFT of the divider, so moving the divider left + // uncovers MORE of the post-event map and moving it right uncovers more + // of the pre-event pane. Both sides are editable. keys: ["A", "S", "D"], description: - "With Swipe on: snap the divider left / centre / right — left uncovers more post-event imagery, right more of the pre-event (or basemap) pane", + "Snap the divider left / centre / right — left uncovers more post-event imagery, right more of the pre-event (or basemap) pane", + }, + { + keys: ["E"], + description: "Leave edit mode", }, ]; From 3321923f89d2de0e2c23e009e212a38802c50377 Mon Sep 17 00:00:00 2001 From: Caleb Robinson Date: Tue, 25 Aug 2026 18:53:32 +0000 Subject: [PATCH 05/10] feat: choose and download saved prediction versions from View Results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving an edit produced a version nobody could reach. The history was rendered only inside the edit panel, so a read-only viewer could not see that versions existed, nothing ever refetched to draw a different one, and every download button fetched the raw output regardless. Switching was not simply unwired. The attribute sidecar the viewer draws from is keyed per model, not per version, so it always described the raw predictions; saving wrote a GeoPackage but no sidecar. There was nothing version-specific to render. Every saved version now gets its own sidecar, written in the same call as its GeoPackage so the two cannot drift, which makes drawing a version the same code path as drawing the raw output with a different URL. The builder moves out of the training workflow into a shared module so the API can produce one without importing an image it has no business importing. Versions saved before this are backfilled by the tiles job, idempotently and skipping any that already have a sidecar. A version still waiting is offered as disabled with the reason, and the raw sidecar is deliberately never substituted for a missing one — quietly drawing the wrong classes is worse than drawing nothing. The selector sits in the read-only view, and switching rebuilds the source, layers and feature-state on both swipe panes. Feature-state is per-renderer and the source id is reused, so state is cleared before the source is removed; otherwise one pane keeps the previous version's colours, which is how this repo has twice shipped a half-updated map. Selection moves the map only — the reports keep reading the newest version — so the screen says when the two diverge rather than leaving it to be discovered. Downloads go through the artifact route, which already handles auth and ranges, instead of rewriting storage URLs. Also fixes a data-loss hazard the switching exposed: saves always derive from the raw GeoPackage, so saving on top of a loaded version had to resend that version's classes or it would silently discard the edits it was built on. --- api/hastefuncapi/function_app.py | 159 +++- api/hastefuncqueues/function_app.py | 37 +- docs/api/hastefuncapi.md | 133 +++- hastelib/src/hastegeo/core/config.py | 9 + .../src/hastegeo/core/models/predictions.py | 8 + hastelib/src/hastegeo/core/models/projects.py | 9 + .../src/hastegeo/core/models/visualizer.py | 10 + .../core/processors/prediction_edits.py | 186 +++++ .../core/processors/prediction_tiles.py | 216 +++++- .../hastegeo/core/processors/visualizer.py | 50 +- .../hastegeo/core/utils/prediction_attrs.py | 314 ++++++++ .../src/hastegeo/core/utils/predictions.py | 26 +- .../workflows/prepare_prediction_tiles.py | 297 ++++---- .../test_prediction_edits_versions.py | 301 ++++++++ .../core/processors/test_prediction_tiles.py | 1 + .../test_prediction_tiles_backfill.py | 539 +++++++++++++ .../processors/test_prediction_tiles_layer.py | 1 + .../test_prediction_tiles_request.py | 1 + .../processors/test_visualizer_versions.py | 284 +++++++ .../tests/core/utils/test_prediction_attrs.py | 288 +++++++ .../utils/test_prediction_source_versions.py | 187 +++++ .../test_prepare_prediction_tiles_versions.py | 358 +++++++++ ...-versioned-derived-prediction-artifacts.md | 245 +++--- spec/features/prediction-editing/README.md | 208 +++-- .../features/prediction-editing/data-model.md | 305 ++++---- spec/features/prediction-editing/design.md | 716 ++++++------------ .../prediction-editing/impact-analysis.md | 167 ++-- spec/features/prediction-editing/plan.md | 180 ++--- spec/features/prediction-editing/rollout.md | 159 ++-- spec/features/prediction-editing/test-plan.md | 223 +++--- .../prediction-editing/user-stories.md | 351 +++++---- .../Visualizer/PredictionEditPanel.jsx | 71 +- .../Visualizer/PredictionStatusNote.jsx | 17 +- .../Visualizer/PredictionVersionControls.jsx | 239 ++++++ ui/src/Components/Visualizer/Visualizer.jsx | 368 ++++++++- .../Visualizer/predictionClassify.js | 116 ++- .../Visualizer/predictionClassify.test.js | 580 +++++++++++++- .../Components/Visualizer/predictionPrep.js | 24 + .../Visualizer/predictionResults.js | 66 +- .../Visualizer/predictionVersions.js | 319 ++++++++ .../Visualizer/usePredictionArtifacts.js | 139 +++- .../Visualizer/usePredictionFootprints.js | 69 +- 42 files changed, 6181 insertions(+), 1795 deletions(-) create mode 100644 hastelib/src/hastegeo/core/utils/prediction_attrs.py create mode 100644 hastelib/tests/core/processors/test_prediction_edits_versions.py create mode 100644 hastelib/tests/core/processors/test_prediction_tiles_backfill.py create mode 100644 hastelib/tests/core/processors/test_visualizer_versions.py create mode 100644 hastelib/tests/core/utils/test_prediction_attrs.py create mode 100644 hastelib/tests/core/utils/test_prediction_source_versions.py create mode 100644 hastelib/tests/workflows/test_prepare_prediction_tiles_versions.py create mode 100644 ui/src/Components/Visualizer/PredictionVersionControls.jsx create mode 100644 ui/src/Components/Visualizer/predictionVersions.js diff --git a/api/hastefuncapi/function_app.py b/api/hastefuncapi/function_app.py index 81553e3e..a29952fc 100644 --- a/api/hastefuncapi/function_app.py +++ b/api/hastefuncapi/function_app.py @@ -1435,6 +1435,12 @@ async def GetLayerModelsDetails(req: func.HttpRequest) -> func.HttpResponse: _MODEL_ARTIFACT_KINDS = sorted( set(_MODEL_ARTIFACT_URL_FIELDS) | set(_LAYER_ARTIFACT_URL_FIELDS) ) +# Kinds that exist per edited-prediction version. Everything else is +# version-independent (the footprint tiles are geometry only, and the +# embedding artifacts predate editing), so a `version` on those is +# ignored rather than rejected — a client may pass the viewer's current +# version to every artifact call. +_VERSIONED_ARTIFACT_KINDS = {"prediction_attrs", "gpkg"} @app.route( @@ -1466,10 +1472,19 @@ async def GetModelArtifact(req: func.HttpRequest) -> func.HttpResponse: ``application/vnd.pmtiles``). ``footprint_pmtiles`` lives on the ImageLayer; pass ``imageLayerId`` to select it explicitly, otherwise the model's own image layer is used. + + ``version`` (optional, ``prediction_attrs`` and ``gpkg`` only) picks + an analyst-edited revision from ``Model.editedPredictions``: ``0`` + forces the raw model output, an absent parameter keeps the historic + behaviour (the model-level artifact, i.e. the raw output), and ``N`` + serves that version's own artifact. An unknown version — or a version + whose sidecar has not been built yet — is a 404. The parameter is + ignored for the version-independent kinds. """ try: project_id = _require_guid_param(req, "projectId") model_id = _require_short_int_id_param(req, "modelId") + version = _optional_version_param(req) except ValueError as e: return _bad_request(str(e)) @@ -1540,7 +1555,43 @@ async def GetModelArtifact(req: func.HttpRequest) -> func.HttpResponse: "Error loading image layer.", status_code=500 ) - blob_url = (document or {}).get(url_field) or "" + document = document or {} + blob_url = document.get(url_field) or "" + if version is not None and kind in _VERSIONED_ARTIFACT_KINDS: + # Version resolution is a hastegeo decision (which revision, and + # which of its two artifacts); this handler only maps the outcome + # onto HTTP. Lazy import: the module pulls in fiona. + from hastegeo.core.utils.predictions import ( + PredictionVersionNotFoundError, + describe_prediction_source, + ) + + try: + source = describe_prediction_source(document, version=version) + except PredictionVersionNotFoundError as e: + logger.warning(f"GetModelArtifact: {e}") + return func.HttpResponse( + f"Prediction version {version} not found for this model.", + status_code=404, + ) + blob_url = (source.url if kind == "gpkg" else source.attrs_url) or "" + if not blob_url: + logger.warning( + f"GetModelArtifact: model {model_id} version {version} " + f"has no {kind} artifact yet" + ) + return func.HttpResponse( + f"Artifact '{kind}' is not available for prediction " + f"version {version} yet; request preparation with " + "PutPreparePredictionTilesQueueMessage.", + status_code=404, + ) + elif version is not None: + logger.info( + f"GetModelArtifact: ignoring version={version} for " + f"version-independent kind '{kind}'" + ) + if not blob_url: return func.HttpResponse( "Artifact not available for this model.", status_code=404 @@ -1574,9 +1625,11 @@ async def GetModelArtifact(req: func.HttpRequest) -> func.HttpResponse: # interactive labeler's other artifacts are fetched by range and parsed # in-browser, so they must NOT be forced as downloads). if kind == "gpkg": - headers[ - "Content-Disposition" - ] = f'attachment; filename="building_predictions_{model_id}.gpkg"' + suffix = f"_v{version}" if version else "" + headers["Content-Disposition"] = ( + "attachment; " + f'filename="building_predictions_{model_id}{suffix}.gpkg"' + ) if result.etag: headers["ETag"] = ( result.etag if result.etag.startswith('"') else f'"{result.etag}"' @@ -2320,6 +2373,16 @@ async def GetVisualizerResults(req: func.HttpRequest) -> func.HttpResponse: Omit for the newest edit (falling back to the raw model output); pass ``0`` to force the raw model output. + ``predictionAttrsUrl`` points at the SELECTED version's sidecar + (``GetModelArtifact?...&kind=prediction_attrs&version=N``), so + switching versions only changes a URL. ``predictionVersionIsLatest`` + says whether that version is the newest saved one: version selection + moves the map only, while the Assessment/Validation reports always + read the newest version, and the UI has to be able to say when the + two diverge. A version whose sidecar has not been built yet reports + ``predictionsReadiness.attrsReady = false`` (reason ``preparing``) + rather than falling back to the raw model's classes. + The payload assembly lives in :func:`hastegeo.core.processors.visualizer.build_visualizer_results`. """ @@ -2404,7 +2467,13 @@ async def GetVisualizerResults(req: func.HttpRequest) -> func.HttpResponse: "Requested prediction version not found.", status_code=404 ) - predictions_info = PredictionInfo(version=source.version) + # The sidecar the map renders from belongs to the SELECTED + # version; the model-level one always describes the raw output. + predictions_info = PredictionInfo( + version=source.version, + attrs_url=source.attrs_url, + is_latest=source.is_latest, + ) if source.url: # Read the selected GeoPackage for its flavor: the embedding # producer's damage fraction is a degenerate 0/1 copy of @@ -2420,6 +2489,8 @@ async def GetVisualizerResults(req: func.HttpRequest) -> func.HttpResponse: ) predictions_info = PredictionInfo( version=source.version, + attrs_url=source.attrs_url, + is_latest=source.is_latest, flavor=predictions.flavor, supports_threshold=predictions.supports_threshold, building_count=len(predictions), @@ -3079,20 +3150,29 @@ async def PutPreparePredictionTilesQueueMessage( "projectId": "...", "imageLayerId": "...", "modelId": "5557", - "force": false + "force": false, + "backfillVersions": true } - Returns - ``{ modelId, queued, tilesReady, attrsReady, status, statusMessage }`` - — the state the editor polls ``GetPredictionEditSession`` for while - it waits. + Returns ``{ modelId, queued, tilesReady, attrsReady, versionsPending, + status, statusMessage }`` — the state the editor polls + ``GetPredictionEditSession`` for while it waits. + ``versionsPending`` counts the analyst-edited versions that still + have no attribute sidecar and will be rebuilt by the queued job. Building the layer's footprint PMTiles and the model's prediction attribute sidecar needs ``tippecanoe``, which ships only in the training image, so the work is always a queued job; this route only requests it. Nothing is enqueued when both artifacts already exist - (``queued: false``) unless ``force`` is set — used after predictions - are regenerated, which leaves stale artifacts behind. + and no version is missing its sidecar (``queued: false``) unless + ``force`` is set — used after predictions are regenerated, which + leaves stale artifacts behind. + + ``backfillVersions`` (default ``true``) additionally rebuilds the + sidecar of every saved version that lacks one. It is idempotent: the + version list is derived from the model document, so versions that + already have a sidecar are skipped. Set it to ``false`` to prepare + the model-level artifacts alone. """ logger.info( "PutPreparePredictionTilesQueueMessage HTTP trigger function " @@ -3145,6 +3225,7 @@ async def PutPreparePredictionTilesQueueMessage( model, ImageLayer(**image_layer_data), force=prep_request.force, + backfill_versions=prep_request.backfillVersions, ) except PredictionTilesUnavailableError as e: # Raw inputs missing: nothing to prepare from (yet). @@ -3207,20 +3288,26 @@ async def PutEditedPredictions(req: func.HttpRequest) -> func.HttpResponse: "overrides": [ {"id": 12, "class": "Damaged"}, ... ] } - Returns ``{ version, gpkgUrl, editedCount }``. + Returns + ``{ version, gpkgUrl, predictionAttrsUrl, editedCount, buildingCount }``. ``Model.gpkgUrl`` is never rewritten: it holds the RAW model output that this and every future edit derives from. Each save appends an ``EditedPredictionVersion`` to ``Model.editedPredictions`` instead. + + Every version is stored as a PAIR: the edited GeoPackage and its own + attribute sidecar, derived from that GeoPackage in one call path + (``prediction_edits.save_edited_version``). The map renders from the + sidecar, so a version stored without one would draw the raw model's + classes while claiming to show the edit. """ logger.info( "PutEditedPredictions HTTP trigger function processed a request." ) # Lazy import: prediction_edits pulls in fiona at module scope. from hastegeo.core.processors.prediction_edits import ( - apply_edits, next_version, - store_edited_version, + save_edited_version, ) try: @@ -3247,7 +3334,6 @@ async def PutEditedPredictions(req: func.HttpRequest) -> func.HttpResponse: src_path = None footprints_path = None - edited_path = None try: model_data = await asyncio.to_thread( MetadataProcessor( @@ -3282,22 +3368,25 @@ async def PutEditedPredictions(req: func.HttpRequest) -> func.HttpResponse: footprints_path = await download_blob_to_tempfile( footprints_url, suffix=".gpkg" ) - fd, edited_path = tempfile.mkstemp(suffix=".gpkg") - os.close(fd) overrides = { override.rowIndex: override.editedClass for override in edit_request.overrides } + version = next_version(model_data) try: - summary = await asyncio.to_thread( - apply_edits, + # Derives the edited GeoPackage AND its sidecar, and stores + # both, before this handler records the version. + saved = await asyncio.to_thread( + save_edited_version, + project_id, + model_id, + version, src_path, - edited_path, + footprints_path, threshold=edit_request.threshold, unknown_threshold=edit_request.unknownThreshold, overrides=overrides, - footprints_path=footprints_path, ) except ValueError as e: # The prediction → footprint join is positional, so a row @@ -3309,23 +3398,15 @@ async def PutEditedPredictions(req: func.HttpRequest) -> func.HttpResponse: status_code=422, ) - version = next_version(model_data) - edited_gpkg_url = await asyncio.to_thread( - store_edited_version, - project_id, - model_id, - version, - edited_path, - ) - entry = EditedPredictionVersion( - version=version, - gpkgUrl=edited_gpkg_url, + version=saved.version, + gpkgUrl=saved.gpkg_url, + predictionAttrsUrl=saved.attrs_url, createdAt=MetadataUtils.get_timestamp(), createdBy=created_by, threshold=edit_request.threshold, unknownThreshold=edit_request.unknownThreshold, - editedCount=summary.overrides_applied, + editedCount=saved.summary.overrides_applied, sourceGpkgUrl=source_gpkg_url, ) # Append only. model_data["gpkgUrl"] is deliberately untouched: @@ -3343,13 +3424,7 @@ async def PutEditedPredictions(req: func.HttpRequest) -> func.HttpResponse: ) return func.HttpResponse( - json.dumps( - { - "version": version, - "gpkgUrl": edited_gpkg_url, - "editedCount": summary.overrides_applied, - } - ), + json.dumps(saved.to_dict()), status_code=200, mimetype="application/json", ) @@ -3375,7 +3450,7 @@ async def PutEditedPredictions(req: func.HttpRequest) -> func.HttpResponse: "Error saving edited predictions.", status_code=500 ) finally: - for path in (src_path, footprints_path, edited_path): + for path in (src_path, footprints_path): if path and os.path.exists(path): try: os.unlink(path) diff --git a/api/hastefuncqueues/function_app.py b/api/hastefuncqueues/function_app.py index ef765162..19c5d439 100644 --- a/api/hastefuncqueues/function_app.py +++ b/api/hastefuncqueues/function_app.py @@ -31,6 +31,7 @@ from hastegeo.core.processors.prediction_tiles import ( PredictionTilesPostprocessor, needs_preparation, + versions_needing_attrs, ) from hastegeo.core.processors.publishing import PublishingProcessor from hastegeo.core.processors.stats import StatsPostProcessor @@ -723,6 +724,7 @@ async def _prepare_model_prediction_tiles( image_layer_id: Optional[str], model_id: str, force: bool, + backfill_versions: bool = True, ) -> None: """Build a model's attribute sidecar (+ the layer's tiles if absent). @@ -730,6 +732,11 @@ async def _prepare_model_prediction_tiles( poll -> finalize) for one model. On completion the model gets its attribute-sidecar URL and the image layer gets the shared footprint PMTiles URL, so both documents are persisted. + + With ``backfill_versions`` the run also rebuilds the sidecar of every + saved edited version that has none, and records each URL on its + ``Model.editedPredictions`` entry. Versions that already have one are + skipped, so this is safe to repeat. """ model_data = None try: @@ -775,7 +782,18 @@ async def _prepare_model_prediction_tiles( needs_pmtiles, needs_attrs = needs_preparation( model_data, image_layer ) - if not force and not needs_pmtiles and not needs_attrs: + # A saved version with no sidecar cannot be rendered, so it + # is outstanding work even when the model's own artifacts + # are already there. + pending_versions = ( + versions_needing_attrs(model_data) if backfill_versions else [] + ) + if ( + not force + and not needs_pmtiles + and not needs_attrs + and not pending_versions + ): logger.info( f"Prediction tiles for model {model_id} are already " "available; nothing to do." @@ -792,7 +810,9 @@ async def _prepare_model_prediction_tiles( return model_data.predictionTilesStatus = statuses.PENDING.value - processor = PredictionTilesPostprocessor(model_data, image_layer) + processor = PredictionTilesPostprocessor( + model_data, image_layer, backfill_versions=backfill_versions + ) output = await asyncio.to_thread(processor.process) await asyncio.to_thread( @@ -872,14 +892,16 @@ async def GetPreparePredictionTilesQueueMessage( Message schema (identifiers only):: {"projectId", "imageLayerId", "modelId", "sourceGpkgUrl", - "sourceFootprintsUrl", "force"} + "sourceFootprintsUrl", "force", "backfillVersions"} An empty/absent ``modelId`` selects **layer-only** preparation: build the image layer's shared footprint PMTiles and nothing else. Imagery preprocessing queues that as soon as a layer's footprints are cached so the editor never has to wait for tiling. With a ``modelId`` the message is **model-scoped**: build the model's attribute sidecar, - plus the layer's tiles when they are still missing. + plus the layer's tiles when they are still missing, and (unless + ``backfillVersions`` is false) the sidecar of every saved edited + version that has none. The authoritative job state is read from metadata, so a fresh request and the postprocessor's own poll messages take the same @@ -898,6 +920,7 @@ async def GetPreparePredictionTilesQueueMessage( model_id = payload.get("modelId") image_layer_id = payload.get("imageLayerId") force = bool(payload.get("force", False)) + backfill_versions = bool(payload.get("backfillVersions", True)) if not project_id or not (model_id or image_layer_id): raise ValueError( "Queue message requires projectId plus modelId or " @@ -906,7 +929,11 @@ async def GetPreparePredictionTilesQueueMessage( if model_id: await _prepare_model_prediction_tiles( - project_id, image_layer_id, model_id, force + project_id, + image_layer_id, + model_id, + force, + backfill_versions=backfill_versions, ) else: await _prepare_layer_footprint_tiles( diff --git a/docs/api/hastefuncapi.md b/docs/api/hastefuncapi.md index 8ac2756c..001fb783 100644 --- a/docs/api/hastefuncapi.md +++ b/docs/api/hastefuncapi.md @@ -54,7 +54,7 @@ All functions are defined in `function_app.py` as a single Azure Functions app. | DELETE | `DeleteModel` | Delete a model. Requires `projectId` and `modelId`. | | GET | `GetVisualizerResults` | Results-viewer data for one model: imagery tile URLs, the vector prediction artifacts, readiness, and (inference models only) the raster prediction layers. Requires `projectId`, `imageLayerId`, and `modelId`. See [below](#get-getvisualizerresults). | | PUT | `PutArtifactsZipQueueMessage` | Queue a job to zip model artifacts for download. | -| GET | `GetModelArtifact` | Stream a model artifact (or an image layer's footprint tiles) through the function app instead of a direct blob SAS URL, honoring HTTP `Range`. See [Model artifacts](#model-artifacts). | +| GET | `GetModelArtifact` | Stream a model artifact (or an image layer's footprint tiles) through the function app instead of a direct blob SAS URL, honoring HTTP `Range`. Accepts an optional `version` for the per-version prediction artifacts. See [Model artifacts](#model-artifacts). | #### `predictionsReady` on model payloads @@ -102,12 +102,13 @@ See [Prediction Editing](#prediction-editing). "predictionsLayer": { "url": "https://titiler…&colormap=…", "bounds": [ … ], … }, "footprintTilesUrl": "GetModelArtifact?projectId=…&modelId=5557&kind=footprint_pmtiles&imageLayerId=…", - "predictionAttrsUrl": "GetModelArtifact?projectId=…&modelId=5557&kind=prediction_attrs", + "predictionAttrsUrl": "GetModelArtifact?projectId=…&modelId=5557&kind=prediction_attrs&version=2", "flavor": "inference", "supportsThreshold": true, "buildingCount": 125430, - "predictionVersion": null, - "predictionVersions": [ { "version": 1, "gpkgUrl": "…", "createdAt": "…", "createdBy": "…", "threshold": 0.5, "unknownThreshold": 0.0, "editedCount": 53, "sourceGpkgUrl": "…" } ], + "predictionVersion": 2, + "predictionVersionIsLatest": true, + "predictionVersions": [ { "version": 1, "gpkgUrl": "…", "predictionAttrsUrl": "…", "createdAt": "…", "createdBy": "…", "threshold": 0.5, "unknownThreshold": 0.0, "editedCount": 53, "sourceGpkgUrl": "…" } ], "predictionsReady": true, "predictionsReadiness": { "ready": true, "reason": "ready", "detail": "", @@ -146,6 +147,24 @@ See [Prediction Editing](#prediction-editing). - **`predictionVersion`** is the edited version that was read (`null` = raw model output); **`predictionVersions`** is `Model.editedPredictions`, newest first, so the viewer can offer a version switch without a second round trip. + Each entry carries its own `predictionAttrsUrl` (the blob URL of that + version's sidecar; `null` while it is still being built). +- **`predictionAttrsUrl` is pinned to the selected version.** It carries + `&version=N` for an edited version and no `version` for the raw output, so + switching versions in the viewer is the same renderer pointed at a different + URL. A version whose sidecar has not been built yet reports + `predictionsReadiness.attrsReady: false` with `reason: "preparing"` and a + `null` `predictionAttrsUrl` — the raw model's sidecar is never substituted, + because it describes the model's classes and not the analyst's. Request the + backfill with + [`PutPreparePredictionTilesQueueMessage`](#put-putpreparepredictiontilesqueuemessage). +- **`predictionVersionIsLatest`** (boolean, default `true`) says whether the + selected version is the newest saved state of the model's predictions. It is + `false` for a pinned older version, and for `version=0` when edits exist. + Version selection moves the **map** only: the Assessment and Validation + reports always read the newest version, so the UI uses this flag to tell the + analyst when the two diverge instead of recomputing it from + `predictionVersions`. - **`predictionsReady`** here is stricter than the model-payload flag above: it additionally requires both browser artifacts to exist, because that is what the viewer actually needs to draw. `predictionsReadiness.reason` is one of @@ -226,7 +245,7 @@ fetch `footprint_pmtiles` and `prediction_attrs` through `GetModelArtifact` → | Method | Route | Description | |--------|-------|-------------| | GET | `GetPredictionEditSession` | Everything the editor needs to open a session: prediction flavor, threshold support, building count, artifact readiness, preparation status, and existing versions. | -| PUT | `PutPreparePredictionTilesQueueMessage` | Queue the job that builds the layer's footprint PMTiles and the model's prediction attribute sidecar. | +| PUT | `PutPreparePredictionTilesQueueMessage` | Queue the job that builds the layer's footprint PMTiles and the model's prediction attribute sidecar, and backfills the sidecar of any saved version that lacks one. | | PUT | `PutEditedPredictions` | Apply thresholds plus analyst overrides to the raw predictions and store the result as the next numbered version. | | GET | `GetEditedPredictionVersions` | List a model's saved edited-prediction versions, newest first. | @@ -251,6 +270,7 @@ fetch `footprint_pmtiles` and `prediction_attrs` through `GetModelArtifact` → { "version": 1, "gpkgUrl": "https://.../edited_predictions_5557_v1.gpkg", + "predictionAttrsUrl": "https://.../prediction_attrs_5557_v1.json", "createdAt": "2026-08-21T05:10:48.123456+00:00", "createdBy": "analyst@example.com", "threshold": 0.5, @@ -282,7 +302,12 @@ fetch `footprint_pmtiles` and `prediction_attrs` through `GetModelArtifact` → requested. It lets the UI tell "still building" from "failed" instead of polling forever; `predictionTilesStatusMessage` carries the job's appended progress/failure lines (empty string when there are none). -- `versions` is `Model.editedPredictions` sorted by version descending. +- `versions` is `Model.editedPredictions` sorted by version descending. Each + entry carries the version's own `gpkgUrl` **and** `predictionAttrsUrl` (the + attribute sidecar the map renders that version from). `predictionAttrsUrl` is + `null` for a version saved before per-version sidecars existed — request the + backfill with + [`PutPreparePredictionTilesQueueMessage`](#put-putpreparepredictiontilesqueuemessage). | Code | Condition | |------|-----------| @@ -316,13 +341,21 @@ tiles built here on demand instead. "projectId": "string — required, GUID", "imageLayerId": "string — required, GUID", "modelId": "string — required", - "force": false + "force": false, + "backfillVersions": true } ``` - `force` (default `false`) rebuilds both artifacts even when they already exist, and re-queues even when a job is already in flight. Use it after predictions are regenerated, which leaves stale artifacts behind. +- `backfillVersions` (default `true`) additionally rebuilds the attribute + sidecar of every entry in `Model.editedPredictions` that has a `gpkgUrl` but + no `predictionAttrsUrl` — versions saved before per-version sidecars existed, + or whose sidecar write failed. It is **idempotent**: the version list is + derived from the model document at submit time, so versions that already have + a sidecar are skipped and re-running changes nothing. Set it to `false` to + prepare the model-level artifacts alone. **Response (200):** @@ -332,16 +365,23 @@ tiles built here on demand instead. "queued": true, "tilesReady": false, "attrsReady": false, + "versionsPending": 2, "status": "Queued", "statusMessage": "\n2026-08-21T05:10:48.123456+00:00: Queued for prediction tile preparation" } ``` - `queued` says whether a message was actually put on the queue. It is `false` - — with `status: "Processed"` — when both artifacts already exist, so opening - the editor on a prepared model costs no Batch task. It is also `false` when a - job for this model is already `Queued`/`InProgress`, so a double-click or a - retry cannot submit a duplicate job. + — with `status: "Processed"` — when both artifacts already exist **and** no + saved version is missing its sidecar, so opening the editor on a prepared + model costs no Batch task. It is also `false` when a job for this model is + already `Queued`/`InProgress`, so a double-click or a retry cannot submit a + duplicate job. +- `versionsPending` counts the saved versions whose sidecar the queued job will + build (`0` when everything is already backfilled, or when + `backfillVersions: false`). Each URL lands on its version entry when the job + finishes; a version that could not be rebuilt keeps a `null` + `predictionAttrsUrl` and is retried by the next request. - `tilesReady` / `attrsReady` describe the state **at request time**; they flip to `true` once the queued job finishes. Poll [`GetPredictionEditSession`](#get-getpredictioneditsession) (or this route) @@ -352,7 +392,7 @@ tiles built here on demand instead. | Code | Condition | |------|-----------| -| 400 | Invalid JSON, non-GUID `projectId`/`imageLayerId`, non-numeric `modelId`, or non-boolean `force` | +| 400 | Invalid JSON, non-GUID `projectId`/`imageLayerId`, non-numeric `modelId`, or non-boolean `force`/`backfillVersions` | | 404 | Model or image layer not found; model has no raw prediction GeoPackage (`gpkgUrl`); layer has no cached building footprints — in either case there is nothing to tile | | 500 | Metadata or queue failure | @@ -391,7 +431,9 @@ leaves the model document unchanged. { "version": 2, "gpkgUrl": "https://.../edited_predictions_5557_v2.gpkg", - "editedCount": 53 + "predictionAttrsUrl": "https://.../prediction_attrs_5557_v2.json", + "editedCount": 53, + "buildingCount": 125430 } ``` @@ -399,8 +441,18 @@ The stored GeoPackage keeps every source column and row **in source order** (the downstream join is positional) and adds `edited_class`, `edit_threshold`, and `overture_id`; `damaged` is rewritten to agree with the final class. The new `EditedPredictionVersion` is appended to `Model.editedPredictions` with -`createdAt` (UTC ISO-8601) and `createdBy` (from the Static Web Apps client -principal when present). +`createdAt` (UTC ISO-8601), `createdBy` (from the Static Web Apps client +principal when present) and `predictionAttrsUrl`. + +Every save stores a **pair**: the edited GeoPackage and that version's own +attribute sidecar, derived from the very same file in one call path +(`hastegeo.core.processors.prediction_edits.save_edited_version`). The map +renders from the sidecar, so a version stored without one would draw the raw +model's classes while claiming to show the edit. `predictionAttrsUrl` is the +blob URL of that sidecar; fetch it through +[`GetModelArtifact`](#get-getmodelartifact) with +`kind=prediction_attrs&version=N`. `buildingCount` is the number of rows the +sidecar (and the GeoPackage) describes. | Code | Condition | |------|-----------| @@ -431,17 +483,36 @@ function app does the blob I/O server-side. `Range` is honored (`206` partial content, `416` when unsatisfiable) so `pmtiles.js` can do partial reads. **Query params:** `projectId` (GUID), `modelId`, `kind`, plus optional -`imageLayerId` (GUID) for layer-scoped kinds. +`imageLayerId` (GUID) for layer-scoped kinds and optional `version` (integer) +for the per-version kinds. | `kind` | Source field | Content type | Notes | |--------|--------------|--------------|-------| | `pmtiles` | `Model.pmtilesUrl` | `application/octet-stream` | Interactive labeler footprint tiles. | | `sidecar` | `Model.featuresSidecarUrl` | `application/octet-stream` | Per-building embedding vectors. | | `geojson` | `Model.embeddingsGeoJSONUrl` | `application/geo+json` | | -| `gpkg` | `Model.gpkgUrl` | `application/geopackage+sqlite3` | Served as a download attachment. | -| `prediction_attrs` | `Model.predictionAttrsUrl` | `application/json` | Columnar prediction attribute sidecar for the prediction editor. | +| `gpkg` | `Model.gpkgUrl` | `application/geopackage+sqlite3` | Served as a download attachment. Accepts `version`. | +| `prediction_attrs` | `Model.predictionAttrsUrl` | `application/json` | Columnar prediction attribute sidecar for the prediction editor and results viewer. Accepts `version`. | | `footprint_pmtiles` | `ImageLayer.footprintPmtilesUrl` | `application/vnd.pmtiles` | Geometry-only footprint tiles, shared by every model on that layer. Pass `imageLayerId` to select the layer explicitly; it defaults to the model's own image layer. | +**`version` (optional, `prediction_attrs` and `gpkg` only)** picks an +analyst-edited revision out of `Model.editedPredictions`: + +| `version` | Artifact served | +|-----------|-----------------| +| omitted | the model-level artifact — i.e. the **raw** model output (unchanged historic behaviour) | +| `0` | the raw model output, explicitly, even when edits exist | +| `N` | that version's own artifact (`EditedPredictionVersion.gpkgUrl` / `.predictionAttrsUrl`) | + +An unknown `N` is a **404** naming the version; a version whose sidecar has not +been built yet is also a 404, telling the caller to request preparation with +[`PutPreparePredictionTilesQueueMessage`](#put-putpreparepredictiontilesqueuemessage). +A non-numeric or negative value is a **400**. The parameter is ignored (and +logged) for the version-independent kinds, so a client may pass the viewer's +current version to every artifact call. `Range`, the content types and the +`gpkg` download attachment are unchanged; a versioned `gpkg` download is named +`building_predictions__v.gpkg`. + The sidecar payload is columnar and index-aligned with the prediction GeoPackage rows: @@ -450,10 +521,24 @@ GeoPackage rows: "damage": [0.0, 0.42, 0.8], "unknown": [0.0, 0.2, 0.0], "damaged": [0, 1, 1] } ``` +A **version's** sidecar has the same shape plus a `classes` array holding the +analyst's final class per row: + +```json +{ "n": 3, "ids": [0, 1, 2], "overtureIds": [ … ], + "damage": [0.0, 0.42, 0.8], "unknown": [0.0, 0.2, 0.0], "damaged": [0, 0, 1], + "classes": ["NotDamaged", "Unknown", "Damaged"] } +``` + +`damage` / `unknown` stay the model's raw fractions (the edit changes the class, +not the model's confidence), so a building the analyst forced to `Unknown` is +only visible in `classes`. `damaged` already agrees with the edit, so a client +that ignores `classes` still renders damaged-vs-not correctly. + | Code | Condition | |------|-----------| -| 400 | Missing/malformed `projectId`, `modelId`, `kind`, or an `imageLayerId` that is neither supplied nor resolvable from the model | -| 404 | Model or image layer not found, or the artifact is not available yet | +| 400 | Missing/malformed `projectId`, `modelId`, `kind`, `version`, or an `imageLayerId` that is neither supplied nor resolvable from the model | +| 404 | Model or image layer not found; the artifact is not available yet; or the requested `version` does not exist (or has no such artifact yet) | | 416 | Requested range starts past the end of the artifact | | 502 | Blob read failure | @@ -495,6 +580,14 @@ returns **400**. There is deliberately no mutable "active version" pointer on the model — newest-wins plus an explicit override is the design in [ADR-0005](../../spec/architecture/decisions/0005-versioned-derived-prediction-artifacts.md). +[`GetModelArtifact`](#get-getmodelartifact) resolves `version` through the same +seam for `kind=prediction_attrs` and `kind=gpkg`, with one deliberate +difference: an **omitted** `version` there keeps serving the model-level +artifact (the raw output) rather than the newest edit, because that route is +also how pre-existing clients fetch the raw artifacts. Pass the version +explicitly — `GetVisualizerResults` already returns a version-pinned +`predictionAttrsUrl`. + Note that an edited GeoPackage overrides `damaged` (and adds `edited_class`, `edit_threshold`, `overture_id`) but preserves the producer's original `damage_pct_0m`. `GetValidationReport` reads `damaged`, so analyst corrections diff --git a/hastelib/src/hastegeo/core/config.py b/hastelib/src/hastegeo/core/config.py index 8890d6ff..f9b545e8 100644 --- a/hastelib/src/hastegeo/core/config.py +++ b/hastelib/src/hastegeo/core/config.py @@ -168,7 +168,16 @@ class ArtifactTypes(Enum): EDITED_PREDICTIONS_GPKG = Template( "edited_predictions_${modelId}_v${version}" ) + # Sidecar of the RAW model output (Model.gpkgUrl). PREDICTION_ATTRS = Template("prediction_attrs_${modelId}") + # Sidecar of ONE saved edited version (Model.editedPredictions[]). + # Every version gets its own so that rendering a version is the same + # code path as rendering the raw output with a different URL; a + # version whose GeoPackage exists without a matching sidecar would + # silently draw the raw classes. + PREDICTION_ATTRS_VERSION = Template( + "prediction_attrs_${modelId}_v${version}" + ) LAYER_FOOTPRINT_PMTILES = Template("footprints_${imageLayerId}") diff --git a/hastelib/src/hastegeo/core/models/predictions.py b/hastelib/src/hastegeo/core/models/predictions.py index f498af60..6a3a0197 100644 --- a/hastelib/src/hastegeo/core/models/predictions.py +++ b/hastelib/src/hastegeo/core/models/predictions.py @@ -103,9 +103,17 @@ class PreparePredictionTilesRequest(BaseModel): attribute sidecar of ``modelId`` to be built. ``force`` rebuilds them even when both already exist — used after predictions are regenerated, which leaves stale artifacts behind. + + ``backfillVersions`` (default ``True``) additionally rebuilds the + sidecar of every saved edited version that has none, which is how a + version saved before per-version sidecars existed becomes + renderable. It skips versions that already have one, so requesting it + repeatedly is harmless; pass ``False`` to prepare only the model's + own artifacts. """ projectId: str = Field(pattern=GUID_PATTERN) imageLayerId: str = Field(pattern=GUID_PATTERN) modelId: str = Field(pattern=SHORT_INT_ID_PATTERN) force: bool = Field(default=False) + backfillVersions: bool = Field(default=True) diff --git a/hastelib/src/hastegeo/core/models/projects.py b/hastelib/src/hastegeo/core/models/projects.py index 1c9423d7..edfcb9ae 100644 --- a/hastelib/src/hastegeo/core/models/projects.py +++ b/hastelib/src/hastegeo/core/models/projects.py @@ -355,6 +355,14 @@ class EditedPredictionVersion(BaseModel): Args: version: Monotonically increasing revision number, starting at 1 gpkgUrl: URL to the edited GeoPackage for this version + predictionAttrsUrl: URL to this version's columnar attribute + sidecar (``ArtifactTypes.PREDICTION_ATTRS_VERSION``). The map + renders from the sidecar, not the GeoPackage, so a version + without one cannot be drawn — it is written in the same call + path as the GeoPackage + (``prediction_edits.save_edited_version``) and backfilled by + the prediction-tiles job for versions saved before per-version + sidecars existed. createdAt: ISO formatted timestamp when the version was saved createdBy: Identifier of the user who saved the version threshold: Damage fraction (0.0-1.0) above which a building was @@ -380,6 +388,7 @@ class EditedPredictionVersion(BaseModel): version: int gpkgUrl: str createdAt: str + predictionAttrsUrl: Optional[str] = None createdBy: Optional[str] = None threshold: Optional[float] = None unknownThreshold: Optional[float] = None diff --git a/hastelib/src/hastegeo/core/models/visualizer.py b/hastelib/src/hastegeo/core/models/visualizer.py index 0d9e01d4..420299b0 100644 --- a/hastelib/src/hastegeo/core/models/visualizer.py +++ b/hastelib/src/hastegeo/core/models/visualizer.py @@ -63,6 +63,10 @@ class Visualizer(BaseModel): # stream through the function app (auth + Range + managed identity), # and the UI turns them into absolute URLs with its own buildUrl(). footprintTilesUrl: Optional[str] = Field(default=None) + # Carries "&version=N" when an edited version is selected, so the + # viewer renders that version's own classes instead of the raw + # model's. None while the selected version's sidecar is still being + # built (predictionsReadiness.attrsReady is then False). predictionAttrsUrl: Optional[str] = Field(default=None) # Prediction flavor, from hastegeo.core.utils.predictions. The # embedding producer's damage fraction is a degenerate 0/1 copy of @@ -73,6 +77,12 @@ class Visualizer(BaseModel): # Which prediction GeoPackage was read: an analyst-edited version # number, or None for the raw model output. predictionVersion: Optional[int] = Field(default=None) + # Whether that version is the newest saved state of the model's + # predictions. Version selection changes the MAP only — the + # Assessment/Validation reports always read the newest version — so + # the UI needs to be told when the two diverge rather than having to + # recompute it from predictionVersions. + predictionVersionIsLatest: bool = Field(default=True) # Model.editedPredictions, newest version first, so the viewer can # offer a version switch without a second round trip. predictionVersions: List[Dict[str, Any]] = Field(default_factory=list) diff --git a/hastelib/src/hastegeo/core/processors/prediction_edits.py b/hastelib/src/hastegeo/core/processors/prediction_edits.py index 9596d806..a6f2c74c 100644 --- a/hastelib/src/hastegeo/core/processors/prediction_edits.py +++ b/hastelib/src/hastegeo/core/processors/prediction_edits.py @@ -16,11 +16,19 @@ appended. **Row order is preserved exactly**, because the prediction → footprint join downstream is positional (see ``hastegeo.core.utils.assessment.build_assessment_inputs_from_gpkgs``). + +Every saved version also gets its OWN attribute sidecar, derived from +the edited GeoPackage in the same call path +(:func:`save_edited_version`). The map renders from the sidecar, not the +GeoPackage, so a version stored without one would silently draw the raw +model's classes; deriving both together is what makes that impossible. """ from __future__ import annotations import os +import shutil +import tempfile from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union @@ -32,6 +40,10 @@ from ..utils.assessment import DAMAGED, NOT_DAMAGED, UNKNOWN from ..utils.gdal_security import harden_gdal from ..utils.logs import Logger +from ..utils.prediction_attrs import ( + version_attrs_artifact_name, + write_edited_prediction_attrs, +) from ..utils.predictions import DAMAGED_FIELD, PredictionRow, read_predictions if TYPE_CHECKING: @@ -420,3 +432,177 @@ def store_edited_version( artifact_name, ) return url + + +def store_version_attrs( + project_id: str, + model_id: str, + version: int, + local_attrs_path: str, + *, + processor: Optional["ArtifactProcessor"] = None, + config: Optional[Config] = None, +) -> str: + """Store one version's attribute sidecar and return its URL. + + Mirrors :func:`store_edited_version` for the JSON payload the map + actually renders from + (``ArtifactTypes.PREDICTION_ATTRS_VERSION``). + + Args: + project_id: Storage partition key. + model_id: Model whose predictions were edited. + version: Version number the sidecar describes. + local_attrs_path: Path to the JSON written by + ``hastegeo.core.utils.prediction_attrs``. + processor: Optional pre-built ``ArtifactProcessor``. + config: Optional ``Config`` used when building the processor. + + Returns: + The download URL of the stored artifact. + + Raises: + FileNotFoundError: if ``local_attrs_path`` does not exist. + """ + if not os.path.exists(local_attrs_path): + raise FileNotFoundError( + f"Prediction attribute sidecar not found: {local_attrs_path}" + ) + + if processor is None: + from .artifacts import ArtifactProcessor as _ArtifactProcessor + + processor = _ArtifactProcessor(project_id, config=config) + + artifact_name = version_attrs_artifact_name(model_id, version) + processor.store_artifact( + artifact_name=artifact_name, src_path=local_attrs_path + ) + url = processor.get_download_url(identifier=artifact_name) + logger.info( + "Stored prediction attributes for v%s of model %s at %s", + version, + model_id, + artifact_name, + ) + return url + + +@dataclass +class SavedEditedVersion: + """Everything one save produced: the GeoPackage AND its sidecar. + + Attributes: + version: Version number that was saved. + gpkg_url: Blob URL of the edited GeoPackage. + attrs_url: Blob URL of that version's attribute sidecar. + summary: The :class:`EditSummary` from :func:`apply_edits`. + building_count: Rows in the sidecar (and in the GeoPackage). + """ + + version: int + gpkg_url: str + attrs_url: str + summary: EditSummary + building_count: int = 0 + + def to_dict(self) -> Dict[str, Any]: + """Render as the JSON body ``PutEditedPredictions`` returns.""" + return { + "version": self.version, + "gpkgUrl": self.gpkg_url, + "predictionAttrsUrl": self.attrs_url, + "editedCount": self.summary.overrides_applied, + "buildingCount": self.building_count, + } + + +def save_edited_version( + project_id: str, + model_id: str, + version: int, + src_gpkg: str, + footprints_path: str, + *, + threshold: float, + unknown_threshold: float = 0.0, + overrides: Dict[int, str], + processor: Optional["ArtifactProcessor"] = None, + config: Optional[Config] = None, +) -> SavedEditedVersion: + """Derive, store and describe one edited-prediction version. + + ONE call path produces both halves of a version: the GeoPackage + (:func:`apply_edits`) and the attribute sidecar built from that very + file. They are stored before the caller records the version, so a + version entry can never reference a GeoPackage whose sidecar is + missing — which would leave the map drawing the raw model's classes + while claiming to show the edit. + + Args: + project_id: Storage partition key. + model_id: Model whose predictions were edited. + version: Version number from :func:`next_version`. + src_gpkg: Local path to the RAW prediction GeoPackage. Never + modified. + footprints_path: Local path to the image layer's footprints + GeoPackage; the positional join that resolves Overture ids. + threshold: Damage fraction in [0, 1]. + unknown_threshold: Unknown/cloud fraction in [0, 1]. + overrides: Analyst class overrides keyed by row index. + processor: Optional pre-built ``ArtifactProcessor``. + config: Optional ``Config`` used when building the processor. + + Returns: + A :class:`SavedEditedVersion`. + + Raises: + ValueError: on an invalid threshold or override, or when the + predictions and footprints do not line up row for row. + """ + work_dir = tempfile.mkdtemp(prefix="haste-edited-version-") + try: + edited_path = os.path.join( + work_dir, edited_version_artifact_name(model_id, version) + ) + attrs_path = os.path.join( + work_dir, version_attrs_artifact_name(model_id, version) + ) + summary = apply_edits( + src_gpkg, + edited_path, + threshold=threshold, + unknown_threshold=unknown_threshold, + overrides=overrides, + footprints_path=footprints_path, + ) + # Derived from the EDITED file, so its classes are the analyst's + # and not the model's. + payload = write_edited_prediction_attrs( + edited_path, footprints_path, attrs_path + ) + gpkg_url = store_edited_version( + project_id, + model_id, + version, + edited_path, + processor=processor, + config=config, + ) + attrs_url = store_version_attrs( + project_id, + model_id, + version, + attrs_path, + processor=processor, + config=config, + ) + return SavedEditedVersion( + version=version, + gpkg_url=gpkg_url, + attrs_url=attrs_url, + summary=summary, + building_count=int(payload["n"]), + ) + finally: + shutil.rmtree(work_dir, ignore_errors=True) diff --git a/hastelib/src/hastegeo/core/processors/prediction_tiles.py b/hastelib/src/hastegeo/core/processors/prediction_tiles.py index 62f56514..d1022a5c 100644 --- a/hastelib/src/hastegeo/core/processors/prediction_tiles.py +++ b/hastelib/src/hastegeo/core/processors/prediction_tiles.py @@ -57,9 +57,24 @@ "attrs": "prediction_attrs_.json" }, "tiles": {"build_pmtiles": true}, + "versions": [ + { + "version": 1, + "predictions": "inputs/.gpkg", + "attrs": "prediction_attrs__v1.json" + } + ], "store_artifacts": true } +``versions`` is the **backfill** list: one entry per saved edited +version (``Model.editedPredictions``) that has no sidecar yet. Each gets +its own ``ArtifactTypes.PREDICTION_ATTRS_VERSION`` payload built from +that version's own GeoPackage, which is what lets the viewer switch +versions by swapping a URL. The list is derived from the model document +at submit time, so versions that already have a sidecar are skipped and +re-running the job is a no-op for them. + Queue message (``prediction-edit-prep-queue``):: { @@ -68,11 +83,13 @@ "modelId": "...", "sourceGpkgUrl": "...", "sourceFootprintsUrl": "...", - "force": false + "force": false, + "backfillVersions": true } An empty ``modelId`` (and, with it, an empty ``sourceGpkgUrl``) selects -the layer-only mode. +the layer-only mode, in which ``backfillVersions`` is meaningless (there +is no model to read versions from). The trigger treats the message as a *request* and reads the authoritative state from metadata, so the postprocessor's own poll re-queues (which @@ -128,6 +145,47 @@ def attrs_artifact_name(model_id: str) -> str: ) +def version_attrs_artifact_name(model_id: str, version: int) -> str: + """Artifact name for ONE edited version's attribute sidecar. + + Same template as + ``hastegeo.core.utils.prediction_attrs.version_attrs_artifact_name`` + — which the Functions app uses at save time — but derived here from + ``ArtifactTypes`` directly, because this module is imported by the + queue app, which must not pull in fiona. + """ + return ( + ArtifactTypes.PREDICTION_ATTRS_VERSION.value.substitute( + modelId=model_id, version=int(version) + ) + + ".json" + ) + + +def versions_needing_attrs(model: Model) -> List[Dict[str, Any]]: + """Return this model's saved versions that still have no sidecar. + + Oldest first. A version whose GeoPackage exists without a matching + sidecar cannot be drawn by the map at all, so these are exactly the + revisions a backfill run has to rebuild. Versions that already have + one never appear here, which is what makes the backfill idempotent. + """ + entries: List[Dict[str, Any]] = [] + for entry in model.editedPredictions or []: + gpkg_url = getattr(entry, "gpkgUrl", None) + attrs_url = getattr(entry, "predictionAttrsUrl", None) + version = getattr(entry, "version", None) + if version is None or not gpkg_url or attrs_url: + continue + entries.append( + { + "version": int(version), + "gpkgUrl": str(gpkg_url), + } + ) + return sorted(entries, key=lambda entry: entry["version"]) + + def build_prep_message( project_id: str, image_layer_id: str, @@ -135,6 +193,7 @@ def build_prep_message( source_gpkg_url: Optional[str] = None, source_footprints_url: Optional[str] = None, force: bool = False, + backfill_versions: bool = True, ) -> Dict[str, Any]: """Build a ``prediction-edit-prep-queue`` message payload. @@ -153,6 +212,9 @@ def build_prep_message( (and empty) in layer-only mode. source_footprints_url: The layer's cached footprints GeoPackage. force: Rebuild even when the artifacts already exist. + backfill_versions: Also (re)build the attribute sidecar of every + saved edited version that has none. Ignored in layer-only + mode, where there is no model to read versions from. """ return { "projectId": project_id, @@ -161,6 +223,7 @@ def build_prep_message( "sourceGpkgUrl": source_gpkg_url or "", "sourceFootprintsUrl": source_footprints_url or "", "force": bool(force), + "backfillVersions": bool(backfill_versions), } @@ -172,6 +235,7 @@ def enqueue_prediction_tiles( source_footprints_url: Optional[str] = None, force: bool = False, config: Optional[Config] = None, + backfill_versions: bool = True, ) -> Dict[str, Any]: """Put a preparation request on the prediction-edit prep queue. @@ -189,6 +253,7 @@ def enqueue_prediction_tiles( source_gpkg_url=source_gpkg_url, source_footprints_url=source_footprints_url, force=force, + backfill_versions=backfill_versions, ) queue_client = AzureQueueHandler( config.queue_config["queue_connection_string"], @@ -253,6 +318,7 @@ def request_preparation( image_layer: ImageLayer, force: bool = False, config: Optional[Config] = None, + backfill_versions: bool = True, ) -> Dict[str, Any]: """Decide whether to enqueue preparation, and do it if so. @@ -275,12 +341,19 @@ def request_preparation( job is already in flight. Used after predictions are regenerated, which leaves stale artifacts behind. config: Optional config override (tests inject a fake). + backfill_versions: Also rebuild the attribute sidecar of every + saved edited version that has none — a job is queued for + that alone when the model's own artifacts are already there. + This is how versions saved before per-version sidecars + existed become renderable. Returns: - ``{"modelId", "queued", "tilesReady", "attrsReady", "status", - "statusMessage"}``. ``tilesReady``/``attrsReady`` describe the - state *now*, so a caller that polls sees them flip to ``True`` - once the queued job finishes. + ``{"modelId", "queued", "tilesReady", "attrsReady", + "versionsPending", "status", "statusMessage"}``. + ``tilesReady``/``attrsReady`` describe the state *now*, so a + caller that polls sees them flip to ``True`` once the queued job + finishes; ``versionsPending`` counts the saved versions that + still have no sidecar and drops to 0 the same way. Raises: PredictionTilesUnavailableError: when the model has no raw @@ -305,6 +378,9 @@ def request_preparation( ) needs_pmtiles, needs_attrs = needs_preparation(model, image_layer) + pending_versions = ( + versions_needing_attrs(model) if backfill_versions else [] + ) in_flight = model.predictionTilesStatus in ( statuses.PENDING.value, statuses.IN_PROGRESS.value, @@ -316,12 +392,16 @@ def _state(queued: bool) -> Dict[str, Any]: "queued": queued, "tilesReady": not needs_pmtiles, "attrsReady": not needs_attrs, + "versionsPending": len(pending_versions), "status": model.predictionTilesStatus, "statusMessage": model.predictionTilesStatusMessage or "", } - if not force and not needs_pmtiles and not needs_attrs: - # Both artifacts exist: record that and skip the queue rather + nothing_outstanding = ( + not needs_pmtiles and not needs_attrs and not pending_versions + ) + if not force and nothing_outstanding: + # Every artifact exists: record that and skip the queue rather # than pay for a redundant Batch task. Only the transition is # recorded — an editor that re-opens a prepared model must not # grow the status message a line at a time. @@ -358,13 +438,15 @@ def _state(queued: bool) -> Dict[str, Any]: source_footprints_url=image_layer.buildingFootprintsUrl, force=force, config=config, + backfill_versions=backfill_versions, ) logger.info( "Queued prediction tiles for model %s (pmtiles=%s, attrs=%s, " - "force=%s)", + "versions=%s, force=%s)", model.modelId, needs_pmtiles or force, needs_attrs or force, + [entry["version"] for entry in pending_versions], force, ) return _state(True) @@ -428,7 +510,15 @@ def queue_for_processing(self, force: bool = False) -> Model: needs_pmtiles, needs_attrs = needs_preparation( self.model_data, self.image_layer ) - if not force and not needs_pmtiles and not needs_attrs: + # A saved version without a sidecar cannot be drawn, so it is + # outstanding work exactly like a missing model-level sidecar. + pending_versions = versions_needing_attrs(self.model_data) + if ( + not force + and not needs_pmtiles + and not needs_attrs + and not pending_versions + ): self.model_data.predictionTilesStatus = ( self.config.get_status_types().COMPLETED.value ) @@ -464,10 +554,12 @@ def queue_for_processing(self, force: bool = False) -> Model: visibility_timeout=0, ) self.logger.info( - "Queued prediction tiles for model %s (pmtiles=%s, attrs=%s)", + "Queued prediction tiles for model %s (pmtiles=%s, attrs=%s, " + "versions=%s)", self.model_data.modelId, needs_pmtiles or force, needs_attrs or force, + [entry["version"] for entry in pending_versions], ) return self.model_data @@ -484,6 +576,12 @@ class PredictionTilesPostprocessor: footprint PMTiles, which is what imagery prep asks for at layer-creation time. Job state lives on the ``ImageLayer`` and no model document is read or written. + + A model-scoped run also backfills the attribute sidecar of every + saved edited version that lacks one (``backfill_versions``). The + input list comes from the model document at submit time, so a + version that already has a sidecar is never rebuilt and re-running + the job is a no-op for it. """ def __init__( @@ -491,6 +589,7 @@ def __init__( model: Optional[Model], image_layer: ImageLayer, config: Optional[Config] = None, + backfill_versions: bool = True, ) -> None: if config is None: config = Config() @@ -504,6 +603,7 @@ def __init__( self.image_layer = image_layer # No model -> layer-only mode: footprint tiles alone, no sidecar. self.layer_only = model is None + self.backfill_versions = bool(backfill_versions) self.project_id = ( image_layer.projectId if self.layer_only else model.projectId ) @@ -594,6 +694,7 @@ def _poll_message(self) -> str: None if self.layer_only else self.model_data.gpkgUrl ), source_footprints_url=footprints_url, + backfill_versions=self.backfill_versions, ) ) @@ -733,6 +834,9 @@ def _create_job_config(self) -> Dict[str, Dict[str, str]]: In layer-only mode neither the prediction GeoPackage nor the sidecar is referenced: the task tiles the footprints and stops. + In model-scoped mode the config additionally lists every saved + edited version that still lacks a sidecar, so the task rebuilds + those from the versions' own GeoPackages. """ filename_pattern = ( rf"{MetadataUtils.hash_string(self.project_id)}/(.*)\?+" @@ -764,6 +868,7 @@ def _create_job_config(self) -> Dict[str, Dict[str, str]]: predictions_url = None predictions_fn = "" + version_inputs: List[Dict[str, Any]] = [] if self.layer_only: # Nothing to reuse and nothing to join: the whole point of # this job is to produce the layer's archive. @@ -785,6 +890,39 @@ def _create_job_config(self) -> Dict[str, Dict[str, str]]: workflow_config["tiles"] = {"build_pmtiles": needs_pmtiles} config_identifier = self.model_data.modelId + # Backfill inputs: one edited GeoPackage per version that + # has no sidecar yet. Reading the model document here (and + # not the queue message) is what keeps this idempotent. + pending = ( + versions_needing_attrs(self.model_data) + if self.backfill_versions + else [] + ) + for entry in pending: + version = int(entry["version"]) + version_url = entry["gpkgUrl"] + version_fn = ( + "inputs/" + f"{extract_from_url(version_url, filename_pattern)}" + ) + version_inputs.append( + { + "version": version, + "url": version_url, + "file_path": version_fn, + } + ) + workflow_config["versions"] = [ + { + "version": entry["version"], + "predictions": entry["file_path"], + "attrs": version_attrs_artifact_name( + self.model_data.modelId, entry["version"] + ), + } + for entry in version_inputs + ] + self.storage.save( identifier=config_identifier, data=workflow_config, @@ -823,6 +961,11 @@ def _create_job_config(self) -> Dict[str, Dict[str, str]]: ), "file_path": predictions_fn, } + for entry in version_inputs: + input_files[f"predictions_v{entry['version']}"] = { + "http_url": extract_from_url(entry["url"], plain_url_pattern), + "file_path": entry["file_path"], + } return input_files # ── finalization ────────────────────────────────────────────────── @@ -884,6 +1027,57 @@ def _update_results_from_job(self) -> None: "Prepared prediction attributes for " f"{self.model_data.predictedBuildingCount} buildings" ) + self._record_version_attrs(manifest) + + def _record_version_attrs(self, manifest: Dict[str, Any]) -> None: + """Attach each backfilled sidecar URL to its version entry. + + A version whose sidecar could not be built keeps an empty + ``predictionAttrsUrl``, so the next preparation request picks it + up again instead of the model silently claiming a renderable + version it does not have. + """ + records = manifest.get("version_attrs") or [] + if not records: + return + by_version = { + int(entry.version): entry + for entry in (self.model_data.editedPredictions or []) + if getattr(entry, "version", None) is not None + } + backfilled: List[int] = [] + for record in records: + try: + version = int(record.get("version")) + except (TypeError, ValueError): + continue + entry = by_version.get(version) + if entry is None: + self.logger.warning( + "Prediction tiles manifest reports a sidecar for " + "version %s, which model %s no longer has", + version, + self.model_data.modelId, + ) + continue + url = record.get("url") or self._artifact_url( + record.get("filename", "") + ) + if not url: + self.logger.warning( + "No sidecar URL for version %s of model %s: %s", + version, + self.model_data.modelId, + record.get("error") or "not built", + ) + continue + entry.predictionAttrsUrl = url + backfilled.append(version) + if backfilled: + self._update_progress( + "Prepared prediction attributes for edited version(s) " + + ", ".join(str(version) for version in sorted(backfilled)) + ) def _artifact_url(self, filename: str) -> str: """Resolve a task output filename to a downloadable URL.""" diff --git a/hastelib/src/hastegeo/core/processors/visualizer.py b/hastelib/src/hastegeo/core/processors/visualizer.py index 7d30eb5c..589152e3 100644 --- a/hastelib/src/hastegeo/core/processors/visualizer.py +++ b/hastelib/src/hastegeo/core/processors/visualizer.py @@ -90,12 +90,20 @@ class PredictionInfo: supports_threshold: Whether re-thresholding the damage fraction is meaningful for this flavor. building_count: Rows in the prediction file. + attrs_url: Blob URL of the attribute sidecar that describes the + selected version. Empty when that version has no sidecar + yet — the payload then reports "still preparing" rather than + pointing the map at the raw model's classes. + is_latest: Whether the selected version is the newest saved + state of the model's predictions. """ version: Optional[int] = None flavor: Optional[str] = None supports_threshold: Optional[bool] = None building_count: Optional[int] = None + attrs_url: str = "" + is_latest: bool = True def model_artifact_url( @@ -103,12 +111,17 @@ def model_artifact_url( model_id: str, kind: str, image_layer_id: Optional[str] = None, + version: Optional[int] = None, ) -> str: """Build the API-relative ``GetModelArtifact`` route for one artifact. Relative on purpose: the function app does not know the client's API base URL (or its APIM subscription key), and the UI already funnels every call through its own ``buildUrl()``. + + ``version`` pins an edited-prediction revision; it is only meaningful + for the per-version kinds (``prediction_attrs`` and ``gpkg``), so it + is omitted for everything else. """ params = [ ("projectId", project_id or ""), @@ -117,6 +130,8 @@ def model_artifact_url( ] if image_layer_id: params.append(("imageLayerId", image_layer_id)) + if version is not None: + params.append(("version", str(int(version)))) return f"{MODEL_ARTIFACT_ROUTE}?{urlencode(params)}" @@ -171,6 +186,7 @@ def visualizer_readiness( model: Model, image_layer: ImageLayer, config: Optional[Config] = None, + attrs_ready: Optional[bool] = None, ) -> PredictionsReadiness: """Report whether the viewer can draw this model's predictions. @@ -180,10 +196,21 @@ def visualizer_readiness( browser artifacts must have been built by the prediction-tiles job. The second is transient — the UI shows a "still preparing" state and polls — while the first usually is not. + + Args: + model: The model being viewed. + image_layer: Its image layer, which owns the footprint tiles. + config: Optional config override. + attrs_ready: Whether the sidecar for the *selected* prediction + version exists. ``None`` falls back to the model-level + sidecar, which describes the raw output. Passing the + version's own answer is what stops the viewer from drawing + raw classes while claiming to show an edit. """ base = prediction_readiness(model, config=config) tiles_ready = bool(resolve_tiles_url(model, image_layer)) - attrs_ready = bool(model.predictionAttrsUrl) + if attrs_ready is None: + attrs_ready = bool(model.predictionAttrsUrl) reason = base.reason detail = base.detail @@ -245,7 +272,16 @@ def build_visualizer_results( info = predictions or PredictionInfo() bounds = _study_area_bounds(study_area) rasters = raster_layer_urls(model) - readiness = visualizer_readiness(model, image_layer, config=config) + # An edited version renders from ITS OWN sidecar, so readiness has to + # be judged against that file and not the model-level one (which + # always describes the raw output). + selected_attrs_ready = bool(info.attrs_url) if info.version else None + readiness = visualizer_readiness( + model, + image_layer, + config=config, + attrs_ready=selected_attrs_ready, + ) pre_event_url = ( image_layer.preEventProcessedImageryUrl @@ -269,8 +305,15 @@ def build_visualizer_results( if readiness.tilesReady else None ) + # Pin the route to the selected version so switching versions is + # just a different URL for the same renderer. prediction_attrs_url = ( - model_artifact_url(project_id, model_id, PREDICTION_ATTRS_KIND) + model_artifact_url( + project_id, + model_id, + PREDICTION_ATTRS_KIND, + version=info.version if info.version else None, + ) if readiness.attrsReady else None ) @@ -326,6 +369,7 @@ def build_visualizer_results( supportsThreshold=info.supports_threshold, buildingCount=info.building_count, predictionVersion=info.version, + predictionVersionIsLatest=info.is_latest, predictionVersions=edited_prediction_versions(model), predictionsReady=readiness.ready, predictionsReadiness=readiness, diff --git a/hastelib/src/hastegeo/core/utils/prediction_attrs.py b/hastelib/src/hastegeo/core/utils/prediction_attrs.py new file mode 100644 index 00000000..2bf243b9 --- /dev/null +++ b/hastelib/src/hastegeo/core/utils/prediction_attrs.py @@ -0,0 +1,314 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Builder for the columnar prediction attribute sidecar. + +The map clients (prediction editor and results viewer) render EVERY +predicted building of an image layer from two artifacts: geometry-only +footprint PMTiles, shared by every model on the layer, and this +*sidecar* — a compact columnar JSON payload of the per-building damage +values, indexed by the same integer row id that is baked into the tiles. + +The builder used to live in +``hastegeo.workflows.prepare_prediction_tiles``, next to the tippecanoe +helpers. That made it unreachable from the Azure Functions app, which +has no tippecanoe and must not import a training-image workflow module +— yet the app is exactly where a sidecar has to be written when an +analyst saves an edited version of a model's predictions. It therefore +lives here, and the workflow imports it. + +Two shapes come out of this module: + +* :func:`build_prediction_attrs` — the sidecar of a *raw* prediction + GeoPackage: ``{"n", "ids", "overtureIds", "damage", "unknown", + "damaged"}``. +* :func:`build_edited_prediction_attrs` — the same payload plus a + ``"classes"`` column, for a GeoPackage written by + ``hastegeo.core.processors.prediction_edits.apply_edits``. That file + carries the analyst's final call per row in ``edited_class``, which + the numeric columns alone cannot express (a building overridden to + ``Unknown`` still has whatever unknown fraction the model predicted). + +CRITICAL — row-order invariant: + Predictions join to the layer's footprints GeoPackage **by row + index**, so every array here is ordered by that index and a + footprint/prediction count mismatch is a hard error rather than a + silent misalignment. +""" + +from __future__ import annotations + +import json +import logging +import os +from typing import Any, Dict, List, Optional + +import fiona + +from ..config import ArtifactTypes +from .gdal_security import harden_gdal +from .predictions import PredictionSet, read_predictions + +# Harden GDAL/OGR drivers before any fiona read of a user-supplied vector +# file (GDAL CVE compensating control — docs/known-vulnerabilities.md +# Root Cause C). +harden_gdal() + +logger = logging.getLogger(__name__) + +# Damage/unknown values are fractions in [0, 1]; six decimals is well +# below any threshold a user can set and keeps the payload compact. +VALUE_PRECISION = 6 + +# Column ``apply_edits`` writes the analyst's final class to. Kept as a +# literal so this module does not import the processors package (which +# pulls in the artifact-storage stack). +EDITED_CLASS_FIELD = "edited_class" + +#: Extra column carried by an edited version's sidecar. +CLASSES_KEY = "classes" + + +class FootprintPredictionMismatchError(ValueError): + """Raised when predictions and footprints do not line up row for row.""" + + +def count_features(path: str, layer: Optional[str] = None) -> int: + """Count features in a vector file without loading its geometry.""" + if layer is None: + layers = fiona.listlayers(path) + if not layers: + raise ValueError(f"Vector file has no layers: {path}") + layer = layers[0] + with fiona.open(path, layer=layer) as src: + return len(src) + + +def prediction_layer(predictions_path: str) -> str: + """Return the layer a prediction GeoPackage stores its rows in.""" + layers = fiona.listlayers(predictions_path) + if not layers: + raise ValueError( + f"Prediction GeoPackage has no layers: {predictions_path}" + ) + # The embedding flavor writes a named "predictions" layer; the + # trained-inference flavor uses the default (first) layer. + return "predictions" if "predictions" in layers else layers[0] + + +def _assert_row_counts_match( + predictions_path: str, footprints_path: str +) -> int: + """Fail loudly when predictions and footprints do not line up. + + The prediction -> footprint join is positional, so a count mismatch + would silently attach every damage value to the wrong building. + + Returns: + The (shared) row count. + + Raises: + FootprintPredictionMismatchError: on any mismatch. + """ + footprint_count = count_features(footprints_path) + prediction_count = count_features( + predictions_path, layer=prediction_layer(predictions_path) + ) + if footprint_count != prediction_count: + raise FootprintPredictionMismatchError( + "Prediction/footprint row count mismatch: " + f"{prediction_count} predictions in {predictions_path} vs " + f"{footprint_count} footprints in {footprints_path}. The " + "prediction-to-footprint join is positional, so both files " + "must have the same number of rows in the same order." + ) + return footprint_count + + +def build_prediction_attrs( + predictions_path: str, footprints_path: str +) -> Dict[str, Any]: + """Build the columnar attribute payload for one model. + + Args: + predictions_path: Prediction GeoPackage (either flavor — the + trained-inference merge output or the embedding labeler's + ``predictions`` layer). + footprints_path: The image layer's building-footprints + GeoPackage, used to resolve Overture ids positionally. + + Returns: + ``{"n", "ids", "overtureIds", "damage", "unknown", "damaged"}`` + with every array the same length and ordered by row index. + + Raises: + FootprintPredictionMismatchError: when the two files disagree on + row count. + ValueError: when the prediction row indices are not the + contiguous range ``0..n-1``. + """ + expected_count = _assert_row_counts_match( + predictions_path, footprints_path + ) + predictions: PredictionSet = read_predictions( + predictions_path, footprints_path=footprints_path + ) + rows = sorted(predictions.rows, key=lambda row: row.row_index) + + ids: List[int] = [int(row.row_index) for row in rows] + if ids != list(range(expected_count)): + raise ValueError( + f"Prediction GeoPackage {predictions_path} does not carry a " + f"contiguous 0..{expected_count - 1} row index; the editor " + "indexes the sidecar arrays by tile feature id, so gaps or " + "duplicates would mislabel buildings." + ) + + payload: Dict[str, Any] = { + "n": expected_count, + "ids": ids, + "overtureIds": [ + "" if row.overture_id is None else str(row.overture_id) + for row in rows + ], + "damage": [ + round(float(row.damage_fraction), VALUE_PRECISION) for row in rows + ], + "unknown": [ + round(float(row.unknown_fraction), VALUE_PRECISION) for row in rows + ], + "damaged": [int(row.damaged) for row in rows], + } + + lengths = { + key: len(value) + for key, value in payload.items() + if isinstance(value, list) + } + if set(lengths.values()) != {expected_count}: + raise ValueError( + "Prediction attribute arrays have inconsistent lengths " + f"{lengths}; expected {expected_count} for every column." + ) + return payload + + +def write_prediction_attrs( + predictions_path: str, footprints_path: str, attrs_path: str +) -> Dict[str, Any]: + """Build and write the attribute sidecar; return the payload.""" + payload = build_prediction_attrs(predictions_path, footprints_path) + with open(attrs_path, "w") as handle: + json.dump(payload, handle, separators=(",", ":")) + logger.info( + "Wrote prediction attributes for %s buildings -> %s", + payload["n"], + os.path.basename(attrs_path), + ) + return payload + + +def read_edited_classes(predictions_path: str) -> Optional[List[str]]: + """Return the ``edited_class`` column in row order, if present. + + Returns ``None`` for a GeoPackage that has no such column, i.e. any + raw model output — only ``apply_edits`` writes it. + """ + layer = prediction_layer(predictions_path) + with fiona.open(predictions_path, layer=layer) as src: + if EDITED_CLASS_FIELD not in src.schema["properties"]: + return None + return [ + str(feature["properties"].get(EDITED_CLASS_FIELD) or "") + for feature in src + ] + + +def build_edited_prediction_attrs( + predictions_path: str, footprints_path: str +) -> Dict[str, Any]: + """Build the sidecar of one analyst-edited prediction version. + + Same payload as :func:`build_prediction_attrs` plus a ``"classes"`` + array holding the analyst's final class per row (``"Damaged"``, + ``"NotDamaged"`` or ``"Unknown"``), read from the ``edited_class`` + column ``apply_edits`` writes. + + The numeric columns alone cannot express that call: ``damage`` and + ``unknown`` are still the model's fractions, so a building the + analyst forced to ``Unknown`` looks pristine there. ``damaged`` + *does* already agree with the edit (``apply_edits`` rewrites it), so + a client that ignores ``classes`` still renders damaged-vs-not + correctly for the version. + + Args: + predictions_path: An edited-version GeoPackage. + footprints_path: The image layer's footprints GeoPackage. + + Returns: + The payload. ``classes`` is omitted when the GeoPackage carries + no ``edited_class`` column, which makes the function safe to + call on a raw prediction file too. + + Raises: + FootprintPredictionMismatchError: on a row-count mismatch. + ValueError: when ``classes`` would not line up with the other + columns. + """ + payload = build_prediction_attrs(predictions_path, footprints_path) + classes = read_edited_classes(predictions_path) + if classes is None: + logger.warning( + "Prediction GeoPackage %s has no '%s' column; its sidecar " + "carries no per-row class column.", + predictions_path, + EDITED_CLASS_FIELD, + ) + return payload + if len(classes) != payload["n"]: + raise ValueError( + f"Edited class column of {predictions_path} has " + f"{len(classes)} values but the sidecar has {payload['n']} " + "rows; refusing to write a misaligned sidecar." + ) + payload[CLASSES_KEY] = classes + return payload + + +def write_edited_prediction_attrs( + predictions_path: str, footprints_path: str, attrs_path: str +) -> Dict[str, Any]: + """Build and write an edited version's sidecar; return the payload.""" + payload = build_edited_prediction_attrs(predictions_path, footprints_path) + with open(attrs_path, "w") as handle: + json.dump(payload, handle, separators=(",", ":")) + logger.info( + "Wrote edited prediction attributes for %s buildings -> %s", + payload["n"], + os.path.basename(attrs_path), + ) + return payload + + +def attrs_artifact_name(model_id: str) -> str: + """Artifact name for a model's RAW prediction attribute sidecar.""" + return ( + ArtifactTypes.PREDICTION_ATTRS.value.substitute(modelId=model_id) + + ".json" + ) + + +def version_attrs_artifact_name(model_id: str, version: int) -> str: + """Artifact name for ONE edited version's attribute sidecar. + + The version is part of the name, so each save lands on its own blob + and never overwrites another revision's sidecar — mirroring + ``prediction_edits.edited_version_artifact_name`` for the GeoPackage + the sidecar describes. + """ + return ( + ArtifactTypes.PREDICTION_ATTRS_VERSION.value.substitute( + modelId=model_id, version=int(version) + ) + + ".json" + ) diff --git a/hastelib/src/hastegeo/core/utils/predictions.py b/hastelib/src/hastegeo/core/utils/predictions.py index e81ae712..a51be240 100644 --- a/hastelib/src/hastegeo/core/utils/predictions.py +++ b/hastelib/src/hastegeo/core/utils/predictions.py @@ -261,11 +261,19 @@ class PredictionSource: Attributes: url: Blob URL of the GeoPackage. Empty when the model has no predictions at all. + attrs_url: Blob URL of the matching columnar attribute sidecar — + the model-level one for the raw output, the version's own + for an edited version. Empty when it has not been built yet + (the prediction-tiles job backfills those). version: Edited-version number, or ``None`` for the raw model output in ``Model.gpkgUrl``. created_at: When the edited version was saved (``None`` for raw). created_by: Who saved the edited version (``None`` for raw). edited_count: Buildings the analyst overrode in this version. + is_latest: Whether this is the newest saved state of the model's + predictions. The reports always read the newest version, so + a viewer pinned to an older one (or to the raw output while + edits exist) has to be able to say the two diverge. """ url: str = "" @@ -273,6 +281,8 @@ class PredictionSource: created_at: Optional[str] = None created_by: Optional[str] = None edited_count: int = 0 + attrs_url: str = "" + is_latest: bool = True @property def is_edited(self) -> bool: @@ -283,11 +293,13 @@ def to_dict(self) -> Dict[str, Any]: """Serialize for an HTTP payload.""" return { "url": self.url, + "attrsUrl": self.attrs_url, "version": self.version, "createdAt": self.created_at, "createdBy": self.created_by, "editedCount": self.edited_count, "isEdited": self.is_edited, + "isLatest": self.is_latest, } @@ -354,20 +366,28 @@ def describe_prediction_source( such edited version exists. """ raw_url = model_field(model, "gpkgUrl") or "" + raw_attrs_url = model_field(model, "predictionAttrsUrl") or "" entries = [ entry for entry in edited_prediction_versions(model) if entry.get("gpkgUrl") ] + # The newest saved state of this model's predictions: the highest + # edited version, or the raw output when there is none. + latest_version = _entry_version(entries[0]) if entries else 0 if version is None: if not entries: - return PredictionSource(url=raw_url) + return PredictionSource(url=raw_url, attrs_url=raw_attrs_url) entry = entries[0] else: requested = int(version) if requested == RAW_PREDICTION_VERSION: - return PredictionSource(url=raw_url) + return PredictionSource( + url=raw_url, + attrs_url=raw_attrs_url, + is_latest=latest_version == RAW_PREDICTION_VERSION, + ) entry = next( (e for e in entries if _entry_version(e) == requested), None ) @@ -381,10 +401,12 @@ def describe_prediction_source( return PredictionSource( url=str(entry.get("gpkgUrl") or ""), + attrs_url=str(entry.get("predictionAttrsUrl") or ""), version=_entry_version(entry), created_at=entry.get("createdAt"), created_by=entry.get("createdBy"), edited_count=int(entry.get("editedCount") or 0), + is_latest=_entry_version(entry) == latest_version, ) diff --git a/hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py b/hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py index 0bd44413..08aa28b5 100644 --- a/hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py +++ b/hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py @@ -27,6 +27,19 @@ a layer's footprints are cached, so the tiles exist before any model is trained — there are no predictions to join yet. +Model-scoped runs additionally **backfill edited versions**: every entry +in the config's ``versions`` list gets its own sidecar +(``ArtifactTypes.PREDICTION_ATTRS_VERSION``) built from that version's +own GeoPackage, so switching versions in the viewer is the same code +path as rendering the raw output with a different URL. The processor +lists only versions that still lack a sidecar, which makes the backfill +idempotent. + +The sidecar builder itself lives in +``hastegeo.core.utils.prediction_attrs`` (and is re-exported here): the +Functions app has to write one every time an analyst saves an edited +version, and it can neither run nor import this tippecanoe-bound module. + CRITICAL — row-order invariant: Predictions join to the layer's ``buildingFootprintsUrl`` GeoPackage **by row index** (``hastegeo.core.utils.assessment``). Both artifacts @@ -56,12 +69,28 @@ from datetime import datetime, timezone from typing import Any, Dict, List, Optional -import fiona import geopandas as gpd from hastegeo.core.config import ArtifactTypes, Config, StorageType from hastegeo.core.utils.gdal_security import harden_gdal from hastegeo.core.utils.logs import Logger as HasteLogger -from hastegeo.core.utils.predictions import PredictionSet, read_predictions + +# The sidecar builder lives in hastegeo.core.utils.prediction_attrs so the +# Functions app can write a sidecar when an analyst saves an edited +# version, without importing this training-image-only module (tippecanoe). +# Re-exported here because this module IS the sidecar's public workflow +# entry point. +from hastegeo.core.utils.prediction_attrs import ( # noqa: F401 + VALUE_PRECISION, + FootprintPredictionMismatchError, + build_edited_prediction_attrs, + build_prediction_attrs, + count_features, + prediction_layer, + version_attrs_artifact_name, + write_edited_prediction_attrs, + write_prediction_attrs, +) +from hastegeo.core.utils.predictions import read_predictions WORKDIR = os.getenv("WORKDIR", ".") LOG_DIR = os.path.join(WORKDIR, "logs") @@ -91,9 +120,6 @@ TILE_ID_FIELD = "id" TILE_OVERTURE_ID_FIELD = "overture_id" TILING_CRS = "EPSG:4326" -# Damage/unknown values are fractions in [0, 1]; six decimals is well -# below any threshold a user can set and keeps the payload compact. -VALUE_PRECISION = 6 MANIFEST_FILENAME = "prediction_tiles_manifest.json" DEFAULT_OUTPUT_DIR = "outputs" @@ -107,10 +133,6 @@ class TippecanoeError(RuntimeError): """Raised when tippecanoe runs but exits non-zero.""" -class FootprintPredictionMismatchError(ValueError): - """Raised when predictions and footprints do not line up row for row.""" - - def log_progress(message: str) -> None: """Append a friendly progress line consumed by the postprocessor.""" logger.info(message) @@ -293,143 +315,6 @@ def build_footprint_pmtiles( return count -# --------------------------------------------------------------------------- -# Prediction attribute sidecar -# --------------------------------------------------------------------------- -def count_features(path: str, layer: Optional[str] = None) -> int: - """Count features in a vector file without loading its geometry.""" - if layer is None: - layers = fiona.listlayers(path) - if not layers: - raise ValueError(f"Vector file has no layers: {path}") - layer = layers[0] - with fiona.open(path, layer=layer) as src: - return len(src) - - -def _prediction_layer(predictions_path: str) -> str: - """Return the layer a prediction GeoPackage stores its rows in.""" - layers = fiona.listlayers(predictions_path) - if not layers: - raise ValueError( - f"Prediction GeoPackage has no layers: {predictions_path}" - ) - # The embedding flavor writes a named "predictions" layer; the - # trained-inference flavor uses the default (first) layer. - return "predictions" if "predictions" in layers else layers[0] - - -def _assert_row_counts_match( - predictions_path: str, footprints_path: str -) -> int: - """Fail loudly when predictions and footprints do not line up. - - The prediction -> footprint join is positional, so a count mismatch - would silently attach every damage value to the wrong building. - - Returns: - The (shared) row count. - - Raises: - FootprintPredictionMismatchError: on any mismatch. - """ - footprint_count = count_features(footprints_path) - prediction_count = count_features( - predictions_path, layer=_prediction_layer(predictions_path) - ) - if footprint_count != prediction_count: - raise FootprintPredictionMismatchError( - "Prediction/footprint row count mismatch: " - f"{prediction_count} predictions in {predictions_path} vs " - f"{footprint_count} footprints in {footprints_path}. The " - "prediction-to-footprint join is positional, so both files " - "must have the same number of rows in the same order." - ) - return footprint_count - - -def build_prediction_attrs( - predictions_path: str, footprints_path: str -) -> Dict[str, Any]: - """Build the columnar attribute payload for one model. - - Args: - predictions_path: Prediction GeoPackage (either flavor — the - trained-inference merge output or the embedding labeler's - ``predictions`` layer). - footprints_path: The image layer's building-footprints - GeoPackage, used to resolve Overture ids positionally. - - Returns: - ``{"n", "ids", "overtureIds", "damage", "unknown", "damaged"}`` - with every array the same length and ordered by row index. - - Raises: - FootprintPredictionMismatchError: when the two files disagree on - row count. - ValueError: when the prediction row indices are not the - contiguous range ``0..n-1``. - """ - expected_count = _assert_row_counts_match( - predictions_path, footprints_path - ) - predictions: PredictionSet = read_predictions( - predictions_path, footprints_path=footprints_path - ) - rows = sorted(predictions.rows, key=lambda row: row.row_index) - - ids: List[int] = [int(row.row_index) for row in rows] - if ids != list(range(expected_count)): - raise ValueError( - f"Prediction GeoPackage {predictions_path} does not carry a " - f"contiguous 0..{expected_count - 1} row index; the editor " - "indexes the sidecar arrays by tile feature id, so gaps or " - "duplicates would mislabel buildings." - ) - - payload: Dict[str, Any] = { - "n": expected_count, - "ids": ids, - "overtureIds": [ - "" if row.overture_id is None else str(row.overture_id) - for row in rows - ], - "damage": [ - round(float(row.damage_fraction), VALUE_PRECISION) for row in rows - ], - "unknown": [ - round(float(row.unknown_fraction), VALUE_PRECISION) for row in rows - ], - "damaged": [int(row.damaged) for row in rows], - } - - lengths = { - key: len(value) - for key, value in payload.items() - if isinstance(value, list) - } - if set(lengths.values()) != {expected_count}: - raise ValueError( - "Prediction attribute arrays have inconsistent lengths " - f"{lengths}; expected {expected_count} for every column." - ) - return payload - - -def write_prediction_attrs( - predictions_path: str, footprints_path: str, attrs_path: str -) -> Dict[str, Any]: - """Build and write the attribute sidecar; return the payload.""" - payload = build_prediction_attrs(predictions_path, footprints_path) - with open(attrs_path, "w") as handle: - json.dump(payload, handle, separators=(",", ":")) - log_progress( - f"Wrote prediction attributes for {payload['n']} buildings -> " - f"{os.path.basename(attrs_path)}" - ) - return payload - - # --------------------------------------------------------------------------- # Artifact storage # --------------------------------------------------------------------------- @@ -519,6 +404,96 @@ def default_attrs_name(model_id: str) -> str: ) +def default_version_attrs_name(model_id: str, version: int) -> str: + """Artifact filename of ONE edited version's attribute sidecar.""" + return version_attrs_artifact_name(model_id, version) + + +def build_version_attrs( + versions: List[Dict[str, Any]], + footprints_path: str, + output_dir: str, + model_id: str, +) -> List[Dict[str, Any]]: + """Build the attribute sidecar of each requested edited version. + + The backfill half of the job: an edited GeoPackage saved before + per-version sidecars existed (or one whose sidecar write failed) has + no class data for the viewer to render, so the processor lists those + versions in the config and they are rebuilt here from the very same + GeoPackage they describe. + + Failures are per version and non-fatal: one unreadable revision must + not throw away the model's own sidecar or the layer's tiles, and the + next preparation request retries whatever is still missing (the + whole flow is idempotent and skips versions that already have one). + + Args: + versions: ``[{"version": 1, "predictions": , + "attrs": }, ...]``. + footprints_path: The layer's footprints GeoPackage. + output_dir: Directory the sidecars are written to. + model_id: Owning model, used for the default artifact name. + + Returns: + One manifest entry per version: + ``{"version", "filename", "url", "n", "error"}``. ``filename`` + is empty when the sidecar could not be built. + """ + results: List[Dict[str, Any]] = [] + for entry in versions: + try: + version = int(entry.get("version")) + except (TypeError, ValueError): + logger.warning( + "Skipping edited-version entry without a numeric " + "version: %s", + entry, + ) + continue + record: Dict[str, Any] = { + "version": version, + "filename": "", + "url": None, + "n": 0, + "error": "", + } + results.append(record) + predictions_path = entry.get("predictions") or "" + attrs_name = os.path.basename( + entry.get("attrs") or default_version_attrs_name(model_id, version) + ) + try: + if not predictions_path or not os.path.exists(predictions_path): + raise FileNotFoundError( + "Edited prediction GeoPackage not found for version " + f"{version}: {predictions_path}" + ) + attrs_path = os.path.join(output_dir, attrs_name) + payload = write_edited_prediction_attrs( + predictions_path, footprints_path, attrs_path + ) + record["filename"] = attrs_name + record["n"] = int(payload["n"]) + log_progress( + f"Built prediction attributes for edited version {version} " + f"({payload['n']} buildings)" + ) + except Exception as error: # noqa: BLE001 - reported per version + logger.error( + "Failed to build attributes for edited version %s: %s", + version, + error, + exc_info=True, + ) + record["error"] = str(error) + log_progress( + f"Could not build prediction attributes for edited " + f"version {version}: {error}" + ) + return results + + def run(config: Dict[str, Any], output_dir: str) -> Dict[str, Any]: """Run the workflow described by ``config`` and return its manifest. @@ -526,7 +501,9 @@ def run(config: Dict[str, Any], output_dir: str) -> Dict[str, Any]: config: Parsed workflow config (see the module docstring of ``hastegeo.core.processors.prediction_tiles`` for the shape the processor writes). Omitting ``model_id`` selects - layer-only mode: footprint PMTiles, no sidecar. + layer-only mode: footprint PMTiles, no sidecar. A + ``versions`` list additionally backfills the attribute + sidecar of each analyst-edited version listed there. output_dir: Directory the artifacts are written to. The runner uploads everything in here after the task completes. @@ -592,6 +569,8 @@ def run(config: Dict[str, Any], output_dir: str) -> Dict[str, Any]: "building_count": 0, "prediction_flavor": "", "supports_threshold": False, + # One entry per edited version whose sidecar was (re)built. + "version_attrs": [], } to_store: Dict[str, str] = {} @@ -630,6 +609,27 @@ def run(config: Dict[str, Any], output_dir: str) -> Dict[str, Any]: manifest["prediction_flavor"] = predictions.flavor manifest["supports_threshold"] = bool(predictions.supports_threshold) to_store[attrs_name] = attrs_path + log_progress( + f"Wrote prediction attributes for {payload['n']} buildings -> " + f"{attrs_name}" + ) + + # Backfill: every edited version listed in the config gets its + # own sidecar, keyed to the very GeoPackage it describes. The + # processor only lists versions that still lack one, so re-running + # this job is a no-op for versions already backfilled. + version_records = build_version_attrs( + config.get("versions") or [], + footprints_path, + output_dir, + model_id, + ) + manifest["version_attrs"] = version_records + for record in version_records: + if record["filename"]: + to_store[record["filename"]] = os.path.join( + output_dir, record["filename"] + ) else: log_progress("No model requested; skipping prediction attributes") @@ -642,6 +642,9 @@ def run(config: Dict[str, Any], output_dir: str) -> Dict[str, Any]: manifest["attrs_url"] = ( urls.get(attrs_name) if attrs_name else None ) + for record in manifest["version_attrs"]: + if record["filename"]: + record["url"] = urls.get(record["filename"]) else: # Not an error: on Azure Batch the runner uploads outputs/ # and the postprocessor resolves the URLs from the task's diff --git a/hastelib/tests/core/processors/test_prediction_edits_versions.py b/hastelib/tests/core/processors/test_prediction_edits_versions.py new file mode 100644 index 00000000..8131ae4f --- /dev/null +++ b/hastelib/tests/core/processors/test_prediction_edits_versions.py @@ -0,0 +1,301 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for saving a version's GeoPackage AND sidecar in one call. + +The map renders from the attribute sidecar, not from the GeoPackage. A +version stored without its own sidecar therefore draws the RAW model's +classes while claiming to show the analyst's edit — the exact silent +disagreement :func:`save_edited_version` exists to prevent. These tests +pin that both artifacts are produced together and that the sidecar +describes the EDITED file. +""" + +import json +import os +import shutil +import tempfile +import unittest + +import fiona +from fiona.crs import CRS +from fiona.model import Feature +from hastegeo.core.processors.prediction_edits import ( + SavedEditedVersion, + save_edited_version, + store_version_attrs, +) +from shapely.geometry import Polygon, mapping + +INFERENCE_SCHEMA = { + "geometry": "Polygon", + "properties": { + "id": "int", + "damage_pct_0m": "float", + "damaged": "int", + "unknown_pct": "float", + }, +} +FOOTPRINT_SCHEMA = { + "geometry": "Polygon", + "properties": {"id": "str", "subtype": "str", "class": "str"}, +} + + +def _square(index: int) -> Polygon: + x = -122.0 + index * 0.001 + y = 47.0 + index * 0.001 + return Polygon( + [(x, y), (x + 0.0001, y), (x + 0.0001, y + 0.0001), (x, y + 0.0001)] + ) + + +def write_raw_predictions(path: str, damages: list, unknowns: list) -> str: + with fiona.open( + path, + "w", + driver="GPKG", + crs=CRS.from_epsg(4326), + schema=INFERENCE_SCHEMA, + ) as dst: + for index, damage in enumerate(damages): + dst.write( + Feature.from_dict( + **{ + "geometry": mapping(_square(index)), + "properties": { + "id": index, + "damage_pct_0m": damage, + "damaged": 1 if damage >= 0.5 else 0, + "unknown_pct": unknowns[index], + }, + } + ) + ) + return path + + +def write_footprints(path: str, count: int) -> str: + with fiona.open( + path, + "w", + driver="GPKG", + crs=CRS.from_epsg(4326), + schema=FOOTPRINT_SCHEMA, + ) as dst: + for index in range(count): + dst.write( + Feature.from_dict( + **{ + "geometry": mapping(_square(index)), + "properties": { + "id": f"overture-{index}", + "subtype": "residential", + "class": "house", + }, + } + ) + ) + return path + + +class FakeArtifactProcessor: + """Records stores and keeps a copy of every uploaded file.""" + + def __init__(self, root: str): + self.root = root + self.stored: list = [] + + def store_artifact(self, artifact_name: str, src_path: str) -> None: + self.stored.append(artifact_name) + shutil.copyfile(src_path, os.path.join(self.root, artifact_name)) + + def get_download_url(self, identifier: str) -> str: + return f"https://blob.example/{identifier}?sas" + + def read_json(self, artifact_name: str) -> dict: + with open(os.path.join(self.root, artifact_name)) as handle: + return json.load(handle) + + +class SaveFixture(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix="haste-save-version-") + self.addCleanup(shutil.rmtree, self.tmpdir, True) + self.blobs = os.path.join(self.tmpdir, "blobs") + os.makedirs(self.blobs) + self.processor = FakeArtifactProcessor(self.blobs) + # Row 0 pristine, row 1 borderline, row 2 clearly damaged. + self.raw = write_raw_predictions( + os.path.join(self.tmpdir, "raw.gpkg"), + damages=[0.0, 0.6, 0.9], + unknowns=[0.0, 0.0, 0.0], + ) + self.footprints = write_footprints( + os.path.join(self.tmpdir, "footprints.gpkg"), 3 + ) + + def save(self, version: int = 1, **kwargs) -> SavedEditedVersion: + params = { + "threshold": 0.5, + "unknown_threshold": 0.0, + "overrides": {}, + } + params.update(kwargs) + return save_edited_version( + "project-1", + "model-123", + version, + self.raw, + self.footprints, + processor=self.processor, + **params, + ) + + +class TestSaveProducesBothArtifacts(SaveFixture): + def test_stores_the_gpkg_and_its_sidecar(self): + saved = self.save(version=2) + + self.assertEqual( + self.processor.stored, + [ + "edited_predictions_model-123_v2.gpkg", + "prediction_attrs_model-123_v2.json", + ], + ) + self.assertEqual( + saved.gpkg_url, + "https://blob.example/edited_predictions_model-123_v2.gpkg?sas", + ) + self.assertEqual( + saved.attrs_url, + "https://blob.example/prediction_attrs_model-123_v2.json?sas", + ) + + def test_response_body_carries_the_attrs_url(self): + body = self.save(version=1).to_dict() + + self.assertEqual( + sorted(body), + [ + "buildingCount", + "editedCount", + "gpkgUrl", + "predictionAttrsUrl", + "version", + ], + ) + self.assertEqual(body["version"], 1) + self.assertEqual(body["buildingCount"], 3) + self.assertTrue(body["predictionAttrsUrl"]) + + def test_temporary_working_directory_is_cleaned_up(self): + before = set(os.listdir(tempfile.gettempdir())) + self.save() + leaked = [ + name + for name in set(os.listdir(tempfile.gettempdir())) - before + if name.startswith("haste-edited-version-") + ] + self.assertEqual(leaked, []) + + +class TestSidecarDescribesTheEditedFile(SaveFixture): + def test_threshold_change_is_reflected_in_the_sidecar(self): + # At threshold 0.8 only row 2 stays damaged, even though the raw + # model called rows 1 and 2 damaged at 0.5. + self.save(version=1, threshold=0.8) + + payload = self.processor.read_json( + "prediction_attrs_model-123_v1.json" + ) + + self.assertEqual(payload["damaged"], [0, 0, 1]) + self.assertEqual( + payload["classes"], ["NotDamaged", "NotDamaged", "Damaged"] + ) + + def test_overrides_are_reflected_in_the_sidecar(self): + self.save(version=1, overrides={0: "Unknown", 2: "NotDamaged"}) + + payload = self.processor.read_json( + "prediction_attrs_model-123_v1.json" + ) + + self.assertEqual( + payload["classes"], ["Unknown", "Damaged", "NotDamaged"] + ) + self.assertEqual(payload["damaged"], [0, 1, 0]) + + def test_raw_fractions_and_ids_are_preserved(self): + self.save(version=1, overrides={0: "Damaged"}) + + payload = self.processor.read_json( + "prediction_attrs_model-123_v1.json" + ) + + self.assertEqual(payload["n"], 3) + self.assertEqual(payload["ids"], [0, 1, 2]) + self.assertEqual(payload["damage"], [0.0, 0.6, 0.9]) + self.assertEqual( + payload["overtureIds"], + ["overture-0", "overture-1", "overture-2"], + ) + + def test_sidecar_disagrees_with_the_raw_model_on_purpose(self): + # Guards the regression this feature exists to fix: rendering + # the raw sidecar for an edited version would show [0, 1, 1]. + self.save(version=1, overrides={1: "NotDamaged"}) + + payload = self.processor.read_json( + "prediction_attrs_model-123_v1.json" + ) + + self.assertEqual(payload["damaged"], [0, 0, 1]) + + +class TestSaveValidation(SaveFixture): + def test_invalid_threshold_stores_nothing(self): + with self.assertRaises(ValueError): + self.save(threshold=1.5) + + self.assertEqual(self.processor.stored, []) + + def test_unknown_override_class_stores_nothing(self): + with self.assertRaises(ValueError): + self.save(overrides={0: "Rubble"}) + + self.assertEqual(self.processor.stored, []) + + def test_footprint_mismatch_stores_no_sidecar(self): + # apply_edits succeeds row-wise only when the counts line up, so + # a mismatch must fail the save rather than half-store it. + self.footprints = write_footprints( + os.path.join(self.tmpdir, "short.gpkg"), 2 + ) + + with self.assertRaises(ValueError): + self.save() + + self.assertNotIn( + "prediction_attrs_model-123_v1.json", self.processor.stored + ) + + +class TestStoreVersionAttrs(SaveFixture): + def test_missing_local_file_raises(self): + with self.assertRaises(FileNotFoundError): + store_version_attrs( + "project-1", + "model-123", + 1, + os.path.join(self.tmpdir, "missing.json"), + processor=self.processor, + ) + + self.assertEqual(self.processor.stored, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/hastelib/tests/core/processors/test_prediction_tiles.py b/hastelib/tests/core/processors/test_prediction_tiles.py index 5bd98ece..7583df58 100644 --- a/hastelib/tests/core/processors/test_prediction_tiles.py +++ b/hastelib/tests/core/processors/test_prediction_tiles.py @@ -141,6 +141,7 @@ def test_enqueues_when_work_is_outstanding(self): "sourceGpkgUrl", "sourceFootprintsUrl", "force", + "backfillVersions", }, ) self.assertEqual(payload["imageLayerId"], "layer-1") diff --git a/hastelib/tests/core/processors/test_prediction_tiles_backfill.py b/hastelib/tests/core/processors/test_prediction_tiles_backfill.py new file mode 100644 index 00000000..af868bb7 --- /dev/null +++ b/hastelib/tests/core/processors/test_prediction_tiles_backfill.py @@ -0,0 +1,539 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for backfilling per-version prediction attribute sidecars. + +Versions saved before per-version sidecars existed have a GeoPackage but +no sidecar, so the map cannot draw them. The prediction-tiles job grew a +backfill mode that rebuilds those. The property that matters is +**idempotency**: the version list is derived from the model document at +submit time, never from the queue message, so a version that already has +a sidecar is never rebuilt and a re-run of a completed job is a no-op. +""" + +import json +import unittest +from unittest.mock import patch + +from hastegeo.core.config import Config +from hastegeo.core.models.projects import ( + EditedPredictionVersion, + ImageLayer, + Model, + TrainingJob, +) +from hastegeo.core.utils.metadata import MetadataUtils + +STATUSES = Config.get_status_types() +# Task input filenames are extracted from the blob path, which is +# partitioned by the project hash — so the fixtures use the real one. +PARTITION = MetadataUtils.hash_string("proj-1") +RAW_URL = f"https://acct.blob/c/{PARTITION}/predicted_damage_m.gpkg?sas" +FOOTPRINTS_URL = ( + f"https://acct.blob/c/{PARTITION}/building_footprints_p_l.gpkg?sas" +) + + +def _version(version: int, attrs: str = None) -> EditedPredictionVersion: + return EditedPredictionVersion( + version=version, + gpkgUrl=( + f"https://acct.blob/c/{PARTITION}/" + f"edited_predictions_model-1_v{version}.gpkg?sas" + ), + createdAt="2026-08-21T05:10:48+00:00", + createdBy="analyst@example.com", + predictionAttrsUrl=attrs, + threshold=0.5, + unknownThreshold=0.0, + editedCount=3, + sourceGpkgUrl=RAW_URL, + ) + + +def _model(**overrides) -> Model: + data = { + "modelId": "model-1", + "projectId": "proj-1", + "imageLayerId": "layer-1", + "name": "test model", + "gpkgUrl": RAW_URL, + } + data.update(overrides) + return Model(**data) + + +def _layer(**overrides) -> ImageLayer: + data = { + "imageLayerId": "layer-1", + "projectId": "proj-1", + "buildingFootprintsUrl": FOOTPRINTS_URL, + } + data.update(overrides) + return ImageLayer(**data) + + +def _prepared_model(**overrides) -> Model: + """A model whose model-level artifacts are already built.""" + data = { + "predictionAttrsUrl": "https://acct/prediction_attrs_model-1.json", + } + data.update(overrides) + return _model(**data) + + +def _build_preprocessor(model: Model, layer: ImageLayer): + with patch( + "hastegeo.core.processors.prediction_tiles.AzureQueueHandler", + autospec=True, + ): + from hastegeo.core.processors.prediction_tiles import ( + PredictionTilesPreprocessor, + ) + + return PredictionTilesPreprocessor(model, layer) + + +def _build_postprocessor(model: Model, layer: ImageLayer, **kwargs): + with patch( + "hastegeo.core.processors.prediction_tiles.UnifiedDataLayer", + autospec=True, + ), patch( + "hastegeo.core.processors.prediction_tiles.UnifiedRunner", + autospec=True, + ), patch( + "hastegeo.core.processors.prediction_tiles.AzureQueueHandler", + autospec=True, + ): + from hastegeo.core.processors.prediction_tiles import ( + PredictionTilesPostprocessor, + ) + + return PredictionTilesPostprocessor(model, layer, **kwargs) + + +class TestVersionsNeedingAttrs(unittest.TestCase): + def test_empty_without_edited_versions(self): + from hastegeo.core.processors.prediction_tiles import ( + versions_needing_attrs, + ) + + self.assertEqual(versions_needing_attrs(_model()), []) + + def test_reports_versions_without_a_sidecar(self): + from hastegeo.core.processors.prediction_tiles import ( + versions_needing_attrs, + ) + + model = _model(editedPredictions=[_version(1), _version(2)]) + + pending = versions_needing_attrs(model) + + self.assertEqual([entry["version"] for entry in pending], [1, 2]) + self.assertTrue( + pending[0]["gpkgUrl"].endswith( + "edited_predictions_model-1_v1.gpkg?sas" + ) + ) + + def test_skips_versions_that_already_have_one(self): + from hastegeo.core.processors.prediction_tiles import ( + versions_needing_attrs, + ) + + model = _model( + editedPredictions=[ + _version(1, attrs="https://acct/prediction_attrs_v1.json"), + _version(2), + ] + ) + + self.assertEqual( + [entry["version"] for entry in versions_needing_attrs(model)], + [2], + ) + + def test_nothing_pending_once_every_version_has_one(self): + from hastegeo.core.processors.prediction_tiles import ( + versions_needing_attrs, + ) + + model = _model( + editedPredictions=[ + _version(1, attrs="https://acct/v1.json"), + _version(2, attrs="https://acct/v2.json"), + ] + ) + + self.assertEqual(versions_needing_attrs(model), []) + + def test_versions_without_a_gpkg_are_skipped(self): + from hastegeo.core.processors.prediction_tiles import ( + versions_needing_attrs, + ) + + broken = _version(1) + broken.gpkgUrl = "" + model = _model(editedPredictions=[broken, _version(2)]) + + self.assertEqual( + [entry["version"] for entry in versions_needing_attrs(model)], + [2], + ) + + def test_oldest_version_first(self): + from hastegeo.core.processors.prediction_tiles import ( + versions_needing_attrs, + ) + + model = _model( + editedPredictions=[_version(3), _version(1), _version(2)] + ) + + self.assertEqual( + [entry["version"] for entry in versions_needing_attrs(model)], + [1, 2, 3], + ) + + +class TestBackfillTriggersAJob(unittest.TestCase): + def test_pending_versions_are_outstanding_work(self): + # Both model-level artifacts exist: without backfill this model + # would report COMPLETED and never rebuild v1. + model = _prepared_model(editedPredictions=[_version(1)]) + layer = _layer(footprintPmtilesUrl="https://acct/tiles.pmtiles") + preprocessor = _build_preprocessor(model, layer) + + output = preprocessor.queue_for_processing() + + self.assertEqual(output.predictionTilesStatus, STATUSES.PENDING.value) + preprocessor.queue_client.put_message.assert_called_once() + payload = json.loads( + preprocessor.queue_client.put_message.call_args.args[0] + ) + self.assertTrue(payload["backfillVersions"]) + + def test_nothing_is_queued_when_every_version_has_a_sidecar(self): + model = _prepared_model( + editedPredictions=[_version(1, attrs="https://acct/v1.json")] + ) + layer = _layer(footprintPmtilesUrl="https://acct/tiles.pmtiles") + preprocessor = _build_preprocessor(model, layer) + + output = preprocessor.queue_for_processing() + + self.assertEqual( + output.predictionTilesStatus, STATUSES.COMPLETED.value + ) + preprocessor.queue_client.put_message.assert_not_called() + + def test_request_preparation_reports_pending_versions(self): + from hastegeo.core.processors import prediction_tiles + + model = _prepared_model(editedPredictions=[_version(1), _version(2)]) + layer = _layer(footprintPmtilesUrl="https://acct/tiles.pmtiles") + + with patch.object( + prediction_tiles, "AzureQueueHandler", autospec=True + ): + result = prediction_tiles.request_preparation(model, layer) + + self.assertTrue(result["queued"]) + self.assertEqual(result["versionsPending"], 2) + self.assertEqual(model.predictionTilesStatus, STATUSES.PENDING.value) + + def test_request_preparation_is_a_no_op_when_nothing_is_pending(self): + from hastegeo.core.processors import prediction_tiles + + model = _prepared_model( + editedPredictions=[_version(1, attrs="https://acct/v1.json")] + ) + layer = _layer(footprintPmtilesUrl="https://acct/tiles.pmtiles") + + with patch.object( + prediction_tiles, "AzureQueueHandler", autospec=True + ): + result = prediction_tiles.request_preparation(model, layer) + + self.assertFalse(result["queued"]) + self.assertEqual(result["versionsPending"], 0) + + def test_backfill_can_be_switched_off(self): + from hastegeo.core.processors import prediction_tiles + + model = _prepared_model(editedPredictions=[_version(1)]) + layer = _layer(footprintPmtilesUrl="https://acct/tiles.pmtiles") + + with patch.object( + prediction_tiles, "AzureQueueHandler", autospec=True + ): + result = prediction_tiles.request_preparation( + model, layer, backfill_versions=False + ) + + self.assertFalse(result["queued"]) + self.assertEqual(result["versionsPending"], 0) + + +class TestJobConfigCarriesVersions(unittest.TestCase): + def _submit(self, model: Model, **kwargs): + processor = _build_postprocessor(model, _layer(), **kwargs) + processor.runner.add_task.return_value = ("job-1", "ptl-abc") + processor.storage.get_file_remote_path.return_value = ( + "https://acct.blob/c/hash/prediction_tiles_config_m.json?sas" + ) + processor.process() + return processor + + def test_version_gpkgs_are_task_inputs(self): + model = _prepared_model( + predictionTilesStatus=STATUSES.PENDING.value, + editedPredictions=[_version(1), _version(2)], + ) + + processor = self._submit(model) + + kwargs = processor.runner.add_task.call_args.kwargs + uploads = kwargs["resource_files_for_upload"] + self.assertIn("predictions_v1", uploads) + self.assertIn("predictions_v2", uploads) + self.assertTrue( + uploads["predictions_v1"]["file_path"].startswith("inputs/") + ) + + def test_workflow_config_lists_version_outputs(self): + model = _prepared_model( + predictionTilesStatus=STATUSES.PENDING.value, + editedPredictions=[_version(2)], + ) + + processor = self._submit(model) + + workflow_config = processor.storage.save.call_args.kwargs["data"] + self.assertEqual( + workflow_config["versions"], + [ + { + "version": 2, + "predictions": ( + "inputs/edited_predictions_model-1_v2.gpkg" + ), + "attrs": "prediction_attrs_model-1_v2.json", + } + ], + ) + + def test_versions_with_a_sidecar_are_not_resubmitted(self): + model = _prepared_model( + predictionTilesStatus=STATUSES.PENDING.value, + editedPredictions=[ + _version(1, attrs="https://acct/v1.json"), + _version(2), + ], + ) + + processor = self._submit(model) + + workflow_config = processor.storage.save.call_args.kwargs["data"] + self.assertEqual( + [entry["version"] for entry in workflow_config["versions"]], [2] + ) + uploads = processor.runner.add_task.call_args.kwargs[ + "resource_files_for_upload" + ] + self.assertNotIn("predictions_v1", uploads) + + def test_backfill_disabled_submits_no_versions(self): + model = _prepared_model( + predictionTilesStatus=STATUSES.PENDING.value, + editedPredictions=[_version(1)], + ) + + processor = self._submit(model, backfill_versions=False) + + workflow_config = processor.storage.save.call_args.kwargs["data"] + self.assertEqual(workflow_config["versions"], []) + + +class TestCompletionRecordsVersionUrls(unittest.TestCase): + def _completed_processor(self, model: Model, manifest: dict): + model.predictionTilesStatus = STATUSES.IN_PROGRESS.value + model.predictionTilesJob = TrainingJob( + jobId="job-1", + taskId="ptl-abc", + modelId="model-1", + projectId="proj-1", + status=STATUSES.IN_PROGRESS.value, + ) + processor = _build_postprocessor(model, _layer()) + processor.runner.get_task_status.return_value = ( + STATUSES.COMPLETED.value + ) + + def _file_content(job_id, task_id, filename): + if filename.endswith(".json"): + return json.dumps(manifest) + return "2026-08-21T00:00:00+00:00|Building prediction attributes" + + processor.runner.get_filecontent_from_task.side_effect = _file_content + return processor + + def _manifest(self, version_attrs: list) -> dict: + return { + "pmtiles_built": False, + "pmtiles_filename": "", + "pmtiles_url": None, + "attrs_filename": "prediction_attrs_model-1.json", + "attrs_url": "https://acct/prediction_attrs_model-1.json", + "building_count": 12, + "version_attrs": version_attrs, + } + + def test_urls_land_on_the_version_entries(self): + model = _prepared_model(editedPredictions=[_version(1), _version(2)]) + processor = self._completed_processor( + model, + self._manifest( + [ + { + "version": 1, + "filename": "prediction_attrs_model-1_v1.json", + "url": "https://acct/prediction_attrs_v1.json", + }, + { + "version": 2, + "filename": "prediction_attrs_model-1_v2.json", + "url": "https://acct/prediction_attrs_v2.json", + }, + ] + ), + ) + + output = processor.process() + + self.assertEqual( + [entry.predictionAttrsUrl for entry in output.editedPredictions], + [ + "https://acct/prediction_attrs_v1.json", + "https://acct/prediction_attrs_v2.json", + ], + ) + + def test_a_second_run_has_nothing_left_to_do(self): + from hastegeo.core.processors.prediction_tiles import ( + versions_needing_attrs, + ) + + model = _prepared_model(editedPredictions=[_version(1)]) + processor = self._completed_processor( + model, + self._manifest( + [ + { + "version": 1, + "filename": "prediction_attrs_model-1_v1.json", + "url": "https://acct/prediction_attrs_v1.json", + } + ] + ), + ) + + output = processor.process() + + # Idempotency: the backfill is driven by this list, and the run + # emptied it. + self.assertEqual(versions_needing_attrs(output), []) + + def test_url_is_resolved_from_task_outputs_when_absent(self): + model = _prepared_model(editedPredictions=[_version(1)]) + processor = self._completed_processor( + model, + self._manifest( + [ + { + "version": 1, + "filename": "prediction_attrs_model-1_v1.json", + "url": None, + } + ] + ), + ) + processor.storage.get_file_remote_path.return_value = ( + "https://acct/hash/ptl-abc/prediction_attrs_model-1_v1.json?sas" + ) + + output = processor.process() + + self.assertEqual( + output.editedPredictions[0].predictionAttrsUrl, + "https://acct/hash/ptl-abc/prediction_attrs_model-1_v1.json?sas", + ) + + def test_a_failed_version_stays_pending(self): + from hastegeo.core.processors.prediction_tiles import ( + versions_needing_attrs, + ) + + model = _prepared_model(editedPredictions=[_version(1)]) + processor = self._completed_processor( + model, + self._manifest( + [ + { + "version": 1, + "filename": "", + "url": None, + "error": "input GeoPackage missing", + } + ] + ), + ) + processor.storage.get_file_remote_path.return_value = "" + + output = processor.process() + + self.assertEqual( + output.predictionTilesStatus, STATUSES.COMPLETED.value + ) + self.assertIsNone(output.editedPredictions[0].predictionAttrsUrl) + # Left in the pending list so the next request retries it. + self.assertEqual( + [entry["version"] for entry in versions_needing_attrs(output)], + [1], + ) + + def test_unknown_version_in_the_manifest_is_ignored(self): + model = _prepared_model(editedPredictions=[_version(1)]) + processor = self._completed_processor( + model, + self._manifest( + [ + { + "version": 9, + "filename": "prediction_attrs_model-1_v9.json", + "url": "https://acct/prediction_attrs_v9.json", + } + ] + ), + ) + + output = processor.process() + + self.assertEqual( + output.predictionTilesStatus, STATUSES.COMPLETED.value + ) + self.assertIsNone(output.editedPredictions[0].predictionAttrsUrl) + + def test_a_manifest_without_versions_is_fine(self): + model = _prepared_model(editedPredictions=[_version(1)]) + processor = self._completed_processor(model, self._manifest([])) + + output = processor.process() + + self.assertEqual( + output.predictionTilesStatus, STATUSES.COMPLETED.value + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/hastelib/tests/core/processors/test_prediction_tiles_layer.py b/hastelib/tests/core/processors/test_prediction_tiles_layer.py index 4f48e3b5..6fb84c15 100644 --- a/hastelib/tests/core/processors/test_prediction_tiles_layer.py +++ b/hastelib/tests/core/processors/test_prediction_tiles_layer.py @@ -105,6 +105,7 @@ def test_message_omits_the_model(self): "sourceGpkgUrl", "sourceFootprintsUrl", "force", + "backfillVersions", }, ) self.assertEqual(message["modelId"], "") diff --git a/hastelib/tests/core/processors/test_prediction_tiles_request.py b/hastelib/tests/core/processors/test_prediction_tiles_request.py index c7d8fcfc..2ec63710 100644 --- a/hastelib/tests/core/processors/test_prediction_tiles_request.py +++ b/hastelib/tests/core/processors/test_prediction_tiles_request.py @@ -94,6 +94,7 @@ def test_queues_when_nothing_is_prepared(self): "sourceGpkgUrl", "sourceFootprintsUrl", "force", + "backfillVersions", }, ) self.assertEqual(payload["projectId"], "proj-1") diff --git a/hastelib/tests/core/processors/test_visualizer_versions.py b/hastelib/tests/core/processors/test_visualizer_versions.py new file mode 100644 index 00000000..4f007fab --- /dev/null +++ b/hastelib/tests/core/processors/test_visualizer_versions.py @@ -0,0 +1,284 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Viewer payload tests for per-version prediction rendering. + +Switching versions in the results viewer must be nothing more than the +same renderer pointed at a different sidecar, so the payload has to: + +* pin ``predictionAttrsUrl`` to the SELECTED version rather than always + handing back the model-level (raw) sidecar; +* judge readiness against that version's sidecar, so a version saved + before per-version sidecars existed reports "still preparing" instead + of drawing the raw model's classes under an edited version's name; +* state whether the selection is the newest version, because version + selection changes the map only — the Assessment and Validation reports + keep reading the newest version, and the UI must be able to say when + the two diverge without recomputing it. +""" + +import unittest +from urllib.parse import parse_qs, urlparse + +from hastegeo.core.config import Config +from hastegeo.core.models.projects import ImageLayer, Model, Project +from hastegeo.core.processors.visualizer import ( + REASON_PREPARING, + PredictionInfo, + build_visualizer_results, + model_artifact_url, +) + +STATUSES = Config.get_status_types() +PROCESSED = STATUSES.COMPLETED.value + +TITILER = "https://titiler.example.net/" +PROJECT_ID = "11111111-1111-1111-1111-111111111111" +LAYER_ID = "22222222-2222-2222-2222-222222222222" +GPKG_URL = "https://acct.blob/c/hash/predicted_damage_m.gpkg?sas" +ATTRS_URL = "https://acct.blob/c/hash/prediction_attrs_5557.json?sas" +LAYER_PMTILES = "https://acct.blob/c/hash/footprints_layer.pmtiles?sas" + + +def _project() -> Project: + return Project( + projectId=PROJECT_ID, + name="Hurricane Test", + eventDate="2026-01-02T00:00:00Z", + ) + + +def _layer(**overrides) -> ImageLayer: + data = { + "imageLayerId": LAYER_ID, + "projectId": PROJECT_ID, + "postEventProcessedImageryUrl": "https://acct.blob/c/h/post.tif?s", + "buildingFootprintsUrl": "https://acct.blob/c/hash/fp.gpkg?sas", + "footprintPmtilesUrl": LAYER_PMTILES, + } + data.update(overrides) + return ImageLayer(**data) + + +def _edited(version: int, attrs: bool = True) -> dict: + return { + "version": version, + "gpkgUrl": f"https://acct.blob/c/hash/edited_v{version}.gpkg?sas", + "predictionAttrsUrl": ( + f"https://acct.blob/c/hash/attrs_v{version}.json?sas" + if attrs + else None + ), + "createdAt": "2026-08-21T05:10:48+00:00", + "editedCount": 5, + } + + +def _model(*edits, **overrides) -> Model: + data = { + "modelId": "5557", + "projectId": PROJECT_ID, + "imageLayerId": LAYER_ID, + "modelType": "trained", + "status": PROCESSED, + "inferenceStatus": PROCESSED, + "gpkgUrl": GPKG_URL, + "predictionAttrsUrl": ATTRS_URL, + "predictionTilesStatus": PROCESSED, + "editedPredictions": list(edits), + } + data.update(overrides) + return Model(**data) + + +def _build(model: Model, **kwargs): + return build_visualizer_results( + project=_project(), + image_layer=_layer(), + model=model, + titiler_endpoint=TITILER, + study_area=[], + **kwargs, + ) + + +def _params(url: str) -> dict: + return { + key: values[0] for key, values in parse_qs(urlparse(url).query).items() + } + + +class TestVersionPinnedAttrsUrl(unittest.TestCase): + def test_raw_selection_has_no_version_parameter(self): + visualizer = _build( + _model(), predictions=PredictionInfo(attrs_url=ATTRS_URL) + ) + + params = _params(visualizer.predictionAttrsUrl) + self.assertEqual(params["kind"], "prediction_attrs") + self.assertEqual(params["modelId"], "5557") + self.assertNotIn("version", params) + + def test_selected_version_is_pinned_on_the_route(self): + model = _model(_edited(1), _edited(2)) + + visualizer = _build( + model, + predictions=PredictionInfo( + version=2, + attrs_url="https://acct.blob/c/hash/attrs_v2.json?sas", + ), + ) + + params = _params(visualizer.predictionAttrsUrl) + self.assertEqual(params["version"], "2") + self.assertEqual(params["kind"], "prediction_attrs") + + def test_older_version_gets_its_own_route(self): + model = _model(_edited(1), _edited(2)) + + first = _build( + model, + predictions=PredictionInfo( + version=1, + attrs_url="https://acct.blob/c/hash/attrs_v1.json?sas", + is_latest=False, + ), + ) + second = _build( + model, + predictions=PredictionInfo( + version=2, + attrs_url="https://acct.blob/c/hash/attrs_v2.json?sas", + ), + ) + + self.assertNotEqual( + first.predictionAttrsUrl, second.predictionAttrsUrl + ) + self.assertEqual(_params(first.predictionAttrsUrl)["version"], "1") + + def test_route_builder_omits_an_absent_version(self): + self.assertNotIn( + "version", + model_artifact_url(PROJECT_ID, "5557", "prediction_attrs"), + ) + self.assertIn( + "version=3", + model_artifact_url( + PROJECT_ID, "5557", "prediction_attrs", version=3 + ), + ) + + +class TestReadinessFollowsTheSelectedVersion(unittest.TestCase): + def test_version_without_a_sidecar_reports_preparing(self): + # Models 0448/5553 in dev: an edited version exists, its sidecar + # does not. Falling back to the model-level sidecar here would + # draw the RAW classes while claiming to show the edit. + model = _model(_edited(1, attrs=False)) + + visualizer = _build(model, predictions=PredictionInfo(version=1)) + + self.assertFalse(visualizer.predictionsReady) + self.assertEqual( + visualizer.predictionsReadiness.reason, REASON_PREPARING + ) + self.assertFalse(visualizer.predictionsReadiness.attrsReady) + self.assertIsNone(visualizer.predictionAttrsUrl) + + def test_version_with_a_sidecar_is_ready(self): + model = _model(_edited(1)) + + visualizer = _build( + model, + predictions=PredictionInfo( + version=1, + attrs_url="https://acct.blob/c/hash/attrs_v1.json?sas", + ), + ) + + self.assertTrue(visualizer.predictionsReady) + self.assertTrue(visualizer.predictionsReadiness.attrsReady) + self.assertIsNotNone(visualizer.predictionAttrsUrl) + + def test_raw_selection_still_uses_the_model_level_sidecar(self): + # An unbuilt model-level sidecar is still "preparing" even when + # no version is selected. + visualizer = _build(_model(predictionAttrsUrl=None)) + + self.assertFalse(visualizer.predictionsReadiness.attrsReady) + self.assertIsNone(visualizer.predictionAttrsUrl) + + +class TestVersionIsLatestFlag(unittest.TestCase): + def test_defaults_to_true_for_an_unedited_model(self): + visualizer = _build( + _model(), predictions=PredictionInfo(attrs_url=ATTRS_URL) + ) + + self.assertTrue(visualizer.predictionVersionIsLatest) + + def test_false_when_an_older_version_is_pinned(self): + model = _model(_edited(1), _edited(2)) + + visualizer = _build( + model, + predictions=PredictionInfo( + version=1, + attrs_url="https://acct.blob/c/hash/attrs_v1.json?sas", + is_latest=False, + ), + ) + + self.assertEqual(visualizer.predictionVersion, 1) + self.assertFalse(visualizer.predictionVersionIsLatest) + # The reports keep reading the newest version, which is still v2. + self.assertEqual( + [entry["version"] for entry in visualizer.predictionVersions], + [2, 1], + ) + + def test_true_when_the_newest_version_is_selected(self): + model = _model(_edited(1), _edited(2)) + + visualizer = _build( + model, + predictions=PredictionInfo( + version=2, + attrs_url="https://acct.blob/c/hash/attrs_v2.json?sas", + is_latest=True, + ), + ) + + self.assertTrue(visualizer.predictionVersionIsLatest) + + def test_false_for_the_raw_output_of_an_edited_model(self): + model = _model(_edited(1)) + + visualizer = _build( + model, + predictions=PredictionInfo(attrs_url=ATTRS_URL, is_latest=False), + ) + + self.assertIsNone(visualizer.predictionVersion) + self.assertFalse(visualizer.predictionVersionIsLatest) + + def test_flag_survives_serialization(self): + model = _model(_edited(1), _edited(2)) + + payload = _build( + model, + predictions=PredictionInfo( + version=1, + attrs_url="https://acct.blob/c/hash/attrs_v1.json?sas", + is_latest=False, + ), + ).model_dump() + + self.assertIn("predictionVersionIsLatest", payload) + self.assertFalse(payload["predictionVersionIsLatest"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/hastelib/tests/core/utils/test_prediction_attrs.py b/hastelib/tests/core/utils/test_prediction_attrs.py new file mode 100644 index 00000000..1bc9e27b --- /dev/null +++ b/hastelib/tests/core/utils/test_prediction_attrs.py @@ -0,0 +1,288 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Unit tests for the shared prediction attribute sidecar builder. + +The builder used to live in +``hastegeo.workflows.prepare_prediction_tiles`` next to the tippecanoe +helpers, which made it unreachable from the Functions app. These tests +pin two things: + +* the move was **behaviour preserving** — the workflow's re-export is + literally the same object, and the payload is byte-identical for both + prediction flavors; +* the edited-version extension (``classes``) reads the analyst's final + call out of the GeoPackage instead of re-deriving it from thresholds. +""" + +import json +import os +import shutil +import tempfile +import unittest + +import fiona +from hastegeo.core.utils import prediction_attrs +from hastegeo.workflows import prepare_prediction_tiles as ppt +from shapely.geometry import Polygon, mapping + +FOOTPRINT_SCHEMA = { + "geometry": "Polygon", + "properties": {"id": "str", "subtype": "str", "class": "str"}, +} +TRAINED_SCHEMA = { + "geometry": "Polygon", + "properties": { + "id": "int", + "damage_pct_0m": "float", + "damaged": "int", + "unknown_pct": "float", + }, +} +# apply_edits' output: the source columns plus the edit columns. +EDITED_SCHEMA = { + "geometry": "Polygon", + "properties": { + "id": "int", + "damage_pct_0m": "float", + "damaged": "int", + "unknown_pct": "float", + "edited_class": "str", + "edit_threshold": "float", + "overture_id": "str", + }, +} + + +def _square(index: int) -> Polygon: + x = -122.0 + index * 0.001 + y = 47.0 + index * 0.001 + return Polygon( + [(x, y), (x + 0.0001, y), (x + 0.0001, y + 0.0001), (x, y + 0.0001)] + ) + + +def write_footprints(path: str, count: int) -> None: + with fiona.open( + path, "w", driver="GPKG", crs="EPSG:4326", schema=FOOTPRINT_SCHEMA + ) as dst: + for index in range(count): + dst.write( + { + "geometry": mapping(_square(index)), + "properties": { + "id": f"overture-{index}", + "subtype": "residential", + "class": "house", + }, + } + ) + + +def write_trained_predictions(path: str, damages: list, unknowns: list): + with fiona.open( + path, "w", driver="GPKG", crs="EPSG:32610", schema=TRAINED_SCHEMA + ) as dst: + for index, damage in enumerate(damages): + dst.write( + { + "geometry": mapping(_square(index)), + "properties": { + "id": index, + "damage_pct_0m": damage, + "damaged": 1 if damage > 0 else 0, + "unknown_pct": unknowns[index], + }, + } + ) + + +def write_edited_predictions( + path: str, damages: list, unknowns: list, classes: list +): + """Write what ``apply_edits`` produces for the same three rows.""" + with fiona.open( + path, "w", driver="GPKG", crs="EPSG:32610", schema=EDITED_SCHEMA + ) as dst: + for index, damage in enumerate(damages): + dst.write( + { + "geometry": mapping(_square(index)), + "properties": { + "id": index, + "damage_pct_0m": damage, + "damaged": 1 if classes[index] == "Damaged" else 0, + "unknown_pct": unknowns[index], + "edited_class": classes[index], + "edit_threshold": 0.5, + "overture_id": f"overture-{index}", + }, + } + ) + + +class _TempFiles(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix="haste-attrs-") + self.addCleanup(shutil.rmtree, self.tmpdir, True) + self.footprints = os.path.join(self.tmpdir, "footprints.gpkg") + self.predictions = os.path.join(self.tmpdir, "predictions.gpkg") + + +class TestTheMoveIsBehaviourPreserving(_TempFiles): + def test_workflow_reexports_the_shared_functions(self): + # Identity, not equality: the workflow must not keep a copy that + # could drift from the one the Functions app calls. + self.assertIs( + ppt.build_prediction_attrs, + prediction_attrs.build_prediction_attrs, + ) + self.assertIs( + ppt.write_prediction_attrs, + prediction_attrs.write_prediction_attrs, + ) + self.assertIs( + ppt.FootprintPredictionMismatchError, + prediction_attrs.FootprintPredictionMismatchError, + ) + + def test_payload_shape_is_unchanged(self): + damages = [0.0, 0.25, 1.0] + unknowns = [0.0, 0.1, 0.5] + write_footprints(self.footprints, 3) + write_trained_predictions(self.predictions, damages, unknowns) + + payload = prediction_attrs.build_prediction_attrs( + self.predictions, self.footprints + ) + + self.assertEqual( + sorted(payload), + ["damage", "damaged", "ids", "n", "overtureIds", "unknown"], + ) + self.assertEqual(payload["n"], 3) + self.assertEqual(payload["ids"], [0, 1, 2]) + self.assertEqual(payload["damage"], damages) + self.assertEqual(payload["unknown"], unknowns) + self.assertEqual(payload["damaged"], [0, 1, 1]) + self.assertEqual( + payload["overtureIds"], + ["overture-0", "overture-1", "overture-2"], + ) + + def test_row_count_mismatch_is_still_a_value_error(self): + write_footprints(self.footprints, 4) + write_trained_predictions(self.predictions, [0.0, 1.0], [0.0, 0.0]) + + with self.assertRaises(ValueError) as caught: + prediction_attrs.build_prediction_attrs( + self.predictions, self.footprints + ) + self.assertIn("positional", str(caught.exception)) + + def test_write_round_trips_json(self): + write_footprints(self.footprints, 2) + write_trained_predictions(self.predictions, [0.0, 0.75], [0.0, 0.0]) + attrs_path = os.path.join(self.tmpdir, "attrs.json") + + payload = prediction_attrs.write_prediction_attrs( + self.predictions, self.footprints, attrs_path + ) + + with open(attrs_path) as handle: + self.assertEqual(json.load(handle), payload) + + def test_shared_module_does_not_need_tippecanoe(self): + # The whole point of the move: no workflow import, no subprocess. + self.assertFalse( + hasattr(prediction_attrs, "run_tippecanoe"), + "the sidecar builder must not pull in the tiling helpers", + ) + + +class TestEditedVersionSidecar(_TempFiles): + def test_classes_come_from_the_edited_class_column(self): + write_footprints(self.footprints, 3) + # Row 0 is pristine but the analyst forced it to Unknown; row 2 + # is heavily damaged but the analyst cleared it. + write_edited_predictions( + self.predictions, + damages=[0.0, 0.6, 0.9], + unknowns=[0.0, 0.0, 0.0], + classes=["Unknown", "Damaged", "NotDamaged"], + ) + + payload = prediction_attrs.build_edited_prediction_attrs( + self.predictions, self.footprints + ) + + self.assertEqual( + payload["classes"], ["Unknown", "Damaged", "NotDamaged"] + ) + # `damaged` agrees with the edit, so a client that ignores + # `classes` still renders damaged-vs-not correctly. + self.assertEqual(payload["damaged"], [0, 1, 0]) + # The raw fractions are preserved: only the class was edited. + self.assertEqual(payload["damage"], [0.0, 0.6, 0.9]) + + def test_classes_are_omitted_for_a_raw_prediction_file(self): + write_footprints(self.footprints, 2) + write_trained_predictions(self.predictions, [0.0, 1.0], [0.0, 0.0]) + + payload = prediction_attrs.build_edited_prediction_attrs( + self.predictions, self.footprints + ) + + self.assertNotIn("classes", payload) + self.assertEqual(payload["n"], 2) + + def test_read_edited_classes_returns_none_without_the_column(self): + write_footprints(self.footprints, 1) + write_trained_predictions(self.predictions, [0.0], [0.0]) + + self.assertIsNone( + prediction_attrs.read_edited_classes(self.predictions) + ) + + def test_write_edited_round_trips_json(self): + write_footprints(self.footprints, 2) + write_edited_predictions( + self.predictions, + damages=[0.0, 1.0], + unknowns=[0.0, 0.0], + classes=["NotDamaged", "Damaged"], + ) + attrs_path = os.path.join(self.tmpdir, "attrs_v1.json") + + payload = prediction_attrs.write_edited_prediction_attrs( + self.predictions, self.footprints, attrs_path + ) + + with open(attrs_path) as handle: + on_disk = json.load(handle) + self.assertEqual(on_disk, payload) + self.assertEqual(on_disk["classes"], ["NotDamaged", "Damaged"]) + + +class TestArtifactNames(unittest.TestCase): + def test_raw_sidecar_name(self): + self.assertEqual( + prediction_attrs.attrs_artifact_name("5557"), + "prediction_attrs_5557.json", + ) + + def test_version_sidecar_name_embeds_the_version(self): + self.assertEqual( + prediction_attrs.version_attrs_artifact_name("5557", 2), + "prediction_attrs_5557_v2.json", + ) + + def test_version_name_never_collides_with_the_raw_one(self): + self.assertNotEqual( + prediction_attrs.version_attrs_artifact_name("5557", 1), + prediction_attrs.attrs_artifact_name("5557"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/hastelib/tests/core/utils/test_prediction_source_versions.py b/hastelib/tests/core/utils/test_prediction_source_versions.py new file mode 100644 index 00000000..14e34804 --- /dev/null +++ b/hastelib/tests/core/utils/test_prediction_source_versions.py @@ -0,0 +1,187 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Version resolution for the per-version attribute sidecar. + +``describe_prediction_source`` now answers two more questions the +results viewer needs: + +* **which sidecar** describes the selected GeoPackage — the model-level + one for the raw output, the version's own for an edited version. The + map renders from the sidecar, so pairing the wrong one with a + GeoPackage draws the wrong classes; +* **whether the selection is the newest saved state**. Version selection + changes the map only; the Assessment and Validation reports always + read the newest version, so the UI has to be able to say when the two + diverge instead of recomputing it. +""" + +import unittest + +from hastegeo.core.models.projects import EditedPredictionVersion, Model +from hastegeo.core.utils.predictions import ( + PredictionVersionNotFoundError, + describe_prediction_source, +) + +RAW_URL = "https://acct.blob/c/hash/predicted_damage_m.gpkg?sas" +RAW_ATTRS = "https://acct.blob/c/hash/prediction_attrs_5557.json?sas" + + +def _edit(version: int, attrs: bool = True, **overrides) -> dict: + entry = { + "version": version, + "gpkgUrl": ( + "https://acct.blob/c/hash/edited_predictions_5557_v" + f"{version}.gpkg?sas" + ), + "predictionAttrsUrl": ( + "https://acct.blob/c/hash/prediction_attrs_5557_v" + f"{version}.json?sas" + if attrs + else None + ), + "createdAt": f"2026-08-2{version}T05:10:48+00:00", + "createdBy": "analyst@example.com", + "threshold": 0.5, + "unknownThreshold": 0.0, + "editedCount": version * 10, + "sourceGpkgUrl": RAW_URL, + } + entry.update(overrides) + return entry + + +def _model(*edits, **overrides) -> dict: + data = { + "modelId": "5557", + "projectId": "proj-1", + "imageLayerId": "layer-1", + "gpkgUrl": RAW_URL, + "predictionAttrsUrl": RAW_ATTRS, + "editedPredictions": list(edits), + } + data.update(overrides) + return data + + +class TestSidecarSelection(unittest.TestCase): + def test_raw_output_uses_the_model_level_sidecar(self): + source = describe_prediction_source(_model()) + + self.assertEqual(source.url, RAW_URL) + self.assertEqual(source.attrs_url, RAW_ATTRS) + + def test_version_zero_uses_the_model_level_sidecar(self): + source = describe_prediction_source(_model(_edit(1)), version=0) + + self.assertEqual(source.url, RAW_URL) + self.assertEqual(source.attrs_url, RAW_ATTRS) + self.assertIsNone(source.version) + + def test_absent_version_uses_the_newest_edit_sidecar(self): + source = describe_prediction_source(_model(_edit(1), _edit(2))) + + self.assertEqual(source.version, 2) + self.assertTrue(source.attrs_url.endswith("_v2.json?sas")) + + def test_pinned_version_uses_its_own_sidecar(self): + source = describe_prediction_source( + _model(_edit(1), _edit(2)), version=1 + ) + + self.assertEqual(source.version, 1) + self.assertTrue(source.attrs_url.endswith("_v1.json?sas")) + self.assertNotEqual(source.attrs_url, RAW_ATTRS) + + def test_version_without_a_sidecar_reports_an_empty_url(self): + # Never fall back to the raw sidecar: it describes the model's + # classes, not the analyst's, so it would silently mis-draw. + source = describe_prediction_source( + _model(_edit(1, attrs=False)), version=1 + ) + + self.assertEqual(source.attrs_url, "") + self.assertTrue(source.url) + + def test_unknown_version_raises(self): + with self.assertRaises(PredictionVersionNotFoundError) as caught: + describe_prediction_source(_model(_edit(1)), version=7) + + message = str(caught.exception) + self.assertIn("version 7", message) + self.assertIn("[1]", message) + + def test_source_is_json_serializable(self): + payload = describe_prediction_source(_model(_edit(1))).to_dict() + + self.assertEqual(payload["version"], 1) + self.assertTrue(payload["attrsUrl"].endswith("_v1.json?sas")) + self.assertTrue(payload["isLatest"]) + + +class TestIsLatest(unittest.TestCase): + def test_raw_output_is_latest_without_edits(self): + self.assertTrue(describe_prediction_source(_model()).is_latest) + + def test_raw_output_is_not_latest_once_edits_exist(self): + source = describe_prediction_source(_model(_edit(1)), version=0) + + self.assertFalse(source.is_latest) + + def test_newest_edit_is_latest(self): + source = describe_prediction_source(_model(_edit(1), _edit(2))) + + self.assertTrue(source.is_latest) + + def test_older_edit_is_not_latest(self): + source = describe_prediction_source( + _model(_edit(1), _edit(2)), version=1 + ) + + self.assertFalse(source.is_latest) + + def test_latest_is_by_version_number_not_list_order(self): + source = describe_prediction_source( + _model(_edit(2), _edit(1)), version=2 + ) + + self.assertTrue(source.is_latest) + + +class TestModelInstances(unittest.TestCase): + """Model documents reach this module as dicts AND as objects.""" + + def _instance(self, attrs: bool) -> Model: + return Model( + modelId="5557", + projectId="proj-1", + imageLayerId="layer-1", + gpkgUrl=RAW_URL, + predictionAttrsUrl=RAW_ATTRS, + editedPredictions=[ + EditedPredictionVersion(**_edit(1, attrs=attrs)) + ], + ) + + def test_version_sidecar_is_read_off_the_entry_object(self): + source = describe_prediction_source(self._instance(True), version=1) + + self.assertTrue(source.attrs_url.endswith("_v1.json?sas")) + self.assertTrue(source.is_latest) + + def test_missing_sidecar_is_empty_not_the_raw_one(self): + source = describe_prediction_source(self._instance(False), version=1) + + self.assertEqual(source.attrs_url, "") + self.assertNotEqual(source.attrs_url, RAW_ATTRS) + + def test_instance_and_dict_agree(self): + instance = describe_prediction_source(self._instance(True)) + as_dict = describe_prediction_source(_model(_edit(1))) + + self.assertEqual(instance.to_dict(), as_dict.to_dict()) + + +if __name__ == "__main__": + unittest.main() diff --git a/hastelib/tests/workflows/test_prepare_prediction_tiles_versions.py b/hastelib/tests/workflows/test_prepare_prediction_tiles_versions.py new file mode 100644 index 00000000..023890d6 --- /dev/null +++ b/hastelib/tests/workflows/test_prepare_prediction_tiles_versions.py @@ -0,0 +1,358 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for the backfill half of the prediction-tiles workflow. + +The workflow rebuilds the attribute sidecar of every edited version the +processor listed in ``config["versions"]``. Two properties matter: + +* the sidecar is built from the VERSION's GeoPackage, so its classes are + the analyst's and not the model's; +* one unreadable revision is reported, not fatal — the model's own + sidecar and the layer's tiles still have to reach storage, and the + next preparation request retries whatever is still missing. +""" + +import json +import os +import shutil +import tempfile +import unittest + +import fiona +from hastegeo.workflows import prepare_prediction_tiles as ppt +from shapely.geometry import Polygon, mapping + +FOOTPRINT_SCHEMA = { + "geometry": "Polygon", + "properties": {"id": "str", "subtype": "str", "class": "str"}, +} +TRAINED_SCHEMA = { + "geometry": "Polygon", + "properties": { + "id": "int", + "damage_pct_0m": "float", + "damaged": "int", + "unknown_pct": "float", + }, +} +EDITED_SCHEMA = dict( + TRAINED_SCHEMA, + properties=dict( + TRAINED_SCHEMA["properties"], + edited_class="str", + edit_threshold="float", + overture_id="str", + ), +) + + +def _square(index: int) -> Polygon: + x = -122.0 + index * 0.001 + y = 47.0 + index * 0.001 + return Polygon( + [(x, y), (x + 0.0001, y), (x + 0.0001, y + 0.0001), (x, y + 0.0001)] + ) + + +def write_footprints(path: str, count: int) -> str: + with fiona.open( + path, "w", driver="GPKG", crs="EPSG:4326", schema=FOOTPRINT_SCHEMA + ) as dst: + for index in range(count): + dst.write( + { + "geometry": mapping(_square(index)), + "properties": { + "id": f"overture-{index}", + "subtype": "residential", + "class": "house", + }, + } + ) + return path + + +def write_predictions(path: str, damages: list) -> str: + with fiona.open( + path, "w", driver="GPKG", crs="EPSG:4326", schema=TRAINED_SCHEMA + ) as dst: + for index, damage in enumerate(damages): + dst.write( + { + "geometry": mapping(_square(index)), + "properties": { + "id": index, + "damage_pct_0m": damage, + "damaged": 1 if damage >= 0.5 else 0, + "unknown_pct": 0.0, + }, + } + ) + return path + + +def write_edited(path: str, damages: list, classes: list) -> str: + with fiona.open( + path, "w", driver="GPKG", crs="EPSG:4326", schema=EDITED_SCHEMA + ) as dst: + for index, damage in enumerate(damages): + dst.write( + { + "geometry": mapping(_square(index)), + "properties": { + "id": index, + "damage_pct_0m": damage, + "damaged": 1 if classes[index] == "Damaged" else 0, + "unknown_pct": 0.0, + "edited_class": classes[index], + "edit_threshold": 0.8, + "overture_id": f"overture-{index}", + }, + } + ) + return path + + +class BackfillFixture(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix="haste-backfill-") + self.addCleanup(shutil.rmtree, self.tmpdir, True) + self.output_dir = os.path.join(self.tmpdir, "outputs") + os.makedirs(self.output_dir) + self.footprints = write_footprints( + os.path.join(self.tmpdir, "footprints.gpkg"), 3 + ) + self.predictions = write_predictions( + os.path.join(self.tmpdir, "predictions.gpkg"), [0.0, 0.6, 0.9] + ) + # v1 raised the threshold to 0.8; v2 also cleared the last row. + self.v1 = write_edited( + os.path.join(self.tmpdir, "edited_v1.gpkg"), + [0.0, 0.6, 0.9], + ["NotDamaged", "NotDamaged", "Damaged"], + ) + self.v2 = write_edited( + os.path.join(self.tmpdir, "edited_v2.gpkg"), + [0.0, 0.6, 0.9], + ["Unknown", "NotDamaged", "NotDamaged"], + ) + + def read_output(self, filename: str) -> dict: + with open(os.path.join(self.output_dir, filename)) as handle: + return json.load(handle) + + def _config(self, versions: list) -> dict: + return { + "project_id": "proj-1", + "image_layer_id": "layer-1", + "model_id": "model-1", + "files": { + "footprints": self.footprints, + "predictions": self.predictions, + }, + "tiles": {"build_pmtiles": False}, + "store_artifacts": False, + "versions": versions, + } + + def _version_entry(self, version: int, path: str) -> dict: + return { + "version": version, + "predictions": path, + "attrs": f"prediction_attrs_model-1_v{version}.json", + } + + +class TestBuildVersionAttrs(BackfillFixture): + def test_builds_one_sidecar_per_version(self): + records = ppt.build_version_attrs( + [ + self._version_entry(1, self.v1), + self._version_entry(2, self.v2), + ], + self.footprints, + self.output_dir, + "model-1", + ) + + self.assertEqual([record["version"] for record in records], [1, 2]) + self.assertEqual( + [record["filename"] for record in records], + [ + "prediction_attrs_model-1_v1.json", + "prediction_attrs_model-1_v2.json", + ], + ) + self.assertEqual([record["n"] for record in records], [3, 3]) + self.assertEqual([record["error"] for record in records], ["", ""]) + + def test_each_sidecar_describes_its_own_version(self): + ppt.build_version_attrs( + [ + self._version_entry(1, self.v1), + self._version_entry(2, self.v2), + ], + self.footprints, + self.output_dir, + "model-1", + ) + + first = self.read_output("prediction_attrs_model-1_v1.json") + second = self.read_output("prediction_attrs_model-1_v2.json") + + self.assertEqual( + first["classes"], ["NotDamaged", "NotDamaged", "Damaged"] + ) + self.assertEqual( + second["classes"], ["Unknown", "NotDamaged", "NotDamaged"] + ) + self.assertNotEqual(first["damaged"], second["damaged"]) + + def test_default_artifact_name_is_used_when_absent(self): + records = ppt.build_version_attrs( + [{"version": 3, "predictions": self.v1}], + self.footprints, + self.output_dir, + "model-1", + ) + + self.assertEqual( + records[0]["filename"], "prediction_attrs_model-1_v3.json" + ) + self.assertEqual( + ppt.default_version_attrs_name("model-1", 3), + "prediction_attrs_model-1_v3.json", + ) + + def test_missing_input_is_reported_not_raised(self): + records = ppt.build_version_attrs( + [ + self._version_entry( + 1, os.path.join(self.tmpdir, "missing.gpkg") + ), + self._version_entry(2, self.v2), + ], + self.footprints, + self.output_dir, + "model-1", + ) + + self.assertEqual(records[0]["filename"], "") + self.assertIn("not found", records[0]["error"]) + # The healthy version is still built. + self.assertEqual( + records[1]["filename"], "prediction_attrs_model-1_v2.json" + ) + + def test_row_count_mismatch_is_reported_per_version(self): + short = write_footprints( + os.path.join(self.tmpdir, "short_footprints.gpkg"), 2 + ) + + records = ppt.build_version_attrs( + [self._version_entry(1, self.v1)], + short, + self.output_dir, + "model-1", + ) + + self.assertEqual(records[0]["filename"], "") + self.assertIn("mismatch", records[0]["error"]) + + def test_entries_without_a_version_are_skipped(self): + records = ppt.build_version_attrs( + [{"predictions": self.v1}, self._version_entry(1, self.v1)], + self.footprints, + self.output_dir, + "model-1", + ) + + self.assertEqual([record["version"] for record in records], [1]) + + def test_no_versions_is_a_no_op(self): + self.assertEqual( + ppt.build_version_attrs( + [], self.footprints, self.output_dir, "model-1" + ), + [], + ) + + +class TestRunBackfillsVersions(BackfillFixture): + def test_manifest_lists_the_versions_it_built(self): + manifest = ppt.run( + self._config([self._version_entry(1, self.v1)]), self.output_dir + ) + + self.assertEqual(len(manifest["version_attrs"]), 1) + record = manifest["version_attrs"][0] + self.assertEqual(record["version"], 1) + self.assertEqual( + record["filename"], "prediction_attrs_model-1_v1.json" + ) + self.assertTrue( + os.path.exists(os.path.join(self.output_dir, record["filename"])) + ) + + def test_model_sidecar_is_still_built(self): + manifest = ppt.run( + self._config([self._version_entry(1, self.v1)]), self.output_dir + ) + + self.assertEqual( + manifest["attrs_filename"], "prediction_attrs_model-1.json" + ) + model_attrs = self.read_output("prediction_attrs_model-1.json") + # The model-level sidecar keeps the RAW classes... + self.assertEqual(model_attrs["damaged"], [0, 1, 1]) + self.assertNotIn("classes", model_attrs) + # ...while the version's own reflects the analyst's threshold. + version_attrs = self.read_output("prediction_attrs_model-1_v1.json") + self.assertEqual(version_attrs["damaged"], [0, 0, 1]) + + def test_config_without_versions_still_runs(self): + manifest = ppt.run(self._config([]), self.output_dir) + + self.assertEqual(manifest["version_attrs"], []) + self.assertEqual(manifest["building_count"], 3) + + def test_absent_versions_key_is_treated_as_none(self): + config = self._config([]) + del config["versions"] + + manifest = ppt.run(config, self.output_dir) + + self.assertEqual(manifest["version_attrs"], []) + + def test_a_broken_version_does_not_fail_the_job(self): + manifest = ppt.run( + self._config( + [ + self._version_entry( + 1, os.path.join(self.tmpdir, "missing.gpkg") + ) + ] + ), + self.output_dir, + ) + + self.assertTrue(manifest["version_attrs"][0]["error"]) + self.assertIsNone(manifest["version_attrs"][0]["url"]) + # The model sidecar survived the broken revision. + self.assertTrue( + os.path.exists( + os.path.join(self.output_dir, manifest["attrs_filename"]) + ) + ) + + def test_manifest_on_disk_matches_the_return_value(self): + manifest = ppt.run( + self._config([self._version_entry(2, self.v2)]), self.output_dir + ) + + self.assertEqual(self.read_output(ppt.MANIFEST_FILENAME), manifest) + + +if __name__ == "__main__": + unittest.main() diff --git a/spec/architecture/decisions/0005-versioned-derived-prediction-artifacts.md b/spec/architecture/decisions/0005-versioned-derived-prediction-artifacts.md index a8f99d0c..63dcda9c 100644 --- a/spec/architecture/decisions/0005-versioned-derived-prediction-artifacts.md +++ b/spec/architecture/decisions/0005-versioned-derived-prediction-artifacts.md @@ -10,160 +10,165 @@ Prediction editing needs analysts to save corrected building-level prediction outputs without losing the raw model result. HASTE's raw prediction pointer is -`Model.gpkgUrl`; overwriting that pointer or blob would remove the provenance -needed to compare model output with analyst edits and would be especially risky -because artifact writes can overwrite same-named blobs in the storage layer. - -The implemented prediction-editing design writes edited GeoPackages as derived -artifacts and records a numbered metadata entry for each save. The raw -`Model.gpkgUrl` remains the producer output. There is no stored mutable -"current edited version" pointer. Instead, readers that support edited versions -call `resolve_prediction_source(model, version=None)`: omitted `version` resolves -to the newest edited artifact when one exists, `version=0` forces the raw output, -and an explicit positive version resolves that exact edited artifact -(`hastelib/src/hastegeo/core/utils/predictions.py:332-401`). - -That resolver is now used by `GetVisualizerResults`, `GetValidationReport`, and -`GetAssessmentReport`, each with an optional `version` query parameter. The -visualizer payload reports which version is on the map and lists available -versions, but the current UI version history is read-only: choosing an older -version in the panel does not refetch the map yet -(`api/hastefuncapi/function_app.py:2296-2435`, -`api/hastefuncapi/function_app.py:4607-4688`, -`api/hastefuncapi/function_app.py:4929-5027`, -`ui/src/Components/Visualizer/PredictionEditPanel.jsx:513-550`). +`Model.gpkgUrl`; overwriting that pointer or blob would remove provenance and +would be risky because artifact writes can overwrite same-named blobs. + +The established design writes edited GeoPackages as derived artifacts and +records a numbered metadata entry for each save. The raw `Model.gpkgUrl` remains +the producer output. There is no stored mutable "current edited version" pointer; +readers resolve raw, newest, or explicit versions through the version contract +(`api/hastefuncapi/function_app.py:2307-2340`, +`api/hastefuncapi/function_app.py:2386-2397`). + +The View Results map now needs to select and download individual versions. The +map does not read classes from the GeoPackage directly; it renders PMTiles +geometry colored by a compact prediction-attribute sidecar. That sidecar was +keyed per model, `prediction_attrs_${modelId}`, and described only raw +predictions (`hastelib/src/hastegeo/core/config.py:172`, +`hastelib/src/hastegeo/core/models/projects.py:529-535`). The current API save route writes a new edited GeoPackage and appends metadata, +but still needs to adopt the shared save helper that stores the matching sidecar +(`api/hastefuncapi/function_app.py:3302-3325`, +`hastelib/src/hastegeo/core/processors/prediction_edits.py:520-608`). Selecting an edited GPKG +without a matching sidecar would silently render raw classes. + +The sidecar builder now lives in `hastegeo.core.utils.prediction_attrs`, and the +training-image workflow imports it (`hastelib/src/hastegeo/core/utils/prediction_attrs.py:128-202`, +`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:77-92`). This lets +the Functions app build the sidecar in the save path without importing workflow +code that belongs to tile preparation. ## Options Considered ### Option A: Overwrite `Model.gpkgUrl` in place -- **Pros:** Smallest data-model change; all current consumers would immediately - see analyst edits without new parameters. -- **Cons:** Destroys the raw model output, loses auditability, makes it hard to - compare model vs analyst decisions, and is unsafe because artifact storage can - overwrite same-named blobs. -- **Impact on HASTE components:** Minimal code change, but high behavioral risk - across reports, validation, publishing, downloads, and visualizer rendering. +- **Pros:** Smallest data-model change; all consumers immediately see edits. +- **Cons:** Destroys raw output, loses auditability, and makes downloads/reports + impossible to trace back to producer output. +- **Impact on HASTE components:** Minimal code change but high behavioral risk. ### Option B: Use Azure Blob snapshots for edited outputs -- **Pros:** Keeps physical versions close to the source blob; relies on Azure - Storage features rather than new metadata structures. -- **Cons:** Couples HASTE semantics to one storage backend, snapshots are not a - clear user-facing version history, SAS/download flows become harder to reason - about, and the metadata store still needs to know which snapshot is an edited - prediction. -- **Impact on HASTE components:** Requires storage-layer snapshot support and - new API logic to list and authorize snapshots; weak fit for local/Azurite and - any future non-Blob artifact storage. +- **Pros:** Keeps physical versions near the source blob. +- **Cons:** Couples semantics to Blob snapshots, still needs user-facing + metadata, and complicates authorization/download behavior. +- **Impact on HASTE components:** Storage-specific API and UI changes. ### Option C: Introduce a generic artifact registry -- **Pros:** Solves versioning for all artifact types, can model provenance and - lifecycle uniformly, and could support future publishing/report selection. -- **Cons:** Large architecture change for a focused editing feature; requires - new metadata schemas, migrations, APIs, UI patterns, and rollout planning - beyond the current scope. -- **Impact on HASTE components:** Broad changes across `hastelib`, API, UI, - storage, and downstream consumers; higher schedule and migration risk. +- **Pros:** Uniform lifecycle and provenance model for all artifacts. +- **Cons:** Large architecture change beyond prediction editing. +- **Impact on HASTE components:** Broad schema, API, migration, and UI work. ### Option D: Store a numbered edited-version list on the Model document (Chosen) -- **Pros:** Preserves raw `Model.gpkgUrl`, gives analysts a simple version - history, uses unique blob artifact names, avoids new containers, and lets - readers select raw/newest/explicit versions without a mutable active pointer. -- **Cons:** Model documents grow with each save; version history is scoped to - prediction editing rather than a reusable artifact registry; the current - implementation does not yet protect concurrent saves when assigning the next - number. -- **Impact on HASTE components:** Adds optional Model fields, new artifact-type - templates, API additions, a source resolver, report/visualizer version - support, and UI version-list rendering. +- **Pros:** Preserves raw `Model.gpkgUrl`, gives analysts a simple history, uses + versioned artifact names, and avoids mutable active-version state. +- **Cons:** Model documents grow with each save, concurrent saves still need + stronger version allocation, and sidecar consistency must be enforced. +- **Impact on HASTE components:** Adds Model fields, artifact templates, source + resolution, visualizer/report version support, and UI version controls. ### Option E: Store an `activeEditedPredictionVersion` pointer -- **Pros:** Lets users switch the default edited version without changing every - reader URL. -- **Cons:** Introduces mutable global state on the Model document; report and - visualizer results could change after a pointer update even when callers did - not ask for a different artifact; races and audit semantics become harder. -- **Impact on HASTE components:** Requires write APIs and UI for switching the - active pointer, plus stronger concurrency controls. This is not implemented. +- **Pros:** Lets users switch a global default without passing query parameters. +- **Cons:** Introduces mutable global state; reports and maps could change after + a pointer update even when callers did not ask for a different artifact. +- **Impact on HASTE components:** Requires write APIs, conflict handling, and + more audit semantics. This remains rejected. ## Decision Adopt **Option D: a numbered edited-version list on the Model document** and -reject a mutable active-version pointer. +reject a mutable active-version pointer. This ADR is amended to include +versioned sidecars as derived data for each edited version. -Each prediction-edit save writes a new immutable-by-convention blob named from +Each prediction-edit save writes a new GeoPackage named from `EDITED_PREDICTIONS_GPKG = Template("edited_predictions_${modelId}_v${version}")` -and appends one `EditedPredictionVersion` entry to `Model.editedPredictions`. -The displayed version names are `edit_v1`, `edit_v2`, and so on, derived from -the numeric `version` field. The raw prediction remains in `Model.gpkgUrl` and -must not be mutated by the edit flow. - -`EditedPredictionVersion` stores `version`, `gpkgUrl`, `createdAt`, `createdBy`, -`threshold`, `unknownThreshold`, `editedCount`, and `sourceGpkgUrl`. The API -allocates the next version from the current Model document, writes the blob under -that versioned artifact name, and appends metadata. The implemented v1 does -**not** include the proposed 409 conflict response for simultaneous saves: -`next_version` plus metadata save is currently a read-modify-write without -optimistic concurrency, so a follow-up must add ETag, lease, or retry-safe -allocation before multi-analyst collision safety is guaranteed. - -Readers use `resolve_prediction_source` rather than a persisted active pointer. -By default, `GetVisualizerResults`, `GetValidationReport`, and -`GetAssessmentReport` use the newest edited version when one exists. Callers can -request `version=0` for the raw model output or `version=N` for an explicit -edited artifact; the public API contract documents these query parameters and -the visualizer response fields (`docs/api/hastefuncapi.md:78-157`, -`docs/api/hastefuncapi.md:480-502`). - -The separate `PutPreparePredictionTilesQueueMessage` route affects only PMTiles -and prediction-attribute preparation; it does not change this artifact-versioning -decision. PMTiles and sidecars are derived artifacts used by the vector-first -results viewer, while edited GeoPackage versions remain the durable analyst -outputs. +and appends one `EditedPredictionVersion` entry. The raw prediction remains in +`Model.gpkgUrl` and must not be mutated by the edit flow (`api/hastefuncapi/function_app.py:3202-3204`). + +Each saved version must also write a matching prediction-attribute sidecar named +`prediction_attrs_${modelId}_v${version}`. `EditedPredictionVersion` records the +sidecar URL next to `gpkgUrl`. The raw/model-scoped sidecar +`prediction_attrs_${modelId}` remains the raw-output sidecar. Sidecars are +derived artifacts, but a versioned sidecar must be written in the same call path +as its GeoPackage; if the sidecar cannot be generated or stored, the version must +not be advertised as selectable. Otherwise the map could show classes from one +artifact while downloads provide another. + +Keep `build_prediction_attrs` and `write_prediction_attrs` in +`hastegeo.core.utils` so the Functions save path, queue prep, and backfill use +one implementation. The prediction-tiles workflow imports and re-exports those +helpers (`hastelib/src/hastegeo/core/utils/prediction_attrs.py:128-202`, +`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:77-92`). + +`GetVisualizerResults` supports `version`: omitted selects newest edited (or raw +fallback), `version=0` selects raw, and positive `version=N` selects that saved +version. The response returns the selected version's `predictionAttrsUrl`, the +selected `predictionVersion`, and an `isNewestPredictionVersion` flag. Unknown +positive versions return 404 and malformed versions return 400. + +Version selection changes **only the map**. Assessment and Validation report +buttons continue to omit the selector version and therefore use their existing +newest-edited default. The accepted trade-off is that the map can show v2 while +a report reflects v3; the UI must state this clearly instead of letting users +discover it (`api/hastefuncapi/function_app.py:4607-4688`, +`api/hastefuncapi/function_app.py:4929-5027`). + +Downloads are exposed in two places: beside the View Results selector and on +each edit-panel version-history row. New version downloads route through +`GetModelArtifact` with `kind=gpkg&version=` rather than direct blob or +SAS URL rewriting, preserving existing auth, managed identity, Range, and +content-disposition handling (`api/hastefuncapi/function_app.py:1430-1570`, +`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:61-69`). + +Pre-existing edited versions are backfilled once through the prediction-tiles job +instead of generated lazily on first selection. Backfill is idempotent and skips +versions that already have sidecars. Dev models `0448` v1 and `5553` v1 are the +known initial backfill targets. ### Components Affected | Component | Path | Change | |---|---|---| -| Model metadata | `hastelib/src/hastegeo/core/models/projects.py` | Add `EditedPredictionVersion` and `Model.editedPredictions`; preserve raw `gpkgUrl`. | -| Artifact naming | `hastelib/src/hastegeo/core/config.py` | Add `EDITED_PREDICTIONS_GPKG` and prediction prep artifact templates. | -| Prediction editing processor | `hastelib/src/hastegeo/core/processors/prediction_edits.py` | Allocate versions, write edited GeoPackages, and append metadata. | -| Prediction source resolver | `hastelib/src/hastegeo/core/utils/predictions.py` | Resolve newest edited, raw, or explicit edited source without a mutable pointer. | -| REST API | `api/hastefuncapi/function_app.py` | Add save/list/prep endpoints; update visualizer, validation, and assessment readers to accept `version`. | -| React UI | `ui/src/Components/Visualizer/` | Render vector-first results and edit mode on the existing Visualizer page; show read-only version history. | +| Model metadata | `hastelib/src/hastegeo/core/models/projects.py` | Extend `EditedPredictionVersion` with `predictionAttrsUrl`; keep no active pointer. | +| Artifact naming | `hastelib/src/hastegeo/core/config.py` | Keep raw `PREDICTION_ATTRS`; add `prediction_attrs_${modelId}_v${version}`. | +| Sidecar utilities | `hastelib/src/hastegeo/core/utils/` | Own shared sidecar build/write helpers. | +| Prediction editing processor/API | `hastelib/src/hastegeo/core/processors/`, `api/hastefuncapi/function_app.py` | Save GeoPackage + sidecar together and append metadata only after both are ready. | +| Prediction tiles/backfill | `hastelib/src/hastegeo/core/processors/prediction_tiles.py`, `api/hastefuncqueues/function_app.py` | Backfill missing version sidecars idempotently (`hastelib/src/hastegeo/core/processors/prediction_tiles.py:148-165`, `hastelib/src/hastegeo/core/processors/prediction_tiles.py:304-340`). | +| REST API | `api/hastefuncapi/function_app.py` | Add version-aware artifact downloads and visualizer sidecar selection; reports keep newest default. | +| React UI | `ui/src/Components/Visualizer/` | Selector, disabled missing-sidecar state, map-only warning, dual-pane switching, and downloads. | ### Azure Services Affected | Service | Change | |---|---| -| Cosmos DB | Existing Model documents gain an optional embedded version list and optional prep/readiness metadata. | -| Blob Storage | Stores one edited GeoPackage blob per version plus PMTiles and sidecar derived artifacts. | -| Azure Functions | New HTTP save/list/prep operations read and update Model metadata; existing visualizer/report operations resolve versions. | -| Azure Queue / Batch | Prep messages and jobs generate PMTiles and prediction attribute sidecars; edited versioning itself remains HTTP + Blob/Cosmos. | +| Cosmos DB | Existing Model documents gain optional `predictionAttrsUrl` inside edited-version entries. | +| Blob Storage | Stores one versioned sidecar per edited GeoPackage version. | +| Azure Functions | Save and artifact routes resolve versioned sidecars/downloads. | +| Azure Queue / Batch | Prediction-edit prep job backfills historical version sidecars. | ## Consequences -- **Easier:** Analysts can save multiple reviewed outputs; engineers can reason - about raw vs edited provenance; rollback does not require restoring raw blobs; - visualizer, validation, and assessment callers can choose raw/newest/explicit - sources with the same `version` contract. -- **Harder:** A Model document can grow over time, concurrent saves still need - protection around version allocation, and the UI does not yet provide wired - version switching even though the payload lists available versions. -- **New constraints:** The edit flow must never write edited data to - `Model.gpkgUrl`; every edited artifact name must include the assigned version; - source selection must go through `resolve_prediction_source`; readers must use - `version=0` when they need the raw producer output. -- **Known semantic gap:** `GetValidationReport` reads edited `damaged`, so edits - move its metrics. `GetAssessmentReport` opens the selected GeoPackage but - still thresholds the producer's preserved `damage_pct_0m`, so per-building - overrides do not move assessment counts until a follow-up decision changes the - assessment contract. +- **Easier:** The map renders raw and edited versions through one code path; + downloads are authenticated through one route; raw output stays auditable. +- **Harder:** Save must coordinate two derived artifacts, historical versions + need backfill, and the UI must explain the map/report split. +- **New constraints:** Do not advertise a version without both `gpkgUrl` and + `predictionAttrsUrl`; do not generate sidecars in GET/read handlers; do not + introduce an active-version pointer. +- **Accepted trade-off:** The map can show raw or v2 while reports use newest + v3. This is intentional and must be explicit in the UI. +- **Backfill window:** Before backfill completes, pre-existing versions cannot + be selected. The selector must disable them and say why. +- **Swipe-map constraint:** Both panes must switch together because feature-state + is per renderer; a partial switch leaves stale colors on one side. +- **Known semantic gap:** Validation reads edited `damaged`, but Assessment + counts still threshold preserved `damage_pct_0m`; version selection does not + solve that. +- **Known concurrency gap:** No 409/ETag handling for concurrent saves is added + by this ADR amendment. - **Impact on Docker Compose local dev stack:** No new storage service; local - Azurite must hold additional edited GeoPackage blobs, PMTiles, and sidecars. -- **Impact on CI/CD workflows:** No workflow change expected unless additional - automated test jobs are added later. + Azurite stores additional versioned sidecar blobs. +- **Impact on CI/CD workflows:** No workflow change expected unless automated + browser/Playwright tests are added later. diff --git a/spec/features/prediction-editing/README.md b/spec/features/prediction-editing/README.md index 41f02fd6..124f44ca 100644 --- a/spec/features/prediction-editing/README.md +++ b/spec/features/prediction-editing/README.md @@ -11,128 +11,98 @@ ## Summary -Prediction editing is now a **mode inside the existing View Results page**, not -a standalone screen. Analysts open `/visualizer/:projectId/:imageLayerId/:modelId` -from the Results menu, then enter edit mode with the pencil next to Back or the -`E` shortcut; Done or `E` exits, with a discard-confirmation dialog for unsaved -edits (`ui/src/Components/AppBody.jsx:73-75`, -`ui/src/Components/Visualizer/Labels.jsx:8-12`, +Prediction editing remains a **mode inside the existing View Results page**, not +a standalone screen. Analysts open `/visualizer/:projectId/:imageLayerId/:modelId`, +enter edit mode with the pencil next to Back or the `E` shortcut, and save +append-only edited prediction GeoPackages as `edit_v1`, `edit_v2`, and later +versions without mutating raw `Model.gpkgUrl` (`ui/src/Components/AppBody.jsx:73-75`, `ui/src/Components/Visualizer/Labels.jsx:117-128`, -`ui/src/Components/Visualizer/Visualizer.jsx:457-605`). - -The View Results page is vector-first for both prediction workflows. It draws -predicted building footprints from footprint PMTiles plus the prediction -attribute sidecar, artifacts both trained inference and embedding models can -provide; trained-inference rasters remain optional overlays and are nullable in -the payload (`hastelib/src/hastegeo/core/processors/visualizer.py:4-29`, -`hastelib/src/hastegeo/core/processors/visualizer.py:278-331`, -`hastelib/src/hastegeo/core/models/visualizer.py:45-82`). The embedding model -row now exposes View Results as the first Results menu action, so the embedding -workflow has a working results-viewer entry point -(`ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:116-130`). - -Each save still creates a new, numbered edited prediction GeoPackage (`edit_v1`, -`edit_v2`, …) as a derived artifact. The raw model output remains in -`Model.gpkgUrl`, while `GetVisualizerResults`, `GetValidationReport`, and -`GetAssessmentReport` default to the newest saved edit and accept an optional -`version` query parameter; `version=0` forces the raw output -(`hastelib/src/hastegeo/core/utils/predictions.py:332-401`, -`api/hastefuncapi/function_app.py:2386-2435`, -`api/hastefuncapi/function_app.py:4677-4688`, -`api/hastefuncapi/function_app.py:5017-5027`). +`api/hastefuncapi/function_app.py:3186-3325`). + +This extension adds version selection and per-version downloads to View Results. +The selector changes **only the map**: Assessment and Validation reports continue +to read the newest edited version, even when the map is showing raw or an older +version. That trade-off is intentional to avoid adding an active-version pointer; +the UI must state the mismatch whenever the selected map version is not newest +(`api/hastefuncapi/function_app.py:4607-4688`, +`api/hastefuncapi/function_app.py:4929-5027`). + +The architectural fix is versioned prediction-attribute sidecars. The current +sidecar is model-scoped (`prediction_attrs_${modelId}`) and describes only raw +predictions (`hastelib/src/hastegeo/core/config.py:172`, +`hastelib/src/hastegeo/core/models/projects.py:529-535`). Each saved edited +GeoPackage must now get a matching derived sidecar named +`prediction_attrs_${modelId}_v${version}` and recorded on its +`EditedPredictionVersion`. Rendering a saved version then uses the same vector +code path as raw rendering, with different `GetModelArtifact` URLs. ## Motivation -- Disaster analysts need a fast way to correct false positives, false negatives, - and ambiguous buildings before handing outputs to response partners. -- The previous raster-only viewer could draw only the `_visualizer.tif` and - `_predictions.tif` COGs produced by trained inference. Embedding predictions - produce no raster, so vector PMTiles plus the attribute sidecar are now the - shared results path (`hastelib/src/hastegeo/core/processors/visualizer.py:4-29`, - `ui/src/Components/Visualizer/Visualizer.jsx:13-28`). -- Three call sites previously answered "does this model have results" from - different fields. `hastegeo.core.utils.model_readiness` is now the single - server-side rule, exposed as `predictionsReady` on model payloads and reused - by publishing (`hastelib/src/hastegeo/core/utils/model_readiness.py:4-25`, - `api/hastefuncapi/function_app.py:785-788`, - `api/hastefuncapi/function_app.py:1262-1266`, - `api/hastefuncapi/function_app.py:1380-1383`, - `hastelib/src/hastegeo/core/publishing/source.py:116-124`). -- `GetBuildingFootprintsGeoJSON` remains a sampled preview path, not an editing - data path. Editing requires the complete footprint PMTiles and sidecar route - (`api/hastefuncapi/function_app.py:1400-1424`, - `api/hastefuncapi/function_app.py:1453-1458`). -- HASTE still has no generic artifact versioning: edited outputs must be - numbered derived artifacts rather than overwriting raw model outputs - (`hastelib/src/hastegeo/core/models/projects.py:343-385`, - `hastelib/src/hastegeo/core/processors/prediction_edits.py:1-19`, - `hastelib/src/hastegeo/core/processors/prediction_edits.py:329-422`). +- Analysts need to compare raw and edited outputs on the map, then download the + exact GeoPackage version they intend to share. +- The vector viewer colors PMTiles from a compact JSON sidecar, not from the + GeoPackage directly. Without a per-version sidecar, selecting an edited GPKG + would silently render raw classes (`hastelib/src/hastegeo/core/utils/prediction_attrs.py:128-202`). +- Downloads should use `GetModelArtifact` so authentication, managed identity, + and HTTP Range behavior stay centralized instead of relying on direct SAS URL + rewriting (`api/hastefuncapi/function_app.py:1430-1570`, + `ui/src/Components/ProjectManagement/ModelResultsButton.jsx:61-69`). +- Pre-existing edited versions need a one-time backfill. The read path must stay + free of sidecar generation logic, so the selector disables versions whose + sidecar has not been generated yet. ## Success Criteria -- [ ] Trained and embedding model rows expose **View** as the Results menu entry - point, enabled from server-derived `predictionsReady` with legacy - fallbacks; there are no model-row Edit buttons or `/edit-predictions/...` - route (`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:87-110`, - `ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:85-130`, - `ui/src/Components/AppBody.jsx:73-75`). -- [ ] `GetVisualizerResults` returns the vector artifacts and readiness for both - workflows, while `predictedDamageLayer` and `predictionsLayer` are nullable - trained-inference-only overlays; the full response shape remains aligned - with `docs/api/hastefuncapi.md` (`docs/api/hastefuncapi.md:78-157`). -- [ ] Opening View Results for an embedding model renders a usable 200 payload - and predicted footprints rather than an empty raster-only page - (`hastelib/tests/core/processors/test_visualizer_payload.py:222-268`). -- [ ] The results page loads all predicted footprints through PMTiles and the - prediction attribute sidecar; missing PMTiles or attributes are requested - through the explicit prep PUT route and generated by a queued job, not by - the GET handler (`ui/src/Components/Visualizer/usePredictionArtifacts.js:4-24`, - `ui/src/Components/Visualizer/usePredictionArtifacts.js:224-299`, - `hastelib/src/hastegeo/core/processors/prediction_tiles.py:251-370`). -- [ ] Analysts can enter edit mode with the pencil or `E`, click individual - buildings, ctrl+drag box-select groups, set `Damaged`, `NotDamaged`, or - `Unknown`, and leave through Done/`E` with unsaved-edits confirmation - (`ui/src/Components/Visualizer/Visualizer.jsx:496-605`, - `ui/src/Components/Visualizer/usePredictionFootprints.js:313-376`, - `ui/src/Components/keyboardShortcuts.js:60-80`). -- [ ] Trained-inference models show live damage/unknown threshold sliders using - `damage_pct_0m`; embedding models hide the sliders because their - `damage_pct_0m` values are a degenerate 0.0/1.0 copy of `damaged` - (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:346-397`, - `api/hastefuncapi/function_app.py:2738-2815`). -- [ ] Saving creates `edit_v1`, `edit_v2`, … without mutating `Model.gpkgUrl` or - the raw model output; the written edited GeoPackage preserves row order - and adds `edited_class`, `edit_threshold`, and `overture_id` - (`api/hastefuncapi/function_app.py:3181-3345`, - `hastelib/src/hastegeo/core/processors/prediction_edits.py:226-308`). -- [ ] Saved versions are visible in the edit panel and the payload reports which - version is on the map. Version switching in the UI is **not** wired yet: - the history rows are read-only and the visualizer fetch does not append a - `version` parameter (`ui/src/Components/Visualizer/Visualizer.jsx:213-223`, - `ui/src/Components/Visualizer/PredictionEditPanel.jsx:513-550`). -- [ ] Validation and assessment/report readers can see edited versions through - `resolve_prediction_source`; `version=0` forces raw. The known asymmetry is - documented: validation reads edited `damaged`, while assessment thresholds - the preserved `damage_pct_0m` and therefore ignores per-building overrides - for its threshold-based counts (`api/hastefuncapi/function_app.py:4808-4827`, - `hastelib/src/hastegeo/core/utils/assessment.py:150-190`, - `docs/api/hastefuncapi.md:480-502`). +- [ ] Saving an edited version writes both + `edited_predictions_${modelId}_v${version}.gpkg` and + `prediction_attrs_${modelId}_v${version}` in the same call path, then + appends one `EditedPredictionVersion` with both URLs. +- [ ] `build_prediction_attrs` and `write_prediction_attrs` live in + `hastegeo.core.utils` so the Functions app can build sidecars without + importing the training-image workflow that previously held them + (`hastelib/src/hastegeo/core/utils/prediction_attrs.py:128-202`). +- [ ] `GetModelArtifact` accepts optional `version` for `kind=gpkg` and + `kind=prediction_attrs`; `version=0` returns raw output, positive versions + return edited artifacts, and unknown versions return 404 + (`api/hastefuncapi/function_app.py:1400-1570`). +- [ ] `GetVisualizerResults?version=N` returns the selected version's + `predictionAttrsUrl`, `predictionVersion`, and `isNewestPredictionVersion` + flag. Omitting `version` keeps the default newest-edited map behavior. +- [ ] The View Results version selector refetches the map only. It does not + change Assessment or Validation report inputs; the UI states when the map + and reports can disagree. +- [ ] Both swipe panes switch together when the selected version changes. Feature + state is per renderer, so source, sidecar, class cache, and repaint state + must update for both panes in one transition + (`ui/src/Components/Visualizer/usePredictionFootprints.js:19-25`, + `ui/src/Components/Visualizer/usePredictionFootprints.js:212-228`). +- [ ] Download buttons appear beside the View Results selector and on each edit + panel history row. They use `GetModelArtifact?kind=gpkg&version=N` instead + of direct blob/SAS URL rewriting. +- [ ] The prediction-tiles job has an idempotent backfill mode that builds + missing per-version sidecars and skips versions already carrying a sidecar. + Dev models `0448` v1 and `5553` v1 are the known initial backfill targets. +- [ ] During the backfill window, versions without sidecars are visible but + disabled in the selector with an explanation rather than rendering an + empty or raw-colored map. +- [ ] Known out-of-scope gaps remain documented: no concurrent-save 409, no + Playwright coverage, and Assessment counts still threshold preserved + `damage_pct_0m` even though Validation reads edited `damaged`. ## HASTE Components Affected | Component | Impact | |---|---| -| `hastelib/src/hastegeo/core/models/` | add `EditedPredictionVersion`; add `Model.editedPredictions`, `Model.predictedBuildingCount`, `Model.predictedAt`, `Model.predictionAttrsUrl`, `Model.predictionTilesJob`, `Model.predictionTilesStatus`, `Model.predictionTilesStatusMessage`, and `ImageLayer.footprintPmtilesUrl`; add visualizer payload fields for vector artifacts, readiness, flavor, building count, and versions (`hastelib/src/hastegeo/core/models/projects.py:343-505`, `hastelib/src/hastegeo/core/models/projects.py:520-529`, `hastelib/src/hastegeo/core/models/projects.py:842-851`, `hastelib/src/hastegeo/core/models/visualizer.py:45-82`) | -| `hastelib/src/hastegeo/core/config.py` | add artifact templates for edited prediction GeoPackages, prediction attributes, and layer footprint PMTiles; add the prediction-edit prep queue config (`hastelib/src/hastegeo/core/config.py:112-118`, `hastelib/src/hastegeo/core/config.py:165-172`, `hastelib/src/hastegeo/core/config.py:341-347`) | -| `hastelib/src/hastegeo/core/processors/` | `prediction_edits.py` applies edits and stores versions; `prediction_tiles.py` queues/finalizes prep; `visualizer.py` assembles the vector-first results payload (`hastelib/src/hastegeo/core/processors/prediction_edits.py:1-19`, `hastelib/src/hastegeo/core/processors/prediction_tiles.py:4-84`, `hastelib/src/hastegeo/core/processors/visualizer.py:215-336`) | -| `hastelib/src/hastegeo/core/utils/` | `predictions.py` normalizes both prediction GeoPackage flavors and resolves raw vs edited versions; `model_readiness.py` owns the single results-readiness rule (`hastelib/src/hastegeo/core/utils/predictions.py:4-34`, `hastelib/src/hastegeo/core/utils/predictions.py:318-401`, `hastelib/src/hastegeo/core/utils/model_readiness.py:132-237`) | -| `hastelib/src/hastegeo/workflows/` | queued tile/sidecar preparation workflow that runs where `tippecanoe` is available (`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:4-46`, `hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:322-416`) | -| `api/hastefuncapi/` | prediction edit session/prep/save/version endpoints; vector-first `GetVisualizerResults`; `version` support in visualizer, validation, and assessment reports; `GetModelArtifact` serves `footprint_pmtiles` and `prediction_attrs` (`api/hastefuncapi/function_app.py:1400-1510`, `api/hastefuncapi/function_app.py:2296-2435`, `api/hastefuncapi/function_app.py:2920-3420`, `api/hastefuncapi/function_app.py:4607-4688`, `api/hastefuncapi/function_app.py:4929-5027`) | -| `api/hastefuncqueues/` | prediction-edit prep queue trigger supports model-scoped and layer-only preparation (`api/hastefuncqueues/function_app.py:861-914`) | -| `ui/src/Components/ProjectManagement/` | Results menu View action gates on `predictionsReady`; embedding row gets View Results; standalone Edit buttons are removed (`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:87-110`, `ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:85-130`) | -| `ui/src/Components/Visualizer/` | existing View Results page owns vector-footprint loading, status notes, edit mode, edit panel, save flow, version display, and keyboard shortcuts (`ui/src/Components/Visualizer/Visualizer.jsx:166-199`, `ui/src/Components/Visualizer/Visualizer.jsx:873-921`, `ui/src/Components/Visualizer/usePredictionArtifacts.js:177-221`, `ui/src/Components/Visualizer/usePredictionFootprints.js:838-902`) | -| `ui/src/util/pmtiles.js` | shared PMTiles protocol and in-memory source used by the visualizer's vector artifacts (`ui/src/Components/Visualizer/usePredictionArtifacts.js:25-32`, `ui/src/Components/Visualizer/usePredictionArtifacts.js:201-212`) | -| `.github/workflows/` | no expected dependency change; CI should enforce tests and no-regression UI lint baseline | +| `hastelib/src/hastegeo/core/models/` | Extend `EditedPredictionVersion` with a per-version sidecar URL while keeping `Model.gpkgUrl` raw and `Model.predictionAttrsUrl` raw/model-scoped (`hastelib/src/hastegeo/core/models/projects.py:343-389`, `hastelib/src/hastegeo/core/models/projects.py:529-535`). | +| `hastelib/src/hastegeo/core/config.py` | Keep raw `PREDICTION_ATTRS = Template("prediction_attrs_${modelId}")` and add a versioned sidecar artifact template `prediction_attrs_${modelId}_v${version}` (`hastelib/src/hastegeo/core/config.py:168-180`). | +| `hastelib/src/hastegeo/core/utils/` | Own shared prediction-attribute sidecar building/writing so API save and queue backfill use the same logic. | +| `hastelib/src/hastegeo/core/processors/` | Save edited GeoPackage and sidecar together; prediction-tiles processor adds idempotent backfill mode. | +| `hastelib/src/hastegeo/workflows/` | Continue queued PMTiles/raw-sidecar preparation, but import shared sidecar helpers instead of defining them in the workflow (`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:77-92`, `hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:601-610`). | +| `api/hastefuncapi/` | Extend `GetModelArtifact`, `GetVisualizerResults`, and `PutEditedPredictions` for versioned sidecars and downloads; reports keep newest-version defaults (`api/hastefuncapi/function_app.py:1430-1570`, `api/hastefuncapi/function_app.py:2307-2435`, `api/hastefuncapi/function_app.py:3186-3325`). | +| `api/hastefuncqueues/` | Run backfill through the existing prediction-edit prep queue rather than generating sidecars lazily on GET. | +| `ui/src/Components/Visualizer/` | Add the selector, map-only warning, disabled missing-sidecar states, dual-pane switching, and per-row downloads. | +| `ui/src/Components/ProjectManagement/` | Replace direct GeoPackage blob download paths with `GetModelArtifact` where prediction downloads are exposed (`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:113-119`). | +| `.github/workflows/` | No new workflow is expected; validation remains targeted backend tests plus UI helper tests and documented Playwright gap. | ## Related Specs @@ -140,7 +110,7 @@ Each save still creates a new, numbered edited prediction GeoPackage (`edit_v1`, |---|---| | [data-publishing](../data-publishing/) | related — edited versions are saved artifacts but are not publishable datasets in this feature | | [open-data-catalog](../open-data-catalog/) | related — shares Azure Maps/TiTiler geospatial UI patterns and the queue-first approach for heavy geospatial work | -| [ADR-0005: Introduce Versioned Derived Prediction Artifacts](../../architecture/decisions/0005-versioned-derived-prediction-artifacts.md) | records the artifact-versioning decision for edited prediction GeoPackages and the no-mutable-pointer reader rule | +| [ADR-0005: Introduce Versioned Derived Prediction Artifacts](../../architecture/decisions/0005-versioned-derived-prediction-artifacts.md) | records append-only edited artifacts, no active pointer, and this per-version sidecar extension | ## Document Index @@ -158,11 +128,11 @@ Each save still creates a new, numbered edited prediction GeoPackage (`edit_v1`, | Date | Decision | Rationale | |---|---|---| -| 2026-08-21 | Support both trained-inference and embedding workflows | Analysts need one review/edit entry point regardless of how predictions were produced. | -| 2026-08-21 | Store saves as numbered derived artifacts (`edit_v1`, `edit_v2`, …) | HASTE has no generic artifact versioning today, and overwriting `Model.gpkgUrl` would clobber the raw model output. | -| 2026-08-21 | Use PMTiles plus a columnar JSON attribute sidecar for the full browser dataset | Existing full-attribute APIs do not exist, and the sampled GeoJSON route is capped at 2,000 features. | -| 2026-08-21 | Keep `GetPredictionEditSession` read-only and queue prep through `PutPreparePredictionTilesQueueMessage` | `tippecanoe` is installed in the training image only, so HTTP handlers must not generate tiles inline (`hastelib/src/hastegeo/core/processors/prediction_tiles.py:13-19`, `hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:40-45`). | -| 2026-08-22 | Fold prediction editing into the existing View Results page | The implementation removed the standalone `/edit-predictions/...` route and uses the visualizer pencil/`E` affordance instead (`ui/src/Components/AppBody.jsx:73-75`, `ui/src/Components/Visualizer/Labels.jsx:117-128`). | -| 2026-08-22 | Make the results viewer vector-first and treat rasters as optional trained-inference overlays | Embedding models produce no rasters but can provide the same footprint PMTiles and sidecar as trained models (`hastelib/src/hastegeo/core/processors/visualizer.py:4-29`, `hastelib/src/hastegeo/core/models/visualizer.py:55-82`). | -| 2026-08-22 | Centralize model results readiness server-side | `predictionsReady` now comes from `model_readiness.py` and is stamped onto model payloads instead of being derived differently in each UI/publishing call site (`hastelib/src/hastegeo/core/utils/model_readiness.py:132-237`). | -| 2026-08-22 | Let readers default to the newest edited prediction version, with explicit `version` override and `version=0` raw | The no-mutable-pointer ADR still holds, while edited versions now reach visualizer, validation, and assessment readers (`hastelib/src/hastegeo/core/utils/predictions.py:332-401`, `docs/api/hastefuncapi.md:480-502`). | +| 2026-08-21 | Store saves as numbered derived artifacts (`edit_v1`, `edit_v2`, …) | Overwriting `Model.gpkgUrl` would clobber raw model output and provenance. | +| 2026-08-21 | Use PMTiles plus a columnar JSON sidecar for browser rendering | The sampled GeoJSON route is capped and not an editing data path. | +| 2026-08-22 | Fold prediction editing into View Results | Users should review, edit, and download from one map route. | +| 2026-08-22 | Keep no mutable active-version pointer | Readers use explicit query parameters or newest defaults; metadata stays append-only. | +| 2026-08-25 | Add per-version prediction-attribute sidecars | The model-scoped raw sidecar cannot represent edited classes, so each edited GeoPackage needs matching derived class data. | +| 2026-08-25 | Version selection changes the map only | Reports continuing to use newest avoids broad report state management; the accepted trade-off is that the UI must disclose map/report mismatch. | +| 2026-08-25 | Route version downloads through `GetModelArtifact` | Auth, managed identity, Range, and content disposition should stay centralized in the Functions app. | +| 2026-08-25 | Backfill existing version sidecars via the prediction-tiles job | Read handlers must not generate artifacts; dev models `0448` v1 and `5553` v1 require one-time idempotent backfill. | diff --git a/spec/features/prediction-editing/data-model.md b/spec/features/prediction-editing/data-model.md index f93546b9..73b4dfc8 100644 --- a/spec/features/prediction-editing/data-model.md +++ b/spec/features/prediction-editing/data-model.md @@ -6,11 +6,9 @@ ### New Containers -No new Cosmos containers. Prediction edit metadata is embedded in the existing -Model and ImageLayer metadata documents so reads remain local to the project. -`predictionsReady` is derived on reads and is not persisted -(`docs/api/hastefuncapi.md:59-76`, -`hastelib/src/hastegeo/core/utils/model_readiness.py:225-237`). +No new Cosmos containers. Prediction edit metadata remains embedded in the +existing Model document, and layer PMTiles metadata remains embedded in the +ImageLayer document. | Container | Partition Key | Description | |---|---|---| @@ -20,20 +18,23 @@ Model and ImageLayer metadata documents so reads remain local to the project. | Container | Change | Migration Needed? | |---|---|---| -| Model metadata | Add `editedPredictions`, `predictedBuildingCount`, `predictedAt`, `predictionAttrsUrl`, `predictionTilesJob`, `predictionTilesStatus`, and `predictionTilesStatusMessage` | no — nullable/defaulted fields are backward-compatible (`hastelib/src/hastegeo/core/models/projects.py:491-529`) | -| ImageLayer metadata | Add `footprintPmtilesUrl`, `footprintTilesJob`, `footprintTilesStatus`, and `footprintTilesStatusMessage` | no — nullable/defaulted fields are backward-compatible (`hastelib/src/hastegeo/core/models/projects.py:842-851`) | +| Model metadata | Keep `gpkgUrl` as raw, keep raw/model-scoped `predictionAttrsUrl`, and extend each `EditedPredictionVersion` with a per-version sidecar URL | no — nullable/defaulted fields stay backward-compatible (`hastelib/src/hastegeo/core/models/projects.py:343-389`, `hastelib/src/hastegeo/core/models/projects.py:491-535`) | +| ImageLayer metadata | No schema change for version selection; existing `footprintPmtilesUrl` remains shared by all model versions on the layer | no | ### New Document Schema -**Container:** existing Model metadata document +**Container:** existing Model metadata document **Partition key:** `projectId` -`EditedPredictionVersion` is embedded as a list entry on `Model`: +`EditedPredictionVersion` remains an embedded append-only list entry on `Model`. +The extension adds `predictionAttrsUrl` so every edited GeoPackage has the exact +sidecar the map must render with it. ```python class EditedPredictionVersion(BaseModel): version: int gpkgUrl: str + predictionAttrsUrl: Optional[str] createdAt: str createdBy: Optional[str] threshold: Optional[float] @@ -49,8 +50,9 @@ Serialized example: "editedPredictions": [ { "version": 1, - "gpkgUrl": "https://storage/.../edited_predictions_12345_v1.gpkg", - "createdAt": "2026-08-21T05:10:48Z", + "gpkgUrl": "https://storage/.../edited_predictions_5553_v1.gpkg", + "predictionAttrsUrl": "https://storage/.../prediction_attrs_5553_v1.json", + "createdAt": "2026-08-25T17:00:00Z", "createdBy": "analyst@example.com", "threshold": 0.1, "unknownThreshold": 0.0, @@ -61,50 +63,31 @@ Serialized example: } ``` -The fields and raw-output invariant are implemented in the Model schema and the -save handler (`hastelib/src/hastegeo/core/models/projects.py:343-385`, -`api/hastefuncapi/function_app.py:3311-3325`). - -**RU estimate:** One point read of the Model, one point read of the ImageLayer, -and one Model upsert per save. The embedded list is expected to be small; if -version history grows beyond Cosmos document limits, promote it to a dedicated -registry in a follow-up ADR. +The schema now records the GeoPackage, sidecar URL, and provenance fields on +the same embedded object (`hastelib/src/hastegeo/core/models/projects.py:343-389`). +Keeping both URLs together prevents a sidecar generated later, by a different +path, from silently disagreeing with the saved GeoPackage. ### Modified Document Schema | Container | Field | Before | After | Notes | |---|---|---|---|---| -| Model metadata | `gpkgUrl` | optional string holding the prediction GeoPackage URL | unchanged | Remains the raw prediction pointer; writing edited versions here would clobber the source (`hastelib/src/hastegeo/core/models/projects.py:431-438`). | -| Model metadata | `editedPredictions` | absent | `Optional[List[EditedPredictionVersion]]`, default empty list | Append-only numbered history: `edit_v1`, `edit_v2`, … (`hastelib/src/hastegeo/core/models/projects.py:492-496`). | -| Model metadata | `predictedBuildingCount` | absent | `Optional[int]` | Positive count gates embedding readiness; `0` means the analyst cleared labels and should not show results as ready (`hastelib/src/hastegeo/core/utils/model_readiness.py:168-198`). | -| Model metadata | `predictedAt` | absent | `Optional[str]` ISO 8601 timestamp | Set when embedding predictions are written or prep validates the raw prediction set. | -| Model metadata | `predictionAttrsUrl` | absent | `Optional[str]` | URL to the per-model columnar prediction attribute JSON sidecar (`hastelib/src/hastegeo/core/models/projects.py:520-526`). | -| Model metadata | `predictionTilesJob` | absent | `Optional[TrainingJob]` | Batch/local runner job metadata for the queued prep workflow. | -| Model metadata | `predictionTilesStatus` | absent | `Optional[str]` | Prep status using HASTE status values: `Queued`, `InProgress`, `Processed`, `Failed`, `Cancelled`. | -| Model metadata | `predictionTilesStatusMessage` | absent | `Optional[str]`, default `""` | User-visible appended progress/failure messages for prep polling. | -| ImageLayer metadata | `footprintPmtilesUrl` | absent | `Optional[str]` | Layer-level PMTiles for all footprints used by results viewing/editing. Normally written by the layer-only tiling job queued at image-layer creation; still written by the model-scoped prep job for older layers. | -| ImageLayer metadata | `footprintTilesJob` | absent | `Optional[TrainingJob]` | Batch/local runner job metadata for the layer-only tiling job. Separate from `Model.predictionTilesJob` because the job has no model. | -| ImageLayer metadata | `footprintTilesStatus` | absent | `Optional[str]` | Status of that job using HASTE status values. Deliberately not `ImageLayer.status`: tiling is an optimisation and must never affect imagery preprocessing. | -| ImageLayer metadata | `footprintTilesStatusMessage` | absent | `Optional[str]`, default `""` | Appended progress/failure messages for the layer-only tiling job. | +| Model metadata | `gpkgUrl` | optional string holding the prediction GeoPackage URL | unchanged | Always the raw producer output; editing must not overwrite it (`api/hastefuncapi/function_app.py:3202-3204`). | +| Model metadata | `predictionAttrsUrl` | raw/model-scoped sidecar URL | unchanged | Describes raw predictions only; the artifact template is `prediction_attrs_${modelId}` (`hastelib/src/hastegeo/core/config.py:172`, `hastelib/src/hastegeo/core/models/projects.py:529-535`). | +| Model metadata | `editedPredictions[].gpkgUrl` | edited GeoPackage URL | unchanged | The durable analyst-edited GeoPackage (`api/hastefuncapi/function_app.py:3311-3319`). | +| Model metadata | `editedPredictions[].predictionAttrsUrl` | absent | optional string URL to `prediction_attrs_${modelId}_v${version}` | Required before that version can be selected on the map; backfill may populate it for existing versions. | +| Model metadata | `editedPredictions` | optional list | append-only list | No mutable `activeEditedPredictionVersion` pointer is added. | ### Transport-Only Wire Models -`PredictionOverrideRequest`, `EditedPredictionsRequest`, and -`PreparePredictionTilesRequest` live in -`hastelib/src/hastegeo/core/models/predictions.py`. They validate HTTP request -bodies for `PutEditedPredictions` and `PutPreparePredictionTilesQueueMessage` -but are not persisted in Cosmos DB. - -They deliberately do not live in `function_app.py`, which remains a thin HTTP -wrapper, or in `projects.py`, which holds persisted document schemas. This -mirrors the publishing split between `PublishRequest` transport models and the -persisted `PublishedDataset` schema in `publishing.py`. +`EditedPredictionsRequest` stays the save request body. It does not carry a +sidecar payload. The API builds the edited GeoPackage, calls the shared sidecar +builder, stores both artifacts, and only then appends the version metadata. -`store_artifact` uploads by artifact name, so version identity comes from unique -artifact names instead of mutating the raw model pointer. The edit writer and API -handler never write edited output to `Model.gpkgUrl` -(`hastelib/src/hastegeo/core/processors/prediction_edits.py:355-422`, -`api/hastefuncapi/function_app.py:3202-3205`). +`PreparePredictionTilesRequest` gains a backfill mode for existing edited +versions. The mode is idempotent: it reads `Model.editedPredictions`, skips any +entry already carrying a valid `predictionAttrsUrl`, and writes only missing +`prediction_attrs_${modelId}_v${version}` sidecars. --- @@ -124,16 +107,20 @@ partitioning used by `ArtifactProcessor`. | Container | Change | Description | |---|---|---| | existing artifacts container | add edited prediction GeoPackage blobs | One immutable-by-convention blob per numbered edit version. | -| existing artifacts container | add prediction attribute sidecar blobs | Columnar JSON sidecar for full prediction attributes. | -| existing artifacts container | add layer footprint PMTiles blobs | Layer-level PMTiles shared by models for an image layer. | +| existing artifacts container | add raw prediction attribute sidecar blobs | `prediction_attrs_${modelId}` describes raw predictions. | +| existing artifacts container | add versioned prediction attribute sidecar blobs | `prediction_attrs_${modelId}_v${version}` describes one edited GeoPackage. | +| existing artifacts container | add layer footprint PMTiles blobs | Shared geometry archive for all raw and edited versions on an image layer. | ### Blob Path Conventions -Artifact names are added to `ArtifactTypes` (`hastelib/src/hastegeo/core/config.py:165-172`): +Artifact names are defined in `ArtifactTypes`; the current raw sidecar template +is model-scoped (`hastelib/src/hastegeo/core/config.py:168-180`). Add a separate +versioned sidecar artifact name rather than changing raw sidecar semantics. ```python EDITED_PREDICTIONS_GPKG = Template("edited_predictions_${modelId}_v${version}") -PREDICTION_ATTRS = Template("prediction_attrs_${modelId}") +PREDICTION_ATTRS = Template("prediction_attrs_${modelId}") +PREDICTION_ATTRS_VERSION = Template("prediction_attrs_${modelId}_v${version}") LAYER_FOOTPRINT_PMTILES = Template("footprints_${imageLayerId}") ``` @@ -143,48 +130,43 @@ Logical layout: {artifact-container}/ {projectId}/ {modelId}/ + raw_predictions.gpkg + prediction_attrs_{modelId}.json edited_predictions_{modelId}_v1.gpkg + prediction_attrs_{modelId}_v1.json edited_predictions_{modelId}_v2.gpkg - prediction_attrs_{modelId}.json + prediction_attrs_{modelId}_v2.json {imageLayerId}/ footprints_{imageLayerId}.pmtiles ``` -The exact physical namespace follows `ArtifactProcessor` conventions, but -artifact names must match the templates above. Edited GeoPackages are immutable -by convention; a later save writes the next version. +The physical namespace still follows `ArtifactProcessor`. The invariant is that +one edited GeoPackage version and its sidecar are derived from the same edited +rows and are advertised together or not at all. #### Edited prediction GeoPackage schema -The source schemas differ by producer. Trained inference writes continuous -fractions and a default layer name; embedding writes layer `"predictions"`, an -`area` column, and `damage_pct_0m` as a 0.0/1.0 copy of `damaged` -(`docker/training/code/merge_with_building_footprints.py:221-258`, -`api/hastefuncapi/function_app.py:2738-2815`). The edited output normalizes the -minimum columns below while preserving any safe source columns that do not -conflict. +The source schemas differ by producer, but edited versions keep the same minimum +columns and row order. Edits rewrite `damaged` and `edited_class` but preserve +`damage_pct_0m`; that preserved fraction is why Assessment counts remain a +known out-of-scope semantic gap. | Column | Type | Required | Description | |---|---|---|---| | `id` | int | yes | Source row index; must remain in original order. | -| `damage_pct_0m` | float | yes | Damage fraction in `[0,1]`; continuous for trained inference, degenerate 0.0/1.0 for embedding. Preserved from the producer even when `damaged` is overridden. | -| `damage_pct_10m` | float | trained source only | Preserve when present. | -| `damage_pct_20m` | float | trained source only | Preserve when present. | +| `damage_pct_0m` | float | yes | Preserved producer damage fraction; not rewritten for manual overrides. | | `unknown_pct` | float | yes | Unknown fraction; default to `0.0` when absent. | -| `damaged` | int | yes | Rewritten to `1` only when final class is `Damaged`; otherwise `0`. | -| `area` | float | embedding source only | Preserve when present. | +| `damaged` | int | yes | Rewritten to `1` only when final class is `Damaged`. | | `edited_class` | string | yes | `Damaged`, `NotDamaged`, or `Unknown` after overrides and thresholds. | -| `edit_threshold` | float | yes | Threshold used for this save; still written for embedding for provenance. | -| `overture_id` | string | yes | Explicit Overture building id copied from source footprints by row order. | +| `edit_threshold` | float | yes | Threshold used for this save. | +| `overture_id` | string | yes | Source footprint id copied by row order. | | `geometry` | geometry | yes | Original prediction geometry and CRS. | -The edited writer implements the rewrite/add-column behavior and preserves row -order (`hastelib/src/hastegeo/core/processors/prediction_edits.py:246-279`). - #### Attribute sidecar schema -The prediction attribute sidecar is JSON and is streamed by -`GetModelArtifact?kind=prediction_attrs` as `application/json`: +The prediction attribute sidecar is JSON streamed by +`GetModelArtifact?kind=prediction_attrs` as `application/json`. Raw and edited +sidecars share the same schema. ```json { @@ -197,11 +179,11 @@ The prediction attribute sidecar is JSON and is streamed by } ``` -All arrays must have length `n` and must be ordered exactly like the prediction -GeoPackage rows (`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:351-416`). -This order matters because current report logic joins predictions to Overture ids -positionally, not by id (`hastelib/src/hastegeo/core/utils/assessment.py:368-395`, -`api/hastefuncapi/function_app.py:4808-4827`). +All arrays must have length `n` and match the GeoPackage row order. The shared +builder now validates these invariants in `hastegeo.core.utils`, and the workflow +imports it so save-time generation and queue backfill use the same code +(`hastelib/src/hastegeo/core/utils/prediction_attrs.py:128-202`, +`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:77-92`). --- @@ -209,8 +191,8 @@ positionally, not by id (`hastelib/src/hastegeo/core/utils/assessment.py:368-395 ### New Filesystems / Paths -No Data Lake filesystem changes are required for v1. Edited versions are Blob -artifacts only. +No Data Lake filesystem changes are required. Edited GeoPackages and sidecars +are Blob artifacts only. | Filesystem | Path Pattern | Data Format | Description | |---|---|---|---| @@ -222,18 +204,16 @@ artifacts only. ### New Queues +No additional queue beyond the existing prediction-edit prep queue. The queue +message gains a backfill mode. + | Queue Name | Message Schema | Producer | Consumer | |---|---|---|---| -| `prediction-edit-prep-queue` | See [design.md](design.md#queue-prediction-edit-prep-queue) | `hastefuncapi` `PutPreparePredictionTilesQueueMessage` and `ImageryPostProcessor` layer-only enqueue | `hastefuncqueues` prediction-edit-prep trigger | +| `prediction-edit-prep-queue` | Existing prep fields plus `backfillVersions` | `PutPreparePredictionTilesQueueMessage` or maintenance/backfill call | `hastefuncqueues` prediction-edit-prep trigger | -The queue is for PMTiles and sidecar preparation only. Saving edited -GeoPackages remains an API-driven write in v1. - -`infra/modules/functions.bicep` does not add explicit app-setting parity for -this queue in the current implementation. `Config` supplies the -`prediction-edit-prep-queue` default, the Functions host can create the queue, -and editing Bicep without regenerating `infra/main.json` would create infra -drift (`hastelib/src/hastegeo/core/config.py:341-347`). +Backfill mode is for historical edited versions only. Normal save-time sidecar +creation happens synchronously with `PutEditedPredictions`; lazy sidecar +creation during `GetVisualizerResults` is not allowed. --- @@ -243,9 +223,9 @@ drift (`hastelib/src/hastegeo/core/config.py:341-347`). | Setting | Value | Notes | |---|---|---| -| VM SKU | existing training/CPU-capable pool | No GPU requirement; uses the training image because it includes `tippecanoe`. | -| Pool size | existing autoscale | Prep is bursty and should not require a dedicated pool in v1. | -| Container image | `docker/training/` | The prep workflow must run where `tippecanoe` is available, not inline in the Function App (`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:40-45`). | +| VM SKU | existing training/CPU-capable pool | No GPU requirement. | +| Pool size | existing autoscale | Backfill is bounded and idempotent. | +| Container image | `docker/training/` | Prep still runs where existing geospatial tooling is installed. | --- @@ -256,104 +236,86 @@ drift (`hastelib/src/hastegeo/core/config.py:341-347`). Edit save path: ```text -Visualizer edit mode save overrides + thresholds +Visualizer save overrides + thresholds → hastefuncapi PutEditedPredictions - → hastegeo.core.processors.prediction_edits.apply_edits - → hastegeo.core.processors.prediction_edits.store_edited_version - → Blob Storage edited_predictions_{modelId}_v{version}.gpkg - → Cosmos Model.editedPredictions append + → apply edits to raw GeoPackage + → write edited_predictions_{modelId}_v{version}.gpkg + → build prediction_attrs_{modelId}_v{version}.json from the edited GeoPackage + → store both blobs + → append EditedPredictionVersion {version, gpkgUrl, predictionAttrsUrl, ...} ``` -Prep write path: - -```text -Visualizer opens / loads predictions - → hastefuncapi GetVisualizerResults reports readiness and artifact routes - → hastefuncapi GetPredictionEditSession when artifacts are missing or edit mode opens - → hastefuncapi PutPreparePredictionTilesQueueMessage when missing - → Queue Storage prediction-edit-prep-queue - → hastefuncqueues - → training image workflow builds PMTiles + sidecar - → Blob Storage footprints_{imageLayerId}.pmtiles + prediction_attrs_{modelId}.json - → Cosmos ImageLayer.footprintPmtilesUrl + Model.predictionAttrsUrl/predictedBuildingCount/predictedAt/predictionTilesStatus -``` +The sidecar is derived data, but it must be written in the same call path as its +GeoPackage. If sidecar generation or upload fails, the version must not be shown +as selectable because the map could otherwise draw classes from a different +artifact. -Layer-time prep write path (no model; runs at image-layer creation so the tiles -already exist by the time anyone opens View Results): +Prep/backfill path: ```text -imageryprep workflow caches building footprints - → hastegeo.core.processors.imagery.ImageryPostProcessor completes the layer - → Queue Storage prediction-edit-prep-queue (message with empty modelId) - → hastefuncqueues - → training image workflow builds PMTiles only (no sidecar) - → Blob Storage footprints_{imageLayerId}.pmtiles - → Cosmos ImageLayer.footprintPmtilesUrl/footprintTilesStatus/footprintTilesJob +Operator or deployment task requests prediction-tile backfill + → prediction-edit-prep-queue message with backfillVersions=true + → worker loads Model.editedPredictions + → for each version lacking predictionAttrsUrl, build sidecar from gpkgUrl + → skip versions that already have sidecars unless force=true + → save only the newly populated version metadata ``` -### Read Path - -Visualizer/read-only and edit-mode path: +Read path: ```text -UI View Results page - → hastefuncapi GetVisualizerResults (imagery, nullable rasters, vector artifact routes, readiness, versions) - → hastefuncapi GetModelArtifact?kind=footprint_pmtiles - → hastefuncapi GetModelArtifact?kind=prediction_attrs - → Azure Maps PMTiles + in-memory sidecar rendering - → pencil / E enters edit mode - → hastefuncapi GetPredictionEditSession (lazy flavor/readiness/history refresh) - → hastefuncapi GetEditedPredictionVersions after save +UI selects raw / v1 / v2 on View Results + → GetVisualizerResults?version=N + → response carries selected predictionAttrsUrl and isNewestPredictionVersion + → UI fetches footprint_pmtiles and prediction_attrs through GetModelArtifact + → both swipe panes replace their sidecar/class state together ``` -Report/readers path: +Report path: ```text -GetVisualizerResults / GetValidationReport / GetAssessmentReport - → hastegeo.core.utils.predictions.resolve_prediction_source(model, version) - → newest edited GeoPackage by default, raw Model.gpkgUrl for version=0, - or the requested edited version for version=N +Validation / Assessment report buttons + → GetValidationReport or GetAssessmentReport without the map selector version + → backend resolves newest edited version by default ``` +This intentionally allows the map to show v2 while reports reflect v3. The UI +must disclose that consequence. + --- ## Migration Plan ### Forward Migration -1. Deploy Pydantic schema changes with nullable/defaulted fields. -2. Deploy new artifact types and `GetModelArtifact` kinds. -3. Deploy queue worker support for PMTiles and sidecar creation. -4. Deploy API routes and the explicit prep PUT route. -5. Deploy vector-first `GetVisualizerResults`, `predictionsReady` on model - payloads, and `version` support in visualizer/validation/assessment readers. -6. Deploy Visualizer edit mode and Results menu changes; do not add a standalone - edit route. -7. Enable the feature in dev/test and backfill `predictedBuildingCount` through - `PutBuildingPredictions` for embedding models or prep completion for raw - prediction GeoPackages. - -Existing trained models can use the server readiness fallback for processed -inference artifacts. Existing embedding models with `gpkgUrl` but no -`predictedBuildingCount` fall back to `gpkgUrl` for backward compatibility; -`predictedBuildingCount == 0` is explicitly not ready because Clear labels can -write an empty predictions GeoPackage while still setting `gpkgUrl` -(`hastelib/src/hastegeo/core/utils/model_readiness.py:142-146`, -`hastelib/src/hastegeo/core/utils/model_readiness.py:168-198`). +1. Deploy the extended `EditedPredictionVersion` schema and versioned sidecar + artifact template. +2. Move shared sidecar generation into `hastegeo.core.utils` and update the + workflow to import it. +3. Update `PutEditedPredictions` to write edited GeoPackage and sidecar together. +4. Update `GetModelArtifact` and `GetVisualizerResults` to resolve raw vs edited + sidecars by `version`. +5. Deploy UI selector, warning copy, disabled missing-sidecar state, and download + buttons. +6. Run backfill for existing edited versions. Dev currently has two known + targets: model `0448` v1 and model `5553` v1. +7. Verify no selectable version lacks `predictionAttrsUrl` before broad rollout. + +The backfill creates a temporary window where pre-existing versions cannot be +selected. The selector must show those rows disabled with explanatory text +instead of rendering an empty map. ### Backward Migration -1. Revert UI deployment to remove Visualizer edit mode and the embedding View - Results entry point if needed. -2. Revert API deployment if direct prediction-editing calls or newest-edited - report defaults must be disabled. -3. Stop or drain the prediction-edit prep queue if workers are failing. -4. Leave `editedPredictions`, `predictedBuildingCount`, `predictedAt`, - `predictionAttrsUrl`, `predictionTilesJob`, `predictionTilesStatus`, - `predictionTilesStatusMessage`, and `footprintPmtilesUrl` fields in place; - old code ignores unknown optional fields. -5. Leave edited GeoPackage, PMTiles, and sidecar blobs in storage unless a - cleanup script is explicitly approved. +1. Revert UI selector/download deployment if the map-selection workflow must be + hidden. +2. Revert API changes if versioned artifact resolution regresses. +3. Leave `editedPredictions[].predictionAttrsUrl` in Cosmos; older code ignores + the unknown optional field. +4. Leave versioned sidecar blobs in storage unless approved cleanup tooling is + run. +5. Use `version=0` for raw reports and omit `version` for newest-edited reports + while rollback is evaluated. ## Data Volume Estimates @@ -361,15 +323,16 @@ write an empty predictions GeoPackage while still setting `gpkgUrl` |---|---|---|---| | `Model.editedPredictions` list | 0-20 small objects per model | one entry per save | same as model metadata | | Edited GeoPackage blobs | roughly source prediction GPKG size per version | one blob per save | same as project artifacts | -| Prediction attribute sidecar | one compact JSON array set per model | regenerated when source predictions change | replaceable derived cache | +| Raw prediction sidecar | one compact JSON array set per model | regenerated when raw predictions change | replaceable derived cache | +| Versioned prediction sidecar | one compact JSON array set per edited version | one per save plus one-time backfill | same as edited version unless cleanup approved | | Footprint PMTiles | one geometry-only archive per image layer | generated once per layer | replaceable derived cache | ## Caching Strategy | Data | Cache Layer | TTL | Invalidation | |---|---|---|---| -| `GetVisualizerResults` payload | Browser route state | current View Results load | Refetch on route change or manual reload; UI version switching is not wired. | -| `GetPredictionEditSession` metadata | Browser state | until model refresh or route leave | Loaded lazily on edit/prep and refreshed during prep polling or after save. | -| `prediction_attrs` sidecar | Browser memory | current visualizer session | Refetch when `predictedAt` or source `gpkgUrl` changes. | -| `footprint_pmtiles` | Browser memory / HTTP cache | current visualizer session; cacheable by blob version/url | Regenerate when source footprints change. | -| Edited version list | Browser state | current visualizer session | Seeded by `GetVisualizerResults`, refreshed after `PutEditedPredictions` succeeds. | +| `GetVisualizerResults` payload | Browser route state | current View Results load | Refetch when selector changes or route changes. | +| `prediction_attrs` sidecar | Browser memory keyed by selected version | current visualizer session | Refetch when `predictionAttrsUrl` or selected version changes. | +| `footprint_pmtiles` | Browser memory / HTTP cache | current visualizer session | Shared across versions for the same layer. | +| Edited version list | Browser state | current visualizer session | Refresh after save and after backfill/polling detects new sidecar URLs. | +| Disabled version state | Browser state from metadata | current visualizer session | Re-evaluate after backfill completes. | diff --git a/spec/features/prediction-editing/design.md b/spec/features/prediction-editing/design.md index de1f181b..e0d08de0 100644 --- a/spec/features/prediction-editing/design.md +++ b/spec/features/prediction-editing/design.md @@ -4,587 +4,338 @@ ## Overview -Prediction editing is a mode of the existing **View Results** page. The -visualizer already owns the two-map swipe view, raster overlays, imagery -metadata, and results URL; edit mode adds the predicted-footprint vector layer, -right-side edit panel, save action, and version history without navigating away -from `/visualizer/:projectId/:imageLayerId/:modelId` +Prediction editing is a mode of the existing **View Results** page. The page +already owns the two-map swipe view, imagery metadata, raster overlays, vector +footprints, edit panel, and save action; this design adds version selection and +per-version downloads without introducing a standalone editor route (`ui/src/Components/AppBody.jsx:73-75`, -`ui/src/Components/Visualizer/Visualizer.jsx:13-28`, -`ui/src/Components/Visualizer/Labels.jsx:117-128`). - -The results viewer is vector-first. Both trained inference and embedding models -can render predicted building footprints from the layer/model PMTiles plus the -model's columnar prediction attribute sidecar. The trained-inference rasters -remain optional overlays; embedding models return `null` for those fields because -they do not write COGs (`hastelib/src/hastegeo/core/processors/visualizer.py:4-29`, -`hastelib/src/hastegeo/core/processors/visualizer.py:303-331`, -`hastelib/src/hastegeo/core/models/visualizer.py:55-82`). - -The raw prediction GeoPackage remains immutable. Each edit save appends a new -`EditedPredictionVersion` and writes a versioned GeoPackage, while readers use -`resolve_prediction_source` to select the newest edit by default or an explicit -`version` (`0` selects raw). This keeps the ADR's no-mutable-pointer decision -while making edits visible to the visualizer, validation report, and assessment -report (`hastelib/src/hastegeo/core/utils/predictions.py:332-401`, -`api/hastefuncapi/function_app.py:2386-2435`, -`api/hastefuncapi/function_app.py:4677-4688`, -`api/hastefuncapi/function_app.py:5017-5027`). +`ui/src/Components/Visualizer/PredictionEditPanel.jsx:513-550`). + +The important data-model constraint is that the sidecar rendered by the browser +is currently keyed per model, not per version: +`PREDICTION_ATTRS = Template("prediction_attrs_${modelId}")` +(`hastelib/src/hastegeo/core/config.py:172`). That raw sidecar cannot render an +edited GeoPackage whose `damaged` values have changed. Each saved edited version +therefore gets its own sidecar, `prediction_attrs_${modelId}_v${version}`, and +`EditedPredictionVersion` records both the GeoPackage URL and sidecar URL. + +Version selection is **map-only**. The selector refetches +`GetVisualizerResults?version=N`, swaps the map's sidecar/source state, and lets +analysts inspect or download that version. Assessment and Validation reports +continue to call their endpoints without the selector's version and therefore +continue to use the newest edited version by default. This accepted trade-off +keeps ADR-0005's no-active-pointer decision; the UI must make the possible +map/report mismatch explicit (`api/hastefuncapi/function_app.py:4607-4688`, +`api/hastefuncapi/function_app.py:4929-5027`). ## Architecture ### Component Diagram -``` -┌────────────────────────────────────────────┐ -│ React UI │ -│ Results menu → /visualizer/... │ -│ Visualizer + Labels pencil / E shortcut │ -│ PredictionEditPanel + vector footprints │ -└──────────────────┬─────────────────────────┘ - │ GET visualizer / GET session / PUT prep / artifacts / PUT save - ▼ -┌────────────────────────────────────────────┐ metadata ┌────────────────────┐ -│ hastefuncapi │◀─────────────────▶│ Cosmos metadata │ -│ GetVisualizerResults (vector-first) │ │ Project/Layer/Model │ -│ GetPredictionEditSession │ └────────────────────┘ -│ PutPreparePredictionTilesQueueMessage │ -│ PutEditedPredictions │ -│ GetEditedPredictionVersions │ -│ GetModelArtifact kinds │ -│ GetValidationReport / GetAssessmentReport │ -└──────────────┬─────────────────┬───────────┘ - │ stream artifacts │ queue after explicit prep request - ▼ ▼ -┌──────────────────┐ ┌────────────────────────────┐ -│ Blob Storage │ │ hastefuncqueues │ -│ raw GPKG │ │ prediction-edit-prep queue │ -│ edited GPKG vN │ └─────────────┬──────────────┘ -│ PMTiles + attrs │ │ run training image workflow -└──────────────────┘ ▼ - ┌────────────────────────────┐ - │ hastegeo workflow │ - │ fiona/geopandas + │ - │ tippecanoe PMTiles │ - └────────────────────────────┘ +```text +React View Results + ├─ version selector + map-only warning + ├─ per-version download button + ├─ edit panel history row downloads + └─ both swipe panes load one selected sidecar + │ + ▼ +hastefuncapi + ├─ GetVisualizerResults?version=N + ├─ GetModelArtifact?kind=prediction_attrs&version=N + ├─ GetModelArtifact?kind=gpkg&version=N + └─ PutEditedPredictions writes GPKG + sidecar together + │ + ▼ +hastegeo core + ├─ prediction_edits applies overrides and stores edited GPKG + ├─ core.utils.prediction_attrs builds/writes sidecars + └─ prediction_tiles backfills missing version sidecars + │ + ▼ +Blob + Cosmos + ├─ edited_predictions_{modelId}_v{version}.gpkg + ├─ prediction_attrs_{modelId}_v{version}.json + └─ Model.editedPredictions[] records both URLs ``` ### New Components | Component | Path | Responsibility | Technology | |---|---|---|---| -| Prediction edit engine | `hastelib/src/hastegeo/core/processors/prediction_edits.py` | Apply overrides and thresholds, derive final classes, allocate the next version, and store edited GeoPackages (`hastelib/src/hastegeo/core/processors/prediction_edits.py:1-19`, `hastelib/src/hastegeo/core/processors/prediction_edits.py:226-308`) | Python / Fiona | -| Prediction schema and source utilities | `hastelib/src/hastegeo/core/utils/predictions.py` | Normalize trained-inference vs embedding GeoPackage schemas, preserve row order, resolve Overture ids positionally, and choose raw/newest/explicit edited sources (`hastelib/src/hastegeo/core/utils/predictions.py:4-34`, `hastelib/src/hastegeo/core/utils/predictions.py:318-401`) | Python / Fiona | -| Model readiness utility | `hastelib/src/hastegeo/core/utils/model_readiness.py` | Single server-side readiness rule for model rows, visualizer readiness, and publishing completion (`hastelib/src/hastegeo/core/utils/model_readiness.py:4-25`, `hastelib/src/hastegeo/core/utils/model_readiness.py:132-237`) | Python | -| Visualizer payload builder | `hastelib/src/hastegeo/core/processors/visualizer.py` | Build the vector-first `GetVisualizerResults` payload and nullable raster layers for both workflows (`hastelib/src/hastegeo/core/processors/visualizer.py:215-336`) | Python | -| Prediction HTTP wire models | `hastelib/src/hastegeo/core/models/predictions.py` | Transport-only Pydantic request bodies for save and prep routes; kept out of persisted project schemas | Python / Pydantic | -| Prediction edit models | `hastelib/src/hastegeo/core/models/projects.py` | `EditedPredictionVersion`; new optional `Model` and `ImageLayer` fields (`hastelib/src/hastegeo/core/models/projects.py:343-505`, `hastelib/src/hastegeo/core/models/projects.py:520-529`, `hastelib/src/hastegeo/core/models/projects.py:842-851`) | Python / Pydantic | -| Prediction edit prep workflow | `hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py` | Build footprint PMTiles and prediction attribute sidecar from the raw prediction GeoPackage and layer footprints (`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:4-46`, `hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:322-416`) | Python / tippecanoe | -| Prediction tiles job processor | `hastelib/src/hastegeo/core/processors/prediction_tiles.py` | Decide whether tiles/sidecar are missing, submit the workflow to the training image through `UnifiedRunner`, persist artifact URLs (`hastelib/src/hastegeo/core/processors/prediction_tiles.py:251-370`, `hastelib/src/hastegeo/core/processors/prediction_tiles.py:475-560`) | Python | -| Queue trigger | `api/hastefuncqueues/function_app.py` | Consume prediction-edit-prep messages and invoke model-scoped or layer-only preparation through the existing runner pattern (`api/hastefuncqueues/function_app.py:861-914`) | Azure Functions | -| Visualizer edit affordance | `ui/src/Components/Visualizer/Labels.jsx` | Pencil/Done button next to Back; disabled-state tooltip (`ui/src/Components/Visualizer/Labels.jsx:8-12`, `ui/src/Components/Visualizer/Labels.jsx:117-128`) | React / Fluent UI | -| Visualizer edit mode | `ui/src/Components/Visualizer/Visualizer.jsx` | Enters/leaves edit mode, hides conflicting rasters while editing, handles unsaved discard dialog, keyboard shortcuts, and edit panel render (`ui/src/Components/Visualizer/Visualizer.jsx:457-605`, `ui/src/Components/Visualizer/Visualizer.jsx:873-921`) | React / Azure Maps | -| Prediction artifact hook | `ui/src/Components/Visualizer/usePredictionArtifacts.js` | Load vector artifacts, request prep, poll readiness, cache versions, and expose active version (`ui/src/Components/Visualizer/usePredictionArtifacts.js:4-24`, `ui/src/Components/Visualizer/usePredictionArtifacts.js:177-221`, `ui/src/Components/Visualizer/usePredictionArtifacts.js:377-459`) | React / PMTiles | -| Prediction footprint hook | `ui/src/Components/Visualizer/usePredictionFootprints.js` | Add footprint layers to both swipe panes, apply feature-state coloring, selection, overrides, save, and discard (`ui/src/Components/Visualizer/usePredictionFootprints.js:4-29`, `ui/src/Components/Visualizer/usePredictionFootprints.js:313-376`, `ui/src/Components/Visualizer/usePredictionFootprints.js:838-902`) | React / Azure Maps | -| Prediction edit panel | `ui/src/Components/Visualizer/PredictionEditPanel.jsx` | Counts, filters, traversal, threshold sliders when supported, save button, Done button, keyboard help, and read-only saved-version history (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:4-16`, `ui/src/Components/Visualizer/PredictionEditPanel.jsx:300-585`) | React / Fluent UI | -| Results decision helpers | `ui/src/Components/Visualizer/predictionResults.js`, `predictionClassify.js`, `predictionPrep.js`, `predictionFootprintMap.js`, `visualizerSwipe.js` | Pure helper logic for payload interpretation, classification, prep polling, map paint expressions, and swipe hints; covered by Node tests (`ui/src/Components/Visualizer/predictionResults.js:20-25`, `ui/src/Components/Visualizer/predictionResults.js:96-103`, `ui/src/Components/Visualizer/predictionResults.js:320-385`) | JavaScript | +| Shared prediction-attribute helpers | `hastelib/src/hastegeo/core/utils/prediction_attrs.py` | Build and write raw or edited sidecar JSON from a GeoPackage plus source footprints; moved out of the workflow that previously defined `build_prediction_attrs`/`write_prediction_attrs` (`hastelib/src/hastegeo/core/utils/prediction_attrs.py:128-202`) | Python / Fiona | +| Versioned sidecar artifact | `hastelib/src/hastegeo/core/config.py` | Add `prediction_attrs_${modelId}_v${version}` alongside raw `prediction_attrs_${modelId}` (`hastelib/src/hastegeo/core/config.py:168-180`) | Python config | +| Versioned artifact resolver | `api/hastefuncapi/function_app.py` + `hastegeo.core.utils.predictions` | Resolve raw, newest, and explicit edited `gpkg`/`prediction_attrs` artifacts for `GetModelArtifact` and `GetVisualizerResults` | Python | +| Version selector | `ui/src/Components/Visualizer/` | Select raw or a saved version, refetch the map only, disable versions missing sidecars, and show report-mismatch copy | React / Fluent UI | +| Version download controls | `ui/src/Components/Visualizer/PredictionEditPanel.jsx` and View Results controls | Download the selected version or a row's version through `GetModelArtifact` | React | +| Backfill mode | `hastelib/src/hastegeo/core/processors/prediction_tiles.py` / queue worker | Build missing per-version sidecars once and skip already-ready versions (`hastelib/src/hastegeo/core/processors/prediction_tiles.py:148-165`, `hastelib/src/hastegeo/core/processors/prediction_tiles.py:304-340`) | Python / queue worker | ### Modified Components | Component | Path | Change Description | |---|---|---| -| Artifact types | `hastelib/src/hastegeo/core/config.py` | Add `EDITED_PREDICTIONS_GPKG`, `PREDICTION_ATTRS`, and `LAYER_FOOTPRINT_PMTILES` templates; queue config defaults to `prediction-edit-prep-queue` (`hastelib/src/hastegeo/core/config.py:165-172`, `hastelib/src/hastegeo/core/config.py:341-347`) | -| Model schema | `hastelib/src/hastegeo/core/models/projects.py` | Add edited-version, predicted-building, sidecar, and prep-status fields while keeping `gpkgUrl` as the raw prediction pointer (`hastelib/src/hastegeo/core/models/projects.py:431-438`, `hastelib/src/hastegeo/core/models/projects.py:491-529`) | -| Image layer schema | `hastelib/src/hastegeo/core/models/projects.py` | Add `footprintPmtilesUrl` and layer-only tiling status fields (`hastelib/src/hastegeo/core/models/projects.py:758-770`, `hastelib/src/hastegeo/core/models/projects.py:842-851`) | -| API module | `api/hastefuncapi/function_app.py` | Adds prediction-editing endpoints; extends `GetModelArtifact`; updates `GetVisualizerResults`, `GetValidationReport`, and `GetAssessmentReport`; stamps `predictionsReady` on model payloads (`api/hastefuncapi/function_app.py:1400-1510`, `api/hastefuncapi/function_app.py:2296-2435`, `api/hastefuncapi/function_app.py:2920-3420`, `api/hastefuncapi/function_app.py:4607-4688`, `api/hastefuncapi/function_app.py:4929-5027`) | -| Trained model row | `ui/src/Components/ProjectManagement/ModelResultsButton.jsx` | Uses `predictionsReady` to enable View Results and removes the standalone Edit action (`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:87-110`) | -| Embedding model row | `ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx` | Adds View Results as the first Results menu item and removes the standalone Edit action (`ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:85-130`) | -| App routing | `ui/src/Components/AppBody.jsx` | Keeps `/visualizer/:projectId/:imageLayerId/:modelId`; no `/edit-predictions/...` route is registered (`ui/src/Components/AppBody.jsx:73-75`) | -| Existing editor references | `ui/src/Components/Visualizer/`, `ui/src/util/pmtiles.js` | Visualizer now owns PMTiles loading, feature-state coloring, filters, prev/next traversal, box-select, keyboard shortcuts, and shared PMTiles protocol (`ui/src/Components/Visualizer/usePredictionArtifacts.js:25-32`, `ui/src/Components/Visualizer/usePredictionFootprints.js:313-376`, `ui/src/Components/keyboardShortcuts.js:60-80`) | +| Model schema | `hastelib/src/hastegeo/core/models/projects.py` | Extend `EditedPredictionVersion` with `predictionAttrsUrl`; existing fields are at `hastelib/src/hastegeo/core/models/projects.py:343-389`. | +| Save route | `api/hastefuncapi/function_app.py` | `PutEditedPredictions` currently appends a version after writing the GeoPackage (`api/hastefuncapi/function_app.py:3302-3325`); it must adopt `save_edited_version`, which builds/stores the matching sidecar before appending (`hastelib/src/hastegeo/core/processors/prediction_edits.py:520-608`). | +| Visualizer payload model | `hastelib/src/hastegeo/core/models/visualizer.py` | Add an `isNewestPredictionVersion` boolean to the existing version fields (`hastelib/src/hastegeo/core/models/visualizer.py:65-78`). | +| Visualizer payload builder | `hastelib/src/hastegeo/core/processors/visualizer.py` | Build `predictionAttrsUrl` with the selected version query instead of always using the raw/model-scoped URL (`hastelib/src/hastegeo/core/processors/visualizer.py:272-329`). | +| Artifact route | `api/hastefuncapi/function_app.py` | Extend `GetModelArtifact` so `kind=gpkg` and `kind=prediction_attrs` accept `version`; current dispatch is field-based (`api/hastefuncapi/function_app.py:1400-1570`). | +| Visualizer fetch | `ui/src/Components/Visualizer/Visualizer.jsx` | Include selector state in `GetVisualizerResults`; current fetch omits `version` (`ui/src/Components/Visualizer/Visualizer.jsx:213-223`). | +| Model-row download | `ui/src/Components/ProjectManagement/ModelResultsButton.jsx` | Replace direct URL rewrite/download for prediction GPKGs with `GetModelArtifact` where this flow exposes prediction downloads (`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:61-69`, `ui/src/Components/ProjectManagement/ModelResultsButton.jsx:113-119`). | ## API Design The route names follow the current Azure Functions convention in -`function_app.py`. Endpoints use `func.AuthLevel.FUNCTION` and delegate non-HTTP -logic to `hastegeo`. - -### Model payloads: `predictionsReady` - -Every endpoint that returns model objects (`GetProjectDetails`, -`GetLayerDetailView`, and `GetLayerModelsDetails`) stamps a derived -`predictionsReady` boolean in memory. It is not persisted and should not be sent -back in a `PutModel` body (`api/hastefuncapi/function_app.py:785-788`, -`api/hastefuncapi/function_app.py:1262-1266`, -`api/hastefuncapi/function_app.py:1380-1383`). The exact readiness rule is -specified in the API docs and implemented in `model_readiness.py` -(`docs/api/hastefuncapi.md:59-76`, -`hastelib/src/hastegeo/core/utils/model_readiness.py:132-237`). - -### hastefuncapi Endpoints - -#### `GET /api/GetVisualizerResults` - -**Auth:** `func.AuthLevel.FUNCTION` +`function_app.py`. Endpoints use the existing function/SWA auth path and keep +non-HTTP logic in `hastegeo`. -**Description:** Return everything the View Results page needs for one model. -This is the primary read path for both workflows and the data source for the -vector footprint layer. The full response shape is documented in -`docs/api/hastefuncapi.md`; keep that API reference as the contract rather than -restating a divergent schema here (`docs/api/hastefuncapi.md:78-157`). - -**Key semantics:** - -- `footprintTilesUrl` and `predictionAttrsUrl` are API-relative - `GetModelArtifact` routes, not blob URLs. -- `predictedDamageLayer` and `predictionsLayer` are nullable. They are normally - present only for trained-inference models with prediction COGs. -- `predictionsReady` in this payload is stricter than the model-row flag because - it also requires browser artifacts to exist; `predictionsReadiness` explains - `ready`, `not_processed`, `no_predictions`, `no_buildings`, or `preparing`. -- `flavor`, `supportsThreshold`, and `buildingCount` come from reading the - selected prediction GeoPackage. If the file cannot be read, the payload still - returns imagery and readiness with those fields null. -- `predictionVersion` reports the edited version on the map (`null` for raw), - and `predictionVersions` returns `Model.editedPredictions` newest first. -- Optional `version` follows the shared reader contract: omit for newest edit, - `0` for raw, or `N` for a specific edited version - (`api/hastefuncapi/function_app.py:157-171`, - `api/hastefuncapi/function_app.py:2386-2435`). - -#### `GET /api/GetPredictionEditSession` +### `GET /api/GetVisualizerResults` (modified) **Auth:** `func.AuthLevel.FUNCTION` -**Description:** Return the additional data edit mode needs when it opens. The -endpoint uses `projectId` to load the image layer and model, distinguishes -trained inference from embedding predictions by reading the selected raw -GeoPackage, and reports whether PMTiles and the sidecar already exist. It is -side-effect-free: it does not enqueue preparation work. When preparation is -missing, the UI calls `PutPreparePredictionTilesQueueMessage` and then polls -this endpoint (`api/hastefuncapi/function_app.py:2920-3025`). - -**Query parameters:** - -| Name | Type | Required | Description | -|---|---|---|---| -| `projectId` | string | yes | Project metadata partition key. | -| `imageLayerId` | string | yes | Image layer that owns the source building footprints. | -| `modelId` | string | yes | Model whose raw `gpkgUrl` supplies predictions. | +**Description:** Return the View Results payload for one selected prediction +source. Omitting `version` selects the newest edited version when one exists; +`version=0` selects raw; `version=N` selects that edited version. Unknown +positive versions return 404, malformed values return 400 +(`api/hastefuncapi/function_app.py:2307-2340`, +`api/hastefuncapi/function_app.py:2386-2397`). -**Response (200):** `modelId`, `flavor`, `supportsThreshold`, -`defaultThreshold`, `buildingCount`, `tilesReady`, `attrsReady`, -`predictionTilesStatus`, `predictionTilesStatusMessage`, and `versions`. -Embedding models return `flavor="embedding"` and `supportsThreshold=false`, so -the UI hides threshold sliders (`api/hastefuncapi/function_app.py:3005-3025`). +**Additional/changed response fields:** -**Error Responses:** +| Field | Type | Description | +|---|---|---| +| `predictionAttrsUrl` | string/null | API-relative `GetModelArtifact?kind=prediction_attrs&version=` for the selected raw or edited sidecar. | +| `predictionVersion` | int/null | Positive edited version on the map; `null` for raw. | +| `predictionVersions` | array | `Model.editedPredictions`, newest first, including `predictionAttrsUrl` readiness. | +| `isNewestPredictionVersion` | bool | `true` when the map selection is the newest edited version, or raw when no edits exist; `false` for raw/older selections when a newer edit exists. | -| Code | Condition | -|---|---| -| 400 | Missing or malformed `projectId`, `imageLayerId`, or `modelId` | -| 404 | Model, image layer, or raw prediction GeoPackage not found | -| 500 | Storage or metadata failure | +**Decision:** This endpoint controls the map only. The UI must not pass the +selector's version to Validation or Assessment report buttons. -#### `PUT /api/PutPreparePredictionTilesQueueMessage` +### `GET /api/GetModelArtifact` (modified) **Auth:** `func.AuthLevel.FUNCTION` -**Description:** Queue the job that builds the layer footprint PMTiles and the -model prediction attribute sidecar. This route is the only HTTP endpoint that -requests prediction-edit preparation; `GetPredictionEditSession` remains -read-only. - -**Request:** +**Description:** Stream model artifacts through the Functions app so auth, +managed identity, content disposition, and HTTP Range stay centralized. The route +already serves `gpkg` and `prediction_attrs` from model fields +(`api/hastefuncapi/function_app.py:1400-1570`). It now resolves those two kinds +by optional `version`. -```json -{ - "projectId": "string — required", - "imageLayerId": "string — required", - "modelId": "string — required", - "force": "bool — optional; default false" -} -``` - -**Response (200):** `modelId`, `queued`, `tilesReady`, `attrsReady`, `status`, -and `statusMessage`, matching `request_preparation` (`hastelib/src/hastegeo/core/processors/prediction_tiles.py:251-370`). - -**Semantics:** - -- When both artifacts are already ready and `force` is false, the response has - `queued: false`, `tilesReady: true`, `attrsReady: true`, and nothing is - enqueued. -- When `Model.predictionTilesStatus` is already `Queued` or `InProgress` and - `force` is false, the response has `queued: false` and no duplicate message is - enqueued. -- Otherwise the model status is set to `Queued` and exactly one message is put - on `prediction-edit-prep-queue`. -- `force: true` rebuilds even when artifacts exist or a previous job is in - flight. - -**Error Responses:** +| Kind | Version handling | Returns | +|---|---|---| +| `gpkg` | omitted or `version=0` = raw `Model.gpkgUrl`; positive `version=N` = `EditedPredictionVersion.gpkgUrl`; unknown `N` = 404 | GeoPackage attachment | +| `prediction_attrs` | omitted or `version=0` = raw `Model.predictionAttrsUrl`; positive `version=N` = `EditedPredictionVersion.predictionAttrsUrl`; unknown/missing sidecar = 404 | JSON sidecar | +| `footprint_pmtiles` | ignores prediction version | Shared layer or embedding PMTiles | -| Code | Condition | -|---|---| -| 400 | Invalid JSON or validation failure for `projectId`, `imageLayerId`, `modelId`, or `force` | -| 404 | Model or image layer not found; no raw `Model.gpkgUrl`; or no `ImageLayer.buildingFootprintsUrl` to prepare from | -| 500 | Metadata or queue failure | +New UI downloads should always pass an explicit version (`0` for raw or `N` for +an edited row) so the selected artifact is unambiguous. -#### `PUT /api/PutEditedPredictions` +### `PUT /api/PutEditedPredictions` (modified) **Auth:** `func.AuthLevel.FUNCTION` -**Description:** Apply a threshold and explicit user overrides to the raw source -prediction GeoPackage, write a new edited GeoPackage, upload it under the next -numbered version, and append an `EditedPredictionVersion` entry to the `Model`. -The endpoint is synchronous in v1, but all geospatial work lives in `hastegeo` -(`api/hastefuncapi/function_app.py:3181-3345`). +**Description:** Apply overrides and thresholds, write a new edited GeoPackage, +write the matching versioned sidecar, and append metadata. The core helper now stores the GPKG and sidecar together +(`hastelib/src/hastegeo/core/processors/prediction_edits.py:520-608`). The API +route must adopt it before appending metadata +(`api/hastefuncapi/function_app.py:3186-3325`). -**Request:** +**Response (200):** ```json { - "projectId": "string — required", - "imageLayerId": "string — required", - "modelId": "string — required", - "threshold": "number — optional; default 0.0", - "unknownThreshold": "number — optional; default 0.0", - "overrides": [ - { "id": "integer row id", "class": "Damaged | NotDamaged | Unknown" } - ] + "version": 2, + "gpkgUrl": "https://storage/.../edited_predictions_5553_v2.gpkg", + "predictionAttrsUrl": "https://storage/.../prediction_attrs_5553_v2.json", + "editedCount": 17 } ``` -**Response (200):** `version`, `gpkgUrl`, and `editedCount`. - -**Error Responses:** - -| Code | Condition | -|---|---| -| 400 | Invalid JSON, threshold outside `[0,1]`, unknown threshold outside `[0,1]`, invalid class, duplicate override ids | -| 404 | Model, image layer, raw predictions, or source footprints not found | -| 422 | Source prediction and footprint GeoPackages do not line up row for row | -| 500 | Blob, metadata, or geospatial write failure | +**Failure rule:** If the sidecar cannot be generated or uploaded, the route must +not advertise the version in `Model.editedPredictions`. The sidecar is derived, +but it must agree with the GeoPackage or the map can silently draw wrong colors. -Override ids outside the source row range are ignored and logged rather than -rejected. The response `editedCount` counts only overrides that matched a row -(`hastelib/src/hastegeo/core/processors/prediction_edits.py:293-308`). +**Known limitation:** The route still has no 409/ETag concurrency protection for +simultaneous saves. That remains out of scope for this change. -#### `GET /api/GetEditedPredictionVersions` +### `GET /api/GetEditedPredictionVersions` (modified) **Auth:** `func.AuthLevel.FUNCTION` -**Query parameters:** +Returns the same version metadata list as before, now including +`predictionAttrsUrl` when present. Versions missing that URL are saved artifacts +but are not selectable until backfill completes. -| Name | Type | Required | Description | -|---|---|---|---| -| `projectId` | string | yes | Project metadata partition key. | -| `modelId` | string | yes | Model id. | - -**Response (200):** `{"versions": [EditedPredictionVersion, ...]}`, newest -first. The same helper backs the visualizer payload and the edit session -(`api/hastefuncapi/function_app.py:2912-2917`, -`api/hastefuncapi/function_app.py:3376-3410`). - -**Error Responses:** - -| Code | Condition | -|---|---| -| 400 | Missing or malformed `projectId` or `modelId` | -| 404 | Model not found | -| 500 | Metadata read failure | - -#### `GET /api/GetModelArtifact` (modified) +### `PUT /api/PutPreparePredictionTilesQueueMessage` (modified) **Auth:** `func.AuthLevel.FUNCTION` -Adds two `kind` values. The route streams bytes through the Function App so auth, -managed identity, and HTTP `Range` support remain central (`api/hastefuncapi/function_app.py:1430-1458`). - -| Kind | Required params | Returns | -|---|---|---| -| `footprint_pmtiles` | `projectId`, `imageLayerId`, `modelId` | Streamed bytes for the layer PMTiles, or the embedding model's own `pmtilesUrl` when available (`api/hastefuncapi/function_app.py:1489-1507`) | -| `prediction_attrs` | `projectId`, `modelId` | JSON sidecar for `prediction_attrs_${modelId}` (`api/hastefuncapi/function_app.py:1400-1424`) | - -The sidecar response uses the columnar format below. Arrays must be the same -length and order as the source prediction GeoPackage rows -(`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:351-416`). +Adds idempotent backfill support to the existing prediction-tiles job. The +request can include: ```json { - "n": 3, - "ids": [0, 1, 2], - "overtureIds": ["08b...", "08c...", "08d..."], - "damage": [0.0, 0.42, 0.8], - "unknown": [0.0, 0.2, 0.0], - "damaged": [0, 1, 1] + "projectId": "string", + "imageLayerId": "string", + "modelId": "string", + "force": false, + "backfillVersions": true } ``` -#### `GET /api/GetValidationReport`, `GET /api/GetAssessmentReport` (modified) +When `backfillVersions` is true, the worker builds missing +`prediction_attrs_${modelId}_v${version}` sidecars for existing +`Model.editedPredictions[]`. It skips versions with sidecars unless `force` is +true. It does not run from `GetVisualizerResults` or from `GetModelArtifact`, so +read requests stay free of generation logic. -Both report endpoints accept optional `version` with the same semantics as -`GetVisualizerResults`: omitted = newest edit or raw fallback, `0` = raw, and -`N` = a specific edited version. Unknown `N` returns 404 and malformed values -return 400 (`api/hastefuncapi/function_app.py:4607-4688`, -`api/hastefuncapi/function_app.py:4929-5027`, -`docs/api/hastefuncapi.md:480-502`). +### `GET /api/GetValidationReport`, `GET /api/GetAssessmentReport` (unchanged for selector) -Important asymmetry: edited GeoPackages rewrite `damaged` but preserve the -producer's original `damage_pct_0m`. `GetValidationReport` builds metrics from -`damaged`, so analyst overrides move validation metrics. `GetAssessmentReport` -feeds `damage_pct_0m` into `compute_assessment_report`, so per-building overrides -do not move its threshold-based damaged counts until a follow-up changes the -assessment data model (`api/hastefuncapi/function_app.py:4808-4827`, -`api/hastefuncapi/function_app.py:5080-5103`, -`hastelib/src/hastegeo/core/utils/assessment.py:150-190`). +Both endpoints keep their existing version contract: omitted = newest edited, +`version=0` = raw, explicit `version=N` = that edit, unknown `N` = 404 +(`api/hastefuncapi/function_app.py:4607-4688`, +`api/hastefuncapi/function_app.py:4929-5027`). The View Results selector does not +modify these report requests. -### Queue Messages (hastefuncqueues) - -#### Queue: `prediction-edit-prep-queue` - -**Message Schema:** - -```json -{ - "projectId": "string", - "imageLayerId": "string", - "modelId": "string — empty selects layer-only preparation", - "sourceGpkgUrl": "string — empty in layer-only mode", - "sourceFootprintsUrl": "string", - "force": false -} -``` - -**Trigger behavior (model-scoped, `modelId` set):** The worker downloads the -source footprints and raw prediction GeoPackage, validates equal row count and -positional row order, writes or refreshes `footprints_${imageLayerId}.pmtiles` -when missing, writes `prediction_attrs_${modelId}` from prediction columns, -uploads both artifacts, and updates `ImageLayer.footprintPmtilesUrl`, -`Model.predictionAttrsUrl`, `Model.predictedBuildingCount`, `Model.predictedAt`, -`Model.predictionTilesJob`, `Model.predictionTilesStatus`, and -`Model.predictionTilesStatusMessage` (`api/hastefuncqueues/function_app.py:721-827`). - -**Trigger behavior (layer-only, `modelId` empty):** The worker downloads the -source footprints only, writes `footprints_${imageLayerId}.pmtiles`, and updates -`ImageLayer.footprintPmtilesUrl`, `ImageLayer.footprintTilesJob`, -`ImageLayer.footprintTilesStatus`, and `ImageLayer.footprintTilesStatusMessage`. -No sidecar is built and no model document is read or written (`api/hastefuncqueues/function_app.py:639-719`, -`api/hastefuncqueues/function_app.py:877-914`). - -`ImageryPostProcessor` enqueues the layer-only message as soon as an image layer -completes with cached building footprints and no `footprintPmtilesUrl`. That -enqueue is best effort: a queue failure is logged and imagery preprocessing -still succeeds, because the visualizer/edit preparation path rebuilds tiles on -demand (`hastelib/src/hastegeo/core/processors/imagery.py:249-257`, -`hastelib/src/hastegeo/core/processors/imagery.py:399-441`). +The semantic gap remains: edited GeoPackages override `damaged` but preserve +`damage_pct_0m`. Validation reads `damaged`; Assessment thresholds +`damage_pct_0m`, so manual overrides still do not move Assessment counts +(`api/hastefuncapi/function_app.py:4808-4827`, +`api/hastefuncapi/function_app.py:5081-5092`). ### Internal Interfaces (hastegeo) -| Module | Function/Class | Signature | Description | +| Module | Function/Class | Signature / Contract | Description | |---|---|---|---| -| `core/models/projects.py` | `EditedPredictionVersion` | `BaseModel` | Embedded version metadata on `Model`; see [data-model.md](data-model.md#modified-document-schema). | -| `core/models/predictions.py` | `PredictionOverrideRequest`, `EditedPredictionsRequest`, `PreparePredictionTilesRequest` | `BaseModel` | Transport-only HTTP request bodies. | -| `core/utils/model_readiness.py` | `prediction_readiness`, `predictions_ready`, `annotate_predictions_ready` | `(model, config=None) -> PredictionReadiness/bool/dict` | Single model-readiness rule for UI payloads and publishing (`hastelib/src/hastegeo/core/utils/model_readiness.py:132-237`). | -| `core/utils/predictions.py` | `read_predictions` | `(path: str, footprints_path: Optional[str] = None) -> PredictionSet` | Detects `inference` vs `embedding`, normalizes row attributes, and resolves Overture ids by positional row order. | -| `core/utils/predictions.py` | `resolve_prediction_source`, `describe_prediction_source`, `edited_prediction_versions` | `(model, version=None) -> str/PredictionSource/list` | Implements newest-wins, `version=0` raw, and explicit edited version selection (`hastelib/src/hastegeo/core/utils/predictions.py:318-401`). | -| `core/processors/visualizer.py` | `build_visualizer_results`, `visualizer_readiness`, `raster_layer_urls` | pure payload helpers | Assemble vector-first `GetVisualizerResults` payload and nullable raster layers (`hastelib/src/hastegeo/core/processors/visualizer.py:152-336`). | -| `core/processors/prediction_edits.py` | `apply_edits` | `(src_gpkg, dst_gpkg, threshold, unknown_threshold, overrides, footprints_path=None) -> EditSummary` | Applies class derivation, preserves row order, and writes the edited GeoPackage. | -| `core/processors/prediction_edits.py` | `derive_class`, `next_version`, `store_edited_version` | helper functions | Compute final class, allocate the next version number, and store `edited_predictions_${modelId}_v${version}.gpkg`. | -| `core/processors/prediction_tiles.py` | `needs_preparation`, `request_preparation`, `resolve_tiles_url` | `(model, image_layer, force=False) -> flags/response` | Decide whether PMTiles/sidecar artifacts are ready and enqueue at most one prep message for the explicit PUT route. | -| `core/processors/prediction_tiles.py` | `layer_needs_footprint_tiles`, `enqueue_prediction_tiles`, `PredictionTilesPostprocessor` | helper / postprocessor | Queue and run model-scoped or layer-only tile prep. | -| `core/processors/imagery.py` | `ImageryPostProcessor._enqueue_footprint_tiles` | `() -> None` | Best-effort layer-only enqueue once a completed layer has footprints and no tiles; never raises into imagery prep. | -| `hastegeo/workflows/prepare_prediction_tiles.py` | `run` | `(config: dict, output_dir: str) -> dict` | Builds footprint PMTiles and, when `config["model_id"]` is set, the prediction attribute JSON sidecar. | -| `api/hastefuncapi/function_app.py` | `GetModelArtifact` | HTTP route | Adds `footprint_pmtiles` and `prediction_attrs` kinds. | +| `core/models/projects.py` | `EditedPredictionVersion` | add `predictionAttrsUrl: Optional[str]` | Version metadata carries both renderable artifacts. | +| `core/utils/prediction_attrs.py` | `build_prediction_attrs`, `write_prediction_attrs` | `(predictions_path, footprints_path, attrs_path?)` | Shared sidecar generation for raw, edited, and backfill paths. | +| `core/processors/prediction_edits.py` | `store_version_attrs`, `save_edited_version` | returns GPKG URL and sidecar URL | Writes derived artifacts for one version (`hastelib/src/hastegeo/core/processors/prediction_edits.py:437-488`, `hastelib/src/hastegeo/core/processors/prediction_edits.py:520-608`). | +| `core/processors/prediction_tiles.py` | `versions_needing_attrs`, `request_preparation` | `(model, image_layer, force=False, backfill_versions=True)` | Idempotently fills missing sidecars (`hastelib/src/hastegeo/core/processors/prediction_tiles.py:148-165`, `hastelib/src/hastegeo/core/processors/prediction_tiles.py:304-340`). | +| `core/processors/visualizer.py` | `model_artifact_url` | add optional `version` query | Builds versioned API-relative artifact URLs. | ## Behavior & Logic ### Core Flow -1. Analyst opens a model's **Results** menu and selects **View**. Trained rows - and embedding rows both navigate to `/visualizer/:projectId/:imageLayerId/:modelId`. -2. The View item is enabled from server-derived `predictionsReady`, with - client-side legacy fallbacks for models saved before the field existed. -3. `Visualizer` calls `GetVisualizerResults` without a `version` parameter, so - the newest edited version is selected by default when edits exist - (`ui/src/Components/Visualizer/Visualizer.jsx:213-223`). -4. The payload supplies imagery, nullable raster overlays, vector artifact URLs, - readiness, flavor, threshold support, building count, active version, and - version history. -5. `usePredictionArtifacts` fetches `prediction_attrs` and `footprint_pmtiles` - through `GetModelArtifact`. If the payload or artifact response says they are - missing, it lazily reads `GetPredictionEditSession`, calls - `PutPreparePredictionTilesQueueMessage`, and polls the session endpoint. -6. `usePredictionFootprints` adds the PMTiles source/layers to both swipe panes, - then colors features from sidecar attributes, current thresholds, and manual - overrides. -7. The analyst clicks the pencil next to Back or presses `E` to enter edit mode. - Rasters are hidden while editing and restored when edit mode exits. -8. The analyst clicks or ctrl+drag box-selects buildings, filters by `Damaged`, - `NotDamaged`, `Unknown`, or `edited`, and uses prev/next traversal. Keys - `1`, `2`, and `3` set the selected building's class in edit mode. -9. On save, the UI calls `PutEditedPredictions` with threshold, - unknownThreshold, and only explicit overrides. -10. The backend writes `edited_predictions_${modelId}_v${version}.gpkg`, appends - version metadata, and returns `{ version, gpkgUrl, editedCount }`. -11. The UI refreshes the version list and resets the unsaved baseline. Raw - `Model.gpkgUrl` remains unchanged. -12. `GetVisualizerResults`, `GetValidationReport`, and `GetAssessmentReport` - use the newest edit on later calls unless the caller pins `version` or passes - `version=0`. - -### Existing implementation constraints - -- Trained inference writes `id`, `damage_pct_0m`, `damage_pct_10m`, - `damage_pct_20m`, `damaged`, and `unknown_pct` in the raster CRS with the - default layer name. It sets `damaged` to `1` when `damage_pct_0m > 0` - (`docker/training/code/merge_with_building_footprints.py:221-258`). -- The classic writer can skip footprints outside raster bounds before writing - predictions, so the positional join can silently lose rows before this feature - ever sees the GeoPackage (`docker/training/code/merge_with_building_footprints.py:151-190`). -- The embedding workflow writes predictions through `PutBuildingPredictions`. It - uses layer name `"predictions"`, adds `area`, and sets `damage_pct_0m` to a - 0.0/1.0 copy of `damaged`, which makes thresholding meaningless for embedding - models (`api/hastefuncapi/function_app.py:2738-2815`). -- Neither producer writes an explicit `overture_id` column. Current reports join - prediction rows to Overture ids by reading the footprints in order and indexing - with the prediction row id (`hastelib/src/hastegeo/core/utils/assessment.py:368-395`, - `api/hastefuncapi/function_app.py:4808-4827`). Edited GeoPackages must keep - row order exactly and add `overture_id` for auditability. -- `GetBuildingFootprintsGeoJSON` remains only a sampled preview path; prediction - editing uses complete PMTiles + sidecar data through `GetModelArtifact`. -- PMTiles existed for the embedding workflow through the embedding model's own - archive. The viewer reuses that archive when available and otherwise uses the - layer-scoped `ImageLayer.footprintPmtilesUrl` - (`hastelib/src/hastegeo/core/processors/prediction_tiles.py:202-226`, - `api/hastefuncapi/function_app.py:1489-1507`). - -### Class derivation rule - -The editor supports exactly three classes. Recompute each row at save time using -source prediction values plus explicit user overrides: +1. Analyst opens **View Results** for a model. +2. `GetVisualizerResults` defaults to the newest edited version if one exists. +3. The payload lists raw plus edited versions, marks which one is newest, and + includes the selected version's `predictionAttrsUrl`. +4. The UI downloads PMTiles and the selected sidecar through `GetModelArtifact`. +5. The analyst changes the selector. The UI refetches `GetVisualizerResults` for + that version and switches both swipe panes together. +6. If the selected version is not newest, the UI states that Assessment and + Validation reports still use the newest version. +7. The analyst downloads the selected map version beside the selector, or a + specific saved row from the edit panel history. +8. When the analyst saves a new edit, the backend writes the edited GeoPackage + and matching sidecar, records both URLs, and returns them. +9. Pre-existing saved versions lacking sidecars are disabled until the backfill + job populates them. + +### Version selection rule + +| Selector value | Map source | Reports from View Results buttons | +|---|---|---| +| Latest / omitted | newest edited version, or raw if no edits | newest edited version, or raw if no edits | +| Raw (`version=0`) | raw `Model.gpkgUrl` + raw `Model.predictionAttrsUrl` | newest edited version if edits exist | +| Edited vN | `EditedPredictionVersion.gpkgUrl` + `predictionAttrsUrl` for vN | newest edited version if edits exist | + +This split is a deliberate product decision. It avoids adding mutable global +state and keeps report defaults stable, at the cost of possible map/report +mismatch that the UI must disclose. + +### Sidecar consistency rule + +A version is selectable only when both artifacts exist: ```text -final_class = override if the row was explicitly overridden by the user, else - "Unknown" if unknown_fraction > unknown_threshold (default 0.0), else - "Damaged" if damage_fraction > threshold, else - "NotDamaged" +EditedPredictionVersion.gpkgUrl exists +AND EditedPredictionVersion.predictionAttrsUrl exists ``` -The written `damaged` integer column is `1` when -`final_class == "Damaged"`; otherwise it is `0`. The edited GeoPackage also -writes `edited_class` (string), `edit_threshold` (float), and `overture_id` -(string). Row order must be preserved exactly from the source prediction -GeoPackage (`hastelib/src/hastegeo/core/processors/prediction_edits.py:246-279`). +The sidecar must be derived from the same edited GeoPackage rows. Building it +from raw predictions, lazily on first selection, or through a separate code path +that can drift is not allowed. + +### Backfill rule + +Backfill is a one-time, idempotent prediction-tiles job mode. It targets existing +versions that predate `predictionAttrsUrl`, including dev model `0448` v1 and +`5553` v1. During the backfill window, those versions remain visible in history +but disabled in the selector and in map-only download controls that require a +renderable sidecar. ### UI behavior -- The screen is the existing Visualizer route; there is no standalone - `PredictionEditor` directory or `/edit-predictions/...` route in the current - implementation (`ui/src/Components/AppBody.jsx:73-75`). -- The screen uses Azure Maps with PMTiles loaded through the shared in-memory - protocol pattern (`ui/src/Components/Visualizer/usePredictionArtifacts.js:201-212`). -- Styling uses Fluent UI `makeStyles` and `tokens` so the editor works in dark - mode. Hard-coded hex colors are not allowed for semantic UI colors - (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:72-80`, - `ui/src/Components/Visualizer/predictionFootprintMap.js:70-95`). -- Feature-state colors update live when overrides or thresholds change; the - source PMTiles are not regenerated in the browser. -- The right panel shows counts for `Damaged`, `NotDamaged`, `Unknown`, and - `edited`, plus filters, prev/next traversal, click-action mode, threshold - controls when supported, saved-version history, Save as new version, and Done - editing. -- The threshold slider appears only when `supportsThreshold` is true. Embedding - models can still be manually reclassified, but do not display the slider - (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:346-397`). -- The saved-version history is read-only in this branch. It shows which version - is currently on the map but does not refetch when a row is selected - (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:513-550`, - `ui/src/Components/Visualizer/Visualizer.jsx:213-223`). +- The selector includes Raw plus saved edited versions. +- Versions missing sidecars are disabled and explain that the backfill job has + not finished. +- The download beside the selector downloads the selected GeoPackage through + `GetModelArtifact?kind=gpkg&version=`. +- Each version-history row has its own download action using the row's version. +- The map-only warning appears when selected version is not newest. +- Both panes must reset source URL, sidecar cache, feature-state colors, and + selected/edited class baselines together. This class of partial-switch defect + has occurred before because feature-state is per renderer + (`ui/src/Components/Visualizer/usePredictionFootprints.js:19-25`, + `ui/src/Components/Visualizer/usePredictionFootprints.js:212-228`). ### Edge Cases | Case | Expected Behavior | |---|---| -| Missing raw `Model.gpkgUrl` | View/edit remains unavailable; direct session request returns 404. | -| Trained inference processed but no `gpkgUrl` and no `predictedDamageLayerUrl` | `predictionsReady` is false; results View is disabled or the visualizer explains there are no predictions. | -| Embedding `gpkgUrl` exists but `predictedBuildingCount` is `0` | `predictionsReady` is false with reason `no_buildings`; the visualizer should not queue a prep job that can never produce buildings (`hastelib/src/hastegeo/core/utils/model_readiness.py:168-198`). | -| Embedding model predates `predictedBuildingCount` | Falls back to `gpkgUrl` so older successful models remain viewable (`hastelib/src/hastegeo/core/utils/model_readiness.py:142-146`). | -| PMTiles or sidecar missing | `predictionsReadiness.reason` is `preparing`; UI requests prep and polls until artifacts are available. | -| Source prediction and footprint row counts differ | Save returns 422; prep records a failed `predictionTilesStatus` with a row-count message; no edited version is appended. | -| Duplicate override ids | PUT returns 400; client must de-duplicate before retrying. | -| Override id outside source range | Save succeeds; the override is ignored and not counted in `editedCount`. | -| Concurrent saves | Known limitation: backend uses `next_version` plus a metadata save without optimistic concurrency, so concurrent saves can collide instead of returning 409. | -| Invalid thresholds | PUT returns 400 for values outside `[0,1]`. | -| Very large layers | UI avoids GeoJSON; prep/save still read whole GeoPackages and must expose progress/failure logs. | -| User exits edit mode with unsaved edits | Visualizer shows a discard-confirmation dialog and either discards or keeps editing (`ui/src/Components/Visualizer/Visualizer.jsx:502-528`). | -| User wants to inspect older edits | API supports `version=N`, but UI selection is not wired; use the API directly or wait for follow-up UI work. | +| Missing raw `Model.gpkgUrl` | View/edit/download unavailable; artifact request returns 404. | +| Unknown positive version | `GetVisualizerResults` and `GetModelArtifact` return 404. | +| Malformed version | Return 400. | +| Edited version lacks sidecar | Selector disables it and says backfill has not completed. | +| Backfill rerun | Skips versions with sidecars; fills only missing ones unless `force=true`. | +| Sidecar write fails during save | Do not append/select the version; return an error. | +| Concurrent saves | Known gap: no 409 conflict handling yet. | +| Map shows older/raw while reports use newest | UI shows explicit warning; this is accepted behavior. | +| Assessment counts ignore manual overrides | Still out of scope because `damage_pct_0m` is preserved. | +| No Playwright coverage | Documented validation gap; repo has no Playwright config (`ui/package.json:6-15`, `ui/package.json:62-75`). | ### Error Handling | Error Condition | Response | Recovery | |---|---|---| -| Prep queue enqueue fails | `PutPreparePredictionTilesQueueMessage` returns 500 | Retry the prep request; the route is idempotent by readiness/status. | -| PMTiles generation fails | Visualizer status note reports not ready with status details; session continues to report failure | Queue retry/dead-letter; user can retry with force from the status note. | -| Attribute sidecar missing or invalid | UI blocks editing and reports a load failure | Regenerate prep artifacts with `force: true`. | -| Blob upload timeout on edited GeoPackage | `PutEditedPredictions` returns 500 | Retry save; if a blob exists without model metadata, next version allocation must not reuse it. | -| Metadata conflict appending version | Not detected in the current implementation | Follow up with ETag/lease-based optimistic concurrency before relying on multi-analyst collision safety. | -| Unknown explicit prediction version | Reader returns 404 | Refresh version history or use `version=0` for raw. | -| Malformed prediction version | Reader returns 400 | Fix the query parameter. | +| Versioned sidecar missing | 404 from artifact route; disabled UI state | Run or wait for backfill. | +| Backfill job fails | Version remains disabled with status details | Retry idempotent backfill. | +| Blob upload fails on sidecar save | Save returns 500 and does not advertise version | Retry save; inspect orphan cleanup if needed. | +| Metadata save fails after artifacts upload | Save returns 500; artifacts may be orphaned | Retry after checking version allocation. | +| Unknown explicit prediction version | 404 | Refresh version list or use raw/latest. | ### Known limitations / follow-ups -- UI version switching is not wired. The history is read-only; `predictionVersion` - reports what is on the map, but selecting another version does not refetch - (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:513-550`, - `ui/src/Components/Visualizer/Visualizer.jsx:213-223`). -- Edited GeoPackages override `damaged` but preserve the producer's - `damage_pct_0m`. `GetValidationReport` reads `damaged`, so edits move its - metrics; `GetAssessmentReport` thresholds `damage_pct_0m`, so per-building - overrides do not move threshold-based counts (`api/hastefuncapi/function_app.py:4808-4827`, - `api/hastefuncapi/function_app.py:5080-5103`). -- `PutEditedPredictions` does not implement the 409 conflict response that the - original draft proposed. `next_version` plus `MetadataProcessor.save` is a - read-modify-write sequence with no ETag, lease, or retry-safe compare step. -- API-level integration tests for the rewritten handlers are not present. - Current automated coverage is at the processor, workflow, wire-model, and UI - helper level. -- No browser or Playwright validation exists for the viewer or edit mode; this - repo currently has no Playwright configuration or dependency (`ui/package.json:6-15`, - `ui/package.json:62-75`). -- Two pre-existing correctness risks remain out of scope: the classic workflow - can drop footprint rows before writing predictions, and neither producer writes - `overture_id` in the raw prediction GeoPackage (`docker/training/code/merge_with_building_footprints.py:151-190`, - `docker/training/code/merge_with_building_footprints.py:221-258`, - `api/hastefuncapi/function_app.py:2738-2815`). -- `infra/modules/functions.bicep` does not include an explicit app-setting row - for `PREDICTION_EDIT_PREP_QUEUE_NAME`. This was intentionally skipped because - `Config` has a default, the Functions host can create the queue, and changing - the Bicep without regenerating `infra/main.json` would introduce infra drift. +- No 409 on concurrent edited-version saves; add ETag, lease, or retry-safe + allocation before multi-analyst editing. +- No Playwright/browser coverage exists today. +- Assessment report counts still threshold preserved `damage_pct_0m`; version + selection does not address this. +- Publishing edited versions remains out of scope. +- Raw producers still lack explicit `overture_id` and can rely on positional + joins. ## Configuration | Config Key | Type | Default | Where Set | Description | |---|---|---|---|---| -| `prediction_edit_prep_queue_name` | string | `prediction-edit-prep-queue` | `local.settings.json` / App Settings / `Config.get_queue_config()` | Queue used to generate missing PMTiles and sidecars (`hastelib/src/hastegeo/core/config.py:341-347`). | +| `prediction_edit_prep_queue_name` | string | `prediction-edit-prep-queue` | `local.settings.json` / App Settings / `Config.get_queue_config()` | Queue used for PMTiles, raw sidecar prep, and edited-version sidecar backfill. | -No feature flag is implemented in the current branch; the API routes and UI -entry points are present when the branch is deployed. No new third-party -dependency is required. PMTiles support already exists in the UI, and -`tippecanoe` already exists in the training image. +No new feature flag is part of this design. If production needs a kill switch, +add API/UI flags before broad rollout. ## Observability -- **Logs:** Log model/readiness decisions, visualizer version selection, queued - prep requests, source schema flavor, row-count validation, version allocation, - edit counts, and final artifact URLs without logging SAS tokens. -- **Metrics:** Track `GetVisualizerResults` readiness failures, prep duration, - save duration, edited GeoPackage size, and edited counts. -- **Queue depth:** Monitor `prediction-edit-prep-queue` depth and dead-letter - count. -- **Storage:** Alert on failed uploads for PMTiles, sidecars, and edited - GeoPackages. -- **UI errors:** Surface load, sidecar parse, prep timeout, and save errors in - the status note or edit panel with retry actions. +- **Logs:** Record selected version, newest flag, sidecar URL presence, disabled + sidecar state, save version allocation, and backfill skip/build counts. Do not + log SAS tokens. +- **Metrics:** Track sidecar save failures, backfill duration, disabled-version + counts, versioned downloads, and report/map mismatch warnings shown. +- **Queue depth:** Monitor prediction-edit prep queue during backfill. +- **UI errors:** Surface missing sidecars, map switch failures, and download + failures with retry guidance. ## Open Questions @@ -592,10 +343,7 @@ dependency is required. PMTiles support already exists in the UI, and Azure Functions request-timeout or memory budgets? - [ ] Should edited-version saves use Cosmos ETags, blob leases, or another optimistic-concurrency mechanism to prevent concurrent version collisions? -- [ ] Should the UI implement version switching by refetching - `GetVisualizerResults?version=N`, by adding a dedicated version-selection - endpoint, or by keeping the history read-only? -- [ ] Should assessment reports use edited `damaged`, persist edited +- [ ] Should Assessment reports use edited `damaged`, persist edited `damage_pct_0m`, expose override-aware counts separately, or keep the current threshold-only interpretation? - [ ] Should raw prediction producers add explicit `overture_id` and stop diff --git a/spec/features/prediction-editing/impact-analysis.md b/spec/features/prediction-editing/impact-analysis.md index 8845a2fa..a6a37bf1 100644 --- a/spec/features/prediction-editing/impact-analysis.md +++ b/spec/features/prediction-editing/impact-analysis.md @@ -8,23 +8,23 @@ | Component | Path | Type of Change | Severity | |---|---|---|---| -| Core library | `hastelib/src/hastegeo/core/models/`, `hastelib/src/hastegeo/core/processors/`, `hastelib/src/hastegeo/core/utils/`, `hastelib/src/hastegeo/core/config.py` | modified / new; adds version metadata, vector-results payload assembly, readiness, prep, source resolution, and edit writer | high | -| REST API | `api/hastefuncapi/function_app.py` | new edit/prep/version endpoints; vector-first `GetVisualizerResults`; `version` support in visualizer/validation/assessment readers; modified artifact dispatch | high | -| Queue workers | `api/hastefuncqueues/function_app.py` | new prep trigger with model-scoped and layer-only modes | medium | -| React UI | `ui/src/Components/ProjectManagement/`, `ui/src/Components/Visualizer/` | Results menu gating, embedding View Results entry, vector-first viewer, edit mode, and removal of standalone Edit route/screen | high | -| Docker config | `docker/training/` | no new package expected; uses existing `tippecanoe` in training env | low | -| CI/CD / infra | `.github/workflows/...`, `infra/modules/functions.bicep` | no workflow change; explicit Bicep app-setting parity for the prep queue was skipped to avoid `infra/main.json` drift | low | +| Core library | `hastelib/src/hastegeo/core/models/`, `core/utils/`, `core/processors/`, `core/config.py` | add versioned sidecar metadata, shared sidecar helpers, save-time sidecar write, and backfill helper | high | +| REST API | `api/hastefuncapi/function_app.py` | extend `GetVisualizerResults`, `GetModelArtifact`, and `PutEditedPredictions`; preserve report defaults | high | +| Queue workers | `api/hastefuncqueues/function_app.py` | add idempotent backfill mode to prediction-edit prep | medium | +| React UI | `ui/src/Components/Visualizer/`, `ui/src/Components/ProjectManagement/` | add version selector, warnings, disabled states, dual-pane switching, and downloads | high | +| Blob Storage | existing artifact container | add `prediction_attrs_${modelId}_v${version}` blobs | medium | +| CI/CD / infra | `.github/workflows/...` | no expected workflow change; Playwright remains absent | low | ## Azure Service Impact | Service | Change | New Cost Impact | |---|---|---| -| Cosmos DB | Model and ImageLayer documents gain optional fields; Model appends small version records; model reads derive `predictionsReady` in memory | low RU increase per session/save | -| Blob Storage | Stores PMTiles, sidecars, and one edited GeoPackage per save | proportional to footprint count and version count | -| Queue Storage | Adds prep messages for missing PMTiles/sidecars and layer-only footprint tiling | low; bursty when results are first opened for older layers | -| Azure Functions | Adds edit/prep/version routes and expands visualizer/report readers | low to medium CPU/memory during GeoPackage reads and saves | -| Azure Batch | Reuses existing runner/training image path for `tippecanoe` prep | low; CPU-bound tile jobs may occupy existing nodes | -| Static Web Apps | View Results now downloads and renders vector footprint artifacts; edit mode runs in the existing route | low hosting impact; browser memory is the main concern | +| Cosmos DB | Each `EditedPredictionVersion` stores one more URL; backfill updates existing version entries | low RU increase | +| Blob Storage | One compact sidecar JSON per edited version plus existing edited GeoPackage | proportional to version count and building count | +| Queue Storage | Backfill messages for historical versions | low, bounded by existing version count | +| Azure Functions | Save path does extra sidecar build/upload; artifact route resolves version metadata | medium during saves/downloads | +| Azure Batch / runners | Backfill and prep remain CPU-bound geospatial jobs | low to medium during backfill | +| Static Web Apps | UI downloads selected sidecar and GPKG through API routes | low hosting impact; browser memory unchanged per selected version | ## Dependency Analysis @@ -32,111 +32,84 @@ | Dependency | Type | Status | Risk if Unavailable | |---|---|---|---| -| Raw prediction GeoPackage (`Model.gpkgUrl`) | artifact | available after trained inference or embedding predictions | Edit session cannot open; save cannot derive a version. | -| Vector readiness flag (`predictionsReady`) | API-derived field | returned by model payload endpoints | UI falls back to legacy checks, but stale clients can diverge until refreshed. | -| Source building footprints (`ImageLayer.buildingFootprintsUrl`) | artifact | available after imagery prep | Cannot derive `overture_id`, build layer PMTiles, or validate row-order mapping. | -| Layer/model PMTiles (`footprintPmtilesUrl` or embedding `pmtilesUrl`) | artifact | generated at layer creation or on demand | Results page shows a preparing state and queues prep; without it no vector layer draws. | -| Prediction attribute sidecar (`Model.predictionAttrsUrl`) | artifact | generated on demand per model | Results page can show imagery but not predicted footprints or edit mode. | -| `tippecanoe` in training image | container tool | available only in training env | PMTiles cannot be generated from Functions inline (`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:40-45`). | -| PMTiles JS support | UI dependency | already present | Visualizer cannot stream full geometry efficiently. | -| Azure Maps | UI mapping | available in app | Results viewer loses primary visual interaction surface. | +| Raw prediction GeoPackage (`Model.gpkgUrl`) | artifact | required | Raw map, saves, and raw downloads unavailable. | +| Raw prediction sidecar (`Model.predictionAttrsUrl`) | artifact | required for raw vector rendering | Raw selection is disabled or reports not ready. | +| Edited GeoPackage (`EditedPredictionVersion.gpkgUrl`) | artifact | required per version | Version cannot be downloaded or backfilled. | +| Versioned sidecar (`EditedPredictionVersion.predictionAttrsUrl`) | artifact | required per selectable version | Version must be disabled in the selector. | +| Source building footprints | artifact | required for sidecar building and row-order validation | Save/backfill fails visibly. | +| Shared sidecar helper | core code | lives in `hastegeo.core.utils` and is imported by the workflow | Divergent sidecar builders could render wrong classes. | +| `GetModelArtifact` auth/range streaming | API route | existing route | Downloads would regress to direct SAS URL behavior. | ### Downstream Impact (things affected by this feature) | Consumer | How Affected | Breaking? | Migration Needed? | |---|---|---|---| -| `hastefuncapi` callers | New endpoints and artifact kinds; `GetVisualizerResults` adds vector fields and nullable raster fields | low risk | callers must null-check `predictedDamageLayer` and `predictionsLayer` (`docs/api/hastefuncapi.md:124-131`) | -| React model rows | Results View gating now uses `predictionsReady`; embedding rows gain View Results; standalone Edit buttons are gone | no | no data migration | -| Existing Cosmos documents | Optional fields absent until touched/backfilled; derived `predictionsReady` not persisted | no | no blocking migration | -| Visualizer | Changed from raster-first to vector-first; embedding workflow now has a usable entry point | yes for code path | existing route remains `/visualizer/...` (`ui/src/Components/AppBody.jsx:73-75`) | -| Validation report | Defaults to newest edited version and supports `version`; reads edited `damaged` | behavioral change | document raw access with `version=0` (`docs/api/hastefuncapi.md:480-502`) | -| Assessment report | Defaults to newest edited version and supports `version`; still thresholds preserved `damage_pct_0m` | behavioral nuance | product follow-up required for override-aware counts | -| Data publishing | Uses unified completion/readiness rule for eligibility but does not publish edited versions | no | follow-up spec required | +| View Results UI | Selector changes map sidecar/version; downloads selected version | yes for UI behavior | update UI state/tests | +| Validation report | Continues newest-edited default; selector does not affect it | behavioral nuance | UI warning required | +| Assessment report | Continues newest-edited default but still thresholds preserved `damage_pct_0m` | behavioral nuance | product follow-up required | +| Existing edited versions | Need backfilled sidecars before selection | temporary window | run idempotent backfill for `0448` v1 and `5553` v1 in dev | +| External callers of `GetModelArtifact?kind=gpkg` | Omitted/`version=0` continues raw; positive `version` adds edited downloads | additive | none | +| Data publishing | No active-version pointer or publishing changes | no | follow-up spec if needed | ## Risk Assessment | Risk | Likelihood | Impact | Mitigation | Owner | |---|---|---|---|---| -| Positional row-order invariant breaks Overture id mapping | medium | high | Assert row count and row order in prep/save tests; never sort or spatial-join edited output; write explicit `overture_id` for audit. | `gis` | -| Classic inference can silently drop footprint rows before prediction output | medium | high | Capture as a follow-up: the current writer skips out-of-bounds geometries and writes only `valid_building_geoms`, which can invalidate the positional join (`docker/training/code/merge_with_building_footprints.py:151-190`, `docker/training/code/merge_with_building_footprints.py:239-258`). | `gis` | -| Raw prediction GeoPackages lack `overture_id` | high | medium | Edited outputs add `overture_id`; open a producer-side follow-up so raw outputs do not rely solely on row order (`api/hastefuncapi/function_app.py:2738-2815`). | `gis`, `backend-dev` | -| Editor default threshold and `GetAssessmentReport` default differ | medium | medium | Document the current split: editor defaults to `0.0` to reproduce raw stored predictions, while `GetAssessmentReport` still defaults to threshold `0.1`; add product follow-up if this confuses users. | `backend-dev` | -| Edited `damaged` moves validation metrics but assessment thresholds preserved `damage_pct_0m` | high | medium | Document the asymmetry and decide whether assessment should consume overrides differently (`api/hastefuncapi/function_app.py:4808-4827`, `hastelib/src/hastegeo/core/utils/assessment.py:187-190`). | `backend-dev`, `gis` | -| Large layers exceed memory in tile prep, artifact loading, or edit application | medium | high | Keep browser geometry in PMTiles; measure whole-GPKG reads; add performance tests; move save to async if needed. | `backend-dev`, `gis`, `ui` | -| HTTP handler tries to run `tippecanoe` inline | low | high | Keep PMTiles generation in `prediction-edit-prep-queue`; test absence of inline generation path. | `backend-dev` | -| Embedding `gpkgUrl` is treated as a full prediction set after Clear labels | medium | medium | Gate on server-derived `predictionsReady`; `predictedBuildingCount == 0` returns `no_buildings` (`hastelib/src/hastegeo/core/utils/model_readiness.py:168-198`). | `backend-dev`, `ui` | -| Edited artifact overwrites raw output | low | high | Never write to `Model.gpkgUrl`; use `EDITED_PREDICTIONS_GPKG` with version in the name and append metadata. | `backend-dev` | -| UI version history appears selectable but does not switch versions | medium | low | Label active version clearly; document read-only history and add version-switching follow-up (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:513-550`). | `ui` | -| UI hard-coded colors fail dark mode | medium | medium | Require `makeStyles` + Fluent tokens; add UI review checklist item. | `ui` | -| UI lint remains red because of existing ESLint 9 flat-config mismatch | high | medium | Treat CI gate as no regression from baseline; record baseline failure and require targeted UI helper tests. | `ui-validation` | -| Concurrent edited-version saves collide | medium | medium | Current implementation has no 409/ETag conflict handling; add optimistic concurrency before relying on simultaneous multi-analyst saves. | `backend-dev` | -| Lack of browser/Playwright coverage misses visualizer regressions | high | medium | Add Playwright or explicitly waive with manual evidence; current repo has no Playwright config or dependency (`ui/package.json:6-15`, `ui/package.json:62-75`). | `ui-validation` | +| Versioned sidecar and edited GeoPackage disagree | medium | high | Build/store sidecar in the same save call path as the GPKG; do not advertise version until both URLs are recorded. | `backend-dev`, `gis` | +| Read path lazily generates sidecars and times out | medium | high | Run backfill through prediction-tiles job; `GetVisualizerResults`/`GetModelArtifact` return readiness/404 only. | `backend-dev` | +| Backfill leaves historical versions unselectable for a time | high | medium | Disable those versions and explain that sidecar prep is pending; run dev backfill for `0448` v1 and `5553` v1. | `backend-dev`, `ui` | +| Map version and reports disagree | high | medium | Record map-only decision; show explicit UI copy whenever selection is not newest. | `ui`, `backend-dev` | +| Only one swipe pane changes version | medium | high | Reset sidecar/source/feature-state for both renderers together; test the dual-pane case (`ui/src/Components/Visualizer/usePredictionFootprints.js:19-25`, `ui/src/Components/Visualizer/usePredictionFootprints.js:212-228`). | `ui` | +| Direct SAS URL download bypasses API auth/range path | medium | medium | Use `GetModelArtifact` for selector and history downloads; avoid URL rewriting (`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:61-69`). | `ui`, `backend-dev` | +| Concurrent edited-version saves collide | medium | medium | Document no 409/ETag handling; add follow-up before multi-analyst editing. | `backend-dev` | +| Edited `damaged` moves Validation but not Assessment counts | high | medium | Keep documented as out of scope; product decision needed for Assessment semantics. | `backend-dev`, `gis` | +| Lack of browser/Playwright coverage misses visual regressions | high | medium | Add helper tests and manual evidence; record no Playwright config (`ui/package.json:6-15`, `ui/package.json:62-75`). | `ui-validation` | +| Classic prediction row-order risks remain | medium | high | Preserve row-order validation and keep producer-side fix as follow-up. | `gis` | ## Performance Impact -- **Visualizer latency:** `GetVisualizerResults` may read the selected GeoPackage - to populate `flavor`, `supportsThreshold`, and `buildingCount`. If that read - fails, imagery/readiness still return (`api/hastefuncapi/function_app.py:2397-2423`). -- **API latency:** `GetPredictionEditSession` is read-only and does not enqueue, - but it downloads the raw prediction GeoPackage to detect flavor and count rows. - `PutPreparePredictionTilesQueueMessage` performs the queue request. - `PutEditedPredictions` reads and writes a full GeoPackage in v1, so large - layers may approach function timeout or memory limits. -- **Queue throughput:** New prep jobs are CPU and I/O bound. They should be - idempotent and skip PMTiles or sidecar generation when artifacts already exist. -- **Tile serving:** The visualizer uses static PMTiles artifacts, not TiTiler for - vector tiles. Tile serving load shifts to Function App streaming and - Blob/download bandwidth. -- **Browser memory:** The UI downloads the PMTiles archive and sidecar once per - visualizer route (`ui/src/Components/Visualizer/usePredictionArtifacts.js:177-221`). -- **Batch compute:** No GPU is needed. Existing training-image jobs may consume - CPU on the current runner pool while generating PMTiles. -- **Storage I/O:** Each first results open may download PMTiles and sidecar data; - each save writes a full edited GeoPackage. +- **Save path:** `PutEditedPredictions` now writes one compact sidecar in addition + to the edited GeoPackage. Large layers still drive memory and duration risk. +- **Backfill:** Backfill reads historical edited GeoPackages and footprints. It + is bounded, idempotent, and should skip versions that already have sidecars. +- **Visualizer latency:** Version switching refetches the payload and selected + sidecar. It does not rebuild artifacts. +- **Artifact streaming:** Versioned downloads use `GetModelArtifact`, preserving + Range support and central download behavior (`api/hastefuncapi/function_app.py:1539-1585`). +- **Browser memory:** Only the selected sidecar is active. Footprint PMTiles are + shared across versions. ## Security Impact -- [x] New API endpoints exposed? Use existing `func.AuthLevel.FUNCTION` and SWA - auth pattern. -- [x] New data classification handled? Edited predictions are derived disaster - assessment geospatial data, same sensitivity as raw model outputs. -- [x] Artifact access constrained? PMTiles and sidecars are streamed through - `GetModelArtifact`, preserving server-side auth and managed identity - rather than exposing raw blob URLs (`api/hastefuncapi/function_app.py:1435-1458`). -- [ ] MSAL/Entra ID auth changes? None expected. +- [x] New API surface uses existing Function App auth and same-origin UI calls. +- [x] Versioned downloads stream through `GetModelArtifact`; no new direct blob + SAS exposure is required. +- [x] Edited sidecars are derived disaster assessment data with the same + sensitivity as raw prediction sidecars. - [ ] New secrets or connection strings required? None expected. -- [ ] CORS configuration changes in SWA? None expected. -- [ ] New federated credentials needed? None expected. +- [ ] Component Governance scan implications? None unless implementation adds + dependencies; the design reuses existing packages. ## Compliance & Data Impact -- [x] Geospatial data sovereignty concerns? Same as raw project artifacts; - edited versions must stay in the project storage boundary. -- [x] Partner data sharing agreements affected? No external sharing automation - in v1; downloads are existing-authenticated artifact access. -- [x] New data retention requirements? Versioned edited GeoPackages increase - retained derived artifacts; retention follows project artifact retention. -- [x] Audit logging for new operations? Save logs should include project, - model, version, editor identity when available, and edited count. -- [ ] Component Governance scan implications? None unless implementation adds - dependencies; current design reuses existing packages. +- [x] Geospatial data stays in the project artifact boundary. +- [x] Versioned sidecars are retained with edited GeoPackages unless approved + cleanup tooling removes them. +- [x] Auditability improves because every selectable edited version has its own + render data and downloadable GPKG. +- [ ] Release notes must explain map-only selection and report-newest behavior. ## Rollback Assessment -- **Reversibility:** runtime behavior is reversible by redeploying the previous - UI/API. The current implementation has no feature flag kill switch. -- **Cosmos data:** Old code ignores optional `editedPredictions`, - `predictedBuildingCount`, `predictedAt`, `predictionAttrsUrl`, - `predictionTilesJob`, `predictionTilesStatus`, - `predictionTilesStatusMessage`, and `footprintPmtilesUrl`. Cleanup is - optional, not required for rollback. -- **Blob data:** Edited GeoPackages, sidecars, and PMTiles are additive derived - artifacts. They can be deleted by approved maintenance tooling if needed. -- **API:** New endpoints and artifact kinds are additive. `GetVisualizerResults` - now returns nullable raster layers; reverting API restores the old raster-only - contract if an external caller cannot tolerate nulls. -- **Reports:** If newest-edited defaults cause issues, callers can use - `version=0` as an immediate raw-output workaround while API rollback is - evaluated. -- **Estimated rollback time:** Immediate previous-build redeploy; less than 30 - minutes to redeploy a reverted UI/API if required. +- **Reversibility:** Revert UI selector/downloads and API versioned artifact + resolution if needed. The feature has no runtime flag. +- **Cosmos data:** `predictionAttrsUrl` on version entries is optional and safe + for older code to ignore. +- **Blob data:** Versioned sidecars are additive derived artifacts. They can be + left in storage after rollback. +- **Reports:** If default newest behavior causes confusion, callers can use + `version=0` for raw reports while a product follow-up is evaluated. +- **Backfill:** Stop or drain the prep queue if backfill fails repeatedly; no + read path depends on in-flight generation. +- **Estimated rollback time:** Previous-build redeploy for UI/API; less than 30 + minutes in the normal deployment path. diff --git a/spec/features/prediction-editing/plan.md b/spec/features/prediction-editing/plan.md index b06b619d..20bb6406 100644 --- a/spec/features/prediction-editing/plan.md +++ b/spec/features/prediction-editing/plan.md @@ -4,94 +4,79 @@ ## Phases -### Phase 1: Core Library — implemented +### Phase 1: Core Library — base implemented, versioned sidecars in progress -**Goal:** Implement core models, artifact naming, schema normalization, -versioned edit writing, readiness, and reader source selection in -`hastelib/src/hastegeo/`. +**Goal:** Preserve append-only edited GeoPackages and add per-version sidecars so +every selectable version has renderable class data. | Task | Agent | Dependencies | Story Ref | Status | |---|---|---|---|---| -| Add `EditedPredictionVersion`, `Model.editedPredictions`, `Model.predictedBuildingCount`, `Model.predictedAt`, `Model.predictionAttrsUrl`, `Model.predictionTilesJob`, `Model.predictionTilesStatus`, `Model.predictionTilesStatusMessage`, and `ImageLayer.footprintPmtilesUrl` | `backend-dev` | — | US-002, US-004 | complete (`hastelib/src/hastegeo/core/models/projects.py:343-505`, `hastelib/src/hastegeo/core/models/projects.py:520-529`, `hastelib/src/hastegeo/core/models/projects.py:842-851`) | -| Add transport-only wire models in `hastelib/src/hastegeo/core/models/predictions.py` | `backend-dev` | model fields | US-002, US-004 | complete | -| Add `EDITED_PREDICTIONS_GPKG`, `PREDICTION_ATTRS`, and `LAYER_FOOTPRINT_PMTILES` artifact types | `backend-dev` | — | US-002, US-004 | complete (`hastelib/src/hastegeo/core/config.py:165-172`) | -| Implement prediction schema detection for trained inference vs embedding outputs in `core/utils/predictions.py` | `backend-dev`, `gis` | model fields | US-002 | complete (`hastelib/src/hastegeo/core/utils/predictions.py:4-34`) | -| Implement row-order validation and Overture id extraction from source footprints | `gis` | schema detection | US-002, US-004 | complete (`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:322-416`) | -| Implement class derivation and edited GeoPackage writer in `core/processors/prediction_edits.py` | `backend-dev`, `gis` | row-order validation | US-004 | complete (`hastelib/src/hastegeo/core/processors/prediction_edits.py:226-308`) | -| Add one server-derived readiness rule in `core/utils/model_readiness.py` | `backend-dev` | model fields | US-001, US-002 | complete (`hastelib/src/hastegeo/core/utils/model_readiness.py:132-237`) | -| Add `resolve_prediction_source(model, version=None)` next to `read_predictions` | `backend-dev` | `Model.editedPredictions` | US-006 | complete (`hastelib/src/hastegeo/core/utils/predictions.py:332-401`) | -| Write unit tests for schema detection, class derivation, version allocation, row-order preservation, readiness, source resolution, and visualizer payload assembly | `backend-dev`, `gis` | all above | US-001, US-002, US-004, US-006 | complete (`hastelib/tests/core/utils/test_model_readiness.py:148-229`, `hastelib/tests/core/utils/test_prediction_source.py:89-188`, `hastelib/tests/core/processors/test_visualizer_payload.py:222-392`) | +| Keep `EditedPredictionVersion` and append-only `Model.editedPredictions`; raw `Model.gpkgUrl` remains unchanged | `backend-dev` | — | US-004 | complete (`hastelib/src/hastegeo/core/models/projects.py:343-389`, `api/hastefuncapi/function_app.py:3202-3204`) | +| Add `predictionAttrsUrl` to `EditedPredictionVersion` | `backend-dev` | versioned sidecar artifact | US-004, US-005 | complete (`hastelib/src/hastegeo/core/models/projects.py:356-389`) | +| Keep raw/model-scoped `PREDICTION_ATTRS = prediction_attrs_${modelId}` for raw predictions | `backend-dev` | — | US-002 | complete (`hastelib/src/hastegeo/core/config.py:172`) | +| Add versioned sidecar artifact type `prediction_attrs_${modelId}_v${version}` | `backend-dev` | artifact naming | US-004, US-005 | complete (`hastelib/src/hastegeo/core/config.py:178-180`) | +| Use shared `build_prediction_attrs` and `write_prediction_attrs` from `hastegeo.core.utils` | `backend-dev`, `gis` | sidecar schema | US-002, US-004, US-008 | complete (`hastelib/src/hastegeo/core/utils/prediction_attrs.py:128-202`, `hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:77-92`) | +| Update the prediction-tiles workflow to import the shared sidecar helpers | `gis` | shared helper move | US-002, US-008 | complete (`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:77-92`) | +| Add edited-version save helpers to build/store GeoPackage and sidecar in one call path | `backend-dev`, `gis` | shared helper, artifact naming | US-004 | complete (`hastelib/src/hastegeo/core/processors/prediction_edits.py:437-488`, `hastelib/src/hastegeo/core/processors/prediction_edits.py:520-608`) | +| Add idempotent edited-version sidecar backfill helper that skips versions with existing sidecars | `backend-dev`, `gis` | shared helper, model field | US-008 | complete (`hastelib/src/hastegeo/core/processors/prediction_tiles.py:148-165`, `hastelib/src/hastegeo/core/processors/prediction_tiles.py:304-340`) | +| Add unit tests for versioned sidecar template rendering, save-time sidecar consistency, and backfill skip/build behavior | `backend-dev`, `gis` | implementation above | US-004, US-008 | not-started | > **Agent column:** Use HASTE agent names (`backend-dev`, `gis`, `ui`, `security`). See [user-stories.md](user-stories.md#agent-assignment-map) for the full agent→story mapping. **Exit Criteria:** -- [x] `hastelib` unit tests cover both producer schemas and row-order preservation. -- [x] Edited GeoPackage generation works independently of the API layer. -- [x] Raw `Model.gpkgUrl` remains unchanged after saves. -- [x] Server readiness and raw-vs-edited source selection are pure helpers with targeted tests. +- [ ] Every new edited version has both `gpkgUrl` and `predictionAttrsUrl`. +- [ ] Shared sidecar helper is used by save-time generation and queue backfill. +- [ ] Backfill is idempotent and can target dev models `0448` v1 and `5553` v1. +- [ ] Raw `Model.gpkgUrl` and raw `Model.predictionAttrsUrl` semantics are unchanged. -### Phase 2: API Layer — implemented with known test gaps +### Phase 2: API Layer — versioned artifact contract in progress -**Goal:** Expose prediction editing and vector-first results through thin -`hastefuncapi` routes and a queued preparation worker. +**Goal:** Expose selected-version map payloads and downloads without adding lazy +generation to GET handlers. | Task | Agent | Dependencies | Story Ref | Status | |---|---|---|---|---| -| Add side-effect-free `GetPredictionEditSession` route | `backend-dev` | Phase 1 models | US-002 | complete (`api/hastefuncapi/function_app.py:2920-3025`) | -| Add `PutPreparePredictionTilesQueueMessage` route for explicit prep queue requests | `backend-dev` | `core/processors/prediction_tiles.py` | US-002 | complete | -| Add `PutEditedPredictions` route | `backend-dev` | edited GeoPackage writer | US-004 | complete (`api/hastefuncapi/function_app.py:3181-3345`) | -| Add `GetEditedPredictionVersions` route | `backend-dev` | Phase 1 models | US-005 | complete (`api/hastefuncapi/function_app.py:3376-3410`) | -| Extend `GetModelArtifact` with `footprint_pmtiles` and `prediction_attrs` kinds | `backend-dev` | artifact types | US-002, US-005 | complete (`api/hastefuncapi/function_app.py:1400-1510`) | -| Add `workflows/prepare_prediction_tiles.py` prep workflow (footprint PMTiles + attribute sidecar) | `gis` | Phase 1 prediction reader | US-002 | complete (`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:4-46`, `hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:322-416`) | -| Add `core/processors/prediction_tiles.py` runner orchestration | `gis` | prep workflow | US-002 | complete (`hastelib/src/hastegeo/core/processors/prediction_tiles.py:4-84`, `hastelib/src/hastegeo/core/processors/prediction_tiles.py:475-560`) | -| Add `prediction-edit-prep-queue` trigger in `hastefuncqueues` | `backend-dev`, `gis` | prep workflow | US-002 | complete (`api/hastefuncqueues/function_app.py:861-914`) | -| Build the layer's footprint PMTiles at image-layer creation (layer-only prep mode; `ImageLayer.footprintTiles*` fields; best-effort enqueue from `ImageryPostProcessor`) | `gis` | prep workflow, queue trigger | US-002 | complete (`hastelib/src/hastegeo/core/processors/imagery.py:249-257`, `hastelib/src/hastegeo/core/processors/imagery.py:399-441`) | -| Make `GetVisualizerResults` workflow-agnostic and vector-first (footprint tiles + attrs sidecar as `GetModelArtifact` routes, `predictionsReady`/readiness detail, `flavor`/`supportsThreshold`, nullable raster layers); payload assembly in `core/processors/visualizer.py` | `backend-dev` | `core/processors/prediction_tiles.py`, prediction reader | US-001, US-002, US-006 | complete (`api/hastefuncapi/function_app.py:2296-2435`, `hastelib/src/hastegeo/core/processors/visualizer.py:215-336`) | -| Surface `predictionsReady` on `GetLayerModelsDetails`, `GetProjectDetails`, and `GetLayerDetailView`; reuse the same completion rule in `core/publishing/source.py` | `backend-dev` | `core/utils/model_readiness.py` | US-001 | complete (`api/hastefuncapi/function_app.py:785-788`, `api/hastefuncapi/function_app.py:1262-1266`, `api/hastefuncapi/function_app.py:1380-1383`, `hastelib/src/hastegeo/core/publishing/source.py:116-124`) | -| Adopt `resolve_prediction_source(model, version=None)` and optional `version` query param in `GetVisualizerResults`, `GetValidationReport`, and `GetAssessmentReport` | `backend-dev` | `Model.editedPredictions` | US-006 | complete (`api/hastefuncapi/function_app.py:2386-2435`, `api/hastefuncapi/function_app.py:4677-4688`, `api/hastefuncapi/function_app.py:5017-5027`) | -| Document the full `GetVisualizerResults` shape and reader `version` behavior in the API docs | `backend-dev` | route implementation | US-002, US-006 | complete (`docs/api/hastefuncapi.md:78-157`, `docs/api/hastefuncapi.md:480-502`) | -| Add API integration tests for visualizer payloads, validation, readiness, save, and version-list responses | `backend-dev` | routes | US-002, US-004, US-005, US-006 | not-started | -| Add `infra/modules/functions.bicep` app-setting parity for the new queue | `backend-dev` | queue config | US-002 | skipped — `Config` has a default and changing Bicep without regenerating `infra/main.json` would create infra drift | +| Extend `GetModelArtifact` so `kind=gpkg` resolves raw with omitted/`version=0` and edited versions with `version=N` | `backend-dev` | `EditedPredictionVersion.gpkgUrl` | US-007 | in-progress (`api/hastefuncapi/function_app.py:1400-1570`) | +| Extend `GetModelArtifact` so `kind=prediction_attrs` resolves raw and versioned sidecars with the same `version` contract | `backend-dev` | versioned sidecar URLs | US-005, US-007 | in-progress (`api/hastefuncapi/function_app.py:1400-1570`) | +| Return 400 for malformed `version` and 404 for unknown positive versions in artifact and visualizer routes | `backend-dev` | version parsing | US-005, US-007 | in-progress (`api/hastefuncapi/function_app.py:157-171`, `api/hastefuncapi/function_app.py:2386-2397`) | +| Extend `GetVisualizerResults?version=N` to return the selected version's `predictionAttrsUrl` and `isNewestPredictionVersion` | `backend-dev` | visualizer payload model | US-005, US-006 | in-progress (`hastelib/src/hastegeo/core/models/visualizer.py:65-78`, `hastelib/src/hastegeo/core/processors/visualizer.py:272-329`) | +| Update `PutEditedPredictions` to call `save_edited_version`, return `predictionAttrsUrl`, and append it to `EditedPredictionVersion` | `backend-dev` | Phase 1 save helper | US-004 | in-progress (`api/hastefuncapi/function_app.py:3186-3325`) | +| Keep Assessment and Validation report buttons defaulting to newest; do not pass the View Results selector state to them | `backend-dev`, `ui` | product decision | US-006 | planned (`api/hastefuncapi/function_app.py:4607-4688`, `api/hastefuncapi/function_app.py:4929-5027`) | +| Add backfill mode to `PutPreparePredictionTilesQueueMessage` / queue worker | `backend-dev`, `gis` | Phase 1 backfill helper | US-008 | in-progress | +| Ensure backfill is not invoked from `GetVisualizerResults` or `GetModelArtifact` | `backend-dev` | API route review | US-005, US-008 | planned | +| Add API integration tests for visualizer version selection, artifact downloads, missing sidecars, unknown versions, and report default split | `backend-dev` | API changes | US-005-US-008 | not-started | **Exit Criteria:** -- [x] Endpoints are implemented as Azure Functions routes. -- [x] Missing PMTiles/sidecars are generated by the queue worker, not inline in HTTP. -- [x] Footprint PMTiles are built once per image layer at layer-creation time; the on-demand path still covers pre-existing layers. -- [x] `PutEditedPredictions` returns `version`, `gpkgUrl`, and `editedCount` for both producer schemas. -- [x] Readers default to the newest edited version and accept an explicit `version` override (no mutable "active version" pointer — see ADR-0005). -- [x] `GetVisualizerResults` returns a usable 200 payload for an embedding model, with the raster fields nullable rather than broken. -- [ ] Docker Compose local stack can exercise session prep and save. -- [ ] API-level integration tests exist for the new and modified routes. +- [ ] `GetVisualizerResults?version=N` selects the correct sidecar and reports whether it is newest. +- [ ] `GetModelArtifact?kind=gpkg&version=N` downloads edited versions through the Function App. +- [ ] `GetModelArtifact?kind=prediction_attrs&version=N` streams the matching sidecar. +- [ ] Unknown edited versions return 404; malformed versions return 400. +- [ ] Read paths never generate sidecars. -### Phase 3: UI — implemented with validation gaps +### Phase 3: UI — selector and downloads in progress -**Goal:** Use the existing View Results page as the prediction review and edit -surface with Azure Maps, PMTiles, Fluent UI, and existing HASTE interaction -patterns. +**Goal:** Let analysts select and download versions while clearly communicating +that reports still use newest. | Task | Agent | Dependencies | Story Ref | Status | |---|---|---|---|---| -| Remove the standalone `/edit-predictions/:projectId/:imageLayerId/:modelId` route and `PredictionEditor` screen; keep only `/visualizer/:projectId/:imageLayerId/:modelId` | `ui` | route component removal | US-001 | complete (`ui/src/Components/AppBody.jsx:73-75`) | -| Remove standalone model-row Edit buttons; make trained model Results → View use `predictionsReady` with a processed-inference fallback | `ui` | API model payload flag | US-001 | complete (`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:87-110`) | -| Add embedding View Results as the first Results menu item, gated by `predictionsReady` with a legacy `gpkgUrl` fallback | `ui` | API model payload flag | US-001 | complete (`ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:85-130`) | -| Add vector-first predicted-footprint rendering to the Visualizer through `usePredictionArtifacts`, `usePredictionFootprints`, and `predictionFootprintMap.js` | `ui` | `GetVisualizerResults`, `GetModelArtifact` | US-002, US-003 | complete (`ui/src/Components/Visualizer/Visualizer.jsx:166-199`, `ui/src/Components/Visualizer/usePredictionArtifacts.js:177-221`, `ui/src/Components/Visualizer/usePredictionFootprints.js:4-29`) | -| Add status-note handling for loading/preparing/empty/unavailable predicted buildings | `ui` | readiness contract | US-002 | complete (`ui/src/Components/Visualizer/PredictionStatusNote.jsx`, `ui/src/Components/Visualizer/predictionResults.js:320-385`) | -| Add pencil/Done affordance next to Back, `E` shortcut, and unsaved-edits discard confirmation | `ui` | vector footprint readiness | US-001, US-003 | complete (`ui/src/Components/Visualizer/Labels.jsx:117-128`, `ui/src/Components/Visualizer/Visualizer.jsx:496-605`, `ui/src/Components/keyboardShortcuts.js:7-17`) | -| Move the former editor right panel into `Visualizer/PredictionEditPanel.jsx` with filters, counts, edited filter, prev/next traversal, class controls, threshold sliders, save, and read-only version history | `ui` | map selection state | US-003, US-005 | complete (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:4-16`, `ui/src/Components/Visualizer/PredictionEditPanel.jsx:300-585`) | -| Add save-as-new-version action that calls `PutEditedPredictions`, refreshes versions, and resets the unsaved baseline | `ui` | `PutEditedPredictions`, versions API | US-004, US-005 | complete (`ui/src/Components/Visualizer/usePredictionFootprints.js:838-902`, `ui/src/Components/Visualizer/usePredictionArtifacts.js:159-168`) | -| Add active-version readout from `predictionVersion`/`predictionVersions` | `ui` | vector-first payload | US-005, US-006 | complete (`ui/src/Components/Visualizer/predictionResults.js:174-180`, `ui/src/Components/Visualizer/predictionResults.js:231-249`, `ui/src/Components/Visualizer/PredictionEditPanel.jsx:513-550`) | -| Wire version-history row selection to refetch `GetVisualizerResults?version=N` | `ui` | active-version UI design | US-005, US-006 | not-started — history is read-only and `getVisualizerResults` sends no `version` param (`ui/src/Components/Visualizer/Visualizer.jsx:213-223`) | -| Add one-click edited-version download action in the right panel | `ui` | version history display | US-005 | not-started | -| Add shared PMTiles protocol singleton in `ui/src/util/pmtiles.js` and use it from Visualizer artifact loading | `ui` | PMTiles map sources | US-002, US-003 | complete (`ui/src/Components/Visualizer/usePredictionArtifacts.js:25-32`, `ui/src/Components/Visualizer/usePredictionArtifacts.js:201-212`) | -| Add plain Node unit tests for `predictionClassify.js`, `predictionResults.js`, `predictionPrep.js`, `predictionFootprintMap.js`, and `visualizerSwipe.js` behavior | `ui` | UI helpers | US-001-US-006 | complete (`ui/src/Components/Visualizer/predictionClassify.test.js:388-407`, `ui/src/Components/Visualizer/predictionClassify.test.js:958-1030`, `ui/src/Components/Visualizer/predictionClassify.test.js:1112-1243`) | -| Add browser/Playwright coverage for View Results gating, vector loading, threshold visibility, selection, save flow, and version history | `ui-validation` | UI implementation | US-001, US-003, US-005 | not-started — this repo has no Playwright config or dependency (`ui/package.json:6-15`, `ui/package.json:62-75`) | +| Add View Results version selector with Raw, newest, and saved edited versions | `ui` | `GetVisualizerResults` version payload | US-005 | in-progress | +| Refetch `GetVisualizerResults?version=N` when the selector changes | `ui` | API version contract | US-005 | in-progress (`ui/src/Components/Visualizer/Visualizer.jsx:213-223`) | +| Disable versions missing `predictionAttrsUrl` and explain that backfill has not completed | `ui` | version metadata | US-005, US-008 | planned | +| Show a map-only/report-newest warning when selected version is not newest | `ui` | `isNewestPredictionVersion` flag | US-005, US-006 | planned | +| Switch both swipe panes together by clearing/reloading sidecar and feature-state for both renderers | `ui` | PMTiles/vector state | US-003, US-005 | planned (`ui/src/Components/Visualizer/usePredictionFootprints.js:19-25`, `ui/src/Components/Visualizer/usePredictionFootprints.js:212-228`) | +| Add download button beside the selector using `GetModelArtifact?kind=gpkg&version=` | `ui` | artifact route | US-007 | in-progress | +| Add per-row download action in the edit panel version history | `ui` | artifact route | US-007 | in-progress (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:513-550`) | +| Replace direct prediction GPKG blob/SAS download usage with `GetModelArtifact` for new versioned download paths | `ui` | artifact route | US-007 | planned (`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:61-69`) | +| Add helper/unit tests for selector options, disabled missing-sidecar versions, download URL generation, warning copy, and dual-pane state reset | `ui` | UI implementation | US-003, US-005, US-007, US-008 | not-started | +| Add Playwright/browser coverage | `ui-validation` | Playwright config | US-001-US-008 | not-started — repo has no Playwright config or dependency (`ui/package.json:6-15`, `ui/package.json:62-75`) | **Exit Criteria:** -- [x] Feature is accessible from both model-row workflows through View Results. -- [ ] Edit mode works with PMTiles and sidecar data in local SWA dev. -- [x] UI uses `makeStyles` and Fluent tokens; no hard-coded semantic hex colors in the edit panel/map helpers. -- [ ] UI validation shows no regression from the current lint baseline. -- [ ] Browser/Playwright validation exists for the Visualizer edit mode or is explicitly waived. +- [ ] Selecting a version changes only the map. +- [ ] Downloads are available beside the selector and per version-history row. +- [ ] Missing-sidecar versions are disabled with explanation. +- [ ] Both swipe panes switch without stale colors. +- [ ] UI helper tests pass; Playwright gap remains documented if no config is added. ### Phase 4: Integration & Deployment — TBD @@ -99,38 +84,40 @@ patterns. | Task | Agent | Dependencies | Story Ref | Status | |---|---|---|---|---| -| Run end-to-end Docker Compose scenario for trained-inference View Results and edit mode | `backend-dev`, `ui`, `gis` | Phases 1-3 | US-001, US-002, US-003, US-004, US-006 | not-started | -| Run end-to-end Docker Compose scenario for embedding View Results and edit mode | `backend-dev`, `ui`, `gis` | Phases 1-3 | US-001, US-002, US-003, US-004, US-006 | not-started | -| Verify versioned downloads and raw `Model.gpkgUrl` immutability | `backend-dev` | Phases 1-3 | US-004, US-005 | not-started | -| Verify `GetValidationReport`, `GetAssessmentReport`, and `GetVisualizerResults` default/newest, explicit version, and `version=0` raw behavior | `backend-dev`, `gis` | Phase 2 | US-006 | not-started | -| Verify Azure monitoring and queue dead-letter visibility | `backend-dev` | Phase 2 | US-002 | not-started | -| Update end-user docs only after behavior is implemented and validated | `ui` | Feature complete | US-001-US-006 | not-started | +| Run backfill for dev models `0448` v1 and `5553` v1 | `backend-dev`, `gis` | Phases 1-2 | US-008 | not-started | +| Verify View Results selector can show raw, newest, and an older edited version | `backend-dev`, `ui`, `gis` | Phases 1-3 | US-005 | not-started | +| Verify selector changes the map only and reports still read newest | `backend-dev`, `ui`, `gis` | Phases 2-3 | US-006 | not-started | +| Verify versioned downloads from selector and edit panel rows | `backend-dev`, `ui` | Phases 2-3 | US-007 | not-started | +| Verify disabled missing-sidecar state during a simulated backfill window | `ui`, `backend-dev` | Phases 2-3 | US-005, US-008 | not-started | +| Run targeted backend/unit tests for sidecar, save, backfill, and artifact resolution | `backend-validation` | Phases 1-2 | US-004, US-005, US-007, US-008 | not-started | +| Run targeted UI helper tests and record Playwright absence | `ui-validation` | Phase 3 | US-003, US-005, US-007 | not-started | **Exit Criteria:** -- [ ] Docker Compose validates both workflows. -- [ ] Targeted backend tests pass. -- [ ] Targeted UI helper tests pass; Playwright coverage is added or explicitly waived. -- [ ] CI passes or has a documented no-regression exception for the known UI lint baseline. -- [ ] Known follow-ups are triaged: UI version switching, API integration tests, browser validation, concurrent-save conflict handling, assessment/report semantics, and producer-side Overture ids. +- [ ] Dev backfill completes or failures are visible and actionable. +- [ ] Versioned sidecar and GeoPackage URLs match for every selectable version. +- [ ] Map/report mismatch warning is visible when applicable. +- [ ] Direct blob/SAS URL download paths are not used for new version downloads. +- [ ] Known follow-ups are triaged: concurrent-save 409, Playwright/browser validation, Assessment semantics, and producer-side Overture ids. ## Milestones | Milestone | Date | Deliverable | |---|---|---| -| Spec approved | TBD | Draft spec and ADR reviewed. | -| Core library done | TBD | Models, artifact types, class derivation, readiness, source resolution, and GeoPackage writer merged. | -| Prep/API done | TBD | Session, save, version list, artifact retrieval, vector-first visualizer, report version params, and queue prep working. | -| Results edit mode done | TBD | View Results entry, vector map, filters, threshold, overrides, version list, and save flow working. | +| Spec amended | 2026-08-25 | Draft spec and ADR describe per-version sidecars, map-only selection, downloads, and backfill. | +| Core/API contract done | TBD | Versioned sidecar schema, helpers, save path, artifact resolution, and backfill implemented. | +| UI selector/downloads done | TBD | View Results selector, warnings, disabled states, and downloads working. | +| Dev validation done | TBD | Backfill and E2E selector/download scenarios verified in dev. | | Release | TBD | Feature promoted after dev/test validation. | ## Agent Summary | Agent | Tasks Owned | Phases | |---|---|---| -| `backend-dev` | 21 | 1, 2, 4 | -| `gis` | 8 | 1, 2, 4 | -| `ui` | 15 | 3, 4 | -| `ui-validation` | 1 | 3 | +| `backend-dev` | data model, API, artifact resolution, save, backfill | 1, 2, 4 | +| `gis` | sidecar correctness, GeoPackage row order, backfill validation | 1, 2, 4 | +| `ui` | selector, warning, dual-pane switch, downloads | 3, 4 | +| `backend-validation` | targeted backend and queue validation | 4 | +| `ui-validation` | UI helper validation and Playwright-gap reporting | 3, 4 | | `security` | 0 | —; no new dependency is expected | ## Resource Requirements @@ -139,20 +126,17 @@ patterns. `ui-validation`. - **Azure services:** Existing Cosmos metadata store, Blob Storage, Queue Storage, Azure Functions, and existing Batch/runner capacity. -- **GPU compute:** None required for editing or tile generation. The training - container is used because it contains `tippecanoe`, not because GPU is needed. -- **External data:** None beyond existing project imagery, footprints, and model - prediction artifacts. +- **GPU compute:** None required for editing, sidecar generation, or backfill. +- **External data:** None beyond existing project imagery, footprints, raw + predictions, and edited GeoPackages. ## Open Questions -- [x] Confirm the concrete queue config key name before implementation. - Resolved: `prediction_edit_prep_queue_name` in `Config.get_queue_config()` - (env `PREDICTION_EDIT_PREP_QUEUE_NAME`, default `prediction-edit-prep-queue`, - `local-prediction-edit-prep-queue` in the Docker Compose stack). - [ ] Decide whether high-volume saves need an async save path after measuring - real production layer sizes. -- [ ] Decide whether UI version switching should refetch `GetVisualizerResults?version=N`, add a separate version-selection endpoint, or stay read-only. -- [ ] Decide how assessment counts should incorporate per-building overrides when edited GeoPackages preserve the producer's original `damage_pct_0m`. -- [ ] Add optimistic concurrency for simultaneous saves before supporting multi-analyst editing of the same model. -- [ ] Fix or explicitly mitigate the pre-existing positional-join risks: classic inference can drop footprint rows before writing predictions, and neither producer writes an explicit `overture_id` column (`docker/training/code/merge_with_building_footprints.py:151-190`, `docker/training/code/merge_with_building_footprints.py:221-258`, `api/hastefuncapi/function_app.py:2738-2815`). + production layer sizes. +- [ ] Add optimistic concurrency for simultaneous saves before supporting + multi-analyst editing of the same model. +- [ ] Decide how Assessment counts should incorporate per-building overrides + when edited GeoPackages preserve the producer's original `damage_pct_0m`. +- [ ] Fix or explicitly mitigate pre-existing positional-join risks in raw + prediction producers. diff --git a/spec/features/prediction-editing/rollout.md b/spec/features/prediction-editing/rollout.md index d4bd0f8d..93fb07a2 100644 --- a/spec/features/prediction-editing/rollout.md +++ b/spec/features/prediction-editing/rollout.md @@ -4,24 +4,27 @@ ## Rollout Strategy -**Type:** phased by environment deployment +**Type:** phased by environment deployment plus one-time backfill **Target date:** TBD -The current implementation does not include API or UI feature flags. Start with -internal dev/test deployments and test projects, then promote to production -after both trained-inference and embedding workflows can open View Results, -render vector footprints, enter Visualizer edit mode, save edited versions, and -read the expected version from visualizer, validation, and assessment readers. -Add feature flags as a follow-up if rollout needs a runtime kill switch. +The current design has no API or UI feature flag. Deploy first to dev/test, +verify new saves write GeoPackage + sidecar together, run idempotent backfill for +historical edited versions, then enable analysts to use the selector and +versioned downloads. + +Rollout must account for a temporary backfill window: pre-existing versions with +no `predictionAttrsUrl` cannot be selected, and the UI must disable them with a +clear explanation rather than drawing an empty or raw-colored map. ## Deployment Targets | Component | Deployment Method | Target | |---|---|---| -| `hastelib` | pip install / Docker rebuild | All Function Apps and queue workers | +| `hastelib` | pip install / Docker rebuild | Function Apps and queue workers | | `hastefuncapi` | GitHub Actions `deploy-apps.yml` | Azure Functions | | `hastefuncqueues` | GitHub Actions `deploy-apps.yml` | Azure Functions | | React UI | GitHub Actions `deploy-apps.yml` | Azure Static Web Apps | +| Backfill job | prediction-edit prep queue / maintenance command | Dev/test then production historical versions | ## Feature Flags @@ -34,31 +37,25 @@ Add feature flags as a follow-up if rollout needs a runtime kill switch. ### Phase 1: Dev1 Environment — TBD - **Target:** SWA `dev1` environment -- **Duration:** one sprint or until both workflows pass E2E validation +- **Duration:** one sprint or until selector/download/backfill validation passes - **Deployment:** - 1. Deploy the branch to dev1. - 2. Verify trained-inference and embedding View Results flows against test - projects. - 3. Verify edit mode is entered from the existing `/visualizer/...` route by - the pencil affordance and `E` shortcut, not a standalone editor route - (`ui/src/Components/AppBody.jsx:73-75`, - `ui/src/Components/Visualizer/Labels.jsx:117-128`). + 1. Deploy core/API/queue changes. + 2. Deploy UI selector/download changes. + 3. Run edited-sidecar backfill for known dev models `0448` v1 and `5553` v1. + 4. Verify the selector disables any version whose sidecar remains missing. - **Success criteria:** - - [ ] Server-derived `predictionsReady` enables Results consistently for - trained and embedding models. - - [ ] `GetVisualizerResults` returns the vector-first payload documented for - PMTiles, prediction attributes, readiness, version metadata, flavor, - and nullable classic rasters (`docs/api/hastefuncapi.md:78-157`). - - [ ] `PutPreparePredictionTilesQueueMessage` queues missing PMTiles and - sidecars only when needed. - - [ ] Queue workers generate missing PMTiles and sidecars. - - [ ] UI renders vectors, filters, selection, threshold behavior when - supported, and edit-mode entry/exit. - - [ ] Saving creates `edit_v1` without changing raw `Model.gpkgUrl`. - - [ ] Validation and assessment endpoints accept `version`; default behavior - selects the newest edited version while `version=0` selects raw. -- **Rollback trigger:** Any raw artifact mutation, repeated prep queue failures, - failed Visualizer payload contract, report reader regression, or browser + - [ ] New saves append `EditedPredictionVersion` with `gpkgUrl` and + `predictionAttrsUrl`. + - [ ] `GetModelArtifact?kind=gpkg&version=N` downloads edited versions through + the API route, not direct blob URL rewriting. + - [ ] `GetVisualizerResults?version=N` returns that version's + `predictionAttrsUrl` and `isNewestPredictionVersion`. + - [ ] Selecting raw or an older version changes the map only; reports still + read newest and the UI says so. + - [ ] Both swipe panes switch together with no stale colors. + - [ ] Backfill is idempotent and skips already-sidecarred versions. +- **Rollback trigger:** Raw artifact mutation, repeated sidecar mismatch, + versioned downloads bypassing `GetModelArtifact`, report regression, or browser crashes on representative layers. ### Phase 2: Testing Environment — TBD @@ -66,36 +63,29 @@ Add feature flags as a follow-up if rollout needs a runtime kill switch. - **Target:** SWA `testing` environment - **Duration:** one response exercise or agreed analyst validation window - **Success criteria:** - - [ ] Analysts can open View Results and save edited versions for trained - models. - - [ ] Analysts can open View Results and save edited versions for embedding - models. - - [ ] Analysts understand that version history is read-only in the UI: the - payload reports which version is mapped, but selecting another version - does not refetch in this branch. - - [ ] The documented split is accepted: validation metrics read edited - `damaged`, while assessment counts still threshold the producer's - preserved `damage_pct_0m`. - - [ ] Memory and duration metrics stay within accepted bounds. - - [ ] No regression from baseline UI lint behavior. -- **Rollback trigger:** Save failures above the agreed threshold, invalid - row-order output, confusing report semantics that block analyst use, or editor - performance that blocks analyst workflows. + - [ ] Analysts can select raw/newest/older versions on trained models. + - [ ] Analysts can select raw/newest/older versions on embedding models. + - [ ] Analysts can download selected versions and per-row versions. + - [ ] Analysts understand map-only selection: Assessment and Validation report + buttons continue to use newest. + - [ ] Backfill status is clear for any historical version not yet selectable. + - [ ] No regression from baseline UI lint/helper-test behavior. +- **Rollback trigger:** Sidecar/backfill failures above agreed threshold, + confusing map/report semantics that block analyst use, or partial swipe-pane + switching. ### Phase 3: Production — TBD - **Target:** Production SWA + Function Apps - **Federated credentials:** `fed-cred-main.json` (GitHub Actions OIDC) - **Success criteria:** - - [ ] Error rate and queue depth remain stable after production deployment. - - [ ] First production trained and embedding View Results sessions render - vector footprints. - - [ ] First production edited version downloads and validates row count/order. - - [ ] Validation report default/`version=0` behavior is verified on the first - edited production model. - - [ ] Assessment report asymmetry is visible in release notes and support + - [ ] Error rate and prep queue depth remain stable. + - [ ] First production save creates both GeoPackage and sidecar. + - [ ] First production versioned download uses `GetModelArtifact` and matches + the requested version. + - [ ] First production older-version map selection shows map/report warning. + - [ ] Assessment `damage_pct_0m` gap is documented in release notes/support guidance. - - [ ] Analyst feedback confirms the editor is usable in dark and light themes. - **Kill-switch follow-up:** If production requires runtime disablement, add the missing API/UI feature flags before broad enablement. @@ -103,16 +93,16 @@ Add feature flags as a follow-up if rollout needs a runtime kill switch. | Step | Action | Owner | ETA | |---|---|---|---| -| 1 | Redeploy the previous UI build to remove Visualizer edit-mode affordances and embedding View Results entry points | `ui` | <1 hour | -| 2 | Redeploy the previous API build if vector-first visualizer or prediction-editing calls must fail closed | `backend-dev` | <1 hour | -| 3 | Stop or drain `prediction-edit-prep-queue` if workers are failing | `backend-dev` | <30 min | -| 4 | Verify raw `Model.gpkgUrl`, classic raster results, and existing reports still work | `backend-validation` | <1 hour | -| 5 | Tell analysts that edited versions saved before rollback remain derived artifacts but may not be selected by the reverted UI/API | `orchestrator` | <1 hour | +| 1 | Redeploy previous UI build to remove selector/download affordances | `ui` | <1 hour | +| 2 | Redeploy previous API build if artifact version resolution regresses | `backend-dev` | <1 hour | +| 3 | Stop or drain `prediction-edit-prep-queue` if backfill fails repeatedly | `backend-dev` | <30 min | +| 4 | Verify raw `Model.gpkgUrl`, raw sidecar, and report defaults still work | `backend-validation` | <1 hour | +| 5 | Tell analysts that saved edited versions remain stored but older UI may not select/download them | `orchestrator` | <1 hour | -**Cosmos data rollback required?** no — new fields are optional and backward-compatible. -**Blob artifacts cleanup needed?** no for functional rollback — edited GeoPackages, -PMTiles, and sidecars are additive derived artifacts. Cleanup can run later if -storage cost requires it. +**Cosmos data rollback required?** no — `predictionAttrsUrl` on version entries is +optional and backward-compatible. +**Blob artifacts cleanup needed?** no for functional rollback — versioned +sidecars are additive derived artifacts. ## Monitoring & Alerting @@ -120,49 +110,44 @@ storage cost requires it. | Metric | Source | Baseline | Alert Threshold | |---|---|---|---| -| `GetVisualizerResults` error rate | Azure Functions metrics / Application Insights | existing route with new payload | >5% 5xx over 15 minutes | -| Prediction edit session error rate | Azure Functions metrics / Application Insights | new metric | >5% 5xx over 15 minutes | -| `PutEditedPredictions` duration and memory | Application Insights | new metric | p95 near function timeout or memory ceiling | -| Prep queue depth | Azure Queue Storage metrics | 0 when idle | sustained growth for 30 minutes | -| Prep job failures | queue worker logs / Batch task status | 0 | any repeated failure for same model or layer | -| Edited artifact upload failures | Blob SDK logs | 0 | any production failure | -| Validation/assessment report failures with `version` | Application Insights | new metric | repeated 4xx/5xx for valid version requests | -| Browser-side Visualizer errors | UI telemetry / support reports | 0 | repeated sidecar parse, PMTiles, or map-load failures | +| `GetVisualizerResults?version` error rate | Azure Functions / App Insights | new metric | >5% 5xx over 15 minutes | +| `GetModelArtifact` versioned download errors | Azure Functions / App Insights | new metric | repeated 4xx/5xx for valid versions | +| Save sidecar failures | App Insights logs | 0 | any repeated failure | +| Backfill queue depth | Azure Queue Storage metrics | 0 when idle | sustained growth for 30 minutes | +| Versions disabled due to missing sidecar | UI telemetry / logs | temporary during backfill | nonzero after backfill sign-off | +| Browser-side map switch errors | UI telemetry / support reports | 0 | repeated stale-pane or sidecar parse failures | ### Alerts to Configure | Alert | Condition | Severity | Notify | |---|---|---|---| -| Visualizer failures | `GetVisualizerResults` 5xx rate >5% over 15 minutes | P2 | Engineering on-call | -| Prep queue stalled | `prediction-edit-prep-queue` depth rising and no completions for 30 minutes | P2 | Engineering on-call | -| Save failures | `PutEditedPredictions` 5xx rate >5% over 15 minutes | P2 | Engineering on-call | -| Row-order validation failure | Any 422 row-count/order failure in production | P1 | Backend + GIS leads | -| Blob upload failures | Edited GeoPackage upload errors >0 for production saves | P2 | Engineering on-call | -| Report version regression | Valid `GetValidationReport` or `GetAssessmentReport` version requests fail repeatedly | P2 | Backend on-call | +| Versioned visualizer failures | `GetVisualizerResults?version` 5xx rate >5% over 15 minutes | P2 | Engineering on-call | +| Versioned download failures | valid `GetModelArtifact` version requests fail repeatedly | P2 | Backend on-call | +| Backfill stalled | Queue depth rising and no completions for 30 minutes | P2 | Engineering on-call | +| Sidecar mismatch/failure | Any save advertises a version without sidecar | P1 | Backend + GIS leads | +| Partial swipe switch | Support/telemetry shows one pane on stale colors | P2 | UI lead | ## Communication Plan | Audience | Channel | When | Message | |---|---|---|---| -| Engineering team | GitHub PR / Teams | Before dev1 deployment | Prediction editing has no runtime flags in this branch; verify vector-first View Results, readiness, report `version`, and raw artifact immutability before promotion. | -| Disaster analysts | Release notes / Teams | Before testing enablement | Open View Results for trained or embedding models, use the pencil or `E` to edit predictions in place, save numbered versions, and download them. Version switching in the UI is not wired yet. | -| Product / data science | Design review | Before testing sign-off | Validation reads edited `damaged`; assessment still thresholds preserved `damage_pct_0m`, so manual overrides do not move assessment counts until a follow-up decision. | -| Partners | Release notes | At production enablement | Edited prediction GeoPackages may be shared as downloadable derived files; use `version=0` for raw report inputs and the default/newest version for edited report inputs. | +| Engineering team | GitHub PR / Teams | Before dev1 deployment | Versioned sidecars are derived data but must be written with the GPKG; read paths do not generate them. | +| Disaster analysts | Release notes / Teams | Before testing enablement | Use View Results to choose raw or edited map versions and download them. Reports still use newest, and the UI will say when the map differs. | +| Product / data science | Design review | Before testing sign-off | Map-only selection is intentional; Validation reads edited `damaged`, while Assessment counts still threshold preserved `damage_pct_0m`. | +| Partners | Release notes | At production enablement | Downloaded GeoPackages identify the selected raw or edited version and are served through authenticated API routes. | ## Post-Rollout Checklist +- [ ] Backfill completed for known historical edited versions or failures are + tracked with disabled selector states. - [ ] Decide whether to add runtime feature flags before broad production use. -- [ ] Decide whether UI version switching should refetch visualizer/report data. - [ ] Decide whether `GetAssessmentReport` should count manual per-building overrides instead of only thresholding `damage_pct_0m`. - [ ] Open follow-up issues for concurrent-save 409/ETag handling, API integration tests, Playwright/browser validation, classic row-loss risk, and raw `overture_id` producer columns. -- [ ] Temporary rollout monitoring removed or converted to normal dashboards. -- [ ] End-user docs updated with Visualizer edit-mode workflow and versioned - report behavior. -- [ ] GitHub Pages docs rebuilt (`docs-deploy.yml`) if public docs changed. +- [ ] End-user docs updated with map-only version selection and download flow. - [ ] Docker Compose stack verified after release. - [ ] `CHANGELOG.md` updated. - [ ] Follow-up spec opened for publishing/downstream consumption if edited - versions need active selection outside the current readers. + versions need active selection outside the View Results map. diff --git a/spec/features/prediction-editing/test-plan.md b/spec/features/prediction-editing/test-plan.md index b0211e60..01908f32 100644 --- a/spec/features/prediction-editing/test-plan.md +++ b/spec/features/prediction-editing/test-plan.md @@ -6,12 +6,16 @@ | Level | Scope | Tool/Framework | Coverage Target | |---|---|---|---| -| Unit | `hastegeo` readiness, source resolution, schema detection, class derivation, sidecar generation, row-order preservation, and version allocation | pytest / unittest (`hastelib/tests/`) | all core rules, both producer schemas, and raw/newest/explicit version selection | -| Integration | `GetVisualizerResults`, prediction edit/prep/version HTTP endpoints, artifact retrieval, and report `version` query handling | pytest + Azure Functions test harness | success and negative responses; API-level tests for the rewritten handler are not implemented in the current branch | -| Queue | PMTiles and sidecar prep worker | pytest / Docker Compose worker test | idempotent generation, layer-only prep, model prep, and failure handling | -| UI | Existing Visualizer route, vector layer loading, edit-mode entry/exit, keyboard shortcut, discard confirmation, save flow, and read-only version history | Plain Node unit tests for helper modules today; browser/Playwright follow-up | critical analyst flows without a standalone editor screen | -| E2E | Full stack with trained and embedding predictions | Docker Compose + manual verification; Playwright unavailable today | View Results works for both workflows and at least one edited version can be saved | -| Performance | Large layer prep/save/browser memory | custom scripts with representative GeoPackages | no timeout/memory regression beyond agreed thresholds | +| Unit | sidecar generation, artifact template rendering, source resolution, save consistency, backfill skip/build | pytest / unittest (`hastelib/tests/`) | raw and edited sidecars agree with their GeoPackages | +| Integration | `GetVisualizerResults`, `GetModelArtifact`, save, backfill queue, and reports | pytest + Azure Functions test harness | version success and negative responses; report default split | +| Queue | prediction-tiles prep and edited-version sidecar backfill | pytest / Docker Compose worker test | idempotent generation and failure handling | +| UI | selector, disabled versions, map-only warning, downloads, dual-pane switching | existing plain Node helper tests plus manual/browser evidence | critical analyst flows without Playwright | +| E2E | full stack trained and embedding predictions | Docker Compose + manual verification | map selection, downloads, and backfill work for representative data | +| Performance | large layer save/backfill/browser memory | custom scripts with representative GeoPackages | no timeout/memory regression beyond agreed thresholds | + +No Playwright coverage is available in the current repo: `ui/package.json` has no +Playwright script or dependency (`ui/package.json:6-15`, `ui/package.json:62-75`). +Do not imply browser automation is solved unless that configuration is added. ## Test Scenarios @@ -19,167 +23,144 @@ | ID | Module | Scenario | Input | Expected Output | Story Ref | |---|---|---|---|---|---| -| UT-001 | `hastegeo/core/models/projects.py` | Model defaults | Model without optional prediction fields | `editedPredictions` behaves as empty list; prediction count/timestamps/sidecar/job/status fields nullable/defaulted | US-004, US-005 | -| UT-002 | `hastegeo/core/config.py` | Artifact template rendering | `modelId=123`, `version=2`, `imageLayerId=abc` | `edited_predictions_123_v2`, `prediction_attrs_123`, `footprints_abc` | US-002, US-004 | -| UT-003 | `hastegeo/core/utils/model_readiness.py` | Unified model-row readiness | Inference, embedding, empty, clear-label, and missing-artifact model states | One `predictionsReady` result and reason contract is applied across model payloads and publishing (`hastelib/src/hastegeo/core/utils/model_readiness.py:132-237`) | US-001, US-002 | -| UT-004 | `hastegeo/core/utils/predictions.py` | Source resolution | No `version`, `version=0`, explicit edited version, missing version | Defaults to newest edited version; `version=0` returns raw output; explicit version returns that edit or raises not found (`hastelib/src/hastegeo/core/utils/predictions.py:332-401`) | US-006 | -| UT-005 | `hastegeo/core/utils/predictions.py` | Trained schema detection | GPKG with `damage_pct_0m`, `damage_pct_10m`, `damage_pct_20m`, `damaged`, `unknown_pct` | `flavor="inference"`, `supportsThreshold=true` | US-002, US-003 | -| UT-006 | `hastegeo/core/utils/predictions.py` | Embedding schema detection | GPKG layer `predictions` with `area`, `damaged`, degenerate `damage_pct_0m` | `flavor="embedding"`, `supportsThreshold=false` | US-002, US-003 | -| UT-007 | `hastegeo/core/processors/prediction_edits.py` | Class derivation without override | damage `0.2`, unknown `0.0`, threshold `0.1` | `Damaged`, `damaged=1` | US-003, US-004 | -| UT-008 | `hastegeo/core/processors/prediction_edits.py` | Unknown wins before damage | damage `0.8`, unknown `0.3`, unknownThreshold `0.0` | `Unknown`, `damaged=0` | US-003, US-004 | -| UT-009 | `hastegeo/core/processors/prediction_edits.py` | Override wins over thresholds | override `NotDamaged`, damage `0.9` | `NotDamaged`, `damaged=0` | US-003, US-004 | -| UT-010 | `hastegeo/core/processors/prediction_edits.py` | Row-order invariant | Footprints ids `[a,b,c]`; predictions rows `[0,1,2]` | Edited rows remain `[0,1,2]` with `overture_id` `[a,b,c]` | US-004 | -| UT-011 | `hastegeo/core/processors/prediction_edits.py` | Row-count mismatch | Footprints 3 rows; predictions 2 rows | Raises validation error; no version metadata appended | US-002, US-004 | -| UT-012 | `hastegeo/core/processors/prediction_edits.py` | Version allocation | Existing versions `[1,2]` | Next artifact uses version `3`; concurrent-save conflict is a known follow-up, not expected here | US-004, US-005 | -| UT-013 | `hastegeo/workflows/prepare_prediction_tiles.py` | Sidecar shape | Three prediction rows | JSON has `n=3` and same-length `ids`, `overtureIds`, `damage`, `unknown`, `damaged` arrays | US-002 | -| UT-014 | `hastegeo/core/models/predictions.py` | Wire request validation | Save/prep request bodies | Invalid IDs, thresholds, classes, duplicate override IDs rejected before processors run | US-002, US-004 | -| UT-015 | `hastegeo/core/processors/prediction_tiles.py` | Prep request idempotency | Ready, missing, in-flight, forced model, and layer-only states | Returns `{modelId, queued, tilesReady, attrsReady, status, statusMessage}` and enqueues at most one message | US-002 | +| UT-001 | `core/models/projects.py` | Version metadata schema | Edited version with GPKG and sidecar URLs | `EditedPredictionVersion` stores `gpkgUrl` and `predictionAttrsUrl` without changing `Model.gpkgUrl` | US-004 | +| UT-002 | `core/config.py` | Artifact template rendering | `modelId=5553`, `version=2` | `edited_predictions_5553_v2` and `prediction_attrs_5553_v2`; raw sidecar remains `prediction_attrs_5553` | US-004, US-005 | +| UT-003 | `core/utils/prediction_attrs.py` | Raw sidecar shape | Raw prediction GPKG + matching footprints | JSON arrays have equal length/order and expected `damaged` values | US-002 | +| UT-004 | `core/utils/prediction_attrs.py` | Edited sidecar shape | Edited GPKG with overrides | Sidecar `damaged` and class inputs reflect edited rows, not raw rows | US-004, US-005 | +| UT-005 | `core/utils/prediction_attrs.py` | Row-count mismatch | Predictions and footprints differ | Raises validation error; no sidecar written | US-002, US-004 | +| UT-006 | `core/utils/predictions.py` | Source resolution | No `version`, `version=0`, explicit edited version, missing version | Newest/raw/explicit behavior remains correct; unknown positive version raises not found | US-006 | +| UT-007 | `core/processors/prediction_edits.py` | Save consistency | Overrides and thresholds | Store helper returns both GPKG URL and versioned sidecar URL | US-004 | +| UT-008 | `core/processors/prediction_edits.py` | Sidecar failure | Simulated sidecar upload failure | Version metadata is not appended/advertised | US-004 | +| UT-009 | `core/processors/prediction_tiles.py` | Backfill builds missing sidecar | Edited version has `gpkgUrl` and no `predictionAttrsUrl` | Writes `prediction_attrs_${modelId}_v${version}` and updates metadata | US-008 | +| UT-010 | `core/processors/prediction_tiles.py` | Backfill idempotent skip | Version already has sidecar and `force=false` | No rebuild; metadata unchanged | US-008 | +| UT-011 | `core/processors/visualizer.py` | Versioned artifact URL | Selected raw, v1, v2 | `predictionAttrsUrl` includes the selected version query; `isNewestPredictionVersion` is correct | US-005 | +| UT-012 | `core/processors/visualizer.py` | Report split metadata | Map selected v2 while v3 exists | Payload supports UI warning without changing reports | US-005, US-006 | ### API Integration Tests -No prediction-editing API integration tests are implemented in the current -branch. `api/hastefuncapi/tests/` contains only publishing-route coverage; the -cases below remain follow-up coverage for the rewritten handlers. - | ID | Endpoint | Method | Scenario | Preconditions | Expected Response | Story Ref | |---|---|---|---|---|---|---| -| IT-001 | `/api/GetVisualizerResults` | GET | Ready trained model | Processed inference model with raw GPKG, footprint PMTiles, and sidecar | 200 with `footprintTilesUrl`, `predictionAttrsUrl`, readiness object, `flavor="inference"`, `supportsThreshold=true`, `predictionVersion`, `predictionVersions`, and nullable raster fields as documented (`docs/api/hastefuncapi.md:78-157`) | US-002, US-006 | -| IT-002 | `/api/GetVisualizerResults` | GET | Ready embedding model | Embedding model with `gpkgUrl`, PMTiles, sidecar, and `predictedBuildingCount>0` | 200 with vector fields, `flavor="embedding"`, `supportsThreshold=false`, and no required classic rasters | US-001, US-002 | -| IT-003 | `/api/GetVisualizerResults` | GET | Explicit raw version | Model with edited versions; query `version=0` | Payload reports raw source version and raw building count/readiness | US-006 | -| IT-004 | `/api/GetVisualizerResults` | GET | Explicit edited version | Model with version `2`; query `version=2` | Payload reports `predictionVersion=2` and selects the edited GeoPackage | US-006 | -| IT-005 | `/api/GetVisualizerResults` | GET | Missing prep artifacts | Raw GPKG exists, PMTiles/sidecar absent | 200 with readiness false, null vector URLs as applicable, and `predictionsReadiness` reason | US-002 | -| IT-006 | `/api/GetPredictionEditSession` | GET | Ready trained model | Processed inference model with raw GPKG, PMTiles, sidecar | 200 with `flavor="inference"`, `supportsThreshold=true`, `defaultThreshold=0.0`, readiness flags, and prep status fields | US-002, US-003 | -| IT-007 | `/api/GetPredictionEditSession` | GET | Ready embedding model | Embedding model with `gpkgUrl` and `predictedBuildingCount>0` | 200 with `flavor="embedding"`, `supportsThreshold=false` | US-002, US-003 | -| IT-008 | `/api/GetPredictionEditSession` | GET | Missing prep artifacts | Raw GPKG exists, PMTiles/sidecar absent | 200 with readiness false and no queued message | US-002 | -| IT-009 | `/api/PutPreparePredictionTilesQueueMessage` | PUT | Queue missing prep | Raw GPKG and building footprints exist; artifacts missing | 200 with `queued=true`, `status="Queued"`, and exactly one queue message | US-002 | -| IT-010 | `/api/PutPreparePredictionTilesQueueMessage` | PUT | Ready no-op | PMTiles and sidecar already exist; `force=false` | 200 with `queued=false`, `tilesReady=true`, `attrsReady=true`, no queue message | US-002 | -| IT-011 | `/api/PutPreparePredictionTilesQueueMessage` | PUT | In-flight no-op | `predictionTilesStatus` is `Queued` or `InProgress`; `force=false` | 200 with `queued=false`, current status, no duplicate queue message | US-002 | -| IT-012 | `/api/PutPreparePredictionTilesQueueMessage` | PUT | Missing source inputs | No `gpkgUrl` or no `buildingFootprintsUrl` | 404 | US-002 | -| IT-013 | `/api/PutEditedPredictions` | PUT | Save first edit | Valid thresholds and overrides from Visualizer edit mode | 200 with `version=1`, `gpkgUrl`, `editedCount`; Model gets one version | US-004 | -| IT-014 | `/api/PutEditedPredictions` | PUT | Invalid threshold | `threshold=2` | 400 | US-004 | -| IT-015 | `/api/PutEditedPredictions` | PUT | Override out of range | `id >= buildingCount` | 200; unmatched override ignored and not counted | US-004 | -| IT-016 | `/api/GetEditedPredictionVersions` | GET | Existing versions | Model has versions | 200 with version metadata list, newest first | US-005 | -| IT-017 | `/api/GetModelArtifact` | GET | Fetch new artifact kinds | Prepared PMTiles and sidecar | 200 for `footprint_pmtiles` and JSON `prediction_attrs` | US-002, US-005 | -| IT-018 | `/api/GetValidationReport` | GET | Edited version selected by default | Model has edited version whose `damaged` differs from raw | Default response reflects newest edit; `version=0` restores raw (`api/hastefuncapi/function_app.py:4607-4688`) | US-006 | -| IT-019 | `/api/GetAssessmentReport` | GET | Edited version selected by default | Model has edited version whose `damaged` differs but `damage_pct_0m` is preserved | Default reader opens newest edit, but thresholded counts remain tied to `damage_pct_0m`; this asymmetry is documented (`api/hastefuncapi/function_app.py:4929-5027`) | US-006 | -| IT-020 | `/api/GetPredictionEditSession` | GET | Missing model | Unknown `modelId` | 404 | US-002 | +| IT-001 | `/api/GetVisualizerResults` | GET | Default newest map | Model has raw and versions 1, 2 | 200 selects version 2 and returns version 2 sidecar URL | US-005 | +| IT-002 | `/api/GetVisualizerResults` | GET | Explicit raw map | Query `version=0` | 200 uses raw `Model.predictionAttrsUrl`; newest flag false when edits exist | US-005 | +| IT-003 | `/api/GetVisualizerResults` | GET | Explicit older map | Query `version=1` and version 2 exists | 200 uses v1 sidecar; `isNewestPredictionVersion=false` | US-005, US-006 | +| IT-004 | `/api/GetVisualizerResults` | GET | Unknown version | Query `version=99` | 404 | US-005 | +| IT-005 | `/api/GetVisualizerResults` | GET | Malformed version | Query `version=abc` | 400 | US-005 | +| IT-006 | `/api/GetVisualizerResults` | GET | Missing sidecar | Version has GPKG but no `predictionAttrsUrl` | Payload lists version as disabled/unready or route returns documented non-selectable state | US-005, US-008 | +| IT-007 | `/api/GetModelArtifact` | GET | Raw GPKG download | `kind=gpkg&version=0` | 200 attachment from raw `Model.gpkgUrl` | US-007 | +| IT-008 | `/api/GetModelArtifact` | GET | Edited GPKG download | `kind=gpkg&version=2` | 200 attachment from `EditedPredictionVersion.gpkgUrl` | US-007 | +| IT-009 | `/api/GetModelArtifact` | GET | Raw attrs download | `kind=prediction_attrs&version=0` | 200 JSON from raw `Model.predictionAttrsUrl` | US-005 | +| IT-010 | `/api/GetModelArtifact` | GET | Edited attrs download | `kind=prediction_attrs&version=2` | 200 JSON from `EditedPredictionVersion.predictionAttrsUrl` | US-005, US-007 | +| IT-011 | `/api/GetModelArtifact` | GET | Missing edited sidecar | Version lacks sidecar URL | 404; no lazy generation | US-005, US-008 | +| IT-012 | `/api/PutEditedPredictions` | PUT | Save first new version | Valid overrides | 200 returns `version`, `gpkgUrl`, `predictionAttrsUrl`, `editedCount`; model stores both URLs | US-004 | +| IT-013 | `/api/PutEditedPredictions` | PUT | Sidecar generation failure | Mock helper failure | 500 or documented failure; version not appended | US-004 | +| IT-014 | `/api/PutPreparePredictionTilesQueueMessage` | PUT | Backfill missing versions | `backfillVersions=true` | Queues/executes backfill and skips ready versions | US-008 | +| IT-015 | `/api/GetValidationReport` | GET | Selector map v1, report default | Model has versions 1 and 2 | Report default uses version 2 because UI does not pass selector version | US-006 | +| IT-016 | `/api/GetAssessmentReport` | GET | Preserved fraction gap | Edited `damaged` differs but `damage_pct_0m` preserved | Endpoint opens selected/default GPKG, but threshold counts remain tied to `damage_pct_0m` | US-006 | ### Queue Worker Tests | ID | Queue | Scenario | Message | Expected Side Effect | Story Ref | |---|---|---|---|---|---| -| QT-001 | `prediction-edit-prep-queue` | Build missing PMTiles and sidecar | valid project/layer/model/source urls | PMTiles and sidecar blobs uploaded; metadata fields updated | US-002 | -| QT-002 | `prediction-edit-prep-queue` | Idempotent no-op | artifacts already exist and `force=false` | No duplicate work; metadata remains consistent | US-002 | -| QT-003 | `prediction-edit-prep-queue` | Force rebuild | artifacts exist and `force=true` | Artifacts regenerated and metadata timestamp refreshed | US-002 | -| QT-004 | `prediction-edit-prep-queue` | Malformed message | neither `modelId` nor `imageLayerId` | Worker logs validation error and fails without partial metadata | US-002 | -| QT-005 | `prediction-edit-prep-queue` | Row-count mismatch | predictions and footprints lengths differ | Prep fails; no `predictedAt` update | US-002 | -| QT-006 | `prediction-edit-prep-queue` | Layer-only prep | empty `modelId`, layer with footprints | PMTiles blob uploaded; only `ImageLayer.footprintPmtilesUrl`/`footprintTiles*` written; no sidecar and no model document touched | US-002 | -| QT-007 | `prediction-edit-prep-queue` | Layer-only no-op | empty `modelId`, layer already has `footprintPmtilesUrl`, `force=false` | No job submitted; layer marked `Processed` | US-002 | -| QT-008 | imagery prep (`ImageryPostProcessor`) | Layer-time scheduling | layer completes with cached footprints and no tiles | Exactly one layer-only message enqueued; none when tiles exist or the footprint step errored; enqueue failure never fails imagery prep | US-002 | +| QT-001 | `prediction-edit-prep-queue` | Backfill dev model 0448 | model `0448`, version 1 missing sidecar | Sidecar uploaded and metadata updated, or visible failure status | US-008 | +| QT-002 | `prediction-edit-prep-queue` | Backfill dev model 5553 | model `5553`, version 1 missing sidecar | Sidecar uploaded and metadata updated, or visible failure status | US-008 | +| QT-003 | `prediction-edit-prep-queue` | Idempotent no-op | all versions already have sidecars | No duplicate artifact writes when `force=false` | US-008 | +| QT-004 | `prediction-edit-prep-queue` | Force rebuild | version has sidecar and `force=true` | Sidecar regenerated and metadata refreshed | US-008 | +| QT-005 | `prediction-edit-prep-queue` | Row-count mismatch | edited GPKG and footprints differ | Backfill fails visibly; old metadata not replaced | US-008 | ### UI Component Tests -The current branch includes plain Node helper tests, but it does not include a -React Testing Library, Vitest, or Playwright harness for browser rendering. UI -coverage below is therefore a required follow-up before release sign-off. +Current automated UI coverage is helper-level only. Browser behavior must be +validated manually or with new tooling before release. | ID | Component | Scenario | User Action | Expected Behavior | Story Ref | |---|---|---|---|---|---| -| UI-001 | `ModelResultsButton.jsx` | Trained results gating | Render model variations | Results menu follows server-derived `predictionsReady` with legacy fallback (`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:87-110`) | US-001 | -| UI-002 | `EmbeddingModelRow.jsx` | Embedding View Results | Open Results menu for ready and unready embedding models | First menu item navigates to `/visualizer/...` only when `predictionsReady` is true (`ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:85-130`) | US-001 | -| UI-003 | `Visualizer.jsx` | Vector-first load | Open `/visualizer/:projectId/:imageLayerId/:modelId` | Fetches visualizer payload and loads footprint PMTiles plus prediction attrs before edit mode (`ui/src/Components/Visualizer/Visualizer.jsx:457-605`) | US-002 | -| UI-004 | `Labels.jsx` / `Visualizer.jsx` | Enter edit mode | Click pencil next to Back or press `E` | Existing visualizer switches to edit mode; no route change or standalone screen (`ui/src/Components/Visualizer/Labels.jsx:117-128`, `ui/src/Components/Visualizer/Visualizer.jsx:873-921`) | US-003 | -| UI-005 | `Visualizer.jsx` | Leave clean edit mode | Click Done or press `E` with no unsaved edits | Edit controls disappear; vectors remain visible on the View Results page | US-003 | -| UI-006 | `Visualizer.jsx` | Discard confirmation | Press `E`, Back, or Done with unsaved edits | Confirmation dialog appears; cancel keeps edits; discard exits mode | US-003 | -| UI-007 | `PredictionEditPanel.jsx` / `predictionResults.js` | Trained threshold | Load `supportsThreshold=true`; move slider | Slider visible; colors and flip counts update | US-003 | -| UI-008 | `PredictionEditPanel.jsx` | Embedding no threshold | Load `supportsThreshold=false` | Slider hidden; manual overrides available | US-003 | -| UI-009 | `Visualizer.jsx` | Click classify | Click footprint and choose class | Feature color and counts update via vector state | US-003 | -| UI-010 | `Visualizer.jsx` | Box-select classify | Ctrl+drag selection and choose class | All selected features update | US-003 | -| UI-011 | `PredictionEditPanel.jsx` | Save version | Click Save | PUT body includes thresholds and overrides; version list refreshes; raw route stays on `/visualizer/...` | US-004, US-005 | -| UI-012 | `PredictionEditPanel.jsx` | Version history read-only | Load existing versions or save a version | History displays version, timestamp, threshold, editor, edited count, and which version is mapped; selecting another version does not refetch in this branch (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:513-550`) | US-005 | -| UI-013 | `Visualizer.jsx` | Dark mode | Render in dark theme | Styles use Fluent tokens and remain legible | US-003 | -| UI-014 | `ui/src/util/pmtiles.js` | Shared protocol singleton | Render multiple PMTiles screens | Both screens share one `pmtiles://` protocol instance | US-002, US-003 | +| UI-001 | Version selector | Default latest | Open View Results with versions | Selector shows latest; map loads latest sidecar | US-005 | +| UI-002 | Version selector | Select raw | Choose Raw | Calls `GetVisualizerResults?version=0`; warning says reports still use newest when edits exist | US-005, US-006 | +| UI-003 | Version selector | Select older edit | Choose version 1 while version 2 exists | Calls `GetVisualizerResults?version=1`; warning appears | US-005, US-006 | +| UI-004 | Version selector | Missing sidecar | Open list with version lacking `predictionAttrsUrl` | Option is disabled and explains backfill is pending | US-005, US-008 | +| UI-005 | Visualizer map | Dual-pane switch | Change selected version | Both swipe panes update colors and no pane keeps stale feature-state | US-003, US-005 | +| UI-006 | Selector download | Download selected | Click download beside selector | Uses `GetModelArtifact?kind=gpkg&version=` | US-007 | +| UI-007 | Edit panel | Per-row download | Click a version row download | Downloads that row's GPKG through `GetModelArtifact` | US-007 | +| UI-008 | Report buttons | Map-only split | Map on raw/older | Report action copy states reports use newest; request does not include selector version | US-006 | +| UI-009 | Save flow | Save new version | Click Save | Version list refreshes with sidecar URL and saved baseline resets | US-004 | ### End-to-End Tests (Docker Compose) | ID | User Flow | Steps | Expected Outcome | Story Ref | |---|---|---|---|---| -| E2E-001 | Trained View Results and edit mode | 1. Start Docker Compose 2. Use a processed trained model with `predictionsReady=true` 3. Open Results → View Results 4. Confirm vector footprints render 5. Enter edit mode with pencil or `E` 6. Change threshold/override one building 7. Save | `edit_v1` GeoPackage downloads; raw `Model.gpkgUrl` unchanged; visualizer payload defaults to newest edit after refresh | US-001-US-006 | -| E2E-002 | Embedding View Results and edit mode | 1. Start Docker Compose 2. Use an embedding model with non-empty predictions 3. Open Results → View Results 4. Confirm vector footprints render and no threshold slider 5. Override one building 6. Save | `edit_v1` GeoPackage downloads with expected class columns; embedding View Results uses `/visualizer/...` | US-001-US-006 | -| E2E-003 | Empty embedding predictions | 1. Save empty embedding predictions 2. Return to project management | Results View remains disabled because server-derived `predictionsReady` is false with `no_buildings` readiness reason | US-001, US-002 | -| E2E-004 | Unsaved edit discard | 1. Enter edit mode 2. Modify one building 3. Press `E` or Done 4. Cancel and then discard | Dialog protects unsaved edits; discard returns to normal visualizer mode | US-003 | -| E2E-005 | Report reader versions | 1. Save edited version 2. Request validation and assessment reports with default, `version=0`, and explicit version | Validation metrics follow edited `damaged`; assessment opens the requested GeoPackage but counts still threshold `damage_pct_0m` | US-006 | +| E2E-001 | Trained version selection | 1. Start stack 2. Open trained model View Results 3. Save two versions 4. Select raw, v1, v2 | Map changes to each selected sidecar; reports still default newest; raw `Model.gpkgUrl` unchanged | US-001-US-007 | +| E2E-002 | Embedding version selection | 1. Open embedding model 2. Save version 3. Select raw and v1 | Selector works; threshold slider remains hidden; downloads use API route | US-001-US-007 | +| E2E-003 | Backfill window | 1. Seed edited version without sidecar 2. Open View Results 3. Run backfill | Version is disabled before backfill and selectable after sidecar URL appears | US-005, US-008 | +| E2E-004 | Version downloads | 1. Select v1 2. Download selected 3. Download v2 row | Downloaded files match requested versions | US-007 | +| E2E-005 | Assessment gap | 1. Save override that changes `damaged` only 2. Run reports | Validation changes; Assessment counts do not move with override because `damage_pct_0m` is preserved | US-006 | ### Edge Case & Negative Tests | ID | Scenario | Input | Expected Behavior | |---|---|---|---| -| NEG-001 | Unauthenticated API request | No function key / invalid auth context | 401 or existing platform auth failure | -| NEG-002 | Non-existent project ID | Random GUID | 404 | -| NEG-003 | Invalid class | override class `Destroyed` | 400 | -| NEG-004 | Duplicate override ids | two overrides for id `7` | 400 or deterministic client-side collapse before request | -| NEG-005 | Missing raw GPKG | Model lacks `gpkgUrl` | 404 from edit session; Results disabled in UI through readiness | -| EDGE-001 | Very large layer | Representative large GeoPackage | Prep/save complete within agreed memory/time budget or produce actionable error | -| EDGE-002 | Concurrent saves | Parallel PUT requests | Known gap: current implementation can allocate the same next version; add optimistic concurrency follow-up | -| EDGE-003 | Threshold default split | Session default vs report default | Editor session remains `0.0`; assessment report default remains `0.1`; product decision is documented | -| EDGE-004 | UI lint baseline | Current repo-wide ESLint 9 flat-config failure | Validation records no regression from baseline, not necessarily clean lint | -| EDGE-005 | Version switching | User clicks an older version in the history | Known gap: history is read-only; payload reports current version but selection does not refetch | -| EDGE-006 | Classic footprint row loss | Prediction GPKG has fewer rows than source footprints | Prep/save should fail loudly; producer-side fix remains a follow-up | -| EDGE-007 | Raw Overture id absence | Raw prediction GeoPackage has no explicit `overture_id` | Prep/save relies on positional join today; explicit producer column remains a follow-up | +| NEG-001 | Unknown version selected | `version=99` | 404 from API; UI shows unavailable state. | +| NEG-002 | Malformed version selected | `version=abc` | 400 from API; UI shows error. | +| NEG-003 | Missing sidecar download | `kind=prediction_attrs&version=1` without sidecar | 404; no generation in GET. | +| NEG-004 | Direct URL fallback | Version download after API 404 | UI does not fall back to raw blob/SAS URL. | +| NEG-005 | Concurrent saves | Parallel PUT requests | Known gap: no 409; document behavior and follow-up. | +| EDGE-001 | Partial swipe switch | Switch while both panes mounted | Both panes repaint from same selected sidecar. | +| EDGE-002 | Backfill rerun | Run backfill twice | Second run skips ready sidecars. | +| EDGE-003 | No Playwright | CI/UI validation | Record absence; do not claim browser automation. | ### Performance Tests | ID | Scenario | Load Profile | Target Metric | Threshold | |---|---|---|---|---| -| PERF-001 | Visualizer payload readiness | 50 concurrent `GetVisualizerResults` requests that read selected prediction GeoPackages for flavor/count | p99 latency | threshold TBD after representative GPKG measurement | -| PERF-002 | PMTiles/sidecar prep | One dense urban layer | job duration and peak memory | fit existing worker/Batch limits; no OOM | -| PERF-003 | Save edited version | GeoPackage at 95th percentile building count | function duration and peak memory | complete below platform timeout or trigger async-save follow-up | -| PERF-004 | Browser editing | PMTiles + sidecar for dense layer | Chrome heap and interaction latency | no tab crash; pan/selection remains usable | +| PERF-001 | Version switch | 50 `GetVisualizerResults?version=N` requests | p99 latency | threshold TBD after representative measurement | +| PERF-002 | Save with sidecar | 95th percentile building-count GPKG | function duration and memory | below platform timeout or async-save follow-up | +| PERF-003 | Backfill | All historical edited versions in dev/test | queue duration and failures | completes without sustained queue growth | +| PERF-004 | Browser switch | Dense PMTiles + multiple sidecars | heap and interaction latency | no tab crash; both panes repaint promptly | ## Test Data Requirements | Dataset | Description | Source | Sensitive? | |---|---|---|---| -| Trained inference sample GeoPackage | Includes continuous `damage_pct_0m`, `damage_pct_10m`, `damage_pct_20m`, `damaged`, `unknown_pct` | Synthetic or sanitized existing fixture | no | -| Embedding prediction sample GeoPackage | Layer `predictions`, `area`, `damaged`, degenerate `damage_pct_0m` | Synthetic or sanitized existing fixture | no | -| Source footprints GeoPackage | Ordered Overture ids matching prediction rows | Synthetic | no | -| Layer footprint PMTiles | Building geometry artifact independent of a model | Synthetic or generated by prep worker | no | -| Prediction attribute sidecar | Model-scoped arrays matching PMTiles feature ids | Synthetic or generated by prep worker | no | -| Edited prediction GeoPackages | Raw plus `edit_v1` and `edit_v2` documents | Synthetic | no | -| Large dense footprint set | Stress PMTiles, sidecar, and save memory | Synthetic | no | -| Model/ImageLayer metadata fixtures | Raw, unready, ready, and edited model documents | Synthetic | no | +| Raw trained inference GeoPackage | Continuous damage fractions and `damaged` | synthetic or sanitized fixture | no | +| Raw embedding GeoPackage | Degenerate 0/1 `damage_pct_0m` copy of `damaged` | synthetic or sanitized fixture | no | +| Edited GeoPackages v1/v2 | Override `damaged` while preserving `damage_pct_0m` | synthetic | no | +| Source footprints GeoPackage | Ordered Overture ids matching prediction rows | synthetic | no | +| Raw sidecar | `prediction_attrs_${modelId}` | generated fixture | no | +| Versioned sidecars | `prediction_attrs_${modelId}_v1/v2` | generated fixture/backfill | no | +| Historical metadata | Version with `gpkgUrl` but no `predictionAttrsUrl` | synthetic plus dev models `0448`, `5553` | no | +| Large dense footprint set | Stress save/backfill/browser memory | synthetic | no | ## Coverage Matrix | User Story | Unit | API Integration | Queue | UI | E2E | Performance | |---|---|---|---|---|---|---| -| US-001 | UT-003 | IT-002 | — | UI-001, UI-002 | E2E-001, E2E-002, E2E-003 | — | -| US-002 | UT-002, UT-003, UT-005, UT-006, UT-010, UT-011, UT-013, UT-014, UT-015 | IT-001-IT-012, IT-017, IT-020 | QT-001-QT-008 | UI-003, UI-014 | E2E-001, E2E-002, E2E-003 | PERF-001, PERF-002 | -| US-003 | UT-005-UT-009 | — | — | UI-004-UI-010, UI-013, UI-014 | E2E-001, E2E-002, E2E-004 | PERF-004 | -| US-004 | UT-001, UT-002, UT-007-UT-012, UT-014 | IT-013-IT-015 | — | UI-011 | E2E-001, E2E-002 | PERF-003 | -| US-005 | UT-001 | IT-016, IT-017 | — | UI-011, UI-012 | E2E-001, E2E-002 | — | -| US-006 | UT-004 | IT-001, IT-003, IT-004, IT-018, IT-019 | — | UI-012 | E2E-001, E2E-005 | — | +| US-001 | — | — | — | UI-001 | E2E-001, E2E-002 | — | +| US-002 | UT-003, UT-005 | IT-009, IT-010 | — | UI-001 | E2E-001, E2E-002 | PERF-001 | +| US-003 | — | — | — | UI-005 | E2E-001, E2E-002 | PERF-004 | +| US-004 | UT-001, UT-002, UT-004, UT-007, UT-008 | IT-012, IT-013 | — | UI-009 | E2E-001, E2E-002 | PERF-002 | +| US-005 | UT-011, UT-012 | IT-001-IT-006 | — | UI-001-UI-005 | E2E-001-E2E-003 | PERF-001, PERF-004 | +| US-006 | UT-006, UT-012 | IT-015, IT-016 | — | UI-002, UI-003, UI-008 | E2E-001, E2E-005 | — | +| US-007 | — | IT-007-IT-011 | — | UI-006, UI-007 | E2E-004 | — | +| US-008 | UT-009, UT-010 | IT-014 | QT-001-QT-005 | UI-004 | E2E-003 | PERF-003 | ## Environment Requirements | Environment | Purpose | Config | |---|---|---| -| Local (Docker Compose) | Developer testing of UI, API, queue, Azurite artifacts | `docker/docker-compose.yml`; no prediction-editing feature flags implemented | -| CI (GitHub Actions) | Automated backend and UI tests | Existing secret scan/deploy workflows plus targeted tests | -| Dev1 SWA | Integration testing with realistic project data | Internal testers use existing route and auth; no runtime feature flag | -| Testing SWA | Pre-production validation | Promote after dev1 sign-off; no runtime feature flag | +| Local (Docker Compose) | Developer testing of UI, API, queue, Azurite artifacts | `docker/docker-compose.yml`; no feature flag | +| CI (GitHub Actions) | Automated backend and UI helper tests | Existing workflows plus targeted tests | +| Dev1 SWA | Backfill and selector validation with known models | Includes models `0448` and `5553` historical v1 sidecar backfill | +| Testing SWA | Pre-production analyst validation | Promote after dev sign-off | ## Sign-off Criteria -- [ ] All P0 stories have E2E coverage for trained and embedding workflows. -- [ ] Row-order preservation is asserted in unit tests; producer-side row loss and missing raw `overture_id` are tracked as follow-ups. -- [ ] `hastelib` targeted tests pass for readiness, source resolution, prep, and edit processors. -- [ ] API integration tests are added and pass for visualizer payloads, session, prep, save, version list, artifact retrieval, validation report, and assessment report version handling. -- [ ] UI helper tests pass; browser/Playwright tests are added for gating, - vector-first rendering, edit-mode entry/exit, discard confirmation, - threshold visibility, selection, save, read-only version history, and dark - mode. -- [ ] Performance tests establish safe limits or document a follow-up async-save - requirement. -- [ ] UI lint validation records no regression from the known repo-wide ESLint 9 - flat-config baseline. +- [ ] Every selectable edited version has a matching `predictionAttrsUrl`. +- [ ] `GetVisualizerResults` version selection changes the map only. +- [ ] Assessment and Validation report buttons keep newest defaults and the UI + warns when map/report versions can differ. +- [ ] Versioned downloads use `GetModelArtifact`, not direct blob/SAS rewriting. +- [ ] Backfill is complete for known dev historical versions or their failures + are visible and selector options remain disabled. +- [ ] Both swipe panes update together on version switch. +- [ ] Targeted backend/API/queue tests pass. +- [ ] UI helper tests pass; Playwright/browser gap is explicitly documented if + no Playwright config is added. diff --git a/spec/features/prediction-editing/user-stories.md b/spec/features/prediction-editing/user-stories.md index 1e7a223b..3423eab7 100644 --- a/spec/features/prediction-editing/user-stories.md +++ b/spec/features/prediction-editing/user-stories.md @@ -6,9 +6,9 @@ | Persona | Description | Key Goals | |---|---|---| -| Disaster Analyst | Domain expert who reviews building-level damage predictions during response | Correct model outputs quickly and preserve provenance | -| ML Engineer | Builds and evaluates trained and embedding-based prediction workflows | Keep raw model outputs immutable while comparing edited versions | -| External Partner | Collaborator who receives HASTE-generated files | Download a clear edited deliverable without needing editor access | +| Disaster Analyst | Domain expert who reviews building-level damage predictions during response | Correct model outputs quickly, compare versions, and preserve provenance | +| ML Engineer | Builds and evaluates trained and embedding-based prediction workflows | Keep raw model outputs immutable while making versioned artifacts testable | +| External Partner | Collaborator who receives HASTE-generated files | Download a clear raw or edited deliverable without editor access | --- @@ -22,111 +22,59 @@ **Priority:** P0 **Estimate:** M -**Component(s):** `ui/src/Components/ProjectManagement/ModelResultsButton.jsx`, `ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx`, `ui/src/Components/AppBody.jsx`, `ui/src/Components/Visualizer/Labels.jsx`, `ui/src/Components/Visualizer/Visualizer.jsx` +**Component(s):** `ui/src/Components/ProjectManagement/`, `ui/src/Components/Visualizer/` **Acceptance Criteria:** ```gherkin -Given a trained-inference model with server-derived predictionsReady true +Given a model with server-derived predictionsReady true When I open the Results menu -Then the View item is enabled and navigates to /visualizer/:projectId/:imageLayerId/:modelId -And there is no standalone Edit button on the model row -``` - -```gherkin -Given an embedding model with server-derived predictionsReady true -When I open the embedding Results menu -Then View is the first menu item and navigates to /visualizer/:projectId/:imageLayerId/:modelId -And there is no standalone Edit button on the embedding row +Then View navigates to /visualizer/:projectId/:imageLayerId/:modelId +And there is no standalone /edit-predictions route ``` ```gherkin Given the View Results page has loaded predicted footprints and attributes When I click the pencil next to Back or press E Then the same /visualizer page enters prediction edit mode -And the edit panel replaces the read-only overlay controls -``` - -```gherkin -Given I am in edit mode with unsaved edits -When I click Done or press E -Then HASTE asks me to discard unsaved edits before leaving edit mode ``` -```gherkin -Given the model is not ready, has no predictions, has no predicted buildings, or is still preparing vector artifacts -When I view the Results menu or the visualizer edit affordance -Then the disabled state explains why editing cannot open yet -``` - -**UI Wireframe:** The Results menu opens the existing View Results route. A -pencil/Done button sits beside Back on the visualizer; edit mode overlays a -right-side edit panel on the same swipe map. - -**Notes:** `AppBody.jsx` registers `/visualizer/...` and no -`/edit-predictions/...` route (`ui/src/Components/AppBody.jsx:73-75`). The -trained row and embedding row both navigate to `/visualizer/...` from View -(`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:87-110`, -`ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx:116-130`). The -pencil affordance and `E` shortcut are wired in `Labels.jsx` and `Visualizer.jsx` -(`ui/src/Components/Visualizer/Labels.jsx:117-128`, -`ui/src/Components/Visualizer/Visualizer.jsx:496-605`). +**Notes:** The existing route and edit affordance live in `AppBody.jsx` and +`Labels.jsx` (`ui/src/Components/AppBody.jsx:73-75`, +`ui/src/Components/Visualizer/Labels.jsx:117-128`). --- ### US-002: Prepare a Complete Footprint Results/Edit Session **As a** Disaster Analyst, -**I want to** load all predicted building footprints, not a sample, +**I want to** load all predicted building footprints and raw prediction attributes, **So that** both viewing and editing cover the complete model output. **Priority:** P0 **Estimate:** L -**Component(s):** `api/hastefuncapi`, `api/hastefuncqueues`, `hastelib`, `docker/training`, `ui/src/Components/Visualizer/` +**Component(s):** `api/hastefuncapi`, `api/hastefuncqueues`, `hastelib`, `ui/src/Components/Visualizer/` **Acceptance Criteria:** ```gherkin Given GetVisualizerResults returns footprintTilesUrl and predictionAttrsUrl When the UI loads the results page -Then it fetches the PMTiles archive and prediction attribute sidecar through GetModelArtifact routes +Then it fetches PMTiles and prediction attributes through GetModelArtifact And it renders predicted buildings as vectors for either workflow ``` ```gherkin -Given the model has predictions but PMTiles or attributes are missing -When GetVisualizerResults reports predictionsReadiness.reason "preparing" or the artifact request returns 404 -Then the UI calls GetPredictionEditSession and PutPreparePredictionTilesQueueMessage -And it polls GetPredictionEditSession until tilesReady and attrsReady are true -``` - -```gherkin -Given GetPredictionEditSession is called for a raw prediction GeoPackage -When the raw GeoPackage can be read -Then the response includes tilesReady, attrsReady, buildingCount, flavor, supportsThreshold, defaultThreshold, predictionTilesStatus, predictionTilesStatusMessage, and versions -And the GET does not enqueue work or run tippecanoe inline -``` - -```gherkin -Given source footprints and predictions have different row counts -When the prep worker validates the session inputs -Then it fails the prep job and records a user-visible readiness error +Given PMTiles or raw attributes are missing +When preparation is requested +Then the queued prediction-tiles job generates missing artifacts +And GET handlers do not run tippecanoe inline ``` -**UI Wireframe:** Results page status note with spinner/retry while predicted -buildings are prepared; once ready, the same vector footprint layer is visible -in read-only and edit modes. - -**Notes:** `GetModelArtifact` streams `footprint_pmtiles` and `prediction_attrs` -through the API (`api/hastefuncapi/function_app.py:1400-1424`, -`api/hastefuncapi/function_app.py:1453-1458`). The visualizer artifact hook owns -loading, queueing, and polling (`ui/src/Components/Visualizer/usePredictionArtifacts.js:4-24`, -`ui/src/Components/Visualizer/usePredictionArtifacts.js:224-299`, -`ui/src/Components/Visualizer/usePredictionArtifacts.js:377-459`). The prep job -runs in the queue/training-image path because `tippecanoe` is not an HTTP-handler -concern (`hastelib/src/hastegeo/core/processors/prediction_tiles.py:13-19`, -`api/hastefuncqueues/function_app.py:861-914`, -`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:40-45`). +**Notes:** `GetModelArtifact` streams artifacts server-side +(`api/hastefuncapi/function_app.py:1430-1570`). The sidecar builder now lives in shared core utilities and the workflow imports +it (`hastelib/src/hastegeo/core/utils/prediction_attrs.py:128-202`, +`hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py:77-92`). --- @@ -138,46 +86,32 @@ concern (`hastelib/src/hastegeo/core/processors/prediction_tiles.py:13-19`, **Priority:** P0 **Estimate:** L -**Component(s):** `ui/src/Components/Visualizer/PredictionEditPanel.jsx`, `ui/src/Components/Visualizer/usePredictionFootprints.js`, `ui/src/Components/Visualizer/predictionClassify.js`, `ui/src/Components/Visualizer/predictionFootprintMap.js` +**Component(s):** `ui/src/Components/Visualizer/PredictionEditPanel.jsx`, `ui/src/Components/Visualizer/usePredictionFootprints.js` **Acceptance Criteria:** ```gherkin Given edit mode loaded a trained-inference model When I move the damage or unknown threshold slider -Then footprint colors update live from the sidecar and the panel shows how many buildings would change class +Then footprint colors update live from the sidecar ``` ```gherkin Given edit mode loaded an embedding model When I view the edit panel -Then no threshold slider is shown -And I can still set explicit Damaged, NotDamaged, or Unknown overrides -``` - -```gherkin -Given visible predicted footprints on the map -When I click a building or ctrl+drag a selection box -Then selected buildings can be assigned Damaged, NotDamaged, or Unknown -And the edited count updates +Then threshold sliders are hidden +And I can still set Damaged, NotDamaged, or Unknown overrides ``` ```gherkin Given the swipe map is visible -When I edit footprints on either side of the divider -Then feature-state coloring and selection stay mirrored between the two panes +When I edit footprints or switch versions +Then both swipe panes show the same classes and selection state ``` -**UI Wireframe:** Azure Maps swipe canvas underneath a right panel with class -counts, filters, prev/next traversal, threshold controls when supported, saved -version history, Save as new version, and Done editing. - -**Notes:** The edit panel lives in the Visualizer directory and is rendered only -when `isEditMode` is true (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:4-16`, -`ui/src/Components/Visualizer/Visualizer.jsx:873-921`). Map classification is -browser-side feature-state over PMTiles, so threshold moves do not need a server -round trip (`ui/src/Components/Visualizer/usePredictionFootprints.js:4-29`, -`ui/src/Components/Visualizer/predictionFootprintMap.js:4-18`). +**Notes:** Feature-state writes must be mirrored to both panes; otherwise one +pane can remain on stale colors (`ui/src/Components/Visualizer/usePredictionFootprints.js:19-25`, +`ui/src/Components/Visualizer/usePredictionFootprints.js:212-228`). --- @@ -189,132 +123,183 @@ round trip (`ui/src/Components/Visualizer/usePredictionFootprints.js:4-29`, **Priority:** P0 **Estimate:** L -**Component(s):** `api/hastefuncapi`, `hastelib/src/hastegeo/core/models/`, `hastelib/src/hastegeo/core/processors/`, Blob Storage, `ui/src/Components/Visualizer/usePredictionFootprints.js` +**Component(s):** `api/hastefuncapi`, `hastelib/src/hastegeo/core/models/`, `hastelib/src/hastegeo/core/processors/`, Blob Storage, `ui/src/Components/Visualizer/` **Acceptance Criteria:** ```gherkin Given a loaded prediction edit mode session and a set of overrides -When I save with threshold 0.1 and unknownThreshold 0.0 -Then PutEditedPredictions returns version, gpkgUrl, and editedCount -And the Model document appends one EditedPredictionVersion entry +When I save a new version +Then PutEditedPredictions writes a new GeoPackage and a matching prediction_attrs sidecar +And the Model document appends one EditedPredictionVersion entry with gpkgUrl and predictionAttrsUrl And Model.gpkgUrl remains the raw prediction pointer ``` ```gherkin -Given a source prediction GeoPackage with N rows -When an edited GeoPackage is written -Then the edited file has N rows in the exact same order, preserves the source geometry, writes overture_id, edited_class, and edit_threshold, and sets damaged to 1 only for final_class Damaged +Given sidecar generation fails after the GeoPackage is written +When the save response is returned +Then the version is not advertised as selectable +And the failure is visible to the analyst +``` + +**Notes:** The current API route appends metadata after storing a versioned GPKG +(`api/hastefuncapi/function_app.py:3302-3325`). It must adopt the shared helper +that writes the sidecar in the same call path before metadata append +(`hastelib/src/hastegeo/core/processors/prediction_edits.py:520-608`). The current implementation still +has no optimistic concurrency or 409 response. + +--- + +### US-005: Select Which Prediction Version the Map Shows + +**As a** Disaster Analyst, +**I want to** choose raw, newest, or a saved edited version on View Results, +**So that** I can compare map output across review passes. + +**Priority:** P0 +**Estimate:** M +**Component(s):** `api/hastefuncapi`, `hastelib/src/hastegeo/core/processors/visualizer.py`, `ui/src/Components/Visualizer/` + +**Acceptance Criteria:** + +```gherkin +Given a model has raw predictions and edited versions 1, 2, and 3 +When I select version 2 on View Results +Then the UI calls GetVisualizerResults?version=2 +And the map renders the sidecar for version 2 +And the response says isNewestPredictionVersion is false ``` ```gherkin -Given a save has succeeded -When the edit panel refreshes versions -Then the saved version appears in the history and the saved baseline becomes the new unsaved-edits baseline +Given the map is showing raw or an older version while a newer edit exists +When report actions are visible +Then the UI states that Assessment and Validation reports still use the newest version ``` -**UI Wireframe:** Save button displays success/failure in the edit panel. The -saved version appears in the right-panel history; the rows are informational in -this branch. +```gherkin +Given a version has no predictionAttrsUrl because backfill has not completed +When I open the selector +Then that version is disabled and explains that its sidecar is still being prepared +``` -**Notes:** The save path builds the sparse `PutEditedPredictions` payload in the -visualizer hook (`ui/src/Components/Visualizer/usePredictionFootprints.js:838-887`). -The API appends metadata without touching `gpkgUrl` (`api/hastefuncapi/function_app.py:3181-3345`). -The current implementation does not implement optimistic concurrency or a 409 -conflict response; concurrent saves can collide and need a follow-up fix. +**Notes:** The current visualizer fetch omits `version` and must be extended +(`ui/src/Components/Visualizer/Visualizer.jsx:213-223`). This story changes the +map only; it does not add an active-version pointer. --- -### US-005: Show Edited Version History Without Switching Versions in the UI +### US-006: Keep Reports on the Newest Version Unless Explicitly Requested -**As an** External Partner, -**I want to** identify saved edited prediction versions, -**So that** I can request or download the correct analyst-reviewed file while HASTE keeps raw outputs separate. +**As an** ML Engineer, +**I want to** keep Assessment and Validation report defaults stable while map selection changes, +**So that** report URLs remain predictable and newest-edited analysis stays the default. -**Priority:** P1 +**Priority:** P0 **Estimate:** M -**Component(s):** `api/hastefuncapi`, `ui/src/Components/Visualizer/PredictionEditPanel.jsx`, `hastelib/src/hastegeo/core/models/` +**Component(s):** `api/hastefuncapi`, `hastelib/src/hastegeo/core/utils/predictions.py`, `ui/src/Components/Visualizer/` **Acceptance Criteria:** ```gherkin -Given a model with editedPredictions entries -When GetVisualizerResults or GetPredictionEditSession returns -Then the payload includes predictionVersions or versions sorted newest first -And the edit panel displays version, timestamp, threshold, editor, and edited count +Given the View Results map is showing version 2 and version 3 also exists +When I request Validation or Assessment from the standard UI buttons +Then the request omits version +And the backend resolves version 3 ``` ```gherkin -Given I view the Saved versions list in edit mode -When I click or focus a version row -Then the row does not refetch the map or switch the served version in the current branch -And the active version badge only reports the version already on the map +Given an API caller explicitly passes version=0 or version=N to a report endpoint +When the version exists +Then the endpoint keeps honoring that explicit contract +And an unknown numeric version returns 404 ``` ```gherkin -Given I need a specific edited GeoPackage -When I call GetEditedPredictionVersions or inspect the visualizer payload -Then the gpkgUrl for each edited version is available while raw Model.gpkgUrl is unchanged +Given an edited GeoPackage changes damaged but preserves damage_pct_0m +When reports run +Then Validation metrics can move with damaged +But Assessment threshold counts remain tied to damage_pct_0m until a follow-up changes that product decision ``` -**UI Wireframe:** Version history list in the edit panel. The active version gets -an "On the map" badge; rows are read-only until a follow-up wires selection to a -`GetVisualizerResults?version=N` refetch. - -**Notes:** `PredictionEditPanel` renders history without an `onClick`/selection -handler (`ui/src/Components/Visualizer/PredictionEditPanel.jsx:513-550`). The -visualizer fetch currently omits `version`, so UI version switching is not wired -(`ui/src/Components/Visualizer/Visualizer.jsx:213-223`). +**Notes:** The report routes already parse optional versions and resolve the +selected source (`api/hastefuncapi/function_app.py:4607-4688`, +`api/hastefuncapi/function_app.py:4929-5027`). The UI selector must not mutate +those report defaults. --- -### US-006: Read Edited Versions in Results and Reports +### US-007: Download Raw or Edited Prediction Versions -**As an** ML Engineer, -**I want to** use the same raw-or-edited prediction source selection in every reader, -**So that** visual results and validation/report metrics reflect saved analyst edits consistently where their data model allows it. +**As an** External Partner, +**I want to** download the selected map version or a specific saved row, +**So that** I receive the exact GeoPackage I need without direct blob access. **Priority:** P0 **Estimate:** M -**Component(s):** `api/hastefuncapi`, `hastelib/src/hastegeo/core/utils/predictions.py`, `hastelib/src/hastegeo/core/processors/visualizer.py`, `docs/api/hastefuncapi.md` +**Component(s):** `api/hastefuncapi/function_app.py`, `ui/src/Components/Visualizer/PredictionEditPanel.jsx`, `ui/src/Components/ProjectManagement/ModelResultsButton.jsx` **Acceptance Criteria:** ```gherkin -Given a model has editedPredictions versions 1 and 2 -When GetVisualizerResults, GetValidationReport, or GetAssessmentReport is called without version -Then the reader uses version 2 +Given View Results is showing raw or edited version N +When I click Download beside the version selector +Then the browser downloads GetModelArtifact?kind=gpkg&version= +And the request goes through the Function App auth path +``` + +```gherkin +Given the edit panel shows saved versions +When I click a row's download action +Then HASTE downloads that row's GeoPackage through GetModelArtifact with the row version ``` ```gherkin -Given a model has editedPredictions versions 1 and 2 -When a reader is called with version=0 -Then the reader uses raw Model.gpkgUrl +Given an unknown version is requested for download +When GetModelArtifact resolves it +Then it returns 404 rather than a direct blob URL fallback ``` +**Notes:** Current model-row download code rewrites direct blob/SAS URLs +(`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:61-69`, +`ui/src/Components/ProjectManagement/ModelResultsButton.jsx:113-119`). New +version downloads use `GetModelArtifact`, which already handles Range and +content disposition (`api/hastefuncapi/function_app.py:1430-1570`). + +--- + +### US-008: Backfill Sidecars for Existing Edited Versions + +**As an** ML Engineer, +**I want to** generate missing sidecars for versions saved before this change, +**So that** historical edits can become selectable without adding generation to the read path. + +**Priority:** P0 +**Estimate:** M +**Component(s):** `hastelib/src/hastegeo/core/processors/prediction_tiles.py`, `api/hastefuncqueues/function_app.py`, Blob Storage, Cosmos DB + +**Acceptance Criteria:** + ```gherkin -Given a model has editedPredictions versions 1 and 2 -When a reader is called with version=1 -Then the reader uses version 1 -And an unknown numeric version returns 404 while a malformed version returns 400 +Given an edited version has gpkgUrl but no predictionAttrsUrl +When the prediction-tiles job runs in backfill mode +Then it builds prediction_attrs_${modelId}_v${version} +And updates that EditedPredictionVersion with predictionAttrsUrl ``` ```gherkin -Given an edited GeoPackage changes damaged but preserves damage_pct_0m -When GetValidationReport computes metrics -Then the explicit edits affect validation because it reads damaged -But GetAssessmentReport threshold-based counts continue to derive from damage_pct_0m until a follow-up resolves that product decision +Given a version already has predictionAttrsUrl and force is false +When backfill runs +Then the job skips that version without rewriting it ``` -**UI Wireframe:** The results map displays the served version in the edit panel; -UI controls for switching versions remain a follow-up. +```gherkin +Given dev models 0448 and 5553 each have version 1 without sidecars +When the backfill is run +Then both versions receive sidecars or a visible failure status +``` -**Notes:** `resolve_prediction_source` implements newest-wins, explicit version, -and `version=0` raw selection (`hastelib/src/hastegeo/core/utils/predictions.py:332-401`). -The three readers call it (`api/hastefuncapi/function_app.py:2386-2435`, -`api/hastefuncapi/function_app.py:4677-4688`, -`api/hastefuncapi/function_app.py:5017-5027`). The API docs capture the full -reader contract and the validation/assessment asymmetry (`docs/api/hastefuncapi.md:480-502`). +**Notes:** Backfill is not lazy on first selection. The selector must disable +versions until the sidecar URL is present. --- @@ -336,12 +321,14 @@ Every user story must be assigned to one or more HASTE agents. The **implementin | Story | Implementing Agent(s) | Validating Agent(s) | Notes | |---|---|---|---| -| US-001 | `ui`, `backend-dev` | `ui-validation`, `backend-validation` | UI entry point uses server-derived `predictionsReady`; no standalone route. | -| US-002 | `backend-dev`, `gis`, `ui` | `backend-validation`, `ui-validation` | Queue/API ownership is backend; PMTiles, GeoPackage, CRS, and row-order checks require GIS review; visualizer owns artifact loading. | -| US-003 | `ui` | `ui-validation` | UI edit-mode behavior; GIS should be consulted for class semantics but does not own UI code. | -| US-004 | `backend-dev`, `gis`, `ui` | `backend-validation`, `ui-validation` | Version metadata plus GeoPackage read/write and row-order invariant; UI save wiring. | -| US-005 | `backend-dev`, `ui` | `backend-validation`, `ui-validation` | API version list and read-only UI history; version switching remains a follow-up. | -| US-006 | `backend-dev`, `gis` | `backend-validation` | Raw-vs-edited source resolution across visualizer, validation, and assessment; assessment semantics need GIS/product follow-up. | +| US-001 | `ui`, `backend-dev` | `ui-validation`, `backend-validation` | UI entry point uses server-derived readiness. | +| US-002 | `backend-dev`, `gis`, `ui` | `backend-validation`, `ui-validation` | Prep/API ownership is backend; row-order and GeoPackage logic require GIS review. | +| US-003 | `ui` | `ui-validation` | UI edit-mode behavior and dual-pane feature state. | +| US-004 | `backend-dev`, `gis`, `ui` | `backend-validation`, `ui-validation` | Save writes GPKG + sidecar; UI resets baseline. | +| US-005 | `backend-dev`, `ui` | `backend-validation`, `ui-validation` | Map-only version selection and warning copy. | +| US-006 | `backend-dev`, `gis`, `ui` | `backend-validation`, `ui-validation` | Reports keep newest; UI must not pass selector version. | +| US-007 | `backend-dev`, `ui` | `backend-validation`, `ui-validation` | Downloads route through `GetModelArtifact`. | +| US-008 | `backend-dev`, `gis` | `backend-validation` | Idempotent backfill for historical versions. | ### Agent Workflow Per Phase @@ -356,21 +343,21 @@ Every user story must be assigned to one or more HASTE agents. The **implementin | Priority | Story | Phase | Implementing Agent | Component | |---|---|---|---|---| -| P0 | US-001 | Phase 2/3 — Readiness & UI Entry | `backend-dev`, `ui` | model payloads, `ui/src/Components/ProjectManagement/`, `ui/src/Components/Visualizer/` | -| P0 | US-002 | Phase 2/3 — Prep Workflow & Vector Viewer | `backend-dev`, `gis`, `ui` | `hastelib`, `hastefuncapi`, `hastefuncqueues`, `ui/src/Components/Visualizer/` | -| P0 | US-003 | Phase 3 — Results Viewer Edit Mode | `ui` | `ui/src/Components/Visualizer/` | -| P0 | US-004 | Phase 1/2/3 — Data Model, API & UI Save | `backend-dev`, `gis`, `ui` | `hastelib`, Blob Storage, `hastefuncapi`, Visualizer hooks | -| P1 | US-005 | Phase 3/4 — Version History | `backend-dev`, `ui` | `hastefuncapi`, `ui/src/Components/Visualizer/` | -| P0 | US-006 | Phase 2/4 — Reader Integration | `backend-dev`, `gis` | `hastefuncapi`, `hastelib/src/hastegeo/core/utils/predictions.py` | +| P0 | US-001 | Phase 2/3 — Readiness & UI Entry | `backend-dev`, `ui` | model payloads, Visualizer route | +| P0 | US-002 | Phase 2/3 — Prep Workflow & Vector Viewer | `backend-dev`, `gis`, `ui` | `hastelib`, queues, Visualizer artifacts | +| P0 | US-003 | Phase 3 — Results Viewer Edit Mode | `ui` | Visualizer edit panel and map state | +| P0 | US-004 | Phase 1/2/3 — Data Model, API & UI Save | `backend-dev`, `gis`, `ui` | GPKG + sidecar save | +| P0 | US-005 | Phase 2/3 — Map Version Selection | `backend-dev`, `ui` | `GetVisualizerResults`, selector | +| P0 | US-006 | Phase 2/3 — Report Default Split | `backend-dev`, `gis`, `ui` | report endpoints and UI warning | +| P0 | US-007 | Phase 2/3 — Version Downloads | `backend-dev`, `ui` | `GetModelArtifact`, download controls | +| P0 | US-008 | Phase 2/4 — Backfill | `backend-dev`, `gis` | prediction-tiles job and metadata | ## Out of Scope Stories explicitly excluded from this feature: - [ ] Publish edited versions through the data-publishing workflow. -- [ ] Switch served prediction versions from the UI; history is read-only in the current branch. -- [ ] Add a dedicated one-click edited-version download button in the edit panel. - [ ] Add collaborative real-time editing, locking, 409 conflict handling, or audit diff playback. -- [ ] Introduce a generic artifact registry beyond the Model-level edited version list. -- [ ] Resolve the assessment-report asymmetry where edited `damaged` changes validation metrics but preserved `damage_pct_0m` drives threshold-based assessment counts. +- [ ] Resolve the Assessment-report asymmetry where edited `damaged` changes Validation metrics but preserved `damage_pct_0m` drives threshold-based Assessment counts. +- [ ] Add browser/Playwright coverage in this branch; the repo has no Playwright config. - [ ] Fix the pre-existing positional-join risks in the classic prediction writer or add explicit producer-side `overture_id` columns. diff --git a/ui/src/Components/Visualizer/PredictionEditPanel.jsx b/ui/src/Components/Visualizer/PredictionEditPanel.jsx index bce630a2..09543bfd 100644 --- a/ui/src/Components/Visualizer/PredictionEditPanel.jsx +++ b/ui/src/Components/Visualizer/PredictionEditPanel.jsx @@ -6,6 +6,12 @@ // models that support them), the live "would change class" readout, the save // action, and the saved-version history. // +// The history is append-only and every entry is downloadable: the GeoPackage +// for a version is written when it is saved, so it can be exported even when +// its per-building sidecar has not been backfilled yet and the map therefore +// cannot draw it. Downloads go through GetModelArtifact like everything else +// on this page, never a blob SAS URL. +// // This is the overlay the pencil affordance opens over the results view. It // was the standalone Prediction Editor's right panel and is deliberately // unchanged in look: the same review controls, now on the map the analyst was @@ -29,6 +35,7 @@ import { RadioGroup, Slider, Text, + Tooltip, makeStyles, tokens, } from "@fluentui/react-components"; @@ -47,6 +54,7 @@ import { toPercentLabel, } from "./predictionClassify"; import { describeServedVersion } from "./predictionResults"; +import { describeVersionDownload } from "./predictionVersions"; const CLASS_ORDER = [CLASS_DAMAGED, CLASS_NOT_DAMAGED, CLASS_UNKNOWN]; @@ -223,10 +231,19 @@ const useStyles = makeStyles({ }, versionHeader: { display: "flex", - alignItems: "baseline", + alignItems: "center", justifyContent: "space-between", gap: tokens.spacingHorizontalXS, }, + // Badge + download sit together on the right of a version row. + versionActions: { + display: "flex", + alignItems: "center", + gap: tokens.spacingHorizontalXS, + }, + versionNote: { + marginBottom: tokens.spacingVerticalXS, + }, versionTitle: { fontWeight: tokens.fontWeightSemibold, }, @@ -272,6 +289,9 @@ const PredictionEditPanel = ({ savedResult, versions, activeVersion = null, + onDownloadVersion, + reportDivergence = null, + thresholdNote = "", }) => { const styles = useStyles(); @@ -392,8 +412,8 @@ const PredictionEditPanel = ({ {!supportsThreshold && (
- This model does not expose a tunable score, so classes come from - its own decisions plus your edits. + {thresholdNote || + "This model does not expose a tunable score, so classes come from its own decisions plus your edits."}
)} @@ -513,6 +533,14 @@ const PredictionEditPanel = ({ {/* Saved versions */}
Saved versions
+ {reportDivergence && ( + + + {reportDivergence.title} + {reportDivergence.body} + + + )} {orderedVersions.length === 0 ? (
No edited versions yet. Saving creates version 1 — the model’s @@ -533,11 +561,30 @@ const PredictionEditPanel = ({ Version {version.version} - {version.version === activeVersion && ( - - On the map - - )} + + {version.version === activeVersion && ( + + On the map + + )} + {/* Every saved version is downloadable, including the + ones whose sidecar has not been backfilled yet: the + GeoPackage is written at save time, so it exists + even when the map cannot draw that version. */} + {typeof onDownloadVersion === "function" && ( + +
{formatDate(version.createdAt)} @@ -630,10 +677,18 @@ PredictionEditPanel.propTypes = { savedResult: PropTypes.shape({ version: PropTypes.number, gpkgUrl: PropTypes.string, + predictionAttrsUrl: PropTypes.string, editedCount: PropTypes.number, + buildingCount: PropTypes.number, }), versions: PropTypes.array.isRequired, activeVersion: PropTypes.number, + onDownloadVersion: PropTypes.func, + reportDivergence: PropTypes.shape({ + title: PropTypes.string, + body: PropTypes.string, + }), + thresholdNote: PropTypes.string, }; export default PredictionEditPanel; diff --git a/ui/src/Components/Visualizer/PredictionStatusNote.jsx b/ui/src/Components/Visualizer/PredictionStatusNote.jsx index b2dc56f3..a9305afd 100644 --- a/ui/src/Components/Visualizer/PredictionStatusNote.jsx +++ b/ui/src/Components/Visualizer/PredictionStatusNote.jsx @@ -12,6 +12,10 @@ // // All copy comes from predictionResults.js / predictionPrep.js so the wording // is decided in pure, unit-tested code rather than in JSX. +// +// It renders inside the page's top-centre overlay column, above which the +// version selector sits — one stack, so a tall note and a tall selector can +// never end up on top of each other. import PropTypes from "prop-types"; import { Button, @@ -37,16 +41,12 @@ import { } from "./predictionPrep.js"; const useStyles = makeStyles({ - // Centred under the app header, clear of the pre/post imagery blocks in the - // corners and below the edit panel's stacking level. + // A card in the results page's top-centre overlay column (see Visualizer's + // `topStack`): the column owns where this sits, so all this has to do is + // fill it and take back the pointer events the column gives up. root: { - position: "absolute", - top: "66px", - left: "50%", - transform: "translateX(-50%)", - zIndex: 900, boxSizing: "border-box", - width: "min(560px, calc(100% - 32px))", + width: "100%", display: "flex", flexDirection: "column", gap: tokens.spacingVerticalXS, @@ -56,6 +56,7 @@ const useStyles = makeStyles({ border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, borderRadius: tokens.borderRadiusMedium, boxShadow: tokens.shadow16, + pointerEvents: "auto", }, detail: { color: tokens.colorNeutralForeground3, diff --git a/ui/src/Components/Visualizer/PredictionVersionControls.jsx b/ui/src/Components/Visualizer/PredictionVersionControls.jsx new file mode 100644 index 00000000..5b8b9d2f --- /dev/null +++ b/ui/src/Components/Visualizer/PredictionVersionControls.jsx @@ -0,0 +1,239 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// The results page's prediction-version control: which saved version the map +// is drawing, how to switch, and how to download the one on screen. +// +// A model's predictions are append-only — the raw model output plus every +// version an analyst saved from edit mode — but until now that history was +// only visible INSIDE edit mode, so a read-only analyst could not tell that +// the map was showing someone's corrections rather than the model's own +// output (or that corrections existed at all). This card says so on every +// visit, in both modes, next to the two actions that follow from it: +// switching versions and downloading the one being shown. +// +// Three things it is careful about: +// +// • a version whose sidecar has not been backfilled yet is offered as +// DISABLED with the reason, because selecting it would produce an empty +// map, not a different one — and when the page lands on one anyway (the +// server's default can be a version that was saved before its sidecar +// existed), the card says so and points at the raw output; +// • version selection moves the MAP only — Assessment and Validation +// always read the newest saved version — so when the server says the +// selection is not the newest, that divergence is stated here rather +// than left to be discovered in a report; and +// • the download goes through GetModelArtifact (auth, managed identity, +// Range) like every other artifact on this page, never a blob SAS URL. +// +// All copy and every option comes from predictionVersions.js, which is pure +// and unit-tested; this file is layout. +import PropTypes from "prop-types"; +import { + Button, + Dropdown, + MessageBar, + MessageBarActions, + MessageBarBody, + MessageBarTitle, + Option, + Spinner, + Text, + Tooltip, + makeStyles, + tokens, +} from "@fluentui/react-components"; +import { FluentIcon } from "../../util/icons"; +import { + describeVersionDownload, + selectedVersionText, + versionKey, +} from "./predictionVersions"; + +const useStyles = makeStyles({ + // A card in the results page's top-centre overlay column (see Visualizer's + // `topStack`), which owns the positioning: this one only has to size itself + // and take back the pointer events the column gives up. + root: { + boxSizing: "border-box", + width: "100%", + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalXS, + padding: tokens.spacingHorizontalS, + borderRadius: tokens.borderRadiusMedium, + pointerEvents: "auto", + }, + row: { + display: "flex", + alignItems: "center", + gap: tokens.spacingHorizontalS, + }, + label: { + color: tokens.colorNeutralForeground2, + whiteSpace: "nowrap", + }, + dropdown: { + flex: 1, + minWidth: "200px", + }, + note: { + width: "100%", + }, +}); + +const PredictionVersionControls = ({ + options, + selectedVersion, + onSelectVersion, + onDownload, + isSwitching = false, + pending = null, + divergence = null, + failure = null, + onDismissFailure, + disabled = false, +}) => { + const styles = useStyles(); + if (!Array.isArray(options) || options.length === 0) return null; + + const selectedKey = versionKey(selectedVersion); + + return ( +
+
+ + Prediction version + + { + if (!data?.optionValue) return; + onSelectVersion(Number(data.optionValue)); + }} + > + {options.map((option) => ( + + ))} + + {isSwitching && ( + + )} + +
+ + {pending && ( + + + {pending.title} + {pending.body} + + + )} + + {divergence && ( + + + {divergence.title} + {divergence.body} + + + )} + + {failure && ( + + + {failure.title} + {failure.body} + + {typeof onDismissFailure === "function" && ( + } + onClick={onDismissFailure} + /> + } + /> + )} + + )} +
+ ); +}; + +PredictionVersionControls.propTypes = { + options: PropTypes.arrayOf( + PropTypes.shape({ + key: PropTypes.string.isRequired, + version: PropTypes.number.isRequired, + text: PropTypes.string.isRequired, + disabled: PropTypes.bool, + disabledReason: PropTypes.string, + }) + ).isRequired, + selectedVersion: PropTypes.number, + onSelectVersion: PropTypes.func.isRequired, + onDownload: PropTypes.func.isRequired, + isSwitching: PropTypes.bool, + pending: PropTypes.shape({ + title: PropTypes.string, + body: PropTypes.string, + }), + divergence: PropTypes.shape({ + title: PropTypes.string, + body: PropTypes.string, + }), + failure: PropTypes.shape({ + title: PropTypes.string, + body: PropTypes.string, + }), + onDismissFailure: PropTypes.func, + disabled: PropTypes.bool, +}; + +export default PredictionVersionControls; diff --git a/ui/src/Components/Visualizer/Visualizer.jsx b/ui/src/Components/Visualizer/Visualizer.jsx index cd80a5fe..98a45b8e 100644 --- a/ui/src/Components/Visualizer/Visualizer.jsx +++ b/ui/src/Components/Visualizer/Visualizer.jsx @@ -26,16 +26,24 @@ // The pencil next to Back turns this same view into an editor — same maps, // same footprints, now clickable — instead of sending the analyst to a // separate screen. See handleToggleEditMode. +// +// VERSIONS. A model's predictions are append-only: the raw model output plus +// every version an analyst saved. GetVisualizerResults serves ONE of them at +// a time, so the version selector (PredictionVersionControls) is a refetch — +// switching means a new payload with a version-pinned sidecar, which the +// artifact hook reloads and the footprint hook rebuilds on BOTH swipe panes. +// See handleSelectVersion. // Dependencies import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import { apiGet } from "../../util/api"; +import { apiGet, buildUrl } from "../../util/api"; import { useParams } from "react-router-dom"; import Labels from "./Labels"; import { AppContext } from "../../AppContext"; import PropType from "prop-types"; import { makeStyles, tokens } from "@fluentui/react-components"; import { convertDateToString } from "../../util/conversion"; +import { fileDownload } from "../../util/file"; import VisualizerImageryControls from "./VisualizerImageryControls" import "../../assets/css/visualizer.css"; import { getAzureMapsAuthOptions } from "../../util/azureMapsAuth"; @@ -43,12 +51,14 @@ import { shouldIgnoreShortcut } from "../keyboardShortcuts"; import { useTheme } from "../../util/ThemeContext.jsx"; import PredictionEditPanel from "./PredictionEditPanel"; import PredictionStatusNote from "./PredictionStatusNote"; +import PredictionVersionControls from "./PredictionVersionControls"; import usePredictionArtifacts from "./usePredictionArtifacts"; import usePredictionFootprints from "./usePredictionFootprints"; import { CLASS_DAMAGED, CLASS_NOT_DAMAGED, CLASS_UNKNOWN, + hasSavedClasses, } from "./predictionClassify"; import { FOOTPRINTS_READY, @@ -60,6 +70,22 @@ import { resolveSupportsThreshold, visualizerLayerOptions, } from "./predictionResults"; +import { + MAX_VERSION_POLL_ATTEMPTS, + RAW_VERSION, + VERSION_POLL_INTERVAL_MS, + buildVersionGpkgUrl, + buildVisualizerResultsUrl, + describeReportDivergence, + describeSavedClassNote, + describeVersionSidecarPending, + describeVersionSwitchDiscard, + describeVersionSwitchFailure, + normalizeVersionSelection, + shouldPollVersionSidecar, + versionLabel, + versionSelectorOptions, +} from "./predictionVersions"; import { dividerPositionForKey, resolveSwipeMode, @@ -80,6 +106,31 @@ const useStyles = makeStyles({ navigationControls: { flexDirection: "row", }, + // The results page's top-centre overlay column: the version selector above + // the layer's status note. + // + // They share one stack rather than each pinning itself to the map, because + // both are variable height (a divergence warning, a failed switch, a + // progress bar) and two independently positioned overlays at fixed offsets + // would sooner or later cover each other. It starts below the navigation + // controls' row so a narrow desktop cannot overlap them either. + // + // The column itself is transparent to the pointer; only the cards inside it + // take clicks, so the map still pans through the gaps. + topStack: { + position: "absolute", + top: "66px", + left: "50%", + transform: "translateX(-50%)", + zIndex: 950, + boxSizing: "border-box", + width: "min(560px, calc(100% - 32px))", + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: tokens.spacingVerticalS, + pointerEvents: "none", + }, // Ctrl+drag box-select rectangle. Absolutely positioned inside the // visualizer container, which shares its top-left corner with both map // canvases, so the drag offsets need no translation. @@ -139,6 +190,28 @@ const Visualizer = ({ setModalComponent }) => { const [mapsReady, setMapsReady] = useState(false); const [swipeStateMobile, setSwipeStateMobile] = useState("post"); const [isEditMode, setIsEditMode] = useState(false); + // Which prediction version is on the map is NOT held in state: it is + // whatever the payload currently on screen says it was served from + // (`servedVersion` below). One source of truth means the selector can never + // claim a version the map is not actually drawing — including while a + // switch is in flight, or after one failed. + const [isSwitchingVersion, setIsSwitchingVersion] = useState(false); + // A switch that failed. Kept as its own state so the previous version stays + // on the map with an explanation instead of the page half-applying a + // version it could not load. + const [versionSwitchFailure, setVersionSwitchFailure] = useState(null); + // How many times we have re-asked for a version whose sidecar is still + // being backfilled. Reset by every deliberate switch. + const [versionPollAttempt, setVersionPollAttempt] = useState(0); + // Only the newest version request may write results: an analyst clicking + // through three versions must not be left on whichever response happens to + // arrive last. + const versionRunRef = useRef(0); + // The run id of the switch the spinner belongs to. A background poll shares + // the counter above (its response must lose to a newer request too), so + // without this a poll landing mid-switch would leave the selector spinning + // for a switch that had already finished. + const switchRunRef = useRef(0); // The status the analyst last dismissed a note for. Stored rather than a // plain boolean so a NEW status (preparing -> failed, say) shows up again // without an effect that resets state behind their back. @@ -193,6 +266,10 @@ const Visualizer = ({ setModalComponent }) => { palette, defaultThreshold: artifacts.session?.defaultThreshold, onSaved: artifacts.refreshVersions, + // Identity of the prediction data being drawn. A version switch changes + // it, which is what makes the hook rebuild the source, the layers and the + // feature-state on BOTH swipe panes rather than repaint stale ones. + renderKey: artifacts.renderKey, }); const footprintStatus = artifacts.status; @@ -210,17 +287,51 @@ const Visualizer = ({ setModalComponent }) => { [globalVisualizerResults] ); - // Visualizer data fetching function + // ── Version selection ──────────────────────────────────────────────────── + // Everything about which version is on the map, and about the divergence + // between the map and the reports, is decided in predictionVersions.js. + const servedVersion = artifacts.activeVersion ?? RAW_VERSION; + const versionOptions = useMemo( + () => + versionSelectorOptions({ + versions: artifacts.versions, + servedVersion, + }), + [artifacts.versions, servedVersion] + ); + // Version selection moves the MAP only — the reports always read the newest + // saved version — so this is disclosed wherever the analyst can act on it. + // `predictionVersionIsLatest` is the server's answer; it knows about + // versions this page may not have listed. + const reportDivergence = useMemo( + () => + describeReportDivergence({ + isLatest: artifacts.versionIsLatest, + servedVersion, + versions: artifacts.versions, + }), + [artifacts.versionIsLatest, servedVersion, artifacts.versions] + ); + + // Visualizer data fetching function. `version` is left out for the very + // first load so the API applies its own default (the newest saved state); + // every switch pins one explicitly, 0 being the raw model output. + const fetchVisualizerResults = useCallback( + (version) => + apiGet( + buildVisualizerResultsUrl({ + projectId, + imageLayerId, + modelId, + version, + }) + ), + [projectId, imageLayerId, modelId] + ); + async function getVisualizerResults() { setIsLoading(true); - return await apiGet( - "GetVisualizerResults?projectId=" + - projectId + - "&imageLayerId=" + - imageLayerId + - "&modelId=" + - modelId - ) + return await fetchVisualizerResults() .then((response) => { setIsLoading(false); return response; @@ -232,6 +343,166 @@ const Visualizer = ({ setModalComponent }) => { }); } + /** + * Point the map at another saved version. + * + * The whole switch is one refetch: the new payload carries a version-pinned + * sidecar URL, which reloads the scores (the footprint geometry is shared + * by every version) and rebuilds both swipe panes. A failure changes + * nothing — the previous version stays on screen with the reason — because + * results are only replaced once the new payload is in hand. + */ + const loadVersion = useCallback( + async (version, { poll = false } = {}) => { + const runId = versionRunRef.current + 1; + versionRunRef.current = runId; + if (!poll) { + switchRunRef.current = runId; + setIsSwitchingVersion(true); + setVersionSwitchFailure(null); + setIsLoading(true, `Loading ${versionLabel(version).toLowerCase()}`); + } + try { + const results = await fetchVisualizerResults(version); + if (runId !== versionRunRef.current) return; + setGlobalVisualizerResults(results); + if (!poll) setVersionPollAttempt(0); + } catch (error) { + if (runId !== versionRunRef.current) return; + console.error("Could not load the selected prediction version:", error); + if (!poll) { + setVersionSwitchFailure( + describeVersionSwitchFailure({ + version, + shownVersion: servedVersion, + message: "The version could not be read from the server.", + }) + ); + } + } finally { + // Released by the switch that owns it, even when a background poll + // has since taken over the run counter — an unreleased spinner would + // lock the selector on a page that is otherwise perfectly usable. + if (!poll && switchRunRef.current === runId) { + setIsSwitchingVersion(false); + setIsLoading(false); + } + } + }, + [fetchVisualizerResults, servedVersion, setIsLoading] + ); + + const handleSelectVersion = useCallback( + (version) => { + const next = normalizeVersionSelection(version); + if (next === servedVersion && !versionSwitchFailure) return; + // Nothing is written until "Save as new version", and the switch + // reloads the predictions from the server, so pending edits would go + // with them. Same confirmation as leaving edit mode. + if (isEditMode && footprints.isDirty) { + setDialog( + "Discard unsaved edits?", + `${describeUnsavedEdits( + footprints.overrides, + footprints.baseline + )} ${describeVersionSwitchDiscard(next)}`, + [ + { + type: "primary", + key: "discard", + text: "Discard and switch", + onClick: () => { + setDialog(); + footprints.discardEdits(); + loadVersion(next); + }, + }, + { + type: "default", + key: "keep", + text: "Keep editing", + onClick: () => setDialog(), + }, + ] + ); + return; + } + loadVersion(next); + }, + [ + servedVersion, + versionSwitchFailure, + isEditMode, + footprints, + loadVersion, + setDialog, + ] + ); + + // A version saved before its sidecar existed has nothing to draw. The + // artifact hook has already asked for the backfill; re-ask the API for the + // same version on a bounded schedule so the map fills in on its own instead + // of making the analyst reload. One timer at a time, cleared on every + // change. + useEffect(() => { + if ( + !shouldPollVersionSidecar({ + pending: artifacts.versionPending, + attempt: versionPollAttempt, + maxAttempts: MAX_VERSION_POLL_ATTEMPTS, + }) + ) { + return undefined; + } + const timer = window.setTimeout(() => { + setVersionPollAttempt((attempt) => attempt + 1); + loadVersion(servedVersion, { poll: true }); + }, VERSION_POLL_INTERVAL_MS); + return () => window.clearTimeout(timer); + }, [ + artifacts.versionPending, + versionPollAttempt, + servedVersion, + loadVersion, + ]); + + // Switching versions re-fetches everything, including the lazily loaded + // edit session that carries the model's flavour and threshold support. An + // analyst who was already editing should not have to leave and re-enter + // edit mode to get those back, so it is re-fetched once per version — and + // only while editing, so a read-only view still never pays for it. + const sessionRenderKeyRef = useRef(""); + useEffect(() => { + if (!isEditMode || !artifacts.renderKey) return; + if (sessionRenderKeyRef.current === artifacts.renderKey) return; + sessionRenderKeyRef.current = artifacts.renderKey; + artifacts.ensureSession().catch((error) => { + // Editing still works without it: the thresholds start from their + // defaults and the history comes from the results payload. + console.warn("Could not reload the prediction edit session:", error); + }); + }, [isEditMode, artifacts]); + + // The GeoPackage for one version, through GetModelArtifact — auth, managed + // identity and Range are handled there, which is why this page never + // rewrites blob SAS URLs the way the model rows do. + const handleDownloadVersion = useCallback( + (version) => { + fileDownload( + buildUrl( + buildVersionGpkgUrl({ + projectId, + imageLayerId, + modelId, + version, + }) + ), + setDialog + ); + }, + [projectId, imageLayerId, modelId, setDialog] + ); + // The width the swipe divider is measured against. The container is the // element both map canvases fill, so it beats window.innerWidth whenever the // page is not full-bleed. @@ -463,6 +734,9 @@ const Visualizer = ({ setModalComponent }) => { setIsLoading(true, "Preparing prediction editing"); try { await artifacts.ensureSession(); + // Claim the session for the version on screen so the effect that + // re-fetches it after a version switch does not immediately ask again. + sessionRenderKeyRef.current = artifacts.renderKey; } catch (error) { // Editing still works without it: the thresholds simply start from // their defaults and the version list stays empty until the first save. @@ -828,6 +1102,28 @@ const Visualizer = ({ setModalComponent }) => { resultsReady && footprintStatus !== FOOTPRINTS_READY && dismissedNoteStatus !== footprintStatus; + // A version saved before its sidecar existed has nothing to draw, which is + // a fact about the VERSION rather than about the layer — so it is said on + // the version card, next to the selector that can get the analyst out of + // it, and the layer's own status note is left to describe the layer. + const versionPendingNote = artifacts.versionPending + ? describeVersionSidecarPending({ + version: servedVersion, + versionsPending: artifacts.versionsPending, + }) + : null; + // The version history is only worth a control once something has been + // saved: with no edited versions there is nothing to choose between, and + // the raw output is already what the page shows. + const showVersionControls = resultsReady && versionOptions.length > 1; + // A version's own classes were decided by an analyst, so the thresholds + // cannot move them; offering sliders that do nothing would be a lie. + const versionHasSavedClasses = hasSavedClasses(artifacts.attrs); + const supportsThreshold = + resolveSupportsThreshold({ + results: globalVisualizerResults, + session: artifacts.session, + }) && !versionHasSavedClasses; return (
@@ -858,16 +1154,34 @@ const Visualizer = ({ setModalComponent }) => { visualizerResults={globalVisualizerResults} /> - {showStatusNote && ( - setDismissedNoteStatus(footprintStatus)} - /> + {(showVersionControls || showStatusNote) && ( +
+ {showVersionControls && ( + setVersionSwitchFailure(null)} + /> + )} + + {showStatusNote && ( + setDismissedNoteStatus(footprintStatus)} + /> + )} +
)} {isEditMode && classification && ( @@ -882,10 +1196,7 @@ const Visualizer = ({ setModalComponent }) => { results: globalVisualizerResults, session: artifacts.session, })} - supportsThreshold={resolveSupportsThreshold({ - results: globalVisualizerResults, - session: artifacts.session, - })} + supportsThreshold={supportsThreshold} counts={classification.counts} total={classification.total} editedCount={classification.editedCount} @@ -918,6 +1229,15 @@ const Visualizer = ({ setModalComponent }) => { savedResult={footprints.savedResult} versions={artifacts.versions} activeVersion={artifacts.activeVersion} + // Downloads and the report disclosure are the same in both modes; + // the panel is simply where the version history already lives. + onDownloadVersion={handleDownloadVersion} + reportDivergence={reportDivergence} + thresholdNote={ + versionHasSavedClasses + ? describeSavedClassNote(servedVersion) + : "" + } /> )} diff --git a/ui/src/Components/Visualizer/predictionClassify.js b/ui/src/Components/Visualizer/predictionClassify.js index 442ce2e3..ec2ea428 100644 --- a/ui/src/Components/Visualizer/predictionClassify.js +++ b/ui/src/Components/Visualizer/predictionClassify.js @@ -86,6 +86,13 @@ export function deriveClass(damage, unknown, threshold, unknownThreshold = 0) { * Coerce the raw prediction_attrs sidecar into the shape the editor uses. * Missing arrays become empty ones and `n` is clamped to the shortest array * actually present, so a truncated sidecar can't index past its data. + * + * A VERSION's sidecar has the same shape plus `classes`: the analyst's final + * class per row. `damage` / `unknown` stay the model's own fractions there + * (an edit changes the class, not the model's confidence), so re-deriving a + * class from them would quietly undo the edit — `classes` is kept and wins. + * Entries that are not one of the three classes are dropped to null so a + * malformed row falls back to the derived class instead of poisoning it. */ export function normalizeAttrs(raw) { const ids = Array.isArray(raw?.ids) ? raw.ids : []; @@ -93,9 +100,47 @@ export function normalizeAttrs(raw) { const damage = Array.isArray(raw?.damage) ? raw.damage : []; const unknown = Array.isArray(raw?.unknown) ? raw.unknown : []; const damaged = Array.isArray(raw?.damaged) ? raw.damaged : []; + const classes = Array.isArray(raw?.classes) + ? raw.classes.map((value) => (isPredictionClass(value) ? value : null)) + : []; const declared = num(raw?.n, ids.length); const n = Math.max(0, Math.min(declared, ids.length)); - return { n, ids, overtureIds, damage, unknown, damaged }; + return { n, ids, overtureIds, damage, unknown, damaged, classes }; +} + +/** The class this sidecar already stores for row `index`, or null. */ +export function savedClassAt(attrs, index) { + const value = attrs?.classes?.[index]; + return isPredictionClass(value) ? value : null; +} + +/** + * True when the sidecar carries saved classes for buildings — i.e. it is a + * version's sidecar rather than the raw model's. The thresholds cannot move + * those buildings, so the page stops offering to. + */ +export function hasSavedClasses(attrs) { + const n = attrs?.n || 0; + for (let i = 0; i < n; i++) { + if (savedClassAt(attrs, i)) return true; + } + return false; +} + +/** + * The class of row `index` before any edit in this session: the version's + * saved class when there is one, else the model's own scores thresholded. + */ +export function baseClassAt(attrs, index, threshold, unknownThreshold = 0) { + return ( + savedClassAt(attrs, index) || + deriveClass( + attrs?.damage?.[index], + attrs?.unknown?.[index], + threshold, + unknownThreshold + ) + ); } /** Map of building id -> row index, for turning a clicked feature id into attrs. */ @@ -200,6 +245,44 @@ export function toOverrideList(overrides) { .sort((a, b) => a.id - b.id); } +/** + * Every override the server needs to reproduce what is on screen. + * + * PutEditedPredictions always derives a new version from the RAW model + * GeoPackage: it applies the thresholds and then the overrides. So when the + * page is editing on top of a saved version — whose sidecar carries the + * analyst's final `classes` — the classes that version established have to + * travel with the save, or the new version would silently drop them. + * + * Only the rows the thresholds cannot reproduce are sent, computed against + * the very thresholds in the payload, so the list stays as small as it can + * be and can never go stale: the user's own edits, plus each carried-over + * class that the raw scores would not derive on their own. + */ +export function mergedOverrideList( + attrs, + overrides, + threshold = 0.5, + unknownThreshold = 0 +) { + const merged = { ...(overrides || {}) }; + const n = attrs?.n || 0; + for (let i = 0; i < n; i++) { + const id = attrs.ids[i]; + if (getOverride(merged, id)) continue; + const saved = savedClassAt(attrs, i); + if (!saved) continue; + const derived = deriveClass( + attrs.damage[i], + attrs.unknown[i], + threshold, + unknownThreshold + ); + if (saved !== derived) merged[id] = saved; + } + return toOverrideList(merged); +} + /** The exact PUT body for PutEditedPredictions. */ export function buildSavePayload({ projectId, @@ -208,6 +291,7 @@ export function buildSavePayload({ threshold, unknownThreshold, overrides, + attrs = null, }) { return { projectId, @@ -215,7 +299,12 @@ export function buildSavePayload({ modelId, threshold: num(threshold), unknownThreshold: num(unknownThreshold), - overrides: toOverrideList(overrides), + overrides: mergedOverrideList( + attrs, + overrides, + num(threshold), + num(unknownThreshold) + ), }; } @@ -227,12 +316,7 @@ export function resolveClassAt(attrs, index, options) { options || {}; const override = getOverride(overrides, attrs?.ids?.[index]); if (override) return override; - return deriveClass( - attrs?.damage?.[index], - attrs?.unknown?.[index], - threshold, - unknownThreshold - ); + return baseClassAt(attrs, index, threshold, unknownThreshold); } /** @@ -254,14 +338,7 @@ export function classifyAll(attrs, options) { let editedCount = 0; for (let i = 0; i < n; i++) { const override = getOverride(overrides, attrs.ids[i]); - const cls = - override || - deriveClass( - attrs.damage[i], - attrs.unknown[i], - threshold, - unknownThreshold - ); + const cls = override || baseClassAt(attrs, i, threshold, unknownThreshold); classes[i] = cls; edited[i] = override != null; if (override != null) editedCount++; @@ -295,8 +372,10 @@ export function filterIndices(classification, filter) { * How many buildings would change class when moving from one threshold * setting to another. Buildings the user has explicitly edited are excluded: * their class is pinned by the override, so a slider move can never flip - * them. This is what drives the live "N buildings would change class" - * readout — no server round-trip involved. + * them. Buildings whose class was SAVED into this version's sidecar are + * excluded for the same reason — the analyst already decided them. + * This is what drives the live "N buildings would change class" readout — + * no server round-trip involved. */ export function countClassChanges(attrs, baseline, candidate, overrides = null) { const n = attrs?.n || 0; @@ -308,6 +387,7 @@ export function countClassChanges(attrs, baseline, candidate, overrides = null) let changed = 0; for (let i = 0; i < n; i++) { if (getOverride(overrides, attrs.ids[i])) continue; + if (savedClassAt(attrs, i)) continue; const before = deriveClass(attrs.damage[i], attrs.unknown[i], fromT, fromU); const after = deriveClass(attrs.damage[i], attrs.unknown[i], toT, toU); if (before !== after) changed++; diff --git a/ui/src/Components/Visualizer/predictionClassify.test.js b/ui/src/Components/Visualizer/predictionClassify.test.js index 34ec05a0..08851e65 100644 --- a/ui/src/Components/Visualizer/predictionClassify.test.js +++ b/ui/src/Components/Visualizer/predictionClassify.test.js @@ -7,9 +7,10 @@ // layer: class derivation and overrides (predictionClassify.js), the // preparation-job state machine (predictionPrep.js), the swipe divider // (visualizerSwipe.js), the layer/status decisions the page renders from -// (predictionResults.js) and the renderer helpers that paint the footprints -// (predictionFootprintMap.js). None of them may import React, Azure Maps or -// FluentUI — this file is run by plain `node --test`. +// (predictionResults.js), the version selector's options, URLs and +// disclosures (predictionVersions.js) and the renderer helpers that paint the +// footprints (predictionFootprintMap.js). None of them may import React, +// Azure Maps or FluentUI — this file is run by plain `node --test`. import test from "node:test"; import assert from "node:assert/strict"; @@ -20,6 +21,7 @@ import { CLASS_UNKNOWN, FILTER_ALL, FILTER_EDITED, + baseClassAt, buildSavePayload, classifyAll, clearOverride, @@ -29,12 +31,15 @@ import { deriveClass, filterIndices, getOverride, + hasSavedClasses, indexById, latestVersion, matchesFilter, + mergedOverrideList, nextIndexInList, normalizeAttrs, resolveClassAt, + savedClassAt, setOverride, setOverrideEntries, setOverrides, @@ -56,6 +61,7 @@ import { applyPrepResponse, buildPrepRequest, describeOutstandingArtifacts, + describePendingVersions, evaluatePrepState, isPrepReady, isTerminalPrepStatus, @@ -94,6 +100,7 @@ import { hasAnyRasterLayer, hasRasterLayer, hasUnsavedEdits, + normalizeVersionParam, rasterLayerAvailability, resolveActiveVersion, resolveFootprintStatus, @@ -104,12 +111,38 @@ import { resolvePredictionsReady, resolveReadinessDetail, resolveReadinessReason, + resolveVersionIsLatest, shouldRequestPreparation, statusForReadinessReason, resolveSupportsThreshold, sameOverrides, + versionSidecarPending, visualizerLayerOptions, } from "./predictionResults.js"; +import { + MAX_VERSION_POLL_ATTEMPTS, + RAW_VERSION, + RAW_VERSION_LABEL, + VERSION_POLL_INTERVAL_MS, + VERSION_PREPARING_REASON, + buildVersionGpkgUrl, + buildVisualizerResultsUrl, + describeReportDivergence, + describeSavedClassNote, + describeVersionDownload, + describeVersionInline, + describeVersionSidecarPending, + describeVersionSwitchDiscard, + describeVersionSwitchFailure, + findVersionOption, + isVersionReady, + normalizeVersionSelection, + selectedVersionText, + shouldPollVersionSidecar, + versionKey, + versionLabel, + versionSelectorOptions, +} from "./predictionVersions.js"; import { CLASS_CODES, FALLBACK_COLORS, @@ -886,6 +919,8 @@ test("artifact URLs prefer the server's own and fall back to the standard route" { footprintTilesUrl: "GetModelArtifact?x=1&kind=footprint_pmtiles", predictionAttrsUrl: "GetModelArtifact?x=1&kind=prediction_attrs", + // Nothing was pinned, so this payload is the model's raw output. + version: null, } ); // ...and today's payload, which has neither field, still resolves. @@ -894,6 +929,7 @@ test("artifact URLs prefer the server's own and fall back to the standard route" "GetModelArtifact?projectId=p1&imageLayerId=l1&modelId=m1&kind=footprint_pmtiles", predictionAttrsUrl: "GetModelArtifact?projectId=p1&imageLayerId=l1&modelId=m1&kind=prediction_attrs", + version: null, }); }); @@ -1520,3 +1556,541 @@ test("centroids and drag rectangles survive the shapes the map hands over", () = assert.equal(normalizeSelectionBox(null, { x: 1, y: 1 }), null); assert.equal(normalizeSelectionBox({ x: 1, y: 1 }, null), null); }); + +// ── Version sidecars ──────────────────────────────────────────────────────── +// A saved version's sidecar is the raw shape plus `classes`: the class each +// building was saved with. The model's `damage` / `unknown` fractions are +// left untouched beside them, so anything that re-derives a class from those +// fractions would silently undo the analyst's edit. + +// The same five buildings as sampleAttrs(), saved as a version in which two +// were corrected by hand: #11 (0.5, would derive NotDamaged) was marked +// Damaged, and #13 (0.9, would derive Damaged) was marked NotDamaged. +function versionAttrs() { + return normalizeAttrs({ + n: 5, + ids: [10, 11, 12, 13, 14], + overtureIds: ["a", "b", "c", "d", "e"], + damage: [0.05, 0.5, 0.51, 0.9, 0.8], + unknown: [0, 0, 0, 0, 0.4], + damaged: [0, 1, 1, 0, 0], + classes: [ + CLASS_NOT_DAMAGED, + CLASS_DAMAGED, + CLASS_DAMAGED, + CLASS_NOT_DAMAGED, + CLASS_UNKNOWN, + ], + }); +} + +test("normalizeAttrs keeps a version's saved classes and drops malformed ones", () => { + const attrs = versionAttrs(); + assert.equal(attrs.classes.length, 5); + assert.equal(attrs.classes[1], CLASS_DAMAGED); + + // The raw model's sidecar has no classes at all — not an absent array the + // rest of the code has to guard against. + assert.deepEqual(sampleAttrs().classes, []); + assert.deepEqual(normalizeAttrs({ ids: [1], classes: "Damaged" }).classes, []); + + // A row the server could not classify must fall back to the derived class + // rather than poisoning it with a value nothing understands. + const messy = normalizeAttrs({ + n: 3, + ids: [1, 2, 3], + damage: [0.9, 0.9, 0.9], + classes: ["Damaged", "Rubble", null], + }); + assert.deepEqual(messy.classes, [CLASS_DAMAGED, null, null]); +}); + +test("savedClassAt and hasSavedClasses tell a version's sidecar from the raw one", () => { + const attrs = versionAttrs(); + assert.equal(savedClassAt(attrs, 1), CLASS_DAMAGED); + assert.equal(savedClassAt(attrs, 99), null); + assert.equal(savedClassAt(null, 0), null); + assert.equal(hasSavedClasses(attrs), true); + + assert.equal(hasSavedClasses(sampleAttrs()), false); + assert.equal(hasSavedClasses(null), false); + // Present but useless: nothing to preserve, so the thresholds still apply. + assert.equal( + hasSavedClasses(normalizeAttrs({ n: 2, ids: [1, 2], classes: [null, "x"] })), + false + ); +}); + +test("baseClassAt prefers a saved class over the thresholds", () => { + const attrs = versionAttrs(); + // Saved NotDamaged at 0.9 damage: the threshold says otherwise and loses. + assert.equal(baseClassAt(attrs, 3, 0.5), CLASS_NOT_DAMAGED); + // Saved Damaged at exactly the threshold, where derivation says NotDamaged. + assert.equal(baseClassAt(attrs, 1, 0.5), CLASS_DAMAGED); + // Moving the slider cannot shift a saved class either. + assert.equal(baseClassAt(attrs, 3, 0.99), CLASS_NOT_DAMAGED); + // The raw sidecar has nothing saved, so the threshold decides. + assert.equal(baseClassAt(sampleAttrs(), 3, 0.5), CLASS_DAMAGED); +}); + +test("classifyAll colours an edited version from its saved classes", () => { + const attrs = versionAttrs(); + const result = classifyAll(attrs, { threshold: 0.5, unknownThreshold: 0.3 }); + assert.deepEqual(result.classes, [ + CLASS_NOT_DAMAGED, + CLASS_DAMAGED, + CLASS_DAMAGED, + CLASS_NOT_DAMAGED, + CLASS_UNKNOWN, + ]); + // Saved classes are not "edited" in this session: nothing is pending. + assert.equal(result.editedCount, 0); + assert.deepEqual(result.edited, [false, false, false, false, false]); + assert.equal(result.counts[CLASS_DAMAGED], 2); + + // A fresh edit in this session still wins over the saved class. + const edited = classifyAll(attrs, { + threshold: 0.5, + overrides: { 13: CLASS_DAMAGED }, + }); + assert.equal(edited.classes[3], CLASS_DAMAGED); + assert.equal(edited.editedCount, 1); + assert.equal(resolveClassAt(attrs, 3, { threshold: 0.5 }), CLASS_NOT_DAMAGED); +}); + +test("countClassChanges ignores buildings a version already decided", () => { + const attrs = versionAttrs(); + // Every row has a saved class, so no slider move can flip anything. + assert.equal( + countClassChanges(attrs, { threshold: 0.5 }, { threshold: 0.95 }), + 0 + ); + // The same move on the raw sidecar does flip buildings, which is what makes + // the readout worth showing there. + assert.ok( + countClassChanges(sampleAttrs(), { threshold: 0.5 }, { threshold: 0.95 }) > 0 + ); +}); + +test("mergedOverrideList carries the classes the thresholds cannot reproduce", () => { + const attrs = versionAttrs(); + // At 0.5 the raw scores would derive NotDamaged, Damaged, Damaged for rows + // 1/3/4 differently from what was saved, so exactly those travel. + assert.deepEqual(mergedOverrideList(attrs, {}, 0.5, 0.3), [ + { id: 11, class: CLASS_DAMAGED }, + { id: 13, class: CLASS_NOT_DAMAGED }, + ]); + // Row 14's saved Unknown IS reproducible at unknownThreshold 0.3, so it is + // not sent; drop the unknown threshold and it has to be. + assert.deepEqual(mergedOverrideList(attrs, {}, 0.5, 0.9), [ + { id: 11, class: CLASS_DAMAGED }, + { id: 13, class: CLASS_NOT_DAMAGED }, + { id: 14, class: CLASS_UNKNOWN }, + ]); + // A fresh edit replaces the carried-over class rather than duplicating it. + assert.deepEqual(mergedOverrideList(attrs, { 13: CLASS_UNKNOWN }, 0.5, 0.3), [ + { id: 11, class: CLASS_DAMAGED }, + { id: 13, class: CLASS_UNKNOWN }, + ]); + // The raw sidecar has nothing to carry: only the user's own edits go. + assert.deepEqual( + mergedOverrideList(sampleAttrs(), { 10: CLASS_DAMAGED }, 0.5, 0), + [{ id: 10, class: CLASS_DAMAGED }] + ); + assert.deepEqual(mergedOverrideList(null, null, 0.5, 0), []); +}); + +test("buildSavePayload carries a version's classes into the next version", () => { + const attrs = versionAttrs(); + // The server derives every new version from the RAW GeoPackage, so saving + // an edit on top of version N must re-state what N established. + const payload = buildSavePayload({ + projectId: "p", + imageLayerId: "l", + modelId: "m", + threshold: 0.5, + unknownThreshold: 0.3, + overrides: { 10: CLASS_UNKNOWN }, + attrs, + }); + assert.deepEqual(payload.overrides, [ + { id: 10, class: CLASS_UNKNOWN }, + { id: 11, class: CLASS_DAMAGED }, + { id: 13, class: CLASS_NOT_DAMAGED }, + ]); + assert.equal(payload.threshold, 0.5); + + // Editing the raw output is unchanged: no attrs, no carried-over classes. + assert.deepEqual( + buildSavePayload({ + projectId: "p", + imageLayerId: "l", + modelId: "m", + threshold: 0.5, + unknownThreshold: 0, + overrides: { 12: CLASS_UNKNOWN }, + }).overrides, + [{ id: 12, class: CLASS_UNKNOWN }] + ); +}); + +// ── Version-pinned artifacts ──────────────────────────────────────────────── + +test("normalizeVersionParam keeps the raw output's explicit zero", () => { + assert.equal(normalizeVersionParam(0), 0); + assert.equal(normalizeVersionParam("0"), 0); + assert.equal(normalizeVersionParam(3), 3); + assert.equal(normalizeVersionParam("3"), 3); + assert.equal(normalizeVersionParam(3.7), 3); + // "Say nothing and let the route apply its own default." + assert.equal(normalizeVersionParam(null), null); + assert.equal(normalizeVersionParam(undefined), null); + assert.equal(normalizeVersionParam(""), null); + // The route 400s on these, so they never leave the browser. + assert.equal(normalizeVersionParam(-1), null); + assert.equal(normalizeVersionParam("latest"), null); + assert.equal(normalizeVersionParam(NaN), null); +}); + +test("buildArtifactUrl pins a version only when there is one to pin", () => { + assert.equal( + buildArtifactUrl({ projectId: "p", modelId: "m", kind: "gpkg" }), + "GetModelArtifact?projectId=p&modelId=m&kind=gpkg" + ); + assert.equal( + buildArtifactUrl({ projectId: "p", modelId: "m", kind: "gpkg", version: 2 }), + "GetModelArtifact?projectId=p&modelId=m&kind=gpkg&version=2" + ); + // Zero is a real request — "the raw output, explicitly" — not an absence. + assert.equal( + buildArtifactUrl({ projectId: "p", modelId: "m", kind: "gpkg", version: 0 }), + "GetModelArtifact?projectId=p&modelId=m&kind=gpkg&version=0" + ); + assert.equal( + buildArtifactUrl({ + projectId: "p", + modelId: "m", + kind: "gpkg", + version: null, + }), + "GetModelArtifact?projectId=p&modelId=m&kind=gpkg" + ); +}); + +test("resolvePredictionArtifacts never substitutes the raw sidecar for a version", () => { + const ids = { projectId: "p", imageLayerId: "l", modelId: "m" }; + + // The server named the artifacts: use exactly what it said. + const served = resolvePredictionArtifacts( + { + predictionVersion: 2, + footprintTilesUrl: "tiles", + predictionAttrsUrl: "attrs?version=2", + }, + ids + ); + assert.equal(served.predictionAttrsUrl, "attrs?version=2"); + assert.equal(served.version, 2); + + // It did not, and the payload was served for version 2 — so the + // reconstructed endpoint is pinned to 2 rather than falling back to the raw + // sidecar, which describes the model's classes and not the analyst's. + const reconstructed = resolvePredictionArtifacts( + { predictionVersion: 2 }, + ids + ); + assert.ok(reconstructed.predictionAttrsUrl.includes("kind=prediction_attrs")); + assert.ok(reconstructed.predictionAttrsUrl.endsWith("&version=2")); + // The geometry is shared by every version, so the tiles are never pinned. + assert.ok(!reconstructed.footprintTilesUrl.includes("version")); + + // Raw output: no version segment at all. + const raw = resolvePredictionArtifacts({}, ids); + assert.ok(!raw.predictionAttrsUrl.includes("version")); + assert.equal(raw.version, null); +}); + +test("resolveVersionIsLatest trusts the server's flag", () => { + // Absent means "assume newest", which is what omitting `version` has always + // meant — an older backend must not read as a divergence. + assert.equal(resolveVersionIsLatest({}), true); + assert.equal(resolveVersionIsLatest(null), true); + assert.equal(resolveVersionIsLatest({ predictionVersionIsLatest: true }), true); + assert.equal( + resolveVersionIsLatest({ predictionVersionIsLatest: false }), + false + ); +}); + +test("versionSidecarPending only fires for a version the server says is not ready", () => { + // Version selected, no sidecar, server says it is being prepared. + assert.equal( + versionSidecarPending({ + predictionVersion: 2, + predictionsReadiness: { attrsReady: false, reason: "preparing" }, + }), + true + ); + assert.equal( + versionSidecarPending({ predictionVersion: 2, predictionsReady: false }), + true + ); + // The sidecar is there: nothing pending, whatever else the payload says. + assert.equal( + versionSidecarPending({ + predictionVersion: 2, + predictionAttrsUrl: "attrs?version=2", + predictionsReady: false, + }), + false + ); + // The raw output is never "a version waiting to be backfilled". + assert.equal(versionSidecarPending({ predictionsReady: false }), false); + assert.equal(versionSidecarPending({ predictionVersion: 2 }), false); + assert.equal(versionSidecarPending(null), false); +}); + +test("prep responses carry the version backfill queue onto the session", () => { + const session = applyPrepResponse( + { tilesReady: false, attrsReady: false }, + { predictionTilesStatus: "InProgress", versionsPending: 3 } + ); + assert.equal(session.versionsPending, 3); + assert.equal(describePendingVersions(session), "Rebuilding 3 saved versions."); + assert.equal( + describePendingVersions({ versionsPending: 1 }), + "Rebuilding 1 saved version." + ); + // Nothing outstanding, or a backend that never said: say nothing. + assert.equal(describePendingVersions({ versionsPending: 0 }), ""); + assert.equal(describePendingVersions({}), ""); + assert.equal(describePendingVersions(null), ""); + assert.equal( + applyPrepResponse({ tilesReady: false }, { versionsPending: "nope" }) + .versionsPending, + undefined + ); +}); + +// ── Version selection ─────────────────────────────────────────────────────── + +test("version selections normalise to an integer, raw output included", () => { + assert.equal(normalizeVersionSelection(2), 2); + assert.equal(normalizeVersionSelection("2"), 2); + assert.equal(normalizeVersionSelection(0), RAW_VERSION); + assert.equal(normalizeVersionSelection(null), RAW_VERSION); + assert.equal(normalizeVersionSelection(-4), RAW_VERSION); + assert.equal(versionKey(2), "2"); + assert.equal(versionKey(null), "0"); + assert.equal(versionLabel(3), "Version 3"); + assert.equal(versionLabel(null), RAW_VERSION_LABEL); + assert.equal(describeVersionInline(3), "edited version 3"); + assert.equal(describeVersionInline(0), "the model's own predictions"); +}); + +test("isVersionReady is about the sidecar, not the version's existence", () => { + assert.equal(isVersionReady({ version: 2, predictionAttrsUrl: "a" }), true); + // Saved, but nothing to draw yet: offering it would produce an empty map. + assert.equal(isVersionReady({ version: 2, predictionAttrsUrl: null }), false); + assert.equal(isVersionReady({ version: 2, predictionAttrsUrl: " " }), false); + assert.equal(isVersionReady({ version: 2 }), false); + assert.equal(isVersionReady(null), false); +}); + +test("buildVisualizerResultsUrl asks for one version at a time", () => { + const ids = { projectId: "p", imageLayerId: "l", modelId: "m" }; + // No version: the API applies its own default, the newest saved state. + assert.equal( + buildVisualizerResultsUrl(ids), + "GetVisualizerResults?projectId=p&imageLayerId=l&modelId=m" + ); + assert.equal( + buildVisualizerResultsUrl({ ...ids, version: 2 }), + "GetVisualizerResults?projectId=p&imageLayerId=l&modelId=m&version=2" + ); + // Dropping to the raw output is an explicit request, not an omission. + assert.equal( + buildVisualizerResultsUrl({ ...ids, version: RAW_VERSION }), + "GetVisualizerResults?projectId=p&imageLayerId=l&modelId=m&version=0" + ); +}); + +test("version downloads go through the artifact route, never a SAS URL", () => { + const ids = { projectId: "p", imageLayerId: "l", modelId: "m" }; + assert.equal( + buildVersionGpkgUrl({ ...ids, version: 2 }), + "GetModelArtifact?projectId=p&imageLayerId=l&modelId=m&kind=gpkg&version=2" + ); + // The raw output is pinned explicitly so the file the analyst gets always + // matches the option they picked. + assert.equal( + buildVersionGpkgUrl(ids), + "GetModelArtifact?projectId=p&imageLayerId=l&modelId=m&kind=gpkg&version=0" + ); + assert.match(describeVersionDownload(2), /version 2/); + assert.match(describeVersionDownload(0), /model's own predictions/); + assert.match(describeVersionDownload(2), /\.gpkg/); +}); + +test("the selector lists saved versions newest first with the raw output last", () => { + const versions = [ + { version: 1, predictionAttrsUrl: "a1" }, + { version: 3, predictionAttrsUrl: null }, + { version: 2, predictionAttrsUrl: "a2" }, + ]; + const options = versionSelectorOptions({ versions, servedVersion: 2 }); + assert.deepEqual( + options.map((option) => option.version), + [3, 2, 1, RAW_VERSION] + ); + + // Version 3 is the newest but has no sidecar: offered, disabled, with the + // reason attached rather than left to produce an empty map. + assert.equal(options[0].disabled, true); + assert.equal(options[0].isNewest, true); + assert.equal(options[0].disabledReason, VERSION_PREPARING_REASON); + assert.match(options[0].text, /preparing/); + + // Version 2 is what the map is drawing, and says so. + assert.equal(options[1].disabled, false); + assert.equal(options[1].isServed, true); + assert.match(options[1].text, /on the map/); + assert.equal(options[2].isServed, false); + assert.equal(options[2].text, "Version 1"); + + // The raw output is always selectable — it is the fallback when a version + // cannot be drawn. + assert.equal(options[3].isRaw, true); + assert.equal(options[3].disabled, false); + assert.equal(options[3].isNewest, false); + assert.equal(options[3].text, RAW_VERSION_LABEL); +}); + +test("with nothing saved the selector offers only the raw output", () => { + const options = versionSelectorOptions({ versions: [], servedVersion: null }); + assert.equal(options.length, 1); + assert.equal(options[0].isRaw, true); + assert.equal(options[0].isNewest, true); + assert.equal(options[0].isServed, true); + assert.match(options[0].text, /on the map/); + // Junk in the version list is not offered as something to switch to. + assert.deepEqual( + versionSelectorOptions({ + versions: [{ version: 0 }, { version: null }, {}], + }).map((option) => option.version), + [RAW_VERSION] + ); + assert.equal(versionSelectorOptions().length, 1); +}); + +test("the closed selector shows what is actually on the map", () => { + const options = versionSelectorOptions({ + versions: [{ version: 1, predictionAttrsUrl: "a1" }], + servedVersion: 1, + }); + assert.equal(findVersionOption(options, 1).version, 1); + assert.equal(findVersionOption(options, 9), null); + assert.equal(findVersionOption(null, 1), null); + assert.match(selectedVersionText(options, 1), /Version 1/); + assert.match(selectedVersionText(options, 1), /on the map/); + // A selection with no option (a version that vanished) still reads sanely. + assert.equal(selectedVersionText(options, 9), "Version 9"); +}); + +test("a version that is not the newest discloses the report divergence", () => { + const versions = [{ version: 2 }, { version: 3 }]; + // The server says this is the newest: nothing to disclose. + assert.equal( + describeReportDivergence({ isLatest: true, servedVersion: 3, versions }), + null + ); + assert.equal(describeReportDivergence(), null); + + const note = describeReportDivergence({ + isLatest: false, + servedVersion: 2, + versions, + }); + assert.match(note.title, /newest/i); + assert.match(note.body, /edited version 2/); + assert.match(note.body, /version 3/); + assert.match(note.body, /Assessment and Validation/); + + // Sitting on the raw output while edits exist is the same disclosure. + const rawNote = describeReportDivergence({ + isLatest: false, + servedVersion: RAW_VERSION, + versions, + }); + assert.match(rawNote.body, /model's own predictions/); +}); + +test("a version with no sidecar explains itself and offers a way out", () => { + const note = describeVersionSidecarPending({ version: 2, versionsPending: 2 }); + assert.match(note.title, /Version 2/); + assert.match(note.body, /nothing to draw/); + assert.match(note.body, /2 saved versions are waiting/); + assert.match(note.body, /raw model output/); + assert.match( + describeVersionSidecarPending({ version: 2, versionsPending: 1 }).body, + /1 saved version is waiting/ + ); + // The backend said nothing about a queue: do not invent one. + assert.ok( + !/waiting to be rebuilt/.test( + describeVersionSidecarPending({ version: 2 }).body + ) + ); +}); + +test("a failed switch names what is still on the map", () => { + const failure = describeVersionSwitchFailure({ + version: 2, + shownVersion: 3, + message: "The version could not be read from the server.", + }); + assert.match(failure.title, /Version 2/); + assert.match(failure.body, /could not be read/); + // The point of the message: the previous version is still there, not a + // blank map the analyst has to guess about. + assert.match(failure.body, /still shows edited version 3/); + assert.match( + describeVersionSwitchFailure({ version: 1, shownVersion: RAW_VERSION }).body, + /still shows the model's own predictions/ + ); +}); + +test("switching away from unsaved edits says what will be lost", () => { + const copy = describeVersionSwitchDiscard(2); + assert.match(copy, /version 2/); + assert.match(copy, /discards the edits you have not saved/); + assert.match(copy, /Save them as a new version first/); + + // And an edited version's thresholds are inert, which is worth saying + // rather than leaving a control silently missing. + assert.match(describeSavedClassNote(2), /Version 2/); + assert.match(describeSavedClassNote(2), /thresholds no longer apply/); + assert.equal(describeSavedClassNote(RAW_VERSION), ""); +}); + +test("the pending-version poll is bounded", () => { + assert.equal(shouldPollVersionSidecar({ pending: true, attempt: 0 }), true); + assert.equal( + shouldPollVersionSidecar({ + pending: true, + attempt: MAX_VERSION_POLL_ATTEMPTS - 1, + }), + true + ); + // A forgotten tab stops asking instead of polling forever. + assert.equal( + shouldPollVersionSidecar({ + pending: true, + attempt: MAX_VERSION_POLL_ATTEMPTS, + }), + false + ); + assert.equal(shouldPollVersionSidecar({ pending: false, attempt: 0 }), false); + assert.equal(shouldPollVersionSidecar(), false); + assert.ok(VERSION_POLL_INTERVAL_MS >= 1000); +}); diff --git a/ui/src/Components/Visualizer/predictionPrep.js b/ui/src/Components/Visualizer/predictionPrep.js index 472aad54..84b5d13b 100644 --- a/ui/src/Components/Visualizer/predictionPrep.js +++ b/ui/src/Components/Visualizer/predictionPrep.js @@ -114,6 +114,21 @@ export function describeOutstandingArtifacts(session) { return `Still generating ${missing.join(" and ")}.`; } +/** + * How many saved versions the prep job still has to backfill sidecars for, or + * "" when none (or when the backend never said). + * + * The prep call the editor already makes asks for the backfill, so this is + * the only thing that explains why a saved version is not selectable yet. + */ +export function describePendingVersions(session) { + const pending = Number(session?.versionsPending); + if (!Number.isFinite(pending) || pending <= 0) return ""; + return `Rebuilding ${pending.toLocaleString()} saved ${ + pending === 1 ? "version" : "versions" + }.`; +} + /** Label for the status chip; unknown/missing reads as "Starting". */ export function prepStatusLabel(value) { switch (normalizePrepStatus(value)) { @@ -259,6 +274,15 @@ export function applyPrepResponse(session, response) { if (typeof response.attrsReady === "boolean") { next.attrsReady = response.attrsReady; } + // How many saved versions the same job is backfilling sidecars for. Kept on + // the session so the waiting card can explain why a version the analyst can + // see in the history is not selectable yet. + if (response.versionsPending !== null && response.versionsPending !== undefined) { + const versionsPending = Number(response.versionsPending); + if (Number.isFinite(versionsPending) && versionsPending >= 0) { + next.versionsPending = versionsPending; + } + } const status = normalizePrepStatus( response.predictionTilesStatus ?? response.status ); diff --git a/ui/src/Components/Visualizer/predictionResults.js b/ui/src/Components/Visualizer/predictionResults.js index 3f55c180..933b32bd 100644 --- a/ui/src/Components/Visualizer/predictionResults.js +++ b/ui/src/Components/Visualizer/predictionResults.js @@ -63,18 +63,38 @@ function cleanString(value) { return typeof value === "string" ? value.trim() : ""; } +/** + * A version query value, or null when there is nothing to pin. + * + * `0` is a real answer — GetModelArtifact reads it as "the raw model output, + * explicitly" — so it survives, while null/undefined/"" mean "say nothing and + * let the route apply its own default". + */ +export function normalizeVersionParam(value) { + if (value === null || value === undefined || value === "") return null; + const version = Number(value); + if (!Number.isFinite(version) || version < 0) return null; + return Math.floor(version); +} + /** * The API-relative GetModelArtifact endpoint for one artifact `kind`. * * `imageLayerId` is optional in the contract (the route falls back to the * model's own layer) but is always sent when known: footprint tiles are * layer-scoped, and being explicit costs nothing. + * + * `version` is optional and only meaningful for the per-version kinds + * (`prediction_attrs`, `gpkg`): omitted serves the model-level artifact (the + * raw output), `0` forces the raw output explicitly, and `N` serves that + * edited version's own artifact. */ export function buildArtifactUrl({ projectId, imageLayerId, modelId, kind, + version, } = {}) { const params = new URLSearchParams(); params.set("projectId", String(projectId ?? "")); @@ -83,6 +103,8 @@ export function buildArtifactUrl({ } params.set("modelId", String(modelId ?? "")); params.set("kind", String(kind ?? "")); + const pinned = normalizeVersionParam(version); + if (pinned !== null) params.set("version", String(pinned)); return `GetModelArtifact?${params.toString()}`; } @@ -92,15 +114,24 @@ export function buildArtifactUrl({ * Prefers whatever the server handed back so the API stays free to move the * artifacts, and reconstructs the standard endpoints when those fields are * missing (the pre-vector-first payload). + * + * The sidecar is PER VERSION and the geometry is not: every version of a + * model describes the same buildings, so the PMTiles archive is shared while + * the scores and classes come from the selected version's own file. When the + * payload was served for version N and did not name a sidecar, the + * reconstructed endpoint is pinned to N — the raw model's sidecar is never + * substituted, because it describes the model's classes and not the + * analyst's. */ export function resolvePredictionArtifacts(results, ids = {}) { + const version = resolveActiveVersion(results); const footprintTilesUrl = cleanString(results?.footprintTilesUrl) || buildArtifactUrl({ ...ids, kind: "footprint_pmtiles" }); const predictionAttrsUrl = cleanString(results?.predictionAttrsUrl) || - buildArtifactUrl({ ...ids, kind: "prediction_attrs" }); - return { footprintTilesUrl, predictionAttrsUrl }; + buildArtifactUrl({ ...ids, kind: "prediction_attrs", version }); + return { footprintTilesUrl, predictionAttrsUrl, version }; } // ── Model shape ───────────────────────────────────────────────────────────── @@ -249,6 +280,37 @@ export function describeServedVersion(activeVersion) { : "Showing the model's own predictions."; } +/** + * Whether the version the payload was served from is the newest saved state + * of this model's predictions. + * + * SERVER-DECIDED, never recomputed here: the API knows about versions this + * page may not have listed yet, and it is the same flag the report routes + * resolve their default from. Absent (an older payload) means "assume + * newest", which is what omitting `version` has always meant. + */ +export function resolveVersionIsLatest(results) { + return results?.predictionVersionIsLatest !== false; +} + +/** + * True when the payload was served for an edited version whose sidecar has + * not been built yet. + * + * That version genuinely has nothing to draw — the raw sidecar is never + * substituted — so the page shows the "still preparing" state instead of an + * empty map. Requires the server to actually say so (`attrsReady: false` or + * `predictionsReady: false`): a payload that simply omits the URL falls + * through to the reconstructed version-pinned endpoint, which 404s on its own + * if the file really is missing. + */ +export function versionSidecarPending(results) { + if (resolveActiveVersion(results) === null) return false; + if (cleanString(results?.predictionAttrsUrl)) return false; + if (results?.predictionsReadiness?.attrsReady === false) return true; + return results?.predictionsReady === false; +} + // ── Footprint layer status ────────────────────────────────────────────────── // LOADING artifacts are being fetched (or we do not know yet). diff --git a/ui/src/Components/Visualizer/predictionVersions.js b/ui/src/Components/Visualizer/predictionVersions.js new file mode 100644 index 00000000..60e359b1 --- /dev/null +++ b/ui/src/Components/Visualizer/predictionVersions.js @@ -0,0 +1,319 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Pure decision logic for CHOOSING which prediction version the results map +// draws, and for downloading any of them. +// +// A model's predictions are append-only: the raw model output plus every +// version an analyst saved from edit mode. GetVisualizerResults serves one of +// them at a time — omit `version` for the newest saved state, pass `0` for +// the raw output, pass `N` for that version — and returns a version-pinned +// `predictionAttrsUrl` plus `predictionVersionIsLatest`. So "which versions +// can I pick, which one is on the map, which one can't be drawn yet, and what +// do I have to disclose about the reports?" is a set of plain functions over +// the payload, which is what this module holds. Nothing here touches React, +// Azure Maps or fetch; the rules are unit-tested in predictionClassify.test.js. +// +// Two things this module deliberately does NOT do: +// +// • it never recomputes "is this the newest version?" from the version +// list — the server says so, and it knows about versions this page may +// not have listed yet; and +// • it never points a version at another version's artifacts. A version +// with no sidecar yet is offered as DISABLED with the reason, rather than +// silently drawing the raw model's classes under an edited version's name. +import { latestVersion, sortVersionsDescending } from "./predictionClassify.js"; +import { buildArtifactUrl, normalizeVersionParam } from "./predictionResults.js"; + +/** The raw model output, as a selection value. */ +export const RAW_VERSION = 0; +export const RAW_VERSION_LABEL = "Raw model output"; + +/** Why a saved version cannot be selected yet. */ +export const VERSION_PREPARING_REASON = + "This version's per-building scores are still being generated, so it " + + "cannot be drawn yet. It becomes selectable on its own once the backfill " + + "finishes."; + +// How often to re-ask GetVisualizerResults for a version whose sidecar is +// still being backfilled. Same 5s rhythm as the tile-preparation poll (see +// predictionPrep.js) so the app has one polling cadence, not several. +export const VERSION_POLL_INTERVAL_MS = 5000; + +// Give up after 60 polls (5 minutes). Backfilling one already-saved version's +// sidecar is a small job compared with tiling a whole layer, and the analyst +// can always pick another version in the meantime, so this stops far sooner +// than the tiling wait. +export const MAX_VERSION_POLL_ATTEMPTS = 60; + +/** A selection value normalised to an integer >= 0 (raw output = 0). */ +export function normalizeVersionSelection(value) { + const version = normalizeVersionParam(value); + return version === null ? RAW_VERSION : version; +} + +/** The dropdown key for a selection. Fluent's Dropdown works in strings. */ +export function versionKey(version) { + return String(normalizeVersionSelection(version)); +} + +/** Human name for one version. */ +export function versionLabel(version) { + const normalized = normalizeVersionSelection(version); + return normalized === RAW_VERSION + ? RAW_VERSION_LABEL + : `Version ${normalized}`; +} + +/** The same name inside a sentence ("the map is showing …"). */ +export function describeVersionInline(version) { + const normalized = normalizeVersionSelection(version); + return normalized === RAW_VERSION + ? "the model's own predictions" + : `edited version ${normalized}`; +} + +/** True when a saved version's own sidecar exists and can be drawn. */ +export function isVersionReady(entry) { + const url = entry?.predictionAttrsUrl; + return typeof url === "string" && url.trim() !== ""; +} + +/** + * The GetVisualizerResults endpoint for one version selection. + * + * `version` is left out entirely when it is null/undefined, which is how the + * page asks for the server's own default (the newest saved state); `0` asks + * for the raw model output explicitly. + */ +export function buildVisualizerResultsUrl({ + projectId, + imageLayerId, + modelId, + version, +} = {}) { + const params = new URLSearchParams(); + params.set("projectId", String(projectId ?? "")); + params.set("imageLayerId", String(imageLayerId ?? "")); + params.set("modelId", String(modelId ?? "")); + const pinned = normalizeVersionParam(version); + if (pinned !== null) params.set("version", String(pinned)); + return `GetVisualizerResults?${params.toString()}`; +} + +/** + * Where to download one version's GeoPackage. + * + * Always the GetModelArtifact route, never the blob SAS URL the model rows + * rewrite: the artifact route already carries auth, managed identity and + * Range, and is what every other artifact on this page goes through. + */ +export function buildVersionGpkgUrl({ + projectId, + imageLayerId, + modelId, + version, +} = {}) { + return buildArtifactUrl({ + projectId, + imageLayerId, + modelId, + kind: "gpkg", + version: normalizeVersionSelection(version), + }); +} + +/** Tooltip/aria copy for a download action. */ +export function describeVersionDownload(version) { + const normalized = normalizeVersionSelection(version); + return normalized === RAW_VERSION + ? "Download the model's own predictions as a GeoPackage (.gpkg)" + : `Download version ${normalized} as a GeoPackage (.gpkg)`; +} + +/** + * The options the version selector offers: every saved version newest first, + * then the raw model output. + * + * Raw goes last on purpose — the newest edit is what an analyst normally + * wants, and the raw output is the fallback they drop to deliberately. + * + * Each option carries everything the control needs to render honestly: + * whether it is the newest saved state, whether it is the one currently on + * the map, and whether it can be picked at all (a version whose sidecar has + * not been backfilled yet cannot). + */ +export function versionSelectorOptions({ + versions = [], + servedVersion = null, +} = {}) { + const served = normalizeVersionSelection(servedVersion); + const ordered = sortVersionsDescending(versions).filter( + (entry) => normalizeVersionParam(entry?.version) > 0 + ); + const newest = latestVersion(ordered); + const newestNumber = newest ? normalizeVersionParam(newest.version) : null; + + const options = ordered.map((entry) => { + const version = normalizeVersionParam(entry.version); + const ready = isVersionReady(entry); + const isNewest = version === newestNumber; + const isServed = version === served; + const markers = []; + if (isNewest) markers.push("newest"); + if (isServed) markers.push("on the map"); + if (!ready) markers.push("preparing…"); + return { + key: versionKey(version), + version, + label: versionLabel(version), + text: markers.length + ? `${versionLabel(version)} · ${markers.join(" · ")}` + : versionLabel(version), + disabled: !ready, + disabledReason: ready ? "" : VERSION_PREPARING_REASON, + isNewest, + isServed, + isRaw: false, + }; + }); + + const rawServed = served === RAW_VERSION; + options.push({ + key: versionKey(RAW_VERSION), + version: RAW_VERSION, + label: RAW_VERSION_LABEL, + text: rawServed ? `${RAW_VERSION_LABEL} · on the map` : RAW_VERSION_LABEL, + disabled: false, + disabledReason: "", + // The raw output is only "newest" when nothing has ever been saved. + isNewest: newestNumber === null, + isServed: rawServed, + isRaw: true, + }); + + return options; +} + +/** The option matching a selection, or null. */ +export function findVersionOption(options, version) { + const key = versionKey(version); + return (options || []).find((option) => option.key === key) || null; +} + +/** What the closed dropdown displays for the current selection. */ +export function selectedVersionText(options, version) { + const option = findVersionOption(options, version); + return option ? option.text : versionLabel(version); +} + +/** + * The disclosure that the map and the reports are looking at different + * versions, or null when they agree. + * + * Version selection moves the MAP only: Assessment and Validation always read + * the newest saved version. Saying so where the analyst can see it is the + * difference between a disclosed limitation and a wrong number nobody + * questioned. `isLatest` comes straight from `predictionVersionIsLatest`. + */ +export function describeReportDivergence({ + isLatest = true, + servedVersion = null, + versions = [], +} = {}) { + if (isLatest) return null; + const newest = latestVersion(versions); + const newestNumber = newest ? normalizeVersionParam(newest.version) : null; + const newestName = + newestNumber && newestNumber > 0 ? `version ${newestNumber}` : "the newest"; + return { + title: "Reports use the newest version", + body: + `The map is showing ${describeVersionInline(servedVersion)}. The ` + + `Assessment and Validation reports always read the newest saved ` + + `version (${newestName}), so their numbers will not match what is on ` + + `screen.`, + }; +} + +/** Copy for a selected version whose sidecar is still being backfilled. */ +export function describeVersionSidecarPending({ + version = null, + versionsPending = null, +} = {}) { + const normalized = normalizeVersionSelection(version); + const pending = Number(versionsPending); + const queue = + Number.isFinite(pending) && pending > 0 + ? ` ${pending.toLocaleString()} saved ${ + pending === 1 ? "version is" : "versions are" + } waiting to be rebuilt.` + : ""; + return { + title: `${versionLabel(normalized)} is still being prepared`, + body: + `This version was saved before its per-building scores were ` + + `generated, so there is nothing to draw for it yet. HASTE has asked ` + + `for them; the map fills in on its own when they arrive.${queue} ` + + `Pick another version — ${RAW_VERSION_LABEL.toLowerCase()} always ` + + `works — to keep working in the meantime.`, + }; +} + +/** Copy for a version switch that failed, naming what is still on screen. */ +export function describeVersionSwitchFailure({ + version = null, + shownVersion = null, + message = "", +} = {}) { + const detail = typeof message === "string" ? message.trim() : ""; + return { + title: `${versionLabel(version)} could not be loaded`, + body: + `${detail ? `${detail} ` : ""}The map still shows ` + + `${describeVersionInline(shownVersion)}.`, + }; +} + +/** + * Why the class thresholds are inert on an edited version. + * + * A saved version stores the class an analyst decided per building, and the + * server keeps the model's raw fractions untouched beside them. Re-deriving + * classes from those fractions would throw the analyst's decision away, so + * the sliders are hidden and this says why rather than leaving a control + * silently missing. + */ +export function describeSavedClassNote(version) { + const normalized = normalizeVersionSelection(version); + if (normalized === RAW_VERSION) return ""; + return ( + `Version ${normalized} stores the class each building was saved with, ` + + `so the thresholds no longer apply to it. Switch to ` + + `${RAW_VERSION_LABEL.toLowerCase()} to work from the model's scores again.` + ); +} + +/** Confirmation copy for switching versions with unsaved edits pending. */ +export function describeVersionSwitchDiscard(version) { + return ( + `Switching to ${versionLabel(version).toLowerCase()} reloads the ` + + `predictions from the server, which discards the edits you have not ` + + `saved. Save them as a new version first if you want to keep them.` + ); +} + +/** + * Whether to schedule another "has this version's sidecar appeared yet?" + * poll. Only while a version really is pending, and only up to the cap so a + * forgotten tab cannot poll forever. + */ +export function shouldPollVersionSidecar({ + pending = false, + attempt = 0, + maxAttempts = MAX_VERSION_POLL_ATTEMPTS, +} = {}) { + if (!pending) return false; + const current = Number.isFinite(Number(attempt)) ? Number(attempt) : 0; + return current < maxAttempts; +} diff --git a/ui/src/Components/Visualizer/usePredictionArtifacts.js b/ui/src/Components/Visualizer/usePredictionArtifacts.js index cc2f86b9..ddc85949 100644 --- a/ui/src/Components/Visualizer/usePredictionArtifacts.js +++ b/ui/src/Components/Visualizer/usePredictionArtifacts.js @@ -22,6 +22,15 @@ // history), fetched lazily: reading it costs the API a GeoPackage read, so a // plain results view that finds its artifacts on the first try never asks for // one. Entering edit mode does. +// +// VERSIONS. The sidecar is per version and the geometry is not: every saved +// version of a model describes the same buildings, so switching versions +// re-downloads the scores and reuses the PMTiles archive. The load is keyed +// on the resolved artifact URLs rather than on the route, which is what makes +// a version switch (a new results payload with a version-pinned +// `predictionAttrsUrl`) reload exactly as much as it has to — and a version +// whose sidecar has not been backfilled yet is reported as "preparing" +// instead of being drawn from the raw model's scores. import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PMTiles } from "pmtiles"; import { apiGet, apiPut, buildUrl } from "../../util/api"; @@ -41,7 +50,9 @@ import { resolvePredictionsReady, resolveReadinessDetail, resolveReadinessReason, + resolveVersionIsLatest, shouldRequestPreparation, + versionSidecarPending, } from "./predictionResults.js"; import { MAX_PREP_POLL_ATTEMPTS, @@ -78,6 +89,13 @@ const usePredictionArtifacts = ({ // that produced `session`. const sessionRef = useRef(null); const sessionPromiseRef = useRef(null); + // The PMTiles archive already downloaded and registered with the protocol. + // Footprint geometry is shared by every version of a model, so a version + // switch must not re-download it. + const loadedArchiveRef = useRef(""); + // True once a backfill has been asked for on this run, so a version whose + // sidecar is missing requests the job once rather than on every render. + const backfillRequestedRef = useRef(""); const [attrs, setAttrs] = useState(null); const [archiveKey, setArchiveKey] = useState(""); @@ -99,6 +117,17 @@ const usePredictionArtifacts = ({ [results, projectId, imageLayerId, modelId] ); + // The identity of what is being drawn: the sidecar decides every class on + // the map, so this string changes exactly when a version switch (or a route + // change) means the renderer has to be rebuilt. Held as a plain string so + // effects can depend on it without re-running for an unrelated payload + // refresh that happens to produce a new object. + const attrsKey = artifactUrls.predictionAttrsUrl; + const tilesKey = artifactUrls.footprintTilesUrl; + // A version that was saved before its sidecar existed: nothing to fetch, + // and the raw sidecar must never stand in for it. + const versionPending = versionSidecarPending(results); + const sessionEndpoint = useMemo( () => `GetPredictionEditSession?projectId=${encodeURIComponent(projectId)}` + @@ -177,11 +206,19 @@ const usePredictionArtifacts = ({ // ── Artifact download ───────────────────────────────────────────────────── const loadArtifacts = useCallback( async (runId) => { + if (!attrsKey || !tilesKey) { + const failure = new Error( + "This version's per-building predictions are not available yet." + ); + failure.missing = true; + throw failure; + } setIsLoading(true); setError(""); // Streamed through the same-origin API proxy (managed identity server - // side) so analysts behind the storage firewall can read them. - const attrsUrl = buildUrl(artifactUrls.predictionAttrsUrl); + // side) so analysts behind the storage firewall can read them. The URL + // is version-pinned, so this is also the whole of a version switch. + const attrsUrl = buildUrl(attrsKey); const response = await fetch(attrsUrl); if (!response.ok) { const notFound = response.status === 404; @@ -200,15 +237,20 @@ const usePredictionArtifacts = ({ // Download the whole archive once and serve pmtiles.js from memory: the // SWA /api proxy in front of the function app does not honour range - // requests. - const archiveUrl = buildUrl(artifactUrls.footprintTilesUrl); + // requests. Every version of a model describes the SAME buildings, so + // an archive already registered for this URL is reused rather than + // re-downloaded on a version switch. + const archiveUrl = buildUrl(tilesKey); const protocol = getPmtilesProtocol(); - const buffer = await fetchArtifactBuffer(archiveUrl); - if (isStale(runId)) return false; - const archive = new PMTiles( - new InMemoryPMTilesSource(archiveUrl, buffer) - ); - if (protocol) protocol.add(archive); + if (loadedArchiveRef.current !== archiveUrl) { + const buffer = await fetchArtifactBuffer(archiveUrl); + if (isStale(runId)) return false; + const archive = new PMTiles( + new InMemoryPMTilesSource(archiveUrl, buffer) + ); + if (protocol) protocol.add(archive); + loadedArchiveRef.current = archiveUrl; + } indexByIdRef.current = indexById(loadedAttrs); setBuildingCount(loadedAttrs.n); @@ -218,7 +260,7 @@ const usePredictionArtifacts = ({ setIsLoading(false); return true; }, - [artifactUrls, isStale] + [attrsKey, tilesKey, isStale] ); // ── Preparation ─────────────────────────────────────────────────────────── @@ -310,15 +352,19 @@ const usePredictionArtifacts = ({ ); // ── Load ────────────────────────────────────────────────────────────────── + // Keyed on the resolved artifact URLs, not just the route: switching to + // another saved version keeps the same model but points at that version's + // own sidecar, and that is precisely when everything below has to be + // thrown away and fetched again. useEffect(() => { if (!resultsReady) return undefined; const runId = runRef.current + 1; runRef.current = runId; - // Route params can change without remounting; start from a clean slate — - // but keep whatever the results payload already told us, so a - // vector-first API answers "how many buildings?" and "which versions - // exist?" without a single extra request. + // Route params (and the selected version) can change without remounting; + // start from a clean slate — but keep whatever the results payload + // already told us, so a vector-first API answers "how many buildings?" + // and "which versions exist?" without a single extra request. setAttrs(null); indexByIdRef.current = new Map(); sessionRef.current = null; @@ -337,6 +383,19 @@ const usePredictionArtifacts = ({ // ask for artifacts that cannot exist. if (initialBuildingCount === 0) return; const reason = resolveReadinessReason(results); + // This version was saved before its sidecar was: there is genuinely + // nothing to draw, and the raw model's scores must NOT stand in for it. + // Ask for the backfill once (the prep job rebuilds missing versions) + // and let the page sit on its "preparing" note until the poll upstairs + // sees the sidecar appear. + if (versionPending) { + setIsLoading(false); + if (backfillRequestedRef.current !== attrsKey) { + backfillRequestedRef.current = attrsKey; + await requestPreparation(false); + } + return; + } // A payload that already says "not ready" saves us a doomed download. if (resolvePredictionsReady({ results }) === false) { await beginPreparation(runId, reason); @@ -369,10 +428,20 @@ const usePredictionArtifacts = ({ // results through isStale(). runRef.current += 1; }; - // `results` is only read for its readiness flag and artifact URLs, both of - // which are folded into artifactUrls / resultsReady. + // `results` is only read for its readiness flags and artifact URLs, all of + // which are folded into attrsKey / tilesKey / versionPending / + // resultsReady. Listing `results` itself would reload the layer whenever + // an unrelated field of the payload changed identity. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId, imageLayerId, modelId, resultsReady]); + }, [ + projectId, + imageLayerId, + modelId, + resultsReady, + attrsKey, + tilesKey, + versionPending, + ]); // ── Preparation polling ─────────────────────────────────────────────────── // Each pass schedules exactly ONE timeout and then re-runs off the state it @@ -432,13 +501,31 @@ const usePredictionArtifacts = ({ loaded: isLoaded, loading: isLoading, error, - ready: prepState ? false : resolvePredictionsReady({ results, session }), + // A version with no sidecar is never "ready", whatever the + // model-level flags say: those describe the raw artifacts, which this + // version may not use. + ready: + prepState || versionPending + ? false + : resolvePredictionsReady({ results, session }), buildingCount, // Only trusted while a job is not already running: once one is, the - // poll knows more than the reason the page loaded with. - reason: prepState ? "" : resolveReadinessReason(results), + // poll knows more than the reason the page loaded with. A pending + // version keeps its own reason, because the job that fixes it is the + // backfill rather than anything the poll watches. + reason: + prepState && !versionPending ? "" : resolveReadinessReason(results), }), - [isLoaded, isLoading, error, prepState, results, session, buildingCount] + [ + isLoaded, + isLoading, + error, + prepState, + versionPending, + results, + session, + buildingCount, + ] ); return { @@ -447,6 +534,14 @@ const usePredictionArtifacts = ({ error, readinessDetail: resolveReadinessDetail(results), activeVersion: resolveActiveVersion(results), + // Whether the served version is the newest saved state (server-decided), + // and whether it is one whose sidecar is still being backfilled. + versionIsLatest: resolveVersionIsLatest(results), + versionPending, + versionsPending: session?.versionsPending ?? null, + // Changes exactly when the thing being drawn changes, so the renderer can + // rebuild both swipe panes from scratch on a version switch. + renderKey: attrsKey, attrs, indexByIdRef, archiveKey, diff --git a/ui/src/Components/Visualizer/usePredictionFootprints.js b/ui/src/Components/Visualizer/usePredictionFootprints.js index f6623d0f..75d5ebed 100644 --- a/ui/src/Components/Visualizer/usePredictionFootprints.js +++ b/ui/src/Components/Visualizer/usePredictionFootprints.js @@ -24,6 +24,15 @@ // feature-state write and paint expression — otherwise half the map is inert // and the far side draws every footprint in the "not classified" colour. // +// SWITCHING VERSIONS goes through the same rule and is the easiest place to +// break it: feature-state lives on the RENDERER, one per pane, so pointing +// the page at another version's sidecar has to tear down and rebuild the +// source, the layers and the feature-state on BOTH panes. `renderKey` — the +// version-pinned sidecar URL — is in the layer effect's deps for exactly that +// reason, and the teardown clears the feature-state before removing the +// source so nothing from the previous version can survive under a re-created +// source of the same name. +// // Saving PUTs the thresholds plus the sparse override list to // PutEditedPredictions, which writes a brand-new version — nothing is // destructive. @@ -114,6 +123,10 @@ const usePredictionFootprints = ({ palette, defaultThreshold, onSaved, + // Identity of the prediction data on the map (the version-pinned sidecar + // URL). A new value means a different version is being drawn, so the layers + // and every scrap of per-building state are rebuilt from scratch. + renderKey = "", }) => { // ── Refs the long-lived map handlers read ──────────────────────────────── // A handler registered when the layers are built closes over that render's @@ -480,6 +493,11 @@ const usePredictionFootprints = ({ // Built once the maps are ready and the archive is in memory, on BOTH panes. // Everything the handlers need comes from refs, so this effect never re-runs // for an edit-mode toggle or a class change. + // + // It DOES re-run for `renderKey`: a version switch draws different classes + // for the same buildings, and feature-state is per renderer, so both panes + // are torn down (state cleared, layers and source removed, handlers and the + // hydrate timer detached) and rebuilt rather than repainted in place. useEffect(() => { if (!mapsReady || !archiveKey || !window.atlas) return undefined; const maps = (mapRefs || []) @@ -606,6 +624,21 @@ const usePredictionFootprints = ({ console.warn("Could not detach a footprint handler:", error); } } + // Wipe this pane's feature-state BEFORE the source goes: a rebuild + // re-creates the source under the same id, and a renderer that kept + // its state map keyed by that id would hand the next version the + // previous one's colours on this pane only. That asymmetry between + // the two panes is the bug this page has shipped twice. + try { + if (pane.glMap && typeof pane.glMap.removeFeatureState === "function") { + pane.glMap.removeFeatureState({ + source: sourceIdsRef.current[pane.key] || pane.sourceId, + sourceLayer: PMTILES_SOURCE_LAYER, + }); + } + } catch (error) { + console.warn("Could not clear the footprint feature-state:", error); + } try { pane.map.layers.remove(pane.fillLayer); pane.map.layers.remove(pane.lineLayer); @@ -631,7 +664,35 @@ const usePredictionFootprints = ({ // would not change when this effect runs, and featureAtEvent / // attachBoxSelect are plain closures defined in this module scope. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mapsReady, archiveKey]); + }, [mapsReady, archiveKey, renderKey]); + + // A different version means different classes for the same buildings, so + // every piece of per-building state belongs to the version it came from: + // the analyst's in-progress overrides, what "saved" means, the selection, + // the filter and the thresholds. Rewinding them here (rather than leaving + // them to bleed across the switch) is what stops version 2 being saved with + // version 1's edits silently folded in. + // + // Skipped on the first run: everything below is already at its initial + // value, and setting it again would cost a render for nothing. + const renderKeyRef = useRef(renderKey); + useEffect(() => { + if (renderKeyRef.current === renderKey) return; + renderKeyRef.current = renderKey; + setOverridesState({}); + setSavedBaseline(null); + setSavedResult(null); + setSaveError(""); + setSelectedIndex(-1); + setFilter(FILTER_ALL); + setThresholdOverride(null); + setUnknownThresholdOverride(null); + selectedIdRef.current = null; + // Centroids were harvested from the previous render pass; the geometry is + // the same, but the cache refills itself on the next hydrate and keeping + // it would pin memory to a version nobody is looking at any more. + centroidsRef.current = new Map(); + }, [renderKey]); // ── Classification ─────────────────────────────────────────────────────── // Every building's class is a pure function of the scores, the thresholds @@ -847,6 +908,11 @@ const usePredictionFootprints = ({ threshold, unknownThreshold, overrides, + // Carries the loaded version's own classes into the save: the server + // derives every version from the RAW GeoPackage, so editing on top of + // version N has to re-send what N established or the new version + // would quietly lose it. + attrs, }); const result = await apiPut("PutEditedPredictions", payload); // apiPut surfaces a conflict as the bare status code. @@ -884,6 +950,7 @@ const usePredictionFootprints = ({ threshold, unknownThreshold, overrides, + attrs, onSaved, ]); From a26c74daaefc020eac92bf605c320484b8f73767 Mon Sep 17 00:00:00 2001 From: Caleb Robinson Date: Tue, 25 Aug 2026 22:41:56 +0000 Subject: [PATCH 06/10] feat(ui): make prediction editing a class picker and stop double-click zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing a footprint's class went through two controls that overlapped: a "Clicking a footprint" radio group whose default was a cycle mode, and a separate row of buttons that set the *selected* building's class. The two were coupled — pressing 1/2/3, or using the buttons, silently changed the click mode as a side effect — so what a click would do depended on state the analyst had changed several actions ago. Replace both with one class picker. The picked class is what every edit gesture applies: left-click a footprint, or Ctrl+drag to box-select many. Right-click still undoes an edit. The cycle mode is gone, along with cycleClass() and the branch that advanced each building from its own class during a box-select. Picking a class no longer edits anything on its own. Relabelling whatever happened to be selected, because the analyst pressed a number key to line up their next click, is the surprise this mode should not have; Enter is now the explicit "apply to the selected building" for the arrow-key review flow, and is listed in the shortcut help. normalizeEditClass() guards the one-way door: every path that writes an override now funnels through it, so a stale value (the old "cycle" sentinel, say) cannot reach the overrides map and, from there, a saved GeoPackage. Also disable Azure Maps' double-click zoom for as long as edit mode is on. Labelling two neighbouring buildings quickly registers as a double-click, and the map zoomed out from under the analyst mid-edit. Panning and scroll zoom are untouched, and the gesture is restored on exit and on unmount. --- .../Visualizer/PredictionEditPanel.jsx | 65 ++++++-------- ui/src/Components/Visualizer/Visualizer.jsx | 28 +++--- .../Visualizer/predictionClassify.js | 21 +++-- .../Visualizer/predictionClassify.test.js | 24 +++-- .../Visualizer/usePredictionFootprints.js | 89 +++++++++++-------- ui/src/Components/keyboardShortcuts.js | 10 ++- 6 files changed, 139 insertions(+), 98 deletions(-) diff --git a/ui/src/Components/Visualizer/PredictionEditPanel.jsx b/ui/src/Components/Visualizer/PredictionEditPanel.jsx index 09543bfd..eb1c0190 100644 --- a/ui/src/Components/Visualizer/PredictionEditPanel.jsx +++ b/ui/src/Components/Visualizer/PredictionEditPanel.jsx @@ -31,8 +31,6 @@ import { MessageBarBody, MessageBarTitle, Option, - Radio, - RadioGroup, Slider, Text, Tooltip, @@ -268,9 +266,9 @@ const PredictionEditPanel = ({ filteredIndices, selectedIndex, currentBuilding, - clickAction, - setClickAction, - onSetClass, + activeClass, + setActiveClass, + onApplyToSelected, onClearOverride, onClearAllEdits, onPrev, @@ -485,19 +483,30 @@ const PredictionEditPanel = ({
- {/* Editing */} + {/* Editing — one picker decides what every gesture applies. */} + +
+ {CLASS_ORDER.map((cls) => ( + + ))} +
+
+
+ Click a footprint — or Ctrl+drag to box-select several — to set it to{" "} + {CLASS_LABELS[activeClass] || activeClass}. Right-click undoes an + edit. +
+
-
Set the selected building to:
- {CLASS_ORDER.map((cls) => ( - - ))} +
- - setClickAction(data.value)} - > - - {CLASS_ORDER.map((cls) => ( - - ))} - - -
+ {!loading && !error && summarySentence && ( {summarySentence} @@ -387,6 +402,7 @@ AssessmentReportModal.propTypes = { imageLayerId: PropTypes.string.isRequired, modelId: PropTypes.string.isRequired, modelName: PropTypes.string, + versions: PropTypes.array, onDismiss: PropTypes.func.isRequired, }; diff --git a/ui/src/Components/BuildingValidation/ValidationReportModal.jsx b/ui/src/Components/BuildingValidation/ValidationReportModal.jsx index dfb9a718..4650c43f 100644 --- a/ui/src/Components/BuildingValidation/ValidationReportModal.jsx +++ b/ui/src/Components/BuildingValidation/ValidationReportModal.jsx @@ -18,6 +18,8 @@ import { import { FluentIcon } from "../../util/icons"; import PropTypes from "prop-types"; import { buildUrl } from "../../util/api"; +import PredictionVersionPicker from "../OtherComponents/PredictionVersionPicker"; +import { defaultPredictionVersion } from "../Visualizer/predictionVersions"; /* ── Theme-aware design tokens (follow light/dark via Fluent) ─── */ const tokens = { @@ -193,18 +195,24 @@ ConfusionMatrix.propTypes = { }; /* ── Main component ──────────────────────────────────────────── */ -const ValidationReportModal = ({ projectId, imageLayerId, modelId, modelName, onDismiss }) => { +const ValidationReportModal = ({ projectId, imageLayerId, modelId, modelName, versions, onDismiss }) => { ValidationReportModal.propTypes = { projectId: PropTypes.string.isRequired, imageLayerId: PropTypes.string.isRequired, modelId: PropTypes.string.isRequired, modelName: PropTypes.string, + versions: PropTypes.array, onDismiss: PropTypes.func.isRequired, }; const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Which predictions the report describes. Defaults to the newest saved + // edit, matching what the server would have picked on its own. + const [version, setVersion] = useState(() => + defaultPredictionVersion(versions) + ); const fetchReport = () => { setLoading(true); @@ -218,7 +226,7 @@ const ValidationReportModal = ({ projectId, imageLayerId, modelId, modelName, on // is a hard error. fetch( buildUrl( - `GetValidationReport?projectId=${projectId}&imageLayerId=${imageLayerId}&modelId=${modelId}` + `GetValidationReport?projectId=${projectId}&imageLayerId=${imageLayerId}&modelId=${modelId}&version=${version}` ) ) .then(async (response) => { @@ -244,7 +252,7 @@ const ValidationReportModal = ({ projectId, imageLayerId, modelId, modelName, on useEffect(() => { fetchReport(); - }, [projectId, imageLayerId, modelId]); + }, [projectId, imageLayerId, modelId, version]); const subText = loading ? undefined @@ -277,6 +285,13 @@ const ValidationReportModal = ({ projectId, imageLayerId, modelId, modelName, on
+ {subText && ( {subText} diff --git a/ui/src/Components/OtherComponents/DownloadPredictionsDialog.jsx b/ui/src/Components/OtherComponents/DownloadPredictionsDialog.jsx new file mode 100644 index 00000000..3eaa590c --- /dev/null +++ b/ui/src/Components/OtherComponents/DownloadPredictionsDialog.jsx @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Asks which predictions to download when a model has more than one set. +// +// The model rows used to download the raw GeoPackage unconditionally, so an +// analyst who had spent an afternoon correcting predictions still got the +// model's uncorrected output, with a filename that gave no hint which one it +// was. When saved versions exist the caller opens this dialog instead; when +// they don't, it never appears and the download happens straight away. +import { useState } from "react"; +import PropTypes from "prop-types"; +import { + Button, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + Text, + tokens, +} from "@fluentui/react-components"; +import { FluentIcon } from "../../util/icons"; +import PredictionVersionPicker from "./PredictionVersionPicker"; +import { defaultPredictionVersion } from "../Visualizer/predictionVersions"; + +const DownloadPredictionsDialog = ({ + versions, + modelName, + onDownload, + onDismiss, +}) => { + const [version, setVersion] = useState(() => + defaultPredictionVersion(versions) + ); + + return ( + { + if (!data.open) onDismiss(); + }} + > + + + +
+ + Download predictions +
+
+ + + {modelName + ? `${modelName} has saved edits. Choose which predictions to download.` + : "This model has saved edits. Choose which predictions to download."} + + + + + + + +
+
+
+ ); +}; + +DownloadPredictionsDialog.propTypes = { + versions: PropTypes.array, + modelName: PropTypes.string, + onDownload: PropTypes.func.isRequired, + onDismiss: PropTypes.func.isRequired, +}; + +export default DownloadPredictionsDialog; diff --git a/ui/src/Components/OtherComponents/PredictionVersionPicker.jsx b/ui/src/Components/OtherComponents/PredictionVersionPicker.jsx new file mode 100644 index 00000000..9661ffbd --- /dev/null +++ b/ui/src/Components/OtherComponents/PredictionVersionPicker.jsx @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Which of a model's saved predictions to read. +// +// A model's predictions are append-only: the raw model output plus a version +// for every set of edits an analyst saved. Reports and downloads used to take +// whatever the server picked, which meant a report could quietly describe +// different data than the file next to it. This control makes the choice +// explicit wherever predictions are read. +// +// It renders nothing when there is only the raw output, so a model nobody has +// edited looks exactly as it did before. +import PropTypes from "prop-types"; +import { Dropdown, Field, Option } from "@fluentui/react-components"; +import { + predictionSourceOptions, + versionLabel, +} from "../Visualizer/predictionVersions"; + +const PredictionVersionPicker = ({ + versions, + value, + onChange, + label = "Predictions", + disabled = false, +}) => { + const options = predictionSourceOptions(versions); + if (options.length < 2) return null; + + const selected = options.find((option) => option.version === value); + + return ( + + { + if (data.optionValue == null) return; + onChange(Number(data.optionValue)); + }} + > + {options.map((option) => ( + + ))} + + + ); +}; + +PredictionVersionPicker.propTypes = { + versions: PropTypes.array, + value: PropTypes.number.isRequired, + onChange: PropTypes.func.isRequired, + label: PropTypes.string, + disabled: PropTypes.bool, +}; + +export default PredictionVersionPicker; diff --git a/ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx b/ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx index 651c3c74..8709ef7f 100644 --- a/ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx +++ b/ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx @@ -25,6 +25,11 @@ import StatusIndicator from "../OtherComponents/StatusIndicator"; import ValidationReportModal from "../BuildingValidation/ValidationReportModal"; import AssessmentReportModal from "../BuildingValidation/AssessmentReportModal"; import PublishDatasetModal from "../PublishDatasetModal"; +import DownloadPredictionsDialog from "../OtherComponents/DownloadPredictionsDialog"; +import { + buildVersionGpkgUrl, + hasPredictionVersionChoice, +} from "../Visualizer/predictionVersions"; import { fileDownload } from "../../util/file"; import { limitTextLength } from "../../util/conversion"; @@ -81,6 +86,8 @@ const EmbeddingModelRow = ({ const [showValidationReport, setShowValidationReport] = useState(false); const [showAssessmentReport, setShowAssessmentReport] = useState(false); const [showPublishDataset, setShowPublishDataset] = useState(false); + const [showDownloadPredictions, setShowDownloadPredictions] = + useState(false); const isProcessed = model.status === "Processed"; const hasPredictions = !!model.gpkgUrl; @@ -135,6 +142,12 @@ const EmbeddingModelRow = ({ icon: , disabled: !hasPredictions, onClick: () => { + // With saved edits there is a real choice to make, and downloading + // the raw output silently would throw away the analyst's work. + if (hasPredictionVersionChoice(model.editedPredictions)) { + setShowDownloadPredictions(true); + return; + } // Stream the predictions GeoPackage through the same-origin API // (GetModelArtifact) rather than the raw blob URL, so it works for // remote labelers behind the storage firewall — matching how the @@ -239,12 +252,33 @@ const EmbeddingModelRow = ({ const reportModals = ( <> + {showDownloadPredictions && ( + + fileDownload( + buildUrl( + buildVersionGpkgUrl({ + projectId, + imageLayerId, + modelId: model.modelId, + version, + }) + ), + setDialog + ) + } + onDismiss={() => setShowDownloadPredictions(false)} + /> + )} {showValidationReport && ( setShowValidationReport(false)} /> )} @@ -254,6 +288,7 @@ const EmbeddingModelRow = ({ imageLayerId={imageLayerId} modelId={model.modelId} modelName={model.name} + versions={model.editedPredictions} onDismiss={() => setShowAssessmentReport(false)} /> )} diff --git a/ui/src/Components/ProjectManagement/ModelResultsButton.jsx b/ui/src/Components/ProjectManagement/ModelResultsButton.jsx index 0fcb1c46..3c64d437 100644 --- a/ui/src/Components/ProjectManagement/ModelResultsButton.jsx +++ b/ui/src/Components/ProjectManagement/ModelResultsButton.jsx @@ -20,6 +20,12 @@ import ModelResultsStatusIndicator from "../OtherComponents/ModelResultsStatusIn import ValidationReportModal from "../BuildingValidation/ValidationReportModal"; import AssessmentReportModal from "../BuildingValidation/AssessmentReportModal"; import PublishDatasetModal from "../PublishDatasetModal"; +import DownloadPredictionsDialog from "../OtherComponents/DownloadPredictionsDialog"; +import { buildUrl } from "../../util/api"; +import { + buildVersionGpkgUrl, + hasPredictionVersionChoice, +} from "../Visualizer/predictionVersions"; // A model has usable inference outputs once at least one inference job has run @@ -46,6 +52,8 @@ const ModelResultsButton = ({ model, projectId, imageLayerId, index, validationL const [showValidationReport, setShowValidationReport] = useState(false); const [showAssessmentReport, setShowAssessmentReport] = useState(false); const [showPublishDataset, setShowPublishDataset] = useState(false); + const [showDownloadPredictions, setShowDownloadPredictions] = + useState(false); function evaluateViewResultsButtonState(model) { // Results button must be enabled if inference jobs exist and status is processed @@ -114,6 +122,12 @@ const ModelResultsButton = ({ model, projectId, imageLayerId, index, validationL text: "Download Geopackage (.gpkg)", icon: , onClick: () => { + // With saved edits there is a real choice to make, and downloading + // the raw output silently would throw away the analyst's work. + if (hasPredictionVersionChoice(model.editedPredictions)) { + setShowDownloadPredictions(true); + return; + } handleDownload(model.gpkgUrl); }, disabled: model.gpkgUrl === null || model.gpkgUrl === undefined || model.gpkgUrl === "", @@ -224,12 +238,32 @@ const ModelResultsButton = ({ model, projectId, imageLayerId, index, validationL /> )}
+ {showDownloadPredictions && ( + + handleDownload( + buildUrl( + buildVersionGpkgUrl({ + projectId, + imageLayerId, + modelId: model.modelId, + version, + }) + ) + ) + } + onDismiss={() => setShowDownloadPredictions(false)} + /> + )} {showValidationReport && ( setShowValidationReport(false)} /> )} @@ -239,6 +273,7 @@ const ModelResultsButton = ({ model, projectId, imageLayerId, index, validationL imageLayerId={imageLayerId} modelId={model.modelId} modelName={model.name} + versions={model.editedPredictions} onDismiss={() => setShowAssessmentReport(false)} /> )} diff --git a/ui/src/Components/Visualizer/predictionClassify.test.js b/ui/src/Components/Visualizer/predictionClassify.test.js index 4c979f27..35757c51 100644 --- a/ui/src/Components/Visualizer/predictionClassify.test.js +++ b/ui/src/Components/Visualizer/predictionClassify.test.js @@ -129,6 +129,7 @@ import { VERSION_PREPARING_REASON, buildVersionGpkgUrl, buildVisualizerResultsUrl, + defaultPredictionVersion, describeReportDivergence, describeSavedClassNote, describeVersionDownload, @@ -137,8 +138,10 @@ import { describeVersionSwitchDiscard, describeVersionSwitchFailure, findVersionOption, + hasPredictionVersionChoice, isVersionReady, normalizeVersionSelection, + predictionSourceOptions, selectedVersionText, shouldPollVersionSidecar, versionKey, @@ -2106,3 +2109,65 @@ test("the pending-version poll is bounded", () => { assert.equal(shouldPollVersionSidecar(), false); assert.ok(VERSION_POLL_INTERVAL_MS >= 1000); }); + +// ── Choosing which predictions to download or report on ───────────────────── + +test("predictionSourceOptions lists saved versions newest first, raw last", () => { + const options = predictionSourceOptions([ + { version: 1, gpkgUrl: "v1.gpkg" }, + { version: 3, gpkgUrl: "v3.gpkg" }, + { version: 2, gpkgUrl: "v2.gpkg" }, + ]); + assert.deepEqual( + options.map((o) => o.version), + [3, 2, 1, RAW_VERSION] + ); + assert.equal(options[0].isNewest, true); + assert.match(options[0].text, /newest/); + assert.equal(options[3].isRaw, true); +}); + +test("a version with no GeoPackage is not offered", () => { + const options = predictionSourceOptions([ + { version: 1, gpkgUrl: "v1.gpkg" }, + { version: 2 }, + ]); + assert.deepEqual( + options.map((o) => o.version), + [1, RAW_VERSION] + ); +}); + +test("a version with no sidecar is still downloadable", () => { + // versionSelectorOptions disables this one because the MAP cannot colour + // buildings without the sidecar. A download reads the GeoPackage, so the + // same version has to stay selectable here. + const versions = [{ version: 1, gpkgUrl: "v1.gpkg" }]; + const mapOption = versionSelectorOptions({ versions })[0]; + assert.equal(mapOption.disabled, true); + assert.equal(predictionSourceOptions(versions)[0].version, 1); +}); + +test("reads default to the newest saved edit, else the raw output", () => { + assert.equal( + defaultPredictionVersion([ + { version: 1, gpkgUrl: "v1.gpkg" }, + { version: 2, gpkgUrl: "v2.gpkg" }, + ]), + 2 + ); + assert.equal(defaultPredictionVersion([]), RAW_VERSION); + assert.equal(defaultPredictionVersion(), RAW_VERSION); + // Nothing downloadable saved: raw is the newest state there is. + assert.equal(defaultPredictionVersion([{ version: 1 }]), RAW_VERSION); +}); + +test("a choice exists only once something has been saved", () => { + assert.equal(hasPredictionVersionChoice([]), false); + assert.equal(hasPredictionVersionChoice(), false); + assert.equal(hasPredictionVersionChoice([{ version: 1 }]), false); + assert.equal( + hasPredictionVersionChoice([{ version: 1, gpkgUrl: "v1.gpkg" }]), + true + ); +}); diff --git a/ui/src/Components/Visualizer/predictionVersions.js b/ui/src/Components/Visualizer/predictionVersions.js index 60e359b1..4524a8b5 100644 --- a/ui/src/Components/Visualizer/predictionVersions.js +++ b/ui/src/Components/Visualizer/predictionVersions.js @@ -317,3 +317,73 @@ export function shouldPollVersionSidecar({ const current = Number.isFinite(Number(attempt)) ? Number(attempt) : 0; return current < maxAttempts; } + +// ── Choosing which predictions to READ (download / report) ────────────────── +// +// Separate from versionSelectorOptions on purpose. That one is for the MAP, +// so it disables any version whose attribute sidecar has not been backfilled +// — without the sidecar there are no per-building classes to colour with. +// Downloads and reports read the GeoPackage itself, where a missing sidecar +// is irrelevant, so gating on it there would hide a perfectly good file. + +/** + * The prediction sources a model can be downloaded from or reported on: + * every saved version that has a GeoPackage, newest first, raw output last. + * + * Always returns at least the raw option, so callers can render the control + * unconditionally and use the length to decide whether a choice exists. + */ +export function predictionSourceOptions(versions = []) { + const ordered = sortVersionsDescending(versions).filter( + (entry) => normalizeVersionParam(entry?.version) > 0 && entry?.gpkgUrl + ); + const newest = latestVersion(ordered); + const newestNumber = newest ? normalizeVersionParam(newest.version) : null; + + const options = ordered.map((entry) => { + const version = normalizeVersionParam(entry.version); + const isNewest = version === newestNumber; + return { + key: versionKey(version), + version, + label: versionLabel(version), + text: isNewest + ? `${versionLabel(version)} · newest` + : versionLabel(version), + isNewest, + isRaw: false, + }; + }); + + options.push({ + key: versionKey(RAW_VERSION), + version: RAW_VERSION, + label: RAW_VERSION_LABEL, + text: RAW_VERSION_LABEL, + // Raw is only the newest state when nothing has ever been saved. + isNewest: newestNumber === null, + isRaw: true, + }); + + return options; +} + +/** + * What a download or report should read unless the analyst says otherwise: + * the newest saved edit, else the raw output. + * + * Matches the server's own rule (`describe_prediction_source` with no + * version), so the default the UI shows is the one the API would have picked + * on its own. + */ +export function defaultPredictionVersion(versions = []) { + const newest = predictionSourceOptions(versions).find( + (option) => option.isNewest + ); + return newest ? newest.version : RAW_VERSION; +} + +/** Whether there is more than one source to choose between. */ +export function hasPredictionVersionChoice(versions = []) { + return predictionSourceOptions(versions).length > 1; +} From cbfd201ad7ff01672a841aa6b3325c1ef79901c7 Mon Sep 17 00:00:00 2001 From: Caleb Robinson Date: Mon, 31 Aug 2026 19:02:51 +0000 Subject: [PATCH 08/10] fix(assessment): count an analyst's saved call, not the model's score Assessment read damage_pct_0m, which apply_edits deliberately leaves at whatever the model predicted, so its headline counts were identical for every version of a model. Picking "Version 1" in the new report dropdown changed nothing on screen: model 5553 reported 740 predicted-damaged buildings whether or not you selected the version whose own sidecar says 770. build_assessment_inputs_from_gpkgs now reads edited_class when a row carries one and falls back to the score otherwise, so raw predictions behave exactly as before. "Unknown" is recorded as full unknown coverage, which is what already excludes a building from the known population and from the damaged count. Verified against the dev stack: model 5553 now reports 740 for the raw output and 770 for v1; model 0448, which has six edits, moves 2601 -> 2595. Both match the per-version attribute sidecars. There is no continuous score behind a human decision, so edited rows come through as 0.0/1.0 and an edited version's precision-recall curve is a single binary operating point rather than a sweep. That is a real change in what the curve means, and it is the honest one: the alternative was ranking buildings by a score the analyst has already overruled. --- .../src/hastegeo/core/utils/assessment.py | 20 +++ hastelib/tests/core/utils/test_assessment.py | 120 ++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/hastelib/src/hastegeo/core/utils/assessment.py b/hastelib/src/hastegeo/core/utils/assessment.py index bf59d1dd..30674c5b 100644 --- a/hastelib/src/hastegeo/core/utils/assessment.py +++ b/hastelib/src/hastegeo/core/utils/assessment.py @@ -362,6 +362,7 @@ def build_assessment_inputs_from_gpkgs( labels: Iterable[tuple[str, str]] | None = None, damage_field: str = "damage_pct_0m", unknown_field: str = "unknown_pct", + edited_class_field: str = "edited_class", ) -> AssessmentInputs: """Build :class:`AssessmentInputs` from on-disk GeoPackages. @@ -373,6 +374,16 @@ def build_assessment_inputs_from_gpkgs( ``labels`` is the validation app's ``{overture_id: {label, ...}}`` map flattened to ``(id, label)`` pairs (or ``None`` if computing aggregate-only stats without any labels). + + Rows carrying ``edited_class`` are read from that column instead of + ``damage_field``. An edited version records an analyst's final call + per building, and ``apply_edits`` deliberately leaves the model's + original ``damage_pct_0m`` score untouched — so reading the score + here would report the raw model's counts under an edited version's + name. There is no continuous score behind a human decision, so those + rows come through as 0.0/1.0; on an edited version the + precision-recall curve is therefore a single binary operating point + rather than a sweep. """ import fiona @@ -388,6 +399,15 @@ def build_assessment_inputs_from_gpkgs( if int_id < 0 or int_id >= len(overture_ids): continue oid = overture_ids[int_id] + edited = str(props.get(edited_class_field) or "").strip() + if edited: + # The analyst's call wins over the model's score. + # "Unknown" is recorded as full unknown coverage, which is + # what excludes a building from the known population and + # from the damaged count. + damage_fractions[oid] = 1.0 if edited == "Damaged" else 0.0 + unknown_fractions[oid] = 1.0 if edited == "Unknown" else 0.0 + continue dmg = props.get(damage_field) if dmg is None: continue diff --git a/hastelib/tests/core/utils/test_assessment.py b/hastelib/tests/core/utils/test_assessment.py index 18b2a78d..b650608e 100644 --- a/hastelib/tests/core/utils/test_assessment.py +++ b/hastelib/tests/core/utils/test_assessment.py @@ -9,6 +9,8 @@ """ import math +import os +import tempfile import unittest from hastegeo.core.utils.assessment import ( @@ -18,6 +20,7 @@ AssessmentInputs, _average_precision, _precision_recall_curve, + build_assessment_inputs_from_gpkgs, compute_assessment_report, ) @@ -234,5 +237,122 @@ def test_json_finite_values_only(self): self.assertTrue(math.isfinite(v), f"{section} -> {v}") +class TestEditedPredictionsAreHonoured(unittest.TestCase): + """An analyst's saved call must outrank the model's own score. + + ``apply_edits`` rewrites ``damaged``/``edited_class`` but leaves + ``damage_pct_0m`` at whatever the model predicted. Reading the score + here reported the raw model's counts under an edited version's name. + """ + + def _write(self, directory, rows): + import fiona + from fiona.crs import CRS + from fiona.model import Feature, Geometry + + footprints = os.path.join(directory, "footprints.gpkg") + predictions = os.path.join(directory, "predictions.gpkg") + + def square(i): + return Geometry( + type="Polygon", + coordinates=[ + [ + (i, 0.0), + (i + 0.0005, 0.0), + (i + 0.0005, 0.0005), + (i, 0.0005), + (i, 0.0), + ] + ], + ) + + with fiona.open( + footprints, + "w", + driver="GPKG", + crs=CRS.from_epsg(4326), + schema={"geometry": "Polygon", "properties": {"id": "str"}}, + ) as dst: + for i, _ in enumerate(rows): + dst.write( + Feature( + geometry=square(float(i)), + properties={"id": f"overture-{i}"}, + ) + ) + + schema = { + "geometry": "Polygon", + "properties": { + "id": "int", + "damage_pct_0m": "float", + "unknown_pct": "float", + "edited_class": "str", + }, + } + with fiona.open( + predictions, + "w", + driver="GPKG", + crs=CRS.from_epsg(4326), + schema=schema, + ) as dst: + for i, (damage, edited) in enumerate(rows): + dst.write( + Feature( + geometry=square(float(i)), + properties={ + "id": i, + "damage_pct_0m": damage, + "unknown_pct": 0.0, + "edited_class": edited, + }, + ) + ) + return footprints, predictions + + def test_edited_class_overrides_the_model_score(self): + # Row 0: model says undamaged, analyst says Damaged. + # Row 1: model says damaged, analyst says NotDamaged. + # Row 2: model says damaged, analyst says Unknown. + with tempfile.TemporaryDirectory() as tmp: + footprints, predictions = self._write( + tmp, + [ + (0.0, "Damaged"), + (0.9, "NotDamaged"), + (0.9, "Unknown"), + ], + ) + inputs = build_assessment_inputs_from_gpkgs( + footprints, predictions + ) + + self.assertEqual(inputs.damage_fractions["overture-0"], 1.0) + self.assertEqual(inputs.damage_fractions["overture-1"], 0.0) + self.assertEqual(inputs.unknown_fractions["overture-2"], 1.0) + + report = compute_assessment_report(inputs) + # Only row 0 counts as damaged, and row 2 leaves the known set. + self.assertEqual(report["predictions"]["predictedDamaged"], 1) + self.assertEqual(report["predictions"]["knownNonCloudy"], 2) + self.assertEqual(report["predictions"]["cloudy"], 1) + + def test_raw_predictions_still_use_the_score(self): + # No edited_class column values: unchanged behaviour. + with tempfile.TemporaryDirectory() as tmp: + footprints, predictions = self._write( + tmp, [(0.0, ""), (0.9, ""), (0.9, "")] + ) + inputs = build_assessment_inputs_from_gpkgs( + footprints, predictions + ) + + self.assertEqual(inputs.damage_fractions["overture-1"], 0.9) + report = compute_assessment_report(inputs) + self.assertEqual(report["predictions"]["predictedDamaged"], 2) + + if __name__ == "__main__": unittest.main() From b05e5032191e4de3e2b41103caddb6a26ae1abe4 Mon Sep 17 00:00:00 2001 From: Caleb Robinson Date: Mon, 31 Aug 2026 19:57:33 +0000 Subject: [PATCH 09/10] fix(ui): lift the version bar onto the top row and stop squashing buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version selector sat at top: 66px, a row of its own below the Back/Edit controls, leaving an obvious band of empty map between the top of the view and the first control. It now shares the top row with the corner blocks. Below 1100px a 560px centre column plus both corners stops fitting side by side, so it drops back to its own row there rather than sliding under them — the corners are z-index 1000 and it is 950. The edit panel's scroll container is a flex column, so every block inside it was free to shrink once the content was taller than the panel. Buttons lost the top and bottom of their own labels instead of the column simply scrolling; "No manual edits" was the clearest case. Direct children of the scroll area and of the button stacks no longer shrink. --- .../Components/Visualizer/PredictionEditPanel.jsx | 13 +++++++++++++ ui/src/Components/Visualizer/Visualizer.jsx | 14 +++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/ui/src/Components/Visualizer/PredictionEditPanel.jsx b/ui/src/Components/Visualizer/PredictionEditPanel.jsx index eb1c0190..e15a478c 100644 --- a/ui/src/Components/Visualizer/PredictionEditPanel.jsx +++ b/ui/src/Components/Visualizer/PredictionEditPanel.jsx @@ -117,6 +117,14 @@ const useStyles = makeStyles({ flexDirection: "column", gap: tokens.spacingVerticalM, touchAction: "pan-y", + // A column flex item shrinks to fit by default, so once the panel's + // content is taller than the panel every block in here gets squeezed — + // buttons lose the top and bottom of their own label rather than the + // column simply scrolling. Nothing in this panel should ever be shorter + // than its content. + "& > *": { + flexShrink: 0, + }, }, header: { paddingBottom: tokens.spacingVerticalS, @@ -180,6 +188,11 @@ const useStyles = makeStyles({ display: "flex", flexDirection: "column", gap: tokens.spacingVerticalXS, + // Same reason as `scroll`: these stacks hold buttons, and a squeezed + // button clips its label instead of getting a scrollbar. + "& > *": { + flexShrink: 0, + }, }, buttonRow: { display: "flex", diff --git a/ui/src/Components/Visualizer/Visualizer.jsx b/ui/src/Components/Visualizer/Visualizer.jsx index 1d43bb9c..646b502f 100644 --- a/ui/src/Components/Visualizer/Visualizer.jsx +++ b/ui/src/Components/Visualizer/Visualizer.jsx @@ -112,14 +112,19 @@ const useStyles = makeStyles({ // They share one stack rather than each pinning itself to the map, because // both are variable height (a divergence warning, a failed switch, a // progress bar) and two independently positioned overlays at fixed offsets - // would sooner or later cover each other. It starts below the navigation - // controls' row so a narrow desktop cannot overlap them either. + // would sooner or later cover each other. + // + // It sits on the same row as the Back/Edit controls and the layer dock, + // which are pinned to the two top corners. Below ~1100px a 560px centre + // column plus both corner blocks stops fitting on one row, so it drops to + // its own row rather than sliding under them (they are z-index 1000, this + // is 950). // // The column itself is transparent to the pointer; only the cards inside it // take clicks, so the map still pans through the gaps. topStack: { position: "absolute", - top: "66px", + top: "10px", left: "50%", transform: "translateX(-50%)", zIndex: 950, @@ -130,6 +135,9 @@ const useStyles = makeStyles({ alignItems: "center", gap: tokens.spacingVerticalS, pointerEvents: "none", + "@media (max-width: 1100px)": { + top: "66px", + }, }, // Ctrl+drag box-select rectangle. Absolutely positioned inside the // visualizer container, which shares its top-left corner with both map From af16bc47a6f54cc18c109c71d91d0929d18a1984 Mon Sep 17 00:00:00 2001 From: Caleb Robinson Date: Mon, 31 Aug 2026 20:58:25 +0000 Subject: [PATCH 10/10] refactor: one footprint archive per image layer, not one per model Footprint geometry belongs to the image layer: every model trained on a layer draws the same buildings. Two jobs were tiling it anyway. Imagery prep builds the layer archive when the footprints are cached, and the embedding job built its own per-model copy on top of that. The two archives were the same file. Both ran tippecanoe with the same layer name, the same --use-attribute-for-id=id, the same -y id -y overture_id, and the same zoom range, over the same rows in the same order (embed_buildings reads the layer footprints, reset_index, and keeps every row including the ones with no valid tokens). So an embedding layer paid for a byte-for-byte duplicate. The embedding workflow no longer tiles. tippecanoe and subprocess drop out of it entirely, and it emits the embeddings GeoJSON, the HFTR feature sidecar and the manifest. The feature sidecar is per model and stays exactly as it was. With one archive, resolve_tiles_url stops choosing between a model field and a layer field, Model.pmtilesUrl and ArtifactTypes.BUILDING_PMTILES retire, and GetModelArtifact loses both the "pmtiles" kind and the branch that reused a model's archive for a layer-scoped request. The Interactive Labeler reads kind=footprint_pmtiles instead. Existing embedding models keep a stale pmtilesUrl in their stored metadata. Nothing reads it, and re-tiling old layers is out of scope by agreement. The labeler used to refuse to start when a model had no archive. That guard is gone with the field, so a failed tile load now raises a clear error instead of warning to the console and leaving an empty map with no buildings to label. --- api/hastefuncapi/function_app.py | 68 +++++++--------- hastelib/src/hastegeo/core/config.py | 9 ++- hastelib/src/hastegeo/core/models/projects.py | 1 - .../src/hastegeo/core/processors/embedding.py | 10 --- .../core/processors/prediction_tiles.py | 17 ++-- .../hastegeo/core/processors/visualizer.py | 8 +- .../src/hastegeo/workflows/embed_buildings.py | 78 +++---------------- .../core/processors/test_prediction_tiles.py | 16 ++-- .../processors/test_visualizer_payload.py | 20 ++--- .../InteractiveLabeler/InteractiveLabeler.jsx | 28 ++++--- 10 files changed, 87 insertions(+), 168 deletions(-) diff --git a/api/hastefuncapi/function_app.py b/api/hastefuncapi/function_app.py index a29952fc..3f30ece8 100644 --- a/api/hastefuncapi/function_app.py +++ b/api/hastefuncapi/function_app.py @@ -1408,7 +1408,6 @@ async def GetLayerModelsDetails(req: func.HttpRequest) -> func.HttpResponse: # Embedding-model artifacts the Interactive Labeler fetches by HTTP byte # range, mapped to the Model field that holds each blob URL. _MODEL_ARTIFACT_URL_FIELDS = { - "pmtiles": "pmtilesUrl", "sidecar": "featuresSidecarUrl", "geojson": "embeddingsGeoJSONUrl", "gpkg": "gpkgUrl", @@ -1513,47 +1512,32 @@ async def GetModelArtifact(req: func.HttpRequest) -> func.HttpResponse: if layer_url_field is not None: model_document = document or {} - # The embedding workflow already tiles these footprints from the - # same archive, keyed on the same row-index id, so reuse the - # model's own PMTiles rather than making the caller wait for an - # identical layer-scoped rebuild. - reused_url = "" - if kind == "footprint_pmtiles": - reused_url = model_document.get("pmtilesUrl") or "" - if reused_url: - document = {url_field: reused_url} - else: - # Layer-scoped artifact: take an explicit imageLayerId when - # the caller passes one, otherwise the model's own layer — a - # client holding a modelId always means that model's - # footprints. - image_layer_id = req.params.get( - "imageLayerId" - ) or model_document.get("imageLayerId") - if not image_layer_id or not _GUID_RE.match(str(image_layer_id)): - return _bad_request( - "Invalid or missing parameter: imageLayerId" - ) - try: - document = await asyncio.to_thread( - MetadataProcessor( - data_type=config.get_metadata_types().IMAGELAYER.value, - partition_key=project_id, - ).load, - image_layer_id, - ) - except FileNotFoundError: - return func.HttpResponse( - "Image layer not found.", status_code=404 - ) - except Exception as e: - logger.error( - f"GetModelArtifact image layer load failed: {e}\n" - f"{traceback.format_exc()}" - ) - return func.HttpResponse( - "Error loading image layer.", status_code=500 - ) + # Layer-scoped artifact: take an explicit imageLayerId when the + # caller passes one, otherwise the model's own layer — a client + # holding a modelId always means that model's footprints. + image_layer_id = req.params.get("imageLayerId") or model_document.get( + "imageLayerId" + ) + if not image_layer_id or not _GUID_RE.match(str(image_layer_id)): + return _bad_request("Invalid or missing parameter: imageLayerId") + try: + document = await asyncio.to_thread( + MetadataProcessor( + data_type=config.get_metadata_types().IMAGELAYER.value, + partition_key=project_id, + ).load, + image_layer_id, + ) + except FileNotFoundError: + return func.HttpResponse("Image layer not found.", status_code=404) + except Exception as e: + logger.error( + f"GetModelArtifact image layer load failed: {e}\n" + f"{traceback.format_exc()}" + ) + return func.HttpResponse( + "Error loading image layer.", status_code=500 + ) document = document or {} blob_url = document.get(url_field) or "" diff --git a/hastelib/src/hastegeo/core/config.py b/hastelib/src/hastegeo/core/config.py index f9b545e8..f0ecaa61 100644 --- a/hastelib/src/hastegeo/core/config.py +++ b/hastelib/src/hastegeo/core/config.py @@ -155,11 +155,12 @@ class ArtifactTypes(Enum): TRAINING_ARTIFACTS_ZIP = Template("training_artifacts_${modelName}") INFERENCE_ARTIFACTS_ZIP = Template("inference_artifacts_${modelName}") # Building labeling workflow: per-building MOSAIKS / DINOv2 embeddings - # (footprints + f_* feature columns), the matching PMTiles vector tiles - # (geometry + id only), the binary HFTR sidecar (id -> feature vector), - # and the per-building predictions written by the interactive labeler. + # (footprints + f_* feature columns), the binary HFTR sidecar + # (id -> feature vector), and the per-building predictions written by + # the interactive labeler. The footprint vector tiles are NOT here: + # they belong to the image layer (LAYER_FOOTPRINT_PMTILES), shared by + # every model trained on it. BUILDING_EMBEDDINGS = Template("building_embeddings_${modelName}") - BUILDING_PMTILES = Template("building_pmtiles_${modelName}") BUILDING_FEATURES_SIDECAR = Template("building_features_${modelName}") BUILDING_PREDICTIONS_GPKG = Template("building_predictions_${modelName}") # Prediction editing workflow: each save writes a NEW versioned diff --git a/hastelib/src/hastegeo/core/models/projects.py b/hastelib/src/hastegeo/core/models/projects.py index edfcb9ae..e8614f20 100644 --- a/hastelib/src/hastegeo/core/models/projects.py +++ b/hastelib/src/hastegeo/core/models/projects.py @@ -522,7 +522,6 @@ class Model(BaseModel): numFeatures: Optional[int] = Field(default=None) embeddingJob: Optional[TrainingJob] = Field(default=None) embeddingsGeoJSONUrl: Optional[str] = Field(default=None) - pmtilesUrl: Optional[str] = Field(default=None) # Binary sidecar (HFTR format) carrying per-building f_* feature # vectors keyed by row-index id. The Interactive Labeler fetches it # once at session start and looks vectors up by id; the PMTiles diff --git a/hastelib/src/hastegeo/core/processors/embedding.py b/hastelib/src/hastegeo/core/processors/embedding.py index 9afc8bc0..f49b6f9e 100644 --- a/hastelib/src/hastegeo/core/processors/embedding.py +++ b/hastelib/src/hastegeo/core/processors/embedding.py @@ -235,12 +235,6 @@ def _create_embedding_config(self): ) + ".geojson" ) - pmtiles_name = ( - ArtifactTypes.BUILDING_PMTILES.value.substitute( - modelName=self.model_data.modelId - ) - + ".pmtiles" - ) sidecar_name = ( ArtifactTypes.BUILDING_FEATURES_SIDECAR.value.substitute( modelName=self.model_data.modelId @@ -260,7 +254,6 @@ def _create_embedding_config(self): "imagery": imagery_fn, "footprints": footprints_fn, "embeddings": embeddings_name, - "pmtiles": pmtiles_name, "sidecar": sidecar_name, }, "pipeline": { @@ -317,9 +310,6 @@ def _update_results_from_job(self): self.model_data.embeddingsGeoJSONUrl = self._artifact_url( manifest.get("embeddings_filename", "") ) - self.model_data.pmtilesUrl = self._artifact_url( - manifest.get("pmtiles_filename", "") - ) self.model_data.featuresSidecarUrl = self._artifact_url( manifest.get("sidecar_filename", "") ) diff --git a/hastelib/src/hastegeo/core/processors/prediction_tiles.py b/hastelib/src/hastegeo/core/processors/prediction_tiles.py index d1022a5c..0eacb3a4 100644 --- a/hastelib/src/hastegeo/core/processors/prediction_tiles.py +++ b/hastelib/src/hastegeo/core/processors/prediction_tiles.py @@ -265,15 +265,18 @@ def enqueue_prediction_tiles( def resolve_tiles_url(model: Model, image_layer: ImageLayer) -> Optional[str]: - """Return the PMTiles archive the editor should read, if any. + """Return the PMTiles archive a map should read, if any. - The embedding workflow already tiles the same footprints from the - same PMTiles archive, keyed on the same integer row-index ``id`` - (see ``workflows/embed_buildings.py``), so those tiles are reused - rather than rebuilt. Only trained-inference models need a layer - level archive built for them. + Footprint geometry belongs to the image layer, not to a model: every + model trained on a layer draws the same buildings. Both workflows + therefore share one archive, built once when the layer's footprints + are cached. + + ``model`` is accepted so callers can pass the pair without caring + which one owns the tiles. """ - return model.pmtilesUrl or image_layer.footprintPmtilesUrl + del model + return image_layer.footprintPmtilesUrl def needs_preparation( diff --git a/hastelib/src/hastegeo/core/processors/visualizer.py b/hastelib/src/hastegeo/core/processors/visualizer.py index 589152e3..98970084 100644 --- a/hastelib/src/hastegeo/core/processors/visualizer.py +++ b/hastelib/src/hastegeo/core/processors/visualizer.py @@ -13,11 +13,9 @@ Both workflows *do* have vector artifacts, and they are the same two in both cases: -* the building footprints as PMTiles — the layer's shared archive - (``ImageLayer.footprintPmtilesUrl``) or, for an embedding model, the - archive it already tiled for the labeler (``Model.pmtilesUrl``); - :func:`~hastegeo.core.processors.prediction_tiles.resolve_tiles_url` - is the seam that picks between them, and +* the building footprints as PMTiles, the layer's shared archive + (``ImageLayer.footprintPmtilesUrl``), which every model trained on + that layer draws from, and * the model's columnar prediction attribute sidecar (``Model.predictionAttrsUrl``), keyed by the same row-index id the tiles carry. diff --git a/hastelib/src/hastegeo/workflows/embed_buildings.py b/hastelib/src/hastegeo/workflows/embed_buildings.py index d82cb8c2..30652e0b 100644 --- a/hastelib/src/hastegeo/workflows/embed_buildings.py +++ b/hastelib/src/hastegeo/workflows/embed_buildings.py @@ -14,7 +14,13 @@ MOSAIKS (torchgeo RCF) model on the crop, mask the building polygon into the token grid, and average the token features into one vector per building. * Writes three outputs to ``outputs/``: a GeoJSON of footprints + ``f_0..f_N`` - feature columns, a PMTiles vector-tile file (via tippecanoe), and a manifest. + feature columns, a binary feature sidecar, and a manifest. + +This workflow does NOT tile the footprints. The layer's shared footprint +PMTiles archive is built once per image layer by +``workflows.prepare_prediction_tiles`` and reused by the labeler and the +results viewer, so tiling the same geometry again per model produced a +byte-for-byte duplicate archive. CRITICAL — row-order invariant: ``GetValidationReport`` / ``GetAssessmentReport`` join predictions to the @@ -29,7 +35,6 @@ import json import math import os -import subprocess import sys from collections import defaultdict from datetime import datetime, timezone @@ -709,60 +714,6 @@ def embed_footprints( return out -def write_pmtiles(geojson_path: str, pmtiles_path: str) -> None: - """Convert a GeoJSON to PMTiles via tippecanoe. - - Keeps only the row-index ``id`` and ``overture_id`` attributes on each - feature (via ``-y``); the heavy ``f_*`` columns ride in a separate - binary sidecar (see :func:`write_features_sidecar`). Without ``-y`` - every tile would carry several KB of feature vectors per building, - blowing up archive size and slowing every pan. - - Lift the per-tile size cap so a dense max-zoom tile carrying every - geometry-only feature isn't trimmed; with ``f_*`` no longer in the - tiles this is a very cheap "keep all buildings" guarantee. - """ - cmd = [ - "tippecanoe", - "-o", - pmtiles_path, - "-l", - "buildings", - # Bake the row-index `id` into each tile as the MVT feature id. This - # gives every footprint a stable, native feature id so the interactive - # labeler can drive per-building coloring via map feature-state — both - # MapLibre and Azure Maps need this (Azure Maps' VectorTileSource does - # not honor client-side promoteId). - "--use-attribute-for-id=id", - # Strip every attribute except id + overture_id. The labeler does - # not need anything else in the tiles — feature vectors are looked - # up from the sidecar by id, and Overture id is needed for the - # PutInteractiveLabels round-trip. - "-y", - "id", - "-y", - "overture_id", - "--force", - # Cull world-scale levels — buildings are invisible below z=10, - # so generating tiles there only adds bytes. - "--minimum-zoom=10", - # Pin the max zoom to the labeler's working zoom + 1. Tiles at - # z>15 are rendered by the SDK via overzoom; the interactive - # labeler's queryRenderedFeatures + setFeatureState work unchanged - # on overzoomed tiles. - "--maximum-zoom=15", - # With f_* gone from the tiles even a dense urban tile is small; - # we still pass --no-tile-size-limit so the default 500 KB cap - # never bites and the build never silently drops a footprint. - "--no-tile-size-limit", - # Last-resort safety valve. - "--drop-densest-as-needed", - geojson_path, - ] - log_progress(f"Running tippecanoe -> {os.path.basename(pmtiles_path)}") - subprocess.run(cmd, check=True) - - # Binary sidecar format: # bytes 0-3 : magic "HFTR" (4 ASCII chars) # bytes 4-7 : u32 LE version (currently 1) @@ -787,8 +738,9 @@ def write_features_sidecar( The labeler fetches this file once at session start and looks vectors up by their row-index id — the in-tile attributes are kept down to just - id + overture_id (see ``write_pmtiles``), so the PMTiles archive stays - small and the labeler still has the data it needs to train + predict. + id + overture_id, so the layer's shared footprint PMTiles archive + stays small and the labeler still has what it needs to train and + predict. Returns ``(num_buildings, feat_dim)``. """ @@ -860,9 +812,6 @@ def main(): embeddings_name = files.get( "embeddings", f"building_embeddings_{config.get('model_id')}.geojson" ) - pmtiles_name = files.get( - "pmtiles", f"building_pmtiles_{config.get('model_id')}.pmtiles" - ) sidecar_name = files.get( "sidecar", f"building_features_{config.get('model_id')}.bin" ) @@ -896,18 +845,11 @@ def main(): log_progress(f"Writing {len(gdf)} buildings -> {embeddings_path}") gdf.to_file(embeddings_path, driver="GeoJSON") - # Features sidecar — must be written BEFORE the PMTiles step strips - # the f_* columns from the tiles, so the in-memory gdf is still - # the authoritative source for both. sidecar_path = os.path.join(output_dir, os.path.basename(sidecar_name)) num_buildings, feat_dim = write_features_sidecar(gdf, sidecar_path) - pmtiles_path = os.path.join(output_dir, os.path.basename(pmtiles_name)) - write_pmtiles(embeddings_path, pmtiles_path) - manifest = { "embeddings_filename": os.path.basename(embeddings_path), - "pmtiles_filename": os.path.basename(pmtiles_path), "sidecar_filename": os.path.basename(sidecar_path), "num_buildings": int(num_buildings), "num_features": int(feat_dim), diff --git a/hastelib/tests/core/processors/test_prediction_tiles.py b/hastelib/tests/core/processors/test_prediction_tiles.py index 7583df58..4929f548 100644 --- a/hastelib/tests/core/processors/test_prediction_tiles.py +++ b/hastelib/tests/core/processors/test_prediction_tiles.py @@ -92,26 +92,22 @@ def test_tiles_are_reused_across_models_on_a_layer(self): self.assertTrue(needs_attrs) def test_embedding_model_pmtiles_are_reused(self): - """The embedding workflow already tiles the same footprints. + """The layer's archive covers every model trained on it. - Rebuilding them would spawn a multi-gigabyte container job to - produce a byte-for-byte equivalent archive. + Footprint geometry belongs to the image layer, so a model whose + layer is already tiled needs only its attribute sidecar. """ from hastegeo.core.processors.prediction_tiles import needs_preparation - model = _model(pmtilesUrl="https://acct/buildings_5553.pmtiles") - needs_pmtiles, needs_attrs = needs_preparation(model, _layer()) + layer = _layer(footprintPmtilesUrl="https://acct/layer.pmtiles") + needs_pmtiles, needs_attrs = needs_preparation(_model(), layer) self.assertFalse(needs_pmtiles) self.assertTrue(needs_attrs) - def test_resolve_tiles_url_prefers_the_model_archive(self): + def test_resolve_tiles_url_reads_the_layer_archive(self): from hastegeo.core.processors.prediction_tiles import resolve_tiles_url - model = _model(pmtilesUrl="https://acct/model.pmtiles") layer = _layer(footprintPmtilesUrl="https://acct/layer.pmtiles") - self.assertEqual( - resolve_tiles_url(model, layer), "https://acct/model.pmtiles" - ) self.assertEqual( resolve_tiles_url(_model(), layer), "https://acct/layer.pmtiles" ) diff --git a/hastelib/tests/core/processors/test_visualizer_payload.py b/hastelib/tests/core/processors/test_visualizer_payload.py index 57c49eb8..9bef5b7a 100644 --- a/hastelib/tests/core/processors/test_visualizer_payload.py +++ b/hastelib/tests/core/processors/test_visualizer_payload.py @@ -48,7 +48,6 @@ PREDICTIONS_URL = "https://acct.blob/c/hash/5557_predictions.tif?sas=a&b=c" GPKG_URL = "https://acct.blob/c/hash/predicted_damage_m.gpkg?sas" LAYER_PMTILES = "https://acct.blob/c/hash/footprints_layer.pmtiles?sas" -MODEL_PMTILES = "https://acct.blob/c/hash/buildings_5558.pmtiles?sas" ATTRS_URL = "https://acct.blob/c/hash/prediction_attrs_5557.json?sas" BBOX = [-1.0, -2.0, 3.0, 4.0] @@ -115,9 +114,6 @@ def _embedding_model(**overrides) -> Model: "status": PROCESSED, "gpkgUrl": GPKG_URL, "predictedBuildingCount": 1200, - # The embedding workflow tiles the same footprints for the - # labeler, so the editor/viewer reuses that archive. - "pmtilesUrl": MODEL_PMTILES, "predictionAttrsUrl": ATTRS_URL, "predictionTilesStatus": PROCESSED, } @@ -237,15 +233,21 @@ def test_vector_artifacts_are_served_through_the_api(self): self.assertTrue(url.startswith("GetModelArtifact?")) self.assertNotIn("blob", url) - def test_footprint_tiles_reuse_the_models_own_archive(self): - # No layer-level PMTiles at all: resolve_tiles_url still finds - # the embedding model's own archive, so the viewer is ready. + def test_footprint_tiles_come_from_the_layer(self): + # Footprint geometry belongs to the image layer and one archive is + # shared by every model on it, so a layer that has not been tiled + # yet is "preparing" rather than ready — an embedding model has no + # raster to fall back on, and an empty map is the worst answer. visualizer = _build( _embedding_model(), layer=_layer(footprintPmtilesUrl=None) ) - self.assertTrue(visualizer.predictionsReady) - self.assertIsNotNone(visualizer.footprintTilesUrl) + self.assertFalse(visualizer.predictionsReady) + + # With the layer tiled, the same model is ready. + ready = _build(_embedding_model(), layer=_layer()) + self.assertTrue(ready.predictionsReady) + self.assertIsNotNone(ready.footprintTilesUrl) def test_embedding_flavor_disables_thresholding(self): visualizer = _build( diff --git a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx index 976b21ce..2b8e1eb5 100644 --- a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx +++ b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx @@ -770,10 +770,11 @@ const InteractiveLabeler = () => { // the layer has no pre-event imagery). layerImageryRef.current = layerData?.imagery || null; - // Resolve the model's PMTiles URL. Models are returned by - // GetLayerModelsDetails; pick ours by modelId. The pmtilesUrl is - // populated by the embedding workflow's postprocessor. - let pmtilesUrl = ""; + // The features sidecar is per model, so it still comes from + // GetLayerModelsDetails. The footprint tiles do not: geometry belongs + // to the image layer and one archive is shared by every model on it, + // so those are requested by kind below and 404 until the layer's + // tiling job has run. let sidecarUrl = ""; setInitialLoad({ step: 1, loaded: null, total: null }); try { @@ -783,17 +784,11 @@ const InteractiveLabeler = () => { const model = (models || []).find( (m) => String(m.modelId) === String(modelId) ); - pmtilesUrl = model?.pmtilesUrl || ""; sidecarUrl = model?.featuresSidecarUrl || ""; } catch (e) { console.warn("Could not fetch model URLs:", e); } signal.throwIfAborted(); - if (!pmtilesUrl) { - throw new Error( - "No PMTiles available for this model — the embedding workflow has not produced building tiles." - ); - } if (!sidecarUrl) { throw new Error( "No features sidecar available for this model — re-embed the layer to produce one." @@ -806,7 +801,7 @@ const InteractiveLabeler = () => { // remote/mobile labelers hit a 403. const browserPmtilesUrl = buildUrl( `GetModelArtifact?projectId=${projectId}&modelId=${modelId}` + - `&kind=pmtiles` + `&imageLayerId=${imageLayerId}&kind=footprint_pmtiles` ); const browserSidecarUrl = buildUrl( `GetModelArtifact?projectId=${projectId}&modelId=${modelId}` + @@ -837,7 +832,16 @@ const InteractiveLabeler = () => { // (otherwise the map sits at [0, 0] zoom 3 and the user sees no tiles). pmtilesHeader = await pm.getHeader(); } catch (e) { - console.warn("Failed to load PMTiles archive (continuing):", e); + // Without the footprint tiles there are no buildings to label, so an + // empty map is the one thing this must not silently become. The + // archive belongs to the image layer and is built once its footprints + // are cached, so the usual cause is that job not having run yet. + console.error("Failed to load the footprint PMTiles archive:", e); + throw new Error( + "The building footprint tiles for this image layer are not ready " + + "yet. They are built once per layer; if this persists, re-run " + + "preparation for the layer." + ); } // Fetch the binary features sidecar and parse the HFTR header. The