Source code for pycsamt.iot.core

from __future__ import annotations

from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from enum import Enum
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


[docs] class IoTCapability(str, Enum): """IoT capability flags used in deployment reports.""" TELEMETRY = "telemetry" EDGE_PROCESSING = "edge_processing" SYNCHRONIZATION = "synchronization" ENERGY_MANAGEMENT = "energy_management" HEALTH_MONITORING = "health_monitoring"
[docs] class DeviceRole(str, Enum): """Role of an IoT device in a field deployment.""" SENSOR_NODE = "sensor_node" RECORDER = "recorder" GATEWAY = "gateway" BASE_STATION = "base_station" REMOTE_REFERENCE = "remote_reference" TRANSMITTER = "transmitter"
[docs] class PacketKind(str, Enum): """Telemetry packet categories used by pyCSAMT IoT workflows.""" DATA = "data" QC = "qc" HEALTH = "health" SYNC = "sync" POWER = "power" EVENT = "event" SOURCE = "source"
def _as_nonempty_str(value: Any, name: str) -> str: text = str(value).strip() if not text: raise ValueError(f"{name} cannot be empty.") return text def _normalise_enum(value: Any, enum_cls: type[Enum], name: str) -> Enum: if isinstance(value, enum_cls): return value text = str(value).strip().lower() for item in enum_cls: if item.value == text or item.name.lower() == text: return item allowed = ", ".join(item.value for item in enum_cls) raise ValueError(f"{name} must be one of: {allowed}.") def _normalise_capabilities( values: Iterable[IoTCapability | str], ) -> list[IoTCapability]: out: list[IoTCapability] = [] for value in values: cap = _normalise_enum(value, IoTCapability, "capability") if cap not in out: out.append(cap) return out
[docs] @dataclass class DeviceConfig(PyCSAMTObject, MetadataMixin): """Configuration for one field IoT node or recorder gateway.""" device_id: str station: str | None = None protocol: str = "mqtt" sample_rate_hz: float | None = None channels: list[str] = field(default_factory=list) role: DeviceRole | str = DeviceRole.SENSOR_NODE enabled: bool = True metadata: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: self.validate()
[docs] def validate(self) -> None: """Validate and normalise device configuration.""" self.device_id = _as_nonempty_str(self.device_id, "device_id") if self.station is not None: self.station = _as_nonempty_str(self.station, "station") self.protocol = _as_nonempty_str(self.protocol, "protocol").lower() if self.sample_rate_hz is not None: self.sample_rate_hz = float(self.sample_rate_hz) if self.sample_rate_hz <= 0: raise ValueError("sample_rate_hz must be positive.") self.channels = [ _as_nonempty_str(ch, "channel").lower() for ch in list(self.channels or []) ] self.role = _normalise_enum(self.role, DeviceRole, "role") self.enabled = _c.as_bool(self.enabled) if not isinstance(self.metadata, dict): self.metadata = dict(self.metadata or {})
[docs] @property def n_channels(self) -> int: """Number of declared acquisition channels.""" return len(self.channels)
[docs] def topic( self, kind: PacketKind | str, *, survey_id: str | None = None, ) -> str: """Return a canonical telemetry topic for this device.""" pkt_kind = _normalise_enum(kind, PacketKind, "kind") prefix = f"pycsamt/{survey_id}" if survey_id else "pycsamt" station = self.station or "unassigned" return f"{prefix}/{station}/{self.device_id}/{pkt_kind.value}"
[docs] def as_dict(self) -> dict[str, Any]: """Return a serialisable representation.""" return dict( device_id=self.device_id, station=self.station, protocol=self.protocol, sample_rate_hz=self.sample_rate_hz, channels=list(self.channels), role=self.role.value if isinstance(self.role, DeviceRole) else str(self.role), enabled=bool(self.enabled), metadata=dict(self.metadata), )
[docs] @classmethod def from_mapping(cls, data: Mapping[str, Any]) -> DeviceConfig: """Build a device config from a mapping.""" return cls(**dict(data))
[docs] @dataclass class TelemetryPacket(PyCSAMTObject): """A timestamped message emitted by an IoT-enabled field node.""" device_id: str timestamp: float topic: str payload: dict[str, Any] qos: int = 0 kind: PacketKind | str = PacketKind.DATA retained: bool = False def __post_init__(self) -> None: self.validate()
[docs] def validate(self) -> None: """Validate and normalise packet fields.""" self.device_id = _as_nonempty_str(self.device_id, "device_id") # Reject NaN/inf/negative time: a common sign of an un-synced clock. self.timestamp = _c.as_timestamp(self.timestamp, "timestamp") self.topic = _as_nonempty_str(self.topic, "topic") self.payload = dict(self.payload or {}) self.qos = _c.as_qos(self.qos, "qos") self.kind = _normalise_enum(self.kind, PacketKind, "kind") self.retained = _c.as_bool(self.retained)
[docs] def as_dict(self) -> dict[str, Any]: """Return a flat packet dictionary suitable for logging.""" return dict( device_id=self.device_id, timestamp=float(self.timestamp), topic=self.topic, qos=int(self.qos), kind=self.kind.value if isinstance(self.kind, PacketKind) else str(self.kind), retained=bool(self.retained), payload=dict(self.payload), )
[docs] @classmethod def from_device( cls, device: DeviceConfig, *, timestamp: float, payload: Mapping[str, Any], kind: PacketKind | str = PacketKind.DATA, survey_id: str | None = None, qos: int = 0, retained: bool = False, ) -> TelemetryPacket: """Create a packet using a device's canonical topic.""" return cls( device_id=device.device_id, timestamp=timestamp, topic=device.topic(kind, survey_id=survey_id), payload=dict(payload), qos=qos, kind=kind, retained=retained, )
[docs] @dataclass class DeploymentConfig(PyCSAMTObject, MetadataMixin): """Survey-level IoT deployment metadata.""" survey_id: str devices: list[DeviceConfig] = field(default_factory=list) capabilities: list[IoTCapability] = field(default_factory=list) 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 deployment fields.""" self.survey_id = _as_nonempty_str(self.survey_id, "survey_id") self.devices = [ device if isinstance(device, DeviceConfig) else DeviceConfig.from_mapping(device) for device in list(self.devices or []) ] ids = [device.device_id for device in self.devices] if len(ids) != len(set(ids)): raise ValueError("device_id values must be unique.") self.capabilities = _normalise_capabilities(self.capabilities) self.notes = str(self.notes or "") if not isinstance(self.metadata, dict): self.metadata = dict(self.metadata or {})
[docs] @property def n_devices(self) -> int: """Number of devices in the deployment.""" return len(self.devices)
[docs] def add_device(self, device: DeviceConfig | Mapping[str, Any]) -> None: """Append a device and revalidate uniqueness.""" self.devices.append( device if isinstance(device, DeviceConfig) else DeviceConfig.from_mapping(device) ) self.validate()
[docs] def has_capability(self, capability: IoTCapability | str) -> bool: """Return whether the deployment declares *capability*.""" cap = _normalise_enum(capability, IoTCapability, "capability") return cap in self.capabilities
[docs] def device(self, device_id: str) -> DeviceConfig: """Return a device by id.""" key = _as_nonempty_str(device_id, "device_id") for device in self.devices: if device.device_id == key: return device raise KeyError(f"Unknown device_id {key!r}.")
[docs] def as_dict(self) -> dict[str, Any]: """Return a serialisable deployment dictionary.""" return dict( survey_id=self.survey_id, devices=[device.as_dict() for device in self.devices], capabilities=[cap.value for cap in self.capabilities], notes=self.notes, metadata=dict(self.metadata), )
[docs] def deployment_report( deployment: DeploymentConfig, *, api: bool | None = None, ) -> Any: """Return one row per device describing declared IoT capabilities.""" deployment.validate() caps = [ c.value if isinstance(c, IoTCapability) else str(c) for c in deployment.capabilities ] rows = [] for device in deployment.devices: row = device.as_dict() row.update( survey_id=deployment.survey_id, capabilities=";".join(caps), n_capabilities=len(caps), n_channels=device.n_channels, notes=deployment.notes, ) rows.append(row) df = pd.DataFrame.from_records(rows) return maybe_wrap_frame( df, api=api, name="iot_deployment_report", kind="iot.deployment", source=deployment, description="IoT deployment devices and declared capabilities.", )
__all__ = [ "IoTCapability", "DeviceRole", "PacketKind", "DeviceConfig", "TelemetryPacket", "DeploymentConfig", "deployment_report", ]