nrl-tracker 1.6.0__py3-none-any.whl → 1.7.1__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 (75) hide show
  1. {nrl_tracker-1.6.0.dist-info → nrl_tracker-1.7.1.dist-info}/METADATA +14 -10
  2. {nrl_tracker-1.6.0.dist-info → nrl_tracker-1.7.1.dist-info}/RECORD +75 -68
  3. pytcl/__init__.py +2 -2
  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 +35 -0
  11. pytcl/astronomical/ephemerides.py +14 -11
  12. pytcl/astronomical/reference_frames.py +110 -4
  13. pytcl/astronomical/relativity.py +6 -5
  14. pytcl/astronomical/special_orbits.py +532 -0
  15. pytcl/atmosphere/__init__.py +11 -0
  16. pytcl/atmosphere/nrlmsise00.py +809 -0
  17. pytcl/clustering/dbscan.py +2 -2
  18. pytcl/clustering/gaussian_mixture.py +3 -3
  19. pytcl/clustering/hierarchical.py +15 -15
  20. pytcl/clustering/kmeans.py +4 -4
  21. pytcl/containers/base.py +3 -3
  22. pytcl/containers/cluster_set.py +12 -2
  23. pytcl/containers/covertree.py +5 -3
  24. pytcl/containers/rtree.py +1 -1
  25. pytcl/containers/vptree.py +4 -2
  26. pytcl/coordinate_systems/conversions/geodetic.py +272 -5
  27. pytcl/coordinate_systems/jacobians/jacobians.py +2 -2
  28. pytcl/coordinate_systems/projections/projections.py +2 -2
  29. pytcl/coordinate_systems/rotations/rotations.py +10 -6
  30. pytcl/core/validation.py +3 -3
  31. pytcl/dynamic_estimation/__init__.py +26 -0
  32. pytcl/dynamic_estimation/gaussian_sum_filter.py +434 -0
  33. pytcl/dynamic_estimation/imm.py +14 -14
  34. pytcl/dynamic_estimation/kalman/__init__.py +12 -0
  35. pytcl/dynamic_estimation/kalman/constrained.py +382 -0
  36. pytcl/dynamic_estimation/kalman/extended.py +8 -8
  37. pytcl/dynamic_estimation/kalman/h_infinity.py +2 -2
  38. pytcl/dynamic_estimation/kalman/square_root.py +8 -2
  39. pytcl/dynamic_estimation/kalman/sr_ukf.py +3 -3
  40. pytcl/dynamic_estimation/kalman/ud_filter.py +11 -5
  41. pytcl/dynamic_estimation/kalman/unscented.py +8 -6
  42. pytcl/dynamic_estimation/particle_filters/bootstrap.py +15 -15
  43. pytcl/dynamic_estimation/rbpf.py +589 -0
  44. pytcl/gravity/spherical_harmonics.py +3 -3
  45. pytcl/gravity/tides.py +6 -6
  46. pytcl/logging_config.py +3 -3
  47. pytcl/magnetism/emm.py +10 -3
  48. pytcl/magnetism/wmm.py +4 -4
  49. pytcl/mathematical_functions/combinatorics/combinatorics.py +5 -5
  50. pytcl/mathematical_functions/geometry/geometry.py +5 -5
  51. pytcl/mathematical_functions/numerical_integration/quadrature.py +6 -6
  52. pytcl/mathematical_functions/signal_processing/detection.py +24 -24
  53. pytcl/mathematical_functions/signal_processing/filters.py +14 -14
  54. pytcl/mathematical_functions/signal_processing/matched_filter.py +12 -12
  55. pytcl/mathematical_functions/special_functions/bessel.py +15 -3
  56. pytcl/mathematical_functions/special_functions/debye.py +5 -1
  57. pytcl/mathematical_functions/special_functions/error_functions.py +3 -1
  58. pytcl/mathematical_functions/special_functions/gamma_functions.py +4 -4
  59. pytcl/mathematical_functions/special_functions/hypergeometric.py +6 -4
  60. pytcl/mathematical_functions/transforms/fourier.py +8 -8
  61. pytcl/mathematical_functions/transforms/stft.py +12 -12
  62. pytcl/mathematical_functions/transforms/wavelets.py +9 -9
  63. pytcl/navigation/geodesy.py +3 -3
  64. pytcl/navigation/great_circle.py +5 -5
  65. pytcl/plotting/coordinates.py +7 -7
  66. pytcl/plotting/tracks.py +2 -2
  67. pytcl/static_estimation/maximum_likelihood.py +16 -14
  68. pytcl/static_estimation/robust.py +5 -5
  69. pytcl/terrain/loaders.py +5 -5
  70. pytcl/trackers/hypothesis.py +1 -1
  71. pytcl/trackers/mht.py +9 -9
  72. pytcl/trackers/multi_target.py +1 -1
  73. {nrl_tracker-1.6.0.dist-info → nrl_tracker-1.7.1.dist-info}/LICENSE +0 -0
  74. {nrl_tracker-1.6.0.dist-info → nrl_tracker-1.7.1.dist-info}/WHEEL +0 -0
  75. {nrl_tracker-1.6.0.dist-info → nrl_tracker-1.7.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,371 @@
1
+ """
2
+ Network flow solutions for assignment problems.
3
+
4
+ This module provides min-cost flow formulations for assignment problems,
5
+ offering an alternative to Hungarian algorithm and relaxation methods.
6
+
7
+ A min-cost flow approach:
8
+ 1. Models assignment as flow network
9
+ 2. Uses cost edges for penalties
10
+ 3. Enforces supply/demand constraints
11
+ 4. Finds minimum-cost flow solution
12
+ 5. Extracts assignment from flow
13
+
14
+ References
15
+ ----------
16
+ .. [1] Ahuja, R. K., Magnanti, T. L., & Orlin, J. B. (1993). Network Flows:
17
+ Theory, Algorithms, and Applications. Prentice-Hall.
18
+ .. [2] Costain, G., & Liang, H. (2012). An Auction Algorithm for the
19
+ Minimum Cost Flow Problem. CoRR, abs/1208.4859.
20
+ """
21
+
22
+ from enum import Enum
23
+ from typing import Any, NamedTuple, Tuple
24
+
25
+ import numpy as np
26
+ from numpy.typing import NDArray
27
+
28
+
29
+ class FlowStatus(Enum):
30
+ """Status of min-cost flow computation."""
31
+
32
+ OPTIMAL = 0
33
+ UNBOUNDED = 1
34
+ INFEASIBLE = 2
35
+ TIMEOUT = 3
36
+
37
+
38
+ class MinCostFlowResult(NamedTuple):
39
+ """Result of min-cost flow computation.
40
+
41
+ Attributes
42
+ ----------
43
+ flow : ndarray
44
+ Flow values on each edge, shape (n_edges,).
45
+ cost : float
46
+ Total flow cost.
47
+ status : FlowStatus
48
+ Optimization status.
49
+ iterations : int
50
+ Number of iterations used.
51
+ """
52
+
53
+ flow: NDArray[np.float64]
54
+ cost: float
55
+ status: FlowStatus
56
+ iterations: int
57
+
58
+
59
+ class FlowEdge(NamedTuple):
60
+ """Edge in a flow network.
61
+
62
+ Attributes
63
+ ----------
64
+ from_node : int
65
+ Source node index.
66
+ to_node : int
67
+ Destination node index.
68
+ capacity : float
69
+ Maximum flow on edge (default 1.0 for assignment).
70
+ cost : float
71
+ Cost per unit flow.
72
+ """
73
+
74
+ from_node: int
75
+ to_node: int
76
+ capacity: float
77
+ cost: float
78
+
79
+
80
+ def assignment_to_flow_network(
81
+ cost_matrix: NDArray[np.float64],
82
+ ) -> Tuple[list[FlowEdge], NDArray[np.floating], NDArray[Any]]:
83
+ """
84
+ Convert 2D assignment problem to min-cost flow network.
85
+
86
+ Network structure:
87
+ - Source node (0) supplies all workers
88
+ - Worker nodes (1 to m) demand 1 unit each
89
+ - Task nodes (m+1 to m+n) supply 1 unit each
90
+ - Sink node (m+n+1) collects all completed tasks
91
+
92
+ Parameters
93
+ ----------
94
+ cost_matrix : ndarray
95
+ Cost matrix of shape (m, n) where cost[i,j] is cost of
96
+ assigning worker i to task j.
97
+
98
+ Returns
99
+ -------
100
+ edges : list[FlowEdge]
101
+ List of edges in the flow network.
102
+ supplies : ndarray
103
+ Supply/demand at each node (shape n_nodes,).
104
+ Positive = supply, negative = demand.
105
+ node_names : ndarray
106
+ Names of nodes for reference.
107
+ """
108
+ m, n = cost_matrix.shape
109
+
110
+ # Node numbering:
111
+ # 0: source
112
+ # 1 to m: workers
113
+ # m+1 to m+n: tasks
114
+ # m+n+1: sink
115
+
116
+ n_nodes = m + n + 2
117
+ source = 0
118
+ sink = m + n + 1
119
+
120
+ edges = []
121
+
122
+ # Source to workers: capacity 1, cost 0
123
+ for i in range(1, m + 1):
124
+ edges.append(FlowEdge(from_node=source, to_node=i, capacity=1.0, cost=0.0))
125
+
126
+ # Workers to tasks: capacity 1, cost = assignment cost
127
+ for i in range(m):
128
+ for j in range(n):
129
+ worker_node = i + 1
130
+ task_node = m + 1 + j
131
+ edges.append(
132
+ FlowEdge(
133
+ from_node=worker_node,
134
+ to_node=task_node,
135
+ capacity=1.0,
136
+ cost=cost_matrix[i, j],
137
+ )
138
+ )
139
+
140
+ # Tasks to sink: capacity 1, cost 0
141
+ for j in range(1, n + 1):
142
+ task_node = m + j
143
+ edges.append(
144
+ FlowEdge(from_node=task_node, to_node=sink, capacity=1.0, cost=0.0)
145
+ )
146
+
147
+ # Supply/demand: source supplies m units, sink demands m units
148
+ supplies = np.zeros(n_nodes)
149
+ supplies[source] = float(m)
150
+ supplies[sink] = float(-m)
151
+
152
+ node_names = np.array(
153
+ ["source"]
154
+ + [f"worker_{i}" for i in range(m)]
155
+ + [f"task_{j}" for j in range(n)]
156
+ + ["sink"]
157
+ )
158
+
159
+ return edges, supplies, node_names
160
+
161
+
162
+ def min_cost_flow_successive_shortest_paths(
163
+ edges: list[FlowEdge],
164
+ supplies: NDArray[np.float64],
165
+ max_iterations: int = 1000,
166
+ ) -> MinCostFlowResult:
167
+ """
168
+ Solve min-cost flow using successive shortest paths.
169
+
170
+ Algorithm:
171
+ 1. While there is excess supply:
172
+ - Find shortest path from a supply node to a demand node
173
+ - Push maximum feasible flow along path
174
+ - Update supplies and residual capacities
175
+
176
+ Parameters
177
+ ----------
178
+ edges : list[FlowEdge]
179
+ List of edges with capacities and costs.
180
+ supplies : ndarray
181
+ Supply/demand at each node.
182
+ max_iterations : int, optional
183
+ Maximum iterations (default 1000).
184
+
185
+ Returns
186
+ -------
187
+ MinCostFlowResult
188
+ Solution with flow values, cost, status, and iterations.
189
+
190
+ Notes
191
+ -----
192
+ This is a simplified implementation using Bellman-Ford for shortest
193
+ paths. Production code would use more efficient implementations.
194
+ """
195
+ n_nodes = len(supplies)
196
+ n_edges = len(edges)
197
+
198
+ # Build adjacency lists for residual graph
199
+ graph: list[list[tuple[int, int, float]]] = [[] for _ in range(n_nodes)]
200
+ flow = np.zeros(n_edges)
201
+ residual_capacity = np.array([e.capacity for e in edges])
202
+
203
+ for edge_idx, edge in enumerate(edges):
204
+ graph[edge.from_node].append((edge.to_node, edge_idx, edge.cost))
205
+ # Add reverse edge with negative cost
206
+ graph[edge.to_node].append((edge.from_node, edge_idx, -edge.cost))
207
+
208
+ current_supplies = supplies.copy()
209
+ iteration = 0
210
+
211
+ while iteration < max_iterations:
212
+ # Find a node with excess supply
213
+ excess_node = None
214
+ for node in range(n_nodes):
215
+ if current_supplies[node] > 1e-10:
216
+ excess_node = node
217
+ break
218
+
219
+ if excess_node is None:
220
+ break
221
+
222
+ # Find a node with deficit
223
+ deficit_node = None
224
+ for node in range(n_nodes):
225
+ if current_supplies[node] < -1e-10:
226
+ deficit_node = node
227
+ break
228
+
229
+ if deficit_node is None:
230
+ break
231
+
232
+ # Find shortest path using Bellman-Ford relaxation
233
+ dist = np.full(n_nodes, np.inf)
234
+ dist[excess_node] = 0.0
235
+ parent = np.full(n_nodes, -1, dtype=int)
236
+ parent_edge = np.full(n_nodes, -1, dtype=int)
237
+
238
+ for _ in range(n_nodes - 1):
239
+ for u in range(n_nodes):
240
+ if dist[u] == np.inf:
241
+ continue
242
+ for v, edge_idx, cost in graph[u]:
243
+ if residual_capacity[edge_idx] > 1e-10:
244
+ new_dist = dist[u] + cost
245
+ if new_dist < dist[v]:
246
+ dist[v] = new_dist
247
+ parent[v] = u
248
+ parent_edge[v] = edge_idx
249
+
250
+ if dist[deficit_node] == np.inf:
251
+ # No path found
252
+ break
253
+
254
+ # Extract path and find bottleneck capacity
255
+ path_edges = []
256
+ node = deficit_node
257
+ while parent[node] != -1:
258
+ path_edges.append(parent_edge[node])
259
+ node = parent[node]
260
+
261
+ path_edges.reverse()
262
+
263
+ # Find minimum capacity along path
264
+ min_flow = min(residual_capacity[e] for e in path_edges)
265
+ min_flow = min(
266
+ min_flow, current_supplies[excess_node], -current_supplies[deficit_node]
267
+ )
268
+
269
+ # Push flow along path
270
+ total_cost = 0.0
271
+ for edge_idx in path_edges:
272
+ flow[edge_idx] += min_flow
273
+ residual_capacity[edge_idx] -= min_flow
274
+ total_cost += min_flow * edges[edge_idx].cost
275
+
276
+ current_supplies[excess_node] -= min_flow
277
+ current_supplies[deficit_node] += min_flow
278
+
279
+ iteration += 1
280
+
281
+ # Compute total cost
282
+ total_cost = float(np.sum(flow[i] * edges[i].cost for i in range(n_edges)))
283
+
284
+ # Determine status
285
+ if np.allclose(current_supplies, 0):
286
+ status = FlowStatus.OPTIMAL
287
+ elif iteration >= max_iterations:
288
+ status = FlowStatus.TIMEOUT
289
+ else:
290
+ status = FlowStatus.INFEASIBLE
291
+
292
+ return MinCostFlowResult(
293
+ flow=flow,
294
+ cost=total_cost,
295
+ status=status,
296
+ iterations=iteration,
297
+ )
298
+
299
+
300
+ def assignment_from_flow_solution(
301
+ flow: NDArray[np.float64],
302
+ edges: list[FlowEdge],
303
+ cost_matrix_shape: Tuple[int, int],
304
+ ) -> Tuple[NDArray[np.intp], float]:
305
+ """
306
+ Extract assignment from flow network solution.
307
+
308
+ Parameters
309
+ ----------
310
+ flow : ndarray
311
+ Flow values on each edge.
312
+ edges : list[FlowEdge]
313
+ List of edges used in network.
314
+ cost_matrix_shape : tuple
315
+ Shape of original cost matrix (m, n).
316
+
317
+ Returns
318
+ -------
319
+ assignment : ndarray
320
+ Assignment array of shape (n_assignments, 2) with [worker, task].
321
+ cost : float
322
+ Total assignment cost.
323
+ """
324
+ m, n = cost_matrix_shape
325
+ assignment = []
326
+
327
+ for edge_idx, edge in enumerate(edges):
328
+ # Worker-to-task edges: from_node in [1, m], to_node in [m+1, m+n]
329
+ if 1 <= edge.from_node <= m and m + 1 <= edge.to_node <= m + n:
330
+ if flow[edge_idx] > 0.5: # Flow > 0 (allowing for numerical tolerance)
331
+ worker_idx = edge.from_node - 1
332
+ task_idx = edge.to_node - m - 1
333
+ assignment.append([worker_idx, task_idx])
334
+
335
+ assignment = np.array(assignment, dtype=np.intp)
336
+ cost = 0.0
337
+ if len(assignment) > 0:
338
+ cost = float(
339
+ np.sum(
340
+ flow[edge_idx] * edges[edge_idx].cost for edge_idx in range(len(edges))
341
+ )
342
+ )
343
+
344
+ return assignment, cost
345
+
346
+
347
+ def min_cost_assignment_via_flow(
348
+ cost_matrix: NDArray[np.float64],
349
+ ) -> Tuple[NDArray[np.intp], float]:
350
+ """
351
+ Solve 2D assignment problem via min-cost flow network.
352
+
353
+ Parameters
354
+ ----------
355
+ cost_matrix : ndarray
356
+ Cost matrix of shape (m, n).
357
+
358
+ Returns
359
+ -------
360
+ assignment : ndarray
361
+ Assignment array of shape (n_assignments, 2).
362
+ total_cost : float
363
+ Total assignment cost.
364
+ """
365
+ edges, supplies, _ = assignment_to_flow_network(cost_matrix)
366
+ result = min_cost_flow_successive_shortest_paths(edges, supplies)
367
+ assignment, cost = assignment_from_flow_solution(
368
+ result.flow, edges, cost_matrix.shape
369
+ )
370
+
371
+ return assignment, cost
@@ -11,7 +11,7 @@ cost subject to the constraint that each index appears in at most one
11
11
  selected tuple.
12
12
  """
13
13
 
14
- from typing import List, NamedTuple, Optional, Tuple
14
+ from typing import Any, List, NamedTuple, Optional, Tuple
15
15
 
16
16
  import numpy as np
17
17
  from numpy.typing import ArrayLike, NDArray
@@ -511,7 +511,7 @@ def assign3d_auction(
511
511
  assign_i: List[Optional[Tuple[int, int]]] = [None] * n1
512
512
 
513
513
  # Reverse: which i is assigned to (j, k)
514
- reverse: dict = {}
514
+ reverse: dict[tuple[int, int], int] = {}
515
515
 
516
516
  converged = False
517
517
 
@@ -585,7 +585,7 @@ def assign3d(
585
585
  cost_tensor: ArrayLike,
586
586
  method: str = "lagrangian",
587
587
  maximize: bool = False,
588
- **kwargs,
588
+ **kwargs: Any,
589
589
  ) -> Assignment3DResult:
590
590
  """
591
591
  Solve 3D assignment problem.
@@ -136,6 +136,24 @@ from pytcl.astronomical.sgp4 import (
136
136
  sgp4_propagate,
137
137
  sgp4_propagate_batch,
138
138
  )
139
+ from pytcl.astronomical.special_orbits import (
140
+ OrbitType,
141
+ classify_orbit,
142
+ eccentricity_vector,
143
+ escape_velocity_at_radius,
144
+ hyperbolic_anomaly_to_true_anomaly,
145
+ hyperbolic_asymptote_angle,
146
+ hyperbolic_deflection_angle,
147
+ hyperbolic_excess_velocity,
148
+ mean_to_parabolic_anomaly,
149
+ mean_to_true_anomaly_parabolic,
150
+ parabolic_anomaly_to_true_anomaly,
151
+ radius_parabolic,
152
+ semi_major_axis_from_energy,
153
+ true_anomaly_to_hyperbolic_anomaly,
154
+ true_anomaly_to_parabolic_anomaly,
155
+ velocity_parabolic,
156
+ )
139
157
  from pytcl.astronomical.time_systems import (
140
158
  JD_GPS_EPOCH, # Julian dates; Time scales; Unix time; GPS week; Sidereal time; Leap seconds; Constants
141
159
  )
@@ -337,4 +355,21 @@ __all__ = [
337
355
  "geodetic_precession",
338
356
  "lense_thirring_precession",
339
357
  "relativistic_range_correction",
358
+ # Special orbits - Parabolic and hyperbolic
359
+ "OrbitType",
360
+ "classify_orbit",
361
+ "mean_to_parabolic_anomaly",
362
+ "parabolic_anomaly_to_true_anomaly",
363
+ "true_anomaly_to_parabolic_anomaly",
364
+ "mean_to_true_anomaly_parabolic",
365
+ "radius_parabolic",
366
+ "velocity_parabolic",
367
+ "hyperbolic_anomaly_to_true_anomaly",
368
+ "true_anomaly_to_hyperbolic_anomaly",
369
+ "escape_velocity_at_radius",
370
+ "hyperbolic_excess_velocity",
371
+ "semi_major_axis_from_energy",
372
+ "hyperbolic_asymptote_angle",
373
+ "hyperbolic_deflection_angle",
374
+ "eccentricity_vector",
340
375
  ]
@@ -49,9 +49,10 @@ References
49
49
 
50
50
  """
51
51
 
52
- from typing import Literal, Optional, Tuple
52
+ from typing import Any, Literal, Optional, Tuple
53
53
 
54
54
  import numpy as np
55
+ from numpy.typing import NDArray
55
56
 
56
57
  # Constants for unit conversion
57
58
  AU_PER_KM = 1.0 / 149597870.7 # 1 AU in km
@@ -152,10 +153,10 @@ class DEEphemeris:
152
153
  self.version = version
153
154
  self._jplephem = jplephem
154
155
  self._kernel: Optional[object] = None
155
- self._cache: dict = {}
156
+ self._cache: dict[str, Any] = {}
156
157
 
157
158
  @property
158
- def kernel(self):
159
+ def kernel(self) -> Optional[object]:
159
160
  """Lazy-load ephemeris kernel on first access.
160
161
 
161
162
  Note: This requires jplephem to be installed and the kernel file
@@ -204,7 +205,7 @@ class DEEphemeris:
204
205
 
205
206
  def sun_position(
206
207
  self, jd: float, frame: Literal["icrf", "ecliptic"] = "icrf"
207
- ) -> Tuple[np.ndarray, np.ndarray]:
208
+ ) -> Tuple[NDArray[np.floating], NDArray[np.floating]]:
208
209
  """Compute Sun position and velocity.
209
210
 
210
211
  Parameters
@@ -253,7 +254,7 @@ class DEEphemeris:
253
254
 
254
255
  def moon_position(
255
256
  self, jd: float, frame: Literal["icrf", "ecliptic", "earth_centered"] = "icrf"
256
- ) -> Tuple[np.ndarray, np.ndarray]:
257
+ ) -> Tuple[NDArray[np.floating], NDArray[np.floating]]:
257
258
  """Compute Moon position and velocity.
258
259
 
259
260
  Parameters
@@ -323,7 +324,7 @@ class DEEphemeris:
323
324
  ],
324
325
  jd: float,
325
326
  frame: Literal["icrf", "ecliptic"] = "icrf",
326
- ) -> Tuple[np.ndarray, np.ndarray]:
327
+ ) -> Tuple[np.ndarray[Any, Any], np.ndarray[Any, Any]]:
327
328
  """Compute planet position and velocity.
328
329
 
329
330
  Parameters
@@ -379,7 +380,7 @@ class DEEphemeris:
379
380
 
380
381
  def barycenter_position(
381
382
  self, body: str, jd: float
382
- ) -> Tuple[np.ndarray, np.ndarray]:
383
+ ) -> Tuple[NDArray[np.floating], NDArray[np.floating]]:
383
384
  """Compute position of any body relative to Solar System Barycenter.
384
385
 
385
386
  Parameters
@@ -424,7 +425,7 @@ def _get_default_ephemeris() -> DEEphemeris:
424
425
 
425
426
  def sun_position(
426
427
  jd: float, frame: Literal["icrf", "ecliptic"] = "icrf"
427
- ) -> Tuple[np.ndarray, np.ndarray]:
428
+ ) -> Tuple[NDArray[np.floating], NDArray[np.floating]]:
428
429
  """Convenience function: Compute Sun position and velocity.
429
430
 
430
431
  Parameters
@@ -451,7 +452,7 @@ def sun_position(
451
452
 
452
453
  def moon_position(
453
454
  jd: float, frame: Literal["icrf", "ecliptic", "earth_centered"] = "icrf"
454
- ) -> Tuple[np.ndarray, np.ndarray]:
455
+ ) -> Tuple[NDArray[np.floating], NDArray[np.floating]]:
455
456
  """Convenience function: Compute Moon position and velocity.
456
457
 
457
458
  Parameters
@@ -482,7 +483,7 @@ def planet_position(
482
483
  ],
483
484
  jd: float,
484
485
  frame: Literal["icrf", "ecliptic"] = "icrf",
485
- ) -> Tuple[np.ndarray, np.ndarray]:
486
+ ) -> Tuple[np.ndarray[Any, Any], np.ndarray[Any, Any]]:
486
487
  """Convenience function: Compute planet position and velocity.
487
488
 
488
489
  Parameters
@@ -509,7 +510,9 @@ def planet_position(
509
510
  return _get_default_ephemeris().planet_position(planet, jd, frame=frame)
510
511
 
511
512
 
512
- def barycenter_position(body: str, jd: float) -> Tuple[np.ndarray, np.ndarray]:
513
+ def barycenter_position(
514
+ body: str, jd: float
515
+ ) -> Tuple[NDArray[np.floating], NDArray[np.floating]]:
513
516
  """Convenience function: Position relative to Solar System Barycenter.
514
517
 
515
518
  Parameters
@@ -23,7 +23,7 @@ References
23
23
 
24
24
  import logging
25
25
  from functools import lru_cache
26
- from typing import Optional, Tuple
26
+ from typing import Any, Optional, Tuple
27
27
 
28
28
  import numpy as np
29
29
  from numpy.typing import NDArray
@@ -97,7 +97,9 @@ def precession_angles_iau76(T: float) -> Tuple[float, float, float]:
97
97
 
98
98
 
99
99
  @lru_cache(maxsize=_CACHE_MAXSIZE)
100
- def _precession_matrix_cached(jd_quantized: float) -> tuple:
100
+ def _precession_matrix_cached(
101
+ jd_quantized: float,
102
+ ) -> tuple[tuple[np.ndarray[Any, Any], ...], ...]:
101
103
  """Cached precession matrix computation (internal).
102
104
 
103
105
  Returns tuple of tuples for hashability.
@@ -234,7 +236,9 @@ def mean_obliquity_iau80(jd: float) -> float:
234
236
 
235
237
 
236
238
  @lru_cache(maxsize=_CACHE_MAXSIZE)
237
- def _nutation_matrix_cached(jd_quantized: float) -> tuple:
239
+ def _nutation_matrix_cached(
240
+ jd_quantized: float,
241
+ ) -> tuple[tuple[np.ndarray[Any, Any], ...], ...]:
238
242
  """Cached nutation matrix computation (internal).
239
243
 
240
244
  Returns tuple of tuples for hashability.
@@ -1302,6 +1306,106 @@ def itrf_to_tod(
1302
1306
  return R.T @ (W.T @ r_itrf)
1303
1307
 
1304
1308
 
1309
+ def gcrf_to_pef(
1310
+ r_gcrf: NDArray[np.floating],
1311
+ jd_ut1: float,
1312
+ jd_tt: float,
1313
+ ) -> NDArray[np.floating]:
1314
+ """
1315
+ Transform position from GCRF (inertial) to PEF (Earth-fixed, rotation only).
1316
+
1317
+ PEF (Pseudo-Earth Fixed) is an intermediate reference frame between
1318
+ GCRF and ITRF. It includes precession, nutation, and Earth rotation,
1319
+ but excludes polar motion.
1320
+
1321
+ Parameters
1322
+ ----------
1323
+ r_gcrf : ndarray
1324
+ Position in GCRF (km), shape (3,).
1325
+ jd_ut1 : float
1326
+ Julian date in UT1.
1327
+ jd_tt : float
1328
+ Julian date in TT.
1329
+
1330
+ Returns
1331
+ -------
1332
+ r_pef : ndarray
1333
+ Position in PEF (km), shape (3,).
1334
+
1335
+ Notes
1336
+ -----
1337
+ The transformation chain is: GCRF -> MOD -> TOD -> PEF
1338
+ - Precession: GCRF -> MOD
1339
+ - Nutation: MOD -> TOD
1340
+ - Sidereal rotation: TOD -> PEF
1341
+
1342
+ See Also
1343
+ --------
1344
+ pef_to_gcrf : Inverse transformation
1345
+ gcrf_to_itrf : Includes polar motion
1346
+
1347
+ References
1348
+ ----------
1349
+ .. [1] Vallado et al., "Fundamentals of Astrodynamics and Applications", 4th ed.
1350
+ """
1351
+ # Precession: GCRF -> MOD
1352
+ P = precession_matrix_iau76(jd_tt)
1353
+ r_mod = P @ r_gcrf
1354
+
1355
+ # Nutation: MOD -> TOD
1356
+ N = nutation_matrix(jd_tt)
1357
+ r_tod = N @ r_mod
1358
+
1359
+ # Sidereal rotation: TOD -> PEF
1360
+ gast = gast_iau82(jd_ut1, jd_tt)
1361
+ R = sidereal_rotation_matrix(gast)
1362
+ r_pef = R @ r_tod
1363
+
1364
+ return r_pef
1365
+
1366
+
1367
+ def pef_to_gcrf(
1368
+ r_pef: NDArray[np.floating],
1369
+ jd_ut1: float,
1370
+ jd_tt: float,
1371
+ ) -> NDArray[np.floating]:
1372
+ """
1373
+ Transform position from PEF (Earth-fixed, rotation only) to GCRF (inertial).
1374
+
1375
+ Inverse of gcrf_to_pef.
1376
+
1377
+ Parameters
1378
+ ----------
1379
+ r_pef : ndarray
1380
+ Position in PEF (km), shape (3,).
1381
+ jd_ut1 : float
1382
+ Julian date in UT1.
1383
+ jd_tt : float
1384
+ Julian date in TT.
1385
+
1386
+ Returns
1387
+ -------
1388
+ r_gcrf : ndarray
1389
+ Position in GCRF (km), shape (3,).
1390
+
1391
+ See Also
1392
+ --------
1393
+ gcrf_to_pef : Forward transformation
1394
+ """
1395
+ # Compute rotation matrices
1396
+ P = precession_matrix_iau76(jd_tt)
1397
+ N = nutation_matrix(jd_tt)
1398
+ gast = gast_iau82(jd_ut1, jd_tt)
1399
+ R = sidereal_rotation_matrix(gast)
1400
+
1401
+ # Inverse transformation: GCRF = P.T * N.T * R.T * PEF
1402
+ r_tod = R.T @ r_pef
1403
+ r_mod = N.T @ r_tod
1404
+ r_gcrf = P.T @ r_mod
1405
+
1406
+ return r_gcrf
1407
+
1408
+
1305
1409
  def clear_transformation_cache() -> None:
1306
1410
  """Clear cached transformation matrices.
1307
1411
 
@@ -1314,7 +1418,7 @@ def clear_transformation_cache() -> None:
1314
1418
  _logger.debug("Transformation matrix cache cleared")
1315
1419
 
1316
1420
 
1317
- def get_cache_info() -> dict:
1421
+ def get_cache_info() -> dict[str, Any]:
1318
1422
  """Get cache statistics for transformation matrices.
1319
1423
 
1320
1424
  Returns
@@ -1351,6 +1455,8 @@ __all__ = [
1351
1455
  # Full transformations
1352
1456
  "gcrf_to_itrf",
1353
1457
  "itrf_to_gcrf",
1458
+ "gcrf_to_pef",
1459
+ "pef_to_gcrf",
1354
1460
  "eci_to_ecef",
1355
1461
  "ecef_to_eci",
1356
1462
  # Ecliptic/equatorial