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/models.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
groundmeas.models
|
|
3
|
+
=================
|
|
4
|
+
|
|
5
|
+
Pydantic/SQLModel data models for earthing measurements.
|
|
6
|
+
|
|
7
|
+
Defines:
|
|
8
|
+
- Location: a measurement site with geographic coordinates.
|
|
9
|
+
- Measurement: a test event with metadata and related items.
|
|
10
|
+
- MeasurementItem: a measured data point (e.g. impedance, resistivity) with
|
|
11
|
+
magnitude and optional complex components.
|
|
12
|
+
|
|
13
|
+
Includes an SQLAlchemy event listener to ensure consistency between
|
|
14
|
+
value, value_real/value_imag, and value_angle_deg fields.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import math
|
|
19
|
+
import numpy as np
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from typing import Optional, List, Literal
|
|
22
|
+
|
|
23
|
+
from sqlalchemy import Column, String, event
|
|
24
|
+
from sqlmodel import SQLModel, Field, Relationship
|
|
25
|
+
|
|
26
|
+
from pydantic import field_validator
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
MeasurementType = Literal[
|
|
32
|
+
"prospective_touch_voltage",
|
|
33
|
+
"touch_voltage",
|
|
34
|
+
"earth_potential_rise",
|
|
35
|
+
"step_voltage",
|
|
36
|
+
"transferred_potential",
|
|
37
|
+
"earth_fault_current",
|
|
38
|
+
"earthing_current",
|
|
39
|
+
"shield_current",
|
|
40
|
+
"earthing_resistance",
|
|
41
|
+
"earthing_impedance",
|
|
42
|
+
"soil_resistivity",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
MethodType = Literal[
|
|
46
|
+
"staged_fault_test",
|
|
47
|
+
"injection_remote_substation",
|
|
48
|
+
"injection_earth_electrode",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
AssetType = Literal[
|
|
52
|
+
"substation",
|
|
53
|
+
"overhead_line_tower",
|
|
54
|
+
"cable",
|
|
55
|
+
"cable_cabinet",
|
|
56
|
+
"house",
|
|
57
|
+
"pole_mounted_transformer",
|
|
58
|
+
"mv_lv_earthing_system",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Location(SQLModel, table=True):
|
|
63
|
+
"""
|
|
64
|
+
A geographic location where measurements are taken.
|
|
65
|
+
|
|
66
|
+
Attributes:
|
|
67
|
+
id: Auto-generated primary key.
|
|
68
|
+
name: Human-readable site name.
|
|
69
|
+
latitude: Decimal degrees latitude.
|
|
70
|
+
longitude: Decimal degrees longitude.
|
|
71
|
+
altitude: Altitude in meters (optional).
|
|
72
|
+
measurements: Back-reference to Measurement records for this site.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
id: Optional[int] = Field(default=None, primary_key=True)
|
|
76
|
+
name: str = Field(..., description="Site name")
|
|
77
|
+
latitude: Optional[float] = Field(None, description="Latitude (°)")
|
|
78
|
+
longitude: Optional[float] = Field(None, description="Longitude (°)")
|
|
79
|
+
altitude: Optional[float] = Field(None, description="Altitude (m)")
|
|
80
|
+
measurements: List["Measurement"] = Relationship(back_populates="location")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Measurement(SQLModel, table=True):
|
|
84
|
+
"""
|
|
85
|
+
A single earthing measurement event.
|
|
86
|
+
|
|
87
|
+
Attributes:
|
|
88
|
+
id: Auto-generated primary key.
|
|
89
|
+
timestamp: UTC datetime when the measurement occurred.
|
|
90
|
+
location_id: FK to Location.
|
|
91
|
+
location: Relationship to the Location object.
|
|
92
|
+
method: Measurement method used.
|
|
93
|
+
voltage_level_kv: System voltage in kilovolts (optional).
|
|
94
|
+
asset_type: Type of asset under test.
|
|
95
|
+
fault_resistance_ohm: Fault resistance in ohms (optional).
|
|
96
|
+
operator: Name or identifier of the operator (optional).
|
|
97
|
+
description: Free-text notes (optional).
|
|
98
|
+
items: List of MeasurementItem objects associated to this event.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
id: Optional[int] = Field(default=None, primary_key=True)
|
|
102
|
+
timestamp: datetime = Field(
|
|
103
|
+
default_factory=lambda: datetime.now(timezone.utc),
|
|
104
|
+
description="UTC timestamp of measurement",
|
|
105
|
+
)
|
|
106
|
+
location_id: Optional[int] = Field(default=None, foreign_key="location.id")
|
|
107
|
+
location: Optional[Location] = Relationship(back_populates="measurements")
|
|
108
|
+
method: MethodType = Field(
|
|
109
|
+
sa_column=Column(String, nullable=False), description="Measurement method"
|
|
110
|
+
)
|
|
111
|
+
voltage_level_kv: Optional[float] = Field(None, description="Voltage level in kV")
|
|
112
|
+
asset_type: AssetType = Field(
|
|
113
|
+
sa_column=Column(String, nullable=False), description="Type of asset"
|
|
114
|
+
)
|
|
115
|
+
fault_resistance_ohm: Optional[float] = Field(
|
|
116
|
+
None, description="Fault resistance (Ω)"
|
|
117
|
+
)
|
|
118
|
+
operator: Optional[str] = Field(None, description="Operator name")
|
|
119
|
+
description: Optional[str] = Field(None, description="Notes")
|
|
120
|
+
items: List["MeasurementItem"] = Relationship(back_populates="measurement")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class MeasurementItem(SQLModel, table=True):
|
|
124
|
+
"""
|
|
125
|
+
A single data point within a Measurement.
|
|
126
|
+
|
|
127
|
+
Supports both real/imaginary and magnitude/angle representations.
|
|
128
|
+
|
|
129
|
+
Attributes:
|
|
130
|
+
id: Auto-generated primary key.
|
|
131
|
+
measurement_type: Type of this data point.
|
|
132
|
+
value: Scalar magnitude (Ω or other unit).
|
|
133
|
+
value_real: Real component, if complex (Ω).
|
|
134
|
+
value_imag: Imaginary component, if complex (Ω).
|
|
135
|
+
value_angle_deg: Phase angle in degrees (optional).
|
|
136
|
+
unit: Unit string, e.g. "Ω", "m".
|
|
137
|
+
description: Free-text notes (optional).
|
|
138
|
+
frequency_hz: Frequency in Hz (optional).
|
|
139
|
+
additional_resistance_ohm: Extra series resistance (optional).
|
|
140
|
+
input_impedance_ohm: Instrument input impedance (optional).
|
|
141
|
+
measurement_distance_m: Depth or distance for resistivity (optional).
|
|
142
|
+
measurement_id: FK to parent Measurement.
|
|
143
|
+
measurement: Relationship to the Measurement object.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
id: Optional[int] = Field(default=None, primary_key=True)
|
|
147
|
+
measurement_type: MeasurementType = Field(
|
|
148
|
+
sa_column=Column(String, nullable=False), description="Data point type"
|
|
149
|
+
)
|
|
150
|
+
value: Optional[float] = Field(None, description="Magnitude or scalar value")
|
|
151
|
+
value_real: Optional[float] = Field(None, description="Real part of complex value")
|
|
152
|
+
value_imag: Optional[float] = Field(
|
|
153
|
+
None, description="Imaginary part of complex value"
|
|
154
|
+
)
|
|
155
|
+
value_angle_deg: Optional[float] = Field(None, description="Phase angle in degrees")
|
|
156
|
+
unit: str = Field(..., description="Unit of the measurement")
|
|
157
|
+
description: Optional[str] = Field(None, description="Item notes")
|
|
158
|
+
frequency_hz: Optional[float] = Field(None, description="Frequency (Hz)")
|
|
159
|
+
additional_resistance_ohm: Optional[float] = Field(
|
|
160
|
+
None, description="Additional series resistance (Ω)"
|
|
161
|
+
)
|
|
162
|
+
input_impedance_ohm: Optional[float] = Field(
|
|
163
|
+
None, description="Instrument input impedance (Ω)"
|
|
164
|
+
)
|
|
165
|
+
measurement_distance_m: Optional[float] = Field(
|
|
166
|
+
None, description="Depth/distance for soil resistivity (m)"
|
|
167
|
+
)
|
|
168
|
+
measurement_id: Optional[int] = Field(default=None, foreign_key="measurement.id")
|
|
169
|
+
measurement: Optional[Measurement] = Relationship(back_populates="items")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@event.listens_for(MeasurementItem, "before_insert", propagate=True)
|
|
173
|
+
@event.listens_for(MeasurementItem, "before_update", propagate=True)
|
|
174
|
+
def _compute_magnitude(mapper, connection, target: MeasurementItem):
|
|
175
|
+
"""
|
|
176
|
+
SQLAlchemy event listener to enforce and propagate between
|
|
177
|
+
complex and polar representations:
|
|
178
|
+
|
|
179
|
+
- If `value` is None but real/imag are set, computes magnitude
|
|
180
|
+
and phase angle (degrees).
|
|
181
|
+
- If `value` is set and `value_angle_deg` is set, computes
|
|
182
|
+
`value_real` and `value_imag`.
|
|
183
|
+
- If neither representation is present, raises ValueError.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
ValueError: if no valid value is provided.
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
# Case A: only rectangular given → compute scalar and angle
|
|
190
|
+
if target.value is None:
|
|
191
|
+
if target.value_real is not None or target.value_imag is not None:
|
|
192
|
+
r = target.value_real or 0.0
|
|
193
|
+
i = target.value_imag or 0.0
|
|
194
|
+
target.value = math.hypot(r, i)
|
|
195
|
+
target.value_angle_deg = float(np.degrees(np.arctan2(i, r)))
|
|
196
|
+
else:
|
|
197
|
+
logger.error(
|
|
198
|
+
"MeasurementItem %s lacks both magnitude and real/imag components",
|
|
199
|
+
getattr(target, "id", "<new>"),
|
|
200
|
+
)
|
|
201
|
+
raise ValueError(
|
|
202
|
+
"Either `value` or at least one of (`value_real`, `value_imag`) must be provided"
|
|
203
|
+
)
|
|
204
|
+
# Case B: polar given → compute rectangular components
|
|
205
|
+
elif target.value_angle_deg is not None:
|
|
206
|
+
angle_rad = math.radians(target.value_angle_deg)
|
|
207
|
+
target.value_real = float(target.value * math.cos(angle_rad))
|
|
208
|
+
target.value_imag = float(target.value * math.sin(angle_rad))
|
|
209
|
+
except Exception:
|
|
210
|
+
# Ensure that any unexpected error in conversion is logged
|
|
211
|
+
logger.exception(
|
|
212
|
+
"Failed to compute magnitude/angle for MeasurementItem %s",
|
|
213
|
+
getattr(target, "id", "<new>"),
|
|
214
|
+
)
|
|
215
|
+
raise
|
groundmeas/plots.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# src/groundmeas/plots.py
|
|
2
|
+
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
from typing import Tuple, Union, List, Dict, Any, Optional
|
|
5
|
+
import warnings
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from .analytics import (
|
|
9
|
+
impedance_over_frequency,
|
|
10
|
+
real_imag_over_frequency,
|
|
11
|
+
voltage_vt_epr,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def plot_imp_over_f(
|
|
16
|
+
measurement_ids: Union[int, List[int]], normalize_freq_hz: Optional[float] = None
|
|
17
|
+
) -> plt.Figure:
|
|
18
|
+
"""
|
|
19
|
+
Plot earthing impedance versus frequency for one or multiple measurements on a single figure.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
measurement_ids: single ID or list of Measurement IDs.
|
|
23
|
+
normalize_freq_hz: if provided, normalize each impedance curve by its impedance at this frequency.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
A matplotlib Figure containing all curves.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
ValueError: if normalize_freq_hz is specified but a measurement lacks that frequency,
|
|
30
|
+
or if no data is available for a single measurement.
|
|
31
|
+
"""
|
|
32
|
+
# Normalize input to list
|
|
33
|
+
single = isinstance(measurement_ids, int)
|
|
34
|
+
ids: List[int] = [measurement_ids] if single else list(measurement_ids)
|
|
35
|
+
|
|
36
|
+
# Create a single figure and axis
|
|
37
|
+
fig, ax = plt.subplots()
|
|
38
|
+
|
|
39
|
+
plotted = False
|
|
40
|
+
for mid in ids:
|
|
41
|
+
# Retrieve impedance-frequency map
|
|
42
|
+
freq_imp = impedance_over_frequency(mid)
|
|
43
|
+
if not freq_imp:
|
|
44
|
+
warnings.warn(
|
|
45
|
+
f"No earthing_impedance data for measurement_id={mid}; skipping curve",
|
|
46
|
+
UserWarning,
|
|
47
|
+
)
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
# Sort frequencies
|
|
51
|
+
freqs = sorted(freq_imp.keys())
|
|
52
|
+
imps = [freq_imp[f] for f in freqs]
|
|
53
|
+
|
|
54
|
+
# Normalize if requested
|
|
55
|
+
if normalize_freq_hz is not None:
|
|
56
|
+
baseline = freq_imp.get(normalize_freq_hz)
|
|
57
|
+
if baseline is None:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"Measurement {mid} has no impedance at {normalize_freq_hz} Hz for normalization"
|
|
60
|
+
)
|
|
61
|
+
imps = [val / baseline for val in imps]
|
|
62
|
+
|
|
63
|
+
# Plot the curve
|
|
64
|
+
ax.plot(freqs, imps, marker="o", linestyle="-", label=f"ID {mid}")
|
|
65
|
+
plotted = True
|
|
66
|
+
|
|
67
|
+
if not plotted:
|
|
68
|
+
if single:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"No earthing_impedance data available for measurement_id={measurement_ids}"
|
|
71
|
+
)
|
|
72
|
+
else:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
"No earthing_impedance data available for the provided measurement IDs."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Labels and title
|
|
78
|
+
ax.set_xlabel("Frequency (Hz)")
|
|
79
|
+
ylabel = (
|
|
80
|
+
"Normalized Impedance" if normalize_freq_hz is not None else "Impedance (Ω)"
|
|
81
|
+
)
|
|
82
|
+
ax.set_ylabel(ylabel)
|
|
83
|
+
title = "Impedance vs Frequency"
|
|
84
|
+
if normalize_freq_hz is not None:
|
|
85
|
+
title += f" (Normalized @ {normalize_freq_hz} Hz)"
|
|
86
|
+
ax.set_title(title)
|
|
87
|
+
|
|
88
|
+
# Grid and scientific tick formatting
|
|
89
|
+
ax.grid(True, which="both", linestyle="--", linewidth=0.5)
|
|
90
|
+
ax.ticklabel_format(axis="y", style="sci", scilimits=(0, 0))
|
|
91
|
+
|
|
92
|
+
# Legend
|
|
93
|
+
ax.legend()
|
|
94
|
+
fig.tight_layout()
|
|
95
|
+
return fig
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def plot_rho_f_model(
|
|
99
|
+
measurement_ids: List[int],
|
|
100
|
+
rho_f: Tuple[float, float, float, float, float],
|
|
101
|
+
rho: Union[float, List[float]] = 100,
|
|
102
|
+
) -> plt.Figure:
|
|
103
|
+
"""
|
|
104
|
+
Plot measured impedance and rho-f model on the same axes.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
measurement_ids: list of Measurement IDs.
|
|
108
|
+
rho_f: tuple (k1, k2, k3, k4, k5).
|
|
109
|
+
rho: single float or list of rho values. For list, multiple model curves are plotted.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
A matplotlib Figure with measured and modeled impedance magnitude vs frequency.
|
|
113
|
+
"""
|
|
114
|
+
# Plot measured curves
|
|
115
|
+
fig = plot_imp_over_f(measurement_ids)
|
|
116
|
+
ax = fig.axes[0]
|
|
117
|
+
|
|
118
|
+
# Gather real/imag data
|
|
119
|
+
rimap = real_imag_over_frequency(measurement_ids)
|
|
120
|
+
# Union of frequencies
|
|
121
|
+
all_freqs = set()
|
|
122
|
+
for freq_map in rimap.values():
|
|
123
|
+
all_freqs.update(freq_map.keys())
|
|
124
|
+
freqs = sorted(all_freqs)
|
|
125
|
+
|
|
126
|
+
# Unpack model coefficients
|
|
127
|
+
k1, k2, k3, k4, k5 = rho_f
|
|
128
|
+
|
|
129
|
+
# Normalize rho parameter to list
|
|
130
|
+
rhos: List[float] = [rho] if isinstance(rho, (int, float)) else list(rho)
|
|
131
|
+
|
|
132
|
+
# Plot model curves for each rho
|
|
133
|
+
for rho_val in rhos:
|
|
134
|
+
model_mag = [
|
|
135
|
+
abs((k1) * rho_val + (k2 + 1j * k3) * f + (k4 + 1j * k5) * rho_val * f)
|
|
136
|
+
for f in freqs
|
|
137
|
+
]
|
|
138
|
+
ax.plot(
|
|
139
|
+
freqs, model_mag, linestyle="--", linewidth=2, label=f"Model (ρ={rho_val})"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
ax.legend()
|
|
143
|
+
return fig
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def plot_voltage_vt_epr(
|
|
147
|
+
measurement_ids: Union[int, List[int]],
|
|
148
|
+
frequency: float = 50.0
|
|
149
|
+
) -> plt.Figure:
|
|
150
|
+
"""
|
|
151
|
+
Bar‐plot of EPR and both min/max of prospective and actual touch voltages.
|
|
152
|
+
|
|
153
|
+
For each measurement ID you’ll see:
|
|
154
|
+
• EPR (V) at x–width
|
|
155
|
+
• Prospective TV max & min overlayed at x
|
|
156
|
+
• Actual TV max & min overlayed at x+width
|
|
157
|
+
|
|
158
|
+
All voltage bars start at zero so the longer (max) bars show their full length
|
|
159
|
+
behind the shorter (min) bars.
|
|
160
|
+
"""
|
|
161
|
+
# 1) get the numbers
|
|
162
|
+
data = voltage_vt_epr(measurement_ids, frequency=frequency)
|
|
163
|
+
single = isinstance(measurement_ids, int)
|
|
164
|
+
ids: List[int] = [measurement_ids] if single else list(measurement_ids)
|
|
165
|
+
if single:
|
|
166
|
+
data = {measurement_ids: data}
|
|
167
|
+
|
|
168
|
+
# 2) prepare figure
|
|
169
|
+
fig, ax = plt.subplots()
|
|
170
|
+
x = np.arange(len(ids))
|
|
171
|
+
width = 0.25
|
|
172
|
+
|
|
173
|
+
# 3) EPR bars
|
|
174
|
+
epr = [data[mid].get("epr", 0.0) for mid in ids]
|
|
175
|
+
ax.bar(x - width, epr, width, label="EPR (V/A)", color="C0")
|
|
176
|
+
|
|
177
|
+
# 4) Prospective TV (V/A): max behind (semi‐transparent), min on top
|
|
178
|
+
vtp_max = [data[mid].get("vtp_max", 0.0) for mid in ids]
|
|
179
|
+
vtp_min = [data[mid].get("vtp_min", 0.0) for mid in ids]
|
|
180
|
+
ax.bar(x, vtp_max, width, color="C1", alpha=0.6, label="Vtp max")
|
|
181
|
+
ax.bar(x, vtp_min, width, color="C1", alpha=1.0, label="Vtp min")
|
|
182
|
+
|
|
183
|
+
# 5) Actual TV (V/A): max behind, min on top
|
|
184
|
+
vt_max = [data[mid].get("vt_max", 0.0) for mid in ids]
|
|
185
|
+
vt_min = [data[mid].get("vt_min", 0.0) for mid in ids]
|
|
186
|
+
ax.bar(x + width, vt_max, width, color="C2", alpha=0.6, label="Vt max")
|
|
187
|
+
ax.bar(x + width, vt_min, width, color="C2", alpha=1.0, label="Vt min")
|
|
188
|
+
|
|
189
|
+
# 6) formatting
|
|
190
|
+
ax.set_xticks(x)
|
|
191
|
+
ax.set_xticklabels([str(mid) for mid in ids])
|
|
192
|
+
ax.set_xlabel("Measurement ID")
|
|
193
|
+
ax.set_ylabel("V/A")
|
|
194
|
+
ax.set_title(f"EPR & Touch Voltages Min/Max @ {frequency} Hz")
|
|
195
|
+
ax.legend()
|
|
196
|
+
ax.grid(True, which="both", linestyle="--", linewidth=0.5)
|
|
197
|
+
fig.tight_layout()
|
|
198
|
+
return fig
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: groundmeas
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary:
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Author: Christian Ehlert
|
|
7
|
+
Author-email: christian.ehlert@mailbox.org
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Dist: matplotlib (>=3.10.1,<4.0.0)
|
|
14
|
+
Requires-Dist: numpy (>=2.2.5,<3.0.0)
|
|
15
|
+
Requires-Dist: pandas (>=2.2.3,<3.0.0)
|
|
16
|
+
Requires-Dist: prompt_toolkit (>=3.0.48,<4.0.0)
|
|
17
|
+
Requires-Dist: sqlite-utils (>=3.38,<4.0)
|
|
18
|
+
Requires-Dist: sqlmodel (>=0.0.24,<0.0.25)
|
|
19
|
+
Requires-Dist: typer (>=0.15.3,<0.16.0)
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# groundmeas: Grounding System Measurements & Analysis
|
|
23
|
+
|
|
24
|
+
**groundmeas** is a Python package for collecting, storing, analyzing, and visualizing earthing (grounding) measurement data.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Project Description
|
|
29
|
+
|
|
30
|
+
groundmeas provides:
|
|
31
|
+
|
|
32
|
+
* **Database models & CRUD** via SQLModel/SQLAlchemy (`Location`, `Measurement`, `MeasurementItem`).
|
|
33
|
+
* **Export utilities** to JSON, CSV, and XML.
|
|
34
|
+
* **Analytics routines** for impedance-over-frequency, real–imaginary processing, rho–f model fitting, and shield-current split factors (`calculate_split_factor`).
|
|
35
|
+
* **Plotting helpers** for impedance vs frequency and model overlays using Matplotlib.
|
|
36
|
+
* **CLI** (`gm-cli`) with interactive entry, DB-backed autocomplete, listing, add/edit items and measurements, default DB config, and JSON import/export.
|
|
37
|
+
|
|
38
|
+
It’s designed to help engineers and researchers work with earthing measurement campaigns, automate data pipelines, and quickly gain insights on soil resistivity and grounding impedance behavior.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Technical Background
|
|
43
|
+
|
|
44
|
+
In grounding studies, measurements of earth electrode impedance are taken over a range of frequencies, and soil resistivity measurements at various depths are collected.
|
|
45
|
+
|
|
46
|
+
* **Earthing Impedance** $Z$ vs. **Frequency** (f): typically expressed in Ω.
|
|
47
|
+
* **Soil Resistivity** (ρ) vs. **Depth** (d): used to model frequency‑dependent behavior.
|
|
48
|
+
* **rho–f Model**: fits the relationship
|
|
49
|
+
|
|
50
|
+
$$
|
|
51
|
+
Z(ρ, f) = k_1·ρ + (k_2 + j·k_3)·f + (k_4 + j·k_5)·ρ·f
|
|
52
|
+
$$
|
|
53
|
+
|
|
54
|
+
where $k_1…k_5$ are real coefficients determined by least‑squares across multiple measurements.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
Requires Python 3.12+:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
git clone https://github.com/Ce1ectric/groundmeas.git
|
|
64
|
+
cd groundmeas
|
|
65
|
+
poetry install
|
|
66
|
+
poetry shell
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
or using pip locally:
|
|
70
|
+
```bash
|
|
71
|
+
git clone https://github.com/Ce1ectric/groundmeas.git
|
|
72
|
+
cd groundmeas
|
|
73
|
+
pip install .
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Or install via pip: `pip install groundmeas`.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Usage
|
|
81
|
+
### 0. CLI quickstart
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
gm-cli --db path/to/data.db add-measurement # interactive wizard with autocomplete
|
|
85
|
+
gm-cli --db path/to/data.db list-measurements
|
|
86
|
+
gm-cli --db path/to/data.db list-items 1
|
|
87
|
+
gm-cli --db path/to/data.db edit-measurement 1 # edit a measurement with defaults prefilled
|
|
88
|
+
gm-cli --db path/to/data.db import-json notebooks/measurements/foo_measurement.json
|
|
89
|
+
gm-cli --db path/to/data.db export-json out.json
|
|
90
|
+
# Add a single item to an existing measurement
|
|
91
|
+
gm-cli --db path/to/data.db add-item 5
|
|
92
|
+
# Edit an existing item
|
|
93
|
+
gm-cli --db path/to/data.db edit-item 42
|
|
94
|
+
# Analytics from CLI
|
|
95
|
+
gm-cli --db path/to/data.db impedance-over-frequency 1 --json-out imp.json
|
|
96
|
+
gm-cli --db path/to/data.db rho-f-model 1 2 3 --json-out rho.json
|
|
97
|
+
gm-cli --db path/to/data.db voltage-vt-epr 1 2 -f 50 --json-out vt.json
|
|
98
|
+
gm-cli --db path/to/data.db calculate-split-factor --earth-fault-id 10 --shield-id 11 --shield-id 12
|
|
99
|
+
# Plotting from CLI (writes images)
|
|
100
|
+
gm-cli --db path/to/data.db plot-impedance 1 2 --out imp.png
|
|
101
|
+
gm-cli --db path/to/data.db plot-rho-f-model 1 2 3 --out rho.png
|
|
102
|
+
gm-cli --db path/to/data.db plot-voltage-vt-epr 1 2 --out vt.png
|
|
103
|
+
# Save a default DB path (~/.config/groundmeas/config.json) so --db is optional
|
|
104
|
+
gm-cli set-default-db path/to/data.db
|
|
105
|
+
# Enable shell completion (example for zsh)
|
|
106
|
+
gm-cli --install-completion zsh
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Set `GROUNDMEAS_DB` to avoid passing `--db` each time.
|
|
110
|
+
|
|
111
|
+
### 1. Database Setup
|
|
112
|
+
|
|
113
|
+
Initialize or connect to a SQLite database (tables will be created automatically):
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from groundmeas.db import connect_db
|
|
117
|
+
connect_db("mydata.db", echo=True)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 2. Creating Measurements
|
|
121
|
+
|
|
122
|
+
Insert a measurement (optionally with nested location) and its items:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from groundmeas.db import create_measurement, create_item
|
|
126
|
+
|
|
127
|
+
# Create measurement with nested Location
|
|
128
|
+
meas_id = create_measurement({
|
|
129
|
+
"timestamp": "2025-01-01T12:00:00",
|
|
130
|
+
"method": "staged_fault_test",
|
|
131
|
+
"voltage_level_kv": 10.0,
|
|
132
|
+
"asset_type": "substation",
|
|
133
|
+
"location": {"name": "Site A", "latitude": 52.0, "longitude": 13.0},
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
# Add earthing impedance item
|
|
137
|
+
item_id = create_item({
|
|
138
|
+
"measurement_type": "earthing_impedance",
|
|
139
|
+
"frequency_hz": 50.0,
|
|
140
|
+
"value": 12.3
|
|
141
|
+
}, measurement_id=meas_id)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### 3. Exporting Data
|
|
145
|
+
|
|
146
|
+
Export measurements (and nested items) to various formats:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from groundmeas.export import (
|
|
150
|
+
export_measurements_to_json,
|
|
151
|
+
export_measurements_to_csv,
|
|
152
|
+
export_measurements_to_xml,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
export_measurements_to_json("data.json")
|
|
156
|
+
export_measurements_to_csv("data.csv")
|
|
157
|
+
export_measurements_to_xml("data.xml")
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### 4. Analytics
|
|
161
|
+
|
|
162
|
+
Compute relevant connections between quantities of the earthing system:
|
|
163
|
+
- impedance and
|
|
164
|
+
- real / imaginary parts over frequency,
|
|
165
|
+
- fit the rho–f model
|
|
166
|
+
- prospective touch voltage vs. Earth Potential Rise
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
from groundmeas.analytics import (
|
|
170
|
+
impedance_over_frequency,
|
|
171
|
+
real_imag_over_frequency,
|
|
172
|
+
rho_f_model,
|
|
173
|
+
voltage_vt_epr,
|
|
174
|
+
shield_currents_for_location,
|
|
175
|
+
calculate_split_factor,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Impedance vs frequency for a single measurement
|
|
179
|
+
imp_map = impedance_over_frequency(1)
|
|
180
|
+
|
|
181
|
+
# Real & Imag components for multiple measurements
|
|
182
|
+
ri_map = real_imag_over_frequency([1, 2, 3])
|
|
183
|
+
|
|
184
|
+
# Fit rho–f model across measurements [1,2,3]
|
|
185
|
+
k1, k2, k3, k4, k5 = rho_f_model([1, 2, 3])
|
|
186
|
+
|
|
187
|
+
# summarise the min, max measured touch voltages and the EPR for multiple measurements
|
|
188
|
+
touch_min, touch_max, epr = voltage_vt_epr([1, 2, 3])
|
|
189
|
+
|
|
190
|
+
# Gather available shield currents at a site and compute split factors
|
|
191
|
+
candidates = shield_currents_for_location(location_id=5, frequency_hz=50.0)
|
|
192
|
+
# Pick the shield_current item IDs that share the same angle reference
|
|
193
|
+
shield_ids = [c["id"] for c in candidates]
|
|
194
|
+
split = calculate_split_factor(
|
|
195
|
+
earth_fault_current_id=42,
|
|
196
|
+
shield_current_ids=shield_ids,
|
|
197
|
+
)
|
|
198
|
+
split_factor = split["split_factor"]
|
|
199
|
+
local_earthing_current = split["local_earthing_current"]["value"]
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### 5. Plotting
|
|
203
|
+
|
|
204
|
+
Visualize raw and modeled curves:
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
from groundmeas.plots import plot_imp_over_f, plot_rho_f_model
|
|
208
|
+
|
|
209
|
+
# Raw impedance curves
|
|
210
|
+
fig1 = plot_imp_over_f([1, 2, 3])
|
|
211
|
+
fig1.show()
|
|
212
|
+
|
|
213
|
+
# Normalized at 50 Hz
|
|
214
|
+
fig2 = plot_imp_over_f(1, normalize_freq_hz=50)
|
|
215
|
+
fig2.show()
|
|
216
|
+
|
|
217
|
+
# Overlay rho–f model
|
|
218
|
+
fig3 = plot_rho_f_model([1,2,3], (k1,k2,k3,k4,k5), rho=[100, 200])
|
|
219
|
+
fig3.show()
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Contributing
|
|
223
|
+
|
|
224
|
+
Pull requests are welcome!
|
|
225
|
+
For major changes, please open an issue first to discuss.
|
|
226
|
+
Ensure tests pass and add new tests for your changes.
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## License
|
|
231
|
+
|
|
232
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
|
233
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
groundmeas/__init__.py,sha256=uIF7B3aewIWQZjigqinH4-wt8cYOWne0JhbZm1mims4,2271
|
|
2
|
+
groundmeas/analytics.py,sha256=atyH3KYm4hOECuq5eUR8e8JOvpS49TGcrNdP_MCmRHI,18093
|
|
3
|
+
groundmeas/cli.py,sha256=KP7JKpzrIwKljkWW74sGnKtg4wyPQ9kmNELhTjBek4M,30462
|
|
4
|
+
groundmeas/db.py,sha256=p4F8rl_CME6skcAgGWF9HgRTMxvS4dWRw9XF5z4fEk8,13709
|
|
5
|
+
groundmeas/export.py,sha256=cBY7zJaXXHCdRqJXwSCuhnX6ywMoo7Jbp0hhPxnEUjM,5687
|
|
6
|
+
groundmeas/models.py,sha256=LgnRbxdjwDtAyxcB8PaLutL4L1os1fh2P_888jDV7Uk,8270
|
|
7
|
+
groundmeas/plots.py,sha256=oeYp9HqIRGPdtnUDVMrL0sYGHkyF0stmAZp12WxVzbU,6424
|
|
8
|
+
groundmeas-0.3.1.dist-info/METADATA,sha256=VT-a_DSZ50gh0jfV-UlsomIH9gy3z4_RhU2qDXfzrMw,7142
|
|
9
|
+
groundmeas-0.3.1.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
10
|
+
groundmeas-0.3.1.dist-info/entry_points.txt,sha256=YW0mRPeeW_evhusVPCOW4AfFTCTBAIG1MMy91exwRZY,45
|
|
11
|
+
groundmeas-0.3.1.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
groundmeas-0.3.1.dist-info/RECORD,,
|
|
File without changes
|