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
23 changes: 23 additions & 0 deletions src/lib/build_csv.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,15 @@ def align_features_to_dsm(self, features, dsm):
f"Warning: Features CRS ({features.crs}) does not match DSM CRS ({dsm_crs}). Reprojecting features to DSM CRS."
)
features = features.to_crs(dsm_crs)
# Distances and buffers below assume metres. A geographic DSM CRS
# (degrees) silently truncates the x1000-scaled distance matrix to zero
# integers, yielding an arbitrary TSP route. Fail loud instead.
if features.crs is not None and not features.crs.is_projected:
raise ValueError(
f"DSM CRS ({dsm_crs}) is geographic (degrees). A projected CRS "
"(e.g. UTM) is required so distances and buffers are in metres. "
"Reproject the DSM to a projected CRS and retry."
)
return features

# -------------------------------------------------------------------------
Expand Down Expand Up @@ -209,6 +218,15 @@ def extract_features_elevations(self, features):

stats = zonal_stats(gdf_buffers, self.dsm_path, stats=["max"])
max_elev = [s["max"] for s in stats]
# zonal_stats returns max=None where a buffer falls entirely on DSM
# nodata (extent edge / hole). Left unguarded, None flows to the CSV as
# "None" and later float("None") crashes mission generation. Fail loud.
missing = [features.iloc[i].get("point_id", i) for i, v in enumerate(max_elev) if v is None]
if missing:
raise ValueError(
f"DSM has no data under {len(missing)} feature buffer(s) "
f"(point_id {missing[:10]}). Increase DSM coverage or reduce buffer_feature."
)
features = features.copy()
features["elev"] = max_elev
return features
Expand Down Expand Up @@ -340,6 +358,11 @@ def extract_path_checkpoints(self, ordered_features):
# Use zonal_stats to get max elevation in buffer
stats = zonal_stats(buffer_gdf, self.dsm_path, stats=["max"])
max_elev = stats[0]["max"]
if max_elev is None:
raise ValueError(
f"DSM has no data under the path buffer between waypoints {i} and "
f"{i + 1}. Increase DSM coverage or reduce buffer_path."
)
# Get centroid of buffer for checkpoint
centroid = buffer_gdf.centroid.iloc[0]
checkpoints.append(
Expand Down
60 changes: 60 additions & 0 deletions tests/test_build_csv_dsm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""DSM-coverage guards in BuildCSV — fail loud instead of flying a bad mission.

Two safety guards:
* a geographic DSM CRS would silently truncate the integer distance matrix to
zero and yield an arbitrary route;
* a feature/path buffer over DSM nodata yields max=None, which otherwise
reaches the CSV as "None" and crashes mission generation on float("None").
"""

from types import SimpleNamespace

import geopandas as gpd
import numpy as np
import pytest
import rasterio
from rasterio.transform import from_origin
from shapely.geometry import Point

from src.lib.build_csv import BuildCSV


def test_geographic_dsm_crs_raises():
feats = gpd.GeoDataFrame(
{"point_id": [1, 2]},
geometry=[Point(-74.0, 45.0), Point(-74.001, 45.001)],
crs="EPSG:4326",
)
dsm = SimpleNamespace(crs=rasterio.crs.CRS.from_epsg(4326)) # geographic
with pytest.raises(ValueError, match="geographic"):
BuildCSV("f", "d").align_features_to_dsm(feats, dsm)


def test_missing_dsm_coverage_raises(tmp_path):
# all-nodata projected DSM; any feature buffer over it -> max=None
dsm_path = tmp_path / "nodata.tif"
nodata = -9999.0
transform = from_origin(500000, 5000000, 1.0, 1.0) # 1 m pixels, UTM-like
data = np.full((50, 50), nodata, dtype="float32")
with rasterio.open(
dsm_path,
"w",
driver="GTiff",
height=50,
width=50,
count=1,
dtype="float32",
crs="EPSG:32618",
transform=transform,
nodata=nodata,
) as dst:
dst.write(data, 1)

feats = gpd.GeoDataFrame(
{"point_id": [7]},
geometry=[Point(500025, 4999975)], # inside the raster
crs="EPSG:32618",
)
bc = BuildCSV("f", str(dsm_path), buffer_feature=3)
with pytest.raises(ValueError, match="no data"):
bc.extract_features_elevations(feats)
Loading