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.
- {nrl_tracker-1.6.0.dist-info → nrl_tracker-1.7.1.dist-info}/METADATA +14 -10
- {nrl_tracker-1.6.0.dist-info → nrl_tracker-1.7.1.dist-info}/RECORD +75 -68
- pytcl/__init__.py +2 -2
- pytcl/assignment_algorithms/__init__.py +28 -0
- pytcl/assignment_algorithms/gating.py +10 -10
- pytcl/assignment_algorithms/jpda.py +40 -40
- pytcl/assignment_algorithms/nd_assignment.py +379 -0
- pytcl/assignment_algorithms/network_flow.py +371 -0
- pytcl/assignment_algorithms/three_dimensional/assignment.py +3 -3
- pytcl/astronomical/__init__.py +35 -0
- pytcl/astronomical/ephemerides.py +14 -11
- pytcl/astronomical/reference_frames.py +110 -4
- pytcl/astronomical/relativity.py +6 -5
- pytcl/astronomical/special_orbits.py +532 -0
- pytcl/atmosphere/__init__.py +11 -0
- pytcl/atmosphere/nrlmsise00.py +809 -0
- pytcl/clustering/dbscan.py +2 -2
- pytcl/clustering/gaussian_mixture.py +3 -3
- pytcl/clustering/hierarchical.py +15 -15
- pytcl/clustering/kmeans.py +4 -4
- pytcl/containers/base.py +3 -3
- pytcl/containers/cluster_set.py +12 -2
- pytcl/containers/covertree.py +5 -3
- pytcl/containers/rtree.py +1 -1
- pytcl/containers/vptree.py +4 -2
- pytcl/coordinate_systems/conversions/geodetic.py +272 -5
- pytcl/coordinate_systems/jacobians/jacobians.py +2 -2
- pytcl/coordinate_systems/projections/projections.py +2 -2
- pytcl/coordinate_systems/rotations/rotations.py +10 -6
- pytcl/core/validation.py +3 -3
- pytcl/dynamic_estimation/__init__.py +26 -0
- pytcl/dynamic_estimation/gaussian_sum_filter.py +434 -0
- pytcl/dynamic_estimation/imm.py +14 -14
- pytcl/dynamic_estimation/kalman/__init__.py +12 -0
- pytcl/dynamic_estimation/kalman/constrained.py +382 -0
- pytcl/dynamic_estimation/kalman/extended.py +8 -8
- pytcl/dynamic_estimation/kalman/h_infinity.py +2 -2
- pytcl/dynamic_estimation/kalman/square_root.py +8 -2
- pytcl/dynamic_estimation/kalman/sr_ukf.py +3 -3
- pytcl/dynamic_estimation/kalman/ud_filter.py +11 -5
- pytcl/dynamic_estimation/kalman/unscented.py +8 -6
- pytcl/dynamic_estimation/particle_filters/bootstrap.py +15 -15
- pytcl/dynamic_estimation/rbpf.py +589 -0
- pytcl/gravity/spherical_harmonics.py +3 -3
- pytcl/gravity/tides.py +6 -6
- pytcl/logging_config.py +3 -3
- pytcl/magnetism/emm.py +10 -3
- pytcl/magnetism/wmm.py +4 -4
- pytcl/mathematical_functions/combinatorics/combinatorics.py +5 -5
- pytcl/mathematical_functions/geometry/geometry.py +5 -5
- pytcl/mathematical_functions/numerical_integration/quadrature.py +6 -6
- pytcl/mathematical_functions/signal_processing/detection.py +24 -24
- pytcl/mathematical_functions/signal_processing/filters.py +14 -14
- pytcl/mathematical_functions/signal_processing/matched_filter.py +12 -12
- pytcl/mathematical_functions/special_functions/bessel.py +15 -3
- pytcl/mathematical_functions/special_functions/debye.py +5 -1
- pytcl/mathematical_functions/special_functions/error_functions.py +3 -1
- pytcl/mathematical_functions/special_functions/gamma_functions.py +4 -4
- pytcl/mathematical_functions/special_functions/hypergeometric.py +6 -4
- pytcl/mathematical_functions/transforms/fourier.py +8 -8
- pytcl/mathematical_functions/transforms/stft.py +12 -12
- pytcl/mathematical_functions/transforms/wavelets.py +9 -9
- pytcl/navigation/geodesy.py +3 -3
- pytcl/navigation/great_circle.py +5 -5
- pytcl/plotting/coordinates.py +7 -7
- pytcl/plotting/tracks.py +2 -2
- pytcl/static_estimation/maximum_likelihood.py +16 -14
- pytcl/static_estimation/robust.py +5 -5
- pytcl/terrain/loaders.py +5 -5
- pytcl/trackers/hypothesis.py +1 -1
- pytcl/trackers/mht.py +9 -9
- pytcl/trackers/multi_target.py +1 -1
- {nrl_tracker-1.6.0.dist-info → nrl_tracker-1.7.1.dist-info}/LICENSE +0 -0
- {nrl_tracker-1.6.0.dist-info → nrl_tracker-1.7.1.dist-info}/WHEEL +0 -0
- {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.
|
pytcl/astronomical/__init__.py
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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(
|
|
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(
|
|
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
|