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.
- groundtem_io-0.1.0/.gitignore +21 -0
- groundtem_io-0.1.0/LICENSE +21 -0
- groundtem_io-0.1.0/PKG-INFO +108 -0
- groundtem_io-0.1.0/PUBLISHING_GUIDE.md +129 -0
- groundtem_io-0.1.0/README.md +82 -0
- groundtem_io-0.1.0/pyproject.toml +39 -0
- groundtem_io-0.1.0/src/groundtem_io/__init__.py +22 -0
- groundtem_io-0.1.0/src/groundtem_io/plotting.py +118 -0
- groundtem_io-0.1.0/src/groundtem_io/protocol.py +191 -0
- groundtem_io-0.1.0/src/groundtem_io/stb.py +195 -0
|
@@ -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
|