nrl-tracker 1.1.3__py3-none-any.whl → 1.3.0__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.
pytcl/gravity/egm.py CHANGED
@@ -21,6 +21,7 @@ References
21
21
  https://earth-info.nga.mil/
22
22
  """
23
23
 
24
+ import logging
24
25
  import os
25
26
  from functools import lru_cache
26
27
  from pathlib import Path
@@ -32,6 +33,9 @@ from numpy.typing import NDArray
32
33
  from .clenshaw import clenshaw_gravity, clenshaw_potential
33
34
  from .models import WGS84, normal_gravity_somigliana
34
35
 
36
+ # Module logger
37
+ _logger = logging.getLogger("pytcl.gravity.egm")
38
+
35
39
 
36
40
  class EGMCoefficients(NamedTuple):
37
41
  """Earth Gravitational Model coefficients.
@@ -317,6 +321,8 @@ def _load_coefficients_cached(
317
321
  data_dir = get_data_dir()
318
322
  filepath = data_dir / f"{model}.cof"
319
323
 
324
+ _logger.debug("Loading %s coefficients from %s", model, filepath)
325
+
320
326
  if not filepath.exists():
321
327
  raise FileNotFoundError(
322
328
  f"Coefficient file not found: {filepath}\n"
@@ -330,6 +336,13 @@ def _load_coefficients_cached(
330
336
  actual_n_max = n_max if n_max is not None else int(params["n_max_full"])
331
337
  C, S = parse_egm_file(filepath, actual_n_max)
332
338
 
339
+ _logger.info(
340
+ "Loaded %s coefficients: n_max=%d, array_size=%.1f MB",
341
+ model,
342
+ C.shape[0] - 1,
343
+ C.nbytes / 1024 / 1024 * 2, # Both C and S arrays
344
+ )
345
+
333
346
  return EGMCoefficients(
334
347
  C=C,
335
348
  S=S,
@@ -11,11 +11,69 @@ References
11
11
  .. [2] O. Montenbruck and E. Gill, "Satellite Orbits," Springer, 2000.
12
12
  """
13
13
 
14
+ import logging
15
+ from functools import lru_cache
14
16
  from typing import Optional, Tuple
15
17
 
16
18
  import numpy as np
17
19
  from numpy.typing import NDArray
18
20
 
21
+ # Module logger
22
+ _logger = logging.getLogger("pytcl.gravity.spherical_harmonics")
23
+
24
+ # Cache configuration for Legendre polynomials
25
+ _LEGENDRE_CACHE_DECIMALS = 8 # Precision for x quantization
26
+ _LEGENDRE_CACHE_MAXSIZE = 64 # Max cached (n_max, m_max, x) combinations
27
+
28
+
29
+ def _quantize_x(x: float) -> float:
30
+ """Quantize x value for cache key compatibility."""
31
+ return round(x, _LEGENDRE_CACHE_DECIMALS)
32
+
33
+
34
+ @lru_cache(maxsize=_LEGENDRE_CACHE_MAXSIZE)
35
+ def _associated_legendre_cached(
36
+ n_max: int,
37
+ m_max: int,
38
+ x_quantized: float,
39
+ normalized: bool,
40
+ ) -> tuple:
41
+ """Cached Legendre polynomial computation (internal).
42
+
43
+ Returns tuple of tuples for hashability.
44
+ """
45
+ P = np.zeros((n_max + 1, m_max + 1))
46
+ u = np.sqrt(1 - x_quantized * x_quantized)
47
+
48
+ P[0, 0] = 1.0
49
+
50
+ for m in range(1, m_max + 1):
51
+ if normalized:
52
+ P[m, m] = u * np.sqrt((2 * m + 1) / (2 * m)) * P[m - 1, m - 1]
53
+ else:
54
+ P[m, m] = (2 * m - 1) * u * P[m - 1, m - 1]
55
+
56
+ for m in range(m_max):
57
+ if m + 1 <= n_max:
58
+ if normalized:
59
+ P[m + 1, m] = x_quantized * np.sqrt(2 * m + 3) * P[m, m]
60
+ else:
61
+ P[m + 1, m] = x_quantized * (2 * m + 1) * P[m, m]
62
+
63
+ for m in range(m_max + 1):
64
+ for n in range(m + 2, n_max + 1):
65
+ if normalized:
66
+ a_nm = np.sqrt((4 * n * n - 1) / (n * n - m * m))
67
+ b_nm = np.sqrt(((n - 1) ** 2 - m * m) / (4 * (n - 1) ** 2 - 1))
68
+ P[n, m] = a_nm * (x_quantized * P[n - 1, m] - b_nm * P[n - 2, m])
69
+ else:
70
+ P[n, m] = (
71
+ (2 * n - 1) * x_quantized * P[n - 1, m] - (n + m - 1) * P[n - 2, m]
72
+ ) / (n - m)
73
+
74
+ # Convert to tuple of tuples for hashability
75
+ return tuple(tuple(row) for row in P)
76
+
19
77
 
20
78
  def associated_legendre(
21
79
  n_max: int,
@@ -53,6 +111,9 @@ def associated_legendre(
53
111
 
54
112
  \\int_{-1}^{1} [\\bar{P}_n^m(x)]^2 dx = \\frac{2}{2n+1}
55
113
 
114
+ Results are cached for repeated queries with the same parameters.
115
+ Cache key quantizes x to 8 decimal places (~1e-8 precision).
116
+
56
117
  Examples
57
118
  --------
58
119
  >>> P = associated_legendre(2, 2, 0.5)
@@ -63,42 +124,10 @@ def associated_legendre(
63
124
  if not -1 <= x <= 1:
64
125
  raise ValueError("x must be in [-1, 1]")
65
126
 
66
- P = np.zeros((n_max + 1, m_max + 1))
67
-
68
- # Compute sqrt(1 - x^2) = sin(theta) for colatitude
69
- u = np.sqrt(1 - x * x)
70
-
71
- # Seed values
72
- P[0, 0] = 1.0
73
-
74
- # Sectoral recursion: P_m^m from P_{m-1}^{m-1}
75
- for m in range(1, m_max + 1):
76
- if normalized:
77
- P[m, m] = u * np.sqrt((2 * m + 1) / (2 * m)) * P[m - 1, m - 1]
78
- else:
79
- P[m, m] = (2 * m - 1) * u * P[m - 1, m - 1]
80
-
81
- # Compute P_{m+1}^m from P_m^m
82
- for m in range(m_max):
83
- if m + 1 <= n_max:
84
- if normalized:
85
- P[m + 1, m] = x * np.sqrt(2 * m + 3) * P[m, m]
86
- else:
87
- P[m + 1, m] = x * (2 * m + 1) * P[m, m]
88
-
89
- # General recursion: P_n^m from P_{n-1}^m and P_{n-2}^m
90
- for m in range(m_max + 1):
91
- for n in range(m + 2, n_max + 1):
92
- if normalized:
93
- a_nm = np.sqrt((4 * n * n - 1) / (n * n - m * m))
94
- b_nm = np.sqrt(((n - 1) ** 2 - m * m) / (4 * (n - 1) ** 2 - 1))
95
- P[n, m] = a_nm * (x * P[n - 1, m] - b_nm * P[n - 2, m])
96
- else:
97
- P[n, m] = (
98
- (2 * n - 1) * x * P[n - 1, m] - (n + m - 1) * P[n - 2, m]
99
- ) / (n - m)
100
-
101
- return P
127
+ # Use cached computation
128
+ x_q = _quantize_x(x)
129
+ cached = _associated_legendre_cached(n_max, m_max, x_q, normalized)
130
+ return np.array(cached)
102
131
 
103
132
 
104
133
  def associated_legendre_derivative(
@@ -230,6 +259,14 @@ def spherical_harmonic_sum(
230
259
  if n_max is None:
231
260
  n_max = C.shape[0] - 1
232
261
 
262
+ _logger.debug(
263
+ "spherical_harmonic_sum: lat=%.4f, lon=%.4f, r=%.1f, n_max=%d",
264
+ lat,
265
+ lon,
266
+ r,
267
+ n_max,
268
+ )
269
+
233
270
  # Colatitude for Legendre polynomials
234
271
  colat = np.pi / 2 - lat
235
272
  cos_colat = np.cos(colat)
@@ -495,6 +532,28 @@ def associated_legendre_scaled(
495
532
  return P_scaled, scale_exp
496
533
 
497
534
 
535
+ def clear_legendre_cache() -> None:
536
+ """Clear cached Legendre polynomial results.
537
+
538
+ Call this function to clear the cached associated Legendre
539
+ polynomial arrays. Useful when memory is constrained or after
540
+ processing a batch with different colatitude values.
541
+ """
542
+ _associated_legendre_cached.cache_clear()
543
+ _logger.debug("Legendre polynomial cache cleared")
544
+
545
+
546
+ def get_legendre_cache_info():
547
+ """Get cache statistics for Legendre polynomials.
548
+
549
+ Returns
550
+ -------
551
+ CacheInfo
552
+ Named tuple with hits, misses, maxsize, currsize.
553
+ """
554
+ return _associated_legendre_cached.cache_info()
555
+
556
+
498
557
  __all__ = [
499
558
  "associated_legendre",
500
559
  "associated_legendre_derivative",
@@ -502,4 +561,6 @@ __all__ = [
502
561
  "gravity_acceleration",
503
562
  "legendre_scaling_factors",
504
563
  "associated_legendre_scaled",
564
+ "clear_legendre_cache",
565
+ "get_legendre_cache_info",
505
566
  ]
@@ -45,7 +45,10 @@ from pytcl.magnetism.wmm import (
45
45
  WMM2020,
46
46
  MagneticCoefficients,
47
47
  MagneticResult,
48
+ clear_magnetic_cache,
49
+ configure_magnetic_cache,
48
50
  create_wmm2020_coefficients,
51
+ get_magnetic_cache_info,
49
52
  magnetic_declination,
50
53
  magnetic_field_intensity,
51
54
  magnetic_field_spherical,
@@ -66,6 +69,10 @@ __all__ = [
66
69
  "magnetic_declination",
67
70
  "magnetic_inclination",
68
71
  "magnetic_field_intensity",
72
+ # Cache management
73
+ "get_magnetic_cache_info",
74
+ "clear_magnetic_cache",
75
+ "configure_magnetic_cache",
69
76
  # IGRF
70
77
  "IGRF13",
71
78
  "create_igrf13_coefficients",
pytcl/magnetism/wmm.py CHANGED
@@ -12,13 +12,30 @@ References
12
12
  .. [2] https://www.ngdc.noaa.gov/geomag/WMM/
13
13
  """
14
14
 
15
- from typing import NamedTuple, Tuple
15
+ from functools import lru_cache
16
+ from typing import NamedTuple, Optional, Tuple
16
17
 
17
18
  import numpy as np
18
19
  from numpy.typing import NDArray
19
20
 
20
21
  from pytcl.gravity.spherical_harmonics import associated_legendre
21
22
 
23
+ # =============================================================================
24
+ # Cache Configuration
25
+ # =============================================================================
26
+
27
+ # Default cache size (number of unique location/time combinations to cache)
28
+ _DEFAULT_CACHE_SIZE = 1024
29
+
30
+ # Precision for rounding inputs (radians for lat/lon, km for radius, years for time)
31
+ # These control how aggressively similar inputs are grouped
32
+ _CACHE_PRECISION = {
33
+ "lat": 6, # ~0.1 meter precision at Earth surface
34
+ "lon": 6,
35
+ "r": 3, # 1 meter precision
36
+ "year": 2, # ~4 day precision
37
+ }
38
+
22
39
 
23
40
  class MagneticResult(NamedTuple):
24
41
  """Result of magnetic field computation.
@@ -404,37 +421,90 @@ def create_wmm2020_coefficients() -> MagneticCoefficients:
404
421
  WMM2020 = create_wmm2020_coefficients()
405
422
 
406
423
 
407
- def magnetic_field_spherical(
424
+ # =============================================================================
425
+ # Cached Computation Core
426
+ # =============================================================================
427
+
428
+
429
+ def _quantize_inputs(
430
+ lat: float, lon: float, r: float, year: float
431
+ ) -> Tuple[float, float, float, float]:
432
+ """Round inputs to cache precision for consistent cache hits."""
433
+ return (
434
+ round(lat, _CACHE_PRECISION["lat"]),
435
+ round(lon, _CACHE_PRECISION["lon"]),
436
+ round(r, _CACHE_PRECISION["r"]),
437
+ round(year, _CACHE_PRECISION["year"]),
438
+ )
439
+
440
+
441
+ @lru_cache(maxsize=_DEFAULT_CACHE_SIZE)
442
+ def _magnetic_field_spherical_cached(
408
443
  lat: float,
409
444
  lon: float,
410
445
  r: float,
411
446
  year: float,
412
- coeffs: MagneticCoefficients = WMM2020,
447
+ n_max: int,
448
+ coeff_id: int,
413
449
  ) -> Tuple[float, float, float]:
414
450
  """
415
- Compute magnetic field in spherical coordinates.
451
+ Cached core computation of magnetic field in spherical coordinates.
452
+
453
+ This is the internal cached version. The coefficient arrays are identified
454
+ by their id() since NamedTuples with numpy arrays aren't hashable.
416
455
 
417
456
  Parameters
418
457
  ----------
419
458
  lat : float
420
- Geocentric latitude in radians.
459
+ Geocentric latitude in radians (quantized).
421
460
  lon : float
422
- Longitude in radians.
461
+ Longitude in radians (quantized).
423
462
  r : float
424
- Radial distance from Earth's center in km.
463
+ Radial distance in km (quantized).
425
464
  year : float
426
- Decimal year (e.g., 2023.5 for mid-2023).
427
- coeffs : MagneticCoefficients, optional
428
- Model coefficients. Default WMM2020.
465
+ Decimal year (quantized).
466
+ n_max : int
467
+ Maximum spherical harmonic degree.
468
+ coeff_id : int
469
+ Unique identifier for the coefficient set.
429
470
 
430
471
  Returns
431
472
  -------
432
- B_r : float
433
- Radial component (positive outward) in nT.
434
- B_theta : float
435
- Colatitude component (positive southward) in nT.
436
- B_phi : float
437
- Longitude component (positive eastward) in nT.
473
+ B_r, B_theta, B_phi : tuple of float
474
+ Magnetic field components in spherical coordinates (nT).
475
+ """
476
+ # Retrieve coefficients from registry
477
+ coeffs = _coefficient_registry.get(coeff_id)
478
+ if coeffs is None:
479
+ raise ValueError(f"Coefficient set {coeff_id} not found in registry")
480
+
481
+ return _compute_magnetic_field_spherical_impl(lat, lon, r, year, coeffs)
482
+
483
+
484
+ # Registry to hold coefficient sets by id
485
+ _coefficient_registry: dict = {}
486
+
487
+
488
+ def _register_coefficients(coeffs: "MagneticCoefficients") -> int:
489
+ """Register a coefficient set and return its unique ID."""
490
+ coeff_id = id(coeffs)
491
+ if coeff_id not in _coefficient_registry:
492
+ _coefficient_registry[coeff_id] = coeffs
493
+ return coeff_id
494
+
495
+
496
+ def _compute_magnetic_field_spherical_impl(
497
+ lat: float,
498
+ lon: float,
499
+ r: float,
500
+ year: float,
501
+ coeffs: "MagneticCoefficients",
502
+ ) -> Tuple[float, float, float]:
503
+ """
504
+ Core implementation of magnetic field computation.
505
+
506
+ This contains the actual spherical harmonic expansion logic,
507
+ separated for clarity and to support caching.
438
508
  """
439
509
  n_max = coeffs.n_max
440
510
  a = 6371.2 # Reference radius in km (WMM convention)
@@ -452,11 +522,9 @@ def magnetic_field_spherical(
452
522
  sin_theta = np.sin(theta)
453
523
 
454
524
  # Compute associated Legendre functions (Schmidt semi-normalized)
455
- # WMM uses Schmidt semi-normalization
456
525
  P = associated_legendre(n_max, n_max, cos_theta, normalized=True)
457
526
 
458
- # Compute dP/dtheta = -sin(theta) * dP/d(cos(theta))
459
- # For efficiency, use recurrence relation
527
+ # Compute dP/dtheta
460
528
  dP = np.zeros((n_max + 1, n_max + 1))
461
529
  if abs(sin_theta) > 1e-10:
462
530
  for n in range(1, n_max + 1):
@@ -464,7 +532,6 @@ def magnetic_field_spherical(
464
532
  if m == n:
465
533
  dP[n, m] = n * cos_theta / sin_theta * P[n, m]
466
534
  elif n > m:
467
- # Recurrence relation for derivative
468
535
  factor = np.sqrt((n - m) * (n + m + 1))
469
536
  if m + 1 <= n:
470
537
  dP[n, m] = (
@@ -489,13 +556,10 @@ def magnetic_field_spherical(
489
556
  cos_m_lon = np.cos(m * lon)
490
557
  sin_m_lon = np.sin(m * lon)
491
558
 
492
- # Gauss coefficients
493
559
  gnm = g[n, m]
494
560
  hnm = h[n, m]
495
561
 
496
- # Field contributions
497
562
  B_r += (n + 1) * r_power * P[n, m] * (gnm * cos_m_lon + hnm * sin_m_lon)
498
-
499
563
  B_theta += -r_power * dP[n, m] * (gnm * cos_m_lon + hnm * sin_m_lon)
500
564
 
501
565
  if abs(sin_theta) > 1e-10:
@@ -510,6 +574,175 @@ def magnetic_field_spherical(
510
574
  return B_r, B_theta, B_phi
511
575
 
512
576
 
577
+ # =============================================================================
578
+ # Cache Management
579
+ # =============================================================================
580
+
581
+
582
+ def get_magnetic_cache_info() -> dict:
583
+ """
584
+ Get information about the magnetic field computation cache.
585
+
586
+ Returns
587
+ -------
588
+ info : dict
589
+ Dictionary containing cache statistics:
590
+ - hits: Number of cache hits
591
+ - misses: Number of cache misses
592
+ - maxsize: Maximum cache size
593
+ - currsize: Current number of cached entries
594
+ - hit_rate: Ratio of hits to total calls (0-1)
595
+
596
+ Examples
597
+ --------
598
+ >>> from pytcl.magnetism import get_magnetic_cache_info
599
+ >>> info = get_magnetic_cache_info()
600
+ >>> print(f"Cache hit rate: {info['hit_rate']:.1%}")
601
+ """
602
+ cache_info = _magnetic_field_spherical_cached.cache_info()
603
+ total = cache_info.hits + cache_info.misses
604
+ hit_rate = cache_info.hits / total if total > 0 else 0.0
605
+
606
+ return {
607
+ "hits": cache_info.hits,
608
+ "misses": cache_info.misses,
609
+ "maxsize": cache_info.maxsize,
610
+ "currsize": cache_info.currsize,
611
+ "hit_rate": hit_rate,
612
+ }
613
+
614
+
615
+ def clear_magnetic_cache() -> None:
616
+ """
617
+ Clear the magnetic field computation cache.
618
+
619
+ This can be useful when memory is constrained or when switching
620
+ between different coefficient sets.
621
+
622
+ Examples
623
+ --------
624
+ >>> from pytcl.magnetism import clear_magnetic_cache
625
+ >>> clear_magnetic_cache() # Free cached computations
626
+ """
627
+ _magnetic_field_spherical_cached.cache_clear()
628
+ _coefficient_registry.clear()
629
+
630
+
631
+ def configure_magnetic_cache(
632
+ maxsize: Optional[int] = None,
633
+ precision: Optional[dict] = None,
634
+ ) -> None:
635
+ """
636
+ Configure the magnetic field computation cache.
637
+
638
+ Parameters
639
+ ----------
640
+ maxsize : int, optional
641
+ Maximum number of entries in the cache. If None, keeps current.
642
+ Set to 0 to disable caching.
643
+ precision : dict, optional
644
+ Dictionary with keys 'lat', 'lon', 'r', 'year' specifying
645
+ decimal places for rounding. Higher values = more precision
646
+ but fewer cache hits.
647
+
648
+ Notes
649
+ -----
650
+ Changing cache configuration clears the existing cache.
651
+
652
+ Examples
653
+ --------
654
+ >>> from pytcl.magnetism import configure_magnetic_cache
655
+ >>> # Increase cache size for batch processing
656
+ >>> configure_magnetic_cache(maxsize=4096)
657
+ >>> # Reduce precision for more cache hits
658
+ >>> configure_magnetic_cache(precision={'lat': 4, 'lon': 4, 'r': 2, 'year': 1})
659
+ """
660
+ global _magnetic_field_spherical_cached
661
+
662
+ if precision is not None:
663
+ for key in ["lat", "lon", "r", "year"]:
664
+ if key in precision:
665
+ _CACHE_PRECISION[key] = precision[key]
666
+
667
+ if maxsize is not None:
668
+ # Recreate the cached function with new maxsize
669
+ clear_magnetic_cache()
670
+
671
+ @lru_cache(maxsize=maxsize)
672
+ def new_cached(
673
+ lat: float,
674
+ lon: float,
675
+ r: float,
676
+ year: float,
677
+ n_max: int,
678
+ coeff_id: int,
679
+ ) -> Tuple[float, float, float]:
680
+ coeffs = _coefficient_registry.get(coeff_id)
681
+ if coeffs is None:
682
+ raise ValueError(f"Coefficient set {coeff_id} not found")
683
+ return _compute_magnetic_field_spherical_impl(lat, lon, r, year, coeffs)
684
+
685
+ _magnetic_field_spherical_cached = new_cached
686
+
687
+
688
+ def magnetic_field_spherical(
689
+ lat: float,
690
+ lon: float,
691
+ r: float,
692
+ year: float,
693
+ coeffs: MagneticCoefficients = WMM2020,
694
+ use_cache: bool = True,
695
+ ) -> Tuple[float, float, float]:
696
+ """
697
+ Compute magnetic field in spherical coordinates.
698
+
699
+ Parameters
700
+ ----------
701
+ lat : float
702
+ Geocentric latitude in radians.
703
+ lon : float
704
+ Longitude in radians.
705
+ r : float
706
+ Radial distance from Earth's center in km.
707
+ year : float
708
+ Decimal year (e.g., 2023.5 for mid-2023).
709
+ coeffs : MagneticCoefficients, optional
710
+ Model coefficients. Default WMM2020.
711
+ use_cache : bool, optional
712
+ Whether to use LRU caching for repeated queries. Default True.
713
+ Set to False for single-use queries or when memory is constrained.
714
+
715
+ Returns
716
+ -------
717
+ B_r : float
718
+ Radial component (positive outward) in nT.
719
+ B_theta : float
720
+ Colatitude component (positive southward) in nT.
721
+ B_phi : float
722
+ Longitude component (positive eastward) in nT.
723
+
724
+ Notes
725
+ -----
726
+ Results are cached by default using LRU caching. Inputs are quantized
727
+ to a configurable precision before caching to improve hit rates for
728
+ nearby queries. Use `get_magnetic_cache_info()` to check cache
729
+ statistics and `clear_magnetic_cache()` to free memory.
730
+ """
731
+ if use_cache:
732
+ # Quantize inputs for cache key
733
+ q_lat, q_lon, q_r, q_year = _quantize_inputs(lat, lon, r, year)
734
+
735
+ # Register coefficients and get ID
736
+ coeff_id = _register_coefficients(coeffs)
737
+
738
+ return _magnetic_field_spherical_cached(
739
+ q_lat, q_lon, q_r, q_year, coeffs.n_max, coeff_id
740
+ )
741
+ else:
742
+ # Direct computation without caching
743
+ return _compute_magnetic_field_spherical_impl(lat, lon, r, year, coeffs)
744
+
745
+
513
746
  def wmm(
514
747
  lat: float,
515
748
  lon: float,
@@ -706,4 +939,8 @@ __all__ = [
706
939
  "magnetic_declination",
707
940
  "magnetic_inclination",
708
941
  "magnetic_field_intensity",
942
+ # Cache management
943
+ "get_magnetic_cache_info",
944
+ "clear_magnetic_cache",
945
+ "configure_magnetic_cache",
709
946
  ]