"""High-level 1D electromagnetic workflows and compatibility facade."""
from __future__ import annotations
import math
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Sequence
import numpy as np
from PyHydroGeophysX._internal.optional_dependencies import BackendUnavailable
from PyHydroGeophysX._internal.utils import noop as _noop, utc_now as _utc_now
from PyHydroGeophysX.data_processing import table_io
from PyHydroGeophysX.data_processing.em1d import (
TEMCOMPANY_MOMENTS,
_normalise_temcompany_moment,
_response_on_times,
is_temcompany_source,
is_ttem_source,
gate_report,
load_line_geometry,
load_sounding,
load_sounding_container,
load_temcompany_sounding,
load_ttem_sounding,
save_sounding_container,
survey_summary,
)
from PyHydroGeophysX.forward.em1d import (
DEFAULT_FDEM,
DEFAULT_MODEL,
DEFAULT_TDEM,
_fdem_config,
_tdem_config,
_tdem_geometry,
fdem_forward,
model_arrays,
model_depth_profile,
tdem_forward,
)
from PyHydroGeophysX.inversion.em1d import (
DEFAULT_INVERSION,
INVERSION_PRESETS,
preset_inversion,
_inversion_layer_thicknesses,
_log_resistivity_bounds,
fdem_invert,
tdem_invert,
tdem_joint_invert,
)
LogFn = Callable[[str], None]
def _scale_bounds(inv: Dict[str, Any]) -> "tuple[float, float]":
"""How far auto-lambda may scale the smoothness, as ``(low, high)``.
Kept beside :func:`_log_resistivity_bounds` so both box constraints reach the
coupled solver by the same route, and so a bad pair fails at the call rather
than inside the search.
"""
pair = inv.get("scale_bounds")
pair = (1e-4, 1e4) if pair is None else tuple(pair)
if len(pair) != 2:
raise ValueError(
f"scale_bounds must hold exactly two values; got {len(pair)}.")
low, high = (float(value) for value in pair)
if not (0.0 < low <= high):
raise ValueError(
f"scale_bounds must be positive and ordered; got {low} and {high}.")
return low, high
METHODS = ("FDEM", "TDEM")
def _tdem_calibration_view(
data: Dict[str, Any], geom: Dict[str, Any]
) -> "tuple[Dict[str, Any], Dict[str, Any]]":
"""Data block and complete instrument geometry used for TDEM calibration."""
moments = dict(data.get("moments", {}))
if not moments:
return data, _tdem_geometry(data, geom)
requested = _normalise_temcompany_moment(str(geom.get("tem_moment", "LM+HM")))
if requested in moments:
name = requested
elif "HM" in moments:
# The joint reader exposes HM as its preview whenever HM exists.
name = "HM"
else:
name = next(iter(moments))
item = dict(moments[name])
return item, _tdem_geometry(data, geom, item.get("transmitter"))
def _line_block(head: Dict[str, Any],
lines: Optional[Sequence[int]]) -> "tuple[int, int]":
"""First station and count for a line selection, as an offset into the file.
``None`` means the whole file from its first station. Otherwise the stations
on the named lines, which are contiguous because the reader orders them by
line. A gap means the request would have to span a line nobody asked for, and
that is refused: inverting an unrequested line under settings chosen for its
neighbours is worse than declining.
"""
n_total = int(head.get("n_soundings", 1))
if lines is None:
return 0, n_total
wanted = {int(v) for v in np.asarray(lines, dtype=int).ravel()}
if not wanted:
raise ValueError("lines must name at least one survey line.")
numbers = np.asarray(head.get("line_numbers", []), dtype=int).ravel()
if numbers.size < n_total:
raise ValueError(
"this source does not record a line number per station, so it "
"cannot be inverted one line at a time.")
found = np.flatnonzero(np.isin(numbers[:n_total], sorted(wanted)))
if not found.size:
raise ValueError(
f"no station is on line {sorted(wanted)}; the survey holds "
f"{sorted(set(numbers[:n_total].tolist()))}.")
if found.size != int(found[-1] - found[0] + 1):
raise ValueError(
f"lines {sorted(wanted)} are not adjacent in this survey, so they "
"cannot be run as one block. Invert them one at a time.")
return int(found[0]), int(found.size)
def _line_chi2_summary(
chi2_per_sounding, data_counts, *, objective_chi2=None,
) -> Dict[str, float]:
"""Summarise line misfit without confusing gate and sounding weighting.
Each sounding value is the mean squared uncertainty-normalised residual for
that sounding. The line objective therefore weights it by the number of
retained gates. Equal-sounding mean and median values are useful QC
summaries, but they are not substitutes for the objective used by the fit.
Aarhus software's per-model ``data residual`` is also reported on a
residual/RMS scale, hence the square-root values returned here as a useful
scale comparison. Its documented tTEM calculation uses log-data space, so
the two values need not be numerically identical.
"""
values = np.asarray(chi2_per_sounding, dtype=float).ravel()
counts = np.asarray(data_counts, dtype=float).ravel()
n = min(values.size, counts.size)
values, counts = values[:n], counts[:n]
valid = np.isfinite(values) & np.isfinite(counts) & (counts > 0)
weighted = float("nan")
if valid.any():
weighted = float(np.sum(values[valid] * counts[valid]) / np.sum(counts[valid]))
try:
reported = float(objective_chi2)
except (TypeError, ValueError):
reported = float("nan")
if np.isfinite(reported):
weighted = reported
finite_values = values[valid]
sounding_mean = (float(np.mean(finite_values)) if finite_values.size
else float("nan"))
sounding_median = (float(np.median(finite_values)) if finite_values.size
else float("nan"))
return {
"global": weighted,
"sounding_mean": sounding_mean,
"sounding_median": sounding_median,
"data_residual_global": (float(math.sqrt(weighted))
if np.isfinite(weighted) and weighted >= 0
else float("nan")),
"data_residual_sounding_median": (float(math.sqrt(sounding_median))
if np.isfinite(sounding_median)
and sounding_median >= 0
else float("nan")),
}
[docs]
def backend_status(method: Optional[str] = None) -> Dict[str, Any]:
"""Report whether the requested EM forward/inversion backend is usable.
The check imports the same method-specific forward class used by inversion.
This prevents the UI and AQUAH from announcing a background inversion that
cannot start because SimPEG or one of its runtime dependencies is missing.
"""
methods = (method,) if method is not None else METHODS
result: Dict[str, Dict[str, Any]] = {}
for selected in methods:
if selected not in METHODS:
raise ValueError(f"method must be one of {METHODS}, got {selected!r}.")
try:
if selected == "FDEM":
from PyHydroGeophysX.forward.fdem_forward import FDEMForwardModeling # noqa: F401
else:
from PyHydroGeophysX.forward.tdem_forward import TDEMForwardModeling # noqa: F401
result[selected] = {"available": True, "error": ""}
except Exception as exc: # noqa: BLE001 - optional numerical backend
result[selected] = {"available": False, "error": str(exc)}
if method is not None:
return result[method]
return {
"available": all(item["available"] for item in result.values()),
"methods": result,
}
[docs]
def example_catalog() -> Dict[str, Dict[str, Any]]:
"""Return the desktop EM examples and their documented settings.
Prefer the source checkout for development and fall back to the compact
package-data copy installed from a wheel.
"""
package_root = Path(__file__).resolve().parents[1]
checkout_root = package_root.parent / "examples" / "data" / "EM"
bundled_root = package_root / "data" / "em_examples"
root = checkout_root if checkout_root.is_dir() else bundled_root
east_river = root / "EastRiver_VTEM"
return {
"east_river_vtem": {
"label": "East River VTEM (recommended)",
"method": "TDEM",
"path": east_river / "eastriver_vtem_line22030.csv",
"geometry_path": east_river / "eastriver_vtem_line22030_geometry.csv",
"params": {
"source_radius": 13.0, "height": 82.0, "orientation": "z",
"waveform": "step_off", "n_layers": 13, "min_thickness": 8.0,
"max_thickness": 55.0, "smoothness": 0.5, "rel_error": 0.08,
"max_iterations": 10, "ref_resistivity": 320.0,
"auto_scale": False, "max_soundings": 22,
},
"note": "Configured VTEM line with companion geometry and reference calibration.",
},
"skytem_bhmar": {
"label": "SkyTEM BHMAR (quick preview)",
"method": "TDEM",
"path": root / "skytem_bhmar_tdem.csv",
"geometry_path": root / "skytem_bhmar_geometry.csv",
"params": {"auto_scale": True, "ref_resistivity": 0.0, "max_soundings": 5},
"note": "Relative airborne TDEM preview; system calibration is not supplied.",
},
"synthetic_fdem": {
"label": "Synthetic FDEM (1D)",
"method": "FDEM",
"path": root / "synthetic_fdem.csv",
"params": {
"source_radius": 10.0, "tx_rx_sep": 10.0, "height": 30.0,
"orientation": "z", "component": "secondary", "waveform": "dipole",
"auto_scale": False, "ref_resistivity": 0.0,
},
"note": "Deterministic 3% noisy response of 50/200/20 ohm-m layers (10/20 m).",
},
"synthetic_tem_lci": {
"label": "Synthetic LM+HM line (LCI)",
"method": "TDEM",
"path": root / "synthetic_tem_lci",
"params": {
"tem_moment": "LM+HM", "data_scale": 1.0,
"auto_scale": False, "ref_resistivity": 0.0,
"max_iterations": 6, "max_soundings": 9,
"lateral_smoothness": 1.3, "lci_passes": 1,
},
"note": (
"Nine-station synthetic LM+HM line with 3% deterministic noise "
"and a known smooth lateral resistivity trend."
),
},
}
[docs]
def estimate_data_scale(path: str, method: str, geom: Dict[str, Any], *,
max_soundings: int = 8, log: LogFn = _noop) -> float:
"""Estimate the amplitude calibration (``data_scale``) for normalized data.
Normalized airborne responses (e.g. moment-normalized dB/dt) differ from the
studio's 1D forward by a near-constant amplitude factor. This fits each
sounding's decay SHAPE to a grid of half-space forward responses at the current
geometry and takes the geometric-mean amplitude ratio ``forward/observed`` at
the best-fitting resistivity. Returns ``1.0`` if it cannot be estimated (so the
caller can fall back to no scaling).
"""
moment = str(geom.get("tem_moment", "HM"))
use_flags = bool(geom.get("use_project_flags", True))
tail_cut = geom.get("tail_max_relative_std")
gate_rejection = str(geom.get("gate_rejection", "truncate"))
reject_negative = bool(geom.get("reject_negative", False))
min_gates_per_moment = geom.get("min_gates_per_moment")
try:
head = load_sounding(
path, method, sounding=0, moment=moment, use_flags=use_flags,
max_relative_std=tail_cut, gate_rejection=gate_rejection,
reject_negative=reject_negative,
min_gates_per_moment=min_gates_per_moment,
ttem_loop_area=geom.get("loop_area"),
ttem_gex_path=geom.get("ttem_gex_path"),
ttem_tfi_path=geom.get("ttem_tfi_path"),
)
except Exception as exc: # noqa: BLE001
log(f"Auto-calibration skipped ({exc}); using data_scale = 1.0")
return 1.0
n_total = int(head.get("n_soundings", 1))
probe = np.unique(np.linspace(0, n_total - 1, min(int(max_soundings), n_total)).astype(int))
try:
if method == "TDEM":
from PyHydroGeophysX.forward.tdem_forward import TDEMForwardModeling
calibration_data, calibration_geom = _tdem_calibration_view(head, geom)
abscissa = np.asarray(calibration_data["times"], dtype=float).ravel()
cfg = _tdem_config(calibration_geom, abscissa)
md = TDEMForwardModeling(
thicknesses=np.array([50.0]), survey_config=cfg)
grid = []
for R in np.geomspace(25.0, 3000.0, 20):
grid.append(
float(calibration_geom.get("response_sign", 1.0))
* np.asarray(md.forward(np.array([1.0 / R, 1.0 / R])),
dtype=float).ravel()[:abscissa.size]
)
preds = np.asarray(grid)
def observed(s):
return _response_on_times(
load_sounding(
path, method, sounding=int(s), moment=moment,
use_flags=use_flags, max_relative_std=tail_cut,
gate_rejection=gate_rejection,
reject_negative=reject_negative,
min_gates_per_moment=min_gates_per_moment,
ttem_loop_area=geom.get("loop_area"),
ttem_gex_path=geom.get("ttem_gex_path"),
ttem_tfi_path=geom.get("ttem_tfi_path"),
),
abscissa,
)
else:
from PyHydroGeophysX.forward.fdem_forward import FDEMForwardModeling
abscissa = np.asarray(head["frequencies"], dtype=float).ravel()
cfg = _fdem_config(geom, abscissa)
grid = []
for R in np.geomspace(25.0, 3000.0, 20):
md = FDEMForwardModeling(thicknesses=np.array([50.0]), survey_config=cfg)
resp = np.asarray(md.forward(np.array([1.0 / R, 1.0 / R]))).ravel()
if resp.size == 2 * abscissa.size and not np.iscomplexobj(resp):
resp = resp[0::2] + 1j * resp[1::2]
grid.append(np.abs(np.asarray(resp, dtype=complex).ravel()[:abscissa.size]))
preds = np.asarray(grid)
def observed(s):
d = load_sounding(
path, method, sounding=int(s), moment=moment,
use_flags=use_flags, max_relative_std=tail_cut,
gate_rejection=gate_rejection,
reject_negative=reject_negative,
min_gates_per_moment=min_gates_per_moment,
ttem_loop_area=geom.get("loop_area"),
ttem_gex_path=geom.get("ttem_gex_path"),
ttem_tfi_path=geom.get("ttem_tfi_path"),
)
return np.abs(np.asarray(d["real"], float) + 1j * np.asarray(d["imag"], float))
except Exception as exc: # noqa: BLE001
log(f"Auto-calibration skipped ({exc}); using data_scale = 1.0")
return 1.0
ks: List[float] = []
for s in probe:
obs = observed(s)
finite = obs > 0
best = None
for pr in preds:
mm = finite & (pr > 0)
if mm.sum() < 5:
continue
lr = np.log10(pr[mm]) - np.log10(obs[mm]) # = log10(scale) at this half-space R
resid = float(lr.std())
if best is None or resid < best[0]:
best = (resid, 10.0 ** float(lr.mean()))
if best is not None:
ks.append(best[1])
if not ks:
return 1.0
k = float(np.exp(np.mean(np.log(ks))))
log(f"Estimated data_scale = {k:.4g} from {len(ks)} soundings.")
return k
[docs]
def calibrate_to_reference(path: str, method: str, geom: Dict[str, Any], inv: Dict[str, Any],
ref_resistivity: float, *, max_probe: int = 6,
log: LogFn = _noop) -> float:
"""Find the ``data_scale`` that makes the recovered near-surface resistivity match
a known/expected value.
The amplitude scale and the absolute resistivity level are degenerate — the EM
data alone cannot fix the level (any ``data_scale`` fits the data, with the
resistivity shifting to compensate). This breaks the degeneracy with EXTERNAL
information: the user supplies ``ref_resistivity`` (a known background, e.g. from
a borehole or regional geology), and this inverts a few probe soundings at two
trial scales, fits the near-surface resistivity's log-linear response to the
scale, and solves for the scale that yields ``ref_resistivity``. Returns the
current ``data_scale`` unchanged if calibration is not possible.
"""
ref = float(ref_resistivity)
current = float(inv.get("data_scale", 1.0))
if ref <= 0:
return current
moment = str(geom.get("tem_moment", "HM"))
use_flags = bool(geom.get("use_project_flags", True))
tail_cut = geom.get("tail_max_relative_std")
gate_rejection = str(geom.get("gate_rejection", "truncate"))
reject_negative = bool(geom.get("reject_negative", False))
min_gates_per_moment = geom.get("min_gates_per_moment")
try:
head = load_sounding(
path, method, sounding=0, moment=moment, use_flags=use_flags,
max_relative_std=tail_cut, gate_rejection=gate_rejection,
reject_negative=reject_negative,
min_gates_per_moment=min_gates_per_moment,
ttem_loop_area=geom.get("loop_area"),
ttem_gex_path=geom.get("ttem_gex_path"),
ttem_tfi_path=geom.get("ttem_tfi_path"),
)
n_total = int(head.get("n_soundings", 1))
probe = np.unique(np.linspace(0, n_total - 1, min(int(max_probe), n_total)).astype(int))
if method == "TDEM":
from PyHydroGeophysX.forward.tdem_forward import TDEMForwardModeling
calibration_data, calibration_geom = _tdem_calibration_view(head, geom)
abscissa = np.asarray(calibration_data["times"], dtype=float).ravel()
md = TDEMForwardModeling(
thicknesses=np.array([50.0]),
survey_config=_tdem_config(calibration_geom, abscissa))
pred = (
float(calibration_geom.get("response_sign", 1.0))
* np.asarray(md.forward(np.array([1.0 / ref, 1.0 / ref])),
dtype=float).ravel()[:abscissa.size]
)
def observed(s):
return _response_on_times(
load_sounding(
path, method, sounding=int(s), moment=moment,
use_flags=use_flags, max_relative_std=tail_cut,
gate_rejection=gate_rejection,
reject_negative=reject_negative,
min_gates_per_moment=min_gates_per_moment,
ttem_loop_area=geom.get("loop_area"),
ttem_gex_path=geom.get("ttem_gex_path"),
ttem_tfi_path=geom.get("ttem_tfi_path"),
),
abscissa,
)
else:
from PyHydroGeophysX.forward.fdem_forward import FDEMForwardModeling
abscissa = np.asarray(head["frequencies"], dtype=float).ravel()
md = FDEMForwardModeling(thicknesses=np.array([50.0]), survey_config=_fdem_config(geom, abscissa))
resp = np.asarray(md.forward(np.array([1.0 / ref, 1.0 / ref]))).ravel()
if resp.size == 2 * abscissa.size and not np.iscomplexobj(resp):
resp = resp[0::2] + 1j * resp[1::2]
pred = np.abs(np.asarray(resp, dtype=complex).ravel()[:abscissa.size])
def observed(s):
d = load_sounding(
path, method, sounding=int(s), moment=moment,
use_flags=use_flags, max_relative_std=tail_cut,
gate_rejection=gate_rejection,
reject_negative=reject_negative,
min_gates_per_moment=min_gates_per_moment,
ttem_loop_area=geom.get("loop_area"),
ttem_gex_path=geom.get("ttem_gex_path"),
ttem_tfi_path=geom.get("ttem_tfi_path"),
)
return np.abs(np.asarray(d["real"], float) + 1j * np.asarray(d["imag"], float))
except Exception as exc: # noqa: BLE001
log(f"Reference calibration unavailable ({exc}); kept data_scale.")
return current
# Tie the data AMPLITUDE to a half-space at ``ref``: data_scale = geomean(pred/obs).
# Deterministic and stable (one forward, no inversion). The recovered model is not
# forced exactly to ``ref`` (a half-space differs from the layered earth), but the
# absolute level is pinned to a known value the same way for every dataset.
ks = []
for s in probe:
obs = observed(s)
m = (pred > 0) & (obs > 0)
if m.sum() >= 5:
ks.append(10.0 ** float((np.log10(pred[m]) - np.log10(obs[m])).mean()))
if not ks:
return current
k = float(np.clip(np.exp(np.mean(np.log(ks))), 1e-4, 1e4))
log(f"Reference calibration to a half-space at {ref:.0f} ohm-m: data_scale = {k:.4g}.")
return k
#: Geometry a station measures for itself rather than inheriting from the survey.
#:
#: A TEMcompany project records the transmitter-receiver distance and the two
#: heights per station. Only the distance actually varies on a walking ground
#: system, and it varies by more than the nominal layout suggests: one survey
#: spans 11.58 to 17.63 m against a spec that states 15.0 m for every station.
#: It is a per-station quantity.
_STATION_GEOMETRY_KEYS = ("tx_rx_sep", "height", "rx_height", "tx_height")
#: Distance bin the per-station transmitter-receiver separation is rounded to.
#:
#: Only used when ``per_station_geometry`` is switched on; see
#: :func:`_station_geometry` for why that is off by default. A quarter of a
#: metre is 1.7 percent of a typical 15 m offset, which is well inside what the
#: response can tell apart, and it takes one survey's 794 distinct distances
#: down to 25.
STATION_DISTANCE_BIN_M = 0.25
def _station_geometry(geom: Dict[str, Any], data: Dict[str, Any]) -> Dict[str, Any]:
"""Overlay one station's measured geometry on the survey-wide dictionary.
On by default. The project records the distance per station, and a walking
ground survey genuinely records a different one at nearly every station: 794
distinct values over 929 stations on one line, spanning 11.58 to 17.63 m
against a nominal 15.0.
It was briefly off, because with the earlier forward path an operator took
about fourteen seconds to build and a distinct distance per station turned a
line inversion from minutes into hours. The native-order instrument chain
removed that: SimPEG now models a compact step response, a build costs about
twenty milliseconds, and the reason to switch it off went with it.
It is also worth more than an earlier measurement suggested, because that
measurement predated the instrument model above. Replacing the measured
column with the nominal 15 m moves one survey's low-moment response by 1.4
percent at the median and 18 percent at its worst gate.
``tx_rx_sep`` is still rounded to ``tx_rx_sep_bin`` metres, defaulting to
:data:`STATION_DISTANCE_BIN_M`, which keeps the operator cache useful for
little cost; set it to zero to pass the measured value through.
A value the station did not record, or recorded as non-positive, leaves the
survey-wide entry alone. That matters for ``tx_rx_sep``, where zero is how a
failed measurement is stored rather than a coincident loop and coil.
"""
if not bool(geom.get("per_station_geometry", True)):
return geom
system = data.get("system")
if not isinstance(system, dict):
return geom
try:
bin_m = float(geom.get("tx_rx_sep_bin", STATION_DISTANCE_BIN_M))
except (TypeError, ValueError):
bin_m = STATION_DISTANCE_BIN_M
updates: Dict[str, Any] = {}
for key in _STATION_GEOMETRY_KEYS:
value = system.get(key)
try:
number = float(value)
except (TypeError, ValueError):
continue
if not np.isfinite(number):
continue
if key == "tx_rx_sep":
if number <= 0.0:
continue
if bin_m > 0.0:
number = round(number / bin_m) * bin_m
updates[key] = number
return {**geom, **updates} if updates else geom
def _with_sensor_height(geom: Dict[str, Any], height: Any) -> Dict[str, Any]:
"""Put a caller's own sensor height in charge of the whole geometry.
All three keys, or the override does nothing. The forward reads
``rx_height`` and ``tx_height`` in preference to ``height``, and the station
dictionary carries both, so setting ``height`` alone leaves a caller's
heights silently ignored while looking as though they were applied.
The loop and the coil end up at the same height, which is what a single
number can say. Every TEMcompany project seen so far records them equal
anyway; a survey that does not should pass its own geometry rather than one
height per station.
"""
try:
value = float(height)
except (TypeError, ValueError):
return geom
if not np.isfinite(value):
return geom
return {**geom, "height": value, "rx_height": value, "tx_height": value}
def _latest_gate(data: Dict[str, Any]) -> Optional[float]:
"""The last time channel this station actually carries, over all moments.
A joint station's ``times`` entry holds one preview moment only, so reading
it would understate a station whose latest gates are in the other moment,
and would say nothing at all about stations that differ from the first one
on the line.
"""
moments = data.get("moments") or {}
times = ([np.asarray(item.get("times", []), dtype=float).ravel()
for item in moments.values()]
if moments else [np.asarray(data.get("times", []), dtype=float).ravel()])
usable = [array.max() for array in times if array.size]
return float(max(usable)) if usable else None
def _sounding_data_count(data: Dict[str, Any], method: str) -> int:
"""Number of residual entries one sounding contributes.
FDEM counts real and imaginary parts separately; a joint TDEM station counts
every gate of every moment it carries.
"""
moments = data.get("moments", {})
if moments:
return int(sum(np.asarray(item.get("times", [])).size
for item in moments.values()))
if str(method).upper() == "FDEM":
return int(2 * np.asarray(data.get("frequencies", [])).size)
return int(np.asarray(data.get("times", [])).size)
#: Half-spaces the automatic starting model is chosen from, in ohm-m. Twelve
#: values over three and a half decades put the grid about a third of a decade
#: apart, which is finer than the starting model needs to be: the inversion
#: moves from wherever it starts, and what matters is not landing a decade away.
_STARTING_HALF_SPACES = np.geomspace(3.0, 5000.0, 12)
#: Soundings the search evaluates. The starting model is one number for the
#: whole line, so it does not need every station to choose it, and a dozen
#: spread along the survey rank the candidates the same way the full set does.
_STARTING_SAMPLE = 12
def _best_starting_resistivity(blocks, n_layers: int, workers: int, *,
default: float, log: LogFn = _noop) -> float:
"""Pick the half-space whose forward response best matches the data.
A starting model far from the ground costs more than iterations. The
Gauss-Newton step is built from a linearization about the current model, and
from a decade and a half away that linearization describes a different
problem; the line search then shortens the step, the run spends its budget
crossing the gap, and where it stops depends on where it started. On one
ground survey the project's own 40 ohm-m default begins at a chi-squared of
3.1e6 while the best half-space begins at 164.
Cheap because it runs after the blocks are built: the first candidate warms
the forward operators the inversion is about to use anyway, and every
candidate after it is one forward per sampled sounding. Returns ``default``
if nothing can be evaluated, so a forward that will not run here fails in
the inversion rather than in the search.
A half-space is a poor start for a layered conductive site, and two richer
searches were built, measured and removed. Both are recorded here because
neither failure is visible from the idea.
**Layered candidates, ranked the same way, changed nothing.** On one
conductive site running about 126, 27, 300 and 21 ohm-m with depth, a
22.7 ohm-m half-space still scored best on initial misfit, 117 against 145
for the two- and three-layer shapes, and the run ended identically. Initial
misfit says how close a model already is, which on a multi-minimum problem
is not where the solver goes from it: after four iterations those same
layered candidates reached 37.6 while the half-space reached 46.2.
**Deciding by trial worked where it was aimed and broke everything else.**
Ranking cheaply and giving the best four a short run found the better
minimum: that survey went from DataFit 3.39 to 2.83, and its median deep
resistivity from a tenth of the reference model's to a half. But full-line
trials cost more than the inversion they prepare, running past ten minutes
on a 518-station line against a forty-second run. Sampling them to thirty
soundings restored the cost and destroyed the answer, because a sampled
ranking is not the full-line ranking: the same survey then chose the
half-space again and lost the gain, while another regressed from
chi-squared 1.6 to 9.5. A search that helps one survey and ruins another is
worse than no search.
What does work, on the same survey, is starting from an existing model:
DataFit 1.79, better than the reference's own 1.98. Until a search can be
made both cheap and representative of the whole line, pass
``initial_models`` rather than extending the scan here.
"""
from PyHydroGeophysX.inversion.em1d_lci import (
_forward_line, _misfit, _worker_pool, resolve_worker_count,
)
if not blocks:
return default
step = max(1, len(blocks) // _STARTING_SAMPLE)
sampled = list(blocks)[::step][:_STARTING_SAMPLE]
n_data = int(sum(block.dobs.size for block in sampled))
if n_data <= 0:
return default
best_rho, best_chi2 = float(default), float("inf")
try:
with _worker_pool(resolve_worker_count(len(sampled), workers)) as pool:
for rho in _STARTING_HALF_SPACES:
x = np.full(len(sampled) * n_layers, math.log10(float(rho)))
residual, _ = _misfit(sampled, _forward_line(sampled, x, n_layers, pool))
chi2 = float(residual @ residual) / n_data
if np.isfinite(chi2) and chi2 < best_chi2:
best_rho, best_chi2 = float(rho), chi2
except Exception as exc: # noqa: BLE001 - the inversion is the thing that must run
log(f" Starting-model search skipped ({exc}); using {default:g} ohm-m.")
return default
log(f" Starting model: {best_rho:.0f} ohm-m, chosen from "
f"{_STARTING_HALF_SPACES.size} half-spaces on {len(sampled)} soundings "
f"(initial chi2 {best_chi2:.3g}).")
return best_rho
[docs]
def invert_line(path: str, method: str, geom: Dict[str, Any], inv: Dict[str, Any],
*, spacing: float = 50.0, positions: Optional[np.ndarray] = None,
heights: Optional[np.ndarray] = None, max_soundings: int = 12,
lines: Optional[Sequence[int]] = None,
doi_blank: bool = True, doi_factor: float = 0.5, ref_resistivity: float = 0.0,
out_dir: Optional[Path] = None,
initial_models: Optional[np.ndarray] = None,
log: LogFn = _noop) -> Dict[str, Any]:
"""Invert a line on a shared fixed-layer grid.
``inv["lci_mode"]`` selects how the soundings are coupled:
``simultaneous`` (the default whenever ``lateral_smoothness`` is positive)
Solves the whole line as one system, with the lateral constraint part of
what is being minimized. See
:mod:`PyHydroGeophysX.inversion.em1d_lci`.
``sequential``
The older block-coordinate passes: each station is re-inverted on its
own against the distance-weighted model its neighbours had at the end of
the previous pass. Kept because it needs no analytic Jacobian, so it
still runs against a forward operator that cannot supply one.
``off``
Independent 1D inversion per sounding, no lateral coupling.
The models are laid side by side to form a ``resistivity(position, depth)``
section ready for :meth:`Model3DView.show_model`: ``edges = (ex, ey, ez)``
(``ez`` is elevation, increasing upward) and ``model3d`` of shape
``(n_pos, 1, n_depth)``.
``positions`` gives the along-line distance of each sounding (the section
x-axis); ``heights`` overrides the sensor height per sounding. When
``doi_blank`` is set, cells below a per-sounding depth of investigation
(a diffusion-depth estimate scaled by ``doi_factor``) are blanked (NaN) so the
unconstrained deep part of an early-time sounding is not shown as railed.
``inv["robust_errors"]`` retains all imported gates and iteratively inflates
effective errors for large residuals. It overrides hard rejection. The main
chi2 uses ORIGINAL errors; ``result["robust"]`` records effective errors and
a separate effective chi2. Import-time flags and QC still apply.
``inv["auto_lambda"]`` re-solves the line at other smoothness
weights to reach ``target_chi2``. ``inv["reject_outliers"]`` drops the gates
the converged model cannot explain (beyond ``outlier_threshold`` sigma, over
``outlier_passes`` cycles, never below ``min_data_fraction`` of the gates)
and solves again; what it removed is reported under ``result["outliers"]``.
They address different causes, so they can be used together: relaxing the
smoothness helps when the model is too stiff for the data, rejection helps
when a minority of gates are simply wrong.
``lines`` restricts the run to the named survey lines, so a line whose data
is thinner than the rest can be given its own settings instead of one set
having to suit every line. Passing ``None`` runs from the first station, as
before. The lateral constraint already groups by line, so a line inverted on
its own is tied exactly as it would be inside a whole-survey run; what
changes is which settings reach it, and that the other lines are not
re-solved. ``max_soundings`` then counts within the selection.
Stations arrive ordered by line, so a selection is a contiguous block. A set
of lines that is not contiguous is refused rather than quietly widened to
the span that encloses it, which would invert the lines in between under
settings chosen for their neighbours.
"""
if method not in METHODS:
raise ValueError(f"method must be one of {METHODS}, got {method!r}.")
moment = (
_normalise_temcompany_moment(str(geom.get("tem_moment", "HM")))
if (is_temcompany_source(path) or is_ttem_source(path))
else str(geom.get("tem_moment", "HM"))
)
use_flags = bool(geom.get("use_project_flags", True))
tail_cut = geom.get("tail_max_relative_std")
gate_rejection = str(geom.get("gate_rejection", "truncate"))
reject_negative = bool(geom.get("reject_negative", False))
min_gates_per_moment = geom.get("min_gates_per_moment")
head = load_sounding(
path, method, sounding=0, moment=moment, use_flags=use_flags,
max_relative_std=tail_cut, gate_rejection=gate_rejection,
reject_negative=reject_negative,
min_gates_per_moment=min_gates_per_moment,
ttem_loop_area=geom.get("loop_area"),
ttem_gex_path=geom.get("ttem_gex_path"),
ttem_tfi_path=geom.get("ttem_tfi_path"),
)
joint = method == "TDEM" and bool(head.get("moments"))
invert = (
fdem_invert if method == "FDEM"
else tdem_joint_invert if joint
else tdem_invert
)
n_total = int(head.get("n_soundings", 1))
offset, n_available = _line_block(head, lines)
n_pos = min(int(max_soundings), max(1, n_available))
# The coupled solve needs at least two stations to tie together, and a
# lateral weight to tie them with.
lci_mode = str(inv.get("lci_mode", "simultaneous")).strip().lower()
if lci_mode not in {"simultaneous", "sequential", "off"}:
lci_mode = "simultaneous"
simultaneous = (
lci_mode == "simultaneous"
and float(inv.get("lateral_smoothness", 0.0)) > 0.0
and n_pos >= 2
)
sequential = (
lci_mode == "sequential" and joint and n_pos >= 2
and float(inv.get("lateral_smoothness", 0.0)) > 0.0
and int(inv.get("lci_passes", 1)) > 0
)
mode = ("simultaneous LCI" if simultaneous
else "block-coordinate LCI" if (lci_mode == "sequential" and joint)
else f"{method} independent 1D")
selected = ("" if lines is None
else f", line{'s' if len(set(lines)) > 1 else ''} "
f"{','.join(str(v) for v in sorted(set(lines)))}")
log(f"Line inversion: {n_pos} of {n_total} soundings{selected} ({mode})")
# Calibrate the amplitude scale to a known reference resistivity if requested
# (breaks the data_scale <-> resistivity-level degeneracy with external info).
if ref_resistivity and float(ref_resistivity) > 0:
inv = {**inv, "data_scale": calibrate_to_reference(
path, method, geom, inv, float(ref_resistivity), log=log)}
data_scale_used = float(inv.get("data_scale", 1.0))
# Shared layer grid (identical for every sounding).
n_layers = int(inv.get("n_layers", 15))
thick = _inversion_layer_thicknesses(inv)
pad = float(inv.get("max_thickness", 40.0))
depth_edges = np.concatenate([[0.0], np.cumsum(thick), [float(np.sum(thick)) + pad]])
ez = (-depth_edges)[::-1] # elevation edges, increasing upward (surface at 0)
hts = np.asarray(heights, dtype=float).ravel() if heights is not None else None
model = np.full((n_pos, 1, n_layers), np.nan, dtype=float)
surface_models = np.full((n_pos, n_layers), np.nan, dtype=float)
chi2_list: List[float] = []
data_count_list: List[int] = []
datasets: List[Optional[Dict[str, Any]]] = [None] * n_pos
geometries: List[Dict[str, Any]] = [geom] * n_pos
per_sounding_outliers: Dict[int, Dict[str, Any]] = {}
per_sounding_robust: Dict[int, Dict[str, Any]] = {}
lateral_requested = float(inv.get("lateral_smoothness", 0.0))
warm_models = np.asarray(initial_models, dtype=float) if initial_models is not None else None
use_warm_models = (
warm_models is not None
and warm_models.shape == (n_pos, n_layers)
and np.all(np.isfinite(warm_models))
and np.all(warm_models > 0.0)
)
if use_warm_models:
surface_models[:, :] = warm_models
log("Using supplied line models as the LCI warm start.")
use_common_lci_start = (
not use_warm_models
and (simultaneous or sequential)
)
if use_common_lci_start:
start = float(inv.get("starting_resistivity", 100.0))
surface_models[:, :] = max(start, 1.0)
log(f"Using a common {start:g} ohm-m starting model for the LCI.")
t_ref = f_ref = None # last time / min frequency, for the DOI estimate
# Imported here rather than at module scope: this module is imported by the
# CLI and the Qt app, and the LCI module pulls in SciPy sparse.
from PyHydroGeophysX.inversion.em1d_lci import _worker_pool, resolve_worker_count
workers = resolve_worker_count(n_pos, int(inv.get("parallel_workers", 0)))
lci_supplies_model = (simultaneous or sequential) and (use_warm_models or use_common_lci_start)
prior_context = bool(inv.get("shallow_prior_enabled", False))
def prepare(s: int):
"""Read one station, and fit it unless the LCI will supply its model.
Runs on a worker thread, so it touches nothing shared: the station's
data, its geometry and its own result go back to the caller, which does
the ordered bookkeeping. Its own exception travels with it for the same
reason, since one station failing must not stop the line.
"""
try:
data = load_sounding(
path, method, sounding=offset + s, moment=moment,
use_flags=use_flags,
max_relative_std=tail_cut, gate_rejection=gate_rejection,
reject_negative=reject_negative,
min_gates_per_moment=min_gates_per_moment,
ttem_loop_area=geom.get("loop_area"),
ttem_gex_path=geom.get("ttem_gex_path"),
ttem_tfi_path=geom.get("ttem_tfi_path"),
)
geom_s = _station_geometry(geom, data)
if hts is not None and s < hts.size:
geom_s = _with_sensor_height(geom_s, hts[s])
if lci_supplies_model or prior_context:
return s, data, geom_s, None, None
# Quiet inside the worker: the inner per-iteration lines would
# interleave across stations. The caller logs one line per station,
# in order, below.
local_inv = {**inv, "starting_model": warm_models[s]} if use_warm_models else inv
return s, data, geom_s, invert(data, geom_s, local_inv, log=_noop), None
except Exception as exc: # noqa: BLE001 - keep the line going
return s, None, geom, None, exc
if workers > 1:
log(f"Reading and fitting {n_pos} soundings on {workers} threads")
with _worker_pool(workers) as executor:
prepared = ([prepare(s) for s in range(n_pos)] if executor is None
else list(executor.map(prepare, range(n_pos))))
for s, data, geom_s, result, failure in prepared:
if failure is not None:
chi2_list.append(float("nan"))
data_count_list.append(0)
log(f" sounding {s + 1}/{n_pos} failed: {failure}")
continue
datasets[s] = data
geometries[s] = geom_s
if t_ref is None and "times" in data and np.size(data["times"]):
t_ref = float(np.asarray(data["times"]).ravel()[-1])
if f_ref is None and "frequencies" in data and np.size(data["frequencies"]):
f_ref = float(np.asarray(data["frequencies"]).ravel().min())
if result is None:
# The LCI supplies the model, so the per-sounding inversion is
# skipped; only the data count is needed here.
chi2_list.append(float("nan"))
data_count_list.append(_sounding_data_count(data, method))
continue
res = np.asarray(result["resistivity"], dtype=float).ravel()
surface_models[s, :] = res
model[s, 0, :] = res[::-1] # deepest layer first to match ez ordering
chi2_list.append(float(result.get("chi2", np.nan)))
data_count_list.append(int(result.get("n_data", 0)))
if bool(result.get("outliers", {}).get("enabled", False)):
per_sounding_outliers[s] = dict(result["outliers"])
if result.get("robust", {}).get("enabled"):
per_sounding_robust[s] = result["robust"]
log(f" sounding {s + 1}/{n_pos}: chi2={result.get('chi2', float('nan')):.3f}")
embedded_positions = np.asarray(head.get("positions", []), dtype=float).ravel()
requested_positions = (
np.asarray(positions, dtype=float).ravel()
if positions is not None else embedded_positions
)
if requested_positions.size >= offset + n_pos:
pos_lci = requested_positions[offset:offset + n_pos]
else:
pos_lci = np.arange(n_pos, dtype=float) * float(spacing)
# Ground level per sounding, carried through for plotting only.
embedded_elevation = np.asarray(head.get("elevation", []), dtype=float).ravel()
surface_elevation = (
embedded_elevation[offset:offset + n_pos]
if embedded_elevation.size >= offset + n_pos
else np.full(n_pos, np.nan, dtype=float)
)
embedded_lines = np.asarray(head.get("line_numbers", []), dtype=int).ravel()
line_numbers = (
embedded_lines[offset:offset + n_pos]
if embedded_lines.size >= offset + n_pos
else np.zeros(n_pos, dtype=int)
)
def _per_sounding(key: str, dtype=float):
"""A per-station column from the source, cut to the inverted stations."""
values = np.asarray(head.get(key, []), dtype=dtype).ravel()
if values.size >= offset + n_pos:
return values[offset:offset + n_pos]
return np.full(n_pos, np.nan if dtype is float else "", dtype=dtype)
# Map coordinates travel with the section so an export can place each model
# in the ground rather than only along the line.
easting, northing = _per_sounding("x"), _per_sounding("y")
longitude, latitude = _per_sounding("longitude"), _per_sounding("latitude")
station_ids = _per_sounding("station_ids", dtype=object)
from PyHydroGeophysX.inversion.em1d_priors import shallow_prior_scores
quality_rows = None
if prior_context and any(data and "raw_lm_quality" in data for data in datasets):
from PyHydroGeophysX.inversion.em1d_priors import raw_lm_quality_rows
quality_rows = raw_lm_quality_rows(
datasets, int(inv.get("shallow_prior_reference_gate", 2)))
signal_limits = None
if prior_context and inv.get("shallow_prior_mode", "quality_trend") == "signal_threshold":
from PyHydroGeophysX.inversion.em1d_priors import shallow_signal_thresholds
log("Calibrating the resistive-background LM signal limit using the instrument forward model.")
signal_limits = shallow_signal_thresholds(datasets, geometries, inv)
available = signal_limits[np.isfinite(signal_limits)]
if available.size:
log(f" LM signal threshold: {available.min():.4g} .. {available.max():.4g} "
"(stored project response units; homogeneous reference, not a depth estimate).")
else:
log(" No raw LM diagnostics available: absolute-signal prior cannot activate. Re-import the project.")
prior_scores, prior_report = shallow_prior_scores(
datasets, pos_lci, line_numbers, inv, quality_rows, signal_limits)
def station_inv(s):
options = {**inv, "_shallow_prior_score": float(prior_scores[s])}
if use_warm_models:
# The automatic soft target follows the model that actually starts
# this station, not a stale project fallback value.
valid = warm_models[s][np.isfinite(warm_models[s]) & (warm_models[s] > 0.)]
if valid.size:
options["_resistive_prior_reference_resistivity"] = float(
10. ** np.mean(np.log10(valid)))
return options
if prior_context:
if quality_rows is None and inv.get("shallow_prior_mode", "quality_trend") == "quality_trend":
log(" Resistive-background prior uses imported LM quality; raw fixed-gate signal/noise "
"checks are unavailable for this input. Re-import a TEMcompany project "
"to preserve the raw quality diagnostics.")
log(f"Empirical resistive-background prior: "
f"{prior_report['active_soundings']}/{n_pos} stations activated; whole-model "
f"one-sided tendency (not a shallow-depth interpretation), weight "
f"{prior_report['weight']:g}.")
if not lci_supplies_model:
# Spatial quality needs the read-only first pass over the line before
# independent fits can receive their individual prior weights.
def fit_with_prior(s):
try:
options = station_inv(s)
if use_warm_models:
options["starting_model"] = warm_models[s]
return s, invert(datasets[s], geometries[s], options, log=_noop), None
except Exception as exc:
return s, None, exc
usable_prior = [s for s in range(n_pos) if datasets[s] is not None]
with _worker_pool(workers) as pool:
fits = (list(map(fit_with_prior, usable_prior)) if pool is None
else list(pool.map(fit_with_prior, usable_prior)))
for s, fit, failure in fits:
if failure:
log(f" sounding {s+1} failed: {failure}")
data_count_list[s] = 0
continue
surface_models[s] = fit["resistivity"]
chi2_list[s], data_count_list[s] = float(fit["chi2"]), int(fit["n_data"])
if fit.get("robust", {}).get("enabled"):
per_sounding_robust[s] = fit["robust"]
if fit.get("outliers", {}).get("enabled"):
per_sounding_outliers[s] = fit["outliers"]
# LCI keeps model nodes even where the local gate set is too sparse for the
# SimPEG time spline. Seed those nodes by log-resistivity interpolation along
# their own survey line; subsequent passes update them from their neighbors.
for line in np.unique(line_numbers):
indices = np.flatnonzero(line_numbers == line)
valid = indices[np.all(np.isfinite(surface_models[indices]), axis=1)]
missing = indices[~np.all(np.isfinite(surface_models[indices]), axis=1)]
if not valid.size or not missing.size:
continue
order = np.argsort(pos_lci[valid])
xp = pos_lci[valid][order]
for layer in range(n_layers):
fp = np.log10(surface_models[valid, layer][order])
surface_models[missing, layer] = np.power(
10.0, np.interp(pos_lci[missing], xp, fp))
model[:, 0, :] = surface_models[:, ::-1]
lateral = lateral_requested
lateral_weight_scale = max(float(inv.get("lateral_weight_scale", 1.0)), 0.0)
lci_passes = max(0, int(inv.get("lci_passes", 1)))
reference_distance = max(float(inv.get("reference_distance", 10.0)), 1e-6)
lateral_distance_power = max(
float(inv.get("lateral_distance_power", 1.0)), 0.0)
lci_report: Dict[str, Any] = {}
outlier_info: Dict[str, Any] = {"enabled": False}
robust_info: Dict[str, Any] = {"enabled": False}
# Kept for the depth-of-investigation pass below, which reads the same
# analytic Jacobian the coupled solver used.
doi_blocks: Dict[int, Any] = {}
if simultaneous:
from PyHydroGeophysX.inversion.em1d import build_sounding_block
from PyHydroGeophysX.inversion.em1d_lci import (
invert_lci,
invert_lci_rejecting_outliers,
invert_lci_with_robust_errors,
)
usable = [s for s in range(n_pos) if datasets[s] is not None]
def build(s: int):
"""Assemble one station's block, or hand back why it could not be."""
try:
return s, build_sounding_block(
datasets[s], geometries[s], station_inv(s), method,
position=float(pos_lci[s]), line=int(line_numbers[s]),
label=f"sounding {s + 1}"), None
except Exception as exc: # noqa: BLE001 - one bad station is not fatal
return s, None, exc
# Worth parallelizing in its own right: each block constructs a SimPEG
# simulation and pays that operator's one-time setup, which on a long
# line adds up to more than the coupled solve it feeds.
with _worker_pool(resolve_worker_count(len(usable), workers)) as executor:
built = ([build(s) for s in usable] if executor is None
else list(executor.map(build, usable)))
sounding_blocks = []
kept: List[int] = []
for s, block, failure in built:
if failure is not None:
log(f" sounding {s + 1} excluded from the LCI: {failure}")
continue
sounding_blocks.append(block)
kept.append(s)
if len(kept) < 2:
# The per-sounding pass was skipped on the assumption the LCI would
# supply the models, so it has to run now or nothing is inverted.
log(" Fewer than two usable soundings; falling back to independent 1D.")
simultaneous = False
for s in usable:
try:
result = invert(datasets[s], geometries[s], station_inv(s), log=log)
surface_models[s, :] = np.asarray(
result["resistivity"], dtype=float).ravel()
chi2_list[s] = float(result.get("chi2", np.nan))
data_count_list[s] = int(result.get("n_data", 0))
if bool(result.get("outliers", {}).get("enabled", False)):
per_sounding_outliers[s] = dict(result["outliers"])
if result.get("robust", {}).get("enabled"):
per_sounding_robust[s] = result["robust"]
log(f" sounding {s + 1}/{n_pos}: "
f"chi2={result.get('chi2', float('nan')):.3f}")
except Exception as exc: # noqa: BLE001
log(f" sounding {s + 1}/{n_pos} failed: {exc}")
model[:, 0, :] = surface_models[:, ::-1]
else:
log(f"Simultaneous LCI: {len(kept)} soundings, lateral="
f"{lateral:g}, vertical={float(inv.get('smoothness', 0.3)):g}")
warm = (surface_models[kept] if use_warm_models else None)
start_resistivity = float(inv.get("starting_resistivity", 100.0))
if warm is None and bool(inv.get("auto_starting_model", True)):
start_resistivity = _best_starting_resistivity(
sounding_blocks, n_layers, workers,
default=start_resistivity, log=log)
if prior_context:
# Block construction precedes the data-driven starting-model
# search. Rebuild only the cheap prior vectors here, using the
# half-space that the optimiser will actually start from; the
# expensive forward operators are retained unchanged.
from PyHydroGeophysX.inversion.em1d_priors import (
resistive_prior_target,
shallow_prior_terms,
)
targets = []
references = []
for block, s in zip(sounding_blocks, kept):
options = station_inv(s)
if warm is None:
options["_resistive_prior_reference_resistivity"] = start_resistivity
block.prior_lower, block.prior_weights = shallow_prior_terms(options, thick)
reference, target, _, source = resistive_prior_target(options)
references.append(reference)
targets.append(target)
prior_report["reference_resistivity"] = float(np.median(references))
prior_report["target_resistivity"] = float(np.median(targets))
prior_report["minimum_resistivity"] = prior_report["target_resistivity"]
prior_report["target_source"] = source
if source == "explicit":
target_description = (
f"explicit {prior_report['target_resistivity']:.0f} ohm-m")
else:
target_description = (
f"effective starting model "
f"{prior_report['reference_resistivity']:.0f} ohm-m × "
f"{prior_report['resistivity_factor']:g} → "
f"{prior_report['target_resistivity']:.0f} ohm-m")
log(f" Background soft tendency: {target_description} "
"(capped by rho_max; all layers, not a depth estimate).")
lci_kwargs = dict(
solver=str(inv.get("lci_solver", "trf")),
trf_max_nfev=int(inv.get("lci_max_nfev", 90)),
trf_ftol=float(inv.get("lci_ftol", 1e-4)),
trf_xtol=float(inv.get("lci_xtol", 1e-6)),
trf_gtol=float(inv.get("lci_gtol", 1e-5)),
smoothness=float(inv.get("smoothness", 0.3)),
lateral_smoothness=lateral * lateral_weight_scale,
reference_distance=reference_distance,
lateral_distance_power=lateral_distance_power,
starting_resistivity=start_resistivity,
max_iterations=int(inv.get("max_iterations", 20)),
convergence_tolerance=float(inv.get("convergence_tolerance", 0.02)),
min_iterations=int(inv.get("min_iterations", 2)),
auto_lambda=bool(inv.get("auto_lambda", True)),
target_chi2=float(inv.get("target_chi2", 1.0)),
chi2_tolerance=float(inv.get("chi2_tolerance", 0.2)),
max_lambda_trials=int(inv.get("max_lambda_trials", 5)),
# How far auto-lambda may move the smoothness. The default span
# is four decades either way, which on a station carrying four
# or five gates buys a chi-squared of 1 with a model that swings
# to match noise. A caller that wants the search available but
# bounded passes something like (0.5, 2.0).
scale_bounds=_scale_bounds(inv),
bounds=_log_resistivity_bounds(inv),
parallel_workers=workers,
verbose=bool(inv.get("verbose", True)),
)
doi_blocks.update(zip(kept, sounding_blocks))
if bool(inv.get("robust_errors", False)):
from PyHydroGeophysX.inversion.robust_errors import robust_error_options
log("Robust error weighting: retain every imported gate; hard rejection bypassed.")
error_options = robust_error_options(inv)
error_options["error_target_chi2"] = error_options.pop("target_chi2")
outcome, sounding_blocks, robust_info = invert_lci_with_robust_errors(
sounding_blocks, n_layers, initial_model=warm, log=log,
**error_options, **lci_kwargs)
robust_info["sounding_indices"] = list(kept)
log(f" Robust weighting finished: {robust_info['kept']} gates retained; "
f"{robust_info['downweighted']} downweighted; "
f"{robust_info['unchanged_fraction']:.1%} errors unchanged.")
if robust_info.get("target_chi2", 0) > 0:
log(f" Effective chi2 target {robust_info['target_chi2']:g} "
f"± {robust_info['target_tolerance']:g}: "
f"{'reached' if robust_info['target_reached'] else 'not reached'}; "
f"current-model lower bound under error limits="
f"{robust_info['final_model_error_limits']['fixed_model_min_chi2']:.3f}.")
elif bool(inv.get("reject_outliers", False)):
log(f"Outlier rejection: cut beyond "
f"{float(inv.get('outlier_threshold', 3.0)):g} sigma, "
f"{int(inv.get('outlier_passes', 2))} pass(es), keeping at least "
f"{int(float(inv.get('min_data_fraction', 0.8)) * 100)} % of the gates "
f"and {int(inv.get('min_gates_per_sounding', 3))} per sounding.")
outcome, sounding_blocks, outlier_info = invert_lci_rejecting_outliers(
sounding_blocks, n_layers,
threshold=float(inv.get("outlier_threshold", 3.0)),
passes=int(inv.get("outlier_passes", 2)),
min_fraction=float(inv.get("min_data_fraction", 0.8)),
min_gates=int(inv.get("min_gates_per_sounding", 3)),
initial_model=warm, log=log, **lci_kwargs)
log(f" Rejection finished: {outlier_info['kept']} of "
f"{outlier_info['n_start']} gates kept "
f"({outlier_info['stopped_because']}).")
else:
outcome = invert_lci(sounding_blocks, n_layers,
initial_model=warm, log=log, **lci_kwargs)
surface_models[kept] = outcome.models
model[:, 0, :] = surface_models[:, ::-1]
for index, s in enumerate(kept):
chi2_list[s] = float(
robust_info["chi2_per_sounding_original"][index]
if robust_info["enabled"] else outcome.chi2_per_sounding[index])
# The blocks are what was actually fitted, so they, not the file,
# carry the gate count and the sensitivity once rejection has run.
data_count_list[s] = int(sounding_blocks[index].dobs.size)
doi_blocks[s] = sounding_blocks[index]
# Every stage the solver actually ran, laid end to end. With
# rejection on, the final run's own history is a couple of points
# and hides the two solves before it.
track = [{
"stage": "solve",
"lambda": float(outlier_info.get("initial", {}).get(
"smoothness_scale", outcome.smoothness_scale)),
"chi2": list(outlier_info.get("initial", {}).get(
"convergence", outcome.chi2_history)),
"chi2_median": list(outlier_info.get("initial", {}).get(
"convergence_median", outcome.chi2_median_history)),
"n_data": int(outlier_info.get("initial", {}).get(
"n_data", sum(b.dobs.size for b in sounding_blocks))),
}]
for entry in outlier_info.get("passes") or []:
track.append({
"stage": f"reject {entry['pass']}",
"lambda": float(outcome.smoothness_scale),
"chi2": list(entry.get("convergence") or []),
"chi2_median": list(entry.get("convergence_median") or []),
"n_data": int(entry.get("kept", 0)),
})
if robust_info["enabled"]:
# Each stage uses its own effective errors. The main chi2 below
# always uses original errors and all original gates.
track = [{"stage": "initial" if entry["pass"] == 0 else f"reweight {entry['pass']}",
"lambda": float(outcome.smoothness_scale),
"chi2": entry["convergence"], "n_data": entry["kept"],
"chi2_median": list(entry.get("convergence_median") or []),
"chi2_original_median": entry.get("chi2_original_median")}
for entry in [robust_info["initial"], *robust_info["passes"]]]
lci_report = {
"mode": "simultaneous",
"chi2": robust_info.get("chi2_original", outcome.chi2),
"chi2_effective": outcome.chi2,
"chi2_history": outcome.chi2_history,
"chi2_median_history": outcome.chi2_median_history,
"chi2_effective_sounding_median": float(np.nanmedian(outcome.chi2_per_sounding)),
"convergence_track": track,
"iterations": robust_info.get("total_iterations", outcome.iterations),
"stop_reason": outcome.stop_reason,
"diagnostics": outcome.diagnostics,
"smoothness_scale": outcome.smoothness_scale,
"lambda_search": robust_info.get("initial_lambda_search", outcome.lambda_search),
"seconds": robust_info.get("solve_seconds", outcome.seconds),
"n_soundings": len(kept),
"n_lateral_ties": int(sum(max(count - 1, 0) for count in
np.unique([b.line for b in sounding_blocks], return_counts=True)[1])),
}
log(f" LCI done: chi2={lci_report['chi2']:.3f} after "
f"{lci_report['iterations']} total iteration(s) ({outcome.stop_reason}), "
f"{lci_report['seconds']:.1f}s")
if not simultaneous and sequential:
lci_report = {"mode": "sequential", "lci_passes": lci_passes}
log(
f"LCI refinement: {lci_passes} pass(es), lateral smoothness={lateral:g}, "
f"vertical smoothness={float(inv.get('smoothness', 0.3)):g}, "
f"auto-scale={lateral_weight_scale:g}"
)
for pass_index in range(lci_passes):
previous = surface_models.copy()
updated = previous.copy()
for s in range(n_pos):
if datasets[s] is None or not np.all(np.isfinite(previous[s])):
continue
same_line = np.flatnonzero(line_numbers == line_numbers[s])
before = same_line[same_line < s]
after = same_line[same_line > s]
neighbors = []
if before.size:
neighbors.append(int(before[-1]))
if after.size:
neighbors.append(int(after[0]))
neighbors = [
index for index in neighbors
if np.all(np.isfinite(previous[index]))
]
distances = np.asarray([
max(abs(float(pos_lci[index] - pos_lci[s])), reference_distance)
for index in neighbors
])
weights = (reference_distance / distances) ** lateral_distance_power
# A one-station survey line still needs a real independent fit,
# even when other lines make the overall selection sequential.
reference_log = (np.average(
np.log10(previous[neighbors]), axis=0, weights=weights)
if neighbors else np.log10(previous[s]))
local_inv = {
**station_inv(s),
"starting_model": previous[s],
"lateral_reference": np.power(10.0, reference_log),
"lateral_weight": (
lateral * lateral_weight_scale
* math.sqrt(float(np.sum(weights)))
),
}
usable_local_data = (
not joint
or any(
np.asarray(item.get("times", [])).size >= 1
for item in datasets[s].get("moments", {}).values()
)
)
if not usable_local_data:
updated[s] = np.power(10.0, reference_log)
continue
try:
result = invert(
datasets[s], geometries[s], local_inv, log=log)
updated[s] = np.asarray(
result["resistivity"], dtype=float).ravel()
chi2_list[s] = float(result.get("chi2", np.nan))
data_count_list[s] = int(result.get("n_data", 0))
if bool(result.get("outliers", {}).get("enabled", False)):
per_sounding_outliers[s] = dict(result["outliers"])
if result.get("robust", {}).get("enabled"):
per_sounding_robust[s] = result["robust"]
except Exception as exc: # noqa: BLE001
log(
f" LCI pass {pass_index + 1}, sounding {s + 1} "
f"kept previous model: {exc}"
)
surface_models = updated
model[:, 0, :] = surface_models[:, ::-1]
finite_pair = np.isfinite(previous) & np.isfinite(updated)
change = (
float(np.sqrt(np.mean(
(np.log10(updated[finite_pair])
- np.log10(previous[finite_pair])) ** 2
)))
if np.any(finite_pair) else float("nan")
)
log(f" LCI pass {pass_index + 1}/{lci_passes}: model change={change:.4g}")
if not simultaneous and bool(inv.get("robust_errors", False)):
entries = [{"sounding": s, **report} for s, report in sorted(per_sounding_robust.items())]
total = sum(entry["kept"] for entry in entries)
robust_info = {
"enabled": True, "mode": "per_sounding", "soundings": entries,
"n_start": total, "kept": total, "dropped": 0,
"downweighted": sum(entry["downweighted"] for entry in entries),
"unchanged": sum(entry["unchanged"] for entry in entries),
"unchanged_fraction": (sum(entry["unchanged"] for entry in entries) / total
if total else float("nan")),
"min_unchanged_fraction": float(inv.get("robust_min_unchanged_fraction", 0.0)),
"fraction_scope": "per_sounding",
"target_chi2": float(inv.get("robust_target_chi2", 0.0)),
"target_tolerance": float(inv.get("robust_target_tolerance", .25)),
"chi2_original": (sum(e["chi2_original"] * e["kept"] for e in entries) / total
if total else float("nan")),
"chi2_effective": (sum(e["chi2_effective"] * e["kept"] for e in entries) / total
if total else float("nan")),
}
robust_info["target_reached"] = (
abs(robust_info["chi2_effective"] - robust_info["target_chi2"]) <= robust_info["target_tolerance"]
if robust_info["target_chi2"] > 0 else None)
# Carry the effective errors into sensitivity/DOI, not just the fit.
from PyHydroGeophysX.inversion.em1d import build_sounding_block
for s, report in per_sounding_robust.items():
try:
block = build_sounding_block(datasets[s], geometries[s], station_inv(s), method,
position=float(pos_lci[s]), line=int(line_numbers[s]))
block.uncertainty = np.asarray(report["uncertainty_effective"], dtype=float)
doi_blocks[s] = block
except Exception as exc:
log(f" Robust sensitivity unavailable at sounding {s + 1}: {exc}")
elif not simultaneous and bool(inv.get("reject_outliers", False)):
entries = [
{"sounding": index, **per_sounding_outliers[index]}
for index in sorted(per_sounding_outliers)
]
outlier_info = {
"enabled": True,
"mode": "per_sounding",
"soundings": entries,
"n_start": int(sum(item.get("n_start", 0) for item in entries)),
"kept": int(sum(item.get("kept", 0) for item in entries)),
"dropped": int(sum(item.get("dropped", 0) for item in entries)),
}
# How far down the data still constrain each sounding, and what to hide.
#
# Where the analytic Jacobian is available the reach comes from the cumulated
# sensitivity, which is what the depth of investigation actually means: below
# it, moving the whole remaining column by a decade would not move the
# predicted response out of its error bars. The diffusion-depth rule is the
# fallback for solvers that supply no Jacobian; it is a rule of thumb about
# the latest gate, so it uses each sounding's OWN latest gate rather than a
# single time borrowed from the first sounding on the line.
from PyHydroGeophysX.inversion.em1d_lci import (
DOI_SENSITIVITY_THRESHOLD,
cumulated_sensitivity,
sensitivity_doi,
)
depth_ctr = 0.5 * (depth_edges[:-1] + depth_edges[1:]) # surface-ordered
doi_threshold = float(inv.get("doi_threshold", DOI_SENSITIVITY_THRESHOLD))
sensitivity = np.full((n_pos, n_layers), np.nan, dtype=float)
doi = np.full(n_pos, np.nan, dtype=float)
mu0 = 4e-7 * np.pi
for s in range(n_pos):
row = surface_models[s]
if not np.all(np.isfinite(row)):
continue
block = doi_blocks.get(s)
if block is not None:
sensitivity[s] = cumulated_sensitivity(block, row)
doi[s] = sensitivity_doi(block, row, depth_edges,
threshold=doi_threshold)
continue
rho_ref = float(np.nanpercentile(row, 40))
last_time = _latest_gate(datasets[s]) if datasets[s] is not None else t_ref
if method == "TDEM" and last_time:
doi[s] = doi_factor * math.sqrt(2.0 * last_time * rho_ref / mu0)
elif method == "FDEM" and f_ref:
doi[s] = doi_factor * 503.0 * math.sqrt(rho_ref / f_ref)
# A cell stuck at the resistivity bound is a railed, meaningless value
# whatever the sensitivity says, so that mask is applied either way.
rail = 10 ** (5.0 - 0.2) # near the _occam_1d resistivity upper bound (1e5 Ω·m)
for s in range(n_pos):
col = model[s, 0, :] # deepest-first
if not np.isfinite(col).any():
continue
if doi_blank and np.isfinite(doi[s]):
col[~(depth_ctr <= doi[s])[::-1]] = np.nan
col[col >= rail] = np.nan
pos = pos_lci
if pos.size >= 2:
step = float(np.median(np.diff(pos)))
else:
step = float(spacing)
ex = np.concatenate([[pos[0] - step / 2.0],
0.5 * (pos[:-1] + pos[1:]) if pos.size >= 2 else [],
[pos[-1] + step / 2.0]])
ey = np.array([-step / 2.0, step / 2.0], dtype=float)
finite = np.isfinite(model)
# Keep the optimizer's gate-weighted objective as the headline value. The
# equal-sounding mean and median remain available for spatial QC.
chi2_summary = _line_chi2_summary(
chi2_list, data_count_list, objective_chi2=lci_report.get("chi2"),
)
chi2_global = chi2_summary["global"]
chi2_effective_list = list(chi2_list)
if robust_info.get("enabled"):
chi2_effective_list = [float("nan")] * n_pos
if robust_info.get("mode") == "per_sounding":
for entry in robust_info.get("soundings", []):
chi2_effective_list[int(entry["sounding"])] = float(entry["chi2_effective"])
else:
weighted = (np.asarray(robust_info["residual_original"], float)
/ np.asarray(robust_info["error_factor"], float))
cursor = 0
for s, count in enumerate(data_count_list):
if count:
chi2_effective_list[s] = float(np.mean(weighted[cursor:cursor+count]**2))
cursor += count
data_residual_list = [
float(math.sqrt(value)) if np.isfinite(value) and value >= 0 else float("nan")
for value in chi2_list
]
result = {
"method": method, "edges": (ex, ey, ez), "model3d": model,
"label": "resistivity (Ω·m)", "cmap": "turbo", "log_scale": True,
"positions": pos, "depth_edges": depth_edges, "thickness": thick,
"sensitivity": sensitivity, "doi": doi, "doi_threshold": doi_threshold,
# Ground level at each sounding, so a section can be drawn against
# elevation instead of depth. The inversion itself is per sounding and
# does not use it: each 1D model starts at its own ground surface.
"surface_elevation": surface_elevation,
"x": easting, "y": northing,
"longitude": longitude, "latitude": latitude,
"station_ids": station_ids,
"coordinate_system": str(head.get("coordinate_system", "")),
# ``chi2`` remains an alias for compatibility. It is the whole-line,
# gate-weighted mean squared normalized residual for every solve mode.
"chi2": chi2_global, "chi2_global": chi2_global,
"chi2_sounding_mean": chi2_summary["sounding_mean"],
"chi2_sounding_median": chi2_summary["sounding_median"],
"data_residual_global": chi2_summary["data_residual_global"],
"data_residual_sounding_median": chi2_summary["data_residual_sounding_median"],
"chi2_list": chi2_list, "data_residual_list": data_residual_list,
"chi2_effective_list": chi2_effective_list,
"n_soundings": n_pos,
"n_layers": n_layers, "n_data": int(sum(data_count_list)),
"data_count_list": data_count_list, "data_scale": data_scale_used,
"joint_moments": joint, "lci": bool(lci_report),
"lci_mode": lci_report.get("mode", "off"), "lci_report": lci_report,
"outliers": outlier_info,
"robust": robust_info,
"shallow_prior": prior_report,
"chi2_effective": robust_info.get("chi2_effective", chi2_global),
"lateral_smoothness": lateral, "lci_passes": lci_passes,
"lateral_weight_scale": lateral_weight_scale,
"lateral_distance_power": lateral_distance_power,
"line_numbers": line_numbers,
"model_range": (float(np.nanmin(model)) if finite.any() else float("nan"),
float(np.nanmax(model)) if finite.any() else float("nan")),
}
if out_dir is not None:
out = table_io.ensure_dir(out_dir)
np.savez(out / "resistivity_section.npz",
positions=pos, elevation_edges=ez, position_edges=ex,
resistivity=model[:, 0, :], chi2=np.asarray(chi2_list, dtype=float),
# Saved so the depth cut can be reproduced, or moved, without
# re-running the inversion.
sensitivity=sensitivity, doi=doi, depth_edges=depth_edges,
line_numbers=np.asarray(line_numbers, dtype=int),
surface_elevation=surface_elevation,
x=easting, y=northing, longitude=longitude, latitude=latitude)
result["saved"] = [str(out / "resistivity_section.npz")]
if lci_report:
result["saved"].append(str(table_io.write_json(out / "lci_report.json", lci_report)))
if prior_report.get("enabled"):
result["saved"].append(str(table_io.write_json(out / "shallow_prior.json", prior_report)))
result["saved"].append(str(table_io.write_csv(
out / "shallow_prior.csv",
zip(station_ids, line_numbers, prior_report["line_distance_m"],
prior_report["early_lm_snr"], prior_report["smoothed_snr_ratio"],
prior_report["signal_ratio"], prior_report["noise_ratio"],
prior_report["signal_threshold"], prior_report["signal_to_threshold"], prior_report["score"]),
header=["station", "line", "line_distance_m", "early_lm_snr",
"smoothed_snr_ratio", "signal_ratio", "noise_ratio",
"signal_threshold", "signal_to_threshold", "prior_score"])))
log(f" saved {out / 'resistivity_section.npz'}")
for written in save_line_csv(result, out):
result["saved"].append(written)
log(f" saved {written}")
if robust_info.get("enabled"):
# Gate order is LM then HM for joint TDEM, exactly as the block
# assembler uses it. Keep identifiers so sparse early gates can be audited.
rows = []
offsets = robust_info.get("block_offsets", [])
reports = ([(entry["sounding"], entry, 0, entry["kept"])
for entry in robust_info["soundings"]]
if robust_info.get("mode") == "per_sounding" else
[(s, robust_info, offsets[i], offsets[i + 1])
for i, s in enumerate(robust_info["sounding_indices"])])
for s, report, begin, end in reports:
data = datasets[s]
if method == "TDEM":
moments = data.get("moments") or {"TDEM": data}
labels = [(name, i, float(t)) for name in ("LM", "HM", "TDEM")
if name in moments for i, t in enumerate(moments[name]["times"])]
else:
labels = [(name, i, float(f)) for name in ("real", "imag")
for i, f in enumerate(data["frequencies"])]
for j, k in enumerate(range(begin, end)):
name, gate, coordinate = labels[j]
rows.append((str(station_ids[s]), int(line_numbers[s]), name, gate, coordinate,
report["observed"][k], report["predicted"][k],
report["uncertainty_original"][k], report["uncertainty_effective"][k],
report["error_factor"][k], report["weights"][k],
report["residual_original"][k]))
result["saved"].append(str(table_io.write_csv(
out / "robust_gate_errors.csv", rows,
header=["station", "line", "moment", "gate_index", "time_s_or_frequency_hz",
"observed", "predicted", "error_original", "error_effective",
"error_factor", "inverse_variance_weight", "residual_original"])))
result["saved"].append(str(table_io.write_json(out / "robust_errors.json", robust_info)))
return result
[docs]
def save_line_csv(result: Dict[str, Any], out_dir: Path) -> List[str]:
"""Write the section as two flat tables; return the paths written.
``model_cells.csv`` is one row per layer per sounding, which is the form a
GIS or a gridding package wants: every row carries its own map coordinate
and its own elevation, so the section can be reconstructed without knowing
anything about the layer grid. ``soundings.csv`` is the per-station summary
that would otherwise have to be recovered by grouping the first table.
Depths are below each station's own ground level, and ``z`` is the elevation
of the cell centre where the survey carries ground elevations. A cell below
the depth of investigation is written out with its resistivity and flagged
rather than dropped: what the inversion produced there is still the answer
to a question the data cannot settle, and a reader filtering on the flag can
decide for themselves.
"""
out = table_io.ensure_dir(out_dir)
res = np.asarray(result["model3d"], dtype=float)[:, 0, :][:, ::-1] # surface first
depth_edges = np.asarray(result["depth_edges"], dtype=float).ravel()
n_pos, n_layers = res.shape
top, bottom = depth_edges[:n_layers], depth_edges[1:n_layers + 1]
centre = 0.5 * (top + bottom)
def column(key: str, fill=np.nan) -> np.ndarray:
values = np.asarray(result.get(key, []), dtype=float).ravel()
return values[:n_pos] if values.size >= n_pos else np.full(n_pos, fill)
lines = np.asarray(result.get("line_numbers", []), dtype=int).ravel()
lines = lines[:n_pos] if lines.size >= n_pos else np.zeros(n_pos, dtype=int)
stations = np.asarray(result.get("station_ids", []), dtype=object).ravel()
stations = stations[:n_pos] if stations.size >= n_pos else np.arange(1, n_pos + 1)
surface = column("surface_elevation")
x, y = column("x"), column("y")
longitude, latitude = column("longitude"), column("latitude")
position, chi2 = column("positions"), column("chi2_list")
doi = column("doi", fill=np.inf)
counts = np.asarray(result.get("data_count_list", []), dtype=float).ravel()
counts = counts[:n_pos] if counts.size >= n_pos else np.zeros(n_pos)
sensitivity = np.asarray(result.get("sensitivity", []), dtype=float)
has_sensitivity = sensitivity.shape == res.shape
cells = []
for s in range(n_pos):
for k in range(n_layers):
cells.append((
int(lines[s]), stations[s], _round(x[s], 3), _round(y[s], 3),
_round(longitude[s], 8), _round(latitude[s], 8),
_round(surface[s], 3), _round(position[s], 3),
_round(top[k], 3), _round(bottom[k], 3), _round(centre[k], 3),
_round(surface[s] - centre[k], 3),
_round(res[s, k], 6),
_round(sensitivity[s, k], 6) if has_sensitivity else "",
int(centre[k] > doi[s]),
_round(chi2[s], 4),
))
paths = [str(table_io.write_csv(
out / "model_cells.csv", cells,
header=["line", "station", "x", "y", "longitude", "latitude",
"surface_elevation", "distance_m", "depth_top_m",
"depth_bottom_m", "depth_center_m", "z", "resistivity_ohm_m",
"sensitivity", "below_doi", "chi2"]))]
summary = [(
int(lines[s]), stations[s], _round(x[s], 3), _round(y[s], 3),
_round(longitude[s], 8), _round(latitude[s], 8),
_round(surface[s], 3), _round(position[s], 3), _round(chi2[s], 4),
int(counts[s]), _round(doi[s], 3) if np.isfinite(doi[s]) else "",
) for s in range(n_pos)]
paths.append(str(table_io.write_csv(
out / "soundings.csv", summary,
header=["line", "station", "x", "y", "longitude", "latitude",
"surface_elevation", "distance_m", "chi2", "n_data", "doi_m"])))
return paths
def _round(value: float, digits: int):
"""A finite number rounded for a table; an empty field for anything else."""
number = float(value)
return round(number, digits) if np.isfinite(number) else ""
[docs]
def build_em_config(method: str, model: Dict[str, Any], geom: Dict[str, Any],
inv: Dict[str, Any]) -> Dict[str, Any]:
return {
"created_time": _utc_now(),
"method": method,
"model": {"thickness": list(model.get("thickness", [])),
"resistivity": list(model.get("resistivity", []))},
"geometry": dict(geom),
"inversion": dict(inv),
}
[docs]
def save_inversion(result: Dict[str, Any], out_dir: Path) -> List[str]:
"""Save recovered model + data fit to npy/csv; return written paths."""
out = table_io.ensure_dir(out_dir)
paths: List[str] = []
res = np.asarray(result["resistivity"], dtype=float)
thick = np.asarray(result["thickness"], dtype=float)
np.save(out / "recovered_resistivity.npy", res); paths.append(str(out / "recovered_resistivity.npy"))
rows = [(float(t),) for t in thick]
table_io.write_csv(out / "recovered_thickness.csv", rows, header=["thickness_m"])
paths.append(str(out / "recovered_thickness.csv"))
depth = np.asarray(result["depth"], dtype=float)
rstep = np.asarray(result["resistivity_step"], dtype=float)
table_io.write_csv(out / "model_depth_resistivity.csv",
list(zip(depth.tolist(), rstep.tolist())),
header=["depth_m", "resistivity_ohm_m"])
paths.append(str(out / "model_depth_resistivity.csv"))
robust = result.get("robust") or {}
if robust.get("enabled"):
keys = ("observed", "predicted", "uncertainty_original", "uncertainty_effective",
"error_factor", "weights", "residual_original")
if result["method"] == "FDEM":
labels = [(name, i, float(f)) for name in ("real", "imag")
for i, f in enumerate(result["frequencies"])]
else:
moments = result.get("moments") or {"TDEM": result}
labels = [(name, i, float(t)) for name, item in moments.items()
for i, t in enumerate(item["times"])]
rows = [(*labels[i], *(robust[key][i] for key in keys))
for i in range(robust["kept"])]
paths.append(str(table_io.write_csv(
out / "robust_gate_errors.csv", rows,
header=["moment", "gate_index", "time_s_or_frequency_hz", "observed", "predicted",
"error_original", "error_effective", "error_factor",
"inverse_variance_weight", "residual_original"])))
paths.append(str(table_io.write_json(out / "robust_errors.json", robust)))
return paths
__all__ = [
"BackendUnavailable",
"METHODS",
"TEMCOMPANY_MOMENTS",
"DEFAULT_MODEL",
"DEFAULT_FDEM",
"DEFAULT_TDEM",
"DEFAULT_INVERSION",
"backend_status",
"example_catalog",
"model_arrays",
"model_depth_profile",
"is_temcompany_source",
"is_ttem_source",
"load_temcompany_sounding",
"load_ttem_sounding",
"load_sounding",
"load_sounding_container",
"save_sounding_container",
"load_line_geometry",
"fdem_forward",
"tdem_forward",
"fdem_invert",
"tdem_invert",
"tdem_joint_invert",
"estimate_data_scale",
"calibrate_to_reference",
"invert_line",
"build_em_config",
"save_inversion",
"save_line_csv",
]