pyelq 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,598 @@
1
+ # SPDX-FileCopyrightText: 2024 Shell Global Solutions International B.V. All Rights Reserved.
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # -*- coding: utf-8 -*-
6
+ """Coordinate System.
7
+
8
+ This code provides the definition of, and the functionality for, all the main coordinate systems that are used in
9
+ pyELQ. Each coordinate system has relevant methods for features that are commonly required. Also provided is a set of
10
+ conversions between each of the systems, alongside some functionality for interpolation.
11
+
12
+ """
13
+ from abc import ABC, abstractmethod
14
+ from copy import deepcopy
15
+ from dataclasses import dataclass, field
16
+ from typing import Union
17
+
18
+ import numpy as np
19
+ import pymap3d as pm
20
+ from scipy.spatial import KDTree
21
+ from scipy.stats import qmc
22
+
23
+ import pyelq.support_functions.spatio_temporal_interpolation as sti
24
+
25
+
26
+ def make_latin_hypercube(bounds: np.ndarray, nof_samples: int) -> np.ndarray:
27
+ """Latin Hypercube samples.
28
+
29
+ Draw samples according to a Latin Hypercube design within the specified bounds.
30
+
31
+ Args:
32
+ bounds (np.ndarray): Limits of the resulting hypercube of size [dim x 2]
33
+ nof_samples (int): Number of samples to draw
34
+
35
+ Returns:
36
+ array (np.ndarray): Samples forming the Latin Hypercube
37
+
38
+ """
39
+ dimension = bounds.shape[0]
40
+ sampler = qmc.LatinHypercube(d=dimension)
41
+ sample = sampler.random(n=nof_samples)
42
+ array = qmc.scale(sample, np.min(bounds, axis=1), np.max(bounds, axis=1))
43
+ return array
44
+
45
+
46
+ @dataclass
47
+ class Coordinate(ABC):
48
+ """Abstract base class for coordinate transformations.
49
+
50
+ Attributes:
51
+ use_degrees (bool): Flag if reference uses degrees (True) or radians (False). Defaults to True.
52
+ ellipsoid (pm.Ellipsoid): Definition of the Ellipsoid used in the coordinate system, for which the default is
53
+ WGS84. See: https://en.wikipedia.org/wiki/World_Geodetic_System.
54
+
55
+ """
56
+
57
+ use_degrees: bool = field(init=False)
58
+ ellipsoid: pm.Ellipsoid = field(init=False)
59
+
60
+ def __post_init__(self):
61
+ self.use_degrees = True
62
+ self.ellipsoid = pm.Ellipsoid.from_name("wgs84")
63
+
64
+ @property
65
+ @abstractmethod
66
+ def nof_observations(self) -> int:
67
+ """Number of observations contained in the class instance, implemented as dependent property."""
68
+
69
+ @abstractmethod
70
+ def from_array(self, array: np.ndarray) -> None:
71
+ """Unstack a numpy array into the corresponding coordinates.
72
+
73
+ The method has no return as it sets the corresponding attributes of the coordinate class instance.
74
+
75
+ Args:
76
+ array (np.ndarray): Numpy array of size [n x dim] with n>0 containing the coordinates stacked into a single
77
+ array
78
+
79
+ """
80
+
81
+ @abstractmethod
82
+ def to_array(self, dim: int = 3) -> np.ndarray:
83
+ """Stacks coordinates together into a numpy array.
84
+
85
+ Args:
86
+ dim (int, optional): Number of dimensions to use, which is either 2 or 3.
87
+
88
+ Returns:
89
+ np.ndarray: Numpy array of size [n x dim] with n>0 containing the coordinates stacked into a single array
90
+
91
+ """
92
+
93
+ @abstractmethod
94
+ def to_lla(self):
95
+ """LLA: Converts coordinates to latitude/longitude/altitude system."""
96
+
97
+ @abstractmethod
98
+ def to_ecef(self):
99
+ """ECEF: Convert coordinates to earth centered earth fixed coordinates."""
100
+
101
+ @abstractmethod
102
+ def to_enu(self, ref_latitude: float = None, ref_longitude: float = None, ref_altitude: float = None):
103
+ """Converts coordinates to East North Up system.
104
+
105
+ If a reference is not provided, the minimum of coordinates in Lat/Lon/Alt is used as the reference.
106
+
107
+ Args:
108
+ ref_latitude (float, optional): reference latitude for ENU
109
+ ref_longitude (float, optional): reference longitude for ENU
110
+ ref_altitude (float, optional): reference altitude for ENU
111
+
112
+ Returns:
113
+ (ENU): East North Up coordinate object
114
+
115
+ """
116
+
117
+ def to_object_type(self, coordinate_object):
118
+ """Converts current object to same class as input coordinate_object.
119
+
120
+ Args:
121
+ coordinate_object (Coordinate): An coordinate object which provides the coordinate system to convert self to
122
+
123
+ Returns:
124
+ (Coordinate): The converted coordinate object
125
+
126
+ """
127
+ if type(coordinate_object) is not type(self):
128
+ if isinstance(coordinate_object, LLA):
129
+ temp_object = self.to_lla()
130
+ elif isinstance(coordinate_object, ENU):
131
+ temp_object = self.to_enu(
132
+ ref_latitude=coordinate_object.ref_latitude,
133
+ ref_longitude=coordinate_object.ref_longitude,
134
+ ref_altitude=coordinate_object.ref_altitude,
135
+ )
136
+ elif isinstance(coordinate_object, ECEF):
137
+ temp_object = self.to_ecef()
138
+ else:
139
+ raise TypeError("Please provide a valid coordinate type")
140
+
141
+ return temp_object
142
+
143
+ return self
144
+
145
+ def interpolate(self, values: np.ndarray, locations, dim: int = 3, **kwargs) -> np.ndarray:
146
+ """Interpolate data using coordinate object.
147
+
148
+ If locations coordinate system does not match self's coordinate system it will be converted to same type as
149
+ self. In the ENU case extra checking needs to take place to check reference locations match up.
150
+
151
+ If only 1 value is provided which needs to be interpolated to many other locations we just set the value at all
152
+ these locations to the single input value
153
+
154
+ Args:
155
+ values (np.ndarray): Values to interpolate, consistent with location in self
156
+ locations (Coordinate): Coordinate object containing locations to which you want to interpolate
157
+ dim (int): Number of dimensions to use for interpolation (2 or 3)
158
+ **kwargs (dict): Other arguments available in scipy.interpolate.griddata e.g. method, fill_value
159
+
160
+ Returns:
161
+ Result (np.ndarray): Interpolated values at requested locations.
162
+
163
+ """
164
+ locations = locations.to_object_type(coordinate_object=self)
165
+
166
+ if isinstance(self, ENU):
167
+ if (
168
+ self.ref_latitude != locations.ref_latitude
169
+ or self.ref_longitude != locations.ref_longitude
170
+ or self.ref_altitude != locations.ref_altitude
171
+ ):
172
+ locations = locations.to_lla()
173
+ locations = locations.to_enu(
174
+ ref_latitude=self.ref_latitude, ref_longitude=self.ref_longitude, ref_altitude=self.ref_altitude
175
+ )
176
+ result = sti.interpolate(
177
+ location_in=self.to_array(dim),
178
+ values_in=values.flatten(),
179
+ location_out=locations.to_array(dim=dim),
180
+ **kwargs,
181
+ )
182
+
183
+ return result
184
+
185
+ def make_grid(
186
+ self, bounds: np.ndarray, grid_type: str = "rectangular", shape: Union[tuple, np.ndarray] = (5, 5, 1)
187
+ ) -> np.ndarray:
188
+ """Generates grid of values locations based on specified inputs.
189
+
190
+ If the grid type is 'spherical', we scale the latitude and longitude from -90/90 and -180/180 to 0/1 for the
191
+ use in temp_lat_rad and temp_lon_rad.
192
+
193
+ Args:
194
+ bounds (np.ndarray): Limits of the grid on which to generate the grid of size [dim x 2]
195
+ if dim == 2 we assume the third dimension will be zeros
196
+ grid_type (str, optional): Type of grid to generate, default 'rectangular':
197
+ rectangular == rectangular grid of shape grd_shape,
198
+ spherical == grid of shape grid_shape taking into account a spherical spacing
199
+ shape: (tuple, optional): Number of grid cells to generate in each dimension, total number of
200
+ grid cells will be the product of the entries of this tuple
201
+
202
+ Returns
203
+ np.ndarray: gridded of locations
204
+
205
+ """
206
+ dimension = bounds.shape[0]
207
+
208
+ if grid_type == "rectangular":
209
+ dim_0 = np.linspace(bounds[0, 0], bounds[0, 1], num=shape[0])
210
+ dim_1 = np.linspace(bounds[1, 0], bounds[1, 1], num=shape[1])
211
+ if dimension == 3:
212
+ dim_2 = np.linspace(bounds[2, 0], bounds[2, 1], num=shape[2])
213
+ else:
214
+ dim_2 = np.array(0)
215
+
216
+ dim_0, dim_1, dim_2 = np.meshgrid(dim_0, dim_1, dim_2)
217
+ array = np.stack([dim_0.flatten(), dim_1.flatten(), dim_2.flatten()], axis=1)
218
+ elif grid_type == "spherical":
219
+ temp_object = deepcopy(self)
220
+ temp_object.from_array(array=bounds)
221
+ temp_object = temp_object.to_lla()
222
+ temp_object.latitude = (temp_object.latitude - (-90)) / 180
223
+ temp_object.longitude = (temp_object.longitude - (-180)) / 360
224
+
225
+ temp_lat_rad = np.linspace(start=temp_object.latitude[0], stop=temp_object.latitude[1], num=shape[0])
226
+ temp_lon_rad = np.linspace(start=temp_object.longitude[0], stop=temp_object.longitude[1], num=shape[1])
227
+
228
+ longitude = (2 * np.pi * temp_lon_rad - np.pi) * 180 / np.pi
229
+ latitude = (np.arccos(1 - 2 * temp_lat_rad) - 0.5 * np.pi) * 180 / np.pi
230
+ if dimension == 3:
231
+ altitude = np.linspace(start=temp_object.altitude[0], stop=temp_object.altitude[1], num=shape[2])
232
+ latitude, longitude, altitude = np.meshgrid(latitude, longitude, altitude)
233
+ array = np.stack(
234
+ [latitude.flatten() * np.pi / 180, longitude.flatten() * np.pi / 180, altitude.flatten()], axis=1
235
+ )
236
+ else:
237
+ latitude, longitude = np.meshgrid(latitude, longitude)
238
+ array = np.stack([latitude.flatten() * np.pi / 180, longitude.flatten() * np.pi / 180], axis=1)
239
+
240
+ temp_object.from_array(array=array)
241
+ temp_object = temp_object.to_object_type(self)
242
+ array = temp_object.to_array()
243
+ else:
244
+ raise NotImplementedError("Please provide a valid grid type")
245
+
246
+ return array
247
+
248
+ def create_tree(self) -> KDTree:
249
+ """Create KD tree for the purpose of fast distance computation.
250
+
251
+ Returns:
252
+ KDTree: Spatial KD tree
253
+
254
+ """
255
+ return KDTree(self.to_array())
256
+
257
+
258
+ @dataclass
259
+ class LLA(Coordinate):
260
+ """Defines the properties and functionality of the latitude/ longitude/ altitude coordinate system.
261
+
262
+ Attributes:
263
+ latitude (np.ndarray): Latitude values in degrees.
264
+ longitude (np.ndarray): Longitude values in degrees.
265
+ altitude (np.ndarray): Altitude values in meters with respect to a spheroid.
266
+
267
+ """
268
+
269
+ latitude: np.ndarray = None
270
+ longitude: np.ndarray = None
271
+ altitude: np.ndarray = None
272
+
273
+ @property
274
+ def nof_observations(self):
275
+ """Number of observations contained in the class instance, implemented as dependent property."""
276
+ if self.latitude is None:
277
+ return 0
278
+ return self.latitude.size
279
+
280
+ def from_array(self, array):
281
+ """Unstack a numpy array into the corresponding coordinates.
282
+
283
+ The method has no return as it sets the corresponding attributes of the coordinate class instance.
284
+
285
+ Args:
286
+ array (np.ndarray): Numpy array of size [n x dim] with n>0 containing the coordinates stacked into a single
287
+ array
288
+
289
+ """
290
+ dim = array.shape[1]
291
+ self.latitude = array[:, 0]
292
+ self.longitude = array[:, 1]
293
+ self.altitude = np.zeros_like(self.latitude)
294
+ if dim == 3:
295
+ self.altitude = array[:, 2]
296
+
297
+ def to_array(self, dim=3):
298
+ """Stacks coordinates together into a numpy array.
299
+
300
+ Args:
301
+ dim (int, optional): Number of dimensions to use, which is either 2 or 3.
302
+
303
+ Returns:
304
+ (np.ndarray): Numpy array of size [n x dim] with n>0 containing the coordinates stacked into a single array
305
+
306
+ """
307
+ if dim == 2:
308
+ return np.stack((self.latitude.flatten(), self.longitude.flatten()), axis=1)
309
+ return np.stack((self.latitude.flatten(), self.longitude.flatten(), self.altitude.flatten()), axis=1)
310
+
311
+ def to_lla(self):
312
+ """LLA: Converts coordinates to latitude/longitude/altitude system."""
313
+ return self
314
+
315
+ def to_ecef(self):
316
+ """ECEF: Convert coordinates to earth centered earth fixed coordinates."""
317
+ if self.altitude is None:
318
+ self.altitude = np.zeros(self.latitude.shape)
319
+ ecef_object = ECEF()
320
+ ecef_object.x, ecef_object.y, ecef_object.z = pm.geodetic2ecef(
321
+ lat=self.latitude, lon=self.longitude, alt=self.altitude, ell=self.ellipsoid, deg=self.use_degrees
322
+ )
323
+
324
+ return ecef_object
325
+
326
+ def to_enu(self, ref_latitude=None, ref_longitude=None, ref_altitude=None):
327
+ """Converts coordinates to East North Up system.
328
+
329
+ If a reference is not provided, the minimum of coordinates in Lat/Lon/Alt is used as the reference.
330
+
331
+ Args:
332
+ ref_latitude (float, optional): reference latitude for ENU
333
+ ref_longitude (float, optional): reference longitude for ENU
334
+ ref_altitude (float, optional): reference altitude for ENU
335
+
336
+ Returns:
337
+ (ENU): East North Up coordinate object
338
+
339
+ """
340
+ if self.altitude is None:
341
+ self.altitude = np.zeros(self.latitude.shape)
342
+
343
+ if ref_altitude is None:
344
+ ref_altitude = np.amin(self.altitude)
345
+
346
+ if ref_latitude is None:
347
+ ref_latitude = np.amin(self.latitude)
348
+
349
+ if ref_longitude is None:
350
+ ref_longitude = np.amin(self.longitude)
351
+
352
+ enu_object = ENU(ref_latitude=ref_latitude, ref_longitude=ref_longitude, ref_altitude=ref_altitude)
353
+
354
+ enu_object.east, enu_object.north, enu_object.up = pm.geodetic2enu(
355
+ lat=self.latitude,
356
+ lon=self.longitude,
357
+ h=self.altitude,
358
+ lat0=ref_latitude,
359
+ lon0=ref_longitude,
360
+ h0=ref_altitude,
361
+ ell=self.ellipsoid,
362
+ deg=self.use_degrees,
363
+ )
364
+
365
+ return enu_object
366
+
367
+
368
+ @dataclass
369
+ class ENU(Coordinate):
370
+ """Defines the properties and functionality of a local East-North-Up coordinate system.
371
+
372
+ Positions relative to some reference location in metres.
373
+
374
+ Attributes:
375
+ ref_latitude (float): Reference latitude for current ENU system.
376
+ ref_longitude (float): Reference longitude for current ENU system.
377
+ ref_altitude (float): Reference altitude for current ENU system.
378
+ east (np.ndarray): East values.
379
+ north (np.ndarray): North values.
380
+ up: (np.ndarray): Up values.
381
+
382
+ """
383
+
384
+ ref_latitude: float
385
+ ref_longitude: float
386
+ ref_altitude: float
387
+ east: np.ndarray = None
388
+ north: np.ndarray = None
389
+ up: np.ndarray = None
390
+
391
+ @property
392
+ def nof_observations(self):
393
+ """Number of observations contained in the class instance, implemented as dependent property."""
394
+ if self.east is None:
395
+ return 0
396
+ return self.east.size
397
+
398
+ def from_array(self, array):
399
+ """Unstack a numpy array into the corresponding coordinates.
400
+
401
+ The method has no return as it sets the corresponding attributes of the coordinate class instance.
402
+
403
+ Args:
404
+ array (np.ndarray): Numpy array of size [n x dim] with n>0 containing the coordinates stacked into a single
405
+ array
406
+
407
+ """
408
+ dim = array.shape[1]
409
+ self.east = array[:, 0]
410
+ self.north = array[:, 1]
411
+ self.up = np.zeros_like(self.east)
412
+ if dim == 3:
413
+ self.up = array[:, 2]
414
+
415
+ def to_array(self, dim=3):
416
+ """Stacks coordinates together into a numpy array.
417
+
418
+ Args:
419
+ dim (int, optional): Number of dimensions to use, which is either 2 or 3.
420
+
421
+ Returns:
422
+ (np.ndarray): Numpy array of size [n x dim] with n>0 containing the coordinates stacked into a single array
423
+
424
+ """
425
+ if dim == 2:
426
+ return np.stack((self.east.flatten(), self.north.flatten()), axis=1)
427
+ return np.stack((self.east.flatten(), self.north.flatten(), self.up.flatten()), axis=1)
428
+
429
+ def to_enu(self, ref_latitude=None, ref_longitude=None, ref_altitude=None):
430
+ """Converts coordinates to East North Up system.
431
+
432
+ If a reference is not provided, the minimum of coordinates in Lat/Lon/Alt is used as the reference.
433
+
434
+ Args:
435
+ ref_latitude (float, optional): reference latitude for ENU
436
+ ref_longitude (float, optional): reference longitude for ENU
437
+ ref_altitude (float, optional): reference altitude for ENU
438
+
439
+ Returns:
440
+ (ENU): East North Up coordinate object
441
+
442
+ """
443
+ if ref_latitude is None:
444
+ ref_latitude = self.ref_latitude
445
+
446
+ if ref_longitude is None:
447
+ ref_longitude = self.ref_longitude
448
+
449
+ if ref_altitude is None:
450
+ ref_altitude = self.ref_altitude
451
+
452
+ if (
453
+ self.ref_latitude == ref_latitude
454
+ and self.ref_longitude == ref_longitude
455
+ and self.ref_altitude == ref_altitude
456
+ ):
457
+ return self
458
+
459
+ ecef_temp = self.to_ecef()
460
+
461
+ return ecef_temp.to_enu(ref_longitude=ref_longitude, ref_latitude=ref_latitude, ref_altitude=ref_altitude)
462
+
463
+ def to_lla(self):
464
+ """LLA: Converts coordinates to latitude/longitude/altitude system."""
465
+ lla_object = LLA()
466
+
467
+ lla_object.latitude, lla_object.longitude, lla_object.altitude = pm.enu2geodetic(
468
+ e=self.east,
469
+ n=self.north,
470
+ u=self.up,
471
+ lat0=self.ref_latitude,
472
+ lon0=self.ref_longitude,
473
+ h0=self.ref_altitude,
474
+ ell=self.ellipsoid,
475
+ deg=self.use_degrees,
476
+ )
477
+
478
+ return lla_object
479
+
480
+ def to_ecef(self):
481
+ """ECEF: Convert coordinates to earth centered earth fixed coordinates."""
482
+ ecef_object = ECEF()
483
+
484
+ ecef_object.x, ecef_object.y, ecef_object.z = pm.enu2ecef(
485
+ e1=self.east,
486
+ n1=self.north,
487
+ u1=self.up,
488
+ lat0=self.ref_latitude,
489
+ lon0=self.ref_longitude,
490
+ h0=self.ref_altitude,
491
+ ell=self.ellipsoid,
492
+ deg=self.use_degrees,
493
+ )
494
+
495
+ return ecef_object
496
+
497
+
498
+ @dataclass
499
+ class ECEF(Coordinate):
500
+ """Defines the properties and functionality of an Earth-Centered, Earth-Fixed coordinate system.
501
+
502
+ See: https://en.wikipedia.org/wiki/Earth-centered,_Earth-fixed_coordinate_system
503
+
504
+ Attributes:
505
+ x (np.ndarray): Eastings values [metres]
506
+ y (np.ndarray): Northings values [metres]
507
+ z (np.ndarray): Altitude values [metres]
508
+
509
+ """
510
+
511
+ x: np.ndarray = None
512
+ y: np.ndarray = None
513
+ z: np.ndarray = None
514
+
515
+ @property
516
+ def nof_observations(self):
517
+ """Number of observations contained in the class instance, implemented as dependent property."""
518
+ if self.x is None:
519
+ return 0
520
+ return self.x.size
521
+
522
+ def from_array(self, array):
523
+ """Unstack a numpy array into the corresponding coordinates.
524
+
525
+ The method has no return as it sets the corresponding attributes of the coordinate class instance.
526
+
527
+ Args:
528
+ array (np.ndarray): Numpy array of size [n x dim] with n>0 containing the coordinates stacked into a single
529
+ array
530
+
531
+ """
532
+ dim = array.shape[1]
533
+ self.x = array[:, 0]
534
+ self.y = array[:, 1]
535
+ self.z = np.zeros_like(self.x)
536
+ if dim == 3:
537
+ self.z = array[:, 2]
538
+
539
+ def to_array(self, dim=3):
540
+ """Stacks coordinates together into a numpy array.
541
+
542
+ Args:
543
+ dim (int, optional): Number of dimensions to use, which is either 2 or 3.
544
+
545
+ Returns:
546
+ (np.ndarray): Numpy array of size [n x dim] with n>0 containing the coordinates stacked into a single array
547
+
548
+ """
549
+ if dim == 2:
550
+ return np.stack((self.x.flatten(), self.y.flatten()), axis=1)
551
+ return np.stack((self.x.flatten(), self.y.flatten(), self.z.flatten()), axis=1)
552
+
553
+ def to_ecef(self):
554
+ """ECEF: Convert coordinates to earth centered earth fixed coordinates."""
555
+ return self
556
+
557
+ def to_lla(self):
558
+ """LLA: Converts coordinates to latitude/longitude/altitude system."""
559
+ lla_object = LLA()
560
+
561
+ lla_object.latitude, lla_object.longitude, lla_object.altitude = pm.ecef2geodetic(
562
+ self.x, self.y, self.z, ell=self.ellipsoid, deg=self.use_degrees
563
+ )
564
+
565
+ return lla_object
566
+
567
+ def to_enu(self, ref_latitude=None, ref_longitude=None, ref_altitude=None):
568
+ """Converts coordinates to East North Up system.
569
+
570
+ If a reference is not provided, the minimum of coordinates in Lat/Lon/Alt is used as the reference.
571
+
572
+ Args:
573
+ ref_latitude (float, optional): reference latitude for ENU
574
+ ref_longitude (float, optional): reference longitude for ENU
575
+ ref_altitude (float, optional): reference altitude for ENU
576
+
577
+ Returns:
578
+ (ENU): East North Up coordinate object
579
+
580
+ """
581
+ if ref_latitude is None or ref_longitude is None or ref_altitude is None:
582
+ lla_object = self.to_lla()
583
+ return lla_object.to_enu()
584
+
585
+ enu_object = ENU(ref_latitude=ref_latitude, ref_longitude=ref_longitude, ref_altitude=ref_altitude)
586
+
587
+ enu_object.east, enu_object.north, enu_object.up = pm.ecef2enu(
588
+ x=self.x,
589
+ y=self.y,
590
+ z=self.z,
591
+ lat0=ref_latitude,
592
+ lon0=ref_longitude,
593
+ h0=ref_altitude,
594
+ ell=self.ellipsoid,
595
+ deg=self.use_degrees,
596
+ )
597
+
598
+ return enu_object
@@ -0,0 +1,5 @@
1
+ # SPDX-FileCopyrightText: 2024 Shell Global Solutions International B.V. All Rights Reserved.
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Data Access Module."""
5
+ __all__ = ["data_access"]