Skip to content

Commit 090efca

Browse files
committed
Issue #114/#200 support promoting feature properties to cube values
- flattening: move options to export phase iso vector cube constructor - introduce `VectorCube.from_geodataframe` wiith support for promoting selected columns to cube values - regardless of promotion: all properties are still associated with `VectorCube.geometries` for now (otherwise properties can not be preserved when using `aggregate_spatial`, see Open-EO/openeo-api#504) - only promote numerical values by default for now
1 parent 4f97c75 commit 090efca

File tree

4 files changed

+281
-70
lines changed

4 files changed

+281
-70
lines changed

openeo_driver/datacube.py

+102-22
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import io
88

99
import geopandas as gpd
10-
import numpy as np
10+
import numpy
1111
import pyproj
1212
import shapely.geometry
1313
import shapely.geometry.base
@@ -218,12 +218,13 @@ class DriverVectorCube:
218218
DIM_GEOMETRIES = "geometries"
219219
DIM_BANDS = "bands"
220220
FLATTEN_PREFIX = "vc"
221+
COLUMN_SELECTION_ALL = "all"
222+
COLUMN_SELECTION_NUMERICAL = "numerical"
221223

222224
def __init__(
223225
self,
224226
geometries: gpd.GeoDataFrame,
225227
cube: Optional[xarray.DataArray] = None,
226-
flatten_prefix: str = FLATTEN_PREFIX,
227228
):
228229
"""
229230
@@ -237,18 +238,78 @@ def __init__(
237238
log.error(f"First cube dim should be {self.DIM_GEOMETRIES!r} but got dims {cube.dims!r}")
238239
raise VectorCubeError("Cube's first dimension is invalid.")
239240
if not geometries.index.equals(cube.indexes[cube.dims[0]]):
240-
log.error(f"Invalid VectorCube components {geometries.index!r} != {cube.indexes[cube.dims[0]]!r}")
241+
log.error(f"Invalid VectorCube components {geometries.index=} != {cube.indexes[cube.dims[0]]=}")
241242
raise VectorCubeError("Incompatible vector cube components")
242243
self._geometries: gpd.GeoDataFrame = geometries
243244
self._cube = cube
244-
self._flatten_prefix = flatten_prefix
245245

246-
def with_cube(self, cube: xarray.DataArray, flatten_prefix: str = FLATTEN_PREFIX) -> "DriverVectorCube":
246+
def with_cube(self, cube: xarray.DataArray) -> "DriverVectorCube":
247247
"""Create new vector cube with same geometries but new cube"""
248248
log.info(f"Creating vector cube with new cube {cube.name!r}")
249-
return type(self)(
250-
geometries=self._geometries, cube=cube, flatten_prefix=flatten_prefix
251-
)
249+
return type(self)(geometries=self._geometries, cube=cube)
250+
251+
@classmethod
252+
def from_geodataframe(
253+
cls,
254+
data: gpd.GeoDataFrame,
255+
*,
256+
columns_for_cube: Union[List[str], str] = COLUMN_SELECTION_NUMERICAL,
257+
# TODO: change default band name to "properties" (per `load_geojson` spec introduced by https://github.com/Open-EO/openeo-processes/pull/427)
258+
dimension_name: str = DIM_BANDS,
259+
) -> "DriverVectorCube":
260+
"""
261+
Build a DriverVectorCube from given GeoPandas data frame,
262+
using the data frame geometries as vector cube geometries
263+
and other columns (as specified) as cube values along a "bands" dimension
264+
265+
:param data: geopandas data frame
266+
:param columns_for_cube: which data frame columns to use as cube values.
267+
One of:
268+
- "numerical": automatically pick numerical columns
269+
- "all": use all columns as cube values
270+
- list of column names
271+
:param dimension_name: name of the "bands" dimension
272+
:return: vector cube
273+
"""
274+
available_columns = [c for c in data.columns if c != "geometry"]
275+
276+
if columns_for_cube is None:
277+
# TODO #114: what should default selection be?
278+
columns_for_cube = cls.COLUMN_SELECTION_NUMERICAL
279+
280+
if columns_for_cube == cls.COLUMN_SELECTION_NUMERICAL:
281+
columns_for_cube = [c for c in available_columns if numpy.issubdtype(data[c].dtype, numpy.number)]
282+
elif columns_for_cube == cls.COLUMN_SELECTION_ALL:
283+
columns_for_cube = available_columns
284+
elif isinstance(columns_for_cube, list):
285+
# TODO #114 limit to subset with available columns (and automatically fill in missing columns with nodata)?
286+
columns_for_cube = columns_for_cube
287+
else:
288+
raise ValueError(columns_for_cube)
289+
assert isinstance(columns_for_cube, list)
290+
291+
if columns_for_cube:
292+
cube_df = data[columns_for_cube]
293+
# TODO: remove `columns_for_cube` from geopandas data frame?
294+
# Enabling that triggers failure of som existing tests that use `aggregate_spatial`
295+
# to "enrich" a vector cube with pre-existing properties
296+
# Also see https://github.com/Open-EO/openeo-api/issues/504
297+
# geometries_df = data.drop(columns=columns_for_cube)
298+
geometries_df = data
299+
300+
# TODO: leverage pandas `to_xarray` and xarray `to_array` instead of this manual building?
301+
cube: xarray.DataArray = xarray.DataArray(
302+
data=cube_df.values,
303+
dims=[cls.DIM_GEOMETRIES, dimension_name],
304+
coords={
305+
cls.DIM_GEOMETRIES: data.geometry.index.to_list(),
306+
dimension_name: cube_df.columns,
307+
},
308+
)
309+
return cls(geometries=geometries_df, cube=cube)
310+
311+
else:
312+
return cls(geometries=data)
252313

253314
@classmethod
254315
def from_fiona(
@@ -261,15 +322,21 @@ def from_fiona(
261322
if len(paths) != 1:
262323
# TODO #114 EP-3981: support multiple paths
263324
raise FeatureUnsupportedException(message="Loading a vector cube from multiple files is not supported")
325+
columns_for_cube = (options or {}).get("columns_for_cube", cls.COLUMN_SELECTION_NUMERICAL)
264326
# TODO #114 EP-3981: lazy loading like/with DelayedVector
265327
# note for GeoJSON: will consider Feature.id as well as Feature.properties.id
266328
if "parquet" == driver:
267-
return cls.from_parquet(paths=paths)
329+
return cls.from_parquet(paths=paths, columns_for_cube=columns_for_cube)
268330
else:
269-
return cls(geometries=gpd.read_file(paths[0], driver=driver))
331+
gdf = gpd.read_file(paths[0], driver=driver)
332+
return cls.from_geodataframe(gdf, columns_for_cube=columns_for_cube)
270333

271334
@classmethod
272-
def from_parquet(cls, paths: List[Union[str, Path]]):
335+
def from_parquet(
336+
cls,
337+
paths: List[Union[str, Path]],
338+
columns_for_cube: Union[List[str], str] = COLUMN_SELECTION_NUMERICAL,
339+
):
273340
if len(paths) != 1:
274341
# TODO #114 EP-3981: support multiple paths
275342
raise FeatureUnsupportedException(
@@ -287,10 +354,14 @@ def from_parquet(cls, paths: List[Union[str, Path]]):
287354
if "OGC:CRS84" in str(df.crs) or "WGS 84 (CRS84)" in str(df.crs):
288355
# workaround for not being able to decode ogc:crs84
289356
df.crs = CRS.from_epsg(4326)
290-
return cls(geometries=df)
357+
return cls.from_geodataframe(df, columns_for_cube=columns_for_cube)
291358

292359
@classmethod
293-
def from_geojson(cls, geojson: dict) -> "DriverVectorCube":
360+
def from_geojson(
361+
cls,
362+
geojson: dict,
363+
columns_for_cube: Union[List[str], str] = COLUMN_SELECTION_NUMERICAL,
364+
) -> "DriverVectorCube":
294365
"""Construct vector cube from GeoJson dict structure"""
295366
validate_geojson_coordinates(geojson)
296367
# TODO support more geojson types?
@@ -308,7 +379,8 @@ def from_geojson(cls, geojson: dict) -> "DriverVectorCube":
308379
raise FeatureUnsupportedException(
309380
f"Can not construct DriverVectorCube from {geojson.get('type', type(geojson))!r}"
310381
)
311-
return cls(geometries=gpd.GeoDataFrame.from_features(features))
382+
gdf = gpd.GeoDataFrame.from_features(features)
383+
return cls.from_geodataframe(gdf, columns_for_cube=columns_for_cube)
312384

313385
@classmethod
314386
def from_geometry(
@@ -323,7 +395,9 @@ def from_geometry(
323395
geometry = [geometry]
324396
return cls(geometries=gpd.GeoDataFrame(geometry=geometry))
325397

326-
def _as_geopandas_df(self) -> gpd.GeoDataFrame:
398+
def _as_geopandas_df(
399+
self, flatten_prefix: Optional[str] = None, flatten_name_joiner: str = "~"
400+
) -> gpd.GeoDataFrame:
327401
"""Join geometries and cube as a geopandas dataframe"""
328402
# TODO: avoid copy?
329403
df = self._geometries.copy(deep=True)
@@ -334,18 +408,19 @@ def _as_geopandas_df(self) -> gpd.GeoDataFrame:
334408
if self._cube.dims[1:]:
335409
stacked = self._cube.stack(prop=self._cube.dims[1:])
336410
log.info(f"Flattened cube component of vector cube to {stacked.shape[1]} properties")
411+
name_prefix = [flatten_prefix] if flatten_prefix else []
337412
for p in stacked.indexes["prop"]:
338-
name = "~".join(str(x) for x in [self._flatten_prefix] + list(p))
413+
name = flatten_name_joiner.join(str(x) for x in name_prefix + list(p))
339414
# TODO: avoid column collisions?
340415
df[name] = stacked.sel(prop=p)
341416
else:
342-
df[self._flatten_prefix] = self._cube
417+
df[flatten_prefix or self.FLATTEN_PREFIX] = self._cube
343418

344419
return df
345420

346-
def to_geojson(self) -> dict:
421+
def to_geojson(self, flatten_prefix: Optional[str] = None) -> dict:
347422
"""Export as GeoJSON FeatureCollection."""
348-
return shapely.geometry.mapping(self._as_geopandas_df())
423+
return shapely.geometry.mapping(self._as_geopandas_df(flatten_prefix=flatten_prefix))
349424

350425
def to_wkt(self) -> List[str]:
351426
wkts = [str(g) for g in self._geometries.geometry]
@@ -369,7 +444,8 @@ def write_assets(
369444
)
370445
return self.to_legacy_save_result().write_assets(directory)
371446

372-
self._as_geopandas_df().to_file(path, driver=format_info.fiona_driver)
447+
gdf = self._as_geopandas_df(flatten_prefix=options.get("flatten_prefix"))
448+
gdf.to_file(path, driver=format_info.fiona_driver)
373449

374450
if not format_info.multi_file:
375451
# single file format
@@ -464,6 +540,9 @@ def geometry_count(self) -> int:
464540
def get_geometries(self) -> Sequence[shapely.geometry.base.BaseGeometry]:
465541
return self._geometries.geometry
466542

543+
def get_cube(self) -> Optional[xarray.DataArray]:
544+
return self._cube
545+
467546
def get_ids(self) -> Optional[Sequence]:
468547
return self._geometries.get("id")
469548

@@ -474,8 +553,9 @@ def get_xarray_cube_basics(self) -> Tuple[tuple, dict]:
474553
return dims, coords
475554

476555
def __eq__(self, other):
477-
return (isinstance(other, DriverVectorCube)
478-
and np.array_equal(self._as_geopandas_df().values, other._as_geopandas_df().values))
556+
return isinstance(other, DriverVectorCube) and numpy.array_equal(
557+
self._as_geopandas_df().values, other._as_geopandas_df().values
558+
)
479559

480560
def fit_class_random_forest(
481561
self,

openeo_driver/dummy/dummy_backend.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ def assert_polygon_sequence(geometries: Union[Sequence, BaseMultipartGeometry])
265265
coords=coords,
266266
name="aggregate_spatial",
267267
)
268-
return geometries.with_cube(cube=cube, flatten_prefix="agg")
268+
return geometries.with_cube(cube=cube)
269269
elif isinstance(geometries, str):
270270
geometries = [geometry for geometry in DelayedVector(geometries).geometries]
271271
n_geometries = assert_polygon_sequence(geometries)

0 commit comments

Comments
 (0)