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.
- groundtem_io/__init__.py +22 -0
- groundtem_io/plotting.py +118 -0
- groundtem_io/protocol.py +191 -0
- groundtem_io/stb.py +195 -0
- groundtem_io-0.1.0.dist-info/METADATA +108 -0
- groundtem_io-0.1.0.dist-info/RECORD +8 -0
- groundtem_io-0.1.0.dist-info/WHEEL +4 -0
- groundtem_io-0.1.0.dist-info/licenses/LICENSE +21 -0
groundtem_io/__init__.py
ADDED
|
@@ -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
|
+
]
|
groundtem_io/plotting.py
ADDED
|
@@ -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
|
groundtem_io/protocol.py
ADDED
|
@@ -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,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.
|