gri-plot 0.2.0.post1__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.
- gri_plot/__init__.py +94 -0
- gri_plot/figure3d.py +476 -0
- gri_plot/frames.py +181 -0
- gri_plot/observables/__init__.py +21 -0
- gri_plot/observables/aoa.py +123 -0
- gri_plot/observables/fdoa.py +254 -0
- gri_plot/observables/los.py +154 -0
- gri_plot/observables/range_sphere.py +74 -0
- gri_plot/observables/tdoa.py +201 -0
- gri_plot/observables/terrain.py +293 -0
- gri_plot/plot_ellipse.py +183 -0
- gri_plot/py.typed +0 -0
- gri_plot/scatter.py +76 -0
- gri_plot/scatter_map.py +93 -0
- gri_plot/shapes/__init__.py +19 -0
- gri_plot/shapes/cone.py +227 -0
- gri_plot/shapes/cylinder.py +219 -0
- gri_plot/shapes/ellipse.py +117 -0
- gri_plot/shapes/ellipsoid.py +227 -0
- gri_plot/shapes/meshgen.py +245 -0
- gri_plot/shapes/sphere.py +152 -0
- gri_plot/surfaces/__init__.py +171 -0
- gri_plot/surfaces/gradients/__init__.py +39 -0
- gri_plot/surfaces/gradients/axis.py +74 -0
- gri_plot/surfaces/gradients/line.py +86 -0
- gri_plot/surfaces/gradients/plane.py +53 -0
- gri_plot/surfaces/gradients/point.py +40 -0
- gri_plot/surfaces/intersection.py +217 -0
- gri_plot/surfaces/mesh.py +603 -0
- gri_plot-0.2.0.post1.dist-info/METADATA +149 -0
- gri_plot-0.2.0.post1.dist-info/RECORD +33 -0
- gri_plot-0.2.0.post1.dist-info/WHEEL +4 -0
- gri_plot-0.2.0.post1.dist-info/licenses/LICENSE +21 -0
gri_plot/frames.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Frame definitions and coordinate transformations.
|
|
2
|
+
|
|
3
|
+
This module provides coordinate frame handling for 3D visualization using
|
|
4
|
+
gri-utils for coordinate conversions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from gri_utils.conversion import get_enu, lla_to_xyz, translate_enu, xyz_to_lla
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from numpy.typing import NDArray
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Frame(Enum):
|
|
18
|
+
"""Coordinate frame for 3D visualization.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
XYZ: Earth-Centered Earth-Fixed (ECEF) Cartesian coordinates in meters.
|
|
22
|
+
ENU: East-North-Up local tangent plane coordinates in meters.
|
|
23
|
+
LLA: Latitude, Longitude, Altitude (degrees, degrees, meters).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
XYZ = "xyz"
|
|
27
|
+
ENU = "enu"
|
|
28
|
+
LLA = "lla"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Bounds(NamedTuple):
|
|
32
|
+
"""Axis-aligned bounding box in a specific frame.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
min_corner: Minimum corner of the bounding box, shape (3,).
|
|
36
|
+
max_corner: Maximum corner of the bounding box, shape (3,).
|
|
37
|
+
frame: Coordinate frame of the bounds.
|
|
38
|
+
origin_xyz: Origin in XYZ coordinates, required for ENU frame.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
min_corner: NDArray[np.floating]
|
|
42
|
+
max_corner: NDArray[np.floating]
|
|
43
|
+
frame: Frame = Frame.XYZ
|
|
44
|
+
origin_xyz: NDArray[np.floating] | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FrameTransformer:
|
|
48
|
+
"""Transforms coordinates between frames.
|
|
49
|
+
|
|
50
|
+
All internal computation is done in XYZ (ECEF). This class handles
|
|
51
|
+
conversion to/from the display frame.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
display_frame: Target frame for visualization.
|
|
55
|
+
origin_xyz: Origin for ENU frame in XYZ coordinates.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
display_frame: Frame = Frame.XYZ,
|
|
61
|
+
origin_xyz: NDArray[np.floating] | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Initialize the frame transformer.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
display_frame: Target frame for visualization.
|
|
67
|
+
origin_xyz: Origin in XYZ, required if display_frame is ENU.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
ValueError: If display_frame is ENU but origin_xyz is not provided.
|
|
71
|
+
"""
|
|
72
|
+
if display_frame == Frame.ENU and origin_xyz is None:
|
|
73
|
+
raise ValueError("origin_xyz is required for ENU display frame")
|
|
74
|
+
|
|
75
|
+
self.display_frame = display_frame
|
|
76
|
+
self.origin_xyz = np.asarray(origin_xyz) if origin_xyz is not None else None
|
|
77
|
+
|
|
78
|
+
def to_display(self, xyz: NDArray[np.floating]) -> NDArray[np.floating]:
|
|
79
|
+
"""Transform XYZ coordinates to display frame.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
xyz: Coordinates in XYZ (ECEF), shape (..., 3).
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Coordinates in display frame, shape (..., 3).
|
|
86
|
+
"""
|
|
87
|
+
xyz = np.asarray(xyz)
|
|
88
|
+
|
|
89
|
+
if self.display_frame == Frame.XYZ:
|
|
90
|
+
return xyz
|
|
91
|
+
if self.display_frame == Frame.ENU:
|
|
92
|
+
if self.origin_xyz is None: # pragma: no cover
|
|
93
|
+
raise RuntimeError("origin_xyz required for ENU frame")
|
|
94
|
+
return get_enu(self.origin_xyz, xyz)
|
|
95
|
+
if self.display_frame == Frame.LLA:
|
|
96
|
+
return xyz_to_lla(xyz)
|
|
97
|
+
raise ValueError(f"Unknown display frame: {self.display_frame}")
|
|
98
|
+
|
|
99
|
+
def from_display(self, coords: NDArray[np.floating]) -> NDArray[np.floating]:
|
|
100
|
+
"""Transform display frame coordinates to XYZ.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
coords: Coordinates in display frame, shape (..., 3).
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Coordinates in XYZ (ECEF), shape (..., 3).
|
|
107
|
+
"""
|
|
108
|
+
coords = np.asarray(coords)
|
|
109
|
+
|
|
110
|
+
if self.display_frame == Frame.XYZ:
|
|
111
|
+
return coords
|
|
112
|
+
if self.display_frame == Frame.ENU:
|
|
113
|
+
if self.origin_xyz is None: # pragma: no cover
|
|
114
|
+
raise RuntimeError("origin_xyz required for ENU frame")
|
|
115
|
+
return translate_enu(self.origin_xyz, coords)
|
|
116
|
+
if self.display_frame == Frame.LLA:
|
|
117
|
+
return lla_to_xyz(coords)
|
|
118
|
+
raise ValueError(f"Unknown display frame: {self.display_frame}")
|
|
119
|
+
|
|
120
|
+
def transform_bounds(self, bounds_xyz: tuple[NDArray, NDArray]) -> Bounds:
|
|
121
|
+
"""Transform XYZ bounds to display frame.
|
|
122
|
+
|
|
123
|
+
For non-linear transforms (LLA), this samples the bounding box to
|
|
124
|
+
find the actual extent in the display frame.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
bounds_xyz: Tuple of (min_corner, max_corner) in XYZ.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Bounds in the display frame.
|
|
131
|
+
"""
|
|
132
|
+
min_xyz, max_xyz = np.asarray(bounds_xyz[0]), np.asarray(bounds_xyz[1])
|
|
133
|
+
|
|
134
|
+
if self.display_frame == Frame.XYZ:
|
|
135
|
+
return Bounds(min_xyz, max_xyz, Frame.XYZ, None)
|
|
136
|
+
|
|
137
|
+
if self.display_frame == Frame.ENU:
|
|
138
|
+
min_enu = self.to_display(min_xyz)
|
|
139
|
+
max_enu = self.to_display(max_xyz)
|
|
140
|
+
# Ensure min < max
|
|
141
|
+
actual_min = np.minimum(min_enu, max_enu)
|
|
142
|
+
actual_max = np.maximum(min_enu, max_enu)
|
|
143
|
+
return Bounds(actual_min, actual_max, Frame.ENU, self.origin_xyz)
|
|
144
|
+
|
|
145
|
+
if self.display_frame == Frame.LLA:
|
|
146
|
+
# Sample corners of the bounding box
|
|
147
|
+
corners = np.array(
|
|
148
|
+
[
|
|
149
|
+
[min_xyz[0], min_xyz[1], min_xyz[2]],
|
|
150
|
+
[min_xyz[0], min_xyz[1], max_xyz[2]],
|
|
151
|
+
[min_xyz[0], max_xyz[1], min_xyz[2]],
|
|
152
|
+
[min_xyz[0], max_xyz[1], max_xyz[2]],
|
|
153
|
+
[max_xyz[0], min_xyz[1], min_xyz[2]],
|
|
154
|
+
[max_xyz[0], min_xyz[1], max_xyz[2]],
|
|
155
|
+
[max_xyz[0], max_xyz[1], min_xyz[2]],
|
|
156
|
+
[max_xyz[0], max_xyz[1], max_xyz[2]],
|
|
157
|
+
],
|
|
158
|
+
)
|
|
159
|
+
lla_corners = self.to_display(corners)
|
|
160
|
+
return Bounds(
|
|
161
|
+
lla_corners.min(axis=0),
|
|
162
|
+
lla_corners.max(axis=0),
|
|
163
|
+
Frame.LLA,
|
|
164
|
+
None,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
raise ValueError(f"Unknown display frame: {self.display_frame}")
|
|
168
|
+
|
|
169
|
+
def get_axis_labels(self) -> tuple[str, str, str]:
|
|
170
|
+
"""Get axis labels for the display frame.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Tuple of (x_label, y_label, z_label) for the display frame.
|
|
174
|
+
"""
|
|
175
|
+
if self.display_frame == Frame.XYZ:
|
|
176
|
+
return ("X (m)", "Y (m)", "Z (m)")
|
|
177
|
+
if self.display_frame == Frame.ENU:
|
|
178
|
+
return ("East (m)", "North (m)", "Up (m)")
|
|
179
|
+
if self.display_frame == Frame.LLA:
|
|
180
|
+
return ("Latitude (deg)", "Longitude (deg)", "Altitude (m)")
|
|
181
|
+
raise ValueError(f"Unknown display frame: {self.display_frame}")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Observable surface classes for geolocation visualization.
|
|
2
|
+
|
|
3
|
+
This module provides surfaces for visualizing TDOA, FDOA, AOA, and other
|
|
4
|
+
geolocation observables.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .aoa import AoaSurface
|
|
8
|
+
from .fdoa import FdoaSurface
|
|
9
|
+
from .los import LosSurface
|
|
10
|
+
from .range_sphere import RangeSphere
|
|
11
|
+
from .tdoa import TdoaSurface
|
|
12
|
+
from .terrain import TerrainSurface
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AoaSurface",
|
|
16
|
+
"FdoaSurface",
|
|
17
|
+
"LosSurface",
|
|
18
|
+
"RangeSphere",
|
|
19
|
+
"TdoaSurface",
|
|
20
|
+
"TerrainSurface",
|
|
21
|
+
]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""AOA (Angle of Arrival) cone surface.
|
|
2
|
+
|
|
3
|
+
This module provides the AoaSurface class for visualizing AOA measurements.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from gri_plot.shapes.cone import Cone
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from numpy.typing import NDArray
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AoaSurface(Cone):
|
|
17
|
+
"""AOA cone surface representing an angle of arrival measurement.
|
|
18
|
+
|
|
19
|
+
The cone extends from the collector in the measured direction. If an
|
|
20
|
+
error radius is specified, a cone with that half-angle is rendered;
|
|
21
|
+
otherwise, a thin cone (line-like) is shown.
|
|
22
|
+
|
|
23
|
+
For 1D AOA (azimuth only), use a half-plane or cylinder instead.
|
|
24
|
+
|
|
25
|
+
This class inherits from Cone and always has is_volume=True since
|
|
26
|
+
an AOA measurement defines a volume where the target could be.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
collector_xyz: Collector position in XYZ coordinates.
|
|
30
|
+
direction_xyz: Measured direction (unit vector).
|
|
31
|
+
error_rad: Angular error (half-angle) in radians.
|
|
32
|
+
max_range: Maximum range to extend the cone.
|
|
33
|
+
label: Optional label for legends.
|
|
34
|
+
is_volume: Always True for AOA surfaces.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
collector_xyz: NDArray[np.floating],
|
|
40
|
+
direction_xyz: NDArray[np.floating],
|
|
41
|
+
error_rad: float | None = None,
|
|
42
|
+
max_range: float = 1e6,
|
|
43
|
+
label: str | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Initialize the AOA surface.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
collector_xyz: Collector position, shape (3,).
|
|
49
|
+
direction_xyz: Measured direction (will be normalized), shape (3,).
|
|
50
|
+
error_rad: Angular error (half-angle) in radians. If None or very
|
|
51
|
+
small, renders a thin cone.
|
|
52
|
+
max_range: Maximum range to extend the cone in meters.
|
|
53
|
+
label: Optional label for legends.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
ValueError: If max_range is not positive.
|
|
57
|
+
"""
|
|
58
|
+
# Normalize direction
|
|
59
|
+
direction = np.asarray(direction_xyz, dtype=np.float64)
|
|
60
|
+
direction = direction / np.linalg.norm(direction)
|
|
61
|
+
|
|
62
|
+
# Minimum half-angle for visualization
|
|
63
|
+
min_angle = 0.01 # ~0.5 degrees
|
|
64
|
+
if error_rad is None or error_rad < min_angle:
|
|
65
|
+
half_angle = min_angle
|
|
66
|
+
self._is_exact = True
|
|
67
|
+
else:
|
|
68
|
+
half_angle = float(error_rad)
|
|
69
|
+
self._is_exact = False
|
|
70
|
+
|
|
71
|
+
if max_range <= 0:
|
|
72
|
+
raise ValueError("max_range must be positive")
|
|
73
|
+
|
|
74
|
+
# Initialize parent Cone
|
|
75
|
+
super().__init__(
|
|
76
|
+
apex_xyz=np.asarray(collector_xyz, dtype=np.float64),
|
|
77
|
+
axis=direction,
|
|
78
|
+
half_angle=half_angle,
|
|
79
|
+
height=float(max_range),
|
|
80
|
+
label=label,
|
|
81
|
+
as_volume=True, # AOA is always a volume
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def is_volume(self) -> bool:
|
|
86
|
+
"""AOA surfaces always represent volumes."""
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def is_exact(self) -> bool:
|
|
91
|
+
"""Whether this represents an exact AOA (no error specified)."""
|
|
92
|
+
return self._is_exact
|
|
93
|
+
|
|
94
|
+
# Convenience aliases for AOA-specific terminology
|
|
95
|
+
@property
|
|
96
|
+
def collector_xyz(self) -> NDArray[np.floating]:
|
|
97
|
+
"""Get collector position."""
|
|
98
|
+
return self.apex_xyz
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def direction_xyz(self) -> NDArray[np.floating]:
|
|
102
|
+
"""Get measured direction (unit vector)."""
|
|
103
|
+
return self.axis
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def error_rad(self) -> float:
|
|
107
|
+
"""Get angular error in radians."""
|
|
108
|
+
return self.half_angle
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def max_range(self) -> float:
|
|
112
|
+
"""Get maximum range."""
|
|
113
|
+
return self.height
|
|
114
|
+
|
|
115
|
+
def __repr__(self) -> str:
|
|
116
|
+
"""Return string representation."""
|
|
117
|
+
return (
|
|
118
|
+
f"AoaSurface(collector={self.apex_xyz}, "
|
|
119
|
+
f"direction={self.axis}, error={self.half_angle:.4f}rad)"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
__all__ = ["AoaSurface"]
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""FDOA (Frequency Difference of Arrival) iso-Doppler surface.
|
|
2
|
+
|
|
3
|
+
This module provides the FdoaSurface class for visualizing FDOA measurements.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from gri_utils import constants
|
|
10
|
+
|
|
11
|
+
from gri_plot.surfaces import ImplicitShape, expand_bounds
|
|
12
|
+
from gri_plot.surfaces.mesh import field_to_mesh, vertices_to_mesh3d
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
import plotly.graph_objects as go
|
|
18
|
+
from numpy.typing import NDArray
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FdoaSurface(ImplicitShape):
|
|
22
|
+
"""FDOA iso-Doppler surface for frequency difference of arrival.
|
|
23
|
+
|
|
24
|
+
The surface represents all points where the difference in Doppler shift
|
|
25
|
+
between two collectors equals the measured FDOA.
|
|
26
|
+
|
|
27
|
+
The Doppler shift at collector i is:
|
|
28
|
+
f_doppler_i = f_tx * (v_i . r_hat_i) / c
|
|
29
|
+
|
|
30
|
+
where r_hat_i is the unit vector from emitter to collector i.
|
|
31
|
+
|
|
32
|
+
The FDOA is then:
|
|
33
|
+
fdoa = f_doppler_1 - f_doppler_2
|
|
34
|
+
= (f_tx / c) * (v_1 . r_hat_1 - v_2 . r_hat_2)
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
c1_xyz: First collector position in XYZ coordinates.
|
|
38
|
+
c2_xyz: Second collector position in XYZ coordinates.
|
|
39
|
+
c1_vel: First collector velocity in XYZ coordinates (m/s).
|
|
40
|
+
c2_vel: Second collector velocity in XYZ coordinates (m/s).
|
|
41
|
+
fdoa_hz: FDOA measurement in Hz.
|
|
42
|
+
freq_hz: Carrier frequency in Hz.
|
|
43
|
+
bounds_xyz: Bounding box for visualization.
|
|
44
|
+
label: Optional label for legends.
|
|
45
|
+
is_volume: Always False for FDOA surfaces (target on iso-doppler surface).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__( # noqa: PLR0913 - FDOA requires two positions + two velocities + freq
|
|
49
|
+
self,
|
|
50
|
+
c1_xyz: NDArray[np.floating],
|
|
51
|
+
c2_xyz: NDArray[np.floating],
|
|
52
|
+
c1_vel: NDArray[np.floating],
|
|
53
|
+
c2_vel: NDArray[np.floating],
|
|
54
|
+
fdoa_hz: float,
|
|
55
|
+
freq_hz: float,
|
|
56
|
+
bounds_xyz: tuple[NDArray[np.floating], NDArray[np.floating]] | None = None,
|
|
57
|
+
label: str | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Initialize the FDOA surface.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
c1_xyz: First collector position, shape (3,).
|
|
63
|
+
c2_xyz: Second collector position, shape (3,).
|
|
64
|
+
c1_vel: First collector velocity, shape (3,).
|
|
65
|
+
c2_vel: Second collector velocity, shape (3,).
|
|
66
|
+
fdoa_hz: FDOA measurement in Hz.
|
|
67
|
+
freq_hz: Carrier frequency in Hz (must be positive).
|
|
68
|
+
bounds_xyz: Optional bounding box for visualization.
|
|
69
|
+
label: Optional label for legends.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ValueError: If freq_hz is not positive.
|
|
73
|
+
"""
|
|
74
|
+
self._c1 = np.asarray(c1_xyz, dtype=np.float64)
|
|
75
|
+
self._c2 = np.asarray(c2_xyz, dtype=np.float64)
|
|
76
|
+
self._v1 = np.asarray(c1_vel, dtype=np.float64)
|
|
77
|
+
self._v2 = np.asarray(c2_vel, dtype=np.float64)
|
|
78
|
+
self._fdoa = float(fdoa_hz)
|
|
79
|
+
|
|
80
|
+
if freq_hz <= 0:
|
|
81
|
+
raise ValueError("freq_hz must be positive")
|
|
82
|
+
self._freq = float(freq_hz)
|
|
83
|
+
|
|
84
|
+
self._label = label
|
|
85
|
+
|
|
86
|
+
# Precompute scaling factor
|
|
87
|
+
self._scale = self._freq / constants.C
|
|
88
|
+
|
|
89
|
+
# Compute default bounds if not provided
|
|
90
|
+
if bounds_xyz is None:
|
|
91
|
+
self._bounds_xyz = self._compute_default_bounds()
|
|
92
|
+
else:
|
|
93
|
+
self._bounds_xyz = (
|
|
94
|
+
np.asarray(bounds_xyz[0], dtype=np.float64),
|
|
95
|
+
np.asarray(bounds_xyz[1], dtype=np.float64),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def _compute_default_bounds(
|
|
99
|
+
self,
|
|
100
|
+
) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
|
|
101
|
+
"""Compute default bounding box from collector positions."""
|
|
102
|
+
baseline = np.linalg.norm(self._c2 - self._c1)
|
|
103
|
+
padding = baseline * 2
|
|
104
|
+
|
|
105
|
+
min_corner = np.minimum(self._c1, self._c2) - padding
|
|
106
|
+
max_corner = np.maximum(self._c1, self._c2) + padding
|
|
107
|
+
|
|
108
|
+
return min_corner, max_corner
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def c1_xyz(self) -> NDArray[np.floating]:
|
|
112
|
+
"""Get first collector position."""
|
|
113
|
+
return self._c1
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def c2_xyz(self) -> NDArray[np.floating]:
|
|
117
|
+
"""Get second collector position."""
|
|
118
|
+
return self._c2
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def c1_vel(self) -> NDArray[np.floating]:
|
|
122
|
+
"""Get first collector velocity."""
|
|
123
|
+
return self._v1
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def c2_vel(self) -> NDArray[np.floating]:
|
|
127
|
+
"""Get second collector velocity."""
|
|
128
|
+
return self._v2
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def fdoa_hz(self) -> float:
|
|
132
|
+
"""Get FDOA in Hz."""
|
|
133
|
+
return self._fdoa
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def freq_hz(self) -> float:
|
|
137
|
+
"""Get carrier frequency in Hz."""
|
|
138
|
+
return self._freq
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def label(self) -> str | None:
|
|
142
|
+
"""Get the label."""
|
|
143
|
+
return self._label
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def is_volume(self) -> bool:
|
|
147
|
+
"""FDOA surfaces are not volumes (target on iso-doppler surface)."""
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
def residual_fn(self, xyz: NDArray[np.floating]) -> NDArray[np.floating]:
|
|
151
|
+
"""Compute the FDOA residual.
|
|
152
|
+
|
|
153
|
+
The residual is the computed FDOA minus the measured FDOA,
|
|
154
|
+
normalized by the maximum possible FDOA for numerical stability.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
xyz: Points to evaluate, shape (..., 3).
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Residual values, shape (...).
|
|
161
|
+
"""
|
|
162
|
+
xyz = np.asarray(xyz)
|
|
163
|
+
|
|
164
|
+
# Unit vectors from emitter to collectors
|
|
165
|
+
diff1 = self._c1 - xyz
|
|
166
|
+
diff2 = self._c2 - xyz
|
|
167
|
+
|
|
168
|
+
r1 = np.linalg.norm(diff1, axis=-1, keepdims=True)
|
|
169
|
+
r2 = np.linalg.norm(diff2, axis=-1, keepdims=True)
|
|
170
|
+
|
|
171
|
+
# Avoid division by zero
|
|
172
|
+
r1 = np.maximum(r1, 1e-10)
|
|
173
|
+
r2 = np.maximum(r2, 1e-10)
|
|
174
|
+
|
|
175
|
+
r_hat_1 = diff1 / r1
|
|
176
|
+
r_hat_2 = diff2 / r2
|
|
177
|
+
|
|
178
|
+
# Doppler shifts (negative because we're measuring approaching/receding)
|
|
179
|
+
# When collector moves toward emitter (positive v.r_hat), frequency increases
|
|
180
|
+
doppler_1 = self._scale * np.einsum("i,...i->...", self._v1, r_hat_1)
|
|
181
|
+
doppler_2 = self._scale * np.einsum("i,...i->...", self._v2, r_hat_2)
|
|
182
|
+
|
|
183
|
+
computed_fdoa = doppler_1 - doppler_2
|
|
184
|
+
|
|
185
|
+
# Normalize by maximum possible FDOA (based on velocity magnitudes)
|
|
186
|
+
max_fdoa = self._scale * (np.linalg.norm(self._v1) + np.linalg.norm(self._v2))
|
|
187
|
+
if max_fdoa > 0:
|
|
188
|
+
return (computed_fdoa - self._fdoa) / max_fdoa
|
|
189
|
+
return computed_fdoa - self._fdoa
|
|
190
|
+
|
|
191
|
+
def get_bounds_xyz(
|
|
192
|
+
self,
|
|
193
|
+
) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
|
|
194
|
+
"""Get bounding box in XYZ coordinates.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Tuple of (min_corner, max_corner).
|
|
198
|
+
"""
|
|
199
|
+
return self._bounds_xyz
|
|
200
|
+
|
|
201
|
+
def to_mesh(
|
|
202
|
+
self,
|
|
203
|
+
resolution: int | None = None,
|
|
204
|
+
) -> tuple[NDArray[np.floating], NDArray[np.integer]]:
|
|
205
|
+
"""Generate mesh vertices and faces.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
resolution: Grid resolution. If None, uses field_to_mesh default.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Tuple of (vertices, faces).
|
|
212
|
+
"""
|
|
213
|
+
bounds = expand_bounds(self._bounds_xyz, 1.05)
|
|
214
|
+
if resolution is None:
|
|
215
|
+
return field_to_mesh(self.residual_fn, bounds)
|
|
216
|
+
return field_to_mesh(self.residual_fn, bounds, resolution=resolution)
|
|
217
|
+
|
|
218
|
+
def to_trace(
|
|
219
|
+
self,
|
|
220
|
+
resolution: int | None = None,
|
|
221
|
+
intensity_fn: Callable[[NDArray[np.floating]], NDArray[np.floating]]
|
|
222
|
+
| None = None,
|
|
223
|
+
**kwargs,
|
|
224
|
+
) -> go.Mesh3d:
|
|
225
|
+
"""Generate a Plotly Mesh3d trace.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
resolution: Grid resolution. If None, uses field_to_mesh default.
|
|
229
|
+
intensity_fn: Optional function to compute vertex intensities for
|
|
230
|
+
coloring. Takes vertices (N, 3) and returns intensities (N,).
|
|
231
|
+
If None, defaults to Z-coordinate coloring.
|
|
232
|
+
**kwargs: Additional arguments for vertices_to_mesh3d.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Plotly Mesh3d trace.
|
|
236
|
+
"""
|
|
237
|
+
vertices, faces = self.to_mesh(resolution)
|
|
238
|
+
return vertices_to_mesh3d(
|
|
239
|
+
vertices,
|
|
240
|
+
faces,
|
|
241
|
+
intensity_fn=intensity_fn,
|
|
242
|
+
name=self._label,
|
|
243
|
+
**kwargs,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def __repr__(self) -> str:
|
|
247
|
+
"""Return string representation."""
|
|
248
|
+
return (
|
|
249
|
+
f"FdoaSurface(c1={self._c1}, c2={self._c2}, "
|
|
250
|
+
f"fdoa={self._fdoa}Hz, freq={self._freq}Hz)"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
__all__ = ["FdoaSurface"]
|