diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 408c6a7a..a517de05 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -39,31 +39,38 @@ jobs: - python-version: "3.9" name: minimal os: ubuntu - conda: "'scipy=1.9' 'numba=0.53'" + conda: "'scipy=1.9' 'numba=0.53' 'numpy<2.0' 'empymod>=2.3'" + pip: "" - python-version: "3.10" name: full os: ubuntu - conda: "numba scipy xarray h5py discretize matplotlib" # tqdm + conda: "numba scipy xarray h5py discretize matplotlib 'numpy<2.0' 'empymod>=2.3'" # tqdm + pip: "" - python-version: "3.10" name: plain os: ubuntu - conda: "numba scipy" + conda: "numba scipy 'numpy<2.0' 'empymod>=2.3'" + pip: "" - python-version: "3.11" name: plain os: ubuntu - conda: "numba scipy" + conda: "numba scipy 'numpy<2.0' 'empymod>=2.3'" + pip: "" - python-version: "3.11" name: full os: ubuntu - conda: "numba scipy xarray tqdm h5py discretize matplotlib" + conda: "numba scipy xarray tqdm h5py discretize matplotlib 'numpy<2.0' 'empymod>=2.3'" + pip: "pygimli" - python-version: "3.12" name: plain os: ubuntu - conda: "numba scipy" + conda: "numba scipy 'numpy<2.0' 'empymod>=2.3'" + pip: "" - python-version: "3.12" name: full os: ubuntu - conda: "numba scipy xarray tqdm h5py discretize matplotlib" + conda: "numba scipy xarray tqdm h5py discretize matplotlib 'numpy<2.0' 'empymod>=2.3'" + pip: "" env: # Used for coveralls flag @@ -105,7 +112,8 @@ jobs: conda config --show-sources conda config --show conda info -a - conda install ${{ matrix.case.conda }} pytest pytest-cov pytest-console-scripts coveralls flake8 setuptools-scm + conda install -c conda-forge ${{ matrix.case.conda }} pip pytest pytest-cov pytest-console-scripts coveralls flake8 setuptools-scm + pip install ${{ matrix.case.pip }} - name: Conda list shell: bash -l {0} @@ -118,7 +126,7 @@ jobs: - name: Test with pytest shell: bash -l {0} run: | - python -m pip install . + python -m pip install --no-build-isolation --no-deps . pytest --cov=emg3d - name: Coveralls diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4838efd0..216f1bd5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,14 @@ Changelog """""""""" +latest +------ + +- New module ``inversion``: + + - pyGIMLi(emg3d) + + v1.8.3 : tol_gradient isfinite ------------------------------ diff --git a/docs/api/index.rst b/docs/api/index.rst index 4c03f995..adb52fe1 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -27,6 +27,7 @@ API reference surveys time utils + inversion/index .. grid:: 1 diff --git a/docs/api/inversion/index.rst b/docs/api/inversion/index.rst new file mode 100644 index 00000000..16d9d4a3 --- /dev/null +++ b/docs/api/inversion/index.rst @@ -0,0 +1,21 @@ +.. _inversionapi: + +Inversion +######### + +.. automodapi:: emg3d.inversion + :no-inheritance-diagram: + :no-heading: + +.. toctree:: + :maxdepth: 1 + :hidden: + + pygimli + +.. grid:: 1 + :gutter: 2 + + .. grid-item-card:: + + pyGIMLi(emg3d): :mod:`emg3d.inversion.pygimli` diff --git a/docs/api/inversion/pygimli.rst b/docs/api/inversion/pygimli.rst new file mode 100644 index 00000000..937341ff --- /dev/null +++ b/docs/api/inversion/pygimli.rst @@ -0,0 +1,6 @@ +pyGIMLi(emg3d) +============== + +.. automodapi:: emg3d.inversion.pygimli + :no-inheritance-diagram: + :no-heading: diff --git a/docs/conf.py b/docs/conf.py index 8c4c3a74..8252e5dd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -39,6 +39,7 @@ "empymod": ("https://empymod.emsig.xyz/en/stable", None), "xarray": ("https://docs.xarray.dev/en/stable", None), "numba": ("https://numba.readthedocs.io/en/stable", None), + "pygimli": ("https://www.pygimli.org", None), } # ==== 2. General Settings ==== diff --git a/docs/manual/installation.rst b/docs/manual/installation.rst index b620bf82..471f3c0c 100644 --- a/docs/manual/installation.rst +++ b/docs/manual/installation.rst @@ -39,6 +39,9 @@ or via ``pip``: pip install emg3d[full] +The wrappers provided in :mod:`emg3d.inversion` may require additional +packages, please consult the :ref:`inversionapi`-API for more information. + If you are new to Python we recommend using a Python distribution, which will ensure that all dependencies are met, specifically properly compiled versions of ``NumPy`` and ``SciPy``; we recommend using `Anaconda diff --git a/emg3d/__init__.py b/emg3d/__init__.py index 55a64a5b..8d182543 100644 --- a/emg3d/__init__.py +++ b/emg3d/__init__.py @@ -22,6 +22,7 @@ RxElectricPoint, RxMagneticPoint, ) from emg3d.fields import Field, get_source_field, get_magnetic_field +from emg3d import inversion from emg3d.io import save, load, convert from emg3d.meshes import TensorMesh, construct_mesh from emg3d.models import Model diff --git a/emg3d/_multiprocessing.py b/emg3d/_multiprocessing.py index 1fb095e3..f6eb5aa8 100644 --- a/emg3d/_multiprocessing.py +++ b/emg3d/_multiprocessing.py @@ -43,6 +43,7 @@ def process_map(fn, *iterables, max_workers, **kwargs): execution. """ + process_map.count += 1 # Parallel if max_workers > 1 and tqdm is None: @@ -64,6 +65,10 @@ def process_map(fn, *iterables, max_workers, **kwargs): iterable=map(fn, *iterables), total=len(iterables[0]), **kwargs)) +# Counter for processing map (used, e.g., for inversions). +process_map.count = 0 + + def solve(inp): """Thin wrapper of `solve` or `solve_source` for a `process_map`. diff --git a/emg3d/inversion/__init__.py b/emg3d/inversion/__init__.py new file mode 100644 index 00000000..0ee0bcb7 --- /dev/null +++ b/emg3d/inversion/__init__.py @@ -0,0 +1,43 @@ +""" +The inversion submodule of emg3d provides wrapper functionalities to use emg3d +as a forward modelling kernel within third-party inversion frameworks. These +third-party libraries are not included in emg3d, you have to install them +Separately. + +Currently implemented wrappers and their corresponding requirements: + +- pyGIMLi(emg3d): Requires the *Geophysical Inversion & Modelling + Library* `pyGIMLi `_. +""" +# Copyright 2024 The emsig community. +# +# This file is part of emg3d. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy +# of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +import importlib as _importlib + + +submodules = [ + 'pygimli', +] + +__all__ = submodules + + +def __dir__(): + return __all__ + + +def __getattr__(name): + if name in submodules: + return _importlib.import_module(f"emg3d.inversion.{name}") diff --git a/emg3d/inversion/pygimli.py b/emg3d/inversion/pygimli.py new file mode 100644 index 00000000..b4d7f529 --- /dev/null +++ b/emg3d/inversion/pygimli.py @@ -0,0 +1,342 @@ +""" +Thin wrappers to use emg3d as a forward modelling kernel within the +*Geophysical Inversion & Modelling Library* `pyGIMLi `_. + +It deals mainly with converting the data and model from the emg3d format to the +pyGIMLi format and back, and creating the correct classes and functions as +expected by a pyGIMLi inversion. +""" +# Copyright 2024 The emsig community. +# +# This file is part of emg3d. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy +# of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +import numpy as np + +from emg3d import utils, _multiprocessing + +try: + import pygimli + # Add pygimli and pgcore to the emg3d.Report(). + utils.OPTIONAL.extend(['pygimli', 'pgcore']) +except ImportError: + pygimli = None + +__all__ = ['Kernel', 'Inversion'] + + +def __dir__(): + return __all__ + + +class Kernel(pygimli.Modelling if pygimli else object): + """Create a forward operator of emg3d to use within a pyGIMLi inversion. + + + Parameters + ---------- + simulation : Simulation + The simulation; a :class:`emg3d.simulations.Simulation` instance. + + markers : ndarray of dtype int, default: None + An ndarray of integers of the same shape as the model. All cells with + the same number belong to the same region with this number, which can + subsequently be defined through + :func:`pygimli.frameworks.modelling.Modelling.setRegionProperties`. + + pgthreads : int, default: 2 + Number of threads for pyGIMLi (sets ``OPENBLAS_NUM_THREADS``). This is + by default a small number, as the important parallelization in + pyGIMLi(emg3d) happens over sources and frequencies in emg3d. This is + controlled in the parameter ``max_workers`` when creating the + simulation. + + """ + + @utils._requires('pygimli') + def __init__(self, simulation, markers=None, pgthreads=2): + """Initialize a pyGIMLi(emg3d)-wrapper.""" + super().__init__() + + # Set pyGIMLi threads. + pygimli.setThreadCount(pgthreads) + + # Check current limitations. + checks = { + 'case': (simulation.model.case, 'isotropic'), + 'mapping': (simulation.model.map.name, 'Conductivity'), + } + for k, v in checks.items(): + if v[0] != v[1]: + msg = f"pyGIMLi(emg3d) is not implemented for {v[0]} {k}." + raise NotImplementedError(msg) + + # Store the simulation. + self.simulation = simulation + + # Translate discretize TensorMesh to pygimli-Grid. + mesh = pygimli.createGrid( + x=simulation.model.grid.nodes_x, + y=simulation.model.grid.nodes_y, + z=simulation.model.grid.nodes_z, + ) + + # Set markers. + if markers is not None: + self.markers = markers + else: + self.markers = np.arange(simulation.model.size, dtype=int) + mesh.setCellMarkers(self.markers.ravel('F')) + # Store original props; required if a region is set to ``background``. + self._model = simulation.model.property_x.copy() + # Store volumes; required if a region is set to ``single``. + self._volumes = simulation.model.grid.cell_volumes.reshape( + self._model.shape, order='F') + # Set mesh. + self.setMesh(mesh) + self._fullmodel = None + + # Create J, store and set it. + self.J = self.Jacobian( + simulation=self.simulation, + data2gimli=self.data2gimli, + data2emg3d=self.data2emg3d, + model2gimli=self.model2gimli, + model2emg3d=self.model2emg3d, + ) + self.setJacobian(self.J) + + def response(self, model): + """Create synthetic data for provided pyGIMLi model.""" + + # Clean emg3d-simulation, so things are recomputed + self.simulation.clean('computed') + + # Replace model + self.simulation.model.property_x = self.model2emg3d(model) + + # Compute forward model and set initial residuals. + _ = self.simulation.misfit + + # Return the responses as pyGIMLi array + return self.data2gimli(self.simulation.data.synthetic.data) + + def createStartModel(self, dataVals=None): + """Returns the model from the provided simulation.""" + return self.model2gimli(self.simulation.model.property_x) + + def createJacobian(self, model): + """Dummy to prevent pyGIMLi from doing it the hard way.""" + + def data2gimli(self, data): + """Convert an emg3d data-xarray to a pyGIMLi data array.""" + out = data[self.simulation.survey.isfinite] + if np.iscomplexobj(out): + return np.hstack((out.real, out.imag)) + else: # For standard deviation + return np.hstack((out, out)) + + def data2emg3d(self, data): + """Convert a pyGIMLi data array to an emg3d data-xarray.""" + out = np.ones( + self.simulation.survey.shape, + dtype=self.simulation.data.observed.dtype + )*np.nan + data = np.asarray(data) + ind = data.size//2 + out[self.simulation.survey.isfinite] = data[:ind] + 1j*data[ind:] + return out + + def model2gimli(self, model): + """Convert an emg3d Model property to a pyGIMLi model array. + + This function deals with the regions defined in pyGIMLi. + """ + + # If the inversion model is smaller than the model, we have to + # take care of the regions. + if self.fullmodel: + + out = np.empty(model.size) + out[self.mesh().cellMarkers()] = model.ravel('F') + + else: + + out = np.empty(self.simulation.model.size) + i = 0 + + for n, v in self.regionProperties().items(): + ni = self.markers == n + if v['background'] or v['fix']: + ii = 0 + elif v['single']: + ii = 1 + out[i] = np.average(model[ni], weights=self._volumes[ni]) + else: + ii = np.sum(ni) + out[i:i+ii] = model[ni] + i += ii + + out = out[:i] + + return out + + def model2emg3d(self, model): + """Convert a pyGIMLi model array to an emg3d Model property. + + This function deals with the regions defined in pyGIMLi. + """ + + # If the inversion model is smaller than the model, we have to + # take care of the regions. + if self.fullmodel: + + out = np.asarray(model[self.mesh().cellMarkers()]).reshape( + self.simulation.model.shape, order='F') + + else: + + out = np.empty(self.simulation.model.shape) + i = 0 + + for n, v in self.regionProperties().items(): + ni = self.markers == n + if v['background']: + ii = 0 + out[ni] = self._model[ni] + elif v['fix']: + ii = 0 + out[ni] = v['startModel'] + elif v['single']: + ii = 1 + out[ni] = model[i] + else: + ii = np.sum(ni) + out[ni] = model[i:ii+i] + i += ii + + return out + + @property + def fullmodel(self): + """Flag if the full model is used for the inversion or not.""" + if self._fullmodel is None: + self._fullmodel = True + if self.regionProperties(): + keys = ['background', 'fix', 'single'] + for v in self.regionProperties().values(): + if np.any([v[k] is True for k in keys]): + self._fullmodel = False + break + + return self._fullmodel + + class Jacobian(pygimli.Matrix if pygimli else object): + """Return Jacobian operator for pyGIMLi(emg3d).""" + + def __init__(self, simulation, + data2gimli, data2emg3d, model2gimli, model2emg3d): + """Initiate a new Jacobian instance.""" + super().__init__() + self.simulation = simulation + self.data2gimli = data2gimli + self.data2emg3d = data2emg3d + self.model2gimli = model2gimli + self.model2emg3d = model2emg3d + + def cols(self): + """The number of columns corresponds to the model size.""" + return self.simulation.model.size + + def rows(self): + """The number of rows corresponds to 2x data-size (Re; Im).""" + return self.simulation.survey.count * 2 + + def mult(self, x): + """Multiply the Jacobian with a vector, Jm.""" + jvec = self.simulation.jvec(vector=self.model2emg3d(x)) + return self.data2gimli(jvec) + + def transMult(self, x): + """Multiply Jacobian transposed with a vector, Jᵀd = (dJᵀ)ᵀ.""" + jtvec = self.simulation.jtvec(self.data2emg3d(x)) + return self.model2gimli(jtvec) + + def save(self, *args): + """There is no save for this pseudo-Jacobian.""" + + +@utils._requires('pygimli') +class Inversion(pygimli.Inversion if pygimli else object): + """Thin wrapper, adding verbosity and taking care of data format.""" + + @utils._requires('pygimli') + def __init__(self, fop=None, inv=None, **kwargs): + """Initialize an Inversion instance.""" + super().__init__(fop=fop, inv=inv, **kwargs) + self._postStep = _post_step + + def run(self, dataVals=None, errorVals=None, **kwargs): + """Run the inversion.""" + + # Reset counter, start timer, print message. + _multiprocessing.process_map.count = 0 + timer = utils.Timer() + pygimli.info(":: pyGIMLi(emg3d) START ::") + + # Take data from the survey if not provided. + if dataVals is None: + dataVals = self.fop.data2gimli( + self.fop.simulation.data.observed.data) + + # Take the error from the survey if not provided. + if errorVals is None: + std_dev = self.fop.data2gimli( + self.fop.simulation.survey.standard_deviation.data) + errorVals = std_dev / abs(dataVals) + + # Reset full-model flag. + self.fop._fullmodel = None + + # Run the inversion + out = super().run(dataVals=dataVals, errorVals=errorVals, **kwargs) + + # Print passed time and exit + pygimli.info(f":: pyGIMLi(emg3d) END :: runtime = {timer.runtime}") + + return out + + +def _post_step(n, inv): + """Print some values for each iteration.""" + + # Print info + sim = inv.fop.simulation + sim.survey.data[f"it{n}"] = sim.survey.data.synthetic + phi = inv.inv.getPhi() + if not hasattr(inv, 'lastphi'): + lastphi = "" + else: + lastphi = f"; Δϕ = {(1-phi/inv.lastphi)*100:.2f}%" + inv.lastphi = phi + pygimli.info( + f"{n}: " + f"χ² = {inv.inv.chi2():7.2f}; " + f"λ = {inv.inv.getLambda()}; " + f"{_multiprocessing.process_map.count:2d} kernel calls; " + f"ϕ = {inv.inv.getPhiD():.2f} + {inv.inv.getPhiM():.2f}·λ = " + f"{phi:.2f}{lastphi}" + ) + + # Reset counter + _multiprocessing.process_map.count = 0 diff --git a/emg3d/utils.py b/emg3d/utils.py index 356707dc..cc64b3e0 100644 --- a/emg3d/utils.py +++ b/emg3d/utils.py @@ -42,6 +42,8 @@ __all__ = ['Report', 'EMArray', 'Timer'] +OPTIONAL = ['xarray', 'discretize', 'h5py', 'matplotlib', 'tqdm', 'IPython'] + def __dir__(): return __all__ @@ -158,8 +160,7 @@ def __init__(self, add_pckg=None, ncol=3, text_width=80, sort=False): core = ['numpy', 'scipy', 'numba', 'emg3d', 'empymod'] # Optional packages. - optional = ['xarray', 'discretize', 'h5py', 'matplotlib', - 'tqdm', 'IPython'] + optional = OPTIONAL super().__init__(additional=add_pckg, core=core, optional=optional, ncol=ncol, text_width=text_width, sort=sort) diff --git a/requirements-dev.txt b/requirements-dev.txt index 43002fea..f0bd1181 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,6 +7,7 @@ h5py xarray discretize matplotlib +pygimli # SETUP RELATED setuptools_scm diff --git a/setup.py b/setup.py index d7a0d3ca..504fd6c0 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ author_email="info@emsig.xyz", url="https://emsig.xyz", license="Apache-2.0", - packages=["emg3d", "emg3d.cli"], + packages=["emg3d", "emg3d.inversion", "emg3d.cli"], classifiers=[ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: Apache Software License", diff --git a/tests/test_pygimli.py b/tests/test_pygimli.py new file mode 100644 index 00000000..f39359ee --- /dev/null +++ b/tests/test_pygimli.py @@ -0,0 +1,195 @@ +import pytest +import logging +import numpy as np +from numpy.testing import assert_allclose + +import emg3d +from emg3d import inversion +from emg3d.inversion import pygimli as ipygimli + + +# Soft dependencies +try: + import xarray +except ImportError: + xarray = None +try: + import discretize +except ImportError: + discretize = None +try: + import pygimli +except ImportError: + pygimli = None + + +LOGGER = logging.getLogger(__name__) + + +@pytest.mark.skipif(xarray is None, reason="xarray not installed.") +class TestPygimli(): + + if xarray is not None: + # Create data + survey = emg3d.surveys.Survey( + sources=emg3d.TxElectricDipole((0, 0, -250, 0, 0)), + receivers=emg3d.RxElectricPoint((0, 0, -1250, 0, 0)), + frequencies=1.0, + noise_floor=1e-17, + relative_error=0.05, + ) + + hx = np.ones(3)*500.0 + grid = emg3d.TensorMesh([hx, hx, hx], [-750, -750, -1500]) + + model_start = emg3d.Model(grid, 1.0, mapping='Conductivity') + model_true = emg3d.Model(grid, 1.0, mapping='Conductivity') + model_true.property_x[1, 1, 1] = 1/1000 + + # Create an emg3d Simulation instance + sim = emg3d.simulations.Simulation( + survey=survey.copy(), + model=model_true, + gridding='both', + max_workers=1, + gridding_opts={'center_on_edge': False}, + receiver_interpolation='linear', + solver_opts={'tol_gradient': 1e-2}, + tqdm_opts=False, + ) + sim.compute(observed=True) + synthetic = sim.survey.data.synthetic.copy() + sim.clean('computed') + + sim.model = model_start + + sim.compute() + sim.survey.data['start'] = sim.survey.data.synthetic + sim.clean('computed') + + markers = np.zeros(sim.model.shape, dtype=int) + markers[1, 1, 1] = 1 + markers[0, :, :] = 2 + markers[2, :, :] = 3 + + def set_regions(self, fop): + fop.setRegionProperties(1, limits=(0.0001, 2), startModel=1.0) + fop.setRegionProperties(0, background=True) + fop.setRegionProperties(2, fix=True, startModel=1) + fop.setRegionProperties( + 3, single=True, limits=(0.99999, 1.00001), startModel=1.0) + + @pytest.mark.skipif(pygimli is None, reason="pygimli not installed.") + def test_Kernel_errors(self): + + sim = self.sim.copy() + sim.model = emg3d.Model(sim.model.grid, mapping='Resistivity') + with pytest.raises(NotImplementedError, match='for Resistivity'): + _ = ipygimli.Kernel(simulation=sim) + + sim.model = emg3d.Model(sim.model.grid, 1, 2) + with pytest.raises(NotImplementedError, match='for HTI'): + _ = ipygimli.Kernel(simulation=sim) + + @pytest.mark.skipif(pygimli is None, reason="pygimli not installed.") + def test_Kernel_markers(self): + + # No regions + sim = self.sim.copy() + fop = ipygimli.Kernel(simulation=sim) + assert_allclose(fop.markers, np.arange(sim.model.size, dtype=int)) + assert fop.fullmodel is True + + # Regions + fop = ipygimli.Kernel(simulation=sim, markers=self.markers) + self.set_regions(fop) + assert_allclose(fop.markers, self.markers) + assert fop.fullmodel is False + + @pytest.mark.skipif(pygimli is None, reason="pygimli not installed.") + def test_Kernel_conversions(self): + sim = self.sim.copy() + fop1 = ipygimli.Kernel(simulation=sim) + fop2 = ipygimli.Kernel(simulation=sim, markers=self.markers) + self.set_regions(fop2) + + for fop in [fop1, fop2]: + + assert_allclose( + fop.model2emg3d(fop.createStartModel()), + sim.model.property_x + ) + + assert_allclose( + sim.data.observed, + fop.data2emg3d(fop.data2gimli(sim.data.observed.data)) + ) + + assert_allclose( + sim.model.property_x, + fop.model2emg3d(fop.model2gimli(sim.model.property_x)) + ) + data = sim.survey.standard_deviation.data + assert fop.data2gimli(data).dtype == np.float64 + + @pytest.mark.skipif(pygimli is None, reason="pygimli not installed.") + def test_Kernel_response(self): + sim = self.sim.copy() + sim.model = self.model_true + fop = ipygimli.Kernel(simulation=sim) + assert_allclose( + fop.data2emg3d(fop.response(fop.createStartModel())), + self.synthetic.data + ) + + @pytest.mark.skipif(pygimli is None, reason="pygimli not installed.") + @pytest.mark.skipif(discretize is None, reason="discretize not installed.") + def test_Inversion_noregions(self, caplog): + # Mainly "runtest" + sim = self.sim.copy() + + fop = ipygimli.Kernel(simulation=sim, pgthreads=1) + + INV = ipygimli.Inversion(fop=fop) + INV.inv.setCGLSTolerance(10) + INV.inv.setMaxCGLSIter(30) + + _ = INV.run(maxIter=2, lam=0.1) + + assert 'pyGIMLi(emg3d)' in caplog.text + assert 'Created startmodel from forward operator: 27' in caplog.text + assert 'λ = 0.1' in caplog.text + + assert INV.inv.chi2() < 1 + + @pytest.mark.skipif(pygimli is None, reason="pygimli not installed.") + @pytest.mark.skipif(discretize is None, reason="discretize not installed.") + def test_Inversion_regions(self, caplog): + # Mainly "runtest" + sim = self.sim.copy() + + fop = ipygimli.Kernel( + simulation=sim, markers=self.markers, pgthreads=1) + + INV = ipygimli.Inversion(fop=fop) + INV.inv.setCGLSTolerance(10) + INV.inv.setMaxCGLSIter(30) + + INV.fop.setRegionProperties(1, limits=(0.0001, 2), startModel=1.0) + INV.fop.setRegionProperties(0, background=True) + INV.fop.setRegionProperties(2, fix=True, startModel=1) + INV.fop.setRegionProperties( + 3, single=True, limits=(0.99999, 1.00001), startModel=1.0) + + _ = INV.run(maxIter=2, lam=1) + + assert 'pyGIMLi(emg3d)' in caplog.text + assert 'Created startmodel from region infos: 2' in caplog.text + assert 'λ = 1.0' in caplog.text + + assert INV.inv.chi2() < 1 + + +def test_all_dir(): + assert set(inversion.__all__) == set(dir(inversion)) + assert set(ipygimli.__all__) == set(dir(ipygimli)) diff --git a/tests/test_utils.py b/tests/test_utils.py index b4ec4796..5eab52e7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,6 +5,7 @@ import scooby from emg3d import utils +from emg3d.inversion import pygimli as ipygimli def test_known_class(): @@ -30,13 +31,18 @@ def dummy(): def test_Report(capsys): out, _ = capsys.readouterr() # Empty capsys + if ipygimli.pygimli is None: + add = [] + else: + add = ['pygimli', 'pgcore'] + # Reporting is now done by the external package scooby. # We just ensure the shown packages do not change (core and optional). out1 = utils.Report() out2 = scooby.Report( - core=['numpy', 'scipy', 'numba', 'emg3d'], + core=['numpy', 'scipy', 'numba', 'emg3d', 'empymod'], optional=['empymod', 'xarray', 'discretize', 'h5py', - 'matplotlib', 'tqdm', 'IPython'], + 'matplotlib', 'tqdm', 'IPython'] + add, ncol=4) # Ensure they're the same; exclude time to avoid errors.