groundmeas 0.1.0__tar.gz

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.

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,160 @@
1
+ # groundmeas: Grounding System Measurements & Analysis
2
+
3
+ **groundmeas** is a Python package for collecting, storing, analyzing, and visualizing earthing (grounding) measurement data.
4
+
5
+ ---
6
+
7
+ ## Project Description
8
+
9
+ groundmeas provides:
10
+
11
+ * **Database models & CRUD** via SQLModel/SQLAlchemy (`Location`, `Measurement`, `MeasurementItem`).
12
+ * **Export utilities** to JSON, CSV, and XML.
13
+ * **Analytics routines** for impedance-over-frequency, real–imaginary processing, and rho–f model fitting.
14
+ * **Plotting helpers** for impedance vs frequency and model overlays using Matplotlib.
15
+
16
+ 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.
17
+
18
+ ---
19
+
20
+ ## Technical Background
21
+
22
+ In grounding studies, measurements of earth electrode impedance are taken over a range of frequencies, and soil resistivity measurements at various depths are collected.
23
+
24
+ * **Earthing Impedance** $Z$ vs. **Frequency** (f): typically expressed in Ω.
25
+ * **Soil Resistivity** (ρ) vs. **Depth** (d): used to model frequency‑dependent behavior.
26
+ * **rho–f Model**: fits the relationship
27
+
28
+ $$
29
+ Z(ρ, f) = k_1·ρ + (k_2 + j·k_3)·f + (k_4 + j·k_5)·ρ·f
30
+ $$
31
+
32
+ where $k_1…k_5$ are real coefficients determined by least‑squares across multiple measurements.
33
+
34
+ ---
35
+
36
+ ## Installation
37
+
38
+ Requires Python 3.12+:
39
+
40
+ ```bash
41
+ git clone https://github.com/Ce1ectric/groundmeas.git
42
+ cd groundmeas
43
+ poetry install
44
+ poetry shell
45
+ ```
46
+
47
+ or using pip locally:
48
+ ```bash
49
+ git clone https://github.com/Ce1ectric/groundmeas.git
50
+ cd groundmeas
51
+ pip install .
52
+ ```
53
+
54
+ Or install via pip: `pip install groundmeas`.
55
+
56
+ ---
57
+
58
+ ## Usage
59
+
60
+ ### 1. Database Setup
61
+
62
+ Initialize or connect to a SQLite database (tables will be created automatically):
63
+
64
+ ```python
65
+ from groundmeas.db import connect_db
66
+ connect_db("mydata.db", echo=True)
67
+ ```
68
+
69
+ ### 2. Creating Measurements
70
+
71
+ Insert a measurement (optionally with nested location) and its items:
72
+
73
+ ```python
74
+ from groundmeas.db import create_measurement, create_item
75
+
76
+ # Create measurement with nested Location
77
+ meas_id = create_measurement({
78
+ "timestamp": "2025-01-01T12:00:00",
79
+ "method": "staged_fault_test",
80
+ "voltage_level_kv": 10.0,
81
+ "asset_type": "substation",
82
+ "location": {"name": "Site A", "latitude": 52.0, "longitude": 13.0},
83
+ })
84
+
85
+ # Add earthing impedance item
86
+ item_id = create_item({
87
+ "measurement_type": "earthing_impedance",
88
+ "frequency_hz": 50.0,
89
+ "value": 12.3
90
+ }, measurement_id=meas_id)
91
+ ```
92
+
93
+ ### 3. Exporting Data
94
+
95
+ Export measurements (and nested items) to various formats:
96
+
97
+ ```python
98
+ from groundmeas.export import (
99
+ export_measurements_to_json,
100
+ export_measurements_to_csv,
101
+ export_measurements_to_xml,
102
+ )
103
+
104
+ export_measurements_to_json("data.json")
105
+ export_measurements_to_csv("data.csv")
106
+ export_measurements_to_xml("data.xml")
107
+ ```
108
+
109
+ ### 4. Analytics
110
+
111
+ Compute impedance and resistivity mappings, and fit the rho–f model:
112
+
113
+ ```python
114
+ from groundmeas.analytics import (
115
+ impedance_over_frequency,
116
+ real_imag_over_frequency,
117
+ rho_f_model,
118
+ )
119
+
120
+ # Impedance vs frequency for a single measurement
121
+ imp_map = impedance_over_frequency(1)
122
+
123
+ # Real & Imag components for multiple measurements
124
+ ri_map = real_imag_over_frequency([1, 2, 3])
125
+
126
+ # Fit rho–f model across measurements [1,2,3]
127
+ k1, k2, k3, k4, k5 = rho_f_model([1, 2, 3])
128
+ ```
129
+
130
+ ### 5. Plotting
131
+
132
+ Visualize raw and modeled curves:
133
+
134
+ ```python
135
+ from groundmeas.plots import plot_imp_over_f, plot_rho_f_model
136
+
137
+ # Raw impedance curves
138
+ fig1 = plot_imp_over_f([1, 2, 3])
139
+ fig1.show()
140
+
141
+ # Normalized at 50 Hz
142
+ fig2 = plot_imp_over_f(1, normalize_freq_hz=50)
143
+ fig2.show()
144
+
145
+ # Overlay rho–f model
146
+ fig3 = plot_rho_f_model([1,2,3], (k1,k2,k3,k4,k5), rho=[100, 200])
147
+ fig3.show()
148
+ ```
149
+
150
+ ## Contributing
151
+
152
+ Pull requests are welcome!
153
+ For major changes, please open an issue first to discuss.
154
+ Ensure tests pass and add new tests for your changes.
155
+
156
+ ---
157
+
158
+ ## License
159
+
160
+ MIT License. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "groundmeas"
3
+ version = "0.1.0"
4
+ description = ""
5
+ authors = [
6
+ {name = "Christian Ehlert",email = "christian.ehlert@mailbox.org"}
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "sqlmodel (>=0.0.24,<0.0.25)",
12
+ "sqlite-utils (>=3.38,<4.0)",
13
+ "pandas (>=2.2.3,<3.0.0)",
14
+ "numpy (>=2.2.5,<3.0.0)",
15
+ "matplotlib (>=3.10.1,<4.0.0)"
16
+ ]
17
+
18
+ [tool.poetry]
19
+ packages = [{include = "groundmeas", from = "src"}]
20
+
21
+
22
+ [tool.poetry.group.dev.dependencies]
23
+ typer = "^0.15.3"
24
+ pytest = "^8.3.5"
25
+ black = "^25.1.0"
26
+ ipykernel = "^6.29.5"
27
+ pytest-cov = "^6.1.1"
28
+
29
+ [build-system]
30
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
31
+ build-backend = "poetry.core.masonry.api"
@@ -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
+ ]
@@ -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)