groundmeas 0.3.1__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.
groundmeas/db.py ADDED
@@ -0,0 +1,437 @@
1
+ """
2
+ groundmeas.db
3
+ =============
4
+
5
+ Database interface for groundmeas package.
6
+
7
+ Provides functions to connect to a SQLite database and perform
8
+ CRUD operations on Location, Measurement, and MeasurementItem models.
9
+ """
10
+
11
+ import logging
12
+ from typing import List, Optional, Dict, Any, Tuple
13
+
14
+ from sqlalchemy import and_, text
15
+ from sqlalchemy.exc import SQLAlchemyError
16
+ from sqlalchemy.orm import selectinload, Session
17
+ from sqlmodel import SQLModel, create_engine, select
18
+
19
+ from .models import Location, Measurement, MeasurementItem
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _engine = None
24
+
25
+
26
+ def connect_db(path: str, echo: bool = False) -> None:
27
+ """
28
+ Initialize a SQLite database (or connect to existing).
29
+
30
+ Creates an SQLModel engine pointing at `path` and issues
31
+ CREATE TABLE IF NOT EXISTS for all defined models.
32
+
33
+ Args:
34
+ path: Filesystem path to the SQLite file (use ":memory:" for RAM DB).
35
+ echo: If True, SQLAlchemy will log all SQL statements.
36
+
37
+ Raises:
38
+ RuntimeError: if the database or tables cannot be created.
39
+ """
40
+ global _engine
41
+ database_url = f"sqlite:///{path}"
42
+ try:
43
+ _engine = create_engine(database_url, echo=echo)
44
+ SQLModel.metadata.create_all(_engine)
45
+ logger.info("Connected to database at %s", path)
46
+ except SQLAlchemyError as e:
47
+ logger.exception("Failed to initialize database at %s", path)
48
+ raise RuntimeError(f"Could not initialize database: {e}") from e
49
+
50
+
51
+ def _get_session() -> Session:
52
+ """
53
+ Internal: obtain a new Session bound to the global engine.
54
+
55
+ Returns:
56
+ A new SQLModel Session.
57
+
58
+ Raises:
59
+ RuntimeError: if `connect_db` has not been called.
60
+ """
61
+ if _engine is None:
62
+ raise RuntimeError("Database not initialized; call connect_db() first")
63
+ return Session(_engine)
64
+
65
+
66
+ def create_measurement(data: Dict[str, Any]) -> int:
67
+ """
68
+ Insert a new Measurement record, optionally creating a nested Location.
69
+
70
+ Args:
71
+ data: A dict of Measurement fields. May include a "location" key
72
+ whose value is a dict for creating a Location.
73
+
74
+ Returns:
75
+ The primary key (id) of the newly created Measurement.
76
+
77
+ Raises:
78
+ RuntimeError: on any database error during insertion.
79
+ """
80
+ loc_data = data.pop("location", None)
81
+ if loc_data:
82
+ try:
83
+ with _get_session() as session:
84
+ loc = Location(**loc_data)
85
+ session.add(loc)
86
+ session.commit()
87
+ session.refresh(loc)
88
+ data["location_id"] = loc.id
89
+ except SQLAlchemyError as e:
90
+ logger.exception("Failed to create Location with data %s", loc_data)
91
+ raise RuntimeError(f"Could not create Location: {e}") from e
92
+
93
+ try:
94
+ with _get_session() as session:
95
+ meas = Measurement(**data)
96
+ session.add(meas)
97
+ session.commit()
98
+ session.refresh(meas)
99
+ return meas.id # type: ignore
100
+ except SQLAlchemyError as e:
101
+ logger.exception("Failed to create Measurement with data %s", data)
102
+ raise RuntimeError(f"Could not create Measurement: {e}") from e
103
+
104
+
105
+ def create_item(data: Dict[str, Any], measurement_id: int) -> int:
106
+ """
107
+ Insert a new MeasurementItem linked to an existing Measurement.
108
+
109
+ Args:
110
+ data: A dict of MeasurementItem fields (excluding measurement_id).
111
+ measurement_id: The parent Measurement.id.
112
+
113
+ Returns:
114
+ The primary key (id) of the newly created MeasurementItem.
115
+
116
+ Raises:
117
+ RuntimeError: on any database error during insertion.
118
+ """
119
+ payload = data.copy()
120
+ payload["measurement_id"] = measurement_id
121
+ try:
122
+ with _get_session() as session:
123
+ item = MeasurementItem(**payload)
124
+ session.add(item)
125
+ session.commit()
126
+ session.refresh(item)
127
+ return item.id # type: ignore
128
+ except SQLAlchemyError as e:
129
+ logger.exception(
130
+ "Failed to create MeasurementItem for measurement_id=%s with data %s",
131
+ measurement_id,
132
+ data,
133
+ )
134
+ raise RuntimeError(f"Could not create MeasurementItem: {e}") from e
135
+
136
+
137
+ def read_measurements(
138
+ where: Optional[str] = None,
139
+ ) -> Tuple[List[Dict[str, Any]], List[int]]:
140
+ """
141
+ Retrieve Measurements, optionally filtered by a raw SQL WHERE clause.
142
+
143
+ Args:
144
+ where: A SQLAlchemy-compatible WHERE clause string (e.g. "asset_type = 'substation'").
145
+
146
+ Returns:
147
+ A tuple:
148
+ - List of measurement dicts (each includes a nested "items" list)
149
+ - List of measurement IDs in the same order
150
+
151
+ Raises:
152
+ RuntimeError: if a database error occurs.
153
+ """
154
+ stmt = select(Measurement).options(
155
+ selectinload(Measurement.items),
156
+ selectinload(Measurement.location),
157
+ )
158
+ if where:
159
+ stmt = stmt.where(text(where))
160
+
161
+ try:
162
+ with _get_session() as session:
163
+ result = session.execute(stmt)
164
+ results = result.scalars().all()
165
+
166
+ except Exception as e:
167
+ logger.exception("Failed to execute read_measurements query")
168
+ raise RuntimeError(f"Could not read measurements: {e}") from e
169
+
170
+ records, ids = [], []
171
+ for meas in results:
172
+ d = meas.model_dump()
173
+ loc = getattr(meas, "location", None)
174
+ if loc is not None:
175
+ d["location"] = loc.model_dump() if hasattr(loc, "model_dump") else loc
176
+ else:
177
+ d["location"] = None
178
+ d["items"] = [it.model_dump() for it in meas.items]
179
+ records.append(d)
180
+ ids.append(meas.id) # type: ignore
181
+ return records, ids
182
+
183
+
184
+ def read_measurements_by(**filters: Any) -> Tuple[List[Dict[str, Any]], List[int]]:
185
+ """
186
+ Retrieve Measurements by keyword filters with suffix operators.
187
+
188
+ Supported operators: __eq (default), __ne, __lt, __lte, __gt, __gte, __in.
189
+
190
+ Args:
191
+ **filters: Field lookups, e.g. asset_type='substation',
192
+ voltage_level_kv__gte=10.
193
+
194
+ Returns:
195
+ A tuple of (list of measurement dicts, list of IDs).
196
+
197
+ Raises:
198
+ ValueError: on unsupported filter operator.
199
+ RuntimeError: on database error.
200
+ """
201
+ stmt = select(Measurement).options(
202
+ selectinload(Measurement.items),
203
+ selectinload(Measurement.location),
204
+ )
205
+ clauses = []
206
+ for key, val in filters.items():
207
+ if "__" in key:
208
+ field, op = key.split("__", 1)
209
+ else:
210
+ field, op = key, "eq"
211
+ col = getattr(Measurement, field, None)
212
+ if col is None:
213
+ raise ValueError(f"Unknown filter field: {field}")
214
+ if op == "eq":
215
+ clauses.append(col == val)
216
+ elif op == "ne":
217
+ clauses.append(col != val)
218
+ elif op == "lt":
219
+ clauses.append(col < val)
220
+ elif op == "lte":
221
+ clauses.append(col <= val)
222
+ elif op == "gt":
223
+ clauses.append(col > val)
224
+ elif op == "gte":
225
+ clauses.append(col >= val)
226
+ elif op == "in":
227
+ clauses.append(col.in_(val))
228
+ else:
229
+ raise ValueError(f"Unsupported filter operator: {op}")
230
+ if clauses:
231
+ stmt = stmt.where(and_(*clauses))
232
+
233
+ try:
234
+ with _get_session() as session:
235
+ result = session.execute(stmt)
236
+ results = result.scalars().all()
237
+ except Exception as e:
238
+ logger.exception("Failed to read measurements by filters: %s", filters)
239
+ raise RuntimeError(f"Could not read measurements_by: {e}") from e
240
+
241
+ records, ids = [], []
242
+ for meas in results:
243
+ d = meas.model_dump()
244
+ loc = getattr(meas, "location", None)
245
+ if loc is not None:
246
+ d["location"] = loc.model_dump() if hasattr(loc, "model_dump") else loc
247
+ else:
248
+ d["location"] = None
249
+ d["items"] = [it.model_dump() for it in meas.items]
250
+ records.append(d)
251
+ ids.append(meas.id) # type: ignore
252
+ return records, ids
253
+
254
+
255
+ def read_items_by(**filters: Any) -> Tuple[List[Dict[str, Any]], List[int]]:
256
+ """
257
+ Retrieve MeasurementItem records by keyword filters with suffix operators.
258
+
259
+ Supported operators: __eq (default), __ne, __lt, __lte, __gt, __gte, __in.
260
+
261
+ Args:
262
+ **filters: Field lookups, e.g. measurement_id=1, frequency_hz__gte=50.
263
+
264
+ Returns:
265
+ A tuple of (list of item dicts, list of IDs).
266
+
267
+ Raises:
268
+ ValueError: on unsupported filter operator.
269
+ RuntimeError: on database error.
270
+ """
271
+ stmt = select(MeasurementItem)
272
+ clauses = []
273
+ for key, val in filters.items():
274
+ if "__" in key:
275
+ field, op = key.split("__", 1)
276
+ else:
277
+ field, op = key, "eq"
278
+ col = getattr(MeasurementItem, field, None)
279
+ if col is None:
280
+ raise ValueError(f"Unknown filter field: {field}")
281
+ if op == "eq":
282
+ clauses.append(col == val)
283
+ elif op == "ne":
284
+ clauses.append(col != val)
285
+ elif op == "lt":
286
+ clauses.append(col < val)
287
+ elif op == "lte":
288
+ clauses.append(col <= val)
289
+ elif op == "gt":
290
+ clauses.append(col > val)
291
+ elif op == "gte":
292
+ clauses.append(col >= val)
293
+ elif op == "in":
294
+ clauses.append(col.in_(val))
295
+ else:
296
+ raise ValueError(f"Unsupported filter operator: {op}")
297
+ if clauses:
298
+ stmt = stmt.where(and_(*clauses))
299
+
300
+ try:
301
+ with _get_session() as session:
302
+ result = session.execute(stmt)
303
+ results = result.scalars().all()
304
+ except Exception as e:
305
+ logger.exception("Failed to execute read_items_by query")
306
+ raise RuntimeError(f"Could not read items_by: {e}") from e
307
+
308
+ records, ids = [], []
309
+ for it in results:
310
+ records.append(it.model_dump())
311
+ ids.append(it.id) # type: ignore
312
+ return records, ids
313
+
314
+
315
+ def update_measurement(measurement_id: int, updates: Dict[str, Any]) -> bool:
316
+ """
317
+ Update an existing Measurement by ID.
318
+
319
+ Args:
320
+ measurement_id: The ID of the Measurement to update.
321
+ updates: Dict of field names to new values.
322
+
323
+ Returns:
324
+ True if the Measurement existed and was updated; False if not found.
325
+
326
+ Raises:
327
+ RuntimeError: on database error.
328
+ """
329
+ loc_updates = updates.pop("location", None)
330
+ try:
331
+ with _get_session() as session:
332
+ meas = session.get(Measurement, measurement_id)
333
+ if meas is None:
334
+ return False
335
+ if loc_updates:
336
+ if meas.location_id and meas.location:
337
+ for field, val in loc_updates.items():
338
+ setattr(meas.location, field, val)
339
+ session.add(meas.location)
340
+ else:
341
+ new_loc = Location(**loc_updates)
342
+ session.add(new_loc)
343
+ session.flush()
344
+ meas.location_id = new_loc.id
345
+ for field, val in updates.items():
346
+ setattr(meas, field, val)
347
+ session.add(meas)
348
+ session.commit()
349
+ return True
350
+ except SQLAlchemyError as e:
351
+ logger.exception(
352
+ "Failed to update Measurement %s with %s", measurement_id, updates
353
+ )
354
+ raise RuntimeError(f"Could not update measurement {measurement_id}: {e}") from e
355
+
356
+
357
+ def delete_measurement(measurement_id: int) -> bool:
358
+ """
359
+ Delete a Measurement (and its items) by ID.
360
+
361
+ Args:
362
+ measurement_id: The ID of the Measurement to delete.
363
+
364
+ Returns:
365
+ True if deleted; False if not found.
366
+
367
+ Raises:
368
+ RuntimeError: on database error.
369
+ """
370
+ try:
371
+ with _get_session() as session:
372
+ meas = session.get(Measurement, measurement_id)
373
+ if meas is None:
374
+ return False
375
+ session.delete(meas)
376
+ session.commit()
377
+ return True
378
+ except SQLAlchemyError as e:
379
+ logger.exception("Failed to delete Measurement %s", measurement_id)
380
+ raise RuntimeError(f"Could not delete measurement {measurement_id}: {e}") from e
381
+
382
+
383
+ def update_item(item_id: int, updates: Dict[str, Any]) -> bool:
384
+ """
385
+ Update an existing MeasurementItem by ID.
386
+
387
+ Args:
388
+ item_id: The ID of the item to update.
389
+ updates: Dict of field names to new values.
390
+
391
+ Returns:
392
+ True if updated; False if not found.
393
+
394
+ Raises:
395
+ RuntimeError: on database error.
396
+ """
397
+ try:
398
+ with _get_session() as session:
399
+ it = session.get(MeasurementItem, item_id)
400
+ if it is None:
401
+ return False
402
+ for field, val in updates.items():
403
+ setattr(it, field, val)
404
+ session.add(it)
405
+ session.commit()
406
+ return True
407
+ except SQLAlchemyError as e:
408
+ logger.exception(
409
+ "Failed to update MeasurementItem %s with %s", item_id, updates
410
+ )
411
+ raise RuntimeError(f"Could not update item {item_id}: {e}") from e
412
+
413
+
414
+ def delete_item(item_id: int) -> bool:
415
+ """
416
+ Delete a MeasurementItem by ID.
417
+
418
+ Args:
419
+ item_id: The ID of the item to delete.
420
+
421
+ Returns:
422
+ True if deleted; False if not found.
423
+
424
+ Raises:
425
+ RuntimeError: on database error.
426
+ """
427
+ try:
428
+ with _get_session() as session:
429
+ it = session.get(MeasurementItem, item_id)
430
+ if it is None:
431
+ return False
432
+ session.delete(it)
433
+ session.commit()
434
+ return True
435
+ except SQLAlchemyError as e:
436
+ logger.exception("Failed to delete MeasurementItem %s", item_id)
437
+ raise RuntimeError(f"Could not delete item {item_id}: {e}") from e
groundmeas/export.py ADDED
@@ -0,0 +1,169 @@
1
+ """
2
+ groundmeas.export
3
+ =================
4
+
5
+ Export utilities for the groundmeas package.
6
+
7
+ Provides functions to export Measurement data (with nested items) to JSON, CSV, and XML formats.
8
+ """
9
+
10
+ import json
11
+ import csv
12
+ import xml.etree.ElementTree as ET
13
+ import datetime
14
+ import logging
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from .db import read_measurements_by
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def export_measurements_to_json(path: str, **filters: Any) -> None:
24
+ """
25
+ Export measurements (and nested items) matching filters to a JSON file.
26
+
27
+ Uses the same keyword filters as read_measurements_by().
28
+
29
+ Args:
30
+ path: Filesystem path where the JSON file will be written.
31
+ **filters: Field lookups passed through to read_measurements_by().
32
+
33
+ Raises:
34
+ RuntimeError: if reading the data fails.
35
+ IOError: if writing the file fails.
36
+ """
37
+ try:
38
+ data, _ = read_measurements_by(**filters)
39
+ except Exception as e:
40
+ logger.exception(
41
+ "Failed to retrieve measurements for JSON export with filters %s", filters
42
+ )
43
+ raise RuntimeError(f"Could not read measurements: {e}") from e
44
+
45
+ try:
46
+ out_path = Path(path)
47
+ with out_path.open("w", encoding="utf-8") as f:
48
+ json.dump(
49
+ data,
50
+ f,
51
+ indent=2,
52
+ default=lambda o: (
53
+ o.isoformat() if isinstance(o, datetime.datetime) else str(o)
54
+ ),
55
+ )
56
+ logger.info("Exported %d measurements to JSON: %s", len(data), path)
57
+ except Exception as e:
58
+ logger.exception("Failed to write JSON export to %s", path)
59
+ raise IOError(f"Could not write JSON file '{path}': {e}") from e
60
+
61
+
62
+ def export_measurements_to_csv(path: str, **filters: Any) -> None:
63
+ """
64
+ Export measurements (and nested items) matching filters to a CSV file.
65
+
66
+ Each row is one measurement; the 'items' column contains a JSON-encoded list.
67
+
68
+ Args:
69
+ path: Filesystem path where the CSV file will be written.
70
+ **filters: Field lookups passed through to read_measurements_by().
71
+
72
+ Raises:
73
+ RuntimeError: if reading the data fails.
74
+ IOError: if writing the file fails.
75
+ """
76
+ try:
77
+ data, _ = read_measurements_by(**filters)
78
+ except Exception as e:
79
+ logger.exception(
80
+ "Failed to retrieve measurements for CSV export with filters %s", filters
81
+ )
82
+ raise RuntimeError(f"Could not read measurements: {e}") from e
83
+
84
+ if not data:
85
+ logger.warning("No data to export to CSV with filters %s", filters)
86
+ return
87
+
88
+ # Determine columns (exclude nested 'items')
89
+ cols = [c for c in data[0].keys() if c != "items"]
90
+ fieldnames = cols + ["items"]
91
+
92
+ try:
93
+ out_path = Path(path)
94
+ with out_path.open("w", newline="", encoding="utf-8") as f:
95
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
96
+ writer.writeheader()
97
+ for m in data:
98
+ row = {c: m.get(c) for c in cols}
99
+ row["items"] = json.dumps(m.get("items", []))
100
+ writer.writerow(row)
101
+ logger.info("Exported %d measurements to CSV: %s", len(data), path)
102
+ except Exception as e:
103
+ logger.exception("Failed to write CSV export to %s", path)
104
+ raise IOError(f"Could not write CSV file '{path}': {e}") from e
105
+
106
+
107
+ def export_measurements_to_xml(path: str, **filters: Any) -> None:
108
+ """
109
+ Export measurements (and nested items) matching filters to an XML file.
110
+
111
+ The XML structure:
112
+ <measurements>
113
+ <measurement id="...">
114
+ <field1>...</field1>
115
+ ...
116
+ <items>
117
+ <item id="...">
118
+ <subfield>...</subfield>
119
+ ...
120
+ </item>
121
+ ...
122
+ </items>
123
+ </measurement>
124
+ ...
125
+ </measurements>
126
+
127
+ Args:
128
+ path: Filesystem path where the XML file will be written.
129
+ **filters: Field lookups passed through to read_measurements_by().
130
+
131
+ Raises:
132
+ RuntimeError: if reading the data fails.
133
+ IOError: if writing the file fails.
134
+ """
135
+ try:
136
+ data, _ = read_measurements_by(**filters)
137
+ except Exception as e:
138
+ logger.exception(
139
+ "Failed to retrieve measurements for XML export with filters %s", filters
140
+ )
141
+ raise RuntimeError(f"Could not read measurements: {e}") from e
142
+
143
+ root = ET.Element("measurements")
144
+ for m in data:
145
+ meas_elem = ET.SubElement(root, "measurement", id=str(m.get("id")))
146
+ for key, val in m.items():
147
+ if key == "id":
148
+ continue
149
+ if key == "items":
150
+ items_elem = ET.SubElement(meas_elem, "items")
151
+ for it in val:
152
+ item_elem = ET.SubElement(items_elem, "item", id=str(it.get("id")))
153
+ for subkey, subval in it.items():
154
+ if subkey == "id":
155
+ continue
156
+ child = ET.SubElement(item_elem, subkey)
157
+ child.text = "" if subval is None else str(subval)
158
+ else:
159
+ child = ET.SubElement(meas_elem, key)
160
+ child.text = "" if val is None else str(val)
161
+
162
+ try:
163
+ out_path = Path(path)
164
+ tree = ET.ElementTree(root)
165
+ tree.write(out_path, encoding="utf-8", xml_declaration=True)
166
+ logger.info("Exported %d measurements to XML: %s", len(data), path)
167
+ except Exception as e:
168
+ logger.exception("Failed to write XML export to %s", path)
169
+ raise IOError(f"Could not write XML file '{path}': {e}") from e