diff --git a/simpeg_drivers-assets/uijson/fdem1d_forward.ui.json b/simpeg_drivers-assets/uijson/fdem1d_forward.ui.json index 8a423744..549d3ed8 100644 --- a/simpeg_drivers-assets/uijson/fdem1d_forward.ui.json +++ b/simpeg_drivers-assets/uijson/fdem1d_forward.ui.json @@ -22,13 +22,15 @@ "z_imag_channel_bool": { "group": "Survey", "main": true, - "label": "Z imag component", + "label": "Vertical (imaginary)", + "tooltip": "Vertical (w) imaginary component of the magnetic data.\nPositive up along the z-axis if no receiver orientation provided", "value": true }, "z_real_channel_bool": { "group": "Survey", "main": true, - "label": "Z real component", + "label": "Vertical (real)", + "tooltip": "Vertical (w) real component of the magnetic data.\nPositive up along the z-axis if no receiver orientation provided", "value": true }, "u_cell_size": { diff --git a/simpeg_drivers-assets/uijson/fdem1d_inversion.ui.json b/simpeg_drivers-assets/uijson/fdem1d_inversion.ui.json index cd951543..941f14da 100644 --- a/simpeg_drivers-assets/uijson/fdem1d_inversion.ui.json +++ b/simpeg_drivers-assets/uijson/fdem1d_inversion.ui.json @@ -28,7 +28,8 @@ "group": "Data", "dataGroupType": "Multi-element", "main": true, - "label": "z-imag component (ppm)", + "label": "Vertical (imaginary)", + "tooltip": "Vertical (w) imaginary component of the magnetic data.\nPositive up along the z-axis if no receiver orientation provided", "parent": "data_object", "optional": true, "enabled": true, @@ -43,10 +44,10 @@ "group": "Data", "dataGroupType": "Multi-element", "main": true, - "label": "Uncertainty (ppm)", + "label": "Uncertainty", "parent": "data_object", "dependency": "z_imag_channel", - "dependencyType": "enabled", + "dependencyType": "show", "value": "" }, "z_real_channel": { @@ -58,7 +59,8 @@ "group": "Data", "dataGroupType": "Multi-element", "main": true, - "label": "z-real component (ppm)", + "label": "Vertical (real)", + "tooltip": "Vertical (w) real component of the magnetic data.\nPositive up along the z-axis if no receiver orientation provided", "parent": "data_object", "optional": true, "enabled": true, @@ -73,10 +75,10 @@ "group": "Data", "dataGroupType": "Multi-element", "main": true, - "label": "Uncertainty (ppm)", + "label": "Uncertainty", "parent": "data_object", "dependency": "z_real_channel", - "dependencyType": "enabled", + "dependencyType": "show", "value": "" }, "u_cell_size": { diff --git a/simpeg_drivers-assets/uijson/fdem_forward.ui.json b/simpeg_drivers-assets/uijson/fdem_forward.ui.json index d8279797..8917d993 100644 --- a/simpeg_drivers-assets/uijson/fdem_forward.ui.json +++ b/simpeg_drivers-assets/uijson/fdem_forward.ui.json @@ -19,16 +19,68 @@ ], "value": "" }, + "receivers_orientation": { + "group": "Survey", + "main": true, + "association": "Vertex", + "dataType": "Float", + "dataGroupType": [ + "Dip direction & dip", + "3D vector" + ], + "label": "Receivers orientation", + "tooltip": [ + "Receivers orientation provided as 'Dip direction & dip' or '3D vector' data group. If not provided, it assumes:
", + "
In-line component
Positive towards North (Y).
", + "
Cross-line component
Positive towards East (X).
", + "
Vertical component
Positive up (Z).
", + "
" + ], + "optional": true, + "enabled": false, + "parent": "data_object", + "value": "" + }, "z_imag_channel_bool": { "group": "Survey", "main": true, - "label": "Z imag component", + "label": "Vertical (imaginary)", + "tooltip": "Vertical (w) imaginary component of the magnetic data.\nPositive up along the z-axis if no receiver orientation provided", "value": true }, "z_real_channel_bool": { "group": "Survey", "main": true, - "label": "Z real component", + "label": "Vertical (real)", + "tooltip": "Vertical (w) real component of the magnetic data.\nPositive up along the z-axis if no receiver orientation provided", + "value": true + }, + "y_imag_channel_bool": { + "group": "Survey", + "main": true, + "label": "In-line (imaginary)", + "tooltip": "In-line (u) imaginary component of the magnetic data.\nPositive towards North if no receiver orientation provided", + "value": true + }, + "y_real_channel_bool": { + "group": "Survey", + "main": true, + "label": "In-line (real)", + "tooltip": "In-line (u) real component of the magnetic data.\nPositive towards North if no receiver orientation provided", + "value": true + }, + "x_imag_channel_bool": { + "group": "Survey", + "main": true, + "label": "Cross-line (imaginary)", + "tooltip": "Cross-line (v) imaginary component of the magnetic data.\nPositive towards East if no receiver orientation provided", + "value": true + }, + "x_real_channel_bool": { + "group": "Survey", + "main": true, + "label": "Cross-line (real)", + "tooltip": "Cross-line (v) real component of the magnetic data.\nPositive towards East if no receiver orientation provided", "value": true }, "mesh": { diff --git a/simpeg_drivers-assets/uijson/fdem_inversion.ui.json b/simpeg_drivers-assets/uijson/fdem_inversion.ui.json index a79f50e2..109c7c15 100644 --- a/simpeg_drivers-assets/uijson/fdem_inversion.ui.json +++ b/simpeg_drivers-assets/uijson/fdem_inversion.ui.json @@ -19,6 +19,28 @@ ], "value": "" }, + "receivers_orientation": { + "group": "Survey", + "main": true, + "association": "Vertex", + "dataType": "Float", + "dataGroupType": [ + "Dip direction & dip", + "3D vector" + ], + "label": "Receivers orientation", + "tooltip": [ + "Receivers orientation provided as 'Dip direction & dip' or '3D vector' data group. If not provided, it assumes:
", + "
In-line component
Positive towards North (Y).
", + "
Cross-line component
Positive towards East (X).
", + "
Vertical component
Positive up (Z).
", + "
" + ], + "optional": true, + "enabled": false, + "parent": "data_object", + "value": "" + }, "z_imag_channel": { "association": [ "Cell", @@ -28,7 +50,8 @@ "group": "Data", "dataGroupType": "Multi-element", "main": true, - "label": "z-imag component", + "label": "Vertical (imaginary)", + "tooltip": "Vertical (w) imaginary component of the magnetic data.\nPositive up along the z-axis if no receiver orientation provided", "parent": "data_object", "optional": true, "enabled": true, @@ -46,7 +69,7 @@ "label": "Uncertainty", "parent": "data_object", "dependency": "z_imag_channel", - "dependencyType": "enabled", + "dependencyType": "show", "value": "" }, "z_real_channel": { @@ -58,7 +81,8 @@ "group": "Data", "dataGroupType": "Multi-element", "main": true, - "label": "z-real component", + "label": "Vertical (real)", + "tooltip": "Vertical (w) real component of the magnetic data.\nPositive up along the z-axis if no receiver orientation provided", "parent": "data_object", "optional": true, "enabled": true, @@ -76,7 +100,131 @@ "label": "Uncertainty", "parent": "data_object", "dependency": "z_real_channel", - "dependencyType": "enabled", + "dependencyType": "show", + "value": "" + }, + "y_imag_channel": { + "association": [ + "Cell", + "Vertex" + ], + "dataType": "Float", + "group": "Data", + "dataGroupType": "Multi-element", + "main": true, + "label": "In-line (imaginary)", + "tooltip": "In-line (u) imaginary component of the magnetic data.\nPositive towards North if no receiver orientation provided", + "parent": "data_object", + "optional": true, + "enabled": false, + "value": "" + }, + "y_imag_uncertainty": { + "association": [ + "Cell", + "Vertex" + ], + "dataType": "Float", + "group": "Data", + "dataGroupType": "Multi-element", + "main": true, + "label": "Uncertainty", + "parent": "data_object", + "dependency": "y_imag_channel", + "dependencyType": "show", + "value": "" + }, + "y_real_channel": { + "association": [ + "Cell", + "Vertex" + ], + "dataType": "Float", + "group": "Data", + "dataGroupType": "Multi-element", + "main": true, + "label": "In-line (real)", + "tooltip": "In-line (u) real component of the magnetic data.\nPositive towards North if no receiver orientation provided", + "parent": "data_object", + "optional": true, + "enabled": false, + "value": "" + }, + "y_real_uncertainty": { + "association": [ + "Cell", + "Vertex" + ], + "dataType": "Float", + "group": "Data", + "dataGroupType": "Multi-element", + "main": true, + "label": "Uncertainty", + "parent": "data_object", + "dependency": "y_real_channel", + "dependencyType": "show", + "value": "" + }, + "x_imag_channel": { + "association": [ + "Cell", + "Vertex" + ], + "dataType": "Float", + "group": "Data", + "dataGroupType": "Multi-element", + "main": true, + "label": "Cross-line (imaginary)", + "tooltip": "Cross-line (v) imaginary component of the magnetic data.\nPositive towards East if no receiver orientation provided", + "parent": "data_object", + "optional": true, + "enabled": false, + "value": "" + }, + "x_imag_uncertainty": { + "association": [ + "Cell", + "Vertex" + ], + "dataType": "Float", + "group": "Data", + "dataGroupType": "Multi-element", + "main": true, + "label": "Uncertainty", + "parent": "data_object", + "dependency": "x_imag_channel", + "dependencyType": "show", + "value": "" + }, + "x_real_channel": { + "association": [ + "Cell", + "Vertex" + ], + "dataType": "Float", + "group": "Data", + "dataGroupType": "Multi-element", + "main": true, + "label": "Cross-line (real)", + "tooltip": "Cross-line (v) real component of the magnetic data.\nPositive towards East if no receiver orientation provided", + "parent": "data_object", + "optional": true, + "enabled": false, + "value": "" + }, + "x_real_uncertainty": { + "association": [ + "Cell", + "Vertex" + ], + "dataType": "Float", + "group": "Data", + "dataGroupType": "Multi-element", + "main": true, + "label": "Uncertainty", + "parent": "data_object", + "dependency": "x_real_channel", + "dependencyType": "show", "value": "" }, "mesh": { diff --git a/simpeg_drivers-assets/uijson/tdem_forward.ui.json b/simpeg_drivers-assets/uijson/tdem_forward.ui.json index f46567a5..13806380 100644 --- a/simpeg_drivers-assets/uijson/tdem_forward.ui.json +++ b/simpeg_drivers-assets/uijson/tdem_forward.ui.json @@ -20,6 +20,28 @@ ], "value": "" }, + "receivers_orientation": { + "group": "Survey", + "main": true, + "association": "Vertex", + "dataType": "Float", + "dataGroupType": [ + "Dip direction & dip", + "3D vector" + ], + "label": "Receivers orientation", + "tooltip": [ + "Receivers orientation provided as 'Dip direction & dip' or '3D vector' data group. If not provided, it assumes:
", + "
In-line component
Positive towards North (Y).
", + "
Cross-line component
Positive towards East (X).
", + "
Vertical component
Positive up (Z).
", + "
" + ], + "optional": true, + "enabled": false, + "parent": "data_object", + "value": "" + }, "data_units": { "choiceList": [ "Airborne dB/dt (V/Am^4)", diff --git a/simpeg_drivers-assets/uijson/tdem_inversion.ui.json b/simpeg_drivers-assets/uijson/tdem_inversion.ui.json index 28b47d0b..79c6411c 100644 --- a/simpeg_drivers-assets/uijson/tdem_inversion.ui.json +++ b/simpeg_drivers-assets/uijson/tdem_inversion.ui.json @@ -20,6 +20,28 @@ ], "value": "" }, + "receivers_orientation": { + "group": "Survey", + "main": true, + "association": "Vertex", + "dataType": "Float", + "dataGroupType": [ + "Dip direction & dip", + "3D vector" + ], + "label": "Receivers orientation", + "tooltip": [ + "Receivers orientation provided as 'Dip direction & dip' or '3D vector' data group. If not provided, it assumes:
", + "
In-line component
Positive towards North (Y).
", + "
Cross-line component
Positive towards East (X).
", + "
Vertical component
Positive up (Z).
", + "
" + ], + "optional": true, + "enabled": false, + "parent": "data_object", + "value": "" + }, "data_units": { "choiceList": [ "Airborne dB/dt (V/Am^4)", diff --git a/simpeg_drivers/components/factories/receiver_factory.py b/simpeg_drivers/components/factories/receiver_factory.py index 596a1035..202fede6 100644 --- a/simpeg_drivers/components/factories/receiver_factory.py +++ b/simpeg_drivers/components/factories/receiver_factory.py @@ -23,9 +23,11 @@ from simpeg_drivers.options import BaseOptions import numpy as np -from geoapps_utils.utils.transformations import rotate_xyz +from geoapps_utils.utils.transformations import x_rotation_matrix, z_rotation_matrix +from geoh5py.objects.surveys.electromagnetics.base import AirborneEMSurvey from simpeg_drivers.components.factories.simpeg_factory import SimPEGFactory +from simpeg_drivers.utils.regularization import direction_and_dip, get_cell_normals class ReceiversFactory(SimPEGFactory): @@ -38,6 +40,7 @@ def __init__(self, params: BaseParams | BaseOptions): """ super().__init__(params) self.simpeg_object = self.concrete_object() + self.orientations = self.validate_orientations() def concrete_object(self): if self.factory_type in ["magnetic vector", "magnetic scalar"]: @@ -94,7 +97,11 @@ def concrete_object(self): return receivers.ApparentConductivity def assemble_arguments( - self, locations=None, data=None, local_index=None, component=None + self, + locations=None, + data=None, + local_indices=None, + component=None, ): """Provides implementations to assemble arguments for receivers object.""" @@ -106,7 +113,7 @@ def assemble_arguments( ): args += self._dcip_arguments( locations=locations, - local_index=local_index, + local_indices=local_indices, ) elif self.factory_type in [ "apparent conductivity", @@ -128,7 +135,11 @@ def assemble_arguments( return args def assemble_keyword_arguments( - self, locations=None, data=None, local_index=None, component=None + self, + locations=None, + data=None, + local_indices=None, + component=None, ): """Provides implementations to assemble keyword arguments for receivers object.""" kwargs = {} @@ -141,23 +152,34 @@ def assemble_keyword_arguments( comp = component.split("_")[0] kwargs["orientation"] = comp[0] if "fdem" in self.factory_type else comp[1:] kwargs["component"] = component.split("_")[1] + if self.factory_type in ["tipper"]: kwargs["orientation"] = kwargs["orientation"][::-1] + if "tdem" in self.factory_type: kwargs["orientation"] = component if self.factory_type == "fdem 1d": kwargs["data_type"] = "ppm" + # Overload orientation if provided + if ( + isinstance(self.params.data_object, AirborneEMSurvey) + and local_indices is not None + ): + kwargs["orientation"] = self.orientations[kwargs["orientation"]][ + local_indices, : + ] + return kwargs - def _dcip_arguments(self, locations=None, local_index=None): + def _dcip_arguments(self, locations=None, local_indices=None): args = [] - local_index = np.vstack(local_index) + local_indices = np.vstack(local_indices) - args.append(locations[local_index[:, 0], :]) + args.append(locations[local_indices[:, 0], :]) - if np.all(local_index[:, 0] == local_index[:, 1]): + if np.all(local_indices[:, 0] == local_indices[:, 1]): if "direct current" in self.factory_type: from simpeg.electromagnetics.static.resistivity import receivers else: @@ -166,7 +188,7 @@ def _dcip_arguments(self, locations=None, local_index=None): ) self.simpeg_object = receivers.Pole else: - args.append(locations[local_index[:, 1], :]) + args.append(locations[local_indices[:, 1], :]) return args @@ -196,3 +218,27 @@ def _base_station_arguments(self, locations=None): # H-field on locations with base stations return locations, stations + + def validate_orientations(self): + """ + Validate the various options for the orientation parameter and + return an orientation array of shape (n_receivers, 3) for use in SimPEG receivers. + """ + n_recs = self.params.data_object.n_vertices + normals = { + comp: get_cell_normals(n_recs, comp, True, 3).reshape((-1, 3)) + for comp in "xyz" + } + + if getattr(self.params, "receivers_orientation", None): + azi_dip = np.deg2rad(direction_and_dip(self.params.receivers_orientation)) + orientations = {} + for axis in "xyz": + orientations[axis] = ( + z_rotation_matrix(-azi_dip[:, 0]) + * (x_rotation_matrix(-azi_dip[:, 1]) * normals[axis].flatten()) + ).reshape((-1, 3)) + + return orientations + + return normals diff --git a/simpeg_drivers/components/factories/survey_factory.py b/simpeg_drivers/components/factories/survey_factory.py index 052a5a41..4496ceb2 100644 --- a/simpeg_drivers/components/factories/survey_factory.py +++ b/simpeg_drivers/components/factories/survey_factory.py @@ -13,7 +13,6 @@ from __future__ import annotations -from gc import is_finalized from typing import TYPE_CHECKING @@ -46,8 +45,6 @@ def __init__(self, params: BaseParams | BaseOptions): """ super().__init__(params) self.simpeg_object = self.concrete_object() - self.local_index = None - self.survey = None self.ordering = None self.sorting = None @@ -187,7 +184,7 @@ def _dcip_arguments(self, data=None): sorting.append(receiver_indices) receivers = ReceiversFactory(self.params).build( locations=receiver_locations, - local_index=receiver_entity.cells[receiver_indices], + local_indices=receiver_entity.cells[receiver_indices], ) if receivers.nD == 0: @@ -292,9 +289,7 @@ def _tdem_arguments(self, data=None): for comp_id, component in enumerate(data.components): rx_obj = rx_factory.build( - locations=locs, - data=data, - component=component, + locations=locs, data=data, component=component, local_indices=rx_ids ) rx_list.append(rx_obj) n_times = len(receivers.channels) @@ -334,9 +329,7 @@ def _fem_arguments(self, data=None): receivers = [] for comp_id, component in enumerate(data.components): receiver = rx_factory.build( - locations=locs, - data=data, - component=component, + locations=locs, data=data, component=component, local_indices=rx_id ) block_ordering.append([comp_id, rx_id]) receivers.append(receiver) diff --git a/simpeg_drivers/electromagnetics/frequency_domain/options.py b/simpeg_drivers/electromagnetics/frequency_domain/options.py index 5e0c672f..942bac8d 100644 --- a/simpeg_drivers/electromagnetics/frequency_domain/options.py +++ b/simpeg_drivers/electromagnetics/frequency_domain/options.py @@ -85,9 +85,14 @@ class FDEMForwardOptions(BaseForwardOptions, BaseFDEMOptions): """ Frequency Domain Electromagnetic Forward options. - :param z_real_channel_bool: Real impedance channel boolean. - :param z_imag_channel_bool: Imaginary impedance channel boolean. - :param model_type: Specify whether the models are provided in resistivity or conductivity. + :param receivers_orientation: Orientation of the receivers provided as a group. + :param z_real_channel_bool: Vertical (real) component of impedance channel boolean. + :param z_imag_channel_bool: Vertical (imaginary) component of impedance channel boolean. + :param y_real_channel_bool: In-line (real) component of impedance channel boolean. + :param y_imag_channel_bool: In-line (imaginary) component of impedance channel boolean. + :param x_real_channel_bool: Cross-line (real) component of impedance channel boolean. + :param x_imag_channel_bool: Cross-line (imaginary) component of impedance channel + :param models: Specify whether the models are provided in resistivity or conductivity. """ name: ClassVar[str] = "Frequency Domain Electromagnetics Forward" @@ -101,8 +106,13 @@ class FDEMForwardOptions(BaseForwardOptions, BaseFDEMOptions): | LargeLoopGroundFEMReceivers | AirborneFEMReceivers ) - z_real_channel_bool: bool - z_imag_channel_bool: bool + receivers_orientation: PropertyGroup | None = None + z_real_channel_bool: bool = False + z_imag_channel_bool: bool = False + y_real_channel_bool: bool = False + y_imag_channel_bool: bool = False + x_real_channel_bool: bool = False + x_imag_channel_bool: bool = False models: ConductivityModelOptions @@ -110,11 +120,19 @@ class FDEMInversionOptions(BaseFDEMOptions, BaseInversionOptions): """ Frequency Domain Electromagnetic Inversion options. - :param z_real_channel: Real impedance channel. - :param z_real_uncertainty: Real impedance uncertainty channel. - :param z_imag_channel: Imaginary impedance channel. - :param z_imag_uncertainty: Imaginary impedance uncertainty channel. - :param model_type: Specify whether the models are provided in resistivity or conductivity. + :param z_real_channel: Vertical (real) impedance channel. + :param z_real_uncertainty: Vertical (real) impedance uncertainty channel. + :param z_imag_channel: Vertical (imaginary) impedance channel. + :param z_imag_uncertainty: Vertical (imaginary) impedance uncertainty channel. + :param y_real_channel: In-line (real) impedance channel. + :param y_real_uncertainty: In-line (real) impedance uncertainty channel. + :param y_imag_channel: In-line (imaginary) impedance channel. + :param y_imag_uncertainty: In-line (imaginary) impedance uncertainty channel + :param x_real_channel: Cross-line (real) impedance channel. + :param x_real_uncertainty: Cross-line (real) impedance uncertainty channel. + :param x_imag_channel: Cross-line (imaginary) impedance channel. + :param x_imag_uncertainty: Cross-line (imaginary) impedance uncertainty channel + :param models: Specify whether the models are provided in resistivity or conductivity. """ name: ClassVar[str] = "Frequency Domain Electromagnetics Inversion" @@ -128,10 +146,19 @@ class FDEMInversionOptions(BaseFDEMOptions, BaseInversionOptions): | LargeLoopGroundFEMReceivers | AirborneFEMReceivers ) + receivers_orientation: PropertyGroup | None = None z_real_channel: PropertyGroup | None = None z_real_uncertainty: PropertyGroup | None = None z_imag_channel: PropertyGroup | None = None z_imag_uncertainty: PropertyGroup | None = None + y_real_channel: PropertyGroup | None = None + y_real_uncertainty: PropertyGroup | None = None + y_imag_channel: PropertyGroup | None = None + y_imag_uncertainty: PropertyGroup | None = None + x_real_channel: PropertyGroup | None = None + x_real_uncertainty: PropertyGroup | None = None + x_imag_channel: PropertyGroup | None = None + x_imag_uncertainty: PropertyGroup | None = None models: ConductivityModelOptions diff --git a/simpeg_drivers/electromagnetics/frequency_domain_1d/options.py b/simpeg_drivers/electromagnetics/frequency_domain_1d/options.py index 875db4a6..e7b9ebb8 100644 --- a/simpeg_drivers/electromagnetics/frequency_domain_1d/options.py +++ b/simpeg_drivers/electromagnetics/frequency_domain_1d/options.py @@ -15,17 +15,20 @@ from typing import ClassVar from geoh5py.groups import PropertyGroup +from geoh5py.objects import AirborneFEMReceivers from simpeg_drivers import assets_path from simpeg_drivers.electromagnetics.base_1d_options import Base1DOptions -from simpeg_drivers.electromagnetics.frequency_domain.options import ( - FDEMForwardOptions, - FDEMInversionOptions, +from simpeg_drivers.electromagnetics.frequency_domain.options import BaseFDEMOptions +from simpeg_drivers.options import ( + BaseForwardOptions, + BaseInversionOptions, + ConductivityModelOptions, + DirectiveOptions, ) -from simpeg_drivers.options import DirectiveOptions -class FDEM1DForwardOptions(FDEMForwardOptions, Base1DOptions): +class FDEM1DForwardOptions(BaseForwardOptions, BaseFDEMOptions, Base1DOptions): """ Frequency Domain Electromagnetic forward options. @@ -38,13 +41,15 @@ class FDEM1DForwardOptions(FDEMForwardOptions, Base1DOptions): default_ui_json: ClassVar[Path] = assets_path() / "uijson/fdem1d_forward.ui.json" title: str = "Frequency-domain EM-1D (FEM-1D) Forward" + physical_property: str = "conductivity" inversion_type: str = "fdem 1d" - + data_object: AirborneFEMReceivers z_real_channel_bool: bool z_imag_channel_bool: bool + models: ConductivityModelOptions -class FDEM1DInversionOptions(FDEMInversionOptions, Base1DOptions): +class FDEM1DInversionOptions(BaseFDEMOptions, BaseInversionOptions, Base1DOptions): """ Frequency Domain Electromagnetic Inversion options. @@ -58,8 +63,10 @@ class FDEM1DInversionOptions(FDEMInversionOptions, Base1DOptions): name: ClassVar[str] = "Frequency Domain 1D Electromagnetics Inversion" default_ui_json: ClassVar[Path] = assets_path() / "uijson/fdem1d_inversion.ui.json" title: str = "Frequency-domain EM-1D (FEM-1D) Inversion" + physical_property: str = "conductivity" inversion_type: str = "fdem 1d" + data_object: AirborneFEMReceivers directives: DirectiveOptions = DirectiveOptions( sens_wts_threshold=100.0, ) @@ -67,3 +74,5 @@ class FDEM1DInversionOptions(FDEMInversionOptions, Base1DOptions): z_real_uncertainty: PropertyGroup | None = None z_imag_channel: PropertyGroup | None = None z_imag_uncertainty: PropertyGroup | None = None + models: ConductivityModelOptions + directives: DirectiveOptions = DirectiveOptions() diff --git a/simpeg_drivers/electromagnetics/time_domain/options.py b/simpeg_drivers/electromagnetics/time_domain/options.py index 0f3e4fbb..ef52632a 100644 --- a/simpeg_drivers/electromagnetics/time_domain/options.py +++ b/simpeg_drivers/electromagnetics/time_domain/options.py @@ -76,9 +76,9 @@ class TDEMForwardOptions(BaseTDEMOptions, BaseForwardOptions): """ Time Domain Electromagnetic forward options. - :param z_channel_bool: Z-component data channel boolean. - :param x_channel_bool: X-component data channel boolean. - :param y_channel_bool: Y-component data channel boolean. + :param z_channel_bool: Vertical data channel boolean. + :param y_channel_bool: In-line data channel boolean. + :param x_channel_bool: Cross-line data channel boolean. """ name: ClassVar[str] = "Time Domain Electromagnetics Forward" @@ -92,6 +92,7 @@ class TDEMForwardOptions(BaseTDEMOptions, BaseForwardOptions): | LargeLoopGroundTEMReceivers | AirborneTEMReceivers ) + receivers_orientation: PropertyGroup | None = None z_channel_bool: bool | None = None x_channel_bool: bool | None = None y_channel_bool: bool | None = None @@ -104,10 +105,10 @@ class TDEMInversionOptions(BaseTDEMOptions, BaseInversionOptions): :param z_channel: Z-component data channel. :param z_uncertainty: Z-component data channel uncertainty. - :param x_channel: X-component data channel. - :param x_uncertainty: X-component data channel uncertainty. - :param y_channel: Y-component data channel. - :param y_uncertainty: Y-component data channel uncertainty. + :param y_channel: In-line data channel. + :param y_uncertainty: In-line data channel uncertainty. + :param x_channel: Cross-line data channel. + :param x_uncertainty: Cross-line data channel uncertainty. """ name: ClassVar[str] = "Time Domain Electromagnetics Inversion" @@ -121,10 +122,12 @@ class TDEMInversionOptions(BaseTDEMOptions, BaseInversionOptions): | LargeLoopGroundTEMReceivers | AirborneTEMReceivers ) + receivers_orientation: PropertyGroup | None = None z_channel: PropertyGroup | None = None z_uncertainty: PropertyGroup | None = None - x_channel: PropertyGroup | None = None - x_uncertainty: PropertyGroup | None = None y_channel: PropertyGroup | None = None y_uncertainty: PropertyGroup | None = None + x_channel: PropertyGroup | None = None + x_uncertainty: PropertyGroup | None = None + models: ConductivityModelOptions diff --git a/simpeg_drivers/natural_sources/tipper/options.py b/simpeg_drivers/natural_sources/tipper/options.py index 4f55f377..d689199a 100644 --- a/simpeg_drivers/natural_sources/tipper/options.py +++ b/simpeg_drivers/natural_sources/tipper/options.py @@ -46,6 +46,7 @@ class TipperForwardOptions(EMDataMixin, BaseForwardOptions): inversion_type: str = "tipper" data_object: TipperReceivers + txz_real_channel_bool: bool | None = None txz_imag_channel_bool: bool | None = None tyz_real_channel_bool: bool | None = None @@ -75,6 +76,7 @@ class TipperInversionOptions(EMDataMixin, BaseInversionOptions): inversion_type: str = "tipper" data_object: TipperReceivers + txz_real_channel: PropertyGroup | None = None txz_real_uncertainty: PropertyGroup | None = None txz_imag_channel: PropertyGroup | None = None diff --git a/simpeg_drivers/utils/synthetics/driver.py b/simpeg_drivers/utils/synthetics/driver.py index 9b6c848f..f116f2dd 100644 --- a/simpeg_drivers/utils/synthetics/driver.py +++ b/simpeg_drivers/utils/synthetics/driver.py @@ -80,6 +80,7 @@ def mesh(self): survey=self.survey, topography=self.topography, options=self.options.mesh, + plate=self.options.model.plate if self.options.refine_plate else None, ) self._mesh = entity return self._mesh diff --git a/simpeg_drivers/utils/synthetics/meshes/factory.py b/simpeg_drivers/utils/synthetics/meshes/factory.py index 8ee89802..d50136a4 100644 --- a/simpeg_drivers/utils/synthetics/meshes/factory.py +++ b/simpeg_drivers/utils/synthetics/meshes/factory.py @@ -9,6 +9,7 @@ # ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' from discretize import TensorMesh, TreeMesh +from geoapps_utils.modelling.plates import PlateModel from geoh5py.objects import CellObject, DrapeModel, Octree, Points, Surface from simpeg_drivers.utils.synthetics.options import MeshOptions @@ -22,6 +23,7 @@ def get_mesh( survey: Points, topography: Surface, options: MeshOptions, + plate: PlateModel | None, ) -> DrapeModel | Octree: """Factory for mesh creation with behaviour modified by the provided method.""" @@ -40,5 +42,6 @@ def get_mesh( cell_size=options.cell_size, refinement=options.refinement, padding_distance=options.padding_distance, + plate=plate, name=options.name, ) diff --git a/simpeg_drivers/utils/synthetics/meshes/octrees.py b/simpeg_drivers/utils/synthetics/meshes/octrees.py index 795cf952..8945de57 100644 --- a/simpeg_drivers/utils/synthetics/meshes/octrees.py +++ b/simpeg_drivers/utils/synthetics/meshes/octrees.py @@ -11,10 +11,14 @@ import numpy as np from discretize import TreeMesh from discretize.utils import mesh_builder_xyz +from geoapps_utils.modelling.plates import PlateModel from geoh5py.objects import Octree, Points, Surface from grid_apps.octree_creation.driver import OctreeDriver from grid_apps.utils import treemesh_2_octree +from simpeg_drivers.plate_simulation.models.options import PlateOptions +from simpeg_drivers.plate_simulation.models.parametric import Plate + def get_base_octree( survey: Points, @@ -58,6 +62,7 @@ def get_octree_mesh( cell_size: tuple[float, float, float], refinement: tuple | list, padding_distance: float, + plate: PlateModel | None = None, name: str = "mesh", ) -> Octree: """Generate a survey centered mesh with topography and survey refinement. @@ -68,11 +73,12 @@ def get_octree_mesh( :param cell_size: Tuple defining the cell size in all directions. :param refinement: Tuple containing the number of cells to refine at each level around the topography. - :param padding: Distance to pad the mesh in all directions. + :param padding_distance: Distance to pad the mesh in all directions. + :param plate: Optional PlateModel object to refine the mesh around the plate. + :param name: Name of the Octree object to create in geoh5. Default is "mesh". :return entity: The geoh5py Octree object to store the results of computation in the shared cells of the computational mesh. - :return mesh: The discretize TreeMesh object for computations. """ mesh = get_base_octree(survey, topography, cell_size, (0, 0, 1), padding_distance) @@ -81,6 +87,24 @@ def get_octree_mesh( mesh, survey.vertices, levels=refinement, finalize=False ) + if plate is not None: + # TODO Consolidate PlateOptions and PlateModel into a single class to avoid this redundancy + plate_options = PlateOptions( + plate=1.0, # thickness + width=plate.width, + strike_length=plate.strike_length, + dip_length=plate.dip_length, + dip=plate.dip, + dip_direction=plate.direction, + elevation=0, + ) + center = list(plate.origin) + center[2] += plate.width # Unclear why offsetted vertically + plate = Plate(plate_options, center=center, workspace=survey.workspace) + mesh = OctreeDriver.refine_tree_from_surface( + mesh, plate.surface, levels=(4,), finalize=False + ) + mesh.finalize() entity = treemesh_2_octree(survey.workspace, mesh, name=name) diff --git a/simpeg_drivers/utils/synthetics/options.py b/simpeg_drivers/utils/synthetics/options.py index 87fd1da0..18ef2b81 100644 --- a/simpeg_drivers/utils/synthetics/options.py +++ b/simpeg_drivers/utils/synthetics/options.py @@ -22,6 +22,7 @@ class SurveyOptions(BaseModel): drape: float = 0.0 n_stations: int = 20 n_lines: int = 5 + rotation: float = 0.0 topography: Callable = lambda x, y: gaussian(x, y, amplitude=50.0, width=100.0) name: str = "survey" @@ -62,6 +63,7 @@ class SyntheticsComponentsOptions(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) method: str = "gravity" + refine_plate: bool = False survey: SurveyOptions = SurveyOptions() mesh: MeshOptions = MeshOptions() model: ModelOptions = ModelOptions() diff --git a/simpeg_drivers/utils/synthetics/surveys/factory.py b/simpeg_drivers/utils/synthetics/surveys/factory.py index 83a8a945..ef86c809 100644 --- a/simpeg_drivers/utils/synthetics/surveys/factory.py +++ b/simpeg_drivers/utils/synthetics/surveys/factory.py @@ -11,6 +11,7 @@ from collections.abc import Callable import numpy as np +from geoapps_utils.utils.transformations import rotate_xyz from geoh5py import Workspace from geoh5py.objects import ObjectBase, Points @@ -30,22 +31,28 @@ def grid_layout( n_stations: int, n_lines: int, topography: Callable, -): + center: tuple[float, float] = (0.0, 0.0), + rotation: float = 0.0, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """ Generates grid locations based on limits and spacing. - - :param limits: Tuple of (xmin, xmax, ymin, ymax). - :param n_stations: Number of stations along each line. - :param n_lines: Number of lines in the grid. - :param topography: Callable that generates the topography (z values). """ x = np.linspace(limits[0], limits[1], n_stations) y = np.linspace(limits[2], limits[3], n_lines) - X, Y = np.meshgrid(x, y) - Z = topography(X, Y) + grid_x, grid_y = np.meshgrid(x, y) + + xy_locs = rotate_xyz( + np.c_[grid_x.flatten(), grid_y.flatten()], list(center), rotation + ) - return X, Y, Z + z = topography(xy_locs[:, 0], xy_locs[:, 1]) + + return ( + xy_locs[:, 0].reshape(grid_x.shape), + xy_locs[:, 1].reshape(grid_y.shape), + z.reshape(grid_x.shape), + ) def get_survey( @@ -61,37 +68,49 @@ def get_survey( :param options: Survey options. """ - X, Y, Z = grid_layout( + grid_x, grid_y, grid_z = grid_layout( limits=options.limits, n_stations=options.n_stations, n_lines=options.n_lines, topography=options.topography, + center=options.center, + rotation=options.rotation, ) - Z += options.drape + grid_z += options.drape if "current" in method or "polarization" in method: - return generate_dc_survey(geoh5, X, Y, Z, name=options.name) + return generate_dc_survey(geoh5, grid_x, grid_y, grid_z, name=options.name) if "magnetotellurics" in method: - return generate_magnetotellurics_survey(geoh5, X, Y, Z, name=options.name) + return generate_magnetotellurics_survey( + geoh5, grid_x, grid_y, grid_z, name=options.name + ) if "tipper" in method: - return generate_tipper_survey(geoh5, X, Y, Z, name=options.name) + return generate_tipper_survey(geoh5, grid_x, grid_y, grid_z, name=options.name) if "apparent conductivity" in method: - return generate_apparent_conductivity_survey(geoh5, X, Y, Z, name=options.name) + return generate_apparent_conductivity_survey( + geoh5, grid_x, grid_y, grid_z, name=options.name + ) if method in ["fdem", "fem", "fdem 1d"]: - return generate_fdem_survey(geoh5, X, Y, Z, name=options.name) + return generate_fdem_survey(geoh5, grid_x, grid_y, grid_z, name=options.name) if "tdem" in method: if "airborne" in method: - return generate_airborne_tdem_survey(geoh5, X, Y, Z, name=options.name) + return generate_airborne_tdem_survey( + geoh5, grid_x, grid_y, grid_z, name=options.name + ) else: - return generate_tdem_survey(geoh5, X, Y, Z, name=options.name) + return generate_tdem_survey( + geoh5, grid_x, grid_y, grid_z, name=options.name + ) return Points.create( geoh5, - vertices=np.column_stack([X.flatten(), Y.flatten(), Z.flatten()]), + vertices=np.column_stack( + [grid_x.flatten(), grid_y.flatten(), grid_z.flatten()] + ), name=options.name, ) diff --git a/simpeg_drivers/utils/synthetics/surveys/frequency_domain/fdem.py b/simpeg_drivers/utils/synthetics/surveys/frequency_domain/fdem.py index c1eaf1c5..2e599897 100644 --- a/simpeg_drivers/utils/synthetics/surveys/frequency_domain/fdem.py +++ b/simpeg_drivers/utils/synthetics/surveys/frequency_domain/fdem.py @@ -22,22 +22,43 @@ def generate_fdem_survey( - geoh5: Workspace, X: np.ndarray, Y: np.ndarray, Z: np.ndarray, name: str = "survey" + geoh5: Workspace, + x_grid: np.ndarray, + y_grid: np.ndarray, + z_grid: np.ndarray, + name: str = "survey", ) -> AirborneFEMReceivers: """Create an FDEM survey object from survey grid locations.""" - vertices = np.column_stack([X.flatten(), Y.flatten(), Z.flatten()]) - survey = AirborneFEMReceivers.create(geoh5, vertices=vertices, name=name) + vertices = np.column_stack([x_grid.flatten(), y_grid.flatten(), z_grid.flatten()]) + + if len(vertices) < 2: + raise ValueError("FDEM survey requires at least 2 vertices") + survey = AirborneFEMReceivers.create(geoh5, vertices=vertices, name=name) + survey.remove_cells(mask_large_connections(survey, 200.0)) survey.metadata["EM Dataset"]["Frequency configurations"] = frequency_config tx_locs_list = [] frequency_list = [] + for config in frequency_config: - tx_vertices = vertices.copy() - tx_vertices[:, 0] -= config["Offset"] - tx_locs_list.append(tx_vertices) - frequency_list.append([[config["Frequency"]] * len(vertices)]) + for part in np.unique(survey.parts): + line = survey.parts == part + delta = np.diff(vertices[line, :], axis=0) + delta[:, 2] = 0 + length = np.linalg.norm(delta, axis=1) + + if np.any(length <= 0): + raise ValueError("FDEM should not have duplicate vertices") + + delta /= length[:, None] + delta = np.vstack([delta, delta[-1, :]]) # Repeat last offset + + tx_vertices = vertices[line, :] - delta * config["Offset"] + tx_locs_list.append(tx_vertices) + frequency_list.append([[config["Frequency"]] * sum(line)]) + tx_locs = np.vstack(tx_locs_list) freqs = np.hstack(frequency_list).flatten() @@ -58,7 +79,6 @@ def generate_fdem_survey( } ) - survey.remove_cells(mask_large_connections(survey, 200.0)) transmitters.remove_cells(mask_large_connections(transmitters, 200.0)) return survey diff --git a/tests/run_tests/driver_airborne_fem_1d_test.py b/tests/run_tests/driver_airborne_fem_1d_test.py index d1f10f87..da501fa6 100644 --- a/tests/run_tests/driver_airborne_fem_1d_test.py +++ b/tests/run_tests/driver_airborne_fem_1d_test.py @@ -41,7 +41,7 @@ # To test the full run and validate the inversion. # Move this file out of the test directory and run. -target_run = {"data_norm": 804.9848595951983, "phi_d": 64500, "phi_m": 717} +target_run = {"data_norm": 804.9849282354428, "phi_d": 58200, "phi_m": 118} def test_fem_fwr_1d_run( diff --git a/tests/run_tests/driver_fem_test.py b/tests/run_tests/driver_airborne_fem_test.py similarity index 100% rename from tests/run_tests/driver_fem_test.py rename to tests/run_tests/driver_airborne_fem_test.py diff --git a/tests/run_tests/oriented_airborne_tem_receiver_test.py b/tests/run_tests/oriented_airborne_tem_receiver_test.py new file mode 100644 index 00000000..78530c6c --- /dev/null +++ b/tests/run_tests/oriented_airborne_tem_receiver_test.py @@ -0,0 +1,151 @@ +# ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' +# Copyright (c) 2023-2026 Mira Geoscience Ltd. ' +# ' +# This file is part of simpeg-drivers package. ' +# ' +# simpeg-drivers is distributed under the terms and conditions of the MIT License ' +# (see LICENSE file at the root of this source code package). ' +# ' +# ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' + +# pylint: disable=too-many-locals + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest +from geoapps_utils.modelling.plates import PlateModel +from geoh5py import Workspace +from geoh5py.groups import PropertyGroup, UIJsonGroup +from geoh5py.objects import AirborneTEMReceivers +from geoh5py.shared.utils import fetch_active_workspace + +from simpeg_drivers.electromagnetics.time_domain.driver import ( + TDEMForwardDriver, +) +from simpeg_drivers.electromagnetics.time_domain.options import ( + TDEMForwardOptions, +) +from simpeg_drivers.utils.synthetics.driver import ( + SyntheticsComponents, +) +from simpeg_drivers.utils.synthetics.options import ( + MeshOptions, + ModelOptions, + SurveyOptions, + SyntheticsComponentsOptions, +) +from tests.utils.targets import get_workspace + + +def collect_components(geoh5): + # Load results and validate + data_list = {} + with fetch_active_workspace(geoh5) as ws: + group = next(group for group in ws.groups if isinstance(group, UIJsonGroup)) + survey = next( + child for child in group.children if isinstance(child, AirborneTEMReceivers) + ) + for comp in "xyz": + data_group = survey.get_entity(f"Iteration_0_{comp}")[0] + data_list[comp] = np.vstack( + [survey.get_data(uid)[0].values for uid in data_group.properties] + ) + return data_list + + +@pytest.mark.parametrize("azimuth, dip", [(90, 0), (45, 0), (90, 90)]) +def test_tem_fwr_run(tmp_path: Path, azimuth, dip): + """ + Forward simulations with variable receiver orientations. + The results are not expected to be the same, but should be similar. + """ + refinement = (2, 4) + cell_size = (5.0, 5.0, 5.0) + # Run the forward east-west + opts = SyntheticsComponentsOptions( + method="airborne tdem", + refine_plate=True, + survey=SurveyOptions( + height=0.0, + n_stations=16, + n_lines=1, + drape=15.0, + rotation=90 - azimuth, + topography=lambda x, y: np.zeros(x.shape), + ), + mesh=MeshOptions( + cell_size=cell_size, refinement=refinement, padding_distance=400.0 + ), + model=ModelOptions( + background=1e-3, + plate=PlateModel( + strike_length=70.0, + dip_length=100.0, + width=10.0, + origin=(0.0, 0.0, -60.0), + direction=azimuth, + dip=45.0, + ), + ), + ) + + with get_workspace(tmp_path / "inversion_test.ui.geoh5") as geoh5: + components = SyntheticsComponents(geoh5, options=opts) + survey = components.survey + + # Create property group with orientation + dip_values = np.ones(survey.n_vertices) * dip + azimuth_values = np.ones(survey.n_vertices) * azimuth + data_list = survey.add_data( + { + "azimuth": {"values": azimuth_values}, + "dip": {"values": dip_values}, + } + ) + pg = PropertyGroup( + survey, properties=data_list, property_group_type="Dip direction & dip" + ) + + params = TDEMForwardOptions.build( + geoh5=geoh5, + title=f"Forward: Azimuth {azimuth}, Dip {dip}", + mesh=components.mesh, + topography_object=components.topography, + data_object=components.survey, + starting_model=components.model, + z_channel_bool=True, + x_channel_bool=True, + y_channel_bool=True, + receivers_orientation=pg, + ) + + fwr_driver = TDEMForwardDriver(params) + fwr_driver.run() + + +def test_validate_orientations(tmp_path: Path): + + with Workspace( + tmp_path / "../test_tem_fwr_run_90_0_0/inversion_test.ui.geoh5" + ) as geoh5: + sim_90_0 = collect_components(geoh5) + + with Workspace( + tmp_path / "../test_tem_fwr_run_45_0_0/inversion_test.ui.geoh5" + ) as geoh5: + sim_45_0 = collect_components(geoh5) + + # Components almost the same at 45 + assert np.mean((sim_90_0["y"] - sim_45_0["y"]) / sim_90_0["y"]) < 0.3 + + with Workspace( + tmp_path / "../test_tem_fwr_run_90_90_0/inversion_test.ui.geoh5" + ) as geoh5: + sim_90_90 = collect_components(geoh5) + + # 90 dip makes Y point down and Z east, so Y should be -Z, and Z should be Y + assert np.mean((sim_90_0["y"] - sim_90_90["z"]) / sim_90_0["y"]) < 0.1 + assert np.mean((sim_90_0["z"] + sim_90_90["y"]) / sim_90_0["z"]) < 0.1 diff --git a/tests/run_tests/oriented_fem_receiver_test.py b/tests/run_tests/oriented_fem_receiver_test.py new file mode 100644 index 00000000..89bec0d2 --- /dev/null +++ b/tests/run_tests/oriented_fem_receiver_test.py @@ -0,0 +1,160 @@ +# ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' +# Copyright (c) 2023-2026 Mira Geoscience Ltd. ' +# ' +# This file is part of simpeg-drivers package. ' +# ' +# simpeg-drivers is distributed under the terms and conditions of the MIT License ' +# (see LICENSE file at the root of this source code package). ' +# ' +# ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' + +# pylint: disable=too-many-locals + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest +from geoapps_utils.modelling.plates import PlateModel +from geoh5py import Workspace +from geoh5py.groups import PropertyGroup, UIJsonGroup +from geoh5py.objects import AirborneFEMReceivers +from geoh5py.shared.utils import fetch_active_workspace + +from simpeg_drivers.electromagnetics.frequency_domain.driver import ( + FDEMForwardDriver, +) +from simpeg_drivers.electromagnetics.frequency_domain.options import ( + FDEMForwardOptions, +) +from simpeg_drivers.utils.synthetics.driver import ( + SyntheticsComponents, +) +from simpeg_drivers.utils.synthetics.options import ( + MeshOptions, + ModelOptions, + SurveyOptions, + SyntheticsComponentsOptions, +) +from tests.utils.targets import get_workspace + + +# To test the full run and validate the inversion. +# Move this file out of the test directory and run. + +target_run = {"data_norm": 91.18814842528005, "phi_d": 4250, "phi_m": 968} + + +def collect_real_components(geoh5): + # Load results and validate + data_list = {} + with fetch_active_workspace(geoh5) as ws: + group = next(group for group in ws.groups if isinstance(group, UIJsonGroup)) + survey = next( + child for child in group.children if isinstance(child, AirborneFEMReceivers) + ) + for comp in "xyz": + data_group = survey.get_entity(f"Iteration_0_{comp}_real")[0] + data_list[comp] = np.vstack( + [survey.get_data(uid)[0].values for uid in data_group.properties] + ) + return data_list + + +@pytest.mark.parametrize("azimuth, dip", [(90, 0), (45, 0), (90, 90)]) +def test_fem_fwr_run(tmp_path: Path, azimuth, dip): + """ + Forward simulations with variable receiver orientations. + The results are not expected to be the same, but should be similar. + """ + refinement = (2, 4) + cell_size = (5.0, 5.0, 5.0) + # Run the forward east-west + opts = SyntheticsComponentsOptions( + method="fdem", + refine_plate=True, + survey=SurveyOptions( + height=0.0, + n_stations=16, + n_lines=1, + drape=15.0, + rotation=90 - azimuth, + topography=lambda x, y: np.zeros(x.shape), + ), + mesh=MeshOptions( + cell_size=cell_size, refinement=refinement, padding_distance=400.0 + ), + model=ModelOptions( + background=1e-3, + plate=PlateModel( + strike_length=70.0, + dip_length=100.0, + width=10.0, + origin=(0.0, 0.0, -60.0), + direction=azimuth, + dip=45.0, + ), + ), + ) + + with get_workspace(tmp_path / "inversion_test.ui.geoh5") as geoh5: + components = SyntheticsComponents(geoh5, options=opts) + survey = components.survey + + # Create property group with orientation + dip = np.ones(survey.n_vertices) * dip + azimuth = np.ones(survey.n_vertices) * azimuth + data_list = survey.add_data( + { + "azimuth": {"values": azimuth}, + "dip": {"values": dip}, + } + ) + pg = PropertyGroup( + survey, properties=data_list, property_group_type="Dip direction & dip" + ) + + params = FDEMForwardOptions.build( + geoh5=geoh5, + title=f"Forward: Azimuth {azimuth}, Dip {dip}", + mesh=components.mesh, + topography_object=components.topography, + data_object=components.survey, + starting_model=components.model, + z_real_channel_bool=True, + z_imag_channel_bool=True, + x_real_channel_bool=True, + x_imag_channel_bool=True, + y_real_channel_bool=True, + y_imag_channel_bool=True, + receivers_orientation=pg, + ) + + fwr_driver = FDEMForwardDriver(params) + fwr_driver.run() + + +def test_validate_orientations(tmp_path: Path): + + with Workspace( + tmp_path / "../test_fem_fwr_run_90_0_0/inversion_test.ui.geoh5" + ) as geoh5: + sim_90_0 = collect_real_components(geoh5) + + with Workspace( + tmp_path / "../test_fem_fwr_run_45_0_0/inversion_test.ui.geoh5" + ) as geoh5: + sim_45_0 = collect_real_components(geoh5) + + # Components almost the same at 45 + assert np.mean((sim_90_0["y"] - sim_45_0["y"]) / sim_90_0["y"]) < 0.2 + + with Workspace( + tmp_path / "../test_fem_fwr_run_90_90_0/inversion_test.ui.geoh5" + ) as geoh5: + sim_90_90 = collect_real_components(geoh5) + + # 90 dip makes Y point down and Z east, so Y should be -Z, and Z should be Y + assert np.mean((sim_90_0["y"] - sim_90_90["z"]) / sim_90_0["y"]) < 0.2 + assert np.mean((sim_90_0["z"] + sim_90_90["y"]) / sim_90_0["z"]) < 0.2