Skip to content

Commit 3b2cca5

Browse files
committed
add traditional phase pick method
1 parent e06998b commit 3b2cca5

File tree

5 files changed

+969
-21
lines changed

5 files changed

+969
-21
lines changed

README.md

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
# QuakeCore AI Agent
22

3-
这是一个基于 AI 的地震数据处理智能体框架。它允许用户上传 SEGY 文件,并通过自然语言与 AI 对话来分析文件结构和内容
3+
这是一个基于 AI 的地震数据处理智能体框架。它允许用户上传 MiniSEED、SAC、SEG-Y、HDF5 等多种地震数据格式,并通过自然语言与 AI 对话来分析文件结构、获取统计信息或执行相位拾取
44

55
## 功能特点
66

7+
* **多格式支持**: 一次性接入 SEGY、MiniSEED、SAC、HDF5、NumPy 数组等主流格式,自动识别采样率与起始时间。
8+
* **智能拾取**: 内置 STA/LTA、AIC、频率比、AR 模型等多种传统拾取算法,统一归一化评分并输出摘要。
9+
* **HDF5 自适应读取**: 自动遍历数据集并解码自定义键名/起始时间字段,减少手动配置。
710
* **Web 界面**: 类似 GPT 的聊天界面,基于 Streamlit 构建。
8-
* **SEGY 支持**: 支持上传和解析标准 SEGY 地震数据文件。
9-
* **本地 AI**: 集成 LangChain 和 Ollama,支持在本地运行 LLM (如 Llama 3) 进行推理,保护数据隐私。
10-
* **双模推理**: 也可切换至 DeepSeek API(OpenAI SDK 兼容),满足云端推理需求。
11-
* **智能工具**: AI 可以自动调用工具读取 SEGY 头信息(文本头、二进制头)和道集数据。
11+
* **本地/云端 AI**: 集成 LangChain + Ollama(本地)以及 DeepSeek API(云端),按需切换推理路径。
12+
* **智能工具**: AI 可自动调用读取头信息、数据导出、相位拾取等工具,完成常见地震处理任务。
1213

1314
## 快速开始
1415

@@ -57,25 +58,38 @@ streamlit run app.py
5758
1. 在左侧边栏选择推理方式:
5859
* **本地 Ollama**:填入模型名称(默认 `qwen2.5:3b`),并确保服务正在运行。
5960
* **DeepSeek API**:填入模型名称、Base URL 和 API Key(也可通过 `DEEPSEEK_API_KEY` 环境变量注入)。
60-
2. 在“数据源”区域上传 `.segy`/`.sgy` 文件,或直接指向仓库中的示例文件 `data/viking_small.segy`
61+
2. 在“数据源”区域上传 `.segy`/`.sgy`/`.mseed`/`.sac`/`.h5` 文件,或直接使用仓库中的示例文件(如 `data/example.mseed``data/example.h5``data/viking_small.segy`
6162
3. 在聊天框中输入指令,例如:
6263
* "读取segy文件,给我说明其内部的结构"
6364
* "显示这个文件的文本头信息"
6465
* "这个文件的采样率是多少?"
6566
* "读取第0道的统计数据"
67+
* "对当前加载的波形做初至拾取"
6668
* "将第100到200道导出为Excel文件,保存在data/convert/目录下"
6769

70+
## 相位拾取工具
71+
72+
聊天对话触发“run_phase_picking”后,Agent 会自动判断数据类型并运行多种传统拾取算法。可选参数:
73+
74+
* `source_type`: 数据来源(`mseed``sac``segy``hdf5``npy` 等),缺省时按文件扩展名或当前上下文推断。
75+
* `dataset`: 针对 HDF5 指定数据集名称;留空时会自动遍历并选取首个可用数据集。
76+
* `sampling_rate`: 当文件缺失采样率元数据时手动提供(单位 Hz)。
77+
78+
结果会列出每条 Trace 的多种拾取方法(STA/LTA、AIC、频率比、AR 模型、特征阈值、自相关等),并附带统一归一化分数与综合摘要,便于快速确认最佳拾取时间。
79+
6880
## 项目结构
6981

7082
* `app.py`: Streamlit 前端主程序。
7183
* `agent/`: AI 智能体相关代码。
72-
* `core.py`: Agent 初始化和配置。
73-
* `tools.py`: 定义 AI 可调用的 SEGY 处理工具
84+
* `core.py`: Agent 初始化和配置,注册所有工具
85+
* `tools.py`: 定义文件读取、转换、相位拾取等 LangChain 工具
7486
* `utils/`: 底层工具库。
75-
* `segy_handler.py`: 基于 `segyio` 的文件读取封装。
87+
* `segy_handler.py` / `miniseed_handler.py`: 针对不同格式的读取与转换封装。
88+
* `phase_picker.py`: 统一的波形预处理与多算法拾取实现。
89+
* `hdf5_handler.py`: 自适应数据集解析和导出逻辑。
7690

7791
## 扩展
7892

7993
* **模型支持**: 已内置 Ollama 与 DeepSeek,如需扩展更多 OpenAI 兼容接口,可在 `agent/core.py``_build_llm` 中新增 provider。
80-
* **功能增强**: 在 `utils/segy_handler.py` `agent/tools.py` 中添加更多地震处理算法(如频谱分析、增益控制等)。
94+
* **功能增强**: 在 `utils/segy_handler.py``utils/phase_picker.py` `agent/tools.py` 中扩展更多地震处理算法(如频谱分析、自动剪切、机器学习拾取等)。
8195

agent/core.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
convert_miniseed_to_numpy,
2828
convert_miniseed_to_hdf5,
2929
convert_miniseed_to_sac,
30+
run_phase_picking,
3031
)
3132

3233
Provider = Literal["deepseek", "ollama"]
@@ -98,6 +99,7 @@ def get_agent_executor(
9899
convert_miniseed_to_numpy,
99100
convert_miniseed_to_hdf5,
100101
convert_miniseed_to_sac,
102+
run_phase_picking,
101103
]
102104

103105
template = '''Answer the following questions as best you can. You have access to the following tools:

agent/tools.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
from dataclasses import asdict
2+
13
from langchain.tools import tool
24
from utils.segy_handler import SegyHandler
35
from utils.miniseed_handler import MiniSEEDHandler
46
from utils.hdf5_handler import HDF5Handler
7+
from utils.phase_picker import pick_phases, summarize_pick_results
58
import json
69
from typing import Union
710
import numpy as np
@@ -71,6 +74,121 @@ def _coerce_int(value, *, allow_none=False, default=None, field_name="value"):
7174
raise ValueError(f"{field_name} must be an integer, got {value!r}")
7275

7376

77+
def _coerce_float(value, *, allow_none=False, default=None, field_name="value"):
78+
if value is None:
79+
if allow_none:
80+
return default
81+
if default is not None:
82+
return float(default)
83+
raise ValueError(f"{field_name} must be provided")
84+
if isinstance(value, (int, float, np.integer, np.floating)):
85+
return float(value)
86+
if isinstance(value, str):
87+
lowered = value.strip()
88+
if allow_none and lowered.lower() in {"none", "null", ""}:
89+
return default
90+
return float(lowered)
91+
raise ValueError(f"{field_name} must be a float, got {value!r}")
92+
93+
94+
def _normalize_method_list(raw_value):
95+
if raw_value is None:
96+
return None
97+
if isinstance(raw_value, str):
98+
candidate = raw_value.strip()
99+
if not candidate:
100+
return None
101+
if candidate.startswith("["):
102+
try:
103+
data = json.loads(candidate)
104+
if isinstance(data, list):
105+
return [str(item).strip() for item in data if str(item).strip()]
106+
except json.JSONDecodeError:
107+
pass
108+
return [part.strip() for part in candidate.split(",") if part.strip()]
109+
if isinstance(raw_value, (list, tuple, set)):
110+
return [str(item).strip() for item in raw_value if str(item).strip()]
111+
return None
112+
113+
114+
def _parse_method_params(raw_value):
115+
if raw_value is None:
116+
return None
117+
if isinstance(raw_value, dict):
118+
return raw_value
119+
if isinstance(raw_value, str):
120+
candidate = raw_value.strip()
121+
if not candidate:
122+
return None
123+
try:
124+
data = json.loads(candidate)
125+
if isinstance(data, dict):
126+
return data
127+
except json.JSONDecodeError:
128+
return None
129+
return None
130+
131+
132+
def _normalize_source_type(value: str | None):
133+
if not value:
134+
return None
135+
normalized = value.lower().strip()
136+
alias = {
137+
"miniseed": "mseed",
138+
"mseed": "mseed",
139+
"segy": "segy",
140+
"sgy": "segy",
141+
"hdf5": "hdf5",
142+
"h5": "hdf5",
143+
"npy": "npy",
144+
"npz": "npz",
145+
"sac": "sac",
146+
}
147+
return alias.get(normalized, normalized)
148+
149+
150+
def _infer_file_type_from_path(path: str | None):
151+
if not path:
152+
return None
153+
ext = os.path.splitext(path)[1].lower()
154+
mapping = {
155+
".segy": "segy",
156+
".sgy": "segy",
157+
".mseed": "mseed",
158+
".miniseed": "mseed",
159+
".h5": "hdf5",
160+
".hdf5": "hdf5",
161+
".npy": "npy",
162+
".npz": "npz",
163+
".sac": "sac",
164+
}
165+
return mapping.get(ext)
166+
167+
168+
def _resolve_source_path(path: str | None, source_type: str | None):
169+
normalized_type = _normalize_source_type(source_type)
170+
if path:
171+
inferred = normalized_type or _infer_file_type_from_path(path)
172+
return path, inferred
173+
174+
candidates = [
175+
("segy", CURRENT_SEGY_PATH),
176+
("mseed", CURRENT_MINISEED_PATH),
177+
("hdf5", CURRENT_HDF5_PATH),
178+
]
179+
180+
if normalized_type:
181+
for ctype, cpath in candidates:
182+
if ctype == normalized_type and cpath:
183+
return cpath, ctype
184+
return None, normalized_type
185+
186+
for ctype, cpath in candidates:
187+
if cpath:
188+
return cpath, ctype
189+
return None, None
190+
191+
74192
def _resolve_output_path(output_path: str | None, *, default_filename: str) -> str:
75193
"""Resolve output file path.
76194
@@ -603,3 +721,64 @@ def convert_miniseed_to_sac(params: Union[str, dict, None] = None):
603721
if "error" in result:
604722
return json.dumps(result, indent=2)
605723
return json.dumps(result, indent=2)
724+
725+
# 运行传统拾取方法
726+
@tool
727+
def run_phase_picking(params: Union[str, dict, None] = None):
728+
"""
729+
Run classical phase picking on the loaded file (SEGY/MiniSEED/HDF5/NumPy) or a specified path.
730+
Args: path (optional), file_type/source_type, dataset (HDF5), sampling_rate (for NumPy), methods, method_params.
731+
"""
732+
"""在已加载或指定的地震数据文件上运行传统初至拾取。参数:path(可选)、file_type/source_type、dataset、sampling_rate、methods、method_params。"""
733+
parsed = _parse_param_dict(params)
734+
735+
requested_path = parsed.get("path")
736+
source_type = parsed.get("file_type") or parsed.get("source_type")
737+
dataset = parsed.get("dataset")
738+
739+
try:
740+
sampling_rate = _coerce_float(
741+
parsed.get("sampling_rate"),
742+
allow_none=True,
743+
default=None,
744+
field_name="sampling_rate",
745+
)
746+
except ValueError as exc:
747+
return str(exc)
748+
749+
methods = _normalize_method_list(parsed.get("methods"))
750+
method_params = _parse_method_params(parsed.get("method_params"))
751+
752+
resolved_path, inferred_type = _resolve_source_path(requested_path, source_type)
753+
if not resolved_path:
754+
return "No suitable data file is currently loaded. Please upload or specify a file path."
755+
756+
file_type = _normalize_source_type(source_type) or inferred_type
757+
758+
try:
759+
picks = pick_phases(
760+
resolved_path,
761+
file_type=file_type,
762+
dataset=dataset,
763+
sampling_rate=sampling_rate,
764+
methods=methods,
765+
method_params=method_params,
766+
)
767+
except Exception as exc:
768+
return json.dumps({"error": str(exc)}, indent=2)
769+
770+
if not picks:
771+
return "No phase arrivals were detected."
772+
773+
serialized = [asdict(item) for item in picks]
774+
summary = summarize_pick_results(picks)
775+
return json.dumps(
776+
{
777+
"file": resolved_path,
778+
"file_type": file_type,
779+
"count": len(serialized),
780+
"results": serialized,
781+
"summary": summary,
782+
},
783+
indent=2,
784+
)

utils/hdf5_handler.py

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66

77
class HDF5Handler:
8+
_PREFERRED_DATASETS = ("traces", "data", "dataset", "waveforms", "waveform", "values")
9+
810
def __init__(self, filepath: str):
911
self.filepath = filepath
1012
self._validate_file()
@@ -35,19 +37,29 @@ def visitor(name, node):
3537
return datasets
3638

3739
def _select_dataset(self, h5f, dataset_name: str | None):
40+
def _candidate_names(name: str):
41+
norm = str(name).strip()
42+
if not norm:
43+
return []
44+
candidates = [norm]
45+
if norm.startswith("/"):
46+
candidates.append(norm.lstrip("/"))
47+
else:
48+
candidates.append(f"/{norm}")
49+
return [c for c in candidates if c]
50+
3851
if dataset_name:
39-
if dataset_name in h5f:
40-
node = h5f[dataset_name]
41-
if isinstance(node, h5py.Dataset):
42-
return node, dataset_name
43-
raise ValueError(f"Path {dataset_name} is not a dataset")
44-
if dataset_name.startswith("/") and dataset_name[1:] in h5f:
45-
node = h5f[dataset_name[1:]]
46-
if isinstance(node, h5py.Dataset):
47-
return node, dataset_name
52+
for candidate in _candidate_names(dataset_name):
53+
node = h5f.get(candidate)
54+
if node is not None and isinstance(node, h5py.Dataset):
55+
return node, candidate
4856
raise ValueError(f"Dataset {dataset_name} not found")
49-
if "traces" in h5f and isinstance(h5f["traces"], h5py.Dataset):
50-
return h5f["traces"], "traces"
57+
58+
for preferred in self._PREFERRED_DATASETS:
59+
node = h5f.get(preferred)
60+
if node is not None and isinstance(node, h5py.Dataset):
61+
return node, preferred
62+
5163
datasets = self._collect_datasets(h5f)
5264
if datasets:
5365
return datasets[0]

0 commit comments

Comments
 (0)