sgptools 1.2.0__py3-none-any.whl → 2.0.0__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,699 @@
1
+ # Copyright 2024 The SGP-Tools Contributors. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Provides transforms to model complex sensor field of views and handle informative path planning
15
+ """
16
+
17
+ import tensorflow as tf
18
+ from gpflow.config import default_float
19
+
20
+ float_type = default_float()
21
+
22
+ import numpy as np
23
+ from typing import Optional, Union, Any, Tuple, List # Import necessary types for type hinting
24
+
25
+
26
+ class Transform:
27
+ """
28
+ Base class for transformations applied to inducing points in sparse Gaussian process models.
29
+ This class defines common interfaces for expanding inducing points (e.g., to model
30
+ sensor fields of view or continuous paths) and aggregating kernel matrices.
31
+ It also provides a base for adding constraint terms to the optimization objective.
32
+
33
+ Refer to the following papers for more details:
34
+ - Efficient Sensor Placement from Regression with Sparse Gaussian Processes in Continuous and Discrete Spaces [Jakkala and Akella, 2023]
35
+ - Multi-Robot Informative Path Planning from Regression with Sparse Gaussian Processes [Jakkala and Akella, 2024]
36
+ """
37
+
38
+ def __init__(self,
39
+ aggregation_size: Optional[int] = None,
40
+ constraint_weight: float = 1.0,
41
+ **kwargs: Any):
42
+ """
43
+ Initializes the base Transform class.
44
+
45
+ Args:
46
+ aggregation_size (Optional[int]): Number of consecutive inducing points to aggregate
47
+ when transforming kernel matrices. If None, no aggregation
48
+ is performed. Defaults to None.
49
+ constraint_weight (float): A scalar weight that controls the importance of the
50
+ constraint terms in the SGP's optimization objective function.
51
+ A higher weight means stronger enforcement of constraints.
52
+ Defaults to 1.0.
53
+ **kwargs (Any): Additional keyword arguments to be passed to the constructor.
54
+ """
55
+ self.aggregation_size = aggregation_size
56
+ self.constraint_weight = constraint_weight
57
+
58
+ def expand(
59
+ self, Xu: Union[np.ndarray,
60
+ tf.Tensor]) -> Union[np.ndarray, tf.Tensor]:
61
+ """
62
+ Applies an expansion transform to the inducing points.
63
+ In this base class, it simply returns the input inducing points unchanged.
64
+ Subclasses should override this method to implement specific expansion logic.
65
+
66
+ Args:
67
+ Xu (Union[np.ndarray, tf.Tensor]): The input inducing points.
68
+ Shape: (m, d) where `m` is the number of inducing points
69
+ and `d` is their dimensionality.
70
+
71
+ Returns:
72
+ Union[np.ndarray, tf.Tensor]: The expanded inducing points.
73
+ """
74
+ return Xu
75
+
76
+ def aggregate(self, k: tf.Tensor) -> tf.Tensor:
77
+ """
78
+ Applies an aggregation transform to kernel matrices. This is typically used
79
+ to reduce the size of kernel matrices after expansion, by averaging or summing
80
+ over groups of expanded points. This can reduce computational cost for
81
+ matrix inversions (e.g., in `Kuu`).
82
+
83
+ Args:
84
+ k (tf.Tensor): The input kernel matrix.
85
+ Can be (mp, mp) for `Kuu` (square matrix for inducing points
86
+ against themselves), or (mp, n) for `Kuf` (rectangular matrix
87
+ for inducing points against training data).
88
+ `m` is the number of original inducing points.
89
+ `p` is the number of points each inducing point is mapped to
90
+ by the expansion transform.
91
+ `n` is the number of training data points.
92
+
93
+ Returns:
94
+ tf.Tensor: The aggregated kernel matrix.
95
+ Shape: (m, m) if input was (mp, mp), or (m, n) if input was (mp, n).
96
+ """
97
+ if self.aggregation_size is None:
98
+ return k
99
+
100
+ # The aggregation logic assumes `k` has leading dimensions that are
101
+ # multiples of `self.aggregation_size`.
102
+ if k.shape[0] == k.shape[
103
+ 1]: # This is K(U, U) or K(U_expanded, U_expanded)
104
+ # Reshape for `tf.nn.avg_pool`: [batch, height, width, channels]
105
+ # Here, we treat the matrix as a 1-channel image.
106
+ k_reshaped = tf.expand_dims(tf.expand_dims(k, axis=0),
107
+ axis=-1) # (1, mp, mp, 1)
108
+
109
+ # Apply average pooling. `ksize` and `strides` define the window size
110
+ # and movement for aggregation. This effectively averages blocks.
111
+ k_aggregated = tf.nn.avg_pool(
112
+ k_reshaped,
113
+ ksize=[1, self.aggregation_size, self.aggregation_size, 1],
114
+ strides=[1, self.aggregation_size, self.aggregation_size, 1],
115
+ padding='VALID')
116
+ # Squeeze back to (m, m)
117
+ k = tf.squeeze(k_aggregated, axis=[0, -1])
118
+ else: # This is K(U, F) or K(U_expanded, F)
119
+ # Reshape for `tf.nn.avg_pool`: (1, mp, n) -> (1, mp, n, 1) if channels are 1
120
+ # Or (1, mp, n) directly for 1D pooling if `n` is treated as a feature dimension.
121
+ # Here, we're pooling along the inducing point dimension.
122
+ k_reshaped = tf.expand_dims(k, axis=0) # (1, mp, n)
123
+ k_aggregated = tf.nn.avg_pool(
124
+ k_reshaped,
125
+ ksize=[1, self.aggregation_size, 1], # Pool along height (mp)
126
+ strides=[1, self.aggregation_size, 1],
127
+ padding='VALID')
128
+ # Squeeze back to (m, n)
129
+ k = tf.squeeze(k_aggregated, axis=[0])
130
+ return k
131
+
132
+ def constraints(self, Xu: tf.Tensor) -> tf.Tensor:
133
+ """
134
+ Computes constraint terms that are added to the SGP's optimization function (ELBO).
135
+ This base implementation returns a zero tensor, implying no constraints by default.
136
+ Subclasses should override this to implement specific constraints (e.g., path length budget).
137
+
138
+ Args:
139
+ Xu (tf.Tensor): The inducing points, from which to compute the constraints.
140
+ Shape: (m, d).
141
+
142
+ Returns:
143
+ tf.Tensor: A scalar tensor representing the constraint penalty. Defaults to 0.0.
144
+ """
145
+ return tf.constant(0.0, dtype=float_type)
146
+
147
+
148
+ class IPPTransform(Transform):
149
+ """
150
+ Transform to model Informative Path Planning (IPP) problems for single or multiple robots.
151
+ It handles continuous sensing, non-point fields of view (FoV), and distance constraints.
152
+
153
+ * For point sensing (discrete waypoints), set `sampling_rate = 2`.
154
+ * For continuous sensing along paths, set `sampling_rate > 2` to interpolate
155
+ additional points between waypoints for information gathering.
156
+ * For continuous sensing with aggregation for computational efficiency,
157
+ set `sampling_rate > 2` and `aggregate_fov = True`. This averages
158
+ covariances from interpolated points, potentially diminishing solution quality slightly.
159
+ * If using a non-point FoV model (e.g., `SquareTransform`) with continuous sampling,
160
+ only the FoV inducing points are aggregated.
161
+ * For multi-robot scenarios, set `num_robots > 1`.
162
+ * For online IPP where some visited waypoints are fixed, use `update_Xu_fixed`
163
+ to freeze these waypoints from further optimization.
164
+ """
165
+
166
+ def __init__(self,
167
+ sampling_rate: int = 2,
168
+ distance_budget: Optional[float] = None,
169
+ num_robots: int = 1,
170
+ Xu_fixed: Optional[np.ndarray] = None,
171
+ num_dim: int = 2,
172
+ sensor_model: Optional[Transform] = None,
173
+ aggregate_fov: bool = False,
174
+ **kwargs: Any):
175
+ """
176
+ Initializes the IPPTransform.
177
+
178
+ Args:
179
+ sampling_rate (int): Number of points to sample along each segment between two
180
+ consecutive inducing points. `sampling_rate=2` implies
181
+ only the two endpoints are used (point sensing).
182
+ `sampling_rate > 2` implies continuous sensing via interpolation.
183
+ Must be $\ge 2$. Defaults to 2.
184
+ distance_budget (Optional[float]): The maximum allowable total path length for each robot.
185
+ If None, no distance constraint is applied. Defaults to None.
186
+ num_robots (int): The number of robots or agents involved in the IPP problem. Defaults to 1.
187
+ Xu_fixed (Optional[np.ndarray]): (num_robots, num_visited, num_dim);
188
+ An array of pre-defined, fixed waypoints that should
189
+ not be optimized (e.g., already visited locations in online IPP).
190
+ If None, all waypoints are optimizable. Defaults to None.
191
+ num_dim (int): The dimensionality of the inducing points (e.g., 2 for (x,y), 3 for (x,y,angle)).
192
+ Defaults to 2.
193
+ sensor_model (Optional[Transform]): A `Transform` object that defines a non-point
194
+ Field of View (FoV) for the sensor (e.g., `SquareTransform`).
195
+ If None, a point sensor model is assumed. Defaults to None.
196
+ aggregate_fov (bool): If True, and `sampling_rate > 2` (continuous sensing is enabled),
197
+ or if `sensor_model` is provided, aggregation will be enabled.
198
+ This reduces computation by averaging expanded points' covariances.
199
+ Defaults to False.
200
+ **kwargs (Any): Additional keyword arguments passed to the base `Transform` constructor.
201
+
202
+ Raises:
203
+ ValueError: If `sampling_rate` is less than 2.
204
+
205
+ Usage:
206
+ ```python
207
+ # Single robot, point sensing
208
+ transform_point = IPPTransform(num_robots=1, num_dim=2, sampling_rate=2)
209
+
210
+ # Single robot, continuous sensing
211
+ transform_continuous = IPPTransform(num_robots=1, num_dim=2, sampling_rate=10)
212
+
213
+ # Multi-robot, continuous sensing with distance budget
214
+ transform_multi_budget = IPPTransform(num_robots=2, num_dim=2, sampling_rate=5, distance_budget=50.0, constraint_weight=100.0)
215
+ ```
216
+ """
217
+ super().__init__(**kwargs)
218
+ if sampling_rate < 2:
219
+ raise ValueError(
220
+ 'Sampling rate must be greater than or equal to 2.')
221
+
222
+ self.sampling_rate = sampling_rate
223
+ self.distance_budget = distance_budget
224
+ self.num_robots = num_robots
225
+ self.num_dim = num_dim
226
+ self.sensor_model = sensor_model
227
+
228
+ # Enable aggregation if `aggregate_fov` is True, potentially leveraging the sensor_model's aggregation
229
+ if aggregate_fov:
230
+ if self.sensor_model is not None:
231
+ # If a sensor model exists, let it handle its own aggregation settings.
232
+ # This assumes `sensor_model` has an `enable_aggregation` method.
233
+ if hasattr(self.sensor_model, 'enable_aggregation'):
234
+ self.sensor_model.enable_aggregation()
235
+ elif self.sampling_rate > 2:
236
+ # If no specific sensor model but continuous sensing, aggregate based on sampling rate.
237
+ self.aggregation_size = self.sampling_rate
238
+
239
+ # Initialize TensorFlow Variable for fixed waypoints if provided, for online IPP.
240
+ if Xu_fixed is not None:
241
+ self.Xu_fixed = tf.Variable(
242
+ Xu_fixed,
243
+ shape=tf.TensorShape(
244
+ None), # Shape can be inferred dynamically
245
+ trainable=False, # Fixed points are not optimized
246
+ dtype=float_type)
247
+ else:
248
+ self.Xu_fixed = None
249
+
250
+ def update_Xu_fixed(self, Xu_fixed: np.ndarray) -> None:
251
+ """
252
+ Updates the set of visited (fixed) waypoints for online IPP scenarios.
253
+ These waypoints will not be optimized in subsequent steps.
254
+
255
+ Args:
256
+ Xu_fixed (np.ndarray): A NumPy array of shape (num_robots, num_visited_waypoints, num_dim)
257
+ representing the new set of fixed waypoints.
258
+ """
259
+ self.num_fixed = Xu_fixed.shape[
260
+ 1] # Store number of fixed waypoints per robot
261
+ if self.Xu_fixed is not None:
262
+ self.Xu_fixed.assign(tf.constant(Xu_fixed, dtype=float_type))
263
+ else:
264
+ self.Xu_fixed = tf.Variable(tf.constant(Xu_fixed,
265
+ dtype=float_type),
266
+ shape=tf.TensorShape(None),
267
+ trainable=False)
268
+
269
+ def expand(self,
270
+ Xu: tf.Tensor,
271
+ expand_sensor_model: bool = True) -> tf.Tensor:
272
+ """
273
+ Applies the expansion transform to the inducing points based on the IPP settings.
274
+ This can involve:
275
+ 1. Adding fixed (already visited) waypoints.
276
+ 2. Interpolating points between waypoints for continuous sensing.
277
+ 3. Expanding each point into a sensor's Field of View (FoV) if a `sensor_model` is present.
278
+
279
+ Args:
280
+ Xu (tf.Tensor): The current set of optimizable inducing points.
281
+ Shape: (num_robots * num_optimizable_waypoints, num_dim).
282
+ Note: `num_dim` might include angle if `sensor_model` requires it.
283
+ expand_sensor_model (bool): If True, applies the `sensor_model`'s expansion
284
+ (if a `sensor_model` is configured). If False,
285
+ only the path interpolation and fixed point handling
286
+ are performed, useful for internal calculations like distance.
287
+ Defaults to True.
288
+
289
+ Returns:
290
+ tf.Tensor: The expanded inducing points, ready for kernel computations.
291
+ Shape: (total_expanded_points, d_output), where `d_output`
292
+ is typically 2 for (x,y) coordinates used in the kernel.
293
+ """
294
+ # If using single-robot offline IPP with point sensing, return inducing points as is.
295
+ if self.sampling_rate == 2 and self.Xu_fixed is None and self.sensor_model is None:
296
+ return Xu
297
+
298
+ # Reshape Xu to (num_robots, num_waypoints_per_robot, num_dim)
299
+ Xu = tf.reshape(Xu, (self.num_robots, -1, self.num_dim))
300
+
301
+ # If using online IPP, add visited waypoints that won't be optimized anymore
302
+ if self.Xu_fixed is not None:
303
+ Xu = tf.concat([self.Xu_fixed, Xu[:, self.num_fixed:]], axis=1)
304
+
305
+ if not expand_sensor_model:
306
+ return tf.reshape(Xu, (-1, self.num_dim))
307
+
308
+ # Interpolate additional inducing points between waypoints to approximate
309
+ # the continuous data sensing model
310
+ if self.sampling_rate > 2:
311
+ Xu = tf.linspace(Xu[:, :-1], Xu[:, 1:], self.sampling_rate)
312
+ Xu = tf.transpose(Xu, perm=[1, 2, 0, 3])
313
+ Xu = tf.reshape(Xu, (self.num_robots, -1, self.num_dim))
314
+
315
+ # If a sensor model is defined and `expand_sensor_model` is True,
316
+ # apply the sensor model's expansion for each robot.
317
+ if self.sensor_model is not None:
318
+ Xu_ = []
319
+ for i in range(self.num_robots):
320
+ Xu_.append(self.sensor_model.expand(Xu[i]))
321
+ Xu = tf.concat(Xu_, axis=0)
322
+ return Xu
323
+
324
+ Xu = tf.reshape(Xu, (-1, self.num_dim))
325
+ return Xu
326
+
327
+ def aggregate(self, k: tf.Tensor) -> tf.Tensor:
328
+ """
329
+ Applies the aggregation transform to kernel matrices.
330
+ If a `sensor_model` is defined, it delegates aggregation to the sensor model.
331
+ Otherwise, it uses the base class's aggregation logic (which depends on `self.aggregation_size`).
332
+
333
+ Args:
334
+ k (tf.Tensor): The input kernel matrix (e.g., K_expanded_expanded, K_expanded_training).
335
+
336
+ Returns:
337
+ tf.Tensor: The aggregated kernel matrix.
338
+ """
339
+ if self.sensor_model is not None:
340
+ return self.sensor_model.aggregate(k)
341
+ else:
342
+ return super().aggregate(k)
343
+
344
+ def constraints(self, Xu: tf.Tensor) -> tf.Tensor:
345
+ """
346
+ Computes the distance constraint term that is added to the SGP's optimization function.
347
+ Each robot's path length is constrained by `distance_budget`. A penalty is applied
348
+ if the path length exceeds the budget.
349
+
350
+ Args:
351
+ Xu (tf.Tensor): The inducing points (waypoints) from which to compute path lengths.
352
+ Shape: (num_robots * num_waypoints, num_dim).
353
+
354
+ Returns:
355
+ tf.Tensor: A scalar tensor representing the total distance constraint penalty.
356
+ This value is negative, and its magnitude increases with constraint violation.
357
+ """
358
+ if self.distance_budget is None:
359
+ return tf.constant(0.0, dtype=float_type) # No distance constraint
360
+ else:
361
+ # Expand Xu without sensor model to get the true path points for distance calculation.
362
+ # Xu is the optimizable part; self.expand will add fixed points if any.
363
+ Xu_for_distance = self.expand(Xu, expand_sensor_model=False)
364
+
365
+ # Calculate distances for each robot's path
366
+ individual_robot_distances = self.distance(Xu_for_distance)
367
+
368
+ # Compute the positive violation for each robot's path
369
+ violations = individual_robot_distances - self.distance_budget
370
+
371
+ # Apply ReLU to ensure only positive violations contribute to the penalty
372
+ # Sum all violations and apply the constraint weight
373
+ penalty = -tf.reduce_sum(
374
+ tf.nn.relu(violations)) * self.constraint_weight
375
+ return penalty
376
+
377
+ def distance(self, Xu: tf.Tensor) -> tf.Tensor:
378
+ """
379
+ Computes the total path length(s) incurred by sequentially visiting the inducing points.
380
+ For multiple robots, returns a tensor of individual path lengths.
381
+
382
+ Args:
383
+ Xu (tf.Tensor): The inducing points (waypoints) from which to compute the path lengths.
384
+ Shape: (total_waypoints, num_dim). This input is typically already
385
+ expanded to include fixed points if `Xu_fixed` is used.
386
+
387
+ Returns:
388
+ tf.Tensor: A scalar tensor if `num_robots=1`, or a 1D tensor of floats
389
+ (shape: `(num_robots,)`) representing individual path lengths.
390
+ """
391
+ # Reshape to (num_robots, num_waypoints_per_robot, num_dim)
392
+ Xu_reshaped = tf.reshape(Xu, (self.num_robots, -1, self.num_dim))
393
+
394
+ if self.sensor_model is not None:
395
+ # If a sensor model is present, delegate distance calculation to it,
396
+ # as it might have specific logic for its FoV's contribution to distance.
397
+ dists: List[tf.Tensor] = []
398
+ for i in range(self.num_robots):
399
+ # Pass each robot's path (which includes position and potentially angle)
400
+ dists.append(self.sensor_model.distance(Xu_reshaped[i]))
401
+ return tf.concat(
402
+ dists, axis=0) # Concatenate distances if multiple robots
403
+ else:
404
+ # For point/continuous sensing without a special FoV model:
405
+ # Calculate Euclidean distance between consecutive waypoints.
406
+ # Assuming first two dimensions are (x,y) for distance calculation.
407
+ # `Xu_reshaped[:, 1:, :2]` are points from the second to last.
408
+ # `Xu_reshaped[:, :-1, :2]` are points from the first to second to last.
409
+ segment_distances = tf.norm(Xu_reshaped[:, 1:, :2] -
410
+ Xu_reshaped[:, :-1, :2],
411
+ axis=-1)
412
+ total_distances = tf.reduce_sum(
413
+ segment_distances, axis=1) # Sum distances for each robot
414
+ return total_distances
415
+
416
+
417
+ class SquareTransform(Transform):
418
+ """
419
+ Non-point Transform to model a square Field of View (FoV) for a sensor.
420
+ This transform expands each inducing point (waypoint with position and orientation)
421
+ into a grid of points approximating a square area, which is then used in kernel computations.
422
+ This typically applies to single-robot cases as part of an `IPPTransform`.
423
+ """
424
+
425
+ def __init__(self,
426
+ side_length: float,
427
+ pts_per_side: int,
428
+ aggregate_fov: bool = False,
429
+ **kwargs: Any):
430
+ """
431
+ Initializes the SquareTransform for a square FoV.
432
+
433
+ Args:
434
+ side_length (float): The side length of the square FoV.
435
+ pts_per_side (int): The number of points to sample along each side of the square.
436
+ A `pts_per_side` of 3 will create a 3x3 grid of 9 points to approximate the FoV.
437
+ aggregate_fov (bool): If True, aggregation will be enabled for the expanded FoV points.
438
+ This averages covariances from the FoV points to reduce computational cost.
439
+ Defaults to False.
440
+ **kwargs (Any): Additional keyword arguments passed to the base `Transform` constructor.
441
+
442
+ Usage:
443
+ ```python
444
+ # Create a square FoV of side length 10.0, approximated by a 5x5 grid of points
445
+ square_fov_transform = SquareTransform(length=10.0, pts_per_side=5, aggregate_fov=True)
446
+ ```
447
+ """
448
+ super().__init__(**kwargs)
449
+ self.side_length = side_length
450
+ self.pts_per_side = pts_per_side
451
+ # Calculate the spacing between points along each side
452
+ self.side_length_factor = side_length / (self.pts_per_side)
453
+
454
+ if aggregate_fov:
455
+ self.enable_aggregation()
456
+
457
+ def enable_aggregation(self, size: Optional[int] = None) -> None:
458
+ """
459
+ Enables FoV covariance aggregation. This reduces the covariance matrix inversion
460
+ cost by effectively reducing the covariance matrix size.
461
+
462
+ Args:
463
+ size (Optional[int]): If None, all the interpolated inducing points within the FoV
464
+ (i.e., `pts_per_side^2` points) are aggregated into a single aggregated point.
465
+ Alternatively, the number of inducing points to aggregate can be
466
+ explicitly defined using this variable (e.g., if a custom
467
+ aggregation strategy is desired that groups `size` points).
468
+ Defaults to None.
469
+ """
470
+ if size is None:
471
+ self.aggregation_size = self.pts_per_side**2 # Aggregate all points within a FoV
472
+ else:
473
+ self.aggregation_size = size
474
+
475
+ def expand(self, Xu: tf.Tensor) -> tf.Tensor:
476
+ """
477
+ Applies the expansion transformation to the inducing points, modeling a square FoV.
478
+ Each input inducing point, which includes position (x, y) and orientation (theta),
479
+ is expanded into a grid of `pts_per_side` x `pts_per_side` points representing the FoV.
480
+
481
+ Args:
482
+ Xu (tf.Tensor): Inducing points in the position and orientation space.
483
+ Shape: (m, 3) where `m` is the number of inducing points,
484
+ and `3` corresponds to (x, y, angle in radians).
485
+
486
+ Returns:
487
+ tf.Tensor: The expanded inducing points in 2D input space (x,y).
488
+ Shape: (m * pts_per_side * pts_per_side, 2).
489
+ `m` is the number of original inducing points.
490
+ `pts_per_side * pts_per_side` is the number of points each inducing
491
+ point is mapped to in order to form the FoV.
492
+ """
493
+ # Split Xu into x, y coordinates and orientation (theta)
494
+ x_coords, y_coords, angles = tf.split(Xu, num_or_size_splits=3, axis=1)
495
+ x = tf.reshape(x_coords, [
496
+ -1,
497
+ ]) # Flatten to (m,)
498
+ y = tf.reshape(y_coords, [
499
+ -1,
500
+ ])
501
+ theta = tf.reshape(angles, [
502
+ -1,
503
+ ])
504
+
505
+ points: List[tf.Tensor] = []
506
+ # Iterate to create `pts_per_side` lines forming the square grid.
507
+ # The loop runs from -floor(pts_per_side/2) to ceil(pts_per_side/2) to center the grid.
508
+ for i in range(-int(np.floor((self.pts_per_side - 1) / 2)),
509
+ int(np.ceil((self.pts_per_side - 1) / 2)) + 1):
510
+ # Calculate start and end points for each line segment of the square grid.
511
+ # `(i * self.side_length_factor)` shifts the line perpendicular to the orientation `theta`.
512
+ # `self.side_length/2` extends the line segment along the orientation `theta`.
513
+
514
+ # Start point (x,y) for the current line
515
+ start_x = x + (i * self.side_length_factor) * tf.cos(
516
+ theta + np.pi / 2) - self.side_length / 2 * tf.cos(theta)
517
+ start_y = y + (i * self.side_length_factor) * tf.sin(
518
+ theta + np.pi / 2) - self.side_length / 2 * tf.sin(theta)
519
+
520
+ # End point (x,y) for the current line
521
+ end_x = x + (i * self.side_length_factor) * tf.cos(
522
+ theta + np.pi / 2) + self.side_length / 2 * tf.cos(theta)
523
+ end_y = y + (i * self.side_length_factor) * tf.sin(
524
+ theta + np.pi / 2) + self.side_length / 2 * tf.sin(theta)
525
+
526
+ # Stack start and end points for linspace
527
+ line_starts = tf.stack([start_x, start_y], axis=-1) # (m, 2)
528
+ line_ends = tf.stack([end_x, end_y], axis=-1) # (m, 2)
529
+
530
+ # Generate `self.pts_per_side` points along each line segment.
531
+ # `axis=1` ensures interpolation is done column-wise for each (start, end) pair.
532
+ # The result is (m, pts_per_side, 2) for each `i`.
533
+ points.append(
534
+ tf.linspace(line_starts, line_ends, self.pts_per_side, axis=1))
535
+
536
+ # Concatenate all generated line segments.
537
+ # `tf.concat` will stack them along a new axis, forming (num_lines, m, pts_per_side, 2)
538
+ xy = tf.concat(
539
+ points, axis=1
540
+ ) # (m, pts_per_side * pts_per_side, 2) after the transpose in the original code.
541
+
542
+ xy = tf.reshape(xy, (-1, 2))
543
+ return xy
544
+
545
+ def distance(self, Xu: tf.Tensor) -> tf.Tensor:
546
+ """
547
+ Computes the Euclidean distance incurred by sequentially visiting the inducing points.
548
+ For a Square FoV, the distance is typically only based on the (x,y) movement,
549
+ ignoring the angle.
550
+
551
+ Args:
552
+ Xu (tf.Tensor): Inducing points.
553
+ Shape: (m, 3) where `m` is the number of inducing points,
554
+ and `3` corresponds to (x, y, angle).
555
+
556
+ Returns:
557
+ tf.Tensor: A scalar tensor representing the total path length.
558
+ """
559
+ # Reshape to (number_of_points, 3) and take only the (x,y) coordinates
560
+ Xu_xy = tf.reshape(
561
+ Xu, (-1, self.num_dim))[:, :2] # Assuming num_dim is 3 (x,y,angle)
562
+
563
+ if Xu_xy.shape[0] < 2:
564
+ return tf.constant(0.0, dtype=float_type)
565
+
566
+ # Calculate Euclidean distance between consecutive (x,y) points
567
+ segment_distances = tf.norm(Xu_xy[1:] - Xu_xy[:-1], axis=-1)
568
+ total_distance = tf.reduce_sum(segment_distances, axis=0)
569
+ return total_distance
570
+
571
+
572
+ class SquareHeightTransform(Transform):
573
+ """
574
+ Non-point Transform to model a height-dependent square Field of View (FoV).
575
+ The size of the square FoV changes with the 'height' (z-dimension) of the sensor.
576
+ This transform expands each inducing point (waypoint with x, y, z coordinates)
577
+ into a grid of points approximating a square area whose size depends on 'z'.
578
+ """
579
+
580
+ def __init__(self,
581
+ pts_per_side: int,
582
+ aggregate_fov: bool = False,
583
+ **kwargs: Any):
584
+ """
585
+ Initializes the SquareHeightTransform for a height-dependent square FoV.
586
+
587
+ Args:
588
+ pts_per_side (int): The number of points to sample along each side of the square FoV.
589
+ A `pts_per_side` of 3 will create a 3x3 grid of 9 points to approximate the FoV.
590
+ aggregate_fov (bool): If True, aggregation will be enabled for the expanded FoV points.
591
+ This averages covariances from the FoV points to reduce computational cost.
592
+ Defaults to False.
593
+ **kwargs (Any): Additional keyword arguments passed to the base `Transform` constructor.
594
+
595
+ Usage:
596
+ ```python
597
+ # Create a height-dependent square FoV approximated by a 7x7 grid
598
+ square_height_fov_transform = SquareHeightTransform(pts_per_side=7, aggregate_fov=True)
599
+ ```
600
+ """
601
+ super().__init__(**kwargs)
602
+ self.pts_per_side = pts_per_side
603
+
604
+ if aggregate_fov:
605
+ self.enable_aggregation()
606
+
607
+ def enable_aggregation(self, size: Optional[int] = None) -> None:
608
+ """
609
+ Enables FoV covariance aggregation, which reduces the covariance matrix inversion
610
+ cost by effectively reducing the covariance matrix size.
611
+
612
+ Args:
613
+ size (Optional[int]): If None, all the interpolated inducing points within the FoV
614
+ (i.e., `pts_per_side^2` points) are aggregated into a single aggregated point.
615
+ Alternatively, the number of inducing points to aggregate can be
616
+ explicitly defined using this variable. Defaults to None.
617
+ """
618
+ if size is None:
619
+ self.aggregation_size = self.pts_per_side**2
620
+ else:
621
+ self.aggregation_size = size
622
+
623
+ def expand(self, Xu):
624
+ """
625
+ Applies the expansion transform to the inducing points
626
+
627
+ Args:
628
+ Xu (ndarray): (m, 3); Inducing points in the 3D position space.
629
+ `m` is the number of inducing points,
630
+ `3` is the dimension of the space (x, y, z)
631
+
632
+ Returns:
633
+ Xu (ndarray): (mp, 2); Inducing points in input space.
634
+ `p` is the number of points each inducing point is mapped
635
+ to in order to form the FoV.
636
+ """
637
+ x, y, h = tf.split(Xu, num_or_size_splits=3, axis=1)
638
+ x = tf.reshape(x, [
639
+ -1,
640
+ ])
641
+ y = tf.reshape(y, [
642
+ -1,
643
+ ])
644
+ h = tf.reshape(h, [
645
+ -1,
646
+ ])
647
+
648
+ delta = h / (self.pts_per_side - 1)
649
+
650
+ pts = []
651
+ for i in range(self.pts_per_side):
652
+ line_starts = [x - h / 2, y - (h / 2) + (delta * i)]
653
+ line_ends = [x + h / 2, y - (h / 2) + (delta * i)]
654
+ pts.append(
655
+ tf.linspace(line_starts, line_ends, self.pts_per_side, axis=1))
656
+ xy = tf.concat(pts, axis=1)
657
+ xy = tf.transpose(xy, [2, 1, 0])
658
+ xy = tf.reshape(xy, [-1, 2])
659
+ xy = self._reshape(xy, tf.shape(Xu)[0])
660
+ return xy
661
+
662
+ def _reshape(self, X: tf.Tensor, num_inducing: tf.Tensor) -> tf.Tensor:
663
+ """
664
+ Reorders the expanded inducing points.
665
+
666
+ Args:
667
+ X (tf.Tensor): Expanded inducing points.
668
+ num_inducing (tf.Tensor): Original number of inducing points.
669
+
670
+ Returns:
671
+ tf.Tensor: Reordered expanded inducing points.
672
+ """
673
+ X = tf.reshape(
674
+ X, (num_inducing, -1, self.pts_per_side, self.pts_per_side, 2))
675
+ X = tf.transpose(X, (0, 2, 1, 3, 4))
676
+ X = tf.reshape(X, (-1, 2))
677
+ return X
678
+
679
+ def distance(self, Xu: tf.Tensor) -> tf.Tensor:
680
+ """
681
+ Computes the Euclidean distance incurred by sequentially visiting the inducing points.
682
+ For a height-dependent Square FoV, the distance is typically only based on the
683
+ (x,y,z) movement.
684
+
685
+ Args:
686
+ Xu (tf.Tensor): Inducing points.
687
+ Shape: (m, 3) where `m` is the number of inducing points,
688
+ and `3` corresponds to (x, y, z).
689
+
690
+ Returns:
691
+ tf.Tensor: A scalar tensor representing the total path length.
692
+ """
693
+ # Reshape to (number_of_points, 3)
694
+ Xu_xyz = tf.reshape(Xu, (-1, 3))
695
+
696
+ # Calculate Euclidean distance between consecutive (x,y,z) points
697
+ segment_distances = tf.norm(Xu_xyz[1:] - Xu_xyz[:-1], axis=-1)
698
+ total_distance = tf.reduce_sum(segment_distances, axis=0)
699
+ return total_distance