pfc-geometry 0.1.0__py3-none-any.whl → 0.2.4__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/__init__.py CHANGED
@@ -9,6 +9,8 @@ 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
+ from .base import Base
13
+ from .time import Time
12
14
  from .point import *
13
15
  from .quaternion import *
14
16
  from .gps import GPS
@@ -17,11 +19,11 @@ from .transformation import Transformation
17
19
  from .mass import Mass
18
20
 
19
21
 
20
- def Euler(*args, **kwargs):
22
+ def Euler(*args, **kwargs) -> Quaternion:
21
23
  return Quaternion.from_euler(Point(*args, **kwargs))
22
24
 
23
25
 
24
- def Euldeg(*args, **kwargs):
26
+ def Euldeg(*args, **kwargs) -> Quaternion:
25
27
  return Quaternion.from_euler(Point(*args, **kwargs).radians())
26
28
 
27
29
 
geometry/base.py CHANGED
@@ -10,8 +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 Type, List
13
+ from typing import Self
14
14
  import numpy as np
15
+ import numpy.typing as npt
15
16
  import pandas as pd
16
17
  from numbers import Number
17
18
 
@@ -47,11 +48,11 @@ class Base:
47
48
  elif "data" in kwargs:
48
49
  args = [kwargs["data"]]
49
50
  else:
50
- raise TypeError("unknown kwargs passed")
51
+ raise TypeError(f"unknown kwargs passed to {self.__class__.__name__}: {args}")
51
52
 
52
53
  if len(args)==1:
53
- if isinstance(args[0], np.ndarray): #data was passed directly
54
- self.data = self.__class__._clean_data(args[0])
54
+ if isinstance(args[0], np.ndarray) or isinstance(args[0], list): #data was passed directly
55
+ self.data = self.__class__._clean_data(np.array(args[0]))
55
56
 
56
57
  elif all([isinstance(a, self.__class__) for a in args[0]]):
57
58
  #a list of self.__class__ is passed, concatenate into one
@@ -60,7 +61,7 @@ class Base:
60
61
  elif isinstance(args[0], pd.DataFrame):
61
62
  self.data = self.__class__._clean_data(np.array(args[0]))
62
63
  else:
63
- raise TypeError("unknown data passed")
64
+ raise TypeError(f"unknown args passed to {self.__class__.__name__}: {args[0]}")
64
65
 
65
66
  elif len(args) == len(self.__class__.cols):
66
67
  #three args passed, each represents a col
@@ -70,17 +71,18 @@ class Base:
70
71
  self.data = self.__class__._clean_data(np.array(args).T)
71
72
  elif all(isinstance(arg, list) for arg in args):
72
73
  self.data = self.__class__._clean_data(np.array(args).T)
73
-
74
+ elif all(isinstance(arg, pd.Series) for arg in args):
75
+ self.data = self.__class__._clean_data(np.array(pd.concat(args, axis=1)))
74
76
  else:
75
77
  raise TypeError
76
78
  else:
77
79
  raise TypeError(f"Empty {self.__class__.__name__} not allowed")
78
80
 
79
81
  @classmethod
80
- def _clean_data(cls, data) -> np.ndarray:
82
+ def _clean_data(cls, data) -> npt.NDArray[np.float64]:
81
83
  assert isinstance(data, np.ndarray)
82
84
  if data.dtype == 'O':
83
- raise TypeError('data must have homogeneous shape')
85
+ raise TypeError(f'data must have homogeneous shape for {cls.__name__}, given {data.shape}')
84
86
  if len(data.shape) == 1:
85
87
  data = data.reshape(1, len(data))
86
88
 
@@ -102,21 +104,31 @@ class Base:
102
104
  return a, b
103
105
 
104
106
  @classmethod
105
- def concatenate(cls, items):
107
+ def concatenate(cls, items) -> Self:
106
108
  return cls(np.concatenate([i.data for i in items], axis=0))
107
109
 
108
- def __getattr__(self, name):
110
+ def __getattr__(self, name) -> npt.NDArray[np.float64]:
109
111
  if name in self.__class__.cols:
110
112
  return self.data[:,self.__class__.cols.index(name)]
111
113
  #return res[0] if len(res) == 1 else res
112
114
  elif name in self.__class__.from_np + self.__class__.from_np_base:
113
115
  return self.__class__(getattr(np, name)(self.data))
116
+ else:
117
+ for col in self.__class__.cols:
118
+ if len(name) > len(col):
119
+ if name[:len(col)] == col:
120
+ try:
121
+ id = int(name[len(col):])
122
+ except ValueError:
123
+ break
124
+ return getattr(self, col)[id]
125
+
114
126
  raise AttributeError(f"Cannot get attribute {name}")
115
127
 
116
128
  def __dir__(self):
117
129
  return self.__class__.cols
118
130
 
119
- def __getitem__(self, sli):
131
+ def __getitem__(self, sli) -> Self:
120
132
  return self.__class__(self.data[sli,:])
121
133
 
122
134
  def _dprep(self, other):
@@ -146,78 +158,86 @@ class Base:
146
158
  else:
147
159
  raise ValueError(f"unhandled datatype ({other.__class__.name})")
148
160
 
149
- def radians(self):
161
+ def radians(self) -> Self:
150
162
  return self.__class__(np.radians(self.data))
151
163
 
152
- def degrees(self):
164
+ def degrees(self) -> Self:
153
165
  return self.__class__(np.degrees(self.data))
154
166
 
155
-
156
-
157
- def count(self):
167
+ def count(self) -> int:
158
168
  return len(self)
159
169
 
160
- def __len__(self):
170
+ def __len__(self) -> int:
161
171
  return self.data.shape[0]
162
172
 
173
+ @property
174
+ def ends(self) -> Self:
175
+ return self.__class__(self.data[[0,-1], :])
176
+
163
177
  @dprep
164
178
  def __eq__(self, other):
165
179
  return np.all(self.data == other)
166
180
 
167
181
  @dprep
168
- def __add__(self, other):
182
+ def __add__(self, other) -> Self:
169
183
  return self.__class__(self.data + other)
170
184
 
171
185
  @dprep
172
- def __radd__(self, other):
186
+ def __radd__(self, other) -> Self:
173
187
  return self.__class__(other + self.data)
174
188
 
175
189
  @dprep
176
- def __sub__(self, other):
190
+ def __sub__(self, other) -> Self:
177
191
  return self.__class__(self.data - other)
178
192
 
179
193
  @dprep
180
- def __rsub__(self, other):
194
+ def __rsub__(self, other) -> Self:
181
195
  return self.__class__(other - self.data)
182
196
 
183
197
  @dprep
184
- def __mul__(self, other):
198
+ def __mul__(self, other) -> Self:
185
199
  return self.__class__(self.data * other)
186
200
 
187
201
  @dprep
188
- def __rmul__(self, other):
202
+ def __rmul__(self, other) -> Self:
189
203
  return self.__class__(other * self.data)
190
204
 
191
205
  @dprep
192
- def __rtruediv__(self, other):
206
+ def __rtruediv__(self, other) -> Self:
193
207
  return self.__class__(other / self.data)
194
208
 
195
209
  @dprep
196
- def __truediv__(self, other):
210
+ def __truediv__(self, other) -> Self:
197
211
  return self.__class__(self.data / other)
198
212
 
199
213
  def __str__(self):
200
- return str(pd.DataFrame(self.data, columns=self.__class__.cols))
214
+ means = ' '.join(f'{c}_={v}' for c, v in zip(self.cols, np.mean(self.data, axis=0).round(2)))
215
+ return f'{self.__class__.__name__}({means}, len={len(self)})'
201
216
 
202
217
  def __abs__(self):
203
218
  return np.linalg.norm(self.data, axis=1)
219
+
220
+ def abs(self) -> Self:
221
+ return self.__class__(np.abs(self.data))
204
222
 
205
- def __neg__(self):
223
+ def __neg__(self) -> Self:
206
224
  return self.__class__(-self.data)
207
225
 
208
226
  @dprep
209
- def dot(self, other):
227
+ def dot(self, other: Self) -> Self:
210
228
  return np.einsum('ij,ij->i', self.data, other)
211
229
 
212
- def diff(self, dt:np.array):
230
+ def diff(self, dt:np.array) -> Self:
231
+ if not pd.api.types.is_list_like(dt):
232
+ dt = np.full(len(self), dt)
213
233
  assert len(dt) == len(self)
214
234
  return self.__class__(
215
235
  np.gradient(self.data,axis=0) \
216
- / \
236
+ / \
217
237
  np.tile(dt, (len(self.__class__.cols),1)).T)
218
238
 
219
239
  def to_pandas(self, prefix='', suffix='', columns=None, index=None):
220
- if not columns is None:
240
+ if columns is not None:
221
241
  cols = columns
222
242
  else:
223
243
  cols = [prefix + col + suffix for col in self.__class__.cols]
@@ -227,7 +247,7 @@ class Base:
227
247
  index=index
228
248
  )
229
249
 
230
- def tile(self, count):
250
+ def tile(self, count) -> Self:
231
251
  return self.__class__(np.tile(self.data, (count, 1)))
232
252
 
233
253
  def to_dict(self):
@@ -235,6 +255,17 @@ class Base:
235
255
  return {key: getattr(self, key)[0] for key in self.cols}
236
256
  else:
237
257
  return {key: getattr(self, key) for key in self.cols}
258
+
259
+ @classmethod
260
+ def from_dict(Cls, data):
261
+ return Cls(**data)
262
+
263
+ def to_dicts(self):
264
+ return self.to_pandas().to_dict('records')
265
+
266
+ @classmethod
267
+ def from_dicts(Cls, data: dict):
268
+ return Cls(pd.DataFrame.from_dict(data))
238
269
 
239
270
  @classmethod
240
271
  def full(cls, val, count):
@@ -257,4 +288,47 @@ class Base:
257
288
  return self.__class__(np.cumsum(self.data,axis=0))
258
289
 
259
290
  def round(self, decimals=0):
260
- return self.__class__(self.data.round(decimals))
291
+ return self.__class__(self.data.round(decimals))
292
+
293
+ def __repr__(self):
294
+ return str(self)
295
+
296
+ def copy(self):
297
+ return self.__class__(self.data.copy())
298
+
299
+
300
+ def unwrap(self, discont=np.pi):
301
+ return self.__class__(np.unwrap(self.data, discont=discont, axis=0))
302
+
303
+ def filter(self, order, cutoff, ts: np.ndarray=None):
304
+ from scipy.signal import butter, freqz, filtfilt
305
+ if ts is None:
306
+ ts = np.array(range(len(self)))
307
+ N = len(self)
308
+ T = (ts[-1] - ts[0]) / N
309
+
310
+ fs = 1/T
311
+ b, a = butter(
312
+ order,
313
+ cutoff,
314
+ fs=fs,
315
+ btype='low', analog=False
316
+ )
317
+
318
+ return self.__class__(filtfilt(b, a, self.data, axis=0))
319
+
320
+ def fft(self, ts: np.ndarray=None):
321
+ from scipy.fft import fft, fftfreq
322
+ if ts is None:
323
+ ts = np.array(range(len(self)))
324
+ N = len(self)*2
325
+ T = (ts[-1] - ts[0]) / len(self)
326
+
327
+ yf = fft(self.data, axis=0, n=N)
328
+ xf = fftfreq(N, T)[:N//2]
329
+
330
+
331
+ y=2.0/N * np.abs(yf[0:N//2, :])
332
+
333
+ return pd.DataFrame(np.column_stack([xf, y]), columns=['freq'] + self.cols).set_index('freq')
334
+
@@ -19,7 +19,7 @@ from geometry.base import Base
19
19
 
20
20
  class Coord(Base):
21
21
  cols = [
22
- "ox", "oy", "ox",
22
+ "ox", "oy", "oz",
23
23
  "x1", "y1", "z1",
24
24
  "x2", "y2", "z2",
25
25
  "x3", "y3", "z3",
@@ -42,6 +42,10 @@ class Coord(Base):
42
42
  z.unit().data
43
43
  ],axis=1))
44
44
 
45
+ @staticmethod
46
+ def zero(count=1):
47
+ return Coord.from_nothing(count)
48
+
45
49
  @staticmethod
46
50
  def from_nothing(count=1):
47
51
  return Coord.from_axes(P0(count), PX(1,count), PY(1,count), PZ(1,count))
geometry/gps.py CHANGED
@@ -29,59 +29,52 @@ LOCFAC = math.radians(erad)
29
29
 
30
30
 
31
31
  class GPS(Base):
32
- cols = ["lat", "long"]
32
+ cols = ["lat", "long", "alt"]
33
33
  # was 6378137, extra precision removed to match ardupilot
34
34
 
35
35
  def __init__(self, *args, **kwargs):
36
36
  super().__init__(*args, **kwargs)
37
37
  self._longfac = safecos(self.lat)
38
38
 
39
-
40
- def offset(self, pin: Point):
41
- assert len(pin) == len(self)
42
- latb = self.lat - pin.x / LOCFAC
43
-
44
- return GPS(
45
- latb,
46
- self.long + pin.y / (LOCFAC * safecos(latb))
47
- )
48
-
49
39
  def __eq__(self, other) -> bool:
50
40
  return np.all(self.data == other.data)
51
41
 
52
42
  def __sub__(self, other) -> Point:
53
- assert isinstance(other, GPS)
43
+ assert isinstance(other, GPS), f'Cannot offset a GPS by a {other.__class__.__name__}'
54
44
  if len(other) == len(self):
55
45
  return Point(
56
- -(other.lat - self.lat) * LOCFAC,
57
- -(other.long - self.long) * LOCFAC * self._longfac,
58
- np.zeros(len(self))
46
+ (self.lat - other.lat) * LOCFAC,
47
+ (self.long - other.long) * LOCFAC * self._longfac,
48
+ other.alt - self.alt
59
49
  )
60
50
  elif len(other) == 1:
61
51
  return self - GPS.full(other, len(self))
62
52
  elif len(self) == 1:
63
53
  return GPS.full(self, len(self)) - other
64
54
  else:
65
- raise ValueError(f"incompatible lengths for sub ({len(self)}) - ({len(other)})")
55
+ raise ValueError(f"incompatible lengths for GPS sub ({len(self)}) != ({len(other)})")
66
56
 
67
57
  def offset(self, pin: Point):
58
+ '''Offset by a point in NED coordinates'''
68
59
  if len(pin) == 1 and len(self) > 1:
69
- pin = Point.full(pin, self.count)
60
+ pin = Point.full(pin, len(self))
70
61
  elif len(self) == 1 and len(pin) > 1:
71
- return self.full(len(pin)).offset(pin)
62
+ return GPS.full(self, len(pin)).offset(pin)
72
63
 
73
64
  if not len(pin) == len(self):
74
- raise ValueError(f"incompatible lengths for offset ({len(self)}) - ({len(pin)})")
65
+ raise ValueError(f"incompatible lengths for GPS offset ({len(self)}) != ({len(pin)})")
75
66
 
76
67
  latb = self.lat + pin.x / LOCFAC
77
68
  return GPS(
78
69
  latb,
79
- self.long + pin.y / (LOCFAC * safecos(latb))
70
+ self.long + pin.y / (LOCFAC * safecos(latb)),
71
+ self.alt - pin.z
80
72
  )
81
73
 
82
74
 
83
-
84
75
  '''
76
+ Extract from ardupilot:
77
+
85
78
  // scaling factor from 1e-7 degrees to meters at equator
86
79
  // == 1.0e-7 * DEG_TO_RAD * RADIUS_OF_EARTH
87
80
  static constexpr float LOCATION_SCALING_FACTOR = 0.011131884502145034f;
@@ -102,10 +95,3 @@ float Location::long_scale() const
102
95
  }
103
96
  '''
104
97
 
105
-
106
- if __name__ == "__main__":
107
- home = GPS(51.459387, -2.791393)
108
-
109
- new = GPS(51.458876, -2.789092)
110
- coord = home - new
111
- print(coord.x, coord.y)
geometry/mass.py CHANGED
@@ -33,6 +33,10 @@ class Mass(Base):
33
33
  def matrix(self):
34
34
  return self.data[:,1:].reshape((len(self), 3, 3))
35
35
 
36
+ @property
37
+ def I(self):
38
+ return self.matrix()
39
+
36
40
  def offset(self, v: Point):
37
41
  xx = v.y**2 + v.z**2
38
42
  yy = v.z**2 + v.x**2
@@ -46,5 +50,14 @@ class Mass(Base):
46
50
  ]))
47
51
  ) + self
48
52
 
53
+ def momentum(self, v: Point):
54
+ return self.m * v
55
+
56
+ def angular_momentum(self, rvel: Point):
57
+ return Point(
58
+ self.xx * rvel.x + self.xy * rvel.y + self.xz * rvel.z,
59
+ self.yx * rvel.x + self.yy * rvel.y + self.yz * rvel.z,
60
+ self.zx * rvel.x + self.zy * rvel.y + self.zz * rvel.z,
61
+ )
62
+
49
63
 
50
-
geometry/point.py CHANGED
@@ -9,6 +9,7 @@ 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
+ from __future__ import annotations
12
13
  from .base import Base
13
14
  import numpy as np
14
15
  import pandas as pd
@@ -23,15 +24,13 @@ class Point(Base):
23
24
  "arcsin","arccos","arctan",
24
25
  ]
25
26
 
26
- def scale(self, value):
27
- a, b=value, abs(self)
28
- res = a/b
29
- res[b==0] = 0
30
- res = self * res
27
+ def scale(self, value) -> Point:
28
+ with np.errstate(divide="ignore"):
29
+ res = value/abs(self)
30
+ res[res==np.inf] = 0
31
+ return self * res
31
32
 
32
- return res
33
-
34
- def unit(self):
33
+ def unit(self) -> Point:
35
34
  return self.scale(1)
36
35
 
37
36
  def remove_outliers(self, nstds = 2):
@@ -43,7 +42,7 @@ class Point(Base):
43
42
 
44
43
  data[abs(ab - mean) > nstds * std, :] = [np.nan, np.nan, np.nan]
45
44
 
46
- return Point(pd.DataFrame(data).fillna(method="ffill").to_numpy())
45
+ return Point(pd.DataFrame(data).ffill().bfill().to_numpy())
47
46
 
48
47
  def mean(self):
49
48
  return Point(np.mean(self.data, axis=0))
@@ -141,31 +140,27 @@ def cross(a, b) -> Point:
141
140
 
142
141
  @ppmeth
143
142
  def cos_angle_between(a: Point, b: Point) -> np.ndarray:
144
- if a == 0 or b == 0:
145
- raise ValueError("cannot measure the angle to a zero length vector")
146
143
  return a.unit().dot(b.unit())
147
144
 
148
-
149
145
  @ppmeth
150
146
  def angle_between(a: Point, b: Point) -> np.ndarray:
151
147
  return np.arccos(a.cos_angle_between(b))
152
148
 
153
149
  @ppmeth
154
150
  def scalar_projection(a: Point, b: Point) -> Point:
155
- if a==0 or b==0:
156
- return 0
157
151
  return a.cos_angle_between(b) * abs(a)
158
152
 
159
153
  @ppmeth
160
154
  def vector_projection(a: Point, b: Point) -> Point:
161
- if abs(a) == 0:
162
- return Point.zeros()
163
155
  return b.scale(a.scalar_projection(b))
164
156
 
157
+ @ppmeth
158
+ def vector_rejection(a: Point, b: Point) -> Point:
159
+ return a - ((Point.dot(a, b)) / Point.dot(b,b)) * b
160
+
161
+
165
162
  @ppmeth
166
163
  def is_parallel(a: Point, b: Point, tolerance=1e-6):
167
- if a.unit() == b.unit():
168
- return True
169
164
  return abs(a.cos_angle_between(b) - 1) < tolerance
170
165
 
171
166
  @ppmeth
@@ -182,9 +177,7 @@ def angle_between(a: Point, b: Point) -> float:
182
177
  return np.arccos(cos_angle_between(a, b))
183
178
 
184
179
  def arbitrary_perpendicular(v: Point) -> Point:
185
- if v.x == 0 and v.y == 0:
186
- return Point(0, 1, 0)
187
- return Point(-v.y, v.x, 0).unit
180
+ return Point(-v.y, v.x, 0).unit()
188
181
 
189
182
  def vector_norm(point: Point):
190
183
  return abs(point)