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.
- edgeengine_aware/__init__.py +93 -0
- edgeengine_aware/actions.py +194 -0
- edgeengine_aware/agriculture.py +191 -0
- edgeengine_aware/application.py +222 -0
- edgeengine_aware/communication.py +99 -0
- edgeengine_aware/config.py +942 -0
- edgeengine_aware/deployment.py +411 -0
- edgeengine_aware/domains.py +284 -0
- edgeengine_aware/energy.py +195 -0
- edgeengine_aware/env.py +441 -0
- edgeengine_aware/indoor.py +186 -0
- edgeengine_aware/industrial.py +192 -0
- edgeengine_aware/interfaces.py +171 -0
- edgeengine_aware/metrics.py +131 -0
- edgeengine_aware/observation.py +490 -0
- edgeengine_aware/policies.py +285 -0
- edgeengine_aware/process.py +136 -0
- edgeengine_aware/rendering.py +199 -0
- edgeengine_aware/reward.py +92 -0
- edgeengine_aware/rl.py +368 -0
- edgeengine_aware/scenarios.py +131 -0
- edgeengine_aware/sensing.py +45 -0
- edgeengine_aware/traces.py +636 -0
- edgeengine_aware-0.4.0.dist-info/METADATA +739 -0
- edgeengine_aware-0.4.0.dist-info/RECORD +28 -0
- edgeengine_aware-0.4.0.dist-info/WHEEL +5 -0
- edgeengine_aware-0.4.0.dist-info/licenses/LICENSE +21 -0
- edgeengine_aware-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|