pfc-geometry 0.2.13__py3-none-any.whl → 0.2.15__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.
geometry/base.py CHANGED
@@ -10,7 +10,9 @@ You should have received a copy of the GNU General Public License along with
10
10
  this program. If not, see <http://www.gnu.org/licenses/>.
11
11
  """
12
12
 
13
- from typing import Self
13
+ from __future__ import annotations
14
+ from typing import Self, Literal
15
+ from httpx import get
14
16
  import numpy as np
15
17
  import numpy.typing as npt
16
18
  import pandas as pd
@@ -92,6 +94,14 @@ class Base:
92
94
  else:
93
95
  raise TypeError(f"Empty {self.__class__.__name__} not allowed")
94
96
 
97
+ def to_numpy(self, cols: str | list = None):
98
+ cols = self.cols if cols is None else cols
99
+ return np.column_stack([getattr(self, c) for c in cols])
100
+
101
+ @classmethod
102
+ def from_numpy(Cls, data: npt.NDArray, cols: str | list):
103
+ return Cls(np.column_stack([data[:, cols.index(col)] for col in Cls.cols]))
104
+
95
105
  @classmethod
96
106
  def _clean_data(cls, data) -> npt.NDArray[np.float64]:
97
107
  assert isinstance(data, np.ndarray)
@@ -247,14 +257,17 @@ class Base:
247
257
  def dot(self, other: Self) -> Self:
248
258
  return np.einsum("ij,ij->i", self.data, other)
249
259
 
250
- def diff(self, dt: np.array) -> Self:
260
+ def diff(
261
+ self, dt: npt.NDArray, method: Literal["gradient", "diff"] = "gradient"
262
+ ) -> Self:
251
263
  if not pd.api.types.is_list_like(dt):
252
264
  dt = np.full(len(self), dt)
253
- assert len(dt) == len(self)
254
- return self.__class__(
255
- np.gradient(self.data, axis=0)
256
- / np.tile(dt, (len(self.__class__.cols), 1)).T
257
- )
265
+ self, dt = Base.length_check(self, dt)
266
+ diff_method = np.gradient if method == "gradient" else np.diff
267
+
268
+ data = diff_method(self.data, axis=0)
269
+ dt = dt if method == "gradient" else dt[:-1]
270
+ return self.__class__(data / np.tile(dt, (len(self.__class__.cols), 1)).T)
258
271
 
259
272
  def to_pandas(self, prefix="", suffix="", columns=None, index=None):
260
273
  if columns is not None:
@@ -263,6 +276,10 @@ class Base:
263
276
  cols = [prefix + col + suffix for col in self.__class__.cols]
264
277
  return pd.DataFrame(self.data, columns=cols, index=index)
265
278
 
279
+ @property
280
+ def df(self):
281
+ return self.to_pandas()
282
+
266
283
  def tile(self, count) -> Self:
267
284
  return self.__class__(np.tile(self.data, (count, 1)))
268
285
 
@@ -347,15 +364,81 @@ class Base:
347
364
  def fill_zeros(self):
348
365
  """fills zero length rows with the previous or next non-zero value"""
349
366
  return self.__class__(
350
- pd.DataFrame(np.where(
351
- np.tile(abs(self) == 0, (3, 1)).T,
352
- np.full(self.data.shape, np.nan),
353
- self.data,
354
- )).ffill().bfill().to_numpy()
367
+ pd.DataFrame(
368
+ np.where(
369
+ np.tile(abs(self) == 0, (3, 1)).T,
370
+ np.full(self.data.shape, np.nan),
371
+ self.data,
372
+ )
373
+ )
374
+ .ffill()
375
+ .bfill()
376
+ .to_numpy()
355
377
  )
356
378
 
357
379
  def ffill(self):
358
380
  return self.__class__(pd.DataFrame(self.data).ffill().to_numpy())
359
381
 
360
382
  def bfill(self):
361
- return self.__class__(pd.DataFrame(self.data).bfill().to_numpy())
383
+ return self.__class__(pd.DataFrame(self.data).bfill().to_numpy())
384
+
385
+ def linterp(
386
+ self,
387
+ index: npt.NDArray | pd.Index,
388
+ extrapolate: Literal["throw", "nearest"] = "throw",
389
+ ):
390
+ "linear interpolation"
391
+ index = pd.Index(np.arange(len(self)) if index is None else index)
392
+ assert len(index) == len(self)
393
+ assert pd.Index(index).is_monotonic_increasing
394
+
395
+ def dolinterp(ts: npt.NDArray | Number):
396
+ starts = index.get_indexer(ts, method="ffill")
397
+ stops = index.get_indexer(ts, method="bfill")
398
+ if np.any(starts * stops < 0) and extrapolate=="throw":
399
+ raise Exception("Cannot extrapolate beyond parent range")
400
+ return self.__class__(np.column_stack(
401
+ [
402
+ np.interp(
403
+ ts, index, self.data[:, i], self.data[0, i], self.data[-1, i]
404
+ )
405
+ for i, col in enumerate(self.cols)
406
+ ]
407
+ ))
408
+ # return lambda t: a + (b - a) * np.clip(t, 0, 1)
409
+ return dolinterp
410
+
411
+ def bspline(self, index: npt.NDArray | pd.Index = None):
412
+ from scipy.interpolate import make_interp_spline
413
+
414
+ bspline = make_interp_spline(
415
+ np.arange(len(self)) if index is None else index, self.data, axis=0
416
+ )
417
+ return lambda i: self.__class__(bspline(i))
418
+
419
+ def interpolate(self, index: npt.NDArray | pd.Index = None, method:str=None):
420
+ if method is None:
421
+ match (self.__class__.__name__):
422
+ case "Point":
423
+ method="bspline"
424
+ case "Quaternion":
425
+ method="slerp"
426
+ case "Time":
427
+ method="linterp"
428
+ return getattr(self, method)(index)
429
+
430
+ def plot(self, index=None, **kwargs):
431
+ import plotly.graph_objects as go
432
+
433
+ fig = go.Figure()
434
+ for col in self.cols:
435
+ fig.add_trace(
436
+ go.Scatter(
437
+ x=np.arange(len(self)) if index is None else index,
438
+ y=getattr(self, col),
439
+ name=col,
440
+ **kwargs,
441
+ )
442
+ )
443
+ # df = self.to_pandas(self.__class__.__name__[0], index=index)
444
+ return fig
geometry/gps.py CHANGED
@@ -13,6 +13,7 @@ import math
13
13
  from geometry.base import Base
14
14
  from geometry.point import Point
15
15
  from typing import List, Union
16
+ import numpy.typing as npt
16
17
  import numpy as np
17
18
  import pandas as pd
18
19
 
@@ -71,6 +72,15 @@ class GPS(Base):
71
72
  self.alt - pin.z
72
73
  )
73
74
 
75
+ def bspline(self, index: npt.NDArray | pd.Index = None):
76
+
77
+ def interpolator(i):
78
+ ps: Point = self - self[0]
79
+ ips = ps.bspline(index)(i)
80
+ return self[0].offset(ips)
81
+
82
+ return interpolator
83
+
74
84
 
75
85
  '''
76
86
  Extract from ardupilot:
geometry/point.py CHANGED
@@ -9,7 +9,9 @@ FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
9
9
  You should have received a copy of the GNU General Public License along with
10
10
  this program. If not, see <http://www.gnu.org/licenses/>.
11
11
  """
12
+
12
13
  from __future__ import annotations
14
+ from typing import Literal
13
15
  from .base import Base
14
16
  import numpy as np
15
17
  import pandas as pd
@@ -19,22 +21,38 @@ import numpy.typing as npt
19
21
 
20
22
 
21
23
  class Point(Base):
22
- cols=["x", "y", "z"]
24
+ cols = ["x", "y", "z"]
23
25
  from_np = [
24
- "sin","cos","tan",
25
- "arcsin","arccos","arctan",
26
+ "sin",
27
+ "cos",
28
+ "tan",
29
+ "arcsin",
30
+ "arccos",
31
+ "arctan",
26
32
  ]
27
33
 
34
+ @property
35
+ def xy(self):
36
+ return Point(self.x, self.y, np.zeros(len(self)))
37
+
38
+ @property
39
+ def yz(self):
40
+ return Point(np.zeros(len(self)), self.y, self.z)
41
+
42
+ @property
43
+ def zx(self):
44
+ return Point(self.x, np.zeros(len(self)), self.z)
45
+
28
46
  def scale(self, value) -> Point:
29
47
  with np.errstate(divide="ignore"):
30
- res = value/abs(self)
31
- res[res==np.inf] = 0
48
+ res = value / abs(self)
49
+ res[res == np.inf] = 0
32
50
  return self * res
33
-
51
+
34
52
  def unit(self) -> Point:
35
53
  return self.scale(1)
36
54
 
37
- def remove_outliers(self, nstds = 2):
55
+ def remove_outliers(self, nstds=2):
38
56
  ab = abs(self)
39
57
  std = np.nanstd(ab)
40
58
  mean = np.nanmean(ab)
@@ -50,69 +68,123 @@ class Point(Base):
50
68
 
51
69
  def max(self):
52
70
  return Point(np.max(self.data, axis=0))
53
-
71
+
54
72
  def min(self):
55
73
  return Point(np.min(self.data, axis=0))
56
74
 
57
75
  def angles(self, p2):
58
76
  return (self.cross(p2) / (abs(self) * abs(p2))).arcsin
59
-
77
+
60
78
  def planar_angles(self):
61
- return Point(np.arctan2(self.y, self.z), np.arctan2(self.z, self.x), np.arctan2(self.x, self.y))
79
+ return Point(
80
+ np.arctan2(self.y, self.z),
81
+ np.arctan2(self.z, self.x),
82
+ np.arctan2(self.x, self.y),
83
+ )
62
84
 
63
85
  def angle(self, p2):
64
86
  return abs(Point.angles(self, p2))
65
-
87
+
66
88
  @staticmethod
67
- def X(value: Number | npt.NDArray=1, count=1):
68
- return np.tile(value, count) * Point(1,0,0)
89
+ def X(value: Number | npt.NDArray = 1, count=1):
90
+ return np.tile(value, count) * Point(1, 0, 0)
69
91
 
70
92
  @staticmethod
71
93
  def Y(value=1, count=1):
72
- return np.tile(value, count) * Point(0,1,0)
94
+ return np.tile(value, count) * Point(0, 1, 0)
73
95
 
74
96
  @staticmethod
75
97
  def Z(value=1, count=1):
76
- return np.tile(value, count) * Point(0,0,1)
98
+ return np.tile(value, count) * Point(0, 0, 1)
77
99
 
78
100
  def rotate(self, rmat=np.ndarray):
79
101
  if len(rmat.shape) == 3:
80
102
  pass
81
103
  elif len(rmat.shape) == 2:
82
- rmat = np.reshape(rmat, (1, 3, 3 ))
104
+ rmat = np.reshape(rmat, (1, 3, 3))
83
105
  else:
84
106
  raise TypeError("expected a 3x3 matrix")
85
-
107
+
86
108
  return self.dot(rmat)
87
109
 
88
110
  def to_rotation_matrix(self):
89
- '''returns the rotation matrix based on a point representing Euler angles'''
111
+ """returns the rotation matrix based on a point representing Euler angles"""
90
112
  s = self.sin
91
113
  c = self.cos
92
- return np.array([
93
- [
94
- c.z * c.y,
95
- c.z * s.y * s.x - c.x * s.z,
96
- c.x * c.z * s.y + s.x * s.z
97
- ], [
98
- c.y * s.z,
99
- c.x * c.z + s.x * s.y * s.z,
100
- -1 * c.z * s.x + c.x * s.y * s.z
101
- ],
102
- [
103
- -1 * s.y,
104
- c.y * s.x,
105
- c.x * c.y
106
- ]
107
- ]).T
114
+ return np.transpose(
115
+ np.array(
116
+ [
117
+ [
118
+ c.z * c.y,
119
+ c.z * s.y * s.x - c.x * s.z,
120
+ c.x * c.z * s.y + s.x * s.z,
121
+ ],
122
+ [
123
+ c.y * s.z,
124
+ c.x * c.z + s.x * s.y * s.z,
125
+ -1 * c.z * s.x + c.x * s.y * s.z,
126
+ ],
127
+ [-1 * s.y, c.y * s.x, c.x * c.y],
128
+ ]
129
+ ),
130
+ (2, 0, 1),
131
+ )
132
+
133
+ def matrix(self):
134
+ return np.einsum("i...,...->i...", self.data, np.identity(3))
135
+
136
+ @staticmethod
137
+ def from_matrix(matrix):
138
+ return Point(matrix[:, 0, 0], matrix[:, 1, 1], matrix[:, 2, 2])
139
+
140
+ def skew_symmetric(self):
141
+ o = np.zeros(len(self))
142
+ return np.transpose(
143
+ np.array(
144
+ [[o, -self.z, self.y], [self.z, o, -self.x], [-self.y, self.x, o]]
145
+ ),
146
+ (2, 0, 1),
147
+ )
108
148
 
109
149
  @staticmethod
110
150
  def zeros(count=1):
111
- return Point(np.zeros((count,3)))
151
+ return Point(np.zeros((count, 3)))
112
152
 
113
153
  def bearing(self):
114
154
  return np.arctan2(self.y, self.x)
115
155
 
156
+ def plot3d(self, **kwargs):
157
+ import plotly.graph_objects as go
158
+ fig = go.Figure()
159
+
160
+ fig.add_trace(go.Scatter3d(x=self.x, y=self.y, z=self.z, **kwargs))
161
+ fig.update_layout(
162
+ scene=dict(aspectmode="data"),
163
+ )
164
+ return fig
165
+
166
+ def plotxy(self):
167
+ import plotly.express as px
168
+
169
+ return px.line(self.df, x="x", y="y").update_layout(
170
+ yaxis=dict(scaleanchor="x", scaleratio=1)
171
+ )
172
+
173
+ def plotyz(self):
174
+ import plotly.express as px
175
+
176
+ return px.line(self.df, x="y", y="z").update_layout(
177
+ yaxis=dict(scaleanchor="x", scaleratio=1, title="z"), xaxis=dict(title="y")
178
+ )
179
+
180
+ def plotzx(self):
181
+ import plotly.express as px
182
+
183
+ return px.line(self.df, x="z", y="x").update_layout(
184
+ yaxis=dict(scaleanchor="x", scaleratio=1, title="x"), xaxis=dict(title="z")
185
+ )
186
+
187
+
116
188
  def Points(*args, **kwargs):
117
189
  warn("Points is deprecated, you can now just use Point", DeprecationWarning)
118
190
  return Point(*args, **kwargs)
@@ -121,15 +193,19 @@ def Points(*args, **kwargs):
121
193
  def PX(length=1, count=1):
122
194
  return Point.X(length, count)
123
195
 
196
+
124
197
  def PY(length=1, count=1):
125
198
  return Point.Y(length, count)
126
199
 
200
+
127
201
  def PZ(length=1, count=1):
128
202
  return Point.Z(length, count)
129
203
 
204
+
130
205
  def P0(count=1):
131
206
  return Point.zeros(count)
132
207
 
208
+
133
209
  def ppmeth(func):
134
210
  def wrapper(a, b, *args, **kwargs):
135
211
  assert all([isinstance(arg, Point) for arg in args])
@@ -143,47 +219,56 @@ def ppmeth(func):
143
219
  @ppmeth
144
220
  def cross(a: Point, b: Point) -> Point:
145
221
  return Point(np.cross(a.data, b.data))
146
-
222
+
147
223
 
148
224
  @ppmeth
149
225
  def cos_angle_between(a: Point, b: Point) -> np.ndarray:
150
226
  return a.unit().dot(b.unit())
151
227
 
228
+
152
229
  @ppmeth
153
230
  def angle_between(a: Point, b: Point) -> np.ndarray:
154
231
  return np.arccos(a.cos_angle_between(b))
155
232
 
233
+
156
234
  @ppmeth
157
235
  def scalar_projection(a: Point, b: Point) -> Point:
158
236
  return a.cos_angle_between(b) * abs(a)
159
237
 
238
+
160
239
  @ppmeth
161
240
  def vector_projection(a: Point, b: Point) -> Point:
162
241
  return b.scale(a.scalar_projection(b))
163
242
 
243
+
164
244
  @ppmeth
165
245
  def vector_rejection(a: Point, b: Point) -> Point:
166
- return a - ((Point.dot(a, b)) / Point.dot(b,b)) * b
246
+ return a - ((Point.dot(a, b)) / Point.dot(b, b)) * b
167
247
 
168
248
 
169
249
  @ppmeth
170
250
  def is_parallel(a: Point, b: Point, tolerance=1e-6):
171
251
  return abs(a.cos_angle_between(b) - 1) < tolerance
172
252
 
253
+
173
254
  @ppmeth
174
255
  def is_perpendicular(a: Point, b: Point, tolerance=1e-6):
175
256
  return abs(a.dot(b)) < tolerance
176
257
 
258
+
177
259
  @ppmeth
178
260
  def min_angle_between(p1: Point, p2: Point):
179
261
  angle = angle_between(p1, p2) % np.pi
180
262
  return np.minimum(angle, np.pi - angle)
181
263
 
264
+
182
265
  def arbitrary_perpendicular(v: Point) -> Point:
183
266
  return Point(-v.y, v.x, 0).unit()
184
267
 
268
+
185
269
  def vector_norm(point: Point):
186
270
  return abs(point)
187
271
 
272
+
188
273
  def normalize_vector(point: Point):
189
- return point / abs(point)
274
+ return point / abs(point)
geometry/quaternion.py CHANGED
@@ -11,13 +11,14 @@ this program. If not, see <http://www.gnu.org/licenses/>.
11
11
  """
12
12
  from __future__ import annotations
13
13
  from .point import Point
14
- from .base import Base, dprep
14
+ from .base import Base
15
15
  from geometry import PZ
16
16
  import numpy as np
17
17
  import numpy.typing as npt
18
18
  import pandas as pd
19
19
  from warnings import warn
20
20
  from numbers import Number
21
+ from typing import Callable, Literal
21
22
 
22
23
 
23
24
  class Quaternion(Base):
@@ -179,10 +180,10 @@ class Quaternion(Base):
179
180
  def body_rotate(self, rate: Point) -> Quaternion:
180
181
  return (self * Quaternion.from_axis_angle(rate)).norm()
181
182
 
182
- def diff(self, dt: Number | npt.NDArray) -> Point:
183
+ def diff(self, dt: Number | npt.NDArray = None) -> Point:
183
184
  """differentiate in the world frame"""
184
185
  if not pd.api.types.is_list_like(dt):
185
- dt = np.full(len(self), dt)
186
+ dt = np.full(len(self), 1 if not dt else dt)
186
187
  assert len(dt) == len(self)
187
188
  dt = dt * len(dt) / (len(dt) - 1)
188
189
 
@@ -192,10 +193,10 @@ class Quaternion(Base):
192
193
  ) / dt[:-1]
193
194
  return Point(np.vstack([ps.data, ps.data[-1,:]]))
194
195
 
195
- def body_diff(self, dt: Number | npt.NDArray) -> Point:
196
+ def body_diff(self, dt: Number | npt.NDArray = None) -> Point:
196
197
  """differentiate in the body frame"""
197
198
  if not pd.api.types.is_list_like(dt):
198
- dt = np.full(len(self), dt)
199
+ dt = np.full(len(self), 1 if not dt else dt)
199
200
  assert len(dt) == len(self)
200
201
  dt = dt * len(dt) / (len(dt) - 1)
201
202
 
@@ -258,7 +259,68 @@ class Quaternion(Base):
258
259
  p = Point.X()
259
260
  return self.transform_point(p).bearing()
260
261
 
261
-
262
+ def slerp(self, index: pd.Index | npt.NDArray = None, extrapolate:Literal["throw", "nearest"]="throw"):
263
+ index = pd.Index(np.arange(len(self)) if index is None else index)
264
+
265
+ assert len(index) == len(self)
266
+ assert pd.Index(index).is_monotonic_increasing
267
+ from rowan.interpolate import slerp
268
+ def doslerp(ts: npt.NDArray | Number) -> Quaternion:
269
+ starts = index.get_indexer(ts, method='ffill')
270
+ stops = index.get_indexer(ts, method='bfill')
271
+
272
+ #case interpolate match (start == stop - 1)
273
+ odata = slerp(
274
+ self[starts].to_numpy("xyzw"),
275
+ self[stops].to_numpy("xyzw"),
276
+ (ts - index[starts]) / (index[stops] - index[starts]),
277
+ True
278
+ )
279
+
280
+ #case exact match (start == stop)
281
+ exacts = starts == stops
282
+ odata[exacts] = self.to_numpy("xyzw")[starts[exacts]]
283
+
284
+ #case outside range above (start == index[-1], stop== -1)
285
+ aboves = stops==-1
286
+ if np.any(aboves):
287
+ if extrapolate=="throw":
288
+ raise Exception("Cannot slerp beyond range")
289
+ else:
290
+ odata[aboves] = self.to_numpy("xyzw")[-1, :]
291
+ #case outside range below (start == -1, stop==index[0])
292
+ belows = starts==-1
293
+ if np.any(belows):
294
+ if extrapolate=="throw":
295
+ raise Exception("Cannot slerp beyond range")
296
+ else:
297
+ odata[belows] = self.to_numpy("xyzw")[0, :]
298
+
299
+ return Quaternion.from_numpy( odata, "xyzw")
300
+
301
+ return doslerp
302
+
303
+
304
+ # @staticmethod
305
+ # def slerp(a: Quaternion, b: Quaternion):
306
+ # """spherical linear interpolation"""
307
+ # from rowan.interpolate import slerp
308
+ # def doslerp(t):
309
+ # xyzw = slerp(a.xyzw, b.xyzw, np.clip(t, 0, 1))
310
+ # return Quaternion(xyzw[:,3], xyzw[:,0], xyzw[:,1], xyzw[:,2])
311
+ # return doslerp
312
+
313
+ @staticmethod
314
+ def squad(p: Quaternion, a: Quaternion, b: Quaternion, q: Quaternion):
315
+ from rowan.interpolate import squad
316
+ def dosquad(t):
317
+ xyzq = squad(p.xyzw, a.xyzw, b.xyzw, q.xyzw, np.clip(t, 0, 1))
318
+ return Quaternion(xyzq[:,3], xyzq[:,0], xyzq[:,1], xyzq[:,2])
319
+ return dosquad
320
+
321
+ def plot_3d(self, size: float=3, vis:Literal["coord", "plane"]="coord"):
322
+ from geometry import Transformation
323
+ return Transformation(self).plot_3d(size, vis)
262
324
 
263
325
  def Q0(count=1):
264
326
  return Quaternion.zero(count)
geometry/time.py CHANGED
@@ -1,9 +1,12 @@
1
1
  from __future__ import annotations
2
2
  from geometry import Base
3
3
  from numbers import Number
4
- from typing import Self
4
+ from typing import Self, Literal
5
5
  import numpy as np
6
+ import numpy.typing as npt
7
+ import pandas as pd
6
8
  from time import time
9
+ from geometry.utils import get_index
7
10
 
8
11
 
9
12
  class Time(Base):
@@ -22,9 +25,13 @@ class Time(Base):
22
25
  return Time(t, dt)
23
26
 
24
27
  @staticmethod
25
- def uniform(duration: float, npoints: int | None, minpoints:int=1) -> Time:
28
+ def uniform(duration: float, npoints: int | None, minpoints: int = 1) -> Time:
26
29
  return Time.from_t(
27
- np.linspace(0, duration, npoints if npoints else max(int(np.ceil(duration * 25)), minpoints))
30
+ np.linspace(
31
+ 0,
32
+ duration,
33
+ npoints if npoints else max(int(np.ceil(duration * 25)), minpoints),
34
+ )
28
35
  )
29
36
 
30
37
  def scale(self, duration) -> Self:
@@ -41,3 +48,39 @@ class Time(Base):
41
48
 
42
49
  def extend(self):
43
50
  return Time.concatenate([self, Time(self.t[-1] + self.dt[-1], self.dt[-1])])
51
+
52
+ def linterp(
53
+ self,
54
+ index: npt.NDArray | pd.Index,
55
+ extrapolate: Literal["throw", "nearest"] = "throw",
56
+ ):
57
+ """linear interpolation between two times"""
58
+ index = pd.Index(np.arange(len(self)) if index is None else index)
59
+ assert len(index) == len(self)
60
+ assert pd.Index(index).is_monotonic_increasing
61
+
62
+ def dolinterp(ts: npt.NDArray | pd.Index):
63
+ starts = index.get_indexer(ts, method="ffill")
64
+ stops = index.get_indexer(ts, method="bfill")
65
+ if np.any(starts * stops < 0) and extrapolate == "throw":
66
+ raise Exception("Cannot extrapolate beyond parent range")
67
+
68
+ new_time = Time.from_t(
69
+ np.interp(ts, index, self.t, self.t[0], self.t[-1])
70
+ )
71
+
72
+ last_p = self[stops][-1]
73
+ if last_p.t[0] == new_time.t[-1]:
74
+ last_dt = last_p.dt[-1]
75
+ else:
76
+ last_dt = last_p.t[0] - new_time.t[-1]
77
+ new_time.data[-1,-1] = last_dt
78
+ return new_time
79
+ return dolinterp
80
+
81
+ def interpolate_t(self, t: float):
82
+ """get the floating point index at a given time"""
83
+ return get_index(self.t, t)
84
+
85
+ def __add__(self, t: float):
86
+ return Time.from_t(self.t + t)
@@ -12,8 +12,7 @@ this program. If not, see <http://www.gnu.org/licenses/>.
12
12
  from geometry import Base, Point, Quaternion, P0, Q0, Coord
13
13
 
14
14
  import numpy as np
15
- from typing import Union
16
- from typing import Self
15
+ from typing import Self, Literal
17
16
 
18
17
 
19
18
  class Transformation(Base):
@@ -24,17 +23,23 @@ class Transformation(Base):
24
23
  args = np.concatenate([P0().data,Q0().data],axis=1)
25
24
  elif len(args) == 1:
26
25
  if isinstance(args[0], Point):
27
- args = np.concatenate([args[0].data,Q0().data],axis=1)
26
+ args = np.concatenate([args[0].data,Q0(len(args[0])).data],axis=1).T
28
27
  elif isinstance(args[0], Quaternion):
29
- args = np.concatenate([P0().data,args[0].data],axis=1)
28
+ args = np.concatenate([P0(len(args[0])).data,args[0].data],axis=1).T
30
29
  elif len(args) == 2:
31
30
  _q = args[0] if isinstance(args[0], Quaternion) else args[1]
32
31
  _p = args[0] if isinstance(args[0], Point) else args[1]
33
32
  assert isinstance(_q, Quaternion) and isinstance(_p, Point), f'expected a Point and a Quaternion, got a {_p.__class__.__name__} and a {_q.__class__.__name__}'
34
- args = [np.concatenate([_p.data, _q.data], axis=1)]
33
+ args = np.concatenate([_p.data, _q.data], axis=1).T
35
34
  super().__init__(*args, **kwargs)
36
- self.p = Point(self.data[:,:3])
37
- self.q = Quaternion(self.data[:,3:])
35
+
36
+ @property
37
+ def p(self):
38
+ return Point(self.data[:,:3])
39
+
40
+ @property
41
+ def q(self):
42
+ return Quaternion(self.data[:,3:])
38
43
 
39
44
  def offset(self, p: Point):
40
45
  return Transformation(self.p + p, self.q)
@@ -91,7 +96,7 @@ class Transformation(Base):
91
96
  )
92
97
 
93
98
 
94
- def apply(self, oin: Union[Point, Quaternion, Self, Coord]):
99
+ def apply(self, oin: Point | Quaternion | Self | Coord):
95
100
  if isinstance(oin, Point):
96
101
  return self.point(oin)
97
102
  elif isinstance(oin, Quaternion):
@@ -102,7 +107,7 @@ class Transformation(Base):
102
107
  return Transformation(self.apply(oin.p), self.apply(oin.q))
103
108
 
104
109
 
105
- def rotate(self, oin: Union[Point, Quaternion]):
110
+ def rotate(self, oin: Point | Quaternion):
106
111
  if isinstance(oin, Point):
107
112
  return self.q.transform_point(oin)
108
113
  elif isinstance(oin, Quaternion):
@@ -128,3 +133,14 @@ class Transformation(Base):
128
133
  outarr[:, 3,:3] = self.translation.data
129
134
  return outarr
130
135
 
136
+
137
+ def plot_3d(self, size: float=3, vis:Literal["coord", "plane"]="coord"):
138
+ import plotly.graph_objects as go
139
+ from plotting.traces import axestrace, meshes
140
+ import plotting.templates
141
+ fig = go.Figure(layout=dict(template="generic3d+clean_paper"))
142
+ if vis=="coord":
143
+ fig.add_traces(axestrace(self, length=size))
144
+ elif vis=="plane":
145
+ fig.add_traces(meshes(len(self), self, scale=size))
146
+ return fig
geometry/utils.py ADDED
@@ -0,0 +1,99 @@
1
+ from numbers import Number
2
+ from typing import Callable, Literal
3
+ import numpy.typing as npt
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+
8
+ def handle_slice(fun: Callable[[npt.NDArray, Number], Number]):
9
+ def inner(
10
+ arr: npt.NDArray, value: slice | Number | npt.ArrayLike | None, *args, **kwargs
11
+ ) -> slice | Number | None:
12
+ if isinstance(value, slice):
13
+ start = (
14
+ fun(arr, value.start, *args, **kwargs)
15
+ if value.start is not None
16
+ else None
17
+ )
18
+ stop = (
19
+ fun(arr, value.stop, *args, **kwargs)
20
+ if value.stop is not None
21
+ else None
22
+ )
23
+ step = None # TODO not sure how to handle this
24
+ return slice(start, stop, step)
25
+ elif pd.api.types.is_list_like(value):
26
+ return [fun(arr, val, *args, **kwargs) for val in value]
27
+ else:
28
+ return None if value is None else fun(arr, value, *args, **kwargs)
29
+
30
+ return inner
31
+
32
+
33
+ @handle_slice
34
+ def get_index(
35
+ arr: npt.NDArray,
36
+ value: Number,
37
+ missing: float | Literal["throw"] = "throw",
38
+ direction: Literal["forward", "backward"] = "forward",
39
+ ):
40
+ """given a value, find the index of the first location in the aray,
41
+ if no exact match, linearly interpolate in the index
42
+ assumes arr is monotonic increasing
43
+ raise value error outside of bounds and missing == "throw", else return missing"""
44
+ increasing = np.sign(np.diff(arr).mean())
45
+ res = np.argwhere(arr == value)
46
+ if len(res):
47
+ return res[0 if direction == "forward" else -1, 0]
48
+ # res[:,0]
49
+ if value > arr.max() or value < arr.min():
50
+ if missing == "throw":
51
+ raise ValueError(f"Time {value} is out of bounds")
52
+ else:
53
+ return missing
54
+
55
+ i0 = np.nonzero(arr <= value if increasing > 0 else arr >= value)[0][-1]
56
+ i1 = i0 + 1
57
+ t0 = arr[i0]
58
+ t1 = arr[i1]
59
+
60
+ return i0 + (value - t0) / (t1 - t0)
61
+
62
+
63
+ @handle_slice
64
+ def get_value(arr: npt.NDArray, index: Number):
65
+ """given an index, find the value in the array
66
+ linearly interpolate if no exact match,
67
+ assumes arr is monotonic increasing"""
68
+ if index > len(arr) - 1:
69
+ raise ValueError(f"Index {index} is out of bounds")
70
+ elif index < 0:
71
+ index = len(arr) + index
72
+ frac = index % 1
73
+ if frac == 0:
74
+ return arr[int(index)]
75
+
76
+ i0 = np.trunc(index)
77
+ i1 = i0 + 1
78
+
79
+ v0 = arr[int(i0)]
80
+ v1 = arr[int(i1)]
81
+ return v0 + (v1 - v0) * frac
82
+
83
+
84
+ def apply_index_slice(index: npt.NDArray, value: slice | Number | npt.ArrayLike | None):
85
+ if isinstance(value, slice):
86
+ middle = pd.Index(index)[
87
+ int(np.ceil(value.start)) if value.start is not None else None : int(
88
+ np.ceil(value.stop)
89
+ )
90
+ if value.stop is not None
91
+ else None
92
+ ]
93
+ if value.start is not None and middle[0] != value.start and value.start > index[0]:
94
+ middle = np.concatenate([[get_value(index, value.start)], middle])
95
+ if value.stop is not None and middle[-1] != value.stop and value.stop < index[-1]:
96
+ middle = np.concatenate([middle, [get_value(index, value.stop)]])
97
+ return middle
98
+ else:
99
+ return index[value]
@@ -1,10 +1,13 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: pfc-geometry
3
- Version: 0.2.13
3
+ Version: 0.2.15
4
4
  Summary: A library for working with 3D geometry.
5
+ License-File: LICENSE
5
6
  Requires-Python: >=3.12
7
+ Requires-Dist: numpy-quaternion>=2024.0.7
6
8
  Requires-Dist: numpy>=2.1.3
7
9
  Requires-Dist: pandas>=2.2.3
10
+ Requires-Dist: rowan>=1.3.2
8
11
  Description-Content-Type: text/markdown
9
12
 
10
13
  # geometry #
@@ -0,0 +1,16 @@
1
+ geometry/__init__.py,sha256=HNhMyemIJzDq1nDjrr09eX5PS7q9ULscSbYsXss3JRM,1253
2
+ geometry/base.py,sha256=lJfqFJz7thRoG1U7WnaykbLMIQjYQMJZBHtRN3nif78,14891
3
+ geometry/checks.py,sha256=o8yMBAdU5Vy0EspBYaof4fPGgRSFZhRDhzBjRPsLd0M,375
4
+ geometry/coordinate_frame.py,sha256=YTCAtCUuIq5LAsO-P9FwFs55f4qpWP9pUP6mf-Nhk54,3145
5
+ geometry/gps.py,sha256=EsokABt40ZoltpAQfKrRc4kA-Lc2ScP_ltJNF7pvAWc,3654
6
+ geometry/mass.py,sha256=BUWBSITwpdRfpJR5-oJTd16BI7FLZt8rhxdzr0cx1HY,1675
7
+ geometry/point.py,sha256=FR2TBAV-YyKei0VA4ECDGA7ah5KRUhSKa2u_pVfAx-s,7057
8
+ geometry/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ geometry/quaternion.py,sha256=J7Yk7qAUsH8SfjhcqQAF7Lw5hk_LGfpotVaVRyJnmeQ,12137
10
+ geometry/time.py,sha256=VTfMHLxhcws8YESYvxxP8W_vSePvl4lwKRXHxFWhJeA,2695
11
+ geometry/transformation.py,sha256=pEJXS7JYxu-f05ELTUUU4w7swcQPAgAxQNI6vPp5VCg,5242
12
+ geometry/utils.py,sha256=N9AQsd1ZOYnOy5QdzpvaKIPRmzMlKsD6KTQersgEUFE,3278
13
+ pfc_geometry-0.2.15.dist-info/METADATA,sha256=UXGOntYl4x1QSSGlCrfiLI-_mcbFGbB8ZKZqVXngWs4,1629
14
+ pfc_geometry-0.2.15.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
15
+ pfc_geometry-0.2.15.dist-info/licenses/LICENSE,sha256=z72U6pv-bQgJ_Svr4uCXnMjemsp38aSerhHEdEAOMJ4,7632
16
+ pfc_geometry-0.2.15.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.26.3
2
+ Generator: hatchling 1.27.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,15 +0,0 @@
1
- geometry/__init__.py,sha256=HNhMyemIJzDq1nDjrr09eX5PS7q9ULscSbYsXss3JRM,1253
2
- geometry/base.py,sha256=yNiO7Fe-S5c4wtvgNmQ2jrX7Yt4tB3eBekYHK0_psYc,11926
3
- geometry/checks.py,sha256=o8yMBAdU5Vy0EspBYaof4fPGgRSFZhRDhzBjRPsLd0M,375
4
- geometry/coordinate_frame.py,sha256=YTCAtCUuIq5LAsO-P9FwFs55f4qpWP9pUP6mf-Nhk54,3145
5
- geometry/gps.py,sha256=Fs3hakSQ754HUqRsA7NWg_MSEdYxNqyiu4gu6EDrFqI,3381
6
- geometry/mass.py,sha256=BUWBSITwpdRfpJR5-oJTd16BI7FLZt8rhxdzr0cx1HY,1675
7
- geometry/point.py,sha256=XF7sdCaCD-si9UAumpyvxzMOTX3utVogJ2tt_7vt7b0,5185
8
- geometry/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
- geometry/quaternion.py,sha256=nggmRu_8xCQNYlf8GcilC4zJZLsac3dwUQKu5pxL_38,9522
10
- geometry/time.py,sha256=ljQxAYxphHU4AE_gdUT7DDTXOF1QvYKpWyGpXw_zWQA,1239
11
- geometry/transformation.py,sha256=eunEC924zPBLhaVSEWE-IClrRfitvUKjSdOTaa2tdDQ,4705
12
- pfc_geometry-0.2.13.dist-info/METADATA,sha256=5H5SFGIU_UzRoAY3uUDF5EVvcDBUksjpv-Miw-TO-c0,1537
13
- pfc_geometry-0.2.13.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
14
- pfc_geometry-0.2.13.dist-info/licenses/LICENSE,sha256=z72U6pv-bQgJ_Svr4uCXnMjemsp38aSerhHEdEAOMJ4,7632
15
- pfc_geometry-0.2.13.dist-info/RECORD,,