From 3bd1bd5bd8be8ac960b3559d89546d4b8972d897 Mon Sep 17 00:00:00 2001
From: Amy Stamile <astamile@usgs.gov>
Date: Wed, 17 Apr 2024 10:54:11 -0700
Subject: [PATCH] Added sensor-library math utils

---
 knoten/utils.py     | 170 ++++++++++++++++++++++++++++++++++++++++++++
 tests/test_utils.py | 100 ++++++++++++++++++++++++++
 2 files changed, 270 insertions(+)
 create mode 100644 tests/test_utils.py

diff --git a/knoten/utils.py b/knoten/utils.py
index 121721a..5920e38 100644
--- a/knoten/utils.py
+++ b/knoten/utils.py
@@ -1,4 +1,174 @@
 import pyproj
+import numpy as np 
+from collections import namedtuple
+
+def sep_angle(a_pt, b_pt, c_pt):
+    return sep_angle(a_pt - b_pt, c_pt - b_pt)
+
+def sep_angle(a_vec, b_vec):
+    dot_prod = a_vec.x * b_vec.x + a_vec.y * b_vec.y + a_vec.z * b_vec.z
+    dot_prod /= magnitude(a_vec) * magnitude(b_vec)
+
+    if(dot_prod >= 1.0): return 0.0
+    if(dot_prod <= -1.0): return np.pi
+
+    return np.arccos(dot_prod)
+
+def magnitude(vec):
+    return np.sqrt(vec.x * vec.x + vec.y * vec.y + vec.z * vec.z)
+
+def distance(start, stop):
+    Point = namedtuple("Point", 'x, y, z')
+    diff = Point(stop.x - start.x, stop.y - start.y, stop.z - start.z)
+
+    return magnitude(diff)
+
+def radiansToDegrees(radian_lat_lon):
+    LatLon = namedtuple("LatLon", 'lat lon')
+
+    degree_lon = radian_lat_lon.lon
+    if (degree_lon < 0):
+      degree_lon += 2 * np.pi
+
+    degree_lon = np.rad2deg(degree_lon)
+    degreeLat = np.rad2deg(radian_lat_lon.lat)
+    return LatLon(degreeLat, degree_lon)
+
+def spherical_to_rect(spherical):
+    Point = namedtuple("Point", 'x, y, z')
+
+    x = spherical.radius * np.cos(spherical.lat) * np.cos(spherical.lon)
+    y = spherical.radius * np.cos(spherical.lat) * np.sin(spherical.lon)
+    z = spherical.radius * np.sin(spherical.lat)
+
+    return Point(x, y, z)
+
+def rect_to_spherical(rectangular):
+    Sphere = namedtuple("Sphere", 'lat, lon, radius')
+
+    rad = magnitude(rectangular)
+    if (rad < 1e-15):
+      return Sphere(0.0, 0.0, 0.0)
+    
+    return Sphere(
+      np.arcsin(rectangular.z / rad),
+      np.arctan2(rectangular.y, rectangular.x),
+      rad
+    )
+
+def ground_azimuth(ground_pt, sub_pt):
+    LatLon = namedtuple("LatLon", 'lat lon')
+
+    if (ground_pt.lat >= 0.0):
+      a = (90.0 - sub_pt.lat) * np.pi / 180.0
+      b = (90.0 - ground_pt.lat) * np.pi / 180.0
+    else:
+      a = (90.0 + sub_pt.lat) * np.pi / 180.0
+      b = (90.0 + ground_pt.lat) * np.pi / 180.0
+
+    cs = LatLon(0, sub_pt.lon)
+    cg = LatLon(0, ground_pt.lon)
+
+    if (cs.lon > cg.lon):
+      if ((cs.lon - cg.lon) > 180.0):
+        while ((cs.lon - cg.lon) > 180.0): 
+           cs = LatLon(0, cs.lon - 360.0)
+    if (cg.lon > cs.lon):
+      if ((cg.lon-cs.lon) > 180.0):
+        while ((cg.lon-cs.lon) > 180.0): 
+           cg = LatLon(0, cg.lon - 360.0)
+    
+    if (sub_pt.lat > ground_pt.lat):
+      if (cs.lon < cg.lon):
+        quad = 2
+      else:
+        quad = 1
+    elif (sub_pt.lat < ground_pt.lat):
+      if (cs.lon < cg.lon):
+        quad = 3
+      else:
+        quad = 4
+    else:
+      if (cs.lon > cg.lon):
+        quad = 1
+      elif (cs.lon < cg.lon):
+        quad = 2
+      else:
+        return 0.0
+
+    C = (cg.lon - cs.lon) * np.pi / 180.0
+    if (C < 0):
+       C = -C
+
+    c = np.arccos(np.cos(a) * np.cos(b) + np.sin(a) * np.sin(b) * np.cos(C))
+
+    azimuth = 0.0
+
+    if (np.sin(b) == 0.0 or np.sin(c) == 0.0):
+      return azimuth
+
+    intermediate = (np.cos(a) - np.cos(b) * np.cos(c)) / (np.sin(b) * np.sin(c))
+    if (intermediate < -1.0):
+      intermediate = -1.0
+    elif (intermediate > 1.0):
+      intermediate = 1.0
+
+    A = np.arccos(intermediate) * 180.0 / np.pi
+
+    if (ground_pt.lat >= 0.0):
+        if (quad == 1 or quad == 4):
+            azimuth = A
+        elif (quad == 2 or quad == 3):
+            azimuth = 360.0 - A
+    else: 
+      if (quad == 1 or quad == 4):
+        azimuth = 180.0 - A
+      elif (quad == 2 or quad == 3):
+        azimuth = 180.0 + A
+    return azimuth
+
+def crossProduct(a_vec, b_vec):
+    Point = namedtuple("Point", 'x, y, z')
+
+    x = a_vec.y * b_vec.z - a_vec.z * b_vec.y
+    y = a_vec.z * b_vec.x - a_vec.x * b_vec.z
+    z = a_vec.x * b_vec.y - a_vec.y * b_vec.x
+    return Point(x, y, z)
+
+
+def unit_vector(vec):
+    mag = magnitude(vec)
+    return vec / mag
+
+def perpendicular_vector(a_vec, b_vec):
+    if (magnitude(a_vec) == 0):
+      return b_vec
+
+    a_norm = unit_vector(a_vec)
+    b_norm = unit_vector(b_vec)
+
+    angle = a_norm * b_norm
+    a_mag = magnitude(a_vec)
+    mag_p = angle * a_mag
+
+    p = b_norm * mag_p
+    q = a_vec - p
+
+    return q
+
+def scale_vector(vec, scalar):
+    Point = namedtuple("Point", 'x, y, z')
+
+    return Point(vec.x * scalar, vec.y * scalar, vec.z * scalar)
+
+def matrixVecProduct(mat, vec):
+    Point = namedtuple("Point", 'x, y, z')
+
+    x = mat.a.x * vec.x + mat.a.y * vec.y + mat.a.z * vec.z
+    y = mat.b.x * vec.x + mat.b.y * vec.y + mat.b.z * vec.z
+    z = mat.c.x * vec.x + mat.c.y * vec.y + mat.c.z * vec.z
+
+    return Point(x, y, z)
 
 def reproject(record, semi_major, semi_minor, source_proj, dest_proj, **kwargs):
     """
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..8547c0e
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,100 @@
+import numpy as np
+from knoten import utils
+from collections import namedtuple
+
+Point = namedtuple("Point", 'x, y, z')
+Sphere = namedtuple("Sphere", 'lat, lon, radius')
+
+def test_sep_angle_right():
+  pt1 = Point(1, 0, 0)
+  pt2 = Point(0, 1, 0)
+  np.testing.assert_array_equal(utils.sep_angle(pt1, pt2), np.pi / 2.0)
+
+def test_sep_angle_acute():
+  pt1 = Point(1, 0, 0)
+  pt2 = Point(1, 1, 0)
+  np.testing.assert_allclose(utils.sep_angle(pt1, pt2), np.pi / 4.0, atol=1e-12)
+
+def test_sep_angle_obtuse():
+  pt1 = Point(1, 0, 0)
+  pt2 = Point(-1, 1, 0)
+  np.testing.assert_array_equal(utils.sep_angle(pt1, pt2), 3.0 * np.pi / 4.0)
+
+def test_sep_angle_normalization():
+  pt1 = Point(1, 0, 0)
+  pt2 = Point(1, 1, 0)
+  pt3 = Point(100, 0, 0)
+  pt4 = Point(100, 100, 0)
+  np.testing.assert_array_equal(utils.sep_angle(pt1, pt2), utils.sep_angle(pt3, pt4))
+
+def test_magnitude_unit():
+  assert utils.magnitude(Point(1.0, 0.0, 0.0)) == 1.0
+  assert utils.magnitude(Point(0.0, 1.0, 0.0)) == 1.0
+  assert utils.magnitude(Point(0.0, 0.0, 1.0)) == 1.0
+
+def test_magnitude_nonunit():
+  assert utils.magnitude(Point(0.0, 0.0, 0.0)) == 0.0
+  assert utils.magnitude(Point(2.0, 1.0, 4.0)) == np.sqrt(21.0)
+  np.testing.assert_allclose(utils.magnitude(Point(0.2, 0.1, 0.4)), np.sqrt(0.21), atol=1e-12)
+
+def test_distance():
+  assert utils.distance(Point(1.0, 2.0, 3.0), Point(6.0, 5.0, 4.0)) == np.sqrt(35)
+
+def test_spherical_to_rect():
+  result = utils.spherical_to_rect(Sphere(0.0, 0.0, 1000.0))
+  np.testing.assert_allclose(result.x, 1000.0, atol=1e-12)
+  np.testing.assert_allclose(result.y, 0.0, atol=1e-12)
+  np.testing.assert_allclose(result.z, 0.0, atol=1e-12)
+
+  result = utils.spherical_to_rect(Sphere(0.0, np.pi, 1000.0))
+  np.testing.assert_allclose( result.x, -1000.0, atol=1e-12)
+  np.testing.assert_allclose( result.y, 0.0, atol=1e-12)
+  np.testing.assert_allclose( result.z, 0.0, atol=1e-12)
+
+  result = utils.spherical_to_rect(Sphere(np.pi / 2.0, 0.0, 1000.0))
+  np.testing.assert_allclose( result.x, 0.0, atol=1e-12)
+  np.testing.assert_allclose( result.y, 0.0, atol=1e-12)
+  np.testing.assert_allclose( result.z, 1000.0, atol=1e-12)
+
+  result = utils.spherical_to_rect(Sphere(np.pi / -2.0, 0.0, 1000.0))
+  np.testing.assert_allclose( result.x, 0.0, atol=1e-12)
+  np.testing.assert_allclose( result.y, 0.0, atol=1e-12)
+  np.testing.assert_allclose( result.z, -1000.0, atol=1e-12)
+
+def test_rect_to_spherical():
+  result = utils.rect_to_spherical(Point(1000.0, 0.0, 0.0))
+  np.testing.assert_array_equal(result, Sphere(0.0, 0.0, 1000.0))
+
+  result = utils.rect_to_spherical(Point(-1000.0, 0.0, 0.0))
+  np.testing.assert_array_equal(result, Sphere(0.0, np.pi, 1000.0))
+
+  result = utils.rect_to_spherical(Point(0.0, 0.0, 1000.0))
+  np.testing.assert_array_equal(result, Sphere(np.pi / 2.0, 0.0, 1000.0))
+
+  result = utils.rect_to_spherical(Point(0.0, 0.0, -1000.0))
+  np.testing.assert_array_equal(result,  Sphere(np.pi / -2.0, 0.0, 1000.0))
+
+def test_ground_azimuth():
+  LatLon = namedtuple("LatLon", "lat lon")
+
+  ground_pt = LatLon(0, -180)
+  subsolar_pt = LatLon(0, 90)
+  np.testing.assert_array_equal(270.0, utils.ground_azimuth(ground_pt, subsolar_pt))
+
+def test_perpendicular_vector():
+  vec_a = Point(6.0, 6.0, 6.0)
+  vec_b = Point(2.0, 0.0, 0.0)
+  result = Point(0.0, 6.0, 6.0)
+  np.testing.assert_array_equal(utils.perpendicular_vector(vec_a, vec_b), result)
+
+def test_unit_vector():
+  result = utils.unit_vector(Point(5.0, 12.0, 0.0))
+  np.testing.assert_allclose(result[0], 0.384615, atol=1e-6)
+  np.testing.assert_allclose(result[1], 0.923077, atol=1e-6)
+  np.testing.assert_array_equal(result[2], 0.0)
+
+def test_scale_vector():
+  vec = Point(1.0, 2.0, -3.0)
+  scalar = 3.0
+  result = Point(3.0, 6.0, -9.0)
+  np.testing.assert_array_equal(utils.scale_vector(vec, scalar), result)
\ No newline at end of file
-- 
GitLab