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/models.py ADDED
@@ -0,0 +1,214 @@
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
+ "earthing_resistance",
40
+ "earthing_impedance",
41
+ "soil_resistivity",
42
+ ]
43
+
44
+ MethodType = Literal[
45
+ "staged_fault_test",
46
+ "injection_remote_substation",
47
+ "injection_earth_electrode",
48
+ ]
49
+
50
+ AssetType = Literal[
51
+ "substation",
52
+ "overhead_line_tower",
53
+ "cable",
54
+ "cable_cabinet",
55
+ "house",
56
+ "pole_mounted_transformer",
57
+ "mv_lv_earthing_system",
58
+ ]
59
+
60
+
61
+ class Location(SQLModel, table=True):
62
+ """
63
+ A geographic location where measurements are taken.
64
+
65
+ Attributes:
66
+ id: Auto-generated primary key.
67
+ name: Human-readable site name.
68
+ latitude: Decimal degrees latitude.
69
+ longitude: Decimal degrees longitude.
70
+ altitude: Altitude in meters (optional).
71
+ measurements: Back-reference to Measurement records for this site.
72
+ """
73
+
74
+ id: Optional[int] = Field(default=None, primary_key=True)
75
+ name: str = Field(..., description="Site name")
76
+ latitude: Optional[float] = Field(None, description="Latitude (°)")
77
+ longitude: Optional[float] = Field(None, description="Longitude (°)")
78
+ altitude: Optional[float] = Field(None, description="Altitude (m)")
79
+ measurements: List["Measurement"] = Relationship(back_populates="location")
80
+
81
+
82
+ class Measurement(SQLModel, table=True):
83
+ """
84
+ A single earthing measurement event.
85
+
86
+ Attributes:
87
+ id: Auto-generated primary key.
88
+ timestamp: UTC datetime when the measurement occurred.
89
+ location_id: FK to Location.
90
+ location: Relationship to the Location object.
91
+ method: Measurement method used.
92
+ voltage_level_kv: System voltage in kilovolts (optional).
93
+ asset_type: Type of asset under test.
94
+ fault_resistance_ohm: Fault resistance in ohms (optional).
95
+ operator: Name or identifier of the operator (optional).
96
+ description: Free-text notes (optional).
97
+ items: List of MeasurementItem objects associated to this event.
98
+ """
99
+
100
+ id: Optional[int] = Field(default=None, primary_key=True)
101
+ timestamp: datetime = Field(
102
+ default_factory=lambda: datetime.now(timezone.utc),
103
+ description="UTC timestamp of measurement",
104
+ )
105
+ location_id: Optional[int] = Field(default=None, foreign_key="location.id")
106
+ location: Optional[Location] = Relationship(back_populates="measurements")
107
+ method: MethodType = Field(
108
+ sa_column=Column(String, nullable=False), description="Measurement method"
109
+ )
110
+ voltage_level_kv: Optional[float] = Field(None, description="Voltage level in kV")
111
+ asset_type: AssetType = Field(
112
+ sa_column=Column(String, nullable=False), description="Type of asset"
113
+ )
114
+ fault_resistance_ohm: Optional[float] = Field(
115
+ None, description="Fault resistance (Ω)"
116
+ )
117
+ operator: Optional[str] = Field(None, description="Operator name")
118
+ description: Optional[str] = Field(None, description="Notes")
119
+ items: List["MeasurementItem"] = Relationship(back_populates="measurement")
120
+
121
+
122
+ class MeasurementItem(SQLModel, table=True):
123
+ """
124
+ A single data point within a Measurement.
125
+
126
+ Supports both real/imaginary and magnitude/angle representations.
127
+
128
+ Attributes:
129
+ id: Auto-generated primary key.
130
+ measurement_type: Type of this data point.
131
+ value: Scalar magnitude (Ω or other unit).
132
+ value_real: Real component, if complex (Ω).
133
+ value_imag: Imaginary component, if complex (Ω).
134
+ value_angle_deg: Phase angle in degrees (optional).
135
+ unit: Unit string, e.g. "Ω", "m".
136
+ description: Free-text notes (optional).
137
+ frequency_hz: Frequency in Hz (optional).
138
+ additional_resistance_ohm: Extra series resistance (optional).
139
+ input_impedance_ohm: Instrument input impedance (optional).
140
+ measurement_distance_m: Depth or distance for resistivity (optional).
141
+ measurement_id: FK to parent Measurement.
142
+ measurement: Relationship to the Measurement object.
143
+ """
144
+
145
+ id: Optional[int] = Field(default=None, primary_key=True)
146
+ measurement_type: MeasurementType = Field(
147
+ sa_column=Column(String, nullable=False), description="Data point type"
148
+ )
149
+ value: Optional[float] = Field(None, description="Magnitude or scalar value")
150
+ value_real: Optional[float] = Field(None, description="Real part of complex value")
151
+ value_imag: Optional[float] = Field(
152
+ None, description="Imaginary part of complex value"
153
+ )
154
+ value_angle_deg: Optional[float] = Field(None, description="Phase angle in degrees")
155
+ unit: str = Field(..., description="Unit of the measurement")
156
+ description: Optional[str] = Field(None, description="Item notes")
157
+ frequency_hz: Optional[float] = Field(None, description="Frequency (Hz)")
158
+ additional_resistance_ohm: Optional[float] = Field(
159
+ None, description="Additional series resistance (Ω)"
160
+ )
161
+ input_impedance_ohm: Optional[float] = Field(
162
+ None, description="Instrument input impedance (Ω)"
163
+ )
164
+ measurement_distance_m: Optional[float] = Field(
165
+ None, description="Depth/distance for soil resistivity (m)"
166
+ )
167
+ measurement_id: Optional[int] = Field(default=None, foreign_key="measurement.id")
168
+ measurement: Optional[Measurement] = Relationship(back_populates="items")
169
+
170
+
171
+ @event.listens_for(MeasurementItem, "before_insert", propagate=True)
172
+ @event.listens_for(MeasurementItem, "before_update", propagate=True)
173
+ def _compute_magnitude(mapper, connection, target: MeasurementItem):
174
+ """
175
+ SQLAlchemy event listener to enforce and propagate between
176
+ complex and polar representations:
177
+
178
+ - If `value` is None but real/imag are set, computes magnitude
179
+ and phase angle (degrees).
180
+ - If `value` is set and `value_angle_deg` is set, computes
181
+ `value_real` and `value_imag`.
182
+ - If neither representation is present, raises ValueError.
183
+
184
+ Raises:
185
+ ValueError: if no valid value is provided.
186
+ """
187
+ try:
188
+ # Case A: only rectangular given → compute scalar and angle
189
+ if target.value is None:
190
+ if target.value_real is not None or target.value_imag is not None:
191
+ r = target.value_real or 0.0
192
+ i = target.value_imag or 0.0
193
+ target.value = math.hypot(r, i)
194
+ target.value_angle_deg = float(np.degrees(np.arctan2(i, r)))
195
+ else:
196
+ logger.error(
197
+ "MeasurementItem %s lacks both magnitude and real/imag components",
198
+ getattr(target, "id", "<new>"),
199
+ )
200
+ raise ValueError(
201
+ "Either `value` or at least one of (`value_real`, `value_imag`) must be provided"
202
+ )
203
+ # Case B: polar given → compute rectangular components
204
+ elif target.value_angle_deg is not None:
205
+ angle_rad = math.radians(target.value_angle_deg)
206
+ target.value_real = float(target.value * math.cos(angle_rad))
207
+ target.value_imag = float(target.value * math.sin(angle_rad))
208
+ except Exception:
209
+ # Ensure that any unexpected error in conversion is logged
210
+ logger.exception(
211
+ "Failed to compute magnitude/angle for MeasurementItem %s",
212
+ getattr(target, "id", "<new>"),
213
+ )
214
+ raise
groundmeas/plots.py ADDED
@@ -0,0 +1,137 @@
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
+ from .analytics import impedance_over_frequency, real_imag_over_frequency
7
+
8
+
9
+ def plot_imp_over_f(
10
+ measurement_ids: Union[int, List[int]], normalize_freq_hz: Optional[float] = None
11
+ ) -> plt.Figure:
12
+ """
13
+ Plot earthing impedance versus frequency for one or multiple measurements on a single figure.
14
+
15
+ Args:
16
+ measurement_ids: single ID or list of Measurement IDs.
17
+ normalize_freq_hz: if provided, normalize each impedance curve by its impedance at this frequency.
18
+
19
+ Returns:
20
+ A matplotlib Figure containing all curves.
21
+
22
+ Raises:
23
+ ValueError: if normalize_freq_hz is specified but a measurement lacks that frequency,
24
+ or if no data is available for a single measurement.
25
+ """
26
+ # Normalize input to list
27
+ single = isinstance(measurement_ids, int)
28
+ ids: List[int] = [measurement_ids] if single else list(measurement_ids)
29
+
30
+ # Create a single figure and axis
31
+ fig, ax = plt.subplots()
32
+
33
+ plotted = False
34
+ for mid in ids:
35
+ # Retrieve impedance-frequency map
36
+ freq_imp = impedance_over_frequency(mid)
37
+ if not freq_imp:
38
+ warnings.warn(
39
+ f"No earthing_impedance data for measurement_id={mid}; skipping curve",
40
+ UserWarning,
41
+ )
42
+ continue
43
+
44
+ # Sort frequencies
45
+ freqs = sorted(freq_imp.keys())
46
+ imps = [freq_imp[f] for f in freqs]
47
+
48
+ # Normalize if requested
49
+ if normalize_freq_hz is not None:
50
+ baseline = freq_imp.get(normalize_freq_hz)
51
+ if baseline is None:
52
+ raise ValueError(
53
+ f"Measurement {mid} has no impedance at {normalize_freq_hz} Hz for normalization"
54
+ )
55
+ imps = [val / baseline for val in imps]
56
+
57
+ # Plot the curve
58
+ ax.plot(freqs, imps, marker="o", linestyle="-", label=f"ID {mid}")
59
+ plotted = True
60
+
61
+ if not plotted:
62
+ if single:
63
+ raise ValueError(
64
+ f"No earthing_impedance data available for measurement_id={measurement_ids}"
65
+ )
66
+ else:
67
+ raise ValueError(
68
+ "No earthing_impedance data available for the provided measurement IDs."
69
+ )
70
+
71
+ # Labels and title
72
+ ax.set_xlabel("Frequency (Hz)")
73
+ ylabel = (
74
+ "Normalized Impedance" if normalize_freq_hz is not None else "Impedance (Ω)"
75
+ )
76
+ ax.set_ylabel(ylabel)
77
+ title = "Impedance vs Frequency"
78
+ if normalize_freq_hz is not None:
79
+ title += f" (Normalized @ {normalize_freq_hz} Hz)"
80
+ ax.set_title(title)
81
+
82
+ # Grid and scientific tick formatting
83
+ ax.grid(True, which="both", linestyle="--", linewidth=0.5)
84
+ ax.ticklabel_format(axis="y", style="sci", scilimits=(0, 0))
85
+
86
+ # Legend
87
+ ax.legend()
88
+ fig.tight_layout()
89
+ return fig
90
+
91
+
92
+ def plot_rho_f_model(
93
+ measurement_ids: List[int],
94
+ rho_f: Tuple[float, float, float, float, float],
95
+ rho: Union[float, List[float]] = 100,
96
+ ) -> plt.Figure:
97
+ """
98
+ Plot measured impedance and rho-f model on the same axes.
99
+
100
+ Args:
101
+ measurement_ids: list of Measurement IDs.
102
+ rho_f: tuple (k1, k2, k3, k4, k5).
103
+ rho: single float or list of rho values. For list, multiple model curves are plotted.
104
+
105
+ Returns:
106
+ A matplotlib Figure with measured and modeled impedance magnitude vs frequency.
107
+ """
108
+ # Plot measured curves
109
+ fig = plot_imp_over_f(measurement_ids)
110
+ ax = fig.axes[0]
111
+
112
+ # Gather real/imag data
113
+ rimap = real_imag_over_frequency(measurement_ids)
114
+ # Union of frequencies
115
+ all_freqs = set()
116
+ for freq_map in rimap.values():
117
+ all_freqs.update(freq_map.keys())
118
+ freqs = sorted(all_freqs)
119
+
120
+ # Unpack model coefficients
121
+ k1, k2, k3, k4, k5 = rho_f
122
+
123
+ # Normalize rho parameter to list
124
+ rhos: List[float] = [rho] if isinstance(rho, (int, float)) else list(rho)
125
+
126
+ # Plot model curves for each rho
127
+ for rho_val in rhos:
128
+ model_mag = [
129
+ abs((k1) * rho_val + (k2 + 1j * k3) * f + (k4 + 1j * k5) * rho_val * f)
130
+ for f in freqs
131
+ ]
132
+ ax.plot(
133
+ freqs, model_mag, linestyle="--", linewidth=2, label=f"Model (ρ={rho_val})"
134
+ )
135
+
136
+ ax.legend()
137
+ return fig
File without changes
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.3
2
+ Name: groundmeas
3
+ Version: 0.1.0
4
+ Summary:
5
+ Author: Christian Ehlert
6
+ Author-email: christian.ehlert@mailbox.org
7
+ Requires-Python: >=3.12
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Requires-Dist: matplotlib (>=3.10.1,<4.0.0)
12
+ Requires-Dist: numpy (>=2.2.5,<3.0.0)
13
+ Requires-Dist: pandas (>=2.2.3,<3.0.0)
14
+ Requires-Dist: sqlite-utils (>=3.38,<4.0)
15
+ Requires-Dist: sqlmodel (>=0.0.24,<0.0.25)
16
+ Description-Content-Type: text/markdown
17
+
18
+ # groundmeas: Grounding System Measurements & Analysis
19
+
20
+ **groundmeas** is a Python package for collecting, storing, analyzing, and visualizing earthing (grounding) measurement data.
21
+
22
+ ---
23
+
24
+ ## Project Description
25
+
26
+ groundmeas provides:
27
+
28
+ * **Database models & CRUD** via SQLModel/SQLAlchemy (`Location`, `Measurement`, `MeasurementItem`).
29
+ * **Export utilities** to JSON, CSV, and XML.
30
+ * **Analytics routines** for impedance-over-frequency, real–imaginary processing, and rho–f model fitting.
31
+ * **Plotting helpers** for impedance vs frequency and model overlays using Matplotlib.
32
+
33
+ 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.
34
+
35
+ ---
36
+
37
+ ## Technical Background
38
+
39
+ In grounding studies, measurements of earth electrode impedance are taken over a range of frequencies, and soil resistivity measurements at various depths are collected.
40
+
41
+ * **Earthing Impedance** $Z$ vs. **Frequency** (f): typically expressed in Ω.
42
+ * **Soil Resistivity** (ρ) vs. **Depth** (d): used to model frequency‑dependent behavior.
43
+ * **rho–f Model**: fits the relationship
44
+
45
+ $$
46
+ Z(ρ, f) = k_1·ρ + (k_2 + j·k_3)·f + (k_4 + j·k_5)·ρ·f
47
+ $$
48
+
49
+ where $k_1…k_5$ are real coefficients determined by least‑squares across multiple measurements.
50
+
51
+ ---
52
+
53
+ ## Installation
54
+
55
+ Requires Python 3.12+:
56
+
57
+ ```bash
58
+ git clone https://github.com/Ce1ectric/groundmeas.git
59
+ cd groundmeas
60
+ poetry install
61
+ poetry shell
62
+ ```
63
+
64
+ or using pip locally:
65
+ ```bash
66
+ git clone https://github.com/Ce1ectric/groundmeas.git
67
+ cd groundmeas
68
+ pip install .
69
+ ```
70
+
71
+ Or install via pip: `pip install groundmeas`.
72
+
73
+ ---
74
+
75
+ ## Usage
76
+
77
+ ### 1. Database Setup
78
+
79
+ Initialize or connect to a SQLite database (tables will be created automatically):
80
+
81
+ ```python
82
+ from groundmeas.db import connect_db
83
+ connect_db("mydata.db", echo=True)
84
+ ```
85
+
86
+ ### 2. Creating Measurements
87
+
88
+ Insert a measurement (optionally with nested location) and its items:
89
+
90
+ ```python
91
+ from groundmeas.db import create_measurement, create_item
92
+
93
+ # Create measurement with nested Location
94
+ meas_id = create_measurement({
95
+ "timestamp": "2025-01-01T12:00:00",
96
+ "method": "staged_fault_test",
97
+ "voltage_level_kv": 10.0,
98
+ "asset_type": "substation",
99
+ "location": {"name": "Site A", "latitude": 52.0, "longitude": 13.0},
100
+ })
101
+
102
+ # Add earthing impedance item
103
+ item_id = create_item({
104
+ "measurement_type": "earthing_impedance",
105
+ "frequency_hz": 50.0,
106
+ "value": 12.3
107
+ }, measurement_id=meas_id)
108
+ ```
109
+
110
+ ### 3. Exporting Data
111
+
112
+ Export measurements (and nested items) to various formats:
113
+
114
+ ```python
115
+ from groundmeas.export import (
116
+ export_measurements_to_json,
117
+ export_measurements_to_csv,
118
+ export_measurements_to_xml,
119
+ )
120
+
121
+ export_measurements_to_json("data.json")
122
+ export_measurements_to_csv("data.csv")
123
+ export_measurements_to_xml("data.xml")
124
+ ```
125
+
126
+ ### 4. Analytics
127
+
128
+ Compute impedance and resistivity mappings, and fit the rho–f model:
129
+
130
+ ```python
131
+ from groundmeas.analytics import (
132
+ impedance_over_frequency,
133
+ real_imag_over_frequency,
134
+ rho_f_model,
135
+ )
136
+
137
+ # Impedance vs frequency for a single measurement
138
+ imp_map = impedance_over_frequency(1)
139
+
140
+ # Real & Imag components for multiple measurements
141
+ ri_map = real_imag_over_frequency([1, 2, 3])
142
+
143
+ # Fit rho–f model across measurements [1,2,3]
144
+ k1, k2, k3, k4, k5 = rho_f_model([1, 2, 3])
145
+ ```
146
+
147
+ ### 5. Plotting
148
+
149
+ Visualize raw and modeled curves:
150
+
151
+ ```python
152
+ from groundmeas.plots import plot_imp_over_f, plot_rho_f_model
153
+
154
+ # Raw impedance curves
155
+ fig1 = plot_imp_over_f([1, 2, 3])
156
+ fig1.show()
157
+
158
+ # Normalized at 50 Hz
159
+ fig2 = plot_imp_over_f(1, normalize_freq_hz=50)
160
+ fig2.show()
161
+
162
+ # Overlay rho–f model
163
+ fig3 = plot_rho_f_model([1,2,3], (k1,k2,k3,k4,k5), rho=[100, 200])
164
+ fig3.show()
165
+ ```
166
+
167
+ ## Contributing
168
+
169
+ Pull requests are welcome!
170
+ For major changes, please open an issue first to discuss.
171
+ Ensure tests pass and add new tests for your changes.
172
+
173
+ ---
174
+
175
+ ## License
176
+
177
+ MIT License. See [LICENSE](LICENSE) for details.
178
+
@@ -0,0 +1,10 @@
1
+ groundmeas/__init__.py,sha256=g2lTdyjsvJaU1MtEYa_ELSmDCkwQbu5tRWlgenb9zIg,2087
2
+ groundmeas/analytics.py,sha256=7mwL0f4M6VXnDI59UX7h-4dApjwvqIm9h9dUcZeRRHU,8102
3
+ groundmeas/db.py,sha256=P5a7xtJDbnvSaBoqd1x6DpN1m1-L_GPTtiwVj0zff7g,12800
4
+ groundmeas/export.py,sha256=cBY7zJaXXHCdRqJXwSCuhnX6ywMoo7Jbp0hhPxnEUjM,5687
5
+ groundmeas/models.py,sha256=Zu8IKNOPp0Y_GOGD_IA8I4MJulveVMQjo29WlTyWCnU,8248
6
+ groundmeas/plots.py,sha256=3Gq45jZh2XvHZ7Fpm9tyDJdiaJwNH_83rp6_kxPaIr4,4363
7
+ groundmeas-0.1.0.dist-info/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ groundmeas-0.1.0.dist-info/METADATA,sha256=vB3Jdffn3fiwHkKkM3U8k7sLF_xXxOskAIu_DDBdoEc,4545
9
+ groundmeas-0.1.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
10
+ groundmeas-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.1.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any