Skip to content

Commit aa02fee

Browse files
committed
update
1 parent b72878a commit aa02fee

19 files changed

Lines changed: 15551 additions & 117 deletions

PyHydroGeophysX/qt_apps/agent/chat_panel.py

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939

4040
_P = theme.PALETTE
4141

42+
# Inputs that, while paused at a checkpoint, resume the workflow with no LLM call.
43+
_CONTINUE_WORDS = frozenset({"continue", "next", "go", "go on", "proceed", "done",
44+
"next shot", "继续", "下一炮", "下一个"})
45+
4246
SYSTEM_PROMPT = """You are AQUAH, an assistant embedded in the PyHydroGeophysX desktop workbench, a hydrogeophysics tool. You help the user process and model geophysical data by driving the workbench GUI through tools, not by writing code.
4347
4448
Rules:
@@ -51,7 +55,7 @@
5155
- For a processing request, a typical sequence is: navigate to the right module, describe it, load data (use the example data if the user gave none), set parameters, then run.
5256
- If a tool result has status "failed" or "declined", read it and adjust, or ask the user what to do. Do not invent module names, actions, or parameters; discover them with the tools.
5357
- Always finish your turn by telling the user the next step: what you will do next, or what they can ask for. When you START a long job (a tool result with status "started"), say it is running in the background and that the user can ask for its status (for example "is it done?") or say "continue" to proceed once it finishes — then end your turn rather than guessing the job is complete.
54-
- Seismic first-break picking is PER SHOT and has a mandatory human checkpoint. Workflow: load_data -> (load_geometry if the user gives a geophone positions/topography file) -> set_geometry (for a REGULAR shot interval, pass first_shot_x + shot_spacing ONCE so each record's shot_x auto-fills on select_record — do NOT set shot_x for every record; use a per-record shot_x only to override an irregular shot) -> then for EACH shot record: select_record -> auto_pick -> review_picks (pause). The review_picks result includes records_remaining and next_record. After the user says continue: if records_remaining is non-empty, call select_record(next_record) -> auto_pick -> review_picks again, and repeat until no shots remain. Run run_srt ONLY when records_remaining is empty (every shot picked and reviewed). Never run_srt while shots remain, and never run_srt in the same turn as auto_pick.
58+
- Seismic first-break picking is PER SHOT and has a mandatory human checkpoint. Workflow: load_data -> (load_geometry if the user gives a geophone positions/topography file) -> set_geometry (for a REGULAR shot interval, pass first_shot_x + shot_spacing ONCE so each record's shot_x auto-fills on select_record — do NOT set shot_x for every record; use a per-record shot_x only to override an irregular shot) -> then step through shots with pick_next_shot — ONE call that selects the next un-picked record, auto-picks it, and pauses for review (it returns records_remaining and next_record). After the user says continue: if records_remaining is non-empty, call pick_next_shot again; repeat until none remain. Prefer pick_next_shot over separate select_record/auto_pick/review_picks (it is much faster, one call per shot); use the individual actions only to manually re-pick a specific shot. Run run_srt ONLY when records_remaining is empty (every shot picked and reviewed). Never run_srt while shots remain, and never run_srt in the same turn as auto_pick.
5559
- When a tool returns status "awaiting_user", the workflow is paused for the user to act in the GUI (for example correcting picks). Relay the message, end your turn, and resume only when the user says to continue. During the pause the user may also ask you to set_pick or delete_pick specific traces.
5660
5761
Workbench modules (key: purpose):
@@ -98,6 +102,8 @@ def __init__(
98102
self._current_call: Optional[Dict[str, Any]] = None
99103
self._executed_in_turn = False
100104
self._awaiting_user = False
105+
self._paused_resume: Optional[Dict[str, Any]] = None # resume action while paused
106+
self._resume_seq = 0
101107
self._busy = False
102108
self._worker: Optional[LlmCallWorker] = None
103109

@@ -290,20 +296,50 @@ def _refresh_ready_state(self) -> None:
290296
def _on_send(self) -> None:
291297
if self._busy:
292298
return
299+
text = self._input.text().strip()
300+
if not text:
301+
return
302+
# Fast-path: while paused at a checkpoint, "continue" runs the module's
303+
# declared resume action directly — no LLM round-trip, no approval click.
304+
if self._paused_resume and text.lower() in _CONTINUE_WORDS:
305+
self._input.clear()
306+
self._render_user(text)
307+
self._resume_paused(text)
308+
return
293309
ok, reason = self._provider.available()
294310
if not ok:
295311
self._render_note(reason)
296312
self._refresh_ready_state()
297313
return
298-
text = self._input.text().strip()
299-
if not text:
300-
return
301314
self._input.clear()
302315
self._render_user(text)
303316
self._messages.append({"role": "user", "content": text})
317+
self._paused_resume = None # a non-"continue" message takes manual control
304318
self._set_busy(True)
305319
self._start_request()
306320

321+
def _resume_paused(self, text: str) -> None:
322+
"""Run the paused checkpoint's resume action directly (no LLM call), keeping the
323+
message log valid (user -> assistant tool_call -> tool result) for later calls."""
324+
spec = self._paused_resume or {}
325+
action = spec.get("action")
326+
if not action:
327+
self._paused_resume = None
328+
return
329+
args = {"action": action, "args": spec.get("args", {}) or {}}
330+
self._resume_seq += 1
331+
cid = f"resume_{self._resume_seq}"
332+
self._messages.append({"role": "user", "content": text})
333+
self._messages.append({"role": "assistant", "content": None,
334+
"tool_calls": [{"id": cid, "name": "apply_action", "arguments": args}]})
335+
result = self._controller.dispatch("apply_action", args)
336+
self._answer_tool({"id": cid, "name": "apply_action"}, result)
337+
self._render_tool_result("apply_action", result)
338+
self._paused_resume = (result.get("resume")
339+
if isinstance(result, dict) and result.get("status") == "awaiting_user"
340+
else None)
341+
self._refresh_ready_state()
342+
307343
def _start_request(self) -> None:
308344
self._send_btn.setText("…")
309345
worker = LlmCallWorker(self._provider, self._system, self._messages, self._tool_specs)
@@ -378,6 +414,7 @@ def _on_approve(self) -> None:
378414
result = self._controller.dispatch(name, args)
379415
if isinstance(result, dict) and result.get("status") == "awaiting_user":
380416
self._awaiting_user = True
417+
self._paused_resume = result.get("resume")
381418
self._answer_tool(call, result)
382419
self._render_tool_result(name, result)
383420
self._executed_in_turn = True
@@ -401,9 +438,18 @@ def _answer_tool(self, call: Dict[str, Any], result: Dict[str, Any]) -> None:
401438
"role": "tool",
402439
"id": call.get("id"),
403440
"name": call.get("name"),
404-
"content": result,
441+
"content": self._compact_for_context(result),
405442
})
406443

444+
@staticmethod
445+
def _compact_for_context(result: Any) -> Any:
446+
"""Trim display-only / redundant fields before a result enters the model
447+
context, so a long multi-step session does not bloat each call."""
448+
if not isinstance(result, dict):
449+
return result
450+
drop = {"message", "records_total", "records_picked", "resume", "suspect_traces"}
451+
return {k: v for k, v in result.items() if k not in drop}
452+
407453
# -- state / rendering ---------------------------------------------------
408454
def _set_busy(self, busy: bool) -> None:
409455
self._busy = busy
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
"""Robust ERT file loading for the desktop workbench (Qt-free).
2+
3+
A single source of truth for turning an ERT data file into a pygimli
4+
``DataContainerERT`` with correct geometry, topography, and apparent
5+
resistivity. Both the single-inversion ERT module and the time-lapse pipeline
6+
use this so they behave identically.
7+
8+
Why this exists: pygimli's native ``ert.load`` cannot parse several common
9+
field formats. The E4D survey export (used here with a ``.ohm`` extension), for
10+
example, carries a leading index column and no ``# a b m n`` token header, so
11+
``ert.load`` misreads the index column as coordinates, drops the topography,
12+
and discards every measurement (``size() == 0``). The device-specific parsers in
13+
:mod:`PyHydroGeophysX.data_processing.ert_data_agent` (resipy or the embedded
14+
fallback) handle those layouts; this module wires them to pygimli and falls back
15+
across loaders so a file never silently loads empty.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import datetime as _dt
21+
import re
22+
import tempfile
23+
from pathlib import Path
24+
from typing import Any, Callable, List, Optional, Sequence, Tuple
25+
26+
import numpy as np
27+
28+
LogFn = Callable[[str], None]
29+
30+
31+
def _noop(_msg: str) -> None:
32+
return None
33+
34+
35+
# ---------------------------------------------------------------------------
36+
# StandardERT -> pygimli DataContainerERT
37+
# ---------------------------------------------------------------------------
38+
def electrode_elevation(electrodes) -> np.ndarray:
39+
"""Per-electrode elevation aligned with ``electrodes``.
40+
41+
``load_ert_resipy`` carries 2D elevation in ``y`` (its
42+
``_normalize_elevation_axis`` moves a flat-y/varying-z profile so elevation
43+
lives in ``y``); fall back to ``z`` if ``y`` is flat.
44+
"""
45+
if not electrodes:
46+
return np.zeros(0)
47+
ys = np.array([float(e.y) for e in electrodes])
48+
zs = np.array([float(e.z) for e in electrodes])
49+
return ys if ys.std() >= zs.std() else zs
50+
51+
52+
def standard_to_pg(std):
53+
"""Build a pygimli ``DataContainerERT`` from a ``StandardERT`` for inversion.
54+
55+
Most instrument loaders report transfer resistance (V/I), not apparent
56+
resistivity, so apparent resistivity is recovered with geometric factors:
57+
``rhoa = R * k``. When the source already provides apparent resistivity it is
58+
used as-is. pygimli's forward operator uses the same ``data["k"]``, so the
59+
inversion stays self-consistent. Returns ``None`` if pygimli is unavailable
60+
or no measurement maps onto the electrode set.
61+
"""
62+
try:
63+
import pygimli as pg
64+
from pygimli.physics import ert as pg_ert
65+
except Exception: # noqa: BLE001
66+
return None
67+
data = pg.DataContainerERT()
68+
id_to_idx = {}
69+
elecs = std.electrodes or []
70+
elev = electrode_elevation(elecs)
71+
for i, e in enumerate(elecs):
72+
data.createSensor(pg.Pos(float(e.x), 0.0, float(elev[i])))
73+
id_to_idx[int(e.id)] = i
74+
keys = ("A", "B", "M", "N")
75+
valid = [
76+
o for o in (std.observations or [])
77+
if o.app_res is not None and all(int(getattr(o.quad, k)) in id_to_idx for k in keys)
78+
]
79+
if not valid:
80+
return None
81+
data.resize(len(valid))
82+
for name, qk in zip(("a", "b", "m", "n"), keys):
83+
data.set(name, [id_to_idx[int(getattr(o.quad, qk))] for o in valid])
84+
vals = np.array([float(o.app_res) for o in valid], dtype=float)
85+
data.set("r", vals)
86+
data.set("err", [float(o.rel_err) if o.rel_err else 0.05 for o in valid])
87+
try:
88+
data["k"] = pg_ert.createGeometricFactors(data, numerical=False)
89+
k = np.asarray(data["k"], dtype=float)
90+
except Exception: # noqa: BLE001
91+
k = np.ones(len(valid))
92+
source = str((std.metadata or {}).get("app_res_source", "")).lower()
93+
rhoa = vals * k if source == "resistance" else vals
94+
data.set("rhoa", rhoa)
95+
data.markValid(data("rhoa") > 0)
96+
return data
97+
98+
99+
# ---------------------------------------------------------------------------
100+
# Robust single-file loader
101+
# ---------------------------------------------------------------------------
102+
_AUTO_NAMES = ("", "auto", "none", "auto-detect", "auto-detect (pygimli)")
103+
#: Count-prefixed / device formats tried when native pygimli load comes back empty.
104+
_RECOVERY_INSTRUMENTS = ("E4D", "BERT", "Syscal")
105+
106+
107+
def _usable(data) -> bool:
108+
try:
109+
return data is not None and int(data.size()) > 0
110+
except Exception: # noqa: BLE001
111+
return False
112+
113+
114+
def _ensure_rhoa(data, log: LogFn = _noop) -> None:
115+
"""Make sure a natively-loaded container has positive apparent resistivity."""
116+
try:
117+
from pygimli.physics import ert as pg_ert
118+
if data is None:
119+
return
120+
have_rhoa = data.haveData("rhoa") and np.any(np.asarray(data["rhoa"], dtype=float) > 0)
121+
if have_rhoa:
122+
return
123+
if not data.haveData("k") or not np.any(np.asarray(data["k"], dtype=float) != 0):
124+
data["k"] = pg_ert.createGeometricFactors(data, numerical=False)
125+
k = np.asarray(data["k"], dtype=float)
126+
if data.haveData("r"):
127+
data["rhoa"] = np.asarray(data["r"], dtype=float) * k
128+
elif data.haveData("u") and data.haveData("i"):
129+
data["rhoa"] = np.asarray(data["u"], dtype=float) / np.asarray(data["i"], dtype=float) * k
130+
except Exception as exc: # noqa: BLE001
131+
log(f"Apparent-resistivity computation skipped: {exc}")
132+
133+
134+
def _via_instrument(path: str, instrument: str, electrode_file: Optional[str],
135+
spacing: Optional[float], log: LogFn):
136+
try:
137+
from PyHydroGeophysX.data_processing.ert_data_agent import load_ert_resipy
138+
except Exception as exc: # noqa: BLE001
139+
log(f"ert_data_agent unavailable ({exc}).")
140+
return None
141+
proj = tempfile.mkdtemp(prefix="phgx_resipy_")
142+
try:
143+
std = load_ert_resipy(project_dir=proj, data_file=str(path), instrument=instrument,
144+
spacing=spacing, electrode_file=electrode_file)
145+
except Exception as exc: # noqa: BLE001
146+
log(f"Instrument '{instrument}' loader error: {exc}")
147+
return None
148+
return standard_to_pg(std)
149+
150+
151+
def _via_native(path: str, log: LogFn):
152+
try:
153+
from pygimli.physics import ert as pg_ert
154+
data = pg_ert.load(str(path), verbose=False)
155+
except Exception as exc: # noqa: BLE001
156+
log(f"pygimli native load failed: {exc}")
157+
return None
158+
_ensure_rhoa(data, log)
159+
return data
160+
161+
162+
def load_ert_container(path: str, instrument: Optional[str] = None,
163+
electrode_file: Optional[str] = None,
164+
spacing: Optional[float] = None, log: LogFn = _noop):
165+
"""Load one ERT file into a pygimli ``DataContainerERT``, robustly.
166+
167+
An explicit ``instrument`` uses the device parsers (handles index-prefixed
168+
E4D, BERT topography, Syscal, etc.); ``None``/``"auto"`` uses pygimli's own
169+
reader. If the chosen path yields no measurements the loader falls back: an
170+
explicit instrument retries native pygimli; a still-empty result triggers a
171+
short sweep of count-prefixed device formats so an E4D-style file never
172+
loads empty. Raises ``ValueError`` if nothing parses.
173+
"""
174+
path = str(path)
175+
inst = str(instrument).strip() if instrument else ""
176+
is_auto = inst.lower() in _AUTO_NAMES
177+
178+
# 1. explicit instrument
179+
if not is_auto:
180+
data = _via_instrument(path, inst, electrode_file, spacing, log)
181+
if _usable(data):
182+
return data
183+
log(f"Instrument '{inst}' parsed no usable measurements; trying pygimli auto-detect.")
184+
185+
# 2. native pygimli
186+
data = _via_native(path, log)
187+
if _usable(data):
188+
return data
189+
190+
# 3. recovery sweep across count-prefixed device formats
191+
for cand in _RECOVERY_INSTRUMENTS:
192+
if cand.lower() == inst.lower():
193+
continue
194+
data = _via_instrument(path, cand, electrode_file, spacing, log)
195+
if _usable(data):
196+
log(f"Auto-recovered '{Path(path).name}' using instrument='{cand}'.")
197+
return data
198+
199+
raise ValueError(
200+
f"No ERT measurements could be parsed from '{Path(path).name}'. "
201+
f"Pick the matching instrument/format in the loader.")
202+
203+
204+
# ---------------------------------------------------------------------------
205+
# Measurement times from filenames
206+
# ---------------------------------------------------------------------------
207+
_DATE_PATTERNS: List[Tuple[re.Pattern, bool]] = [
208+
(re.compile(r"(\d{4})[-_]?(\d{2})[-_]?(\d{2})[-_ ]?(\d{2})(\d{2})"), True), # ...YYYY-MM-DD_HHMM
209+
(re.compile(r"(\d{4})[-_]?(\d{2})[-_]?(\d{2})"), False), # ...YYYY-MM-DD
210+
]
211+
212+
213+
def _parse_date(stem: str) -> Optional[_dt.datetime]:
214+
for pattern, has_time in _DATE_PATTERNS:
215+
m = pattern.search(stem)
216+
if not m:
217+
continue
218+
try:
219+
if has_time:
220+
return _dt.datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)),
221+
int(m.group(4)), int(m.group(5)))
222+
return _dt.datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)))
223+
except ValueError:
224+
continue
225+
return None
226+
227+
228+
def measurement_times_for(files: Sequence[str]) -> Tuple[List[float], List[str]]:
229+
"""Derive numeric measurement times + display labels from filenames.
230+
231+
When every filename embeds a distinct date, times are elapsed days from the
232+
first acquisition and labels are ``YYYY-MM-DD``. Otherwise falls back to a
233+
sequential ``1..n`` with index labels.
234+
"""
235+
dates = [_parse_date(Path(f).stem) for f in files]
236+
if files and all(d is not None for d in dates):
237+
t0 = min(dates)
238+
times = [round((d - t0).total_seconds() / 86400.0, 4) for d in dates]
239+
labels = [d.strftime("%Y-%m-%d") for d in dates]
240+
if len(set(times)) == len(times): # distinct -> usable as a time axis
241+
return times, labels
242+
n = len(files)
243+
return [float(i + 1) for i in range(n)], [str(i + 1) for i in range(n)]
244+
245+
246+
# ---------------------------------------------------------------------------
247+
# Normalize a sequence into clean pygimli files for the core inversion
248+
# ---------------------------------------------------------------------------
249+
def normalize_for_timelapse(files: Sequence[str], instrument: Optional[str],
250+
out_dir: str, log: LogFn = _noop):
251+
"""Load each file robustly and write a clean pygimli ``.dat`` into one folder.
252+
253+
The core :class:`TimeLapseERTInversion` reloads files with ``ert.load``; the
254+
normalized files are written in pygimli's native unified format (proper token
255+
headers, geometric factors, ``rhoa = R*k``, topography in ``z``) so that
256+
reload is correct. Returns ``(clean_dir, basenames, containers)``; all clean
257+
files share one folder so the windowed inversion (which takes a directory +
258+
filenames) works directly.
259+
"""
260+
base = Path(out_dir) / "qt_ert_timelapse" / "normalized"
261+
base.mkdir(parents=True, exist_ok=True)
262+
for stale in base.glob("step_*.dat"):
263+
try:
264+
stale.unlink()
265+
except OSError:
266+
pass
267+
basenames: List[str] = []
268+
containers: List[Any] = []
269+
for i, f in enumerate(files):
270+
data = load_ert_container(f, instrument=instrument, log=log)
271+
name = f"step_{i:03d}.dat"
272+
data.save(str(base / name))
273+
basenames.append(name)
274+
containers.append(data)
275+
log(f"Prepared {i + 1}/{len(files)}: {Path(f).name} -> "
276+
f"{int(data.size())} data, {int(data.sensorCount())} electrodes")
277+
return str(base), basenames, containers

0 commit comments

Comments
 (0)