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 +89 -0
- groundmeas/analytics.py +531 -0
- groundmeas/cli.py +804 -0
- groundmeas/db.py +437 -0
- groundmeas/export.py +169 -0
- groundmeas/models.py +215 -0
- groundmeas/plots.py +198 -0
- groundmeas-0.3.1.dist-info/METADATA +233 -0
- groundmeas-0.3.1.dist-info/RECORD +12 -0
- groundmeas-0.3.1.dist-info/WHEEL +4 -0
- groundmeas-0.3.1.dist-info/entry_points.txt +3 -0
- groundmeas-0.3.1.dist-info/licenses/LICENSE +0 -0
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
|
+
]
|
groundmeas/analytics.py
ADDED
|
@@ -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
|
+
}
|