edgeengine-aware 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ """Domain *indoor air quality*: CO2 in a classroom or office, powered by an
2
+ indoor photovoltaic cell.
3
+
4
+ The hidden world is driven by one weekly **activity schedule** (people in the
5
+ room). It raises the CO2 concentration through breathing and, at the same
6
+ time, switches the lights on - which is where the node's energy comes from.
7
+ Energy and information relevance therefore coincide: nights and weekends
8
+ bring neither. This is the mirror image of the agricultural node (energy from
9
+ the sun, information relevance from a slow soil process) and a good test of
10
+ whether a policy has learned *energy-information trade-offs* rather than the
11
+ solar day.
12
+
13
+ Numbers are for a small sensor node with a low-power NDIR CO2 sensor: a
14
+ cheap reading is a short single-shot measurement (~20 mJ), an accurate one a
15
+ longer averaged acquisition (~150 mJ); the radio is BLE-like (sub-millijoule
16
+ uplinks); the always-on consumption is 20 uW; the storage is a 60 J
17
+ supercapacitor / small cell; the cell delivers ~140 uW under 500 lux.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import math
23
+
24
+ import numpy as np
25
+
26
+ from .config import IndoorAirConfig, IndoorLightConfig, QuantityConfig
27
+ from .process import DAY_S, HOUR_S, ActivitySchedule, ProcessState
28
+
29
+
30
+ class IndoorAirProcess:
31
+ """CO2 mass balance of a room driven by the occupancy schedule."""
32
+
33
+ def __init__(self, cfg: IndoorAirConfig, timestep_s: float, schedule: ActivitySchedule):
34
+ self.cfg = cfg
35
+ self.q: QuantityConfig = cfg.quantity()
36
+ self.dt = timestep_s
37
+ self.schedule = schedule
38
+ self._rng = np.random.default_rng()
39
+ self.ppm = cfg.outdoor_ppm
40
+ self.occupancy = 0.0
41
+ self.ach = cfg.ach_base
42
+ self.window_until_s: float | None = None
43
+ self.last_change = 0.0
44
+ self.event_occurred = False
45
+ self.window_events = 0
46
+
47
+ # -- lifecycle ----------------------------------------------------------------
48
+ def reset(self, rng: np.random.Generator, start_time_s: float) -> None:
49
+ c = self.cfg
50
+ self._rng = rng
51
+ self.ppm = float(rng.uniform(*c.initial_ppm_range))
52
+ self.occupancy = self.schedule.level(start_time_s)
53
+ self.ach = c.ach_hvac if self.occupancy > 0 else c.ach_base
54
+ self.window_until_s = None
55
+ self.last_change = 0.0
56
+ self.event_occurred = False
57
+ self.window_events = 0
58
+
59
+ # -- accessors ----------------------------------------------------------------
60
+ @property
61
+ def value(self) -> float:
62
+ return float(np.clip(self.q.to_normalised(self.ppm), 0.0, 1.0))
63
+
64
+ def zone(self, value: float | None = None) -> int:
65
+ return self.q.zone(self.value if value is None else value)
66
+
67
+ # -- dynamics -----------------------------------------------------------------
68
+ def step(self, time_s: float) -> ProcessState:
69
+ """Advance over [time_s, time_s + dt] (occupancy read at the start of the step)."""
70
+ c, rng, dt = self.cfg, self._rng, self.dt
71
+ prev_value = self.value
72
+ prev_zone = self.q.zone(prev_value)
73
+ occ = self.schedule.level(time_s)
74
+ self.occupancy = occ
75
+
76
+ # ventilation: HVAC follows the activity window; window openings are random
77
+ ach = c.ach_hvac if occ > 0.0 else c.ach_base
78
+ if self.window_until_s is not None and time_s >= self.window_until_s:
79
+ self.window_until_s = None
80
+ if occ > 0.0 and self.window_until_s is None and rng.random() < c.window_events_per_day * dt / (10.0 * HOUR_S):
81
+ self.window_until_s = time_s + c.window_duration_s
82
+ self.window_events += 1
83
+ if self.window_until_s is not None:
84
+ ach = max(ach, c.ach_window)
85
+ self.ach = ach
86
+
87
+ # exact solution of dC/dt = S - lambda (C - C_out) over the step
88
+ lam = ach / HOUR_S # 1/s
89
+ source_ppm_s = c.co2_per_person_l_h * c.max_occupants * occ / (c.room_volume_m3 * 1000.0) * 1e6 / HOUR_S
90
+ steady = c.outdoor_ppm + source_ppm_s / lam
91
+ self.ppm = steady + (self.ppm - steady) * math.exp(-lam * dt)
92
+ self.ppm += rng.normal(0.0, c.process_noise_ppm)
93
+ self.ppm = max(c.outdoor_ppm * 0.95, self.ppm)
94
+
95
+ self.last_change = self.value - prev_value
96
+ crossed = self.q.zone(self.value) != prev_zone
97
+ self.event_occurred = abs(self.last_change) >= self.q.event_change_threshold or crossed
98
+ return self.state()
99
+
100
+ def state(self) -> ProcessState:
101
+ return ProcessState(
102
+ value=self.value,
103
+ last_step_change=self.last_change,
104
+ event_occurred=self.event_occurred,
105
+ zone=self.zone(),
106
+ aux={
107
+ "co2_ppm": self.ppm,
108
+ "occupancy": self.occupancy,
109
+ "air_changes_per_hour": self.ach,
110
+ "window_open": self.window_until_s is not None,
111
+ "window_events": self.window_events,
112
+ },
113
+ )
114
+
115
+
116
+ class IndoorLightSource:
117
+ """Indoor PV harvesting: artificial light when the room is active plus
118
+ daylight through a window (0 for a windowless room).
119
+
120
+ Same interface as ``energy.SolarEnergySource``: ``reset``, ``update(t)``,
121
+ ``measured_power_w`` (noisy, node side), ``true_power_w``,
122
+ ``harvested_energy_j`` and ``daily_clearness`` (here: today's daylight factor).
123
+ """
124
+
125
+ def __init__(self, cfg: IndoorLightConfig, timestep_s: float, schedule: ActivitySchedule):
126
+ self.cfg = cfg
127
+ self.dt = timestep_s
128
+ self.schedule = schedule
129
+ self._rng = np.random.default_rng()
130
+ self._weather = 1.0
131
+ self._power_w = 0.0
132
+ self._measured_w = 0.0
133
+ self._lux = 0.0
134
+ self._day = -1
135
+ self._day_factor = 1.0
136
+
137
+ def reset(self, rng: np.random.Generator, start_time_s: float) -> None:
138
+ self._rng = rng
139
+ self._weather = 0.0
140
+ self._day = -1
141
+ self._day_factor = float(np.clip(rng.uniform(0.4, 1.0), 0.0, 1.0))
142
+ self.update(start_time_s)
143
+
144
+ def _daylight_shape(self, time_s: float) -> float:
145
+ c = self.cfg
146
+ h = (time_s % DAY_S) / HOUR_S
147
+ if h <= c.sunrise_hour or h >= c.sunset_hour:
148
+ return 0.0
149
+ return math.sin(math.pi * (h - c.sunrise_hour) / (c.sunset_hour - c.sunrise_hour))
150
+
151
+ def update(self, time_s: float) -> None:
152
+ c = self.cfg
153
+ day = int(time_s // DAY_S)
154
+ if day != self._day: # a new day: draw its overall brightness
155
+ self._day_factor = float(np.clip(self._rng.uniform(0.4, 1.0), 0.0, 1.0))
156
+ self._day = day
157
+ rho = c.daylight_autocorr
158
+ self._weather = rho * self._weather + math.sqrt(max(0.0, 1 - rho**2)) * self._rng.normal(0.0, c.daylight_noise_std)
159
+ daylight = c.daylight_lux * self._daylight_shape(time_s) * float(np.clip(self._day_factor + self._weather, 0.0, 1.0))
160
+ lights = c.artificial_lux if self.schedule.level(time_s) > c.lights_threshold else 0.0
161
+ self._lux = lights + daylight
162
+ self._power_w = c.cell_power_w_at_ref * self._lux / c.reference_lux * c.efficiency
163
+ noise = 1.0 + self._rng.normal(0.0, c.measurement_noise_std)
164
+ self._measured_w = max(0.0, self._power_w * noise)
165
+
166
+ # -- EnergySource protocol ------------------------------------------------------
167
+ def measured_power_w(self) -> float:
168
+ return self._measured_w
169
+
170
+ # -- simulator-only ---------------------------------------------------------------
171
+ def true_power_w(self) -> float:
172
+ return self._power_w
173
+
174
+ def harvested_energy_j(self) -> float:
175
+ return self._power_w * self.dt
176
+
177
+ @property
178
+ def illuminance_lux(self) -> float:
179
+ return self._lux
180
+
181
+ @property
182
+ def daily_clearness(self) -> float:
183
+ return self._day_factor
184
+
185
+
186
+ __all__ = ["IndoorAirProcess", "IndoorLightSource"]
@@ -0,0 +1,192 @@
1
+ """Domain *industrial condition monitoring*: the bearing temperature of a
2
+ motor, powered by a thermoelectric generator (TEG) on the warm casing.
3
+
4
+ The hidden world is driven by the **shift schedule** (machine load). Running
5
+ heats the bearing - which is what the TEG harvests - and wears it; a random
6
+ *fault onset* accelerates the wear, the temperature drifts towards the alarm
7
+ thresholds, and maintenance eventually restores the bearing. Energy and
8
+ information are coupled through the same physical variable: the node harvests
9
+ most exactly when the machine runs hot, and a fault makes it run hotter still,
10
+ but with the thermal inertia of the casing (tens of minutes) and nothing at
11
+ all over a cold weekend.
12
+
13
+ Numbers: a 45 K steady-state rise at full load, a 45-minute thermal time
14
+ constant, a 30x30 mm TEG module giving ~1.2 mW electrical at 30 K before the
15
+ converter (~1.6 mW after it at full-load temperature), a 120 J storage, a
16
+ LoRa-like radio; sensing is a cheap temperature reading (50 mJ) or a vibration burst
17
+ with on-board FFT (0.5 J).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import math
23
+
24
+ import numpy as np
25
+
26
+ from .config import IndustrialConfig, QuantityConfig, ThermoelectricConfig
27
+ from .process import DAY_S, HOUR_S, ActivitySchedule, ProcessState
28
+
29
+
30
+ class BearingProcess:
31
+ """Thermal model of a motor bearing with slow wear, fault onsets and maintenance."""
32
+
33
+ def __init__(self, cfg: IndustrialConfig, timestep_s: float, schedule: ActivitySchedule):
34
+ self.cfg = cfg
35
+ self.q: QuantityConfig = cfg.quantity()
36
+ self.dt = timestep_s
37
+ self.schedule = schedule
38
+ self._rng = np.random.default_rng()
39
+ self.temperature_c = cfg.ambient_c
40
+ self.ambient_c = cfg.ambient_c
41
+ self.load = 0.0
42
+ self.health = 1.0
43
+ self.fault_active = False
44
+ self.maintenance_due_s: float | None = None
45
+ self.last_change = 0.0
46
+ self.event_occurred = False
47
+ self.fault_onsets = 0
48
+ self.maintenance_events = 0
49
+
50
+ # -- lifecycle ----------------------------------------------------------------
51
+ def reset(self, rng: np.random.Generator, start_time_s: float) -> None:
52
+ c = self.cfg
53
+ self._rng = rng
54
+ self.health = float(rng.uniform(*c.initial_health_range))
55
+ self.fault_active = False
56
+ self.maintenance_due_s = None
57
+ self.load = self.schedule.level(start_time_s)
58
+ self.ambient_c = self._ambient(start_time_s)
59
+ # start at the steady state of the current load (a node installed on a running or idle machine)
60
+ self.temperature_c = self.ambient_c + self._rise_c(self.load)
61
+ self.last_change = 0.0
62
+ self.event_occurred = False
63
+ self.fault_onsets = 0
64
+ self.maintenance_events = 0
65
+
66
+ # -- helpers ------------------------------------------------------------------
67
+ def _ambient(self, time_s: float) -> float:
68
+ c = self.cfg
69
+ h = (time_s % DAY_S) / HOUR_S
70
+ return c.ambient_c + c.ambient_amplitude_c * math.cos(2 * math.pi * (h - 15.0) / 24.0)
71
+
72
+ def _rise_c(self, load: float) -> float:
73
+ c = self.cfg
74
+ return c.temp_rise_full_load_c * load * (1.0 + c.fault_heat_gain * (1.0 - self.health))
75
+
76
+ @property
77
+ def value(self) -> float:
78
+ return float(np.clip(self.q.to_normalised(self.temperature_c), 0.0, 1.0))
79
+
80
+ def zone(self, value: float | None = None) -> int:
81
+ return self.q.zone(self.value if value is None else value)
82
+
83
+ # -- dynamics -----------------------------------------------------------------
84
+ def step(self, time_s: float) -> ProcessState:
85
+ c, rng, dt = self.cfg, self._rng, self.dt
86
+ prev_value = self.value
87
+ prev_zone = self.q.zone(prev_value)
88
+ prev_load = self.load
89
+ load = self.schedule.level(time_s)
90
+ self.load = load
91
+ t_next = time_s + dt
92
+
93
+ # wear and faults happen only while running
94
+ if load > 0.0:
95
+ hours = dt / HOUR_S * load
96
+ wear = (c.fault_wear_per_hour if self.fault_active else c.wear_per_hour) * hours
97
+ self.health = max(0.0, self.health - wear)
98
+ if not self.fault_active and rng.random() < c.fault_onsets_per_day * dt / (16.0 * HOUR_S):
99
+ self.fault_active = True
100
+ self.fault_onsets += 1
101
+
102
+ # maintenance: scheduled some time after the temperature exceeds the critical threshold
103
+ if self.maintenance_due_s is None and self.q.beyond_critical(prev_value):
104
+ self.maintenance_due_s = time_s + float(rng.exponential(c.maintenance_delay_mean_s))
105
+ maintenance = False
106
+ if self.maintenance_due_s is not None and t_next >= self.maintenance_due_s:
107
+ self.health = 1.0
108
+ self.fault_active = False
109
+ self.maintenance_due_s = None
110
+ self.maintenance_events += 1
111
+ maintenance = True
112
+
113
+ # first-order thermal response towards the steady state of the current load
114
+ self.ambient_c = self._ambient(t_next)
115
+ target = self.ambient_c + self._rise_c(load)
116
+ alpha = 1.0 - math.exp(-dt / c.thermal_time_constant_s)
117
+ self.temperature_c += alpha * (target - self.temperature_c) + rng.normal(0.0, c.process_noise_c)
118
+
119
+ self.last_change = self.value - prev_value
120
+ crossed = self.q.zone(self.value) != prev_zone
121
+ started_or_stopped = (prev_load == 0.0) != (load == 0.0)
122
+ self.event_occurred = abs(self.last_change) >= self.q.event_change_threshold or crossed or started_or_stopped or maintenance
123
+ return self.state()
124
+
125
+ def state(self) -> ProcessState:
126
+ return ProcessState(
127
+ value=self.value,
128
+ last_step_change=self.last_change,
129
+ event_occurred=self.event_occurred,
130
+ zone=self.zone(),
131
+ aux={
132
+ "temperature_c": self.temperature_c,
133
+ "ambient_c": self.ambient_c,
134
+ "load": self.load,
135
+ "health": self.health,
136
+ "fault_active": self.fault_active,
137
+ "fault_onsets": self.fault_onsets,
138
+ "maintenance_events": self.maintenance_events,
139
+ },
140
+ )
141
+
142
+
143
+ class ThermoelectricSource:
144
+ """TEG harvesting from the casing-to-ambient temperature difference of a
145
+ :class:`BearingProcess`. Same interface as ``energy.SolarEnergySource``."""
146
+
147
+ def __init__(self, cfg: ThermoelectricConfig, timestep_s: float, process: BearingProcess):
148
+ self.cfg = cfg
149
+ self.dt = timestep_s
150
+ self.process = process
151
+ self._rng = np.random.default_rng()
152
+ self._power_w = 0.0
153
+ self._measured_w = 0.0
154
+ self._dt_c = 0.0
155
+
156
+ def reset(self, rng: np.random.Generator, start_time_s: float) -> None:
157
+ self._rng = rng
158
+ self.update(start_time_s)
159
+
160
+ def update(self, time_s: float) -> None:
161
+ c = self.cfg
162
+ dt_c = max(0.0, self.process.temperature_c - self.process.ambient_c)
163
+ self._dt_c = dt_c
164
+ if dt_c < c.min_dt_c:
165
+ self._power_w = 0.0
166
+ else:
167
+ self._power_w = c.power_w_at_ref_dt * (dt_c / c.reference_dt_c) ** 2 * c.efficiency
168
+ noise = 1.0 + self._rng.normal(0.0, c.measurement_noise_std)
169
+ self._measured_w = max(0.0, self._power_w * noise)
170
+
171
+ # -- EnergySource protocol ------------------------------------------------------
172
+ def measured_power_w(self) -> float:
173
+ return self._measured_w
174
+
175
+ # -- simulator-only ---------------------------------------------------------------
176
+ def true_power_w(self) -> float:
177
+ return self._power_w
178
+
179
+ def harvested_energy_j(self) -> float:
180
+ return self._power_w * self.dt
181
+
182
+ @property
183
+ def temperature_difference_c(self) -> float:
184
+ return self._dt_c
185
+
186
+ @property
187
+ def daily_clearness(self) -> float:
188
+ """Stand-in for the solar clearness: the machine load of the current step."""
189
+ return float(self.process.load)
190
+
191
+
192
+ __all__ = ["BearingProcess", "ThermoelectricSource"]
@@ -0,0 +1,171 @@
1
+ """Hardware abstraction layer of EdgeEngine AWARE.
2
+
3
+ The policy never talks to these objects directly; the *node controller*
4
+ (simulated by ``env.py``, or a real firmware loop as sketched in
5
+ ``deployment.py``) does. Each protocol is deliberately tiny so that it can be
6
+ implemented by
7
+
8
+ * a stochastic model (this package: ``energy.py``, ``sensing.py``, ...),
9
+ * a driver on a microcontroller (ADC, fuel gauge, sensor driver, radio stack),
10
+ * a replay of recorded traces (``traces.py``: irradiance and field traces;
11
+ a trace-driven ``Sensor`` / ``Radio`` would follow the same pattern).
12
+
13
+ Only *measurable* quantities cross these interfaces. Hidden ground truth
14
+ (true soil moisture, future irradiance, ...) stays inside the simulated
15
+ implementations and is only exposed through explicit ``ground_truth()`` style
16
+ accessors used for reward, evaluation and rendering.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+ from typing import Any, Protocol, runtime_checkable
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Data records exchanged between subsystems
27
+ # ---------------------------------------------------------------------------
28
+ @dataclass(frozen=True)
29
+ class Measurement:
30
+ """A value acquired by the node from a sensor."""
31
+
32
+ value: float
33
+ """Measured quantity (moisture units, normalised to field capacity)."""
34
+
35
+ timestamp_s: float
36
+ """Node clock time at which the measurement was taken [s]."""
37
+
38
+ level: int
39
+ """Sensing level that produced it (1 = low-cost, 2 = high-quality)."""
40
+
41
+ noise_std: float
42
+ """Nominal standard deviation of the measurement error (from the sensor
43
+ datasheet / hardware profile). Used as a *quality tag*, it is not the
44
+ realised error."""
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Packet:
49
+ """Payload of one uplink: the latest measurement plus its age."""
50
+
51
+ measurement: Measurement
52
+ sent_at_s: float
53
+
54
+ @property
55
+ def measurement_age_s(self) -> float:
56
+ return self.sent_at_s - self.measurement.timestamp_s
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class TxResult:
61
+ """Outcome of one uplink attempt as seen by the node."""
62
+
63
+ acked: bool
64
+ """Whether an acknowledgement was received."""
65
+
66
+ margin_db: float | None = None
67
+ """Link margin of the *used mode* as measured from the ACK (SNR above the
68
+ demodulation floor), or None when there was no ACK. A LoRaWAN node gets
69
+ this from the downlink SNR/RSSI or from a LinkCheckAns."""
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Hardware-facing protocols
74
+ # ---------------------------------------------------------------------------
75
+ @runtime_checkable
76
+ class Clock(Protocol):
77
+ """Time source of the node (RTC or monotonic timer)."""
78
+
79
+ def now_s(self) -> float: ...
80
+
81
+ def time_of_day_s(self) -> float:
82
+ """Seconds since local midnight, in [0, 86400)."""
83
+ ...
84
+
85
+
86
+ @runtime_checkable
87
+ class EnergyStorage(Protocol):
88
+ """Battery / supercapacitor with a fuel gauge."""
89
+
90
+ def capacity_j(self) -> float: ...
91
+
92
+ def energy_j(self) -> float:
93
+ """Currently stored energy [J] as reported by the gauge."""
94
+ ...
95
+
96
+
97
+ @runtime_checkable
98
+ class EnergySource(Protocol):
99
+ """Harvesting subsystem with a power/current monitor."""
100
+
101
+ def measured_power_w(self) -> float:
102
+ """Average harvesting power over the interval that just elapsed [W],
103
+ i.e. the energy integrated by the harvester monitor since the
104
+ previous wake-up divided by the interval. Never a forecast."""
105
+ ...
106
+
107
+
108
+ @runtime_checkable
109
+ class Sensor(Protocol):
110
+ """Environmental sensor with selectable acquisition modes."""
111
+
112
+ def read(self, level: int, now_s: float) -> Measurement:
113
+ """Acquire a measurement at the requested level (>= 1)."""
114
+ ...
115
+
116
+ def energy_cost_j(self, level: int) -> float:
117
+ """Nominal energy of an acquisition at ``level`` [J]."""
118
+ ...
119
+
120
+ def noise_std(self, level: int) -> float:
121
+ """Nominal measurement noise at ``level`` (quality tag)."""
122
+ ...
123
+
124
+
125
+ @runtime_checkable
126
+ class Radio(Protocol):
127
+ """Low-power long-range uplink with selectable modes (spreading factor / power)."""
128
+
129
+ def transmit(self, packet: Packet, mode: int) -> TxResult:
130
+ """Send a packet with ``modes[mode]``. Energy is consumed regardless of
131
+ the outcome. On a link without confirmations ``acked`` is meaningless
132
+ and the controller ignores it (``NodeProfile.ack_available``)."""
133
+ ...
134
+
135
+ def tx_energy_j(self, mode: int) -> float:
136
+ """Nominal energy of one transmission attempt in ``mode`` [J]."""
137
+ ...
138
+
139
+ def n_modes(self) -> int: ...
140
+
141
+
142
+ @runtime_checkable
143
+ class RemoteApplication(Protocol):
144
+ """Application-side endpoint as seen from the node.
145
+
146
+ On a real system the node only sees the *priority* the application sends
147
+ back (downlink). Delivery of data happens through the ``Radio``."""
148
+
149
+ def priority(self) -> int:
150
+ """Current information priority requested by the application:
151
+ 0 = routine, 1 = elevated, 2 = urgent."""
152
+ ...
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Policy protocol (independent from Gymnasium)
157
+ # ---------------------------------------------------------------------------
158
+ @runtime_checkable
159
+ class Policy(Protocol):
160
+ """Anything that maps a policy observation to an action.
161
+
162
+ ``observation`` is the float32 vector produced by
163
+ ``observation.ObservationBuilder``; the returned action is the
164
+ ``(sensing_level, transmit)`` pair encoded as in ``actions.py``.
165
+ """
166
+
167
+ def act(self, observation: Any) -> Any: ...
168
+
169
+ def reset(self) -> None:
170
+ """Clear any internal state at the start of an episode."""
171
+ ...
@@ -0,0 +1,131 @@
1
+ """Episode-level metrics and a lightweight step logger.
2
+
3
+ ``EpisodeMetrics`` is updated by the environment at every step and returned
4
+ in ``info["metrics"]`` (as a dict) on every step, so it is always available -
5
+ also when an episode is cut short. ``EpisodeLog`` stores per-step arrays for
6
+ plotting (used by the renderer and the notebook).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import asdict, dataclass, field
12
+
13
+
14
+ @dataclass
15
+ class EpisodeMetrics:
16
+ steps: int = 0
17
+ total_harvested_energy_j: float = 0.0
18
+ total_consumed_energy_j: float = 0.0
19
+ baseline_energy_j: float = 0.0
20
+ sensing_energy_j: float = 0.0
21
+ communication_energy_j: float = 0.0
22
+ wasted_harvest_energy_j: float = 0.0
23
+ n_sensing: int = 0
24
+ n_high_quality_sensing: int = 0
25
+ n_transmissions: int = 0
26
+ n_successful_transmissions: int = 0
27
+ transmissions_per_mode: dict[int, int] = field(default_factory=dict)
28
+ deliveries_per_mode: dict[int, int] = field(default_factory=dict)
29
+ n_rejected_actions: int = 0
30
+ battery_depletion_events: int = 0
31
+ steps_low_battery: int = 0
32
+ _soc_sum: float = 0.0
33
+ min_battery_soc: float = 1.0
34
+ _aoi_sum_s: float = 0.0
35
+ max_aoi_s: float = 0.0
36
+ total_application_utility: float = 0.0
37
+ total_reward: float = 0.0
38
+ reward_components: dict[str, float] = field(default_factory=dict)
39
+
40
+ # -- derived --------------------------------------------------------------
41
+ @property
42
+ def average_battery_soc(self) -> float:
43
+ return self._soc_sum / self.steps if self.steps else 0.0
44
+
45
+ @property
46
+ def average_aoi_s(self) -> float:
47
+ return self._aoi_sum_s / self.steps if self.steps else 0.0
48
+
49
+ @property
50
+ def fraction_low_battery(self) -> float:
51
+ return self.steps_low_battery / self.steps if self.steps else 0.0
52
+
53
+ @property
54
+ def delivery_ratio(self) -> float:
55
+ return self.n_successful_transmissions / self.n_transmissions if self.n_transmissions else 0.0
56
+
57
+ def as_dict(self) -> dict:
58
+ d = {k: v for k, v in asdict(self).items() if not k.startswith("_")}
59
+ d.update(
60
+ average_battery_soc=self.average_battery_soc,
61
+ average_aoi_s=self.average_aoi_s,
62
+ average_aoi_h=self.average_aoi_s / 3600.0,
63
+ max_aoi_h=self.max_aoi_s / 3600.0,
64
+ fraction_low_battery=self.fraction_low_battery,
65
+ delivery_ratio=self.delivery_ratio,
66
+ )
67
+ return d
68
+
69
+ def summary(self) -> str:
70
+ d = self.as_dict()
71
+ keys = [
72
+ ("total_reward", "{:.2f}"),
73
+ ("total_application_utility", "{:.2f}"),
74
+ ("total_harvested_energy_j", "{:.1f} J"),
75
+ ("total_consumed_energy_j", "{:.1f} J"),
76
+ ("baseline_energy_j", "{:.1f} J"),
77
+ ("sensing_energy_j", "{:.1f} J"),
78
+ ("communication_energy_j", "{:.1f} J"),
79
+ ("wasted_harvest_energy_j", "{:.1f} J"),
80
+ ("n_sensing", "{}"),
81
+ ("n_high_quality_sensing", "{}"),
82
+ ("n_transmissions", "{}"),
83
+ ("n_successful_transmissions", "{}"),
84
+ ("transmissions_per_mode", "{}"),
85
+ ("delivery_ratio", "{:.2f}"),
86
+ ("n_rejected_actions", "{}"),
87
+ ("average_battery_soc", "{:.3f}"),
88
+ ("min_battery_soc", "{:.3f}"),
89
+ ("fraction_low_battery", "{:.3f}"),
90
+ ("battery_depletion_events", "{}"),
91
+ ("average_aoi_h", "{:.2f} h"),
92
+ ("max_aoi_h", "{:.2f} h"),
93
+ ]
94
+ width = max(len(k) for k, _ in keys)
95
+ return "\n".join(f"{k:<{width}} : {fmt.format(d[k])}" for k, fmt in keys)
96
+
97
+
98
+ @dataclass
99
+ class EpisodeLog:
100
+ """Per-step history for plotting."""
101
+
102
+ time_s: list[float] = field(default_factory=list)
103
+ soc: list[float] = field(default_factory=list)
104
+ harvest_power_w: list[float] = field(default_factory=list)
105
+ harvested_energy_j: list[float] = field(default_factory=list)
106
+ true_moisture: list[float] = field(default_factory=list) # normalised monitored quantity (any domain)
107
+ measured_moisture: list[float] = field(default_factory=list) # nan when no measurement stored
108
+ app_moisture: list[float] = field(default_factory=list) # nan when nothing received yet
109
+ sensing_level: list[int] = field(default_factory=list)
110
+ tx_attempt: list[int] = field(default_factory=list)
111
+ tx_mode: list[int] = field(default_factory=list) # -1 when no transmission
112
+ tx_success: list[int] = field(default_factory=list)
113
+ path_loss_db: list[float] = field(default_factory=list)
114
+ aoi_s: list[float] = field(default_factory=list)
115
+ priority: list[int] = field(default_factory=list)
116
+ reward: list[float] = field(default_factory=list)
117
+ utility: list[float] = field(default_factory=list)
118
+ temperature_c: list[float] = field(default_factory=list) # domain extra (air / bearing temperature)
119
+ humidity: list[float] = field(default_factory=list) # domain extra (humidity / occupancy / load)
120
+
121
+ def append(self, **kwargs) -> None:
122
+ for k, v in kwargs.items():
123
+ getattr(self, k).append(v)
124
+
125
+ @property
126
+ def true_value(self) -> list[float]:
127
+ """Normalised monitored quantity (``true_moisture`` is the historical name)."""
128
+ return self.true_moisture
129
+
130
+ def __len__(self) -> int:
131
+ return len(self.time_s)