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

geo-engine / geoengine / 27023555140

05 Jun 2026 03:20PM UTC coverage: 87.318% (+0.1%) from 87.218%
27023555140

Pull #1199

github

web-flow
Merge cfe7b0044 into 255ac7144
Pull Request #1199: fix: add resolution to python down/up sampling operators

13 of 30 new or added lines in 4 files covered. (43.33%)

6 existing lines in 3 files now uncovered.

117267 of 134299 relevant lines covered (87.32%)

482078.82 hits per line

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

82.02
/python/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, TypedDict, cast
1✔
13
from uuid import UUID
1✔
14

15
import geoengine_api_client
1✔
16
import geoengine_api_client.models
1✔
17
import geoengine_api_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
1✔
65

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

70

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

74
    def to_api_dict(self) -> geoengine_api_client.BoundingBox2D:
1✔
75
        return geoengine_api_client.BoundingBox2D(
1✔
76
            lower_left_coordinate=geoengine_api_client.Coordinate2D(
77
                x=self.xmin,
78
                y=self.ymin,
79
            ),
80
            upper_right_coordinate=geoengine_api_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_api_client.BoundingBox2D) -> BoundingBox2D:
1✔
96
        """create a `BoundingBox2D` from an API response"""
97
        lower_left = response.lower_left_coordinate
×
98
        upper_right = response.upper_right_coordinate
×
99

100
        return BoundingBox2D(
×
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_api_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_api_client.SpatialPartition2D:
1✔
128
        return geoengine_api_client.SpatialPartition2D(
1✔
129
            upper_left_coordinate=geoengine_api_client.Coordinate2D(
130
                x=self.xmin,
131
                y=self.ymax,
132
            ),
133
            lower_right_coordinate=geoengine_api_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
1✔
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_api_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_api_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_api_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):
1✔
247
            return False
×
248
        return self.start == other.start and self.end == other.end
1✔
249

250

251
class SpatialResolutionDict(TypedDict):
1✔
252
    """A spatial resolution as a dictionary"""
253

254
    x: float
1✔
255
    y: float
1✔
256

257

258
class SpatialResolution:
1✔
259
    """'A spatial resolution."""
260

261
    x_resolution: float
1✔
262
    y_resolution: float
1✔
263

264
    def __init__(self, x_resolution: float, y_resolution: float) -> None:
1✔
265
        """Initialize a new `SpatialResolution` object"""
266
        if x_resolution <= 0 or y_resolution <= 0:
1✔
267
            raise InputException("Resolution: Must be positive")
×
268

269
        self.x_resolution = x_resolution
1✔
270
        self.y_resolution = y_resolution
1✔
271

272
    def to_api_dict(self) -> SpatialResolutionDict:
1✔
273
        """create a openapi `SpatialResolution` from self"""
NEW
274
        return SpatialResolutionDict(
×
275
            x=self.x_resolution,
276
            y=self.y_resolution,
277
        )
278

279
    @staticmethod
1✔
280
    def from_response(response: SpatialResolutionDict) -> SpatialResolution:
1✔
281
        """create a `SpatialResolution` from an API response"""
282
        return SpatialResolution(x_resolution=response["x"], y_resolution=response["y"])
×
283

284
    def as_tuple(self) -> tuple[float, float]:
1✔
285
        return (self.x_resolution, self.y_resolution)
×
286

287
    def resolution_ogc(self, srs_code: str) -> tuple[float, float]:
1✔
288
        """
289
        Return the resolution in OGC style
290
        """
291

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

296
        return self.as_tuple()
×
297

298
    def __str__(self) -> str:
1✔
299
        return str(f"{self.x_resolution},{self.y_resolution}")
1✔
300

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

304

305
class QueryRectangle:
1✔
306
    """
307
    A multi-dimensional query rectangle, consisting of spatial and temporal information.
308
    """
309

310
    __spatial_bounds: BoundingBox2D
1✔
311
    __time_interval: TimeInterval
1✔
312
    __srs: str
1✔
313

314
    def __init__(
1✔
315
        self,
316
        spatial_bounds: BoundingBox2D | SpatialPartition2D | tuple[float, float, float, float],
317
        time_interval: TimeInterval | tuple[datetime, datetime | None],
318
        srs="EPSG:4326",
319
    ) -> None:
320
        """
321
        Initialize a new `QueryRectangle` object
322

323
        Parameters
324
        ----------
325
        spatial_bounds
326
            The spatial bounds of the query rectangle.
327
            Either a `BoundingBox2D` or a tuple of floats (xmin, ymin, xmax, ymax)
328
        time_interval
329
            The time interval of the query rectangle.
330
            Either a `TimeInterval` or a tuple of `datetime.datetime` objects (start, end)
331
        """
332

333
        if not isinstance(spatial_bounds, BoundingBox2D):
1✔
334
            if isinstance(spatial_bounds, SpatialPartition2D):
1✔
335
                spatial_bounds = spatial_bounds.to_bounding_box()
1✔
336
            else:
337
                spatial_bounds = BoundingBox2D(*spatial_bounds)
×
338
        if not isinstance(time_interval, TimeInterval):
1✔
339
            time_interval = TimeInterval(*time_interval)
×
340

341
        self.__spatial_bounds = spatial_bounds
1✔
342
        self.__time_interval = time_interval
1✔
343
        self.__srs = srs
1✔
344

345
    @property
1✔
346
    def bbox_str(self) -> str:
1✔
347
        """
348
        A comma-separated string representation of the spatial bounds
349
        """
350
        return self.__spatial_bounds.as_bbox_str()
1✔
351

352
    @property
1✔
353
    def bbox_ogc_str(self) -> str:
1✔
354
        """
355
        A comma-separated string representation of the spatial bounds with OGC axis ordering
356
        """
357
        y_axis_first = self.__srs == "EPSG:4326"
1✔
358
        return self.__spatial_bounds.as_bbox_str(y_axis_first=y_axis_first)
1✔
359

360
    @property
1✔
361
    def bbox_ogc(self) -> tuple[float, float, float, float]:
1✔
362
        """
363
        Return the bbox with OGC axis ordering of the srs
364
        """
365

366
        # TODO: properly handle axis order
367
        y_axis_first = self.__srs == "EPSG:4326"
1✔
368
        return self.__spatial_bounds.as_bbox_tuple(y_axis_first=y_axis_first)
1✔
369

370
    @property
1✔
371
    def time(self) -> TimeInterval:
1✔
372
        """
373
        Return the time instance or interval
374
        """
375
        return self.__time_interval
1✔
376

377
    @property
1✔
378
    def spatial_bounds(self) -> BoundingBox2D:
1✔
379
        """
380
        Return the spatial bounds
381
        """
382
        return self.__spatial_bounds
1✔
383

384
    @property
1✔
385
    def time_str(self) -> str:
1✔
386
        """
387
        Return the time instance or interval as a string representation
388
        """
389
        return self.time.time_str
1✔
390

391
    @property
1✔
392
    def srs(self) -> str:
1✔
393
        """
394
        Return the SRS string
395
        """
396
        return self.__srs
1✔
397

398
    def __repr__(self) -> str:
1✔
399
        """Return a string representation of the query rectangle."""
400
        r = "QueryRectangle( \n"
×
401
        r += "    " + repr(self.__spatial_bounds) + "\n"
×
402
        r += "    " + repr(self.__time_interval) + "\n"
×
403
        r += f"    srs={self.__srs} \n"
×
404
        r += ")"
×
405
        return r
×
406

407
    def with_raster_bands(self, raster_bands: list[int]) -> RasterQueryRectangle:
1✔
408
        """Converts a `QueryRectangle` into a `RasterQueryRectangle`"""
409
        return RasterQueryRectangle(self.spatial_bounds, self.time, raster_bands, self.srs)
1✔
410

411

412
class RasterQueryRectangle(QueryRectangle):
1✔
413
    """
414
    A multi-dimensional query rectangle, consisting of spatial and temporal information and raster bands.
415
    """
416

417
    __bands: list[int] = []
1✔
418

419
    def __init__(
1✔
420
        self,
421
        spatial_bounds: BoundingBox2D | SpatialPartition2D | tuple[float, float, float, float],
422
        time_interval: TimeInterval | tuple[datetime, datetime | None],
423
        raster_bands: list[int] | None | int,
424
        srs="EPSG:4326",
425
    ) -> None:
426
        """
427
        Initialize a new `QueryRectangle` object
428

429
        Parameters
430
        ----------
431
        spatial_bounds
432
            The spatial bounds of the query rectangle.
433
            Either a `BoundingBox2D` or a tuple of floats (xmin, ymin, xmax, ymax)
434
        time_interval
435
            The time interval of the query rectangle.
436
            Either a `TimeInterval` or a tuple of `datetime.datetime` objects (start, end)
437
        bands
438
            The raster bands of the query rectangle.
439
            A List of ints representing the band numbers.
440
        """
441

442
        super().__init__(spatial_bounds, time_interval, srs)
1✔
443
        if raster_bands is None:
1✔
444
            self.__bands = [0]
1✔
445
        elif isinstance(raster_bands, int):
1✔
446
            self.__bands = [raster_bands]
1✔
447
        else:
448
            self.__bands = raster_bands
1✔
449

450
    @property
1✔
451
    def raster_bands(self) -> list[int]:
1✔
452
        """
453
        Return the query bands
454
        """
455
        return self.__bands
1✔
456

457
    def __repr__(self) -> str:
1✔
458
        """Return a string representation of the query rectangle."""
459
        r = "RasterQueryRectangle( \n"
×
460
        r += "    " + repr(self.spatial_bounds) + "\n"
×
461
        r += "    " + repr(self.time) + "\n"
×
462
        r += "    " + repr(self.__bands) + "\n"
×
463
        r += f"    srs={self.srs} \n"
×
464
        r += ")"
×
465
        return r
×
466

467

468
class ResultDescriptor:  # pylint: disable=too-few-public-methods
1✔
469
    """
470
    Base class for result descriptors
471
    """
472

473
    __spatial_reference: str
1✔
474
    __time_bounds: TimeInterval | None
1✔
475

476
    def __init__(
1✔
477
        self,
478
        spatial_reference: str,
479
        time_bounds: TimeInterval | None = None,
480
    ) -> None:
481
        """Initialize a new `ResultDescriptor` object"""
482

483
        self.__spatial_reference = spatial_reference
1✔
484
        self.__time_bounds = time_bounds
1✔
485

486
    @staticmethod
1✔
487
    def from_response(response: geoengine_api_client.TypedResultDescriptor) -> ResultDescriptor:
1✔
488
        """
489
        Parse a result descriptor from an http response
490
        """
491

492
        inner = response.actual_instance
1✔
493

494
        if isinstance(inner, geoengine_api_client.TypedRasterResultDescriptor):
1✔
495
            return RasterResultDescriptor.from_response_raster(inner)
1✔
496
        if isinstance(inner, geoengine_api_client.TypedVectorResultDescriptor):
1✔
497
            return VectorResultDescriptor.from_response_vector(inner)
1✔
498
        if isinstance(inner, geoengine_api_client.TypedPlotResultDescriptor):
1✔
499
            return PlotResultDescriptor.from_response_plot(inner)
1✔
500

501
        raise TypeException("Unknown `ResultDescriptor` type")
×
502

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

510
    @classmethod
1✔
511
    def is_vector_result(cls) -> bool:
1✔
512
        """
513
        Return true if the result is of type vector
514
        """
515
        return False
1✔
516

517
    @classmethod
1✔
518
    def is_plot_result(cls) -> bool:
1✔
519
        """
520
        Return true if the result is of type plot
521
        """
522

523
        return False
×
524

525
    @property
1✔
526
    def spatial_reference(self) -> str:
1✔
527
        """Return the spatial reference"""
528

529
        return self.__spatial_reference
1✔
530

531
    @property
1✔
532
    def time_bounds(self) -> TimeInterval | None:
1✔
533
        """Return the time bounds"""
534

535
        return self.__time_bounds
1✔
536

537
    @abstractmethod
1✔
538
    def to_api_dict(self) -> geoengine_api_client.TypedResultDescriptor:
1✔
539
        pass
×
540

541

542
class VectorResultDescriptor(ResultDescriptor):
1✔
543
    """
544
    A vector result descriptor
545
    """
546

547
    __spatial_bounds: BoundingBox2D | None
1✔
548
    __data_type: VectorDataType
1✔
549
    __columns: dict[str, VectorColumnInfo]
1✔
550

551
    def __init__(  # pylint: disable=too-many-arguments,too-many-positional-arguments
1✔
552
        self,
553
        spatial_reference: str,
554
        data_type: VectorDataType,
555
        columns: dict[str, VectorColumnInfo],
556
        time_bounds: TimeInterval | None = None,
557
        spatial_bounds: BoundingBox2D | None = None,
558
    ) -> None:
559
        """Initialize a vector result descriptor"""
560
        super().__init__(spatial_reference, time_bounds)
1✔
561
        self.__data_type = data_type
1✔
562
        self.__columns = columns
1✔
563
        self.__spatial_bounds = spatial_bounds
1✔
564

565
    @staticmethod
1✔
566
    def from_response_vector(response: geoengine_api_client.TypedVectorResultDescriptor) -> VectorResultDescriptor:
1✔
567
        """Parse a vector result descriptor from an http response"""
568
        sref = response.spatial_reference
1✔
569
        data_type = VectorDataType.from_string(response.data_type)
1✔
570
        columns = {name: VectorColumnInfo.from_response(info) for name, info in response.columns.items()}
1✔
571

572
        time_bounds = None
1✔
573
        if response.time is not None:
1✔
574
            time_bounds = TimeInterval.from_response(response.time)
1✔
575
        spatial_bounds = None
1✔
576
        if response.bbox is not None:
1✔
577
            spatial_bounds = BoundingBox2D.from_response(response.bbox)
×
578

579
        return VectorResultDescriptor(sref, data_type, columns, time_bounds, spatial_bounds)
1✔
580

581
    @classmethod
1✔
582
    def is_vector_result(cls) -> bool:
1✔
583
        return True
1✔
584

585
    @property
1✔
586
    def data_type(self) -> VectorDataType:
1✔
587
        """Return the data type"""
588
        return self.__data_type
1✔
589

590
    @property
1✔
591
    def spatial_reference(self) -> str:
1✔
592
        """Return the spatial reference"""
593
        return super().spatial_reference
1✔
594

595
    @property
1✔
596
    def columns(self) -> dict[str, VectorColumnInfo]:
1✔
597
        """Return the columns"""
598

599
        return self.__columns
1✔
600

601
    @property
1✔
602
    def spatial_bounds(self) -> BoundingBox2D | None:
1✔
603
        """Return the spatial bounds"""
604
        return self.__spatial_bounds
1✔
605

606
    def __repr__(self) -> str:
1✔
607
        """Display representation of the vector result descriptor"""
608
        r = ""
1✔
609
        r += f"Data type:         {self.data_type.value}\n"
1✔
610
        r += f"Spatial Reference: {self.spatial_reference}\n"
1✔
611

612
        r += "Columns:\n"
1✔
613
        for column_name in self.columns:
1✔
614
            column_info = self.columns[column_name]
1✔
615
            r += f"  {column_name}:\n"
1✔
616
            r += f"    Column Type: {column_info.data_type.value}\n"
1✔
617
            r += f"    Measurement: {column_info.measurement}\n"
1✔
618

619
        return r
1✔
620

621
    def to_api_dict(self) -> geoengine_api_client.TypedResultDescriptor:
1✔
622
        """Convert the vector result descriptor to a dictionary"""
623

624
        return geoengine_api_client.TypedResultDescriptor(
1✔
625
            geoengine_api_client.TypedVectorResultDescriptor(
626
                type="vector",
627
                data_type=self.data_type.to_api_enum(),
628
                spatial_reference=self.spatial_reference,
629
                columns={name: column_info.to_api_dict() for name, column_info in self.columns.items()},
630
                time=self.time_bounds.to_api_dict() if self.time_bounds is not None else None,
631
                bbox=self.spatial_bounds.to_api_dict() if self.spatial_bounds is not None else None,
632
            )
633
        )
634

635

636
class FeatureDataType(str, Enum):
1✔
637
    """Vector column data type"""
638

639
    CATEGORY = "category"
1✔
640
    INT = "int"
1✔
641
    FLOAT = "float"
1✔
642
    TEXT = "text"
1✔
643
    BOOL = "bool"
1✔
644
    DATETIME = "dateTime"
1✔
645

646
    @staticmethod
1✔
647
    def from_string(data_type: str) -> FeatureDataType:
1✔
648
        """Create a new `VectorColumnDataType` from a string"""
649

650
        return FeatureDataType(data_type)
1✔
651

652
    def to_api_enum(self) -> geoengine_api_client.FeatureDataType:
1✔
653
        """Convert to an API enum"""
654

655
        return geoengine_api_client.FeatureDataType(self.value)
1✔
656

657

658
@dataclass
1✔
659
class VectorColumnInfo:
1✔
660
    """Vector column information"""
661

662
    data_type: FeatureDataType
1✔
663
    measurement: Measurement
1✔
664

665
    @staticmethod
1✔
666
    def from_response(response: geoengine_api_client.VectorColumnInfo) -> VectorColumnInfo:
1✔
667
        """Create a new `VectorColumnInfo` from a JSON response"""
668

669
        return VectorColumnInfo(
1✔
670
            FeatureDataType.from_string(data_type=response.data_type), Measurement.from_response(response.measurement)
671
        )
672

673
    def to_api_dict(self) -> geoengine_api_client.VectorColumnInfo:
1✔
674
        """Convert to a dictionary"""
675

676
        return geoengine_api_client.VectorColumnInfo(
1✔
677
            data_type=self.data_type.to_api_enum(),
678
            measurement=self.measurement.to_api_dict(),
679
        )
680

681

682
@dataclass(repr=False)
1✔
683
class RasterBandDescriptor:
1✔
684
    """A raster band descriptor"""
685

686
    name: str
1✔
687
    measurement: Measurement
1✔
688

689
    @classmethod
1✔
690
    def from_response(cls, response: geoengine_api_client.RasterBandDescriptor) -> RasterBandDescriptor:
1✔
691
        """Parse an http response to a `RasterBandDescriptor` object"""
692
        return RasterBandDescriptor(response.name, Measurement.from_response(response.measurement))
1✔
693

694
    def to_api_dict(self) -> geoengine_api_client.RasterBandDescriptor:
1✔
695
        return geoengine_api_client.RasterBandDescriptor(
1✔
696
            name=self.name,
697
            measurement=self.measurement.to_api_dict(),
698
        )
699

700
    def __repr__(self) -> str:
1✔
701
        """Display representation of a raster band descriptor"""
702
        return f"{self.name}: {self.measurement}"
×
703

704

705
@dataclass
1✔
706
class GridIdx2D:
1✔
707
    """A grid index"""
708

709
    x_idx: int
1✔
710
    y_idx: int
1✔
711

712
    @classmethod
1✔
713
    def from_response(cls, response: geoengine_api_client.GridIdx2D) -> GridIdx2D:
1✔
714
        """Parse an http response to a `GridIdx2D` object"""
715
        return GridIdx2D(x_idx=response.x_idx, y_idx=response.y_idx)
1✔
716

717
    def to_api_dict(self) -> geoengine_api_client.GridIdx2D:
1✔
718
        return geoengine_api_client.GridIdx2D(y_idx=self.y_idx, x_idx=self.x_idx)
1✔
719

720

721
@dataclass
1✔
722
class GridBoundingBox2D:
1✔
723
    """A grid boundingbox where lower right is inclusive index"""
724

725
    top_left_idx: GridIdx2D
1✔
726
    bottom_right_idx: GridIdx2D
1✔
727

728
    @classmethod
1✔
729
    def from_response(cls, response: geoengine_api_client.GridBoundingBox2D) -> GridBoundingBox2D:
1✔
730
        """Parse an http response to a `GridBoundingBox2D` object"""
731
        ul_idx = GridIdx2D.from_response(response.top_left_idx)
1✔
732
        lr_idx = GridIdx2D.from_response(response.bottom_right_idx)
1✔
733
        return GridBoundingBox2D(top_left_idx=ul_idx, bottom_right_idx=lr_idx)
1✔
734

735
    def to_api_dict(self) -> geoengine_api_client.GridBoundingBox2D:
1✔
736
        return geoengine_api_client.GridBoundingBox2D(
1✔
737
            top_left_idx=self.top_left_idx.to_api_dict(),
738
            bottom_right_idx=self.bottom_right_idx.to_api_dict(),
739
        )
740

741
    @property
1✔
742
    def width(self) -> int:
1✔
743
        return abs(self.bottom_right_idx.x_idx - self.top_left_idx.x_idx)
×
744

745
    @property
1✔
746
    def height(self) -> int:
1✔
747
        return abs(self.top_left_idx.y_idx - self.bottom_right_idx.y_idx)
×
748

749
    def contains_idx(self, idx: GridIdx2D) -> bool:
1✔
750
        """Test if a `GridIdx2D` is contained by this"""
751
        contains_x = self.top_left_idx.x_idx <= idx.x_idx <= self.bottom_right_idx.x_idx
×
752
        contains_y = self.top_left_idx.y_idx <= idx.y_idx <= self.bottom_right_idx.y_idx
×
753
        return contains_x and contains_y
×
754

755

756
@dataclass
1✔
757
class SpatialGridDefinition:
1✔
758
    """A grid boundingbox where lower right is inclusive index"""
759

760
    geo_transform: GeoTransform
1✔
761
    grid_bounds: GridBoundingBox2D
1✔
762

763
    @classmethod
1✔
764
    def from_response(cls, response: geoengine_api_client.SpatialGridDefinition) -> SpatialGridDefinition:
1✔
765
        """Parse an http response to a `SpatialGridDefinition` object"""
766
        geo_transform = GeoTransform.from_response(response.geo_transform)
1✔
767
        grid_bounds = GridBoundingBox2D.from_response(response.grid_bounds)
1✔
768
        return SpatialGridDefinition(geo_transform=geo_transform, grid_bounds=grid_bounds)
1✔
769

770
    def to_api_dict(self) -> geoengine_api_client.SpatialGridDefinition:
1✔
771
        return geoengine_api_client.SpatialGridDefinition(
1✔
772
            geo_transform=self.geo_transform.to_api_dict(),
773
            grid_bounds=self.grid_bounds.to_api_dict(),
774
        )
775

776
    def contains_idx(self, idx: GridIdx2D) -> bool:
1✔
777
        return self.grid_bounds.contains_idx(idx)
×
778

779
    def spatial_bounds(self) -> SpatialPartition2D:
1✔
780
        return self.geo_transform.grid_bounds_to_spatial_bounds(self.grid_bounds)
×
781

782
    def spatial_resolution(self) -> SpatialResolution:
1✔
783
        return self.geo_transform.spatial_resolution()
×
784

785
    def __repr__(self) -> str:
1✔
786
        """Display representation of the SpatialGridDefinition"""
787
        r = "SpatialGridDefinition: \n"
×
788
        r += f"    GeoTransform: {self.geo_transform}\n"
×
789
        r += f"    GridBounds: {self.grid_bounds}\n"
×
790
        return r
×
791

792

793
@dataclass
1✔
794
class SpatialGridDescriptor:
1✔
795
    """A grid boundingbox where lower right is inclusive index"""
796

797
    spatial_grid: SpatialGridDefinition
1✔
798
    descriptor: geoengine_api_client.SpatialGridDescriptorState
1✔
799

800
    @classmethod
1✔
801
    def from_response(cls, response: geoengine_api_client.SpatialGridDescriptor) -> SpatialGridDescriptor:
1✔
802
        """Parse an http response to a `SpatialGridDefinition` object"""
803
        spatial_grid = SpatialGridDefinition.from_response(response.spatial_grid)
1✔
804
        return SpatialGridDescriptor(spatial_grid=spatial_grid, descriptor=response.descriptor)
1✔
805

806
    def to_api_dict(self) -> geoengine_api_client.SpatialGridDescriptor:
1✔
807
        return geoengine_api_client.SpatialGridDescriptor(
1✔
808
            spatial_grid=self.spatial_grid.to_api_dict(),
809
            descriptor=self.descriptor,
810
        )
811

812
    def contains_idx(self, idx: GridIdx2D) -> bool:
1✔
813
        return self.spatial_grid.contains_idx(idx)
×
814

815
    def spatial_resolution(self) -> SpatialResolution:
1✔
816
        return self.spatial_grid.spatial_resolution()
×
817

818
    def spatial_bounds(self) -> SpatialPartition2D:
1✔
819
        return self.spatial_grid.spatial_bounds()
×
820

821
    def is_source(self) -> bool:
1✔
822
        return self.descriptor == "source"
×
823

824
    def is_derived(self) -> bool:
1✔
825
        return self.descriptor == "derived"
×
826

827
    def __repr__(self) -> str:
1✔
828
        """Display representation of the SpatialGridDescriptor"""
829
        r = "SpatialGridDescriptor: \n"
×
830
        r += f"    Definition: {self.spatial_grid}\n"
×
831
        r += f"    Is a {self.descriptor} grid.\n"
×
832
        return r
×
833

834

835
class RasterDataType(str, Enum):
1✔
836
    """Raster data type enum"""
837

838
    U8 = "U8"
1✔
839
    U16 = "U16"
1✔
840
    U32 = "U32"
1✔
841
    U64 = "U64"
1✔
842
    I8 = "I8"
1✔
843
    I16 = "I16"
1✔
844
    I32 = "I32"
1✔
845
    I64 = "I64"
1✔
846
    F32 = "F32"
1✔
847
    F64 = "F64"
1✔
848

849
    @staticmethod
1✔
850
    def from_string(data_type: str) -> RasterDataType:
1✔
851
        """Create a new `RasterDataType` from a string"""
852

853
        if data_type not in RasterDataType._value2member_map_:
1✔
854
            raise ValueError(f"Unknown RasterDataType: {data_type}")
×
855

856
        return RasterDataType(data_type)
1✔
857

858
    @staticmethod
1✔
859
    def from_literal(
1✔
860
        data_type: Literal["U8", "U16", "U32", "U64", "I8", "I16", "I32", "I64", "F32", "F64"],
861
    ) -> RasterDataType:
862
        """Create a new `RasterDataType` from a literal"""
863

864
        return RasterDataType(data_type)
×
865

866
    def to_literal(
1✔
867
        self,
868
    ) -> Literal["U8", "U16", "U32", "U64", "I8", "I16", "I32", "I64", "F32", "F64"]:
869
        """Convert to a literal"""
870

871
        return cast(
×
872
            Literal["U8", "U16", "U32", "U64", "I8", "I16", "I32", "I64", "F32", "F64"],
873
            self.value,
874
        )
875

876
    @staticmethod
1✔
877
    def from_api_enum(data_type: geoengine_api_client.RasterDataType) -> RasterDataType:
1✔
878
        """Create a new `RasterDataType` from an API enum"""
879

880
        return RasterDataType(data_type.value)
1✔
881

882
    def to_api_enum(self) -> geoengine_api_client.RasterDataType:
1✔
883
        """Convert to an API enum"""
884

885
        return geoengine_api_client.RasterDataType(self.value)
1✔
886

887
    def to_np_dtype(self) -> np.dtype:
1✔
888
        """Convert to a numpy dtype"""
889
        mapping = {
×
890
            RasterDataType.U8: np.uint8,
891
            RasterDataType.U16: np.uint16,
892
            RasterDataType.U32: np.uint32,
893
            RasterDataType.U64: np.uint64,
894
            RasterDataType.I8: np.int8,
895
            RasterDataType.I16: np.int16,
896
            RasterDataType.I32: np.int32,
897
            RasterDataType.I64: np.int64,
898
            RasterDataType.F32: np.float32,
899
            RasterDataType.F64: np.float64,
900
        }
901
        return np.dtype(mapping[self])
×
902

903

904
class TimeDimension:
1✔
905
    """A time dimension"""
906

907
    @classmethod
1✔
908
    def from_response(
1✔
909
        cls, response: geoengine_api_client.TimeDimension
910
    ) -> RegularTimeDimension | IrregularTimeDimension:
911
        """Parse a time dimension from an http response"""
912
        actual = response.actual_instance
1✔
913

914
        if actual is None:
1✔
915
            raise ValueError("input is None")
×
916

917
        if actual.type == "regular":
1✔
918
            if not isinstance(actual, geoengine_api_client.RegularTimeDimension):
1✔
919
                raise ValueError("Type should be regular!")
×
920
            return RegularTimeDimension.from_response(response)
1✔
921

922
        if actual.type == "irregular":
1✔
923
            return IrregularTimeDimension.from_response(response)
1✔
924

925
        raise ValueError("unknown input type")
×
926

927
    @abstractmethod
1✔
928
    def to_api_dict(self) -> geoengine_api_client.TimeDimension:
1✔
929
        pass
×
930

931

932
class RegularTimeDimension(TimeDimension):
1✔
933
    """
934
    A regular time dimension
935
    """
936

937
    origin: np.datetime64
1✔
938
    step: TimeStep
1✔
939

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

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

946
    def to_api_dict(self) -> geoengine_api_client.TimeDimension:
1✔
947
        """Convert the regular time dimension to a dictionary"""
948
        time_origin = self.origin.astype("datetime64[ms]").astype(int)
×
949
        return geoengine_api_client.TimeDimension(
×
950
            {"type": "regular", "origin": int(time_origin), "step": self.step.to_api_dict()}
951
        )
952

953
    @classmethod
1✔
954
    def from_response(cls, response: geoengine_api_client.TimeDimension) -> RegularTimeDimension:
1✔
955
        """Parse a regular time dimension from an http response"""
956

957
        actual = response.actual_instance
1✔
958

959
        if actual is None or actual.type != "regular":
1✔
960
            raise ValueError("type must be regular")
×
961

962
        if not isinstance(actual, geoengine_api_client.RegularTimeDimension):
1✔
963
            raise ValueError("Not a valid RegularTimeDimension")
×
964

965
        origin = np.datetime64(actual.origin, "ms")
1✔
966
        step = TimeStep.from_response(actual.step)
1✔
967
        return RegularTimeDimension(step=step, origin=origin)
1✔
968

969

970
class IrregularTimeDimension(TimeDimension):
1✔
971
    """The irregular time dimension"""
972

973
    def to_api_dict(self) -> geoengine_api_client.TimeDimension:
1✔
974
        """Convert the irregular time dimension to a dictionary"""
975

976
        return geoengine_api_client.TimeDimension({"type": "irregular"})
1✔
977

978
    @classmethod
1✔
979
    def from_response(cls, response: Any) -> IrregularTimeDimension:
1✔
980
        """Parse an irregular time dimension from an http response"""
981
        return IrregularTimeDimension()
1✔
982

983

984
class TimeDescriptor:
1✔
985
    """A time descriptor"""
986

987
    bounds: TimeInterval | None
1✔
988
    dimension: TimeDimension
1✔
989

990
    def __init__(self, dimension: TimeDimension, bounds: TimeInterval | None = None) -> None:
1✔
991
        """Initialize a new `TimeDescriptor`"""
992
        self.dimension = dimension
1✔
993
        self.bounds = bounds
1✔
994

995
    def to_api_dict(self) -> geoengine_api_client.TimeDescriptor:
1✔
996
        """Convert the time descriptor to a dictionary"""
997
        return geoengine_api_client.TimeDescriptor(
1✔
998
            dimension=self.dimension.to_api_dict(),
999
            bounds=self.bounds.to_api_dict() if self.bounds is not None else None,
1000
        )
1001

1002
    @staticmethod
1✔
1003
    def from_response(response: geoengine_api_client.TimeDescriptor) -> TimeDescriptor:
1✔
1004
        """Parse a time descriptor from an http response"""
1005
        bounds = None
1✔
1006
        dimension = None
1✔
1007

1008
        if response.bounds is not None:
1✔
1009
            bounds = TimeInterval.from_response(response.bounds)
1✔
1010

1011
        dimension = TimeDimension.from_response(response.dimension)
1✔
1012

1013
        return TimeDescriptor(bounds=bounds, dimension=dimension)
1✔
1014

1015

1016
class RasterResultDescriptor(ResultDescriptor):
1✔
1017
    """
1018
    A raster result descriptor
1019
    """
1020

1021
    __data_type: RasterDataType
1✔
1022
    __bands: list[RasterBandDescriptor]
1✔
1023
    __spatial_grid: SpatialGridDescriptor
1✔
1024
    __time: TimeDescriptor
1✔
1025

1026
    def __init__(  # pylint: disable=too-many-arguments,too-many-positional-arguments
1✔
1027
        self,
1028
        data_type: RasterDataType | Literal["U8", "U16", "U32", "U64", "I8", "I16", "I32", "I64", "F32", "F64"],
1029
        bands: list[RasterBandDescriptor],
1030
        spatial_reference: str,
1031
        spatial_grid: SpatialGridDescriptor,
1032
        time: TimeDescriptor | TimeInterval | TimeDimension | None = None,
1033
    ) -> None:
1034
        """Initialize a new `RasterResultDescriptor`"""
1035

1036
        time_descriptor = time
1✔
1037
        if isinstance(time, TimeInterval):
1✔
1038
            time_descriptor = TimeDescriptor(IrregularTimeDimension(), time)
×
1039
        elif isinstance(time, TimeDimension):
1✔
1040
            time_descriptor = TimeDescriptor(time, None)
×
1041
        elif time is None:
1✔
1042
            time_descriptor = TimeDescriptor(IrregularTimeDimension(), None)
×
1043

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

1047
        if isinstance(data_type, str):
1✔
1048
            data_type = RasterDataType.from_string(data_type)
1✔
1049
        elif not isinstance(data_type, RasterDataType):
×
1050
            raise ValueError(f"no valid RasterDataType, got {type(data_type)}")
×
1051

1052
        super().__init__(spatial_reference, time_descriptor.bounds)
1✔
1053
        self.__data_type = data_type
1✔
1054
        self.__bands = bands
1✔
1055
        self.__spatial_grid = spatial_grid
1✔
1056
        self.__time = time_descriptor
1✔
1057

1058
    def to_api_dict(self) -> geoengine_api_client.TypedResultDescriptor:
1✔
1059
        """Convert the raster result descriptor to a dictionary"""
1060

1061
        return geoengine_api_client.TypedResultDescriptor(
1✔
1062
            geoengine_api_client.TypedRasterResultDescriptor(
1063
                type="raster",
1064
                data_type=self.data_type.to_api_enum(),
1065
                bands=[band.to_api_dict() for band in self.__bands],
1066
                spatial_reference=self.spatial_reference,
1067
                time=self.__time.to_api_dict(),
1068
                spatial_grid=self.__spatial_grid.to_api_dict(),
1069
            )
1070
        )
1071

1072
    @staticmethod
1✔
1073
    def from_response_raster(response: geoengine_api_client.TypedRasterResultDescriptor) -> RasterResultDescriptor:
1✔
1074
        """Parse a raster result descriptor from an http response"""
1075
        spatial_ref = response.spatial_reference
1✔
1076
        data_type = RasterDataType.from_api_enum(response.data_type)
1✔
1077
        bands = [RasterBandDescriptor.from_response(band) for band in response.bands]
1✔
1078

1079
        spatial_grid = SpatialGridDescriptor.from_response(response.spatial_grid)
1✔
1080

1081
        time_bounds = TimeDescriptor.from_response(response.time)
1✔
1082

1083
        return RasterResultDescriptor(
1✔
1084
            data_type=data_type,
1085
            bands=bands,
1086
            spatial_reference=spatial_ref,
1087
            time=time_bounds,
1088
            spatial_grid=spatial_grid,
1089
        )
1090

1091
    @classmethod
1✔
1092
    def is_raster_result(cls) -> bool:
1✔
1093
        return True
1✔
1094

1095
    @property
1✔
1096
    def data_type(self) -> RasterDataType:
1✔
1097
        return self.__data_type
1✔
1098

1099
    @property
1✔
1100
    def bands(self) -> list[RasterBandDescriptor]:
1✔
1101
        return self.__bands
1✔
1102

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

1107
    @property
1✔
1108
    def spatial_bounds(self) -> SpatialPartition2D:
1✔
1109
        return self.spatial_grid.spatial_bounds()
×
1110

1111
    @property
1✔
1112
    def geo_transform(self) -> GeoTransform:
1✔
1113
        return self.spatial_grid.spatial_grid.geo_transform
×
1114

1115
    @property
1✔
1116
    def spatial_reference(self) -> str:
1✔
1117
        """Return the spatial reference"""
1118

1119
        return super().spatial_reference
1✔
1120

1121
    def __repr__(self) -> str:
1✔
1122
        """Display representation of the raster result descriptor"""
1123
        r = ""
×
1124
        r += f"Data type:         {self.data_type}\n"
×
1125
        r += f"Spatial Reference: {self.spatial_reference}\n"
×
1126
        r += f"Spatial Grid: {self.spatial_grid} \n"
×
1127
        r += f"Time Bounds: {self.time_bounds}\n"
×
1128
        r += "Bands:\n"
×
1129

1130
        for band in self.__bands:
×
1131
            r += f"    {band}\n"
×
1132

1133
        return r
×
1134

1135

1136
class PlotResultDescriptor(ResultDescriptor):
1✔
1137
    """
1138
    A plot result descriptor
1139
    """
1140

1141
    __spatial_bounds: BoundingBox2D | None
1✔
1142

1143
    def __init__(  # pylint: disable=too-many-arguments]
1✔
1144
        self,
1145
        spatial_reference: str,
1146
        time_bounds: TimeInterval | None = None,
1147
        spatial_bounds: BoundingBox2D | None = None,
1148
    ) -> None:
1149
        """Initialize a new `PlotResultDescriptor`"""
1150
        super().__init__(spatial_reference, time_bounds)
1✔
1151
        self.__spatial_bounds = spatial_bounds
1✔
1152

1153
    def __repr__(self) -> str:
1✔
1154
        """Display representation of the plot result descriptor"""
1155
        r = "Plot Result"
1✔
1156

1157
        return r
1✔
1158

1159
    @staticmethod
1✔
1160
    def from_response_plot(response: geoengine_api_client.TypedPlotResultDescriptor) -> PlotResultDescriptor:
1✔
1161
        """Create a new `PlotResultDescriptor` from a JSON response"""
1162
        spatial_ref = response.spatial_reference
1✔
1163

1164
        time_bounds = None
1✔
1165
        if response.time is not None:
1✔
1166
            time_bounds = TimeInterval.from_response(response.time)
1✔
1167
        spatial_bounds = None
1✔
1168
        if response.bbox is not None:
1✔
1169
            spatial_bounds = BoundingBox2D.from_response(response.bbox)
×
1170

1171
        return PlotResultDescriptor(
1✔
1172
            spatial_reference=spatial_ref, time_bounds=time_bounds, spatial_bounds=spatial_bounds
1173
        )
1174

1175
    @classmethod
1✔
1176
    def is_plot_result(cls) -> bool:
1✔
1177
        return True
1✔
1178

1179
    @property
1✔
1180
    def spatial_reference(self) -> str:
1✔
1181
        """Return the spatial reference"""
1182
        return super().spatial_reference
×
1183

1184
    @property
1✔
1185
    def spatial_bounds(self) -> BoundingBox2D | None:
1✔
1186
        return self.__spatial_bounds
×
1187

1188
    def to_api_dict(self) -> geoengine_api_client.TypedResultDescriptor:
1✔
1189
        """Convert the plot result descriptor to a dictionary"""
1190

1191
        return geoengine_api_client.TypedResultDescriptor(
×
1192
            geoengine_api_client.TypedPlotResultDescriptor(
1193
                type="plot",
1194
                spatial_reference=self.spatial_reference,
1195
                time=self.time_bounds.to_api_dict() if self.time_bounds is not None else None,
1196
                bbox=self.spatial_bounds.to_api_dict() if self.spatial_bounds is not None else None,
1197
            )
1198
        )
1199

1200

1201
class VectorDataType(str, Enum):
1✔
1202
    """An enum of vector data types"""
1203

1204
    DATA = "Data"
1✔
1205
    MULTI_POINT = "MultiPoint"
1✔
1206
    MULTI_LINE_STRING = "MultiLineString"
1✔
1207
    MULTI_POLYGON = "MultiPolygon"
1✔
1208

1209
    @classmethod
1✔
1210
    def from_geopandas_type_name(cls, name: str) -> VectorDataType:
1✔
1211
        """Resolve vector data type from geopandas geometry type"""
1212

1213
        name_map = {
1✔
1214
            "Point": VectorDataType.MULTI_POINT,
1215
            "MultiPoint": VectorDataType.MULTI_POINT,
1216
            "Line": VectorDataType.MULTI_LINE_STRING,
1217
            "MultiLine": VectorDataType.MULTI_LINE_STRING,
1218
            "Polygon": VectorDataType.MULTI_POLYGON,
1219
            "MultiPolygon": VectorDataType.MULTI_POLYGON,
1220
        }
1221

1222
        if name in name_map:
1✔
1223
            return name_map[name]
1✔
1224

1225
        raise InputException("Invalid vector data type")
×
1226

1227
    def to_api_enum(self) -> geoengine_api_client.VectorDataType:
1✔
1228
        return geoengine_api_client.VectorDataType(self.value)
1✔
1229

1230
    @staticmethod
1✔
1231
    def from_literal(literal: Literal["Data", "MultiPoint", "MultiLineString", "MultiPolygon"]) -> VectorDataType:
1✔
1232
        """Resolve vector data type from literal"""
1233
        return VectorDataType(literal)
×
1234

1235
    @staticmethod
1✔
1236
    def from_api_enum(data_type: geoengine_api_client.VectorDataType) -> VectorDataType:
1✔
1237
        """Resolve vector data type from API enum"""
1238
        return VectorDataType(data_type.value)
×
1239

1240
    @staticmethod
1✔
1241
    def from_string(string: str) -> VectorDataType:
1✔
1242
        """Resolve vector data type from string"""
1243
        if string not in VectorDataType.__members__.values():
1✔
1244
            raise InputException("Invalid vector data type: " + string)
×
1245
        return VectorDataType(string)
1✔
1246

1247

1248
class TimeStepGranularity(Enum):
1✔
1249
    """An enum of time step granularities"""
1250

1251
    MILLIS = "millis"
1✔
1252
    SECONDS = "seconds"
1✔
1253
    MINUTES = "minutes"
1✔
1254
    HOURS = "hours"
1✔
1255
    DAYS = "days"
1✔
1256
    MONTHS = "months"
1✔
1257
    YEARS = "years"
1✔
1258

1259
    def to_api_enum(self) -> geoengine_api_client.TimeGranularity:
1✔
1260
        return geoengine_api_client.TimeGranularity(self.value)
1✔
1261

1262

1263
@dataclass
1✔
1264
class TimeStep:
1✔
1265
    """A time step that consists of a granularity and a step size"""
1266

1267
    step: int
1✔
1268
    granularity: TimeStepGranularity
1✔
1269

1270
    def __init__(self, step: int, granularity: TimeStepGranularity | str) -> None:
1✔
1271
        """Initialize a new `TimeStep` object"""
1272
        self.step = step
×
1273
        if isinstance(granularity, str):
×
1274
            self.granularity = TimeStepGranularity(granularity)
×
1275
        elif isinstance(granularity, TimeStepGranularity):
×
1276
            self.granularity = granularity
×
1277
        else:
1278
            raise InputException("Invalid granularity type. Got: " + str(type(granularity)))
×
1279

1280
    def to_api_dict(self) -> geoengine_api_client.TimeStep:
1✔
1281
        return geoengine_api_client.TimeStep(
×
1282
            step=self.step,
1283
            granularity=self.granularity.to_api_enum(),
1284
        )
1285

1286
    @classmethod
1✔
1287
    def from_response(cls, response: geoengine_api_client.TimeStep) -> TimeStep:
1✔
1288
        """Parse an http response to a `TimeStep` object"""
1289
        granularity = TimeStepGranularity(response.granularity.value)
1✔
1290
        return TimeStep(step=response.step, granularity=granularity)
1✔
1291

1292

1293
@dataclass
1✔
1294
class Provenance:
1✔
1295
    """Provenance information as triplet of citation, license and uri"""
1296

1297
    citation: str
1✔
1298
    license: str
1✔
1299
    uri: str
1✔
1300

1301
    @classmethod
1✔
1302
    def from_response(cls, response: geoengine_api_client.Provenance) -> Provenance:
1✔
1303
        """Parse an http response to a `Provenance` object"""
1304
        return Provenance(response.citation, response.license, response.uri)
1✔
1305

1306
    def to_api_dict(self) -> geoengine_api_client.Provenance:
1✔
1307
        return geoengine_api_client.Provenance(
1✔
1308
            citation=self.citation,
1309
            license=self.license,
1310
            uri=self.uri,
1311
        )
1312

1313

1314
@dataclass
1✔
1315
class ProvenanceEntry:
1✔
1316
    """Provenance of a dataset"""
1317

1318
    data: list[DataId]
1✔
1319
    provenance: Provenance
1✔
1320

1321
    @classmethod
1✔
1322
    def from_response(cls, response: geoengine_api_client.ProvenanceEntry) -> ProvenanceEntry:
1✔
1323
        """Parse an http response to a `ProvenanceEntry` object"""
1324

1325
        dataset = [DataId.from_response(data) for data in response.data]
1✔
1326
        provenance = Provenance.from_response(response.provenance)
1✔
1327

1328
        return ProvenanceEntry(dataset, provenance)
1✔
1329

1330

1331
class Symbology:
1✔
1332
    """Base class for symbology"""
1333

1334
    @abstractmethod
1✔
1335
    def to_api_dict(self) -> geoengine_api_client.Symbology:
1✔
1336
        pass
×
1337

1338
    @staticmethod
1✔
1339
    def from_response(response: geoengine_api_client.Symbology) -> Symbology:
1✔
1340
        """Parse an http response to a `Symbology` object"""
1341
        inner = response.actual_instance
1✔
1342

1343
        if isinstance(
1✔
1344
            inner,
1345
            geoengine_api_client.PointSymbology
1346
            | geoengine_api_client.LineSymbology
1347
            | geoengine_api_client.PolygonSymbology,
1348
        ):
1349
            # return VectorSymbology.from_response_vector(response)
1350
            return VectorSymbology()  # TODO: implement
×
1351
        if isinstance(inner, geoengine_api_client.RasterSymbology):
1✔
1352
            return RasterSymbology.from_response_raster(inner)
1✔
1353

1354
        raise InputException("Invalid symbology type")
×
1355

1356
    def __repr__(self) -> str:
1✔
1357
        return "Symbology"
×
1358

1359

1360
class VectorSymbology(Symbology):
1✔
1361
    """A vector symbology"""
1362

1363
    # TODO: implement
1364

1365
    def to_api_dict(self) -> geoengine_api_client.Symbology:
1✔
1366
        return None  # type: ignore
×
1367

1368

1369
class RasterColorizer:
1✔
1370
    """Base class for raster colorizer"""
1371

1372
    @classmethod
1✔
1373
    def from_response(cls, response: geoengine_api_client.RasterColorizer) -> RasterColorizer:
1✔
1374
        """Parse an http response to a `RasterColorizer` object"""
1375
        inner = response.actual_instance
1✔
1376

1377
        if isinstance(inner, geoengine_api_client.SingleBandRasterColorizer):
1✔
1378
            return SingleBandRasterColorizer.from_single_band_response(inner)
1✔
1379
        if isinstance(inner, geoengine_api_client.MultiBandRasterColorizer):
1✔
1380
            return MultiBandRasterColorizer.from_multi_band_response(inner)
1✔
1381

1382
        raise GeoEngineException({"message": "Unknown RasterColorizer type"})
×
1383

1384
    @abstractmethod
1✔
1385
    def to_api_dict(self) -> geoengine_api_client.RasterColorizer:
1✔
1386
        pass
×
1387

1388

1389
@dataclass
1✔
1390
class SingleBandRasterColorizer(RasterColorizer):
1✔
1391
    """A raster colorizer for a specified band"""
1392

1393
    band: int
1✔
1394
    band_colorizer: Colorizer
1✔
1395

1396
    @staticmethod
1✔
1397
    def from_single_band_response(response: geoengine_api_client.SingleBandRasterColorizer) -> RasterColorizer:
1✔
1398
        return SingleBandRasterColorizer(response.band, Colorizer.from_response(response.band_colorizer))
1✔
1399

1400
    def to_api_dict(self) -> geoengine_api_client.RasterColorizer:
1✔
1401
        return geoengine_api_client.RasterColorizer(
1✔
1402
            geoengine_api_client.SingleBandRasterColorizer(
1403
                type="singleBand",
1404
                band=self.band,
1405
                band_colorizer=self.band_colorizer.to_api_dict(),
1406
            )
1407
        )
1408

1409

1410
@dataclass
1✔
1411
class MultiBandRasterColorizer(RasterColorizer):
1✔
1412
    """A raster colorizer for multiple bands"""
1413

1414
    blue_band: int
1✔
1415
    blue_max: float
1✔
1416
    blue_min: float
1✔
1417
    blue_scale: float | None
1✔
1418
    green_band: int
1✔
1419
    green_max: float
1✔
1420
    green_min: float
1✔
1421
    green_scale: float | None
1✔
1422
    red_band: int
1✔
1423
    red_max: float
1✔
1424
    red_min: float
1✔
1425
    red_scale: float | None
1✔
1426

1427
    @staticmethod
1✔
1428
    def from_multi_band_response(response: geoengine_api_client.MultiBandRasterColorizer) -> RasterColorizer:
1✔
1429
        return MultiBandRasterColorizer(
1✔
1430
            blue_band=response.blue_band,
1431
            blue_max=response.blue_max,
1432
            blue_min=response.blue_min,
1433
            blue_scale=response.blue_scale if response.blue_scale is not None else None,
1434
            green_band=response.green_band,
1435
            green_max=response.green_max,
1436
            green_min=response.green_min,
1437
            green_scale=response.green_scale if response.green_scale is not None else None,
1438
            red_band=response.red_band,
1439
            red_max=response.red_max,
1440
            red_min=response.red_min,
1441
            red_scale=response.red_scale if response.red_scale is not None else None,
1442
        )
1443

1444
    def to_api_dict(self) -> geoengine_api_client.RasterColorizer:
1✔
1445
        return geoengine_api_client.RasterColorizer(
1✔
1446
            geoengine_api_client.MultiBandRasterColorizer(
1447
                type="multiBand",
1448
                blue_band=self.blue_band,
1449
                blue_max=self.blue_max,
1450
                blue_min=self.blue_min,
1451
                blue_scale=self.blue_scale,
1452
                green_band=self.green_band,
1453
                green_max=self.green_max,
1454
                green_min=self.green_min,
1455
                green_scale=self.green_scale,
1456
                red_band=self.red_band,
1457
                red_max=self.red_max,
1458
                red_min=self.red_min,
1459
                red_scale=self.red_scale,
1460
            )
1461
        )
1462

1463

1464
class RasterSymbology(Symbology):
1✔
1465
    """A raster symbology"""
1466

1467
    opacity: float
1✔
1468
    raster_colorizer: RasterColorizer
1✔
1469

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

1473
        self.raster_colorizer = raster_colorizer
1✔
1474
        self.opacity = opacity
1✔
1475

1476
    def to_api_dict(self) -> geoengine_api_client.Symbology:
1✔
1477
        """Convert the raster symbology to a dictionary"""
1478

1479
        return geoengine_api_client.Symbology(
1✔
1480
            geoengine_api_client.RasterSymbology(
1481
                type="raster",
1482
                raster_colorizer=self.raster_colorizer.to_api_dict(),
1483
                opacity=self.opacity,
1484
            )
1485
        )
1486

1487
    @staticmethod
1✔
1488
    def from_response_raster(response: geoengine_api_client.RasterSymbology) -> RasterSymbology:
1✔
1489
        """Parse an http response to a `RasterSymbology` object"""
1490

1491
        raster_colorizer = RasterColorizer.from_response(response.raster_colorizer)
1✔
1492

1493
        return RasterSymbology(raster_colorizer, response.opacity)
1✔
1494

1495
    def __repr__(self) -> str:
1✔
1496
        return str(self.__class__) + f"({self.raster_colorizer}, {self.opacity})"
×
1497

1498
    def __eq__(self, value):
1✔
1499
        """Check if two RasterSymbologies are equal"""
1500

1501
        if not isinstance(value, self.__class__):
1✔
1502
            return False
×
1503
        return self.opacity == value.opacity and self.raster_colorizer == value.raster_colorizer
1✔
1504

1505

1506
class DataId:  # pylint: disable=too-few-public-methods
1✔
1507
    """Base class for data ids"""
1508

1509
    @classmethod
1✔
1510
    def from_response(cls, response: geoengine_api_client.DataId) -> DataId:
1✔
1511
        """Parse an http response to a `DataId` object"""
1512
        inner = response.actual_instance
1✔
1513

1514
        if isinstance(inner, geoengine_api_client.InternalDataId):
1✔
1515
            return InternalDataId.from_response_internal(inner)
1✔
1516
        if isinstance(inner, geoengine_api_client.ExternalDataId):
×
1517
            return ExternalDataId.from_response_external(inner)
×
1518

1519
        raise GeoEngineException({"message": "Unknown DataId type"})
×
1520

1521
    @abstractmethod
1✔
1522
    def to_api_dict(self) -> geoengine_api_client.DataId:
1✔
1523
        pass
×
1524

1525

1526
class InternalDataId(DataId):
1✔
1527
    """An internal data id"""
1528

1529
    __dataset_id: UUID
1✔
1530

1531
    def __init__(self, dataset_id: UUID):
1✔
1532
        self.__dataset_id = dataset_id
1✔
1533

1534
    @classmethod
1✔
1535
    def from_response_internal(cls, response: geoengine_api_client.InternalDataId) -> InternalDataId:
1✔
1536
        """Parse an http response to a `InternalDataId` object"""
1537
        return InternalDataId(response.dataset_id)
1✔
1538

1539
    def to_api_dict(self) -> geoengine_api_client.DataId:
1✔
1540
        return geoengine_api_client.DataId(
×
1541
            geoengine_api_client.InternalDataId(type="internal", dataset_id=str(self.__dataset_id))
1542
        )
1543

1544
    def __str__(self) -> str:
1✔
1545
        return str(self.__dataset_id)
×
1546

1547
    def __repr__(self) -> str:
1✔
1548
        """Display representation of an internal data id"""
1549
        return str(self)
×
1550

1551
    def __eq__(self, other) -> bool:
1✔
1552
        """Check if two internal data ids are equal"""
1553
        if not isinstance(other, self.__class__):
1✔
1554
            return False
×
1555

1556
        return self.__dataset_id == other.__dataset_id  # pylint: disable=protected-access
1✔
1557

1558

1559
class ExternalDataId(DataId):
1✔
1560
    """An external data id"""
1561

1562
    __provider_id: UUID
1✔
1563
    __layer_id: str
1✔
1564

1565
    def __init__(self, provider_id: UUID, layer_id: str):
1✔
1566
        self.__provider_id = provider_id
×
1567
        self.__layer_id = layer_id
×
1568

1569
    @classmethod
1✔
1570
    def from_response_external(cls, response: geoengine_api_client.ExternalDataId) -> ExternalDataId:
1✔
1571
        """Parse an http response to a `ExternalDataId` object"""
1572

1573
        return ExternalDataId(response.provider_id, response.layer_id)
×
1574

1575
    def to_api_dict(self) -> geoengine_api_client.DataId:
1✔
1576
        return geoengine_api_client.DataId(
×
1577
            geoengine_api_client.ExternalDataId(
1578
                type="external",
1579
                provider_id=str(self.__provider_id),
1580
                layer_id=self.__layer_id,
1581
            )
1582
        )
1583

1584
    def __str__(self) -> str:
1✔
1585
        return f"{self.__provider_id}:{self.__layer_id}"
×
1586

1587
    def __repr__(self) -> str:
1✔
1588
        """Display representation of an external data id"""
1589
        return str(self)
×
1590

1591
    def __eq__(self, other) -> bool:
1✔
1592
        """Check if two external data ids are equal"""
1593
        if not isinstance(other, self.__class__):
×
1594
            return False
×
1595

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

1598

1599
class Measurement:  # pylint: disable=too-few-public-methods
1✔
1600
    """
1601
    Base class for measurements
1602
    """
1603

1604
    @staticmethod
1✔
1605
    def from_response(response: geoengine_api_client.Measurement) -> Measurement:
1✔
1606
        """
1607
        Parse a result descriptor from an http response
1608
        """
1609
        inner = response.actual_instance
1✔
1610

1611
        if isinstance(inner, geoengine_api_client.UnitlessMeasurement):
1✔
1612
            return UnitlessMeasurement()
1✔
1613
        if isinstance(inner, geoengine_api_client.ContinuousMeasurement):
1✔
1614
            return ContinuousMeasurement.from_response_continuous(inner)
1✔
1615
        if isinstance(inner, geoengine_api_client.ClassificationMeasurement):
1✔
1616
            return ClassificationMeasurement.from_response_classification(inner)
1✔
1617

1618
        raise TypeException("Unknown `Measurement` type")
×
1619

1620
    @abstractmethod
1✔
1621
    def to_api_dict(self) -> geoengine_api_client.Measurement:
1✔
1622
        pass
×
1623

1624

1625
class UnitlessMeasurement(Measurement):
1✔
1626
    """A measurement that is unitless"""
1627

1628
    def __str__(self) -> str:
1✔
1629
        """String representation of a unitless measurement"""
1630
        return "unitless"
1✔
1631

1632
    def __repr__(self) -> str:
1✔
1633
        """Display representation of a unitless measurement"""
1634
        return str(self)
×
1635

1636
    def to_api_dict(self) -> geoengine_api_client.Measurement:
1✔
1637
        return geoengine_api_client.Measurement(geoengine_api_client.UnitlessMeasurement(type="unitless"))
1✔
1638

1639

1640
class ContinuousMeasurement(Measurement):
1✔
1641
    """A measurement that is continuous"""
1642

1643
    __measurement: str
1✔
1644
    __unit: str | None
1✔
1645

1646
    def __init__(self, measurement: str, unit: str | None) -> None:
1✔
1647
        """Initialize a new `ContiuousMeasurement`"""
1648

1649
        super().__init__()
1✔
1650

1651
        self.__measurement = measurement
1✔
1652
        self.__unit = unit
1✔
1653

1654
    @staticmethod
1✔
1655
    def from_response_continuous(response: geoengine_api_client.ContinuousMeasurement) -> ContinuousMeasurement:
1✔
1656
        """Initialize a new `ContiuousMeasurement from a JSON response"""
1657

1658
        return ContinuousMeasurement(response.measurement, response.unit)
1✔
1659

1660
    def __str__(self) -> str:
1✔
1661
        """String representation of a continuous measurement"""
1662

1663
        if self.__unit is None:
1✔
1664
            return self.__measurement
1✔
1665

1666
        return f"{self.__measurement} ({self.__unit})"
×
1667

1668
    def __repr__(self) -> str:
1✔
1669
        """Display representation of a continuous measurement"""
1670
        return str(self)
×
1671

1672
    def to_api_dict(self) -> geoengine_api_client.Measurement:
1✔
1673
        return geoengine_api_client.Measurement(
1✔
1674
            geoengine_api_client.ContinuousMeasurement(
1675
                type="continuous", measurement=self.__measurement, unit=self.__unit
1676
            )
1677
        )
1678

1679
    @property
1✔
1680
    def measurement(self) -> str:
1✔
1681
        return self.__measurement
×
1682

1683
    @property
1✔
1684
    def unit(self) -> str | None:
1✔
1685
        return self.__unit
×
1686

1687

1688
class ClassificationMeasurement(Measurement):
1✔
1689
    """A measurement that is a classification"""
1690

1691
    __measurement: str
1✔
1692
    __classes: dict[int, str]
1✔
1693

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

1697
        super().__init__()
1✔
1698

1699
        self.__measurement = measurement
1✔
1700
        self.__classes = classes
1✔
1701

1702
    @staticmethod
1✔
1703
    def from_response_classification(
1✔
1704
        response: geoengine_api_client.ClassificationMeasurement,
1705
    ) -> ClassificationMeasurement:
1706
        """Initialize a new `ClassificationMeasurement from a JSON response"""
1707

1708
        measurement = response.measurement
1✔
1709

1710
        str_classes: dict[str, str] = response.classes
1✔
1711
        classes = {int(k): v for k, v in str_classes.items()}
1✔
1712

1713
        return ClassificationMeasurement(measurement, classes)
1✔
1714

1715
    def to_api_dict(self) -> geoengine_api_client.Measurement:
1✔
1716
        str_classes: dict[str, str] = {str(k): v for k, v in self.__classes.items()}
1✔
1717

1718
        return geoengine_api_client.Measurement(
1✔
1719
            geoengine_api_client.ClassificationMeasurement(
1720
                type="classification", measurement=self.__measurement, classes=str_classes
1721
            )
1722
        )
1723

1724
    def __str__(self) -> str:
1✔
1725
        """String representation of a classification measurement"""
1726
        classes_str = ", ".join(f"{k}: {v}" for k, v in self.__classes.items())
×
1727
        return f"{self.__measurement} ({classes_str})"
×
1728

1729
    def __repr__(self) -> str:
1✔
1730
        """Display representation of a classification measurement"""
1731
        return str(self)
×
1732

1733
    @property
1✔
1734
    def measurement(self) -> str:
1✔
1735
        return self.__measurement
×
1736

1737
    @property
1✔
1738
    def classes(self) -> dict[int, str]:
1✔
1739
        return self.__classes
×
1740

1741

1742
class GeoTransform:
1✔
1743
    """The `GeoTransform` specifies the relationship between pixel coordinates and geographic coordinates."""
1744

1745
    x_min: float
1✔
1746
    y_max: float
1✔
1747
    """In Geo Engine, x_pixel_size is always positive."""
1✔
1748
    x_pixel_size: float
1✔
1749
    """In Geo Engine, y_pixel_size is always negative."""
1✔
1750
    y_pixel_size: float
1✔
1751

1752
    def __init__(self, x_min: float, y_max: float, x_pixel_size: float, y_pixel_size: float):
1✔
1753
        """Initialize a new `GeoTransform`"""
1754

1755
        assert x_pixel_size > 0, "In Geo Engine, x_pixel_size is always positive."
1✔
1756
        # assert y_pixel_size < 0, "In Geo Engine, y_pixel_size is always negative."
1757

1758
        self.x_min = x_min
1✔
1759
        self.y_max = y_max
1✔
1760
        self.x_pixel_size = x_pixel_size
1✔
1761
        self.y_pixel_size = y_pixel_size
1✔
1762

1763
    @classmethod
1✔
1764
    def from_response(cls, response: geoengine_api_client.GeoTransform) -> GeoTransform:
1✔
1765
        """Parse a geotransform from an HTTP JSON response"""
1766

1767
        return GeoTransform(
1✔
1768
            x_min=response.origin_coordinate.x,
1769
            y_max=response.origin_coordinate.y,
1770
            x_pixel_size=response.x_pixel_size,
1771
            y_pixel_size=response.y_pixel_size,
1772
        )
1773

1774
    def to_api_dict(self) -> geoengine_api_client.GeoTransform:
1✔
1775
        """Convert the geotransform for an API request"""
1776
        return geoengine_api_client.GeoTransform(
1✔
1777
            origin_coordinate=geoengine_api_client.Coordinate2D(
1778
                x=self.x_min,
1779
                y=self.y_max,
1780
            ),
1781
            x_pixel_size=self.x_pixel_size,
1782
            y_pixel_size=self.y_pixel_size,
1783
        )
1784

1785
    def to_gdal(self) -> tuple[float, float, float, float, float, float]:
1✔
1786
        """Convert to a GDAL geotransform"""
1787
        return (self.x_min, self.x_pixel_size, 0, self.y_max, 0, self.y_pixel_size)
×
1788

1789
    def __str__(self) -> str:
1✔
1790
        return (
×
1791
            f"Origin: ({self.x_min}, {self.y_max}), "
1792
            f"X Pixel Size: {self.x_pixel_size}, "
1793
            f"Y Pixel Size: {self.y_pixel_size}"
1794
        )
1795

1796
    def __repr__(self) -> str:
1✔
1797
        return str(self)
×
1798

1799
    @property
1✔
1800
    def x_half_pixel_size(self) -> float:
1✔
1801
        return self.x_pixel_size / 2.0
1✔
1802

1803
    @property
1✔
1804
    def y_half_pixel_size(self) -> float:
1✔
1805
        return self.y_pixel_size / 2.0
1✔
1806

1807
    def pixel_x_to_coord_x(self, pixel: int) -> float:
1✔
1808
        return self.x_min + pixel * self.x_pixel_size
1✔
1809

1810
    def pixel_y_to_coord_y(self, pixel: int) -> float:
1✔
1811
        return self.y_max + pixel * self.y_pixel_size
1✔
1812

1813
    def coord_to_pixel_ul(self, x_cord: float, y_coord: float) -> GridIdx2D:
1✔
1814
        """Convert a coordinate to a pixel index rould towards top left"""
1815
        return GridIdx2D(
1✔
1816
            x_idx=int(np.floor((x_cord - self.x_min) / self.x_pixel_size)),
1817
            y_idx=int(np.ceil((y_coord - self.y_max) / self.y_pixel_size)),
1818
        )
1819

1820
    def coord_to_pixel_lr(self, x_cord: float, y_coord: float) -> GridIdx2D:
1✔
1821
        """Convert a coordinate to a pixel index ound towards lower right"""
1822
        return GridIdx2D(
×
1823
            x_idx=int(np.ceil((x_cord - self.x_min) / self.x_pixel_size)),
1824
            y_idx=int(np.floor((y_coord - self.y_max) / self.y_pixel_size)),
1825
        )
1826

1827
    def pixel_ul_to_coord(self, x_pixel: int, y_pixel: int) -> tuple[float, float]:
1✔
1828
        """Convert a pixel position into a coordinate"""
1829
        x = self.pixel_x_to_coord_x(x_pixel)
1✔
1830
        y = self.pixel_y_to_coord_y(y_pixel)
1✔
1831
        return (x, y)
1✔
1832

1833
    def pixel_lr_to_coord(self, x_pixel: int, y_pixel: int) -> tuple[float, float]:
1✔
1834
        (x, y) = self.pixel_ul_to_coord(x_pixel, y_pixel)
×
1835
        return (x + self.x_pixel_size, y + self.y_pixel_size)
×
1836

1837
    def pixel_center_to_coord(self, x_pixel, y_pixel) -> tuple[float, float]:
1✔
1838
        (x, y) = self.pixel_ul_to_coord(x_pixel, y_pixel)
×
1839
        return (x + self.x_half_pixel_size, y + self.y_half_pixel_size)
×
1840

1841
    def spatial_resolution(self) -> SpatialResolution:
1✔
1842
        return SpatialResolution(x_resolution=abs(self.x_pixel_size), y_resolution=abs(self.y_pixel_size))
×
1843

1844
    def spatial_to_grid_bounds(self, bounds: SpatialPartition2D | BoundingBox2D) -> GridBoundingBox2D:
1✔
1845
        """Converts a BoundingBox2D or a SpatialPartition2D into a GridBoundingBox2D"""
1846
        ul = self.coord_to_pixel_ul(bounds.xmin, bounds.ymax)
×
1847
        rl = self.coord_to_pixel_lr(bounds.xmax, bounds.ymin)
×
1848
        return GridBoundingBox2D(top_left_idx=ul, bottom_right_idx=rl)
×
1849

1850
    def grid_bounds_to_spatial_bounds(self, bounds: GridBoundingBox2D) -> SpatialPartition2D:
1✔
1851
        """Converts a GridBoundingBox2D into a SpatialPartition2D"""
1852
        xmin, ymax = self.pixel_ul_to_coord(bounds.top_left_idx.x_idx, bounds.top_left_idx.y_idx)
×
1853
        xmax, ymin = self.pixel_lr_to_coord(bounds.bottom_right_idx.x_idx, bounds.bottom_right_idx.y_idx)
×
1854
        return SpatialPartition2D(xmin, ymin, xmax, ymax)
×
1855

1856
    def __eq__(self, other) -> bool:
1✔
1857
        """Check if two geotransforms are equal"""
1858
        if not isinstance(other, GeoTransform):
×
1859
            return False
×
1860

1861
        return (
×
1862
            self.x_min == other.x_min
1863
            and self.y_max == other.y_max
1864
            and self.x_pixel_size == other.x_pixel_size
1865
            and self.y_pixel_size == other.y_pixel_size
1866
        )
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