pygnss 2.1.2__cp314-cp314t-macosx_11_0_arm64.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.
pygnss/geodetic.py ADDED
@@ -0,0 +1,1177 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ Module with several geodetic related methods
4
+ """
5
+ from dataclasses import astuple, dataclass
6
+ import math
7
+ from typing import List, Union, Iterable
8
+
9
+ import numpy as np
10
+
11
+ import pygnss.logger
12
+
13
+ # WGS84 constants
14
+ WGS84_A = 6378137.0
15
+ WGS84_E = 8.1819190842622e-2
16
+
17
+
18
+ @dataclass
19
+ class ElAz:
20
+ elevation: float
21
+ azimuth: float
22
+
23
+ def __iter__(self):
24
+ return iter(astuple(self))
25
+
26
+ def __repr__(self):
27
+ return f'{self.elevation:.3f} {self.azimuth:.3f}'
28
+
29
+
30
+ @dataclass
31
+ class DMS:
32
+ degrees: int
33
+ minutes: int
34
+ seconds: float
35
+
36
+ def __iter__(self):
37
+ return iter(astuple(self))
38
+
39
+ def __repr__(self):
40
+ return f"{self.degrees}º {self.minutes}' {self.seconds:.3f}\""
41
+
42
+
43
+ @dataclass
44
+ class XYZ:
45
+ x: float
46
+ y: float
47
+ z: float
48
+
49
+ def is_valid(self):
50
+ EPS_THRESHOLD = 1.0e-3
51
+ return abs(self) > EPS_THRESHOLD
52
+
53
+ def __iter__(self):
54
+ return iter(astuple(self))
55
+
56
+ def __repr__(self):
57
+ return f'{self.x:.3f} {self.y:.3f} {self.z:.3f}'
58
+
59
+ def __sub__(self, ref: 'XYZ') -> 'XYZ':
60
+ return XYZ(self.x - ref.x, self.y - ref.y, self.z - ref.z)
61
+
62
+ def __add__(self, ref: 'XYZ') -> 'XYZ':
63
+ return XYZ(self.x + ref.x, self.y + ref.y, self.z + ref.z)
64
+
65
+ def __abs__(self):
66
+ return math.sqrt(self.x * self.x + self.y * self.y + self.z * self.z)
67
+
68
+ def __truediv__(self, scale: float) -> 'XYZ':
69
+ return XYZ(self.x / scale, self.y / scale, self.z / scale)
70
+
71
+ def __mul__(self, scale: float) -> 'XYZ':
72
+ return XYZ(self.x * scale, self.y * scale, self.z * scale)
73
+
74
+
75
+ @dataclass
76
+ class LLA:
77
+ longitude: float # In degrees
78
+ latitude: float # In degrees
79
+ altitude: float # In meters
80
+
81
+ def is_valid(self):
82
+ ALT_THRESHOLD_M = 10.0e3 # meters above/under surface level
83
+ return self.altitude < ALT_THRESHOLD_M and self.altitude > -ALT_THRESHOLD_M
84
+
85
+ def __iter__(self):
86
+ return iter(astuple(self))
87
+
88
+ def __repr__(self):
89
+ return f'{self.longitude:.3f} {self.latitude:.3f} {self.altitude:.3f}'
90
+
91
+
92
+ @dataclass
93
+ class LLA_DMS:
94
+ longitude: DMS
95
+ latitude: DMS
96
+ altitude: float
97
+
98
+ def is_valid(self):
99
+ ALT_THRESHOLD_M = 10.0e3 # meters above/under surface level
100
+ return self.altitude < ALT_THRESHOLD_M and self.altitude > -ALT_THRESHOLD_M
101
+
102
+ def __iter__(self):
103
+ return iter(astuple(self))
104
+
105
+ def __repr__(self):
106
+ return f'{self.longitude} {self.latitude} {self.altitude}'
107
+
108
+
109
+ @dataclass
110
+ class ENU:
111
+ east: float
112
+ north: float
113
+ up: float
114
+
115
+ def __iter__(self):
116
+ return iter(astuple(self))
117
+
118
+ def __repr__(self):
119
+ return f'{self.east:.3f} {self.north:.3f} {self.up:.3f}'
120
+
121
+ def get_horizontal_error(self):
122
+ return get_horizontal_error(self.east, self.north)
123
+
124
+ def get_vertical_error(self):
125
+ return get_vertical_error(self.east, self.north, self.up)
126
+
127
+ def __mul__(self, scale: float) -> 'ENU':
128
+ return ENU(self.east * scale, self.north * scale, self.up * scale)
129
+
130
+
131
+ @dataclass
132
+ class AXU:
133
+ along: float
134
+ cross: float
135
+ up: float
136
+
137
+ def __iter__(self):
138
+ return iter(astuple(self))
139
+
140
+ def __repr__(self):
141
+ return f'{self.along:.3f} {self.cross:.3f} {self.up:.3f}'
142
+
143
+
144
+ class Coordinates(object):
145
+ """
146
+ Class to store coordinates and transform among various coordinate systems
147
+ (cartesian and geodetic)
148
+ """
149
+
150
+ def __init__(self, x: float, y: float, z: float) -> None:
151
+ self.point = XYZ(x, y, z)
152
+
153
+ def __eq__(self, other) -> bool:
154
+ """
155
+ >>> a = Coordinates(0, 0, 0)
156
+ >>> a == a
157
+ True
158
+
159
+ >>> b = Coordinates(100, 0, 0)
160
+ >>> a == b
161
+ False
162
+ """
163
+
164
+ if isinstance(other, Coordinates):
165
+ equal_x = math.isclose(self.point.x, other.point.x)
166
+ equal_y = math.isclose(self.point.y, other.point.y)
167
+ equal_z = math.isclose(self.point.z, other.point.z)
168
+ return (equal_x and equal_y and equal_z)
169
+
170
+ return False
171
+
172
+ def is_valid(self) -> bool:
173
+ """
174
+ >>> coordinates = Coordinates(0, 0, 0)
175
+ >>> coordinates.is_valid()
176
+ False
177
+ """
178
+ return self.xyz().is_valid()
179
+
180
+ def xyz(self) -> XYZ:
181
+ """
182
+ >>> coordinates = Coordinates(4889803.653, 170755.706, 4078049.851)
183
+ >>> coordinates.xyz()
184
+ 4889803.653 170755.706 4078049.851
185
+ """
186
+ return self.point
187
+
188
+ def lla(self, a: float = WGS84_A, e: float = WGS84_E) -> LLA:
189
+ """
190
+ >>> coordinates = Coordinates(4889803.653, 170755.706, 4078049.851)
191
+ >>> "lon: {:.0f} lat: {:.0f} height: {:.0f}".format(*coordinates.lla())
192
+ 'lon: 2 lat: 40 height: 100'
193
+ """
194
+ return LLA(*xyz_to_lla(*self.point, a=a, e=e))
195
+
196
+ def lla_dms(self, a: float = WGS84_A, e: float = WGS84_E) -> LLA_DMS:
197
+ """
198
+ >>> coordinates = Coordinates(4889803.653, 170755.706, 4078049.851)
199
+ >>> coord = coordinates.lla_dms()
200
+ >>> coord.longitude.degrees
201
+ 1
202
+ >>> coord.longitude.minutes
203
+ 59
204
+ """
205
+ lla = self.lla(a=a, e=e)
206
+
207
+ longitude = convert_decimal_units_to_dms(lla.longitude)
208
+ latitude = convert_decimal_units_to_dms(lla.latitude)
209
+
210
+ return LLA_DMS(longitude, latitude, lla.altitude)
211
+
212
+ def apply_displacement(self, enu: ENU) -> 'Coordinates':
213
+ """
214
+ >>> coordinates = Coordinates(4787369.149, 183275.989, 4196362.621)
215
+ >>> enu = ENU(0.096,0.010,0.144)
216
+ >>> coordinates.apply_displacement(enu).xyz()
217
+ 4787369.247 183276.089 4196362.724
218
+ """
219
+ displacement = CoordinatesDisplacement(enu=enu)
220
+ displacement.set_reference_position(self)
221
+ xyz = displacement.get_xyz()
222
+ return Coordinates(self.xyz().x + xyz.x, self.xyz().y + xyz.y, self.xyz().z + xyz.z)
223
+
224
+ def __repr__(self) -> str:
225
+ """
226
+ >>> Coordinates(4889803.653, 170755.706, 4078049.851)
227
+ 4889803.653 170755.706 4078049.851
228
+ """
229
+ return f'{self.point.x:.3f} {self.point.y:.3f} {self.point.z:.3f}'
230
+
231
+ def print_lla(self, a=WGS84_A, e=WGS84_E) -> str:
232
+ """
233
+ >>> coordinates = Coordinates(4889803.653, 170755.706, 4078049.851)
234
+ >>> coordinates.print_lla()
235
+ '40.0000000N 2.0000000E 100.000m'
236
+ """
237
+ lla = LLA(*xyz_to_lla(*self.point, a=a, e=e))
238
+ lat_letter = 'N' if lla.latitude else 'S'
239
+ latitude = abs(lla.latitude)
240
+ lon_letter = 'E' if lla.longitude else 'W'
241
+ longitude = abs(lla.longitude)
242
+ return f"{latitude:.7f}{lat_letter} {longitude:.7f}{lon_letter} {lla.altitude:.3f}m"
243
+
244
+ def __sub__(self: 'Coordinates', ref: 'Coordinates') -> 'CoordinatesDisplacement':
245
+ """
246
+ >>> dest_position = Coordinates(4889804, 170755, 4078049)
247
+ >>> orig_position = Coordinates(4889803, 170757, 4078049)
248
+ >>> (dest_position - orig_position).get_xyz()
249
+ 1.000 -2.000 0.000
250
+ """
251
+
252
+ xyz_self = self.xyz()
253
+ xyz_ref = ref.xyz()
254
+
255
+ xyz = XYZ(x=xyz_self.x - xyz_ref.x, y=xyz_self.y - xyz_ref.y, z=xyz_self.z - xyz_ref.z)
256
+ return CoordinatesDisplacement(xyz=xyz, ref_position=ref)
257
+
258
+ @staticmethod
259
+ def from_lla(longitude_deg: float, latitude_deg: float, height_m: float,
260
+ a: float = WGS84_A, e: float = WGS84_E) -> 'Coordinates':
261
+ """
262
+ >>> coordinates = Coordinates.from_lla(2, 40, 100)
263
+ >>> coordinates.is_valid()
264
+ True
265
+ """
266
+
267
+ xyz = lla_to_xyz(longitude_deg, latitude_deg, height_m, a=a, e=e)
268
+
269
+ return Coordinates(*xyz)
270
+
271
+ @staticmethod
272
+ def from_xyz(x: float, y: float, z: float) -> 'Coordinates':
273
+ """
274
+ >>> coordinates = Coordinates.from_lla(4889803.653, 170755.706, 4078049.851)
275
+ >>> coordinates.is_valid()
276
+ True
277
+ """
278
+
279
+ return Coordinates(x, y, z)
280
+
281
+
282
+ class CoordinatesDisplacement(object):
283
+ """
284
+ Representation of a displacement relative to a certain point (Coordinates).
285
+ Use this class to represent deviations such as error vector, lever arms, ...
286
+ """
287
+
288
+ def __init__(self, xyz: XYZ = None, enu: ENU = None, ref_position: Coordinates = None):
289
+ """
290
+ >>> c = CoordinatesDisplacement(xyz=XYZ(0.1, 0.1, 0.1))
291
+ >>> c.is_valid()
292
+ True
293
+ """
294
+ self.xyz = xyz
295
+ self.enu = enu
296
+ self.ref_lla = None
297
+ if ref_position:
298
+ self.set_reference_position(ref_position)
299
+
300
+ def __repr__(self) -> str:
301
+ if self.xyz:
302
+ return f'XYZ: {str(self.xyz)}'
303
+ elif self.enu:
304
+ return f'ENU: {str(self.enu)}'
305
+ else:
306
+ raise Exception('Format not defined')
307
+
308
+ def set_reference_position(self, position: Coordinates) -> None:
309
+
310
+ if not position:
311
+ raise ValueError("Invalid reference position")
312
+
313
+ self.ref_lla = position.lla()
314
+
315
+ if not self.enu:
316
+ self.enu = self.get_enu()
317
+
318
+ def is_valid(self) -> bool:
319
+ """
320
+ >>> c = CoordinatesDisplacement()
321
+ >>> c.is_valid()
322
+ False
323
+ """
324
+ return self.xyz is not None or self.enu is not None
325
+
326
+ def check_validity(self) -> None:
327
+ if not self.is_valid():
328
+ raise ValueError("CoordinatesDisplacement object is not valid")
329
+
330
+ def get_xyz(self) -> XYZ:
331
+ """
332
+ >>> enu = ENU(-0.416, 5.664, -0.081)
333
+ >>> reference_position = Coordinates.from_lla(1.52160871, 41.26531742, 127.0)
334
+ >>> c = CoordinatesDisplacement(enu=enu)
335
+ >>> c.set_reference_position(reference_position)
336
+ >>> xyz = c.get_xyz()
337
+ >>> '{:.03f} {:.03f} {:.03f}'.format(*xyz)
338
+ '-3.784 -0.517 4.204'
339
+ """
340
+
341
+ self.check_validity()
342
+
343
+ if not self.xyz:
344
+ if not self.ref_lla:
345
+ raise ValueError("Missing reference position in CoordinatesDisplacement to convert frame (ENU to XYZ)")
346
+ xyz_list = enu_to_ecef(self.ref_lla.longitude, self.ref_lla.latitude, *self.enu)
347
+ self.xyz = XYZ(*xyz_list)
348
+
349
+ return self.xyz
350
+
351
+ def get_enu(self) -> ENU:
352
+ """
353
+ >>> xyz = XYZ(0.1, 0.1, 0.1)
354
+ >>> ref_position = Coordinates(4889803.653, 170755.706, 4078049.851)
355
+ >>> c = CoordinatesDisplacement(xyz=xyz, ref_position=ref_position)
356
+ >>> enu = c.get_enu()
357
+ >>> '{:.03f} {:.03f} {:.03f}'.format(*enu)
358
+ '0.096 0.010 0.144'
359
+ """
360
+
361
+ self.check_validity()
362
+
363
+ if not self.enu:
364
+ if not self.ref_lla:
365
+ raise ValueError("Missing reference position in CoordinatesDisplacement to convert frame (XYZ to ENU)")
366
+ enu_list = ecef_to_enu(self.ref_lla.longitude, self.ref_lla.latitude, *self.xyz)
367
+ self.enu = ENU(*enu_list)
368
+
369
+ return self.enu
370
+
371
+ def get_absolute_position(self) -> Coordinates:
372
+ """
373
+ >>> enu = ENU(0.096,0.010,0.144)
374
+ >>> ref_position = Coordinates(4889803.653, 170755.706, 4078049.851)
375
+ >>> c = CoordinatesDisplacement(enu=enu, ref_position=ref_position)
376
+ >>> position = c.get_absolute_position()
377
+ >>> '{:.3f} {:.3f} {:.3f}'.format(*position.xyz())
378
+ '4889803.753 170755.806 4078049.951'
379
+ >>> c_displacement = CoordinatesDisplacement.from_positions(ref_position, position)
380
+ >>> '{:.1f} {:.1f} {:.1f}'.format(*c_displacement.get_xyz())
381
+ '0.1 0.1 0.1'
382
+ """
383
+
384
+ xyz = lla_to_xyz(self.ref_lla.longitude, self.ref_lla.latitude, self.ref_lla.altitude)
385
+
386
+ dxyz = self.get_xyz()
387
+
388
+ return Coordinates(*(xyz + dxyz))
389
+
390
+ def get_axu(self, direction: ENU) -> AXU:
391
+ """
392
+ Convert to along-track, cross-track and up for a given direction
393
+
394
+ >>> velocity = ENU(1.0, 1.0, 1)
395
+ >>> error = ENU(1.0, 1.0, 0)
396
+ >>> error = CoordinatesDisplacement(enu=error)
397
+ >>> error.get_axu(velocity)
398
+ 1.414 0.000 0.000
399
+
400
+ >>> error = ENU(1.0, 0.0, 0)
401
+ >>> error = CoordinatesDisplacement(enu=error)
402
+ >>> error.get_axu(velocity)
403
+ 0.707 -0.707 0.000
404
+
405
+ >>> error = ENU(1.0, -1.0, 0)
406
+ >>> error = CoordinatesDisplacement(enu=error)
407
+ >>> error.get_axu(velocity)
408
+ 0.000 -1.414 0.000
409
+
410
+ >>> error = ENU(-1.0, -1.0, 0)
411
+ >>> error = CoordinatesDisplacement(enu=error)
412
+ >>> error.get_axu(velocity)
413
+ -1.414 0.000 0.000
414
+
415
+ >>> error = ENU(0.0, -1.0, 0)
416
+ >>> error = CoordinatesDisplacement(enu=error)
417
+ >>> error.get_axu(velocity)
418
+ -0.707 -0.707 0.000
419
+ """
420
+
421
+ position = self.get_enu()
422
+
423
+ # Compute angle between the horizontal coordinates vector and the horizontal direction
424
+ v_hor = [direction.east, direction.north, 0]
425
+ v_hor_norm = np.linalg.norm(v_hor)
426
+
427
+ u_v = np.array(v_hor) / v_hor_norm
428
+
429
+ p_hor = [position.east, position.north, 0]
430
+ p_hor_norm = np.linalg.norm(p_hor)
431
+
432
+ theta_rad = math.acos(np.dot(v_hor, p_hor) / v_hor_norm / p_hor_norm)
433
+
434
+ signum = +1 if np.cross(u_v, p_hor)[2] >= 0 else -1
435
+ cross_track = p_hor_norm * math.sin(theta_rad) * signum
436
+
437
+ along_track = p_hor_norm * math.cos(theta_rad)
438
+
439
+ return AXU(along_track, cross_track, position.up)
440
+
441
+ def __abs__(self) -> float:
442
+ """
443
+ >>> c = CoordinatesDisplacement.from_xyz(0.1, 0.1, 0.1)
444
+ >>> f'{abs(c):.4f}'
445
+ '0.1732'
446
+ """
447
+
448
+ if not self.is_valid():
449
+ raise ValueError("Cannot compute the magnitude of an empty array")
450
+
451
+ vector = self.xyz or self.enu
452
+
453
+ return math.sqrt(sum([v * v for v in vector]))
454
+
455
+ @staticmethod
456
+ def from_xyz(x: float, y: float, z: float):
457
+ """
458
+ >>> c = CoordinatesDisplacement.from_xyz(0.1, 0.1, 0.1)
459
+ >>> c.is_valid()
460
+ True
461
+ """
462
+ return CoordinatesDisplacement(xyz=XYZ(x, y, z))
463
+
464
+ @staticmethod
465
+ def from_positions(from_position: Coordinates, to_positions: Coordinates) -> 'CoordinatesDisplacement':
466
+ """
467
+ Compute the displacement vector between two positions
468
+
469
+ >>> from_position = Coordinates(0, 0, 0.1)
470
+ >>> to_position = Coordinates(0, 0, 0)
471
+ >>> c = CoordinatesDisplacement.from_positions(from_position, to_position)
472
+ >>> c.is_valid()
473
+ True
474
+ >>> abs(c)
475
+ 0.1
476
+ """
477
+
478
+ from_xyz = from_position.xyz()
479
+ to_xyz = to_positions.xyz()
480
+ dxyz = XYZ(to_xyz.x - from_xyz.x, to_xyz.y - from_xyz.y, to_xyz.z - from_xyz.z)
481
+
482
+ return CoordinatesDisplacement(xyz=dxyz, ref_position=from_position)
483
+
484
+ @staticmethod
485
+ def from_enu(e: float, n: float, u: float):
486
+ """
487
+ >>> c = CoordinatesDisplacement.from_enu(0.1, 0.1, 0.1)
488
+ >>> c.is_valid()
489
+ True
490
+ """
491
+ return CoordinatesDisplacement(enu=ENU(e, n, u))
492
+
493
+
494
+ def get_horizontal_error(x: float, y: float) -> float:
495
+ return math.sqrt(x**2 + y**2)
496
+
497
+
498
+ def get_vertical_error(x: float, y: float, z: float):
499
+ return math.sqrt(x**2 + y**2 + z**2)
500
+
501
+
502
+ def lla_to_xyz(longitude_deg: Union[float, Iterable], latitude_deg: Union[float, Iterable],
503
+ height_m: Union[float, Iterable], a: float = WGS84_A, e: float = WGS84_E) -> XYZ:
504
+ """
505
+ Convert from geodetic coordinates (relative to a reference ellipsoid which
506
+ defaults to WGS84) to Cartesian XYZ-ECEF coordinates.
507
+
508
+ The longitude, latitude and height values can be vectors
509
+
510
+ >>> xyz = lla_to_xyz(9.323302567, 48.685064919, 373.2428)
511
+ >>> [float('{0:.3f}'.format(v)) for v in xyz]
512
+ [4163316.145, 683507.935, 4767789.479]
513
+
514
+ >>> lons = np.array([9.323302567, 9.323335545, 9.323368065])
515
+ >>> lats = np.array([48.685064919, 48.685050295, 48.685036011])
516
+ >>> hgts = np.array([373.2428, 373.2277, 373.2078])
517
+ >>> x, y, z = lla_to_xyz(lons, lats, hgts)
518
+ >>> "{0:.6f}".format(x[0])
519
+ '4163316.144693'
520
+ >>> "{0:.6f}".format(x[1])
521
+ '4163316.946837'
522
+ >>> "{0:.6f}".format(x[2])
523
+ '4163317.723291'
524
+ >>> "{0:.6f}".format(y[0])
525
+ '683507.934738'
526
+ >>> "{0:.6f}".format(y[1])
527
+ '683510.527317'
528
+ >>> "{0:.6f}".format(y[2])
529
+ '683513.081502'
530
+ >>> "{0:.6f}".format(z[0])
531
+ '4767789.478699'
532
+ >>> "{0:.6f}".format(z[1])
533
+ '4767788.393654'
534
+ >>> "{0:.6f}".format(z[2])
535
+ '4767787.329966'
536
+ """
537
+
538
+ # Convert from degrees to radians if necessary
539
+ lon = np.deg2rad(longitude_deg)
540
+ lat = np.deg2rad(latitude_deg)
541
+
542
+ sinlat = np.sin(lat)
543
+ coslat = np.cos(lat)
544
+ sinlon = np.sin(lon)
545
+ coslon = np.cos(lon)
546
+ e2 = e * e
547
+
548
+ # Intermediate calculation (prime vertical radius of curvature)
549
+ N = a / np.sqrt(1.0 - e2 * sinlat * sinlat)
550
+ nalt = N + height_m
551
+
552
+ x = nalt * coslat * coslon
553
+ y = nalt * coslat * sinlon
554
+ z = ((1.0 - e2) * N + height_m) * sinlat
555
+
556
+ return XYZ(x, y, z)
557
+
558
+
559
+ def convert_decimal_units_to_dms(value: float) -> DMS:
560
+ """
561
+ Convert from longitude/latitude to Degrees, minutes and seconds
562
+ >>> latitude_deg = 41.528442316
563
+ >>> dms = convert_decimal_units_to_dms(latitude_deg)
564
+ >>> "{:.0f} {:.0f} {:.7f}".format(*dms)
565
+ '41 31 42.3923376'
566
+ >>> longitude_deg = 2.434318900
567
+ >>> dms = convert_decimal_units_to_dms(longitude_deg)
568
+ >>> "{:.0f} {:.0f} {:.7f}".format(*dms)
569
+ '2 26 3.5480400'
570
+ """
571
+
572
+ degrees = int(value)
573
+ minutes_float = (value - degrees) * 60
574
+ minutes = int(minutes_float)
575
+ seconds = (minutes_float - minutes) * 60
576
+
577
+ return DMS(degrees, minutes, seconds)
578
+
579
+
580
+ def xyz_to_lla(x, y, z, a=WGS84_A, e=WGS84_E) -> LLA:
581
+ """
582
+ Convert from Cartesian XYZ-ECEF coordinates to geodetic coordinates
583
+ (relative to a reference ellipsoid which defaults to WGS84).
584
+
585
+ Output longitude and latitude are expressed in degrees
586
+
587
+ The x, y and z values can be vectors
588
+
589
+ >>> x, y, z = xyz_to_lla(4807314.3520, 98057.0330, 4176767.6160)
590
+ >>> assert math.isclose(x, 1.1685266980613551)
591
+ >>> assert math.isclose(y, 41.170001652314625)
592
+ >>> assert math.isclose(z, 173.4421242615208)
593
+
594
+ >>> xs = np.array([4807314.3520, 4807315.3520, 4807316.3520])
595
+ >>> ys = np.array([98057.0330, 98058.0330, 98059.0330])
596
+ >>> zs = np.array([4176767.6160, 4176768.6160, 4176769.6160])
597
+ >>> lon, lat, height = xyz_to_lla(xs, ys, zs)
598
+ >>> "{0:.7f}".format(lon[0])
599
+ '1.1685267'
600
+ >>> "{0:.7f}".format(lon[1])
601
+ '1.1685384'
602
+ >>> "{0:.7f}".format(lon[2])
603
+ '1.1685500'
604
+ >>> "{0:.7f}".format(lat[0])
605
+ '41.1700017'
606
+ >>> "{0:.7f}".format(lat[1])
607
+ '41.1700024'
608
+ >>> "{0:.7f}".format(lat[2])
609
+ '41.1700031'
610
+ >>> "{0:.7f}".format(height[0])
611
+ '173.4421243'
612
+ >>> "{0:.7f}".format(height[1])
613
+ '174.8683741'
614
+ >>> "{0:.7f}".format(height[2])
615
+ '176.2946241'
616
+ """
617
+
618
+ a2 = a ** 2 # Squared of radius, for convenience
619
+ e2 = e ** 2 # Squared of eccentricity, for convenience
620
+
621
+ b = np.sqrt(a2 * (1 - e2))
622
+ b2 = b ** 2
623
+
624
+ ep = np.sqrt((a2 - b2) / b2)
625
+
626
+ p = np.sqrt(x ** 2 + y ** 2)
627
+ th = np.arctan2(a * z, b * p)
628
+
629
+ lon = np.arctan2(y, x)
630
+ lon = np.fmod(lon, 2 * np.pi)
631
+
632
+ sinth = np.sin(th)
633
+ costh = np.cos(th)
634
+
635
+ lat = np.arctan2((z + b * (ep ** 2) * (sinth ** 3)), (p - e2 * a * costh ** 3))
636
+ sinlat = np.sin(lat)
637
+
638
+ N = a / np.sqrt(1 - e2 * (sinlat ** 2))
639
+ alt = p / np.cos(lat) - N
640
+
641
+ return LLA(np.rad2deg(lon), np.rad2deg(lat), alt)
642
+
643
+
644
+ def body_to_enu_matrix(yaw_deg, pitch_deg, roll_deg):
645
+ """
646
+ Computes the matrix that transforms from Body frame to ENU
647
+
648
+ This accepts scalars or vector of scalars.
649
+
650
+ Also, the yaw, pitch and roll can be expressed as radians (radians=True)
651
+ or degrees (radians=False)
652
+ """
653
+
654
+ # Convert from degrees to radians if necessary
655
+ ya = np.deg2rad(yaw_deg)
656
+ pi = np.deg2rad(pitch_deg)
657
+ ro = np.deg2rad(roll_deg)
658
+
659
+ # Compute the sines and cosines, used later to compute the elements
660
+ # of the transformation matrix
661
+ siny = np.sin(ya)
662
+ cosy = np.cos(ya)
663
+ sinp = np.sin(pi)
664
+ cosp = np.cos(pi)
665
+ sinr = np.sin(ro)
666
+ cosr = np.cos(ro)
667
+
668
+ cosy_cosr = cosy * cosr
669
+ siny_cosr = siny * cosr
670
+ sinp_sinr = sinp * sinr
671
+ sinr_siny = sinr * siny
672
+
673
+ # Compute the matrix coefficients
674
+ m = np.zeros((3, 3), dtype=object)
675
+
676
+ m[0, 0] = cosp * cosy
677
+ m[0, 1] = sinp_sinr * cosy - siny_cosr
678
+ m[0, 2] = cosy_cosr * sinp + sinr_siny
679
+
680
+ m[1, 0] = cosp * siny
681
+ m[1, 1] = sinp_sinr * siny + cosr * cosy
682
+ m[1, 2] = siny_cosr * sinp - sinr * cosy
683
+
684
+ m[2, 0] = - sinp
685
+ m[2, 1] = sinr * cosp
686
+ m[2, 2] = cosr * cosp
687
+
688
+ return m
689
+
690
+
691
+ def body_to_enu(yaw_deg, pitch_deg, roll_deg, x, y, z, matrix=None):
692
+ """
693
+ Converts from Body reference frame to ENU reference point.
694
+
695
+ This accepts scalars or vector of scalars.
696
+
697
+ Also, the yaw, pitch and roll can be expressed as radians (radians=True)
698
+ or degrees (radians=False)
699
+
700
+ TEST RATIONALE HAS TO BE CHANGED ACCORDING TO THE CHANGE IN THE BODY_TO_ENU_MATRIX RESULT INTERPRETATION:
701
+ BODY_TO_ENU_MATRIX IS ACTUALLY IMPLEMENTING BODY2NED_MATRIX!!!!!!
702
+
703
+ >>> body_to_enu(0, 0, 0, 1, 1, 1)
704
+ (1.0, 1.0, -1.0)
705
+
706
+ >>> enu = body_to_enu(90, 0, 0, 1, 1, 1)
707
+ >>> np.round(enu)
708
+ array([ 1., -1., -1.])
709
+
710
+ >>> enu = body_to_enu(0, 90, 0, 1, 1, 1)
711
+ >>> np.round(enu)
712
+ array([1., 1., 1.])
713
+
714
+ >>> enu = body_to_enu(0, 0, 90, 1, 1, 1)
715
+ >>> np.round(enu)
716
+ array([-1., 1., -1.])
717
+
718
+ >>> yaws = [0] * 3; pitches = [0] * 3; rolls = [0] * 3
719
+ >>> xs = [1] * 3; ys = [1] * 3; zs = [1]*3
720
+ >>> es, ns, us = body_to_enu(yaws, pitches, rolls, xs, ys, zs)
721
+ >>> es
722
+ array([1., 1., 1.])
723
+ >>> ns
724
+ array([1., 1., 1.])
725
+ >>> us
726
+ array([-1., -1., -1.])
727
+ """
728
+
729
+ if matrix is None:
730
+ m = body_to_enu_matrix(yaw_deg, pitch_deg, roll_deg)
731
+
732
+ n = m[0, 0] * x + m[0, 1] * y + m[0, 2] * z
733
+ e = m[1, 0] * x + m[1, 1] * y + m[1, 2] * z
734
+ d = m[2, 0] * x + m[2, 1] * y + m[2, 2] * z
735
+
736
+ return e, n, -d
737
+
738
+
739
+ def enu_to_body(yaw_deg, pitch_deg, roll_deg, e, n, u, matrix=None):
740
+ """
741
+ Converts from ENU to XYZ Body reference frame
742
+
743
+ This accepts scalars or vector of scalars.
744
+
745
+ Also, the yaw, pitch and roll can be expressed as radians (radians=True)
746
+ or degrees (radians=False)
747
+
748
+
749
+ >>> np.round(enu_to_body(0, 0, 0, *body_to_enu(0, 0, 0, 0.2, 0.2, 1.5)), decimals=1)
750
+ array([0.2, 0.2, 1.5])
751
+ """
752
+
753
+ if matrix is None:
754
+ m = body_to_enu_matrix(yaw_deg, pitch_deg, roll_deg)
755
+
756
+ x = m[0, 0] * n + m[1, 0] * e + m[2, 0] * -u
757
+ y = m[0, 1] * n + m[1, 1] * e + m[2, 1] * -u
758
+ z = m[0, 2] * n + m[1, 2] * e + m[2, 2] * -u
759
+
760
+ return x, y, z
761
+
762
+
763
+ def enu_to_ecef_matrix(longitude_deg, latitude_deg):
764
+ """
765
+ Computes the transformation matrix from ENU to XYZ (ECEF) and returns
766
+ the matrix coefficients stored as a a numpy two-dimensional array
767
+
768
+ This accepts scalars or vector of scalars.
769
+
770
+ The latitude and longitude can be expressed either in radians (radians=True)
771
+ or degrees (radians = True)
772
+ """
773
+
774
+ n_samples = np.size(longitude_deg)
775
+
776
+ # Compute the sine and cos to avoid recalculations
777
+ sinlon = np.sin(np.deg2rad(longitude_deg))
778
+ coslon = np.cos(np.deg2rad(longitude_deg))
779
+ sinlat = np.sin(np.deg2rad(latitude_deg))
780
+ coslat = np.cos(np.deg2rad(latitude_deg))
781
+
782
+ m = np.zeros((3, 3), dtype=object)
783
+
784
+ # Compute the elemnts of the matrix
785
+
786
+ m[0, 0] = -sinlon
787
+ m[1, 0] = coslon
788
+ m[2, 0] = 0 if n_samples == 1 else np.zeros(n_samples)
789
+
790
+ m[0, 1] = -sinlat * coslon
791
+ m[1, 1] = -sinlat * sinlon
792
+ m[2, 1] = coslat
793
+
794
+ m[0, 2] = coslat * coslon
795
+ m[1, 2] = coslat * sinlon
796
+ m[2, 2] = sinlat
797
+
798
+ return m
799
+
800
+
801
+ def enu_to_ecef(longitude_deg, latitude_deg, e, n, u, matrix=None):
802
+ """
803
+ Converts from East North and Up to Cartesian XYZ ECEF
804
+
805
+ This accepts scalars or vector of scalars.
806
+
807
+ The latitude and longitude can be expressed either in radians (radians=True)
808
+ or degrees (radians = True)
809
+
810
+ >>> enu = (0.5, 0.5, 1.0)
811
+ >>> enu_to_ecef(0, 0, *enu)
812
+ (1.0, 0.5, 0.5)
813
+
814
+ >>> enu = (0.0, 0.0, 1.0)
815
+ >>> enu_to_ecef(0, 0, *enu)
816
+ (1.0, 0.0, 0.0)
817
+
818
+ >>> lons = [90, 180, 270]
819
+ >>> lats = [0, 0, 0]
820
+ >>> es = [-1, -1, +1]
821
+ >>> ns = [1, 1, 1]
822
+ >>> us = [1, -1, -1]
823
+ >>> dxyz = enu_to_ecef(lons, lats, es, ns, us)
824
+ >>> round(dxyz[0][0], 8)
825
+ 1.0
826
+ >>> round(dxyz[0][1], 8)
827
+ 1.0
828
+ >>> round(dxyz[0][2], 8)
829
+ 1.0
830
+ >>> round(dxyz[1][0], 8)
831
+ 1.0
832
+ >>> round(dxyz[1][1], 8)
833
+ 1.0
834
+ >>> round(dxyz[1][2], 8)
835
+ 1.0
836
+ >>> round(dxyz[2][0], 8)
837
+ 1.0
838
+ >>> round(dxyz[2][1], 8)
839
+ 1.0
840
+ >>> round(dxyz[2][2], 8)
841
+ 1.0
842
+ """
843
+
844
+ if matrix is None:
845
+ m = enu_to_ecef_matrix(longitude_deg, latitude_deg)
846
+
847
+ x = m[0, 0] * e + m[0, 1] * n + m[0, 2] * u
848
+ y = m[1, 0] * e + m[1, 1] * n + m[1, 2] * u
849
+ z = m[2, 0] * e + m[2, 1] * n + m[2, 2] * u
850
+
851
+ return x, y, z
852
+
853
+
854
+ def xyz_to_enu(ref_pos, x, y, z, a=WGS84_A, e=WGS84_E):
855
+ """
856
+ Converts from Cartesian XYZ ECEF to East North and Up given a reference
857
+ position in Cartesian absolute XYZ ECEF.
858
+
859
+ >>> xyz = [WGS84_A, 0.0, 0.0] # Reference position
860
+ >>> dxyz = (1.0, 0.0, 0.0) # Deviation relative to reference position
861
+ >>> enu = xyz_to_enu(xyz, *dxyz) # Conversion to ENU at reference position
862
+ >>> enu
863
+ (0.0, 0.0, 1.0)
864
+
865
+ >>> enu = (0.5, 0.5, 1.0)
866
+ >>> dxyz = enu_to_ecef(0, 0, *enu)
867
+ >>> dxyz
868
+ (1.0, 0.5, 0.5)
869
+ >>> xyz_to_enu(xyz, *dxyz) == enu
870
+ True
871
+ """
872
+
873
+ lla = xyz_to_lla(*ref_pos, a=a, e=e)
874
+
875
+ return ecef_to_enu(lla.longitude, lla.latitude, x, y, z)
876
+
877
+
878
+ def ecef_to_enu(longitude_deg, latitude_deg, x, y, z, matrix=None):
879
+ """
880
+ Converts from Cartesian XYZ ECEF to East North and Up
881
+
882
+ This accepts scalars or vector of scalars.
883
+
884
+ The latitude and longitude can be expressed either in radians (radians=True)
885
+ or degrees (radians = True)
886
+
887
+ >>> dxyz = (1, 0, 0)
888
+ >>> ecef_to_enu(0, 0, *dxyz)
889
+ (0.0, 0.0, 1.0)
890
+
891
+ >>> dxyz = (1, 0.5, 0.5)
892
+ >>> ecef_to_enu(0, 0, *dxyz)
893
+ (0.5, 0.5, 1.0)
894
+
895
+ >>> enu = (0.5, 0.5, 1)
896
+ >>> dxyz = enu_to_ecef(0, 0, *enu)
897
+ >>> dxyz
898
+ (1.0, 0.5, 0.5)
899
+ >>> ecef_to_enu(0, 0, *dxyz) == enu
900
+ True
901
+ """
902
+
903
+ if matrix is None:
904
+ m = enu_to_ecef_matrix(longitude_deg, latitude_deg)
905
+
906
+ e = m[0, 0] * x + m[1, 0] * y + m[2, 0] * z
907
+ n = m[0, 1] * x + m[1, 1] * y + m[2, 1] * z
908
+ u = m[0, 2] * x + m[1, 2] * y + m[2, 2] * z
909
+
910
+ return e, n, u
911
+
912
+
913
+ def body_to_ecef(longitude, latitude, yaw, pitch, roll, x, y, z):
914
+ """
915
+ Transform from body fixed reference frame to Cartesian XYZ-ECEF
916
+ """
917
+
918
+ enu = body_to_enu(yaw, pitch, roll, x, y, z)
919
+
920
+ return enu_to_ecef(longitude, latitude, *enu)
921
+
922
+
923
+ def transpose_pos_geodetic(longitude, latitude, height, yaw, pitch, roll, lx, ly, lz):
924
+ """
925
+ Apply lever arm (l=[lx,ly,lz]) to position (longitude,latitude, height) according to rover attitude (yaw,pitch,roll)
926
+ """
927
+
928
+ leverArmEnu = body_to_enu(yaw, pitch, roll, lx, ly, lz)
929
+ leverArmNed = (leverArmEnu[1], leverArmEnu[0], -1 * leverArmEnu[2])
930
+
931
+ deltaPos = small_perturbation_cart_to_geodetic(leverArmNed[0], leverArmNed[1], leverArmNed[2], latitude, height)
932
+
933
+ return latitude + deltaPos[0], longitude + deltaPos[1], height + deltaPos[2]
934
+
935
+
936
+ def small_perturbation_cart_to_geodetic(n, e, d, latitude_deg, h):
937
+ """
938
+ Transform small perturbations from cartesian (in local navigation frame NED) to geodetic, small angle
939
+ aproximation has to apply, which means norm(n,e,d) <<< Earth Radius
940
+ """
941
+
942
+ lat = np.deg2rad(latitude_deg)
943
+
944
+ sinlat = np.sin(lat)
945
+
946
+ RE = WGS84_A / np.sqrt((1 - (sinlat ** 2) * (WGS84_E ** 2)) ** 3)
947
+ RN = WGS84_A * (1 - WGS84_E ** 2) / np.sqrt(1 - (sinlat ** 2) * (WGS84_E ** 2))
948
+
949
+ deltaLat = n / (RN + h)
950
+ deltaLon = e / ((RE + h) * np.cos(lat))
951
+ deltaH = -1 * d
952
+
953
+ return np.rad2deg(deltaLat), np.rad2deg(deltaLon), deltaH
954
+
955
+
956
+ def haversine(lon1_deg, lat1_deg, lon2_deg, lat2_deg, r=6371):
957
+ """
958
+ Calculate the great circle distance between two points
959
+ on the earth (specified in decimal degrees) using the Haversine formula
960
+
961
+ Return units: kilometers
962
+
963
+ Extracted from http://stackoverflow.com/questions/4913349
964
+
965
+ >>> haversine(0, 0, 0, 0)
966
+ 0.0
967
+
968
+ Example extracted from https://rosettacode.org/wiki/Haversine_formula
969
+
970
+ >>> haversine(-86.67, 36.12, -118.40, 33.94, r=6371.8)
971
+ 2886.8068907353736
972
+
973
+ Arrays can also be used (for a vectorized computation)
974
+ >>> haversine([2, 2, 2], [89, 40, 0], [3, 3, 3], [89, 40, 0])
975
+ array([ 1.94059443, 85.17980895, 111.19492664])
976
+ """
977
+ # convert decimal degrees to radians
978
+ lon1, lat1, lon2, lat2 = map(np.radians, [lon1_deg, lat1_deg, lon2_deg, lat2_deg])
979
+
980
+ # haversine formula
981
+ dlon = lon2 - lon1
982
+ dlat = lat2 - lat1
983
+ a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2
984
+ c = 2 * np.arcsin(np.sqrt(a))
985
+
986
+ # Return a float if input is a single float (to avoid test failures due to numpy float64)
987
+ if np.ndim(lon1_deg) == 0:
988
+ c = float(c)
989
+
990
+ return c * r
991
+
992
+
993
+ def area_triangle_in_sphere(coords, r=6371):
994
+ """
995
+
996
+ Area = pi*R^2*E/180
997
+
998
+ R = radius of sphere
999
+ E = spherical excess of triangle, E = A + B + C - 180
1000
+ A, B, C = angles of spherical triangle in degrees
1001
+
1002
+ tan(E/4) = sqrt(tan(s/2)*tan((s-a)/2)*tan((s-b)/2)*tan((s-c)/2))
1003
+
1004
+ where
1005
+
1006
+ a, b, c = sides of spherical triangle
1007
+ s = (a + b + c)/2
1008
+
1009
+ http://mathforum.org/library/drmath/view/65316.html
1010
+
1011
+ # Compute approximate area of India (3287263 km2)
1012
+ >>> pWest = (68.752441, 23.483401)
1013
+ >>> pEast = (90.153809, 22.87744)
1014
+ >>> pNorth = (76.723022, 32.87036)
1015
+ >>> pSouth = (77.45636, 8.119053)
1016
+ >>> triangle1 = (pWest,pEast,pNorth)
1017
+ >>> triangle2 = (pWest,pEast,pSouth)
1018
+ >>> area_triangle_in_sphere(triangle1) + area_triangle_in_sphere(triangle2)
1019
+ 3028215.293314756
1020
+ """
1021
+
1022
+ # Compute the spherical areas using the haversine method
1023
+ a = haversine(coords[0][0], coords[0][1], coords[1][0], coords[1][1], r=1)
1024
+ b = haversine(coords[1][0], coords[1][1], coords[2][0], coords[2][1], r=1)
1025
+ c = haversine(coords[0][0], coords[0][1], coords[2][0], coords[2][1], r=1)
1026
+
1027
+ s = (a + b + c) / 2.0
1028
+
1029
+ tanE = math.sqrt(math.tan(s / 2) * math.tan((s - a) / 2) * math.tan((s - b) / 2) * math.tan((s - c) / 2))
1030
+
1031
+ E = math.atan(tanE * 4.0)
1032
+
1033
+ area = r * r * E
1034
+
1035
+ return area
1036
+
1037
+
1038
+ class Dop(object):
1039
+ """
1040
+ Class that implements computation of Dilution of Precision (DOP)
1041
+
1042
+
1043
+ Check that dop from NMEA GPGSV is the same as the one in NMEA GPGSA message
1044
+ (further investigations need to be conducted if NMEA generated by u-blox NEO 6P,
1045
+ discrepancies have been found)
1046
+
1047
+ $GPGSA,A,3,10,07,05,02,29,04,08,13,,,,,1.72,1.03,1.38*0A
1048
+ $GPGSV,3,1,11,10,63,137,17,07,61,098,15,05,59,290,20,08,54,157,30*70
1049
+ $GPGSV,3,2,11,02,39,223,19,13,28,070,17,26,23,252,,04,14,186,14*79
1050
+ $GPGSV,3,3,11,29,09,301,24,16,09,020,,36,,,*76
1051
+
1052
+ >>> elAz = [ElAz(63,137),
1053
+ ... ElAz(61, 98),
1054
+ ... ElAz(59,290),
1055
+ ... ElAz(39,223),
1056
+ ... ElAz( 9,301),
1057
+ ... ElAz(14,186),
1058
+ ... ElAz(54,157),
1059
+ ... ElAz(28, 70)]
1060
+ >>> dop = Dop(elAz)
1061
+ >>> f'{dop.pdop():.2f}'
1062
+ '1.72'
1063
+ >>> f'{dop.vdop():.2f}'
1064
+ '1.38'
1065
+ >>> f'{dop.hdop():.2f}'
1066
+ '1.04'
1067
+
1068
+
1069
+ Verification through the example of DOP computation proposed
1070
+ in Exercise 6-5 (page 228) of:
1071
+
1072
+ Misra, P., Enge, P., "Global Positioning System: Signals,
1073
+ Measurements and Performance", 2nd Edition. 2006
1074
+
1075
+ Increasing 30 degrees the azimuth position of 3 sats (+ one in zenith), doubles the vdop value
1076
+ leaving pdop approximately constant
1077
+
1078
+ >>> el_az_list = [ElAz( 0, 0),
1079
+ ... ElAz( 0, 120),
1080
+ ... ElAz( 0, 240),
1081
+ ... ElAz(90, 0)]
1082
+ >>> dop = Dop(el_az_list)
1083
+ >>> f'{dop.vdop():.2f}'
1084
+ '1.15'
1085
+ >>> f'{dop.hdop():.2f}'
1086
+ '1.15'
1087
+
1088
+ >>> el_az_list = [ElAz(30, 0),
1089
+ ... ElAz(30, 120),
1090
+ ... ElAz(30, 240),
1091
+ ... ElAz(90, 0)]
1092
+ >>> dop = Dop(el_az_list)
1093
+ >>> f'{dop.vdop():.2f}'
1094
+ '2.31'
1095
+ >>> f'{dop.hdop():.2f}'
1096
+ '1.33'
1097
+ """
1098
+
1099
+ def __init__(self, el_az_list: List[ElAz]) -> None:
1100
+
1101
+ self.el_az_list = el_az_list
1102
+ self.__G() # generate G matrix
1103
+ self.__Q() # generate Q matrix
1104
+
1105
+ def __G(self) -> None:
1106
+ """
1107
+ Construct geometry matrix G
1108
+ """
1109
+
1110
+ elevations = np.radians([v.elevation for v in self.el_az_list])
1111
+ azimuths = np.radians([v.azimuth for v in self.el_az_list])
1112
+
1113
+ n_satellites = len(self.el_az_list)
1114
+
1115
+ cosel = np.cos(elevations)
1116
+ gx = cosel * np.sin(azimuths)
1117
+ gy = cosel * np.cos(azimuths)
1118
+ gz = np.sin(elevations)
1119
+
1120
+ rows = list(zip(gx, gy, gz, [1] * n_satellites))
1121
+
1122
+ self.G = np.array(rows)
1123
+
1124
+ def __Q(self) -> None:
1125
+ """
1126
+ Construct co-factor matrix
1127
+ """
1128
+ try:
1129
+ self.Q = np.linalg.inv(np.dot(np.matrix.transpose(self.G), self.G))
1130
+ except np.linalg.linalg.LinAlgError as e:
1131
+ self.Q = np.full([4, 4], np.nan)
1132
+ pygnss.logger.warning("Could not compute DOP. Reason {}".format(e))
1133
+
1134
+ def gdop(self) -> float:
1135
+ """
1136
+ Geometric DOP
1137
+ """
1138
+
1139
+ return np.sqrt(self.Q[0, 0] + self.Q[1, 1] + self.Q[2, 2] + self.Q[3, 3])
1140
+
1141
+ def pdop(self) -> float:
1142
+ """
1143
+ Position DOP
1144
+ """
1145
+
1146
+ return np.sqrt(self.Q[0, 0] + self.Q[1, 1] + self.Q[2, 2])
1147
+
1148
+ def tdop(self) -> float:
1149
+ """
1150
+ Time DOP
1151
+ """
1152
+
1153
+ return np.sqrt(self.Q[3, 3])
1154
+
1155
+ def vdop(self) -> float:
1156
+ """
1157
+ Vertical DOP
1158
+ """
1159
+
1160
+ return np.sqrt(self.Q[2, 2])
1161
+
1162
+ def hdop(self) -> float:
1163
+ """
1164
+ Horizontal DOP
1165
+ """
1166
+
1167
+ return np.sqrt(self.Q[0, 0] + self.Q[1, 1])
1168
+
1169
+ def __repr__(self) -> str:
1170
+
1171
+ return f'DOP - position: {self.pdop():.3f} horizontal: {self.hdop():.3f} vertical: {self.vdop():.3f}'
1172
+
1173
+
1174
+ if __name__ == "__main__":
1175
+ import doctest
1176
+
1177
+ doctest.testmod(raise_on_error=True)