Source code for uwacan.positional

   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
[docs] 876 def make_figure(self, lat=None, lon=None, extent=None, **kwargs): 877 """Create a plotly figure, styled for this data. 878 879 Parameters 880 ---------- 881 lat : float, optional 882 The center latitude for the map. Uses a reasonable default from the data it not given. 883 lon : float, optional 884 The center longitude for the map. Uses a reasonable default from the data it not given. 885 extent : float, optional 886 How much to cover with the map, in meters. Defaults to fit the data, or 10 km for single points. 887 **kwargs : dict 888 Keywords will be used for the figure layout. Some useful keywords are: 889 890 - ``map_style`` to choose a `map style <https://plotly.com/python/reference/layout/#layout-map-style>`_. 891 Builtin map styles: basic, carto-darkmatter, carto-darkmatter-nolabels, carto-positron, 892 carto-positron-nolabels, carto-voyager, carto-voyager-nolabels, dark, light, open-street-map, outdoors, 893 satellite, satellite-streets, streets, white-bg. 894 - ``height`` and ``width`` sets the figure size in pixels. 895 - ``title`` adds a top level title. 896 897 """ 898 import plotly.graph_objects as go 899 900 fig = go.Figure( 901 layout=dict(template=self._figure_template(lat=lat, lon=lon, extent=extent, **kwargs), **kwargs) 902 ) 903 return fig
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})"