Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/scripts/deploy_apps.sh
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ PUBLISHING_ORGANIZATION_NAME="${PUBLISHING_ORGANIZATION_NAME:-}"
PUBLISHING_ORGANIZATION_URL="${PUBLISHING_ORGANIZATION_URL:-}"
PUBLISH_STORAGE_ACCOUNT_URL="${PUBLISH_STORAGE_ACCOUNT_URL:-}"
PUBLISH_BLOB_CONTAINER="${PUBLISH_BLOB_CONTAINER:-}"
PUBLISH_EXPLORER_RENDER_ENABLED="${PUBLISH_EXPLORER_RENDER_ENABLED:-true}"
MAPS_ACCOUNT="${RESOURCE_PREFIX}haste${RANDOM_SUFFIX}maps"
API_MANAGEMENT="${RESOURCE_PREFIX}-haste-${RANDOM_SUFFIX}-apim"
FIXED_TAGS="project=haste created_by=deploy_apps"
Expand Down Expand Up @@ -167,6 +168,7 @@ deploy_function() {
"PUBLISHING_ORGANIZATION_URL=${PUBLISHING_ORGANIZATION_URL}" \
"PUBLISH_STORAGE_ACCOUNT_URL=${PUBLISH_STORAGE_ACCOUNT_URL}" \
"PUBLISH_BLOB_CONTAINER=${PUBLISH_BLOB_CONTAINER}" \
"PUBLISH_EXPLORER_RENDER_ENABLED=${PUBLISH_EXPLORER_RENDER_ENABLED}" \
"AzureFunctionsWebHost__hostId=${HOST_ID}" \
--output none
fi
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/deploy-apps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ jobs:
# Each defaults to off/unset in deploy_apps.sh when absent.
PUBLISHING_ENABLED: ${{ vars.PUBLISHING_ENABLED }}
PC_PROVIDER_ENABLED: ${{ vars.PC_PROVIDER_ENABLED }}
PUBLISH_EXPLORER_RENDER_ENABLED: ${{ vars.PUBLISH_EXPLORER_RENDER_ENABLED }}
PC_COLLECTION_PREFIX: ${{ vars.PC_COLLECTION_PREFIX }}
PUBLISHING_ORGANIZATION_NAME: ${{ secrets.PUBLISHING_ORGANIZATION_NAME }}
PUBLISHING_ORGANIZATION_URL: ${{ secrets.PUBLISHING_ORGANIZATION_URL }}
Expand Down
30 changes: 30 additions & 0 deletions hastelib/src/hastegeo/core/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
import logging
import math
import os
import re
import tempfile
Expand Down Expand Up @@ -30,6 +31,20 @@ def _get_bounded_int_env(name, default, minimum, maximum=None):
return value


def _get_bounded_float_env(name, default, minimum, maximum=None):
raw = os.getenv(name, str(default))
try:
value = float(raw)
except (TypeError, ValueError):
raise ValueError(f"{name} must be a number")
if not math.isfinite(value):
raise ValueError(f"{name} must be a finite number")
if value < minimum or (maximum is not None and value > maximum):
upper = f" and {maximum}" if maximum is not None else ""
raise ValueError(f"{name} must be between {minimum}{upper}")
return value


def _strip_scheme(value):
"""Reduce a registry URL to the bare login server.

Expand Down Expand Up @@ -368,6 +383,21 @@ def get_publishing_config():
"pc_verify_attempts": _get_bounded_int_env(
"PC_VERIFY_ATTEMPTS", 20, 1, 60
),
# Explorer visualization: render a damage-classification COG (our
# derived output, not source imagery) plus the render/mosaic/tile
# config the GeoCatalog Explorer requires.
"publish_explorer_render_enabled": _get_bool_env(
"PUBLISH_EXPLORER_RENDER_ENABLED", True
),
"publish_damage_raster_meters": _get_bounded_float_env(
"PUBLISH_DAMAGE_RASTER_METERS", 0.5, 0.01, 100.0
),
"publish_damage_raster_max_pixels": _get_bounded_int_env(
"PUBLISH_DAMAGE_RASTER_MAX_PIXELS", 8192, 256, 20000
),
"publish_damage_raster_min_zoom": _get_bounded_int_env(
"PUBLISH_DAMAGE_RASTER_MIN_ZOOM", 13, 0, 24
),
"lease_connection_string": os.getenv("AzureWebJobsStorage"),
"lease_account_url": os.getenv("BLOB_ACCOUNT_URL"),
"lease_container": os.getenv(
Expand Down
258 changes: 232 additions & 26 deletions hastelib/src/hastegeo/core/publishing/planetary_computer_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,19 @@ def __init__(
).strip(),
}
self.max_verify_attempts = int(settings.get("pc_verify_attempts") or 5)
# Explorer visualization (damage classification COG + render config).
self._explorer_render_enabled = bool(
settings.get("publish_explorer_render_enabled", True)
)
self._damage_raster_meters = float(
settings.get("publish_damage_raster_meters", 0.5)
)
self._damage_raster_max_pixels = int(
settings.get("publish_damage_raster_max_pixels") or 8192
)
self._damage_raster_min_zoom = int(
settings.get("publish_damage_raster_min_zoom") or 13
)
self.artifact_storage = artifact_storage or UnifiedArtifactStorage(
storage_type=self.config.artifact_storage_type,
**self.config.artifact_storage_config,
Expand Down Expand Up @@ -1140,6 +1153,7 @@ def _completed_publish(
)
collection_id, _ = self._ids(dataset)
self._upload_collection_tile(dataset, source, collection_id)
self._ensure_explorer_config(dataset, collection_id)
metadata = self._stable_metadata(dataset)
metadata["assetsCopiedToManagedStorage"] = True
return PublishResult(
Expand All @@ -1161,29 +1175,11 @@ def _upload_collection_tile(
# attach it as the collection thumbnail via the Collection Asset API.
# Best-effort: never fail the publish over a tile.
try:
import geopandas as gpd

from .tile import render_collection_tile

mask = source.get(ArtifactKind.VALID_MASK)
if mask is None:
buildings_gdf, aoi_gdf = self._load_buildings_aoi(source)
if aoi_gdf is None:
return
valid_mask = self.json_reader(mask)
aoi_gdf = gpd.GeoDataFrame.from_features(
valid_mask.get("features") or [], crs="EPSG:4326"
)
if aoi_gdf.empty:
return
buildings_artifact = source.get(ArtifactKind.GPKG) or source.get(
ArtifactKind.FOOTPRINTS
)
if buildings_artifact is not None:
with self._materialized_artifact(
buildings_artifact
) as local_path:
buildings_gdf = gpd.read_file(local_path)
else:
buildings_gdf = aoi_gdf.iloc[0:0]
png = render_collection_tile(
buildings_gdf,
aoi_gdf,
Expand Down Expand Up @@ -1379,7 +1375,9 @@ def _build_documents(
organization=self.organization,
)
self.stac_validator(objects)
return serialize_stac_objects(objects)
documents = serialize_stac_objects(objects)
self._attach_damage_class_asset(dataset, source, documents)
return documents

@staticmethod
def _valid_mask_crs(valid_mask: Mapping[str, Any]) -> str:
Expand Down Expand Up @@ -1543,16 +1541,224 @@ def _stage_to_publish(
namespace=["published", str(dataset_id)],
)

def _load_buildings_aoi(self, source: ArtifactBundle):
"""Load ``(buildings, aoi)`` GeoDataFrames from the published bundle.

``aoi`` is ``None`` when there is no valid-area mask (or it is empty);
``buildings`` is an empty frame when no building artifact is present.
Shared by the collection thumbnail and the damage classification COG.
"""
import geopandas as gpd

mask = source.get(ArtifactKind.VALID_MASK)
if mask is None:
return None, None
valid_mask = self.json_reader(mask)
aoi_gdf = gpd.GeoDataFrame.from_features(
valid_mask.get("features") or [], crs="EPSG:4326"
)
if aoi_gdf.empty:
return None, None
buildings_artifact = source.get(ArtifactKind.GPKG) or source.get(
ArtifactKind.FOOTPRINTS
)
if buildings_artifact is not None:
with self._materialized_artifact(buildings_artifact) as local_path:
buildings_gdf = gpd.read_file(local_path)
else:
buildings_gdf = aoi_gdf.iloc[0:0]
return buildings_gdf, aoi_gdf

def _stage_damage_class_asset(
self, dataset: PublishedDataset, source: ArtifactBundle
) -> Optional[dict]:
"""Rasterize the damage output to a COG, stage it, return its STAC asset.

Returns the item asset dict (with a publish-store href), or ``None``
when the feature is disabled or there is nothing to rasterize.
Best-effort: an ordinary failure is caught and degrades to a publish
without Explorer visualization (the collection is still created). The
one exception is resource exhaustion — an OOM-kill terminates the
worker rather than raising, so it can still fail the publish; the raster
is bounded by the per-side pixel cap to keep that path unlikely.
"""
if not self._explorer_render_enabled:
return None
# The classification is derived from the predicted-damage geopackage;
# without it (e.g. a footprints-only publish) there is nothing to
# render, and reusing a previously staged COG would be misleading.
if source.get(ArtifactKind.GPKG) is None:
return None
from .raster import (
DAMAGE_CLASS_ASSET_TITLE,
DAMAGE_CLASS_MEDIA_TYPE,
rasterize_damage_cog,
)

dest_name = "damage_class.tif"
destination = f"published/{dataset.datasetId}/{dest_name}"
try:
if not self.publish_storage.artifact_exists(destination):
Comment thread
prbatero marked this conversation as resolved.
buildings_gdf, aoi_gdf = self._load_buildings_aoi(source)
if aoi_gdf is None:
return None
with tempfile.TemporaryDirectory() as staging_dir:
cog_path = str(Path(staging_dir, dest_name))
result = rasterize_damage_cog(
buildings_gdf,
aoi_gdf,
cog_path,
target_meters=self._damage_raster_meters,
max_pixels_per_side=self._damage_raster_max_pixels,
logger=self.logger,
)
if result is None:
return None
self.publish_storage.store_artifact(
artifact_name=dest_name,
src_path=cog_path,
namespace=["published", str(dataset.datasetId)],
)
return {
"href": self._publish_href(destination),
"type": DAMAGE_CLASS_MEDIA_TYPE,
"title": DAMAGE_CLASS_ASSET_TITLE,
"roles": ["data"],
}
except Exception as error:
Comment thread
prbatero marked this conversation as resolved.
self.logger.warning(
"Skipping Planetary Computer damage classification COG: %s",
type(error).__name__,
)
return None

def _attach_damage_class_asset(
self,
dataset: PublishedDataset,
source: ArtifactBundle,
documents,
) -> None:
"""Inject the ``damage_class`` COG asset into the item + collection.

Added post-serialization so the raster asset (our derived output) is
the renderable asset the Explorer render configuration points at.
"""
from .raster import (
DAMAGE_CLASS_ASSET_KEY,
DAMAGE_CLASS_ASSET_TITLE,
DAMAGE_CLASS_MEDIA_TYPE,
)

asset = self._stage_damage_class_asset(dataset, source)
if asset is None:
return
documents.item.setdefault("assets", {})[DAMAGE_CLASS_ASSET_KEY] = asset
documents.collection.setdefault("item_assets", {})[
DAMAGE_CLASS_ASSET_KEY
] = {
"type": DAMAGE_CLASS_MEDIA_TYPE,
"title": DAMAGE_CLASS_ASSET_TITLE,
"roles": ["data"],
}

def _damage_render_option(self) -> dict:
import json
import urllib.parse

from .raster import (
DAMAGE_CLASS_ASSET_KEY,
DAMAGE_CLASS_COLORMAP,
DAMAGE_CLASS_NODATA,
)

colormap = json.dumps(
{str(value): list(rgba) for value, rgba in DAMAGE_CLASS_COLORMAP.items()}
)
options = urllib.parse.urlencode(
{
"assets": DAMAGE_CLASS_ASSET_KEY,
"nodata": DAMAGE_CLASS_NODATA,
"colormap": colormap,
}
)
return {
"id": "damage",
"name": "Damage classification",
"description": (
"Predicted building damage (red) over undamaged buildings "
"(grey)."
),
"type": "raster-tile",
"options": options,
"minZoom": self._damage_raster_min_zoom,
}

def _ensure_explorer_config(
self, dataset: PublishedDataset, collection_id: str
) -> None:
"""Register the render/mosaic/tile config the Explorer requires.

Idempotent (create-if-absent; tile-settings is a PUT). Runs only when a
damage classification COG was actually staged for this dataset, so the
render option always points at a real renderable asset. Best-effort: a
failure never fails the publish, it just leaves the collection
non-explorable until the next publish.
"""
if not self._explorer_render_enabled:
return
destination = f"published/{dataset.datasetId}/damage_class.tif"
try:
if not self.publish_storage.artifact_exists(destination):
return
render_ids = {
option.get("id")
for option in self.sdk.get_render_options(collection_id)
if isinstance(option, Mapping)
}
if "damage" not in render_ids:
self.sdk.create_render_option(
collection_id, self._damage_render_option()
)
mosaic_ids = {
mosaic.get("id")
for mosaic in self.sdk.get_mosaics(collection_id)
if isinstance(mosaic, Mapping)
}
if "most-recent" not in mosaic_ids:
self.sdk.create_mosaic(
collection_id,
{
"id": "most-recent",
"name": "Most recent available",
"description": "Show the most recent available data",
"cql": [],
},
)
self.sdk.replace_tile_settings(
collection_id,
{
"minZoom": self._damage_raster_min_zoom,
"maxItemsPerTile": 35,
},
)
except Exception as error:
self.logger.warning(
"Skipping Planetary Computer Explorer configuration: %s",
type(error).__name__,
)

def finalize_unpublish(self, dataset: PublishedDataset) -> None:
"""Remove staging copies once an unpublish has fully completed.

With a dedicated publish container, published assets are copied under
Published assets and the damage-classification COG are staged under
``published/<datasetId>/``. GeoCatalog cleanup only removes the STAC
item/collection, so those staging blobs would otherwise accumulate
indefinitely. Best-effort: a failure here must not fail the unpublish.
item/collection, so those blobs would otherwise accumulate. Always
attempt to delete this dataset-specific prefix -- the current feature
flags are not reliable evidence of what was staged when the dataset was
published (rendering may have since been toggled, or a dedicated store
added/removed), and the delete is a no-op when nothing is there.
Best-effort: a failure here must not fail the unpublish.
"""
if not self._stages_to_publish:
return
prefix = f"published/{dataset.datasetId}/"
try:
self.publish_storage.delete_prefix(prefix)
Expand Down
Loading
Loading