Source code for emg3d.electrodes

"""
Electrodes define any type of sources and receivers used in a survey.
"""
# Copyright 2018 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.

from copy import deepcopy

import numpy as np
import scipy as sp

from emg3d import fields, utils

__all__ = [
    'Wire', 'Point', 'Dipole', 'Source',
    'TxElectricPoint', 'TxMagneticPoint',
    'TxElectricDipole', 'TxMagneticDipole',
    'TxElectricWire',
    'RxElectricPoint', 'RxMagneticPoint',
    'rotation', 'point_to_dipole', 'dipole_to_point', 'point_to_square_loop',
]


def __dir__():
    return __all__


# BASE ELECTRODE TYPES
[docs] class Wire: """A wire consists of an arbitrary number of electrodes. .. note:: Use any of the Tx*/Rx* classes to create sources and receivers, not this class. Parameters ---------- coordinates : array_like Electrode locations of shape (n, 3), where n is the number of electrodes: ``[[x1, y1, z1], [...], [xn, yn, zn]]``. """ # Attributes which are stored/required with to_dict/from_dict. _serialize = {'coordinates'} def __init__(self, coordinates): """Initiate an electrode.""" # Cast and check dimension and shape. self._points = np.asarray(np.atleast_2d(coordinates), dtype=float) if not (self._points.ndim == 2 and self._points.shape[1] == 3): raise ValueError( "`coordinates` must be of shape (x, 3), provided: " f"{coordinates}" ) def __eq__(self, electrode): """Compare two electrodes.""" # Check if same Type. equal = self.__class__.__name__ == electrode.__class__.__name__ # Check input. if equal: for name in self._serialize: comp = getattr(self, name) if isinstance(comp, np.ndarray): equal *= np.allclose(comp, getattr(electrode, name)) else: equal *= comp == getattr(electrode, name) return bool(equal) def __repr__(self): """Simple representation.""" s0 = (f"{self.__class__.__name__}: " f"{self._repr_add if hasattr(self, '_repr_add') else ''}\n") s1 = (f" center={{{self.center[0]:,.1f}; " f"{self.center[1]:,.1f}; {self.center[2]:,.1f}}} m; ") s2 = (f"n={self.segment_n}; l={self.length:,.1f} m") return s0 + s1 + s2 if len(s1+s2) < 80 else s0 + s1 + "\n " + s2
[docs] def copy(self): """Return a copy of the Survey.""" return self.from_dict(self.to_dict(True))
[docs] def to_dict(self, copy=False): """Store the necessary information of the Electrode in a dict. Parameters ---------- copy : bool, default: False If True, returns a deep copy of the dict. Returns ------- out : dict Dictionary containing all information to re-create the Electrode. """ out = { '__class__': self.__class__.__name__, **{prop: getattr(self, prop) for prop in self._serialize}, } if copy: return deepcopy(out) else: return out
[docs] @classmethod def from_dict(cls, inp): """Convert dictionary into its class instance. Parameters ---------- inp : dict Dictionary as obtained from the classes' ``to_dict``. Returns ------- electrode : {Tx*, Rx*} A source or receiver instance. """ return cls(**{k: v for k, v in inp.items() if k != '__class__'})
@property def points(self): """Electrode locations (n, 3).""" return self._points @property def coordinates(self): """Electrode coordinate as accepted by its class.""" if hasattr(self, '_coordinates'): return self._coordinates else: return self._points @property def xtype(self): """Flag of the type of electrodes. In reality, all electrodes are electric. But we do idealize some loops as theoretical "magnetic dipoles" (TxMagneticDipole, RxMagneticPoint). ``xtype`` is a flag for this. """ if not hasattr(self, '_xtype'): if 'Magnetic' in self.__class__.__name__: self._xtype = 'magnetic' else: # Default self._xtype = 'electric' return self._xtype @property def center(self): """Center point of all unique electrodes.""" if not hasattr(self, '_center'): self._center = np.unique(self.points, axis=0).mean(axis=0) return self._center @property def length(self): """Total length of all dipole segments formed by the electrodes.""" if not hasattr(self, '_length'): lengths = np.linalg.norm(np.diff(self.points, axis=0), axis=1) self._segment_lengths = lengths self._length = lengths.sum() return self._length @property def segment_lengths(self): """Length of each individual dipole segment in the wire.""" if not hasattr(self, '_segment_lengths'): _ = self.length # Sets length and segment_lengths return self._segment_lengths @property def segment_n(self): """Number of dipole segments in the wire.""" return len(self.segment_lengths) @property def _prefix(self): """Prefix used for collecting Tx/Rx in Surveys.""" name = self.__class__.__name__ return name[:2] + ''.join(c for c in name if c.isupper())[1:]
[docs] class Point(Wire): """A point electrode is defined by its center, azimuth, and elevation. A ``Point`` is a special case of a ``Wire`` that consists of only one electrode, and has therefore no length (infinitesimal small dipole). It is principally used by receivers to sample the field at a given point. .. note:: Use any of the Tx*/Rx* classes to create sources and receivers, not this class. Parameters ---------- coordinates : array_like Point defined as (x, y, z, azimuth, elevation) """ def __init__(self, coordinates): """Initiate an electric point.""" if len(coordinates) != 5: raise ValueError( "Point coordinates are wrong defined. They must be " "defined as (x, y, z, azimuth, elevation)." f"Provided coordinates: {coordinates}." ) # Cast and store input coordinates. self._coordinates = np.asarray(coordinates, dtype=np.float64).squeeze() # Provide center to `Electrode`. super().__init__(coordinates[:3]) def __repr__(self): """Simple representation.""" s0 = (f"{self.__class__.__name__}: " f"{self._repr_add if hasattr(self, '_repr_add') else ''}\n") s1 = (f" x={self.center[0]:,.1f} m, " f"y={self.center[1]:,.1f} m, z={self.center[2]:,.1f} m, ") s2 = f"θ={self.azimuth:.1f}°, φ={self.elevation:.1f}°" return s0 + s1 + s2 if len(s1+s2) < 80 else s0 + s1 + "\n " + s2 @property def azimuth(self): """Anticlockwise rotation (°) from x-axis towards y-axis.""" return self._coordinates[3] @property def elevation(self): """Anticlockwise (upwards) rotation (°) from the xy-plane.""" return self._coordinates[4]
[docs] class Dipole(Wire): """A dipole consists of two electrodes in a straight line. A ``Dipole`` is a special case of a ``Wire`` that consists of exactly two electrodes. A dipole has therefore an azimuth and an elevation. It corresponds to one segment in a wire. .. note:: Use any of the Tx*/Rx* classes to create sources and receivers, not this class. Parameters ---------- coordinates : array_like Dipole coordinates. Three formats are accepted: - [[x1, y1, z1], [x2, y2, z2]]; - (x1, x2, y1, y2, z1, z2); - (x, y, z, azimuth, elevation); this format takes also the ``length`` parameter. length : float, default: 1.0 Length of the dipole (m). This parameter is only used if the provided coordinates are in the format (x, y, z, azimuth, elevation). """ def __init__(self, coordinates, length=1.0): """Initiate an electric dipole.""" # Cast coordinates. coordinates = np.asarray(coordinates, dtype=np.float64).squeeze() # Check which format was provided. is_point = coordinates.shape == (5, ) is_flat = coordinates.shape == (6, ) is_dipole = coordinates.shape == (2, 3) # Store depending on format. if is_point: # Add length to attributes which have to be serialized. self._serialize = {'length'} | self._serialize # If magnetic, get the loop which area corresponds to length. if self.xtype == 'magnetic': points = point_to_square_loop(coordinates, length) # If electric, get the dipole. else: points = point_to_dipole(coordinates, length) # Store length and original input coordinates. self._length = length self._coordinates = coordinates elif is_flat or is_dipole: if is_flat: # Re-arrange for points. points = coordinates.reshape((2, 3), order='F') # Store original input. self._coordinates = coordinates else: # Input is already in the format for Electrode. points = coordinates # If magnetic, get the loop which area corresponds to its length. if self.xtype == 'magnetic': azimuth, elevation, length = dipole_to_point(points) center = tuple(np.sum(points, 0)/2) coo = (*center, azimuth, elevation) points = point_to_square_loop(coo, length) # Store original input. self._coordinates = coordinates # Ensure the two poles are distinct. if np.allclose(points[0, :], points[1, :]): raise ValueError( "The two electrodes are identical, use the format " "(x, y, z, azimuth, elevation) instead. " f"Provided coordinates: {coordinates}." ) else: raise ValueError( "Coordinates are wrong defined. They must be defined either " "as a point, (x, y, z, azimuth, elevation), or as two points, " "(x1, x2, y1, y2, z1, z2) or [[x1, y1, z1], [x2, y2, z2]]. " f"Provided coordinates: {coordinates}." ) super().__init__(points) def __repr__(self): """Simple representation.""" s0 = (f"{self.__class__.__name__}: " f"{self._repr_add if hasattr(self, '_repr_add') else ''}\n") # Point dipole. if self.coordinates.size == 5: s1 = (f" center={{{self.center[0]:,.1f}; " f"{self.center[1]:,.1f}; {self.center[2]:,.1f}}} m; ") s2 = (f"θ={self.azimuth:.1f}°, φ={self.elevation:.1f}°; " f"l={self.length:,.1f} m") # Finite dipole. else: # Finite magnetic dipole. if self._xtype == 'magnetic': if self.coordinates.ndim == 1: points = self.coordinates else: points = self.coordinates.ravel('F') else: points = self.points.ravel('F') s1 = (f" e1={{{points[0]:,.1f}; " f"{points[2]:,.1f}; {points[4]:,.1f}}} m; ") s2 = (f"e2={{{points[1]:,.1f}; " f"{points[3]:,.1f}; {points[5]:,.1f}}} m") return s0 + s1 + s2 if len(s1+s2) < 80 else s0 + s1 + "\n " + s2 @property def azimuth(self): """Anticlockwise rotation (°) from x-axis towards y-axis.""" if not hasattr(self, '_azimuth'): if len(self.coordinates) == 5: out = self._coordinates[3:] else: out = dipole_to_point(self._points)[:2] self._azimuth, self._elevation = out return self._azimuth @property def elevation(self): """Anticlockwise (upwards) rotation (°) from the xy-plane.""" if not hasattr(self, '_elevation'): _ = self.azimuth # Sets azimuth and elevation return self._elevation
# SOURCES
[docs] class Source(Wire): """A source adds strength to a Wire instance. .. note:: Use any of the Tx* classes to create sources, not this class. Parameters ---------- strength : {float, complex} Source strength (A). """ # Add strength to attributes which have to be serialized. _serialize = {'strength'} | Wire._serialize def __init__(self, strength, **kwargs): """Initiate an electric source.""" # Store strength, add a repr-addition. self._strength = strength self._repr_add = f"{self.strength:,.1f} A;" super().__init__(**kwargs) @property def strength(self): """Source strength (A).""" return self._strength
[docs] def get_field(self, grid, frequency): """Returns source field for given grid and frequency.""" return fields.get_source_field(grid, self, frequency)
[docs] @utils._known_class class TxElectricPoint(Source, Point): """Electric point source. Parameters ---------- coordinates : array_like Point coordinates in the format (x, y, z, azimuth, elevation). strength : float, default: 1.0 Source strength (A). """ def __init__(self, coordinates, strength=1.0): """Initiate an electric point source.""" super().__init__(coordinates=coordinates, strength=strength)
[docs] @utils._known_class class TxMagneticPoint(Source, Point): r"""Magnetic point source. Represented by an infinitesimal small magnetic dipole with a magnetic moment of :math:`I^m ds` (as compared to a magnetic source generated by an electric loop, which has a magnetic moment of :math:`I^m = i\omega\mu A I^e`, where A is the area of the loop). .. note:: The magnetic point source is not implemented for magnetic permeability. Parameters ---------- coordinates : array_like Point coordinates in the format (x, y, z, azimuth, elevation). strength : float, default: 1.0 Source strength (A). (Note that the source field is always an electric field; in this case the electric field due to a magnetic point.) """ def __init__(self, coordinates, strength=1.0): """Initiate an magnetic point source.""" super().__init__(coordinates=coordinates, strength=strength)
[docs] @utils._known_class class TxElectricDipole(Source, Dipole): """Electric dipole source, two electrodes connected by a wire. Parameters ---------- coordinates : array_like Dipole coordinates. Three formats are accepted: - [[x1, y1, z1], [x2, y2, z2]]; - (x1, x2, y1, y2, z1, z2); - (x, y, z, azimuth, elevation); this format takes also the ``length`` parameter. strength : float, default: 1.0 Source strength (A). length : float, default: 1.0 Length of the dipole (m). This parameter is only used if the provided coordinates are in the format (x, y, z, azimuth, elevation). """ def __init__(self, coordinates, strength=1.0, length=1.0): """Initiate an electric dipole source.""" super().__init__( coordinates=coordinates, strength=strength, length=length)
[docs] @utils._known_class class TxMagneticDipole(Source, Dipole): r"""Magnetic dipole source using a square loop perpendicular to the dipole. The magnetic dipole source simulates a magnetic dipole with an electric square loop perpendicular and at the center of the dipole; :math:`I^m = i\omega\mu A I^e`. The area A of the loop corresponds to the dipole length, to represent the same strength. Parameters ---------- coordinates : array_like Dipole coordinates. Three formats are accepted: - [[x1, y1, z1], [x2, y2, z2]]; - (x1, x2, y1, y2, z1, z2); - (x, y, z, azimuth, elevation); this format takes also the ``length`` parameter. strength : float, default: 1.0 Source strength (A). length : float, default: 1.0 Length of the dipole (m). This parameter is only used if the provided coordinates are in the format (x, y, z, azimuth, elevation). """ def __init__(self, coordinates, strength=1.0, length=1.0): """Initiate a magnetic source.""" super().__init__( coordinates=coordinates, strength=strength, length=length)
[docs] @utils._known_class class TxElectricWire(Source, Wire): """Electric wire source consisting of a series of dipoles. Parameters ---------- coordinates : array_like Electrode locations of shape (n, 3), where n is the number of electrodes: ``[[x1, y1, z1], [...], [xn, yn, zn]]``. strength : float, default: 1.0 Source strength (A). """ def __init__(self, coordinates, strength=1.0): """Initiate an electric wire source.""" super().__init__(coordinates=coordinates, strength=strength)
# RECEIVERS class Receiver(Wire): """A receiver can be positioned absolutely or relative to source.. .. note:: Use any of the Rx* classes to create receivers, not this class. Parameters ---------- relative : bool If False, the coordinates are absolute coordinates. If True, the coordinates define the offset from the source center. Note that ``relative=True`` makes only sense in combination with sources, such as is the case in a :class:`emg3d.surveys.Survey`. data_type : str Data type of the measured responses. Currently implemented is only ``'complex'``. """ # Add relative to attributes which have to be serialized. _serialize = {'relative', 'data_type'} | Wire._serialize def __init__(self, relative, data_type, **kwargs): """Initiate a receiver.""" # Check data type is a known type. if data_type.lower() != 'complex': raise ValueError(f"Unknown data type '{data_type}'.") # Store relative, add a repr-addition. self._relative = relative self._data_type = data_type.lower() self._repr_add = ( f"{['absolute', 'relative'][self.relative]}; {self.data_type};" ) super().__init__(**kwargs) @property def relative(self): """True if coordinates are relative to source, False if absolute.""" return self._relative @property def data_type(self): """Data type of the measured responses.""" return self._data_type def center_abs(self, source): """Returns points as absolute positions.""" if self.relative: return source.center + self.center else: return self.center def coordinates_abs(self, source): """Returns coordinates as absolute positions.""" if not hasattr(self, 'azimuth'): return self.center_abs(source) else: return (*self.center_abs(source), self.azimuth, self.elevation)
[docs] @utils._known_class class RxElectricPoint(Receiver, Point): """Electric point receiver [V/m] (point sampling the field). Parameters ---------- coordinates : array_like Point defined as (x, y, z, azimuth, elevation) relative : bool, default: False If False, the coordinates are absolute coordinates. If True, the coordinates define the offset from the source center. Note that ``relative=True`` makes only sense in combination with sources, such as is the case in a :class:`emg3d.surveys.Survey`. data_type : str, default: 'complex' Data type of the measured responses. Currently implemented is only the default value. """ _adjoint_source = TxElectricPoint def __init__(self, coordinates, relative=False, data_type='complex'): """Initiate an electric point receiver.""" super().__init__( coordinates=coordinates, relative=relative, data_type=data_type )
[docs] @utils._known_class class RxMagneticPoint(Receiver, Point): """Magnetic point receiver [A/m] (point sampling the field). Parameters ---------- coordinates : array_like Point defined as (x, y, z, azimuth, elevation) relative : bool, default: False If False, the coordinates are absolute coordinates. If True, the coordinates define the offset from the source center. Note that ``relative=True`` makes only sense in combination with sources, such as is the case in a :class:`emg3d.surveys.Survey`. data_type : str, default: 'complex' Data type of the measured responses. Currently implemented is only the default value. """ _adjoint_source = TxMagneticPoint def __init__(self, coordinates, relative=False, data_type='complex'): """Initiate a magnetic point receiver.""" super().__init__( coordinates=coordinates, relative=relative, data_type=data_type )
# ROTATIONS AND CONVERSIONS
[docs] def point_to_dipole(point, length, deg=True): """Return coordinates of dipole points defined by center, angles, length. Spherical to Cartesian. Parameters ---------- point : tuple Point coordinates in the form of (x, y, z, azimuth, elevation). length : float Dipole length (m). deg : bool, default: True Angles are in degrees if True, radians if False. Returns ------- dipole : ndarray Coordinates of shape (2, 3): [[x1, y1, z1], [x2, y2, z2]]. """ # Get coordinates relative to centrum. xyz = rotation(point[3], point[4], deg=deg)*length/2 # Add half a dipole on both sides of the center. return point[:3] + np.array([-xyz, xyz])
[docs] def dipole_to_point(dipole, deg=True): """Return azimuth and elevation for given electrode pair. Cartesian to spherical. Parameters ---------- dipole : ndarray Dipole coordinates of shape (2, 3): [[x1, y1, z1], [x2, y2, z2]]. deg : bool, default: True Return angles in degrees if True, radians if False. Returns ------- azimuth : float Anticlockwise angle from x-axis towards y-axis. elevation : float Anticlockwise (upwards) angle from the xy-plane towards z-axis. length : float Dipole length (m). """ # Get distances between coordinates. dx, dy, dz = np.diff(dipole.T).squeeze() length = np.linalg.norm([dx, dy, dz]) # Get angles from complex planes. azimuth = np.angle(dx + 1j*dy, deg=deg) # same as: np.arctan2(dy, dx) elevation = np.angle(np.sqrt(dx**2+dy**2) + 1j*dz, deg=deg) # (dz, dxy) return azimuth, elevation, length
[docs] def point_to_square_loop(source, area): """Return points of a loop of area perpendicular to source dipole. Parameters ---------- source : tuple Source dipole coordinates in the form of (x, y, z, azimuth, elevation). area : float Area of the square loop (m^2). Returns ------- out : ndarray Array of shape (5, 3), corresponding to the x/y/z-coordinates for the five points describing a closed rectangle perpendicular to the dipole, of side-length length. """ half_diag = np.sqrt(area/2) xyz_hor = rotation(source[3]+90.0, 0.0)*half_diag xyz_ver = rotation(source[3], source[4]+90.0)*half_diag points = source[:3] + np.stack( [xyz_hor, xyz_ver, -xyz_hor, -xyz_ver, xyz_hor]) return points
[docs] def rotation(azimuth, elevation, deg=True): """Rotation factors for RHS coordinate system with positive z upwards. The rotation factors multiplied with the length yield the corresponding Cartesian coordinates. (The rotation factors correspond to the rotation of a unit radius of length 1.) Definition of spherical coordinates: - azimuth θ: anticlockwise from x-axis towards y-axis, (-180°, +180°]. - elevation φ: anticlockwise (upwards) from the xy-plane towards z-axis [-90°, +90°]. - radius (m). Definition of Cartesian coordinates: - x is Easting; - y is Northing; - z is positive upwards (RHS). Parameters ---------- azimuth : float Anticlockwise angle from x-axis towards y-axis. elevation : float Anticlockwise (upwards) angle from the xy-plane towards z-axis. deg : bool, default: True Angles are in degrees if True, radians if False. Returns ------- rot : ndarray Rotation factors (x, y, z). """ if deg: cos, sin = sp.special.cosdg, sp.special.sindg else: cos, sin = np.cos, np.sin return np.array([cos(azimuth)*cos(elevation), sin(azimuth)*cos(elevation), sin(elevation)])