diff --git a/api/hastefuncapi/function_app.py b/api/hastefuncapi/function_app.py index 707cd6c3..3f30ece8 100644 --- a/api/hastefuncapi/function_app.py +++ b/api/hastefuncapi/function_app.py @@ -11,11 +11,17 @@ 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 +from hastegeo.core.models.predictions import ( + PREDICTION_EDIT_DEFAULT_THRESHOLD, + EditedPredictionsRequest, + PreparePredictionTilesRequest, +) from hastegeo.core.models.projects import ( BuildingValidation, + EditedPredictionVersion, ImageLayer, LabelProject, Model, @@ -36,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 @@ -80,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, @@ -127,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 @@ -155,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) @@ -767,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( @@ -1240,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) @@ -1353,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) @@ -1370,17 +1408,38 @@ 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", + # 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) +) +# 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( @@ -1405,22 +1464,37 @@ 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. + + ``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)) 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 +1510,72 @@ 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: + model_document = document or {} + # 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 "" + 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 @@ -1470,9 +1609,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}"' @@ -2200,9 +2341,51 @@ 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. + + ``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`. + """ 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") @@ -2211,6 +2394,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}") @@ -2256,111 +2440,69 @@ 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, + # 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 + # `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, + attrs_url=source.attrs_url, + is_latest=source.is_latest, + 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( @@ -2370,6 +2512,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( @@ -2757,6 +2905,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 +2951,546 @@ 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.""" + # 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( + 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, + "backfillVersions": true + } + + 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 + 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 " + "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, + backfill_versions=prep_request.backfillVersions, + ) + 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, 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 ( + next_version, + save_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 + 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" + ) + + overrides = { + override.rowIndex: override.editedClass + for override in edit_request.overrides + } + version = next_version(model_data) + try: + # 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, + footprints_path, + threshold=edit_request.threshold, + unknown_threshold=edit_request.unknownThreshold, + overrides=overrides, + ) + 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, + ) + + entry = EditedPredictionVersion( + 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=saved.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(saved.to_dict()), + 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): + 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, @@ -4124,6 +4819,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: { @@ -4145,6 +4843,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") @@ -4155,6 +4860,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( @@ -4165,7 +4874,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( @@ -4431,6 +5151,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." @@ -4440,6 +5163,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") @@ -4474,6 +5201,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( @@ -4483,7 +5214,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/api/hastefuncqueues/function_app.py b/api/hastefuncqueues/function_app.py index 07cef137..19c5d439 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 @@ -27,6 +28,11 @@ ) from hastegeo.core.processors.labels import LabelTaskGenerator from hastegeo.core.processors.metadata import MetadataProcessor +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 from hastegeo.core.processors.train import TrainPostprocessor @@ -601,6 +607,356 @@ async def GetRunEmbeddingQueueMessage(msg: func.QueueMessage) -> None: ) +async def _save_layer_footprint_tile_state( + project_id: str, image_layer: ImageLayer +) -> None: + """Persist only the footprint-tiling fields of an image layer. + + 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) + + +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``). + """ + image_layer = None + try: + 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, + backfill_versions: bool = True, +) -> 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. + + 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: + 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 = image_layer_id 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 + ) + # 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." + ) + 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, backfill_versions=backfill_versions + ) + 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 Exception as e: + logger.error( + "PreparePredictionTilesQueueTrigger: Error preparing prediction " + f"tiles for model {model_id}: {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="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", "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, 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 + 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)) + 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 " + f"imageLayerId, got: {sorted(payload.keys())}" + ) + + if model_id: + await _prepare_model_prediction_tiles( + project_id, + image_layer_id, + model_id, + force, + backfill_versions=backfill_versions, + ) + 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", @@ -1013,7 +1369,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..001fb783 100644 --- a/docs/api/hastefuncapi.md +++ b/docs/api/hastefuncapi.md @@ -50,9 +50,497 @@ 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`. | +| 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`. Accepts an optional `version` for the per-version prediction artifacts. 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&version=2", + "flavor": "inference", + "supportsThreshold": true, + "buildingCount": 125430, + "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": "", + "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. + 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 + `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 +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. +> +> 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 +`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, 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. | + +#### `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", + "predictionAttrsUrl": "https://.../prediction_attrs_5557_v1.json", + "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. 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 | +|------|-----------| +| 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. + +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 +{ + "projectId": "string — required, GUID", + "imageLayerId": "string — required, GUID", + "modelId": "string — required", + "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):** + +```json +{ + "modelId": "5557", + "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 **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) + 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`/`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 | + +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", + "predictionAttrsUrl": "https://.../prediction_attrs_5557_v2.json", + "editedCount": 53, + "buildingCount": 125430 +} +``` + +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), `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 | +|------|-----------| +| 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 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. 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: + +```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] } +``` + +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`, `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 | ### Model Catalog @@ -71,8 +559,58 @@ 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). + +[`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 +change its metrics directly; `GetAssessmentReport` thresholds `damage_pct_0m`, +so per-building overrides do not move its threshold-based counts. + +### 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 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..f0ecaa61 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( @@ -146,13 +155,31 @@ 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 + # 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}" + ) + # 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}") class InviteConfig(NamedTuple): @@ -321,18 +348,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 +435,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..6a3a0197 --- /dev/null +++ b/hastelib/src/hastegeo/core/models/predictions.py @@ -0,0 +1,119 @@ +# 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. + + ``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 afdb0ee7..e8614f20 100644 --- a/hastelib/src/hastegeo/core/models/projects.py +++ b/hastelib/src/hastegeo/core/models/projects.py @@ -342,6 +342,60 @@ 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 + 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 + 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 + predictionAttrsUrl: Optional[str] = None + 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 +440,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 +500,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 @@ -455,12 +522,21 @@ 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 # 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 +771,19 @@ 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. + 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: @@ -772,6 +861,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/models/visualizer.py b/hastelib/src/hastegeo/core/models/visualizer.py index 3b3b5976..420299b0 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,44 @@ 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) + # 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 + # `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) + # 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) + 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/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/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_edits.py b/hastelib/src/hastegeo/core/processors/prediction_edits.py new file mode 100644 index 00000000..a6f2c74c --- /dev/null +++ b/hastelib/src/hastegeo/core/processors/prediction_edits.py @@ -0,0 +1,608 @@ +# 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``). + +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 + +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.prediction_attrs import ( + version_attrs_artifact_name, + write_edited_prediction_attrs, +) +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 + + +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 new file mode 100644 index 00000000..0eacb3a4 --- /dev/null +++ b/hastelib/src/hastegeo/core/processors/prediction_tiles.py @@ -0,0 +1,1124 @@ +# 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``). + +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": "...", + "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}, + "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``):: + + { + "projectId": "...", + "imageLayerId": "...", + "modelId": "...", + "sourceGpkgUrl": "...", + "sourceFootprintsUrl": "...", + "force": false, + "backfillVersions": true + } + +An empty ``modelId`` (and, with it, an empty ``sourceGpkgUrl``) selects +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 +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, Union + +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" + +# 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.""" + 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 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, + model_id: Optional[str] = None, + 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. + + 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. + 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, + "imageLayerId": image_layer_id, + "modelId": model_id or "", + "sourceGpkgUrl": source_gpkg_url or "", + "sourceFootprintsUrl": source_footprints_url or "", + "force": bool(force), + "backfillVersions": bool(backfill_versions), + } + + +def enqueue_prediction_tiles( + project_id: str, + image_layer_id: str, + model_id: Optional[str] = None, + source_gpkg_url: Optional[str] = None, + 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. + + 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() + 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, + backfill_versions=backfill_versions, + ) + 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 resolve_tiles_url(model: Model, image_layer: ImageLayer) -> Optional[str]: + """Return the PMTiles archive a map should read, if any. + + 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. + """ + del model + return image_layer.footprintPmtilesUrl + + +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 neither the + model nor the layer already has a usable archive. + """ + needs_pmtiles = not bool(resolve_tiles_url(model, image_layer)) + needs_attrs = not bool(model.predictionAttrsUrl) + 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. + + 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, + backfill_versions: bool = True, +) -> 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). + 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", + "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 + 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) + pending_versions = ( + versions_needing_attrs(model) if backfill_versions else [] + ) + 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, + "versionsPending": len(pending_versions), + "status": model.predictionTilesStatus, + "statusMessage": model.predictionTilesStatusMessage or "", + } + + 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. + 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, + backfill_versions=backfill_versions, + ) + logger.info( + "Queued prediction tiles for model %s (pmtiles=%s, attrs=%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) + + +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 + ) + # 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 + ) + 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, " + "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 + + +class PredictionTilesPostprocessor: + """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. + + 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__( + self, + model: Optional[Model], + image_layer: ImageLayer, + config: Optional[Config] = None, + backfill_versions: bool = True, + ) -> 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.backfill_versions = bool(backfill_versions) + self.project_id = ( + image_layer.projectId if self.layer_only else model.projectId + ) + self.storage = UnifiedDataLayer( + storage_type=config.storage_type, + partition_key=self.project_id, + **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"], + ) + + # ── 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 job back for another status poll.""" + footprints_url = self.image_layer.buildingFootprintsUrl + return json.dumps( + build_prep_message( + project_id=self.project_id, + image_layer_id=self.image_layer.imageLayerId, + 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, + backfill_versions=self.backfill_versions, + ) + ) + + def process(self) -> PredictionTilesTarget: + """Advance the job state machine by one step. + + 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: %s %s prediction tiles status %s", + self.__class__.__name__, + "image layer" if self.layer_only else "model", + self.target_id, + self.status, + ) + statuses = self.config.get_status_types() + + if self.status == statuses.PENDING.value: + self._update_progress("Submitting prediction tile job") + self._execute_job() + + elif self.status == statuses.IN_PROGRESS.value: + job = self.job + if job is None: + self.status = statuses.FAILED.value + self._update_progress( + "Prediction tile job reference is missing; cannot " + "poll for completion" + ) + 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 %s is %s", + self.target_id, + task_status, + ) + + if task_status == statuses.COMPLETED.value: + job.status = task_status + job.completedDate = MetadataUtils.get_timestamp() + try: + self._update_results_from_job() + self.status = task_status + except Exception as error: + self.logger.error( + "Error finalizing prediction tiles for " + f"{self.target_id}: {error}", + stack_info=True, + ) + self.status = 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.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.status = task_status + job.status = task_status + self.queue_client.put_message(self._poll_message()) + + return self.target + + # ── submission ──────────────────────────────────────────────────── + def _execute_job(self) -> PredictionTilesTarget: + 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.project_id)}" 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.job = TrainingJob( + jobId=job_id, + taskId=task_id, + 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.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 " + f"{self.target_id}: {error}", + stack_info=True, + ) + self.status = statuses.FAILED.value + self._update_progress(f"Prediction tile job failed: {error}") + return self.target + + def _create_job_config(self) -> Dict[str, Dict[str, str]]: + """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. + 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)}/(.*)\?+" + ) + plain_url_pattern = r"(.*)\?+" + + footprints_url = self.image_layer.buildingFootprintsUrl + if not footprints_url: + raise ValueError("Image layer has no building footprints.") + footprints_fn = ( + f"inputs/{extract_from_url(footprints_url, filename_pattern)}" + ) + + 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.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": files, + "store_artifacts": True, + } + + 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. + 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 + + # 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, + data_type=( + self.config.get_metadata_types().PREDICTION_TILES_CONFIG.value + ), + data_format="json", + ) + config_filepath = self.storage.get_file_remote_path( + config_identifier, + self.config.get_metadata_types().PREDICTION_TILES_CONFIG.value, + data_format="json", + ) + config_fn = ( + f"inputs/{extract_from_url(config_filepath, filename_pattern)}" + ) + + input_files: Dict[str, Dict[str, str]] = { + "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, + }, + } + if predictions_url: + input_files["predictions"] = { + "http_url": extract_from_url( + predictions_url, plain_url_pattern + ), + "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 ────────────────────────────────────────────────── + def _update_results_from_job(self) -> None: + """Persist artifact URLs and counts from the task manifest.""" + job = self.job + 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 " f"{self.target_id}" + ) + manifest = json.loads(content) + + 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.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) + ) + self.model_data.predictedAt = MetadataUtils.get_timestamp() + self._update_progress( + "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.""" + if not filename: + return "" + return self.storage.get_file_remote_path( + identifier=filename, + 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.status_message: + self._update_progress(message, timestamp=timestamp) + + def _get_friendly_logs(self) -> List[Tuple[str, str]]: + job = self.job + 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.status_message = MetadataUtils.append_status_message( + self.status_message, + message, + timestamp=timestamp, + ) diff --git a/hastelib/src/hastegeo/core/processors/visualizer.py b/hastelib/src/hastegeo/core/processors/visualizer.py new file mode 100644 index 00000000..98970084 --- /dev/null +++ b/hastelib/src/hastegeo/core/processors/visualizer.py @@ -0,0 +1,378 @@ +# 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``), 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. + +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. + 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( + project_id: str, + 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 ""), + ("modelId", model_id or ""), + ("kind", kind), + ] + 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)}" + + +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, + attrs_ready: Optional[bool] = 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. + + 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)) + if attrs_ready is None: + 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) + # 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 + 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 + ) + # 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, + version=info.version if info.version else None, + ) + 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, + predictionVersionIsLatest=info.is_latest, + 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/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/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/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 new file mode 100644 index 00000000..a51be240 --- /dev/null +++ b/hastelib/src/hastegeo/core/utils/predictions.py @@ -0,0 +1,423 @@ +# 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. + +: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, 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 +# 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" + +#: ``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: + """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, + ) + + +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. + 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 = "" + version: Optional[int] = None + 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: + """``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, + "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, + } + + +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 "" + 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, attrs_url=raw_attrs_url) + entry = entries[0] + else: + requested = int(version) + if requested == RAW_PREDICTION_VERSION: + 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 + ) + 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 ""), + 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, + ) + + +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/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/src/hastegeo/workflows/prepare_prediction_tiles.py b/hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py new file mode 100644 index 00000000..08aa28b5 --- /dev/null +++ b/hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py @@ -0,0 +1,711 @@ +# 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. + +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. + +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 + 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 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 + +# 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") +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" + +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.""" + + +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 + + +# --------------------------------------------------------------------------- +# 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. + """ + # 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() + 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}" + ) + storage.store_artifact( + artifact_name=artifact_name, src_path=local_path + ) + urls[artifact_name] = storage.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 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. + + Args: + 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. 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. + + 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") + # 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 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") + 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 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}" + ) + + pmtiles_name = os.path.basename( + files.get("pmtiles") or default_pmtiles_name(image_layer_id) + ) + attrs_name = ( + "" + if model_id is None + else os.path.basename( + files.get("attrs") or default_attrs_name(model_id) + ) + ) + + manifest: Dict[str, Any] = { + "project_id": project_id, + "image_layer_id": image_layer_id, + "model_id": model_id or "", + "pmtiles_filename": "", + "pmtiles_built": False, + "pmtiles_url": None, + "attrs_filename": attrs_name, + "attrs_url": None, + "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] = {} + + if build_pmtiles: + log_progress("Building footprint vector tiles") + pmtiles_path = os.path.join(output_dir, pmtiles_name) + tiled_count = 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 + # 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") + + 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 + 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") + + 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) + # Empty in layer-only mode: no sidecar was built or stored. + 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 + # 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_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_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_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 new file mode 100644 index 00000000..4929f548 --- /dev/null +++ b/hastelib/tests/core/processors/test_prediction_tiles.py @@ -0,0 +1,382 @@ +# 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) + + def test_embedding_model_pmtiles_are_reused(self): + """The layer's archive covers every model trained on it. + + 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 + + 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_reads_the_layer_archive(self): + from hastegeo.core.processors.prediction_tiles import resolve_tiles_url + + layer = _layer(footprintPmtilesUrl="https://acct/layer.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): + 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", + "backfillVersions", + }, + ) + 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_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 new file mode 100644 index 00000000..6fb84c15 --- /dev/null +++ b/hastelib/tests/core/processors/test_prediction_tiles_layer.py @@ -0,0 +1,350 @@ +# 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", + "backfillVersions", + }, + ) + 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/core/processors/test_prediction_tiles_request.py b/hastelib/tests/core/processors/test_prediction_tiles_request.py new file mode 100644 index 00000000..2ec63710 --- /dev/null +++ b/hastelib/tests/core/processors/test_prediction_tiles_request.py @@ -0,0 +1,245 @@ +# 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", + "backfillVersions", + }, + ) + 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/processors/test_visualizer_payload.py b/hastelib/tests/core/processors/test_visualizer_payload.py new file mode 100644 index 00000000..9bef5b7a --- /dev/null +++ b/hastelib/tests/core/processors/test_visualizer_payload.py @@ -0,0 +1,424 @@ +# 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" +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, + "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_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.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( + _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/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_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() 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_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.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/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/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..4d2ca507 --- /dev/null +++ b/hastelib/tests/workflows/test_prepare_prediction_tiles.py @@ -0,0 +1,665 @@ +# 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("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): + 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) + + +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/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/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..63dcda9c --- /dev/null +++ b/spec/architecture/decisions/0005-versioned-derived-prediction-artifacts.md @@ -0,0 +1,174 @@ +# 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's raw prediction pointer is +`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 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 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:** 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 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 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. This ADR is amended to include +versioned sidecars as derived data for each edited version. + +Each prediction-edit save writes a new GeoPackage named from +`EDITED_PREDICTIONS_GPKG = Template("edited_predictions_${modelId}_v${version}")` +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` | 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 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:** 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 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 new file mode 100644 index 00000000..124f44ca --- /dev/null +++ b/spec/features/prediction-editing/README.md @@ -0,0 +1,138 @@ +# 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 + +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`, +`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 + +- 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 + +- [ ] 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/` | 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 + +| Spec | Relationship | +|---|---| +| [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 append-only edited artifacts, no active pointer, and this per-version sidecar extension | + +## 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 | 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 new file mode 100644 index 00000000..73b4dfc8 --- /dev/null +++ b/spec/features/prediction-editing/data-model.md @@ -0,0 +1,338 @@ +# 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 remains embedded in the +existing Model document, and layer PMTiles metadata remains embedded in the +ImageLayer document. + +| Container | Partition Key | Description | +|---|---|---| +| — | — | No new container. | + +### Modified Containers + +| Container | Change | Migration Needed? | +|---|---|---| +| 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 +**Partition key:** `projectId` + +`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] + unknownThreshold: Optional[float] + editedCount: int + sourceGpkgUrl: Optional[str] +``` + +Serialized example: + +```json +{ + "editedPredictions": [ + { + "version": 1, + "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, + "editedCount": 53, + "sourceGpkgUrl": "https://storage/.../raw_predictions.gpkg" + } + ] +} +``` + +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 | 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 + +`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. + +`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. + +--- + +## 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-by-convention blob per numbered edit version. | +| 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 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_VERSION = Template("prediction_attrs_${modelId}_v${version}") +LAYER_FOOTPRINT_PMTILES = Template("footprints_${imageLayerId}") +``` + +Logical layout: + +```text +{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}_v2.json + {imageLayerId}/ + footprints_{imageLayerId}.pmtiles +``` + +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, 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 | 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`. | +| `edited_class` | string | yes | `Damaged`, `NotDamaged`, or `Unknown` after overrides and thresholds. | +| `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. | + +#### Attribute sidecar schema + +The prediction attribute sidecar is JSON streamed by +`GetModelArtifact?kind=prediction_attrs` as `application/json`. Raw and edited +sidecars share the same schema. + +```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 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`). + +--- + +## Data Lake Changes + +### New Filesystems / Paths + +No Data Lake filesystem changes are required. Edited GeoPackages and sidecars +are Blob artifacts only. + +| Filesystem | Path Pattern | Data Format | Description | +|---|---|---|---| +| — | — | — | No Data Lake change. | + +--- + +## Queue Storage Changes + +### 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` | Existing prep fields plus `backfillVersions` | `PutPreparePredictionTilesQueueMessage` or maintenance/backfill call | `hastefuncqueues` prediction-edit-prep trigger | + +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. + +--- + +## Azure Batch Changes + +### Pool Configuration + +| Setting | Value | Notes | +|---|---|---| +| 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. | + +--- + +## Data Flow + +### Write Path + +Edit save path: + +```text +Visualizer save overrides + thresholds + → hastefuncapi PutEditedPredictions + → 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, ...} +``` + +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. + +Prep/backfill path: + +```text +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: + +```text +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 path: + +```text +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 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 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 + +| 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 | +| 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 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 new file mode 100644 index 00000000..e0d08de0 --- /dev/null +++ b/spec/features/prediction-editing/design.md @@ -0,0 +1,350 @@ +# 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 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/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 + +```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 | +|---|---|---|---| +| 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 | +|---|---|---| +| 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 the existing function/SWA auth path and keep +non-HTTP logic in `hastegeo`. + +### `GET /api/GetVisualizerResults` (modified) + +**Auth:** `func.AuthLevel.FUNCTION` + +**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`). + +**Additional/changed response fields:** + +| 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. | + +**Decision:** This endpoint controls the map only. The UI must not pass the +selector's version to Validation or Assessment report buttons. + +### `GET /api/GetModelArtifact` (modified) + +**Auth:** `func.AuthLevel.FUNCTION` + +**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`. + +| 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 | + +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` (modified) + +**Auth:** `func.AuthLevel.FUNCTION` + +**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`). + +**Response (200):** + +```json +{ + "version": 2, + "gpkgUrl": "https://storage/.../edited_predictions_5553_v2.gpkg", + "predictionAttrsUrl": "https://storage/.../prediction_attrs_5553_v2.json", + "editedCount": 17 +} +``` + +**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. + +**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` (modified) + +**Auth:** `func.AuthLevel.FUNCTION` + +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. + +### `PUT /api/PutPreparePredictionTilesQueueMessage` (modified) + +**Auth:** `func.AuthLevel.FUNCTION` + +Adds idempotent backfill support to the existing prediction-tiles job. The +request can include: + +```json +{ + "projectId": "string", + "imageLayerId": "string", + "modelId": "string", + "force": false, + "backfillVersions": true +} +``` + +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. + +### `GET /api/GetValidationReport`, `GET /api/GetAssessmentReport` (unchanged for selector) + +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. + +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 / Contract | Description | +|---|---|---|---| +| `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 **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 +EditedPredictionVersion.gpkgUrl exists +AND EditedPredictionVersion.predictionAttrsUrl exists +``` + +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 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/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 | +|---|---|---| +| 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 + +- 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 for PMTiles, raw sidecar prep, and edited-version sidecar backfill. | + +No new feature flag is part of this design. If production needs a kill switch, +add API/UI flags before broad rollout. + +## Observability + +- **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 + +- [ ] 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 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 new file mode 100644 index 00000000..a6a37bf1 --- /dev/null +++ b/spec/features/prediction-editing/impact-analysis.md @@ -0,0 +1,115 @@ +# 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/`, `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 | 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 + +### Upstream Dependencies (things this feature needs) + +| Dependency | Type | Status | Risk if Unavailable | +|---|---|---|---| +| 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? | +|---|---|---|---| +| 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 | +|---|---|---|---|---| +| 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 + +- **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 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. +- [ ] Component Governance scan implications? None unless implementation adds + dependencies; the design reuses existing packages. + +## Compliance & Data Impact + +- [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:** 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 new file mode 100644 index 00000000..20bb6406 --- /dev/null +++ b/spec/features/prediction-editing/plan.md @@ -0,0 +1,142 @@ +# 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 — base implemented, versioned sidecars in progress + +**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 | +|---|---|---|---|---| +| 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:** +- [ ] 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 — versioned artifact contract in progress + +**Goal:** Expose selected-version map payloads and downloads without adding lazy +generation to GET handlers. + +| Task | Agent | Dependencies | Story Ref | Status | +|---|---|---|---|---| +| 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:** +- [ ] `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 — selector and downloads in progress + +**Goal:** Let analysts select and download versions while clearly communicating +that reports still use newest. + +| Task | Agent | Dependencies | Story Ref | Status | +|---|---|---|---|---| +| 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:** +- [ ] 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 + +**Goal:** Validate end-to-end behavior and prepare safe rollout. + +| Task | Agent | Dependencies | Story Ref | Status | +|---|---|---|---|---| +| 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:** +- [ ] 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 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` | 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 + +- **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, sidecar generation, or backfill. +- **External data:** None beyond existing project imagery, footprints, raw + predictions, and edited GeoPackages. + +## Open Questions + +- [ ] Decide whether high-volume saves need an async save path after measuring + 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 new file mode 100644 index 00000000..93fb07a2 --- /dev/null +++ b/spec/features/prediction-editing/rollout.md @@ -0,0 +1,153 @@ +# 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 plus one-time backfill +**Target date:** TBD + +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 | 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 + +| 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 selector/download/backfill validation passes +- **Deployment:** + 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:** + - [ ] 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 + +- **Target:** SWA `testing` environment +- **Duration:** one response exercise or agreed analyst validation window +- **Success criteria:** + - [ ] 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 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. +- **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 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 — `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 + +### Key Metrics to Watch + +| Metric | Source | Baseline | Alert Threshold | +|---|---|---|---| +| `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 | +|---|---|---|---| +| 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 | 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 `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. +- [ ] 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 View Results map. diff --git a/spec/features/prediction-editing/test-plan.md b/spec/features/prediction-editing/test-plan.md new file mode 100644 index 00000000..01908f32 --- /dev/null +++ b/spec/features/prediction-editing/test-plan.md @@ -0,0 +1,166 @@ +# 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 | 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 + +### Unit Tests (`hastelib/tests/`) + +| ID | Module | Scenario | Input | Expected Output | Story Ref | +|---|---|---|---|---|---| +| 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 + +| ID | Endpoint | Method | Scenario | Preconditions | Expected Response | Story Ref | +|---|---|---|---|---|---|---| +| 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` | 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 + +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 | 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 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 | 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 | 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? | +|---|---|---|---| +| 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 | — | — | — | 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 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 + +- [ ] 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 new file mode 100644 index 00000000..3423eab7 --- /dev/null +++ b/spec/features/prediction-editing/user-stories.md @@ -0,0 +1,363 @@ +# 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, 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 | + +--- + +## Stories + +### US-001: Open Results and Enter Edit Mode from Any Completed Prediction Workflow + +**As a** Disaster Analyst, +**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/`, `ui/src/Components/Visualizer/` + +**Acceptance Criteria:** + +```gherkin +Given a model with server-derived predictionsReady true +When I open the Results menu +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 +``` + +**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 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`, `ui/src/Components/Visualizer/` + +**Acceptance Criteria:** + +```gherkin +Given GetVisualizerResults returns footprintTilesUrl and predictionAttrsUrl +When the UI loads the results page +Then it fetches PMTiles and prediction attributes through GetModelArtifact +And it renders predicted buildings as vectors for either workflow +``` + +```gherkin +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 +``` + +**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`). + +--- + +### 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/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 +``` + +```gherkin +Given edit mode loaded an embedding model +When I view the edit panel +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 or switch versions +Then both swipe panes show the same classes and selection state +``` + +**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`). + +--- + +### 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, `ui/src/Components/Visualizer/` + +**Acceptance Criteria:** + +```gherkin +Given a loaded prediction edit mode session and a set of overrides +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 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 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 +``` + +```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 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-006: Keep Reports on the Newest Version Unless Explicitly Requested + +**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:** P0 +**Estimate:** M +**Component(s):** `api/hastefuncapi`, `hastelib/src/hastegeo/core/utils/predictions.py`, `ui/src/Components/Visualizer/` + +**Acceptance Criteria:** + +```gherkin +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 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 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 +``` + +**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-007: Download Raw or Edited Prediction Versions + +**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/function_app.py`, `ui/src/Components/Visualizer/PredictionEditPanel.jsx`, `ui/src/Components/ProjectManagement/ModelResultsButton.jsx` + +**Acceptance Criteria:** + +```gherkin +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 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 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 a version already has predictionAttrsUrl and force is false +When backfill runs +Then the job skips that version without rewriting it +``` + +```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:** Backfill is not lazy on first selection. The selector must disable +versions until the sidecar URL is present. + +--- + +## 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`, `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 + +| Phase | Lead Agent | Supporting Agents | Validation | +|---|---|---|---| +| Phase 1 — Data Model & Artifact Contract | `backend-dev` | `gis` | `backend-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 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. +- [ ] Add collaborative real-time editing, locking, 409 conflict handling, or audit diff playback. +- [ ] 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/BuildingValidation/AssessmentReportModal.jsx b/ui/src/Components/BuildingValidation/AssessmentReportModal.jsx index 1cc6f278..63200d93 100644 --- a/ui/src/Components/BuildingValidation/AssessmentReportModal.jsx +++ b/ui/src/Components/BuildingValidation/AssessmentReportModal.jsx @@ -19,6 +19,8 @@ import { FluentIcon } from "../../util/icons"; import PropTypes from "prop-types"; import { apiGet } from "../../util/api"; import { buildAssessmentSummary } from "../../util/assessmentSummary"; +import PredictionVersionPicker from "../OtherComponents/PredictionVersionPicker"; +import { defaultPredictionVersion } from "../Visualizer/predictionVersions"; /* ── Theme-aware design tokens (follow light/dark via Fluent) ─── */ const tokens = { @@ -162,18 +164,24 @@ const AssessmentReportModal = ({ imageLayerId, modelId, modelName, + versions, onDismiss, }) => { const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Which predictions the assessment counts. 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); setError(null); setReport(null); apiGet( - `GetAssessmentReport?projectId=${projectId}&imageLayerId=${imageLayerId}&modelId=${modelId}` + `GetAssessmentReport?projectId=${projectId}&imageLayerId=${imageLayerId}&modelId=${modelId}&version=${version}` ) .then((data) => { if (data && data.error && !data.predictions) { @@ -191,7 +199,7 @@ const AssessmentReportModal = ({ // eslint-disable-next-line react-hooks/set-state-in-effect fetchReport(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId, imageLayerId, modelId]); + }, [projectId, imageLayerId, modelId, version]); const preds = report?.predictions; const pop = report?.populationEstimate; @@ -226,6 +234,13 @@ const AssessmentReportModal = ({ + {!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/InteractiveLabeler/InteractiveLabeler.jsx b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx index e5a20338..2b8e1eb5 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. @@ -769,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 { @@ -782,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." @@ -805,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}` + @@ -836,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 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 b50db7fd..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,9 +86,18 @@ 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; + // 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"; + const createdDate = model.creationDate ? `${model.creationDate.substring(0, 10)} ${model.creationDate.substring( 11, @@ -108,12 +122,32 @@ 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)", 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 @@ -161,6 +195,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: [ { @@ -189,12 +252,33 @@ const EmbeddingModelRow = ({ const reportModals = ( <> + {showDownloadPredictions && ( + + fileDownload( + buildUrl( + buildVersionGpkgUrl({ + projectId, + imageLayerId, + modelId: model.modelId, + version, + }) + ), + setDialog + ) + } + onDismiss={() => setShowDownloadPredictions(false)} + /> + )} {showValidationReport && ( setShowValidationReport(false)} /> )} @@ -204,6 +288,7 @@ const EmbeddingModelRow = ({ imageLayerId={imageLayerId} modelId={model.modelId} modelName={model.name} + versions={model.editedPredictions} onDismiss={() => setShowAssessmentReport(false)} /> )} @@ -316,24 +401,13 @@ 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()} @@ -433,24 +507,13 @@ const EmbeddingModelRow = ({ appearance="primary" id={"embeddingResults" + index} className="dashboard-button" - disabled={!hasPredictions} + disabled={!(hasPredictions || canViewResults)} > Results - - {resultsMenu.items.map((mi) => ( - - {mi.text} - - ))} - + {renderResultsMenuItems()} diff --git a/ui/src/Components/ProjectManagement/ModelResultsButton.jsx b/ui/src/Components/ProjectManagement/ModelResultsButton.jsx index 00a4ab2d..3c64d437 100644 --- a/ui/src/Components/ProjectManagement/ModelResultsButton.jsx +++ b/ui/src/Components/ProjectManagement/ModelResultsButton.jsx @@ -8,6 +8,7 @@ import { MenuPopover, MenuList, MenuItem, + Tooltip, } from "@fluentui/react-components"; import { FluentIcon } from "../../util/icons"; import React, { useContext, useState } from "react"; @@ -19,6 +20,20 @@ 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 +// to completion. Shared by the Results button gate and the View action's +// client-side fallback so the two can't drift apart. +function hasCompletedInference(model) { + return model.inferenceJobs.length > 0 && model.inferenceStatus === "Processed"; +} function formatFileSize(bytes) { @@ -37,13 +52,12 @@ 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 - if ( - model.inferenceJobs.length > 0 && - model.inferenceStatus === "Processed" - ) { + if (hasCompletedInference(model)) { return false; // If inference fails, then the button should be enabled because will allow the user to download the artifacts when they are ready. } else if (model.status === "Failed" && model.artifacts != null) { @@ -78,10 +92,17 @@ const ModelResultsButton = ({ model, projectId, imageLayerId, index, validationL ? `Download Inference Artifacts (${formatFileSize(model.artifacts.inferenceZipSize)})` : "Download Inference Artifacts"; + // Viewing results opens the visualizer, which is also where predictions are + // edited. `predictionsReady` is the server-derived readiness flag; models + // saved before it existed fall back to the client-side inference check. + const canViewResults = model.predictionsReady ?? hasCompletedInference(model); + const resultsMenuOptions = (model) => ({ items: [ { - disabled: model.inferenceStatus !== "Processed", + disabled: !canViewResults, + tooltip: + "Inference must finish before results can be viewed or edited", key: "viewResults", text: "View", icon: , @@ -101,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 === "", @@ -165,23 +192,42 @@ const ModelResultsButton = ({ model, projectId, imageLayerId, index, validationL appearance="primary" id={"singleModelResults" + index} className="dashboard-button dashboard-button-light" - disabled={evaluateViewResultsButtonState(model)} + // Keep the menu reachable whenever any action inside it is + // available: the download/report entries use the existing + // inference/artifact check, View uses `predictionsReady`. + disabled={evaluateViewResultsButtonState(model) && !canViewResults} > Results - {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 + ); + })} @@ -192,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)} /> )} @@ -207,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/InfoPanel.jsx b/ui/src/Components/Visualizer/InfoPanel.jsx index 4406b477..6b4ca7df 100644 --- a/ui/src/Components/Visualizer/InfoPanel.jsx +++ b/ui/src/Components/Visualizer/InfoPanel.jsx @@ -1,9 +1,19 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +// +// The results page's map-settings card: which layers are drawn, what their +// colours mean, and the shortcuts that drive the view. +// +// The layer list is NOT fixed. An inference model has pre-coloured damage +// rasters to toggle; an embedding model has none, and offering a checkbox for +// a layer that was never added to the map is worse than offering nothing at +// all. So the rows come from visualizerLayerOptions() — pure and unit-tested — +// and the legends follow whatever is actually on screen. import { Checkbox, Button, Text, + Tooltip, makeStyles, tokens, } from "@fluentui/react-components"; @@ -15,6 +25,8 @@ import { AppContext } from "../../AppContext"; import KeyboardShortcutHelp from "../KeyboardShortcutHelp"; import { VISUALIZER_SHORTCUTS } from "../keyboardShortcuts"; +// The damage raster is baked server-side into these five bands, so this +// legend is only meaningful when that raster exists. const DAMAGE_LEGEND = [ { label: "0 - 20% damaged", color: "#FFFFFF" }, { label: "20 - 40% damaged", color: "#FFB99F" }, @@ -48,10 +60,26 @@ const useStyles = makeStyles({ border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, borderRadius: tokens.borderRadiusSmall, }, + // The footprint swatches use the same theme tokens the map paint + // expressions resolve, so the legend cannot drift from the map. + damagedSwatch: { + backgroundColor: tokens.colorStatusDangerBackground3, + }, + notDamagedSwatch: { + backgroundColor: tokens.colorStatusSuccessBackground3, + }, + unknownSwatch: { + backgroundColor: tokens.colorNeutralForeground3, + }, + pendingSwatch: { + backgroundColor: tokens.colorNeutralBackground5, + }, }); const InfoPanel = ({ - togglePredictedDamageLayerVisibility, + layerOptions, + layerVisibility, + onLayerVisibilityChange, resetMapPosition, visualizerResults, surfaceClassName, @@ -68,6 +96,21 @@ const InfoPanel = ({ } } + const hasDamageRaster = layerOptions.some( + (option) => 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/Visualizer/PredictionEditPanel.jsx b/ui/src/Components/Visualizer/PredictionEditPanel.jsx new file mode 100644 index 00000000..e15a478c --- /dev/null +++ b/ui/src/Components/Visualizer/PredictionEditPanel.jsx @@ -0,0 +1,700 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// 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. +// +// 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 +// 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 { + Button, + Divider, + Dropdown, + Field, + Badge, + MessageBar, + MessageBarBody, + MessageBarTitle, + Option, + Slider, + Text, + Tooltip, + makeStyles, + tokens, +} from "@fluentui/react-components"; +import { FluentIcon } from "../../util/icons"; +import KeyboardShortcutHelp from "../KeyboardShortcutHelp"; +import { PREDICTION_EDIT_SHORTCUTS } from "../keyboardShortcuts"; +import { + CLASS_DAMAGED, + CLASS_LABELS, + CLASS_NOT_DAMAGED, + CLASS_UNKNOWN, + FILTER_ALL, + FILTER_LABELS, + FILTER_VALUES, + sortVersionsDescending, + toPercentLabel, +} from "./predictionClassify"; +import { describeServedVersion } from "./predictionResults"; +import { describeVersionDownload } from "./predictionVersions"; + +const CLASS_ORDER = [CLASS_DAMAGED, CLASS_NOT_DAMAGED, CLASS_UNKNOWN]; + +// Keyboard hints shown on the class buttons, matching PREDICTION_EDIT_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", + // 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, + 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, + // 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", + 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, + }, + // 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: "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, + }, + actions: { + paddingTop: tokens.spacingVerticalS, + marginTop: tokens.spacingVerticalS, + borderTop: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalXS, + }, +}); + +const PredictionEditPanel = ({ + flavor = "", + supportsThreshold = true, + counts, + total, + editedCount, + filter, + setFilter, + filteredIndices, + selectedIndex, + currentBuilding, + activeClass, + setActiveClass, + onApplyToSelected, + onClearOverride, + onClearAllEdits, + onPrev, + onNext, + threshold, + setThreshold, + unknownThreshold, + setUnknownThreshold, + baseline, + changeCount, + swipeHint = "", + onExit, + onSave, + isSaving, + saveError, + savedResult, + versions, + activeVersion = null, + onDownloadVersion, + reportDivergence = null, + thresholdNote = "", +}) => { + 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 + {flavor ? ` · ${flavor} model` : ""} +
+
+ {describeServedVersion(activeVersion)} +
+
+ +
+ {/* Counts */} +
+ {CLASS_ORDER.map((cls) => ( +
+ + + {CLASS_LABELS[cls]} + + + {(counts?.[cls] || 0).toLocaleString()} + +
+ ))} +
+ Edited by hand + + {editedCount.toLocaleString()} + +
+
+ + + + {/* 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 real score support these. */} + {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."} +
+
+ )} + + {!supportsThreshold && ( +
+ {thresholdNote || + "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 — 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. +
+ +
+ + +
+ + + + + + {/* Saved versions */} +
+
Saved versions
+ {reportDivergence && ( + + + {reportDivergence.title} + {reportDivergence.body} + + + )} + {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} + + + {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)} + {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. + + + )} + + +
+
+ ); +}; + +PredictionEditPanel.propTypes = { + flavor: PropTypes.string, + supportsThreshold: PropTypes.bool, + 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, + }), + activeClass: PropTypes.string.isRequired, + setActiveClass: PropTypes.func.isRequired, + onApplyToSelected: 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, + swipeHint: PropTypes.string, + onExit: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.string, + 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 new file mode 100644 index 00000000..a9305afd --- /dev/null +++ b/ui/src/Components/Visualizer/PredictionStatusNote.jsx @@ -0,0 +1,176 @@ +// 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. +// +// 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, + 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({ + // 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: { + boxSizing: "border-box", + width: "100%", + 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, + pointerEvents: "auto", + }, + 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/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 6b79cfd6..646b502f 100644 --- a/ui/src/Components/Visualizer/Visualizer.jsx +++ b/ui/src/Components/Visualizer/Visualizer.jsx @@ -1,34 +1,239 @@ // 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. +// +// 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 { useEffect, useRef, useState, useContext } from "react"; -import { apiGet } from "../../util/api"; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +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"; 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, + canEditFootprints, + describeEditAvailability, + describeUnsavedEdits, + hasRasterLayer, + resolveModelFlavor, + 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, + swipeModeHint, +} from "./visualizerSwipe"; + +// 1 / 2 / 3 choose the class every edit gesture applies, 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", + }, + // 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 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: "10px", + 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", + "@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 + // 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); + // 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. + 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,29 +242,283 @@ const Visualizer = ({ setModalComponent }) => { saturation: 0, }); - // Visualizer data fetching function + 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, + // 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; + const canEdit = canEditFootprints(footprintStatus) && footprints.layersReady; + const layerOptions = useMemo( + () => + visualizerLayerOptions({ + results: globalVisualizerResults, + footprintStatus, + }), + [globalVisualizerResults, footprintStatus] + ); + const swipeMode = useMemo( + () => resolveSwipeMode(globalVisualizerResults), + [globalVisualizerResults] + ); + + // ── 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); - console.log(response); return response; - }) .catch((error) => { + setIsLoading(false); console.error("Error fetching visualizer results:", error); throw error; }); } + /** + * 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. + const swipeAreaWidth = useCallback( + () => containerRef.current?.getBoundingClientRect().width || window.innerWidth, + [] + ); + useEffect(() => { if (swipeMapRef.current) { if (swipeStateMobile === "post") { @@ -68,19 +527,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 +553,7 @@ const Visualizer = ({ setModalComponent }) => { } else { if (swipeMapRef.current) { swipeMapRef.current.setOptions({ - sliderPosition: window.innerWidth / 2 + sliderPosition: swipeAreaWidth() / 2 }); } @@ -114,7 +572,6 @@ const Visualizer = ({ setModalComponent }) => { swipeMapElement.classList.remove('d-none'); } } - } useEffect(() => { @@ -123,38 +580,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 +634,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 +654,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 +676,222 @@ 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(); + // 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. + 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) { + // Picking a class never edits a building on its own — a silent + // relabel of whatever happened to be selected is exactly the + // surprise this mode is meant to avoid. Enter is the explicit apply. + footprints.setActiveClass(cls); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + footprints.applyActiveClassToSelected(); + 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 +909,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 +936,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 +948,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 +965,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 +981,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 +999,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 +1019,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 +1062,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 +1077,6 @@ const Visualizer = ({ setModalComponent }) => { ...imageryValues, [key]: value, }); - } catch (error) { console.error("Error updating imagery values:", error); } @@ -423,9 +1084,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 +1112,54 @@ const Visualizer = ({ setModalComponent }) => { } }; + const classification = footprints.classification; + const showStatusNote = + 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 ( -
-
-
+
+
+
+ {/* Ctrl+drag selection rectangle, shared by both panes. */} +
{ imageryValues={imageryValues} visualizerResults={globalVisualizerResults} /> + + {(showVersionControls || showStatusNote) && ( +
+ {showVersionControls && ( + setVersionSwitchFailure(null)} + /> + )} + + {showStatusNote && ( + setDismissedNoteStatus(footprintStatus)} + /> + )} +
+ )} + + {isEditMode && classification && ( + <> +
+ Pick a class, then click a footprint to apply it · Ctrl+drag + to box-select · right-click to undo an edit · A / S / + D move the swipe divider +
+ 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} + // 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) + : "" + } + /> + + )}
); }; +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/Visualizer/predictionClassify.js b/ui/src/Components/Visualizer/predictionClassify.js new file mode 100644 index 00000000..f0de042e --- /dev/null +++ b/ui/src/Components/Visualizer/predictionClassify.js @@ -0,0 +1,491 @@ +// 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"; + +// Every class an analyst can assign, in the order the class picker shows them. +export const PREDICTION_CLASSES = [ + CLASS_DAMAGED, + CLASS_NOT_DAMAGED, + CLASS_UNKNOWN, +]; + +// The class the editor starts painting with. Damaged is the overwhelmingly +// common correction, so it is the one that costs no clicks to select. +export const DEFAULT_EDIT_CLASS = CLASS_DAMAGED; + +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. + * + * 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 : []; + 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 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, 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. */ +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); +} + +/** + * 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, + imageLayerId, + modelId, + threshold, + unknownThreshold, + overrides, + attrs = null, +}) { + return { + projectId, + imageLayerId, + modelId, + threshold: num(threshold), + unknownThreshold: num(unknownThreshold), + overrides: mergedOverrideList( + attrs, + overrides, + num(threshold), + num(unknownThreshold) + ), + }; +} + +// ── 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 baseClassAt(attrs, 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 || baseClassAt(attrs, 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. 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; + 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; + 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++; + } + return changed; +} + +/** + * The class an edit should assign, or "" when `cls` is not one we know. + * + * Guards the one-way door in the editor: every path that writes an override + * (click, box-select, keyboard) funnels through the active class, so a typo or + * a stale value must not reach the overrides map and, from there, a saved + * GeoPackage. + */ +export function normalizeEditClass(cls) { + return PREDICTION_CLASSES.indexOf(cls) === -1 ? "" : cls; +} + +// ── 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/Visualizer/predictionClassify.test.js b/ui/src/Components/Visualizer/predictionClassify.test.js new file mode 100644 index 00000000..35757c51 --- /dev/null +++ b/ui/src/Components/Visualizer/predictionClassify.test.js @@ -0,0 +1,2173 @@ +// 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), 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"; + +import { + CLASS_DAMAGED, + CLASS_NOT_DAMAGED, + CLASS_UNKNOWN, + DEFAULT_EDIT_CLASS, + FILTER_ALL, + FILTER_EDITED, + PREDICTION_CLASSES, + baseClassAt, + buildSavePayload, + classifyAll, + clearOverride, + countClassChanges, + countOverrides, + deriveClass, + filterIndices, + getOverride, + hasSavedClasses, + indexById, + latestVersion, + matchesFilter, + mergedOverrideList, + nextIndexInList, + normalizeAttrs, + normalizeEditClass, + resolveClassAt, + savedClassAt, + 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, + describePendingVersions, + 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, + normalizeVersionParam, + rasterLayerAvailability, + resolveActiveVersion, + resolveFootprintStatus, + resolveModelFlavor, + resolvePredictionArtifacts, + resolveInitialBuildingCount, + resolveInitialVersions, + 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, + defaultPredictionVersion, + describeReportDivergence, + describeSavedClassNote, + describeVersionDownload, + describeVersionInline, + describeVersionSidecarPending, + describeVersionSwitchDiscard, + describeVersionSwitchFailure, + findVersionOption, + hasPredictionVersionChoice, + isVersionReady, + normalizeVersionSelection, + predictionSourceOptions, + selectedVersionText, + shouldPollVersionSidecar, + versionKey, + versionLabel, + versionSelectorOptions, +} from "./predictionVersions.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("normalizeEditClass accepts only the three real classes", () => { + assert.equal(normalizeEditClass(CLASS_DAMAGED), CLASS_DAMAGED); + assert.equal(normalizeEditClass(CLASS_NOT_DAMAGED), CLASS_NOT_DAMAGED); + assert.equal(normalizeEditClass(CLASS_UNKNOWN), CLASS_UNKNOWN); + // "cycle" was the old click-action sentinel; it must never reach an + // override now that the picker is the only source of a class. + assert.equal(normalizeEditClass("cycle"), ""); + assert.equal(normalizeEditClass(""), ""); + assert.equal(normalizeEditClass(undefined), ""); + assert.equal(normalizeEditClass(null), ""); +}); + +test("the editor starts on a class that is safe to paint with", () => { + assert.equal(normalizeEditClass(DEFAULT_EDIT_CLASS), DEFAULT_EDIT_CLASS); + assert.ok(PREDICTION_CLASSES.includes(DEFAULT_EDIT_CLASS)); +}); + +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", + // Nothing was pinned, so this payload is the model's raw output. + version: null, + } + ); + // ...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", + version: null, + }); +}); + +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); +}); + +// ── 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); +}); + +// ── 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/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/Visualizer/predictionPrep.js b/ui/src/Components/Visualizer/predictionPrep.js new file mode 100644 index 00000000..84b5d13b --- /dev/null +++ b/ui/src/Components/Visualizer/predictionPrep.js @@ -0,0 +1,296 @@ +// 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 ")}.`; +} + +/** + * 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)) { + 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; + } + // 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 + ); + 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/Visualizer/predictionResults.js b/ui/src/Components/Visualizer/predictionResults.js new file mode 100644 index 00000000..933b32bd --- /dev/null +++ b/ui/src/Components/Visualizer/predictionResults.js @@ -0,0 +1,556 @@ +// 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() : ""; +} + +/** + * 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 ?? "")); + if (cleanString(imageLayerId)) { + params.set("imageLayerId", String(imageLayerId)); + } + 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()}`; +} + +/** + * 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). + * + * 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", version }); + return { footprintTilesUrl, predictionAttrsUrl, version }; +} + +// ── 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."; +} + +/** + * 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). +// 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/predictionVersions.js b/ui/src/Components/Visualizer/predictionVersions.js new file mode 100644 index 00000000..4524a8b5 --- /dev/null +++ b/ui/src/Components/Visualizer/predictionVersions.js @@ -0,0 +1,389 @@ +// 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; +} + +// ── 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; +} diff --git a/ui/src/Components/Visualizer/usePredictionArtifacts.js b/ui/src/Components/Visualizer/usePredictionArtifacts.js new file mode 100644 index 00000000..ddc85949 --- /dev/null +++ b/ui/src/Components/Visualizer/usePredictionArtifacts.js @@ -0,0 +1,557 @@ +// 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. +// +// 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"; +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, + resolveVersionIsLatest, + shouldRequestPreparation, + versionSidecarPending, +} 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); + // 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(""); + 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] + ); + + // 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)}` + + `&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) => { + 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. 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; + 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. 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(); + 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); + setAttrs(loadedAttrs); + setArchiveKey(archiveUrl); + setIsLoaded(true); + setIsLoading(false); + return true; + }, + [attrsKey, tilesKey, 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 ────────────────────────────────────────────────────────────────── + // 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 (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; + 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); + // 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); + 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 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, + attrsKey, + tilesKey, + versionPending, + ]); + + // ── 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, + // 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. 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, + versionPending, + results, + session, + buildingCount, + ] + ); + + return { + status, + isEmpty: status === FOOTPRINTS_EMPTY, + 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, + 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..6b4479f1 --- /dev/null +++ b/ui/src/Components/Visualizer/usePredictionFootprints.js @@ -0,0 +1,1022 @@ +// 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. +// +// 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. +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { tokens } from "@fluentui/react-components"; +import { apiPut } from "../../util/api"; +import { + DEFAULT_EDIT_CLASS, + FILTER_ALL, + buildSavePayload, + classifyAll, + clearOverride, + countClassChanges, + filterIndices, + matchesFilter, + nextIndexInList, + normalizeEditClass, + 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, + // 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 + // 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 activeClassRef = useRef(DEFAULT_EDIT_CLASS); + 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 [activeClass, setActiveClass] = useState(DEFAULT_EDIT_CLASS); + 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(() => { + activeClassRef.current = activeClass; + }, [activeClass]); + useEffect(() => { + editModeRef.current = isEditMode; + }, [isEditMode]); + + // Double-click zoom fights click-to-classify: labelling two neighbouring + // buildings in quick succession registers as a double-click and the map + // zooms instead. Azure Maps owns that gesture, so it is switched off for as + // long as edit mode is on and restored on the way out (and on unmount) — + // panning and scroll zoom are untouched, so the map still navigates. + useEffect(() => { + if (!mapsReady) return undefined; + const maps = (mapRefs || []) + .map((ref) => ref?.current) + .filter((map) => map && typeof map.setUserInteraction === "function"); + if (maps.length === 0) return undefined; + const setDoubleClickZoom = (enabled) => { + for (const map of maps) { + try { + map.setUserInteraction({ dblClickZoomInteraction: enabled }); + } catch (error) { + console.warn("Could not toggle double-click zoom:", error); + } + } + }; + setDoubleClickZoom(!isEditMode); + return () => setDoubleClickZoom(true); + }, [mapRefs, mapsReady, 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 ────────────────────────────────────────────────────────────── + // One rule for every edit gesture: whatever class the picker is on is the + // class the building gets. Clicking also selects, so the panel describes + // what was just changed. + const handleFeatureClick = useCallback( + (id) => { + const index = indexByIdRef.current.get(id); + if (index === undefined) return; + setSelectedIndex(index); + const cls = normalizeEditClass(activeClassRef.current); + if (!cls) return; + 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 applyActiveClassToIds = useCallback((ids) => { + if (ids.length === 0) return; + const cls = normalizeEditClass(activeClassRef.current); + if (!cls) return; + setOverridesState((previous) => setOverrides(previous, ids, cls)); + }, []); + + const setClassForSelected = useCallback( + (cls) => { + if (!attrs || selectedIndex < 0 || selectedIndex >= attrs.n) return; + const resolved = normalizeEditClass(cls); + if (!resolved) return; + const id = attrs.ids[selectedIndex]; + setOverridesState((previous) => setOverrides(previous, [id], resolved)); + }, + [attrs, selectedIndex] + ); + + // Keyboard equivalent of clicking the selected building: the review flow + // (arrow to a building, label it) never has to reach for the mouse. + const applyActiveClassToSelected = useCallback(() => { + setClassForSelected(activeClassRef.current); + }, [setClassForSelected]); + + 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) + ), + ]; + applyActiveClassToIds(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. + // + // 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 || []) + .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); + } + } + // 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); + 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, 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 + // 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, + // 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. + 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, + attrs, + 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, + activeClass, + setActiveClass, + applyActiveClassToSelected, + 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/Visualizer/visualizerSwipe.js b/ui/src/Components/Visualizer/visualizerSwipe.js new file mode 100644 index 00000000..c217e4c4 --- /dev/null +++ b/ui/src/Components/Visualizer/visualizerSwipe.js @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Pure decision logic for the results view's swipe map. +// +// 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 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 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"; +// 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 the results page is showing, from the imagery it has. + * + * 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 ?? imagery?.postDisasterImagery?.url + ); + if (!post) return SWIPE_MODE_NONE; + const pre = cleanUrl( + imagery?.preEventTileUrl ?? imagery?.preDisasterImagery?.url + ); + return pre ? SWIPE_MODE_PRE_POST : SWIPE_MODE_BASEMAP_POST; +} + +/** True when there are two panes worth comparing. */ +export function isSwipeAvailable(mode) { + return mode === SWIPE_MODE_PRE_POST || mode === SWIPE_MODE_BASEMAP_POST; +} + +/** 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/label for the right (post-event, editable) pane. */ +export function swipeRightPaneLabel(mode) { + return isSwipeAvailable(mode) ? "Post-event imagery" : ""; +} + +/** + * 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)) { + 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 bc5559a2..5269843d 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,6 +62,51 @@ export const BUILDING_VALIDATION_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: + "Choose the class to apply — Damaged / Not Damaged / Unknown", + }, + { + keys: ["Enter"], + description: "Apply the chosen class to the selected building", + }, + { + keys: ["←", "→"], + description: "Previous / next building in the current filter", + }, + { + keys: ["Click"], + description: "Set a footprint to the chosen class", + }, + { + keys: ["Ctrl", "drag"], + separator: " + ", + description: "Box-select footprints and set them all to the chosen class", + }, + { + keys: ["Right-click"], + description: "Undo an edit — back to the model's class", + }, + { + // 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: + "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", + }, +]; + // Input types that are not free-text entry. Focus can legitimately sit on // one of these while the user keeps driving the page from the keyboard. const NON_TEXT_INPUT_TYPES = new Set([ diff --git a/ui/src/util/pmtiles.js b/ui/src/util/pmtiles.js new file mode 100644 index 00000000..58cdc2b8 --- /dev/null +++ b/ui/src/util/pmtiles.js @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Shared PMTiles plumbing for the Azure Maps screens. +// +// `atlas.addProtocol("pmtiles", fn)` keeps exactly ONE handler per scheme: +// the last registration wins. Each `new Protocol()` owns its own archive +// cache, so if two screens each registered their own instance, whichever +// registered last would serve every tile request — including requests for +// archives the *other* instance had added, which it cannot resolve (it falls +// back to a range-request FetchSource that the SWA /api proxy does not +// support). Both the Interactive Labeler and the Prediction Editor therefore +// share the single instance handed out here. + +import { Protocol } from "pmtiles"; + +let protocolInstance = null; +let isRegistered = false; + +/** + * The process-wide pmtiles Protocol, registering the "pmtiles" scheme with + * Azure Maps the first time the SDK is available. Returns null when there is + * no window (SSR / unit tests). + * + * Callers add their archive with `protocol.add(new PMTiles(source))` and then + * point a VectorTileSource at `pmtiles://`, 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(); +}