Source code for pycsamt.iot.methods

"""Per-method acquisition profiles for method-aware IoT QC.

:class:`~pycsamt.iot.monitoring.EMMethod` names the survey method, but on
its own it does not change any threshold: an operator still has to set the
expected frequency band, the required channels, and so on by hand. This
module carries the canonical, method-specific knowledge that turns
``EMMethod`` into concrete QC defaults.

A :class:`MethodProfile` records, per method, the typical acquisition band,
the channels a valid record must carry, a nominal sample rate, and whether
the method uses a controlled source or is sensitive to powerline noise.
:func:`method_profile` looks one up, :func:`target_bands_for_method` breaks
the band into decade sub-bands for coverage checks, and
:meth:`~pycsamt.iot.monitoring.MonitoringConfig.for_method` seeds a monitor
from a profile.

The band edges are representative defaults, not hard standards; override
them per survey when acquisition parameters differ.
"""

from __future__ import annotations

import math
from dataclasses import dataclass
from typing import Any

from .monitoring import EMMethod, _normalise_enum

__all__ = [
    "MethodProfile",
    "METHOD_PROFILES",
    "method_profile",
    "target_bands_for_method",
]


[docs] @dataclass(frozen=True) class MethodProfile: """Canonical acquisition characteristics for one EM method.""" method: EMMethod frequency_band_hz: tuple[float, float] | None required_channels: tuple[str, ...] default_sample_rate_hz: float | None = None controlled_source: bool = False powerline_sensitive: bool = False description: str = ""
[docs] @property def is_natural_source(self) -> bool: """True for passive methods (no transmitter).""" return not self.controlled_source
[docs] def as_dict(self) -> dict[str, Any]: return dict( method=self.method.value, frequency_band_hz=( list(self.frequency_band_hz) if self.frequency_band_hz else None ), required_channels=list(self.required_channels), default_sample_rate_hz=self.default_sample_rate_hz, controlled_source=self.controlled_source, powerline_sensitive=self.powerline_sensitive, description=self.description, )
# Representative acquisition profiles. Frequency edges follow common # practice (e.g. Zonge/Phoenix instrument bands); tune per survey. METHOD_PROFILES: dict[EMMethod, MethodProfile] = { EMMethod.AMT: MethodProfile( method=EMMethod.AMT, frequency_band_hz=(1.0, 10_000.0), required_channels=("ex", "ey", "hx", "hy"), default_sample_rate_hz=24_000.0, controlled_source=False, powerline_sensitive=True, description="Audio-frequency natural-source magnetotellurics.", ), EMMethod.MT: MethodProfile( method=EMMethod.MT, frequency_band_hz=(1.0e-4, 1_000.0), required_channels=("ex", "ey", "hx", "hy", "hz"), default_sample_rate_hz=1_000.0, controlled_source=False, powerline_sensitive=True, description="Broadband/long-period natural-source magnetotellurics.", ), EMMethod.CSAMT: MethodProfile( method=EMMethod.CSAMT, frequency_band_hz=(0.125, 8_192.0), required_channels=("ex", "ey", "hx", "hy"), default_sample_rate_hz=32_768.0, controlled_source=True, powerline_sensitive=True, description="Controlled-source audio-frequency magnetotellurics.", ), EMMethod.CSEM: MethodProfile( method=EMMethod.CSEM, frequency_band_hz=(0.01, 100.0), required_channels=("ex", "ey", "hx", "hy"), default_sample_rate_hz=1_000.0, controlled_source=True, powerline_sensitive=True, description=( "Controlled-source electromagnetics: a dipole source and a " "receiver array recording amplitude/phase versus offset." ), ), EMMethod.TDEM: MethodProfile( method=EMMethod.TDEM, frequency_band_hz=None, # time-domain: gated transients required_channels=("hz",), default_sample_rate_hz=100_000.0, controlled_source=True, powerline_sensitive=False, description="Time-domain (transient) electromagnetics.", ), EMMethod.TEM: MethodProfile( method=EMMethod.TEM, frequency_band_hz=None, required_channels=("hz",), default_sample_rate_hz=100_000.0, controlled_source=True, powerline_sensitive=False, description="Transient electromagnetics (alias of TDEM).", ), EMMethod.UNKNOWN: MethodProfile( method=EMMethod.UNKNOWN, frequency_band_hz=None, required_channels=(), default_sample_rate_hz=None, controlled_source=False, powerline_sensitive=False, description="Unspecified method; no method-derived defaults.", ), }
[docs] def method_profile(method: EMMethod | str) -> MethodProfile: """Return the :class:`MethodProfile` for *method*. Parameters ---------- method : EMMethod or str Method enum member or a name/value such as ``"csamt"``. Returns ------- MethodProfile The registered profile, or the ``UNKNOWN`` profile for a method with no registered defaults. """ key = _normalise_enum(method, EMMethod, "method") return METHOD_PROFILES.get(key, METHOD_PROFILES[EMMethod.UNKNOWN])
[docs] def target_bands_for_method( method: EMMethod | str, ) -> list[tuple[float, float]]: """Return per-decade sub-bands spanning a method's frequency band. These feed coverage checks such as :func:`~pycsamt.iot.edge_amt.estimate_frequency_coverage`, which report the fraction of target bands a recording actually resolves. A method without a defined band (e.g. time-domain) returns an empty list. """ profile = method_profile(method) band = profile.frequency_band_hz if band is None: return [] lo, hi = band lo_exp = math.floor(math.log10(lo)) hi_exp = math.ceil(math.log10(hi)) edges = [10.0**e for e in range(lo_exp, hi_exp + 1)] # Clamp the outer edges to the actual band limits. edges[0] = lo edges[-1] = hi return [ (edges[i], edges[i + 1]) for i in range(len(edges) - 1) if edges[i] < edges[i + 1] ]