1"""Implementations for computations on coordinates.
2
3This module holds all the implementations and wrappers to handle
4coordinate computations in this package.
5
6A few of the most useful classes are lifted to the main package namespace.
7They should be accessed from there when used externally, but from here when
8used internally.
9
10.. autosummary::
11
12 uwacan.Position
13 uwacan.Track
14 uwacan.sensor
15
16Other coordinate wrappers
17-------------------------
18.. autosummary::
19 :toctree: generated
20
21 Coordinates
22 Positions
23 Line
24 BoundingBox
25 Sensor
26 SensorPosition
27 SensorArray
28 SensorArrayPosition
29 SensorArrayPositions
30
31Unit conversions
32----------------
33.. autosummary::
34 :toctree: generated
35
36 nm_to_m
37 m_to_nm
38 mps_to_knots
39 knots_to_kmph
40 kmph_to_knots
41 wrap_angle
42 wgs84_to_utm
43 utm_to_wgs84
44 wgs84_to_local_transverse_mercator
45 local_transverse_mercator_to_wgs84
46 wgs84_to_sweref99
47 sweref99_to_wgs84
48
49Geodesic computations
50---------------------
51.. autosummary::
52 :toctree: generated
53
54 distance_to
55 bearing_to
56 shift_position
57 average_angle
58 angle_between
59
60"""
61
62import re
63import numpy as np
64import xarray as xr
65from . import _core
66from pathlib import Path
67import functools
68
69
[docs]
70def nm_to_m(nm):
71 """Convert nautical miles to meters."""
72 return nm * 1852
73
74
[docs]
75def m_to_nm(m):
76 """Convert meters to nautical miles."""
77 return m / 1852
78
79
[docs]
80def mps_to_knots(mps):
81 """Convert meters per second to knots."""
82 return mps * (3600 / 1852)
83
84
85def knots_to_mps(knots):
86 """Convert knots to meters per second."""
87 return knots * (1852 / 3600)
88
89
[docs]
90def knots_to_kmph(knots):
91 """Convert knots to kilometers per hour."""
92 return knots * 1.852
93
94
[docs]
95def kmph_to_knots(kmph):
96 """Convert kilometers per hour to knots."""
97 return kmph / 1.852
98
99
[docs]
100def wrap_angle(degrees):
101 """Wrap an angle to (-180, 180]."""
102 return 180 - np.mod(180 - degrees, 360)
103
104
105_WGS84_equatorial_radius = 6_378_137.0
106_WGS84_polar_radius = 6_356_752.3
107_WGS84_square_compression = (_WGS84_polar_radius / _WGS84_equatorial_radius) ** 2
108_mercator_scale_factor = 0.9996
109_WGS84_flattening = 1 / 298.257223563
110_WGS84_third_flattening = _WGS84_flattening / (2 - _WGS84_flattening) # n on wikipedia
111_WGS84_meridian_length = (
112 _WGS84_equatorial_radius
113 / (1 + _WGS84_third_flattening)
114 * (1 + _WGS84_third_flattening**2 / 4 + _WGS84_third_flattening**4 / 64)
115) # A on wikipedia
116_WGS84_UTM_alpha = [
117 1 / 2 * _WGS84_third_flattening - 2 / 3 * _WGS84_third_flattening**2 + 5 / 16 * _WGS84_third_flattening**3,
118 13 / 48 * _WGS84_third_flattening**2 - 3 / 5 * _WGS84_third_flattening**3,
119 61 / 240 * _WGS84_third_flattening**3,
120]
121_WGS84_UTM_beta = [
122 1 / 2 * _WGS84_third_flattening - 2 / 3 * _WGS84_third_flattening**2 + 37 / 96 * _WGS84_third_flattening**3,
123 1 / 48 * _WGS84_third_flattening**2 + 1 / 15 * _WGS84_third_flattening**3,
124 17 / 480 * _WGS84_third_flattening**3,
125]
126
127_WGS84_UTM_delta = [
128 2 * _WGS84_third_flattening - 2 / 3 * _WGS84_third_flattening**2 - 2 * _WGS84_third_flattening**3,
129 7 / 3 * _WGS84_third_flattening**2 - 8 / 5 * _WGS84_third_flattening**3,
130 56 / 15 * _WGS84_third_flattening**3,
131]
132
133
134def _utm_zone(longitude, rounded=True):
135 # Not using our `wrap_angle`` since we want [-180, 180)
136 longitude = np.mod(longitude + 180, 360) - 180
137 utm_zone = (longitude + 180) / 6 + 1
138 if rounded:
139 utm_zone = np.floor(utm_zone).astype(int)
140 else:
141 # The central meridian is 3° east of the indicated zone.
142 utm_zone -= 3 / 6
143 return utm_zone
144
145
[docs]
146def wgs84_to_utm(latitude, longitude, utm_zone=None):
147 """Convert WGS84 latitude and longitude to UTM coordinates.
148
149 Parameters
150 ----------
151 latitude : float, array_like
152 The WGS84 latitude in degrees, positive on the northern hemisphere and negative on the souther hemisphere.
153 longitude : float, array_like
154 The WGS84 longitude in degrees, positive is east and negative is west of the prime meridian.
155 utm_zone : numeric, optional
156 A fixed utm zone to use. Omit this argument and the correct utm zone is chosen.
157
158 Returns
159 -------
160 easting : float, array_like
161 Meters easting.
162 northing : float, array_like
163 Meters northing.
164 utm_zone : int, array_like
165 The utm zone for the outputs.
166
167 Notes
168 -----
169 The UTM zones are 6° wide, with zone 31 covering [0°,6°), with higher zone numbers to the east.
170 The easting values are defined as meters east of 500km west of the central meridian in each zone.
171 The northing values are meters north of the equator on the northern hemisphere,
172 and meters north of 10,000km on the southern hemisphere.
173 This implementation uses formulas from the `Wikipedia article <https://en.m.wikipedia.org/wiki/Universal_Transverse_Mercator_coordinate_system>`_.
174
175 """
176 if utm_zone is None:
177 utm_zone = _utm_zone(longitude)
178 central_longitude = (utm_zone - 1) * 6 - 180 + 3
179
180 # Convert latitude and longitude to radians
181 lat_rad = np.radians(latitude)
182 lon_rad = np.radians(longitude)
183 central_lon_rad = np.radians(central_longitude)
184
185 # Calculate intermediate values
186 t = np.sinh(
187 np.arctanh(np.sin(lat_rad))
188 - (2 * np.sqrt(_WGS84_third_flattening) / (1 + _WGS84_third_flattening))
189 * np.arctanh((2 * np.sqrt(_WGS84_third_flattening) / (1 + _WGS84_third_flattening)) * np.sin(lat_rad))
190 )
191 xi_prime = np.arctan(t / np.cos(lon_rad - central_lon_rad))
192 eta_prime = np.arctanh(np.sin(lon_rad - central_lon_rad) / np.sqrt(1 + t**2))
193
194 easting = 500_000 + _mercator_scale_factor * _WGS84_meridian_length * (
195 eta_prime
196 + sum(
197 alpha * np.cos(2 * j * xi_prime) * np.sinh(2 * j * eta_prime)
198 for j, alpha in enumerate(_WGS84_UTM_alpha, start=1)
199 )
200 )
201 northing = (latitude < 0) * 10_000_000 + _mercator_scale_factor * _WGS84_meridian_length * (
202 xi_prime
203 + sum(
204 alpha * np.sin(2 * j * xi_prime) * np.cosh(2 * j * eta_prime)
205 for j, alpha in enumerate(_WGS84_UTM_alpha, start=1)
206 )
207 )
208
209 return easting, northing, utm_zone
210
211
[docs]
212def utm_to_wgs84(easting, northing, utm_zone, is_south=False):
213 """Convert UTM coordinates to WGS84 coordinates.
214
215 Parameters
216 ----------
217 easting : float, array_like
218 Meters easting.
219 northing : float, array_like
220 Meters northing.
221 utm_zone : int, array_like
222 The utm zone for the coordinate.
223 is_south : bool, array_like, default=False
224 If the coordinate(s) are on the souther hemisphere or not.
225
226 Returns
227 -------
228 latitude : float, array_like
229 The WGS84 latitude in degrees, positive on the northern hemisphere and negative on the souther hemisphere.
230 longitude : float, array_like
231 The WGS84 longitude in degrees, positive is east and negative is west of the prime meridian.
232
233 Notes
234 -----
235 The UTM zones are 6° wide, with zone 31 covering [0°,6°), with higher zone numbers to the east.
236 The easting values are defined as meters east of 500km west of the central meridian in each zone.
237 The northing values are meters north of the equator on the northern hemisphere,
238 and meters north of 10,000km on the southern hemisphere.
239 This implementation uses formulas from the `Wikipedia article <https://en.m.wikipedia.org/wiki/Universal_Transverse_Mercator_coordinate_system>`_.
240
241 """
242 xi = (northing - is_south * 10_000_000) / (_mercator_scale_factor * _WGS84_meridian_length)
243 eta = (easting - 500_000) / (_mercator_scale_factor * _WGS84_meridian_length)
244
245 xi_prime = xi - sum(
246 beta * np.sin(2 * j * xi) * np.cosh(2 * j * eta) for j, beta in enumerate(_WGS84_UTM_beta, start=1)
247 )
248 eta_prime = eta - sum(
249 beta * np.cos(2 * j * xi) * np.sinh(2 * j * eta) for j, beta in enumerate(_WGS84_UTM_beta, start=1)
250 )
251 chi = np.arcsin(np.sin(xi_prime) / np.cosh(eta_prime))
252 latitude = np.degrees(chi + sum(delta * np.sin(2 * j * chi) for j, delta in enumerate(_WGS84_UTM_delta, start=1)))
253 central_longitude = (utm_zone - 1) * 6 - 180 + 3
254 longitude = central_longitude + np.degrees(np.arctan(np.sinh(eta_prime) / np.cos(xi_prime)))
255
256 return latitude, longitude
257
258
[docs]
259def wgs84_to_sweref99(latitude, longitude):
260 """Convert WGS84 coordinates to Sweref99.
261
262 The Sweref99 coordinate system is the same as UTM coordinates,
263 but always using UTM zone 33, as described by
264 `Lantmäteriet <https://www.lantmateriet.se/sv/geodata/gps-geodesi-och-swepos/Om-geodesi/Kartprojektioner/UTM/>`_.
265
266 Parameters
267 ----------
268 latitude : float, array_like
269 The WGS84 latitude in degrees, positive on the northern hemisphere and negative on the souther hemisphere.
270 longitude : float, array_like
271 The WGS84 longitude in degrees, positive is east and negative is west of the prime meridian.
272
273 Returns
274 -------
275 easting : float, array_like
276 Meters easting.
277 northing : float, array_like
278 Meters northing.
279 """
280 return wgs84_to_utm(latitude=latitude, longitude=longitude, utm_zone=33)[:2]
281
282
[docs]
283def sweref99_to_wgs84(easting, northing):
284 """Convert Sweref99 coordinates to WGS84.
285
286 The Sweref99 coordinate system is the same as UTM coordinates,
287 but always using UTM zone 33, as described by
288 `Lantmäteriet <https://www.lantmateriet.se/sv/geodata/gps-geodesi-och-swepos/Om-geodesi/Kartprojektioner/UTM/>`_.
289
290 Parameters
291 ----------
292 easting : float, array_like
293 Meters easting.
294 northing : float, array_like
295 Meters northing.
296
297 Returns
298 -------
299 latitude : float, array_like
300 The WGS84 latitude in degrees, positive on the northern hemisphere and negative on the souther hemisphere.
301 longitude : float, array_like
302 The WGS84 longitude in degrees, positive is east and negative is west of the prime meridian.
303 """
304 return utm_to_wgs84(easting=easting, northing=northing, utm_zone=33, is_south=False)
305
306
[docs]
307def wgs84_to_local_transverse_mercator(latitude, longitude, reference_latitude, reference_longitude):
308 """Convert WGS84 coordinates into a local transverse mercator projection.
309
310 A local transverse mercator projection is a coordinate system measuring meters east and north
311 of a fixed point.
312
313 Parameters
314 ----------
315 latitude : float, array_like
316 The WGS84 latitude in degrees, positive on the northern hemisphere and negative on the souther hemisphere.
317 longitude : float, array_like
318 The WGS84 longitude in degrees, positive is east and negative is west of the prime meridian.
319 reference_latitude : float
320 The WGS84 latitude of the fixed reference point.
321 reference_longitude : float
322 The WGS84 longitude of the fixed reference point.
323
324 Returns
325 -------
326 easting : float, array_like
327 Meters easting.
328 northing : float, array_like
329 Meters northing.
330 """
331 utm_zone = _utm_zone(reference_longitude, rounded=False)
332 east_0, north_0, _ = wgs84_to_utm(reference_latitude, reference_longitude, utm_zone=utm_zone)
333 east, north, _ = wgs84_to_utm(latitude, longitude, utm_zone=utm_zone)
334 return east - east_0, north - north_0
335
336
[docs]
337def local_transverse_mercator_to_wgs84(easting, northing, reference_latitude, reference_longitude):
338 """Convert local transverse mercator coordinates into WGS84.
339
340 A local transverse mercator projection is a coordinate system measuring meters east and north
341 of a fixed point.
342
343 Parameters
344 ----------
345 easting : float, array_like
346 Meters easting.
347 northing : float, array_like
348 Meters northing.
349 reference_latitude : float
350 The WGS84 latitude of the fixed reference point.
351 reference_longitude : float
352 The WGS84 longitude of the fixed reference point.
353
354 Returns
355 -------
356 latitude : float, array_like
357 The WGS84 latitude in degrees, positive on the northern hemisphere and negative on the souther hemisphere.
358 longitude : float, array_like
359 The WGS84 longitude in degrees, positive is east and negative is west of the prime meridian.
360 """
361 utm_zone = _utm_zone(reference_longitude, rounded=False)
362 east_0, north_0, _ = wgs84_to_utm(reference_latitude, reference_longitude, utm_zone=utm_zone)
363 return utm_to_wgs84(
364 easting=easting + east_0, northing=northing + north_0, utm_zone=utm_zone, is_south=reference_latitude < 0
365 )
366
367
368def _geodetic_to_geocentric(lat):
369 r"""Compute the geocentric latitude from geodetic, in radians.
370
371 The geocentric latitude is the latitude as seen from the center
372 of the earth. The geodetic latitude is the angle formed with the
373 equatorial plane when drawing the normal at a surface on the earth.
374
375 The conversion for the geocentric latitude :math:`\hat φ` is
376
377 .. math::
378 \hat φ = \arctan(\tan(φ) b^2/a^2)
379
380 with the geodetic latitude :math:`φ`, and the equatorial and polar
381 earth radii :math:`a, b` respectively.
382 """
383 return np.arctan(np.tan(lat) * _WGS84_square_compression)
384
385
386def _geocentric_to_geodetic(lat):
387 r"""Compute the geodetic latitude from geocentric, in radians.
388
389 The geodetic latitude is the angle formed with the
390 equatorial plane when drawing the normal at a surface on the earth.
391 The geocentric latitude is the latitude as seen from the center
392 of the earth.
393
394 The conversion for the geocentric latitude :math:`φ` is
395
396 .. math::
397 φ = \arctan(\tan(\hat φ) a^2/b^2)
398
399 with the geocentric latitude :math:`\hat φ`, and the equatorial and polar
400 earth radii :math:`a, b` respectively.
401 """
402 return np.arctan(np.tan(lat) / _WGS84_square_compression)
403
404
405def _local_earth_radius(lat):
406 r"""Compute the earth radius at a given latitude, in radians.
407
408 The formula is
409
410 .. math::
411 R( φ) = \sqrt{\frac{(a^2\cos φ)^2+(b^2\sin φ)^2}{(a\cos φ)^2+(b\sin φ)^2}}
412
413 with the geodetic latitude :math:`φ`, and the equatorial and polar earth radii
414 :math:`a, b` respectively, see https://en.wikipedia.org/wiki/Earth_radius#Location-dependent_radii.
415 """
416 return (
417 ((_WGS84_equatorial_radius**2 * np.cos(lat)) ** 2 + (_WGS84_polar_radius**2 * np.sin(lat)) ** 2)
418 / ((_WGS84_equatorial_radius * np.cos(lat)) ** 2 + (_WGS84_polar_radius * np.sin(lat)) ** 2)
419 ) ** 0.5
420
421
422def _haversine(theta):
423 r"""Compute the haversine of an angle, in radians.
424
425 This is the same as
426
427 .. math:: \sin^2(θ/2)
428 """
429 return np.sin(theta / 2) ** 2
430
431
[docs]
432@_core.xr_ufunc()
433def distance_to(lat_1, lon_1, lat_2, lon_2):
434 r"""Calculate the distance between two coordinates.
435
436 Conventions here are λ as the longitude and φ as the latitude.
437 This implementation uses the Haversine formula:
438
439 .. math::
440 c &= H(Δφ) + (1 - H(Δφ) - H(φ_1 + φ_2)) ⋅ H(Δλ)\\
441 d &= 2 R(φ) ⋅ \arcsin(\sqrt{c})\\
442 H(θ) &= \sin^2(θ/2)
443
444 implemented internally with conversions to geocentric coordinates
445 and a latitude-dependent earth radius.
446
447 Parameters
448 ----------
449 lat_1 : float
450 Latitude of the first point in degrees.
451 lon_1 : float
452 Longitude of the first point in degrees.
453 lat_2 : float
454 Latitude of the second point in degrees.
455 lon_2 : float
456 Longitude of the second point in degrees.
457
458 Returns
459 -------
460 float
461 Distance between the two points in meters.
462
463 """
464 lat_1 = np.radians(lat_1)
465 lon_1 = np.radians(lon_1)
466 lat_2 = np.radians(lat_2)
467 lon_2 = np.radians(lon_2)
468 r = _local_earth_radius((lat_1 + lat_2) / 2)
469 lat_1 = _geodetic_to_geocentric(lat_1)
470 lat_2 = _geodetic_to_geocentric(lat_2)
471 central_angle = _haversine(lat_2 - lat_1) + (
472 1 - _haversine(lat_1 - lat_2) - _haversine(lat_1 + lat_2)
473 ) * _haversine(lon_2 - lon_1)
474 d = 2 * r * np.arcsin(central_angle**0.5)
475 return d
476
477
[docs]
478@_core.xr_ufunc()
479def bearing_to(lat_1, lon_1, lat_2, lon_2):
480 r"""Calculate the heading from one coordinate to another.
481
482 Conventions here are λ as the longitude and φ as the latitude.
483 The implementation is based on spherical trigonometry, with
484 conversions to geocentric coordinates.
485 This can be written as
486
487 .. math::
488 Δx &= \cos(φ_1) \sin(φ_2) - \sin(φ_1)\cos(φ_2)\cos(φ_2 - φ_1) \\
489 Δy &= \sin(λ_2 - λ_1)\cos(φ_2) \\
490 θ &= \arctan(Δy/Δx)
491
492 Parameters
493 ----------
494 lat_1 : float
495 Latitude of the first point in degrees.
496 lon_1 : float
497 Longitude of the first point in degrees.
498 lat_2 : float
499 Latitude of the second point in degrees.
500 lon_2 : float
501 Longitude of the second point in degrees.
502
503 Returns
504 -------
505 float
506 Bearing from the first point to the second point in degrees, wrapped to (-180, 180].
507 """
508 lat_1 = np.radians(lat_1)
509 lon_1 = np.radians(lon_1)
510 lat_2 = np.radians(lat_2)
511 lon_2 = np.radians(lon_2)
512 lat_1 = _geodetic_to_geocentric(lat_1)
513 lat_2 = _geodetic_to_geocentric(lat_2)
514
515 dy = np.sin(lon_2 - lon_1) * np.cos(lat_2)
516 dx = np.cos(lat_1) * np.sin(lat_2) - np.sin(lat_1) * np.cos(lat_2) * np.cos(lat_2 - lat_1)
517
518 bearing = np.arctan2(dy, dx)
519 return wrap_angle(np.degrees(bearing))
520
521
[docs]
522@_core.xr_ufunc(n_outputs=2)
523def shift_position(lat, lon, distance, bearing):
524 r"""Shifts a position given by latitude and longitude by a certain distance and bearing.
525
526 The implementation is based on spherical trigonometry, with internal
527 conversions to geocentric coordinates, and using the local radius of the earth.
528 This is expressed as
529
530 .. math::
531 φ_2 &= \arcsin(\sin(φ_1) ⋅ \cos(δ) + \cos(φ_1) ⋅ \sin(δ) ⋅ \cos(θ)) \\
532 λ_2 &= λ_1 + \arctan(\frac{\sin(θ) ⋅ \sin(δ) ⋅ \cos(φ_1)}{\cos(δ) - \sin(φ_1) ⋅ \sin(φ_2)})
533
534 where: φ is latitude, λ is longitude, θ is the bearing (clockwise from north),
535 δ is the angular distance d/R; d being the distance traveled, R the earth's radius.
536
537 Parameters
538 ----------
539 lat : float
540 Latitude of the initial position in degrees.
541 lon : float
542 Longitude of the initial position in degrees.
543 distance : float
544 Distance to move from the initial position in meters.
545 bearing : float
546 Direction to move from the initial position in degrees.
547
548 Returns
549 -------
550 new_lat : float
551 Latitude of the new position in degrees.
552 new_lon : float
553 Longitude of the new position in degrees.
554 """
555 lat = np.radians(lat)
556 lon = np.radians(lon)
557 bearing = np.radians(bearing)
558 r = _local_earth_radius(lat)
559 lat = _geodetic_to_geocentric(lat)
560 dist = distance / r # angular distance
561 new_lat = np.arcsin(np.sin(lat) * np.cos(dist) + np.cos(lat) * np.sin(dist) * np.cos(bearing))
562 new_lon = lon + np.arctan2(
563 np.sin(bearing) * np.sin(dist) * np.cos(lat), np.cos(dist) - np.sin(lat) * np.sin(new_lat)
564 )
565 new_lat = _geocentric_to_geodetic(new_lat)
566 return np.degrees(new_lat), np.degrees(new_lon)
567
568
[docs]
569def average_angle(angle, resolution=None):
570 """Calculate the average angle and optionally round it to a specified resolution.
571
572 Parameters
573 ----------
574 angle : array_like
575 Array of angles in degrees to be averaged.
576 resolution : int, str, optional
577 Specifies the resolution for rounding the angle. It can be an integer specifying the number
578 of divisions (e.g., 4, 8, 16) or a string ('4', '8', '16', 'four', 'eight', 'sixteen').
579
580 Returns
581 -------
582 float or str
583 If resolution is None, returns the average angle in degrees.
584 If resolution is an integer, returns the average angle rounded to this fraction of a turn.
585 If resolution is a string, returns the closest named direction (e.g., 'North', 'Southwest').
586
587 Raises
588 ------
589 ValueError
590 If an unknown resolution specifier is provided.
591
592 Notes
593 -----
594 The function converts the input angles to complex numbers, computes their mean, and then converts back to an angle.
595 If a string resolution is specified, the function maps the average angle to the nearest named direction.
596
597 Examples
598 --------
599 >>> average_angle([350, 10, 40, 40])
600 20.15962133607971
601 >>> average_angle([350, 10, 30], resolution=10)
602 36.0
603 >>> average_angle([350, 10, 30], resolution='four')
604 'North'
605 >>> average_angle([350, 10, 20], resolution='sixteen')
606 'North-northeast'
607 """
608 # Note: this function is not decorated with @_core.xr_ufunc() since it's a reduction, which is trickier to handle with the same generality.
609 complex_angle = np.exp(1j * np.radians(angle))
610 angle = wrap_angle(np.degrees(np.angle(complex_angle.mean())))
611 if resolution is None:
612 return angle
613
614 if not isinstance(resolution, str):
615 return wrap_angle(np.round(angle / 360 * resolution) * 360 / resolution)
616
617 resolution = resolution.lower()
618 if "4" in resolution or "four" in resolution:
619 resolution = 4
620 elif "8" in resolution or "eight" in resolution:
621 resolution = 8
622 elif "16" in resolution or "sixteen" in resolution:
623 resolution = 16
624 else:
625 raise ValueError(f"Unknown resolution specifier '{resolution}'")
626
627 names = [
628 (-180.0, "south"),
629 (-90.0, "west"),
630 (0.0, "north"),
631 (90.0, "east"),
632 (180.0, "south"),
633 ]
634
635 if resolution >= 8:
636 names.extend(
637 [
638 (-135.0, "southwest"),
639 (-45.0, "northwest"),
640 (45.0, "northeast"),
641 (135.0, "southeast"),
642 ]
643 )
644 if resolution >= 16:
645 names.extend(
646 [
647 (-157.5, "south-southwest"),
648 (-112.5, "west-southwest"),
649 (-67.5, "west-northwest"),
650 (-22.5, "north-northwest"),
651 (22.5, "north-northeast"),
652 (67.5, "east-northeast"),
653 (112.5, "east-southeast"),
654 (157.5, "south-southeast"),
655 ]
656 )
657 name = min([(abs(deg - angle), name) for deg, name in names], key=lambda x: x[0])[1]
658 return name.capitalize()
659
660
[docs]
661@_core.xr_ufunc()
662def angle_between(lat, lon, lat_1, lon_1, lat_2, lon_2):
663 """Calculate the angle between two coordinates, as seen from a center vertex.
664
665 The angle is counted positive if the second point is clockwise of the first point,
666 as seen from the center vertex.
667
668 Parameters
669 ----------
670 lat : float
671 Latitude of the center vertex in degrees.
672 lon : float
673 Longitude of the center vertex in degrees.
674 lat_1 : float
675 Latitude of the first point in degrees.
676 lon_1 : float
677 Longitude of the first point in degrees.
678 lat_2 : float
679 Latitude of the second point in degrees.
680 lon_2 : float
681 Longitude of the second point in degrees.
682
683 Returns
684 -------
685 float
686 The angle between the two points as seen from the center vertex, in degrees. The angle is normalized to the range (-180, 180].
687 """
688 bearing_1 = bearing_to(lat, lon, lat_1, lon_1)
689 bearing_2 = bearing_to(lat, lon, lat_2, lon_2)
690 return wrap_angle(bearing_2 - bearing_1)
691
692
[docs]
693class Coordinates(_core.DatasetWrap):
694 """Container for latitude and longitude.
695
696 This class has a number of useful methods to perform computations
697 on coordinates.
698
699 Parameters
700 ----------
701 coordinates : `xarray.Dataset`
702 Dataset with at least "latitude" and "longitude"
703 latitude : float
704 A latitude value to store.
705 longitude : float
706 A longitude value to store.
707 """
708
709 def __init__(self, coordinates=None, /, latitude=None, longitude=None):
710 if coordinates is None:
711 coordinates = xr.Dataset(data_vars={"latitude": latitude, "longitude": longitude})
712 if isinstance(coordinates, Coordinates):
713 coordinates = coordinates._data.copy()
714 super().__init__(coordinates)
715
716 @property
717 def coordinates(self):
718 """The latitude and longitude for this coordinate, as `~xarray.Dataset`."""
719 return self._data[["latitude", "longitude"]]
720
721 @property
722 def latitude(self):
723 """The latitude for coordinate, as `~xarray.DataArray`."""
724 return self._data["latitude"]
725
726 @property
727 def longitude(self):
728 """The longitude for coordinate, as `~xarray.DataArray`."""
729 return self._data["longitude"]
730
[docs]
731 def distance_to(self, other):
732 """Calculate the distance to another coordinate.
733
734 See `uwacan.positional.distance_to` for details on the implementation.
735
736 Parameters
737 ----------
738 other : `Coordinates` or convertible
739 The coordinate to calculate the distance to.
740 If this is an object with ``latitude`` and ``longitude``
741 attributes, they will be used. Otherwise, the
742 `Position` parser will be called.
743
744 Returns
745 -------
746 distance : `~xarray.DataArray`
747 The distance to the other coordinate.
748 """
749 other = self._ensure_latlon(other)
750 return distance_to(self.latitude, self.longitude, other.latitude, other.longitude)
751
[docs]
752 def bearing_to(self, other):
753 """Calculate the bearing to another coordinate.
754
755 See `uwacan.positional.bearing_to` for details on the implementation.
756
757 Parameters
758 ----------
759 other : `Coordinates` or convertible
760 The coordinate to calculate the bearing to.
761 If this is an object with ``latitude`` and ``longitude``
762 attributes, they will be used. Otherwise, the
763 `Position` parser will be called.
764
765 Returns
766 -------
767 bearing : `~xarray.DataArray`
768 The bearing to the other coordinate.
769 """
770 other = self._ensure_latlon(other)
771 return bearing_to(self.latitude, self.longitude, other.latitude, other.longitude)
772
[docs]
773 def shift_position(self, distance, bearing):
774 """Shift this coordinate by a distance in a certain bearing.
775
776 See `uwacan.positional.shift_position` for details on the implementation.
777
778 Parameters
779 ----------
780 distance : array_like
781 The distance to shift, in meters.
782 bearing : array_like
783 The bearing to shift towards, in degrees clockwise from true north.
784
785 Returns
786 -------
787 shifted : cls
788 An object of the same class as the one used for shifting,
789 with modified latitude and longitude.
790 """
791 lat, lon = shift_position(self.latitude, self.longitude, distance, bearing)
792 data = self.data.assign(latitude=lat, longitude=lon)
793 return type(self)(data)
794
795 @classmethod
796 def _ensure_latlon(cls, data):
797 if not (hasattr(data, "latitude") and hasattr(data, "longitude")):
798 # If it doesn't have lat and long we need to construct an object which
799 # has them. If we get lists of values we will get a `Position` with lat,lon
800 # arrays here. This usually doesn't work, but we only need to access them,
801 # which will work fine. The `Position` can handle many of the other
802 # useful stuff, like strings and tuples
803 data = Position(data)
804 return data
805
[docs]
806 def local_length_scale(self):
807 """How many nautical miles one longitude minute is.
808
809 This gives the apparent length scale for the x-axis in
810 mercator projections, i.e., cos(latitude).
811 The scaleratio for an x-axis should be set to this value,
812 if equal length x- and y-axes are desired, e.g.::
813
814 xaxis=dict(
815 title_text='Longitude',
816 constrain='domain',
817 scaleanchor='y',
818 scaleratio=pos.local_length_scale(),
819 ),
820 yaxis=dict(
821 title_text='Latitude',
822 constrain='domain',
823 ),
824 """
825 # We take the mean so that it works with subclasses with arrays, e.g., Line.
826 return np.cos(np.radians(self.latitude.mean().item()))
827
828 def _figure_template(self, lat=None, lon=None, extent=None, **kwargs):
829 template = super()._figure_template(**kwargs)
830 height = kwargs.get("height", 800)
831
832 if lat is None:
833 try:
834 lat = self.bounding_box.center.latitude
835 except AttributeError:
836 lat = self.latitude.mean().item()
837 if lon is None:
838 try:
839 lon = self.bounding_box.center.longitude
840 except AttributeError:
841 lon = self.longitude.mean().item()
842 if extent is None:
843 try:
844 extent = self.bounding_box.extent() * 1.05
845 except AttributeError:
846 extent = 10_000
847
848 lat = float(lat)
849 lon = float(lon)
850
851 # https://docs.mapbox.com/help/glossary/zoom-level/
852 # The meters/pixel in mapbox is 40_075_016.686 * cos(lat) / 2^zoom / 512
853 # Where R=40_075_016.686 is the equator circumference,
854 # cos(lat) is the local length scale,
855 # and 512 is the size of a single tile.
856 # This means the zoom to fit a distance D over P pixels is log2(R/D P/512 cos(lat))
857 zoom = np.log2(40_075_016.686 / float(extent) * height / 512 * np.cos(np.radians(lat)))
858 template.layout.update(
859 map=dict(
860 style="carto-positron",
861 zoom=zoom,
862 center={"lat": lat, "lon": lon},
863 ),
864 height=height,
865 margin={"r": 0, "t": 0, "l": 0, "b": 0},
866 legend=dict(
867 orientation="h",
868 yanchor="bottom",
869 y=1.02,
870 xanchor="left",
871 x=0,
872 ),
873 )
874 return template
875
904
[docs]
905 def plot(self, use_minutes=True, include_time=True, name=None, text=None, hover_data=None, **kwargs):
906 """Create a plotly trace for the coordinates.
907
908 Parameters
909 ----------
910 use_minutes : bool, default=True
911 Uses degrees and decimal minutes for the latitude and longitude hover.
912 include_time : bool, default=True
913 Controls if a time value should be included in the hover.
914 If a string is passed, it will be used to format the times using strftime syntax.
915 Default formatting is ``"%Y-%m-%d %H:%M:%S"``.
916 name : str, optional
917 The name or label of this trace. Used for legend and hover.
918 text : [str], optional
919 A list of text labels to show on hover for each point.
920 hover_data : dict, optional
921 Mapping to add properties to the hover. The keys should match keys in the
922 track data. The values are either ``True``, ``False``, or a d3-style formatting
923 specification, e.g., ``":.3f"``.
924 For data with time types, the formatting string is a strftime specification,
925 defaulting to ``"%Y-%m-%d %H:%D:%S"``.
926 **kwargs
927 All other keywords are passed to `~plotly.graph_objects.Scattermap`.
928 Useful keywords are:
929
930 - ``mode`` to choose ``"lines"``, ``"markers"``, or ``"lines+markers"``
931 - ``line_color`` and ``marker_color``
932
933 Note that the ``hovertemplate``, ``customdata``, ``meta``, ``lat``, ``lon`` keywords will be overwritten.
934 """
935 import plotly.graph_objects as go
936
937 customdata = []
938 meta = []
939 hovertemplate = ""
940
941 lat = np.atleast_1d(self.latitude)
942 lon = np.atleast_1d(self.longitude)
943
944 if name is not None:
945 hovertemplate += name + "<br>"
946
947 if use_minutes:
948 ns = np.where(lat > 0, "N", "S")
949 latdeg, latmin = np.divmod(np.abs(lat), 1)
950 latmin *= 60
951 ew = np.where(lon > 0, "E", "W")
952 londeg, lonmin = np.divmod(np.abs(lon), 1)
953 lonmin *= 60
954 customdata.extend([latdeg, latmin, ns, londeg, lonmin, ew])
955 # The degrees are floats here, so we format with .0f.
956 # Floats can properly handle nan, so it's better to keep them as floats.
957 hovertemplate += "%{customdata[0]:.0f}º %{customdata[1]:.3f}' %{customdata[2]} %{customdata[3]:.0f}º %{customdata[4]:.3f}' %{customdata[5]}"
958 else:
959 hovertemplate += "%{lat:.6f}º %{lon:.6f}º"
960
961 if include_time and hasattr(self, "time"):
962 if include_time is True:
963 include_time = "%Y-%m-%d %H:%M:%S"
964 try:
965 time = self.time.dt.strftime(include_time).data
966 except AttributeError:
967 hovertemplate += f"<br>%{{meta[{len(meta)}]}}"
968 meta.append(self.time.data)
969 else:
970 if (time.size == 1):
971 hovertemplate += f"<br>%{{meta[{len(meta)}]}}"
972 meta.append(time.item())
973 else:
974 hovertemplate += f"<br>%{{customdata[{len(customdata)}]}}"
975 customdata.append(np.atleast_1d(time))
976
977 hover_data = hover_data or {}
978 extra_fields = []
979 hovertemplate += "<extra>"
980 if text is not None:
981 extra_fields.append("%{text}")
982 for key, item in hover_data.items():
983 if not item:
984 continue
985 if item is True:
986 item = ""
987 data = self[key]
988 if np.issubdtype(data.dtype, np.datetime64):
989 # Pre-format time data into strings.
990 item = item or "%Y-%m-%d %H:%M:%S"
991 data = data.dt.strftime(item).data
992 item = ""
993 if data.size == 1:
994 extra_fields.append(f"{key}=%{{meta[{len(meta)}]{item}}}")
995 meta.append(data)
996 else:
997 extra_fields.append(f"{key}=%{{customdata[{len(customdata)}]{item}}}")
998 customdata.append(data)
999 hovertemplate += "<br>".join(extra_fields)
1000 hovertemplate += "</extra>"
1001
1002 customdata = np.stack(customdata, axis=1)
1003 kwargs["lat"] = lat
1004 kwargs["lon"] = lon
1005 kwargs["name"] = name
1006 kwargs["hovertemplate"] = hovertemplate
1007 kwargs["customdata"] = customdata
1008 kwargs["text"] = text
1009 kwargs["meta"] = meta
1010
1011 trace = go.Scattermap(**kwargs)
1012 return trace
1013
1014
[docs]
1015class Position(Coordinates):
1016 """Class for single positions.
1017
1018 This class is designed for handling of single positions,
1019 and includes reasonable operations that can only be performed
1020 with single coordinates at once.
1021
1022 This class supports multiple call signatures:
1023
1024 - ``Position(str)``
1025 A single string which will be parsed. This handles the
1026 most common "readable" formats. Important that the string
1027 has the degree symbol °, minute symbol ', and second symbol ".
1028 The last two are optional, but minutes and seconds will only
1029 be parsed if the string includes the relevant symbols.
1030 - ``Position(pos)``
1031 A single argument with ``latitude`` and ``longitude`` attributes or keys.
1032 - ``Position(lat, lon)``
1033 A pair of two values will be interpreted as the latitude and longitude directly.
1034 - ``Position(lat_deg, lat_min, lon_deg, lon_min)``
1035 Four values will be interpreted as latitude and longitude with degrees and minutes.
1036 - ``Position(lat_deg, lat_min, lat_sec, lon_deg, lon_min, lon_sec)``
1037 Six values will be interpreted as latitude and longitude with degrees, minutes, and seconds.
1038 - ``Position(latitude=lat, longitude=lon)``
1039 Using keyword arguments for latitude and longitude is also supported.
1040 """
1041
1042 @staticmethod
1043 def _parse_coordinates(*args, latitude=None, longitude=None):
1044 if latitude is not None and longitude is not None:
1045 return latitude, longitude
1046
1047 if len(args) == 1:
1048 arg = args[0]
1049 try:
1050 return arg.latitude, arg.longitude
1051 except AttributeError:
1052 pass
1053 try:
1054 return arg["latitude"], arg["longitude"]
1055 except (KeyError, TypeError):
1056 pass
1057 if isinstance(arg, str):
1058 matches = re.match(
1059 r"""((?P<latdeg>[+\-\d.]+)°?)?((?P<latmin>[\d.]+)')?((?P<latsec>[\d.]+)")?(?P<lathemi>[NS])?"""
1060 r"""[,]?"""
1061 r"""((?P<londeg>[+\-\d.]+)°?)?((?P<lonmin>[\d.]+)')?((?P<lonsec>[\d.]+)")?(?P<lonhemi>[EW])?""",
1062 re.sub(r"\s", "", arg),
1063 ).groupdict()
1064 if not matches["latdeg"] or not matches["londeg"]:
1065 raise ValueError(f"Cannot parse coordinate string '{arg}'")
1066
1067 digits_to_parse = len(re.sub(r"\D", "", arg))
1068 digits_parsed = 0
1069 latitude = float(matches["latdeg"])
1070 lat_sign = 1 if latitude >= 0 else -1
1071 digits_parsed += len(re.sub(r"\D", "", matches["latdeg"]))
1072 longitude = float(matches["londeg"])
1073 lon_sign = 1 if longitude >= 0 else -1
1074 digits_parsed += len(re.sub(r"\D", "", matches["londeg"]))
1075
1076 if matches["latmin"]:
1077 latitude += lat_sign * float(matches["latmin"]) / 60
1078 digits_parsed += len(re.sub(r"\D", "", matches["latmin"]))
1079 if matches["lonmin"]:
1080 longitude += lon_sign * float(matches["lonmin"]) / 60
1081 digits_parsed += len(re.sub(r"\D", "", matches["lonmin"]))
1082
1083 if matches["latsec"]:
1084 latitude += lat_sign * float(matches["latsec"]) / 3600
1085 digits_parsed += len(re.sub(r"\D", "", matches["latsec"]))
1086 if matches["lonsec"]:
1087 longitude += lon_sign * float(matches["lonsec"]) / 3600
1088 digits_parsed += len(re.sub(r"\D", "", matches["lonsec"]))
1089
1090 if not digits_parsed == digits_to_parse:
1091 raise ValueError(
1092 f"Could not parse coordinate string '{arg}', used only {digits_parsed} of {digits_to_parse} digits"
1093 )
1094
1095 if matches["lathemi"] == "S":
1096 latitude = -abs(latitude)
1097 if matches["lonhemi"] == "W":
1098 longitude = -abs(longitude)
1099
1100 return latitude, longitude
1101
1102 else:
1103 # We should never have just a single argument, try unpacking.
1104 (*args,) = arg
1105
1106 if len(args) == 2:
1107 latitude, longitude = args
1108 return latitude, longitude
1109 elif len(args) == 4:
1110 (latitude_degrees, latitude_minutes, longitude_degrees, longitude_minutes) = args
1111 latitude = latitude_degrees + latitude_minutes / 60
1112 longitude = longitude_degrees + longitude_minutes / 60
1113 return latitude, longitude
1114 elif len(args) == 6:
1115 (
1116 latitude_degrees,
1117 latitude_minutes,
1118 latitude_seconds,
1119 longitude_degrees,
1120 longitude_minutes,
1121 longitude_seconds,
1122 ) = args
1123 latitude = latitude_degrees + latitude_minutes / 60 + latitude_seconds / 3600
1124 longitude = longitude_degrees + longitude_minutes / 60 + longitude_seconds / 3600
1125 return latitude, longitude
1126 else:
1127 raise TypeError(f"Undefined number of arguments for Position. {len(args)} was given, expects 2, 4, or 6.")
1128
1129 def __init__(self, *args, latitude=None, longitude=None):
1130 if len(args) == 1 and isinstance(args[0], (type(self), xr.Dataset)):
1131 super().__init__(args[0])
1132 else:
1133 latitude, longitude = self._parse_coordinates(*args, latitude=latitude, longitude=longitude)
1134 super().__init__(latitude=latitude, longitude=longitude)
1135
1136 def __repr__(self):
1137 return f"{type(self).__name__}({self.latitude.item():.4f}, {self.longitude.item():.4f})"
1138
[docs]
1139 def angle_between(self, first, second):
1140 """Calculate the angle between two positions, as seen from this position.
1141
1142 The angle between two coordinates is the span that they cover,
1143 measured positive clockwise and negative anti-clockwise.
1144
1145 Parameters
1146 ----------
1147 first : `~uwacan.positional.Coordinates` or convertable
1148 The first coordinate.
1149 second : `~uwacan.positional.Coordinates` or convertable
1150 The second coordinate.
1151
1152 Returns
1153 -------
1154 angle : float or `xarray.DataArray`
1155 The angle between the two coordinates.
1156 This will be a float if both coordinates is a single `Position`,
1157 otherwise it will be a `DataArray`.
1158 """
1159 if not isinstance(first, Coordinates):
1160 first = Position(first)
1161 if not isinstance(second, Coordinates):
1162 second = Position(second)
1163 return angle_between(
1164 self.latitude,
1165 self.longitude,
1166 first.latitude,
1167 first.longitude,
1168 second.latitude,
1169 second.longitude,
1170 )
1171
1172
[docs]
1173class BoundingBox:
1174 """Representation of a bounding box.
1175
1176 Parameters
1177 ----------
1178 west : float
1179 Western edge of the bounding box.
1180 south : float
1181 Southern edge of the bounding box.
1182 east : float
1183 Eastern edge of the bounding box.
1184 north : float
1185 Western edge of the bounding box.
1186 """
1187
[docs]
1188 @classmethod
1189 def from_center_and_size(cls, latitude, longitude, width, height):
1190 """Create a bounding box from a center, width, and height."""
1191 east = shift_position(lat=latitude, lon=longitude, distance=width / 2, bearing=90)[1]
1192 west = shift_position(lat=latitude, lon=longitude, distance=width / 2, bearing=-90)[1]
1193 north = shift_position(lat=latitude, lon=longitude, distance=height / 2, bearing=0)[0]
1194 south = shift_position(lat=latitude, lon=longitude, distance=height / 2, bearing=180)[0]
1195 return cls(west=west, east=east, south=south, north=north)
1196
1197 def __init__(self, west, south, east, north):
1198 self.west = west
1199 self.south = south
1200 self.east = east
1201 self.north = north
1202
1203 def __repr__(self):
1204 return f"{type(self).__name__}({self.west}, {self.south}, {self.east}, {self.north})"
1205
1206 @property
1207 def north_west(self):
1208 """The north-west corner of the bounding box."""
1209 return Position(self.north, self.west)
1210
1211 @property
1212 def north_east(self):
1213 """The north-eest corner of the bounding box."""
1214 return Position(self.north, self.east)
1215
1216 @property
1217 def south_west(self):
1218 """The south-west corner of the bounding box."""
1219 return Position(self.south, self.west)
1220
1221 @property
1222 def south_east(self):
1223 """The south-east corner of the bounding box."""
1224 return Position(self.south, self.east)
1225
1226 @property
1227 def center(self):
1228 """The center of the bounding box."""
1229 return Position(latitude=(self.north + self.south) / 2, longitude=(self.west + self.east) / 2)
1230
1231 def __contains__(self, position):
1232 """Check if a position is within the bounding box.
1233
1234 Parameters
1235 ----------
1236 position : Position
1237 The position to check.
1238
1239 Returns
1240 -------
1241 bool
1242 True if the position is within the bounding box, False otherwise.
1243 """
1244 position = Position(position)
1245 if (self.west <= position.longitude <= self.east) and (self.south <= position.latitude <= self.north):
1246 return True
1247
[docs]
1248 def overlaps(self, other):
1249 """Check if another bounding box overlaps with this one.
1250
1251 Two bounding boxes are considered overlapping if any of the
1252 corners in one of the two bounding boxes are within the other one.
1253
1254 Parameters
1255 ----------
1256 other : `BoundingBox`
1257 Another `BoundingBox` object.
1258
1259 Returns
1260 -------
1261 bool
1262 True if the bounding boxes overlap, False otherwise.
1263 """
1264 return (
1265 other.north_west in self
1266 or other.north_east in self
1267 or other.south_west in self
1268 or other.south_east in self
1269 or self.north_west in other
1270 or self.north_east in other
1271 or self.south_west in other
1272 or self.south_east in other
1273 )
1274
[docs]
1275 def extent(self):
1276 """Calculate the extent of this bounding box.
1277
1278 The extent is the maximum of the height and width.
1279
1280 Returns
1281 -------
1282 extent : float
1283 The extent, in meters.
1284
1285 """
1286 center = self.center
1287 westing, northing = wgs84_to_local_transverse_mercator(
1288 latitude=self.north,
1289 longitude=self.west,
1290 reference_latitude=center.latitude.item(),
1291 reference_longitude=center.longitude.item(),
1292 )
1293 easting, southing = wgs84_to_local_transverse_mercator(
1294 latitude=self.south,
1295 longitude=self.east,
1296 reference_latitude=center.latitude.item(),
1297 reference_longitude=center.longitude.item(),
1298 )
1299 extent = max((northing - southing), (easting - westing))
1300 return extent
1301
1302
[docs]
1303class Positions(Coordinates):
1304 """Container for arrays of coordinates."""
1305
1306 def __repr__(self):
1307 return f"{type(self).__name__} with {self.latitude.size} points"
1308
1309 @property
1310 def bounding_box(self):
1311 """A `BoundingBox` for these coordinates."""
1312 try:
1313 return self._bounding_box
1314 except AttributeError:
1315 pass
1316 west = self.longitude.min().item()
1317 east = self.longitude.max().item()
1318 north = self.latitude.max().item()
1319 south = self.latitude.min().item()
1320 self._bounding_box = BoundingBox(west=west, south=south, east=east, north=north)
1321 return self._bounding_box
1322
1323
[docs]
1324class Line(Positions):
1325 """A simple line of coordinates."""
1326
[docs]
1327 @classmethod
1328 def at_position(cls, position, length, bearing, n_points=100, symmetric=False, dim="line"):
1329 """Create a line at a given position with a specified length and bearing.
1330
1331 Parameters
1332 ----------
1333 position : `Position`
1334 The starting position of the line.
1335 length : float
1336 The total length of the line, in meters.
1337 bearing : float
1338 The bearing (direction) of the line in degrees.
1339 n_points : int, default=100
1340 The number of points to generate along the line.
1341 symmetric : bool, default=False
1342 If True, the line is centered on the given position.
1343 dim : str, default="line"
1344 The dimension name for the line points.
1345
1346 Returns
1347 -------
1348 Line
1349 A new instance of the `Line` class representing the line at the specified position.
1350 """
1351 if symmetric:
1352 n_points += (n_points + 1) % 2
1353 distance = np.linspace(-length / 2, length / 2, n_points)
1354 else:
1355 distance = np.linspace(0, length, n_points)
1356
1357 distance = xr.DataArray(distance, dims=dim)
1358 position = Position(position)
1359 lat, lon = shift_position(position.latitude, position.longitude, distance, bearing)
1360 return cls(latitude=lat, longitude=lon)
1361
1362
[docs]
1363class Track(Positions):
1364 """A class representing a GPS track, which is a sequence of coordinates over time.
1365
1366 Typically instances of this class are created by reading a data file, using the
1367 implemented classmethods.
1368
1369 Parameters
1370 ----------
1371 data : xarray.Dataset
1372 The dataset containing the track data.
1373 calculate_course : bool, optional
1374 Whether to calculate the course of the track, by default False.
1375 calculate_speed : bool, optional
1376 Whether to calculate the speed of the track, by default False.
1377 """
1378
[docs]
1379 @classmethod
1380 def read_nmea_gps(cls, filepath, **kwargs):
1381 """Read a GPS track from an NMEA file.
1382
1383 Parameters
1384 ----------
1385 filepath : str or Path
1386 The path to the NMEA file.
1387 **kwargs : dict, optional
1388 Additional keyword arguments passed to the class constructor.
1389
1390 Returns
1391 -------
1392 Track
1393 A `Track` object containing the GPS data.
1394
1395 Notes
1396 -----
1397 An NMEA file is a plain text file, with one NMEA message per line.
1398 These NMEA messages look something like::
1399
1400 $GPRMC,102100.000,A,5741.4478,N,01152.4111,E,0.00,319.61,300823,,,A*60
1401
1402 If you have a different header (GPRMC), it's likely that this read function will not work properly.
1403 """
1404 latitudes = []
1405 longitudes = []
1406 headings = []
1407 speeds = []
1408 times = []
1409
1410 with open(filepath, "r") as f:
1411 for line in f:
1412 line = line.strip("\"'\n")
1413 (
1414 header,
1415 utc,
1416 status,
1417 lat,
1418 lat_dir,
1419 lon,
1420 lon_dir,
1421 speed,
1422 heading,
1423 date,
1424 mag_var,
1425 mag_var_dir,
1426 mode_chsum,
1427 ) = line.split(",")
1428 times.append(_core.time_to_np(date + utc, fmt="%d%m%y%H%M%S.%f"))
1429 latitudes.append((int(lat[:2]) + float(lat[2:]) / 60) * (1 if lat_dir == "N" else -1))
1430 longitudes.append((int(lon[:3]) + float(lon[3:]) / 60) * (1 if lon_dir == "E" else -1))
1431 headings.append(float(heading))
1432 speeds.append(float(speed))
1433 track = xr.Dataset(
1434 data_vars=dict(
1435 latitude=xr.DataArray(latitudes, coords={"time": times}),
1436 longitude=xr.DataArray(longitudes, coords={"time": times}),
1437 heading=xr.DataArray(headings, coords={"time": times}),
1438 speed=xr.DataArray(speeds, coords={"time": times}, attrs={"unit": "knots"}),
1439 )
1440 ).drop_duplicates("time")
1441 return cls(track, **kwargs)
1442
[docs]
1443 @classmethod
1444 def read_blueflow(cls, filepath, renames=None, **kwargs):
1445 """Read a track from a BlueFlow file (Excel or CSV).
1446
1447 Parameters
1448 ----------
1449 filepath : str or Path
1450 The path to the BlueFlow file.
1451 renames : dict, optional
1452 A dictionary for renaming columns, by default None.
1453 **kwargs : dict, optional
1454 Additional keyword arguments passed to the class constructor.
1455
1456 Returns
1457 -------
1458 Track
1459 A `Track` object containing the BlueFlow data.
1460
1461 """
1462 import pandas
1463
1464 filepath = Path(filepath)
1465 if filepath.suffix == ".xlsx":
1466 data = pandas.read_excel(filepath.as_posix())
1467 elif filepath.suffix == ".csv":
1468 data = pandas.read_csv(filepath.as_posix())
1469 else:
1470 raise ValueError(f"Unknown fileformat for blueflow file '{filepath}'. Only xlsx and csv supported.")
1471 data = data.to_xarray()
1472 names = {}
1473 exp = r"([^\(\)\[\]]*) [\[\(]([^\(\)\[\]]*)[\]\)]"
1474 for key in list(data):
1475 name, unit = re.match(exp, key).groups()
1476 names[key] = name.strip()
1477 data[key].attrs["unit"] = unit
1478 data = data.rename(names)
1479
1480 renames = {
1481 "Latitude": "latitude",
1482 "Longitude": "longitude",
1483 "Timestamp": "time",
1484 "Time": "time",
1485 "Tidpunkt": "time",
1486 "Latitud": "latitude",
1487 "Longitud": "longitude",
1488 } | (renames or {})
1489
1490 renames = {key: value for key, value in renames.items() if key in data}
1491 data = data.rename(renames).set_coords("time").swap_dims(index="time").drop("index")
1492 if not np.issubdtype(data.time.dtype, np.datetime64):
1493 data["time"] = xr.apply_ufunc(np.datetime64, data.time, vectorize=True, keep_attrs=True)
1494 return cls(data, **kwargs)
1495
[docs]
1496 @classmethod
1497 def read_gpx(cls, filepath, **kwargs):
1498 """Read a GPS track from a GPX file.
1499
1500 Parameters
1501 ----------
1502 filepath : str or Path
1503 The path to the GPX file.
1504 **kwargs : dict, optional
1505 Additional keyword arguments passed to the class constructor.
1506
1507 Returns
1508 -------
1509 Track
1510 A `Track` object containing the GPX data.s
1511 """
1512 try:
1513 import gpxpy
1514 except ModuleNotFoundError:
1515 raise ModuleNotFoundError("Module 'gpxpy' required to read gpx data")
1516 with open(filepath, "r") as f:
1517 contents = gpxpy.parse(f)
1518 latitudes = []
1519 longitudes = []
1520 times = []
1521 for point in contents.get_points_data():
1522 latitudes.append(point.point.latitude)
1523 longitudes.append(point.point.longitude)
1524 time = np.datetime64(int(point.point.time.timestamp() * 1e9), "ns")
1525 times.append(time)
1526 times = np.asarray(times)
1527 data = xr.Dataset(
1528 data_vars=dict(
1529 latitude=xr.DataArray(latitudes, coords={"time": times}),
1530 longitude=xr.DataArray(longitudes, coords={"time": times}),
1531 )
1532 )
1533 return cls(data, **kwargs)
1534
1535 def __init__(self, data, calculate_course=False, calculate_speed=False):
1536 super().__init__(data)
1537 if calculate_course:
1538 self["course"] = self.calculate_course()
1539 if calculate_speed:
1540 self["speed"] = self.calculate_speed()
1541
1542 @property
1543 def time(self):
1544 """The time coordinates for the track."""
1545 return self._data["time"]
1546
[docs]
1547 def calculate_course(self):
1548 """Calculate the course of the track.
1549
1550 The computes the course using a "central differences" scheme, i.e.,
1551 `bearing[idx] = bearing_to(coords[idx-1], coords[idx+1])`
1552 for all time points except the first and last in the track.
1553 The first and last time points are computed using a "forward" and "backward"
1554 difference scheme, respectively.
1555 """
1556 coords = self.coordinates
1557 # Shift moves values at idx to be at idx+shift
1558 # Hence before[idx] = coords[idx - 1], and after[idx] = coords[idx + 1]
1559 before = coords.shift(time=1)
1560 after = coords.shift(time=-1)
1561
1562 # before[t=0] = coords[t=0]
1563 before.latitude[dict(time=0)] = coords.latitude[0]
1564 before.longitude[dict(time=0)] = coords.longitude[0]
1565 # after[t=0] = coords[t=1]
1566 after.latitude[dict(time=0)] = coords.latitude[1]
1567 after.longitude[dict(time=0)] = coords.longitude[1]
1568 # before[t=-1] = coords[t=-2]
1569 before.latitude[dict(time=-1)] = coords.latitude[-2]
1570 before.longitude[dict(time=-1)] = coords.longitude[-2]
1571 # after[t=-1] = coords[t=-1]
1572 after.latitude[dict(time=-1)] = coords.latitude[-1]
1573 after.longitude[dict(time=-1)] = coords.longitude[-1]
1574
1575 course = bearing_to(
1576 before.latitude,
1577 before.longitude,
1578 after.latitude,
1579 after.longitude,
1580 )
1581
1582 return course
1583
[docs]
1584 def calculate_speed(self):
1585 """Calculate the speed of the track."""
1586 coords = self.coordinates
1587 before = coords.shift(time=1).dropna("time")
1588 after = coords.shift(time=-1).dropna("time")
1589
1590 distance_delta = distance_to(before.latitude, before.longitude, after.latitude, after.longitude)
1591 # We cannot reuse the previous shift here, since the time coordinate is not shifted there
1592 time_delta = (
1593 coords.time.shift(time=-1).dropna("time") - coords.time.shift(time=1).dropna("time")
1594 ) / np.timedelta64(1, "s")
1595 interior_speed = distance_delta / time_delta
1596
1597 first_distance = distance_to(
1598 coords.isel(time=0).latitude,
1599 coords.isel(time=0).longitude,
1600 coords.isel(time=1).latitude,
1601 coords.isel(time=1).longitude,
1602 )
1603 first_time = (coords.time[1] - coords.time[0]) / np.timedelta64(1, "s")
1604 first_speed = (first_distance / first_time).assign_coords(time=coords.time[0])
1605
1606 last_distance = distance_to(
1607 coords.isel(time=-2).latitude,
1608 coords.isel(time=-2).longitude,
1609 coords.isel(time=-1).latitude,
1610 coords.isel(time=-1).longitude,
1611 )
1612 last_time = (coords.time[-1] - coords.time[-2]) / np.timedelta64(1, "s")
1613 last_speed = (last_distance / last_time).assign_coords(time=coords.time[-1])
1614 speed = xr.concat([first_speed, interior_speed, last_speed], dim="time")
1615 speed.attrs["unit"] = "m/s"
1616 return speed
1617
[docs]
1618 def average_course(self, resolution=None):
1619 """Calculate the average course in the track.
1620
1621 The average course is the average angle of the course,
1622 calculated to handle wrapping.
1623 See `average_angle` for more details.
1624
1625 Parameters
1626 ----------
1627 resolution : str, int, optional
1628 If a numerical resolution is given, the output will be
1629 rounded to this many parts of a turn.
1630 If a string "four", "eight", or "sixteen", is given,
1631 the output is a string with the closest named bearing.
1632 """
1633 try:
1634 course = self["course"]
1635 except KeyError:
1636 course = self.calculate_course()
1637 return average_angle(course, resolution=resolution)
1638
[docs]
1639 def closest_point(self, other):
1640 """Get the point in this track closest to a position.
1641
1642 Parameters
1643 ----------
1644 other : `Position`
1645 The point to compute distances to.
1646
1647 Returns
1648 -------
1649 `Position`
1650 The closest position in this track to the input.
1651 Includes latitude, longitude, time, and distance.
1652 """
1653 distances = self.distance_to(other)
1654 idx = distances.argmin(...)
1655 return Position(self._data.isel(idx).assign(distance=distances.isel(idx)))
1656
[docs]
1657 def aspect_segments(
1658 self,
1659 reference,
1660 angles,
1661 segment_min_length=None,
1662 segment_min_angle=None,
1663 segment_min_duration=None,
1664 ):
1665 """Get time segments corresponding to specific aspect angles.
1666
1667 Aspect angles are measured between the reference and cpa.
1668
1669 Parameters
1670 ----------
1671 reference : Position
1672 Reference position from which to measure cpa.
1673 angles : array_like
1674 The aspect angles to find.
1675 segment_min_length : numeric, optional
1676 The minimum spatial extent of each segment, in meters.
1677 segment_min_angle : numeric, optional
1678 The minimum angular extent of each segment, in degrees.
1679 segment_min_duration : numeric, optional
1680 The minimum temporal extent of each window, in seconds.
1681
1682 Returns
1683 -------
1684 segments : xarray.Dataset
1685 A dataset with coordinates:
1686 - segment : the angles specified to center the segments around
1687 - edge : ["start", "center", "stop"], indicating the start, stop, and center of segment
1688 and data variables:
1689 - latitude (segment, edge) : the latitudes for the segment
1690 - longitude (segment, edge) : the longitudes for the segment
1691 - time (segment, edge) : the times for the segment
1692 - aspect_angle (segment, edge) : the actual aspect angles for the segment
1693 - length (segment) : the spatial extent of each segment, in m
1694 - angle_span (segment) : the angular extent of each segment, in degrees
1695 - duration (segment) : the temporal extent of each segment, in seconds
1696 """
1697 track = self.coordinates
1698 cpa = self.closest_point(reference)
1699
1700 try:
1701 iter(angles)
1702 except TypeError:
1703 single_segment = True
1704 angles = [angles]
1705 else:
1706 single_segment = False
1707
1708 angles = np.sort(angles)
1709 track = track.assign(aspect_angle=reference.angle_between(cpa, track))
1710 if track.aspect_angle[0] > track.aspect_angle[-1]:
1711 # We want the angles to be negative before cpa and positive after
1712 track["aspect_angle"] *= -1
1713
1714 angles = xr.DataArray(angles, coords={"segment": angles})
1715 center_indices = abs(angles - track.aspect_angle).argmin("time")
1716 segment_centers = track.isel(time=center_indices)
1717
1718 # Run a check that we get the windows we want. A sane way might be to check that the
1719 # first and last windows are closer to their targets than the next window.
1720 if angles.size > 1:
1721 actual_first_angle = track.aspect_angle.sel(time=segment_centers.isel(segment=0).time)
1722 if abs(actual_first_angle - angles.isel(segment=0)) > abs(actual_first_angle - angles.isel(segment=1)):
1723 raise ValueError(
1724 f"Could not find window centered at {angles.isel(segment=0):.1f}⁰, found at most {actual_first_angle:.1f}⁰."
1725 )
1726 actual_last_angle = track.aspect_angle.sel(time=segment_centers.isel(segment=-1).time)
1727 if abs(actual_last_angle - angles.isel(segment=-1)) > abs(actual_last_angle - angles.isel(segment=-2)):
1728 raise ValueError(
1729 f"Could not find window centered at {angles.isel(segment=-1):.1f}⁰, found at most {actual_last_angle:.1f}⁰."
1730 )
1731
1732 segments = []
1733 track_angles = track.aspect_angle.data
1734 track_lat = track.latitude.data
1735 track_lon = track.longitude.data
1736 track_time = track.time.data
1737 for angle, segment_center in segment_centers.groupby("segment", squeeze=False):
1738 segment_center = segment_center.squeeze()
1739 # Finding the start of the window
1740 # The inner loops here are somewhat slow, likely due to indexing into the xr.Dataset all the time
1741 # At the time of writing (2023-12-14), there seems to be no way to iterate over a dataset in reverse order.
1742 # The `groupby` method can be used to iterate forwards, which solves finding the end of the segment,
1743 # but calling `track.sel(time=slice(t, None, -1)).groupby('time')` still iterates in the forward order.
1744 center_idx = int(np.abs(track.time - segment_center.time).argmin())
1745 start_idx = center_idx
1746 if segment_min_angle:
1747 while abs(segment_center.aspect_angle - track_angles[start_idx]) < segment_min_angle / 2:
1748 start_idx -= 1
1749 if start_idx < 0:
1750 raise ValueError(
1751 f"Start of window at {angle}⁰ not found in track. Not sufficiently high angles from window center."
1752 )
1753 if segment_min_duration:
1754 while (
1755 abs(segment_center.time - track_time[start_idx]) / np.timedelta64(1, "s") < segment_min_duration / 2
1756 ):
1757 start_idx -= 1
1758 if start_idx < 0:
1759 raise ValueError(
1760 f"Start of window at {angle}⁰ not found in track. Not sufficient time from window center."
1761 )
1762 if segment_min_length:
1763 while (
1764 distance_to(
1765 segment_center.latitude, segment_center.longitude, track_lat[start_idx], track_lon[start_idx]
1766 )
1767 < segment_min_length / 2
1768 ):
1769 start_idx -= 1
1770 if start_idx < 0:
1771 raise ValueError(
1772 f"Start of window at {angle}⁰ not found in track. Not sufficient distance from window center."
1773 )
1774 # Finding the end of the window
1775 stop_idx = center_idx
1776 if segment_min_angle:
1777 while abs(segment_center.aspect_angle - track_angles[stop_idx]) < segment_min_angle / 2:
1778 stop_idx += 1
1779 if stop_idx == track.sizes["time"]:
1780 raise ValueError(
1781 f"End of window at {angle}⁰ not found in track. Not sufficiently high angles from window center."
1782 )
1783 if segment_min_duration:
1784 while (
1785 abs(segment_center.time - track_time[stop_idx]) / np.timedelta64(1, "s") < segment_min_duration / 2
1786 ):
1787 stop_idx += 1
1788 if stop_idx == track.sizes["time"]:
1789 raise ValueError(
1790 f"End of window at {angle}⁰ not found in track. Not sufficient time from window center."
1791 )
1792 if segment_min_length:
1793 while (
1794 distance_to(
1795 segment_center.latitude, segment_center.longitude, track_lat[stop_idx], track_lon[stop_idx]
1796 )
1797 < segment_min_length / 2
1798 ):
1799 stop_idx += 1
1800 if stop_idx == track.sizes["time"]:
1801 raise ValueError(
1802 f"End of window at {angle}⁰ not found in track. Not sufficient distance from window center."
1803 )
1804
1805 # Creating the window and saving some attributes
1806 if start_idx == stop_idx:
1807 segments.append(segment_center.assign(length=0, angle_span=0, duration=0).reset_coords("time"))
1808 else:
1809 segment_start, segment_stop = track.isel(time=start_idx), track.isel(time=stop_idx)
1810 segments.append(
1811 xr.concat([segment_start, segment_center, segment_stop], dim="time")
1812 .assign_coords(edge=("time", ["start", "center", "stop"]))
1813 .swap_dims(time="edge")
1814 .assign(
1815 length=distance_to(
1816 segment_start.latitude,
1817 segment_start.longitude,
1818 segment_stop.latitude,
1819 segment_stop.longitude,
1820 ),
1821 angle_span=segment_stop.aspect_angle - segment_start.aspect_angle,
1822 duration=(segment_stop.time - segment_start.time) / np.timedelta64(1, "s"),
1823 )
1824 .reset_coords("time")
1825 )
1826
1827 if single_segment:
1828 return segments[0]
1829 return xr.concat(segments, dim="segment")
1830
1831 @property
1832 def time_window(self):
1833 """A `~uwacan.TimeWindow` describing when the data start and stops."""
1834 return _core.TimeWindow(start=self.time[0], stop=self.time[-1])
1835
[docs]
1836 def subwindow(self, time=None, /, *, start=None, stop=None, center=None, duration=None, extend=None):
1837 """Select a subset of the data over time.
1838
1839 See `uwacan.TimeWindow.subwindow` for details on the parameters.
1840 """
1841 new_window = self.time_window.subwindow(
1842 time, start=start, stop=stop, center=center, duration=duration, extend=extend
1843 )
1844 if isinstance(new_window, _core.TimeWindow):
1845 start = _core.time_to_np(new_window.start)
1846 stop = _core.time_to_np(new_window.stop)
1847 return type(self)(self._data.sel(time=slice(start, stop)))
1848 else:
1849 time = _core.time_to_np(new_window)
1850 return self._data.sel(time=time, method="nearest")
1851
[docs]
1852 def resample(self, time, /, **kwargs):
1853 """Resample the Track at specific times or rate.
1854
1855 Parameters
1856 ----------
1857 time : float or xr.DataArray
1858 If an `xr.DataArray` is provided, it represents the new time points to which the data will be resampled.
1859 If a float is provided, it represents the frequency in Hz at which to resample the data.
1860 **kwargs : dict
1861 Additional keyword arguments passed to the `xr.DataArray.interp` method for interpolation.
1862
1863 Returns
1864 -------
1865 type(self)
1866 A new instance of the same type as ``self``, with the data resampled at the specified time points.
1867 """
1868 if not isinstance(time, xr.DataArray):
1869 n_samples = int(np.floor(self.time_window.duration * time))
1870 start_time = _core.time_to_np(self.time_window.start)
1871 offsets = np.arange(n_samples) * 1e9 / time
1872 time = start_time + offsets.astype("timedelta64[ns]")
1873 data = self._data.interp(time=time, **kwargs)
1874 new = type(self)(data)
1875 return new
1876
[docs]
1877 def correct_gps_offset(
1878 self, forwards=0, portwards=0, to_bow=0, to_stern=0, to_port=0, to_starboard=0, heading=None
1879 ):
1880 """Correct positions with respect to ship heading and particulars.
1881
1882 The ``to_x`` parameters is the distances from the gps antenna to the ship sides.
1883 The ``forwards`` and ``portwards`` parameters are how much forward and to port the
1884 new positions should be, relative to the center of the ship.
1885
1886 Parameters
1887 ----------
1888 forwards : numeric, default 0
1889 How much forwards to shift the positions, in meters
1890 portwards : numeric, default 0
1891 How much to port side to shift the positions, in meters
1892 to_bow : numeric, default 0
1893 The distance to the bow from the receiver, in meters
1894 to_stern : numeric, default 0
1895 The distance to the stern from the receiver, in meters
1896 to_port : numeric, default 0
1897 The distance to the port side from the receiver, in meters
1898 to_starboard : numeric, default 0
1899 The distance to the starboard side from the receiver, in meters
1900 heading : array_like, optional
1901 The headings of the ship. Must be compatible with the coordinates in this Track
1902 If None, the Track checks for a "heading", then a "course" in the contained data.
1903
1904 Notes
1905 -----
1906 The positions will be shifted in the ``heading`` direction by ``forwards + (to_bow - to_stern) / 2``,
1907 and towards "port" ``heading - 90`` by ``portwards + (to_port - to_starboard) / 2``.
1908 Typical usage is to give the receiver position using the ``to_x`` arguments, and the desired
1909 acoustic reference location with the ``forwards`` and ``portwards`` arguments.
1910 Inserting correct values for all the ``to_x`` arguments will center the position on the ship middle, so that
1911 the ``forwards`` and ``portwards`` arguments are relative to the ship center. Alternatively, leave the ``to_x`` arguments
1912 as the default 0 and only give the desired ``forwards`` and ``portwards`` arguments.
1913 """
1914 forwards = forwards + (to_bow - to_stern) / 2
1915 portwards = portwards + (to_port - to_starboard) / 2
1916 if heading is None:
1917 try:
1918 heading = self.heading
1919 except AttributeError:
1920 heading = self.course
1921 new = self.shift_position(distance=forwards, bearing=heading)
1922 new = new.shift_position(distance=portwards, bearing=heading - 90)
1923 return new
1924
1925
[docs]
1926def sensor(sensor, /, sensitivity=None, depth=None, position=None, latitude=None, longitude=None):
1927 """Collect sensor information.
1928
1929 Typical sensor information is the position, sensitivity, and deployment depth.
1930 The position can be given as a string, a tuple, or separate longitude and latitudes.
1931 This factory function will return a `~uwacan.positional.Sensor` or
1932 `~uwacan.positional.SensorPosition` depending on if a position is provided.
1933 All arguments except ``position`` can be array-like matching an array-like ``sensor``,
1934 or single values.
1935
1936 Parameters
1937 ----------
1938 sensor : str
1939 The label for the sensor. All sensors must have a label.
1940 sensitivity : float
1941 The sensor sensitivity, in dB re. V/Q,
1942 where Q is the desired physical unit.
1943 depth : float
1944 Sensor deployment depth.
1945 position : str or tuple
1946 A coordinate string, or a tuple with lat, lon information.
1947 latitude : float
1948 The latitude the sensor was deployed at.
1949 longitude : float
1950 The longitude the sensor was deployed at.
1951
1952 Returns
1953 -------
1954 Sensor or SensorPosition
1955 """
1956 if isinstance(sensor, Sensor):
1957 sensor = sensor._data
1958 if isinstance(sensor, xr.Dataset):
1959 sensor = sensor[[key for key, value in sensor.notnull().items() if value.any()]]
1960 else:
1961 sensor = xr.Dataset(coords={"sensor": sensor})
1962
1963 if position is not None:
1964 latitude, longitude = Position._parse_coordinates(position)
1965
1966 if latitude is not None:
1967 if np.ndim(latitude):
1968 latitude = xr.DataArray(latitude, dims="sensor")
1969 sensor["latitude"] = latitude
1970
1971 if longitude is not None:
1972 if np.ndim(longitude):
1973 longitude = xr.DataArray(longitude, dims="sensor")
1974 sensor["longitude"] = longitude
1975
1976 if sensitivity is not None:
1977 if np.ndim(sensitivity):
1978 sensitivity = xr.DataArray(sensitivity, dims="sensor")
1979 sensor["sensitivity"] = sensitivity
1980
1981 if depth is not None:
1982 if np.ndim(depth):
1983 depth = xr.DataArray(depth, dims="sensor")
1984 sensor["depth"] = depth
1985
1986 return Sensor.from_dataset(sensor)
1987
1988
[docs]
1989class Sensor(_core.DatasetWrap):
1990 """Container for sensor information.
1991
1992 This class is typically not instantiated directly,
1993 but through the `~uwacan.sensor` factory function.
1994 """
1995
[docs]
1996 @classmethod
1997 def from_dataset(cls, data): # noqa: D102
1998 if isinstance(data, _core.xrwrap):
1999 data = data.data
2000
2001 if data.sensor.ndim == 0:
2002 if "latitude" in data and "longitude" in data:
2003 cls = SensorPosition
2004 else:
2005 cls = Sensor
2006 else:
2007 if "latitude" in data and "longitude" in data:
2008 if np.ptp(data["latitude"].values) == 0 and np.ptp(data["longitude"].values) == 0:
2009 data["latitude"] = data["latitude"].mean()
2010 data["longitude"] = data["longitude"].mean()
2011 cls = SensorArrayPosition
2012 else:
2013 cls = SensorArrayPositions
2014 else:
2015 cls = SensorArray
2016 return cls(data)
2017
2018 def __repr__(self):
2019 sens = "" if "sensitivity" not in self._data else f", sensitivity={self['sensitivity']:.2f}"
2020 depth = "" if "depth" not in self._data else f", depth={self['depth']:.2f}"
2021 return f"{self.__class__.__name__}({self.label}{sens}{depth})"
2022
2023 @property
2024 def label(self):
2025 """The label of this sensor."""
2026 return self._data["sensor"].item()
2027
2028 def __and__(self, other):
2029 if not isinstance(other, (Sensor, xr.Dataset)):
2030 return NotImplemented
2031 return _core.concatenate([self, other], dim="sensor")
2032
[docs]
2033 def with_data(self, **kwargs):
2034 """Create a copy of this sensor with additional information.
2035
2036 This method allows you to add new properties to an existing sensor.
2037 It's particularly useful for adding sensor-specific information
2038 such as gain or recording channel data that is not part of the core sensor
2039 class.
2040
2041 Parameters
2042 ----------
2043 **kwargs : dict
2044 Additional data to add to the sensor. Each keyword argument
2045 corresponds to a new data variable with one of the following input types:
2046
2047 - `~xarrayr.DataArray`: Must have a "sensor" dimension matching
2048 the existing sensor coordinate.
2049 - dict: Keys should be the sensor names, and the dictionary must
2050 contain all sensors in the existing data.
2051 - scalar: A single scalar value that will be assigned to all sensors.
2052 - array_like: An array with a length matching the "sensor" dimension.
2053
2054 """
2055 data = self._data.copy()
2056 if "sensor" not in data.dims:
2057 data = data.expand_dims("sensor")
2058 for key, value in kwargs.items():
2059 if isinstance(value, xr.DataArray):
2060 if "sensor" not in value.dims:
2061 raise ValueError("Cannot add xarray data without sensor dimension to sensors")
2062 data[key] = value
2063 elif isinstance(value, dict):
2064 data[key] = xr.DataArray(
2065 [value[key] for key in data["sensor"].values], coords={"sensor": data["sensor"]}
2066 )
2067 elif np.size(value) == 1:
2068 data[key] = np.squeeze(value)
2069 elif np.size(value) != data["sensor"].size:
2070 raise ValueError(f"Cannot assign {np.size(value)} values to {data['sensor'].size} sensors")
2071 else:
2072 data[key] = xr.DataArray(value, coords={"sensor": data["sensor"]})
2073 return self.from_dataset(data.squeeze())
2074
2075
[docs]
2076class SensorPosition(Sensor, Position):
2077 """Container for sensor information, including position.
2078
2079 This class is typically not instantiated directly,
2080 but through the `~uwacan.sensor` factory function.
2081 """
2082
2083 def __repr__(self):
2084 sens = "" if "sensitivity" not in self._data else f", sensitivity={self['sensitivity']:.2f}"
2085 depth = "" if "depth" not in self._data else f", depth={self['depth']:.2f}"
2086 return f"{self.__class__.__name__}({self.label}, latitude={self.latitude:.4f}, longitude={self.longitude:.4f}{sens}{depth})"
2087
2088
[docs]
2089class SensorArray(Sensor):
2090 """Container for sensor information.
2091
2092 This class is typically not instantiated directly,
2093 but through the `~uwacan.sensor` factory function, concatenating individual sensors,
2094 or using the ``&`` operator on sensors.
2095 """
2096
2097 @property
2098 def sensors(self):
2099 """The stored `Sensor` objects, as a dict."""
2100 return {label: sensor(data.squeeze()) for label, data in self._data.groupby("sensor", squeeze=False)}
2101
2102 def __repr__(self):
2103 return f"{self.__class__.__name__} with {self._data['sensor'].size} sensors"
2104
2105 @property
2106 def label(self):
2107 """The labels of the sensors."""
2108 return tuple(self._data["sensor"].data)
2109
2110
[docs]
2111class SensorArrayPositions(SensorArray, Positions):
2112 """Container for sensor information, including positions.
2113
2114 This class is typically not instantiated directly,
2115 but through the `~uwacan.sensor` factory function, concatenating individual sensors,
2116 or using the ``&`` operator on sensors.
2117 """
2118
2119 def __init__(self, data):
2120 SensorArray.__init__(self, data)
2121
2122
[docs]
2123class SensorArrayPosition(SensorArray, Position):
2124 """Container for sensor information, with a single position for all.
2125
2126 This class is typically not instantiated directly,
2127 but through the `~uwacan.sensor` factory function, concatenating individual sensors,
2128 or using the ``&`` operator on sensors.
2129 """
2130
2131 def __init__(self, data):
2132 SensorArray.__init__(self, data)
2133
2134 def __repr__(self):
2135 return f"{self.__class__.__name__} with {self._data['sensor'].size} sensors at ({self.latitude:.4f}, {self.longitude:.4f})"