from __future__ import annotations
import math
from collections.abc import Iterable
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 .core import DeviceConfig, PacketKind, TelemetryPacket
[docs]
class PowerState(str, Enum):
"""Energy status for an IoT field device."""
SUSTAINING = "sustaining"
OK = "ok"
WARNING = "warning"
CRITICAL = "critical"
def _as_positive(value: Any, name: str) -> float:
out = float(value)
if out <= 0:
raise ValueError(f"{name} must be positive.")
return out
def _as_nonnegative(value: Any, name: str) -> float:
out = float(value)
if out < 0:
raise ValueError(f"{name} must be >= 0.")
return out
def _as_probability(value: Any, name: str) -> float:
out = float(value)
if not 0.0 <= out <= 1.0:
raise ValueError(f"{name} must be between 0 and 1.")
return out
def _as_optional_positive(value: Any, name: str) -> float | None:
if value is None:
return None
return _as_positive(value, name)
[docs]
@dataclass
class EnergyConfig(PyCSAMTObject, MetadataMixin):
"""Power-budget inputs for a field IoT device."""
battery_wh: float
active_power_w: float
sleep_power_w: float = 0.05
duty_cycle: float = 1.0
solar_wh_per_day: float = 0.0
reserve_fraction: float = 0.0
regulator_efficiency: float = 1.0
charge_efficiency: float = 1.0
telemetry_power_w: float = 0.0
telemetry_seconds_per_day: float = 0.0
edge_power_w: float = 0.0
edge_duty_cycle: float = 0.0
auxiliary_wh_per_day: float = 0.0
min_runtime_days: float | None = None
device_id: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
self.validate()
[docs]
def validate(self) -> None:
"""Validate and normalise power-budget inputs."""
self.battery_wh = _as_positive(self.battery_wh, "battery_wh")
self.active_power_w = _as_nonnegative(
self.active_power_w,
"active_power_w",
)
self.sleep_power_w = _as_nonnegative(
self.sleep_power_w,
"sleep_power_w",
)
self.duty_cycle = _as_probability(self.duty_cycle, "duty_cycle")
self.solar_wh_per_day = _as_nonnegative(
self.solar_wh_per_day,
"solar_wh_per_day",
)
self.reserve_fraction = _as_probability(
self.reserve_fraction,
"reserve_fraction",
)
self.regulator_efficiency = _as_probability(
self.regulator_efficiency,
"regulator_efficiency",
)
self.charge_efficiency = _as_probability(
self.charge_efficiency,
"charge_efficiency",
)
if self.regulator_efficiency <= 0:
raise ValueError("regulator_efficiency must be > 0.")
if self.charge_efficiency <= 0:
raise ValueError("charge_efficiency must be > 0.")
self.telemetry_power_w = _as_nonnegative(
self.telemetry_power_w,
"telemetry_power_w",
)
self.telemetry_seconds_per_day = _as_nonnegative(
self.telemetry_seconds_per_day,
"telemetry_seconds_per_day",
)
self.edge_power_w = _as_nonnegative(self.edge_power_w, "edge_power_w")
self.edge_duty_cycle = _as_probability(
self.edge_duty_cycle,
"edge_duty_cycle",
)
self.auxiliary_wh_per_day = _as_nonnegative(
self.auxiliary_wh_per_day,
"auxiliary_wh_per_day",
)
self.min_runtime_days = _as_optional_positive(
self.min_runtime_days,
"min_runtime_days",
)
if self.device_id is not None:
self.device_id = str(self.device_id).strip() or None
if not isinstance(self.metadata, dict):
self.metadata = dict(self.metadata or {})
[docs]
@property
def usable_battery_wh(self) -> float:
"""Battery energy available after reserve is held back."""
return self.battery_wh * (1.0 - self.reserve_fraction)
[docs]
@property
def base_average_power_w(self) -> float:
"""Average active/sleep power before auxiliary loads."""
return (
self.duty_cycle * self.active_power_w
+ (1.0 - self.duty_cycle) * self.sleep_power_w
)
[docs]
@property
def telemetry_wh_per_day(self) -> float:
"""Daily energy used by radio transmission windows."""
return (
self.telemetry_power_w * self.telemetry_seconds_per_day / 3600.0
)
[docs]
@property
def edge_wh_per_day(self) -> float:
"""Daily energy used by edge processing overhead."""
return 24.0 * self.edge_power_w * self.edge_duty_cycle
[docs]
@property
def harvest_wh_per_day(self) -> float:
"""Daily usable harvested energy after charge efficiency."""
return self.solar_wh_per_day * self.charge_efficiency
[docs]
@dataclass
class DevicePowerProfile(PyCSAMTObject):
"""Named power profile for a field node or recorder."""
name: str
active_power_w: float
sleep_power_w: float = 0.05
telemetry_power_w: float = 0.0
edge_power_w: float = 0.0
metadata: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
self.validate()
[docs]
def validate(self) -> None:
"""Validate and normalise profile fields."""
self.name = str(self.name).strip()
if not self.name:
raise ValueError("name cannot be empty.")
self.active_power_w = _as_nonnegative(
self.active_power_w,
"active_power_w",
)
self.sleep_power_w = _as_nonnegative(
self.sleep_power_w,
"sleep_power_w",
)
self.telemetry_power_w = _as_nonnegative(
self.telemetry_power_w,
"telemetry_power_w",
)
self.edge_power_w = _as_nonnegative(self.edge_power_w, "edge_power_w")
if not isinstance(self.metadata, dict):
self.metadata = dict(self.metadata or {})
[docs]
def apply(
self,
*,
battery_wh: float,
duty_cycle: float = 1.0,
solar_wh_per_day: float = 0.0,
**kwargs: Any,
) -> EnergyConfig:
"""Build an :class:`EnergyConfig` from this profile."""
return EnergyConfig(
battery_wh=battery_wh,
active_power_w=self.active_power_w,
sleep_power_w=self.sleep_power_w,
duty_cycle=duty_cycle,
solar_wh_per_day=solar_wh_per_day,
telemetry_power_w=self.telemetry_power_w,
edge_power_w=self.edge_power_w,
**kwargs,
)
[docs]
@dataclass
class EnergyEstimate(PyCSAMTObject):
"""Estimated runtime and power draw."""
average_power_w: float
runtime_hours: float
runtime_days: float
net_wh_per_day: float
load_wh_per_day: float = 0.0
harvest_wh_per_day: float = 0.0
usable_battery_wh: float = 0.0
telemetry_wh_per_day: float = 0.0
edge_wh_per_day: float = 0.0
auxiliary_wh_per_day: float = 0.0
reserve_wh: float = 0.0
energy_margin_wh_per_day: float = 0.0
autonomy_days_no_harvest: float = 0.0
state: PowerState | str = PowerState.OK
issues: list[str] = field(default_factory=list)
def __post_init__(self) -> None:
self.validate()
[docs]
def validate(self) -> None:
"""Validate and normalise estimate fields."""
self.average_power_w = float(self.average_power_w)
self.runtime_hours = float(self.runtime_hours)
self.runtime_days = float(self.runtime_days)
self.net_wh_per_day = float(self.net_wh_per_day)
self.load_wh_per_day = float(self.load_wh_per_day)
self.harvest_wh_per_day = float(self.harvest_wh_per_day)
self.usable_battery_wh = float(self.usable_battery_wh)
self.telemetry_wh_per_day = float(self.telemetry_wh_per_day)
self.edge_wh_per_day = float(self.edge_wh_per_day)
self.auxiliary_wh_per_day = float(self.auxiliary_wh_per_day)
self.reserve_wh = float(self.reserve_wh)
self.energy_margin_wh_per_day = float(self.energy_margin_wh_per_day)
self.autonomy_days_no_harvest = float(self.autonomy_days_no_harvest)
self.state = (
self.state
if isinstance(self.state, PowerState)
else (PowerState(str(self.state)))
)
self.issues = [str(issue) for issue in list(self.issues or [])]
[docs]
@property
def sustaining(self) -> bool:
"""Return whether harvested energy covers daily load."""
return self.state == PowerState.SUSTAINING
[docs]
def as_dict(self) -> dict[str, Any]:
"""Return a serialisable estimate dictionary."""
return dict(
average_power_w=self.average_power_w,
runtime_hours=self.runtime_hours,
runtime_days=self.runtime_days,
net_wh_per_day=self.net_wh_per_day,
load_wh_per_day=self.load_wh_per_day,
harvest_wh_per_day=self.harvest_wh_per_day,
usable_battery_wh=self.usable_battery_wh,
telemetry_wh_per_day=self.telemetry_wh_per_day,
edge_wh_per_day=self.edge_wh_per_day,
auxiliary_wh_per_day=self.auxiliary_wh_per_day,
reserve_wh=self.reserve_wh,
energy_margin_wh_per_day=self.energy_margin_wh_per_day,
autonomy_days_no_harvest=self.autonomy_days_no_harvest,
state=self.state.value
if isinstance(self.state, PowerState)
else str(self.state),
issues=";".join(self.issues),
)
[docs]
def to_packet(
self,
device: DeviceConfig,
*,
timestamp: float,
survey_id: str | None = None,
qos: int = 0,
retained: bool = False,
) -> TelemetryPacket:
"""Encode this estimate as a power telemetry packet."""
return TelemetryPacket.from_device(
device,
timestamp=timestamp,
payload=self.as_dict(),
kind=PacketKind.POWER,
survey_id=survey_id,
qos=qos,
retained=retained,
)
[docs]
def estimate_energy_budget(config: EnergyConfig) -> EnergyEstimate:
"""Estimate runtime from battery, duty cycle, and optional solar input."""
config.validate()
avg_base = config.base_average_power_w
load_daily = (
24.0 * avg_base / config.regulator_efficiency
+ config.telemetry_wh_per_day
+ config.edge_wh_per_day
+ config.auxiliary_wh_per_day
)
avg = load_daily / 24.0
avg = max(avg, 1e-12)
net_daily = load_daily - config.harvest_wh_per_day
if net_daily <= 0:
runtime_hours = float("inf")
else:
runtime_hours = 24.0 * config.usable_battery_wh / net_daily
no_harvest_days = (
config.usable_battery_wh / load_daily
if load_daily > 0
else float("inf")
)
issues = _estimate_issues(config, runtime_hours, net_daily)
state = _estimate_state(config, runtime_hours, net_daily, issues)
return EnergyEstimate(
average_power_w=avg,
runtime_hours=runtime_hours,
runtime_days=runtime_hours / 24.0,
net_wh_per_day=net_daily,
load_wh_per_day=load_daily,
harvest_wh_per_day=config.harvest_wh_per_day,
usable_battery_wh=config.usable_battery_wh,
telemetry_wh_per_day=config.telemetry_wh_per_day,
edge_wh_per_day=config.edge_wh_per_day,
auxiliary_wh_per_day=config.auxiliary_wh_per_day,
reserve_wh=config.battery_wh - config.usable_battery_wh,
energy_margin_wh_per_day=-net_daily,
autonomy_days_no_harvest=no_harvest_days,
state=state,
issues=issues,
)
def _estimate_issues(
config: EnergyConfig,
runtime_hours: float,
net_wh_per_day: float,
) -> list[str]:
issues: list[str] = []
if net_wh_per_day > 0:
issues.append("daily_energy_deficit")
if (
config.min_runtime_days is not None
and not math.isinf(runtime_hours)
and runtime_hours / 24.0 < config.min_runtime_days
):
issues.append("runtime_below_minimum")
if config.usable_battery_wh <= 0:
issues.append("no_usable_battery_after_reserve")
return issues
def _estimate_state(
config: EnergyConfig,
runtime_hours: float,
net_wh_per_day: float,
issues: list[str],
) -> PowerState:
if net_wh_per_day <= 0:
return PowerState.SUSTAINING
if "no_usable_battery_after_reserve" in issues:
return PowerState.CRITICAL
if config.min_runtime_days is None:
return PowerState.OK
runtime_days = runtime_hours / 24.0
if runtime_days >= config.min_runtime_days:
return PowerState.OK
if runtime_days >= 0.5 * config.min_runtime_days:
return PowerState.WARNING
return PowerState.CRITICAL
[docs]
def power_summary_table(
estimates: EnergyEstimate | Iterable[EnergyEstimate],
*,
device_ids: Iterable[str] | None = None,
api: bool | None = None,
) -> Any:
"""Return energy estimates as a pyCSAMT table."""
items = (
[estimates]
if isinstance(estimates, EnergyEstimate)
else (list(estimates))
)
ids = list(device_ids or [])
rows = []
for idx, estimate in enumerate(items):
estimate.validate()
row = estimate.as_dict()
if idx < len(ids):
row["device_id"] = ids[idx]
rows.append(row)
df = pd.DataFrame.from_records(rows)
return maybe_wrap_frame(
df,
api=api,
name="iot_power_summary",
kind="iot.power",
source=items,
description="IoT energy budget and runtime estimates.",
)
[docs]
def estimate_deployment_energy(
configs: Iterable[EnergyConfig],
*,
api: bool | None = None,
) -> Any:
"""Estimate and tabulate energy budgets for multiple devices."""
rows = []
for config in configs:
estimate = estimate_energy_budget(config)
row = estimate.as_dict()
row["device_id"] = config.device_id
rows.append(row)
df = pd.DataFrame.from_records(rows)
return maybe_wrap_frame(
df,
api=api,
name="iot_deployment_energy",
kind="iot.power.deployment",
source=configs,
description="Deployment-scale IoT energy budget estimates.",
)
__all__ = [
"PowerState",
"DevicePowerProfile",
"EnergyConfig",
"EnergyEstimate",
"estimate_deployment_energy",
"estimate_energy_budget",
"power_summary_table",
]