Source code for pycsamt.metadata.instrument
# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
pycsamt.metadata.instrument
============================
Structured acquisition-system descriptor for MT / AMT / CSAMT / TEM surveys.
:class:`InstrumentMeta` captures the recording system and sensor configuration
in a form that can be round-tripped to/from EDI ``>HEAD`` fields and JSON/YAML.
Pre-built presets for common field systems (Phoenix, Metronix, LEMI, Zonge)
are available via :func:`known_system`.
Quick start
-----------
::
from pycsamt.metadata.instrument import InstrumentMeta, SensorSpec
# Manually construct
inst = InstrumentMeta(
system="Phoenix V8",
serial="V8-20473",
magnetic_sensor=SensorSpec(
sensor_type="induction_coil",
model="MTC-150H",
frequency_range=(0.00001, 10000.0),
),
electric_sensor=SensorSpec(sensor_type="electrode", model="Pb-PbCl2"),
)
print(inst.summary())
# Build from a preset
inst2 = InstrumentMeta.from_preset("metronix_adu07")
# Round-trip JSON
import json
s = inst.to_json()
inst3 = InstrumentMeta.from_json(s)
# Push fields to an EDI Head dict
head_dict = inst.to_head_fields() # {"acqby": "Phoenix V8 / V8-20473", ...}
API Reference
-------------
.. autosummary::
:toctree:
SensorSpec
InstrumentMeta
known_system
list_presets
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from typing import Any, Literal
try:
import yaml as _yaml
_HAS_YAML = True
except ModuleNotFoundError:
_HAS_YAML = False
__all__ = [
"SensorSpec",
"InstrumentMeta",
"KNOWN_SYSTEMS",
"known_system",
"list_presets",
]
# ---------------------------------------------------------------------------
# SensorType alias
# ---------------------------------------------------------------------------
SensorType = Literal["induction_coil", "fluxgate", "electrode", "other"]
# ---------------------------------------------------------------------------
# SensorSpec
# ---------------------------------------------------------------------------
[docs]
@dataclass
class SensorSpec:
"""Specification for a single sensor (magnetic or electric).
Parameters
----------
sensor_type : {"induction_coil", "fluxgate", "electrode", "other"}
Physical transducer principle.
model : str
Manufacturer model string, e.g. ``"MTC-150H"``.
frequency_range : tuple[float, float] or None
(f_min, f_max) in Hz that the sensor is rated for.
notes : str
Free-text annotation (calibration file name, mounting details, etc.).
Examples
--------
>>> s = SensorSpec("induction_coil", "MTC-150H", (1e-5, 1e4))
>>> s.covers(0.1)
True
>>> s.covers(1e6)
False
"""
sensor_type: SensorType = "induction_coil"
model: str = ""
frequency_range: tuple[float, float] | None = None
notes: str = ""
# ------------------------------------------------------------------
[docs]
def covers(self, frequency_hz: float) -> bool:
"""Return True if *frequency_hz* lies within the rated band."""
if self.frequency_range is None:
return True # assume universal if unspecified
f_min, f_max = self.frequency_range
return f_min <= frequency_hz <= f_max
# ------------------------------------------------------------------
def __str__(self) -> str:
fr = (
f"{self.frequency_range[0]:.3g}–{self.frequency_range[1]:.3g} Hz"
if self.frequency_range
else "full band"
)
label = self.model if self.model else self.sensor_type
return f"<SensorSpec {label!r} [{fr}]>"
__repr__ = __str__
# ------------------------------------------------------------------
# (de)serialisation helpers used by InstrumentMeta
# ------------------------------------------------------------------
def _to_dict(self) -> dict[str, Any]:
d = asdict(self)
if d["frequency_range"] is not None:
d["frequency_range"] = list(d["frequency_range"])
return d
@classmethod
def _from_dict(cls, d: dict[str, Any]) -> SensorSpec:
fr = d.get("frequency_range")
return cls(
sensor_type=d.get("sensor_type", "induction_coil"),
model=d.get("model", ""),
frequency_range=tuple(fr) if fr is not None else None,
notes=d.get("notes", ""),
)
# ---------------------------------------------------------------------------
# InstrumentMeta
# ---------------------------------------------------------------------------
[docs]
@dataclass
class InstrumentMeta:
"""Structured descriptor for an EM acquisition system.
Parameters
----------
system : str
Human-readable system name, e.g. ``"Phoenix V8"`` or
``"Metronix ADU-07e"``.
serial : str or None
Logger serial number or site-specific tag.
magnetic_sensor : SensorSpec or None
Magnetic-field transducer (H field) specification.
electric_sensor : SensorSpec or None
Electric-field transducer (E field) specification.
software_version : str
Processing / acquisition software version string.
notes : str
Free-text annotation (calibration date, operator remarks, …).
Notes
-----
The class deliberately stays thin — only fields that appear (or could
appear) in a standard EDI ``>HEAD`` block or in a compact YAML survey
manifest are stored here. Calibration tables belong in separate files
referenced by :attr:`notes`.
Examples
--------
>>> from pycsamt.metadata.instrument import InstrumentMeta
>>> inst = InstrumentMeta(system="Phoenix V8", serial="V8-00123")
>>> inst.to_head_fields()
{'acqby': 'Phoenix V8 / V8-00123', 'progvers': 'pyCSAMT'}
"""
system: str = ""
serial: str | None = None
magnetic_sensor: SensorSpec | None = None
electric_sensor: SensorSpec | None = None
software_version: str = ""
notes: str = ""
# ------------------------------------------------------------------
# Convenience properties
# ------------------------------------------------------------------
[docs]
@property
def label(self) -> str:
"""Short identifier: ``"<system> / <serial>"`` or just system."""
if self.serial:
return f"{self.system} / {self.serial}"
return self.system
# ------------------------------------------------------------------
# Summaries
# ------------------------------------------------------------------
[docs]
def summary(self) -> str:
"""Return a multi-line human-readable summary."""
lines = [
f"System : {self.system or '—'}",
f"Serial : {self.serial or '—'}",
]
if self.software_version:
lines.append(f"Software : {self.software_version}")
if self.magnetic_sensor:
lines.append(f"Mag. sensor : {self.magnetic_sensor}")
if self.electric_sensor:
lines.append(f"Elec. sensor : {self.electric_sensor}")
if self.notes:
lines.append(f"Notes : {self.notes}")
return "\n".join(lines)
def __repr__(self) -> str:
return (
f"<InstrumentMeta system={self.system!r} serial={self.serial!r}>"
)
# ------------------------------------------------------------------
# EDI HEAD integration
# ------------------------------------------------------------------
[docs]
def to_head_fields(self) -> dict[str, str]:
"""Return a dict of EDI ``>HEAD`` key-value pairs.
The mapping is::
acqby ← system / serial (or just system)
progvers ← software_version (or the package default)
Returns
-------
dict
Ready to pass to :meth:`~pycsamt.seg.heads.Head.update`.
"""
from pycsamt import (
__version__ as _ver, # lazy import to avoid cycles
)
fields: dict[str, str] = {}
if self.system:
fields["acqby"] = self.label
fields["progvers"] = self.software_version or f"pyCSAMT {_ver}"
return fields
[docs]
@classmethod
def from_head(cls, head: Any) -> InstrumentMeta:
"""Construct from a :class:`~pycsamt.seg.heads.Head` object.
Only ``acqby`` and ``progvers`` are extracted; sensor details
cannot be inferred from the EDI header alone.
Parameters
----------
head :
A :class:`pycsamt.seg.heads.Head` instance (or any object with
``acqby`` and ``progvers`` string attributes).
Returns
-------
InstrumentMeta
"""
acqby = getattr(head, "acqby", None) or ""
progvers = getattr(head, "progvers", None) or ""
# Split "System / Serial" heuristic
parts = [p.strip() for p in acqby.split("/", 1)]
system = parts[0]
serial = parts[1] if len(parts) == 2 else None
return cls(
system=system,
serial=serial,
software_version=progvers,
)
# ------------------------------------------------------------------
# Presets
# ------------------------------------------------------------------
[docs]
@classmethod
def from_preset(cls, name: str) -> InstrumentMeta:
"""Build from a named preset in :data:`KNOWN_SYSTEMS`.
Parameters
----------
name : str
Case-insensitive preset key, e.g. ``"phoenix_v8"``.
Returns
-------
InstrumentMeta
Raises
------
KeyError
If *name* is not in :data:`KNOWN_SYSTEMS`.
Examples
--------
>>> inst = InstrumentMeta.from_preset("lemi_424")
>>> inst.system
'LEMI-424'
"""
key = name.lower().replace("-", "_").replace(" ", "_")
if key not in KNOWN_SYSTEMS:
available = ", ".join(sorted(KNOWN_SYSTEMS))
raise KeyError(f"Unknown preset {name!r}. Available: {available}")
d = KNOWN_SYSTEMS[key]
return cls._from_dict(d)
# ------------------------------------------------------------------
# Serialisation
# ------------------------------------------------------------------
[docs]
def to_dict(self) -> dict[str, Any]:
"""Serialise to a plain dict (JSON-safe)."""
d: dict[str, Any] = {
"system": self.system,
"serial": self.serial,
"software_version": self.software_version,
"notes": self.notes,
}
if self.magnetic_sensor is not None:
d["magnetic_sensor"] = self.magnetic_sensor._to_dict()
if self.electric_sensor is not None:
d["electric_sensor"] = self.electric_sensor._to_dict()
return d
@classmethod
def _from_dict(cls, d: dict[str, Any]) -> InstrumentMeta:
mag = d.get("magnetic_sensor")
elec = d.get("electric_sensor")
return cls(
system=d.get("system", ""),
serial=d.get("serial"),
magnetic_sensor=SensorSpec._from_dict(mag) if mag else None,
electric_sensor=SensorSpec._from_dict(elec) if elec else None,
software_version=d.get("software_version", ""),
notes=d.get("notes", ""),
)
[docs]
def to_json(self, indent: int = 2) -> str:
"""Serialise to a JSON string."""
return json.dumps(self.to_dict(), indent=indent)
[docs]
@classmethod
def from_json(cls, s: str) -> InstrumentMeta:
"""Deserialise from a JSON string."""
return cls._from_dict(json.loads(s))
[docs]
def to_yaml(self) -> str:
"""Serialise to a YAML string (requires *PyYAML*)."""
if not _HAS_YAML:
raise ImportError(
"PyYAML is required for YAML serialisation: pip install pyyaml"
)
return _yaml.dump(self.to_dict(), default_flow_style=False)
[docs]
@classmethod
def from_yaml(cls, s: str) -> InstrumentMeta:
"""Deserialise from a YAML string (requires *PyYAML*)."""
if not _HAS_YAML:
raise ImportError(
"PyYAML is required for YAML deserialisation: pip install pyyaml"
)
return cls._from_dict(_yaml.safe_load(s))
[docs]
def save(self, path: str, fmt: str = "json") -> None:
"""Write to *path* as JSON or YAML.
Parameters
----------
path : str
Destination file path.
fmt : {"json", "yaml"}
"""
from pathlib import Path
p = Path(path)
if fmt == "json":
p.write_text(self.to_json(), encoding="utf-8")
elif fmt in ("yaml", "yml"):
p.write_text(self.to_yaml(), encoding="utf-8")
else:
raise ValueError(
f"Unknown format {fmt!r}; choose 'json' or 'yaml'."
)
[docs]
@classmethod
def load(cls, path: str) -> InstrumentMeta:
"""Load from a JSON or YAML file (format detected by extension)."""
from pathlib import Path
p = Path(path)
text = p.read_text(encoding="utf-8")
if p.suffix.lower() in (".yaml", ".yml"):
return cls.from_yaml(text)
return cls.from_json(text)
# ---------------------------------------------------------------------------
# Pre-built system presets
# ---------------------------------------------------------------------------
#: Registry of known acquisition systems.
#: Each value is a dict compatible with :meth:`InstrumentMeta._from_dict`.
KNOWN_SYSTEMS: dict[str, dict[str, Any]] = {
# ── Phoenix Geophysics ──────────────────────────────────────────────
"phoenix_v8": {
"system": "Phoenix V8",
"serial": None,
"software_version": "SSMT2000",
"magnetic_sensor": {
"sensor_type": "induction_coil",
"model": "MTC-150H",
"frequency_range": [1e-4, 2e3],
"notes": "Broadband MT coil; low-noise below 1 Hz",
},
"electric_sensor": {
"sensor_type": "electrode",
"model": "Pb-PbCl2 porous pot",
"frequency_range": None,
"notes": "Non-polarising; standard 50–100 m dipole",
},
"notes": "Standard Phoenix V8 MT/AMT system",
},
"phoenix_mtx": {
"system": "Phoenix MTX",
"serial": None,
"software_version": "SSMT2000",
"magnetic_sensor": {
"sensor_type": "induction_coil",
"model": "MTC-155",
"frequency_range": [1e-5, 1e4],
"notes": "Ultra-broadband Phoenix coil",
},
"electric_sensor": {
"sensor_type": "electrode",
"model": "Pb-PbCl2 porous pot",
"frequency_range": None,
"notes": "",
},
"notes": "Phoenix MTX broadband system",
},
# ── Metronix ────────────────────────────────────────────────────────
"metronix_adu07": {
"system": "Metronix ADU-07e",
"serial": None,
"software_version": "ADU-07e firmware",
"magnetic_sensor": {
"sensor_type": "induction_coil",
"model": "MFS-06e",
"frequency_range": [1e-4, 4096.0],
"notes": "Metronix broadband coil; pairs with ADU-07e",
},
"electric_sensor": {
"sensor_type": "electrode",
"model": "Pb-PbCl2 porous pot",
"frequency_range": None,
"notes": "",
},
"notes": "Metronix ADU-07e broadband MT system",
},
"metronix_adu08": {
"system": "Metronix ADU-08e",
"serial": None,
"software_version": "ADU-08e firmware",
"magnetic_sensor": {
"sensor_type": "induction_coil",
"model": "MFS-07e",
"frequency_range": [1e-5, 4096.0],
"notes": "High-resolution Metronix coil for quiet sites",
},
"electric_sensor": {
"sensor_type": "electrode",
"model": "Pb-PbCl2 porous pot",
"frequency_range": None,
"notes": "",
},
"notes": "Metronix ADU-08e high-resolution system",
},
# ── LEMI ────────────────────────────────────────────────────────────
"lemi_424": {
"system": "LEMI-424",
"serial": None,
"software_version": "",
"magnetic_sensor": {
"sensor_type": "induction_coil",
"model": "LEMI-120",
"frequency_range": [3e-4, 1e3],
"notes": "Low-frequency induction coil for long-period MT",
},
"electric_sensor": {
"sensor_type": "electrode",
"model": "Pb-PbCl2",
"frequency_range": None,
"notes": "",
},
"notes": "LEMI-424 low-frequency broadband system",
},
# ── Zonge ───────────────────────────────────────────────────────────
"zonge_gdp32": {
"system": "Zonge GDP-32",
"serial": None,
"software_version": "",
"magnetic_sensor": {
"sensor_type": "induction_coil",
"model": "ANT/6",
"frequency_range": [1e-3, 1e4],
"notes": "Zonge broadband coil, common in CSAMT surveys",
},
"electric_sensor": {
"sensor_type": "electrode",
"model": "Pb-PbCl2",
"frequency_range": None,
"notes": "",
},
"notes": "Zonge GDP-32 CSAMT/MT system",
},
# ── Geometrics / OYO ────────────────────────────────────────────────
"geometrics_stratagem": {
"system": "Geometrics Stratagem EH4",
"serial": None,
"software_version": "",
"magnetic_sensor": {
"sensor_type": "induction_coil",
"model": "EH4 coil",
"frequency_range": [10.0, 1e5],
"notes": "AMT band; controlled source / natural field",
},
"electric_sensor": {
"sensor_type": "electrode",
"model": "Pb-PbCl2",
"frequency_range": None,
"notes": "",
},
"notes": "EH4 tensor AMT system (10 Hz – 100 kHz)",
},
# ── Generic fluxgate (TEM / airborne) ───────────────────────────────
"generic_fluxgate": {
"system": "Generic fluxgate magnetometer",
"serial": None,
"software_version": "",
"magnetic_sensor": {
"sensor_type": "fluxgate",
"model": "",
"frequency_range": [0.0, 10.0],
"notes": "Low-frequency or DC fluxgate; used in TEM & airborne",
},
"electric_sensor": None,
"notes": "Generic fluxgate configuration",
},
}
# ---------------------------------------------------------------------------
# Module-level helpers
# ---------------------------------------------------------------------------
[docs]
def list_presets() -> list:
"""Return sorted list of available preset keys.
Examples
--------
>>> from pycsamt.metadata.instrument import list_presets
>>> list_presets()
['geometrics_stratagem', 'generic_fluxgate', 'lemi_424', ...]
"""
return sorted(KNOWN_SYSTEMS.keys())
[docs]
def known_system(name: str) -> InstrumentMeta:
"""Shortcut for :meth:`InstrumentMeta.from_preset`.
Parameters
----------
name : str
Preset key (case-insensitive, spaces/hyphens treated as underscores).
Returns
-------
InstrumentMeta
Examples
--------
>>> from pycsamt.metadata.instrument import known_system
>>> inst = known_system("phoenix_v8")
>>> inst.system
'Phoenix V8'
"""
return InstrumentMeta.from_preset(name)