groundtem-io 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.
@@ -0,0 +1,22 @@
1
+ """Unofficial Python reader and plotting utilities for GroundTEM .stb, .stn, and .sts files."""
2
+
3
+ from .plotting import plot_decay, plot_station, sem
4
+ from .protocol import Moment, Protocol, read_protocol, summarize_protocols
5
+ from .stb import StbFile, read_sounding_dir, read_stb, read_stn, read_survey
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = [
10
+ "StbFile",
11
+ "read_stb",
12
+ "read_stn",
13
+ "read_sounding_dir",
14
+ "read_survey",
15
+ "Moment",
16
+ "Protocol",
17
+ "read_protocol",
18
+ "summarize_protocols",
19
+ "sem",
20
+ "plot_decay",
21
+ "plot_station",
22
+ ]
@@ -0,0 +1,118 @@
1
+ """Plotting utilities for GroundTEM LM and HM decay curves and SNR."""
2
+
3
+ from typing import Mapping, Optional, Union
4
+
5
+ import matplotlib.pyplot as plt
6
+ import numpy as np
7
+
8
+ from .stb import StbFile
9
+
10
+ DEFAULT_COLORS = {"LM": "tab:blue", "HM": "tab:red"}
11
+
12
+
13
+ def sem(record: Mapping, moment: str) -> np.ndarray:
14
+ """Return the larger of empirical and instrument-reported SEM for a station record."""
15
+ return np.fmax(record[f"sem_emp_{moment}"], record[f"sem_rep_{moment}"])
16
+
17
+
18
+ def plot_decay(
19
+ ax: plt.Axes,
20
+ source: Union[StbFile, Mapping],
21
+ stacks: bool = False,
22
+ stack_stride: int = 10,
23
+ colors: Optional[Mapping[str, str]] = None,
24
+ ) -> plt.Axes:
25
+ """Plot LM and HM decay curves and noise floor on a log-log Axes.
26
+
27
+ Parameters
28
+ ----------
29
+ ax : matplotlib.axes.Axes
30
+ Target axes.
31
+ source : StbFile or dict/Series
32
+ Either an ``StbFile`` returned by ``read_stb`` or a row dict/Series returned
33
+ by ``read_sounding_dir`` / ``read_survey``.
34
+ stacks : bool, default False
35
+ If True, also plot individual raw stacks as light dots.
36
+ stack_stride : int, default 10
37
+ Stride used when plotting raw stacks.
38
+ colors : mapping, optional
39
+ Custom color mapping for ``{"LM": ..., "HM": ...}``.
40
+ """
41
+ col = colors or DEFAULT_COLORS
42
+ if isinstance(source, StbFile):
43
+ polarity = -1 if (len(source.stacks["HM"]) > 0 and source.data("HM").mean(0)[:5].mean() < 0) else 1
44
+ summaries = {m: source.summary(m, polarity=polarity) for m in ("LM", "HM") if len(source.stacks[m]) > 0}
45
+ else:
46
+ summaries = {
47
+ m: {
48
+ "t": source[f"t_{m}"],
49
+ "d": source[f"d_{m}"],
50
+ "sem": sem(source, m),
51
+ "stacks": source[f"stacks_{m}"],
52
+ }
53
+ for m in ("LM", "HM")
54
+ if f"d_{m}" in source
55
+ }
56
+
57
+ for m, info in summaries.items():
58
+ t, d, e = info["t"], info["d"], info["sem"]
59
+ if stacks and "stacks" in info:
60
+ for s in info["stacks"][::stack_stride]:
61
+ ax.loglog(t, np.abs(s), ".", color=col[m], alpha=0.12, ms=3)
62
+ pos = d > 0
63
+ ax.errorbar(t[pos], d[pos], e[pos], fmt="o-", color=col[m], ms=3, lw=1, label=m)
64
+ if np.any(~pos):
65
+ ax.errorbar(t[~pos], -d[~pos], e[~pos], fmt="o", mfc="none", color=col[m], ms=4)
66
+ ax.loglog(t, e, "--", color=col[m], lw=0.8, alpha=0.7)
67
+
68
+ ax.set_xscale("log")
69
+ ax.set_yscale("log")
70
+ return ax
71
+
72
+
73
+ def plot_station(
74
+ source: Union[StbFile, Mapping],
75
+ snr_min: float = 3.0,
76
+ stacks: bool = True,
77
+ title: Optional[str] = None,
78
+ ):
79
+ """Plot a two-panel figure showing LM/HM decay curves (with raw stacks) and SNR."""
80
+ fig, axs = plt.subplots(1, 2, figsize=(11, 4))
81
+ plot_decay(axs[0], source, stacks=stacks)
82
+ if title is None:
83
+ if isinstance(source, StbFile):
84
+ title = source.path.name
85
+ else:
86
+ title = f"Station {source.get('station', '')} ({source.get('sounding', '')})"
87
+ axs[0].set_title(title)
88
+ axs[0].set_xlabel("time (s)")
89
+ axs[0].set_ylabel("|dB/dt| x I x N_tx (V/m²)")
90
+ axs[0].legend()
91
+
92
+ if isinstance(source, StbFile):
93
+ polarity = -1 if (len(source.stacks["HM"]) > 0 and source.data("HM").mean(0)[:5].mean() < 0) else 1
94
+ for m in ("LM", "HM"):
95
+ if len(source.stacks[m]) == 0:
96
+ continue
97
+ info = source.summary(m, polarity=polarity)
98
+ axs[1].semilogx(info["t"], info["d"] / info["sem"], "o-", color=DEFAULT_COLORS[m], ms=3, label=m)
99
+ else:
100
+ for m in ("LM", "HM"):
101
+ if f"d_{m}" not in source:
102
+ continue
103
+ axs[1].semilogx(
104
+ source[f"t_{m}"],
105
+ source[f"d_{m}"] / sem(source, m),
106
+ "o-",
107
+ color=DEFAULT_COLORS[m],
108
+ ms=3,
109
+ label=m,
110
+ )
111
+
112
+ axs[1].axhline(snr_min, color="k", ls=":")
113
+ axs[1].set_yscale("symlog")
114
+ axs[1].set_title("SNR = mean / SEM")
115
+ axs[1].set_xlabel("time (s)")
116
+ axs[1].legend()
117
+ fig.tight_layout()
118
+ return fig, axs
@@ -0,0 +1,191 @@
1
+ """Parse a GroundTEM ``.sts`` protocol file into structured dataclasses."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ import re
6
+ from typing import Dict, Tuple, Union
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ DEFAULT_SAMPLE_PERIOD = 0.25e-6
12
+
13
+
14
+ @dataclass
15
+ class Moment:
16
+ name: str
17
+ on_time: float # [s]
18
+ period_time: float # [s]
19
+ stack_size: int
20
+ target_current: float # [A]
21
+ wave_time: np.ndarray # [s], 0 = start of turn-off ramp
22
+ wave_amp: np.ndarray # normalised amplitude [0, 1]
23
+ gate_start: np.ndarray # [s] after end of on-time (start of turn-off ramp)
24
+ gate_end: np.ndarray # [s] after end of on-time
25
+ tap_start: float # [s] start of the tapered (log-spaced) gates
26
+ damper_time: Tuple[float, float] = (0.0, 0.0) # (on, off) [s]
27
+
28
+ @property
29
+ def gate_center(self) -> np.ndarray:
30
+ """Raw gate center times [s] after turn-off start."""
31
+ return 0.5 * (self.gate_start + self.gate_end)
32
+
33
+ @property
34
+ def gate_width(self) -> np.ndarray:
35
+ """Raw gate widths [s]."""
36
+ return self.gate_end - self.gate_start
37
+
38
+ @property
39
+ def turnoff_duration(self) -> float:
40
+ """Duration of the turn-off ramp [s] (from t=0 to final zero-current node)."""
41
+ return float(self.wave_time[-1])
42
+
43
+ def tapered_times(self, per_decade: int = 10, include_early: bool = False) -> np.ndarray:
44
+ """Log-spaced gate centres (matching the '[Tapering]' section) in [s]."""
45
+ t0 = max(self.tap_start, float(self.gate_center[0]))
46
+ t1 = float(self.gate_center[-1])
47
+ n = int(np.floor(np.log10(t1 / t0) * per_decade)) + 1
48
+ t_tap = t0 * 10.0 ** (np.arange(n) / per_decade)
49
+ if include_early:
50
+ early_mask = (self.gate_center > self.turnoff_duration) & (self.gate_center < t0)
51
+ if np.any(early_mask):
52
+ return np.r_[self.gate_center[early_mask], t_tap]
53
+ return t_tap
54
+
55
+
56
+ @dataclass
57
+ class Protocol:
58
+ name: str
59
+ filepath: Path
60
+ sample_freq_hz: float
61
+ tx_xyz: np.ndarray
62
+ tx_size: Tuple[float, float] # (x length, y length) [m]
63
+ tx_turns: int
64
+ rx_xyz: np.ndarray
65
+ rx_area: float # effective area [m^2]
66
+ lp_filters: Tuple[float, ...] # first-order low-pass cut-off frequencies [Hz]
67
+ gate_per_decade: int = 10
68
+ auto_sign: str = "InLoop"
69
+ powerline_freq: float = 60.0
70
+ moments: Dict[str, Moment] = field(default_factory=dict)
71
+
72
+ @property
73
+ def tx_area(self) -> float:
74
+ """Single-turn transmitter loop area [m^2]."""
75
+ return float(self.tx_size[0] * self.tx_size[1])
76
+
77
+ @property
78
+ def offset(self) -> float:
79
+ """Horizontal center-to-center distance between Tx and Rx [m]."""
80
+ return float(np.linalg.norm(self.rx_xyz[:2] - self.tx_xyz[:2]))
81
+
82
+ @property
83
+ def is_inloop(self) -> bool:
84
+ """True if central/in-loop configuration (zero horizontal offset)."""
85
+ return self.offset < 1e-6
86
+
87
+ def dipole_moment(self, moment_name: str) -> float:
88
+ """Transmitter magnetic dipole moment I * N_tx * A_tx [A*m^2]."""
89
+ mom = self.moments[moment_name.upper()]
90
+ return float(mom.target_current * self.tx_turns * self.tx_area)
91
+
92
+
93
+ def _read_sts(path: Union[str, Path]) -> dict:
94
+ out = {}
95
+ text = Path(path).read_text(encoding="utf-8", errors="ignore")
96
+ lines = text.splitlines()
97
+ if lines and lines[0].startswith(";[ProtocolDesigner]"):
98
+ m = re.search(r"SampleFrequencyMHz\s*=\s*([0-9.]+)", lines[0])
99
+ if m:
100
+ out["SampleFrequencyMHz"] = float(m.group(1))
101
+ for line in lines:
102
+ if line.startswith((";", "[")) or "=" not in line:
103
+ continue
104
+ k, v = (s.strip() for s in line.split("=", 1))
105
+ try:
106
+ out[k] = np.array([float(x) for x in v.split(",")])
107
+ except ValueError:
108
+ out[k] = v
109
+ return out
110
+
111
+
112
+ def read_protocol(path: Union[str, Path], channel: str = "A") -> Protocol:
113
+ """Read a GroundTEM ``.sts`` protocol file."""
114
+ path = Path(path)
115
+ d = _read_sts(path)
116
+ sample_freq_hz = float(d.get("SampleFrequencyMHz", 4.0)) * 1e6
117
+ sample_period = 1.0 / sample_freq_hz
118
+
119
+ p = Protocol(
120
+ name=path.stem,
121
+ filepath=path,
122
+ sample_freq_hz=sample_freq_hz,
123
+ tx_xyz=d["TxLoop_XYZPos"],
124
+ tx_size=tuple(float(x) for x in d["TxLoop_XYLength"]),
125
+ tx_turns=int(d["TxLoop_NTurns"][0]),
126
+ rx_xyz=d["RxCoil_XYZPos"],
127
+ rx_area=float(d[f"RxCoil_Area{'Ch' + channel}"][0]),
128
+ lp_filters=tuple(float(x) for x in d["LPFilter_1order"]),
129
+ gate_per_decade=int(d.get(f"Ch{channel}_GatePerDecade", [10])[0]),
130
+ auto_sign=str(d.get("AutoSignDetection", "InLoop")),
131
+ powerline_freq=float(d.get("PowerLineMonitorFreq", [60.0])[0]),
132
+ )
133
+ for m in ("LM", "HM"):
134
+ on_time = float(d[f"{m}_OnTime"][0]) * 1e-6
135
+ period_time = float(d[f"{m}_PeriodTime"][0]) * 1e-6
136
+ stack_size = int(d[f"{m}_StackSize"][0])
137
+ damper_on = float(d.get(f"{m}_Damper_TimeOn", [on_time * 1e6])[0]) * 1e-6
138
+ damper_off = float(d.get(f"{m}_Damper_TimeOff", [on_time * 1e6])[0]) * 1e-6
139
+ g0 = d[f"{m}_Ch{channel}_GateIndex0"][1:]
140
+ g1 = d[f"{m}_Ch{channel}_GateIndex1"][1:]
141
+ p.moments[m] = Moment(
142
+ name=m,
143
+ on_time=on_time,
144
+ period_time=period_time,
145
+ stack_size=stack_size,
146
+ target_current=float(d[f"{m}_Tx_TargetCurrent"][0]),
147
+ wave_time=d[f"{m}_Waveform_Time"] * 1e-6,
148
+ wave_amp=d[f"{m}_Waveform_Amplitude"],
149
+ gate_start=g0 * sample_period - on_time,
150
+ gate_end=g1 * sample_period - on_time,
151
+ tap_start=float(d[f"{m}_Ch{channel}_TapStartTime"][0]) * 1e-6,
152
+ damper_time=(damper_on, damper_off),
153
+ )
154
+ return p
155
+
156
+
157
+ def summarize_protocols(protocols: Dict[str, Protocol]) -> pd.DataFrame:
158
+ """Build a summary table of survey configurations from loaded protocols."""
159
+ rows = []
160
+ for label, proto in protocols.items():
161
+ geom_type = (
162
+ f"In-Loop (Offset {proto.offset:.0f} m)"
163
+ if proto.is_inloop
164
+ else f"Offset Loop (Center Offset {proto.offset:.1f} m)"
165
+ )
166
+ for mname, mom in proto.moments.items():
167
+ t_tap = mom.tapered_times(per_decade=proto.gate_per_decade)
168
+ rows.append(
169
+ {
170
+ "Configuration": f"{label} - {mname}",
171
+ "Protocol File": proto.filepath.name,
172
+ "Geometry": geom_type,
173
+ "Tx Loop (m)": f"{proto.tx_size[0]:.0f}x{proto.tx_size[1]:.0f} ({proto.tx_turns} turn{'s' if proto.tx_turns > 1 else ''})",
174
+ "Rx Pos (x, y, z) [m]": f"({proto.rx_xyz[0]:.1f}, {proto.rx_xyz[1]:.1f}, {proto.rx_xyz[2]:.1f})",
175
+ "Rx Eff. Area (m²)": proto.rx_area,
176
+ "Moment": mname,
177
+ "Current (A)": mom.target_current,
178
+ "Tx Moment (A·m²)": proto.dipole_moment(mname),
179
+ "On-Time (µs)": mom.on_time * 1e6,
180
+ "Turn-off Ramp (µs)": mom.turnoff_duration * 1e6,
181
+ "Period (µs)": mom.period_time * 1e6,
182
+ "Stack Size": mom.stack_size,
183
+ "Raw Gates": len(mom.gate_center),
184
+ "Raw Gate Range (µs)": f"{mom.gate_center[0]*1e6:.2f} – {mom.gate_center[-1]*1e6:.1f}",
185
+ "Tapering Start (µs)": mom.tap_start * 1e6,
186
+ "Tapered Gates": len(t_tap),
187
+ "Tapered Range (µs)": f"{t_tap[0]*1e6:.1f} – {t_tap[-1]*1e6:.1f}",
188
+ "LP Filters (kHz)": f"{proto.lp_filters[0]/1e3:.0f}, {proto.lp_filters[1]/1e3:.0f}",
189
+ }
190
+ )
191
+ return pd.DataFrame(rows)
groundtem_io/stb.py ADDED
@@ -0,0 +1,195 @@
1
+ """Parse GroundTEM ``.stb`` stack files and ``.stn`` station files.
2
+
3
+ File layout:
4
+
5
+ * ASCII header (protocol ``[Gate]``, ``[RxTxSpecs]``, ``[ProcessedGateTimes]``, ``[InstrumentInfo]`` ...)
6
+ * Binary stack records, each terminated by ``b"$" * 8``. The record type is given by byte 0
7
+ (0 = LM, 1 = HM) and the number of processed gates by the ``uint16`` at byte 2.
8
+ Record tail: ``uint32 stack_size`` + ``float32 data[n]`` + ``float32 rel_std_percent[n]``.
9
+ GPS latitude / longitude are ``float64`` at byte offsets 33 / 41.
10
+ """
11
+
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ import re
15
+ import struct
16
+ from typing import Dict, Optional, Union
17
+
18
+ import numpy as np
19
+ import pandas as pd
20
+
21
+ _TERMINATOR = b"$" * 8
22
+ _LAT_OFFSET, _LON_OFFSET = 33, 41
23
+
24
+
25
+ @dataclass
26
+ class StbFile:
27
+ path: Path
28
+ gate_times: Dict[str, np.ndarray] # {"LM": s, "HM": s} gate centre times [s] after turn-off start
29
+ stacks: Dict[str, pd.DataFrame] # {"LM": DataFrame, "HM": DataFrame}, one row per saved stack
30
+ header: Dict[str, str] = field(default_factory=dict)
31
+
32
+ def data(self, moment: str) -> np.ndarray:
33
+ """Return (n_stack, n_gate) array of dB/dt data for ``moment`` ('LM' or 'HM')."""
34
+ return np.vstack(self.stacks[moment]["data"].to_list())
35
+
36
+ def rel_std(self, moment: str) -> np.ndarray:
37
+ """Return (n_stack, n_gate) array of per-stack relative std [%] for ``moment``."""
38
+ return np.vstack(self.stacks[moment]["rel_std"].to_list())
39
+
40
+ def summary(self, moment: str, polarity: Optional[int] = None) -> Dict[str, np.ndarray]:
41
+ """Compute mean decay curve and standard error estimates for ``moment``.
42
+
43
+ Parameters
44
+ ----------
45
+ moment : {"LM", "HM"}
46
+ Transmitter moment key.
47
+ polarity : {1, -1}, optional
48
+ Sign multiplier applied to ``data``. If ``None``, inferred automatically
49
+ from the sign of the first 5 early-time gates.
50
+ """
51
+ D_raw = self.data(moment)
52
+ if polarity is None:
53
+ polarity = -1 if D_raw.mean(0)[:5].mean() < 0 else 1
54
+ D = D_raw * polarity
55
+ S = self.rel_std(moment) / 100.0 * np.abs(D)
56
+ n = len(D)
57
+ sem_emp = D.std(0, ddof=1) / np.sqrt(n) if n > 1 else np.zeros(D.shape[1])
58
+ sem_rep = np.sqrt((S**2).sum(0)) / n
59
+ return {
60
+ "t": self.gate_times[moment],
61
+ "d": D.mean(0),
62
+ "sem_emp": sem_emp,
63
+ "sem_rep": sem_rep,
64
+ "sem": np.fmax(sem_emp, sem_rep),
65
+ "stacks": D,
66
+ "polarity": polarity,
67
+ }
68
+
69
+
70
+ def _read_header(raw: bytes):
71
+ start = raw.find(b"[InstrumentInfo]")
72
+ end = raw.find(b"ControllerVersion", start)
73
+ end = raw.find(b"\n", end) + 1
74
+ text = raw[:end].decode("latin1")
75
+ out = {}
76
+ for key in ("LM_DataFactor", "HM_DataFactor", "LM_GateTimeShift", "HM_GateTimeShift", "InstrumentID"):
77
+ m = re.search(rf"{key}=(\S+)", text)
78
+ if m:
79
+ out[key] = m.group(1)
80
+ times = {}
81
+ for mom in ("LM", "HM"):
82
+ m = re.search(rf"{mom}_ChA_CenterTime=([^\r\n]+)", text)
83
+ times[mom] = np.array([float(x) for x in m.group(1).split(",")]) * 1e-6
84
+ return text, end, times, out
85
+
86
+
87
+ def read_stb(path: Union[str, Path]) -> StbFile:
88
+ """Read a ``.stb`` stack file. Truncated trailing records are dropped."""
89
+ path = Path(path)
90
+ raw = path.read_bytes()
91
+ _, hdr_end, times, header = _read_header(raw)
92
+ body = raw[hdr_end:]
93
+ rows = {"LM": [], "HM": []}
94
+ pos = 0
95
+ while True:
96
+ idx = body.find(_TERMINATOR, pos)
97
+ if idx < 0:
98
+ break
99
+ rec = body[pos:idx]
100
+ pos = idx + len(_TERMINATOR)
101
+ for mom, code in (("LM", 0), ("HM", 1)):
102
+ n = len(times[mom])
103
+ full = _record_length(n)
104
+ if len(rec) < full:
105
+ continue
106
+ # locate record start from the tail (the first record carries a file preamble)
107
+ r = rec[-full:]
108
+ if r[0] != code or struct.unpack_from("<H", r, 2)[0] != n:
109
+ continue
110
+ rows[mom].append(
111
+ dict(
112
+ stack_size=struct.unpack_from("<I", r, full - 8 * n - 4)[0],
113
+ lat=struct.unpack_from("<d", r, _LAT_OFFSET)[0],
114
+ lon=struct.unpack_from("<d", r, _LON_OFFSET)[0],
115
+ data=np.frombuffer(r, "<f4", n, full - 8 * n).astype(float),
116
+ rel_std=np.frombuffer(r, "<f4", n, full - 4 * n).astype(float),
117
+ )
118
+ )
119
+ break
120
+ stacks = {m: pd.DataFrame(v) for m, v in rows.items()}
121
+ return StbFile(path=path, gate_times=times, stacks=stacks, header=header)
122
+
123
+
124
+ def _record_length(n: int) -> int:
125
+ """Record body length (without terminator): 231 fixed bytes + uint32 count + 2 float32 arrays."""
126
+ return 235 + 8 * n
127
+
128
+
129
+ def read_stn(path: Union[str, Path]) -> dict:
130
+ """Read a ``.stn`` station file (key=value text)."""
131
+ out = {}
132
+ for line in Path(path).read_text(encoding="utf-8", errors="ignore").splitlines():
133
+ if "=" in line:
134
+ k, v = line.split("=", 1)
135
+ out[k.strip()] = v.strip()
136
+ for k in ("LineNo", "StationNo"):
137
+ if k in out:
138
+ out[k] = int(out[k])
139
+ for k in ("Latitude", "Longitude"):
140
+ if k in out:
141
+ out[k] = float(out[k])
142
+ return out
143
+
144
+
145
+ def read_sounding_dir(directory: Union[str, Path]) -> Optional[dict]:
146
+ """Read a single sounding directory containing ``*.stn`` and ``*.stb`` files."""
147
+ d = Path(directory)
148
+ stb_files, stn_files = sorted(d.glob("*.stb")), sorted(d.glob("*.stn"))
149
+ if not stn_files:
150
+ return None
151
+ st = read_stn(stn_files[0])
152
+ row = {
153
+ "sounding": d.name,
154
+ "station": st.get("StationNo"),
155
+ "line": st.get("LineNo"),
156
+ "lat": st.get("Latitude"),
157
+ "lon": st.get("Longitude"),
158
+ }
159
+ if not stb_files:
160
+ return {**row, "n_lm": 0, "n_hm": 0}
161
+ s = read_stb(stb_files[0])
162
+ row.update(n_lm=len(s.stacks["LM"]), n_hm=len(s.stacks["HM"]), polarity=1)
163
+ if row["n_hm"] > 0 and s.data("HM").mean(0)[:5].mean() < 0:
164
+ row["polarity"] = -1
165
+ for m in ("LM", "HM"):
166
+ row[f"t_{m}"] = s.gate_times[m]
167
+ if len(s.stacks[m]) == 0:
168
+ continue
169
+ D = s.data(m) * row["polarity"]
170
+ S = s.rel_std(m) / 100.0 * np.abs(D)
171
+ n = len(D)
172
+ row[f"d_{m}"] = D.mean(0)
173
+ row[f"sem_emp_{m}"] = D.std(0, ddof=1) / np.sqrt(n) if n > 1 else np.zeros(D.shape[1])
174
+ row[f"sem_rep_{m}"] = np.sqrt((S**2).sum(0)) / n
175
+ row[f"stacks_{m}"] = D
176
+ return row
177
+
178
+
179
+ def read_survey(data_root: Union[str, Path], pattern: str = "*/*") -> pd.DataFrame:
180
+ """Read all sounding directories under ``data_root`` into a DataFrame."""
181
+ root = Path(data_root)
182
+ if (root / "Data").is_dir():
183
+ root = root / "Data"
184
+ rows = []
185
+ for d in sorted(root.glob(pattern)):
186
+ if d.is_dir():
187
+ rec = read_sounding_dir(d)
188
+ if rec is not None:
189
+ rows.append(rec)
190
+ df = pd.DataFrame(rows)
191
+ if not df.empty and "lat" in df and "lon" in df:
192
+ df["x_m"] = (df.lon - df.lon.iloc[0]) * 111320 * np.cos(np.deg2rad(df.lat.iloc[0]))
193
+ df["y_m"] = (df.lat - df.lat.iloc[0]) * 110574
194
+ df["dist_m"] = np.r_[0, np.cumsum(np.hypot(np.diff(df.x_m), np.diff(df.y_m)))]
195
+ return df
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.5
2
+ Name: groundtem-io
3
+ Version: 0.1.0
4
+ Summary: Unofficial Python reader and plotting utilities for GroundTEM .stb, .stn, and .sts files
5
+ Project-URL: Homepage, https://github.com/Kang-geophysics/groundtem-io
6
+ Project-URL: Repository, https://github.com/Kang-geophysics/groundtem-io
7
+ Project-URL: Issues, https://github.com/Kang-geophysics/groundtem-io/issues
8
+ Author-email: Hyeonwoo Kang <hykanghw@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: GroundTEM,TDEM,TEM,electromagnetics,geophysics,stb,stn
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Scientific/Engineering :: Physics
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: matplotlib>=3.7
23
+ Requires-Dist: numpy>=1.24
24
+ Requires-Dist: pandas>=2.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # groundtem-io
28
+
29
+ Unofficial Python reader and plotting utilities for GroundTEM time-domain electromagnetic (TDEM) data files:
30
+ - `.stb` binary stack files (`read_stb`)
31
+ - `.stn` station metadata files (`read_stn`)
32
+ - `.sts` survey protocol files (`read_protocol`)
33
+ - LM / HM decay curve and SNR plotting utilities (`plot_decay`, `plot_station`)
34
+
35
+ > **Disclaimer:** This is an independent, unofficial open-source library for reading GroundTEM data files for scientific and interoperability purposes. It is not affiliated with, endorsed by, or sponsored by ABEM or Guideline Geo.
36
+
37
+ ---
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ pip install groundtem-io
43
+ ```
44
+
45
+ Or for local development with `uv`:
46
+
47
+ ```bash
48
+ uv venv
49
+ uv pip install -e .
50
+ ```
51
+
52
+ ---
53
+
54
+ ## Quick Start
55
+
56
+ ### 1. Read `.stb` and `.stn` files directly
57
+
58
+ ```python
59
+ import matplotlib.pyplot as plt
60
+ from groundtem_io import read_stb, read_stn, plot_station
61
+
62
+ # Read station metadata (.stn)
63
+ stn = read_stn("path/to/station.stn")
64
+ print(stn["StationNo"], stn["Latitude"], stn["Longitude"])
65
+
66
+ # Read stacked LM / HM decay records (.stb)
67
+ stb = read_stb("path/to/sounding.stb")
68
+ print("LM gate times (s):", stb.gate_times["LM"])
69
+ print("LM stacks shape (n_stacks, n_gates):", stb.data("LM").shape)
70
+ print("HM stacks shape (n_stacks, n_gates):", stb.data("HM").shape)
71
+
72
+ # Plot LM & HM decay curves (with individual stacks) and SNR
73
+ fig, axs = plot_station(stb)
74
+ plt.show()
75
+ ```
76
+
77
+ ### 2. Read an entire survey directory into a `pandas.DataFrame`
78
+
79
+ ```python
80
+ from groundtem_io import read_survey, plot_decay
81
+ import matplotlib.pyplot as plt
82
+
83
+ df = read_survey("path/to/survey_folder")
84
+ print(df[["sounding", "station", "n_lm", "n_hm", "dist_m"]])
85
+
86
+ # Plot first station
87
+ fig, ax = plt.subplots(figsize=(5, 4))
88
+ plot_decay(ax, df.iloc[0], stacks=True)
89
+ ax.legend()
90
+ plt.show()
91
+ ```
92
+
93
+ ### 3. Read `.sts` protocol file
94
+
95
+ ```python
96
+ from groundtem_io import read_protocol
97
+
98
+ proto = read_protocol("path/to/Protocol.sts")
99
+ print("Tx loop size (m):", proto.tx_size)
100
+ print("LM dipole moment (A*m^2):", proto.dipole_moment("LM"))
101
+ print("HM dipole moment (A*m^2):", proto.dipole_moment("HM"))
102
+ ```
103
+
104
+ ---
105
+
106
+ ## License
107
+
108
+ Released under the [MIT License](LICENSE).
@@ -0,0 +1,8 @@
1
+ groundtem_io/__init__.py,sha256=vw4FgK5cKc1z8EAq4jtVZG300_z7T-sHGpxvi1N7y2c,557
2
+ groundtem_io/plotting.py,sha256=e7iRWYkko-GBkljHQHDZegp3sZCm8MLn0aDxybSrx4c,4116
3
+ groundtem_io/protocol.py,sha256=LN4ojXlM73PCBADDcFfFuCLLhv7uM2gZUEmqscxzEtE,7758
4
+ groundtem_io/stb.py,sha256=NsZ3U2cms7eR6RrlHhSqMZ-hmpgyPAQFpjHTSH3LAk8,7353
5
+ groundtem_io-0.1.0.dist-info/METADATA,sha256=FOCvUaQw5hVY0TJaiWOvr_wvPY6KDIXVZpITt1gVF9I,3257
6
+ groundtem_io-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ groundtem_io-0.1.0.dist-info/licenses/LICENSE,sha256=-2G6gKg3qSMPCJE6XcO6vALVETQE1Ybvd0HqNdk-UWY,1069
8
+ groundtem_io-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hyeonwoo Kang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING IF NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.