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.
@@ -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"]