nrl-tracker 0.22.5__py3-none-any.whl → 1.7.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. {nrl_tracker-0.22.5.dist-info → nrl_tracker-1.7.5.dist-info}/METADATA +57 -10
  2. {nrl_tracker-0.22.5.dist-info → nrl_tracker-1.7.5.dist-info}/RECORD +84 -69
  3. pytcl/__init__.py +4 -3
  4. pytcl/assignment_algorithms/__init__.py +28 -0
  5. pytcl/assignment_algorithms/gating.py +10 -10
  6. pytcl/assignment_algorithms/jpda.py +40 -40
  7. pytcl/assignment_algorithms/nd_assignment.py +379 -0
  8. pytcl/assignment_algorithms/network_flow.py +371 -0
  9. pytcl/assignment_algorithms/three_dimensional/assignment.py +3 -3
  10. pytcl/astronomical/__init__.py +104 -3
  11. pytcl/astronomical/ephemerides.py +14 -11
  12. pytcl/astronomical/reference_frames.py +865 -56
  13. pytcl/astronomical/relativity.py +6 -5
  14. pytcl/astronomical/sgp4.py +710 -0
  15. pytcl/astronomical/special_orbits.py +532 -0
  16. pytcl/astronomical/tle.py +558 -0
  17. pytcl/atmosphere/__init__.py +43 -1
  18. pytcl/atmosphere/ionosphere.py +512 -0
  19. pytcl/atmosphere/nrlmsise00.py +809 -0
  20. pytcl/clustering/dbscan.py +2 -2
  21. pytcl/clustering/gaussian_mixture.py +3 -3
  22. pytcl/clustering/hierarchical.py +15 -15
  23. pytcl/clustering/kmeans.py +4 -4
  24. pytcl/containers/__init__.py +24 -0
  25. pytcl/containers/base.py +219 -0
  26. pytcl/containers/cluster_set.py +12 -2
  27. pytcl/containers/covertree.py +26 -29
  28. pytcl/containers/kd_tree.py +94 -29
  29. pytcl/containers/rtree.py +200 -1
  30. pytcl/containers/vptree.py +21 -28
  31. pytcl/coordinate_systems/conversions/geodetic.py +272 -5
  32. pytcl/coordinate_systems/jacobians/jacobians.py +2 -2
  33. pytcl/coordinate_systems/projections/__init__.py +1 -1
  34. pytcl/coordinate_systems/projections/projections.py +2 -2
  35. pytcl/coordinate_systems/rotations/rotations.py +10 -6
  36. pytcl/core/__init__.py +18 -0
  37. pytcl/core/validation.py +333 -2
  38. pytcl/dynamic_estimation/__init__.py +26 -0
  39. pytcl/dynamic_estimation/gaussian_sum_filter.py +434 -0
  40. pytcl/dynamic_estimation/imm.py +14 -14
  41. pytcl/dynamic_estimation/kalman/__init__.py +30 -0
  42. pytcl/dynamic_estimation/kalman/constrained.py +382 -0
  43. pytcl/dynamic_estimation/kalman/extended.py +8 -8
  44. pytcl/dynamic_estimation/kalman/h_infinity.py +613 -0
  45. pytcl/dynamic_estimation/kalman/square_root.py +60 -573
  46. pytcl/dynamic_estimation/kalman/sr_ukf.py +302 -0
  47. pytcl/dynamic_estimation/kalman/ud_filter.py +410 -0
  48. pytcl/dynamic_estimation/kalman/unscented.py +8 -6
  49. pytcl/dynamic_estimation/particle_filters/bootstrap.py +15 -15
  50. pytcl/dynamic_estimation/rbpf.py +589 -0
  51. pytcl/gravity/egm.py +13 -0
  52. pytcl/gravity/spherical_harmonics.py +98 -37
  53. pytcl/gravity/tides.py +6 -6
  54. pytcl/logging_config.py +328 -0
  55. pytcl/magnetism/__init__.py +7 -0
  56. pytcl/magnetism/emm.py +10 -3
  57. pytcl/magnetism/wmm.py +260 -23
  58. pytcl/mathematical_functions/combinatorics/combinatorics.py +5 -5
  59. pytcl/mathematical_functions/geometry/geometry.py +5 -5
  60. pytcl/mathematical_functions/numerical_integration/quadrature.py +6 -6
  61. pytcl/mathematical_functions/signal_processing/detection.py +24 -24
  62. pytcl/mathematical_functions/signal_processing/filters.py +14 -14
  63. pytcl/mathematical_functions/signal_processing/matched_filter.py +12 -12
  64. pytcl/mathematical_functions/special_functions/bessel.py +15 -3
  65. pytcl/mathematical_functions/special_functions/debye.py +136 -26
  66. pytcl/mathematical_functions/special_functions/error_functions.py +3 -1
  67. pytcl/mathematical_functions/special_functions/gamma_functions.py +4 -4
  68. pytcl/mathematical_functions/special_functions/hypergeometric.py +81 -15
  69. pytcl/mathematical_functions/transforms/fourier.py +8 -8
  70. pytcl/mathematical_functions/transforms/stft.py +12 -12
  71. pytcl/mathematical_functions/transforms/wavelets.py +9 -9
  72. pytcl/navigation/geodesy.py +246 -160
  73. pytcl/navigation/great_circle.py +101 -19
  74. pytcl/plotting/coordinates.py +7 -7
  75. pytcl/plotting/tracks.py +2 -2
  76. pytcl/static_estimation/maximum_likelihood.py +16 -14
  77. pytcl/static_estimation/robust.py +5 -5
  78. pytcl/terrain/loaders.py +5 -5
  79. pytcl/trackers/hypothesis.py +1 -1
  80. pytcl/trackers/mht.py +9 -9
  81. pytcl/trackers/multi_target.py +1 -1
  82. {nrl_tracker-0.22.5.dist-info → nrl_tracker-1.7.5.dist-info}/LICENSE +0 -0
  83. {nrl_tracker-0.22.5.dist-info → nrl_tracker-1.7.5.dist-info}/WHEEL +0 -0
  84. {nrl_tracker-0.22.5.dist-info → nrl_tracker-1.7.5.dist-info}/top_level.txt +0 -0
@@ -22,7 +22,7 @@ References
22
22
  Speech, and Signal Processing, 32(2), 236-243.
23
23
  """
24
24
 
25
- from typing import NamedTuple, Optional, Union
25
+ from typing import Any, NamedTuple, Optional, Union
26
26
 
27
27
  import numpy as np
28
28
  from numpy.typing import ArrayLike, NDArray
@@ -77,7 +77,7 @@ class Spectrogram(NamedTuple):
77
77
 
78
78
 
79
79
  def get_window(
80
- window: Union[str, tuple, ArrayLike],
80
+ window: Union[str, tuple[str, Any], ArrayLike],
81
81
  length: int,
82
82
  fftbins: bool = True,
83
83
  ) -> NDArray[np.floating]:
@@ -174,7 +174,7 @@ def window_bandwidth(
174
174
  def stft(
175
175
  x: ArrayLike,
176
176
  fs: float = 1.0,
177
- window: Union[str, tuple, ArrayLike] = "hann",
177
+ window: Union[str, tuple[str, Any], ArrayLike] = "hann",
178
178
  nperseg: int = 256,
179
179
  noverlap: Optional[int] = None,
180
180
  nfft: Optional[int] = None,
@@ -264,13 +264,13 @@ def stft(
264
264
  def istft(
265
265
  Zxx: ArrayLike,
266
266
  fs: float = 1.0,
267
- window: Union[str, tuple, ArrayLike] = "hann",
267
+ window: Union[str, tuple[str, Any], ArrayLike] = "hann",
268
268
  nperseg: Optional[int] = None,
269
269
  noverlap: Optional[int] = None,
270
270
  nfft: Optional[int] = None,
271
271
  input_onesided: bool = True,
272
272
  boundary: bool = True,
273
- ) -> tuple:
273
+ ) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
274
274
  """
275
275
  Compute the inverse Short-Time Fourier Transform.
276
276
 
@@ -351,7 +351,7 @@ def istft(
351
351
  def spectrogram(
352
352
  x: ArrayLike,
353
353
  fs: float = 1.0,
354
- window: Union[str, tuple, ArrayLike] = "hann",
354
+ window: Union[str, tuple[str, Any], ArrayLike] = "hann",
355
355
  nperseg: int = 256,
356
356
  noverlap: Optional[int] = None,
357
357
  nfft: Optional[int] = None,
@@ -436,11 +436,11 @@ def spectrogram(
436
436
  def reassigned_spectrogram(
437
437
  x: ArrayLike,
438
438
  fs: float = 1.0,
439
- window: Union[str, tuple, ArrayLike] = "hann",
439
+ window: Union[str, tuple[str, Any], ArrayLike] = "hann",
440
440
  nperseg: int = 256,
441
441
  noverlap: Optional[int] = None,
442
442
  nfft: Optional[int] = None,
443
- ) -> tuple:
443
+ ) -> tuple[NDArray[np.floating], NDArray[np.floating], NDArray[np.floating]]:
444
444
  """
445
445
  Compute reassigned spectrogram for improved time-frequency resolution.
446
446
 
@@ -538,7 +538,7 @@ def mel_spectrogram(
538
538
  window: str = "hann",
539
539
  nperseg: int = 2048,
540
540
  noverlap: Optional[int] = None,
541
- ) -> tuple:
541
+ ) -> tuple[NDArray[np.floating], NDArray[np.floating], NDArray[np.floating]]:
542
542
  """
543
543
  Compute mel-scaled spectrogram.
544
544
 
@@ -611,15 +611,15 @@ def mel_spectrogram(
611
611
  # Mel frequency centers
612
612
  mel_freqs = _mel_frequencies(n_mels, fmin, fmax)
613
613
 
614
- return mel_freqs, spec_result.times, mel_spec
614
+ return (mel_freqs, spec_result.times, mel_spec)
615
615
 
616
616
 
617
- def _hz_to_mel(hz: Union[float, ArrayLike]) -> Union[float, NDArray]:
617
+ def _hz_to_mel(hz: Union[float, ArrayLike]) -> Union[float, NDArray[np.floating]]:
618
618
  """Convert frequency in Hz to mel scale."""
619
619
  return 2595.0 * np.log10(1.0 + np.asarray(hz) / 700.0)
620
620
 
621
621
 
622
- def _mel_to_hz(mel: Union[float, ArrayLike]) -> Union[float, NDArray]:
622
+ def _mel_to_hz(mel: Union[float, ArrayLike]) -> Union[float, NDArray[np.floating]]:
623
623
  """Convert mel scale to frequency in Hz."""
624
624
  return 700.0 * (10.0 ** (np.asarray(mel) / 2595.0) - 1.0)
625
625
 
@@ -20,7 +20,7 @@ References
20
20
  .. [2] Daubechies, I. (1992). Ten Lectures on Wavelets. SIAM.
21
21
  """
22
22
 
23
- from typing import Callable, List, NamedTuple, Optional, Union
23
+ from typing import Any, Callable, List, NamedTuple, Optional, Union
24
24
 
25
25
  import numpy as np
26
26
  from numpy.typing import ArrayLike, NDArray
@@ -262,7 +262,7 @@ def gaussian_wavelet(
262
262
  def cwt(
263
263
  signal: ArrayLike,
264
264
  scales: ArrayLike,
265
- wavelet: Union[str, Callable[[int], NDArray]] = "morlet",
265
+ wavelet: Union[str, Callable[[int], NDArray[np.floating]]] = "morlet",
266
266
  fs: float = 1.0,
267
267
  method: str = "fft",
268
268
  ) -> CWTResult:
@@ -312,16 +312,16 @@ def cwt(
312
312
  n = len(signal)
313
313
 
314
314
  # Determine wavelet function
315
- def _morlet_default(M: int) -> NDArray:
315
+ def _morlet_default(M: int) -> NDArray[np.floating]:
316
316
  return morlet_wavelet(M, w=5.0)
317
317
 
318
- def _ricker_default(M: int) -> NDArray:
318
+ def _ricker_default(M: int) -> NDArray[np.floating]:
319
319
  return ricker_wavelet(M, a=1.0)
320
320
 
321
- def _gaussian1_default(M: int) -> NDArray:
321
+ def _gaussian1_default(M: int) -> NDArray[np.floating]:
322
322
  return gaussian_wavelet(M, order=1)
323
323
 
324
- def _gaussian2_default(M: int) -> NDArray:
324
+ def _gaussian2_default(M: int) -> NDArray[np.floating]:
325
325
  return gaussian_wavelet(M, order=2)
326
326
 
327
327
  if callable(wavelet):
@@ -596,7 +596,7 @@ def dwt_single_level(
596
596
  signal: ArrayLike,
597
597
  wavelet: str = "db4",
598
598
  mode: str = "symmetric",
599
- ) -> tuple:
599
+ ) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
600
600
  """
601
601
  Compute single-level DWT decomposition.
602
602
 
@@ -673,7 +673,7 @@ def wpt(
673
673
  wavelet: str = "db4",
674
674
  level: Optional[int] = None,
675
675
  mode: str = "symmetric",
676
- ) -> dict:
676
+ ) -> dict[str, NDArray[np.floating]]:
677
677
  """
678
678
  Compute the Wavelet Packet Transform.
679
679
 
@@ -748,7 +748,7 @@ def available_wavelets() -> List[str]:
748
748
  return pywt.wavelist()
749
749
 
750
750
 
751
- def wavelet_info(wavelet: str) -> dict:
751
+ def wavelet_info(wavelet: str) -> dict[str, Any]:
752
752
  """
753
753
  Get information about a wavelet.
754
754
 
@@ -8,11 +8,20 @@ This module provides geodetic utilities including:
8
8
  - Earth ellipsoid parameters
9
9
  """
10
10
 
11
- from typing import NamedTuple, Tuple
11
+ import logging
12
+ from functools import lru_cache
13
+ from typing import Any, NamedTuple, Tuple
12
14
 
13
15
  import numpy as np
14
16
  from numpy.typing import ArrayLike, NDArray
15
17
 
18
+ # Module logger
19
+ _logger = logging.getLogger("pytcl.navigation.geodesy")
20
+
21
+ # Cache configuration for Vincenty geodetic calculations
22
+ _VINCENTY_CACHE_DECIMALS = 10 # ~0.01mm precision
23
+ _VINCENTY_CACHE_MAXSIZE = 128 # Max cached coordinate pairs
24
+
16
25
 
17
26
  class Ellipsoid(NamedTuple):
18
27
  """
@@ -51,6 +60,198 @@ GRS80 = Ellipsoid(a=6378137.0, f=1.0 / 298.257222101)
51
60
  SPHERE = Ellipsoid(a=6371000.0, f=0.0)
52
61
 
53
62
 
63
+ def _quantize_geodetic(val: float) -> float:
64
+ """Quantize geodetic coordinate for cache key compatibility."""
65
+ return round(val, _VINCENTY_CACHE_DECIMALS)
66
+
67
+
68
+ @lru_cache(maxsize=_VINCENTY_CACHE_MAXSIZE)
69
+ def _inverse_geodetic_cached(
70
+ lat1_q: float,
71
+ lon1_q: float,
72
+ lat2_q: float,
73
+ lon2_q: float,
74
+ a: float,
75
+ f: float,
76
+ ) -> Tuple[float, float, float]:
77
+ """Cached Vincenty inverse geodetic computation (internal).
78
+
79
+ Returns (distance, azimuth1, azimuth2).
80
+ """
81
+ b = a * (1 - f)
82
+
83
+ # Reduced latitudes
84
+ U1 = np.arctan((1 - f) * np.tan(lat1_q))
85
+ U2 = np.arctan((1 - f) * np.tan(lat2_q))
86
+ sin_U1, cos_U1 = np.sin(U1), np.cos(U1)
87
+ sin_U2, cos_U2 = np.sin(U2), np.cos(U2)
88
+
89
+ L = lon2_q - lon1_q
90
+ lam = L
91
+
92
+ for _ in range(100):
93
+ sin_lam = np.sin(lam)
94
+ cos_lam = np.cos(lam)
95
+
96
+ sin_sigma = np.sqrt(
97
+ (cos_U2 * sin_lam) ** 2 + (cos_U1 * sin_U2 - sin_U1 * cos_U2 * cos_lam) ** 2
98
+ )
99
+
100
+ if sin_sigma == 0:
101
+ # Coincident points
102
+ return 0.0, 0.0, 0.0
103
+
104
+ cos_sigma = sin_U1 * sin_U2 + cos_U1 * cos_U2 * cos_lam
105
+ sigma = np.arctan2(sin_sigma, cos_sigma)
106
+
107
+ sin_alpha = cos_U1 * cos_U2 * sin_lam / sin_sigma
108
+ cos2_alpha = 1 - sin_alpha**2
109
+
110
+ if cos2_alpha == 0:
111
+ cos_2sigma_m = 0
112
+ else:
113
+ cos_2sigma_m = cos_sigma - 2 * sin_U1 * sin_U2 / cos2_alpha
114
+
115
+ C = f / 16 * cos2_alpha * (4 + f * (4 - 3 * cos2_alpha))
116
+
117
+ lam_new = L + (1 - C) * f * sin_alpha * (
118
+ sigma
119
+ + C
120
+ * sin_sigma
121
+ * (cos_2sigma_m + C * cos_sigma * (-1 + 2 * cos_2sigma_m**2))
122
+ )
123
+
124
+ if abs(lam_new - lam) < 1e-12:
125
+ break
126
+ lam = lam_new
127
+
128
+ u2 = cos2_alpha * (a**2 - b**2) / b**2
129
+ A = 1 + u2 / 16384 * (4096 + u2 * (-768 + u2 * (320 - 175 * u2)))
130
+ B = u2 / 1024 * (256 + u2 * (-128 + u2 * (74 - 47 * u2)))
131
+
132
+ delta_sigma = (
133
+ B
134
+ * sin_sigma
135
+ * (
136
+ cos_2sigma_m
137
+ + B
138
+ / 4
139
+ * (
140
+ cos_sigma * (-1 + 2 * cos_2sigma_m**2)
141
+ - B
142
+ / 6
143
+ * cos_2sigma_m
144
+ * (-3 + 4 * sin_sigma**2)
145
+ * (-3 + 4 * cos_2sigma_m**2)
146
+ )
147
+ )
148
+ )
149
+
150
+ distance = b * A * (sigma - delta_sigma)
151
+
152
+ # Azimuths
153
+ azimuth1 = np.arctan2(cos_U2 * sin_lam, cos_U1 * sin_U2 - sin_U1 * cos_U2 * cos_lam)
154
+ azimuth2 = np.arctan2(
155
+ cos_U1 * sin_lam, -sin_U1 * cos_U2 + cos_U1 * sin_U2 * cos_lam
156
+ )
157
+
158
+ return float(distance), float(azimuth1), float(azimuth2)
159
+
160
+
161
+ @lru_cache(maxsize=_VINCENTY_CACHE_MAXSIZE)
162
+ def _direct_geodetic_cached(
163
+ lat1_q: float,
164
+ lon1_q: float,
165
+ azimuth_q: float,
166
+ distance_q: float,
167
+ a: float,
168
+ f: float,
169
+ ) -> Tuple[float, float, float]:
170
+ """Cached Vincenty direct geodetic computation (internal).
171
+
172
+ Returns (lat2, lon2, azimuth2).
173
+ """
174
+ b = a * (1 - f)
175
+
176
+ sin_alpha1 = np.sin(azimuth_q)
177
+ cos_alpha1 = np.cos(azimuth_q)
178
+
179
+ # Reduced latitude
180
+ tan_U1 = (1 - f) * np.tan(lat1_q)
181
+ cos_U1 = 1.0 / np.sqrt(1 + tan_U1**2)
182
+ sin_U1 = tan_U1 * cos_U1
183
+
184
+ sigma1 = np.arctan2(tan_U1, cos_alpha1)
185
+ sin_alpha = cos_U1 * sin_alpha1
186
+ cos2_alpha = 1 - sin_alpha**2
187
+
188
+ u2 = cos2_alpha * (a**2 - b**2) / b**2
189
+ A = 1 + u2 / 16384 * (4096 + u2 * (-768 + u2 * (320 - 175 * u2)))
190
+ B = u2 / 1024 * (256 + u2 * (-128 + u2 * (74 - 47 * u2)))
191
+
192
+ sigma = distance_q / (b * A)
193
+
194
+ for _ in range(100):
195
+ cos_2sigma_m = np.cos(2 * sigma1 + sigma)
196
+ sin_sigma = np.sin(sigma)
197
+ cos_sigma = np.cos(sigma)
198
+
199
+ delta_sigma = (
200
+ B
201
+ * sin_sigma
202
+ * (
203
+ cos_2sigma_m
204
+ + B
205
+ / 4
206
+ * (
207
+ cos_sigma * (-1 + 2 * cos_2sigma_m**2)
208
+ - B
209
+ / 6
210
+ * cos_2sigma_m
211
+ * (-3 + 4 * sin_sigma**2)
212
+ * (-3 + 4 * cos_2sigma_m**2)
213
+ )
214
+ )
215
+ )
216
+
217
+ sigma_new = distance_q / (b * A) + delta_sigma
218
+ if abs(sigma_new - sigma) < 1e-12:
219
+ break
220
+ sigma = sigma_new
221
+
222
+ cos_2sigma_m = np.cos(2 * sigma1 + sigma)
223
+ sin_sigma = np.sin(sigma)
224
+ cos_sigma = np.cos(sigma)
225
+
226
+ sin_U2 = sin_U1 * cos_sigma + cos_U1 * sin_sigma * cos_alpha1
227
+ lat2 = np.arctan2(
228
+ sin_U2,
229
+ (1 - f)
230
+ * np.sqrt(
231
+ sin_alpha**2 + (sin_U1 * sin_sigma - cos_U1 * cos_sigma * cos_alpha1) ** 2
232
+ ),
233
+ )
234
+
235
+ lam = np.arctan2(
236
+ sin_sigma * sin_alpha1, cos_U1 * cos_sigma - sin_U1 * sin_sigma * cos_alpha1
237
+ )
238
+
239
+ C = f / 16 * cos2_alpha * (4 + f * (4 - 3 * cos2_alpha))
240
+ L = lam - (1 - C) * f * sin_alpha * (
241
+ sigma
242
+ + C * sin_sigma * (cos_2sigma_m + C * cos_sigma * (-1 + 2 * cos_2sigma_m**2))
243
+ )
244
+
245
+ lon2 = lon1_q + L
246
+
247
+ # Back azimuth
248
+ azimuth2 = np.arctan2(
249
+ sin_alpha, -sin_U1 * sin_sigma + cos_U1 * cos_sigma * cos_alpha1
250
+ )
251
+
252
+ return float(lat2), float(lon2), float(azimuth2)
253
+
254
+
54
255
  def geodetic_to_ecef(
55
256
  lat: ArrayLike,
56
257
  lon: ArrayLike,
@@ -372,6 +573,7 @@ def direct_geodetic(
372
573
  Solve the direct geodetic problem (Vincenty).
373
574
 
374
575
  Given a starting point, azimuth, and distance, find the destination point.
576
+ Results are cached for repeated queries with the same parameters.
375
577
 
376
578
  Parameters
377
579
  ----------
@@ -400,88 +602,15 @@ def direct_geodetic(
400
602
  .. [1] Vincenty, T., "Direct and Inverse Solutions of Geodesics on the
401
603
  Ellipsoid with Application of Nested Equations", Survey Review, 1975.
402
604
  """
403
- a = ellipsoid.a
404
- f = ellipsoid.f
405
- b = ellipsoid.b
406
-
407
- sin_alpha1 = np.sin(azimuth)
408
- cos_alpha1 = np.cos(azimuth)
409
-
410
- # Reduced latitude
411
- tan_U1 = (1 - f) * np.tan(lat1)
412
- cos_U1 = 1.0 / np.sqrt(1 + tan_U1**2)
413
- sin_U1 = tan_U1 * cos_U1
414
-
415
- sigma1 = np.arctan2(tan_U1, cos_alpha1)
416
- sin_alpha = cos_U1 * sin_alpha1
417
- cos2_alpha = 1 - sin_alpha**2
418
-
419
- u2 = cos2_alpha * (a**2 - b**2) / b**2
420
- A = 1 + u2 / 16384 * (4096 + u2 * (-768 + u2 * (320 - 175 * u2)))
421
- B = u2 / 1024 * (256 + u2 * (-128 + u2 * (74 - 47 * u2)))
422
-
423
- sigma = distance / (b * A)
424
-
425
- for _ in range(100):
426
- cos_2sigma_m = np.cos(2 * sigma1 + sigma)
427
- sin_sigma = np.sin(sigma)
428
- cos_sigma = np.cos(sigma)
429
-
430
- delta_sigma = (
431
- B
432
- * sin_sigma
433
- * (
434
- cos_2sigma_m
435
- + B
436
- / 4
437
- * (
438
- cos_sigma * (-1 + 2 * cos_2sigma_m**2)
439
- - B
440
- / 6
441
- * cos_2sigma_m
442
- * (-3 + 4 * sin_sigma**2)
443
- * (-3 + 4 * cos_2sigma_m**2)
444
- )
445
- )
446
- )
447
-
448
- sigma_new = distance / (b * A) + delta_sigma
449
- if abs(sigma_new - sigma) < 1e-12:
450
- break
451
- sigma = sigma_new
452
-
453
- cos_2sigma_m = np.cos(2 * sigma1 + sigma)
454
- sin_sigma = np.sin(sigma)
455
- cos_sigma = np.cos(sigma)
456
-
457
- sin_U2 = sin_U1 * cos_sigma + cos_U1 * sin_sigma * cos_alpha1
458
- lat2 = np.arctan2(
459
- sin_U2,
460
- (1 - f)
461
- * np.sqrt(
462
- sin_alpha**2 + (sin_U1 * sin_sigma - cos_U1 * cos_sigma * cos_alpha1) ** 2
463
- ),
605
+ return _direct_geodetic_cached(
606
+ _quantize_geodetic(lat1),
607
+ _quantize_geodetic(lon1),
608
+ _quantize_geodetic(azimuth),
609
+ round(distance, 3), # 1mm precision for distance
610
+ ellipsoid.a,
611
+ ellipsoid.f,
464
612
  )
465
613
 
466
- lam = np.arctan2(
467
- sin_sigma * sin_alpha1, cos_U1 * cos_sigma - sin_U1 * sin_sigma * cos_alpha1
468
- )
469
-
470
- C = f / 16 * cos2_alpha * (4 + f * (4 - 3 * cos2_alpha))
471
- L = lam - (1 - C) * f * sin_alpha * (
472
- sigma
473
- + C * sin_sigma * (cos_2sigma_m + C * cos_sigma * (-1 + 2 * cos_2sigma_m**2))
474
- )
475
-
476
- lon2 = lon1 + L
477
-
478
- # Back azimuth
479
- azimuth2 = np.arctan2(
480
- sin_alpha, -sin_U1 * sin_sigma + cos_U1 * cos_sigma * cos_alpha1
481
- )
482
-
483
- return float(lat2), float(lon2), float(azimuth2)
484
-
485
614
 
486
615
  def inverse_geodetic(
487
616
  lat1: float,
@@ -494,6 +623,7 @@ def inverse_geodetic(
494
623
  Solve the inverse geodetic problem (Vincenty).
495
624
 
496
625
  Given two points, find the distance and azimuths between them.
626
+ Results are cached for repeated queries with the same coordinates.
497
627
 
498
628
  Parameters
499
629
  ----------
@@ -526,87 +656,15 @@ def inverse_geodetic(
526
656
  .. [1] Vincenty, T., "Direct and Inverse Solutions of Geodesics on the
527
657
  Ellipsoid with Application of Nested Equations", Survey Review, 1975.
528
658
  """
529
- a = ellipsoid.a
530
- f = ellipsoid.f
531
- b = ellipsoid.b
532
-
533
- # Reduced latitudes
534
- U1 = np.arctan((1 - f) * np.tan(lat1))
535
- U2 = np.arctan((1 - f) * np.tan(lat2))
536
- sin_U1, cos_U1 = np.sin(U1), np.cos(U1)
537
- sin_U2, cos_U2 = np.sin(U2), np.cos(U2)
538
-
539
- L = lon2 - lon1
540
- lam = L
541
-
542
- for _ in range(100):
543
- sin_lam = np.sin(lam)
544
- cos_lam = np.cos(lam)
545
-
546
- sin_sigma = np.sqrt(
547
- (cos_U2 * sin_lam) ** 2 + (cos_U1 * sin_U2 - sin_U1 * cos_U2 * cos_lam) ** 2
548
- )
549
-
550
- if sin_sigma == 0:
551
- # Coincident points
552
- return 0.0, 0.0, 0.0
553
-
554
- cos_sigma = sin_U1 * sin_U2 + cos_U1 * cos_U2 * cos_lam
555
- sigma = np.arctan2(sin_sigma, cos_sigma)
556
-
557
- sin_alpha = cos_U1 * cos_U2 * sin_lam / sin_sigma
558
- cos2_alpha = 1 - sin_alpha**2
559
-
560
- if cos2_alpha == 0:
561
- cos_2sigma_m = 0
562
- else:
563
- cos_2sigma_m = cos_sigma - 2 * sin_U1 * sin_U2 / cos2_alpha
564
-
565
- C = f / 16 * cos2_alpha * (4 + f * (4 - 3 * cos2_alpha))
566
-
567
- lam_new = L + (1 - C) * f * sin_alpha * (
568
- sigma
569
- + C
570
- * sin_sigma
571
- * (cos_2sigma_m + C * cos_sigma * (-1 + 2 * cos_2sigma_m**2))
572
- )
573
-
574
- if abs(lam_new - lam) < 1e-12:
575
- break
576
- lam = lam_new
577
-
578
- u2 = cos2_alpha * (a**2 - b**2) / b**2
579
- A = 1 + u2 / 16384 * (4096 + u2 * (-768 + u2 * (320 - 175 * u2)))
580
- B = u2 / 1024 * (256 + u2 * (-128 + u2 * (74 - 47 * u2)))
581
-
582
- delta_sigma = (
583
- B
584
- * sin_sigma
585
- * (
586
- cos_2sigma_m
587
- + B
588
- / 4
589
- * (
590
- cos_sigma * (-1 + 2 * cos_2sigma_m**2)
591
- - B
592
- / 6
593
- * cos_2sigma_m
594
- * (-3 + 4 * sin_sigma**2)
595
- * (-3 + 4 * cos_2sigma_m**2)
596
- )
597
- )
659
+ return _inverse_geodetic_cached(
660
+ _quantize_geodetic(lat1),
661
+ _quantize_geodetic(lon1),
662
+ _quantize_geodetic(lat2),
663
+ _quantize_geodetic(lon2),
664
+ ellipsoid.a,
665
+ ellipsoid.f,
598
666
  )
599
667
 
600
- distance = b * A * (sigma - delta_sigma)
601
-
602
- # Azimuths
603
- azimuth1 = np.arctan2(cos_U2 * sin_lam, cos_U1 * sin_U2 - sin_U1 * cos_U2 * cos_lam)
604
- azimuth2 = np.arctan2(
605
- cos_U1 * sin_lam, -sin_U1 * cos_U2 + cos_U1 * sin_U2 * cos_lam
606
- )
607
-
608
- return float(distance), float(azimuth1), float(azimuth2)
609
-
610
668
 
611
669
  def haversine_distance(
612
670
  lat1: float,
@@ -646,6 +704,31 @@ def haversine_distance(
646
704
  return radius * c
647
705
 
648
706
 
707
+ def clear_geodesy_cache() -> None:
708
+ """Clear all geodesy computation caches.
709
+
710
+ This can be useful to free memory after processing large datasets
711
+ or when cache statistics are being monitored.
712
+ """
713
+ _inverse_geodetic_cached.cache_clear()
714
+ _direct_geodetic_cached.cache_clear()
715
+ _logger.debug("Geodesy caches cleared")
716
+
717
+
718
+ def get_geodesy_cache_info() -> dict[str, Any]:
719
+ """Get cache statistics for geodesy computations.
720
+
721
+ Returns
722
+ -------
723
+ dict[str, Any]
724
+ Dictionary with cache statistics for inverse and direct geodetic caches.
725
+ """
726
+ return {
727
+ "inverse_geodetic": _inverse_geodetic_cached.cache_info()._asdict(),
728
+ "direct_geodetic": _direct_geodetic_cached.cache_info()._asdict(),
729
+ }
730
+
731
+
649
732
  __all__ = [
650
733
  # Ellipsoids
651
734
  "Ellipsoid",
@@ -663,4 +746,7 @@ __all__ = [
663
746
  "direct_geodetic",
664
747
  "inverse_geodetic",
665
748
  "haversine_distance",
749
+ # Cache management
750
+ "clear_geodesy_cache",
751
+ "get_geodesy_cache_info",
666
752
  ]