Source code for pycsamt.iot.station

"""Field-station configuration for IoT-enabled AMT/CSAMT acquisition.

A :class:`DeviceConfig` describes an IoT *node* (its transport, channels,
and role). A :class:`StationConfig` describes the *ground location* that
one or more nodes occupy: coordinates, profile/line membership, electrode
geometry, and sensor orientation. Separating the two mirrors real field
practice, where a recorder may be redeployed across several stations.
"""

from __future__ import annotations

from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from typing import (
    Any,
)

import pandas as pd

from ..api.property import MetadataMixin, PyCSAMTObject
from ..api.view import maybe_wrap_frame
from . import _common as _c

__all__ = [
    "StationConfig",
    "station_table",
]


[docs] @dataclass class StationConfig(PyCSAMTObject, MetadataMixin): """Geospatial and acquisition metadata for one field station. Parameters ---------- station_id : str Stable station identifier (e.g. ``"S01"``). Used as the join key across telemetry, QC, and provenance records. lat, lon : float, optional Geographic coordinates in decimal degrees. Latitude is validated to ``[-90, 90]``; longitude accepts both ``[-180, 180]`` and ``[0, 360]`` conventions. elevation : float, optional Ground elevation in metres. profile : str, optional Survey line/profile label the station belongs to (e.g. ``"L1"``). position_m : float, optional Chainage (distance along the profile) in metres. Enables ordering of stations into a 2D section. channels : list of str, optional Acquisition channels recorded at the station (e.g. ``["ex", "ey", "hx", "hy"]``). dipole_length_m : float, optional Electric dipole length in metres, used for E-field scaling and contact-resistance checks. ex_azimuth_deg, ey_azimuth_deg : float, optional Orientation of the Ex/Ey electric dipoles, degrees clockwise from geographic north. device_ids : list of str, optional Identifiers of the IoT nodes occupying this station. operator : str, optional Field operator responsible for the occupation. notes : str Free-form field notes. metadata : dict Free-form structured metadata. """ station_id: str lat: float | None = None lon: float | None = None elevation: float | None = None profile: str | None = None position_m: float | None = None channels: list[str] = field(default_factory=list) dipole_length_m: float | None = None ex_azimuth_deg: float | None = None ey_azimuth_deg: float | None = None device_ids: list[str] = field(default_factory=list) operator: str | None = None notes: str = "" metadata: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: self.validate()
[docs] def validate(self) -> None: """Validate and normalise station fields.""" self.station_id = _c.as_nonempty_str(self.station_id, "station_id") self.lat = _c.as_latitude(self.lat) self.lon = _c.as_longitude(self.lon) self.elevation = _c.as_elevation(self.elevation) self.profile = _c.as_optional_str(self.profile, "profile") self.position_m = _c.as_optional_finite_float( self.position_m, "position_m" ) self.channels = _c.as_channel_list(self.channels) self.dipole_length_m = _c.as_optional_positive( self.dipole_length_m, "dipole_length_m" ) self.ex_azimuth_deg = _c.as_optional_finite_float( self.ex_azimuth_deg, "ex_azimuth_deg" ) self.ey_azimuth_deg = _c.as_optional_finite_float( self.ey_azimuth_deg, "ey_azimuth_deg" ) self.device_ids = [ _c.as_nonempty_str(d, "device_id") for d in _c.unique_preserving(list(self.device_ids or [])) ] self.operator = _c.as_optional_str(self.operator, "operator") self.notes = str(self.notes or "") if not isinstance(self.metadata, dict): self.metadata = dict(self.metadata or {})
[docs] @property def has_location(self) -> bool: """Return whether latitude and longitude are both known.""" return self.lat is not None and self.lon is not None
[docs] @property def coords(self) -> tuple[float, float, float] | None: """Return ``(lat, lon, elevation)`` if a location is set.""" if not self.has_location: return None return (self.lat, self.lon, float(self.elevation or 0.0))
[docs] @property def n_channels(self) -> int: """Number of declared acquisition channels.""" return len(self.channels)
[docs] def attach_device(self, device_id: str) -> StationConfig: """Associate an IoT node with this station (idempotent).""" key = _c.as_nonempty_str(device_id, "device_id") if key not in self.device_ids: self.device_ids.append(key) return self
[docs] def as_dict(self) -> dict[str, Any]: """Return a serialisable station dictionary.""" return dict( station_id=self.station_id, lat=self.lat, lon=self.lon, elevation=self.elevation, profile=self.profile, position_m=self.position_m, channels=list(self.channels), dipole_length_m=self.dipole_length_m, ex_azimuth_deg=self.ex_azimuth_deg, ey_azimuth_deg=self.ey_azimuth_deg, device_ids=list(self.device_ids), operator=self.operator, notes=self.notes, metadata=dict(self.metadata), )
[docs] @classmethod def from_mapping(cls, data: Mapping[str, Any]) -> StationConfig: """Build a station config from a mapping, ignoring unknown keys.""" allowed = {f.name for f in cls.__dataclass_fields__.values()} return cls(**{k: v for k, v in dict(data).items() if k in allowed})
[docs] def station_table( stations: StationConfig | Iterable[StationConfig], *, api: bool | None = None, ) -> Any: """Return one row per station describing its acquisition metadata.""" items = ( [stations] if isinstance(stations, StationConfig) else list(stations) ) rows: list[dict[str, Any]] = [] for station in items: station.validate() row = station.as_dict() row["channels"] = ";".join(row["channels"]) row["device_ids"] = ";".join(row["device_ids"]) row["n_channels"] = station.n_channels row["has_location"] = station.has_location rows.append(row) df = pd.DataFrame.from_records(rows) return maybe_wrap_frame( df, api=api, name="iot_station_table", kind="iot.station", source=items, description="IoT field stations and acquisition metadata.", )