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

geo-engine / geoengine / 31079210407

06 Aug 2026 06:59AM UTC coverage: 87.694%. Remained the same
31079210407

push

github

web-flow
fix: AddDatasetTile model and update API handlers (#1231)

* fix:AddDatasetTile model and update API handlers

* refactor(python): replace print statements with eprint for error logging

7 of 10 new or added lines in 4 files covered. (70.0%)

1 existing line in 1 file now uncovered.

125066 of 142617 relevant lines covered (87.69%)

487507.84 hits per line

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

92.42
/python/geoengine/workflow.py
1
"""
2
A workflow representation and methods on workflows
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 vega import VegaLite
1✔
34

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

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

65

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

69

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

74

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

78

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

83

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

89

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

95

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

99

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

104

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

110

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

113

114
class WorkflowId:
1✔
115
    """
116
    A wrapper around a workflow UUID
117
    """
118

119
    __workflow_id: UUID
1✔
120

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

124
        if not isinstance(workflow_id, UUID):
1✔
125
            workflow_id = UUID(workflow_id)
1✔
126

127
        self.__workflow_id = workflow_id
1✔
128

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

136
    def __str__(self) -> str:
1✔
137
        return str(self.__workflow_id)
1✔
138

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

142
    def to_dict(self) -> UUID:
1✔
143
        return self.__workflow_id
1✔
144

145

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

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

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

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

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

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

171
        return tile
1✔
172

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

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

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

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

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

198
        return combined_tile
1✔
199

200

201
class Workflow:
1✔
202
    """
203
    Holds a workflow id and allows querying data
204
    """
205

206
    __workflow_id: WorkflowId
1✔
207
    __result_descriptor: ResultDescriptor
1✔
208

209
    def __init__(self, workflow_id: WorkflowId) -> None:
1✔
210
        self.__workflow_id = workflow_id
1✔
211
        self.__result_descriptor = self.__query_result_descriptor()
1✔
212

213
    def __str__(self) -> str:
1✔
214
        return str(self.__workflow_id)
1✔
215

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

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

224
        session = get_session()
1✔
225

226
        with geoc.ApiClient(session.configuration) as api_client:
1✔
227
            workflows_api = geoc.WorkflowsApi(api_client)
1✔
228
            response = workflows_api.get_workflow_metadata_handler(
1✔
229
                self.__workflow_id.to_dict(), _request_timeout=timeout
230
            )
231

232
        debug(response)
1✔
233

234
        return ResultDescriptor.from_response(response)
1✔
235

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

241
        return self.__result_descriptor
1✔
242

243
    def workflow_definition(self, timeout: int = 60) -> geoc.Workflow:
1✔
244
        """Return the workflow definition for this workflow"""
245

246
        session = get_session()
1✔
247

248
        with geoc.ApiClient(session.configuration) as api_client:
1✔
249
            workflows_api = geoc.WorkflowsApi(api_client)
1✔
250
            response = workflows_api.load_workflow_handler(self.__workflow_id.to_dict(), _request_timeout=timeout)
1✔
251

252
        return response
1✔
253

254
    def get_dataframe(
1✔
255
        self, bbox: QueryRectangle, timeout: int = 3600, resolve_classifications: bool = False
256
    ) -> gpd.GeoDataFrame:
257
        """
258
        Query a workflow and return the WFS result as a GeoPandas `GeoDataFrame`
259
        """
260

261
        if not self.__result_descriptor.is_vector_result():
1✔
262
            raise MethodNotCalledOnVectorException()
1✔
263

264
        session = get_session()
1✔
265

266
        with geoc.ApiClient(session.configuration) as api_client:
1✔
267
            wfs_api = geoc.OGCWFSApi(api_client)
1✔
268
            response = wfs_api.wfs_handler(
1✔
269
                workflow=self.__workflow_id.to_dict(),
270
                service=geoc.WfsService(geoc.WfsService.WFS),
271
                request=geoc.WfsRequest(geoc.WfsRequest.GETFEATURE),
272
                type_names=str(self.__workflow_id),
273
                bbox=bbox.bbox_str,
274
                version=geoc.WfsVersion(geoc.WfsVersion.ENUM_2_DOT_0_DOT_0),
275
                time=bbox.time_str,
276
                srs_name=bbox.srs,
277
                _request_timeout=timeout,
278
            )
279

280
        def geo_json_with_time_to_geopandas(geo_json):
1✔
281
            """
282
            GeoJson has no standard for time, so we parse the when field
283
            separately and attach it to the data frame as columns `start`
284
            and `end`.
285
            """
286

287
            data = gpd.GeoDataFrame.from_features(geo_json)
1✔
288
            data = data.set_crs(bbox.srs, allow_override=True)
1✔
289

290
            start = [f["when"]["start"] for f in geo_json["features"]]
1✔
291
            end = [f["when"]["end"] for f in geo_json["features"]]
1✔
292

293
            # TODO: find a good way to infer BoT/EoT
294

295
            data["start"] = gpd.pd.to_datetime(start, errors="coerce")
1✔
296
            data["end"] = gpd.pd.to_datetime(end, errors="coerce")
1✔
297

298
            return data
1✔
299

300
        def transform_classifications(data: gpd.GeoDataFrame):
1✔
301
            result_descriptor: VectorResultDescriptor = self.__result_descriptor  # type: ignore
×
302
            for column, info in result_descriptor.columns.items():
×
303
                if isinstance(info.measurement, ClassificationMeasurement):
×
304
                    measurement: ClassificationMeasurement = info.measurement
×
305
                    classes = measurement.classes
×
306
                    data[column] = data[column].apply(lambda x, classes=classes: classes[x])  # pylint: disable=cell-var-from-loop
×
307

308
            return data
×
309

310
        result = geo_json_with_time_to_geopandas(response.to_dict())
1✔
311

312
        if resolve_classifications:
1✔
313
            result = transform_classifications(result)
×
314

315
        return result
1✔
316

317
    def wms_get_map_as_image(
1✔
318
        self,
319
        bbox: QueryRectangle,
320
        raster_colorizer: RasterColorizer,
321
        # TODO: allow to use width height
322
        spatial_resolution: SpatialResolution,
323
    ) -> Image.Image:
324
        """Return the result of a WMS request as a PIL Image"""
325

326
        if not self.__result_descriptor.is_raster_result():
1✔
327
            raise MethodNotCalledOnRasterException()
×
328

329
        session = get_session()
1✔
330

331
        with geoc.ApiClient(session.configuration) as api_client:
1✔
332
            wms_api = geoc.OGCWMSApi(api_client)
1✔
333
            response = wms_api.wms_handler(
1✔
334
                workflow=self.__workflow_id.to_dict(),
335
                version=geoc.WmsVersion(geoc.WmsVersion.ENUM_1_DOT_3_DOT_0),
336
                service=geoc.WmsService(geoc.WmsService.WMS),
337
                request=geoc.WmsRequest(geoc.WmsRequest.GETMAP),
338
                width=int((bbox.spatial_bounds.xmax - bbox.spatial_bounds.xmin) / spatial_resolution.x_resolution),
339
                height=int((bbox.spatial_bounds.ymax - bbox.spatial_bounds.ymin) / spatial_resolution.y_resolution),  # pylint: disable=line-too-long
340
                bbox=bbox.bbox_ogc_str,
341
                format=geoc.WmsResponseFormat(geoc.WmsResponseFormat.IMAGE_SLASH_PNG),
342
                layers=str(self),
343
                styles="custom:" + raster_colorizer.to_api_dict().to_json(),
344
                crs=bbox.srs,
345
                time=bbox.time_str,
346
            )
347

348
        if OGCXMLError.is_ogc_error(response):
1✔
349
            raise OGCXMLError(response)
1✔
350

351
        return Image.open(BytesIO(response))
1✔
352

353
    def plot_json(
1✔
354
        self, bbox: QueryRectangle, spatial_resolution: SpatialResolution | None = None, timeout: int = 3600
355
    ) -> geoc.WrappedPlotOutput:
356
        """
357
        Query a workflow and return the plot chart result as WrappedPlotOutput
358
        """
359

360
        if not self.__result_descriptor.is_plot_result():
1✔
361
            raise MethodNotCalledOnPlotException()
×
362

363
        session = get_session()
1✔
364

365
        with geoc.ApiClient(session.configuration) as api_client:
1✔
366
            plots_api = geoc.PlotsApi(api_client)
1✔
367
            return plots_api.get_plot_handler(
1✔
368
                bbox.bbox_str,
369
                bbox.time_str,
370
                str(spatial_resolution),
371
                self.__workflow_id.to_dict(),
372
                bbox.srs,
373
                _request_timeout=timeout,
374
            )
375

376
    def plot_chart(
1✔
377
        self, bbox: QueryRectangle, spatial_resolution: SpatialResolution | None = None, timeout: int = 3600
378
    ) -> VegaLite:
379
        """
380
        Query a workflow and return the plot chart result as a vega plot
381
        """
382

383
        response = self.plot_json(bbox, spatial_resolution, timeout)
1✔
384
        vega_spec: VegaSpec = json.loads(response.data["vegaString"])
1✔
385

386
        return VegaLite(vega_spec)
1✔
387

388
    def __request_wcs(
1✔
389
        self,
390
        bbox: QueryRectangle,
391
        timeout=3600,
392
        file_format: str = "image/tiff",
393
        force_no_data_value: float | None = None,
394
        spatial_resolution: SpatialResolution | None = None,
395
    ) -> ResponseWrapper:
396
        """
397
        Query a workflow and return the coverage
398

399
        Parameters
400
        ----------
401
        bbox : A bounding box for the query
402
        timeout : HTTP request timeout in seconds
403
        file_format : The format of the returned raster
404
        force_no_data_value: If not None, use this value as no data value for the requested raster data. \
405
            Otherwise, use the Geo Engine will produce masked rasters.
406
        """
407

408
        if not self.__result_descriptor.is_raster_result():
1✔
409
            raise MethodNotCalledOnRasterException()
×
410

411
        session = get_session()
1✔
412

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

416
        wcs_url = f"{session.server_url}/wcs/{self.__workflow_id}"
1✔
417
        wcs = WebCoverageService(
1✔
418
            wcs_url,
419
            version="1.1.1",
420
            auth=Authentication(auth_delegate=session.requests_bearer_auth()),
421
        )
422

423
        resx = None
1✔
424
        resy = None
1✔
425
        if spatial_resolution is not None:
1✔
426
            [resx, resy] = spatial_resolution.resolution_ogc(bbox.srs)
1✔
427

428
        kwargs = {}
1✔
429

430
        # TODO: allow subset of bands from RasterQueryRectangle
431
        if force_no_data_value is not None:
1✔
432
            kwargs["nodatavalue"] = str(float(force_no_data_value))
1✔
433
        if resx is not None:
1✔
434
            kwargs["resx"] = str(resx)
1✔
435
        if resy is not None:
1✔
436
            kwargs["resy"] = str(resy)
1✔
437

438
        return wcs.getCoverage(
1✔
439
            identifier=f"{self.__workflow_id}",
440
            bbox=bbox.bbox_ogc,
441
            time=[bbox.time_str],
442
            format=file_format,
443
            crs=crs,
444
            timeout=timeout,
445
            **kwargs,
446
        )
447

448
    def __get_wcs_tiff_as_memory_file(
1✔
449
        self,
450
        bbox: QueryRectangle,
451
        timeout=3600,
452
        force_no_data_value: float | None = None,
453
        spatial_resolution: SpatialResolution | None = None,
454
    ) -> rasterio.io.MemoryFile:
455
        """
456
        Query a workflow and return the raster result as a memory mapped GeoTiff
457

458
        Parameters
459
        ----------
460
        bbox : A bounding box for the query
461
        timeout : HTTP request timeout in seconds
462
        force_no_data_value: If not None, use this value as no data value for the requested raster data. \
463
            Otherwise, use the Geo Engine will produce masked rasters.
464
        """
465

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

468
        # response is checked via `raise_on_error` in `getCoverage` / `openUrl`
469

470
        memory_file = rasterio.io.MemoryFile(response)
1✔
471

472
        return memory_file
1✔
473

474
    def get_array(
1✔
475
        self,
476
        bbox: QueryRectangle,
477
        spatial_resolution: SpatialResolution | None = None,
478
        timeout=3600,
479
        force_no_data_value: float | None = None,
480
    ) -> np.ndarray:
481
        """
482
        Query a workflow and return the raster result as a numpy array
483

484
        Parameters
485
        ----------
486
        bbox : A bounding box for the query
487
        timeout : HTTP request timeout in seconds
488
        force_no_data_value: If not None, use this value as no data value for the requested raster data. \
489
            Otherwise, use the Geo Engine will produce masked rasters.
490
        """
491

492
        with (
1✔
493
            self.__get_wcs_tiff_as_memory_file(bbox, timeout, force_no_data_value, spatial_resolution) as memfile,
494
            memfile.open() as dataset,
495
        ):
496
            array = dataset.read(1)
1✔
497

498
            return array
1✔
499

500
    def get_xarray(
1✔
501
        self,
502
        bbox: QueryRectangle,
503
        spatial_resolution: SpatialResolution | None = None,
504
        timeout=3600,
505
        force_no_data_value: float | None = None,
506
    ) -> xr.DataArray:
507
        """
508
        Query a workflow and return the raster result as a georeferenced xarray
509

510
        Parameters
511
        ----------
512
        bbox : A bounding box for the query
513
        timeout : HTTP request timeout in seconds
514
        force_no_data_value: If not None, use this value as no data value for the requested raster data. \
515
            Otherwise, use the Geo Engine will produce masked rasters.
516
        """
517

518
        with (
1✔
519
            self.__get_wcs_tiff_as_memory_file(bbox, timeout, force_no_data_value, spatial_resolution) as memfile,
520
            memfile.open() as dataset,
521
        ):
522
            data_array = rioxarray.open_rasterio(dataset)
1✔
523

524
            # helping mypy with inference
525
            assert isinstance(data_array, xr.DataArray)
1✔
526

527
            rio: xr.DataArray = data_array.rio
1✔
528
            rio.update_attrs(
1✔
529
                {
530
                    "crs": rio.crs,
531
                    "res": rio.resolution(),
532
                    "transform": rio.transform(),
533
                },
534
                inplace=True,
535
            )
536

537
            # TODO: add time information to dataset
538
            return data_array.load()
1✔
539

540
    # pylint: disable=too-many-arguments,too-many-positional-arguments
541
    def download_raster(
1✔
542
        self,
543
        bbox: QueryRectangle,
544
        file_path: str,
545
        timeout=3600,
546
        file_format: str = "image/tiff",
547
        force_no_data_value: float | None = None,
548
        spatial_resolution: SpatialResolution | None = None,
549
    ) -> None:
550
        """
551
        Query a workflow and save the raster result as a file on disk
552

553
        Parameters
554
        ----------
555
        bbox : A bounding box for the query
556
        file_path : The path to the file to save the raster to
557
        timeout : HTTP request timeout in seconds
558
        file_format : The format of the returned raster
559
        force_no_data_value: If not None, use this value as no data value for the requested raster data. \
560
            Otherwise, use the Geo Engine will produce masked rasters.
561
        """
562

563
        response = self.__request_wcs(bbox, timeout, file_format, force_no_data_value, spatial_resolution)
1✔
564

565
        with open(file_path, "wb") as file:
1✔
566
            file.write(response.read())
1✔
567

568
    def get_provenance(self, timeout: int = 60) -> list[ProvenanceEntry]:
1✔
569
        """
570
        Query the provenance of the workflow
571
        """
572

573
        session = get_session()
1✔
574

575
        with geoc.ApiClient(session.configuration) as api_client:
1✔
576
            workflows_api = geoc.WorkflowsApi(api_client)
1✔
577
            response = workflows_api.get_workflow_provenance_handler(
1✔
578
                self.__workflow_id.to_dict(), _request_timeout=timeout
579
            )
580

581
        return [ProvenanceEntry.from_response(item) for item in response]
1✔
582

583
    def metadata_zip(self, path: PathLike | BytesIO, timeout: int = 60) -> None:
1✔
584
        """
585
        Query workflow metadata and citations and stores it as zip file to `path`
586
        """
587

588
        session = get_session()
1✔
589

590
        with geoc.ApiClient(session.configuration) as api_client:
1✔
591
            workflows_api = geoc.WorkflowsApi(api_client)
1✔
592
            response = workflows_api.get_workflow_all_metadata_zip_handler(
1✔
593
                self.__workflow_id.to_dict(), _request_timeout=timeout
594
            )
595

596
        if isinstance(path, BytesIO):
1✔
597
            path.write(response)
1✔
598
        else:
599
            with open(path, "wb") as file:
×
600
                file.write(response)
×
601

602
    # pylint: disable=too-many-positional-arguments,too-many-positional-arguments
603
    def save_as_dataset(
1✔
604
        self,
605
        query_rectangle: QueryRectangle,
606
        name: None | str,
607
        display_name: str,
608
        description: str = "",
609
        timeout: int = 3600,
610
    ) -> Task:
611
        """Init task to store the workflow result as a layer"""
612

613
        # Currently, it only works for raster results
614
        if not self.__result_descriptor.is_raster_result():
1✔
615
            raise MethodNotCalledOnRasterException()
×
616

617
        session = get_session()
1✔
618

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

622
        qrect = geoc.models.raster_to_dataset_query_rectangle.RasterToDatasetQueryRectangle(
1✔
623
            spatial_bounds=SpatialPartition2D.from_bounding_box(query_rectangle.spatial_bounds).to_api_dict(),
624
            time_interval=query_rectangle.time.to_api_dict(),
625
        )
626

627
        with geoc.ApiClient(session.configuration) as api_client:
1✔
628
            workflows_api = geoc.WorkflowsApi(api_client)
1✔
629
            response = workflows_api.dataset_from_workflow_handler(
1✔
630
                self.__workflow_id.to_dict(),
631
                geoc.RasterDatasetFromWorkflow(
632
                    name=name, display_name=display_name, description=description, query=qrect
633
                ),
634
                _request_timeout=timeout,
635
            )
636

637
        return Task(TaskId.from_response(response))
1✔
638

639
    async def raster_stream(
1✔
640
        self,
641
        query_rectangle: QueryRectangle | RasterQueryRectangle,
642
        open_timeout: int = 60,
643
    ) -> AsyncIterator[RasterTile2D]:
644
        """Stream the workflow result as series of RasterTile2D (transformable to numpy and xarray)"""
645

646
        # Currently, it only works for raster results
647
        if not self.__result_descriptor.is_raster_result():
1✔
648
            raise MethodNotCalledOnRasterException()
×
649

650
        result_descriptor = cast(RasterResultDescriptor, self.__result_descriptor)
1✔
651

652
        if not isinstance(query_rectangle, RasterQueryRectangle):
1✔
653
            query_rectangle = query_rectangle.with_raster_bands(
1✔
654
                # TODO: all bands or first band?
655
                list(range(0, len(result_descriptor.bands)))
656
            )
657

658
        session = get_session()
1✔
659

660
        url = (
1✔
661
            req.Request(
662
                "GET",
663
                url=f"{session.server_url}/workflow/{self.__workflow_id}/rasterStream",
664
                params={
665
                    "resultType": "arrow",
666
                    "spatialBounds": query_rectangle.bbox_str,
667
                    "timeInterval": query_rectangle.time_str,
668
                    "attributes": ",".join(map(str, query_rectangle.raster_bands)),
669
                },
670
            )
671
            .prepare()
672
            .url
673
        )
674

675
        if url is None:
1✔
676
            raise InputException("Invalid websocket url")
×
677

678
        async with websockets.asyncio.client.connect(
1✔
679
            uri=self.__replace_http_with_ws(url),
680
            additional_headers=session.auth_header,
681
            open_timeout=open_timeout,
682
            max_size=None,
683
        ) as websocket:
684
            tile_bytes: bytes | None = None
1✔
685

686
            while websocket.state == websockets.protocol.State.OPEN:
1✔
687

688
                async def read_new_bytes() -> bytes | None:
1✔
689
                    # already send the next request to speed up the process
690
                    try:
1✔
691
                        await websocket.send("NEXT")
1✔
692
                    except websockets.exceptions.ConnectionClosed:
×
693
                        # the websocket connection is already closed, we cannot read anymore
694
                        return None
×
695

696
                    try:
1✔
697
                        data: str | bytes = await websocket.recv()
1✔
698

699
                        if isinstance(data, str):
1✔
700
                            # the server sent an error message
701
                            raise GeoEngineException({"error": data})
×
702

703
                        return data
1✔
704
                    except websockets.exceptions.ConnectionClosedOK:
×
705
                        # the websocket connection closed gracefully, so we stop reading
706
                        return None
×
707

708
                (tile_bytes, tile) = await asyncio.gather(
1✔
709
                    read_new_bytes(),
710
                    # asyncio.to_thread(process_bytes, tile_bytes), # TODO: use this when min Python version is 3.9
711
                    backports.to_thread(RasterStreamProcessing.process_bytes, tile_bytes),
712
                )
713

714
                if tile is not None:
1✔
715
                    yield tile
1✔
716

717
            # process the last tile
718
            tile = RasterStreamProcessing.process_bytes(tile_bytes)
1✔
719

720
            if tile is not None:
1✔
721
                yield tile
1✔
722

723
    async def raster_stream_into_xarray(
1✔
724
        self,
725
        query_rectangle: RasterQueryRectangle,
726
        clip_to_query_rectangle: bool = False,
727
        open_timeout: int = 60,
728
    ) -> xr.DataArray:
729
        """
730
        Stream the workflow result into memory and output a single xarray.
731

732
        NOTE: You can run out of memory if the query rectangle is too large.
733
        """
734

735
        tile_stream = self.raster_stream(query_rectangle, open_timeout=open_timeout)
1✔
736

737
        timestep_xarrays: list[xr.DataArray] = []
1✔
738

739
        spatial_clip_bounds = query_rectangle.spatial_bounds if clip_to_query_rectangle else None
1✔
740

741
        async def read_tiles(
1✔
742
            remainder_tile: RasterTile2D | None,
743
        ) -> tuple[list[xr.DataArray], RasterTile2D | None]:
744
            last_timestep: np.datetime64 | None = None
1✔
745
            tiles = []
1✔
746

747
            if remainder_tile is not None:
1✔
748
                last_timestep = remainder_tile.time_start_ms
1✔
749
                xr_tile = remainder_tile.to_xarray(clip_with_bounds=spatial_clip_bounds)
1✔
750
                tiles.append(xr_tile)
1✔
751

752
            async for tile in tile_stream:
1✔
753
                timestep: np.datetime64 = tile.time_start_ms
1✔
754
                if last_timestep is None:
1✔
755
                    last_timestep = timestep
1✔
756
                elif last_timestep != timestep:
1✔
757
                    return tiles, tile
1✔
758

759
                xr_tile = tile.to_xarray(clip_with_bounds=spatial_clip_bounds)
1✔
760
                tiles.append(xr_tile)
1✔
761

762
            # this seems to be the last time step, so just return tiles
763
            return tiles, None
1✔
764

765
        (tiles, remainder_tile) = await read_tiles(None)
1✔
766

767
        while len(tiles):
1✔
768
            ((new_tiles, new_remainder_tile), new_timestep_xarray) = await asyncio.gather(
1✔
769
                read_tiles(remainder_tile),
770
                backports.to_thread(RasterStreamProcessing.merge_tiles, tiles),
771
                # asyncio.to_thread(merge_tiles, tiles), # TODO: use this when min Python version is 3.9
772
            )
773

774
            tiles = new_tiles
1✔
775
            remainder_tile = new_remainder_tile
1✔
776

777
            if new_timestep_xarray is not None:
1✔
778
                timestep_xarrays.append(new_timestep_xarray)
1✔
779

780
        output: xr.DataArray = cast(
1✔
781
            xr.DataArray,
782
            # await asyncio.to_thread( # TODO: use this when min Python version is 3.9
783
            await backports.to_thread(
784
                xr.concat,
785
                # TODO: This is a typings error, since the method accepts also a `xr.DataArray` and returns one
786
                cast(list[xr.Dataset], timestep_xarrays),
787
                dim="time",
788
            ),
789
        )
790

791
        return output
1✔
792

793
    async def vector_stream(
1✔
794
        self,
795
        query_rectangle: QueryRectangle,
796
        time_start_column: str = "time_start",
797
        time_end_column: str = "time_end",
798
        open_timeout: int = 60,
799
    ) -> AsyncIterator[gpd.GeoDataFrame]:
800
        """Stream the workflow result as series of `GeoDataFrame`s"""
801

802
        def read_arrow_ipc(arrow_ipc: bytes) -> pa.RecordBatch:
1✔
803
            reader = pa.ipc.open_file(arrow_ipc)
1✔
804
            # We know from the backend that there is only one record batch
805
            record_batch = reader.get_record_batch(0)
1✔
806
            return record_batch
1✔
807

808
        def create_geo_data_frame(
1✔
809
            record_batch: pa.RecordBatch, time_start_column: str, time_end_column: str
810
        ) -> gpd.GeoDataFrame:
811
            metadata = record_batch.schema.metadata
1✔
812
            spatial_reference = metadata[b"spatialReference"].decode("utf-8")
1✔
813

814
            data_frame = record_batch.to_pandas()
1✔
815

816
            geometry = gpd.GeoSeries.from_wkt(data_frame[api.GEOMETRY_COLUMN_NAME])
1✔
817
            # delete the duplicated column
818
            del data_frame[api.GEOMETRY_COLUMN_NAME]
1✔
819

820
            geo_data_frame = gpd.GeoDataFrame(
1✔
821
                data_frame,
822
                geometry=geometry,
823
                crs=spatial_reference,
824
            )
825

826
            # split time column
827
            geo_data_frame[[time_start_column, time_end_column]] = geo_data_frame[api.TIME_COLUMN_NAME].tolist()
1✔
828
            # delete the duplicated column
829
            del geo_data_frame[api.TIME_COLUMN_NAME]
1✔
830

831
            # parse time columns
832
            for time_column in [time_start_column, time_end_column]:
1✔
833
                # TODO: use this when Python 3.11 is minimum version
834
                # geo_data_frame[time_column] = pd.to_datetime(
835
                #     geo_data_frame[time_column],
836
                #     utc=True,
837
                #     unit="ms",
838
                #     # TODO: solve time conversion problem from Geo Engine to Python for large (+/-) time instances
839
                #     errors="coerce",
840
                # )
841
                geo_data_frame[time_column] = pd.Series(
1✔
842
                    geo_data_frame[time_column].values.astype("datetime64[ms]")
843
                ).dt.tz_localize("UTC")
844

845
            return geo_data_frame
1✔
846

847
        def process_bytes(batch_bytes: bytes | None) -> gpd.GeoDataFrame | None:
1✔
848
            if batch_bytes is None:
1✔
849
                return None
1✔
850

851
            # process the received data
852
            record_batch = read_arrow_ipc(batch_bytes)
1✔
853
            tile = create_geo_data_frame(
1✔
854
                record_batch,
855
                time_start_column=time_start_column,
856
                time_end_column=time_end_column,
857
            )
858

859
            return tile
1✔
860

861
        # Currently, it only works for raster results
862
        if not self.__result_descriptor.is_vector_result():
1✔
863
            raise MethodNotCalledOnVectorException()
×
864

865
        session = get_session()
1✔
866

867
        params = {
1✔
868
            "resultType": "arrow",
869
            "spatialBounds": query_rectangle.bbox_str,
870
            "timeInterval": query_rectangle.time_str,
871
        }
872

873
        url = (
1✔
874
            req.Request("GET", url=f"{session.server_url}/workflow/{self.__workflow_id}/vectorStream", params=params)
875
            .prepare()
876
            .url
877
        )
878

879
        if url is None:
1✔
880
            raise InputException("Invalid websocket url")
×
881

882
        async with websockets.asyncio.client.connect(
1✔
883
            uri=self.__replace_http_with_ws(url),
884
            additional_headers=session.auth_header,
885
            open_timeout=open_timeout,
886
            max_size=None,  # allow arbitrary large messages, since it is capped by the server's chunk size
887
        ) as websocket:
888
            batch_bytes: bytes | None = None
1✔
889

890
            while websocket.state == websockets.protocol.State.OPEN:
1✔
891

892
                async def read_new_bytes() -> bytes | None:
1✔
893
                    # already send the next request to speed up the process
894
                    try:
1✔
895
                        await websocket.send("NEXT")
1✔
896
                    except websockets.exceptions.ConnectionClosed:
×
897
                        # the websocket connection is already closed, we cannot read anymore
898
                        return None
×
899

900
                    try:
1✔
901
                        data: str | bytes = await websocket.recv()
1✔
902

903
                        if isinstance(data, str):
1✔
904
                            # the server sent an error message
905
                            raise GeoEngineException({"error": data})
×
906

907
                        return data
1✔
908
                    except websockets.exceptions.ConnectionClosedOK:
×
909
                        # the websocket connection closed gracefully, so we stop reading
910
                        return None
×
911

912
                (batch_bytes, batch) = await asyncio.gather(
1✔
913
                    read_new_bytes(),
914
                    # asyncio.to_thread(process_bytes, batch_bytes), # TODO: use this when min Python version is 3.9
915
                    backports.to_thread(process_bytes, batch_bytes),
916
                )
917

918
                if batch is not None:
1✔
919
                    yield batch
1✔
920

921
            # process the last tile
922
            batch = process_bytes(batch_bytes)
1✔
923

924
            if batch is not None:
1✔
925
                yield batch
1✔
926

927
    async def vector_stream_into_geopandas(
1✔
928
        self,
929
        query_rectangle: QueryRectangle,
930
        time_start_column: str = "time_start",
931
        time_end_column: str = "time_end",
932
        open_timeout: int = 60,
933
    ) -> gpd.GeoDataFrame:
934
        """
935
        Stream the workflow result into memory and output a single geo data frame.
936

937
        NOTE: You can run out of memory if the query rectangle is too large.
938
        """
939

940
        chunk_stream = self.vector_stream(
1✔
941
            query_rectangle,
942
            time_start_column=time_start_column,
943
            time_end_column=time_end_column,
944
            open_timeout=open_timeout,
945
        )
946

947
        data_frame: gpd.GeoDataFrame | None = None
1✔
948
        chunk: gpd.GeoDataFrame | None = None
1✔
949

950
        async def read_dataframe() -> gpd.GeoDataFrame | None:
1✔
951
            try:
1✔
952
                return await chunk_stream.__anext__()
1✔
953
            except StopAsyncIteration:
1✔
954
                return None
1✔
955

956
        def merge_dataframes(df_a: gpd.GeoDataFrame | None, df_b: gpd.GeoDataFrame | None) -> gpd.GeoDataFrame | None:
1✔
957
            if df_a is None:
1✔
958
                return df_b
1✔
959

960
            if df_b is None:
1✔
961
                return df_a
×
962

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

965
        while True:
1✔
966
            (chunk, data_frame) = await asyncio.gather(
1✔
967
                read_dataframe(),
968
                backports.to_thread(merge_dataframes, data_frame, chunk),
969
                # TODO: use this when min Python version is 3.9
970
                # asyncio.to_thread(merge_dataframes, data_frame, chunk),
971
            )
972

973
            # we can stop when the chunk stream is exhausted
974
            if chunk is None:
1✔
975
                break
1✔
976

977
        return data_frame
1✔
978

979
    def __replace_http_with_ws(self, url: str) -> str:
1✔
980
        """
981
        Replace the protocol in the url from `http` to `ws`.
982

983
        For the websockets library, it is necessary that the url starts with `ws://`.
984
        For HTTPS, we need to use `wss://` instead.
985
        """
986

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

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

991
        return f"{ws_prefix}{url_part}"
1✔
992

993

994
def register_workflow(workflow: dict[str, Any] | WorkflowBuilderOperator, timeout: int = 60) -> Workflow:
1✔
995
    """
996
    Register a workflow in Geo Engine and receive a `WorkflowId`
997
    """
998

999
    if isinstance(workflow, WorkflowBuilderOperator):
1✔
1000
        workflow = workflow.to_workflow_dict()
1✔
1001

1002
    workflow_model = geoc.Workflow.from_dict(workflow)
1✔
1003

1004
    if workflow_model is None:
1✔
1005
        raise InputException("Invalid workflow definition")
×
1006

1007
    session = get_session()
1✔
1008

1009
    with geoc.ApiClient(session.configuration) as api_client:
1✔
1010
        workflows_api = geoc.WorkflowsApi(api_client)
1✔
1011
        response = workflows_api.register_workflow_handler(workflow_model, _request_timeout=timeout)
1✔
1012

1013
    return Workflow(WorkflowId.from_response(response))
1✔
1014

1015

1016
def workflow_by_id(workflow_id: UUID | str) -> Workflow:
1✔
1017
    """
1018
    Create a workflow object from a workflow id
1019
    """
1020

1021
    # TODO: check that workflow exists
1022

1023
    return Workflow(WorkflowId(workflow_id))
1✔
1024

1025

1026
def get_quota(user_id: UUID | None = None, timeout: int = 60) -> geoc.Quota:
1✔
1027
    """
1028
    Gets a user's quota. Only admins can get other users' quota.
1029
    """
1030

1031
    session = get_session()
1✔
1032

1033
    with geoc.ApiClient(session.configuration) as api_client:
1✔
1034
        user_api = geoc.UserApi(api_client)
1✔
1035

1036
        if user_id is None:
1✔
1037
            return user_api.quota_handler(_request_timeout=timeout)
1✔
1038

1039
        return user_api.get_user_quota_handler(user_id, _request_timeout=timeout)
1✔
1040

1041

1042
def update_quota(user_id: UUID, new_available_quota: int, timeout: int = 60) -> None:
1✔
1043
    """
1044
    Update a user's quota. Only admins can perform this operation.
1045
    """
1046

1047
    session = get_session()
1✔
1048

1049
    with geoc.ApiClient(session.configuration) as api_client:
1✔
1050
        user_api = geoc.UserApi(api_client)
1✔
1051
        user_api.update_user_quota_handler(
1✔
1052
            user_id, geoc.UpdateQuota(available=new_available_quota), _request_timeout=timeout
1053
        )
1054

1055

1056
def data_usage(offset: int = 0, limit: int = 10) -> list[geoc.DataUsage]:
1✔
1057
    """
1058
    Get data usage
1059
    """
1060

1061
    session = get_session()
1✔
1062

1063
    with geoc.ApiClient(session.configuration) as api_client:
1✔
1064
        user_api = geoc.UserApi(api_client)
1✔
1065
        response = user_api.data_usage_handler(offset=offset, limit=limit)
1✔
1066

1067
        # create dataframe from response
1068
        usage_dicts = [data_usage.model_dump(by_alias=True) for data_usage in response]
1✔
1069
        df = pd.DataFrame(usage_dicts)
1✔
1070
        if "timestamp" in df.columns:
1✔
1071
            df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
1✔
1072

1073
    return df
1✔
1074

1075

1076
def data_usage_summary(
1✔
1077
    granularity: geoc.UsageSummaryGranularity, dataset: str | None = None, offset: int = 0, limit: int = 10
1078
) -> pd.DataFrame:
1079
    """
1080
    Get data usage summary
1081
    """
1082

1083
    session = get_session()
1✔
1084

1085
    with geoc.ApiClient(session.configuration) as api_client:
1✔
1086
        user_api = geoc.UserApi(api_client)
1✔
1087
        response = user_api.data_usage_summary_handler(
1✔
1088
            dataset=dataset, granularity=granularity, offset=offset, limit=limit
1089
        )
1090

1091
        # create dataframe from response
1092
        usage_dicts = [data_usage.model_dump(by_alias=True) for data_usage in response]
1✔
1093
        df = pd.DataFrame(usage_dicts)
1✔
1094
        if "timestamp" in df.columns:
1✔
1095
            df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
1✔
1096

1097
    return df
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