Skip to content

Commit 9ee1641

Browse files
committed
Issue #459 VectorCube: basic support for filter_bands, filter_bbox, filter_labels and filter_vector
1 parent a79ec77 commit 9ee1641

File tree

6 files changed

+430
-95
lines changed

6 files changed

+430
-95
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Initial `load_geojson` support with `Connection.load_geojson()` ([#424](https://github.com/Open-EO/openeo-python-client/issues/424))
1515
- Initial `load_url` (for vector cubes) support with `Connection.load_url()` ([#424](https://github.com/Open-EO/openeo-python-client/issues/424))
1616
- Support lambda based property filtering in `Connection.load_stac()` ([#425](https://github.com/Open-EO/openeo-python-client/issues/425))
17-
17+
- `VectorCube`: initial support for `filter_bands`, `filter_bbox`, `filter_labels` and `filter_vector` ([#459](https://github.com/Open-EO/openeo-python-client/issues/459))
1818

1919
### Changed
2020

openeo/rest/_testing.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import re
2+
from typing import Union, Optional
23

3-
from openeo import Connection
4+
from openeo import Connection, DataCube
5+
from openeo.rest.vectorcube import VectorCube
46

57

68
class DummyBackend:
@@ -91,8 +93,33 @@ def get_batch_pg(self) -> dict:
9193
assert len(self.batch_jobs) == 1
9294
return self.batch_jobs[max(self.batch_jobs.keys())]["pg"]
9395

94-
def get_pg(self) -> dict:
95-
"""Get one and only batch process graph (sync or batch)"""
96+
def get_pg(self, process_id: Optional[str] = None) -> dict:
97+
"""
98+
Get one and only batch process graph (sync or batch)
99+
100+
:param process_id: just return single process graph node with this process_id
101+
:return: process graph (flat graph representation) or process graph node
102+
"""
96103
pgs = self.sync_requests + [b["pg"] for b in self.batch_jobs.values()]
97104
assert len(pgs) == 1
98-
return pgs[0]
105+
pg = pgs[0]
106+
if process_id:
107+
# Just return single node (by process_id)
108+
found = [node for node in pg.values() if node.get("process_id") == process_id]
109+
if len(found) != 1:
110+
raise RuntimeError(
111+
f"Expected single process graph node with {process_id=}, but found {len(found)}: {found}"
112+
)
113+
return found[0]
114+
return pg
115+
116+
def execute(self, cube: Union[DataCube, VectorCube], process_id: Optional[str] = None) -> dict:
117+
"""
118+
Execute given cube (synchronously) and return observed process graph (or subset thereof).
119+
120+
:param cube: cube to execute on dummy back-end
121+
:param process_id: just return single process graph node with this process_id
122+
:return: process graph (flat graph representation) or process graph node
123+
"""
124+
cube.execute()
125+
return self.get_pg(process_id=process_id)

openeo/rest/vectorcube.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import json
22
import pathlib
33
import typing
4-
from typing import List, Optional, Union
4+
from typing import List, Optional, Union, Tuple, Callable
55

66
import shapely.geometry.base
77

@@ -14,7 +14,7 @@
1414
from openeo.rest._datacube import THIS, UDF, _ProcessGraphAbstraction, build_child_callback
1515
from openeo.rest.job import BatchJob
1616
from openeo.rest.mlmodel import MlModel
17-
from openeo.util import dict_no_none, guess_format
17+
from openeo.util import dict_no_none, guess_format, crs_to_epsg_code, to_bbox_dict, InvalidBBoxException
1818

1919
if typing.TYPE_CHECKING:
2020
# Imports for type checking only (circular import issue at runtime).
@@ -327,6 +327,75 @@ def create_job(
327327

328328
send_job = legacy_alias(create_job, name="send_job", since="0.10.0")
329329

330+
@openeo_process
331+
def filter_bands(self, bands: List[str]) -> "VectorCube":
332+
"""
333+
.. versionadded:: 0.22.0
334+
"""
335+
# TODO #459 docs
336+
return self.process(
337+
process_id="filter_bands",
338+
arguments={"data": THIS, "bands": bands},
339+
)
340+
341+
@openeo_process
342+
def filter_bbox(
343+
self,
344+
*,
345+
west: Optional[float] = None,
346+
south: Optional[float] = None,
347+
east: Optional[float] = None,
348+
north: Optional[float] = None,
349+
extent: Optional[Union[dict, List[float], Tuple[float, float, float, float], Parameter]] = None,
350+
crs: Optional[int] = None,
351+
) -> "VectorCube":
352+
"""
353+
.. versionadded:: 0.22.0
354+
"""
355+
# TODO #459 docs
356+
if any(c is not None for c in [west, south, east, north]):
357+
if extent is not None:
358+
raise InvalidBBoxException("Don't specify both west/south/east/north and extent")
359+
extent = dict_no_none(west=west, south=south, east=east, north=north)
360+
361+
if isinstance(extent, Parameter):
362+
pass
363+
else:
364+
extent = to_bbox_dict(extent, crs=crs)
365+
return self.process(
366+
process_id="filter_bbox",
367+
arguments={"data": THIS, "extent": extent},
368+
)
369+
370+
@openeo_process
371+
def filter_labels(
372+
self, condition: Union[PGNode, Callable], dimension: str, context: Optional[dict] = None
373+
) -> "VectorCube":
374+
"""
375+
.. versionadded:: 0.22.0
376+
"""
377+
# TODO #459 docs
378+
condition = build_child_callback(condition, parent_parameters=["value"])
379+
return self.process(
380+
process_id="filter_labels",
381+
arguments=dict_no_none(data=THIS, condition=condition, dimension=dimension, context=context),
382+
)
383+
384+
@openeo_process
385+
def filter_vector(
386+
self, geometries: Union["VectorCube", shapely.geometry.base.BaseGeometry, dict], relation: str = "intersects"
387+
) -> "VectorCube":
388+
"""
389+
.. versionadded:: 0.22.0
390+
"""
391+
# TODO #459 docs
392+
if not isinstance(geometries, (VectorCube, Parameter)):
393+
geometries = self.load_geojson(connection=self.connection, data=geometries)
394+
return self.process(
395+
process_id="filter_vector",
396+
arguments={"data": THIS, "geometries": geometries, "relation": relation},
397+
)
398+
330399
@openeo_process
331400
def fit_class_random_forest(
332401
self,

openeo/util.py

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,10 @@ def in_interactive_mode() -> bool:
520520
return hasattr(sys, "ps1")
521521

522522

523+
class InvalidBBoxException(ValueError):
524+
pass
525+
526+
523527
class BBoxDict(dict):
524528
"""
525529
Dictionary based helper to easily create/work with bounding box dictionaries
@@ -528,50 +532,50 @@ class BBoxDict(dict):
528532
.. versionadded:: 0.10.1
529533
"""
530534

531-
def __init__(self, *, west: float, south: float, east: float, north: float, crs: Optional[str] = None):
535+
def __init__(self, *, west: float, south: float, east: float, north: float, crs: Optional[Union[str, int]] = None):
532536
super().__init__(west=west, south=south, east=east, north=north)
533537
if crs is not None:
534-
# TODO: #259, should we covert EPSG strings to int here with crs_to_epsg_code?
535-
# self.update(crs=crs_to_epsg_code(crs))
536-
self.update(crs=crs)
538+
self.update(crs=crs_to_epsg_code(crs))
537539

538540
# TODO: provide west, south, east, north, crs as @properties? Read-only or read-write?
539541

540542
@classmethod
541543
def from_any(cls, x: Any, *, crs: Optional[str] = None) -> 'BBoxDict':
542544
if isinstance(x, dict):
545+
if crs and "crs" in x and crs != x["crs"]:
546+
raise InvalidBBoxException(f"Two CRS values specified: {crs} and {x['crs']}")
543547
return cls.from_dict({"crs": crs, **x})
544548
elif isinstance(x, (list, tuple)):
545549
return cls.from_sequence(x, crs=crs)
546550
elif isinstance(x, shapely.geometry.base.BaseGeometry):
547551
return cls.from_sequence(x.bounds, crs=crs)
548552
# TODO: support other input? E.g.: WKT string, GeoJson-style dictionary (Polygon, FeatureCollection, ...)
549553
else:
550-
raise ValueError(f"Can not construct BBoxDict from {x!r}")
554+
raise InvalidBBoxException(f"Can not construct BBoxDict from {x!r}")
551555

552556
@classmethod
553557
def from_dict(cls, data: dict) -> 'BBoxDict':
554558
"""Build from dictionary with at least keys "west", "south", "east", and "north"."""
555559
expected_fields = {"west", "south", "east", "north"}
556-
# TODO: also support converting support case fields?
557-
if not all(k in data for k in expected_fields):
558-
raise ValueError(
559-
f"Expecting fields {expected_fields}, but only found {expected_fields.intersection(data.keys())}."
560-
)
561-
return cls(
562-
west=data["west"], south=data["south"], east=data["east"], north=data["north"],
563-
crs=data.get("crs")
564-
)
560+
# TODO: also support upper case fields?
561+
# TODO: optional support for parameterized bbox fields?
562+
missing = expected_fields.difference(data.keys())
563+
if missing:
564+
raise InvalidBBoxException(f"Missing bbox fields {sorted(missing)}")
565+
invalid = {k: data[k] for k in expected_fields if not isinstance(data[k], (int, float))}
566+
if invalid:
567+
raise InvalidBBoxException(f"Non-numerical bbox fields {invalid}.")
568+
return cls(west=data["west"], south=data["south"], east=data["east"], north=data["north"], crs=data.get("crs"))
565569

566570
@classmethod
567571
def from_sequence(cls, seq: Union[list, tuple], crs: Optional[str] = None) -> 'BBoxDict':
568572
"""Build from sequence of 4 bounds (west, south, east and north)."""
569573
if len(seq) != 4:
570-
raise ValueError(f"Expected sequence with 4 items, but got {len(seq)}.")
574+
raise InvalidBBoxException(f"Expected sequence with 4 items, but got {len(seq)}.")
571575
return cls(west=seq[0], south=seq[1], east=seq[2], north=seq[3], crs=crs)
572576

573577

574-
def to_bbox_dict(x: Any, *, crs: Optional[str] = None) -> BBoxDict:
578+
def to_bbox_dict(x: Any, *, crs: Optional[Union[str, int]] = None) -> BBoxDict:
575579
"""
576580
Convert given data or object to a bounding box dictionary
577581
(having keys "west", "south", "east", "north", and optionally "crs").

0 commit comments

Comments
 (0)