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/__init__.py ADDED
@@ -0,0 +1,89 @@
1
+ """
2
+ groundmeas
3
+ ==========
4
+
5
+ A Python package for managing, storing, analyzing, and plotting earthing measurements.
6
+
7
+ Features:
8
+ - SQLite + SQLModel (Pydantic) data models for Measurement, MeasurementItem, and Location.
9
+ - CRUD operations with simple `connect_db`, `create_*`, `read_*`, `update_*`, and `delete_*` APIs.
10
+ - Analytics: impedance vs frequency, real/imag mappings, and rho–f modeling.
11
+ - Plotting helpers wrapping matplotlib for quick visualizations.
12
+
13
+ Example:
14
+ import groundmeas as gm
15
+
16
+ gm.connect_db("ground.db")
17
+ mid = gm.create_measurement({...})
18
+ items, ids = gm.read_items_by(measurement_id=mid)
19
+ fig = gm.plot_imp_over_f(mid)
20
+ fig.show()
21
+ """
22
+
23
+ import logging
24
+
25
+ # Configure a library logger with a NullHandler by default
26
+ logger = logging.getLogger(__name__)
27
+ logger.addHandler(logging.NullHandler())
28
+
29
+ __version__ = "0.3.1"
30
+ __author__ = "Ce1ectric"
31
+ __license__ = "MIT"
32
+
33
+ try:
34
+ from .db import (
35
+ connect_db,
36
+ create_measurement,
37
+ create_item,
38
+ read_measurements,
39
+ read_measurements_by,
40
+ read_items_by,
41
+ update_measurement,
42
+ update_item,
43
+ delete_measurement,
44
+ delete_item,
45
+ )
46
+ from .models import Location, Measurement, MeasurementItem
47
+ from .analytics import (
48
+ calculate_split_factor,
49
+ impedance_over_frequency,
50
+ real_imag_over_frequency,
51
+ rho_f_model,
52
+ shield_currents_for_location,
53
+ )
54
+ from .plots import plot_imp_over_f, plot_rho_f_model, plot_voltage_vt_epr
55
+ except ImportError as e:
56
+ logger.error("Failed to import groundmeas submodule: %s", e)
57
+ raise
58
+
59
+ __all__ = [
60
+ # database
61
+ "connect_db",
62
+ "create_measurement",
63
+ "create_item",
64
+ "read_measurements",
65
+ "read_measurements_by",
66
+ "read_items_by",
67
+ "update_measurement",
68
+ "update_item",
69
+ "delete_measurement",
70
+ "delete_item",
71
+ # data models
72
+ "Location",
73
+ "Measurement",
74
+ "MeasurementItem",
75
+ # analytics
76
+ "calculate_split_factor",
77
+ "impedance_over_frequency",
78
+ "real_imag_over_frequency",
79
+ "rho_f_model",
80
+ "shield_currents_for_location",
81
+ # plotting
82
+ "plot_imp_over_f",
83
+ "plot_rho_f_model",
84
+ "plot_voltage_vt_epr",
85
+ # metadata
86
+ "__version__",
87
+ "__author__",
88
+ "__license__",
89
+ ]
@@ -0,0 +1,531 @@
1
+ """
2
+ groundmeas.analytics
3
+ ====================
4
+
5
+ Analytics functions for the groundmeas package. Provides routines to fetch and
6
+ process impedance and resistivity data for earthing measurements, and to fit
7
+ and evaluate rho–f models.
8
+ """
9
+
10
+ import itertools
11
+ import logging
12
+ import math
13
+ import warnings
14
+ from typing import Any, Dict, Union, List, Tuple
15
+
16
+ import numpy as np
17
+
18
+ from .db import read_items_by, read_measurements_by
19
+
20
+ # configure module‐level logger
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def impedance_over_frequency(
25
+ measurement_ids: Union[int, List[int]],
26
+ ) -> Union[Dict[float, float], Dict[int, Dict[float, float]]]:
27
+ """
28
+ Build a mapping from frequency (Hz) to impedance magnitude (Ω).
29
+
30
+ Args:
31
+ measurement_ids: A single measurement ID or a list of IDs for which
32
+ to retrieve earthing_impedance data.
33
+
34
+ Returns:
35
+ If a single ID is provided, returns:
36
+ { frequency_hz: impedance_value, ... }
37
+ If multiple IDs, returns:
38
+ { measurement_id: { frequency_hz: impedance_value, ... }, ... }
39
+
40
+ Raises:
41
+ RuntimeError: if retrieving items from the database fails.
42
+ """
43
+ single = isinstance(measurement_ids, int)
44
+ ids: List[int] = [measurement_ids] if single else list(measurement_ids)
45
+ all_results: Dict[int, Dict[float, float]] = {}
46
+
47
+ for mid in ids:
48
+ try:
49
+ items, _ = read_items_by(
50
+ measurement_id=mid, measurement_type="earthing_impedance"
51
+ )
52
+ except Exception as e:
53
+ logger.error("Error reading impedance items for measurement %s: %s", mid, e)
54
+ raise RuntimeError(
55
+ f"Failed to load impedance data for measurement {mid}"
56
+ ) from e
57
+
58
+ if not items:
59
+ warnings.warn(
60
+ f"No earthing_impedance measurements found for measurement_id={mid}",
61
+ UserWarning,
62
+ )
63
+ all_results[mid] = {}
64
+ continue
65
+
66
+ freq_imp_map: Dict[float, float] = {}
67
+ for item in items:
68
+ freq = item.get("frequency_hz")
69
+ value = item.get("value")
70
+ if freq is None:
71
+ warnings.warn(
72
+ f"MeasurementItem id={item.get('id')} missing frequency_hz; skipping",
73
+ UserWarning,
74
+ )
75
+ continue
76
+ try:
77
+ freq_imp_map[float(freq)] = float(value)
78
+ except Exception:
79
+ warnings.warn(
80
+ f"Could not convert item {item.get('id')} to floats; skipping",
81
+ UserWarning,
82
+ )
83
+
84
+ all_results[mid] = freq_imp_map
85
+
86
+ return all_results[ids[0]] if single else all_results
87
+
88
+
89
+ def real_imag_over_frequency(
90
+ measurement_ids: Union[int, List[int]],
91
+ ) -> Union[Dict[float, Dict[str, float]], Dict[int, Dict[float, Dict[str, float]]]]:
92
+ """
93
+ Build a mapping from frequency to real & imaginary components.
94
+
95
+ Args:
96
+ measurement_ids: A single measurement ID or list of IDs.
97
+
98
+ Returns:
99
+ If single ID:
100
+ { frequency_hz: {"real": real_part, "imag": imag_part}, ... }
101
+ If multiple IDs:
102
+ { measurement_id: { frequency_hz: {...}, ... }, ... }
103
+
104
+ Raises:
105
+ RuntimeError: if retrieving items from the database fails.
106
+ """
107
+ single = isinstance(measurement_ids, int)
108
+ ids: List[int] = [measurement_ids] if single else list(measurement_ids)
109
+ all_results: Dict[int, Dict[float, Dict[str, float]]] = {}
110
+
111
+ for mid in ids:
112
+ try:
113
+ items, _ = read_items_by(
114
+ measurement_id=mid, measurement_type="earthing_impedance"
115
+ )
116
+ except Exception as e:
117
+ logger.error("Error reading impedance items for measurement %s: %s", mid, e)
118
+ raise RuntimeError(
119
+ f"Failed to load impedance data for measurement {mid}"
120
+ ) from e
121
+
122
+ if not items:
123
+ warnings.warn(
124
+ f"No earthing_impedance measurements found for measurement_id={mid}",
125
+ UserWarning,
126
+ )
127
+ all_results[mid] = {}
128
+ continue
129
+
130
+ freq_map: Dict[float, Dict[str, float]] = {}
131
+ for item in items:
132
+ freq = item.get("frequency_hz")
133
+ r = item.get("value_real")
134
+ i = item.get("value_imag")
135
+ if freq is None:
136
+ warnings.warn(
137
+ f"MeasurementItem id={item.get('id')} missing frequency_hz; skipping",
138
+ UserWarning,
139
+ )
140
+ continue
141
+ try:
142
+ freq_map[float(freq)] = {
143
+ "real": float(r) if r is not None else None,
144
+ "imag": float(i) if i is not None else None,
145
+ }
146
+ except Exception:
147
+ warnings.warn(
148
+ f"Could not convert real/imag for item {item.get('id')}; skipping",
149
+ UserWarning,
150
+ )
151
+
152
+ all_results[mid] = freq_map
153
+
154
+ return all_results[ids[0]] if single else all_results
155
+
156
+
157
+ def rho_f_model(
158
+ measurement_ids: List[int],
159
+ ) -> Tuple[float, float, float, float, float]:
160
+ """
161
+ Fit the rho–f model:
162
+ Z(ρ,f) = k1*ρ + (k2 + j*k3)*f + (k4 + j*k5)*ρ*f
163
+
164
+ Enforces that at f=0 the impedance is purely real (→ k1*ρ).
165
+
166
+ Args:
167
+ measurement_ids: List of measurement IDs to include in the fit.
168
+
169
+ Returns:
170
+ A tuple (k1, k2, k3, k4, k5) of real coefficients.
171
+
172
+ Raises:
173
+ ValueError: if no soil_resistivity or no impedance overlap.
174
+ RuntimeError: if the least-squares solve fails.
175
+ """
176
+ # 1) Gather real/imag data
177
+ rimap = real_imag_over_frequency(measurement_ids)
178
+
179
+ # 2) Gather available depths → ρ
180
+ rho_map: Dict[int, Dict[float, float]] = {}
181
+ depth_choices: List[List[float]] = []
182
+
183
+ for mid in measurement_ids:
184
+ try:
185
+ items, _ = read_items_by(
186
+ measurement_id=mid, measurement_type="soil_resistivity"
187
+ )
188
+ except Exception as e:
189
+ logger.error("Error reading soil_resistivity for %s: %s", mid, e)
190
+ raise RuntimeError(
191
+ f"Failed to load soil_resistivity for measurement {mid}"
192
+ ) from e
193
+
194
+ dt = {
195
+ float(it["measurement_distance_m"]): float(it["value"])
196
+ for it in items
197
+ if it.get("measurement_distance_m") is not None
198
+ and it.get("value") is not None
199
+ }
200
+ if not dt:
201
+ raise ValueError(f"No soil_resistivity data for measurement {mid}")
202
+ rho_map[mid] = dt
203
+ depth_choices.append(list(dt.keys()))
204
+
205
+ # 3) Select depths minimizing spread
206
+ best_combo, best_spread = None, float("inf")
207
+ for combo in itertools.product(*depth_choices):
208
+ spread = max(combo) - min(combo)
209
+ if spread < best_spread:
210
+ best_spread, best_combo = spread, combo
211
+
212
+ selected_rhos = {
213
+ mid: rho_map[mid][depth] for mid, depth in zip(measurement_ids, best_combo)
214
+ }
215
+
216
+ # 4) Assemble design matrices & response vectors
217
+ A_R, yR, A_X, yX = [], [], [], []
218
+
219
+ for mid in measurement_ids:
220
+ rho = selected_rhos[mid]
221
+ for f, comp in rimap.get(mid, {}).items():
222
+ R = comp.get("real")
223
+ X = comp.get("imag")
224
+ if R is None or X is None:
225
+ continue
226
+ A_R.append([rho, f, rho * f])
227
+ yR.append(R)
228
+ A_X.append([f, rho * f])
229
+ yX.append(X)
230
+
231
+ if not A_R:
232
+ raise ValueError("No overlapping impedance data available for fitting")
233
+
234
+ try:
235
+ A_R = np.vstack(A_R)
236
+ A_X = np.vstack(A_X)
237
+ R_vec = np.asarray(yR)
238
+ X_vec = np.asarray(yX)
239
+
240
+ kR, *_ = np.linalg.lstsq(A_R, R_vec, rcond=None) # [k1, k2, k4]
241
+ kX, *_ = np.linalg.lstsq(A_X, X_vec, rcond=None) # [k3, k5]
242
+ except Exception as e:
243
+ logger.error("Least-squares solve failed: %s", e)
244
+ raise RuntimeError("Failed to solve rho-f least-squares problem") from e
245
+
246
+ k1, k2, k4 = kR
247
+ k3, k5 = kX
248
+
249
+ return float(k1), float(k2), float(k3), float(k4), float(k5)
250
+
251
+ def voltage_vt_epr(
252
+ measurement_ids: Union[int, List[int]],
253
+ frequency: float = 50.0
254
+ ) -> Union[Dict[str, float], Dict[int, Dict[str, float]]]:
255
+ """
256
+ Calculate per-ampere touch voltages and EPR for measurements at a given frequency.
257
+
258
+ Mandatory data:
259
+ - earthing_impedance (Z in Ω = V/A)
260
+ - earthing_current (I in A)
261
+
262
+ Optional data (include whichever is present):
263
+ - prospective_touch_voltage (V)
264
+ - touch_voltage (V)
265
+
266
+ Returns:
267
+ - If single ID: a dict {key: value, ...}
268
+ - If multiple IDs: a dict {measurement_id: {...}, ...}
269
+
270
+ Keys in each result dict:
271
+ - epr : Earth potential rise = Z * I
272
+ - vtp_min
273
+ - vtp_max (if prospective_touch_voltage data exist)
274
+ - vt_min
275
+ - vt_max (if touch_voltage data exist)
276
+ """
277
+ single = isinstance(measurement_ids, int)
278
+ ids = [measurement_ids] if single else list(measurement_ids)
279
+ results: Dict[int, Dict[str, float]] = {}
280
+
281
+ for mid in ids:
282
+ # 1) Mandatory: impedance Z (V/A) at this frequency
283
+ try:
284
+ imp_items, _ = read_items_by(
285
+ measurement_id=mid,
286
+ measurement_type="earthing_impedance",
287
+ frequency_hz=frequency
288
+ )
289
+ Z = float(imp_items[0]["value"])
290
+ except Exception:
291
+ warnings.warn(f"Measurement {mid}: missing earthing_impedance@{frequency}Hz → skipping", UserWarning)
292
+ continue
293
+
294
+ # 2) Mandatory: current I (A) at this frequency
295
+ try:
296
+ cur_items, _ = read_items_by(
297
+ measurement_id=mid,
298
+ measurement_type="earthing_current",
299
+ frequency_hz=frequency
300
+ )
301
+ I = float(cur_items[0]["value"])
302
+ if I == 0:
303
+ raise ValueError("zero current")
304
+ except Exception:
305
+ warnings.warn(f"Measurement {mid}: missing or zero earthing_current@{frequency}Hz → skipping", UserWarning)
306
+ continue
307
+
308
+ entry: Dict[str, float] = {}
309
+
310
+ # 3) Set EPR
311
+ entry["epr"] = Z
312
+
313
+ # 4) Optional: prospective touch voltage (V/A)
314
+ try:
315
+ vtp_items, _ = read_items_by(
316
+ measurement_id=mid,
317
+ measurement_type="prospective_touch_voltage",
318
+ frequency_hz=frequency
319
+ )
320
+ vtp_vals = [float(it["value"]) / I for it in vtp_items]
321
+ entry["vtp_min"] = min(vtp_vals)
322
+ entry["vtp_max"] = max(vtp_vals)
323
+ except Exception:
324
+ warnings.warn(f"Measurement {mid}: no prospective_touch_voltage@{frequency}Hz", UserWarning)
325
+
326
+ # 5) Optional: actual touch voltage (V/A)
327
+ try:
328
+ vt_items, _ = read_items_by(
329
+ measurement_id=mid,
330
+ measurement_type="touch_voltage",
331
+ frequency_hz=frequency
332
+ )
333
+ vt_vals = [float(it["value"]) / I for it in vt_items]
334
+ entry["vt_min"] = min(vt_vals)
335
+ entry["vt_max"] = max(vt_vals)
336
+ except Exception:
337
+ warnings.warn(f"Measurement {mid}: no touch_voltage@{frequency}Hz", UserWarning)
338
+
339
+ results[mid] = entry
340
+
341
+ # if single measurement, return its dict directly (or empty dict if skipped)
342
+ return results[ids[0]] if single else results
343
+
344
+
345
+ def _current_item_to_complex(item: Dict[str, Any]) -> complex:
346
+ """
347
+ Convert a MeasurementItem-like dict into a complex current (A).
348
+
349
+ Prefers rectangular components if present, otherwise uses magnitude/angle.
350
+ """
351
+ real = item.get("value_real")
352
+ imag = item.get("value_imag")
353
+ if real is not None or imag is not None:
354
+ return complex(float(real or 0.0), float(imag or 0.0))
355
+
356
+ value = item.get("value")
357
+ if value is None:
358
+ raise ValueError(f"MeasurementItem id={item.get('id')} has no current value")
359
+
360
+ angle_deg = item.get("value_angle_deg")
361
+ try:
362
+ magnitude = float(value)
363
+ if angle_deg is None:
364
+ return complex(magnitude, 0.0)
365
+ angle_rad = math.radians(float(angle_deg))
366
+ except Exception as exc:
367
+ raise ValueError(
368
+ f"Invalid magnitude/angle for MeasurementItem id={item.get('id')}"
369
+ ) from exc
370
+
371
+ return complex(
372
+ magnitude * math.cos(angle_rad),
373
+ magnitude * math.sin(angle_rad),
374
+ )
375
+
376
+
377
+ def shield_currents_for_location(
378
+ location_id: int, frequency_hz: float | None = None
379
+ ) -> List[Dict[str, Any]]:
380
+ """
381
+ Collect all shield_current MeasurementItems for a given location.
382
+
383
+ Args:
384
+ location_id: Location.id to search under.
385
+ frequency_hz: Optional frequency filter.
386
+
387
+ Returns:
388
+ List of item dicts (one per shield_current) with measurement_id included.
389
+
390
+ Raises:
391
+ RuntimeError: if reading measurements fails.
392
+ """
393
+ try:
394
+ measurements, _ = read_measurements_by(location_id=location_id)
395
+ except Exception as e:
396
+ logger.error(
397
+ "Error reading measurements for location_id=%s: %s", location_id, e
398
+ )
399
+ raise RuntimeError(
400
+ f"Failed to read measurements for location_id={location_id}"
401
+ ) from e
402
+
403
+ candidates: List[Dict[str, Any]] = []
404
+ for meas in measurements:
405
+ mid = meas.get("id")
406
+ for item in meas.get("items", []):
407
+ if item.get("measurement_type") != "shield_current":
408
+ continue
409
+ if frequency_hz is not None:
410
+ freq = item.get("frequency_hz")
411
+ try:
412
+ if freq is None or float(freq) != float(frequency_hz):
413
+ continue
414
+ except Exception:
415
+ continue
416
+ candidate = {
417
+ "id": item.get("id"),
418
+ "measurement_id": mid,
419
+ "frequency_hz": item.get("frequency_hz"),
420
+ "value": item.get("value"),
421
+ "value_angle_deg": item.get("value_angle_deg"),
422
+ "value_real": item.get("value_real"),
423
+ "value_imag": item.get("value_imag"),
424
+ "unit": item.get("unit"),
425
+ "description": item.get("description"),
426
+ }
427
+ candidates.append(candidate)
428
+
429
+ if not candidates:
430
+ warnings.warn(
431
+ f"No shield_current items found for location_id={location_id}",
432
+ UserWarning,
433
+ )
434
+ return candidates
435
+
436
+
437
+ def calculate_split_factor(
438
+ earth_fault_current_id: int, shield_current_ids: List[int]
439
+ ) -> Dict[str, Any]:
440
+ """
441
+ Compute the split factor and local earthing current from selected shield currents.
442
+
443
+ The caller is responsible for choosing shield_current items with a consistent
444
+ angle reference. Use `shield_currents_for_location` to list candidates and
445
+ pass the chosen item IDs here.
446
+
447
+ Args:
448
+ earth_fault_current_id: MeasurementItem.id carrying the total earth fault current.
449
+ shield_current_ids: MeasurementItem.ids of shield_current values to subtract.
450
+
451
+ Returns:
452
+ Dict with:
453
+ - split_factor (float)
454
+ - shield_current_sum (magnitude/angle/real/imag)
455
+ - local_earthing_current (magnitude/angle/real/imag)
456
+ - earth_fault_current (magnitude/angle/real/imag)
457
+
458
+ Raises:
459
+ ValueError: if inputs are missing or zero.
460
+ RuntimeError: if database access fails.
461
+ """
462
+ if not shield_current_ids:
463
+ raise ValueError("Provide at least one shield_current id for split factor")
464
+
465
+ try:
466
+ earth_items, _ = read_items_by(
467
+ id=earth_fault_current_id, measurement_type="earth_fault_current"
468
+ )
469
+ except Exception as e:
470
+ logger.error(
471
+ "Error reading earth_fault_current id=%s: %s", earth_fault_current_id, e
472
+ )
473
+ raise RuntimeError("Failed to read earth_fault_current item") from e
474
+
475
+ if not earth_items:
476
+ raise ValueError(f"No earth_fault_current item found with id={earth_fault_current_id}")
477
+
478
+ try:
479
+ shield_items, _ = read_items_by(
480
+ measurement_type="shield_current", id__in=shield_current_ids
481
+ )
482
+ except Exception as e:
483
+ logger.error(
484
+ "Error reading shield_current ids=%s: %s", shield_current_ids, e
485
+ )
486
+ raise RuntimeError("Failed to read shield_current items") from e
487
+
488
+ if not shield_items:
489
+ raise ValueError("No shield_current items found for the provided IDs")
490
+
491
+ found_ids = {it.get("id") for it in shield_items}
492
+ missing = [sid for sid in shield_current_ids if sid not in found_ids]
493
+ if missing:
494
+ warnings.warn(
495
+ f"shield_current IDs not found and skipped: {missing}", UserWarning
496
+ )
497
+
498
+ earth_current = _current_item_to_complex(earth_items[0])
499
+ if abs(earth_current) == 0:
500
+ raise ValueError("Earth fault current magnitude is zero; cannot compute split factor")
501
+
502
+ shield_vectors = [_current_item_to_complex(it) for it in shield_items]
503
+ shield_sum = sum(shield_vectors, 0 + 0j)
504
+
505
+ split_factor = 1 - (abs(shield_sum) / abs(earth_current))
506
+ local_current = earth_current - shield_sum
507
+
508
+ def _angle_deg(val: complex) -> float:
509
+ return 0.0 if val == 0 else math.degrees(math.atan2(val.imag, val.real))
510
+
511
+ return {
512
+ "split_factor": split_factor,
513
+ "shield_current_sum": {
514
+ "value": abs(shield_sum),
515
+ "value_angle_deg": _angle_deg(shield_sum),
516
+ "value_real": shield_sum.real,
517
+ "value_imag": shield_sum.imag,
518
+ },
519
+ "local_earthing_current": {
520
+ "value": abs(local_current),
521
+ "value_angle_deg": _angle_deg(local_current),
522
+ "value_real": local_current.real,
523
+ "value_imag": local_current.imag,
524
+ },
525
+ "earth_fault_current": {
526
+ "value": abs(earth_current),
527
+ "value_angle_deg": _angle_deg(earth_current),
528
+ "value_real": earth_current.real,
529
+ "value_imag": earth_current.imag,
530
+ },
531
+ }