diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 765902c..6c83d8e 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.0.11 +current_version = 2.0.12 files = setup.py mtpy/__init__.py README.md docs/source/conf.py commit = True tag = True diff --git a/HISTORY.rst b/HISTORY.rst index be602af..c7b373e 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -58,4 +58,14 @@ History * Occam2d fixes by @alkirkby in https://github.com/MTgeophysics/mtpy-v2/pull/56 * Updates by @kujaku11 in https://github.com/MTgeophysics/mtpy-v2/pull/61 -**Full Changelog**: https://github.com/MTgeophysics/mtpy-v2/compare/v2.0.10...v2.0.11 \ No newline at end of file +**Full Changelog**: https://github.com/MTgeophysics/mtpy-v2/compare/v2.0.10...v2.0.11 + +2.0.12 (2024-10-22) +---------------------------- + +* Fix rotations again by @kujaku11 in https://github.com/MTgeophysics/mtpy-v2/pull/57 +* Pin numpy versions <2.0 by @kkappler in https://github.com/MTgeophysics/mtpy-v2/pull/62 +* Occam2d fixes by @alkirkby in https://github.com/MTgeophysics/mtpy-v2/pull/56 +* Updates by @kujaku11 in https://github.com/MTgeophysics/mtpy-v2/pull/61 + +**Full Changelog**: https://github.com/MTgeophysics/mtpy-v2/compare/v2.0.10...v2.0.12 \ No newline at end of file diff --git a/README.md b/README.md index 8de3733..8b4adf2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Documentation Status](https://readthedocs.org/projects/mtpy-v2/badge/?version=latest)](https://mtpy-v2.readthedocs.io/en/latest/?badge=latest) [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/MTgeophysics/mtpy-v2/main) -## Version 2.0.11 +## Version 2.0.12 # Description diff --git a/docs/source/conf.py b/docs/source/conf.py index ef71801..8963198 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -67,9 +67,9 @@ # # The short X.Y version. -version = "2.0.11" +version = "2.0.12" # The full version, including alpha/beta/rc tags. -release = "2.0.11" +release = "2.0.12" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/mtpy/__init__.py b/mtpy/__init__.py index b603ca3..06cb9fa 100644 --- a/mtpy/__init__.py +++ b/mtpy/__init__.py @@ -18,7 +18,7 @@ from mtpy.imaging.mtcolors import MT_CMAP_DICT, register_cmaps -__version__ = "2.0.11" +__version__ = "2.0.12" __all__ = ["MT", "MTData", "MTCollection"] # ============================================================================= diff --git a/mtpy/core/mt.py b/mtpy/core/mt.py index 737b366..b865b2f 100644 --- a/mtpy/core/mt.py +++ b/mtpy/core/mt.py @@ -14,7 +14,12 @@ from mt_metadata.transfer_functions.core import TF -from mtpy.core import Z, Tipper, COORDINATE_REFERENCE_FRAME_OPTIONS +from mtpy.core.transfer_function import IMPEDANCE_UNITS +from mtpy.core import ( + Z, + Tipper, + COORDINATE_REFERENCE_FRAME_OPTIONS, +) from mtpy.core.mt_location import MTLocation from mtpy.core.mt_dataframe import MTDataFrame from mtpy.utils.estimate_tf_quality_factor import EMTFStats @@ -74,7 +79,7 @@ class MT(TF, MTLocation): """ - def __init__(self, fn=None, **kwargs): + def __init__(self, fn=None, impedance_units="mt", **kwargs): tf_kwargs = {} for key in [ "period", @@ -113,6 +118,9 @@ def __init__(self, fn=None, **kwargs): self.station_metadata.transfer_function.sign_convention ) + self._impedance_unit_factors = IMPEDANCE_UNITS + self.impedance_units = impedance_units + for key, value in kwargs.items(): setattr(self, key, value) @@ -132,6 +140,7 @@ def clone_empty(self): new_mt_obj.model_elevation = self.model_elevation new_mt_obj._rotation_angle = self._rotation_angle new_mt_obj.profile_offset = self.profile_offset + new_mt_obj.impedance_units = self.impedance_units return new_mt_obj @@ -211,6 +220,23 @@ def coordinate_reference_frame(self, value): self.station_metadata.transfer_function.sign_convention = value + @property + def impedance_units(self): + """impedance units""" + return self._impedance_units + + @impedance_units.setter + def impedance_units(self, value): + """impedance units setter options are [ mt | ohm ]""" + if not isinstance(value, str): + raise TypeError("Units input must be a string.") + if value.lower() not in self._impedance_unit_factors.keys(): + raise ValueError( + f"{value} is not an acceptable unit for impedance." + ) + + self._impedance_units = value + @property def rotation_angle(self): """Rotation angle in degrees from north. In the coordinate reference frame""" @@ -279,15 +305,17 @@ def rotate(self, theta_r, inplace=True): @property def Z(self): - r"""Mtpy.core.z.Z object to hold impedance tenso.""" + r"""Mtpy.core.z.Z object to hold impedance tensor.""" if self.has_impedance(): - return Z( + z_object = Z( z=self.impedance.to_numpy(), z_error=self.impedance_error.to_numpy(), frequency=self.frequency, z_model_error=self.impedance_model_error.to_numpy(), ) + z_object.units = self.impedance_units + return z_object return Z() @Z.setter @@ -296,16 +324,24 @@ def Z(self, z_object): recalculate phase tensor and invariants, which shouldn't change except for strike angle. + + Be sure to have appropriate units set """ + # if a z object is given the underlying data is in mt units, even + # if the units are set to ohm. + self.impedance_units = z_object.units if not isinstance(z_object.frequency, type(None)): if self.frequency.size != z_object.frequency.size: self.frequency = z_object.frequency elif not (self.frequency == z_object.frequency).all(): self.frequency = z_object.frequency - self.impedance = z_object.z - self.impedance_error = z_object.z_error - self.impedance_model_error = z_object.z_model_error + # set underlying data to units of mt + self.impedance = z_object._dataset.transfer_function.values + self.impedance_error = z_object._dataset.transfer_function_error.values + self.impedance_model_error = ( + z_object._dataset.transfer_function_model_error.values + ) @property def Tipper(self): @@ -634,7 +670,7 @@ def plot_depth_of_penetration(self, **kwargs): return PlotPenetrationDepth1D(self, **kwargs) - def to_dataframe(self, utm_crs=None, cols=None): + def to_dataframe(self, utm_crs=None, cols=None, impedance_units="mt"): """Create a dataframe from the transfer function for use with plotting and modeling. :param cols: @@ -644,6 +680,8 @@ def to_dataframe(self, utm_crs=None, cols=None): :param eter utm_crs: The utm zone to project station to, could be a name, pyproj.CRS, EPSG number, or anything that pyproj.CRS can intake. :type eter utm_crs: string, int, :class:`pyproj.CRS` + :param impedance_units: ["mt" [mV/km/nT] | "ohm" [Ohms] ] + :type impedance_units: str """ if utm_crs is not None: self.utm_crs = utm_crs @@ -667,19 +705,21 @@ def to_dataframe(self, utm_crs=None, cols=None): mt_df.dataframe.loc[:, "period"] = self.period if self.has_impedance(): - mt_df.from_z_object(self.Z) + z_object = self.Z + z_object.units = impedance_units + mt_df.from_z_object(z_object) if self.has_tipper(): mt_df.from_t_object(self.Tipper) return mt_df - def from_dataframe(self, mt_df): + def from_dataframe(self, mt_df, impedance_units="mt"): """Fill transfer function attributes from a dataframe for a single station. :param mt_df: :param df: DESCRIPTION. :type df: TYPE - :return: DESCRIPTION. - :rtype: TYPE + :param impedance_units: ["mt" [mV/km/nT] | "ohm" [Ohms] ] + :type impedance_units: str """ if not isinstance(mt_df, MTDataFrame): @@ -713,11 +753,9 @@ def from_dataframe(self, mt_df): self.tf_id = self.station - # self._transfer_function = self._initialize_transfer_function( - # mt_df.period - # ) - - self.Z = mt_df.to_z_object() + z_obj = mt_df.to_z_object() + z_obj.units = impedance_units + self.Z = z_obj self.Tipper = mt_df.to_t_object() def compute_model_z_errors( @@ -1072,9 +1110,7 @@ def add_white_noise(self, value, inplace=True): ] = self._transfer_function.transfer_function.real * ( noise_real ) + ( - 1j - * self._transfer_function.transfer_function.imag - * noise_imag + 1j * self._transfer_function.transfer_function.imag * noise_imag ) self._transfer_function["transfer_function_error"] = ( @@ -1088,9 +1124,7 @@ def add_white_noise(self, value, inplace=True): ] = self._transfer_function.transfer_function.real * ( noise_real ) + ( - 1j - * self._transfer_function.transfer_function.imag - * noise_imag + 1j * self._transfer_function.transfer_function.imag * noise_imag ) self._transfer_function["transfer_function_error"] = ( diff --git a/mtpy/core/mt_data.py b/mtpy/core/mt_data.py index ea6093c..1bdd410 100644 --- a/mtpy/core/mt_data.py +++ b/mtpy/core/mt_data.py @@ -18,6 +18,7 @@ import matplotlib.pyplot as plt +from mtpy.core.transfer_function import IMPEDANCE_UNITS from .mt import MT from .mt_stations import MTStations from mtpy.core import MTDataFrame, COORDINATE_REFERENCE_FRAME_OPTIONS @@ -59,11 +60,10 @@ class MTData(OrderedDict, MTStations): """ def __init__(self, mt_list=None, **kwargs): - if mt_list is not None: - for mt_obj in mt_list: - self.add_station(mt_obj, compute_relative_location=False) - MTStations.__init__(self, None, None, **kwargs) + self._coordinate_reference_frame_options = ( + COORDINATE_REFERENCE_FRAME_OPTIONS + ) self.z_model_error = ModelErrors( error_value=5, @@ -78,6 +78,9 @@ def __init__(self, mt_list=None, **kwargs): mode="tipper", ) self.data_rotation_angle = 0 + self.coordinate_reference_frame = "ned" + self._impedance_unit_factors = IMPEDANCE_UNITS + self.impedance_units = "mt" self.model_parameters = {} @@ -94,10 +97,18 @@ def __init__(self, mt_list=None, **kwargs): "rotation_angle", "data_rotation_angle", "model_parameters", + "impedance_units", ] - self._coordinate_reference_frame_options = ( - COORDINATE_REFERENCE_FRAME_OPTIONS + if mt_list is not None: + for mt_obj in mt_list: + self.add_station(mt_obj, compute_relative_location=False) + + MTStations.__init__( + self, + kwargs.pop("utm_epsg", None), + datum_epsg=kwargs.pop("datum_epsg", None), + **kwargs, ) def _validate_item(self, mt_obj): @@ -206,14 +217,16 @@ def clone_empty(self): md = MTData() for attr in self._copy_attrs: - setattr(self, attr, deepcopy(getattr(self, attr))) + setattr(md, attr, deepcopy(getattr(self, attr))) return md @property def coordinate_reference_frame(self): """coordinate reference frame ned or enu""" - return self._coordinate_reference_frame + return self._coordinate_reference_frame_options[ + self._coordinate_reference_frame + ].upper() @coordinate_reference_frame.setter def coordinate_reference_frame(self, value): @@ -255,6 +268,27 @@ def coordinate_reference_frame(self, value): self._coordinate_reference_frame = value + @property + def impedance_units(self): + """impedance units""" + return self._impedance_units + + @impedance_units.setter + def impedance_units(self, value): + """impedance units setter options are [ mt | ohm ]""" + if not isinstance(value, str): + raise TypeError("Units input must be a string.") + if value.lower() not in self._impedance_unit_factors.keys(): + raise ValueError( + f"{value} is not an acceptable unit for impedance." + ) + + self._impedance_units = value + + if self.mt_list is not None: + for mt_obj in self.values(): + mt_obj.impedance_units = self._impedance_units + @property def mt_list(self): """Mt list. @@ -267,6 +301,8 @@ def mt_list(self): @mt_list.setter def mt_list(self, value): """At this point not implemented, mainly here for inheritance of MTStations.""" + if value is None: + return if len(self.values()) != 0: self.logger.warning("mt_list cannot be set.") pass @@ -292,7 +328,9 @@ def get_survey(self, survey_id): survey_list = [ mt_obj for key, mt_obj in self.items() if survey_id in key ] - return MTData(survey_list) + md = self.clone_empty() + md.add_station(survey_list) + return md def add_station( self, @@ -323,8 +361,11 @@ def add_station( for m in mt_object: m = self._validate_item(m) - if self.utm_crs is not None: - m.utm_crs = self.utm_crs + try: + if self.utm_crs is not None: + m.utm_crs = self.utm_crs + except AttributeError: + pass if survey is not None: m.survey = survey @@ -476,14 +517,14 @@ def apply_bounding_box(self, lon_min, lon_max, lat_min, lat_max): def get_subset(self, station_list): """Get a subset of the data from a list of stations, could be station_id - or station_keys + or station_keys. Safest to use keys {survey}.{station} :param station_list: List of station keys as {survey_id}.{station_id}. :type station_list: list :return: Returns just those stations within station_list. :rtype: :class:`mtpy.MTData` """ - mt_data = MTData() + mt_data = self.clone_empty() for station in station_list: if station.count(".") > 0: mt_data.add_station( @@ -505,19 +546,23 @@ def n_stations(self): if self.mt_list is not None: return len(self.mt_list) - def to_dataframe(self, utm_crs=None, cols=None): + def to_dataframe(self, utm_crs=None, cols=None, impedance_units="mt"): """To dataframe. :param utm_crs: DESCRIPTION, defaults to None. :type utm_crs: TYPE, optional :param cols: DESCRIPTION, defaults to None. :type cols: TYPE, optional + :param impedance_units: [ "mt" [mV/km/nT] | "ohm" [Ohms] ] + :type impedance_units: str :return: DESCRIPTION. :rtype: TYPE """ df_list = [ - mt_obj.to_dataframe(utm_crs=utm_crs, cols=cols).dataframe + mt_obj.to_dataframe( + utm_crs=utm_crs, cols=cols, impedance_units=impedance_units + ).dataframe for mt_obj in self.values() ] @@ -525,39 +570,47 @@ def to_dataframe(self, utm_crs=None, cols=None): df.reset_index(drop=True, inplace=True) return df - def to_mt_dataframe(self, utm_crs=None): + def to_mt_dataframe(self, utm_crs=None, impedance_units="mt"): """Create an MTDataFrame. :param utm_crs: DESCRIPTION, defaults to None. :type utm_crs: TYPE, optional + :param impedance_units: [ "mt" [mV/km/nT] | "ohm" [Ohms] ] + :type impedance_units: str :return: DESCRIPTION. :rtype: TYPE """ - return MTDataFrame(self.to_dataframe(utm_crs=utm_crs)) + return MTDataFrame( + self.to_dataframe(utm_crs=utm_crs, impedance_units=impedance_units) + ) - def from_dataframe(self, df): + def from_dataframe(self, df, impedance_units="mt"): """Create an dictionary of MT objects from a dataframe. :param df: Dataframe of mt data. :type df: `pandas.DataFrame` + :param impedance_units: [ "mt" [mV/km/nT] | "ohm" [Ohms] ] + :type impedance_units: str """ for station in df.station.unique(): sdf = df.loc[df.station == station] mt_object = MT(period=sdf.period.unique()) - mt_object.from_dataframe(sdf) + mt_object.from_dataframe(sdf, impedance_units=impedance_units) self.add_station(mt_object, compute_relative_location=False) - def from_mt_dataframe(self, mt_df): + def from_mt_dataframe(self, mt_df, impedance_units="mt"): """Create an dictionary of MT objects from a dataframe. :param mt_df: :param df: Dataframe of mt data. :type df: `MTDataFrame` + :param impedance_units: [ "mt" [mV/km/nT] | "ohm" [Ohms] ] + :type impedance_units: str """ - self.from_dataframe(mt_df.dataframe) + self.from_dataframe(mt_df.dataframe, impedance_units=impedance_units) def to_geo_df(self, model_locations=False, data_type="station_locations"): """Make a geopandas dataframe for easier GIS manipulation. @@ -658,9 +711,7 @@ def interpolate( ) else: - mt_data.add_station( - new_mt_obj, compute_relative_location=False - ) + mt_data.add_station(new_mt_obj, compute_relative_location=False) if not inplace: return mt_data @@ -682,13 +733,10 @@ def rotate(self, rotation_angle, inplace=True): mt_data = self.clone_empty() for mt_obj in self.values(): if not inplace: - rot_mt_obj = mt_obj.copy() - rot_mt_obj.rotation_angle = rotation_angle - mt_data.add_station( - rot_mt_obj, compute_relative_location=False - ) + rot_mt_obj = mt_obj.rotate(rotation_angle, inplace=False) + mt_data.add_station(rot_mt_obj, compute_relative_location=False) else: - mt_obj.rotation_angle = rotation_angle + mt_obj.rotate(rotation_angle) if not inplace: return mt_data @@ -780,12 +828,8 @@ def compute_model_errors( self.t_model_error.floor = t_floor for mt_obj in self.values(): - mt_obj.compute_model_z_errors( - **self.z_model_error.error_parameters - ) - mt_obj.compute_model_t_errors( - **self.t_model_error.error_parameters - ) + mt_obj.compute_model_z_errors(**self.z_model_error.error_parameters) + mt_obj.compute_model_t_errors(**self.t_model_error.error_parameters) def get_nearby_stations(self, station_key, radius, radius_units="m"): """Get stations close to a given station. diff --git a/mtpy/core/mt_dataframe.py b/mtpy/core/mt_dataframe.py index a4b4121..3691557 100644 --- a/mtpy/core/mt_dataframe.py +++ b/mtpy/core/mt_dataframe.py @@ -371,9 +371,9 @@ def survey(self, value): """Survey name.""" if self._has_data(): if self.working_survey in [None, ""]: - self.dataframe.loc[ - self.dataframe.survey == "", "survey" - ] = value + self.dataframe.loc[self.dataframe.survey == "", "survey"] = ( + value + ) self.working_survey = value @property @@ -389,9 +389,9 @@ def station(self, value): """Station name.""" if self._has_data(): if self.working_station in [None, ""]: - self.dataframe.loc[ - self.dataframe.station == "", "station" - ] = value + self.dataframe.loc[self.dataframe.station == "", "station"] = ( + value + ) self.working_station = value @property @@ -638,9 +638,9 @@ def from_z_object(self, z_object): :rtype: TYPE """ - self.dataframe.loc[ - self.dataframe.station == self.station, "period" - ] = z_object.period + self.dataframe.loc[self.dataframe.station == self.station, "period"] = ( + z_object.period + ) # should make a copy of the phase tensor otherwise it gets calculated # multiple times and becomes a time sink. @@ -697,9 +697,9 @@ def from_t_object(self, t_object): :return: DESCRIPTION. :rtype: TYPE """ - self.dataframe.loc[ - self.dataframe.station == self.station, "period" - ] = t_object.period + self.dataframe.loc[self.dataframe.station == self.station, "period"] = ( + t_object.period + ) for error in ["", "_error", "_model_error"]: if getattr(t_object, f"_has_tf{error}")(): @@ -721,7 +721,7 @@ def from_t_object(self, t_object): data_array = self._get_data_array(t_object, t_attr) self._fill_data(data_array, f"t_{t_attr}", None) - def to_z_object(self): + def to_z_object(self, units="mt"): """Fill z_object from dataframe Need to have the components this way for transposing the elements so @@ -755,7 +755,7 @@ def to_z_object(self): self.dataframe.station == self.station, f"z_{comp}_model_error" ] - z_object = Z(z, z_err, self.frequency, z_model_err) + z_object = Z(z, z_err, self.frequency, z_model_err, units=units) if (z == 0).all(): for comp in ["xx", "xy", "yx", "yy"]: @@ -781,12 +781,12 @@ def to_z_object(self): f"phase_{comp}_error", ] - phase_model_err[ - :, index["ii"], index["jj"] - ] = self.dataframe.loc[ - self.dataframe.station == self.station, - f"phase_{comp}_model_error", - ] + phase_model_err[:, index["ii"], index["jj"]] = ( + self.dataframe.loc[ + self.dataframe.station == self.station, + f"phase_{comp}_model_error", + ] + ) if not (res == 0).all(): if not (phase == 0).all(): diff --git a/mtpy/core/mt_stations.py b/mtpy/core/mt_stations.py index b2995e3..2960bf3 100644 --- a/mtpy/core/mt_stations.py +++ b/mtpy/core/mt_stations.py @@ -59,6 +59,7 @@ def __init__(self, utm_epsg, datum_epsg=None, **kwargs): ("profile_offset", float), ] ) + self._datum_crs = CRS.from_epsg(4326) self._utm_crs = None self._center_lat = None @@ -69,6 +70,7 @@ def __init__(self, utm_epsg, datum_epsg=None, **kwargs): self.rotation_angle = 0.0 self.mt_list = None self.utm_epsg = utm_epsg + self.datum_epsg = datum_epsg for key in list(kwargs.keys()): if hasattr(self, key): @@ -491,7 +493,9 @@ def center_point(self): else: self.logger.debug("locating center from UTM grid") - center_location.east = (st_en.east.max() + st_en.east.min()) / 2 + center_location.east = ( + st_en.east.max() + st_en.east.min() + ) / 2 center_location.north = ( st_en.north.max() + st_en.north.min() ) / 2 @@ -835,28 +839,29 @@ def generate_profile(self, units="deg"): "intercept": profile1.intercept, } - - # if the profile is closer to E-W, use minimum x to get profile ends, + # if the profile is closer to E-W, use minimum x to get profile ends, # otherwise use minimum y - if -1 <= profile_line['slope'] <= 1: - sx = np.array([x.min(),x.max()]) - sy = np.array([y[x.idxmin()],y[x.idxmax()]]) + if -1 <= profile_line["slope"] <= 1: + sx = np.array([x.min(), x.max()]) + sy = np.array([y[x.idxmin()], y[x.idxmax()]]) else: - sy = np.array([y.min(),y.max()]) - sx = np.array([x[y.idxmin()],x[y.idxmax()]]) + sy = np.array([y.min(), y.max()]) + sx = np.array([x[y.idxmin()], x[y.idxmax()]]) # get line through point perpendicular to profile - m2 = -1./profile_line['slope'] + m2 = -1.0 / profile_line["slope"] # two intercepts associated with each end point - c2 = sy - m2*sx + c2 = sy - m2 * sx # get point where the lines intercept the profile line - x1, x2 = (c2 - profile_line['intercept'])/(profile_line['slope'] - m2) + x1, x2 = (c2 - profile_line["intercept"]) / ( + profile_line["slope"] - m2 + ) # compute y points - y1, y2 = profile_line["slope"] * np.array([x1,x2]) + profile_line["intercept"] - - - - + y1, y2 = ( + profile_line["slope"] * np.array([x1, x2]) + + profile_line["intercept"] + ) + # # to get x values of end points, need to project first station onto the line # # get min and max x values # sx = np.array([x.min(),x.max()]) @@ -865,25 +870,24 @@ def generate_profile(self, units="deg"): # # two intercepts associated with each end point # c2 = (profile_line['slope'] - m2)*sx + profile_line['intercept'] # # get point where the two lines intercept - # x1,x2 = (c2 - profile_line['intercept'])/(profile_line['slope'] - m2) + # x1,x2 = (c2 - profile_line['intercept'])/(profile_line['slope'] - m2) # # compute y points # y1, y2 = profile_line["slope"] * np.array([x1,x2]) + profile_line["intercept"] - + # else: - # # to get x values of end points, need to project first station onto the line - # # get min and max y values - # sy = np.array([y.min(),y.max()]) - # # get line through y1 perpendicular to profile - # m2 = -1./profile_line['slope'] - # # two intercepts associated with each end point - # c2 = sy - (m2/profile_line['slope'])*(sy - profile_line['intercept']) - # # get point where the two lines intercept - # y1,y2 = ((profile_line['intercept']/profile_line['slope']) - c2/m2)/\ - # (1./profile_line['slope'] - 1./m2) - # # compute x points - # x1,x2 = (np.array([y1,y2]) - profile_line["intercept"])/profile_line["slope"] - - + # # to get x values of end points, need to project first station onto the line + # # get min and max y values + # sy = np.array([y.min(),y.max()]) + # # get line through y1 perpendicular to profile + # m2 = -1./profile_line['slope'] + # # two intercepts associated with each end point + # c2 = sy - (m2/profile_line['slope'])*(sy - profile_line['intercept']) + # # get point where the two lines intercept + # y1,y2 = ((profile_line['intercept']/profile_line['slope']) - c2/m2)/\ + # (1./profile_line['slope'] - 1./m2) + # # compute x points + # x1,x2 = (np.array([y1,y2]) - profile_line["intercept"])/profile_line["slope"] + # x1 = x.min() # x2 = x.max() # y1 = profile_line["slope"] * x1 + profile_line["intercept"] diff --git a/mtpy/core/transfer_function/__init__.py b/mtpy/core/transfer_function/__init__.py index 92a116c..8620e36 100644 --- a/mtpy/core/transfer_function/__init__.py +++ b/mtpy/core/transfer_function/__init__.py @@ -1,3 +1,8 @@ +import numpy as np + +MT_TO_OHM_FACTOR = 1.0 / np.pi * np.sqrt(5.0 / 8.0) * 10**3.5 +IMPEDANCE_UNITS = {"mt": 1, "ohm": MT_TO_OHM_FACTOR} + from .z import Z from .tipper import Tipper from .pt import PhaseTensor diff --git a/mtpy/core/transfer_function/base.py b/mtpy/core/transfer_function/base.py index a13e970..a811991 100644 --- a/mtpy/core/transfer_function/base.py +++ b/mtpy/core/transfer_function/base.py @@ -102,8 +102,16 @@ def __eq__(self, other): # loop over variables to make sure they are all the same. for var in list(self._dataset.data_vars): - if not (self._dataset[var] == other._dataset[var]).all().data: - return False + has_tf_str = f"_has_{var.replace('transfer_function', 'tf')}" + if getattr(self, has_tf_str): + if getattr(other, has_tf_str): + if not np.allclose( + self._dataset[var].data, other._dataset[var].data + ): + self.logger.info(f"Transfer functions {var} not equal") + return False + else: + return False return True def __deepcopy__(self, memo): @@ -164,9 +172,7 @@ def _initialize( tf_error = np.zeros_like( tf_model_error, dtype=self._tf_dtypes["tf_error"] ) - periods = self._validate_frequency( - periods, tf_model_error.shape[0] - ) + periods = self._validate_frequency(periods, tf_model_error.shape[0]) else: periods = self._validate_frequency(periods) diff --git a/mtpy/core/transfer_function/z.py b/mtpy/core/transfer_function/z.py index c9996aa..71a4f3f 100644 --- a/mtpy/core/transfer_function/z.py +++ b/mtpy/core/transfer_function/z.py @@ -18,6 +18,7 @@ import numpy as np from .base import TFBase +from . import MT_TO_OHM_FACTOR, IMPEDANCE_UNITS from .pt import PhaseTensor from .z_analysis import ( ZInvariants, @@ -58,6 +59,7 @@ def __init__( z_error=None, frequency=None, z_model_error=None, + units="mt", ): """Initialize an instance of the Z class. :param z_model_error: @@ -70,8 +72,24 @@ def __init__( :param frequency: Array of frequencyuency values corresponding to impedance tensor elements, defaults to None. :type frequency: np.ndarray(n_frequency), optional + :param units: units for the impedance [ "mt" [mV/km/nT] | ohm [Ohms] ] + :type units: str + """ + self._ohm_factor = MT_TO_OHM_FACTOR + self._unit_factors = IMPEDANCE_UNITS + self.units = units + + # if units input is ohms, then we want to scale them to mt units that + # way the underlying data is consistent in [mV/km/nT] + if z is not None: + z = z * self._scale_factor + if z_error is not None: + z_error = z_error * self._scale_factor + if z_model_error is not None: + z_model_error = z_model_error * self._scale_factor + super().__init__( tf=z, tf_error=z_error, @@ -80,6 +98,28 @@ def __init__( _name="impedance", ) + @property + def units(self): + """impedance units""" + return self._units + + @units.setter + def units(self, value): + """impedance units setter options are [ mt | ohm ]""" + if not isinstance(value, str): + raise TypeError("Units input must be a string.") + if value.lower() not in self._unit_factors.keys(): + raise ValueError( + f"{value} is not an acceptable unit for impedance." + ) + + self._units = value + + @property + def _scale_factor(self): + """unit scale factor""" + return self._unit_factors[self._units] + @property def z(self): """Impedance tensor @@ -87,11 +127,11 @@ def z(self): np.ndarray(nfrequency, 2, 2). """ if self._has_tf(): - return self._dataset.transfer_function.values + return self._dataset.transfer_function.values / self._scale_factor @z.setter def z(self, z): - """Set the attribute 'z'. + """Set the attribute 'z'. Should be in units of mt [mV/km/nT] :param z: Complex impedance tensor array. :type z: np.ndarray(nfrequency, 2, 2) """ @@ -119,7 +159,10 @@ def z(self, z): def z_error(self): """Error of impedance tensor array as standard deviation.""" if self._has_tf_error(): - return self._dataset.transfer_function_error.values + return ( + self._dataset.transfer_function_error.values + / self._scale_factor + ) @z_error.setter def z_error(self, z_error): @@ -151,7 +194,10 @@ def z_error(self, z_error): def z_model_error(self): """Model error of impedance tensor array as standard deviation.""" if self._has_tf_model_error(): - return self._dataset.transfer_function_model_error.values + return ( + self._dataset.transfer_function_model_error.values + / self._scale_factor + ) @z_model_error.setter def z_model_error(self, z_model_error): @@ -249,10 +295,20 @@ def _validate_ss_input(factor): z_corrected = copy.copy(self.z) - z_corrected[:, 0, 0] = self.z[:, 0, 0] / x_factors - z_corrected[:, 0, 1] = self.z[:, 0, 1] / x_factors - z_corrected[:, 1, 0] = self.z[:, 1, 0] / y_factors - z_corrected[:, 1, 1] = self.z[:, 1, 1] / y_factors + z_corrected[:, 0, 0] = ( + self.z[:, 0, 0] * self._scale_factor + ) / x_factors + z_corrected[:, 0, 1] = ( + self.z[:, 0, 1] * self._scale_factor + ) / x_factors + z_corrected[:, 1, 0] = ( + self.z[:, 1, 0] * self._scale_factor + ) / y_factors + z_corrected[:, 1, 1] = ( + self.z[:, 1, 1] * self._scale_factor + ) / y_factors + + z_corrected = z_corrected / self._scale_factor if inplace: self.z = z_corrected @@ -262,6 +318,7 @@ def _validate_ss_input(factor): z_error=self.z_error, frequency=self.frequency, z_model_error=self.z_model_error, + units=self.units, ) def remove_distortion( @@ -310,30 +367,38 @@ def remove_distortion( self, distortion_tensor, distortion_error_tensor, self.logger ) + # into mt units + z_corrected = z_corrected * self._scale_factor + z_corrected_error = z_corrected_error * self._scale_factor + if inplace: self.z = z_corrected self.z_error = z_corrected_error else: - return Z( + z_object = Z( z=z_corrected, z_error=z_corrected_error, frequency=self.frequency, z_model_error=self.z_model_error, ) + z_object.units = self.units + return z_object @property def resistivity(self): """Resistivity of impedance.""" if self.z is not None: return np.apply_along_axis( - lambda x: np.abs(x) ** 2 / self.frequency * 0.2, 0, self.z + lambda x: np.abs(x) ** 2 / self.frequency * 0.2, + 0, + self.z * self._scale_factor, ) @property def phase(self): """Phase of impedance.""" if self.z is not None: - return np.rad2deg(np.angle(self.z)) + return np.rad2deg(np.angle(self.z * self._scale_factor)) @property def resistivity_error(self): @@ -347,7 +412,9 @@ def resistivity_error(self): return np.apply_along_axis( lambda x: x / self.frequency * 0.2, 0, - 2 * self.z_error * np.abs(self.z), + 2 + * (self.z_error * self._scale_factor) + * np.abs(self.z * self._scale_factor), ) @property @@ -371,7 +438,9 @@ def resistivity_model_error(self): return np.apply_along_axis( lambda x: x / self.frequency * 0.2, 0, - 2 * self.z_model_error * np.abs(self.z), + 2 + * (self.z_model_error * self._scale_factor) + * np.abs(self.z * self._scale_factor), ) @property @@ -448,9 +517,7 @@ def set_resistivity_phase( res_error = self._validate_array_input(res_error, float) phase_error = self._validate_array_input(phase_error, float) res_model_error = self._validate_array_input(res_model_error, float) - phase_model_error = self._validate_array_input( - phase_model_error, float - ) + phase_model_error = self._validate_array_input(phase_model_error, float) abs_z = np.sqrt(5.0 * self.frequency * (resistivity.T)).T self.z = abs_z * np.exp(1j * np.radians(phase)) @@ -464,7 +531,9 @@ def set_resistivity_phase( def det(self): """Determinant of impedance.""" if self.z is not None: - det_z = np.array([np.linalg.det(ii) ** 0.5 for ii in self.z]) + det_z = np.array( + [np.linalg.det(ii * self._scale_factor) ** 0.5 for ii in self.z] + ) return det_z @@ -480,12 +549,16 @@ def det_error(self): # calculate manually: # difference of determinant of z + z_error and z - z_error then divide by 2 det_z_error[:] = ( - np.abs( - np.linalg.det(self.z + self.z_error) - - np.linalg.det(self.z - self.z_error) + self._scale_factor + * ( + np.abs( + np.linalg.det(self.z + self.z_error) + - np.linalg.det(self.z - self.z_error) + ) + / 2.0 ) - / 2.0 - ) ** 0.5 + ** 0.5 + ) return det_z_error @property @@ -748,11 +821,13 @@ def estimate_distortion( if self._has_tf(): new_z_object = Z( - z=self.z[0:nf, :, :], + z=self._dataset.transfer_function.values[0:nf, :, :], frequency=self.frequency[0:nf], ) if self._has_tf_error(): - new_z_object.z_error = self.z_error[0:nf] + new_z_object.z_error = ( + self._dataset.transfer_function_error.values[0:nf] + ) return find_distortion( new_z_object, comp=comp, only_2d=only_2d, clockwise=clockwise diff --git a/mtpy/core/transfer_function/z_analysis/distortion.py b/mtpy/core/transfer_function/z_analysis/distortion.py index 3245b18..2777641 100644 --- a/mtpy/core/transfer_function/z_analysis/distortion.py +++ b/mtpy/core/transfer_function/z_analysis/distortion.py @@ -99,16 +99,8 @@ def find_distortion(z_object, comp="det", only_2d=False, clockwise=True): dis[index] = np.mean( np.array( [ - ( - 1.0 - / compr - * np.dot(z_object.z.real[index], rot_mat) - ), - ( - 1.0 - / compi - * np.dot(z_object.z.imag[index], rot_mat) - ), + (1.0 / compr * np.dot(z_object.z.real[index], rot_mat)), + (1.0 / compi * np.dot(z_object.z.imag[index], rot_mat)), ] ), axis=0, @@ -329,9 +321,7 @@ def find_distortion(z_object, comp="det", only_2d=False, clockwise=True): ] ) - dis_error[index] = np.mean( - np.array([dis_error_r, dis_error_i]) - ) + dis_error[index] = np.mean(np.array([dis_error_r, dis_error_i])) else: dis[index] = np.identity(2) @@ -356,6 +346,8 @@ def remove_distortion_from_z_object( the uperturbed "correct" Z0: Z = D * Z0 + units should be in MT units of mV/km/nT + Propagation of errors/uncertainties included :param logger: Defaults to None. @@ -434,6 +426,10 @@ def remove_distortion_from_z_object( z_corrected = np.zeros_like(z_object.z, dtype=complex) z_corrected_error = np.zeros_like(z_object.z, dtype=float) + # get values in mt units + z = z_object._dataset.transfer_function.values.copy() + z_error = z_object._dataset.transfer_function_error.values.copy() + for idx_f in range(len(z_object.z)): z_corrected[idx_f] = np.array(np.dot(DI, np.array(z_object.z[idx_f]))) if z_object._has_tf_error(): @@ -443,10 +439,10 @@ def remove_distortion_from_z_object( np.abs( np.array( [ - DI_error[ii, 0] * z_object.z[idx_f, 0, jj], - DI[ii, 0] * z_object.z_error[idx_f, 0, jj], - DI_error[ii, 1] * z_object.z[idx_f, 1, jj], - DI[ii, 1] * z_object.z_error[idx_f, 1, jj], + DI_error[ii, 0] * z[idx_f, 0, jj], + DI[ii, 0] * z_error[idx_f, 0, jj], + DI_error[ii, 1] * z[idx_f, 1, jj], + DI[ii, 1] * z_error[idx_f, 1, jj], ] ) ) diff --git a/mtpy/imaging/plot_mt_response.py b/mtpy/imaging/plot_mt_response.py index d3510aa..bac36da 100644 --- a/mtpy/imaging/plot_mt_response.py +++ b/mtpy/imaging/plot_mt_response.py @@ -34,35 +34,35 @@ class PlotMTResponse(PlotBase): """Plots Resistivity and phase for the different modes of the MT response. - At -the moment it supports the input of an .edi file. Other formats that will - be supported are the impedance tensor and errors with an array of periods - and .j format. - - The normal use is to input an .edi file, however it would seem that not - everyone uses this format, so you can input the data and put it into - arrays or objects like class mtpy.core.z.Z. Or if the data is in - resistivity and phase format they can be input as arrays or a class - mtpy.imaging.mtplot.ResPhase. Or you can put it into a class - mtpy.imaging.mtplot.MTplot. - - The plot places the apparent resistivity in log scale in the top panel(s), - depending on the plot_num. The phase is below this, note that 180 degrees - has been added to the yx phase so the xy and yx phases plot in the same - quadrant. Both the resistivity and phase share the same x-axis which is in - log period, short periods on the left to long periods on the right. So - if you zoom in on the plot both plots will zoom in to the same - x-coordinates. If there is tipper information, you can plot the tipper - as a third panel at the bottom, and also shares the x-axis. The arrows are - in the convention of pointing towards a conductor. The xx and yy - components can be plotted as well, this adds two panels on the right. - Here the phase is left unwrapped. Other parameters can be added as - subplots such as strike, skew and phase tensor ellipses. - - To manipulate the plot you can change any of the attributes listed below - and call redraw_plot(). If you know more aout matplotlib and want to - change axes parameters, that can be done by changing the parameters in the - axes attributes and then call update_plot(), note the plot must be open. + At + the moment it supports the input of an .edi file. Other formats that will + be supported are the impedance tensor and errors with an array of periods + and .j format. + + The normal use is to input an .edi file, however it would seem that not + everyone uses this format, so you can input the data and put it into + arrays or objects like class mtpy.core.z.Z. Or if the data is in + resistivity and phase format they can be input as arrays or a class + mtpy.imaging.mtplot.ResPhase. Or you can put it into a class + mtpy.imaging.mtplot.MTplot. + + The plot places the apparent resistivity in log scale in the top panel(s), + depending on the plot_num. The phase is below this, note that 180 degrees + has been added to the yx phase so the xy and yx phases plot in the same + quadrant. Both the resistivity and phase share the same x-axis which is in + log period, short periods on the left to long periods on the right. So + if you zoom in on the plot both plots will zoom in to the same + x-coordinates. If there is tipper information, you can plot the tipper + as a third panel at the bottom, and also shares the x-axis. The arrows are + in the convention of pointing towards a conductor. The xx and yy + components can be plotted as well, this adds two panels on the right. + Here the phase is left unwrapped. Other parameters can be added as + subplots such as strike, skew and phase tensor ellipses. + + To manipulate the plot you can change any of the attributes listed below + and call redraw_plot(). If you know more aout matplotlib and want to + change axes parameters, that can be done by changing the parameters in the + axes attributes and then call update_plot(), note the plot must be open. """ def __init__( @@ -152,9 +152,10 @@ def rotation_angle(self): def rotation_angle(self, theta_r): """Only a single value is allowed.""" if not theta_r == 0: - self.Z.rotate(theta_r) - self.Tipper.rotate(theta_r) - self.pt.rotate(theta_r) + self.Z.rotate(theta_r, inplace=True) + self.Tipper.rotate(theta_r, inplace=True) + self.pt = self.Z.phase_tensor + self.pt.rotation_angle = self.Z.rotation_angle self._rotation_angle += theta_r else: diff --git a/mtpy/imaging/plot_pt.py b/mtpy/imaging/plot_pt.py index 9ee9592..418920a 100644 --- a/mtpy/imaging/plot_pt.py +++ b/mtpy/imaging/plot_pt.py @@ -62,7 +62,7 @@ def rotation_angle(self, theta_r): self._rotation_angle = theta_r if not theta_r == 0: - self.pt.rotate(theta_r) + self.pt.rotate(theta_r, inplace=True) def _rotate_pt(self, rotation_angle): """Rotate pt. @@ -99,7 +99,10 @@ def plot(self, rotation_angle=None): color_array = self.get_pt_color_array(self.pt) # -------------plotPhaseTensor----------------------------------- - self.cbax, self.cbpt, = plot_pt_lateral( + ( + self.cbax, + self.cbpt, + ) = plot_pt_lateral( self.ax_pt, self.pt, color_array, diff --git a/mtpy/modeling/simpeg/data_2d.py b/mtpy/modeling/simpeg/data_2d.py index 38c2e7c..205d439 100644 --- a/mtpy/modeling/simpeg/data_2d.py +++ b/mtpy/modeling/simpeg/data_2d.py @@ -13,6 +13,8 @@ from simpeg.electromagnetics import natural_source as nsem from simpeg import data +import matplotlib.pyplot as plt + # ============================================================================= class Simpeg2DData: @@ -30,10 +32,38 @@ def __init__(self, dataframe, **kwargs): self.include_elevation = True self.invert_te = True self.invert_tm = True + self.invert_zxy = False + self.invert_zyx = False + + self.invert_impedance = False for key, value in kwargs.items(): setattr(self, key, value) + @property + def invert_impedance(self): + return self._invert_impedance + + @invert_impedance.setter + def invert_impedance(self, value): + if not isinstance(value, bool): + raise TypeError( + f"invert_impedance must be a boolean, not type{type(value)}" + ) + + if value: + self.invert_zxy = True + self.invert_zyx = True + self.invert_te = False + self.invert_tm = False + self._invert_impedance = True + if not value: + self.invert_zxy = False + self.invert_zyx = False + self.invert_te = True + self.invert_tm = True + self._invert_impedance = False + @property def station_locations(self): """ @@ -68,7 +98,9 @@ def frequencies(self): :rtype: TYPE """ - return 1.0 / self.dataframe.period.unique() + + # surveys sort from small to large. + return np.sort(1.0 / self.dataframe.period.unique()) @property def n_frequencies(self): @@ -87,21 +119,41 @@ def _get_mode_sources(self, simpeg_mode): """ rx_locs = self.station_locations.copy() - rx_list = [ - nsem.receivers.PointNaturalSource( - rx_locs, - orientation=simpeg_mode, - component="apparent_resistivity", - ), - nsem.receivers.PointNaturalSource( - rx_locs, orientation=simpeg_mode, component="phase" - ), - ] - - src_list = [ - nsem.sources.Planewave(rx_list, frequency=f) - for f in self.frequencies - ] + + if not self.invert_impedance: + rx_list = [ + nsem.receivers.PointNaturalSource( + rx_locs, + orientation=simpeg_mode, + component="apparent_resistivity", + ), + nsem.receivers.PointNaturalSource( + rx_locs, orientation=simpeg_mode, component="phase" + ), + ] + + src_list = [ + nsem.sources.Planewave(rx_list, frequency=f) + for f in self.frequencies + ] + else: + rx_list = [ + nsem.receivers.PointNaturalSource( + rx_locs, + orientation=simpeg_mode, + component="real", + ), + nsem.receivers.PointNaturalSource( + rx_locs, + orientation=simpeg_mode, + component="imag", + ), + ] + + src_list = [ + nsem.sources.Planewave(rx_list, frequency=f) + for f in self.frequencies + ] return nsem.Survey(src_list) @property @@ -128,13 +180,15 @@ def tm_survey(self): return self._get_mode_sources(self.component_map["tm"]["simpeg"]) - def _get_data_observations(self, mode): + def _get_data_observations(self, mode, impedance=False): """ get data the output format needs to be [frequency 1 res, frequency 1 phase, ...] and frequency is in order of smallest to largest. + Data needs to be ordered by station [te, tm](f) + :param mode: [ 'te' | 'tm' ] :type simpeg_mode: TYPE :return: DESCRIPTION @@ -142,16 +196,20 @@ def _get_data_observations(self, mode): """ - mode = self.component_map[mode]["z+"] + comp = self.component_map[mode]["z+"] # there is probably a more efficient method here using pandas - obs = [] - for ff in np.sort(self.frequencies): + res = [] + phase = [] + for ff in self.frequencies: f_df = self.dataframe[self.dataframe.period == 1.0 / ff] - obs.append(f_df[f"res_{mode}"]) - obs.append(f_df[f"phase_{mode}"]) + if not self.invert_impedance: + res.append(f_df[f"res_{comp}"]) + phase.append(f_df[f"phase_{comp}"]) + else: + res.append(f_df[f"z_{comp}"].values.real) + phase.append(f_df[f"z_{comp}"].values.imag) - obs = np.array(obs) - return obs.flatten() + return np.hstack((res, phase)).flatten() @property def te_observations(self): @@ -177,16 +235,20 @@ def _get_data_errors(self, mode): """ - mode = self.component_map[mode]["z+"] + comp = self.component_map[mode]["z+"] + res = [] + phase = [] # there is probably a more efficient method here using pandas - obs = [] for ff in np.sort(self.frequencies): f_df = self.dataframe[self.dataframe.period == 1.0 / ff] - obs.append(f_df[f"res_{mode}_model_error"]) - obs.append(f_df[f"phase_{mode}_model_error"]) + if not self.invert_impedance: + res.append(f_df[f"res_{comp}_model_error"]) + phase.append(f_df[f"phase_{comp}_model_error"]) + else: + res.append(f_df[f"z_{comp}_model_error"]) + phase.append(f_df[f"z_{comp}_model_error"]) - obs = np.array(obs) - return obs.flatten() + return np.hstack((res, phase)).flatten() @property def te_data_errors(self): @@ -227,3 +289,111 @@ def tm_data(self): dobs=self.tm_observations, standard_deviation=self.tm_data_errors, ) + + def plot_response(self, **kwargs): + """ + + :param **kwargs: DESCRIPTION + :type **kwargs: TYPE + :return: DESCRIPTION + :rtype: TYPE + + """ + + fig = plt.figure(kwargs.get("fig_num", 1)) + + te_data = self.te_data.dobs.reshape( + (self.n_frequencies, 2, self.n_stations) + ) + tm_data = self.tm_data.dobs.reshape( + (self.n_frequencies, 2, self.n_stations) + ) + + if not self.invert_impedance: + ax_xy_res = fig.add_subplot(2, 2, 1) + ax_yx_res = fig.add_subplot(2, 2, 2, sharex=ax_xy_res) + ax_xy_phase = fig.add_subplot(2, 2, 3, sharex=ax_xy_res) + ax_yx_phase = fig.add_subplot(2, 2, 4, sharex=ax_xy_res) + for ii in range(self.n_stations): + ax_xy_res.loglog( + 1.0 / self.frequencies, + te_data[:, 0, ii], + color=(0.5, 0.5, ii / self.n_stations), + ) + ax_xy_phase.semilogx( + 1.0 / self.frequencies, + te_data[:, 1, ii], + color=(0.25, 0.25, ii / self.n_stations), + ) + ax_yx_res.loglog( + 1.0 / self.frequencies, + tm_data[:, 0, ii], + color=(0.5, ii / self.n_stations, 0.75), + ) + ax_yx_phase.semilogx( + 1.0 / self.frequencies, + tm_data[:, 1, ii], + color=(0.25, ii / self.n_stations, 0.75), + ) + + ax_xy_phase.set_xlabel("Period (s)") + ax_yx_phase.set_xlabel("Period (s)") + ax_xy_res.set_ylabel("Apparent Resistivity") + ax_xy_phase.set_ylabel("Phase") + + ax_xy_res.set_title("TE") + ax_yx_res.set_title("TM") + else: + ax_xy_res = fig.add_subplot(2, 2, 1) + ax_yx_res = fig.add_subplot( + 2, 2, 2, sharex=ax_xy_res, sharey=ax_xy_res + ) + ax_xy_phase = fig.add_subplot( + 2, + 2, + 3, + sharex=ax_xy_res, + ) + ax_yx_phase = fig.add_subplot( + 2, 2, 4, sharex=ax_xy_res, sharey=ax_xy_phase + ) + for ii in range(self.n_stations): + ax_xy_res.loglog( + 1.0 / self.frequencies, + np.abs(te_data[:, 0, ii]), + color=(0.5, 0.5, ii / self.n_stations), + ) + ax_xy_phase.loglog( + 1.0 / self.frequencies, + np.abs(te_data[:, 1, ii]), + color=(0.25, 0.25, ii / self.n_stations), + ) + ax_yx_res.loglog( + 1.0 / self.frequencies, + np.abs(tm_data[:, 0, ii]), + color=(0.5, ii / self.n_stations, 0.75), + ) + ax_yx_phase.loglog( + 1.0 / self.frequencies, + np.abs(tm_data[:, 1, ii]), + color=(0.25, ii / self.n_stations, 0.75), + ) + + ax_xy_phase.set_xlabel("Period (s)") + ax_yx_phase.set_xlabel("Period (s)") + ax_xy_res.set_ylabel("Real Impedance [Ohms]") + ax_xy_phase.set_ylabel("Imag Impedance [Ohms]") + + ax_xy_res.set_title("Zxy (TE)") + ax_yx_res.set_title("Zyx (TM)") + + for ax in [ax_xy_res, ax_xy_phase, ax_yx_res, ax_yx_phase]: + ax.grid( + True, + alpha=0.25, + which="both", + color=(0.25, 0.25, 0.25), + lw=0.25, + ) + + plt.show() diff --git a/mtpy/modeling/simpeg/make_2d_mesh.py b/mtpy/modeling/simpeg/make_2d_mesh.py index 9307405..5efa176 100644 --- a/mtpy/modeling/simpeg/make_2d_mesh.py +++ b/mtpy/modeling/simpeg/make_2d_mesh.py @@ -12,93 +12,235 @@ from discretize import TensorMesh from discretize import TreeMesh -from discretize.utils import mkvc -import matplotlib.pyplot as plt -from discretize.utils import active_from_xyz # from dask.distributed import Client, LocalCluster from geoana.em.fdem import skin_depth import discretize.utils as dis_utils import warnings +import matplotlib.pyplot as plt + warnings.filterwarnings("ignore") # ============================================================================= -def generate_2d_mesh_structured( - rx_locs, - frequencies, - sigma_background, - z_factor_max=5, - z_factor_min=5, - pfz_down=1.2, - pfz_up=1.5, - npadz_up=5, - x_factor_max=2, - spacing_factor=4, - pfx=1.5, - n_max=1000, -): - """Creat a 2D structured mesh, the typical way to model the data with uniform - horizontal cells in the station area and geometrically increasing down and - padding cells. - :param rx_locs: DESCRIPTION. - :type rx_locs: TYPE - :param frequencies: DESCRIPTION. - :type frequencies: TYPE - :param sigma_background: DESCRIPTION. - :type sigma_background: TYPE - :param z_factor_max: DESCRIPTION, defaults to 5. - :type z_factor_max: TYPE, optional - :param z_factor_min: DESCRIPTION, defaults to 5. - :type z_factor_min: TYPE, optional - :param pfz_down: DESCRIPTION2, defaults to 1.2. - :type pfz_down: TYPE, optional - :param pfz_up: DESCRIPTION5, defaults to 1.5. - :type pfz_up: TYPE, optional - :param npadz_up: DESCRIPTION, defaults to 5. - :type npadz_up: TYPE, optional - :param x_factor_max: DESCRIPTION, defaults to 2. - :type x_factor_max: TYPE, optional - :param spacing_factor: DESCRIPTION, defaults to 4. - :type spacing_factor: TYPE, optional - :param pfx: DESCRIPTION5, defaults to 1.5. - :type pfx: TYPE, optional - :param n_max: DESCRIPTION, defaults to 1000. - :type n_max: TYPE, optional - :return: DESCRIPTION. - :rtype: TYPE - """ - # Setting the cells in depth dimension - f_min = frequencies.min() - f_max = frequencies.max() - dz_min = np.round(skin_depth(f_max, sigma_background) / z_factor_max) - lz = skin_depth(sigma_background, f_min) * z_factor_max - # Setting the domain length in z-direction - for nz_down in range(n_max): - hz_down = dz_min * pfz_down ** np.arange(nz_down)[::-1] - if hz_down.sum() > lz: - break - hz_up = [(dz_min, npadz_up, pfz_up)] - hz_up = dis_utils.unpack_widths(hz_up) - hz = np.r_[hz_down, hz_up] - # Setting the cells in lateral dimension - d_station = np.diff(rx_locs[:, 0]).min() - dx_min = np.round(d_station / spacing_factor) - lx = rx_locs[:, 0].max() - rx_locs[:, 0].min() - ncx = int(lx / dx_min) - lx_pad = skin_depth(sigma_background, f_min) * x_factor_max - for npadx in range(n_max): - hx_pad = dis_utils.meshTensor([(dx_min, npadx, -pfx)]) - if hx_pad.sum() > lx_pad: - break - hx = [(dx_min, npadx, -pfx), (dx_min, ncx), (dx_min, npadx, pfx)] - - mesh = TensorMesh([hx, hz]) - mesh.origin = np.r_[ - -mesh.hx[:npadx].sum() + rx_locs[:, 0].min(), -hz_down.sum() - ] - return mesh +class StructuredMesh: + def __init__(self, station_locations, frequencies, **kwargs): + self.station_locations = station_locations + self.frequencies = frequencies + self.topography = None + + self.sigma_background = 0.01 + + self.z_factor_min = 5 + self.z_factor_max = 15 + + self.z_geometric_factor_up = 1.5 + self.z_geometric_factor_down = 1.2 + + self.n_pad_z_up = 5 + self.x_factor_max = 2 + self.x_spacing_factor = 4 + self.x_padding_geometric_factor = 1.5 + self.n_max = 1000 + + for key, value in kwargs.items(): + setattr(self, key, value) + + @property + def frequency_max(self): + return self.frequencies.max() + + @property + def frequency_min(self): + return self.frequencies.min() + + @property + def z1_layer_thickness(self): + return np.round( + skin_depth(self.frequency_max, self.sigma_background) + / self.z_factor_max + ) + + @property + def z_bottom(self): + return ( + skin_depth(self.sigma_background, self.frequency_min) + * self.z_factor_max + ) + + @property + def z_mesh_down(self): + for nz_down in range(self.n_max): + z_mesh_down = ( + self.z1_layer_thickness + * self.z_geometric_factor_down ** np.arange(nz_down)[::-1] + ) + if z_mesh_down.sum() > self.z_bottom: + break + return z_mesh_down + + @property + def z_mesh_up(self): + z_mesh_up = [ + ( + self.z1_layer_thickness, + self.n_pad_z_up, + self.z_geometric_factor_up, + ) + ] + return dis_utils.unpack_widths(z_mesh_up) + + def _make_z_mesh(self): + """ + create vertical mesh + """ + + return np.r_[self.z_mesh_down, self.z_mesh_up] + + @property + def dx(self): + d_station = np.diff(self.station_locations[:, 0]).min() + return np.round(d_station / self.x_spacing_factor) + + @property + def station_total_length(self): + return ( + self.station_locations[:, 0].max() + - self.station_locations[:, 0].min() + ) + + @property + def n_station_x_cells(self): + return int(self.station_total_length / self.dx) + + @property + def x_padding_cells(self): + return ( + skin_depth(self.sigma_background, self.frequency_min) + * self.x_factor_max + ) + + @property + def n_x_padding(self): + for npadx in range(self.n_max): + x_pad = dis_utils.unpack_widths( + [ + ( + self.dx, + npadx, + -self.x_padding_geometric_factor, + ) + ] + ) + if x_pad.sum() > self.x_padding_cells: + break + return npadx + + def _make_x_mesh(self): + """ + make horizontal mesh + + :return: DESCRIPTION + :rtype: TYPE + + """ + + return [ + ( + self.dx, + self.n_x_padding, + -self.x_padding_geometric_factor, + ), + (self.dx, self.n_station_x_cells), + ( + self.dx, + self.n_x_padding, + self.x_padding_geometric_factor, + ), + ] + + def make_mesh(self): + """ + create structured mesh + + :return: DESCRIPTION + :rtype: TYPE + + """ + + z_mesh = self._make_z_mesh() + x_mesh = self._make_x_mesh() + + mesh = TensorMesh([x_mesh, z_mesh]) + mesh.origin = np.r_[ + -mesh.h[0][: self.n_x_padding].sum() + + self.station_locations[:, 0].min(), + -self.z_mesh_down.sum(), + ] + + self.mesh = mesh + return self.plot_mesh() + + @property + def active_cell_index(self): + """ + return active cell mask + + TODO: include topographic surface + + :return: DESCRIPTION + :rtype: TYPE + + """ + + if self.topography is None: + return self.mesh.cell_centers[:, 1] < 0 + + else: + raise NotImplementedError("Have not included topography yet.") + + @property + def number_of_active_cells(self): + """ + number of active cells + """ + return int(self.active_cell_index.sum()) + + def plot_mesh(self, **kwargs): + """ + plot the mesh + """ + + if self.mesh is not None: + fig = plt.figure(kwargs.get("fig_num", 1)) + ax = fig.add_subplot(1, 1, 1) + self.mesh.plot_image( + self.active_cell_index, + ax=ax, + grid=True, + grid_opts={"color": (0.75, 0.75, 0.75), "linewidth": 0.1}, + **kwargs + ) + ax.scatter( + self.station_locations[:, 0], + self.station_locations[:, 1], + marker=kwargs.get("marker", "v"), + s=kwargs.get("s", 35), + c=kwargs.get("c", (0, 0, 0)), + zorder=1000, + ) + ax.set_xlim( + kwargs.get( + "xlim", + ( + self.station_locations[:, 0].min() - (2 * self.dx), + self.station_locations[:, 0].max() + (2 * self.dx), + ), + ) + ) + ax.set_ylim(kwargs.get("ylim", (-10000, 1000))) + return ax class QuadTreeMesh: diff --git a/mtpy/modeling/simpeg/recipes/inversion_2d.py b/mtpy/modeling/simpeg/recipes/inversion_2d.py index ededfa2..7d09797 100644 --- a/mtpy/modeling/simpeg/recipes/inversion_2d.py +++ b/mtpy/modeling/simpeg/recipes/inversion_2d.py @@ -43,7 +43,7 @@ # from dask.distributed import Client, LocalCluster from mtpy.modeling.simpeg.data_2d import Simpeg2DData -from mtpy.modeling.simpeg.make_2d_mesh import QuadTreeMesh +from mtpy.modeling.simpeg.make_2d_mesh import QuadTreeMesh, StructuredMesh warnings.filterwarnings("ignore") @@ -58,13 +58,36 @@ class Simpeg2D: - For now the default is a quad tree mesh - Optimization: Inexact Gauss Newton - Regularization: Sparse + + # change mesh to tensor mesh. """ - def __init__(self, dataframe, data_kwargs={}, mesh_kwargs={}, **kwargs): + def __init__( + self, + dataframe, + data_kwargs={}, + mesh_kwargs={}, + mesh_type="tensor", + **kwargs, + ): self.data = Simpeg2DData(dataframe, **data_kwargs) - self.quad_tree = QuadTreeMesh( - self.data.station_locations, self.data.frequencies, **mesh_kwargs - ) + if mesh_type in ["tensor"]: + self.mesh = StructuredMesh( + self.data.station_locations, + self.data.frequencies, + **mesh_kwargs, + ) + elif mesh_type in ["quad", "tree", "quadtree"]: + self.mesh = QuadTreeMesh( + self.data.station_locations, + self.data.frequencies, + **mesh_kwargs, + ) + else: + raise ValueError(f"mesh {mesh_type} is unsupported.") + + self.mesh_type = mesh_type + self.ax = self.make_mesh() self.air_conductivity = 1e-8 self.initial_conductivity = 1e-2 @@ -77,9 +100,9 @@ def __init__(self, dataframe, data_kwargs={}, mesh_kwargs={}, **kwargs): self._solvers_dict = {} # regularization parameters - self.alpha_s = 1e-5 - self.alpha_y = 1 / 5.0 - self.alpha_z = 1.0 + self.alpha_s = 1e-15 + self.alpha_y = 1 + self.alpha_z = 0.5 # optimization parameters self.max_iterations = 30 @@ -87,6 +110,7 @@ def __init__(self, dataframe, data_kwargs={}, mesh_kwargs={}, **kwargs): self.max_iterations_irls = 40 self.minimum_gauss_newton_iterations = 1 self.f_min_change = 1e-5 + self.optimization_tolerance = 1e-30 # inversion parameters self.use_irls = False @@ -149,7 +173,7 @@ def make_mesh(self, **kwargs): """ make QuadTree Mesh """ - ax = self.quad_tree.make_mesh(**kwargs) + ax = self.mesh.make_mesh(**kwargs) return ax @property @@ -162,8 +186,8 @@ def active_map(self): """ return maps.InjectActiveCells( - self.quad_tree.mesh, - self.quad_tree.active_cell_index, + self.mesh.mesh, + self.mesh.active_cell_index, np.log(self.air_conductivity), ) @@ -176,7 +200,7 @@ def exponent_map(self): """ - return maps.ExpMap(mesh=self.quad_tree.mesh) + return maps.ExpMap(mesh=self.mesh.mesh) @property def conductivity_map(self): @@ -206,14 +230,14 @@ def tm_simulation(self): solver = self._get_solver() if solver is not None: return nsem.simulation.Simulation2DElectricField( - self.quad_tree.mesh, + self.mesh.mesh, survey=self.data.tm_survey, sigmaMap=self.conductivity_map, solver=solver, ) else: return nsem.simulation.Simulation2DElectricField( - self.quad_tree.mesh, + self.mesh.mesh, survey=self.data.tm_survey, sigmaMap=self.conductivity_map, ) @@ -226,14 +250,14 @@ def te_simulation(self): solver = self._get_solver() if solver is not None: return nsem.simulation.Simulation2DMagneticField( - self.quad_tree.mesh, + self.mesh.mesh, survey=self.data.te_survey, sigmaMap=self.conductivity_map, solver=solver, ) else: nsem.simulation.Simulation2DMagneticField( - self.quad_tree.mesh, + self.mesh.mesh, survey=self.data.te_survey, sigmaMap=self.conductivity_map, ) @@ -274,7 +298,7 @@ def reference_model(self): :rtype: TYPE """ - return np.ones(self.quad_tree.number_of_active_cells) * np.log( + return np.ones(self.mesh.number_of_active_cells) * np.log( self.initial_conductivity ) @@ -293,13 +317,13 @@ def regularization(self): """ reg = regularization.Sparse( - self.quad_tree.mesh, - active_cells=self.quad_tree.active_cell_index, + self.mesh.mesh, + active_cells=self.mesh.active_cell_index, reference_model=self.reference_model, alpha_s=self.alpha_s, alpha_x=self.alpha_y, - alpha_z=self.alpha_z, - mapping=maps.IdentityMap(nP=self.quad_tree.number_of_active_cells), + alpha_y=self.alpha_z, + mapping=maps.IdentityMap(nP=self.mesh.number_of_active_cells), ) if self.use_irls: @@ -316,7 +340,9 @@ def optimization(self): """ return optimization.InexactGaussNewton( - maxIter=self.max_iterations, maxIterCG=self.max_iterations_cg + maxIter=self.max_iterations, + maxIterCG=self.max_iterations_cg, + tolX=self.optimization_tolerance, ) @property @@ -399,7 +425,7 @@ def directives(self): self.starting_beta, self.beta_schedule, self.saved_model_outputs, - self.target_misfit, + # self.target_misfit, ] def run_inversion(self): @@ -428,8 +454,8 @@ def plot_iteration(self, iteration_number, resistivity=True, **kwargs): ax = fig.add_subplot(1, 1, 1) m = self.iterations[iteration_number]["m"] - sigma = np.ones(self.quad_tree.mesh.nC) * self.air_conductivity - sigma[self.quad_tree.active_cell_index] = np.exp(m) + sigma = np.ones(self.mesh.mesh.nC) * self.air_conductivity + sigma[self.mesh.active_cell_index] = np.exp(m) if resistivity: sigma = 1.0 / sigma vmin = kwargs.get("vmin", 0.3) @@ -439,7 +465,7 @@ def plot_iteration(self, iteration_number, resistivity=True, **kwargs): vmin = kwargs.get("vmin", 1e-3) vmax = kwargs.get("vmax", 1) cmap = kwargs.get("cmap", "turbo") - out = self.quad_tree.mesh.plot_image( + out = self.mesh.mesh.plot_image( sigma, grid=False, ax=ax, @@ -448,12 +474,12 @@ def plot_iteration(self, iteration_number, resistivity=True, **kwargs): "cmap": cmap, }, range_x=( - self.data.station_locations[:, 0].min() - 5 * self.quad_tree.dx, - self.data.station_locations[:, 0].max() + 5 * self.quad_tree.dx, + self.data.station_locations[:, 0].min() - 5 * self.mesh.dx, + self.data.station_locations[:, 0].max() + 5 * self.mesh.dx, ), range_y=kwargs.get( "z_limits", - (-self.quad_tree.mesh.h[1].sum() / 2, 500), + (-self.mesh.mesh.h[1].sum() / 2, 500), ), ) cb = plt.colorbar(out[0], fraction=0.01, ax=ax) @@ -464,7 +490,7 @@ def plot_iteration(self, iteration_number, resistivity=True, **kwargs): ax.set_aspect(1) ax.set_xlabel("Offset (m)") ax.set_ylabel("Elevation (m)") - if self.quad_tree.topography: + if self.mesh.topography: ax.scatter( self.data.station_locations[:, 0], self.data.station_locations[:, 1], @@ -506,6 +532,16 @@ def plot_tikhonov_curve(self): mfc="r", ) + for key in self.iterations.keys(): + ax.text( + self.iterations[key]["phi_m"], + self.iterations[key]["phi_d"], + key, + fontdict={"size": 8}, + ha="center", + va="center", + ) + ax.set_xlabel("$\phi_m$ [model smallness]") ax.set_ylabel("$\phi_d$ [data fit]") ax.set_xscale("log") @@ -513,10 +549,193 @@ def plot_tikhonov_curve(self): xlim = ax.get_xlim() ax.plot( xlim, - np.ones(2) - * (self.data.te_observations.size + self.data.tm_observations.size), + np.ones(2) * (self.data.te_survey.nD + self.data.tm_survey.nD), "--", ) ax.set_xlim(xlim) plt.tight_layout() plt.show() + + def plot_responses(self, iteration_number, **kwargs): + """ + Plot responses all together + + :param iteration: DESCRIPTION + :type iteration: TYPE + :param **kwargs: DESCRIPTION + :type **kwargs: TYPE + :return: DESCRIPTION + :rtype: TYPE + + """ + shape = (self.data.n_frequencies, 2, self.data.n_stations) + + dpred = self.iterations[iteration_number]["dpred"] + te_pred = dpred.reshape( + (2, self.data.n_frequencies, 2, self.data.n_stations) + )[0, :, :, :] + tm_pred = dpred.reshape( + (2, self.data.n_frequencies, 2, self.data.n_stations) + )[1, :, :, :] + + te_obs = self.data.te_data.dobs.copy().reshape(shape) + tm_obs = self.data.tm_data.dobs.copy().reshape(shape) + + obs_color = kwargs.get("obs_color", (0, 118 / 255, 1)) + pred_color = kwargs.get("pred_color", (1, 110 / 255, 0)) + obs_marker = "." + pred_maker = "." + + ## With these plot frequency goes from high on the left to low on the right. + ## Moving shallow to deep from left to right. + + fig = plt.figure(figsize=(10, 3)) + + if not self.data.invert_impedance: + ax1 = fig.add_subplot(2, 2, 1) + ax2 = fig.add_subplot(2, 2, 2, sharex=ax1) + ax3 = fig.add_subplot(2, 2, 3, sharex=ax1) + ax4 = fig.add_subplot(2, 2, 4, sharex=ax1) + + # plot TE Resistivity + ax1.semilogy( + te_obs[:, 0, :].flatten(), + ".", + color=obs_color, + label="observed", + ) + ax1.semilogy( + te_pred[:, 0, :].flatten(), + ".", + color=pred_color, + label="predicted", + ) + ax1.set_title("TE") + ax1.set_ylabel("Apparent Resistivity") + ax1.set_xlim((self.data.n_stations * self.data.n_frequencies, 0)) + ax1.legend() + + # plot TM Resistivity + ax2.semilogy( + tm_obs[:, 0, :].flatten(), + obs_marker, + color=obs_color, + label="observed", + ) + ax2.semilogy( + tm_pred[:, 0, :].flatten(), + pred_maker, + color=pred_color, + label="predicted", + ) + ax2.set_title("TM") + ax2.legend() + + # plot TE Phase + ax3.plot( + te_obs[:, 1, :].flatten(), + obs_marker, + color=obs_color, + label="observed", + ) + ax3.plot( + te_pred[:, 1, :].flatten(), + pred_maker, + color=pred_color, + label="predicted", + ) + ax3.set_xlabel("data point") + ax3.set_ylabel("Phase") + ax3.legend() + + # plot TM Phase + ax4.plot( + tm_obs[:, 1, :].flatten(), + obs_marker, + color=obs_color, + label="observed", + ) + ax4.plot( + tm_pred[:, 1, :].flatten(), + pred_maker, + color=pred_color, + label="predicted", + ) + ax3.legend() + + if self.data.invert_impedance: + te_pred = np.abs(te_pred) + tm_pred = np.abs(tm_pred) + te_obs = np.abs(te_obs) + tm_obs = np.abs(tm_obs) + + ax1 = fig.add_subplot(2, 2, 1) + ax2 = fig.add_subplot(2, 2, 2, sharex=ax1, sharey=ax1) + ax3 = fig.add_subplot(2, 2, 3, sharex=ax1) + ax4 = fig.add_subplot(2, 2, 4, sharex=ax1, sharey=ax3) + + # plot TE Resistivity + ax1.semilogy( + np.abs(te_obs[:, 0, :].flatten()), + ".", + color=obs_color, + label="observed", + ) + ax1.semilogy( + np.abs(te_pred[:, 0, :].flatten()), + ".", + color=pred_color, + label="predicted", + ) + ax1.set_title("TE") + ax1.set_ylabel("Real Impedance [Ohms]") + ax1.set_xlim((self.data.n_stations * self.data.n_frequencies, 0)) + ax1.legend() + + # plot TM Resistivity + ax2.semilogy( + np.abs(tm_obs[:, 0, :].flatten()), + obs_marker, + color=obs_color, + label="observed", + ) + ax2.semilogy( + np.abs(tm_pred[:, 0, :].flatten()), + pred_maker, + color=pred_color, + label="predicted", + ) + ax2.set_title("TM") + ax2.legend() + + # plot TE Phase + ax3.plot( + np.abs(te_obs[:, 1, :].flatten()), + obs_marker, + color=obs_color, + label="observed", + ) + ax3.plot( + np.abs(te_pred[:, 1, :].flatten()), + pred_maker, + color=pred_color, + label="predicted", + ) + ax3.set_xlabel("data point") + ax3.set_ylabel("Imag Impedance [Ohms]") + ax3.legend() + + # plot TM Phase + ax4.plot( + np.abs(tm_obs[:, 1, :].flatten()), + obs_marker, + color=obs_color, + label="observed", + ) + ax4.plot( + np.abs(tm_pred[:, 1, :].flatten()), + pred_maker, + color=pred_color, + label="predicted", + ) + ax3.legend() diff --git a/setup.py b/setup.py index 1fb4129..ac3d8e3 100644 --- a/setup.py +++ b/setup.py @@ -66,7 +66,7 @@ test_suite="tests", tests_require=test_requirements, url="https://github.com/MTgeophysics/mtpy-v2", - version="2.0.11", + version="2.0.12", zip_safe=False, package_data={"": []}, ) diff --git a/tests/core/test_mt.py b/tests/core/test_mt.py index f50f8ab..38430e5 100644 --- a/tests/core/test_mt.py +++ b/tests/core/test_mt.py @@ -13,6 +13,7 @@ import numpy as np from mtpy import MT from mtpy.core.mt_dataframe import MTDataFrame +from mtpy.core.transfer_function import MT_TO_OHM_FACTOR, Z from mt_metadata import TF_EDI_CGG @@ -71,6 +72,16 @@ def test_copy(self): self.assertEqual(self.mt, mt_copy) + def test_impedance_units(self): + + def set_units(unit): + self.mt.impedance_units = unit + + with self.subTest("bad type"): + self.assertRaises(TypeError, set_units, 4) + with self.subTest("bad choice"): + self.assertRaises(ValueError, set_units, "ants") + class TestMTFromKWARGS(unittest.TestCase): def setUp(self): @@ -100,9 +111,7 @@ def setUpClass(self): [[[35.26438968, 0.20257033], [0.20257033, 35.26438968]]] ) - self.pt = np.array( - [[[1.00020002, -0.020002], [-0.020002, 1.00020002]]] - ) + self.pt = np.array([[[1.00020002, -0.020002], [-0.020002, 1.00020002]]]) self.pt_error = np.array( [[[0.01040308, 0.02020604], [0.02020604, 0.01040308]]] ) @@ -242,6 +251,176 @@ def test_remove_component(self): self.assertTrue(np.all(np.isnan(new_mt.Z.z[:, 0, 0]))) +class TestMTSetImpedanceOhm(unittest.TestCase): + @classmethod + def setUpClass(self): + self.z = np.array( + [[0.1 - 0.1j, 10 + 10j], [-10 - 10j, -0.1 + 0.1j]] + ).reshape((1, 2, 2)) + self.z_ohm = self.z / MT_TO_OHM_FACTOR + self.z_err = np.array([[0.1, 0.05], [0.05, 0.1]]).reshape((1, 2, 2)) + self.z_err_ohm = self.z_err / MT_TO_OHM_FACTOR + self.res = np.array([[[4.0e-03, 4.0e01], [4.0e01, 4.0e-03]]]) + self.res_err = np.array( + [[[0.00565685, 0.28284271], [0.28284271, 0.00565685]]] + ) + self.phase = np.array([[[-45.0, 45.0], [-135.0, 135.0]]]) + self.phase_err = np.array( + [[[35.26438968, 0.20257033], [0.20257033, 35.26438968]]] + ) + + self.pt = np.array([[[1.00020002, -0.020002], [-0.020002, 1.00020002]]]) + self.pt_error = np.array( + [[[0.01040308, 0.02020604], [0.02020604, 0.01040308]]] + ) + self.pt_azimuth = np.array([315.0]) + self.pt_azimuth_error = np.array([3.30832308]) + self.pt_skew = np.array([0]) + self.pt_skew_error = np.array([0.40923428]) + + self.z_object = Z( + z=self.z_ohm, + z_error=self.z_err_ohm, + z_model_error=self.z_err_ohm, + units="ohm", + ) + self.mt = MT() + self.mt.station = "mt001" + self.mt.Z = self.z_object + self.z_object.units = "ohm" + + def test_impedance_units(self): + self.assertEqual(self.mt.impedance_units, "ohm") + + def test_period(self): + self.assertTrue((np.array([1]) == self.mt.period).all()) + + def test_impedance(self): + self.assertTrue((self.mt.impedance == self.z).all()) + + def test_z_impedance_ohm(self): + self.assertTrue((self.mt.Z.z == self.z_ohm).all()) + + def test_impedance_error(self): + self.assertTrue(np.allclose(self.mt.impedance_error, self.z_err)) + + def test_z_impedance_error_ohm(self): + self.assertTrue(np.allclose(self.mt.Z.z_error, self.z_err_ohm)) + + def test_impedance_model_error(self): + self.assertTrue(np.allclose(self.mt.impedance_model_error, self.z_err)) + + def test_resistivity(self): + self.assertTrue(np.allclose(self.mt.Z.resistivity, self.res)) + + def test_resistivity_error(self): + self.assertTrue(np.allclose(self.mt.Z.resistivity_error, self.res_err)) + + def test_resistivity_model_error(self): + self.assertTrue( + np.allclose(self.mt.Z.resistivity_model_error, self.res_err) + ) + + def test_phase(self): + self.assertTrue(np.allclose(self.mt.Z.phase, self.phase)) + + def test_phase_error(self): + self.assertTrue(np.allclose(self.mt.Z.phase_error, self.phase_err)) + + def test_phase_model_error(self): + self.assertTrue( + np.allclose(self.mt.Z.phase_model_error, self.phase_err) + ) + + def test_phase_tensor(self): + self.assertTrue(np.allclose(self.pt, self.mt.pt.pt)) + + def test_phase_tensor_error(self): + self.assertTrue(np.allclose(self.pt_error, self.mt.pt.pt_error)) + + def test_phase_tensor_model_error(self): + self.assertTrue(np.allclose(self.pt_error, self.mt.pt.pt_model_error)) + + def test_phase_tensor_azimuth(self): + self.assertTrue(np.allclose(self.pt_azimuth, self.mt.pt.azimuth)) + + def test_phase_tensor_azimuth_error(self): + self.assertTrue( + np.allclose(self.pt_azimuth_error, self.mt.pt.azimuth_error) + ) + + def test_phase_tensor_azimuth_model_error(self): + self.assertTrue( + np.allclose(self.pt_azimuth_error, self.mt.pt.azimuth_model_error) + ) + + def test_phase_tensor_skew(self): + self.assertTrue(np.allclose(self.pt_skew, self.mt.pt.skew)) + + def test_phase_tensor_skew_error(self): + self.assertTrue(np.allclose(self.pt_skew_error, self.mt.pt.skew_error)) + + def test_phase_tensor_skew_model_error(self): + self.assertTrue( + np.allclose(self.pt_skew_error, self.mt.pt.skew_model_error) + ) + + def test_remove_static_shift(self): + new_mt = self.mt.remove_static_shift(ss_x=0.5, ss_y=1.5, inplace=False) + + self.assertTrue( + np.allclose( + (self.mt.impedance.data / new_mt.impedance.data) ** 2, + np.array( + [[[0.5 + 0.0j, 0.5 + 0.0j], [1.5 - 0.0j, 1.5 - 0.0j]]] + ), + ) + ) + + def test_remove_distortion(self): + new_mt = self.mt.remove_distortion() + + self.assertTrue( + np.allclose( + new_mt.Z.z, + np.array( + [ + [ + [ + 0.00012566 - 0.00012566j, + 0.01256574 + 0.01256574j, + ], + [ + -0.01256574 - 0.01256574j, + -0.00012566 + 0.00012566j, + ], + ] + ] + ), + ) + ) + + def test_interpolate_fail_bad_f_type(self): + self.assertRaises( + ValueError, self.mt.interpolate, [0, 1], f_type="wrong" + ) + + def test_interpolate_fail_bad_periods(self): + self.assertRaises(ValueError, self.mt.interpolate, [0.1, 2]) + + def test_phase_flip(self): + new_mt = self.mt.flip_phase(zxy=True, inplace=False) + + self.assertTrue( + np.all(np.isclose(new_mt.Z.phase_xy % 180, self.mt.Z.phase_xy)) + ) + + def test_remove_component(self): + new_mt = self.mt.remove_component(zxx=True, inplace=False) + + self.assertTrue(np.all(np.isnan(new_mt.Z.z[:, 0, 0]))) + + class TestMTComputeModelError(unittest.TestCase): def setUp(self): self.z = np.array( @@ -367,6 +546,25 @@ def test_from_dataframe_fail(self): self.assertRaises(TypeError, self.m1.from_dataframe, "a") +class TestMT2DataFrameOhms(unittest.TestCase): + @classmethod + def setUpClass(self): + self.m1 = MT(TF_EDI_CGG) + self.m1.read() + + self.mt_df = self.m1.to_dataframe(impedance_units="ohm") + + def test_impedance_in_ohms(self): + z_obj = self.m1.Z + z_obj.units = "ohm" + + self.assertEqual(z_obj, self.mt_df.to_z_object(units="ohm")) + + def test_impedance_not_equal(self): + + self.assertNotEqual(self.m1.Z, self.mt_df.to_z_object(units="mt")) + + # ============================================================================= # Run # ============================================================================= diff --git a/tests/core/test_mt_data.py b/tests/core/test_mt_data.py new file mode 100644 index 0000000..659025e --- /dev/null +++ b/tests/core/test_mt_data.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +""" +Created on Thu Oct 17 16:40:56 2024 + +@author: jpeacock +""" +# ============================================================================= +# Imports +# ============================================================================= +import unittest + +from mtpy import MT, MTData + +# ============================================================================= + + +class TestMTData(unittest.TestCase): + @classmethod + def setUpClass(self): + self.utm_epsg = 3216 + self.datum_epsg = 4236 + self.mt_list_01 = [ + MT( + survey="a", + station=f"mt{ii:02}", + latitude=40 + ii, + longitude=-118, + ) + for ii in range(4) + ] + self.mt_list_02 = [ + MT( + survey="b", + station=f"mt{ii:02}", + latitude=45 + ii, + longitude=-118, + ) + for ii in range(4) + ] + + self.md = MTData( + mt_list=self.mt_list_01 + self.mt_list_02, utm_epsg=self.utm_epsg + ) + + def test_validate_item_fail(self): + self.assertRaises(TypeError, self.md._validate_item, 10) + + def test_eq(self): + md = MTData( + mt_list=self.mt_list_01 + self.mt_list_02, utm_epsg=self.utm_epsg + ) + + self.assertEqual(self.md, md) + + def test_neq(self): + md = MTData(mt_list=self.mt_list_01, utm_epsg=self.utm_epsg) + + self.assertNotEqual(self.md, md) + + def test_deep_copy(self): + md = self.md.copy() + self.assertEqual(self.md, md) + + def test_utm_epsg(self): + self.assertEqual(self.md.utm_epsg, self.utm_epsg) + + def test_clone_empty(self): + md_empty = self.md.clone_empty() + + for attr in self.md._copy_attrs: + with self.subTest(attr): + self.assertEqual( + getattr(self.md, attr), getattr(md_empty, attr) + ) + + def test_initialization_utm_epsg_no_mt_list(self): + md = MTData(utm_epsg=self.utm_epsg) + self.assertEqual(md.utm_epsg, self.utm_epsg) + + def test_coordinate_reference_frame(self): + self.assertEqual("NED", self.md.coordinate_reference_frame) + + def test_coordinate_reference_frame_set(self): + md = MTData(mt_list=self.mt_list_01, coordinate_reference_frame="enu") + + with self.subTest("mtdata"): + self.assertEqual("ENU", md.coordinate_reference_frame) + + for mt_obj in md.values(): + with self.subTest(mt_obj.station): + self.assertEqual("ENU", mt_obj.coordinate_reference_frame) + + def test_initialization_datum_epsg_no_mt_list(self): + md = MTData(datum_epsg=self.datum_epsg) + self.assertEqual(md.datum_epsg, self.datum_epsg) + + def test_survey_ids(self): + self.assertListEqual(["a", "b"], sorted(self.md.survey_ids)) + + def test_get_survey(self): + a = self.md.get_survey("a") + + with self.subTest("length"): + self.assertEqual(4, len(a)) + + for attr in self.md._copy_attrs: + with self.subTest(attr): + self.assertEqual(getattr(self.md, attr), getattr(a, attr)) + + def test_rotate_inplace(self): + md = self.md.copy() + md.rotate(30) + + with self.subTest("MTData rotation angle"): + self.assertEqual(md.data_rotation_angle, 30) + + with self.subTest("MT rotation angle"): + self.assertEqual(md["a.mt01"].rotation_angle, 30) + + def test_rotate_not_inplace(self): + md_rot = self.md.rotate(30, inplace=False) + + with self.subTest("MTData rotation angle"): + self.assertEqual(md_rot.data_rotation_angle, 30) + + with self.subTest("MT rotation angle"): + self.assertEqual(md_rot["a.mt01"].rotation_angle, 30) + + # def test_get_station_from_id(self): + # a = self.md.get_station("mt01") + # self.assertEqual(a.station, "mt01") + + def test_get_station_from_key(self): + a = self.md.get_station(station_key="a.mt01") + self.assertEqual(a.station, "mt01") + + def test_get_subset_from_ids_fail(self): + station_list = ["mt01", "mt02"] + self.assertRaises(KeyError, self.md.get_subset, station_list) + + def test_get_subset_from_keys(self): + station_keys = ["a.mt01", "b.mt02"] + md = self.md.get_subset(station_keys) + with self.subTest("keys"): + self.assertListEqual(station_keys, list(md.keys())) + + for attr in self.md._copy_attrs: + with self.subTest(attr): + self.assertEqual(getattr(self.md, attr), getattr(md, attr)) + + def test_n_station(self): + self.assertEqual(8, self.md.n_stations) + + +class TestMTDataMethods(unittest.TestCase): + @classmethod + def setUpClass(self): + self.utm_epsg = 3216 + self.datum_epsg = 4236 + self.mt_list_01 = [ + MT( + survey="a", + station=f"mt{ii:02}", + latitude=40 + ii, + longitude=-118, + ) + for ii in range(4) + ] + + def setUp(self): + self.md = MTData() + + def test_add_station(self): + self.md.add_station(self.mt_list_01[0]) + + self.assertListEqual(["a.mt00"], list(self.md.keys())) + + def test_remove_station(self): + self.md.add_station(self.mt_list_01) + self.md.remove_station("mt00", "a") + + self.assertNotIn("a.mt00", list(self.md.keys())) + + def test_get_station_key(self): + self.md.add_station(self.mt_list_01) + + with self.subTest("no survey"): + self.assertEqual("a.mt01", self.md._get_station_key("mt01", None)) + with self.subTest("with survey"): + self.assertEqual("a.mt01", self.md._get_station_key("mt01", "a")) + with self.subTest("fail"): + self.assertRaises(KeyError, self.md._get_station_key, None, "a") + + def test_impedance_units(self): + + def set_units(unit): + self.md.impedance_units = unit + + with self.subTest("bad type"): + self.assertRaises(TypeError, set_units, 4) + with self.subTest("bad choice"): + self.assertRaises(ValueError, set_units, "ants") + + def test_set_impedance_units(self): + self.md.impedance_units = "ohm" + + for mt_obj in self.md.values(): + with self.subTest(f"mt_obj units {mt_obj.station}"): + self.assertEqual(mt_obj.impedance_units, "ohm") + + +# ============================================================================= +# +# ============================================================================= +if __name__ == "__main__": + unittest.main() diff --git a/tests/core/transfer_function/test_z.py b/tests/core/transfer_function/test_z.py index 8c8f0a5..56e5feb 100644 --- a/tests/core/transfer_function/test_z.py +++ b/tests/core/transfer_function/test_z.py @@ -10,8 +10,10 @@ import unittest import numpy as np +from mtpy.core.transfer_function import MT_TO_OHM_FACTOR from mtpy.core.transfer_function.z import Z + # ============================================================================= @@ -62,6 +64,9 @@ def test_resistivity_model_error(self): def test_phase_model_error(self): self.assertEqual(None, self.z.phase_model_error) + def test_units(self): + self.assertEqual("mt", self.z.units) + class TestZSetResPhase(unittest.TestCase): """ @@ -165,6 +170,10 @@ def test_rotation_minus_5_enu(self): with self.subTest("Invariant Strike"): self.assertAlmostEqual(zr.invariants.strike[0], 40) + def test_rotation_angle(self): + zr = self.z.rotate(40) + self.assertTrue(np.all(zr.rotation_angle == np.array([40]))) + class TestRemoveStaticShift(unittest.TestCase): @classmethod @@ -276,9 +285,7 @@ def test_anisotropy_imag(self): ) def test_electric_twist(self): - self.assertAlmostEqual( - 0.071840, self.z.invariants.electric_twist[0], 5 - ) + self.assertAlmostEqual(0.071840, self.z.invariants.electric_twist[0], 5) def test_phase_distortion(self): self.assertAlmostEqual( @@ -512,6 +519,92 @@ def test_depth_of_investigation(self): self.assertTrue(np.all(np.isclose(doi["period"], self.z.period))) +class TestUnits(unittest.TestCase): + @classmethod + def setUpClass(self): + self.z = np.array( + [ + [-7.420305 - 15.02897j, 53.44306 + 114.4988j], + [-49.96444 - 116.4191j, 11.95081 + 21.52367j], + ] + ) + self.z_in_ohms = self.z / MT_TO_OHM_FACTOR + + def test_initialize_with_units_ohm(self): + z_obj = Z(z=self.z_in_ohms, units="ohm") + with self.subTest("data in mt units"): + self.assertTrue( + np.allclose(self.z, z_obj._dataset.transfer_function.values) + ) + with self.subTest("data in mt units"): + self.assertTrue(np.allclose(self.z_in_ohms, z_obj.z)) + with self.subTest("units"): + self.assertEqual("ohm", z_obj.units) + + def test_initialize_with_units_mt(self): + z_obj = Z(z=self.z, units="mt") + with self.subTest("data in mt units"): + self.assertTrue( + np.allclose(self.z, z_obj._dataset.transfer_function.values) + ) + with self.subTest("data in mt units"): + self.assertTrue(np.allclose(self.z, z_obj.z)) + with self.subTest("units"): + self.assertEqual("mt", z_obj.units) + + def test_units_change_ohm_to_mt(self): + z_obj = Z(z=self.z_in_ohms, units="ohm") + z_obj.units = "mt" + with self.subTest("data in mt units"): + self.assertTrue( + np.allclose(self.z, z_obj._dataset.transfer_function.values) + ) + with self.subTest("data in mt units"): + self.assertTrue(np.allclose(self.z, z_obj.z)) + with self.subTest("units"): + self.assertEqual("mt", z_obj.units) + + def test_units_change_mt_to_ohm(self): + z_obj = Z(z=self.z, units="mt") + z_obj.units = "ohm" + with self.subTest("data in mt units"): + self.assertTrue( + np.allclose(self.z, z_obj._dataset.transfer_function.values) + ) + with self.subTest("data in ohm units"): + self.assertTrue(np.allclose(self.z_in_ohms, z_obj.z)) + with self.subTest("units"): + self.assertEqual("ohm", z_obj.units) + + def test_set_unit_fail(self): + z_obj = Z(z=self.z) + + def set_units(unit): + z_obj.units = unit + + with self.subTest("bad type"): + self.assertRaises(TypeError, set_units, 4) + with self.subTest("bad choice"): + self.assertRaises(ValueError, set_units, "ants") + + def test_phase_tensor_equal(self): + z_ohm = Z(z=self.z_in_ohms, units="ohm") + z_mt = Z(z=self.z, units="mt") + + self.assertTrue( + np.allclose(z_ohm.phase_tensor.pt, z_mt.phase_tensor.pt) + ) + + def test_resistivity_phase_equal(self): + z_ohm = Z(z=self.z_in_ohms, units="ohm") + z_mt = Z(z=self.z, units="mt") + + with self.subTest("resistivity"): + self.assertTrue(np.allclose(z_ohm.resistivity, z_mt.resistivity)) + with self.subTest("phase"): + self.assertTrue(np.allclose(z_ohm.phase, z_mt.phase)) + + # ============================================================================= # Run # ============================================================================= diff --git a/tests/modeling/simpeg/test_simpeg_2d.py b/tests/modeling/simpeg/test_simpeg_2d.py index 1d10b84..a8802ff 100644 --- a/tests/modeling/simpeg/test_simpeg_2d.py +++ b/tests/modeling/simpeg/test_simpeg_2d.py @@ -109,7 +109,9 @@ def test_station_locations_no_elevation(self): def test_frequencies(self): self.assertTrue( - np.allclose(1.0 / self.new_periods, self.simpeg_data.frequencies) + np.allclose( + np.sort(1.0 / self.new_periods), self.simpeg_data.frequencies + ) ) def test_te_survey(self): diff --git a/tests/modeling/simpeg/test_simpeg_2d_inversion_recipe.py b/tests/modeling/simpeg/test_simpeg_2d_inversion_recipe.py index 861dcf8..eaafb7c 100644 --- a/tests/modeling/simpeg/test_simpeg_2d_inversion_recipe.py +++ b/tests/modeling/simpeg/test_simpeg_2d_inversion_recipe.py @@ -55,19 +55,19 @@ def setUpClass(self): def test_active_map(self): self.assertEqual( self.simpeg_inversion.active_map.nP, - self.simpeg_inversion.quad_tree.number_of_active_cells, + self.simpeg_inversion.mesh.number_of_active_cells, ) def test_exponent_map(self): self.assertEqual( - self.simpeg_inversion.quad_tree.active_cell_index.size, + self.simpeg_inversion.mesh.active_cell_index.size, self.simpeg_inversion.exponent_map.nP, ) def test_conductivity_map(self): self.assertEqual( self.simpeg_inversion.conductivity_map.nP, - self.simpeg_inversion.quad_tree.number_of_active_cells, + self.simpeg_inversion.mesh.number_of_active_cells, ) def test_tm_simulation(self): @@ -145,7 +145,7 @@ def target_misfit(self): ) def test_directives(self): - self.assertEqual(4, len(self.simpeg_inversion.directives)) + self.assertEqual(3, len(self.simpeg_inversion.directives)) class TestSimpeg2DRecipeRun(unittest.TestCase): @@ -174,7 +174,9 @@ def setUpClass(self): self.n_iterations = 5 self.simpeg_inversion = Simpeg2D( - self.mt_df, max_iterations=self.n_iterations + self.mt_df, + max_iterations=self.n_iterations, + data_kwargs={"include_elevation": False}, ) self.inv_output = self.simpeg_inversion.run_inversion()