added land
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.core.geo import PathPoint
|
||||
from app.core.raster_sampling import RasterNotConfiguredError, sample_along
|
||||
|
||||
|
||||
class LandcoverNotConfiguredError(RasterNotConfiguredError):
|
||||
"""Raised when landcover rasters are missing or incomplete."""
|
||||
|
||||
|
||||
ESA_WORLDCOVER_CLASSES: dict[int, str] = {
|
||||
10: "tree_cover",
|
||||
20: "shrubland",
|
||||
30: "grassland",
|
||||
40: "cropland",
|
||||
50: "built_up",
|
||||
60: "bare_sparse_vegetation",
|
||||
70: "snow_ice",
|
||||
80: "water",
|
||||
90: "herbaceous_wetland",
|
||||
95: "mangroves",
|
||||
100: "moss_lichen",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LandcoverPoint:
|
||||
distance_m: float
|
||||
class_name: str
|
||||
forest_type: str | None
|
||||
canopy_height_m: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LandcoverPathSegment:
|
||||
from_m: float
|
||||
to_m: float
|
||||
class_name: str
|
||||
forest_type: str | None
|
||||
canopy_height_m: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LandcoverPath:
|
||||
segments: list[LandcoverPathSegment]
|
||||
vegetation_depth_m: float
|
||||
|
||||
|
||||
def worldcover_class(value: float) -> str:
|
||||
if np.isnan(value):
|
||||
return "unknown"
|
||||
return ESA_WORLDCOVER_CLASSES.get(int(round(value)), "unknown")
|
||||
|
||||
|
||||
def forest_type_for_class(class_name: str) -> str | None:
|
||||
if class_name in {"tree_cover", "mangroves"}:
|
||||
return "unknown"
|
||||
return None
|
||||
|
||||
|
||||
def landcover_path(
|
||||
points: list[PathPoint],
|
||||
landcover_path_dir: str | Path,
|
||||
canopy_path_dir: str | Path | None = None,
|
||||
) -> LandcoverPath:
|
||||
if len(points) < 2:
|
||||
raise ValueError("points must contain at least two samples")
|
||||
|
||||
try:
|
||||
landcover_values = sample_along(points, landcover_path_dir, "landcover")
|
||||
except RasterNotConfiguredError as exc:
|
||||
raise LandcoverNotConfiguredError(str(exc)) from exc
|
||||
|
||||
canopy_values = _canopy_values(points, canopy_path_dir)
|
||||
classified_points = [
|
||||
LandcoverPoint(
|
||||
distance_m=point.distance_m,
|
||||
class_name=worldcover_class(landcover_values[index]),
|
||||
forest_type=forest_type_for_class(worldcover_class(landcover_values[index])),
|
||||
canopy_height_m=_canopy_height(canopy_values[index]),
|
||||
)
|
||||
for index, point in enumerate(points)
|
||||
]
|
||||
return _segments_from_points(classified_points)
|
||||
|
||||
|
||||
def _canopy_values(points: list[PathPoint], canopy_path_dir: str | Path | None) -> np.ndarray:
|
||||
if canopy_path_dir is None:
|
||||
return np.full(len(points), np.nan, dtype=float)
|
||||
try:
|
||||
return sample_along(points, canopy_path_dir, "canopy", require_all=False)
|
||||
except RasterNotConfiguredError:
|
||||
return np.full(len(points), np.nan, dtype=float)
|
||||
|
||||
|
||||
def _canopy_height(value: float) -> float | None:
|
||||
if np.isnan(value) or value < 0:
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
||||
def _segments_from_points(points: list[LandcoverPoint]) -> LandcoverPath:
|
||||
segments: list[LandcoverPathSegment] = []
|
||||
vegetation_depth_m = 0.0
|
||||
start = points[0]
|
||||
canopy_values: list[float] = []
|
||||
|
||||
for index in range(len(points) - 1):
|
||||
current = points[index]
|
||||
next_point = points[index + 1]
|
||||
if current.canopy_height_m is not None:
|
||||
canopy_values.append(current.canopy_height_m)
|
||||
|
||||
if current.class_name in {"tree_cover", "mangroves"}:
|
||||
vegetation_depth_m += next_point.distance_m - current.distance_m
|
||||
|
||||
same_segment = (
|
||||
next_point.class_name == start.class_name
|
||||
and next_point.forest_type == start.forest_type
|
||||
)
|
||||
if not same_segment:
|
||||
segments.append(
|
||||
LandcoverPathSegment(
|
||||
from_m=start.distance_m,
|
||||
to_m=next_point.distance_m,
|
||||
class_name=start.class_name,
|
||||
forest_type=start.forest_type,
|
||||
canopy_height_m=_mean_canopy(canopy_values),
|
||||
)
|
||||
)
|
||||
start = next_point
|
||||
canopy_values = []
|
||||
|
||||
last = points[-1]
|
||||
if last.canopy_height_m is not None:
|
||||
canopy_values.append(last.canopy_height_m)
|
||||
segments.append(
|
||||
LandcoverPathSegment(
|
||||
from_m=start.distance_m,
|
||||
to_m=last.distance_m,
|
||||
class_name=start.class_name,
|
||||
forest_type=start.forest_type,
|
||||
canopy_height_m=_mean_canopy(canopy_values),
|
||||
)
|
||||
)
|
||||
|
||||
return LandcoverPath(segments=segments, vegetation_depth_m=vegetation_depth_m)
|
||||
|
||||
|
||||
def _mean_canopy(values: list[float]) -> float | None:
|
||||
if not values:
|
||||
return None
|
||||
return float(np.mean(values))
|
||||
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import rasterio
|
||||
from rasterio.crs import CRS
|
||||
from rasterio.warp import transform
|
||||
|
||||
from app.core.geo import PathPoint
|
||||
|
||||
|
||||
class RasterNotConfiguredError(NotImplementedError):
|
||||
"""Raised when raster files are missing or do not cover requested points."""
|
||||
|
||||
|
||||
def raster_files(path: str | Path) -> list[Path]:
|
||||
raster_path = Path(path)
|
||||
if not raster_path.exists():
|
||||
return []
|
||||
return sorted(
|
||||
file_path
|
||||
for pattern in ("*.tif", "*.tiff", "*.TIF", "*.TIFF")
|
||||
for file_path in raster_path.rglob(pattern)
|
||||
if file_path.is_file()
|
||||
)
|
||||
|
||||
|
||||
def point_in_bounds(x: float, y: float, bounds: object) -> bool:
|
||||
return bounds.left <= x <= bounds.right and bounds.bottom <= y <= bounds.top
|
||||
|
||||
|
||||
def to_dataset_crs(lat: float, lon: float, dst_crs: CRS | None) -> tuple[float, float]:
|
||||
if dst_crs is None or dst_crs == CRS.from_epsg(4326):
|
||||
return lon, lat
|
||||
xs, ys = transform(CRS.from_epsg(4326), dst_crs, [lon], [lat])
|
||||
return xs[0], ys[0]
|
||||
|
||||
|
||||
def sample_dataset(dataset: rasterio.io.DatasetReader, lat: float, lon: float) -> float | None:
|
||||
x, y = to_dataset_crs(lat, lon, dataset.crs)
|
||||
if not point_in_bounds(x, y, dataset.bounds):
|
||||
return None
|
||||
|
||||
value = next(dataset.sample([(x, y)], masked=True))[0]
|
||||
if np.ma.is_masked(value):
|
||||
return None
|
||||
if dataset.nodata is not None and float(value) == float(dataset.nodata):
|
||||
return None
|
||||
if not np.isfinite(value):
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
||||
def sample_at(lat: float, lon: float, path: str | Path, label: str) -> float:
|
||||
files = raster_files(path)
|
||||
if not files:
|
||||
raise RasterNotConfiguredError(f"{label} raster files are not found in {path}")
|
||||
|
||||
for file_path in files:
|
||||
with rasterio.open(file_path) as dataset:
|
||||
value = sample_dataset(dataset, lat, lon)
|
||||
if value is not None:
|
||||
return value
|
||||
|
||||
raise RasterNotConfiguredError(f"No {label} raster tile covers lat={lat}, lon={lon}")
|
||||
|
||||
|
||||
def sample_along(
|
||||
points: Sequence[PathPoint],
|
||||
path: str | Path,
|
||||
label: str,
|
||||
require_all: bool = True,
|
||||
) -> np.ndarray:
|
||||
if not points:
|
||||
return np.array([], dtype=float)
|
||||
|
||||
files = raster_files(path)
|
||||
if not files:
|
||||
raise RasterNotConfiguredError(f"{label} raster files are not found in {path}")
|
||||
|
||||
values: list[float | None] = [None] * len(points)
|
||||
remaining = set(range(len(points)))
|
||||
|
||||
for file_path in files:
|
||||
if not remaining:
|
||||
break
|
||||
with rasterio.open(file_path) as dataset:
|
||||
for index in list(remaining):
|
||||
point = points[index]
|
||||
value = sample_dataset(dataset, point.lat, point.lon)
|
||||
if value is not None:
|
||||
values[index] = value
|
||||
remaining.remove(index)
|
||||
|
||||
if remaining and require_all:
|
||||
missing = ", ".join(str(index) for index in sorted(remaining)[:10])
|
||||
raise RasterNotConfiguredError(
|
||||
f"No {label} raster tile covers {len(remaining)} point(s), "
|
||||
f"first missing indices: {missing}"
|
||||
)
|
||||
|
||||
return np.array([np.nan if value is None else float(value) for value in values], dtype=float)
|
||||
Reference in New Issue
Block a user