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/cli.py ADDED
@@ -0,0 +1,804 @@
1
+ """
2
+ Command-line interface for groundmeas.
3
+
4
+ Provides:
5
+ - Interactive wizard to add measurements and items with autocomplete.
6
+ - Listing of measurements and items.
7
+ - Import/export JSON helpers.
8
+
9
+ The CLI assumes a SQLite database path passed via --db, GROUNDMEAS_DB, or a
10
+ user config file at ~/.config/groundmeas/config.json.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ from pathlib import Path
18
+ from typing import Any, Iterable, List, Optional, Sequence, Tuple, get_args
19
+
20
+ import typer
21
+ from prompt_toolkit import prompt
22
+ from prompt_toolkit.completion import WordCompleter
23
+
24
+ from .db import (
25
+ connect_db,
26
+ create_item,
27
+ create_measurement,
28
+ read_items_by,
29
+ read_measurements_by,
30
+ update_item,
31
+ update_measurement,
32
+ )
33
+ from .export import export_measurements_to_json
34
+ from .models import MeasurementType
35
+ from .analytics import (
36
+ calculate_split_factor,
37
+ impedance_over_frequency,
38
+ real_imag_over_frequency,
39
+ rho_f_model,
40
+ shield_currents_for_location,
41
+ voltage_vt_epr,
42
+ )
43
+ from .plots import plot_imp_over_f, plot_rho_f_model, plot_voltage_vt_epr
44
+
45
+ app = typer.Typer(help="CLI for managing groundmeas data")
46
+ logger = logging.getLogger(__name__)
47
+
48
+ CONFIG_PATH = Path.home() / ".config" / "groundmeas" / "config.json"
49
+
50
+
51
+ # ─── HELPERS ────────────────────────────────────────────────────────────────────
52
+
53
+
54
+ def _word_choice(values: Iterable[str]) -> WordCompleter:
55
+ return WordCompleter(list(values), ignore_case=True, sentence=True)
56
+
57
+
58
+ def _prompt_text(
59
+ message: str, default: str | None = None, completer: WordCompleter | None = None
60
+ ) -> str:
61
+ suffix = f" [{default}]" if default else ""
62
+ out = prompt(f"{message}{suffix}: ", completer=completer)
63
+ return out.strip() or (default or "")
64
+
65
+
66
+ def _prompt_float(
67
+ message: str,
68
+ default: float | None = None,
69
+ suggestions: Sequence[str] | None = None,
70
+ ) -> Optional[float]:
71
+ completer = _word_choice(suggestions) if suggestions else None
72
+ while True:
73
+ raw = _prompt_text(
74
+ message,
75
+ default=None if default is None else str(default),
76
+ completer=completer,
77
+ )
78
+ if raw == "" and default is not None:
79
+ return default
80
+ if raw == "":
81
+ return None
82
+ try:
83
+ return float(raw)
84
+ except ValueError:
85
+ typer.echo("Please enter a number (or leave empty).")
86
+
87
+
88
+ def _prompt_choice(
89
+ message: str,
90
+ choices: Sequence[str],
91
+ default: str | None = None,
92
+ ) -> str:
93
+ completer = _word_choice(choices)
94
+ suffix = f" [{default}]" if default else ""
95
+ while True:
96
+ val = prompt(f"{message}{suffix}: ", completer=completer).strip()
97
+ if val == "" and default:
98
+ return default
99
+ if val in choices:
100
+ return val
101
+ typer.echo(f"Choose one of: {', '.join(choices)}")
102
+
103
+
104
+ def _load_measurement(measurement_id: int) -> dict[str, Any]:
105
+ recs, _ = read_measurements_by(id=measurement_id)
106
+ if not recs:
107
+ raise typer.Exit(f"Measurement id={measurement_id} not found")
108
+ return recs[0]
109
+
110
+
111
+ def _load_item(item_id: int) -> dict[str, Any]:
112
+ recs, _ = read_items_by(id=item_id)
113
+ if not recs:
114
+ raise typer.Exit(f"MeasurementItem id={item_id} not found")
115
+ return recs[0]
116
+
117
+
118
+ def _dump_or_print(data: Any, json_out: Optional[Path]) -> None:
119
+ if json_out:
120
+ json_out.write_text(json.dumps(data, indent=2))
121
+ typer.echo(f"Wrote {json_out}")
122
+ else:
123
+ typer.echo(json.dumps(data, indent=2))
124
+
125
+
126
+ def _measurement_types() -> List[str]:
127
+ return sorted(get_args(MeasurementType)) # type: ignore[arg-type]
128
+
129
+
130
+ def _existing_locations() -> List[str]:
131
+ try:
132
+ measurements, _ = read_measurements_by()
133
+ except Exception:
134
+ return []
135
+ names = {m.get("location", {}).get("name") for m in measurements if m.get("location")}
136
+ return sorted({n for n in names if n})
137
+
138
+
139
+ def _existing_measurement_values(field: str) -> List[str]:
140
+ try:
141
+ measurements, _ = read_measurements_by()
142
+ except Exception:
143
+ return []
144
+ vals = [m.get(field) for m in measurements if m.get(field) not in (None, "")]
145
+ return sorted({str(v) for v in vals})
146
+
147
+
148
+ def _existing_item_units(measurement_type: str) -> List[str]:
149
+ try:
150
+ items, _ = read_items_by(measurement_type=measurement_type)
151
+ except Exception:
152
+ return []
153
+ vals = [it.get("unit") for it in items if it.get("unit")]
154
+ return sorted({str(v) for v in vals})
155
+
156
+
157
+ def _existing_item_values(field: str, measurement_type: str | None = None) -> List[str]:
158
+ filters: dict[str, Any] = {}
159
+ if measurement_type:
160
+ filters["measurement_type"] = measurement_type
161
+ try:
162
+ items, _ = read_items_by(**filters)
163
+ except Exception:
164
+ return []
165
+ vals = [it.get(field) for it in items if it.get(field) not in (None, "")]
166
+ return sorted({str(v) for v in vals})
167
+
168
+
169
+ def _resolve_db(db: Optional[str]) -> str:
170
+ if db:
171
+ return db
172
+ if CONFIG_PATH.exists():
173
+ try:
174
+ cfg = json.loads(CONFIG_PATH.read_text())
175
+ cfg_path = cfg.get("db_path")
176
+ if cfg_path:
177
+ return cfg_path
178
+ except Exception:
179
+ pass
180
+ return str(Path("groundmeas.db").resolve())
181
+
182
+
183
+ def _save_default_db(db_path: str) -> None:
184
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
185
+ CONFIG_PATH.write_text(json.dumps({"db_path": db_path}, indent=2))
186
+
187
+
188
+ def _print_measurement_summary(mid: int, measurement: dict[str, Any], items: List[dict[str, Any]]) -> None:
189
+ typer.echo("\nSummary")
190
+ typer.echo("-------")
191
+ typer.echo(f"Measurement id={mid}")
192
+ loc = measurement.get("location") or {}
193
+ loc_name = loc.get("name", "n/a")
194
+ typer.echo(
195
+ f"Location: {loc_name} "
196
+ f"(lat={loc.get('latitude')}, lon={loc.get('longitude')}, alt={loc.get('altitude')})"
197
+ )
198
+ typer.echo(
199
+ f"Method={measurement.get('method')} | Asset={measurement.get('asset_type')} | "
200
+ f"Voltage kV={measurement.get('voltage_level_kv')} | Fault R Ω={measurement.get('fault_resistance_ohm')}"
201
+ )
202
+ if measurement.get("description"):
203
+ typer.echo(f"Description: {measurement['description']}")
204
+ if measurement.get("operator"):
205
+ typer.echo(f"Operator: {measurement['operator']}")
206
+ typer.echo(f"Items ({len(items)}):")
207
+ for it in items:
208
+ typer.echo(
209
+ f" - id={it.get('id','?')} type={it.get('measurement_type')} "
210
+ f"freq={it.get('frequency_hz')}Hz unit={it.get('unit')} "
211
+ f"value={it.get('value')} angle={it.get('value_angle_deg')} "
212
+ f"real={it.get('value_real')} imag={it.get('value_imag')}"
213
+ )
214
+
215
+
216
+ # ─── APP CALLBACK ───────────────────────────────────────────────────────────────
217
+
218
+
219
+ @app.callback()
220
+ def _connect(
221
+ db: Optional[str] = typer.Option(
222
+ None,
223
+ "--db",
224
+ envvar="GROUNDMEAS_DB",
225
+ help="Path to SQLite database (created if missing).",
226
+ )
227
+ ) -> None:
228
+ """Connect to the database before running any command."""
229
+ db_path = _resolve_db(db)
230
+ db_parent = Path(db_path).expanduser().resolve().parent
231
+ db_parent.mkdir(parents=True, exist_ok=True)
232
+ connect_db(db_path)
233
+ typer.echo(f"Connected to {db_path}")
234
+
235
+
236
+ # ─── COMMANDS ───────────────────────────────────────────────────────────────────
237
+
238
+
239
+ @app.command("add-measurement")
240
+ def add_measurement() -> None:
241
+ """Interactive wizard to add a measurement and its items."""
242
+ typer.echo("Add a new measurement (press Enter to accept defaults).")
243
+
244
+ existing_locs = _existing_locations()
245
+ loc_default = existing_locs[0] if existing_locs else None
246
+ loc_name = _prompt_text("Location name", default=loc_default, completer=_word_choice(existing_locs))
247
+
248
+ lat = _prompt_float("Latitude (optional)", default=None)
249
+ lon = _prompt_float("Longitude (optional)", default=None)
250
+ alt = _prompt_float("Altitude (optional)", default=None)
251
+
252
+ method = _prompt_choice(
253
+ "Method",
254
+ choices=["staged_fault_test", "injection_remote_substation", "injection_earth_electrode"],
255
+ )
256
+ asset = _prompt_choice(
257
+ "Asset type",
258
+ choices=[
259
+ "substation",
260
+ "overhead_line_tower",
261
+ "cable",
262
+ "cable_cabinet",
263
+ "house",
264
+ "pole_mounted_transformer",
265
+ "mv_lv_earthing_system",
266
+ ],
267
+ )
268
+ voltage_choices = _existing_measurement_values("voltage_level_kv")
269
+ fault_res_choices = _existing_measurement_values("fault_resistance_ohm")
270
+ operator_choices = _existing_measurement_values("operator")
271
+
272
+ voltage = _prompt_float("Voltage level kV (optional)", default=None, suggestions=voltage_choices)
273
+ fault_res = _prompt_float("Fault resistance Ω (optional)", default=None, suggestions=fault_res_choices)
274
+ description = _prompt_text("Description (optional)", default="")
275
+ operator_default = operator_choices[0] if operator_choices else ""
276
+ operator = _prompt_text(
277
+ "Operator (optional)",
278
+ default=operator_default,
279
+ completer=_word_choice(operator_choices),
280
+ )
281
+
282
+ measurement_data: dict[str, Any] = {
283
+ "method": method,
284
+ "asset_type": asset,
285
+ "voltage_level_kv": voltage,
286
+ "fault_resistance_ohm": fault_res,
287
+ "description": description or None,
288
+ "operator": operator or None,
289
+ "location": {"name": loc_name, "latitude": lat, "longitude": lon, "altitude": alt},
290
+ }
291
+
292
+ measurement_snapshot = json.loads(json.dumps(measurement_data))
293
+ mid = create_measurement(measurement_data)
294
+ typer.echo(f"Created measurement id={mid} at '{loc_name}'.")
295
+
296
+ # Add items
297
+ created_items: List[dict[str, Any]] = []
298
+ mtypes = _measurement_types()
299
+ while True:
300
+ mtype = _prompt_choice(
301
+ "Measurement type (or type 'done' to finish)",
302
+ choices=mtypes + ["done"],
303
+ default="done",
304
+ )
305
+ if mtype == "done":
306
+ break
307
+
308
+ freq_choices = _existing_item_values("frequency_hz", mtype)
309
+ freq = _prompt_float("Frequency Hz (optional)", default=50.0, suggestions=freq_choices)
310
+
311
+ entry_mode = _prompt_choice(
312
+ "Value entry mode",
313
+ choices=["magnitude_angle", "real_imag"],
314
+ default="magnitude_angle",
315
+ )
316
+
317
+ item: dict[str, Any] = {
318
+ "measurement_type": mtype,
319
+ "frequency_hz": freq,
320
+ }
321
+
322
+ angle_choices = _existing_item_values("value_angle_deg", mtype)
323
+ if entry_mode == "magnitude_angle":
324
+ item["value"] = _prompt_float("Value (magnitude)", default=None)
325
+ item["value_angle_deg"] = _prompt_float(
326
+ "Angle deg (optional)", default=0.0, suggestions=angle_choices
327
+ )
328
+ else:
329
+ item["value_real"] = _prompt_float("Real part", default=None)
330
+ item["value_imag"] = _prompt_float("Imag part", default=None)
331
+
332
+ # Optional fields depending on measurement type
333
+ if mtype == "soil_resistivity":
334
+ dist_choices = _existing_item_values("measurement_distance_m", mtype)
335
+ item["measurement_distance_m"] = _prompt_float(
336
+ "Measurement distance m (optional)", default=None, suggestions=dist_choices
337
+ )
338
+ if mtype in {"earthing_impedance", "earthing_resistance"}:
339
+ add_res_choices = _existing_item_values("additional_resistance_ohm", mtype)
340
+ item["additional_resistance_ohm"] = _prompt_float(
341
+ "Additional series resistance Ω (optional)",
342
+ default=None,
343
+ suggestions=add_res_choices,
344
+ )
345
+
346
+ suggested_unit = "Ω" if "impedance" in mtype or "resistance" in mtype else "A"
347
+ unit_choices = _existing_item_units(mtype)
348
+ unit_default = unit_choices[0] if unit_choices else suggested_unit
349
+ item["unit"] = _prompt_text(
350
+ "Unit",
351
+ default=unit_default,
352
+ completer=_word_choice(unit_choices or [suggested_unit]),
353
+ )
354
+ item["description"] = _prompt_text("Item description (optional)", default="")
355
+
356
+ iid = create_item(item, measurement_id=mid)
357
+ item["id"] = iid
358
+ created_items.append(item)
359
+ typer.echo(f" Added item id={iid} ({mtype})")
360
+
361
+ typer.echo("Done.")
362
+ try:
363
+ meas, _ = read_measurements_by(id=mid)
364
+ measurement_summary = meas[0] if meas else measurement_data
365
+ items_summary = meas[0]["items"] if meas else created_items
366
+ except Exception:
367
+ measurement_summary = measurement_snapshot
368
+ items_summary = created_items
369
+ _print_measurement_summary(mid, measurement_summary, items_summary)
370
+
371
+
372
+ @app.command("list-measurements")
373
+ def list_measurements() -> None:
374
+ """List measurements with basic metadata."""
375
+ measurements, _ = read_measurements_by()
376
+ if not measurements:
377
+ typer.echo("No measurements found.")
378
+ return
379
+
380
+ for m in measurements:
381
+ loc = m.get("location") or {}
382
+ loc_name = loc.get("name") or "n/a"
383
+ typer.echo(
384
+ f"[id={m.get('id')}] {loc_name} | method={m.get('method')} | asset={m.get('asset_type')} | items={len(m.get('items', []))}"
385
+ )
386
+
387
+
388
+ @app.command("list-items")
389
+ def list_items(
390
+ measurement_id: int = typer.Argument(..., help="Measurement ID"),
391
+ measurement_type: Optional[str] = typer.Option(None, "--type", help="Filter by measurement_type"),
392
+ ) -> None:
393
+ """List items for a given measurement."""
394
+ filters: dict[str, Any] = {"measurement_id": measurement_id}
395
+ if measurement_type:
396
+ filters["measurement_type"] = measurement_type
397
+
398
+ items, _ = read_items_by(**filters)
399
+ if not items:
400
+ typer.echo("No items found.")
401
+ return
402
+
403
+ for it in items:
404
+ typer.echo(
405
+ f"[id={it.get('id')}] type={it.get('measurement_type')} freq={it.get('frequency_hz')} value={it.get('value')} unit={it.get('unit')}"
406
+ )
407
+
408
+
409
+ @app.command("add-item")
410
+ def add_item(
411
+ measurement_id: int = typer.Argument(..., help="Measurement ID to attach the item to")
412
+ ) -> None:
413
+ """Interactive wizard to add a single item to an existing measurement."""
414
+ mtypes = _measurement_types()
415
+ mtype = _prompt_choice("Measurement type", choices=mtypes)
416
+ freq_choices = _existing_item_values("frequency_hz", mtype)
417
+ freq = _prompt_float("Frequency Hz (optional)", default=50.0, suggestions=freq_choices)
418
+ entry_mode = _prompt_choice(
419
+ "Value entry mode",
420
+ choices=["magnitude_angle", "real_imag"],
421
+ default="magnitude_angle",
422
+ )
423
+
424
+ item: dict[str, Any] = {
425
+ "measurement_type": mtype,
426
+ "frequency_hz": freq,
427
+ }
428
+
429
+ angle_choices = _existing_item_values("value_angle_deg", mtype)
430
+ if entry_mode == "magnitude_angle":
431
+ item["value"] = _prompt_float("Value (magnitude)", default=None)
432
+ item["value_angle_deg"] = _prompt_float(
433
+ "Angle deg (optional)", default=0.0, suggestions=angle_choices
434
+ )
435
+ else:
436
+ item["value_real"] = _prompt_float("Real part", default=None)
437
+ item["value_imag"] = _prompt_float("Imag part", default=None)
438
+
439
+ if mtype == "soil_resistivity":
440
+ dist_choices = _existing_item_values("measurement_distance_m", mtype)
441
+ item["measurement_distance_m"] = _prompt_float(
442
+ "Measurement distance m (optional)", default=None, suggestions=dist_choices
443
+ )
444
+ if mtype in {"earthing_impedance", "earthing_resistance"}:
445
+ add_res_choices = _existing_item_values("additional_resistance_ohm", mtype)
446
+ item["additional_resistance_ohm"] = _prompt_float(
447
+ "Additional series resistance Ω (optional)", default=None, suggestions=add_res_choices
448
+ )
449
+
450
+ suggested_unit = "Ω" if "impedance" in mtype or "resistance" in mtype else "A"
451
+ unit_choices = _existing_item_units(mtype)
452
+ unit_default = unit_choices[0] if unit_choices else suggested_unit
453
+ item["unit"] = _prompt_text(
454
+ "Unit",
455
+ default=unit_default,
456
+ completer=_word_choice(unit_choices or [suggested_unit]),
457
+ )
458
+ item["description"] = _prompt_text("Item description (optional)", default="")
459
+
460
+ iid = create_item(item, measurement_id=measurement_id)
461
+ typer.echo(f"Added item id={iid} to measurement id={measurement_id}")
462
+
463
+
464
+ @app.command("edit-measurement")
465
+ def edit_measurement(
466
+ measurement_id: int = typer.Argument(..., help="Measurement ID to edit")
467
+ ) -> None:
468
+ """Edit a measurement with defaults pulled from the database."""
469
+ rec = _load_measurement(measurement_id)
470
+ loc = rec.get("location") or {}
471
+
472
+ typer.echo(f"Editing measurement id={measurement_id}. Press Enter to keep existing values.")
473
+
474
+ existing_locs = _existing_locations()
475
+ loc_name = _prompt_text("Location name", default=loc.get("name"), completer=_word_choice(existing_locs))
476
+ lat = _prompt_float("Latitude (optional)", default=loc.get("latitude"))
477
+ lon = _prompt_float("Longitude (optional)", default=loc.get("longitude"))
478
+ alt = _prompt_float("Altitude (optional)", default=loc.get("altitude"))
479
+
480
+ method = _prompt_choice(
481
+ "Method",
482
+ choices=["staged_fault_test", "injection_remote_substation", "injection_earth_electrode"],
483
+ default=rec.get("method"),
484
+ )
485
+ asset = _prompt_choice(
486
+ "Asset type",
487
+ choices=[
488
+ "substation",
489
+ "overhead_line_tower",
490
+ "cable",
491
+ "cable_cabinet",
492
+ "house",
493
+ "pole_mounted_transformer",
494
+ "mv_lv_earthing_system",
495
+ ],
496
+ default=rec.get("asset_type"),
497
+ )
498
+
499
+ voltage_choices = _existing_measurement_values("voltage_level_kv")
500
+ fault_res_choices = _existing_measurement_values("fault_resistance_ohm")
501
+ operator_choices = _existing_measurement_values("operator")
502
+
503
+ voltage = _prompt_float(
504
+ "Voltage level kV (optional)",
505
+ default=rec.get("voltage_level_kv"),
506
+ suggestions=voltage_choices,
507
+ )
508
+ fault_res = _prompt_float(
509
+ "Fault resistance Ω (optional)",
510
+ default=rec.get("fault_resistance_ohm"),
511
+ suggestions=fault_res_choices,
512
+ )
513
+ description = _prompt_text("Description (optional)", default=rec.get("description") or "")
514
+ operator_default = rec.get("operator") or (operator_choices[0] if operator_choices else "")
515
+ operator = _prompt_text(
516
+ "Operator (optional)",
517
+ default=operator_default,
518
+ completer=_word_choice(operator_choices),
519
+ )
520
+
521
+ updates: dict[str, Any] = {
522
+ "method": method,
523
+ "asset_type": asset,
524
+ "voltage_level_kv": voltage,
525
+ "fault_resistance_ohm": fault_res,
526
+ "description": description or None,
527
+ "operator": operator or None,
528
+ "location": {
529
+ "name": loc_name,
530
+ "latitude": lat,
531
+ "longitude": lon,
532
+ "altitude": alt,
533
+ },
534
+ }
535
+
536
+ updated = update_measurement(measurement_id, updates)
537
+ if not updated:
538
+ raise typer.Exit(f"Measurement id={measurement_id} not found")
539
+
540
+ rec_after = _load_measurement(measurement_id)
541
+ _print_measurement_summary(measurement_id, rec_after, rec_after.get("items", []))
542
+
543
+
544
+ @app.command("edit-item")
545
+ def edit_item(item_id: int = typer.Argument(..., help="MeasurementItem ID to edit")) -> None:
546
+ """Edit a measurement item with defaults from the database."""
547
+ item = _load_item(item_id)
548
+ mtypes = _measurement_types()
549
+ mtype = _prompt_choice("Measurement type", choices=mtypes, default=item.get("measurement_type"))
550
+
551
+ freq_choices = _existing_item_values("frequency_hz", mtype)
552
+ freq = _prompt_float("Frequency Hz (optional)", default=item.get("frequency_hz"), suggestions=freq_choices)
553
+
554
+ # decide entry mode based on existing data
555
+ entry_mode_default = "real_imag" if item.get("value_real") is not None or item.get("value_imag") is not None else "magnitude_angle"
556
+ entry_mode = _prompt_choice(
557
+ "Value entry mode",
558
+ choices=["magnitude_angle", "real_imag"],
559
+ default=entry_mode_default,
560
+ )
561
+
562
+ angle_choices = _existing_item_values("value_angle_deg", mtype)
563
+ if entry_mode == "magnitude_angle":
564
+ val = item.get("value")
565
+ ang = item.get("value_angle_deg")
566
+ value = _prompt_float("Value (magnitude)", default=val)
567
+ angle = _prompt_float("Angle deg (optional)", default=ang, suggestions=angle_choices)
568
+ item_updates = {"value": value, "value_angle_deg": angle, "value_real": None, "value_imag": None}
569
+ else:
570
+ val_r = item.get("value_real")
571
+ val_i = item.get("value_imag")
572
+ value_real = _prompt_float("Real part", default=val_r)
573
+ value_imag = _prompt_float("Imag part", default=val_i)
574
+ item_updates = {"value_real": value_real, "value_imag": value_imag, "value": None, "value_angle_deg": None}
575
+
576
+ dist = None
577
+ add_res = None
578
+ if mtype == "soil_resistivity":
579
+ dist_choices = _existing_item_values("measurement_distance_m", mtype)
580
+ dist = _prompt_float(
581
+ "Measurement distance m (optional)",
582
+ default=item.get("measurement_distance_m"),
583
+ suggestions=dist_choices,
584
+ )
585
+ if mtype in {"earthing_impedance", "earthing_resistance"}:
586
+ add_res_choices = _existing_item_values("additional_resistance_ohm", mtype)
587
+ add_res = _prompt_float(
588
+ "Additional series resistance Ω (optional)",
589
+ default=item.get("additional_resistance_ohm"),
590
+ suggestions=add_res_choices,
591
+ )
592
+
593
+ suggested_unit = "Ω" if "impedance" in mtype or "resistance" in mtype else "A"
594
+ unit_choices = _existing_item_units(mtype)
595
+ unit_default = item.get("unit") or (unit_choices[0] if unit_choices else suggested_unit)
596
+ unit = _prompt_text(
597
+ "Unit",
598
+ default=unit_default,
599
+ completer=_word_choice(unit_choices or [suggested_unit]),
600
+ )
601
+ desc = _prompt_text("Item description (optional)", default=item.get("description") or "")
602
+
603
+ updates: dict[str, Any] = {
604
+ "measurement_type": mtype,
605
+ "frequency_hz": freq,
606
+ "unit": unit,
607
+ "description": desc or None,
608
+ "measurement_distance_m": dist if mtype == "soil_resistivity" else None,
609
+ "additional_resistance_ohm": add_res if mtype in {"earthing_impedance", "earthing_resistance"} else None,
610
+ }
611
+ updates.update(item_updates)
612
+
613
+ updated = update_item(item_id, updates)
614
+ if not updated:
615
+ raise typer.Exit(f"MeasurementItem id={item_id} not found")
616
+ typer.echo(f"Updated item id={item_id}")
617
+
618
+
619
+ @app.command("impedance-over-frequency")
620
+ def cli_impedance_over_frequency(
621
+ measurement_ids: List[int] = typer.Argument(..., help="Measurement ID(s)"),
622
+ json_out: Optional[Path] = typer.Option(None, "--json-out", help="Write result to JSON file"),
623
+ ) -> None:
624
+ """Return impedance over frequency for the given measurement IDs."""
625
+ ids = measurement_ids if len(measurement_ids) > 1 else measurement_ids[0]
626
+ data = impedance_over_frequency(ids)
627
+ _dump_or_print(data, json_out)
628
+
629
+
630
+ @app.command("real-imag-over-frequency")
631
+ def cli_real_imag_over_frequency(
632
+ measurement_ids: List[int] = typer.Argument(..., help="Measurement ID(s)"),
633
+ json_out: Optional[Path] = typer.Option(None, "--json-out", help="Write result to JSON file"),
634
+ ) -> None:
635
+ """Return real/imag over frequency for the given measurement IDs."""
636
+ ids = measurement_ids if len(measurement_ids) > 1 else measurement_ids[0]
637
+ data = real_imag_over_frequency(ids)
638
+ _dump_or_print(data, json_out)
639
+
640
+
641
+ @app.command("rho-f-model")
642
+ def cli_rho_f_model(
643
+ measurement_ids: List[int] = typer.Argument(..., help="Measurement IDs to fit"),
644
+ json_out: Optional[Path] = typer.Option(None, "--json-out", help="Write coefficients to JSON"),
645
+ ) -> None:
646
+ """Fit the rho–f model and output coefficients."""
647
+ coeffs = rho_f_model(measurement_ids)
648
+ result = {
649
+ "k1": coeffs[0],
650
+ "k2": coeffs[1],
651
+ "k3": coeffs[2],
652
+ "k4": coeffs[3],
653
+ "k5": coeffs[4],
654
+ }
655
+ _dump_or_print(result, json_out)
656
+
657
+
658
+ @app.command("voltage-vt-epr")
659
+ def cli_voltage_vt_epr(
660
+ measurement_ids: List[int] = typer.Argument(..., help="Measurement ID(s)"),
661
+ frequency: float = typer.Option(50.0, "--frequency", "-f", help="Frequency in Hz"),
662
+ json_out: Optional[Path] = typer.Option(None, "--json-out", help="Write result to JSON file"),
663
+ ) -> None:
664
+ """Calculate per-ampere touch voltages and EPR for measurements."""
665
+ ids = measurement_ids if len(measurement_ids) > 1 else measurement_ids[0]
666
+ data = voltage_vt_epr(ids, frequency=frequency)
667
+ _dump_or_print(data, json_out)
668
+
669
+
670
+ @app.command("shield-currents")
671
+ def cli_shield_currents(
672
+ location_id: int = typer.Argument(..., help="Location ID to search under"),
673
+ frequency_hz: Optional[float] = typer.Option(None, "--frequency", "-f", help="Optional frequency filter"),
674
+ json_out: Optional[Path] = typer.Option(None, "--json-out", help="Write result to JSON file"),
675
+ ) -> None:
676
+ """List shield_current items available for a location."""
677
+ data = shield_currents_for_location(location_id=location_id, frequency_hz=frequency_hz)
678
+ _dump_or_print(data, json_out)
679
+
680
+
681
+ @app.command("calculate-split-factor")
682
+ def cli_calculate_split_factor(
683
+ earth_fault_current_id: int = typer.Option(..., "--earth-fault-id", help="MeasurementItem id for earth_fault_current"),
684
+ shield_current_ids: List[int] = typer.Option(..., "--shield-id", help="Shield current item id(s)", show_default=False),
685
+ json_out: Optional[Path] = typer.Option(None, "--json-out", help="Write result to JSON file"),
686
+ ) -> None:
687
+ """Compute split factor and local earthing current."""
688
+ data = calculate_split_factor(earth_fault_current_id=earth_fault_current_id, shield_current_ids=shield_current_ids)
689
+ _dump_or_print(data, json_out)
690
+
691
+
692
+ @app.command("plot-impedance")
693
+ def cli_plot_impedance(
694
+ measurement_ids: List[int] = typer.Argument(..., help="Measurement ID(s)"),
695
+ normalize_freq_hz: Optional[float] = typer.Option(None, "--normalize", help="Normalize by impedance at this frequency"),
696
+ output: Path = typer.Option(..., "--out", "-o", help="Output image file (e.g., plot.png)"),
697
+ ) -> None:
698
+ """Generate impedance vs frequency plot and save to a file."""
699
+ fig = plot_imp_over_f(measurement_ids, normalize_freq_hz=normalize_freq_hz)
700
+ output.parent.mkdir(parents=True, exist_ok=True)
701
+ fig.savefig(output)
702
+ typer.echo(f"Wrote {output}")
703
+
704
+
705
+ @app.command("plot-rho-f-model")
706
+ def cli_plot_rho_f_model(
707
+ measurement_ids: List[int] = typer.Argument(..., help="Measurement IDs"),
708
+ rho_f_coeffs: Optional[List[float]] = typer.Option(
709
+ None,
710
+ "--rho-f",
711
+ help="Coefficients k1 k2 k3 k4 k5 (if omitted, they are fitted).",
712
+ ),
713
+ rho: List[float] = typer.Option([100.0], "--rho", help="Rho values to plot (repeatable)"),
714
+ output: Path = typer.Option(..., "--out", "-o", help="Output image file"),
715
+ ) -> None:
716
+ """Plot measured impedance and rho–f model, save to file."""
717
+ if rho_f_coeffs and len(rho_f_coeffs) != 5:
718
+ raise typer.Exit("Provide exactly five coefficients for --rho-f")
719
+ coeffs = tuple(rho_f_coeffs) if rho_f_coeffs else rho_f_model(measurement_ids)
720
+ fig = plot_rho_f_model(measurement_ids, coeffs, rho=rho)
721
+ output.parent.mkdir(parents=True, exist_ok=True)
722
+ fig.savefig(output)
723
+ typer.echo(f"Wrote {output}")
724
+
725
+
726
+ @app.command("plot-voltage-vt-epr")
727
+ def cli_plot_voltage_vt_epr(
728
+ measurement_ids: List[int] = typer.Argument(..., help="Measurement ID(s)"),
729
+ frequency: float = typer.Option(50.0, "--frequency", "-f", help="Frequency in Hz"),
730
+ output: Path = typer.Option(..., "--out", "-o", help="Output image file"),
731
+ ) -> None:
732
+ """Plot EPR and touch voltages, save to file."""
733
+ fig = plot_voltage_vt_epr(measurement_ids, frequency=frequency)
734
+ output.parent.mkdir(parents=True, exist_ok=True)
735
+ fig.savefig(output)
736
+ typer.echo(f"Wrote {output}")
737
+
738
+ @app.command("import-json")
739
+ def import_json(path: Path = typer.Argument(..., exists=True, help="Path to JSON with measurement data")) -> None:
740
+ """
741
+ Import measurement(s) from JSON.
742
+
743
+ Accepts either:
744
+ - a list of measurement dicts (each optionally containing 'items'), or
745
+ - a single measurement dict with optional 'items'.
746
+ """
747
+ try:
748
+ data = json.loads(path.read_text())
749
+ except Exception as exc:
750
+ raise typer.Exit(code=1) from exc
751
+
752
+ measurements: List[dict[str, Any]]
753
+ if isinstance(data, list):
754
+ measurements = data
755
+ elif isinstance(data, dict):
756
+ measurements = [data]
757
+ else:
758
+ typer.echo("Unsupported JSON structure; expected object or array.")
759
+ raise typer.Exit(code=1)
760
+
761
+ created: List[Tuple[int, int]] = []
762
+ for m in measurements:
763
+ items = m.pop("items", [])
764
+ mid = create_measurement(m)
765
+ for it in items:
766
+ create_item(it, measurement_id=mid)
767
+ created.append((mid, len(items)))
768
+
769
+ typer.echo(f"Imported {len(created)} measurement(s): " + ", ".join(f"id={mid} items={count}" for mid, count in created))
770
+
771
+
772
+ @app.command("export-json")
773
+ def export_json(
774
+ path: Path = typer.Argument(..., help="Output JSON file"),
775
+ measurement_ids: Optional[List[int]] = typer.Option(
776
+ None,
777
+ "--measurement-id",
778
+ "-m",
779
+ help="Restrict to these measurement IDs (repeatable).",
780
+ ),
781
+ ) -> None:
782
+ """Export measurements (and nested items) to JSON."""
783
+ filters: dict[str, Any] = {}
784
+ if measurement_ids:
785
+ filters["id__in"] = measurement_ids
786
+ export_measurements_to_json(str(path), **filters)
787
+ typer.echo(f"Wrote {path}")
788
+
789
+
790
+ @app.command("set-default-db")
791
+ def set_default_db(path: Path = typer.Argument(..., help="Path to store as default DB")) -> None:
792
+ """Store a default database path in ~/.config/groundmeas/config.json."""
793
+ resolved = str(path.expanduser().resolve())
794
+ Path(resolved).parent.mkdir(parents=True, exist_ok=True)
795
+ _save_default_db(resolved)
796
+ typer.echo(f"Default DB path saved to {CONFIG_PATH} → {resolved}")
797
+
798
+
799
+ def _main() -> None:
800
+ app()
801
+
802
+
803
+ if __name__ == "__main__":
804
+ _main()