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

geo-engine / geoengine-python / 21037270339

15 Jan 2026 03:47PM UTC coverage: 78.884% (-0.7%) from 79.615%
21037270339

Pull #221

github

web-flow
Merge d5dad0f2b into eedc483f5
Pull Request #221: Pixel_based_queries_rewrite

3097 of 3926 relevant lines covered (78.88%)

0.79 hits per line

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

81.2
geoengine/types.py
1
# pylint: disable=too-many-lines
2

3
"""
4
Different type mappings of geo engine types
5
"""
6

7
from __future__ import annotations
1✔
8

9
from abc import abstractmethod
1✔
10
from datetime import datetime, timezone
1✔
11
from enum import Enum
1✔
12
from typing import Any, Literal, cast
1✔
13
from uuid import UUID
1✔
14

15
import geoengine_openapi_client
1✔
16
import geoengine_openapi_client.models
1✔
17
import geoengine_openapi_client.models.raster_to_dataset_query_rectangle
1✔
18
import numpy as np
1✔
19
from attr import dataclass
1✔
20

21
from geoengine.colorizer import Colorizer
1✔
22
from geoengine.error import GeoEngineException, InputException, TypeException
1✔
23

24
DEFAULT_ISO_TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%f%z"
1✔
25

26

27
class SpatialBounds:
1✔
28
    """A spatial bounds object"""
29

30
    xmin: float
1✔
31
    ymin: float
1✔
32
    xmax: float
1✔
33
    ymax: float
1✔
34

35
    def __init__(self, xmin: float, ymin: float, xmax: float, ymax: float) -> None:
1✔
36
        """Initialize a new `SpatialBounds` object"""
37
        if (xmin > xmax) or (ymin > ymax):
1✔
38
            raise InputException("Bbox: Malformed since min must be <= max")
×
39

40
        self.xmin = xmin
1✔
41
        self.ymin = ymin
1✔
42
        self.xmax = xmax
1✔
43
        self.ymax = ymax
1✔
44

45
    def as_bbox_str(self, y_axis_first=False) -> str:
1✔
46
        """
47
        A comma-separated string representation of the spatial bounds with OGC axis ordering
48
        """
49
        bbox_tuple = self.as_bbox_tuple(y_axis_first=y_axis_first)
1✔
50
        return f"{bbox_tuple[0]},{bbox_tuple[1]},{bbox_tuple[2]},{bbox_tuple[3]}"
1✔
51

52
    def as_bbox_tuple(self, y_axis_first=False) -> tuple[float, float, float, float]:
1✔
53
        """
54
        Return the bbox with OGC axis ordering of the srs
55
        """
56

57
        if y_axis_first:
1✔
58
            return (self.ymin, self.xmin, self.ymax, self.xmax)
1✔
59

60
        return (self.xmin, self.ymin, self.xmax, self.ymax)
1✔
61

62
    def x_axis_size(self) -> float:
1✔
63
        """The size of the x axis"""
64
        return self.xmax - self.xmin
×
65

66
    def y_axis_size(self) -> float:
1✔
67
        """The size of the y axis"""
68
        return self.ymax - self.ymin
×
69

70

71
class BoundingBox2D(SpatialBounds):
1✔
72
    """'A 2D bounding box."""
73

74
    def to_api_dict(self) -> geoengine_openapi_client.BoundingBox2D:
1✔
75
        return geoengine_openapi_client.BoundingBox2D(
1✔
76
            lower_left_coordinate=geoengine_openapi_client.Coordinate2D(
77
                x=self.xmin,
78
                y=self.ymin,
79
            ),
80
            upper_right_coordinate=geoengine_openapi_client.Coordinate2D(
81
                x=self.xmax,
82
                y=self.ymax,
83
            ),
84
        )
85

86
    def intersection(self, other: BoundingBox2D) -> BoundingBox2D:
1✔
87
        return BoundingBox2D(
×
88
            min(self.xmin, other.xmin),
89
            min(self.ymin, other.ymin),
90
            max(self.xmax, other.xmax),
91
            max(self.ymax, other.ymax),
92
        )
93

94
    @staticmethod
1✔
95
    def from_response(response: geoengine_openapi_client.BoundingBox2D) -> BoundingBox2D:
1✔
96
        """create a `BoundingBox2D` from an API response"""
97
        lower_left = response.lower_left_coordinate
1✔
98
        upper_right = response.upper_right_coordinate
1✔
99

100
        return BoundingBox2D(
1✔
101
            lower_left.x,
102
            lower_left.y,
103
            upper_right.x,
104
            upper_right.y,
105
        )
106

107
    def __repr__(self) -> str:
1✔
108
        return f"BoundingBox2D(xmin={self.xmin}, ymin={self.ymin}, xmax={self.xmax}, ymax={self.ymax})"
×
109

110

111
class SpatialPartition2D(SpatialBounds):
1✔
112
    """A 2D spatial partition."""
113

114
    @staticmethod
1✔
115
    def from_response(response: geoengine_openapi_client.SpatialPartition2D) -> SpatialPartition2D:
1✔
116
        """create a `SpatialPartition2D` from an API response"""
117
        upper_left = response.upper_left_coordinate
×
118
        lower_right = response.lower_right_coordinate
×
119

120
        return SpatialPartition2D(
×
121
            upper_left.x,
122
            lower_right.y,
123
            lower_right.x,
124
            upper_left.y,
125
        )
126

127
    def to_api_dict(self) -> geoengine_openapi_client.SpatialPartition2D:
1✔
128
        return geoengine_openapi_client.SpatialPartition2D(
1✔
129
            upper_left_coordinate=geoengine_openapi_client.Coordinate2D(
130
                x=self.xmin,
131
                y=self.ymax,
132
            ),
133
            lower_right_coordinate=geoengine_openapi_client.Coordinate2D(
134
                x=self.xmax,
135
                y=self.ymin,
136
            ),
137
        )
138

139
    def to_bounding_box(self) -> BoundingBox2D:
1✔
140
        """convert to a `BoundingBox2D`"""
141
        return BoundingBox2D(self.xmin, self.ymin, self.xmax, self.ymax)
1✔
142

143
    @staticmethod
1✔
144
    def from_bounding_box(bbox: BoundingBox2D) -> SpatialPartition2D:
1✔
145
        """Creates a  `SpatialPartition2D` from a `BoundingBox2D`"""
146
        return SpatialPartition2D(bbox.xmin, bbox.ymin, bbox.xmax, bbox.ymax)
1✔
147

148
    def __repr__(self) -> str:
1✔
149
        return f"SpatialPartition2D(xmin={self.xmin}, ymin={self.ymin}, xmax={self.xmax}, ymax={self.ymax})"
×
150

151

152
class TimeInterval:
1✔
153
    """'A time interval."""
154

155
    start: np.datetime64
1✔
156
    end: np.datetime64 | None
1✔
157

158
    def __init__(self, start: datetime | np.datetime64, end: datetime | np.datetime64 | None = None) -> None:
1✔
159
        """Initialize a new `TimeInterval` object"""
160

161
        if isinstance(start, np.datetime64):
1✔
162
            self.start = start
1✔
163
        elif isinstance(start, datetime):
1✔
164
            # We assume that a datetime without a timezone means UTC
165
            if start.tzinfo is not None:
1✔
166
                start = start.astimezone(tz=timezone.utc).replace(tzinfo=None)
1✔
167
            self.start = np.datetime64(start)
1✔
168
        else:
169
            raise InputException("`start` must be of type `datetime.datetime` or `numpy.datetime64`")
×
170

171
        if end is None:
1✔
172
            self.end = self.start
1✔
173
        elif isinstance(end, np.datetime64):
1✔
174
            self.end = end
1✔
175
        elif isinstance(end, datetime):
1✔
176
            # We assume that a datetime without a timezone means UTC
177
            if end.tzinfo is not None:
1✔
178
                end = end.astimezone(tz=timezone.utc).replace(tzinfo=None)
1✔
179
            self.end = np.datetime64(end)
1✔
180
        else:
181
            raise InputException("`end` must be of type `datetime.datetime` or `numpy.datetime64`")
×
182

183
        # Check validity of time interval if an `end` exists
184
        if end is not None and start > end:
1✔
185
            raise InputException("Time inverval: Start must be <= End")
1✔
186

187
    def is_instant(self) -> bool:
1✔
188
        return self.end is None or self.start == self.end
×
189

190
    @property
1✔
191
    def time_str(self) -> str:
1✔
192
        """
193
        Return the time instance or interval as a string representation
194
        """
195

196
        start_iso = TimeInterval.__datetime_to_iso_str(self.start)
1✔
197

198
        if self.end is None or self.start == self.end:
1✔
199
            return start_iso
1✔
200

201
        end_iso = TimeInterval.__datetime_to_iso_str(self.end)
1✔
202

203
        return start_iso + "/" + end_iso
1✔
204

205
    @staticmethod
1✔
206
    def from_response(response: geoengine_openapi_client.models.TimeInterval) -> TimeInterval:
1✔
207
        """create a `TimeInterval` from an API response"""
208

209
        if response.start is None:
1✔
210
            raise TypeException("TimeInterval must have a start")
×
211

212
        start = cast(int, response.start)
1✔
213
        end = None
1✔
214
        if response.end is not None:
1✔
215
            end = cast(int, response.end)
1✔
216

217
        if start == end:
1✔
218
            end = None
1✔
219

220
        return TimeInterval(
1✔
221
            np.datetime64(start, "ms"),
222
            np.datetime64(end, "ms") if end is not None else None,
223
        )
224

225
    def __repr__(self) -> str:
1✔
226
        return f"TimeInterval(start={self.start}, end={self.end})"
1✔
227

228
    def to_api_dict(self) -> geoengine_openapi_client.TimeInterval:
1✔
229
        """create a openapi `TimeInterval` from self"""
230
        start = self.start.astype("datetime64[ms]").astype(int)
1✔
231
        end = self.end.astype("datetime64[ms]").astype(int) if self.end is not None else None
1✔
232

233
        # The openapi Timeinterval does not accept end: None. So we set it to start IF self is an instant.
234
        end = end if end is not None else start
1✔
235

236
        print(self, start, end)
1✔
237

238
        return geoengine_openapi_client.TimeInterval(start=int(start), end=int(end))
1✔
239

240
    @staticmethod
1✔
241
    def __datetime_to_iso_str(timestamp: np.datetime64) -> str:
1✔
242
        return str(np.datetime_as_string(timestamp, unit="ms", timezone="UTC")).replace("Z", "+00:00")
1✔
243

244
    def __eq__(self, other: Any) -> bool:
1✔
245
        """Check if two `TimeInterval` objects are equal."""
246
        if not isinstance(other, TimeInterval):
×
247
            return False
×
248
        return self.start == other.start and self.end == other.end
×
249

250

251
class SpatialResolution:
1✔
252
    """'A spatial resolution."""
253

254
    x_resolution: float
1✔
255
    y_resolution: float
1✔
256

257
    def __init__(self, x_resolution: float, y_resolution: float) -> None:
1✔
258
        """Initialize a new `SpatialResolution` object"""
259
        if x_resolution <= 0 or y_resolution <= 0:
1✔
260
            raise InputException("Resolution: Must be positive")
×
261

262
        self.x_resolution = x_resolution
1✔
263
        self.y_resolution = y_resolution
1✔
264

265
    def to_api_dict(self) -> geoengine_openapi_client.SpatialResolution:
1✔
266
        return geoengine_openapi_client.SpatialResolution(
×
267
            x=self.x_resolution,
268
            y=self.y_resolution,
269
        )
270

271
    @staticmethod
1✔
272
    def from_response(response: geoengine_openapi_client.SpatialResolution) -> SpatialResolution:
1✔
273
        """create a `SpatialResolution` from an API response"""
274
        return SpatialResolution(x_resolution=response.x, y_resolution=response.y)
×
275

276
    def as_tuple(self) -> tuple[float, float]:
1✔
277
        return (self.x_resolution, self.y_resolution)
×
278

279
    def resolution_ogc(self, srs_code: str) -> tuple[float, float]:
1✔
280
        """
281
        Return the resolution in OGC style
282
        """
283

284
        # TODO: why is the y resolution in this case negative but not in all other cases?
285
        if srs_code == "EPSG:4326":
1✔
286
            return (-self.y_resolution, self.x_resolution)
1✔
287

288
        return self.as_tuple()
×
289

290
    def __str__(self) -> str:
1✔
291
        return str(f"{self.x_resolution},{self.y_resolution}")
1✔
292

293
    def __repr__(self) -> str:
1✔
294
        return str(f"SpatialResolution(x={self.x_resolution}, y={self.y_resolution})")
×
295

296

297
class QueryRectangle:
1✔
298
    """
299
    A multi-dimensional query rectangle, consisting of spatial and temporal information.
300
    """
301

302
    __spatial_bounds: BoundingBox2D
1✔
303
    __time_interval: TimeInterval
1✔
304
    __srs: str
1✔
305

306
    def __init__(
1✔
307
        self,
308
        spatial_bounds: BoundingBox2D | SpatialPartition2D | tuple[float, float, float, float],
309
        time_interval: TimeInterval | tuple[datetime, datetime | None],
310
        srs="EPSG:4326",
311
    ) -> None:
312
        """
313
        Initialize a new `QueryRectangle` object
314

315
        Parameters
316
        ----------
317
        spatial_bounds
318
            The spatial bounds of the query rectangle.
319
            Either a `BoundingBox2D` or a tuple of floats (xmin, ymin, xmax, ymax)
320
        time_interval
321
            The time interval of the query rectangle.
322
            Either a `TimeInterval` or a tuple of `datetime.datetime` objects (start, end)
323
        """
324

325
        if not isinstance(spatial_bounds, BoundingBox2D):
1✔
326
            if isinstance(spatial_bounds, SpatialPartition2D):
1✔
327
                spatial_bounds = spatial_bounds.to_bounding_box()
1✔
328
            else:
329
                spatial_bounds = BoundingBox2D(*spatial_bounds)
×
330
        if not isinstance(time_interval, TimeInterval):
1✔
331
            time_interval = TimeInterval(*time_interval)
×
332

333
        self.__spatial_bounds = spatial_bounds
1✔
334
        self.__time_interval = time_interval
1✔
335
        self.__srs = srs
1✔
336

337
    @property
1✔
338
    def bbox_str(self) -> str:
1✔
339
        """
340
        A comma-separated string representation of the spatial bounds
341
        """
342
        return self.__spatial_bounds.as_bbox_str()
1✔
343

344
    @property
1✔
345
    def bbox_ogc_str(self) -> str:
1✔
346
        """
347
        A comma-separated string representation of the spatial bounds with OGC axis ordering
348
        """
349
        y_axis_first = self.__srs == "EPSG:4326"
1✔
350
        return self.__spatial_bounds.as_bbox_str(y_axis_first=y_axis_first)
1✔
351

352
    @property
1✔
353
    def bbox_ogc(self) -> tuple[float, float, float, float]:
1✔
354
        """
355
        Return the bbox with OGC axis ordering of the srs
356
        """
357

358
        # TODO: properly handle axis order
359
        y_axis_first = self.__srs == "EPSG:4326"
1✔
360
        return self.__spatial_bounds.as_bbox_tuple(y_axis_first=y_axis_first)
1✔
361

362
    @property
1✔
363
    def time(self) -> TimeInterval:
1✔
364
        """
365
        Return the time instance or interval
366
        """
367
        return self.__time_interval
1✔
368

369
    @property
1✔
370
    def spatial_bounds(self) -> BoundingBox2D:
1✔
371
        """
372
        Return the spatial bounds
373
        """
374
        return self.__spatial_bounds
1✔
375

376
    @property
1✔
377
    def time_str(self) -> str:
1✔
378
        """
379
        Return the time instance or interval as a string representation
380
        """
381
        return self.time.time_str
1✔
382

383
    @property
1✔
384
    def srs(self) -> str:
1✔
385
        """
386
        Return the SRS string
387
        """
388
        return self.__srs
1✔
389

390
    def __repr__(self) -> str:
1✔
391
        """Return a string representation of the query rectangle."""
392
        r = "QueryRectangle( \n"
×
393
        r += "    " + repr(self.__spatial_bounds) + "\n"
×
394
        r += "    " + repr(self.__time_interval) + "\n"
×
395
        r += f"    srs={self.__srs} \n"
×
396
        r += ")"
×
397
        return r
×
398

399
    def with_raster_bands(self, raster_bands: list[int]) -> RasterQueryRectangle:
1✔
400
        """Converts a `QueryRectangle` into a `RasterQueryRectangle`"""
401
        return RasterQueryRectangle(self.spatial_bounds, self.time, raster_bands, self.srs)
1✔
402

403

404
class RasterQueryRectangle(QueryRectangle):
1✔
405
    """
406
    A multi-dimensional query rectangle, consisting of spatial and temporal information and raster bands.
407
    """
408

409
    __bands: list[int] = []
1✔
410

411
    def __init__(
1✔
412
        self,
413
        spatial_bounds: BoundingBox2D | SpatialPartition2D | tuple[float, float, float, float],
414
        time_interval: TimeInterval | tuple[datetime, datetime | None],
415
        raster_bands: list[int] | None | int,
416
        srs="EPSG:4326",
417
    ) -> None:
418
        """
419
        Initialize a new `QueryRectangle` object
420

421
        Parameters
422
        ----------
423
        spatial_bounds
424
            The spatial bounds of the query rectangle.
425
            Either a `BoundingBox2D` or a tuple of floats (xmin, ymin, xmax, ymax)
426
        time_interval
427
            The time interval of the query rectangle.
428
            Either a `TimeInterval` or a tuple of `datetime.datetime` objects (start, end)
429
        bands
430
            The raster bands of the query rectangle.
431
            A List of ints representing the band numbers.
432
        """
433

434
        super().__init__(spatial_bounds, time_interval, srs)
1✔
435
        if raster_bands is None:
1✔
436
            self.__bands = [0]
×
437
        elif isinstance(raster_bands, int):
1✔
438
            self.__bands = [raster_bands]
×
439
        else:
440
            self.__bands = raster_bands
1✔
441

442
    @property
1✔
443
    def raster_bands(self) -> list[int]:
1✔
444
        """
445
        Return the query bands
446
        """
447
        return self.__bands
1✔
448

449
    def __repr__(self) -> str:
1✔
450
        """Return a string representation of the query rectangle."""
451
        r = "RasterQueryRectangle( \n"
×
452
        r += "    " + repr(self.spatial_bounds) + "\n"
×
453
        r += "    " + repr(self.time) + "\n"
×
454
        r += "    " + repr(self.__bands) + "\n"
×
455
        r += f"    srs={self.srs} \n"
×
456
        r += ")"
×
457
        return r
×
458

459

460
class ResultDescriptor:  # pylint: disable=too-few-public-methods
1✔
461
    """
462
    Base class for result descriptors
463
    """
464

465
    __spatial_reference: str
1✔
466
    __time_bounds: TimeInterval | None
1✔
467

468
    def __init__(
1✔
469
        self,
470
        spatial_reference: str,
471
        time_bounds: TimeInterval | None = None,
472
    ) -> None:
473
        """Initialize a new `ResultDescriptor` object"""
474

475
        self.__spatial_reference = spatial_reference
1✔
476
        self.__time_bounds = time_bounds
1✔
477

478
    @staticmethod
1✔
479
    def from_response(response: geoengine_openapi_client.TypedResultDescriptor) -> ResultDescriptor:
1✔
480
        """
481
        Parse a result descriptor from an http response
482
        """
483

484
        inner = response.actual_instance
1✔
485

486
        if isinstance(inner, geoengine_openapi_client.TypedRasterResultDescriptor):
1✔
487
            return RasterResultDescriptor.from_response_raster(inner)
1✔
488
        if isinstance(inner, geoengine_openapi_client.TypedVectorResultDescriptor):
1✔
489
            return VectorResultDescriptor.from_response_vector(inner)
1✔
490
        if isinstance(inner, geoengine_openapi_client.TypedPlotResultDescriptor):
1✔
491
            return PlotResultDescriptor.from_response_plot(inner)
1✔
492

493
        raise TypeException("Unknown `ResultDescriptor` type")
×
494

495
    @classmethod
1✔
496
    def is_raster_result(cls) -> bool:
1✔
497
        """
498
        Return true if the result is of type raster
499
        """
500
        return False
×
501

502
    @classmethod
1✔
503
    def is_vector_result(cls) -> bool:
1✔
504
        """
505
        Return true if the result is of type vector
506
        """
507
        return False
1✔
508

509
    @classmethod
1✔
510
    def is_plot_result(cls) -> bool:
1✔
511
        """
512
        Return true if the result is of type plot
513
        """
514

515
        return False
×
516

517
    @property
1✔
518
    def spatial_reference(self) -> str:
1✔
519
        """Return the spatial reference"""
520

521
        return self.__spatial_reference
1✔
522

523
    @property
1✔
524
    def time_bounds(self) -> TimeInterval | None:
1✔
525
        """Return the time bounds"""
526

527
        return self.__time_bounds
1✔
528

529
    @abstractmethod
1✔
530
    def to_api_dict(self) -> geoengine_openapi_client.TypedResultDescriptor:
1✔
531
        pass
×
532

533

534
class VectorResultDescriptor(ResultDescriptor):
1✔
535
    """
536
    A vector result descriptor
537
    """
538

539
    __spatial_bounds: BoundingBox2D | None
1✔
540
    __data_type: VectorDataType
1✔
541
    __columns: dict[str, VectorColumnInfo]
1✔
542

543
    def __init__(  # pylint: disable=too-many-arguments,too-many-positional-arguments
1✔
544
        self,
545
        spatial_reference: str,
546
        data_type: VectorDataType,
547
        columns: dict[str, VectorColumnInfo],
548
        time_bounds: TimeInterval | None = None,
549
        spatial_bounds: BoundingBox2D | None = None,
550
    ) -> None:
551
        """Initialize a vector result descriptor"""
552
        super().__init__(spatial_reference, time_bounds)
1✔
553
        self.__data_type = data_type
1✔
554
        self.__columns = columns
1✔
555
        self.__spatial_bounds = spatial_bounds
1✔
556

557
    @staticmethod
1✔
558
    def from_response_vector(response: geoengine_openapi_client.TypedVectorResultDescriptor) -> VectorResultDescriptor:
1✔
559
        """Parse a vector result descriptor from an http response"""
560
        sref = response.spatial_reference
1✔
561
        data_type = VectorDataType.from_string(response.data_type)
1✔
562
        columns = {name: VectorColumnInfo.from_response(info) for name, info in response.columns.items()}
1✔
563

564
        time_bounds = None
1✔
565
        if response.time is not None:
1✔
566
            time_bounds = TimeInterval.from_response(response.time)
1✔
567
        spatial_bounds = None
1✔
568
        if response.bbox is not None:
1✔
569
            spatial_bounds = BoundingBox2D.from_response(response.bbox)
1✔
570

571
        return VectorResultDescriptor(sref, data_type, columns, time_bounds, spatial_bounds)
1✔
572

573
    @classmethod
1✔
574
    def is_vector_result(cls) -> bool:
1✔
575
        return True
1✔
576

577
    @property
1✔
578
    def data_type(self) -> VectorDataType:
1✔
579
        """Return the data type"""
580
        return self.__data_type
1✔
581

582
    @property
1✔
583
    def spatial_reference(self) -> str:
1✔
584
        """Return the spatial reference"""
585
        return super().spatial_reference
1✔
586

587
    @property
1✔
588
    def columns(self) -> dict[str, VectorColumnInfo]:
1✔
589
        """Return the columns"""
590

591
        return self.__columns
1✔
592

593
    @property
1✔
594
    def spatial_bounds(self) -> BoundingBox2D | None:
1✔
595
        """Return the spatial bounds"""
596
        return self.__spatial_bounds
1✔
597

598
    def __repr__(self) -> str:
1✔
599
        """Display representation of the vector result descriptor"""
600
        r = ""
1✔
601
        r += f"Data type:         {self.data_type.value}\n"
1✔
602
        r += f"Spatial Reference: {self.spatial_reference}\n"
1✔
603

604
        r += "Columns:\n"
1✔
605
        for column_name in self.columns:
1✔
606
            column_info = self.columns[column_name]
1✔
607
            r += f"  {column_name}:\n"
1✔
608
            r += f"    Column Type: {column_info.data_type.value}\n"
1✔
609
            r += f"    Measurement: {column_info.measurement}\n"
1✔
610

611
        return r
1✔
612

613
    def to_api_dict(self) -> geoengine_openapi_client.TypedResultDescriptor:
1✔
614
        """Convert the vector result descriptor to a dictionary"""
615

616
        return geoengine_openapi_client.TypedResultDescriptor(
1✔
617
            geoengine_openapi_client.TypedVectorResultDescriptor(
618
                type="vector",
619
                data_type=self.data_type.to_api_enum(),
620
                spatial_reference=self.spatial_reference,
621
                columns={name: column_info.to_api_dict() for name, column_info in self.columns.items()},
622
                time=self.time_bounds.to_api_dict() if self.time_bounds is not None else None,
623
                bbox=self.spatial_bounds.to_api_dict() if self.spatial_bounds is not None else None,
624
            )
625
        )
626

627

628
class FeatureDataType(str, Enum):
1✔
629
    """Vector column data type"""
630

631
    CATEGORY = "category"
1✔
632
    INT = "int"
1✔
633
    FLOAT = "float"
1✔
634
    TEXT = "text"
1✔
635
    BOOL = "bool"
1✔
636
    DATETIME = "dateTime"
1✔
637

638
    @staticmethod
1✔
639
    def from_string(data_type: str) -> FeatureDataType:
1✔
640
        """Create a new `VectorColumnDataType` from a string"""
641

642
        return FeatureDataType(data_type)
1✔
643

644
    def to_api_enum(self) -> geoengine_openapi_client.FeatureDataType:
1✔
645
        """Convert to an API enum"""
646

647
        return geoengine_openapi_client.FeatureDataType(self.value)
1✔
648

649

650
@dataclass
1✔
651
class VectorColumnInfo:
1✔
652
    """Vector column information"""
653

654
    data_type: FeatureDataType
1✔
655
    measurement: Measurement
1✔
656

657
    @staticmethod
1✔
658
    def from_response(response: geoengine_openapi_client.VectorColumnInfo) -> VectorColumnInfo:
1✔
659
        """Create a new `VectorColumnInfo` from a JSON response"""
660

661
        return VectorColumnInfo(
1✔
662
            FeatureDataType.from_string(data_type=response.data_type), Measurement.from_response(response.measurement)
663
        )
664

665
    def to_api_dict(self) -> geoengine_openapi_client.VectorColumnInfo:
1✔
666
        """Convert to a dictionary"""
667

668
        return geoengine_openapi_client.VectorColumnInfo(
1✔
669
            data_type=self.data_type.to_api_enum(),
670
            measurement=self.measurement.to_api_dict(),
671
        )
672

673

674
@dataclass(repr=False)
1✔
675
class RasterBandDescriptor:
1✔
676
    """A raster band descriptor"""
677

678
    name: str
1✔
679
    measurement: Measurement
1✔
680

681
    @classmethod
1✔
682
    def from_response(cls, response: geoengine_openapi_client.RasterBandDescriptor) -> RasterBandDescriptor:
1✔
683
        """Parse an http response to a `RasterBandDescriptor` object"""
684
        return RasterBandDescriptor(response.name, Measurement.from_response(response.measurement))
1✔
685

686
    def to_api_dict(self) -> geoengine_openapi_client.RasterBandDescriptor:
1✔
687
        return geoengine_openapi_client.RasterBandDescriptor(
1✔
688
            name=self.name,
689
            measurement=self.measurement.to_api_dict(),
690
        )
691

692
    def __repr__(self) -> str:
1✔
693
        """Display representation of a raster band descriptor"""
694
        return f"{self.name}: {self.measurement}"
×
695

696

697
@dataclass
1✔
698
class GridIdx2D:
1✔
699
    """A grid index"""
700

701
    x_idx: int
1✔
702
    y_idx: int
1✔
703

704
    @classmethod
1✔
705
    def from_response(cls, response: geoengine_openapi_client.GridIdx2D) -> GridIdx2D:
1✔
706
        """Parse an http response to a `GridIdx2D` object"""
707
        return GridIdx2D(x_idx=response.x_idx, y_idx=response.y_idx)
1✔
708

709
    def to_api_dict(self) -> geoengine_openapi_client.GridIdx2D:
1✔
710
        return geoengine_openapi_client.GridIdx2D(y_idx=self.y_idx, x_idx=self.x_idx)
1✔
711

712

713
@dataclass
1✔
714
class GridBoundingBox2D:
1✔
715
    """A grid boundingbox where lower right is inclusive index"""
716

717
    top_left_idx: GridIdx2D
1✔
718
    bottom_right_idx: GridIdx2D
1✔
719

720
    @classmethod
1✔
721
    def from_response(cls, response: geoengine_openapi_client.GridBoundingBox2D) -> GridBoundingBox2D:
1✔
722
        """Parse an http response to a `GridBoundingBox2D` object"""
723
        ul_idx = GridIdx2D.from_response(response.top_left_idx)
1✔
724
        lr_idx = GridIdx2D.from_response(response.bottom_right_idx)
1✔
725
        return GridBoundingBox2D(top_left_idx=ul_idx, bottom_right_idx=lr_idx)
1✔
726

727
    def to_api_dict(self) -> geoengine_openapi_client.GridBoundingBox2D:
1✔
728
        return geoengine_openapi_client.GridBoundingBox2D(
1✔
729
            top_left_idx=self.top_left_idx.to_api_dict(),
730
            bottom_right_idx=self.bottom_right_idx.to_api_dict(),
731
        )
732

733
    @property
1✔
734
    def width(self) -> int:
1✔
735
        return abs(self.bottom_right_idx.x_idx - self.top_left_idx.x_idx)
×
736

737
    @property
1✔
738
    def height(self) -> int:
1✔
739
        return abs(self.top_left_idx.y_idx - self.bottom_right_idx.y_idx)
×
740

741
    def contains_idx(self, idx: GridIdx2D) -> bool:
1✔
742
        """Test if a `GridIdx2D` is contained by this"""
743
        contains_x = self.top_left_idx.x_idx <= idx.x_idx <= self.bottom_right_idx.x_idx
×
744
        contains_y = self.top_left_idx.y_idx <= idx.y_idx <= self.bottom_right_idx.y_idx
×
745
        return contains_x and contains_y
×
746

747

748
@dataclass
1✔
749
class SpatialGridDefinition:
1✔
750
    """A grid boundingbox where lower right is inclusive index"""
751

752
    geo_transform: GeoTransform
1✔
753
    grid_bounds: GridBoundingBox2D
1✔
754

755
    @classmethod
1✔
756
    def from_response(cls, response: geoengine_openapi_client.SpatialGridDefinition) -> SpatialGridDefinition:
1✔
757
        """Parse an http response to a `SpatialGridDefinition` object"""
758
        geo_transform = GeoTransform.from_response(response.geo_transform)
1✔
759
        grid_bounds = GridBoundingBox2D.from_response(response.grid_bounds)
1✔
760
        return SpatialGridDefinition(geo_transform=geo_transform, grid_bounds=grid_bounds)
1✔
761

762
    def to_api_dict(self) -> geoengine_openapi_client.SpatialGridDefinition:
1✔
763
        return geoengine_openapi_client.SpatialGridDefinition(
1✔
764
            geo_transform=self.geo_transform.to_api_dict(),
765
            grid_bounds=self.grid_bounds.to_api_dict(),
766
        )
767

768
    def contains_idx(self, idx: GridIdx2D) -> bool:
1✔
769
        return self.grid_bounds.contains_idx(idx)
×
770

771
    def spatial_bounds(self) -> SpatialPartition2D:
1✔
772
        return self.geo_transform.grid_bounds_to_spatial_bounds(self.grid_bounds)
×
773

774
    def spatial_resolution(self) -> SpatialResolution:
1✔
775
        return self.geo_transform.spatial_resolution()
×
776

777
    def __repr__(self) -> str:
1✔
778
        """Display representation of the SpatialGridDefinition"""
779
        r = "SpatialGridDefinition: \n"
×
780
        r += f"    GeoTransform: {self.geo_transform}\n"
×
781
        r += f"    GridBounds: {self.grid_bounds}\n"
×
782
        return r
×
783

784

785
@dataclass
1✔
786
class SpatialGridDescriptor:
1✔
787
    """A grid boundingbox where lower right is inclusive index"""
788

789
    spatial_grid: SpatialGridDefinition
1✔
790
    descriptor: geoengine_openapi_client.SpatialGridDescriptorState
1✔
791

792
    @classmethod
1✔
793
    def from_response(cls, response: geoengine_openapi_client.SpatialGridDescriptor) -> SpatialGridDescriptor:
1✔
794
        """Parse an http response to a `SpatialGridDefinition` object"""
795
        spatial_grid = SpatialGridDefinition.from_response(response.spatial_grid)
1✔
796
        return SpatialGridDescriptor(spatial_grid=spatial_grid, descriptor=response.descriptor)
1✔
797

798
    def to_api_dict(self) -> geoengine_openapi_client.SpatialGridDescriptor:
1✔
799
        return geoengine_openapi_client.SpatialGridDescriptor(
1✔
800
            spatial_grid=self.spatial_grid.to_api_dict(),
801
            descriptor=self.descriptor,
802
        )
803

804
    def contains_idx(self, idx: GridIdx2D) -> bool:
1✔
805
        return self.spatial_grid.contains_idx(idx)
×
806

807
    def spatial_resolution(self) -> SpatialResolution:
1✔
808
        return self.spatial_grid.spatial_resolution()
×
809

810
    def spatial_bounds(self) -> SpatialPartition2D:
1✔
811
        return self.spatial_grid.spatial_bounds()
×
812

813
    def is_source(self) -> bool:
1✔
814
        return self.descriptor == "source"
×
815

816
    def is_derived(self) -> bool:
1✔
817
        return self.descriptor == "derived"
×
818

819
    def __repr__(self) -> str:
1✔
820
        """Display representation of the SpatialGridDescriptor"""
821
        r = "SpatialGridDescriptor: \n"
×
822
        r += f"    Definition: {self.spatial_grid}\n"
×
823
        r += f"    Is a {self.descriptor} grid.\n"
×
824
        return r
×
825

826

827
class RasterDataType(str, Enum):
1✔
828
    """Raster data type enum"""
829

830
    U8 = "U8"
1✔
831
    U16 = "U16"
1✔
832
    U32 = "U32"
1✔
833
    U64 = "U64"
1✔
834
    I8 = "I8"
1✔
835
    I16 = "I16"
1✔
836
    I32 = "I32"
1✔
837
    I64 = "I64"
1✔
838
    F32 = "F32"
1✔
839
    F64 = "F64"
1✔
840

841
    @staticmethod
1✔
842
    def from_string(data_type: str) -> RasterDataType:
1✔
843
        """Create a new `RasterDataType` from a string"""
844

845
        if data_type not in RasterDataType._value2member_map_:
1✔
846
            raise ValueError(f"Unknown RasterDataType: {data_type}")
×
847

848
        return RasterDataType(data_type)
1✔
849

850
    @staticmethod
1✔
851
    def from_literal(
1✔
852
        data_type: Literal["U8", "U16", "U32", "U64", "I8", "I16", "I32", "I64", "F32", "F64"],
853
    ) -> RasterDataType:
854
        """Create a new `RasterDataType` from a literal"""
855

856
        return RasterDataType(data_type)
×
857

858
    def to_literal(
1✔
859
        self,
860
    ) -> Literal["U8", "U16", "U32", "U64", "I8", "I16", "I32", "I64", "F32", "F64"]:
861
        """Convert to a literal"""
862

863
        return cast(
×
864
            Literal["U8", "U16", "U32", "U64", "I8", "I16", "I32", "I64", "F32", "F64"],
865
            self.value,
866
        )
867

868
    @staticmethod
1✔
869
    def from_api_enum(data_type: geoengine_openapi_client.RasterDataType) -> RasterDataType:
1✔
870
        """Create a new `RasterDataType` from an API enum"""
871

872
        return RasterDataType(data_type.value)
1✔
873

874
    def to_api_enum(self) -> geoengine_openapi_client.RasterDataType:
1✔
875
        """Convert to an API enum"""
876

877
        return geoengine_openapi_client.RasterDataType(self.value)
1✔
878

879
    def to_np_dtype(self) -> np.dtype:
1✔
880
        """Convert to a numpy dtype"""
881
        mapping = {
×
882
            RasterDataType.U8: np.uint8,
883
            RasterDataType.U16: np.uint16,
884
            RasterDataType.U32: np.uint32,
885
            RasterDataType.U64: np.uint64,
886
            RasterDataType.I8: np.int8,
887
            RasterDataType.I16: np.int16,
888
            RasterDataType.I32: np.int32,
889
            RasterDataType.I64: np.int64,
890
            RasterDataType.F32: np.float32,
891
            RasterDataType.F64: np.float64,
892
        }
893
        return np.dtype(mapping[self])
×
894

895

896
class TimeDimension:
1✔
897
    """A time dimension"""
898

899
    @classmethod
1✔
900
    def from_response(
1✔
901
        cls, response: geoengine_openapi_client.TimeDimension
902
    ) -> RegularTimeDimension | IrregularTimeDimension:
903
        """Parse a time dimension from an http response"""
904
        actual = response.actual_instance
1✔
905

906
        if actual is None:
1✔
907
            raise ValueError("input is None")
×
908

909
        if actual.type == "regular":
1✔
910
            if not isinstance(actual, geoengine_openapi_client.RegularTimeDimension):
1✔
911
                raise ValueError("Type should be regular!")
×
912
            return RegularTimeDimension.from_response(response)
1✔
913

914
        if actual.type == "irregular":
1✔
915
            return IrregularTimeDimension.from_response(response)
1✔
916

917
        raise ValueError("unknown input type")
×
918

919
    @abstractmethod
1✔
920
    def to_api_dict(self) -> geoengine_openapi_client.TimeDimension:
1✔
921
        pass
×
922

923

924
class RegularTimeDimension(TimeDimension):
1✔
925
    """
926
    A regular time dimension
927
    """
928

929
    origin: np.datetime64
1✔
930
    step: TimeStep
1✔
931

932
    def __init__(self, step: TimeStep, origin: np.datetime64 | None = None) -> None:
1✔
933
        """Initialize a new `RegularTimeDimension`"""
934

935
        self.origin = origin if origin is not None else np.datetime64("1970-01-01T00:00:00Z")
1✔
936
        self.step = step
1✔
937

938
    def to_api_dict(self) -> geoengine_openapi_client.TimeDimension:
1✔
939
        """Convert the regular time dimension to a dictionary"""
940
        time_origin = self.origin.astype("datetime64[ms]").astype(int)
×
941
        return geoengine_openapi_client.TimeDimension(
×
942
            {"type": "regular", "origin": int(time_origin), "step": self.step.to_api_dict()}
943
        )
944

945
    @classmethod
1✔
946
    def from_response(cls, response: geoengine_openapi_client.TimeDimension) -> RegularTimeDimension:
1✔
947
        """Parse a regular time dimension from an http response"""
948

949
        actual = response.actual_instance
1✔
950

951
        if actual is None or actual.type != "regular":
1✔
952
            raise ValueError("type must be regular")
×
953

954
        if not isinstance(actual, geoengine_openapi_client.RegularTimeDimension):
1✔
955
            raise ValueError("Not a valid RegularTimeDimension")
×
956

957
        origin = np.datetime64(actual.origin, "ms")
1✔
958
        step = TimeStep.from_response(actual.step)
1✔
959
        return RegularTimeDimension(step=step, origin=origin)
1✔
960

961

962
class IrregularTimeDimension(TimeDimension):
1✔
963
    """The irregular time dimension"""
964

965
    def to_api_dict(self) -> geoengine_openapi_client.TimeDimension:
1✔
966
        """Convert the irregular time dimension to a dictionary"""
967

968
        return geoengine_openapi_client.TimeDimension({"type": "irregular"})
1✔
969

970
    @classmethod
1✔
971
    def from_response(cls, response: Any) -> IrregularTimeDimension:
1✔
972
        """Parse an irregular time dimension from an http response"""
973
        return IrregularTimeDimension()
1✔
974

975

976
class TimeDescriptor:
1✔
977
    """A time descriptor"""
978

979
    bounds: TimeInterval | None
1✔
980
    dimension: TimeDimension
1✔
981

982
    def __init__(self, dimension: TimeDimension, bounds: TimeInterval | None = None) -> None:
1✔
983
        """Initialize a new `TimeDescriptor`"""
984
        self.dimension = dimension
1✔
985
        self.bounds = bounds
1✔
986

987
    def to_api_dict(self) -> geoengine_openapi_client.TimeDescriptor:
1✔
988
        """Convert the time descriptor to a dictionary"""
989
        return geoengine_openapi_client.TimeDescriptor(
1✔
990
            dimension=self.dimension.to_api_dict(),
991
            bounds=self.bounds.to_api_dict() if self.bounds is not None else None,
992
        )
993

994
    @staticmethod
1✔
995
    def from_response(response: geoengine_openapi_client.TimeDescriptor) -> TimeDescriptor:
1✔
996
        """Parse a time descriptor from an http response"""
997
        bounds = None
1✔
998
        dimension = None
1✔
999

1000
        if response.bounds is not None:
1✔
1001
            bounds = TimeInterval.from_response(response.bounds)
1✔
1002

1003
        dimension = TimeDimension.from_response(response.dimension)
1✔
1004

1005
        return TimeDescriptor(bounds=bounds, dimension=dimension)
1✔
1006

1007

1008
class RasterResultDescriptor(ResultDescriptor):
1✔
1009
    """
1010
    A raster result descriptor
1011
    """
1012

1013
    __data_type: RasterDataType
1✔
1014
    __bands: list[RasterBandDescriptor]
1✔
1015
    __spatial_grid: SpatialGridDescriptor
1✔
1016
    __time: TimeDescriptor
1✔
1017

1018
    def __init__(  # pylint: disable=too-many-arguments,too-many-positional-arguments
1✔
1019
        self,
1020
        data_type: RasterDataType | Literal["U8", "U16", "U32", "U64", "I8", "I16", "I32", "I64", "F32", "F64"],
1021
        bands: list[RasterBandDescriptor],
1022
        spatial_reference: str,
1023
        spatial_grid: SpatialGridDescriptor,
1024
        time: TimeDescriptor | TimeInterval | TimeDimension | None = None,
1025
    ) -> None:
1026
        """Initialize a new `RasterResultDescriptor`"""
1027

1028
        time_descriptor = time
1✔
1029
        if isinstance(time, TimeInterval):
1✔
1030
            time_descriptor = TimeDescriptor(IrregularTimeDimension(), time)
×
1031
        elif isinstance(time, TimeDimension):
1✔
1032
            time_descriptor = TimeDescriptor(time, None)
×
1033
        elif time is None:
1✔
1034
            time_descriptor = TimeDescriptor(IrregularTimeDimension(), None)
×
1035

1036
        if not isinstance(time_descriptor, TimeDescriptor):
1✔
1037
            raise ValueError(f"no valid TimeDimension, got {type(time)}")
×
1038

1039
        if isinstance(data_type, str):
1✔
1040
            data_type = RasterDataType.from_string(data_type)
1✔
1041
        elif not isinstance(data_type, RasterDataType):
×
1042
            raise ValueError(f"no valid RasterDataType, got {type(data_type)}")
×
1043

1044
        super().__init__(spatial_reference, time_descriptor.bounds)
1✔
1045
        self.__data_type = data_type
1✔
1046
        self.__bands = bands
1✔
1047
        self.__spatial_grid = spatial_grid
1✔
1048
        self.__time = time_descriptor
1✔
1049

1050
    def to_api_dict(self) -> geoengine_openapi_client.TypedResultDescriptor:
1✔
1051
        """Convert the raster result descriptor to a dictionary"""
1052

1053
        return geoengine_openapi_client.TypedResultDescriptor(
1✔
1054
            geoengine_openapi_client.TypedRasterResultDescriptor(
1055
                type="raster",
1056
                data_type=self.data_type.to_api_enum(),
1057
                bands=[band.to_api_dict() for band in self.__bands],
1058
                spatial_reference=self.spatial_reference,
1059
                time=self.__time.to_api_dict(),
1060
                spatial_grid=self.__spatial_grid.to_api_dict(),
1061
            )
1062
        )
1063

1064
    @staticmethod
1✔
1065
    def from_response_raster(response: geoengine_openapi_client.TypedRasterResultDescriptor) -> RasterResultDescriptor:
1✔
1066
        """Parse a raster result descriptor from an http response"""
1067
        spatial_ref = response.spatial_reference
1✔
1068
        data_type = RasterDataType.from_api_enum(response.data_type)
1✔
1069
        bands = [RasterBandDescriptor.from_response(band) for band in response.bands]
1✔
1070

1071
        spatial_grid = SpatialGridDescriptor.from_response(response.spatial_grid)
1✔
1072

1073
        time_bounds = TimeDescriptor.from_response(response.time)
1✔
1074

1075
        return RasterResultDescriptor(
1✔
1076
            data_type=data_type,
1077
            bands=bands,
1078
            spatial_reference=spatial_ref,
1079
            time=time_bounds,
1080
            spatial_grid=spatial_grid,
1081
        )
1082

1083
    @classmethod
1✔
1084
    def is_raster_result(cls) -> bool:
1✔
1085
        return True
1✔
1086

1087
    @property
1✔
1088
    def data_type(self) -> RasterDataType:
1✔
1089
        return self.__data_type
1✔
1090

1091
    @property
1✔
1092
    def bands(self) -> list[RasterBandDescriptor]:
1✔
1093
        return self.__bands
1✔
1094

1095
    @property
1✔
1096
    def spatial_grid(self) -> SpatialGridDescriptor:
1✔
1097
        return self.__spatial_grid
×
1098

1099
    @property
1✔
1100
    def spatial_bounds(self) -> SpatialPartition2D:
1✔
1101
        return self.spatial_grid.spatial_bounds()
×
1102

1103
    @property
1✔
1104
    def geo_transform(self) -> GeoTransform:
1✔
1105
        return self.spatial_grid.spatial_grid.geo_transform
×
1106

1107
    @property
1✔
1108
    def spatial_reference(self) -> str:
1✔
1109
        """Return the spatial reference"""
1110

1111
        return super().spatial_reference
1✔
1112

1113
    def __repr__(self) -> str:
1✔
1114
        """Display representation of the raster result descriptor"""
1115
        r = ""
×
1116
        r += f"Data type:         {self.data_type}\n"
×
1117
        r += f"Spatial Reference: {self.spatial_reference}\n"
×
1118
        r += f"Spatial Grid: {self.spatial_grid} \n"
×
1119
        r += f"Time Bounds: {self.time_bounds}\n"
×
1120
        r += "Bands:\n"
×
1121

1122
        for band in self.__bands:
×
1123
            r += f"    {band}\n"
×
1124

1125
        return r
×
1126

1127

1128
class PlotResultDescriptor(ResultDescriptor):
1✔
1129
    """
1130
    A plot result descriptor
1131
    """
1132

1133
    __spatial_bounds: BoundingBox2D | None
1✔
1134

1135
    def __init__(  # pylint: disable=too-many-arguments]
1✔
1136
        self,
1137
        spatial_reference: str,
1138
        time_bounds: TimeInterval | None = None,
1139
        spatial_bounds: BoundingBox2D | None = None,
1140
    ) -> None:
1141
        """Initialize a new `PlotResultDescriptor`"""
1142
        super().__init__(spatial_reference, time_bounds)
1✔
1143
        self.__spatial_bounds = spatial_bounds
1✔
1144

1145
    def __repr__(self) -> str:
1✔
1146
        """Display representation of the plot result descriptor"""
1147
        r = "Plot Result"
1✔
1148

1149
        return r
1✔
1150

1151
    @staticmethod
1✔
1152
    def from_response_plot(response: geoengine_openapi_client.TypedPlotResultDescriptor) -> PlotResultDescriptor:
1✔
1153
        """Create a new `PlotResultDescriptor` from a JSON response"""
1154
        spatial_ref = response.spatial_reference
1✔
1155

1156
        time_bounds = None
1✔
1157
        if response.time is not None:
1✔
1158
            time_bounds = TimeInterval.from_response(response.time)
1✔
1159
        spatial_bounds = None
1✔
1160
        if response.bbox is not None:
1✔
1161
            spatial_bounds = BoundingBox2D.from_response(response.bbox)
1✔
1162

1163
        return PlotResultDescriptor(
1✔
1164
            spatial_reference=spatial_ref, time_bounds=time_bounds, spatial_bounds=spatial_bounds
1165
        )
1166

1167
    @classmethod
1✔
1168
    def is_plot_result(cls) -> bool:
1✔
1169
        return True
1✔
1170

1171
    @property
1✔
1172
    def spatial_reference(self) -> str:
1✔
1173
        """Return the spatial reference"""
1174
        return super().spatial_reference
×
1175

1176
    @property
1✔
1177
    def spatial_bounds(self) -> BoundingBox2D | None:
1✔
1178
        return self.__spatial_bounds
×
1179

1180
    def to_api_dict(self) -> geoengine_openapi_client.TypedResultDescriptor:
1✔
1181
        """Convert the plot result descriptor to a dictionary"""
1182

1183
        return geoengine_openapi_client.TypedResultDescriptor(
×
1184
            geoengine_openapi_client.TypedPlotResultDescriptor(
1185
                type="plot",
1186
                spatial_reference=self.spatial_reference,
1187
                time=self.time_bounds.to_api_dict() if self.time_bounds is not None else None,
1188
                bbox=self.spatial_bounds.to_api_dict() if self.spatial_bounds is not None else None,
1189
            )
1190
        )
1191

1192

1193
class VectorDataType(str, Enum):
1✔
1194
    """An enum of vector data types"""
1195

1196
    DATA = "Data"
1✔
1197
    MULTI_POINT = "MultiPoint"
1✔
1198
    MULTI_LINE_STRING = "MultiLineString"
1✔
1199
    MULTI_POLYGON = "MultiPolygon"
1✔
1200

1201
    @classmethod
1✔
1202
    def from_geopandas_type_name(cls, name: str) -> VectorDataType:
1✔
1203
        """Resolve vector data type from geopandas geometry type"""
1204

1205
        name_map = {
1✔
1206
            "Point": VectorDataType.MULTI_POINT,
1207
            "MultiPoint": VectorDataType.MULTI_POINT,
1208
            "Line": VectorDataType.MULTI_LINE_STRING,
1209
            "MultiLine": VectorDataType.MULTI_LINE_STRING,
1210
            "Polygon": VectorDataType.MULTI_POLYGON,
1211
            "MultiPolygon": VectorDataType.MULTI_POLYGON,
1212
        }
1213

1214
        if name in name_map:
1✔
1215
            return name_map[name]
1✔
1216

1217
        raise InputException("Invalid vector data type")
×
1218

1219
    def to_api_enum(self) -> geoengine_openapi_client.VectorDataType:
1✔
1220
        return geoengine_openapi_client.VectorDataType(self.value)
1✔
1221

1222
    @staticmethod
1✔
1223
    def from_literal(literal: Literal["Data", "MultiPoint", "MultiLineString", "MultiPolygon"]) -> VectorDataType:
1✔
1224
        """Resolve vector data type from literal"""
1225
        return VectorDataType(literal)
×
1226

1227
    @staticmethod
1✔
1228
    def from_api_enum(data_type: geoengine_openapi_client.VectorDataType) -> VectorDataType:
1✔
1229
        """Resolve vector data type from API enum"""
1230
        return VectorDataType(data_type.value)
×
1231

1232
    @staticmethod
1✔
1233
    def from_string(string: str) -> VectorDataType:
1✔
1234
        """Resolve vector data type from string"""
1235
        if string not in VectorDataType.__members__.values():
1✔
1236
            raise InputException("Invalid vector data type: " + string)
×
1237
        return VectorDataType(string)
1✔
1238

1239

1240
class TimeStepGranularity(Enum):
1✔
1241
    """An enum of time step granularities"""
1242

1243
    MILLIS = "millis"
1✔
1244
    SECONDS = "seconds"
1✔
1245
    MINUTES = "minutes"
1✔
1246
    HOURS = "hours"
1✔
1247
    DAYS = "days"
1✔
1248
    MONTHS = "months"
1✔
1249
    YEARS = "years"
1✔
1250

1251
    def to_api_enum(self) -> geoengine_openapi_client.TimeGranularity:
1✔
1252
        return geoengine_openapi_client.TimeGranularity(self.value)
1✔
1253

1254

1255
@dataclass
1✔
1256
class TimeStep:
1✔
1257
    """A time step that consists of a granularity and a step size"""
1258

1259
    step: int
1✔
1260
    granularity: TimeStepGranularity
1✔
1261

1262
    def __init__(self, step: int, granularity: TimeStepGranularity | str) -> None:
1✔
1263
        """Initialize a new `TimeStep` object"""
1264
        self.step = step
×
1265
        if isinstance(granularity, str):
×
1266
            self.granularity = TimeStepGranularity(granularity)
×
1267
        elif isinstance(granularity, TimeStepGranularity):
×
1268
            self.granularity = granularity
×
1269
        else:
1270
            raise InputException("Invalid granularity type. Got: " + str(type(granularity)))
×
1271

1272
    def to_api_dict(self) -> geoengine_openapi_client.TimeStep:
1✔
1273
        return geoengine_openapi_client.TimeStep(
×
1274
            step=self.step,
1275
            granularity=self.granularity.to_api_enum(),
1276
        )
1277

1278
    @classmethod
1✔
1279
    def from_response(cls, response: geoengine_openapi_client.TimeStep) -> TimeStep:
1✔
1280
        """Parse an http response to a `TimeStep` object"""
1281
        granularity = TimeStepGranularity(response.granularity.value)
1✔
1282
        return TimeStep(step=response.step, granularity=granularity)
1✔
1283

1284

1285
@dataclass
1✔
1286
class Provenance:
1✔
1287
    """Provenance information as triplet of citation, license and uri"""
1288

1289
    citation: str
1✔
1290
    license: str
1✔
1291
    uri: str
1✔
1292

1293
    @classmethod
1✔
1294
    def from_response(cls, response: geoengine_openapi_client.Provenance) -> Provenance:
1✔
1295
        """Parse an http response to a `Provenance` object"""
1296
        return Provenance(response.citation, response.license, response.uri)
1✔
1297

1298
    def to_api_dict(self) -> geoengine_openapi_client.Provenance:
1✔
1299
        return geoengine_openapi_client.Provenance(
1✔
1300
            citation=self.citation,
1301
            license=self.license,
1302
            uri=self.uri,
1303
        )
1304

1305

1306
@dataclass
1✔
1307
class ProvenanceEntry:
1✔
1308
    """Provenance of a dataset"""
1309

1310
    data: list[DataId]
1✔
1311
    provenance: Provenance
1✔
1312

1313
    @classmethod
1✔
1314
    def from_response(cls, response: geoengine_openapi_client.ProvenanceEntry) -> ProvenanceEntry:
1✔
1315
        """Parse an http response to a `ProvenanceEntry` object"""
1316

1317
        dataset = [DataId.from_response(data) for data in response.data]
1✔
1318
        provenance = Provenance.from_response(response.provenance)
1✔
1319

1320
        return ProvenanceEntry(dataset, provenance)
1✔
1321

1322

1323
class Symbology:
1✔
1324
    """Base class for symbology"""
1325

1326
    @abstractmethod
1✔
1327
    def to_api_dict(self) -> geoengine_openapi_client.Symbology:
1✔
1328
        pass
×
1329

1330
    @staticmethod
1✔
1331
    def from_response(response: geoengine_openapi_client.Symbology) -> Symbology:
1✔
1332
        """Parse an http response to a `Symbology` object"""
1333
        inner = response.actual_instance
1✔
1334

1335
        if isinstance(
1✔
1336
            inner,
1337
            geoengine_openapi_client.PointSymbology
1338
            | geoengine_openapi_client.LineSymbology
1339
            | geoengine_openapi_client.PolygonSymbology,
1340
        ):
1341
            # return VectorSymbology.from_response_vector(response)
1342
            return VectorSymbology()  # TODO: implement
×
1343
        if isinstance(inner, geoengine_openapi_client.RasterSymbology):
1✔
1344
            return RasterSymbology.from_response_raster(inner)
1✔
1345

1346
        raise InputException("Invalid symbology type")
×
1347

1348
    def __repr__(self) -> str:
1✔
1349
        return "Symbology"
×
1350

1351

1352
class VectorSymbology(Symbology):
1✔
1353
    """A vector symbology"""
1354

1355
    # TODO: implement
1356

1357
    def to_api_dict(self) -> geoengine_openapi_client.Symbology:
1✔
1358
        return None  # type: ignore
×
1359

1360

1361
class RasterColorizer:
1✔
1362
    """Base class for raster colorizer"""
1363

1364
    @classmethod
1✔
1365
    def from_response(cls, response: geoengine_openapi_client.RasterColorizer) -> RasterColorizer:
1✔
1366
        """Parse an http response to a `RasterColorizer` object"""
1367
        inner = response.actual_instance
1✔
1368

1369
        if isinstance(inner, geoengine_openapi_client.SingleBandRasterColorizer):
1✔
1370
            return SingleBandRasterColorizer.from_single_band_response(inner)
1✔
1371
        if isinstance(inner, geoengine_openapi_client.MultiBandRasterColorizer):
1✔
1372
            return MultiBandRasterColorizer.from_multi_band_response(inner)
1✔
1373

1374
        raise GeoEngineException({"message": "Unknown RasterColorizer type"})
×
1375

1376
    @abstractmethod
1✔
1377
    def to_api_dict(self) -> geoengine_openapi_client.RasterColorizer:
1✔
1378
        pass
×
1379

1380

1381
@dataclass
1✔
1382
class SingleBandRasterColorizer(RasterColorizer):
1✔
1383
    """A raster colorizer for a specified band"""
1384

1385
    band: int
1✔
1386
    band_colorizer: Colorizer
1✔
1387

1388
    @staticmethod
1✔
1389
    def from_single_band_response(response: geoengine_openapi_client.SingleBandRasterColorizer) -> RasterColorizer:
1✔
1390
        return SingleBandRasterColorizer(response.band, Colorizer.from_response(response.band_colorizer))
1✔
1391

1392
    def to_api_dict(self) -> geoengine_openapi_client.RasterColorizer:
1✔
1393
        return geoengine_openapi_client.RasterColorizer(
1✔
1394
            geoengine_openapi_client.SingleBandRasterColorizer(
1395
                type="singleBand",
1396
                band=self.band,
1397
                band_colorizer=self.band_colorizer.to_api_dict(),
1398
            )
1399
        )
1400

1401

1402
@dataclass
1✔
1403
class MultiBandRasterColorizer(RasterColorizer):
1✔
1404
    """A raster colorizer for multiple bands"""
1405

1406
    blue_band: int
1✔
1407
    blue_max: float
1✔
1408
    blue_min: float
1✔
1409
    blue_scale: float | None
1✔
1410
    green_band: int
1✔
1411
    green_max: float
1✔
1412
    green_min: float
1✔
1413
    green_scale: float | None
1✔
1414
    red_band: int
1✔
1415
    red_max: float
1✔
1416
    red_min: float
1✔
1417
    red_scale: float | None
1✔
1418

1419
    @staticmethod
1✔
1420
    def from_multi_band_response(response: geoengine_openapi_client.MultiBandRasterColorizer) -> RasterColorizer:
1✔
1421
        return MultiBandRasterColorizer(
1✔
1422
            blue_band=response.blue_band,
1423
            blue_max=response.blue_max,
1424
            blue_min=response.blue_min,
1425
            blue_scale=response.blue_scale if response.blue_scale is not None else None,
1426
            green_band=response.green_band,
1427
            green_max=response.green_max,
1428
            green_min=response.green_min,
1429
            green_scale=response.green_scale if response.green_scale is not None else None,
1430
            red_band=response.red_band,
1431
            red_max=response.red_max,
1432
            red_min=response.red_min,
1433
            red_scale=response.red_scale if response.red_scale is not None else None,
1434
        )
1435

1436
    def to_api_dict(self) -> geoengine_openapi_client.RasterColorizer:
1✔
1437
        return geoengine_openapi_client.RasterColorizer(
1✔
1438
            geoengine_openapi_client.MultiBandRasterColorizer(
1439
                type="multiBand",
1440
                blue_band=self.blue_band,
1441
                blue_max=self.blue_max,
1442
                blue_min=self.blue_min,
1443
                blue_scale=self.blue_scale,
1444
                green_band=self.green_band,
1445
                green_max=self.green_max,
1446
                green_min=self.green_min,
1447
                green_scale=self.green_scale,
1448
                red_band=self.red_band,
1449
                red_max=self.red_max,
1450
                red_min=self.red_min,
1451
                red_scale=self.red_scale,
1452
            )
1453
        )
1454

1455

1456
class RasterSymbology(Symbology):
1✔
1457
    """A raster symbology"""
1458

1459
    opacity: float
1✔
1460
    raster_colorizer: RasterColorizer
1✔
1461

1462
    def __init__(self, raster_colorizer: RasterColorizer, opacity: float = 1.0) -> None:
1✔
1463
        """Initialize a new `RasterSymbology`"""
1464

1465
        self.raster_colorizer = raster_colorizer
1✔
1466
        self.opacity = opacity
1✔
1467

1468
    def to_api_dict(self) -> geoengine_openapi_client.Symbology:
1✔
1469
        """Convert the raster symbology to a dictionary"""
1470

1471
        return geoengine_openapi_client.Symbology(
1✔
1472
            geoengine_openapi_client.RasterSymbology(
1473
                type="raster",
1474
                raster_colorizer=self.raster_colorizer.to_api_dict(),
1475
                opacity=self.opacity,
1476
            )
1477
        )
1478

1479
    @staticmethod
1✔
1480
    def from_response_raster(response: geoengine_openapi_client.RasterSymbology) -> RasterSymbology:
1✔
1481
        """Parse an http response to a `RasterSymbology` object"""
1482

1483
        raster_colorizer = RasterColorizer.from_response(response.raster_colorizer)
1✔
1484

1485
        return RasterSymbology(raster_colorizer, response.opacity)
1✔
1486

1487
    def __repr__(self) -> str:
1✔
1488
        return str(self.__class__) + f"({self.raster_colorizer}, {self.opacity})"
×
1489

1490
    def __eq__(self, value):
1✔
1491
        """Check if two RasterSymbologies are equal"""
1492

1493
        if not isinstance(value, self.__class__):
1✔
1494
            return False
×
1495
        return self.opacity == value.opacity and self.raster_colorizer == value.raster_colorizer
1✔
1496

1497

1498
class DataId:  # pylint: disable=too-few-public-methods
1✔
1499
    """Base class for data ids"""
1500

1501
    @classmethod
1✔
1502
    def from_response(cls, response: geoengine_openapi_client.DataId) -> DataId:
1✔
1503
        """Parse an http response to a `DataId` object"""
1504
        inner = response.actual_instance
1✔
1505

1506
        if isinstance(inner, geoengine_openapi_client.InternalDataId):
1✔
1507
            return InternalDataId.from_response_internal(inner)
1✔
1508
        if isinstance(inner, geoengine_openapi_client.ExternalDataId):
×
1509
            return ExternalDataId.from_response_external(inner)
×
1510

1511
        raise GeoEngineException({"message": "Unknown DataId type"})
×
1512

1513
    @abstractmethod
1✔
1514
    def to_api_dict(self) -> geoengine_openapi_client.DataId:
1✔
1515
        pass
×
1516

1517

1518
class InternalDataId(DataId):
1✔
1519
    """An internal data id"""
1520

1521
    __dataset_id: UUID
1✔
1522

1523
    def __init__(self, dataset_id: UUID):
1✔
1524
        self.__dataset_id = dataset_id
1✔
1525

1526
    @classmethod
1✔
1527
    def from_response_internal(cls, response: geoengine_openapi_client.InternalDataId) -> InternalDataId:
1✔
1528
        """Parse an http response to a `InternalDataId` object"""
1529
        return InternalDataId(response.dataset_id)
1✔
1530

1531
    def to_api_dict(self) -> geoengine_openapi_client.DataId:
1✔
1532
        return geoengine_openapi_client.DataId(
×
1533
            geoengine_openapi_client.InternalDataId(type="internal", dataset_id=str(self.__dataset_id))
1534
        )
1535

1536
    def __str__(self) -> str:
1✔
1537
        return str(self.__dataset_id)
×
1538

1539
    def __repr__(self) -> str:
1✔
1540
        """Display representation of an internal data id"""
1541
        return str(self)
×
1542

1543
    def __eq__(self, other) -> bool:
1✔
1544
        """Check if two internal data ids are equal"""
1545
        if not isinstance(other, self.__class__):
1✔
1546
            return False
×
1547

1548
        return self.__dataset_id == other.__dataset_id  # pylint: disable=protected-access
1✔
1549

1550

1551
class ExternalDataId(DataId):
1✔
1552
    """An external data id"""
1553

1554
    __provider_id: UUID
1✔
1555
    __layer_id: str
1✔
1556

1557
    def __init__(self, provider_id: UUID, layer_id: str):
1✔
1558
        self.__provider_id = provider_id
×
1559
        self.__layer_id = layer_id
×
1560

1561
    @classmethod
1✔
1562
    def from_response_external(cls, response: geoengine_openapi_client.ExternalDataId) -> ExternalDataId:
1✔
1563
        """Parse an http response to a `ExternalDataId` object"""
1564

1565
        return ExternalDataId(response.provider_id, response.layer_id)
×
1566

1567
    def to_api_dict(self) -> geoengine_openapi_client.DataId:
1✔
1568
        return geoengine_openapi_client.DataId(
×
1569
            geoengine_openapi_client.ExternalDataId(
1570
                type="external",
1571
                provider_id=str(self.__provider_id),
1572
                layer_id=self.__layer_id,
1573
            )
1574
        )
1575

1576
    def __str__(self) -> str:
1✔
1577
        return f"{self.__provider_id}:{self.__layer_id}"
×
1578

1579
    def __repr__(self) -> str:
1✔
1580
        """Display representation of an external data id"""
1581
        return str(self)
×
1582

1583
    def __eq__(self, other) -> bool:
1✔
1584
        """Check if two external data ids are equal"""
1585
        if not isinstance(other, self.__class__):
×
1586
            return False
×
1587

1588
        return self.__provider_id == other.__provider_id and self.__layer_id == other.__layer_id  # pylint: disable=protected-access
×
1589

1590

1591
class Measurement:  # pylint: disable=too-few-public-methods
1✔
1592
    """
1593
    Base class for measurements
1594
    """
1595

1596
    @staticmethod
1✔
1597
    def from_response(response: geoengine_openapi_client.Measurement) -> Measurement:
1✔
1598
        """
1599
        Parse a result descriptor from an http response
1600
        """
1601
        inner = response.actual_instance
1✔
1602

1603
        if isinstance(inner, geoengine_openapi_client.UnitlessMeasurement):
1✔
1604
            return UnitlessMeasurement()
1✔
1605
        if isinstance(inner, geoengine_openapi_client.ContinuousMeasurement):
1✔
1606
            return ContinuousMeasurement.from_response_continuous(inner)
1✔
1607
        if isinstance(inner, geoengine_openapi_client.ClassificationMeasurement):
1✔
1608
            return ClassificationMeasurement.from_response_classification(inner)
1✔
1609

1610
        raise TypeException("Unknown `Measurement` type")
×
1611

1612
    @abstractmethod
1✔
1613
    def to_api_dict(self) -> geoengine_openapi_client.Measurement:
1✔
1614
        pass
×
1615

1616

1617
class UnitlessMeasurement(Measurement):
1✔
1618
    """A measurement that is unitless"""
1619

1620
    def __str__(self) -> str:
1✔
1621
        """String representation of a unitless measurement"""
1622
        return "unitless"
1✔
1623

1624
    def __repr__(self) -> str:
1✔
1625
        """Display representation of a unitless measurement"""
1626
        return str(self)
×
1627

1628
    def to_api_dict(self) -> geoengine_openapi_client.Measurement:
1✔
1629
        return geoengine_openapi_client.Measurement(geoengine_openapi_client.UnitlessMeasurement(type="unitless"))
1✔
1630

1631

1632
class ContinuousMeasurement(Measurement):
1✔
1633
    """A measurement that is continuous"""
1634

1635
    __measurement: str
1✔
1636
    __unit: str | None
1✔
1637

1638
    def __init__(self, measurement: str, unit: str | None) -> None:
1✔
1639
        """Initialize a new `ContiuousMeasurement`"""
1640

1641
        super().__init__()
1✔
1642

1643
        self.__measurement = measurement
1✔
1644
        self.__unit = unit
1✔
1645

1646
    @staticmethod
1✔
1647
    def from_response_continuous(response: geoengine_openapi_client.ContinuousMeasurement) -> ContinuousMeasurement:
1✔
1648
        """Initialize a new `ContiuousMeasurement from a JSON response"""
1649

1650
        return ContinuousMeasurement(response.measurement, response.unit)
1✔
1651

1652
    def __str__(self) -> str:
1✔
1653
        """String representation of a continuous measurement"""
1654

1655
        if self.__unit is None:
1✔
1656
            return self.__measurement
1✔
1657

1658
        return f"{self.__measurement} ({self.__unit})"
×
1659

1660
    def __repr__(self) -> str:
1✔
1661
        """Display representation of a continuous measurement"""
1662
        return str(self)
×
1663

1664
    def to_api_dict(self) -> geoengine_openapi_client.Measurement:
1✔
1665
        return geoengine_openapi_client.Measurement(
1✔
1666
            geoengine_openapi_client.ContinuousMeasurement(
1667
                type="continuous", measurement=self.__measurement, unit=self.__unit
1668
            )
1669
        )
1670

1671
    @property
1✔
1672
    def measurement(self) -> str:
1✔
1673
        return self.__measurement
×
1674

1675
    @property
1✔
1676
    def unit(self) -> str | None:
1✔
1677
        return self.__unit
×
1678

1679

1680
class ClassificationMeasurement(Measurement):
1✔
1681
    """A measurement that is a classification"""
1682

1683
    __measurement: str
1✔
1684
    __classes: dict[int, str]
1✔
1685

1686
    def __init__(self, measurement: str, classes: dict[int, str]) -> None:
1✔
1687
        """Initialize a new `ClassificationMeasurement`"""
1688

1689
        super().__init__()
1✔
1690

1691
        self.__measurement = measurement
1✔
1692
        self.__classes = classes
1✔
1693

1694
    @staticmethod
1✔
1695
    def from_response_classification(
1✔
1696
        response: geoengine_openapi_client.ClassificationMeasurement,
1697
    ) -> ClassificationMeasurement:
1698
        """Initialize a new `ClassificationMeasurement from a JSON response"""
1699

1700
        measurement = response.measurement
1✔
1701

1702
        str_classes: dict[str, str] = response.classes
1✔
1703
        classes = {int(k): v for k, v in str_classes.items()}
1✔
1704

1705
        return ClassificationMeasurement(measurement, classes)
1✔
1706

1707
    def to_api_dict(self) -> geoengine_openapi_client.Measurement:
1✔
1708
        str_classes: dict[str, str] = {str(k): v for k, v in self.__classes.items()}
1✔
1709

1710
        return geoengine_openapi_client.Measurement(
1✔
1711
            geoengine_openapi_client.ClassificationMeasurement(
1712
                type="classification", measurement=self.__measurement, classes=str_classes
1713
            )
1714
        )
1715

1716
    def __str__(self) -> str:
1✔
1717
        """String representation of a classification measurement"""
1718
        classes_str = ", ".join(f"{k}: {v}" for k, v in self.__classes.items())
×
1719
        return f"{self.__measurement} ({classes_str})"
×
1720

1721
    def __repr__(self) -> str:
1✔
1722
        """Display representation of a classification measurement"""
1723
        return str(self)
×
1724

1725
    @property
1✔
1726
    def measurement(self) -> str:
1✔
1727
        return self.__measurement
×
1728

1729
    @property
1✔
1730
    def classes(self) -> dict[int, str]:
1✔
1731
        return self.__classes
×
1732

1733

1734
class GeoTransform:
1✔
1735
    """The `GeoTransform` specifies the relationship between pixel coordinates and geographic coordinates."""
1736

1737
    x_min: float
1✔
1738
    y_max: float
1✔
1739
    """In Geo Engine, x_pixel_size is always positive."""
1✔
1740
    x_pixel_size: float
1✔
1741
    """In Geo Engine, y_pixel_size is always negative."""
1✔
1742
    y_pixel_size: float
1✔
1743

1744
    def __init__(self, x_min: float, y_max: float, x_pixel_size: float, y_pixel_size: float):
1✔
1745
        """Initialize a new `GeoTransform`"""
1746

1747
        assert x_pixel_size > 0, "In Geo Engine, x_pixel_size is always positive."
1✔
1748
        assert y_pixel_size < 0, "In Geo Engine, y_pixel_size is always negative."
1✔
1749

1750
        self.x_min = x_min
1✔
1751
        self.y_max = y_max
1✔
1752
        self.x_pixel_size = x_pixel_size
1✔
1753
        self.y_pixel_size = y_pixel_size
1✔
1754

1755
    @classmethod
1✔
1756
    def from_response_gdal_geo_transform(
1✔
1757
        cls, response: geoengine_openapi_client.GdalDatasetGeoTransform
1758
    ) -> GeoTransform:
1759
        """Parse a geotransform from an HTTP JSON response"""
1760
        return GeoTransform(
×
1761
            x_min=response.origin_coordinate.x,
1762
            y_max=response.origin_coordinate.y,
1763
            x_pixel_size=response.x_pixel_size,
1764
            y_pixel_size=response.y_pixel_size,
1765
        )
1766

1767
    @classmethod
1✔
1768
    def from_response(cls, response: geoengine_openapi_client.GeoTransform) -> GeoTransform:
1✔
1769
        """Parse a geotransform from an HTTP JSON response"""
1770

1771
        return GeoTransform(
1✔
1772
            x_min=response.origin_coordinate.x,
1773
            y_max=response.origin_coordinate.y,
1774
            x_pixel_size=response.x_pixel_size,
1775
            y_pixel_size=response.y_pixel_size,
1776
        )
1777

1778
    def to_api_dict(self) -> geoengine_openapi_client.GeoTransform:
1✔
1779
        return geoengine_openapi_client.GeoTransform(
1✔
1780
            origin_coordinate=geoengine_openapi_client.Coordinate2D(
1781
                x=self.x_min,
1782
                y=self.y_max,
1783
            ),
1784
            x_pixel_size=self.x_pixel_size,
1785
            y_pixel_size=self.y_pixel_size,
1786
        )
1787

1788
    def to_api_dict_gdal_geo_transform(self) -> geoengine_openapi_client.GdalDatasetGeoTransform:
1✔
1789
        return geoengine_openapi_client.GdalDatasetGeoTransform(
×
1790
            origin_coordinate=geoengine_openapi_client.Coordinate2D(
1791
                x=self.x_min,
1792
                y=self.y_max,
1793
            ),
1794
            x_pixel_size=self.x_pixel_size,
1795
            y_pixel_size=self.y_pixel_size,
1796
        )
1797

1798
    def to_gdal(self) -> tuple[float, float, float, float, float, float]:
1✔
1799
        """Convert to a GDAL geotransform"""
1800
        return (self.x_min, self.x_pixel_size, 0, self.y_max, 0, self.y_pixel_size)
×
1801

1802
    def __str__(self) -> str:
1✔
1803
        return (
×
1804
            f"Origin: ({self.x_min}, {self.y_max}), "
1805
            f"X Pixel Size: {self.x_pixel_size}, "
1806
            f"Y Pixel Size: {self.y_pixel_size}"
1807
        )
1808

1809
    def __repr__(self) -> str:
1✔
1810
        return str(self)
×
1811

1812
    @property
1✔
1813
    def x_half_pixel_size(self) -> float:
1✔
1814
        return self.x_pixel_size / 2.0
1✔
1815

1816
    @property
1✔
1817
    def y_half_pixel_size(self) -> float:
1✔
1818
        return self.y_pixel_size / 2.0
1✔
1819

1820
    def pixel_x_to_coord_x(self, pixel: int) -> float:
1✔
1821
        return self.x_min + pixel * self.x_pixel_size
1✔
1822

1823
    def pixel_y_to_coord_y(self, pixel: int) -> float:
1✔
1824
        return self.y_max + pixel * self.y_pixel_size
1✔
1825

1826
    def coord_to_pixel_ul(self, x_cord: float, y_coord: float) -> GridIdx2D:
1✔
1827
        """Convert a coordinate to a pixel index rould towards top left"""
1828
        return GridIdx2D(
×
1829
            x_idx=int(np.floor((x_cord - self.x_min) / self.x_pixel_size)),
1830
            y_idx=int(np.ceil((y_coord - self.y_max) / self.y_pixel_size)),
1831
        )
1832

1833
    def coord_to_pixel_lr(self, x_cord: float, y_coord: float) -> GridIdx2D:
1✔
1834
        """Convert a coordinate to a pixel index ound towards lower right"""
1835
        return GridIdx2D(
×
1836
            x_idx=int(np.ceil((x_cord - self.x_min) / self.x_pixel_size)),
1837
            y_idx=int(np.floor((y_coord - self.y_max) / self.y_pixel_size)),
1838
        )
1839

1840
    def pixel_ul_to_coord(self, x_pixel: int, y_pixel: int) -> tuple[float, float]:
1✔
1841
        """Convert a pixel position into a coordinate"""
1842
        x = self.pixel_x_to_coord_x(x_pixel)
×
1843
        y = self.pixel_y_to_coord_y(y_pixel)
×
1844
        return (x, y)
×
1845

1846
    def pixel_lr_to_coord(self, x_pixel: int, y_pixel: int) -> tuple[float, float]:
1✔
1847
        (x, y) = self.pixel_ul_to_coord(x_pixel, y_pixel)
×
1848
        return (x + self.x_pixel_size, y + self.y_pixel_size)
×
1849

1850
    def pixel_center_to_coord(self, x_pixel, y_pixel) -> tuple[float, float]:
1✔
1851
        (x, y) = self.pixel_ul_to_coord(x_pixel, y_pixel)
×
1852
        return (x + self.x_half_pixel_size, y + self.y_half_pixel_size)
×
1853

1854
    def spatial_resolution(self) -> SpatialResolution:
1✔
1855
        return SpatialResolution(x_resolution=abs(self.x_pixel_size), y_resolution=abs(self.y_pixel_size))
×
1856

1857
    def spatial_to_grid_bounds(self, bounds: SpatialPartition2D | BoundingBox2D) -> GridBoundingBox2D:
1✔
1858
        """Converts a BoundingBox2D or a SpatialPartition2D into a GridBoundingBox2D"""
1859
        ul = self.coord_to_pixel_ul(bounds.xmin, bounds.ymax)
×
1860
        rl = self.coord_to_pixel_lr(bounds.xmax, bounds.ymin)
×
1861
        return GridBoundingBox2D(top_left_idx=ul, bottom_right_idx=rl)
×
1862

1863
    def grid_bounds_to_spatial_bounds(self, bounds: GridBoundingBox2D) -> SpatialPartition2D:
1✔
1864
        """Converts a GridBoundingBox2D into a SpatialPartition2D"""
1865
        xmin, ymax = self.pixel_ul_to_coord(bounds.top_left_idx.x_idx, bounds.top_left_idx.y_idx)
×
1866
        xmax, ymin = self.pixel_lr_to_coord(bounds.bottom_right_idx.x_idx, bounds.bottom_right_idx.y_idx)
×
1867
        return SpatialPartition2D(xmin, ymin, xmax, ymax)
×
1868

1869
    def __eq__(self, other) -> bool:
1✔
1870
        """Check if two geotransforms are equal"""
1871
        if not isinstance(other, GeoTransform):
×
1872
            return False
×
1873

1874
        return (
×
1875
            self.x_min == other.x_min
1876
            and self.y_max == other.y_max
1877
            and self.x_pixel_size == other.x_pixel_size
1878
            and self.y_pixel_size == other.y_pixel_size
1879
        )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc