plexus-python-common 1.0.31__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.
plexus/common/proj.py ADDED
@@ -0,0 +1,305 @@
1
+ from abc import ABC
2
+ from abc import abstractmethod
3
+ from typing import Annotated, Self
4
+
5
+ import numpy as np
6
+ import pydantic as pdt
7
+ import pyproj
8
+ from iker.common.utils.funcutils import singleton
9
+ from iker.common.utils.strutils import parse_params_string, repr_data, str_conv
10
+
11
+ from plexus.common.pose import Pose
12
+
13
+
14
+ class Proj(ABC):
15
+ @classmethod
16
+ @abstractmethod
17
+ def typename(cls) -> str:
18
+ pass
19
+
20
+ def __init__(self, spec):
21
+ self.spec = spec
22
+
23
+ def __str__(self):
24
+ return self.to_string()
25
+
26
+ def method(self) -> str:
27
+ return self.typename()
28
+
29
+ @abstractmethod
30
+ def ellipsoid(self) -> str:
31
+ pass
32
+
33
+ @abstractmethod
34
+ def from_latlon(self, lat: float, lon: float) -> tuple[float, float]:
35
+ """
36
+ Performs forward projection from latitude/longitude to projection coordinate system
37
+
38
+ :param lat: latitude
39
+ :param lon: longitude
40
+
41
+ :return: tuple of x and y in projection coordinate system
42
+ """
43
+ pass
44
+
45
+ @abstractmethod
46
+ def to_latlon(self, x: float, y: float) -> tuple[float, float]:
47
+ """
48
+ Performs inverse projection from projection coordinate system to latitude/longitude
49
+
50
+ :param x: x in projection coordinate system
51
+ :param y: y in projection coordinate system
52
+
53
+ :return: tuple of latitude and longitude
54
+ """
55
+ pass
56
+
57
+ @abstractmethod
58
+ def to_string(self) -> str:
59
+ pass
60
+
61
+
62
+ class EQDCProj(Proj):
63
+ """
64
+ Equidistant conic projection
65
+ Please refer to https://en.wikipedia.org/wiki/Equidistant_conic_projection
66
+ """
67
+
68
+ class Spec(pdt.BaseModel):
69
+ lat_1: Annotated[float, pdt.Strict(), pdt.AllowInfNan(False)]
70
+ lat_2: Annotated[float, pdt.Strict(), pdt.AllowInfNan(False)]
71
+ lon_0: Annotated[float, pdt.Strict(), pdt.AllowInfNan(False)] = 0.0
72
+
73
+ @classmethod
74
+ def typename(cls) -> str:
75
+ return "eqdc"
76
+
77
+ def __init__(self, **kwargs):
78
+ super(EQDCProj, self).__init__(EQDCProj.Spec(**kwargs))
79
+ self.proj = self.make_proj()
80
+
81
+ def ellipsoid(self) -> str:
82
+ return "WGS84"
83
+
84
+ def from_latlon(self, lat: float, lon: float) -> tuple[float, float]:
85
+ x, y = self.proj(lon, lat)
86
+ return x, y
87
+
88
+ def to_latlon(self, x: float, y: float) -> tuple[float, float]:
89
+ lon, lat = self.proj(x, y, inverse=True)
90
+ return lat, lon
91
+
92
+ def to_string(self) -> str:
93
+ return "{method}:lat_1={},lat_2={},lon_0={}".format(
94
+ self.spec.lat_1,
95
+ self.spec.lat_2,
96
+ self.spec.lon_0,
97
+ method=self.method(),
98
+ )
99
+
100
+ def make_proj(self):
101
+ return pyproj.Proj(
102
+ "+proj={method} +ellps={ellipsoid} +datum={ellipsoid} +units=m +no_defs +lat_1={} +lat_2={} +lon_0={}".format(
103
+ self.spec.lat_1,
104
+ self.spec.lat_2,
105
+ self.spec.lon_0,
106
+ method=self.method(),
107
+ ellipsoid=self.ellipsoid(),
108
+ )
109
+ )
110
+
111
+
112
+ class UTMProj(Proj):
113
+ """
114
+ Universal Transverse Mercator coordinate system
115
+ Please refer to https://en.wikipedia.org/wiki/Universal_Transverse_Mercator_coordinate_system
116
+ """
117
+
118
+ class Spec(pdt.BaseModel):
119
+ zone: Annotated[int, pdt.Strict()]
120
+ south: Annotated[bool, pdt.Strict()] = False
121
+
122
+ @classmethod
123
+ def typename(cls) -> str:
124
+ return "utm"
125
+
126
+ def __init__(self, **kwargs):
127
+ super(UTMProj, self).__init__(UTMProj.Spec(**kwargs))
128
+ self.proj = self.make_proj()
129
+
130
+ def ellipsoid(self) -> str:
131
+ return "WGS84"
132
+
133
+ def from_latlon(self, lat: float, lon: float) -> tuple[float, float]:
134
+ x, y = self.proj(lon, lat)
135
+ return x, y
136
+
137
+ def to_latlon(self, x: float, y: float) -> tuple[float, float]:
138
+ lon, lat = self.proj(x, y, inverse=True)
139
+ return lat, lon
140
+
141
+ def to_string(self) -> str:
142
+ return "{method}:zone={},south={}".format(self.spec.zone, self.spec.south, method=self.method())
143
+
144
+ def make_proj(self):
145
+ return pyproj.Proj(
146
+ "+proj={method} +ellps={ellipsoid} +datum={ellipsoid} +units=m +no_defs +zone={} {}".format(
147
+ self.spec.zone,
148
+ "+south" if self.spec.south else "",
149
+ method=self.method(),
150
+ ellipsoid=self.ellipsoid(),
151
+ )
152
+ )
153
+
154
+
155
+ class WebMercProj(Proj):
156
+ """
157
+ Web Mercator projection
158
+ Please refer to https://en.wikipedia.org/wiki/Web_Mercator_projection
159
+ """
160
+
161
+ class Spec(pdt.BaseModel):
162
+ lon_0: Annotated[float, pdt.Strict(), pdt.AllowInfNan(False)] = 0.0
163
+
164
+ @classmethod
165
+ def typename(cls) -> str:
166
+ return "webmerc"
167
+
168
+ def __init__(self, **kwargs):
169
+ super(WebMercProj, self).__init__(WebMercProj.Spec(**kwargs))
170
+ self.proj = self.make_proj()
171
+
172
+ def ellipsoid(self) -> str:
173
+ return "WGS84"
174
+
175
+ def from_latlon(self, lat: float, lon: float) -> tuple[float, float]:
176
+ x, y = self.proj(lon, lat)
177
+ return x, y
178
+
179
+ def to_latlon(self, x: float, y: float) -> tuple[float, float]:
180
+ lon, lat = self.proj(x, y, inverse=True)
181
+ return lat, lon
182
+
183
+ def to_string(self) -> str:
184
+ return "{method}:lon_0={}".format(self.spec.lon_0, method=self.method())
185
+
186
+ def make_proj(self):
187
+ return pyproj.Proj(
188
+ "+proj={method} +ellps={ellipsoid} +datum={ellipsoid} +units=m +no_defs +lon_0={}".format(
189
+ self.spec.lon_0,
190
+ method=self.method(),
191
+ ellipsoid=self.ellipsoid(),
192
+ ),
193
+ )
194
+
195
+
196
+ @singleton
197
+ def default_proj() -> Proj:
198
+ return make_proj("eqdc:lat_1=30.0,lat_2=50.0")
199
+
200
+
201
+ def make_proj(spec_str: str) -> Proj | None:
202
+ """
203
+ Constructs cartographic projection from the given name and arguments
204
+
205
+ :param spec_str: projection spec string
206
+
207
+ :return: projection
208
+ """
209
+
210
+ if spec_str is None:
211
+ return None
212
+
213
+ match spec_str.split(":", maxsplit=1):
214
+ case [name]:
215
+ kwargs = {}
216
+ case [name, args]:
217
+ kwargs = parse_params_string(args, str_parser=str_conv)
218
+ case _:
219
+ raise ValueError("bad spec string")
220
+
221
+ if name.lower() == UTMProj.typename():
222
+ return UTMProj(**kwargs)
223
+ if name.lower() == EQDCProj.typename():
224
+ return EQDCProj(**kwargs)
225
+ if name.lower() == WebMercProj.typename():
226
+ return WebMercProj(**kwargs)
227
+
228
+ return None
229
+
230
+
231
+ class Coord(object):
232
+ @classmethod
233
+ def from_pose(cls, pose: Pose, proj: Proj | None) -> Self:
234
+ """
235
+ Creates a coord from given pose
236
+
237
+ :param pose: pose based on which the coord is created
238
+ :param proj: cartographic projection
239
+
240
+ :return: generated coord
241
+ """
242
+ return cls.from_xy(pose.p[0], pose.p[1], pose.p[2], proj, pose.ts)
243
+
244
+ @classmethod
245
+ def from_latlon(cls, lat: float, lon: float, ele: float, proj: Proj | None, ts: float = 0) -> Self:
246
+ """
247
+ Creates a coord from latitude/longitude
248
+
249
+ :param lat: latitude
250
+ :param lon: longitude
251
+ :param ele: elevation
252
+ :param proj: cartographic projection
253
+ :param ts: timestamp
254
+
255
+ :return: generated coord
256
+ """
257
+ if proj is None:
258
+ return Coord(ts, lat, lon, ele, 0.0, 0.0, pdt.BaseModel())
259
+
260
+ x, y = proj.from_latlon(lat, lon)
261
+ return Coord(ts, lat, lon, ele, x, y, proj.spec)
262
+
263
+ @classmethod
264
+ def from_xy(cls, x: float, y: float, ele: float, proj: Proj | None, ts: float = 0) -> Self:
265
+ """
266
+ Creates a coord from coordinate in cartographic projection
267
+
268
+ :param x: x coordinate in cartographic projection
269
+ :param y: y coordinate in cartographic projection
270
+ :param ele: elevation
271
+ :param proj: cartographic projection
272
+ :param ts: timestamp
273
+
274
+ :return: a coord
275
+ """
276
+ if proj is None:
277
+ return Coord(ts, 0.0, 0.0, ele, x, y, pdt.BaseModel())
278
+
279
+ lat, lon = proj.to_latlon(x, y)
280
+ return Coord(ts, lat, lon, ele, x, y, proj.spec)
281
+
282
+ def __init__(self, ts: float, lat: float, lon: float, ele: float, x: float, y: float, spec: pdt.BaseModel):
283
+ """
284
+ Represents a coordinate
285
+
286
+ :param ts: timestamp
287
+ :param lat: latitude
288
+ :param lon: longitude
289
+ :param ele: elevation
290
+ :param x: x in projection coordinate system
291
+ :param y: y in projection coordinate system
292
+ :param spec: projection specification
293
+ """
294
+ self.ts = ts
295
+ self.lat = lat
296
+ self.lon = lon
297
+ self.x = x
298
+ self.y = y
299
+ self.spec = spec
300
+ self.ele = ele
301
+ self.xy = np.array([x, y], dtype=np.float64)
302
+ self.xyz = np.array([x, y, ele], dtype=np.float64)
303
+
304
+ def __str__(self):
305
+ return repr_data(self)
File without changes
@@ -0,0 +1,31 @@
1
+ from contextlib import AbstractContextManager
2
+
3
+ import sqlalchemy.orm as sa_orm
4
+ from fastapi import HTTPException
5
+
6
+ __all__ = [
7
+ "managed_db_session",
8
+ ]
9
+
10
+
11
+ class DBSessionExceptionManager(AbstractContextManager):
12
+ def __init__(self, db: sa_orm.Session, commit_on_exit: bool):
13
+ self.db = db
14
+ self.commit_on_exit = commit_on_exit
15
+
16
+ def __enter__(self):
17
+ return self
18
+
19
+ def __exit__(self, exc_type, exc_value, traceback):
20
+ if isinstance(exc_type, HTTPException):
21
+ return False # Propagate HTTPException
22
+ if exc_type is not None:
23
+ self.db.rollback() # Raise a new HTTPException (or any other exception type)
24
+ raise HTTPException(status_code=500, detail=str(exc_value)) from exc_value
25
+ if self.commit_on_exit:
26
+ self.db.commit()
27
+ return True
28
+
29
+
30
+ def managed_db_session(db: sa_orm.Session, commit_on_exit: bool = True) -> DBSessionExceptionManager:
31
+ return DBSessionExceptionManager(db, commit_on_exit)
@@ -0,0 +1,215 @@
1
+ import os
2
+ from collections.abc import Generator
3
+ from contextlib import AbstractContextManager
4
+ from typing import Literal
5
+
6
+ import sqlalchemy as sa
7
+ import sqlalchemy.orm as sa_orm
8
+
9
+ __all__ = [
10
+ "SerializationFormat",
11
+ "BagTopic",
12
+ "BagMessage",
13
+ "BagSchema",
14
+ "BagMessageDefinition",
15
+ "BagMetadata",
16
+ "BagReader",
17
+ "BagWriter",
18
+ "bag_reader",
19
+ "bag_writer",
20
+ ]
21
+
22
+ SerializationFormat = Literal["cdr", "cdr2", "json", "yaml"]
23
+
24
+ default_bag_db_file = "bag.db"
25
+
26
+
27
+ class BagBase(sa_orm.DeclarativeBase):
28
+ pass
29
+
30
+
31
+ class BagSchema(BagBase):
32
+ __tablename__ = "schema"
33
+
34
+ schema_version: sa_orm.Mapped[int] = sa_orm.mapped_column(sa.Integer, primary_key=True)
35
+ ros_distro: sa_orm.Mapped[str] = sa_orm.mapped_column(sa.String, nullable=False)
36
+
37
+
38
+ class BagMetadata(BagBase):
39
+ __tablename__ = "metadata"
40
+
41
+ id: sa_orm.Mapped[int] = sa_orm.mapped_column(sa.Integer, primary_key=True, autoincrement=True)
42
+ metadata_version: sa_orm.Mapped[int] = sa_orm.mapped_column(sa.Integer, nullable=False)
43
+ metadata_text: sa_orm.Mapped[str] = sa_orm.mapped_column(sa.String, nullable=False)
44
+
45
+
46
+ class BagTopic(BagBase):
47
+ __tablename__ = "topics"
48
+
49
+ id: sa_orm.Mapped[int] = sa_orm.mapped_column(sa.Integer, primary_key=True, autoincrement=True)
50
+ name: sa_orm.Mapped[str] = sa_orm.mapped_column(sa.String, nullable=False)
51
+ type: sa_orm.Mapped[str] = sa_orm.mapped_column(sa.String, nullable=False)
52
+ serialization_format: sa_orm.Mapped[str] = sa_orm.mapped_column(sa.String, nullable=False)
53
+ type_description_hash: sa_orm.Mapped[str] = sa_orm.mapped_column(sa.String, nullable=False)
54
+
55
+ messages: sa_orm.Mapped[list["BagMessage"]] = sa_orm.relationship("BagMessage", back_populates="topic")
56
+
57
+
58
+ class BagMessageDefinition(BagBase):
59
+ __tablename__ = "message_definitions"
60
+
61
+ id: sa_orm.Mapped[int] = sa_orm.mapped_column(sa.Integer, primary_key=True, autoincrement=True)
62
+ topic_type: sa_orm.Mapped[str] = sa_orm.mapped_column(sa.String, nullable=False)
63
+ encoding: sa_orm.Mapped[str] = sa_orm.mapped_column(sa.String, nullable=False)
64
+ encoded_message_definition: sa_orm.Mapped[str] = sa_orm.mapped_column(sa.String, nullable=False)
65
+ type_description_hash: sa_orm.Mapped[str] = sa_orm.mapped_column(sa.String, nullable=False)
66
+
67
+
68
+ class BagMessage(BagBase):
69
+ __tablename__ = "messages"
70
+
71
+ id: sa_orm.Mapped[int] = sa_orm.mapped_column(sa.Integer, primary_key=True, autoincrement=True)
72
+ topic_id: sa_orm.Mapped[int] = sa_orm.mapped_column(sa.Integer, sa.ForeignKey("topics.id"), nullable=False)
73
+ timestamp: sa_orm.Mapped[int] = sa_orm.mapped_column(sa.Integer, nullable=False)
74
+ data: sa_orm.Mapped[bytes] = sa_orm.mapped_column(sa.LargeBinary, nullable=False)
75
+
76
+ topic: sa_orm.Mapped["BagTopic"] = sa_orm.relationship("BagTopic", back_populates="messages")
77
+
78
+
79
+ class BagReader(AbstractContextManager):
80
+ def __init__(self, db_dir: str, db_file: str = default_bag_db_file):
81
+ self.db_path = os.path.join(db_dir, db_file)
82
+ if not os.path.exists(self.db_path):
83
+ raise FileNotFoundError(f"could not find SQLite DB at '{db_dir}'")
84
+
85
+ self.engine = sa.create_engine(f"sqlite:///{self.db_path}")
86
+ self.session = sa_orm.Session(self.engine)
87
+ self.topic_map = None
88
+
89
+ def __enter__(self):
90
+ return self
91
+
92
+ def __exit__(self, exc_type, exc_val, exc_tb):
93
+ self.close()
94
+
95
+ def close(self):
96
+ self.session.close()
97
+ self.engine.dispose()
98
+
99
+ def get_topics(self) -> dict[str, BagTopic]:
100
+ if self.topic_map is None:
101
+ stmt = sa.select(BagTopic)
102
+ self.topic_map = {db_topic.name: db_topic for db_topic in self.session.scalars(stmt).all()}
103
+ return self.topic_map
104
+
105
+ def iter_messages(
106
+ self,
107
+ topic_names: list[str] | None = None,
108
+ begin_timestamp: int | None = None,
109
+ end_timestamp: int | None = None,
110
+ ) -> Generator[BagMessage, None, None]:
111
+ stmt = sa.select(BagMessage)
112
+ if topic_names is not None:
113
+ topic_ids = [db_topic.id for db_topic in self.get_topics().values() if db_topic.name in topic_names]
114
+ stmt = stmt.where(BagMessage.topic_id.in_(topic_ids))
115
+ if begin_timestamp is not None:
116
+ stmt = stmt.where(BagMessage.timestamp >= begin_timestamp)
117
+ if end_timestamp is not None:
118
+ stmt = stmt.where(BagMessage.timestamp <= end_timestamp)
119
+ for db_message in self.session.scalars(stmt.order_by(BagMessage.timestamp)):
120
+ yield db_message
121
+
122
+
123
+ class BagWriter(AbstractContextManager):
124
+ def __init__(self, db_dir: str, db_file: str = default_bag_db_file):
125
+ self.db_path = os.path.join(db_dir, db_file)
126
+ if os.path.exists(self.db_path):
127
+ os.remove(self.db_path)
128
+
129
+ self.engine = sa.create_engine(f"sqlite:///{self.db_path}")
130
+ self.session = sa_orm.Session(self.engine)
131
+ self.topic_map = {}
132
+
133
+ BagBase.metadata.create_all(self.engine)
134
+
135
+ def __enter__(self):
136
+ return self
137
+
138
+ def __exit__(self, exc_type, exc_val, exc_tb):
139
+ self.close()
140
+
141
+ def close(self) -> None:
142
+ self.session.commit()
143
+ self.session.close()
144
+ self.engine.dispose()
145
+
146
+ def get_or_create_topic(
147
+ self,
148
+ topic_name: str,
149
+ topic_type: str,
150
+ *,
151
+ serialization_format: SerializationFormat = "cdr",
152
+ ) -> BagTopic:
153
+ if topic_name in self.topic_map:
154
+ return self.topic_map[topic_name]
155
+
156
+ db_topic = self.session.query(BagTopic).filter(BagTopic.name == topic_name).first()
157
+ if db_topic is None:
158
+ db_topic = BagTopic(name=topic_name,
159
+ type=topic_type,
160
+ serialization_format=serialization_format,
161
+ type_description_hash="")
162
+ self.session.add(db_topic)
163
+ self.session.flush()
164
+
165
+ self.topic_map[topic_name] = db_topic
166
+ self.session.commit()
167
+
168
+ return db_topic
169
+
170
+ def write_message(
171
+ self,
172
+ topic_name: str,
173
+ timestamp: int,
174
+ data: bytes,
175
+ topic_type: str = None,
176
+ *,
177
+ serialization_format: SerializationFormat = "cdr",
178
+ ) -> BagMessage:
179
+ if topic_name not in self.topic_map:
180
+ if topic_type is None:
181
+ raise ValueError(f"missing topic type to create topic '{topic_name}' dynamically")
182
+
183
+ self.get_or_create_topic(topic_name, topic_type, serialization_format=serialization_format)
184
+
185
+ db_topic = self.topic_map[topic_name]
186
+ db_message = BagMessage(topic_id=db_topic.id, timestamp=timestamp, data=data)
187
+
188
+ self.session.add(db_message)
189
+ self.session.flush()
190
+
191
+ return db_message
192
+
193
+
194
+ def bag_reader(db_dir: str, db_file: str = default_bag_db_file) -> BagReader:
195
+ """
196
+ Creates a BagReader instance to read messages from a ROS2 bag file.
197
+
198
+ :param db_dir: directory containing the SQLite DB file.
199
+ :param db_file: name of the SQLite DB file.
200
+ :return: BagReader instance.
201
+ """
202
+ return BagReader(db_dir, db_file)
203
+
204
+
205
+ def bag_writer(db_dir: str, db_file: str = default_bag_db_file) -> BagWriter:
206
+ """
207
+ Creates a BagWriter instance to write messages to a ROS2 bag file.
208
+
209
+ :param db_dir: directory to store the SQLite DB file.
210
+ :param db_file: name of the SQLite DB file.
211
+ :return: BagWriter instance.
212
+ """
213
+ if not os.path.exists(db_dir):
214
+ os.makedirs(db_dir, exist_ok=True)
215
+ return BagWriter(db_dir, db_file)
@@ -0,0 +1,61 @@
1
+ from iker.common.utils import logger
2
+ from iker.common.utils.config import Config
3
+ from iker.common.utils.funcutils import memorized
4
+ from iker.common.utils.shutils import expanded_path
5
+
6
+
7
+ @memorized()
8
+ def config(config_path: str = "") -> Config:
9
+ default_items: list[tuple[str, str, str]] = [
10
+ ("plexus", "logging.level", "INFO"),
11
+ ("plexus", "logging.format", "%(asctime)s [%(levelname)s] %(name)s: %(message)s"),
12
+ ]
13
+
14
+ instance = Config(config_path or expanded_path("~/.plexus.cfg"))
15
+ instance.restore()
16
+ instance.update(default_items, overwrite=False)
17
+
18
+ return instance
19
+
20
+
21
+ def config_print_or_set(config: Config, section: str, key: str, value: str):
22
+ if value is not None:
23
+ if section is None or key is None:
24
+ raise ValueError("cannot specify value without section and key")
25
+
26
+ old_value = config.get(section, key)
27
+ config.set(section, key, value)
28
+ config.persist()
29
+
30
+ print(f"Configuration file '{config.config_path}'", )
31
+ print(f"Section <{section}>")
32
+ print(f" {key} = {old_value} -> {value}")
33
+
34
+ else:
35
+ if section is None and key is None:
36
+ print(f"Configuration file '{config.config_path}'", )
37
+ for section in config.config_parser.sections():
38
+ print(f"Section <{section}>")
39
+ for key, value in config.config_parser.items(section):
40
+ print(f" {key} = {value}")
41
+
42
+ elif section is not None and key is None:
43
+ if not config.has_section(section):
44
+ logger.warning("Configuration section <%s> not found", section)
45
+ return
46
+ print(f"Configuration file '{config.config_path}'", )
47
+ print(f"Section <{section}>")
48
+ for key, value in config.config_parser.items(section):
49
+ print(f" {key} = {value}")
50
+
51
+ elif section is not None and key is not None:
52
+ value = config.get(section, key)
53
+ if value is None:
54
+ logger.warning("Configuration section <%s> key <%s> not found", section, key)
55
+ return
56
+ print(f"Configuration file '{config.config_path}'", )
57
+ print(f"Section <{section}>")
58
+ print(f" {key} = {value}")
59
+
60
+ else:
61
+ raise ValueError("cannot specify key without section")