Skip to content

Commit 7a8317b

Browse files
committed
update
1 parent ec7fbfb commit 7a8317b

11 files changed

Lines changed: 474 additions & 47 deletions

PyHydroGeophysX/qt_apps/artifact_renderers.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@ def select_renderer(
2020
fmt = Path(str(artifact.get("path") or "")).suffix.lower().lstrip(".")
2121
if "model_bundle" in kind or kind in {"pygimli_bundle", "mesh_model_bundle"}:
2222
return "mesh_bundle"
23-
# A bare PyGIMLi mesh is the primary product of the 3-D mesh builder, so it
24-
# needs its own viewer rather than falling through to "no renderer".
25-
if fmt == "bms" or kind in {"mesh", "pygimli_mesh"}:
23+
# A recognized file format is stronger evidence than a broad semantic kind.
24+
# In particular, ``kind=volume`` with ``format=npy`` is a NumPy stack, not a
25+
# VTK file, and a ``figure_*`` data artifact must not be sent to an image
26+
# decoder merely because its kind contains the word "figure".
27+
if fmt == "bms":
2628
return "mesh"
27-
if fmt in _VTK_FORMATS or kind in {"velocity_model", "volume", "vtk"}:
29+
if fmt in _VTK_FORMATS:
2830
return "vtk"
29-
if fmt in _IMAGE_FORMATS or "figure" in kind or kind == "image":
31+
if fmt in _IMAGE_FORMATS:
3032
return "image"
3133
if fmt in {"npy", "npz"}:
3234
shape = tuple(array_shape or artifact.get("shape") or ())
@@ -41,6 +43,14 @@ def select_renderer(
4143
return "table"
4244
if fmt == "json":
4345
return "json"
46+
# Kind-only fallbacks cover virtual/in-record artifacts and files without a
47+
# useful extension. A bare PyGIMLi mesh is the primary mesh-builder product.
48+
if kind in {"mesh", "pygimli_mesh"}:
49+
return "mesh"
50+
if kind in {"velocity_model", "volume", "vtk"}:
51+
return "vtk"
52+
if "figure" in kind or kind == "image":
53+
return "image"
4454
return "file"
4555

4656

PyHydroGeophysX/qt_apps/modules/em_input_format.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,16 @@ A header-less file is read by column order: one column = position; two columns =
106106

107107
## Notes
108108

109+
- A line inversion runs the per-sounding work on threads: reading the stations,
110+
building their forward operators, and every forward and Jacobian evaluation
111+
inside the coupled solve. The thread count comes from the machine; set
112+
`parallel_workers` through `set_params` to pin it. Each sounding owns its
113+
forward operator and the workers only read the shared model, so the models
114+
come back bit for bit identical to a serial run, which the test suite checks
115+
over repeated solves. The coupled solve itself measures 9.5 times faster on 20
116+
threads; end to end the gain is smaller, because building the forward
117+
operators is a fixed cost that threads do not remove (roughly 1.4 times on a
118+
short run, 3.9 times on one that iterates 25 times).
109119
- **Auto-calibrate** (checkbox, on by default) estimates the data-scale
110120
calibration from the data before inverting. Leave it on for normalized airborne
111121
data (e.g. moment-normalized dB/dt); it returns ~1 for data already in the

PyHydroGeophysX/qt_apps/modules/ert_processing.py

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -157,14 +157,37 @@ def __init__(self, state: Any, log: LogFn, parent=None) -> None:
157157
self._plot.addItem(self._sel_scatter)
158158
self._plot.scene().sigMouseClicked.connect(self._on_click)
159159

160-
self._pseudo_widget = pg.PlotWidget()
160+
# Use a GraphicsLayout rather than a bare PlotWidget so the colour scale
161+
# is part of the pseudosection. The plotted quantity is log10(rhoa), but
162+
# the colour-bar axis formats those exponents back into physical ohm-m
163+
# values; readers should not have to infer resistivity from colour alone.
164+
self._pseudo_widget = pg.GraphicsLayoutWidget()
161165
self._pseudo_widget.setBackground("w")
162-
self._pseudo_widget.showGrid(x=True, y=True, alpha=0.25)
163-
self._pseudo_widget.setLabel("bottom", "x (m)")
164-
self._pseudo_widget.setLabel("left", "pseudo-depth (m)")
165-
self._pseudo_plot = self._pseudo_widget.getPlotItem()
166-
self._pseudo_scatter = pg.ScatterPlotItem(size=11)
166+
self._pseudo_plot = self._pseudo_widget.addPlot(row=0, col=0)
167+
self._pseudo_plot.showGrid(x=True, y=True, alpha=0.25)
168+
self._pseudo_plot.setLabel("bottom", "x (m)")
169+
self._pseudo_plot.setLabel("left", "pseudo-depth (m, positive down)")
170+
self._pseudo_plot.invertY(True)
171+
self._pseudo_scatter = pg.ScatterPlotItem(size=11, hoverable=True)
167172
self._pseudo_plot.addItem(self._pseudo_scatter)
173+
self._pseudo_scatter.sigHovered.connect(self._on_pseudo_hover)
174+
self._pseudo_colorbar = pg.ColorBarItem(
175+
values=(0.0, 1.0),
176+
width=18,
177+
colorMap=self._cmap,
178+
label="Apparent resistivity (Ω·m)",
179+
interactive=False,
180+
colorMapMenu=False,
181+
)
182+
self._pseudo_colorbar.axis.setLogMode(True)
183+
self._pseudo_widget.addItem(self._pseudo_colorbar, row=0, col=1)
184+
self._pseudo_readout = self._pseudo_widget.addLabel(
185+
"Hover a measurement to read apparent resistivity.",
186+
row=1,
187+
col=0,
188+
colspan=2,
189+
justify="left",
190+
)
168191

169192
self._model_view = MeshResultView()
170193
# The "Resistivity model" tab shows the single inversion OR any time step
@@ -2226,26 +2249,61 @@ def _draw_pseudosection(self) -> None:
22262249
if not self._pseudo:
22272250
self._pseudo_scatter.setData([])
22282251
self._pseudo_plot.setTitle("")
2252+
self._pseudo_colorbar.setVisible(False)
2253+
self._pseudo_readout.setText("No apparent-resistivity measurements loaded.")
22292254
return
22302255
arr = np.asarray(self._pseudo, dtype=float)
22312256
mid, depth, rhoa = arr[:, 0], arr[:, 1], arr[:, 2]
22322257
valid = np.isfinite(rhoa) & (rhoa > 0)
22332258
mid, depth, rhoa = mid[valid], depth[valid], rhoa[valid]
22342259
if rhoa.size == 0:
22352260
self._pseudo_scatter.setData([])
2261+
self._pseudo_plot.setTitle("No positive finite apparent resistivity to display")
2262+
self._pseudo_colorbar.setVisible(False)
2263+
self._pseudo_readout.setText(
2264+
"All apparent-resistivity values are missing, non-finite, or non-positive."
2265+
)
22362266
return
22372267
log_rhoa = np.log10(rhoa)
22382268
lo, hi = np.percentile(log_rhoa, [3, 97])
2239-
rng = hi - lo if hi > lo else 1.0
2269+
if hi <= lo:
2270+
lo, hi = float(lo) - 0.5, float(hi) + 0.5
2271+
rng = hi - lo
22402272
norm = np.clip((log_rhoa - lo) / rng, 0.0, 1.0)
22412273
lut = self._cmap.map(norm, mode="byte")
22422274
spots = [
2243-
{"pos": (float(mid[i]), -float(depth[i])),
2244-
"brush": pg.mkBrush(int(lut[i, 0]), int(lut[i, 1]), int(lut[i, 2])), "size": 11}
2275+
{"pos": (float(mid[i]), float(depth[i])),
2276+
"data": (float(mid[i]), float(depth[i]), float(rhoa[i])),
2277+
"brush": pg.mkBrush(int(lut[i, 0]), int(lut[i, 1]), int(lut[i, 2])),
2278+
"size": 11}
22452279
for i in range(mid.size)
22462280
]
22472281
self._pseudo_scatter.setData(spots)
2248-
self._pseudo_plot.setTitle(f"Apparent resistivity (Ω·m): {rhoa.min():.0f}{rhoa.max():.0f} (n={rhoa.size})")
2282+
self._pseudo_colorbar.setLevels((float(lo), float(hi)))
2283+
self._pseudo_colorbar.setVisible(True)
2284+
self._pseudo_readout.setText(
2285+
"Hover a measurement to read x, pseudo-depth, and apparent resistivity."
2286+
)
2287+
self._pseudo_plot.setTitle(
2288+
f"Apparent resistivity: {rhoa.min():.3g}{rhoa.max():.3g} Ω·m "
2289+
f"(n={rhoa.size})"
2290+
)
2291+
2292+
def _on_pseudo_hover(self, _item, points, _event) -> None:
2293+
"""Report the physical value behind a pseudosection colour."""
2294+
if not points:
2295+
self._pseudo_readout.setText(
2296+
"Hover a measurement to read x, pseudo-depth, and apparent resistivity."
2297+
)
2298+
return
2299+
data = points[0].data()
2300+
if not data or len(data) != 3:
2301+
return
2302+
x, depth, rhoa = data
2303+
self._pseudo_readout.setText(
2304+
f"x = {float(x):.3g} m · pseudo-depth = {float(depth):.3g} m"
2305+
f" · ρa = {float(rhoa):.4g} Ω·m"
2306+
)
22492307

22502308
# -- interaction ---------------------------------------------------------
22512309
def _nearest(self, x: float, z: float) -> Optional[int]:

PyHydroGeophysX/qt_apps/modules/hydro_geophysics.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -959,7 +959,22 @@ def _update_display(self) -> None:
959959
return
960960
if arr2d is None:
961961
return
962-
self._map.set_array(arr2d)
962+
labels = {
963+
"Water content": "Volumetric water content",
964+
"Porosity": "Porosity",
965+
"Top": "Top elevation / depth (m)",
966+
"Bottom": "Bottom elevation / depth (m)",
967+
}
968+
title = var
969+
if var in {"Water content", "Porosity", "Bottom"}:
970+
title += f" — layer {layer}"
971+
if var == "Water content" and self._wc is not None and self._wc.ndim == 4:
972+
title += f", snapshot {self._snapshot.value()}"
973+
self._map.set_array(
974+
arr2d,
975+
value_label=labels.get(var, var),
976+
title=title,
977+
)
963978
if self._point1 and self._point2:
964979
self._map.set_profile_points(self._point1, self._point2)
965980

@@ -1005,7 +1020,13 @@ def _on_preview_ready(self, profile: dict) -> None:
10051020
self._profile = profile
10061021
try:
10071022
wc = np.asarray(profile["water_content_profile"], dtype=float)
1008-
self._preview.set_array(wc)
1023+
self._preview.set_array(
1024+
wc,
1025+
x_label="Profile sample",
1026+
y_label="Layer",
1027+
value_label="Volumetric water content",
1028+
title="Water-content profile preview",
1029+
)
10091030
except Exception as exc: # noqa: BLE001
10101031
self.log(f"Profile preview failed: {exc}", "warn")
10111032

PyHydroGeophysX/qt_apps/modules/model_viewer.py

Lines changed: 92 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,41 @@ def _artifact_label(artifact: Dict[str, Any], *, missing: bool = False) -> str:
127127
return f"{label} (missing)" if missing else label
128128

129129

130+
def _artifact_plot_options(artifact: Dict[str, Any], path: Path) -> tuple[str, str, bool]:
131+
"""Return ``(title, value_label, log_scale)`` for numeric previews.
132+
133+
Result artifacts already carry field metadata in several workflows. Keep
134+
that meaning when the file reaches the generic viewer instead of reducing
135+
every grid to anonymous rows, columns, and an unlabeled linear colour bar.
136+
"""
137+
metadata = artifact.get("metadata")
138+
metadata = dict(metadata) if isinstance(metadata, dict) else {}
139+
kind = str(artifact.get("kind") or "")
140+
field = str(
141+
metadata.get("label")
142+
or metadata.get("field")
143+
or artifact.get("label")
144+
or (_pretty_kind(kind) if kind else "Value")
145+
).strip()
146+
units = str(metadata.get("units") or "").strip()
147+
tokens = " ".join((kind, path.stem, field)).lower()
148+
is_resistivity = any(token in tokens for token in ("resistiv", "rhoa", "rho_a"))
149+
if not units and is_resistivity:
150+
units = "Ω·m"
151+
value_label = field or "Value"
152+
if units and units.lower() not in value_label.lower():
153+
value_label = f"{value_label} ({units})"
154+
explicit_log = metadata.get("log_scale")
155+
if isinstance(explicit_log, str):
156+
log_scale = explicit_log.strip().lower() in {"1", "true", "yes", "log", "log10"}
157+
elif explicit_log is None:
158+
log_scale = is_resistivity
159+
else:
160+
log_scale = bool(explicit_log)
161+
title = str(metadata.get("title") or path.stem.replace("_", " ").strip()).strip()
162+
return title, value_label, log_scale
163+
164+
130165
def _run_title(record: "RunRecord") -> str:
131166
"""Show the user's own name for a run, or a short stable one.
132167
@@ -789,11 +824,13 @@ def _render_selected_artifact(self, _index: int) -> None:
789824
renderer = select_renderer(artifact)
790825
try:
791826
if renderer in {"array", "array_stack", "curve"} and path.suffix.lower() in {".npy", ".npz"}:
792-
self._render_numpy(path, renderer)
827+
self._render_numpy(path, renderer, artifact)
793828
elif renderer == "curve":
794829
self._render_curve_file(path)
795830
elif renderer == "image":
796-
view = ZoomableImageView(); view.set_image_file(path)
831+
view = ZoomableImageView()
832+
if not view.set_image_file(path):
833+
raise ValueError("the image decoder could not read this file")
797834
self._replace_visual(view)
798835
elif renderer == "vtk":
799836
from PyHydroGeophysX.qt_apps.widgets.model3d_view import VTKVolumeView
@@ -824,7 +861,14 @@ def _replace_visual(
824861
self._visual_resources.extend(resources or [])
825862
self._visual_layout.addWidget(widget)
826863

827-
def _render_numpy(self, path: Path, renderer: str) -> None:
864+
def _render_numpy(
865+
self,
866+
path: Path,
867+
renderer: str,
868+
artifact: Optional[Dict[str, Any]] = None,
869+
) -> None:
870+
artifact = dict(artifact or {})
871+
title, value_label, log_scale = _artifact_plot_options(artifact, path)
828872
loaded = np.load(
829873
path,
830874
allow_pickle=False,
@@ -839,20 +883,43 @@ def _render_numpy(self, path: Path, renderer: str) -> None:
839883
]
840884
if not names:
841885
raise ValueError("NPZ contains no numeric arrays.")
842-
array = np.array(loaded[names[0]], copy=True)
886+
metadata = artifact.get("metadata")
887+
metadata = dict(metadata) if isinstance(metadata, dict) else {}
888+
requested = str(metadata.get("array_key") or "")
889+
chosen = requested if requested in names else names[0]
890+
array = np.array(loaded[chosen], copy=True)
843891
loaded.close()
844892
else:
845893
array = loaded
846-
renderer = select_renderer({"path": str(path)}, array.shape)
894+
# Keep semantic dispatch from the artifact (notably a 2-D curve
895+
# table). Shape only upgrades/downgrades the generic array modes.
896+
if renderer == "array" and array.ndim >= 3:
897+
renderer = "array_stack"
898+
elif renderer == "array_stack" and array.ndim < 3:
899+
renderer = "curve" if array.ndim == 1 else "array"
847900
if renderer == "curve" or array.ndim == 1:
848-
values = np.array(array, copy=True).ravel()
849-
view = CurveViewer(); view.add_curve(
850-
np.arange(values.size), values, path.stem
851-
)
901+
values = np.array(array, copy=True)
902+
view = CurveViewer()
903+
if values.ndim == 2 and min(values.shape) >= 2:
904+
# A compact 2 x N / 3 x N array normally stores x and one
905+
# or two series by row; a tall N x K array stores columns.
906+
if values.shape[0] <= 4 and values.shape[1] > 2 * values.shape[0]:
907+
values = values.T
908+
x = values[:, 0]
909+
for column in range(1, values.shape[1]):
910+
view.add_curve(x, values[:, column], f"{value_label} {column}")
911+
else:
912+
values = values.ravel()
913+
view.add_curve(np.arange(values.size), values, value_label)
852914
self._replace_visual(view); return
853915
if array.ndim == 2:
854916
values = np.array(array, copy=True)
855-
view = ArrayViewer(); view.set_array(values)
917+
view = ArrayViewer(); view.set_array(
918+
values,
919+
log=log_scale,
920+
value_label=value_label,
921+
title=title,
922+
)
856923
self._replace_visual(view); return
857924
if array.ndim >= 3:
858925
host = QWidget(); layout = QVBoxLayout(host)
@@ -861,17 +928,29 @@ def _render_numpy(self, path: Path, renderer: str) -> None:
861928
controls.addWidget(QLabel("Step / slice:")); controls.addWidget(step); controls.addStretch(1)
862929
view = ArrayViewer()
863930
sample = np.asarray(array).ravel()[::max(1, array.size // 250_000)]
931+
if log_scale:
932+
with np.errstate(divide="ignore", invalid="ignore"):
933+
sample = np.log10(np.where(sample > 0, sample, np.nan))
864934
finite = sample[np.isfinite(sample)]
865-
limits = np.percentile(finite, [2, 98]) if finite.size else None
935+
limits = None
936+
if finite.size:
937+
lo, hi = np.percentile(finite, [2, 98])
938+
if hi <= lo:
939+
half_span = 0.5 if log_scale else max(abs(float(lo)) * 0.05, 0.5)
940+
lo, hi = float(lo) - half_span, float(hi) + half_span
941+
limits = (float(lo), float(hi))
866942
def show_slice(value: int) -> None:
867943
# Copy only the visible slice: the widget never owns a view
868944
# of the file mapping, so Delete Run can close it first.
869945
view.set_array(
870946
np.array(array[value], copy=True),
871947
autoscale=limits is None,
948+
log=log_scale,
949+
value_label=value_label,
950+
title=f"{title} — slice {value + 1}",
872951
)
873-
if limits is not None and limits[1] > limits[0]:
874-
view.set_levels(float(limits[0]), float(limits[1]))
952+
if limits is not None:
953+
view.set_levels(*limits)
875954
step.valueChanged.connect(show_slice); show_slice(0)
876955
layout.addLayout(controls); layout.addWidget(view, stretch=1)
877956
self._replace_visual(host, resources=[loaded])

0 commit comments

Comments
 (0)