Repository: obspy/obspy

Issue:
Make locations2degree numpy compatible?
Hi all,

This is not an issue but a sort of enhancement request/ question:
I would like to ask if you plan to make `locations2degree` (https://github.com/obspy/obspy/blob/master/obspy/geodetics/base.py#L309) numpy compatible. From the code it seems straighforward as all python `math` functions have a corresponding `numpy` equivalents (I implemented by myself such a method with a bunch of tests which confirm it's just a matter of string substitution).

Thank you in advance



Discussion:
why don't you open up a PR if you already have existing code + tests ?
Cause I first wanted to check if that was a desired behaviour or not.
I'll open a pull request then later, thanxs. Are there guidelines for tests? (I saw that obpsy uses doctests in the code, I am using pytest)


depends what you plan exactly: so do you plan
* to improve performance by using numpy internally (existing test will not fail)
* to extend it that numpy variables are additionally accepted (existing test will not fail)
* to replace it with numpy variables only (input/output is numpy only, existing tests will fail)

for the first two options you can IMHO just go ahead - for the latter one needs to check how this util function is used within ObsPy and if an API change is acceptable - I don't know how much this util function is used in other projects ...

concerning tests: we got doctests (mainly for documentation) as well as unit tests, e.g. https://github.com/obspy/obspy/blob/master/obspy/geodetics/tests/test_util_geodetics.py#L211-L244 - just have a look at the other unit tests
Done it (with few new tests all passing), thank you. However, when I try to push my branch:
```
git push -u origin locations2degrees_np_compatible
```
it tells me "Permission to obspy/obspy.git denied" (obviously). You should you grant me some permission i guess (or should I do something different?)

Thanks

You have to fork ObsPy to your own account (button at the top right), push your changes to that fork, and then send a PR from that.

https://opensource.guide/how-to-contribute/#opening-a-pull-request
Thanks

Resolving patch:
diff --git a/obspy/geodetics/base.py b/obspy/geodetics/base.py
--- a/obspy/geodetics/base.py
+++ b/obspy/geodetics/base.py
@@ -315,16 +315,17 @@ def locations2degrees(lat1, long1, lat2, long2):
     Earth. For more accurate values use the geodesic distance calculations of
     geopy (https://github.com/geopy/geopy).
 
-    :type lat1: float
-    :param lat1: Latitude of point 1 in degrees
-    :type long1: float
-    :param long1: Longitude of point 1 in degrees
-    :type lat2: float
-    :param lat2: Latitude of point 2 in degrees
-    :type long2: float
-    :param long2: Longitude of point 2 in degrees
-    :rtype: float
-    :return: Distance in degrees as a floating point number.
+    :type lat1: float or :class:`numpy.ndarray`
+    :param lat1: Latitude(s) of point 1 in degrees
+    :type long1: float or :class:`numpy.ndarray`
+    :param long1: Longitude(s) of point 1 in degrees
+    :type lat2: float or :class:`numpy.ndarray`
+    :param lat2: Latitude(s) of point 2 in degrees
+    :type long2: float or :class:`numpy.ndarray`
+    :param long2: Longitude(s) of point 2 in degrees
+    :rtype: float or :class:`numpy.ndarray`
+    :return: Distance in degrees as a floating point number,
+        or numpy array of element-wise distances in degrees
 
     .. rubric:: Example
 
@@ -332,20 +333,24 @@ def locations2degrees(lat1, long1, lat2, long2):
     >>> locations2degrees(5, 5, 10, 10)
     7.0397014191753815
     """
+    # broadcast explicitly here so it raises once instead of somewhere in the
+    # middle if things can't be broadcast
+    lat1, lat2, long1, long2 = np.broadcast_arrays(lat1, lat2, long1, long2)
+
     # Convert to radians.
-    lat1 = math.radians(lat1)
-    lat2 = math.radians(lat2)
-    long1 = math.radians(long1)
-    long2 = math.radians(long2)
+    lat1 = np.radians(np.asarray(lat1))
+    lat2 = np.radians(np.asarray(lat2))
+    long1 = np.radians(np.asarray(long1))
+    long2 = np.radians(np.asarray(long2))
     long_diff = long2 - long1
-    gd = math.degrees(
-        math.atan2(
-            math.sqrt((
-                math.cos(lat2) * math.sin(long_diff)) ** 2 +
-                (math.cos(lat1) * math.sin(lat2) - math.sin(lat1) *
-                    math.cos(lat2) * math.cos(long_diff)) ** 2),
-            math.sin(lat1) * math.sin(lat2) + math.cos(lat1) * math.cos(lat2) *
-            math.cos(long_diff)))
+    gd = np.degrees(
+        np.arctan2(
+            np.sqrt((
+                np.cos(lat2) * np.sin(long_diff)) ** 2 +
+                (np.cos(lat1) * np.sin(lat2) - np.sin(lat1) *
+                    np.cos(lat2) * np.cos(long_diff)) ** 2),
+            np.sin(lat1) * np.sin(lat2) + np.cos(lat1) * np.cos(lat2) *
+            np.cos(long_diff)))
     return gd
 
 


Test patch:
diff --git a/obspy/geodetics/tests/test_util_geodetics.py b/obspy/geodetics/tests/test_util_geodetics.py
--- a/obspy/geodetics/tests/test_util_geodetics.py
+++ b/obspy/geodetics/tests/test_util_geodetics.py
@@ -6,6 +6,7 @@
 import math
 import unittest
 import warnings
+import numpy as np
 
 from obspy.geodetics import (calc_vincenty_inverse, degrees2kilometers,
                              gps2dist_azimuth, kilometer2degrees,
@@ -243,6 +244,49 @@ def assert_loc(lat1, long1, lat2, long2, approx_distance):
         assert_loc(0, 0, 0, 180, 20004)
         assert_loc(11, 55, 11, 55, 0)
 
+        # test numpy inputs:
+        # Inline method to avoid messy code.
+        def assert_loc_np(lat1, long1, lat2, long2,
+                          approx_distance, expected_output_len):
+            loc2deg = locations2degrees(np.array(lat1),
+                                        np.array(long1),
+                                        np.array(lat2),
+                                        np.array(long2))
+            self.assertTrue((np.abs(np.radians(loc2deg) * 6371 -
+                                    approx_distance) <= 20).all())
+            self.assertTrue(np.isscalar(loc2deg)
+                            if expected_output_len == 0 else
+                            len(loc2deg) == expected_output_len)
+
+        # Test just with random location (combining scalars and arrays).
+        assert_loc_np(36.12, -86.67, 33.94, -118.40, 2893, 0)
+        assert_loc_np([36.12, 36.12], -86.67, 33.94, -118.40,
+                      2893, 2)
+        assert_loc_np(36.12, [-86.67, -86.67], 33.94, -118.40,
+                      2893, 2)
+        assert_loc_np(36.12, -86.67, [33.94, 33.94], -118.40,
+                      2893, 2)
+        assert_loc_np(36.12, -86.67, 33.94, [-118.40, -118.40],
+                      2893, 2)
+        assert_loc_np([36.12, 36.12], [-86.67, -86.67], 33.94, -118.40,
+                      2893, 2)
+        assert_loc_np([36.12, 36.12], -86.67, [33.94, 33.94], -118.40,
+                      2893, 2)
+        assert_loc_np([36.12, 36.12], -86.67, 33.94, [-118.40, -118.40],
+                      2893, 2)
+        assert_loc_np([36.12, 36.12], [-86.67, -86.67], [33.94, 33.94],
+                      -118.40, 2893, 2)
+        assert_loc_np([36.12, 36.12], -86.67, [33.94, 33.94],
+                      [-118.40, -118.40], 2893, 2)
+        assert_loc_np(36.12, [-86.67, -86.67], [33.94, 33.94],
+                      [-118.40, -118.40], 2893, 2)
+        assert_loc_np([36.12, 36.12], [-86.67, -86.67], [33.94, 33.94],
+                      [-118.40, -118.40], 2893, 2)
+
+        # test numpy broadcasting (bad shapes)
+        with self.assertRaises(ValueError):
+            locations2degrees(1, 2, [3, 4], [5, 6, 7])
+
     @unittest.skipIf(not HAS_GEOGRAPHICLIB, 'Module geographiclib is not '
                                             'installed')
     def test_issue_375(self):