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