Source code for pycsamt.inversion.backends.occam2d

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Occam2D adapter backend.

This module connects :mod:`pycsamt.inversion` to the Occam2D workflow helpers
under :mod:`pycsamt.models.occam2d`. It prepares Occam2D input directories,
validates runner files, optionally executes the external binary when
``run_external=True``, and loads finished Occam2D results into the common
:class:`pycsamt.inversion.results.InversionResult` container.
"""

from __future__ import annotations

import shlex
from pathlib import Path
from typing import Any

from ..base import BaseInversionBackend
from ..results import InversionResult

__all__ = ["Occam2DBackend"]


[docs] class Occam2DBackend(BaseInversionBackend): name = "occam2d" supports = ( ("mt", "2d"), ("amt", "2d"), ("csamt", "2d"), ("emap", "2d"), )
[docs] def run(self, data: Any | None = None) -> InversionResult: """Prepare, validate, optionally run, and load Occam2D outputs. Parameters ---------- data : object, mapping, path-like, or sequence, optional Input source used by ``pycsamt.models.occam2d.InputBuilder``. When omitted, ``self.config.data`` is used. If no source is supplied, configured file names are resolved inside ``workdir`` and the backend validates whether the runner can be launched. Returns ------- InversionResult Backend-neutral result. The ``files`` mapping contains data, mesh, model, and startup file paths when known. ``metadata`` stores the command, startup file, binary name, external execution flag, exit code, and whether a runner was executed. Raises ------ ImportError If :mod:`pycsamt.models.occam2d` is unavailable. NotImplementedError If the configured method/dimension pair is not supported. Notes ----- External execution is disabled unless ``self.config.run_external`` is ``True``. With the default value, this method prepares or validates the directory and reports the command that would be used when all required files exist. Status values follow the adapter lifecycle: ``"prepared"`` Files were prepared or configured, but the runner is not ready. ``"ready"`` Required files exist and an Occam2D command was assembled. ``"executed"`` The external runner returned exit code ``0``. ``"loaded"`` A finished Occam2D result with ``rho_2d`` was loaded. ``"needs_review"`` Preparation, runner execution, or result loading needs user review. Examples -------- >>> from pycsamt.inversion.backends.occam2d import Occam2DBackend >>> from pycsamt.inversion.config import InversionConfig >>> cfg = InversionConfig( ... method="mt", ... dimension="2d", ... backend="occam2d", ... workdir="occam_run", ... backend_options={ ... "files": { ... "data": "OccamData.dat", ... "mesh": "OccamMesh", ... "model": "OccamModel", ... "startup": "Startup", ... } ... }, ... ) >>> result = Occam2DBackend(cfg).run() # doctest: +SKIP """ self.check_supported() cfg = self.config source = cfg.data if data is None else data workdir = Path(cfg.workdir) workdir.mkdir(parents=True, exist_ok=True) warnings: list[str] = [] files: dict[str, str] = {} native = None status = "prepared" command: str | None = None executed = False exit_code: int | None = None try: from ...models.occam2d import ( InputBuilder, OccamConfig, OccamRunner, ) from ...models.occam2d import ( InversionResult as OccamResult, ) except ImportError as exc: raise ImportError( "Occam2D backend requires pycsamt.models.occam2d." ) from exc occam_cfg = _occam_config(OccamConfig, cfg) if source is not None: try: builder = InputBuilder( source, workdir=workdir, config=occam_cfg ) build_options = _build_options(cfg.backend_options) build_options.setdefault("error_floor_rho", cfg.error_floor) build_options.setdefault("error_floor_phase", cfg.phase_error) builder.build(**build_options) native = builder files = _builder_files(builder) except Exception as exc: warnings.append(f"Occam2D preparation failed: {exc}") status = "needs_review" else: files = _configured_files(workdir, occam_cfg, cfg.backend_options) runner_options = dict(cfg.backend_options.get("runner", {})) missing = _missing_runner_files(files) runner = OccamRunner( workdir, binary_path=runner_options.get( "binary_path", cfg.backend_options.get("binary_path") ), startup_file=runner_options.get( "startup_file", occam_cfg.startup_file ), ) native = native or runner if missing: warnings.append( "Occam2D runner not ready; missing files: " + ", ".join(missing) ) else: binary_name = str( runner_options.get( "binary_path", cfg.backend_options.get( "binary_path", occam_cfg.binary_name ), ) ) command = _command_string(binary_name, runner.startup_file) status = "ready" if status == "prepared" else status if cfg.run_external: try: exit_code = runner.run( max_iter=runner_options.get("max_iter", None), target_misfit=runner_options.get( "target_misfit", None ), auto_compile=bool( runner_options.get("auto_compile", False) ), ) native = runner executed = True if exit_code == 0: status = "executed" else: status = "needs_review" warnings.append( f"Occam2D exited with code {exit_code}; see runner logs." ) except Exception as exc: warnings.append(f"Occam2D runner failed: {exc}") status = "needs_review" rms = float("nan") try: loaded = OccamResult(workdir, config=occam_cfg) if getattr(loaded, "rho_2d", None) is not None: native = loaded status = "loaded" rms = float(getattr(loaded, "final_rms", float("nan"))) except Exception as exc: warnings.append(f"Occam2D result loading skipped: {exc}") return InversionResult( method=cfg.method, dimension=cfg.dimension, backend=self.name, status=status, data=source, rms=rms, workdir=str(workdir), files=files, native=native, warnings=warnings, metadata={ **cfg.metadata, "engine": "occam2d", "run_external": cfg.run_external, "executed": executed, "exit_code": exit_code, "command": command, "binary": occam_cfg.binary_name, "startup_file": occam_cfg.startup_file, }, )
def _occam_config(config_cls: Any, cfg: Any) -> Any: raw = cfg.backend_options.get("config") if raw is None: values = { key: value for key, value in cfg.backend_options.items() if key in getattr(config_cls, "__dataclass_fields__", {}) } return config_cls(**values) if isinstance(raw, config_cls): return raw if isinstance(raw, dict): return config_cls(**raw) return raw def _build_options(options: dict[str, Any]) -> dict[str, Any]: excluded = {"config", "runner", "files", "binary_path"} config_fields = { "data_file", "mesh_file", "model_file", "startup_file", "binary_name", } return { key: value for key, value in options.items() if key not in excluded and key not in config_fields } def _builder_files(builder: Any) -> dict[str, str]: files: dict[str, str] = {} defaults = { "data": builder.config.data_file, "mesh": builder.config.mesh_file, "model": builder.config.model_file, "startup": builder.config.startup_file, } for key, obj in { "data": builder.data, "mesh": builder.mesh, "model": builder.model, "startup": builder.startup, }.items(): path = getattr(obj, "path", None) if path is None: path = builder.workdir / defaults[key] files[key] = str(path) return files def _configured_files( workdir: Path, occam_cfg: Any, options: dict[str, Any] ) -> dict[str, str]: raw = dict(options.get("files", {})) defaults = { "data": occam_cfg.data_file, "mesh": occam_cfg.mesh_file, "model": occam_cfg.model_file, "startup": occam_cfg.startup_file, } for role, name in defaults.items(): raw.setdefault(role, name) return { role: str(_resolve_workdir_path(path, workdir)) for role, path in raw.items() } def _missing_runner_files(files: dict[str, str]) -> list[str]: missing: list[str] = [] for key in ("data", "mesh", "model", "startup"): path = files.get(key) if path is None: missing.append(key) elif not Path(path).exists(): missing.append(f"{key}={path}") return missing def _command_string(binary: str, startup_file: str) -> str: return " ".join(shlex.quote(part) for part in [binary, startup_file]) def _resolve_workdir_path(path: Any, workdir: Path) -> Path: p = Path(path) return p if p.is_absolute() else workdir / p Occam2DBackend.__doc__ = r""" Adapt PyCSAMT inversion workflows to Occam2D. ``Occam2DBackend`` is the external-run adapter for smooth 2-D EM inversion with Occam2D-style workflows. It uses :mod:`pycsamt.models.occam2d` to prepare input files, assemble runner commands, optionally launch the external executable, and load completed model sections. The actual inversion physics are provided by Occam2D, while PyCSAMT keeps a consistent configuration and result interface. The adapter supports MT, AMT, CSAMT, and EMAP in 2-D mode. Its main purpose is to make external Occam2D projects reproducible from :class:`pycsamt.inversion.config.InversionConfig` and to return a common :class:`pycsamt.inversion.results.InversionResult`. Parameters ---------- config : pycsamt.inversion.config.InversionConfig Inversion configuration. Important fields are ``method``, ``dimension``, ``workdir``, ``data``, ``backend_options``, ``run_external``, ``error_floor``, ``phase_error``, and ``metadata``. Attributes ---------- name : str Backend registry name, always ``"occam2d"``. supports : tuple of tuple Supported ``(method, dimension)`` pairs. Notes ----- ``backend_options`` can contain: ``config`` An ``OccamConfig`` instance or dictionary used to configure file names and binary settings. ``files`` Mapping from roles ``"data"``, ``"mesh"``, ``"model"``, and ``"startup"`` to existing or expected paths. Relative paths are resolved inside ``workdir``. ``runner`` Runner-only options such as ``binary_path``, ``startup_file``, ``max_iter``, ``target_misfit``, and ``auto_compile``. ``binary_path`` Convenience runner executable path when not supplied inside ``runner``. When a data source is supplied, ``InputBuilder`` receives the source and the configured error floors. Without a data source, the adapter treats ``backend_options["files"]`` as an existing Occam2D project and only validates whether it is ready to run or load. Examples -------- Validate an existing Occam2D directory without executing the binary:: >>> from pycsamt.inversion.backends.occam2d import Occam2DBackend >>> from pycsamt.inversion.config import InversionConfig >>> cfg = InversionConfig( ... method="mt", ... dimension="2d", ... backend="occam2d", ... workdir="occam_run", ... backend_options={ ... "files": { ... "data": "OccamData.dat", ... "mesh": "OccamMesh", ... "model": "OccamModel", ... "startup": "Startup", ... } ... }, ... ) >>> result = Occam2DBackend(cfg).run() # doctest: +SKIP >>> result.status in {"ready", "loaded", "needs_review"} # doctest: +SKIP True Run through the high-level workflow and allow external execution:: >>> from pycsamt.inversion.workflow import run_inversion >>> result = run_inversion( ... method="csamt", ... dimension="2d", ... backend="occam2d", ... workdir="occam_csamt", ... run_external=True, ... backend_options={ ... "runner": { ... "binary_path": "Occam2D", ... "startup_file": "Startup", ... "target_misfit": 1.0, ... }, ... "files": { ... "data": "OccamData.dat", ... "mesh": "OccamMesh", ... "model": "OccamModel", ... "startup": "Startup", ... }, ... }, ... ) # doctest: +SKIP See Also -------- pycsamt.inversion.workflow.InversionWorkflow High-level entry point that instantiates this backend. pycsamt.inversion.backends.modem.ModEMBackend Similar external-run adapter for ModEM. pycsamt.inversion.results.InversionResult Backend-neutral result returned by :meth:`run`. References ---------- .. [1] deGroot-Hedlin, C. and Constable, S. (1990). Occam's inversion to generate smooth, two-dimensional models from magnetotelluric data. *Geophysics*, 55(12), 1613-1624. .. [2] Constable, S. C., Parker, R. L. and Constable, C. G. (1987). Occam's inversion: A practical algorithm for generating smooth models from electromagnetic sounding data. *Geophysics*, 52(3), 289-300. """