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
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Line of Sight (LOS) ray/cylinder surface.
|
|
2
|
+
|
|
3
|
+
This module provides the LosSurface class for visualizing line of sight.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import plotly.graph_objects as go
|
|
10
|
+
|
|
11
|
+
from gri_plot.shapes.cylinder import Cylinder
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from numpy.typing import NDArray
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LosSurface(Cylinder):
|
|
18
|
+
"""Line of Sight surface rendered as a thin cylinder.
|
|
19
|
+
|
|
20
|
+
Represents a line of sight from a collector in a given direction,
|
|
21
|
+
optionally with a width to indicate uncertainty.
|
|
22
|
+
|
|
23
|
+
This class inherits from Cylinder and always has is_volume=True since
|
|
24
|
+
a LOS measurement defines a volume where the target could be.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
start_xyz: Start point (collector position) in XYZ coordinates.
|
|
28
|
+
direction_xyz: Direction of the LOS (unit vector).
|
|
29
|
+
length: Length of the LOS ray.
|
|
30
|
+
width: Width of the cylinder (for visualization).
|
|
31
|
+
label: Optional label for legends.
|
|
32
|
+
is_volume: Always True for LOS surfaces.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
start_xyz: NDArray[np.floating],
|
|
38
|
+
direction_xyz: NDArray[np.floating],
|
|
39
|
+
length: float,
|
|
40
|
+
width: float | None = None,
|
|
41
|
+
label: str | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Initialize the LOS surface.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
start_xyz: Start point, shape (3,).
|
|
47
|
+
direction_xyz: Direction (will be normalized), shape (3,).
|
|
48
|
+
length: Length of the LOS in meters.
|
|
49
|
+
width: Width of the cylinder. If None, uses length/500.
|
|
50
|
+
label: Optional label for legends.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
ValueError: If length is not positive.
|
|
54
|
+
"""
|
|
55
|
+
start = np.asarray(start_xyz, dtype=np.float64)
|
|
56
|
+
direction = np.asarray(direction_xyz, dtype=np.float64)
|
|
57
|
+
direction = direction / np.linalg.norm(direction)
|
|
58
|
+
|
|
59
|
+
if length <= 0:
|
|
60
|
+
raise ValueError("length must be positive")
|
|
61
|
+
|
|
62
|
+
radius = length / 500 if width is None else float(width)
|
|
63
|
+
|
|
64
|
+
# Store start point for convenience properties
|
|
65
|
+
self._start = start
|
|
66
|
+
|
|
67
|
+
# Cylinder is centered, so compute center point
|
|
68
|
+
center = start + (length / 2) * direction
|
|
69
|
+
|
|
70
|
+
# Initialize parent Cylinder
|
|
71
|
+
super().__init__(
|
|
72
|
+
center_xyz=center,
|
|
73
|
+
axis=direction,
|
|
74
|
+
radius=radius,
|
|
75
|
+
height=float(length),
|
|
76
|
+
label=label,
|
|
77
|
+
as_volume=True, # LOS is always a volume
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def is_volume(self) -> bool:
|
|
82
|
+
"""LOS surfaces always represent volumes."""
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
# Convenience aliases for LOS-specific terminology
|
|
86
|
+
@property
|
|
87
|
+
def start_xyz(self) -> NDArray[np.floating]:
|
|
88
|
+
"""Get start point."""
|
|
89
|
+
return self._start
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def end_xyz(self) -> NDArray[np.floating]:
|
|
93
|
+
"""Get end point."""
|
|
94
|
+
return self._start + self.height * self.axis
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def direction_xyz(self) -> NDArray[np.floating]:
|
|
98
|
+
"""Get direction (unit vector)."""
|
|
99
|
+
return self.axis
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def length(self) -> float:
|
|
103
|
+
"""Get length."""
|
|
104
|
+
return self.height
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def width(self) -> float:
|
|
108
|
+
"""Get width."""
|
|
109
|
+
return self.radius
|
|
110
|
+
|
|
111
|
+
def to_line_trace(
|
|
112
|
+
self,
|
|
113
|
+
color: str | None = None,
|
|
114
|
+
width: int = 2,
|
|
115
|
+
**kwargs,
|
|
116
|
+
) -> go.Scatter3d:
|
|
117
|
+
"""Generate a simple line trace (for cleaner visualization).
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
color: Line color.
|
|
121
|
+
width: Line width.
|
|
122
|
+
**kwargs: Additional arguments for go.Scatter3d.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Plotly Scatter3d trace.
|
|
126
|
+
"""
|
|
127
|
+
end = self.end_xyz
|
|
128
|
+
trace_kwargs: dict = {
|
|
129
|
+
"x": [self._start[0], end[0]],
|
|
130
|
+
"y": [self._start[1], end[1]],
|
|
131
|
+
"z": [self._start[2], end[2]],
|
|
132
|
+
"mode": "lines",
|
|
133
|
+
"line": {"width": width},
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if color is not None:
|
|
137
|
+
trace_kwargs["line"]["color"] = color
|
|
138
|
+
|
|
139
|
+
if self.label:
|
|
140
|
+
trace_kwargs["name"] = self.label
|
|
141
|
+
trace_kwargs["showlegend"] = True
|
|
142
|
+
|
|
143
|
+
trace_kwargs.update(kwargs)
|
|
144
|
+
return go.Scatter3d(**trace_kwargs)
|
|
145
|
+
|
|
146
|
+
def __repr__(self) -> str:
|
|
147
|
+
"""Return string representation."""
|
|
148
|
+
return (
|
|
149
|
+
f"LosSurface(start={self._start}, direction={self.axis}, "
|
|
150
|
+
f"length={self.height})"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
__all__ = ["LosSurface"]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Range sphere surface for TOA/range observables.
|
|
2
|
+
|
|
3
|
+
This module provides the RangeSphere class for visualizing range measurements.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from gri_plot.shapes.sphere import Sphere
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from numpy.typing import NDArray
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RangeSphere(Sphere):
|
|
15
|
+
"""Range sphere representing a TOA or direct range measurement.
|
|
16
|
+
|
|
17
|
+
The sphere is centered on a collector at the measured range distance.
|
|
18
|
+
This is essentially a Sphere with collector/range semantics.
|
|
19
|
+
|
|
20
|
+
This class inherits from Sphere and always has is_volume=True since
|
|
21
|
+
a range measurement defines a volume where the target could be.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
collector_xyz: Collector position in XYZ coordinates.
|
|
25
|
+
range_m: Range in meters.
|
|
26
|
+
label: Optional label for legends.
|
|
27
|
+
is_volume: Always True for range spheres.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
collector_xyz: NDArray,
|
|
33
|
+
range_m: float,
|
|
34
|
+
label: str | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Initialize the range sphere.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
collector_xyz: Collector position, shape (3,).
|
|
40
|
+
range_m: Range measurement in meters (positive).
|
|
41
|
+
label: Optional label for legends.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ValueError: If range_m is not positive.
|
|
45
|
+
"""
|
|
46
|
+
super().__init__(
|
|
47
|
+
center_xyz=collector_xyz,
|
|
48
|
+
radius=range_m,
|
|
49
|
+
label=label,
|
|
50
|
+
as_volume=True, # Range sphere is always a volume
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def is_volume(self) -> bool:
|
|
55
|
+
"""Range spheres always represent volumes."""
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
# Convenience aliases for range-specific terminology
|
|
59
|
+
@property
|
|
60
|
+
def collector_xyz(self) -> NDArray:
|
|
61
|
+
"""Get collector position."""
|
|
62
|
+
return self.center_xyz
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def range_m(self) -> float:
|
|
66
|
+
"""Get range in meters."""
|
|
67
|
+
return self.radius
|
|
68
|
+
|
|
69
|
+
def __repr__(self) -> str:
|
|
70
|
+
"""Return string representation."""
|
|
71
|
+
return f"RangeSphere(collector={self.center_xyz}, range={self.radius}m)"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["RangeSphere"]
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""TDOA (Time Difference of Arrival) hyperboloid surface.
|
|
2
|
+
|
|
3
|
+
This module provides the TdoaSurface class for visualizing TDOA isosurfaces.
|
|
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 TdoaSurface(ImplicitShape):
|
|
22
|
+
"""TDOA hyperboloid surface where range_1 - range_2 = c * tdoa.
|
|
23
|
+
|
|
24
|
+
The surface represents all points where the difference in range to two
|
|
25
|
+
collectors equals the measured TDOA times the speed of light.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
c1_xyz: First collector position in XYZ coordinates.
|
|
29
|
+
c2_xyz: Second collector position in XYZ coordinates.
|
|
30
|
+
tdoa_seconds: TDOA measurement in seconds (r1 - r2 = c * tdoa).
|
|
31
|
+
bounds_xyz: Bounding box for visualization.
|
|
32
|
+
label: Optional label for legends.
|
|
33
|
+
is_volume: Always False for TDOA surfaces (target on hyperboloid).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
c1_xyz: NDArray[np.floating],
|
|
39
|
+
c2_xyz: NDArray[np.floating],
|
|
40
|
+
tdoa_seconds: float,
|
|
41
|
+
bounds_xyz: tuple[NDArray[np.floating], NDArray[np.floating]] | None = None,
|
|
42
|
+
label: str | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Initialize the TDOA surface.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
c1_xyz: First collector position, shape (3,).
|
|
48
|
+
c2_xyz: Second collector position, shape (3,).
|
|
49
|
+
tdoa_seconds: TDOA in seconds. Positive means signal arrived at
|
|
50
|
+
c1 first (r1 < r2), negative means c2 first.
|
|
51
|
+
bounds_xyz: Optional bounding box for visualization. If None,
|
|
52
|
+
computed automatically from collector positions.
|
|
53
|
+
label: Optional label for legends.
|
|
54
|
+
"""
|
|
55
|
+
self._c1 = np.asarray(c1_xyz, dtype=np.float64)
|
|
56
|
+
self._c2 = np.asarray(c2_xyz, dtype=np.float64)
|
|
57
|
+
self._tdoa = float(tdoa_seconds)
|
|
58
|
+
self._label = label
|
|
59
|
+
|
|
60
|
+
# Range difference
|
|
61
|
+
self._range_diff = self._tdoa * constants.C
|
|
62
|
+
|
|
63
|
+
# Compute default bounds if not provided
|
|
64
|
+
if bounds_xyz is None:
|
|
65
|
+
self._bounds_xyz = self._compute_default_bounds()
|
|
66
|
+
else:
|
|
67
|
+
self._bounds_xyz = (
|
|
68
|
+
np.asarray(bounds_xyz[0], dtype=np.float64),
|
|
69
|
+
np.asarray(bounds_xyz[1], dtype=np.float64),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def _compute_default_bounds(
|
|
73
|
+
self,
|
|
74
|
+
) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
|
|
75
|
+
"""Compute default bounding box from collector positions.
|
|
76
|
+
|
|
77
|
+
The bounds are set to encompass both collectors with padding.
|
|
78
|
+
"""
|
|
79
|
+
baseline = np.linalg.norm(self._c2 - self._c1)
|
|
80
|
+
# Extend by 2x baseline in each direction
|
|
81
|
+
padding = baseline * 2
|
|
82
|
+
|
|
83
|
+
min_corner = np.minimum(self._c1, self._c2) - padding
|
|
84
|
+
max_corner = np.maximum(self._c1, self._c2) + padding
|
|
85
|
+
|
|
86
|
+
return min_corner, max_corner
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def c1_xyz(self) -> NDArray[np.floating]:
|
|
90
|
+
"""Get first collector position."""
|
|
91
|
+
return self._c1
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def c2_xyz(self) -> NDArray[np.floating]:
|
|
95
|
+
"""Get second collector position."""
|
|
96
|
+
return self._c2
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def tdoa_seconds(self) -> float:
|
|
100
|
+
"""Get TDOA in seconds."""
|
|
101
|
+
return self._tdoa
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def range_difference(self) -> float:
|
|
105
|
+
"""Get range difference in meters (r1 - r2)."""
|
|
106
|
+
return self._range_diff
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def label(self) -> str | None:
|
|
110
|
+
"""Get the label."""
|
|
111
|
+
return self._label
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def is_volume(self) -> bool:
|
|
115
|
+
"""TDOA surfaces are not volumes (target on hyperboloid surface)."""
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
def residual_fn(self, xyz: NDArray[np.floating]) -> NDArray[np.floating]:
|
|
119
|
+
"""Compute the TDOA residual.
|
|
120
|
+
|
|
121
|
+
The residual is (r1 - r2) - (c * tdoa), normalized by baseline,
|
|
122
|
+
so 0 on the surface, positive on the c1 side, negative on the c2 side.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
xyz: Points to evaluate, shape (..., 3).
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Residual values, shape (...).
|
|
129
|
+
"""
|
|
130
|
+
xyz = np.asarray(xyz)
|
|
131
|
+
|
|
132
|
+
r1 = np.linalg.norm(xyz - self._c1, axis=-1)
|
|
133
|
+
r2 = np.linalg.norm(xyz - self._c2, axis=-1)
|
|
134
|
+
|
|
135
|
+
# Normalize by baseline for numerical stability
|
|
136
|
+
baseline = np.linalg.norm(self._c2 - self._c1)
|
|
137
|
+
if baseline > 0:
|
|
138
|
+
return (r1 - r2 - self._range_diff) / baseline
|
|
139
|
+
return r1 - r2 - self._range_diff
|
|
140
|
+
|
|
141
|
+
def get_bounds_xyz(
|
|
142
|
+
self,
|
|
143
|
+
) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
|
|
144
|
+
"""Get bounding box in XYZ coordinates.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Tuple of (min_corner, max_corner).
|
|
148
|
+
"""
|
|
149
|
+
return self._bounds_xyz
|
|
150
|
+
|
|
151
|
+
def to_mesh(
|
|
152
|
+
self,
|
|
153
|
+
resolution: int | None = None,
|
|
154
|
+
) -> tuple[NDArray[np.floating], NDArray[np.integer]]:
|
|
155
|
+
"""Generate mesh vertices and faces.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
resolution: Grid resolution. If None, uses field_to_mesh default.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Tuple of (vertices, faces).
|
|
162
|
+
"""
|
|
163
|
+
bounds = expand_bounds(self._bounds_xyz, 1.05)
|
|
164
|
+
if resolution is None:
|
|
165
|
+
return field_to_mesh(self.residual_fn, bounds)
|
|
166
|
+
return field_to_mesh(self.residual_fn, bounds, resolution=resolution)
|
|
167
|
+
|
|
168
|
+
def to_trace(
|
|
169
|
+
self,
|
|
170
|
+
resolution: int | None = None,
|
|
171
|
+
intensity_fn: Callable[[NDArray[np.floating]], NDArray[np.floating]]
|
|
172
|
+
| None = None,
|
|
173
|
+
**kwargs,
|
|
174
|
+
) -> go.Mesh3d:
|
|
175
|
+
"""Generate a Plotly Mesh3d trace.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
resolution: Grid resolution. If None, uses field_to_mesh default.
|
|
179
|
+
intensity_fn: Optional function to compute vertex intensities for
|
|
180
|
+
coloring. Takes vertices (N, 3) and returns intensities (N,).
|
|
181
|
+
If None, defaults to Z-coordinate coloring.
|
|
182
|
+
**kwargs: Additional arguments for vertices_to_mesh3d.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Plotly Mesh3d trace.
|
|
186
|
+
"""
|
|
187
|
+
vertices, faces = self.to_mesh(resolution)
|
|
188
|
+
return vertices_to_mesh3d(
|
|
189
|
+
vertices,
|
|
190
|
+
faces,
|
|
191
|
+
intensity_fn=intensity_fn,
|
|
192
|
+
name=self._label,
|
|
193
|
+
**kwargs,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def __repr__(self) -> str:
|
|
197
|
+
"""Return string representation."""
|
|
198
|
+
return f"TdoaSurface(c1={self._c1}, c2={self._c2}, tdoa={self._tdoa:.9f}s)"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
__all__ = ["TdoaSurface"]
|