# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Format-neutral EMTF rotation and covariance transformations.
The implementation follows the general matrix formulation used by EMTF FCU:
``TF' = V @ TF @ U.T``
where ``U`` transforms input-channel coordinates and ``V`` transforms
output-channel coordinates. When full error covariance factors are present,
``S`` (inverse signal covariance) and ``N`` (residual covariance) transform as
``S' = U @ S @ U.T`` and ``N' = V @ N @ V.T``.
The physical :class:`~pycsamt.metadata.SiteLayout` is never rotated here. It
continues to describe the original field geometry while
:class:`~pycsamt.metadata.OrientationMeta` records the coordinate frame of the
transfer-function data.
"""
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass
from math import cos, radians, sin
from typing import Any, Mapping
import warnings
import numpy as np
from ..metadata import OrientationMeta, SiteLayout
from .document import EMTF
from .transfer import TransferFunction
__all__ = [
"ApproximateVarianceRotationWarning",
"DerivedDataRotationWarning",
"EMTFRotationError",
"EMTFRotationWarning",
"LegacyRotationAssumptionWarning",
"RotationMatrices",
"UnsupportedEstimateRotationWarning",
"horizontal_rotation_matrix",
"horizontal_inverse_rotation_matrix",
"rotate_covariance",
"rotate_emtf",
"rotate_transfer_function",
]
[docs]
class EMTFRotationError(ValueError):
"""Raised when an EMTF rotation cannot be defined unambiguously."""
[docs]
class EMTFRotationWarning(UserWarning):
"""Base warning for scientifically incomplete EMTF rotations."""
[docs]
class ApproximateVarianceRotationWarning(EMTFRotationWarning):
"""Warn that variance was handled without complete covariance."""
[docs]
class UnsupportedEstimateRotationWarning(EMTFRotationWarning):
"""Warn that a statistical estimate cannot be rotated safely."""
[docs]
class DerivedDataRotationWarning(EMTFRotationWarning):
"""Warn that derived products were dropped or retained stale."""
[docs]
class LegacyRotationAssumptionWarning(EMTFRotationWarning):
"""Warn that historical EDI rotation metadata are being interpreted."""
[docs]
@dataclass(frozen=True)
class RotationMatrices:
"""Per-period input and output matrices used in an EMTF rotation.
Parameters
----------
input_matrix : ndarray
Array of shape ``(n_period, n_input, n_input)`` containing ``U``.
output_matrix : ndarray
Array of shape ``(n_period, n_output, n_output)`` containing ``V``.
source_mode, target_mode : str
Coordinate-frame modes used to construct the matrices.
source_angles : ndarray or None
Orthogonal source azimuth(s), in degrees clockwise from geographic
north. ``None`` when the source follows the physical site layout.
target_angle : float or None
Orthogonal target azimuth. ``None`` for site-layout targets.
"""
input_matrix: np.ndarray
output_matrix: np.ndarray
source_mode: str
target_mode: str
source_angles: np.ndarray | None
target_angle: float | None
[docs]
@property
def n_periods(self) -> int:
return int(self.input_matrix.shape[0])
[docs]
@property
def is_identity(self) -> bool:
"""Return whether both matrix families are identities."""
ni = self.input_matrix.shape[1]
no = self.output_matrix.shape[1]
return bool(
np.allclose(
self.input_matrix,
np.eye(ni)[None, :, :],
rtol=0.0,
atol=1.0e-12,
)
and np.allclose(
self.output_matrix,
np.eye(no)[None, :, :],
rtol=0.0,
atol=1.0e-12,
)
)
def _angle(value: Any, *, name: str) -> float:
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise EMTFRotationError(f"{name} must be a finite angle") from exc
if not np.isfinite(number):
raise EMTFRotationError(f"{name} must be a finite angle")
return number
def _angle_vector(value: Any, n: int, *, name: str) -> np.ndarray:
arr = np.asarray(value, dtype=float).reshape(-1)
if arr.size == 1:
arr = np.full(n, float(arr[0]), dtype=float)
if arr.size != n:
raise EMTFRotationError(
f"{name} must be scalar or length {n}; got {arr.size}"
)
if not np.all(np.isfinite(arr)):
raise EMTFRotationError(f"{name} must contain finite angles")
return arr
[docs]
def horizontal_rotation_matrix(
theta1: float,
theta2: float,
target_angle: float,
) -> np.ndarray:
"""Return the FCU ``Q`` transform from two channels to an orthogonal frame.
Angles are degrees clockwise from geographic north. The source channels
need not be orthogonal. The matrix maps source vector components into a
right-handed orthogonal coordinate frame whose x-axis has azimuth
``target_angle``.
"""
target_angle = _angle(target_angle, name="target_angle")
t1 = radians(_angle(theta1, name="theta1") - target_angle)
t2 = radians(_angle(theta2, name="theta2") - target_angle)
return np.array(
[
[cos(t1), cos(t2)],
[sin(t1), sin(t2)],
],
dtype=float,
)
[docs]
def horizontal_inverse_rotation_matrix(
theta1: float,
theta2: float,
target_angle: float,
) -> np.ndarray:
"""Return the inverse of :func:`horizontal_rotation_matrix`.
The explicit formula mirrors EMTF FCU ``rot2inv`` and permits a
non-orthogonal original site layout. Parallel channels are singular and
therefore rejected.
"""
theta1 = _angle(theta1, name="theta1")
theta2 = _angle(theta2, name="theta2")
target_angle = _angle(target_angle, name="target_angle")
det = sin(radians(theta2 - theta1))
if abs(det) <= 1.0e-12:
raise EMTFRotationError(
"horizontal channel orientations are parallel or nearly parallel"
)
return np.array(
[
[
sin(radians(theta2 - target_angle)) / det,
-cos(radians(theta2 - target_angle)) / det,
],
[
-sin(radians(theta1 - target_angle)) / det,
cos(radians(theta1 - target_angle)) / det,
],
],
dtype=float,
)
def _is_missing(data: np.ndarray) -> np.ndarray:
if np.iscomplexobj(data):
return ~np.isfinite(data.real) | ~np.isfinite(data.imag)
return ~np.isfinite(data)
def _masked_bilinear(
left: np.ndarray,
data: np.ndarray,
right: np.ndarray,
*,
coefficient_tol: float = 1.0e-13,
) -> np.ndarray:
"""Compute ``left @ data @ right.T`` while respecting missing values.
A missing source entry contaminates only output entries that actually
depend on it. This avoids the usual ``0 * NaN -> NaN`` propagation during
identity or right-angle rotations while still refusing to manufacture a
rotated value when a required component is absent.
"""
arr = np.asarray(data)
missing = _is_missing(arr)
filled = np.where(missing, 0.0, arr)
result = left @ filled @ right.T
if not np.any(missing):
return result
left_dep = np.abs(left) > coefficient_tol
right_dep = np.abs(right) > coefficient_tol
dependencies = np.einsum(
"ia,ab,jb->ij",
left_dep.astype(int),
missing.astype(int),
right_dep.astype(int),
)
required_missing = dependencies > 0
if np.iscomplexobj(result):
result = np.asarray(result, dtype=complex)
result[required_missing] = np.nan + 1j * np.nan
else:
result = np.asarray(result, dtype=float)
result[required_missing] = np.nan
return result
def _channel_pair_indices(names: tuple[str, ...]) -> list[tuple[int, int]]:
"""Identify horizontal x/y output pairs while leaving z channels fixed."""
if len(names) == 1:
return []
if len(names) == 2:
return [(0, 1)]
groups: dict[str, dict[str, int]] = {}
for index, raw_name in enumerate(names):
name = str(raw_name).strip().lower()
if not name or name[-1:] not in {"x", "y"}:
continue
groups.setdefault(name[:-1], {})[name[-1]] = index
pairs: list[tuple[int, int]] = []
paired: set[int] = set()
for group in groups.values():
if "x" in group and "y" in group:
ix, iy = group["x"], group["y"]
pairs.append((ix, iy))
paired.update((ix, iy))
unpaired_horizontal = []
for index, raw_name in enumerate(names):
name = str(raw_name).strip().lower()
if name[-1:] in {"x", "y"} and index not in paired:
unpaired_horizontal.append(raw_name)
if unpaired_horizontal:
raise EMTFRotationError(
"unable to identify complete x/y output pairs for channels: "
+ ", ".join(map(str, unpaired_horizontal))
)
return pairs
def _layout_channels(
layout: SiteLayout,
names: tuple[str, ...],
*,
role: str,
):
channels = []
for name in names:
channel = layout.get_channel(name, role=role)
if channel is None:
raise EMTFRotationError(
f"site layout has no {role} channel matching {name!r}"
)
channels.append(channel)
return channels
def _require_horizontal_pair(channels, *, label: str) -> tuple[float, float]:
if len(channels) != 2:
raise EMTFRotationError(f"{label} must contain exactly two channels")
values = []
for channel in channels:
if channel.orientation is None:
raise EMTFRotationError(
f"{label} channel {channel.name!r} has no orientation"
)
if channel.tilt is not None and abs(float(channel.tilt)) > 1.0e-8:
raise EMTFRotationError(
f"tilted {label} channel {channel.name!r} is not supported"
)
values.append(float(channel.orientation))
return values[0], values[1]
def _site_input_transform(
tf: TransferFunction,
layout: SiteLayout,
target_angle: float,
*,
inverse: bool,
) -> np.ndarray:
channels = _layout_channels(
layout,
tf.input_channels,
role="input",
)
theta1, theta2 = _require_horizontal_pair(
channels,
label="input",
)
if inverse:
return horizontal_rotation_matrix(theta1, theta2, target_angle).T
return horizontal_inverse_rotation_matrix(
theta1,
theta2,
target_angle,
).T
def _site_output_transform(
tf: TransferFunction,
layout: SiteLayout,
target_angle: float,
*,
inverse: bool,
) -> np.ndarray:
matrix = np.eye(tf.n_output, dtype=float)
channels = _layout_channels(
layout,
tf.output_channels,
role="output",
)
for ix, iy in _channel_pair_indices(tf.output_channels):
pair = [channels[ix], channels[iy]]
theta1, theta2 = _require_horizontal_pair(pair, label="output")
if inverse:
block = horizontal_inverse_rotation_matrix(
theta1,
theta2,
target_angle,
)
else:
block = horizontal_rotation_matrix(
theta1,
theta2,
target_angle,
)
indices = np.ix_([ix, iy], [ix, iy])
matrix[indices] = block
return matrix
def _orthogonal_input_transform(
old_angle: float,
new_angle: float,
) -> np.ndarray:
# For an orthogonal source frame, FCU U == Q because Q^-1 == Q.T.
return horizontal_rotation_matrix(
old_angle,
old_angle + 90.0,
new_angle,
)
def _orthogonal_output_transform(
tf: TransferFunction,
old_angle: float,
new_angle: float,
) -> np.ndarray:
matrix = np.eye(tf.n_output, dtype=float)
block = horizontal_rotation_matrix(
old_angle,
old_angle + 90.0,
new_angle,
)
for ix, iy in _channel_pair_indices(tf.output_channels):
matrix[np.ix_([ix, iy], [ix, iy])] = block
return matrix
def _rotation_matrices(
tf: TransferFunction,
*,
source_mode: str,
target_mode: str,
site_layout: SiteLayout | None,
source_angles: Any | None,
target_angle: float | None,
) -> RotationMatrices:
source_mode = str(source_mode).strip().lower()
target_mode = str(target_mode).strip().lower()
if source_mode not in {"orthogonal", "sitelayout"}:
raise EMTFRotationError(
"source_mode must be 'orthogonal' or 'sitelayout'"
)
if target_mode not in {"orthogonal", "sitelayout"}:
raise EMTFRotationError(
"target_mode must be 'orthogonal' or 'sitelayout'"
)
if tf.n_input != 2:
raise EMTFRotationError(
"Phase 7 EMTF rotation currently requires exactly two input "
"channels"
)
n = tf.n_periods
source_vector = None
if source_mode == "orthogonal":
if source_angles is None:
raise EMTFRotationError(
"orthogonal source data require source angle metadata"
)
source_vector = _angle_vector(
source_angles,
n,
name="source_angles",
)
if target_mode == "orthogonal":
if target_angle is None:
raise EMTFRotationError(
"orthogonal target rotation requires target_angle"
)
target_angle = _angle(target_angle, name="target_angle")
else:
target_angle = None
if source_mode == "sitelayout" or target_mode == "sitelayout":
if site_layout is None:
raise EMTFRotationError(
"site-layout rotation requires original SiteLayout metadata"
)
inputs = np.empty((n, tf.n_input, tf.n_input), dtype=float)
outputs = np.empty((n, tf.n_output, tf.n_output), dtype=float)
for index in range(n):
if source_mode == target_mode == "sitelayout":
inputs[index] = np.eye(tf.n_input)
outputs[index] = np.eye(tf.n_output)
continue
if source_mode == "sitelayout" and target_mode == "orthogonal":
inputs[index] = _site_input_transform(
tf,
site_layout,
target_angle,
inverse=False,
)
outputs[index] = _site_output_transform(
tf,
site_layout,
target_angle,
inverse=False,
)
continue
old_angle = float(source_vector[index])
if source_mode == "orthogonal" and target_mode == "orthogonal":
inputs[index] = _orthogonal_input_transform(
old_angle,
target_angle,
)
outputs[index] = _orthogonal_output_transform(
tf,
old_angle,
target_angle,
)
continue
# Orthogonal -> original site layout. FCU applies the inverse site
# transforms at the old orthogonal azimuth.
inputs[index] = _site_input_transform(
tf,
site_layout,
old_angle,
inverse=True,
)
outputs[index] = _site_output_transform(
tf,
site_layout,
old_angle,
inverse=True,
)
return RotationMatrices(
input_matrix=inputs,
output_matrix=outputs,
source_mode=source_mode,
target_mode=target_mode,
source_angles=source_vector,
target_angle=target_angle,
)
[docs]
def rotate_covariance(
covariance: Any,
matrices: Any,
*,
side: str,
) -> np.ndarray:
"""Rotate an inverse-signal or residual covariance matrix family.
Parameters
----------
covariance : array-like
Shape ``(n_period, n, n)``.
matrices : array-like
The corresponding ``U`` or ``V`` matrices for every period.
side : {"input", "output"}
Descriptive validation label. Both covariance kinds use
``R @ C @ R.T``; the side identifies which matrix family is supplied.
"""
normalized = str(side).strip().lower()
if normalized not in {"input", "output"}:
raise ValueError("side must be 'input' or 'output'")
cov = np.asarray(covariance)
transforms = np.asarray(matrices, dtype=float)
if cov.ndim != 3 or cov.shape[1] != cov.shape[2]:
raise EMTFRotationError(
f"{normalized} covariance must have shape (n, m, m)"
)
if transforms.shape != cov.shape:
raise EMTFRotationError(
f"{normalized} covariance/rotation shape mismatch: "
f"{cov.shape} vs {transforms.shape}"
)
out = np.empty_like(cov, dtype=np.result_type(cov, complex))
for index in range(cov.shape[0]):
out[index] = _masked_bilinear(
transforms[index],
cov[index],
transforms[index],
)
if not np.iscomplexobj(cov):
out = np.real(out)
return out
def _variance_from_covariance(
inverse_signal: np.ndarray,
residual: np.ndarray,
) -> np.ndarray:
sdiag = np.diagonal(inverse_signal, axis1=1, axis2=2)
ndiag = np.diagonal(residual, axis1=1, axis2=2)
product = ndiag[:, :, None] * sdiag[:, None, :]
if np.iscomplexobj(product):
scale = np.maximum(1.0, np.abs(product.real))
bad_imag = np.abs(product.imag) > 1.0e-7 * scale
if np.any(bad_imag):
warnings.warn(
"rotated covariance diagonals yield variance with a "
"non-negligible imaginary part; the real part is retained",
EMTFRotationWarning,
stacklevel=3,
)
product = product.real
tiny_negative = (product < 0.0) & (product > -1.0e-12)
if np.any(tiny_negative):
product = np.array(product, copy=True)
product[tiny_negative] = 0.0
if np.any(product < 0.0):
warnings.warn(
"rotated covariance factors produced negative variance entries",
EMTFRotationWarning,
stacklevel=3,
)
return np.asarray(product, dtype=float)
def _rotate_variance_independent(
variance: np.ndarray,
matrices: RotationMatrices,
) -> np.ndarray:
"""Propagate component variances assuming zero cross-covariance."""
var = np.asarray(variance, dtype=float)
expected = (
matrices.n_periods,
matrices.output_matrix.shape[1],
matrices.input_matrix.shape[1],
)
if var.shape != expected:
raise EMTFRotationError(
f"variance shape must be {expected}; got {var.shape}"
)
out = np.empty_like(var, dtype=float)
for index in range(var.shape[0]):
left = np.square(matrices.output_matrix[index])
right = np.square(matrices.input_matrix[index])
out[index] = _masked_bilinear(left, var[index], right)
return out
def _rotate_variance_fcu(
variance: np.ndarray,
matrices: RotationMatrices,
) -> np.ndarray:
"""Replicate FCU's legacy direct variance-matrix rotation."""
var = np.asarray(variance, dtype=float)
expected = (
matrices.n_periods,
matrices.output_matrix.shape[1],
matrices.input_matrix.shape[1],
)
if var.shape != expected:
raise EMTFRotationError(
f"variance shape must be {expected}; got {var.shape}"
)
out = np.empty_like(var, dtype=float)
for index in range(var.shape[0]):
out[index] = _masked_bilinear(
matrices.output_matrix[index],
var[index],
matrices.input_matrix[index],
)
return out
def _handle_variance_without_full_covariance(
variance: np.ndarray,
matrices: RotationMatrices,
*,
policy: str,
) -> np.ndarray | None:
normalized = str(policy).strip().lower().replace("-", "_")
aliases = {
"independent_components": "independent",
"legacy": "fcu",
"legacy_fcu": "fcu",
}
normalized = aliases.get(normalized, normalized)
allowed = {"drop", "raise", "independent", "fcu"}
if normalized not in allowed:
raise ValueError(f"variance_policy must be one of {sorted(allowed)}")
if normalized == "raise":
raise EMTFRotationError(
"VAR cannot be rotated exactly without both INVSIGCOV and "
"RESIDCOV"
)
if normalized == "drop":
warnings.warn(
"dropping VAR because full covariance is unavailable for an "
"exact rotation",
ApproximateVarianceRotationWarning,
stacklevel=3,
)
return None
if normalized == "independent":
warnings.warn(
"rotating VAR under an independent-component assumption because "
"full covariance is unavailable",
ApproximateVarianceRotationWarning,
stacklevel=3,
)
return _rotate_variance_independent(variance, matrices)
warnings.warn(
"using EMTF FCU legacy direct variance-matrix rotation; error bars "
"are not statistically valid without full covariance",
ApproximateVarianceRotationWarning,
stacklevel=3,
)
return _rotate_variance_fcu(variance, matrices)
def _copy_estimate_with_data(estimate, data):
result = estimate.copy()
result.data = np.asarray(data)
return result
[docs]
def rotate_transfer_function(
tf: TransferFunction,
*,
source_mode: str,
target_mode: str = "orthogonal",
target_angle: float | None = 0.0,
source_angles: Any | None = None,
site_layout: SiteLayout | None = None,
variance_policy: str = "drop",
unsupported_estimates: str = "drop",
) -> TransferFunction:
"""Rotate one matrix-valued transfer function and supported estimates.
Full ``INVSIGCOV`` + ``RESIDCOV`` factors are rotated exactly and ``VAR``
is recomputed from their diagonal products. If full covariance is absent,
``variance_policy`` controls whether ``VAR`` is dropped, rejected, or
transformed under an explicit approximation.
"""
if not isinstance(tf, TransferFunction):
raise TypeError("tf must be a TransferFunction")
matrices = _rotation_matrices(
tf,
source_mode=source_mode,
target_mode=target_mode,
site_layout=site_layout,
source_angles=source_angles,
target_angle=target_angle,
)
if matrices.is_identity:
return tf.copy()
out = tf.copy()
rotated = np.empty_like(tf.data, dtype=np.result_type(tf.data, complex))
for index in range(tf.n_periods):
rotated[index] = _masked_bilinear(
matrices.output_matrix[index],
tf.data[index],
matrices.input_matrix[index],
)
if not np.iscomplexobj(tf.data):
rotated = np.real(rotated)
out.data = rotated
out.estimates = {}
invsig = tf.get_estimate("inverse_signal_covariance")
resid = tf.get_estimate("residual_covariance")
variance = tf.get_estimate("variance")
rotated_s = None
rotated_n = None
if invsig is not None:
expected = (tf.n_periods, tf.n_input, tf.n_input)
if invsig.data.shape != expected:
raise EMTFRotationError(
"INVSIGCOV shape mismatch: expected "
f"{expected}, got {invsig.data.shape}"
)
rotated_s = rotate_covariance(
invsig.data,
matrices.input_matrix,
side="input",
)
out.add_estimate(
_copy_estimate_with_data(invsig, rotated_s),
key="inverse_signal_covariance",
replace=True,
)
if resid is not None:
expected = (tf.n_periods, tf.n_output, tf.n_output)
if resid.data.shape != expected:
raise EMTFRotationError(
"RESIDCOV shape mismatch: expected "
f"{expected}, got {resid.data.shape}"
)
rotated_n = rotate_covariance(
resid.data,
matrices.output_matrix,
side="output",
)
out.add_estimate(
_copy_estimate_with_data(resid, rotated_n),
key="residual_covariance",
replace=True,
)
if rotated_s is not None and rotated_n is not None:
new_var = _variance_from_covariance(rotated_s, rotated_n)
if variance is None:
from .estimates import StatisticalEstimate
variance = StatisticalEstimate(
name="VAR",
kind="variance",
data=new_var,
)
out.add_estimate(
_copy_estimate_with_data(variance, new_var),
key="variance",
replace=True,
)
elif variance is not None:
new_var = _handle_variance_without_full_covariance(
variance.data,
matrices,
policy=variance_policy,
)
if new_var is not None:
out.add_estimate(
_copy_estimate_with_data(variance, new_var),
key="variance",
replace=True,
)
handled = {
id(item)
for item in (invsig, resid, variance)
if item is not None
}
policy = str(unsupported_estimates).strip().lower()
if policy not in {"drop", "keep", "raise"}:
raise ValueError(
"unsupported_estimates must be 'drop', 'keep', or 'raise'"
)
for key, estimate in tf.estimates.items():
if id(estimate) in handled:
continue
message = (
f"statistical estimate {estimate.name!r} has no defined Phase 7 "
"rotation rule"
)
if policy == "raise":
raise EMTFRotationError(message)
warnings.warn(
message
+ (
"; keeping it unchanged"
if policy == "keep"
else "; dropping it"
),
UnsupportedEstimateRotationWarning,
stacklevel=2,
)
if policy == "keep":
preserved = estimate.copy()
preserved.attrs["stale_after_rotation"] = True
out.add_estimate(preserved, key=key, replace=True)
out.attrs = dict(out.attrs)
out.attrs["rotation"] = {
"source_mode": matrices.source_mode,
"target_mode": matrices.target_mode,
"source_angles": (
None
if matrices.source_angles is None
else matrices.source_angles.tolist()
),
"target_angle": matrices.target_angle,
}
return out
def _source_angles_for_document(
document: EMTF,
tf: TransferFunction,
explicit: Any | None,
*,
use_legacy_edi_rotation: bool,
) -> tuple[str, Any | None]:
orientation = document.orientation
if explicit is not None:
return "orthogonal", explicit
if orientation is not None and orientation.mode == "sitelayout":
return "sitelayout", None
if orientation is not None and orientation.mode == "orthogonal":
angle = orientation.angle_to_geographic_north
if angle is None:
raise EMTFRotationError(
"orthogonal document orientation has no angle metadata"
)
return "orthogonal", angle
if not use_legacy_edi_rotation:
raise EMTFRotationError(
"transfer-function orientation is ambiguous; provide "
"source_angles or enable use_legacy_edi_rotation"
)
if orientation is None:
raise EMTFRotationError(
"no orientation metadata are available for legacy EDI rotation"
)
extra = orientation.extra or {}
tag = tf.name.lower()
key = "edi_trot" if tag == "tipper" else "edi_zrot"
raw = extra.get(key)
if raw is None and tag == "tipper":
raw = extra.get("edi_zrot")
if raw is not None:
key = "edi_zrot"
if raw is None:
raise EMTFRotationError(
f"no historical EDI rotation vector is available for {tf.name}"
)
warnings.warn(
f"interpreting historical {key.upper()} as an orthogonal source "
"orientation; this opt-in assumption is needed for principal-axis "
"or otherwise frequency-dependent EDI rotations",
LegacyRotationAssumptionWarning,
stacklevel=3,
)
return "orthogonal", raw
def _source_override_for_tf(
source_angles: Any | Mapping[str, Any] | None,
tf: TransferFunction,
) -> Any | None:
if not isinstance(source_angles, Mapping):
return source_angles
candidates = [tf.name]
definition = tf.definition
if definition is not None:
candidates.extend(
[definition.tag, definition.name, *definition.aliases]
)
for candidate in candidates:
if candidate in source_angles:
return source_angles[candidate]
low = str(candidate).lower()
for key, value in source_angles.items():
if str(key).lower() == low:
return value
return None
def _handle_derived(
tf: TransferFunction,
*,
policy: str,
) -> TransferFunction | None:
normalized = str(policy).strip().lower()
if normalized not in {"drop", "keep", "raise"}:
raise ValueError("derived_policy must be 'drop', 'keep', or 'raise'")
message = (
f"derived data type {tf.name!r} should be recomputed after rotating "
"its primary transfer function"
)
if normalized == "raise":
raise EMTFRotationError(message)
warnings.warn(
message
+ (
"; keeping it marked stale"
if normalized == "keep"
else "; dropping it"
),
DerivedDataRotationWarning,
stacklevel=3,
)
if normalized == "drop":
return None
result = tf.copy()
result.attrs["stale_after_rotation"] = True
return result
def _rotation_history(
orientation: OrientationMeta | None,
*,
target_mode: str,
target_angle: float | None,
) -> OrientationMeta:
old = orientation
extra = dict(old.extra if old is not None else {})
history = list(extra.get("rotation_history", []) or [])
history.append(
{
"operation": "emtf_rotation",
"source_mode": None if old is None else old.mode,
"source_angle": (
None if old is None else old.angle_to_geographic_north
),
"target_mode": target_mode,
"target_angle": target_angle,
}
)
extra["rotation_history"] = history
previous = None if old is None else old.rotation_info
statement = (
"pyCSAMT rotated transfer functions to original site layout"
if target_mode == "sitelayout"
else (
"pyCSAMT rotated transfer functions to an orthogonal frame at "
f"{float(target_angle):g} degrees clockwise from geographic north"
)
)
rotation_info = statement if not previous else f"{previous}\n{statement}"
return OrientationMeta(
mode=target_mode,
angle_to_geographic_north=(
float(target_angle) if target_mode == "orthogonal" else None
),
rotation_info=rotation_info,
extra=extra,
)
[docs]
def rotate_emtf(
document: EMTF,
angle: float | None = 0.0,
*,
target: str = "orthogonal",
inplace: bool = False,
source_angles: Any | Mapping[str, Any] | None = None,
use_legacy_edi_rotation: bool = False,
variance_policy: str = "drop",
unsupported_estimates: str = "drop",
derived_policy: str = "drop",
) -> EMTF:
"""Rotate all primary transfer functions in an :class:`EMTF` document.
Parameters
----------
document : EMTF
Source scientific document.
angle : float, optional
Target azimuth in degrees clockwise from geographic north. Ignored for
``target='sitelayout'``.
target : {"orthogonal", "sitelayout"}
Coordinate frame to produce.
inplace : bool, default=False
If ``True``, mutate and return ``document``. The original physical
``SiteLayout`` object is never modified.
source_angles : scalar, array-like, mapping, optional
Explicit orthogonal source angle(s). A mapping may provide different
vectors by TF tag/code, useful for historical principal-axis EDI.
use_legacy_edi_rotation : bool, default=False
Opt in to interpreting retained ``edi_zrot`` / ``edi_trot`` vectors as
orthogonal source frames when document-level orientation is ambiguous.
variance_policy : {"drop", "raise", "independent", "fcu"}
Behavior for ``VAR`` when full covariance factors are unavailable.
unsupported_estimates : {"drop", "keep", "raise"}
Policy for estimates without a defined rotation law.
derived_policy : {"drop", "keep", "raise"}
Derived products are not rotated directly; normally they should be
recomputed after rotating their primary transfer function.
"""
if not isinstance(document, EMTF):
raise TypeError("document must be an EMTF")
target_mode = str(target).strip().lower().replace("_", "")
if target_mode in {"site", "layout", "sitelayout"}:
target_mode = "sitelayout"
target_angle = None
elif target_mode in {"orthogonal", "ortho"}:
target_mode = "orthogonal"
target_angle = _angle(angle, name="angle")
else:
raise ValueError("target must be 'orthogonal' or 'sitelayout'")
result = document if inplace else deepcopy(document)
original_layout = document.site_layout
rotated_tfs: dict[str, TransferFunction] = {}
for key, original_tf in document.transfer_functions.items():
definition = original_tf.definition
if definition is not None and definition.is_derived:
derived = _handle_derived(original_tf, policy=derived_policy)
if derived is not None:
rotated_tfs[key] = derived
continue
explicit = _source_override_for_tf(source_angles, original_tf)
source_mode, angles = _source_angles_for_document(
document,
original_tf,
explicit,
use_legacy_edi_rotation=use_legacy_edi_rotation,
)
rotated_tfs[key] = rotate_transfer_function(
original_tf,
source_mode=source_mode,
target_mode=target_mode,
target_angle=target_angle,
source_angles=angles,
site_layout=original_layout,
variance_policy=variance_policy,
unsupported_estimates=unsupported_estimates,
)
result.transfer_functions = rotated_tfs
result.tags = tuple(rotated_tfs)
result.orientation = _rotation_history(
document.orientation,
target_mode=target_mode,
target_angle=target_angle,
)
# Historical EDI rotation vectors describe the old numerical frame.
# Never carry them forward unchanged after rotating the data.
rotation_extra = dict(result.orientation.extra)
if target_mode == "orthogonal":
normalized_angle = float(target_angle)
count = 0 if result.periods is None else len(result.periods)
rotation_extra["edi_zrot"] = [normalized_angle] * count
rotation_extra["edi_had_zrot"] = True
if result.tipper_tf is not None:
rotation_extra["edi_trot"] = [normalized_angle] * count
rotation_extra["edi_had_trot"] = True
else:
rotation_extra.pop("edi_trot", None)
rotation_extra["edi_had_trot"] = False
rotation_extra["edi_rotation_metadata_normalized"] = True
else:
rotation_extra.pop("edi_zrot", None)
rotation_extra.pop("edi_trot", None)
rotation_extra["edi_had_zrot"] = False
rotation_extra["edi_had_trot"] = False
rotation_extra["edi_rotation_metadata_normalized"] = True
result.orientation.extra = rotation_extra
# Never rotate/replace the physical field geometry. In inplace mode the
# exact original object remains attached; in copy mode deepcopy created a
# detached but numerically identical layout.
if inplace:
result.site_layout = original_layout
result.validate()
return result