groundtem-io 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.
@@ -0,0 +1,21 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Distribution / packaging
7
+ build/
8
+ dist/
9
+ *.egg-info/
10
+
11
+ # Virtual environments
12
+ .venv/
13
+ venv/
14
+ env/
15
+
16
+ # Jupyter Notebook
17
+ .ipynb_checkpoints
18
+
19
+ # OS files
20
+ .DS_Store
21
+ Thumbs.db
@@ -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.
@@ -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,129 @@
1
+ # `groundtem-io` 독립 로컬 환경 세팅 및 GitHub / PyPI 배포 가이드라인
2
+
3
+ 이 문서는 `groundtem-io` 폴더를 다른 디렉토리로 복사한 뒤, **새로운 독립 로컬 프로젝트 환경**에서 바로 개발, 테스트, GitHub 업로드 및 PyPI(`pip`) 배포를 이어서 진행할 수 있도록 작성된 단계별 가이드입니다.
4
+
5
+ ---
6
+
7
+ ## 1. 폴더 구조 확인
8
+
9
+ 복사한 `groundtem-io/` 폴더 내부는 다음과 같이 구성되어 있습니다:
10
+
11
+ ```text
12
+ groundtem-io/
13
+ ├── .gitignore # Git 추적 제외 목록 (가상환경, 빌드 산출물 등)
14
+ ├── LICENSE # MIT 오픈소스 라이선스
15
+ ├── PUBLISHING_GUIDE.md # 현재 보고 계신 배포 및 환경 설정 가이드라인
16
+ ├── README.md # GitHub 및 PyPI 메인 페이지에 표시될 영문 설명서
17
+ ├── pyproject.toml # 패키지 설정, 버전, 의존성 메타데이터
18
+ └── src/
19
+ └── groundtem_io/
20
+ ├── __init__.py # 패키지 공개 API 정의
21
+ ├── stb.py # read_stb, read_stn, read_sounding_dir, read_survey
22
+ ├── protocol.py # read_protocol (.sts 프로토콜 파서)
23
+ └── plotting.py # plot_decay, plot_station (LM/HM 감쇠곡선 및 SNR 플롯)
24
+ ```
25
+
26
+ ---
27
+
28
+ ## 2. 새 로컬 환경에서 프로젝트 시작하기
29
+
30
+ 1. 원하는 작업 경로(예: `~/Projects/groundtem-io`)로 이 `groundtem-io` 폴더를 복사/이동합니다.
31
+ 2. 터미널(또는 새로운 Antigravity/VS Code 창)에서 해당 폴더를 엽니다.
32
+ 3. `pyproject.toml` 파일을 열어 수정할 항목이 있는지 확인합니다:
33
+ - `name`: 기본값은 `"groundtem-io"`입니다. 다른 패키지명을 원하시면 `pyproject.toml`의 `name`을 변경하세요.
34
+ - `authors`: 이름과 이메일 주소를 확인하세요.
35
+ - `[project.urls]`: `YOUR_GITHUB_USERNAME` 부분을 본인의 GitHub 아이디로 변경하세요.
36
+
37
+ ### 로컬 가상환경 생성 및 편집 모드(Editable) 설치
38
+ `uv`가 설치되어 있다면 아래 명령어로 즉시 독립 가상환경을 만들고 패키지를 테스트할 수 있습니다:
39
+
40
+ ```bash
41
+ # 1. 가상환경 생성 (.venv)
42
+ uv venv
43
+
44
+ # 2. 현재 패키지를 editable 모드로 설치
45
+ uv pip install -e .
46
+
47
+ # 3. 정상 임포트 확인
48
+ uv run python -c "import groundtem_io; print(groundtem_io.__version__, groundtem_io.__all__)"
49
+ ```
50
+
51
+ ---
52
+
53
+ ## 3. 배포 전 법적·보안 체크리스트 (필수 확인)
54
+
55
+ 1. **제조사 독점 파일 포함 금지**:
56
+ - ABEM / Guideline Geo의 공식 설치 프로그램(`.zip`, `.exe`), 내부 `.dll` 바이너리, 비공개 PDF 매뉴얼 등은 절대 이 저장소에 넣지 마세요.
57
+ 2. **현장 데이터 보안 확인**:
58
+ - 대용량 현장 로우 데이터나 대외비/미발표 연구 데이터는 커밋하지 마세요.
59
+ - 예제 코드나 단위 테스트용 데이터가 필요하다면 공개 가능한 단일 측정점(`.stb`, `.stn` 1개 세트)만 `examples/` 또는 `tests/data/`에 최소한으로 포함하세요.
60
+ 3. **비공식 오픈소스 명시**:
61
+ - `README.md` 상단에 ABEM / Guideline Geo와 무관한 독립 오픈소스 리더임을 밝히는 Disclaimer 문구가 이미 포함되어 있습니다.
62
+
63
+ ---
64
+
65
+ ## 4. GitHub 저장소 생성 및 업로드
66
+
67
+ 1. [GitHub](https://github.com/new)에서 `groundtem-io`라는 이름으로 새로운 **Public** 저장소를 생성합니다 (`README`, `.gitignore`, `LICENSE`는 이미 로컬에 있으므로 체크하지 않고 빈 저장소로 생성).
68
+ 2. 프로젝트 폴더에서 아래 명령어를 순서대로 실행합니다:
69
+
70
+ ```bash
71
+ git init
72
+ git add .
73
+ git commit -m "Initial commit: groundtem-io package for .stb, .stn, and .sts files"
74
+ git branch -M main
75
+ git remote add origin https://github.com/<본인_GitHub_아이디>/groundtem-io.git
76
+ git push -u origin main
77
+ ```
78
+
79
+ ---
80
+
81
+ ## 5. PyPI (`pip`) 패키지 빌드 및 배포
82
+
83
+ ### Step 1: PyPI 계정 및 API 토큰 준비
84
+ 1. [pypi.org](https://pypi.org/)에 가입 및 로그인합니다 (2FA 이중 인증 등록 필요).
85
+ 2. **Account settings** → **API tokens** → **Add API token**으로 이동합니다.
86
+ - Token name: `groundtem-io-upload`
87
+ - Scope: 처음 올릴 때는 `Entire account (all projects)`를 선택합니다.
88
+ 3. 생성된 토큰(`pypi-AgEIcHlwaS5vcmc...`)을 안전한 곳에 복사해 둡니다.
89
+
90
+ *(연습 삼아 먼저 테스트해보고 싶다면 [test.pypi.org](https://test.pypi.org/)에서 동일하게 토큰을 발급받아 테스트할 수도 있습니다.)*
91
+
92
+ ### Step 2: 패키지 빌드 (`dist/` 생성)
93
+ 프로젝트 루트에서 아래 명령어를 실행합니다:
94
+
95
+ ```bash
96
+ uv build
97
+ ```
98
+ 실행 후 `dist/` 폴더 안에 다음 두 파일이 생성되었는지 확인합니다:
99
+ - `groundtem_io-0.1.0-py3-none-any.whl`
100
+ - `groundtem_io-0.1.0.tar.gz`
101
+
102
+ ### Step 3: PyPI에 업로드 (`uv publish`)
103
+ 발급받은 토큰을 사용해 PyPI에 업로드합니다:
104
+
105
+ ```bash
106
+ uv publish --token pypi-여기에_발급받은_토큰_입력
107
+ ```
108
+
109
+ *(참고: 만약 `twine`을 선호하신다면 `uvx twine upload dist/*`로도 동일하게 업로드할 수 있습니다.)*
110
+
111
+ ### Step 4: 배포 확인 및 설치 테스트
112
+ 업로드가 완료되면 몇 초 내로 `https://pypi.org/project/groundtem-io/` 페이지가 활성화되며, 어디서든 아래 명령어로 설치할 수 있습니다:
113
+
114
+ ```bash
115
+ pip install groundtem-io
116
+ ```
117
+
118
+ ---
119
+
120
+ ## 6. 향후 버전 업데이트 방법
121
+
122
+ 코드를 수정한 뒤 새 버전을 올리고 싶을 때는:
123
+ 1. `pyproject.toml`과 `src/groundtem_io/__init__.py`의 `version`을 `"0.1.1"` 또는 `"0.2.0"`으로 올립니다.
124
+ 2. 기존 빌드 파일을 지우고 다시 빌드 및 배포합니다:
125
+ ```bash
126
+ rm -rf dist/
127
+ uv build
128
+ uv publish --token pypi-여기에_발급받은_토큰_입력
129
+ ```
@@ -0,0 +1,82 @@
1
+ # groundtem-io
2
+
3
+ Unofficial Python reader and plotting utilities for GroundTEM time-domain electromagnetic (TDEM) data files:
4
+ - `.stb` binary stack files (`read_stb`)
5
+ - `.stn` station metadata files (`read_stn`)
6
+ - `.sts` survey protocol files (`read_protocol`)
7
+ - LM / HM decay curve and SNR plotting utilities (`plot_decay`, `plot_station`)
8
+
9
+ > **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.
10
+
11
+ ---
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install groundtem-io
17
+ ```
18
+
19
+ Or for local development with `uv`:
20
+
21
+ ```bash
22
+ uv venv
23
+ uv pip install -e .
24
+ ```
25
+
26
+ ---
27
+
28
+ ## Quick Start
29
+
30
+ ### 1. Read `.stb` and `.stn` files directly
31
+
32
+ ```python
33
+ import matplotlib.pyplot as plt
34
+ from groundtem_io import read_stb, read_stn, plot_station
35
+
36
+ # Read station metadata (.stn)
37
+ stn = read_stn("path/to/station.stn")
38
+ print(stn["StationNo"], stn["Latitude"], stn["Longitude"])
39
+
40
+ # Read stacked LM / HM decay records (.stb)
41
+ stb = read_stb("path/to/sounding.stb")
42
+ print("LM gate times (s):", stb.gate_times["LM"])
43
+ print("LM stacks shape (n_stacks, n_gates):", stb.data("LM").shape)
44
+ print("HM stacks shape (n_stacks, n_gates):", stb.data("HM").shape)
45
+
46
+ # Plot LM & HM decay curves (with individual stacks) and SNR
47
+ fig, axs = plot_station(stb)
48
+ plt.show()
49
+ ```
50
+
51
+ ### 2. Read an entire survey directory into a `pandas.DataFrame`
52
+
53
+ ```python
54
+ from groundtem_io import read_survey, plot_decay
55
+ import matplotlib.pyplot as plt
56
+
57
+ df = read_survey("path/to/survey_folder")
58
+ print(df[["sounding", "station", "n_lm", "n_hm", "dist_m"]])
59
+
60
+ # Plot first station
61
+ fig, ax = plt.subplots(figsize=(5, 4))
62
+ plot_decay(ax, df.iloc[0], stacks=True)
63
+ ax.legend()
64
+ plt.show()
65
+ ```
66
+
67
+ ### 3. Read `.sts` protocol file
68
+
69
+ ```python
70
+ from groundtem_io import read_protocol
71
+
72
+ proto = read_protocol("path/to/Protocol.sts")
73
+ print("Tx loop size (m):", proto.tx_size)
74
+ print("LM dipole moment (A*m^2):", proto.dipole_moment("LM"))
75
+ print("HM dipole moment (A*m^2):", proto.dipole_moment("HM"))
76
+ ```
77
+
78
+ ---
79
+
80
+ ## License
81
+
82
+ Released under the [MIT License](LICENSE).
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "groundtem-io"
3
+ version = "0.1.0"
4
+ description = "Unofficial Python reader and plotting utilities for GroundTEM .stb, .stn, and .sts files"
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ authors = [
8
+ { name = "Hyeonwoo Kang", email = "hykanghw@gmail.com" }
9
+ ]
10
+ requires-python = ">=3.9"
11
+ dependencies = [
12
+ "numpy>=1.24",
13
+ "pandas>=2.0",
14
+ "matplotlib>=3.7",
15
+ ]
16
+ keywords = ["geophysics", "TEM", "TDEM", "electromagnetics", "GroundTEM", "stb", "stn"]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Science/Research",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Topic :: Scientific/Engineering :: Physics",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/Kang-geophysics/groundtem-io"
31
+ Repository = "https://github.com/Kang-geophysics/groundtem-io"
32
+ Issues = "https://github.com/Kang-geophysics/groundtem-io/issues"
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/groundtem_io"]
@@ -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)
@@ -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