• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

geo-engine / geoengine / 38278279550

12 Oct 2026 03:58PM UTC coverage: 87.441% (+0.007%) from 87.434%
38278279550

push

github

web-flow
feat: rename workflow to processing graph in API (#1325)

* docs: add AGENTS.md files for project-specific guidance and update CONTRIBUTING.md and README.md to reference them

* feat: rename workflow to processing graph in API

* backend fixes

* fix: wrong layer type in API

* fix: lints

* fix: add MapService and ViewportSize imports to project.service.ts

* fix: enum destructuring

* fix: rename bands rust

* refactor: rename workflowId to processingGraphId across layers and services

* refactor(python): variable names from 'workflow' to 'processing_graph' across multiple files

* refactor(backend): update return types in project handlers to use specific response types

1586 of 1899 new or added lines in 28 files covered. (83.52%)

3 existing lines in 1 file now uncovered.

132523 of 151557 relevant lines covered (87.44%)

548375.0 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.81
/python/geoengine/processing_graph.py
1
"""
2
A processing graph representation and methods on processing graphs
3
"""
4
# pylint: disable=too-many-lines
5
# TODO: split into multiple files
6

7
from __future__ import annotations
1 ✔
8

9
import asyncio
1 ✔
10
import json
1 ✔
11
from collections import defaultdict
1 ✔
12
from collections.abc import AsyncIterator
1 ✔
13
from io import BytesIO
1 ✔
14
from logging import debug
1 ✔
15
from os import PathLike
1 ✔
16
from typing import Any, TypedDict, cast
1 ✔
17
from uuid import UUID
1 ✔
18

19
import geoengine_api_client as geoc
1 ✔
20
import geopandas as gpd
1 ✔
21
import numpy as np
1 ✔
22
import pandas as pd
1 ✔
23
import pyarrow as pa
1 ✔
24
import rasterio.io
1 ✔
25
import requests as req
1 ✔
26
import rioxarray
1 ✔
27
import websockets
1 ✔
28
import websockets.asyncio.client
1 ✔
29
import xarray as xr
1 ✔
30
from owslib.util import Authentication, ResponseWrapper
1 ✔
31
from owslib.wcs import WebCoverageService
1 ✔
32
from PIL import Image
1 ✔
33
from typing_extensions import deprecated
1 ✔
34
from vega import VegaLite
1 ✔
35

36
from geoengine import api, backports
1 ✔
37
from geoengine.auth import get_session
1 ✔
38
from geoengine.error import (
1 ✔
39
    GeoEngineException,
40
    InputException,
41
    MethodNotCalledOnPlotException,
42
    MethodNotCalledOnRasterException,
43
    MethodNotCalledOnVectorException,
44
    OGCXMLError,
45
)
46
from geoengine.processing_graph_builder.operators import Operator as ProcessingGraphBuilderOperator
1 ✔
47
from geoengine.raster import RasterTile2D
1 ✔
48
from geoengine.tasks import Task, TaskId
1 ✔
49
from geoengine.types import (
1 ✔
50
    ClassificationMeasurement,
51
    ProvenanceEntry,
52
    QueryRectangle,
53
    RasterColorizer,
54
    RasterQueryRectangle,
55
    RasterResultDescriptor,
56
    ResultDescriptor,
57
    SpatialPartition2D,
58
    SpatialResolution,
59
    VectorResultDescriptor,
60
)
61
from geoengine.util import eprint
1 ✔
62

63
# TODO: Define as recursive type when supported in mypy: https://github.com/python/mypy/issues/731
64
JsonType = dict[str, Any] | list[Any] | int | str | float | bool | type[None]
1 ✔
65

66

67
class Axis(TypedDict):
1 ✔
68
    title: str
1 ✔
69

70

71
class Bin(TypedDict):
1 ✔
72
    binned: bool
1 ✔
73
    step: float
1 ✔
74

75

76
class Field(TypedDict):
1 ✔
77
    field: str
1 ✔
78

79

80
class DatasetIds(TypedDict):
1 ✔
81
    upload: UUID
1 ✔
82
    dataset: UUID
1 ✔
83

84

85
class Values(TypedDict):
1 ✔
86
    binStart: float
1 ✔
87
    binEnd: float
1 ✔
88
    Frequency: int
1 ✔
89

90

91
class X(TypedDict):
1 ✔
92
    field: Field
1 ✔
93
    bin: Bin
1 ✔
94
    axis: Axis
1 ✔
95

96

97
class X2(TypedDict):
1 ✔
98
    field: Field
1 ✔
99

100

101
class Y(TypedDict):
1 ✔
102
    field: Field
1 ✔
103
    type: str
1 ✔
104

105

106
class Encoding(TypedDict):
1 ✔
107
    x: X
1 ✔
108
    x2: X2
1 ✔
109
    y: Y
1 ✔
110

111

112
VegaSpec = TypedDict("VegaSpec", {"$schema": str, "data": list[Values], "mark": str, "encoding": Encoding})
1 ✔
113

114

115
class ProcessingGraphId:
1 ✔
116
    """
117
    A wrapper around a processing graph UUID
118
    """
119

120
    __processing_graph_id: UUID
1 ✔
121

122
    def __init__(self, processing_graph_id: UUID | str) -> None:
1 ✔
123
        """Create a new ProcessingGraphId from an UUID or uuid as str"""
124

125
        if not isinstance(processing_graph_id, UUID):
1 ✔
126
            processing_graph_id = UUID(processing_graph_id)
1 ✔
127

128
        self.__processing_graph_id = processing_graph_id
1 ✔
129

130
    @classmethod
1 ✔
131
    def from_response(cls, response: geoc.IdResponse) -> ProcessingGraphId:
1 ✔
132
        """
133
        Create a `ProcessingGraphId` from an http response
134
        """
135
        return ProcessingGraphId(response.id)
1 ✔
136

137
    def __str__(self) -> str:
1 ✔
138
        return str(self.__processing_graph_id)
1 ✔
139

140
    def __repr__(self) -> str:
1 ✔
141
        return str(self)
1 ✔
142

143
    def to_dict(self) -> UUID:
1 ✔
144
        return self.__processing_graph_id
1 ✔
145

146

147
class RasterStreamProcessing:
1 ✔
148
    """
149
    Helper class to process raster stream data
150
    """
151

152
    @classmethod
1 ✔
153
    def read_arrow_ipc(cls, arrow_ipc: bytes) -> pa.RecordBatch:
1 ✔
154
        """Read an Arrow IPC file from a byte array"""
155

156
        reader = pa.ipc.open_file(arrow_ipc)
1 ✔
157
        # We know from the backend that there is only one record batch
158
        record_batch = reader.get_record_batch(0)
1 ✔
159
        return record_batch
1 ✔
160

161
    @classmethod
1 ✔
162
    def process_bytes(cls, tile_bytes: bytes | None) -> RasterTile2D | None:
1 ✔
163
        """Process a tile from a byte array"""
164

165
        if tile_bytes is None:
1 ✔
166
            return None
1 ✔
167

168
        # process the received data
169
        record_batch = RasterStreamProcessing.read_arrow_ipc(tile_bytes)
1 ✔
170
        tile = RasterTile2D.from_ge_record_batch(record_batch)
1 ✔
171

172
        return tile
1 ✔
173

174
    @classmethod
1 ✔
175
    def merge_tiles(cls, tiles: list[xr.DataArray]) -> xr.DataArray | None:
1 ✔
176
        """Merge a list of tiles into a single xarray"""
177

178
        if len(tiles) == 0:
1 ✔
NEW
179
            return None
×
180

181
        # group the tiles by band
182
        tiles_by_band: dict[int, list[xr.DataArray]] = defaultdict(list)
1 ✔
183
        for tile in tiles:
1 ✔
184
            band = tile.band.item()  # assuming 'band' is a coordinate with a single value
1 ✔
185
            tiles_by_band[band].append(tile)
1 ✔
186

187
        # build one spatial tile per band
188
        combined_by_band = []
1 ✔
189
        for band_tiles in tiles_by_band.values():
1 ✔
190
            combined = xr.combine_by_coords(band_tiles)
1 ✔
191
            # `combine_by_coords` always returns a `DataArray` for single variable input arrays.
192
            # This assertion verifies this for mypy
193
            assert isinstance(combined, xr.DataArray)
1 ✔
194
            combined_by_band.append(combined)
1 ✔
195

196
        # build one array with all bands and geo coordinates
197
        combined_tile = xr.concat(combined_by_band, dim="band")
1 ✔
198

199
        return combined_tile
1 ✔
200

201

202
class ProcessingGraph:
1 ✔
203
    """
204
    Holds a processing graph id and allows querying data
205
    """
206

207
    __processing_graph_id: ProcessingGraphId
1 ✔
208
    __result_descriptor: ResultDescriptor
1 ✔
209

210
    def __init__(self, processing_graph_id: ProcessingGraphId) -> None:
1 ✔
211
        self.__processing_graph_id = processing_graph_id
1 ✔
212
        self.__result_descriptor = self.__query_result_descriptor()
1 ✔
213

214
    def __str__(self) -> str:
1 ✔
215
        return str(self.__processing_graph_id)
1 ✔
216

217
    def __repr__(self) -> str:
1 ✔
218
        return repr(self.__processing_graph_id)
1 ✔
219

220
    def __query_result_descriptor(self, timeout: int = 60) -> ResultDescriptor:
1 ✔
221
        """
222
        Query the metadata of the processing graph result
223
        """
224

225
        session = get_session()
1 ✔
226

227
        with geoc.ApiClient(session.configuration) as api_client:
1 ✔
228
            processing_graphs_api = geoc.ProcessingGraphsApi(api_client)
1 ✔
229
            response = processing_graphs_api.get_processing_graph_metadata_handler(
1 ✔
230
                self.__processing_graph_id.to_dict(), _request_timeout=timeout
231
            )
232

233
        debug(response)
1 ✔
234

235
        return ResultDescriptor.from_response(response)
1 ✔
236

237
    def get_result_descriptor(self) -> ResultDescriptor:
1 ✔
238
        """
239
        Return the metadata of the processing graph result
240
        """
241

242
        return self.__result_descriptor
1 ✔
243

244
    def definition(self, timeout: int = 60) -> geoc.ProcessingGraph:
1 ✔
245
        """Return the definition of this processing graph"""
246

247
        session = get_session()
1 ✔
248

249
        with geoc.ApiClient(session.configuration) as api_client:
1 ✔
250
            processing_graphs_api = geoc.ProcessingGraphsApi(api_client)
1 ✔
251
            response = processing_graphs_api.load_processing_graph_handler(
1 ✔
252
                self.__processing_graph_id.to_dict(), _request_timeout=timeout
253
            )
254

255
        return response
1 ✔
256

257
    @deprecated("Use `ProcessingGraph.definition` instead.", category=DeprecationWarning)
1 ✔
258
    def workflow_definition(self, timeout: int = 60) -> geoc.ProcessingGraph:
1 ✔
259
        """Deprecated: use `ProcessingGraph.definition` instead."""
260
        return self.definition(timeout=timeout)
1 ✔
261

262
    def get_dataframe(
1 ✔
263
        self, bbox: QueryRectangle, timeout: int = 3600, resolve_classifications: bool = False
264
    ) -> gpd.GeoDataFrame:
265
        """
266
        Query a processing graph and return the WFS result as a GeoPandas `GeoDataFrame`
267

268
        The computation ID from the server response is attached as an attribute.
269
        Access via: `df.attrs.get('computation_id')`
270
        """
271

272
        if not self.__result_descriptor.is_vector_result():
1 ✔
273
            raise MethodNotCalledOnVectorException()
1 ✔
274

275
        session = get_session()
1 ✔
276

277
        with geoc.ApiClient(session.configuration) as api_client:
1 ✔
278
            wfs_api = geoc.OGCWFSApi(api_client)
1 ✔
279
            response = wfs_api.wfs_handler_with_http_info(
1 ✔
280
                processing_graph=self.__processing_graph_id.to_dict(),
281
                service=geoc.WfsService(geoc.WfsService.WFS),
282
                request=geoc.WfsRequest(geoc.WfsRequest.GETFEATURE),
283
                type_names=str(self.__processing_graph_id),
284
                bbox=bbox.bbox_str,
285
                version=geoc.WfsVersion(geoc.WfsVersion.ENUM_2_DOT_0_DOT_0),
286
                time=bbox.time_str,
287
                srs_name=bbox.srs,
288
                _request_timeout=timeout,
289
            )
290

291
        # Extract computation ID from response headers
292
        computation_id = None
1 ✔
293
        if response.headers:
1 ✔
294
            computation_id = response.headers.get("x-computation-id")
1 ✔
295

296
        def geo_json_with_time_to_geopandas(geo_json):
1 ✔
297
            """
298
            GeoJson has no standard for time, so we parse the when field
299
            separately and attach it to the data frame as columns `start`
300
            and `end`.
301
            """
302

303
            data = gpd.GeoDataFrame.from_features(geo_json)
1 ✔
304
            data = data.set_crs(bbox.srs, allow_override=True)
1 ✔
305

306
            start = [f["when"]["start"] for f in geo_json["features"]]
1 ✔
307
            end = [f["when"]["end"] for f in geo_json["features"]]
1 ✔
308

309
            # TODO: find a good way to infer BoT/EoT
310

311
            data["start"] = gpd.pd.to_datetime(start, errors="coerce")
1 ✔
312
            data["end"] = gpd.pd.to_datetime(end, errors="coerce")
1 ✔
313

314
            return data
1 ✔
315

316
        def transform_classifications(data: gpd.GeoDataFrame):
1 ✔
NEW
317
            result_descriptor: VectorResultDescriptor = self.__result_descriptor  # type: ignore
×
NEW
318
            for column, info in result_descriptor.columns.items():
×
NEW
319
                if isinstance(info.measurement, ClassificationMeasurement):
×
NEW
320
                    measurement: ClassificationMeasurement = info.measurement
×
NEW
321
                    classes = measurement.classes
×
NEW
322
                    data[column] = data[column].apply(lambda x, classes=classes: classes[x])  # pylint: disable=cell-var-from-loop
×
323

NEW
324
            return data
×
325

326
        result = geo_json_with_time_to_geopandas(response.data.to_dict())
1 ✔
327

328
        if resolve_classifications:
1 ✔
NEW
329
            result = transform_classifications(result)
×
330

331
        # Attach computation ID to the result
332
        result.attrs["computation_id"] = computation_id
1 ✔
333

334
        return result
1 ✔
335

336
    def wms_get_map_as_image(
1 ✔
337
        self,
338
        bbox: QueryRectangle,
339
        raster_colorizer: RasterColorizer,
340
        # TODO: allow to use width height
341
        spatial_resolution: SpatialResolution,
342
    ) -> Image.Image:
343
        """Return the result of a WMS request as a PIL Image"""
344

345
        if not self.__result_descriptor.is_raster_result():
1 ✔
NEW
346
            raise MethodNotCalledOnRasterException()
×
347

348
        session = get_session()
1 ✔
349

350
        with geoc.ApiClient(session.configuration) as api_client:
1 ✔
351
            wms_api = geoc.OGCWMSApi(api_client)
1 ✔
352
            response = wms_api.wms_handler(
1 ✔
353
                processing_graph=self.__processing_graph_id.to_dict(),
354
                version=geoc.WmsVersion(geoc.WmsVersion.ENUM_1_DOT_3_DOT_0),
355
                service=geoc.WmsService(geoc.WmsService.WMS),
356
                request=geoc.WmsRequest(geoc.WmsRequest.GETMAP),
357
                width=int((bbox.spatial_bounds.xmax - bbox.spatial_bounds.xmin) / spatial_resolution.x_resolution),
358
                height=int((bbox.spatial_bounds.ymax - bbox.spatial_bounds.ymin) / spatial_resolution.y_resolution),  # pylint: disable=line-too-long
359
                bbox=bbox.bbox_ogc_str,
360
                format=geoc.WmsResponseFormat(geoc.WmsResponseFormat.IMAGE_SLASH_PNG),
361
                layers=str(self),
362
                styles="custom:" + raster_colorizer.to_api_dict().to_json(),
363
                crs=bbox.srs,
364
                time=bbox.time_str,
365
            )
366

367
        if OGCXMLError.is_ogc_error(response):
1 ✔
368
            raise OGCXMLError(response)
1 ✔
369

370
        return Image.open(BytesIO(response))
1 ✔
371

372
    def plot_json(
1 ✔
373
        self, bbox: QueryRectangle, spatial_resolution: SpatialResolution | None = None, timeout: int = 3600
374
    ) -> geoc.WrappedPlotOutput:
375
        """
376
        Query a processing graph and return the plot chart result as WrappedPlotOutput
377
        """
378

379
        if not self.__result_descriptor.is_plot_result():
1 ✔
NEW
380
            raise MethodNotCalledOnPlotException()
×
381

382
        session = get_session()
1 ✔
383

384
        with geoc.ApiClient(session.configuration) as api_client:
1 ✔
385
            plots_api = geoc.PlotsApi(api_client)
1 ✔
386
            return plots_api.get_plot_handler(
1 ✔
387
                bbox.bbox_str,
388
                bbox.time_str,
389
                str(spatial_resolution),
390
                self.__processing_graph_id.to_dict(),
391
                bbox.srs,
392
                _request_timeout=timeout,
393
            )
394

395
    def plot_chart(
1 ✔
396
        self, bbox: QueryRectangle, spatial_resolution: SpatialResolution | None = None, timeout: int = 3600
397
    ) -> VegaLite:
398
        """
399
        Query a processing graph and return the plot chart result as a vega plot
400
        """
401

402
        response = self.plot_json(bbox, spatial_resolution, timeout)
1 ✔
403
        vega_spec: VegaSpec = json.loads(response.data["vegaString"])
1 ✔
404

405
        return VegaLite(vega_spec)
1 ✔
406

407
    def __request_wcs(
1 ✔
408
        self,
409
        bbox: QueryRectangle,
410
        timeout=3600,
411
        file_format: str = "image/tiff",
412
        force_no_data_value: float | None = None,
413
        spatial_resolution: SpatialResolution | None = None,
414
    ) -> ResponseWrapper:
415
        """
416
        Query a processing graph and return the coverage
417

418
        Parameters
419
        ----------
420
        bbox : A bounding box for the query
421
        timeout : HTTP request timeout in seconds
422
        file_format : The format of the returned raster
423
        force_no_data_value: If not None, use this value as no data value for the requested raster data. \
424
            Otherwise, use the Geo Engine will produce masked rasters.
425
        """
426

427
        if not self.__result_descriptor.is_raster_result():
1 ✔
NEW
428
            raise MethodNotCalledOnRasterException()
×
429

430
        session = get_session()
1 ✔
431

432
        # TODO: properly build CRS string for bbox
433
        crs = f"urn:ogc:def:crs:{bbox.srs.replace(':', '::')}"
1 ✔
434

435
        wcs_url = f"{session.server_url}/wcs/{self.__processing_graph_id}"
1 ✔
436
        wcs = WebCoverageService(
1 ✔
437
            wcs_url,
438
            version="1.1.1",
439
            auth=Authentication(auth_delegate=session.requests_bearer_auth()),
440
        )
441

442
        resx = None
1 ✔
443
        resy = None
1 ✔
444
        if spatial_resolution is not None:
1 ✔
445
            [resx, resy] = spatial_resolution.resolution_ogc(bbox.srs)
1 ✔
446

447
        kwargs = {}
1 ✔
448

449
        # TODO: allow subset of bands from RasterQueryRectangle
450
        if force_no_data_value is not None:
1 ✔
451
            kwargs["nodatavalue"] = str(float(force_no_data_value))
1 ✔
452
        if resx is not None:
1 ✔
453
            kwargs["resx"] = str(resx)
1 ✔
454
        if resy is not None:
1 ✔
455
            kwargs["resy"] = str(resy)
1 ✔
456

457
        return wcs.getCoverage(
1 ✔
458
            identifier=f"{self.__processing_graph_id}",
459
            bbox=bbox.bbox_ogc,
460
            time=[bbox.time_str],
461
            format=file_format,
462
            crs=crs,
463
            timeout=timeout,
464
            **kwargs,
465
        )
466

467
    def __get_wcs_tiff_as_memory_file(
1 ✔
468
        self,
469
        bbox: QueryRectangle,
470
        timeout=3600,
471
        force_no_data_value: float | None = None,
472
        spatial_resolution: SpatialResolution | None = None,
473
    ) -> rasterio.io.MemoryFile:
474
        """
475
        Query a processing graph and return the raster result as a memory mapped GeoTiff
476

477
        Parameters
478
        ----------
479
        bbox : A bounding box for the query
480
        timeout : HTTP request timeout in seconds
481
        force_no_data_value: If not None, use this value as no data value for the requested raster data. \
482
            Otherwise, use the Geo Engine will produce masked rasters.
483
        """
484

485
        response = self.__request_wcs(bbox, timeout, "image/tiff", force_no_data_value, spatial_resolution).read()
1 ✔
486

487
        # response is checked via `raise_on_error` in `getCoverage` / `openUrl`
488

489
        memory_file = rasterio.io.MemoryFile(response)
1 ✔
490

491
        return memory_file
1 ✔
492

493
    def get_array(
1 ✔
494
        self,
495
        bbox: QueryRectangle,
496
        spatial_resolution: SpatialResolution | None = None,
497
        timeout=3600,
498
        force_no_data_value: float | None = None,
499
    ) -> np.ndarray:
500
        """
501
        Query a processing graph and return the raster result as a numpy array
502

503
        Parameters
504
        ----------
505
        bbox : A bounding box for the query
506
        timeout : HTTP request timeout in seconds
507
        force_no_data_value: If not None, use this value as no data value for the requested raster data. \
508
            Otherwise, use the Geo Engine will produce masked rasters.
509
        """
510

511
        with (
1 ✔
512
            self.__get_wcs_tiff_as_memory_file(bbox, timeout, force_no_data_value, spatial_resolution) as memfile,
513
            memfile.open() as dataset,
514
        ):
515
            array = dataset.read(1)
1 ✔
516

517
            return array
1 ✔
518

519
    def get_xarray(
1 ✔
520
        self,
521
        bbox: QueryRectangle,
522
        spatial_resolution: SpatialResolution | None = None,
523
        timeout=3600,
524
        force_no_data_value: float | None = None,
525
    ) -> xr.DataArray:
526
        """
527
        Query a processing graph and return the raster result as a georeferenced xarray
528

529
        Parameters
530
        ----------
531
        bbox : A bounding box for the query
532
        timeout : HTTP request timeout in seconds
533
        force_no_data_value: If not None, use this value as no data value for the requested raster data. \
534
            Otherwise, use the Geo Engine will produce masked rasters.
535
        """
536

537
        with (
1 ✔
538
            self.__get_wcs_tiff_as_memory_file(bbox, timeout, force_no_data_value, spatial_resolution) as memfile,
539
            memfile.open() as dataset,
540
        ):
541
            data_array = rioxarray.open_rasterio(dataset)
1 ✔
542

543
            # helping mypy with inference
544
            assert isinstance(data_array, xr.DataArray)
1 ✔
545

546
            rio: xr.DataArray = data_array.rio
1 ✔
547
            rio.update_attrs(
1 ✔
548
                {
549
                    "crs": rio.crs,
550
                    "res": rio.resolution(),
551
                    "transform": rio.transform(),
552
                },
553
                inplace=True,
554
            )
555

556
            # TODO: add time information to dataset
557
            return data_array.load()
1 ✔
558

559
    # pylint: disable=too-many-arguments,too-many-positional-arguments
560
    def download_raster(
1 ✔
561
        self,
562
        bbox: QueryRectangle,
563
        file_path: str,
564
        timeout=3600,
565
        file_format: str = "image/tiff",
566
        force_no_data_value: float | None = None,
567
        spatial_resolution: SpatialResolution | None = None,
568
    ) -> None:
569
        """
570
        Query a processing graph and save the raster result as a file on disk
571

572
        Parameters
573
        ----------
574
        bbox : A bounding box for the query
575
        file_path : The path to the file to save the raster to
576
        timeout : HTTP request timeout in seconds
577
        file_format : The format of the returned raster
578
        force_no_data_value: If not None, use this value as no data value for the requested raster data. \
579
            Otherwise, use the Geo Engine will produce masked rasters.
580
        """
581

582
        response = self.__request_wcs(bbox, timeout, file_format, force_no_data_value, spatial_resolution)
1 ✔
583

584
        with open(file_path, "wb") as file:
1 ✔
585
            file.write(response.read())
1 ✔
586

587
    def get_provenance(self, timeout: int = 60) -> list[ProvenanceEntry]:
1 ✔
588
        """
589
        Query the provenance of the processing graph
590
        """
591

592
        session = get_session()
1 ✔
593

594
        with geoc.ApiClient(session.configuration) as api_client:
1 ✔
595
            processing_graphs_api = geoc.ProcessingGraphsApi(api_client)
1 ✔
596
            response = processing_graphs_api.get_processing_graph_provenance_handler(
1 ✔
597
                self.__processing_graph_id.to_dict(), _request_timeout=timeout
598
            )
599

600
        return [ProvenanceEntry.from_response(item) for item in response]
1 ✔
601

602
    def metadata_zip(self, path: PathLike | BytesIO, timeout: int = 60) -> None:
1 ✔
603
        """
604
        Query processing graph metadata and citations and stores it as zip file to `path`
605
        """
606

607
        session = get_session()
1 ✔
608

609
        with geoc.ApiClient(session.configuration) as api_client:
1 ✔
610
            processing_graphs_api = geoc.ProcessingGraphsApi(api_client)
1 ✔
611
            response = processing_graphs_api.get_processing_graph_all_metadata_zip_handler(
1 ✔
612
                self.__processing_graph_id.to_dict(), _request_timeout=timeout
613
            )
614

615
        if isinstance(path, BytesIO):
1 ✔
616
            path.write(response)
1 ✔
617
        else:
NEW
618
            with open(path, "wb") as file:
×
NEW
619
                file.write(response)
×
620

621
    # pylint: disable=too-many-positional-arguments,too-many-positional-arguments
622
    def save_as_dataset(
1 ✔
623
        self,
624
        query_rectangle: QueryRectangle,
625
        name: None | str,
626
        display_name: str,
627
        description: str = "",
628
        timeout: int = 3600,
629
    ) -> Task:
630
        """Init task to store the processing graph result as a layer"""
631

632
        # Currently, it only works for raster results
633
        if not self.__result_descriptor.is_raster_result():
1 ✔
NEW
634
            raise MethodNotCalledOnRasterException()
×
635

636
        session = get_session()
1 ✔
637

638
        if not isinstance(query_rectangle, QueryRectangle):
1 ✔
NEW
639
            eprint("save_as_dataset ignores params other then spatial and tmporal bounds.")
×
640

641
        qrect = geoc.models.raster_to_dataset_query_rectangle.RasterToDatasetQueryRectangle(
1 ✔
642
            spatial_bounds=SpatialPartition2D.from_bounding_box(query_rectangle.spatial_bounds).to_api_dict(),
643
            time_interval=query_rectangle.time.to_api_dict(),
644
        )
645

646
        with geoc.ApiClient(session.configuration) as api_client:
1 ✔
647
            processing_graphs_api = geoc.ProcessingGraphsApi(api_client)
1 ✔
648
            response = processing_graphs_api.dataset_from_processing_graph_handler(
1 ✔
649
                self.__processing_graph_id.to_dict(),
650
                geoc.RasterDatasetFromProcessingGraph(
651
                    name=name, display_name=display_name, description=description, query=qrect
652
                ),
653
                _request_timeout=timeout,
654
            )
655

656
        return Task(TaskId.from_response(response))
1 ✔
657

658
    async def raster_stream(
1 ✔
659
        self,
660
        query_rectangle: QueryRectangle | RasterQueryRectangle,
661
        open_timeout: int = 60,
662
    ) -> AsyncIterator[RasterTile2D]:
663
        """Stream the processing graph result as series of RasterTile2D (transformable to numpy and xarray)"""
664

665
        # Currently, it only works for raster results
666
        if not self.__result_descriptor.is_raster_result():
1 ✔
NEW
667
            raise MethodNotCalledOnRasterException()
×
668

669
        result_descriptor = cast(RasterResultDescriptor, self.__result_descriptor)
1 ✔
670

671
        if not isinstance(query_rectangle, RasterQueryRectangle):
1 ✔
672
            query_rectangle = query_rectangle.with_raster_bands(
1 ✔
673
                # TODO: all bands or first band?
674
                list(range(0, len(result_descriptor.bands)))
675
            )
676

677
        session = get_session()
1 ✔
678

679
        url = (
1 ✔
680
            req.Request(
681
                "GET",
682
                url=f"{session.server_url}/processingGraphs/{self.__processing_graph_id}/rasterStream",
683
                params={
684
                    "resultType": "arrow",
685
                    "spatialBounds": query_rectangle.bbox_str,
686
                    "timeInterval": query_rectangle.time_str,
687
                    "attributes": ",".join(map(str, query_rectangle.raster_bands)),
688
                },
689
            )
690
            .prepare()
691
            .url
692
        )
693

694
        if url is None:
1 ✔
NEW
695
            raise InputException("Invalid websocket url")
×
696

697
        async with websockets.asyncio.client.connect(
1 ✔
698
            uri=self.__replace_http_with_ws(url),
699
            additional_headers=session.auth_header,
700
            open_timeout=open_timeout,
701
            max_size=None,
702
        ) as websocket:
703
            tile_bytes: bytes | None = None
1 ✔
704

705
            while websocket.state == websockets.protocol.State.OPEN:
1 ✔
706

707
                async def read_new_bytes() -> bytes | None:
1 ✔
708
                    # already send the next request to speed up the process
709
                    try:
1 ✔
710
                        await websocket.send("NEXT")
1 ✔
NEW
711
                    except websockets.exceptions.ConnectionClosed:
×
712
                        # the websocket connection is already closed, we cannot read anymore
NEW
713
                        return None
×
714

715
                    try:
1 ✔
716
                        data: str | bytes = await websocket.recv()
1 ✔
717

718
                        if isinstance(data, str):
1 ✔
719
                            # the server sent an error message
NEW
720
                            raise GeoEngineException({"error": data})
×
721

722
                        return data
1 ✔
NEW
723
                    except websockets.exceptions.ConnectionClosedOK:
×
724
                        # the websocket connection closed gracefully, so we stop reading
NEW
725
                        return None
×
726

727
                (tile_bytes, tile) = await asyncio.gather(
1 ✔
728
                    read_new_bytes(),
729
                    # asyncio.to_thread(process_bytes, tile_bytes), # TODO: use this when min Python version is 3.9
730
                    backports.to_thread(RasterStreamProcessing.process_bytes, tile_bytes),
731
                )
732

733
                if tile is not None:
1 ✔
734
                    yield tile
1 ✔
735

736
            # process the last tile
737
            tile = RasterStreamProcessing.process_bytes(tile_bytes)
1 ✔
738

739
            if tile is not None:
1 ✔
740
                yield tile
1 ✔
741

742
    async def raster_stream_into_xarray(
1 ✔
743
        self,
744
        query_rectangle: RasterQueryRectangle,
745
        clip_to_query_rectangle: bool = False,
746
        open_timeout: int = 60,
747
    ) -> xr.DataArray:
748
        """
749
        Stream the processing graph result into memory and output a single xarray.
750

751
        NOTE: You can run out of memory if the query rectangle is too large.
752
        """
753

754
        tile_stream = self.raster_stream(query_rectangle, open_timeout=open_timeout)
1 ✔
755

756
        timestep_xarrays: list[xr.DataArray] = []
1 ✔
757

758
        spatial_clip_bounds = query_rectangle.spatial_bounds if clip_to_query_rectangle else None
1 ✔
759

760
        async def read_tiles(
1 ✔
761
            remainder_tile: RasterTile2D | None,
762
        ) -> tuple[list[xr.DataArray], RasterTile2D | None]:
763
            last_timestep: np.datetime64 | None = None
1 ✔
764
            tiles = []
1 ✔
765

766
            if remainder_tile is not None:
1 ✔
767
                last_timestep = remainder_tile.time_start_ms
1 ✔
768
                xr_tile = remainder_tile.to_xarray(clip_with_bounds=spatial_clip_bounds)
1 ✔
769
                tiles.append(xr_tile)
1 ✔
770

771
            async for tile in tile_stream:
1 ✔
772
                timestep: np.datetime64 = tile.time_start_ms
1 ✔
773
                if last_timestep is None:
1 ✔
774
                    last_timestep = timestep
1 ✔
775
                elif last_timestep != timestep:
1 ✔
776
                    return tiles, tile
1 ✔
777

778
                xr_tile = tile.to_xarray(clip_with_bounds=spatial_clip_bounds)
1 ✔
779
                tiles.append(xr_tile)
1 ✔
780

781
            # this seems to be the last time step, so just return tiles
782
            return tiles, None
1 ✔
783

784
        (tiles, remainder_tile) = await read_tiles(None)
1 ✔
785

786
        while len(tiles):
1 ✔
787
            ((new_tiles, new_remainder_tile), new_timestep_xarray) = await asyncio.gather(
1 ✔
788
                read_tiles(remainder_tile),
789
                backports.to_thread(RasterStreamProcessing.merge_tiles, tiles),
790
                # asyncio.to_thread(merge_tiles, tiles), # TODO: use this when min Python version is 3.9
791
            )
792

793
            tiles = new_tiles
1 ✔
794
            remainder_tile = new_remainder_tile
1 ✔
795

796
            if new_timestep_xarray is not None:
1 ✔
797
                timestep_xarrays.append(new_timestep_xarray)
1 ✔
798

799
        output: xr.DataArray = cast(
1 ✔
800
            xr.DataArray,
801
            # await asyncio.to_thread( # TODO: use this when min Python version is 3.9
802
            await backports.to_thread(
803
                xr.concat,
804
                # TODO: This is a typings error, since the method accepts also a `xr.DataArray` and returns one
805
                cast(list[xr.Dataset], timestep_xarrays),
806
                dim="time",
807
            ),
808
        )
809

810
        return output
1 ✔
811

812
    async def vector_stream(
1 ✔
813
        self,
814
        query_rectangle: QueryRectangle,
815
        time_start_column: str = "time_start",
816
        time_end_column: str = "time_end",
817
        open_timeout: int = 60,
818
    ) -> AsyncIterator[gpd.GeoDataFrame]:
819
        """Stream the processing graph result as series of `GeoDataFrame`s"""
820

821
        def read_arrow_ipc(arrow_ipc: bytes) -> pa.RecordBatch:
1 ✔
822
            reader = pa.ipc.open_file(arrow_ipc)
1 ✔
823
            # We know from the backend that there is only one record batch
824
            record_batch = reader.get_record_batch(0)
1 ✔
825
            return record_batch
1 ✔
826

827
        def create_geo_data_frame(
1 ✔
828
            record_batch: pa.RecordBatch, time_start_column: str, time_end_column: str
829
        ) -> gpd.GeoDataFrame:
830
            metadata = record_batch.schema.metadata
1 ✔
831
            spatial_reference = metadata[b"spatialReference"].decode("utf-8")
1 ✔
832

833
            data_frame = record_batch.to_pandas()
1 ✔
834

835
            geometry = gpd.GeoSeries.from_wkt(data_frame[api.GEOMETRY_COLUMN_NAME])
1 ✔
836
            # delete the duplicated column
837
            del data_frame[api.GEOMETRY_COLUMN_NAME]
1 ✔
838

839
            geo_data_frame = gpd.GeoDataFrame(
1 ✔
840
                data_frame,
841
                geometry=geometry,
842
                crs=spatial_reference,
843
            )
844

845
            # split time column
846
            geo_data_frame[[time_start_column, time_end_column]] = geo_data_frame[api.TIME_COLUMN_NAME].tolist()
1 ✔
847
            # delete the duplicated column
848
            del geo_data_frame[api.TIME_COLUMN_NAME]
1 ✔
849

850
            # parse time columns
851
            for time_column in [time_start_column, time_end_column]:
1 ✔
852
                # TODO: use this when Python 3.11 is minimum version
853
                # geo_data_frame[time_column] = pd.to_datetime(
854
                #     geo_data_frame[time_column],
855
                #     utc=True,
856
                #     unit="ms",
857
                #     # TODO: solve time conversion problem from Geo Engine to Python for large (+/-) time instances
858
                #     errors="coerce",
859
                # )
860
                geo_data_frame[time_column] = pd.Series(
1 ✔
861
                    geo_data_frame[time_column].values.astype("datetime64[ms]")
862
                ).dt.tz_localize("UTC")
863

864
            return geo_data_frame
1 ✔
865

866
        def process_bytes(batch_bytes: bytes | None) -> gpd.GeoDataFrame | None:
1 ✔
867
            if batch_bytes is None:
1 ✔
868
                return None
1 ✔
869

870
            # process the received data
871
            record_batch = read_arrow_ipc(batch_bytes)
1 ✔
872
            tile = create_geo_data_frame(
1 ✔
873
                record_batch,
874
                time_start_column=time_start_column,
875
                time_end_column=time_end_column,
876
            )
877

878
            return tile
1 ✔
879

880
        # Currently, it only works for raster results
881
        if not self.__result_descriptor.is_vector_result():
1 ✔
NEW
882
            raise MethodNotCalledOnVectorException()
×
883

884
        session = get_session()
1 ✔
885

886
        params = {
1 ✔
887
            "resultType": "arrow",
888
            "spatialBounds": query_rectangle.bbox_str,
889
            "timeInterval": query_rectangle.time_str,
890
        }
891

892
        url = (
1 ✔
893
            req.Request(
894
                "GET",
895
                url=f"{session.server_url}/processingGraphs/{self.__processing_graph_id}/vectorStream",
896
                params=params,
897
            )
898
            .prepare()
899
            .url
900
        )
901

902
        if url is None:
1 ✔
NEW
903
            raise InputException("Invalid websocket url")
×
904

905
        async with websockets.asyncio.client.connect(
1 ✔
906
            uri=self.__replace_http_with_ws(url),
907
            additional_headers=session.auth_header,
908
            open_timeout=open_timeout,
909
            max_size=None,  # allow arbitrary large messages, since it is capped by the server's chunk size
910
        ) as websocket:
911
            batch_bytes: bytes | None = None
1 ✔
912

913
            while websocket.state == websockets.protocol.State.OPEN:
1 ✔
914

915
                async def read_new_bytes() -> bytes | None:
1 ✔
916
                    # already send the next request to speed up the process
917
                    try:
1 ✔
918
                        await websocket.send("NEXT")
1 ✔
NEW
919
                    except websockets.exceptions.ConnectionClosed:
×
920
                        # the websocket connection is already closed, we cannot read anymore
NEW
921
                        return None
×
922

923
                    try:
1 ✔
924
                        data: str | bytes = await websocket.recv()
1 ✔
925

926
                        if isinstance(data, str):
1 ✔
927
                            # the server sent an error message
NEW
928
                            raise GeoEngineException({"error": data})
×
929

930
                        return data
1 ✔
NEW
931
                    except websockets.exceptions.ConnectionClosedOK:
×
932
                        # the websocket connection closed gracefully, so we stop reading
NEW
933
                        return None
×
934

935
                (batch_bytes, batch) = await asyncio.gather(
1 ✔
936
                    read_new_bytes(),
937
                    # asyncio.to_thread(process_bytes, batch_bytes), # TODO: use this when min Python version is 3.9
938
                    backports.to_thread(process_bytes, batch_bytes),
939
                )
940

941
                if batch is not None:
1 ✔
942
                    yield batch
1 ✔
943

944
            # process the last tile
945
            batch = process_bytes(batch_bytes)
1 ✔
946

947
            if batch is not None:
1 ✔
948
                yield batch
1 ✔
949

950
    async def vector_stream_into_geopandas(
1 ✔
951
        self,
952
        query_rectangle: QueryRectangle,
953
        time_start_column: str = "time_start",
954
        time_end_column: str = "time_end",
955
        open_timeout: int = 60,
956
    ) -> gpd.GeoDataFrame:
957
        """
958
        Stream the processing graph result into memory and output a single geo data frame.
959

960
        NOTE: You can run out of memory if the query rectangle is too large.
961
        """
962

963
        chunk_stream = self.vector_stream(
1 ✔
964
            query_rectangle,
965
            time_start_column=time_start_column,
966
            time_end_column=time_end_column,
967
            open_timeout=open_timeout,
968
        )
969

970
        data_frame: gpd.GeoDataFrame | None = None
1 ✔
971
        chunk: gpd.GeoDataFrame | None = None
1 ✔
972

973
        async def read_dataframe() -> gpd.GeoDataFrame | None:
1 ✔
974
            try:
1 ✔
975
                return await chunk_stream.__anext__()
1 ✔
976
            except StopAsyncIteration:
1 ✔
977
                return None
1 ✔
978

979
        def merge_dataframes(df_a: gpd.GeoDataFrame | None, df_b: gpd.GeoDataFrame | None) -> gpd.GeoDataFrame | None:
1 ✔
980
            if df_a is None:
1 ✔
981
                return df_b
1 ✔
982

983
            if df_b is None:
1 ✔
NEW
984
                return df_a
×
985

986
            return pd.concat([df_a, df_b], ignore_index=True)
1 ✔
987

988
        while True:
1 ✔
989
            (chunk, data_frame) = await asyncio.gather(
1 ✔
990
                read_dataframe(),
991
                backports.to_thread(merge_dataframes, data_frame, chunk),
992
                # TODO: use this when min Python version is 3.9
993
                # asyncio.to_thread(merge_dataframes, data_frame, chunk),
994
            )
995

996
            # we can stop when the chunk stream is exhausted
997
            if chunk is None:
1 ✔
998
                break
1 ✔
999

1000
        return data_frame
1 ✔
1001

1002
    def __replace_http_with_ws(self, url: str) -> str:
1 ✔
1003
        """
1004
        Replace the protocol in the url from `http` to `ws`.
1005

1006
        For the websockets library, it is necessary that the url starts with `ws://`.
1007
        For HTTPS, we need to use `wss://` instead.
1008
        """
1009

1010
        [protocol, url_part] = url.split("://", maxsplit=1)
1 ✔
1011

1012
        ws_prefix = "wss://" if "s" in protocol.lower() else "ws://"
1 ✔
1013

1014
        return f"{ws_prefix}{url_part}"
1 ✔
1015

1016

1017
def register_processing_graph(
1 ✔
1018
    processing_graph: dict[str, Any] | ProcessingGraphBuilderOperator, timeout: int = 60
1019
) -> ProcessingGraph:
1020
    """
1021
    Register a processing graph in Geo Engine and receive a `ProcessingGraph`
1022
    """
1023

1024
    if isinstance(processing_graph, ProcessingGraphBuilderOperator):
1 ✔
1025
        processing_graph = processing_graph.to_processing_graph_dict()
1 ✔
1026

1027
    processing_graph_model = geoc.ProcessingGraph.from_dict(processing_graph)
1 ✔
1028

1029
    if processing_graph_model is None:
1 ✔
NEW
1030
        raise InputException("Invalid processing graph definition")
×
1031

1032
    session = get_session()
1 ✔
1033

1034
    with geoc.ApiClient(session.configuration) as api_client:
1 ✔
1035
        processing_graphs_api = geoc.ProcessingGraphsApi(api_client)
1 ✔
1036
        response = processing_graphs_api.register_processing_graph_handler(
1 ✔
1037
            processing_graph_model, _request_timeout=timeout
1038
        )
1039

1040
    return ProcessingGraph(ProcessingGraphId.from_response(response))
1 ✔
1041

1042

1043
def processing_graph_by_id(processing_graph_id: UUID | str) -> ProcessingGraph:
1 ✔
1044
    """
1045
    Create a processing graph object from a processing graph id
1046
    """
1047

1048
    # TODO: check that processing graph exists
1049

1050
    return ProcessingGraph(ProcessingGraphId(processing_graph_id))
1 ✔
1051

1052

1053
@deprecated("Use `register_processing_graph` instead.", category=DeprecationWarning)
1 ✔
1054
def register_workflow(workflow: dict[str, Any] | ProcessingGraphBuilderOperator, timeout: int = 60) -> ProcessingGraph:
1 ✔
1055
    """Deprecated: use `register_processing_graph` instead."""
1056
    return register_processing_graph(workflow, timeout=timeout)
1 ✔
1057

1058

1059
@deprecated("Use `processing_graph_by_id` instead.", category=DeprecationWarning)
1 ✔
1060
def workflow_by_id(workflow_id: UUID | str) -> ProcessingGraph:
1 ✔
1061
    """Deprecated: use `processing_graph_by_id` instead."""
1062
    return processing_graph_by_id(workflow_id)
1 ✔
1063

1064

1065
def get_quota(user_id: UUID | None = None, timeout: int = 60) -> geoc.Quota:
1 ✔
1066
    """
1067
    Gets a user's quota. Only admins can get other users' quota.
1068
    """
1069

1070
    session = get_session()
1 ✔
1071

1072
    with geoc.ApiClient(session.configuration) as api_client:
1 ✔
1073
        user_api = geoc.UserApi(api_client)
1 ✔
1074

1075
        if user_id is None:
1 ✔
1076
            return user_api.quota_handler(_request_timeout=timeout)
1 ✔
1077

1078
        return user_api.get_user_quota_handler(user_id, _request_timeout=timeout)
1 ✔
1079

1080

1081
def update_quota(user_id: UUID, new_available_quota: int, timeout: int = 60) -> None:
1 ✔
1082
    """
1083
    Update a user's quota. Only admins can perform this operation.
1084
    """
1085

1086
    session = get_session()
1 ✔
1087

1088
    with geoc.ApiClient(session.configuration) as api_client:
1 ✔
1089
        user_api = geoc.UserApi(api_client)
1 ✔
1090
        user_api.update_user_quota_handler(
1 ✔
1091
            user_id, geoc.UpdateQuota(available=new_available_quota), _request_timeout=timeout
1092
        )
1093

1094

1095
def data_usage(offset: int = 0, limit: int = 10) -> list[geoc.DataUsage]:
1 ✔
1096
    """
1097
    Get data usage
1098
    """
1099

1100
    session = get_session()
1 ✔
1101

1102
    with geoc.ApiClient(session.configuration) as api_client:
1 ✔
1103
        user_api = geoc.UserApi(api_client)
1 ✔
1104
        response = user_api.data_usage_handler(offset=offset, limit=limit)
1 ✔
1105

1106
        # create dataframe from response
1107
        usage_dicts = [data_usage.model_dump(by_alias=True) for data_usage in response]
1 ✔
1108
        df = pd.DataFrame(usage_dicts)
1 ✔
1109
        if "timestamp" in df.columns:
1 ✔
1110
            df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
1 ✔
1111

1112
    return df
1 ✔
1113

1114

1115
def data_usage_summary(
1 ✔
1116
    granularity: geoc.UsageSummaryGranularity,
1117
    dataset: str | None = None,
1118
    offset: int = 0,
1119
    limit: int = 10,
1120
) -> pd.DataFrame:
1121
    """
1122
    Get data usage summary
1123
    """
1124

1125
    session = get_session()
1 ✔
1126

1127
    with geoc.ApiClient(session.configuration) as api_client:
1 ✔
1128
        user_api = geoc.UserApi(api_client)
1 ✔
1129
        response = user_api.data_usage_summary_handler(
1 ✔
1130
            dataset=dataset, granularity=granularity, offset=offset, limit=limit
1131
        )
1132

1133
        # create dataframe from response
1134
        usage_dicts = [data_usage.model_dump(by_alias=True) for data_usage in response]
1 ✔
1135
        df = pd.DataFrame(usage_dicts)
1 ✔
1136
        if "timestamp" in df.columns:
1 ✔
1137
            df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
1 ✔
1138

1139
    return df
1 ✔
1140

1141

1142
def data_usage_for_computation(computation_id: UUID) -> int:
1 ✔
1143
    """
1144
    Get data usage for a specific computation
1145
    """
1146

1147
    session = get_session()
1 ✔
1148

1149
    with geoc.ApiClient(session.configuration) as api_client:
1 ✔
1150
        user_api = geoc.UserApi(api_client)
1 ✔
1151
        response = user_api.computation_quota_handler(computation_id)
1 ✔
1152

1153
    computation_credits = 0
1 ✔
1154

1155
    for operator_quota in response:
1 ✔
1156
        computation_credits += operator_quota.count
1 ✔
1157

1158
    return computation_credits
1 ✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc