groundmeas 0.1.0__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.
Potentially problematic release.
This version of groundmeas might be problematic. Click here for more details.
- groundmeas/__init__.py +84 -0
- groundmeas/analytics.py +248 -0
- groundmeas/db.py +416 -0
- groundmeas/export.py +169 -0
- groundmeas/models.py +214 -0
- groundmeas/plots.py +137 -0
- groundmeas-0.1.0.dist-info/LICENSE +0 -0
- groundmeas-0.1.0.dist-info/METADATA +178 -0
- groundmeas-0.1.0.dist-info/RECORD +10 -0
- groundmeas-0.1.0.dist-info/WHEEL +4 -0
groundmeas/__init__.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
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.1.0"
|
|
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
|
+
impedance_over_frequency,
|
|
49
|
+
real_imag_over_frequency,
|
|
50
|
+
rho_f_model,
|
|
51
|
+
)
|
|
52
|
+
from .plots import plot_imp_over_f, plot_rho_f_model
|
|
53
|
+
except ImportError as e:
|
|
54
|
+
logger.error("Failed to import groundmeas submodule: %s", e)
|
|
55
|
+
raise
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
# database
|
|
59
|
+
"connect_db",
|
|
60
|
+
"create_measurement",
|
|
61
|
+
"create_item",
|
|
62
|
+
"read_measurements",
|
|
63
|
+
"read_measurements_by",
|
|
64
|
+
"read_items_by",
|
|
65
|
+
"update_measurement",
|
|
66
|
+
"update_item",
|
|
67
|
+
"delete_measurement",
|
|
68
|
+
"delete_item",
|
|
69
|
+
# data models
|
|
70
|
+
"Location",
|
|
71
|
+
"Measurement",
|
|
72
|
+
"MeasurementItem",
|
|
73
|
+
# analytics
|
|
74
|
+
"impedance_over_frequency",
|
|
75
|
+
"real_imag_over_frequency",
|
|
76
|
+
"rho_f_model",
|
|
77
|
+
# plotting
|
|
78
|
+
"plot_imp_over_f",
|
|
79
|
+
"plot_rho_f_model",
|
|
80
|
+
# metadata
|
|
81
|
+
"__version__",
|
|
82
|
+
"__author__",
|
|
83
|
+
"__license__",
|
|
84
|
+
]
|
groundmeas/analytics.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
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 warnings
|
|
13
|
+
from typing import Dict, Union, List, Tuple
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from .db import read_items_by
|
|
18
|
+
|
|
19
|
+
# configure module‐level logger
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def impedance_over_frequency(
|
|
24
|
+
measurement_ids: Union[int, List[int]],
|
|
25
|
+
) -> Union[Dict[float, float], Dict[int, Dict[float, float]]]:
|
|
26
|
+
"""
|
|
27
|
+
Build a mapping from frequency (Hz) to impedance magnitude (Ω).
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
measurement_ids: A single measurement ID or a list of IDs for which
|
|
31
|
+
to retrieve earthing_impedance data.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
If a single ID is provided, returns:
|
|
35
|
+
{ frequency_hz: impedance_value, ... }
|
|
36
|
+
If multiple IDs, returns:
|
|
37
|
+
{ measurement_id: { frequency_hz: impedance_value, ... }, ... }
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
RuntimeError: if retrieving items from the database fails.
|
|
41
|
+
"""
|
|
42
|
+
single = isinstance(measurement_ids, int)
|
|
43
|
+
ids: List[int] = [measurement_ids] if single else list(measurement_ids)
|
|
44
|
+
all_results: Dict[int, Dict[float, float]] = {}
|
|
45
|
+
|
|
46
|
+
for mid in ids:
|
|
47
|
+
try:
|
|
48
|
+
items, _ = read_items_by(
|
|
49
|
+
measurement_id=mid, measurement_type="earthing_impedance"
|
|
50
|
+
)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
logger.error("Error reading impedance items for measurement %s: %s", mid, e)
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
f"Failed to load impedance data for measurement {mid}"
|
|
55
|
+
) from e
|
|
56
|
+
|
|
57
|
+
if not items:
|
|
58
|
+
warnings.warn(
|
|
59
|
+
f"No earthing_impedance measurements found for measurement_id={mid}",
|
|
60
|
+
UserWarning,
|
|
61
|
+
)
|
|
62
|
+
all_results[mid] = {}
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
freq_imp_map: Dict[float, float] = {}
|
|
66
|
+
for item in items:
|
|
67
|
+
freq = item.get("frequency_hz")
|
|
68
|
+
value = item.get("value")
|
|
69
|
+
if freq is None:
|
|
70
|
+
warnings.warn(
|
|
71
|
+
f"MeasurementItem id={item.get('id')} missing frequency_hz; skipping",
|
|
72
|
+
UserWarning,
|
|
73
|
+
)
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
freq_imp_map[float(freq)] = float(value)
|
|
77
|
+
except Exception:
|
|
78
|
+
warnings.warn(
|
|
79
|
+
f"Could not convert item {item.get('id')} to floats; skipping",
|
|
80
|
+
UserWarning,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
all_results[mid] = freq_imp_map
|
|
84
|
+
|
|
85
|
+
return all_results[ids[0]] if single else all_results
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def real_imag_over_frequency(
|
|
89
|
+
measurement_ids: Union[int, List[int]],
|
|
90
|
+
) -> Union[Dict[float, Dict[str, float]], Dict[int, Dict[float, Dict[str, float]]]]:
|
|
91
|
+
"""
|
|
92
|
+
Build a mapping from frequency to real & imaginary components.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
measurement_ids: A single measurement ID or list of IDs.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
If single ID:
|
|
99
|
+
{ frequency_hz: {"real": real_part, "imag": imag_part}, ... }
|
|
100
|
+
If multiple IDs:
|
|
101
|
+
{ measurement_id: { frequency_hz: {...}, ... }, ... }
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
RuntimeError: if retrieving items from the database fails.
|
|
105
|
+
"""
|
|
106
|
+
single = isinstance(measurement_ids, int)
|
|
107
|
+
ids: List[int] = [measurement_ids] if single else list(measurement_ids)
|
|
108
|
+
all_results: Dict[int, Dict[float, Dict[str, float]]] = {}
|
|
109
|
+
|
|
110
|
+
for mid in ids:
|
|
111
|
+
try:
|
|
112
|
+
items, _ = read_items_by(
|
|
113
|
+
measurement_id=mid, measurement_type="earthing_impedance"
|
|
114
|
+
)
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logger.error("Error reading impedance items for measurement %s: %s", mid, e)
|
|
117
|
+
raise RuntimeError(
|
|
118
|
+
f"Failed to load impedance data for measurement {mid}"
|
|
119
|
+
) from e
|
|
120
|
+
|
|
121
|
+
if not items:
|
|
122
|
+
warnings.warn(
|
|
123
|
+
f"No earthing_impedance measurements found for measurement_id={mid}",
|
|
124
|
+
UserWarning,
|
|
125
|
+
)
|
|
126
|
+
all_results[mid] = {}
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
freq_map: Dict[float, Dict[str, float]] = {}
|
|
130
|
+
for item in items:
|
|
131
|
+
freq = item.get("frequency_hz")
|
|
132
|
+
r = item.get("value_real")
|
|
133
|
+
i = item.get("value_imag")
|
|
134
|
+
if freq is None:
|
|
135
|
+
warnings.warn(
|
|
136
|
+
f"MeasurementItem id={item.get('id')} missing frequency_hz; skipping",
|
|
137
|
+
UserWarning,
|
|
138
|
+
)
|
|
139
|
+
continue
|
|
140
|
+
try:
|
|
141
|
+
freq_map[float(freq)] = {
|
|
142
|
+
"real": float(r) if r is not None else None,
|
|
143
|
+
"imag": float(i) if i is not None else None,
|
|
144
|
+
}
|
|
145
|
+
except Exception:
|
|
146
|
+
warnings.warn(
|
|
147
|
+
f"Could not convert real/imag for item {item.get('id')}; skipping",
|
|
148
|
+
UserWarning,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
all_results[mid] = freq_map
|
|
152
|
+
|
|
153
|
+
return all_results[ids[0]] if single else all_results
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def rho_f_model(
|
|
157
|
+
measurement_ids: List[int],
|
|
158
|
+
) -> Tuple[float, float, float, float, float]:
|
|
159
|
+
"""
|
|
160
|
+
Fit the rho–f model:
|
|
161
|
+
Z(ρ,f) = k1*ρ + (k2 + j*k3)*f + (k4 + j*k5)*ρ*f
|
|
162
|
+
|
|
163
|
+
Enforces that at f=0 the impedance is purely real (→ k1*ρ).
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
measurement_ids: List of measurement IDs to include in the fit.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
A tuple (k1, k2, k3, k4, k5) of real coefficients.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
ValueError: if no soil_resistivity or no impedance overlap.
|
|
173
|
+
RuntimeError: if the least-squares solve fails.
|
|
174
|
+
"""
|
|
175
|
+
# 1) Gather real/imag data
|
|
176
|
+
rimap = real_imag_over_frequency(measurement_ids)
|
|
177
|
+
|
|
178
|
+
# 2) Gather available depths → ρ
|
|
179
|
+
rho_map: Dict[int, Dict[float, float]] = {}
|
|
180
|
+
depth_choices: List[List[float]] = []
|
|
181
|
+
|
|
182
|
+
for mid in measurement_ids:
|
|
183
|
+
try:
|
|
184
|
+
items, _ = read_items_by(
|
|
185
|
+
measurement_id=mid, measurement_type="soil_resistivity"
|
|
186
|
+
)
|
|
187
|
+
except Exception as e:
|
|
188
|
+
logger.error("Error reading soil_resistivity for %s: %s", mid, e)
|
|
189
|
+
raise RuntimeError(
|
|
190
|
+
f"Failed to load soil_resistivity for measurement {mid}"
|
|
191
|
+
) from e
|
|
192
|
+
|
|
193
|
+
dt = {
|
|
194
|
+
float(it["measurement_distance_m"]): float(it["value"])
|
|
195
|
+
for it in items
|
|
196
|
+
if it.get("measurement_distance_m") is not None
|
|
197
|
+
and it.get("value") is not None
|
|
198
|
+
}
|
|
199
|
+
if not dt:
|
|
200
|
+
raise ValueError(f"No soil_resistivity data for measurement {mid}")
|
|
201
|
+
rho_map[mid] = dt
|
|
202
|
+
depth_choices.append(list(dt.keys()))
|
|
203
|
+
|
|
204
|
+
# 3) Select depths minimizing spread
|
|
205
|
+
best_combo, best_spread = None, float("inf")
|
|
206
|
+
for combo in itertools.product(*depth_choices):
|
|
207
|
+
spread = max(combo) - min(combo)
|
|
208
|
+
if spread < best_spread:
|
|
209
|
+
best_spread, best_combo = spread, combo
|
|
210
|
+
|
|
211
|
+
selected_rhos = {
|
|
212
|
+
mid: rho_map[mid][depth] for mid, depth in zip(measurement_ids, best_combo)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
# 4) Assemble design matrices & response vectors
|
|
216
|
+
A_R, yR, A_X, yX = [], [], [], []
|
|
217
|
+
|
|
218
|
+
for mid in measurement_ids:
|
|
219
|
+
rho = selected_rhos[mid]
|
|
220
|
+
for f, comp in rimap.get(mid, {}).items():
|
|
221
|
+
R = comp.get("real")
|
|
222
|
+
X = comp.get("imag")
|
|
223
|
+
if R is None or X is None:
|
|
224
|
+
continue
|
|
225
|
+
A_R.append([rho, f, rho * f])
|
|
226
|
+
yR.append(R)
|
|
227
|
+
A_X.append([f, rho * f])
|
|
228
|
+
yX.append(X)
|
|
229
|
+
|
|
230
|
+
if not A_R:
|
|
231
|
+
raise ValueError("No overlapping impedance data available for fitting")
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
A_R = np.vstack(A_R)
|
|
235
|
+
A_X = np.vstack(A_X)
|
|
236
|
+
R_vec = np.asarray(yR)
|
|
237
|
+
X_vec = np.asarray(yX)
|
|
238
|
+
|
|
239
|
+
kR, *_ = np.linalg.lstsq(A_R, R_vec, rcond=None) # [k1, k2, k4]
|
|
240
|
+
kX, *_ = np.linalg.lstsq(A_X, X_vec, rcond=None) # [k3, k5]
|
|
241
|
+
except Exception as e:
|
|
242
|
+
logger.error("Least-squares solve failed: %s", e)
|
|
243
|
+
raise RuntimeError("Failed to solve rho-f least-squares problem") from e
|
|
244
|
+
|
|
245
|
+
k1, k2, k4 = kR
|
|
246
|
+
k3, k5 = kX
|
|
247
|
+
|
|
248
|
+
return float(k1), float(k2), float(k3), float(k4), float(k5)
|