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,942 @@
|
|
|
1
|
+
"""Configuration objects for EdgeEngine AWARE.
|
|
2
|
+
|
|
3
|
+
Every tunable quantity of the simulator lives here, grouped by subsystem, so
|
|
4
|
+
that experiments can be set up without touching the environment code.
|
|
5
|
+
|
|
6
|
+
Units (used consistently across the whole package)
|
|
7
|
+
--------------------------------------------------
|
|
8
|
+
* time : seconds [s]
|
|
9
|
+
* energy : joules [J]
|
|
10
|
+
* power : watts [W] (1 W = 1 J/s)
|
|
11
|
+
* moisture : dimensionless volumetric soil-water content normalised to the
|
|
12
|
+
field capacity, i.e. 1.0 = field capacity, 0.0 = completely dry.
|
|
13
|
+
* temperature: degrees Celsius; humidity: relative humidity in [0, 1].
|
|
14
|
+
|
|
15
|
+
The default values describe a *small* energy-harvesting node: a few-cm^2
|
|
16
|
+
solar cell that peaks at a few milliwatts, a ~300 J storage element
|
|
17
|
+
(roughly a 25 mAh Li-Po cell or a supercapacitor bank) and a LoRa-class radio
|
|
18
|
+
with three selectable modes (fast / standard / robust, 0.3 / 0.6 / 1.2 J per uplink).
|
|
19
|
+
On an average day the node harvests ~55 J, its baseline load takes ~17 J, one
|
|
20
|
+
high-quality sample + uplink costs 1.2 J: hourly reporting is sustainable on
|
|
21
|
+
sunny days but not on cloudy ones, which is exactly the regime in which an
|
|
22
|
+
energy-aware policy pays off.
|
|
23
|
+
They are illustrative, physically plausible values, not a digital twin of a
|
|
24
|
+
specific product; each of them is meant to be replaced by a measurement taken
|
|
25
|
+
on the real device (see ``docs/sim_to_real.md``).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import dataclasses
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
HOUR_S = 3600.0
|
|
35
|
+
DAY_S = 24.0 * HOUR_S
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Time
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
@dataclass
|
|
42
|
+
class TimeConfig:
|
|
43
|
+
"""Discrete-time settings of the simulator."""
|
|
44
|
+
|
|
45
|
+
timestep_s: float = 15.0 * 60.0
|
|
46
|
+
"""Duration of one decision step [s]. Default: 15 minutes."""
|
|
47
|
+
|
|
48
|
+
episode_days: float = 7.0
|
|
49
|
+
"""Episode length in days. The episode is *truncated* after
|
|
50
|
+
``episode_days * 86400 / timestep_s`` steps."""
|
|
51
|
+
|
|
52
|
+
start_hour: float = 0.0
|
|
53
|
+
"""Time of day (hours, 0-24) at which the episode starts."""
|
|
54
|
+
|
|
55
|
+
start_weekday: int = 0
|
|
56
|
+
"""Weekday of episode day 0 (0 = Monday ... 6 = Sunday); used by the
|
|
57
|
+
domains with a weekly activity schedule."""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def max_steps(self) -> int:
|
|
61
|
+
return int(round(self.episode_days * DAY_S / self.timestep_s))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Energy storage and MCU
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
@dataclass
|
|
68
|
+
class EnergyStorageConfig:
|
|
69
|
+
"""Finite energy storage element (battery or supercapacitor)."""
|
|
70
|
+
|
|
71
|
+
capacity_j: float = 300.0
|
|
72
|
+
"""Usable capacity E_max [J] (e.g. a ~25 mAh Li-Po cell or a supercapacitor bank)."""
|
|
73
|
+
|
|
74
|
+
initial_soc: float = 0.5
|
|
75
|
+
"""Initial state of charge in [0, 1] when ``initial_soc_range`` is None."""
|
|
76
|
+
|
|
77
|
+
initial_soc_range: tuple[float, float] | None = None
|
|
78
|
+
"""If given, the initial SoC is drawn uniformly from this range at reset."""
|
|
79
|
+
|
|
80
|
+
reserve_soc: float = 0.02
|
|
81
|
+
"""Fraction of E_max that the node never spends on optional operations
|
|
82
|
+
(sensing / transmission). Below ``reserve_soc`` only the baseline load is
|
|
83
|
+
served; this mimics the brown-out protection of a real power path."""
|
|
84
|
+
|
|
85
|
+
charge_efficiency: float = 1.0
|
|
86
|
+
"""Fraction of the harvested energy that is actually stored (1 = ideal buffer)."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class MCUConfig:
|
|
91
|
+
"""Always-on consumption of the microcontroller and its peripherals."""
|
|
92
|
+
|
|
93
|
+
baseline_power_w: float = 200e-6
|
|
94
|
+
"""Average sleep/idle power [W] (e.g. ~60 uA at 3.3 V, including sensor
|
|
95
|
+
quiescent current and RTC)."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# Energy harvesting
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
@dataclass
|
|
102
|
+
class HarvestingConfig:
|
|
103
|
+
"""Stochastic solar harvesting model.
|
|
104
|
+
|
|
105
|
+
harvested_power(t) = max_power_w * solar_profile(t) * cloud_factor(t) * efficiency
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
max_power_w: float = 0.005
|
|
109
|
+
"""Peak electrical power of the panel under clear sky at solar noon [W]
|
|
110
|
+
(a ~2 cm^2 cell, or a larger cell under partial canopy shading)."""
|
|
111
|
+
|
|
112
|
+
efficiency: float = 0.6
|
|
113
|
+
"""Harvesting-circuit (MPPT/charger) efficiency in (0, 1]."""
|
|
114
|
+
|
|
115
|
+
sunrise_hour: float = 6.0
|
|
116
|
+
sunset_hour: float = 18.0
|
|
117
|
+
|
|
118
|
+
clearness_mean: float = 0.7
|
|
119
|
+
"""Mean daily clearness index (1 = perfectly clear day)."""
|
|
120
|
+
|
|
121
|
+
clearness_std: float = 0.2
|
|
122
|
+
"""Day-to-day standard deviation of the daily clearness index."""
|
|
123
|
+
|
|
124
|
+
clearness_autocorr: float = 0.5
|
|
125
|
+
"""AR(1) coefficient linking consecutive days (weather persistence)."""
|
|
126
|
+
|
|
127
|
+
cloud_noise_std: float = 0.15
|
|
128
|
+
"""Std of the intra-day cloud perturbation (per step)."""
|
|
129
|
+
|
|
130
|
+
cloud_autocorr: float = 0.85
|
|
131
|
+
"""AR(1) coefficient of the intra-day cloud perturbation."""
|
|
132
|
+
|
|
133
|
+
measurement_noise_std: float = 0.05
|
|
134
|
+
"""Relative noise of the *measured* harvesting power exposed to the node."""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Sensing
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
@dataclass
|
|
141
|
+
class SensingConfig:
|
|
142
|
+
"""Sensing modes. Index 0 = no sensing, 1 = low-cost, 2 = high-quality."""
|
|
143
|
+
|
|
144
|
+
energy_j: tuple[float, float, float] = (0.0, 0.10, 0.60)
|
|
145
|
+
"""Energy per sensing operation for each level [J]."""
|
|
146
|
+
|
|
147
|
+
noise_std: tuple[float, float, float] = (0.0, 0.040, 0.010)
|
|
148
|
+
"""Std of the additive Gaussian measurement noise for each level
|
|
149
|
+
(moisture units). Level 0 never produces a measurement."""
|
|
150
|
+
|
|
151
|
+
bias: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
|
152
|
+
"""Optional systematic offset per level (moisture units)."""
|
|
153
|
+
|
|
154
|
+
level_names: tuple[str, str, str] = ("none", "low", "high")
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def n_levels(self) -> int:
|
|
158
|
+
return len(self.energy_j)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Communication
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
@dataclass
|
|
165
|
+
class RadioModeConfig:
|
|
166
|
+
"""One transmission mode of the radio (a spreading factor / power setting).
|
|
167
|
+
|
|
168
|
+
The link budget of a mode is ``tx_power_dbm - path_loss_db - sensitivity_dbm``
|
|
169
|
+
(its *margin*); a mode with a longer spreading factor has a lower (better)
|
|
170
|
+
sensitivity, a longer air time and therefore a higher energy per uplink.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
name: str
|
|
174
|
+
energy_j: float
|
|
175
|
+
"""Energy of one uplink attempt in this mode (radio + MCU awake time) [J]."""
|
|
176
|
+
|
|
177
|
+
tx_power_dbm: float
|
|
178
|
+
"""Transmit power [dBm]."""
|
|
179
|
+
|
|
180
|
+
sensitivity_dbm: float
|
|
181
|
+
"""Receiver sensitivity at the gateway for this spreading factor [dBm]
|
|
182
|
+
(SX127x-class, 125 kHz: SF7 ~ -123, SF9 ~ -129, SF12 ~ -137)."""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
DEFAULT_RADIO_MODES: tuple[RadioModeConfig, ...] = (
|
|
186
|
+
RadioModeConfig("fast", energy_j=0.30, tx_power_dbm=14.0, sensitivity_dbm=-123.0), # SF7-like
|
|
187
|
+
RadioModeConfig("standard", energy_j=0.60, tx_power_dbm=14.0, sensitivity_dbm=-129.0), # SF9-like
|
|
188
|
+
RadioModeConfig("robust", energy_j=1.20, tx_power_dbm=14.0, sensitivity_dbm=-137.0), # SF12-like
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass
|
|
193
|
+
class CommunicationConfig:
|
|
194
|
+
"""Abstract low-power long-range link (LoRa-like) with a link-budget channel.
|
|
195
|
+
|
|
196
|
+
delivery probability of mode k at time t:
|
|
197
|
+
margin_k(t) = tx_power_k - path_loss(t) - sensitivity_k
|
|
198
|
+
p_k(t) = 1 / (1 + exp(-margin_k(t) / margin_scale_db))
|
|
199
|
+
path_loss(t) = path_loss_mean_db + slow_fading(t) + fast_fading (dB)
|
|
200
|
+
|
|
201
|
+
``slow_fading`` is an AR(1) process (shadowing, vegetation, humidity);
|
|
202
|
+
``fast_fading`` is redrawn at every attempt. With the defaults the mean
|
|
203
|
+
margins are +4 dB (*standard*), -2 dB (*fast*, half the energy) and +12 dB
|
|
204
|
+
(*robust*, twice the energy). Averaged over the fading, the long-run
|
|
205
|
+
delivery ratios are about 0.75 / 0.36 / 0.98; at zero slow fading they are
|
|
206
|
+
0.93 / 0.21 / 1.0, so the fast mode pays off only in favourable phases.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
modes: tuple[RadioModeConfig, ...] = DEFAULT_RADIO_MODES
|
|
210
|
+
"""Selectable transmission modes; the action ``transmit = k`` (k >= 1) uses
|
|
211
|
+
``modes[k - 1]``. A single-entry tuple reproduces a binary transmit action."""
|
|
212
|
+
|
|
213
|
+
reference_mode: int = 1
|
|
214
|
+
"""Index of the mode whose energy is reported in the observation and used
|
|
215
|
+
by the simple policies when they have no link estimate."""
|
|
216
|
+
|
|
217
|
+
path_loss_mean_db: float = 139.0
|
|
218
|
+
slow_fading_std_db: float = 5.0
|
|
219
|
+
slow_fading_autocorr: float = 0.97
|
|
220
|
+
"""AR(1) coefficient per step (0.97 at 15 min ~ 8 h correlation time)."""
|
|
221
|
+
|
|
222
|
+
fast_fading_std_db: float = 2.0
|
|
223
|
+
margin_scale_db: float = 1.5
|
|
224
|
+
"""Softness of the delivery curve around zero margin."""
|
|
225
|
+
|
|
226
|
+
ack_available: bool = True
|
|
227
|
+
"""Whether the node learns if an uplink was delivered (confirmed uplink /
|
|
228
|
+
ACK). With ``False`` (unconfirmed uplinks) the node assumes every uplink
|
|
229
|
+
was delivered: its estimate of the information age at the application
|
|
230
|
+
becomes optimistic and its link-quality indicator stays at 1."""
|
|
231
|
+
|
|
232
|
+
ack_margin_noise_db: float = 1.0
|
|
233
|
+
"""Std of the noise on the link margin the node measures from an ACK
|
|
234
|
+
(SNR estimate), i.e. on its path-loss estimate."""
|
|
235
|
+
|
|
236
|
+
priority_update_mode: str = "immediate"
|
|
237
|
+
"""How the node learns the application priority.
|
|
238
|
+
'immediate' : the node always knows the current priority (e.g. a listening
|
|
239
|
+
downlink window or a class-C style device).
|
|
240
|
+
'on_uplink' : the priority is refreshed only after a *successful* uplink,
|
|
241
|
+
as in a LoRaWAN class-A downlink piggybacked on the ACK."""
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def n_modes(self) -> int:
|
|
245
|
+
return len(self.modes)
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def tx_energy_j(self) -> float:
|
|
249
|
+
"""Energy of the reference mode [J] (backwards-compatible accessor)."""
|
|
250
|
+
return self.modes[self.reference_mode].energy_j
|
|
251
|
+
|
|
252
|
+
def mean_margin_db(self, mode: int) -> float:
|
|
253
|
+
m = self.modes[mode]
|
|
254
|
+
return m.tx_power_dbm - self.path_loss_mean_db - m.sensitivity_dbm
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def single_mode_radio(energy_j: float = 0.60, mean_margin_db: float = 4.0) -> CommunicationConfig:
|
|
258
|
+
"""A CommunicationConfig with one transmission mode (binary transmit action)."""
|
|
259
|
+
mode = RadioModeConfig("standard", energy_j=energy_j, tx_power_dbm=14.0, sensitivity_dbm=-129.0)
|
|
260
|
+
return CommunicationConfig(modes=(mode,), reference_mode=0, path_loss_mean_db=14.0 + 129.0 - mean_margin_db)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
# Agriculture ground-truth environment
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
@dataclass
|
|
267
|
+
class AgricultureConfig:
|
|
268
|
+
"""Stochastic soil/atmosphere model (hidden ground truth)."""
|
|
269
|
+
|
|
270
|
+
initial_moisture: float = 0.55
|
|
271
|
+
initial_moisture_range: tuple[float, float] | None = (0.40, 0.70)
|
|
272
|
+
|
|
273
|
+
warning_threshold: float = 0.35
|
|
274
|
+
"""Soil moisture below which the crop starts to experience water stress."""
|
|
275
|
+
|
|
276
|
+
critical_threshold: float = 0.25
|
|
277
|
+
"""Soil moisture below which irrigation is urgently needed."""
|
|
278
|
+
|
|
279
|
+
et_rate_per_day: float = 0.06
|
|
280
|
+
"""Mean evapotranspiration [moisture units / day] at the reference
|
|
281
|
+
temperature (the day/night modulation preserves this daily mean)."""
|
|
282
|
+
|
|
283
|
+
et_temp_coeff: float = 0.03
|
|
284
|
+
"""Relative increase of ET per degree above ``temp_mean``."""
|
|
285
|
+
|
|
286
|
+
et_diurnal_amplitude: float = 0.8
|
|
287
|
+
"""Fraction of ET modulated by the day/night cycle (0 = flat)."""
|
|
288
|
+
|
|
289
|
+
sunrise_hour: float = 6.0
|
|
290
|
+
sunset_hour: float = 18.0
|
|
291
|
+
"""Daylight window used by the ET modulation (keep consistent with HarvestingConfig)."""
|
|
292
|
+
|
|
293
|
+
rain_events_per_day: float = 0.25
|
|
294
|
+
"""Rate of the Poisson process generating rain events."""
|
|
295
|
+
|
|
296
|
+
rain_amount_range: tuple[float, float] = (0.05, 0.25)
|
|
297
|
+
"""Moisture added by a rain event (uniform)."""
|
|
298
|
+
|
|
299
|
+
irrigation_enabled: bool = True
|
|
300
|
+
irrigation_trigger: float = 0.20
|
|
301
|
+
"""Ground-truth moisture below which the (external) irrigation system
|
|
302
|
+
eventually reacts. This models the field being irrigated by a farmer; it is
|
|
303
|
+
*not* a decision of the node."""
|
|
304
|
+
|
|
305
|
+
irrigation_delay_mean_s: float = 6.0 * HOUR_S
|
|
306
|
+
irrigation_amount: float = 0.30
|
|
307
|
+
|
|
308
|
+
process_noise_std: float = 0.003
|
|
309
|
+
"""Std of the per-step random walk on moisture."""
|
|
310
|
+
|
|
311
|
+
max_moisture: float = 1.0
|
|
312
|
+
|
|
313
|
+
temp_mean_c: float = 22.0
|
|
314
|
+
temp_amplitude_c: float = 7.0
|
|
315
|
+
temp_peak_hour: float = 15.0
|
|
316
|
+
temp_noise_std: float = 0.6
|
|
317
|
+
temp_autocorr: float = 0.9
|
|
318
|
+
|
|
319
|
+
humidity_mean: float = 0.60
|
|
320
|
+
humidity_temp_coeff: float = -0.02
|
|
321
|
+
"""Change of relative humidity per degree of temperature deviation."""
|
|
322
|
+
humidity_noise_std: float = 0.03
|
|
323
|
+
|
|
324
|
+
event_change_threshold: float = 0.05
|
|
325
|
+
"""A change of true moisture larger than this within one step (rain,
|
|
326
|
+
irrigation) is registered as an 'environmental event'."""
|
|
327
|
+
|
|
328
|
+
def quantity(self) -> "QuantityConfig":
|
|
329
|
+
return QuantityConfig(
|
|
330
|
+
name="soil moisture",
|
|
331
|
+
unit="fraction of field capacity",
|
|
332
|
+
physical_min=0.0,
|
|
333
|
+
physical_max=1.0,
|
|
334
|
+
warning_threshold=self.warning_threshold,
|
|
335
|
+
critical_threshold=self.critical_threshold,
|
|
336
|
+
critical_is_upper=False,
|
|
337
|
+
event_change_threshold=self.event_change_threshold,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
# Monitored quantity: how the hidden scalar is presented to node and application
|
|
343
|
+
# ---------------------------------------------------------------------------
|
|
344
|
+
@dataclass
|
|
345
|
+
class QuantityConfig:
|
|
346
|
+
"""Semantics of the monitored scalar, shared by every domain.
|
|
347
|
+
|
|
348
|
+
The simulator, the node and the application work with a *normalised* value
|
|
349
|
+
in [0, 1]; ``physical_min``/``physical_max`` only serve display and traces.
|
|
350
|
+
Two thresholds define the stress zones. ``critical_is_upper`` says on which
|
|
351
|
+
side the danger lies: soil moisture is dangerous when *low*, CO2 or a
|
|
352
|
+
bearing temperature when *high*. Everything downstream (importance,
|
|
353
|
+
priority, criticality, zones) reads this flag, so a policy sees the same
|
|
354
|
+
18-vector whatever the domain.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
name: str = "soil moisture"
|
|
358
|
+
unit: str = "fraction of field capacity"
|
|
359
|
+
physical_min: float = 0.0
|
|
360
|
+
physical_max: float = 1.0
|
|
361
|
+
warning_threshold: float = 0.35
|
|
362
|
+
critical_threshold: float = 0.25
|
|
363
|
+
critical_is_upper: bool = False
|
|
364
|
+
event_change_threshold: float = 0.05
|
|
365
|
+
"""A change of the normalised value larger than this within one step is an
|
|
366
|
+
'environmental event' worth reporting."""
|
|
367
|
+
|
|
368
|
+
def to_physical(self, value: float) -> float:
|
|
369
|
+
return self.physical_min + value * (self.physical_max - self.physical_min)
|
|
370
|
+
|
|
371
|
+
def to_normalised(self, physical: float) -> float:
|
|
372
|
+
span = self.physical_max - self.physical_min
|
|
373
|
+
return (physical - self.physical_min) / span if span else 0.0
|
|
374
|
+
|
|
375
|
+
def zone(self, value: float) -> int:
|
|
376
|
+
"""0 = normal, 1 = warning, 2 = critical."""
|
|
377
|
+
if self.critical_is_upper:
|
|
378
|
+
if value > self.critical_threshold:
|
|
379
|
+
return 2
|
|
380
|
+
if value > self.warning_threshold:
|
|
381
|
+
return 1
|
|
382
|
+
return 0
|
|
383
|
+
if value < self.critical_threshold:
|
|
384
|
+
return 2
|
|
385
|
+
if value < self.warning_threshold:
|
|
386
|
+
return 1
|
|
387
|
+
return 0
|
|
388
|
+
|
|
389
|
+
def beyond_critical(self, value: float) -> bool:
|
|
390
|
+
"""At or beyond the critical threshold (saturation of importance / criticality)."""
|
|
391
|
+
return value >= self.critical_threshold if self.critical_is_upper else value <= self.critical_threshold
|
|
392
|
+
|
|
393
|
+
def validate(self) -> None:
|
|
394
|
+
if self.critical_is_upper and not self.critical_threshold > self.warning_threshold:
|
|
395
|
+
raise ValueError("with critical_is_upper the critical threshold must be above the warning threshold")
|
|
396
|
+
if not self.critical_is_upper and not self.critical_threshold < self.warning_threshold:
|
|
397
|
+
raise ValueError("critical_threshold must be below warning_threshold")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
# Weekly activity schedule (people in a room, a machine on shift) - shared by a
|
|
402
|
+
# domain's process and its harvesting source
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
@dataclass
|
|
405
|
+
class ScheduleConfig:
|
|
406
|
+
"""Hidden weekly activity level in [0, 1] driving both the monitored
|
|
407
|
+
process and the energy source of the indoor and industrial domains.
|
|
408
|
+
|
|
409
|
+
Activity is ``base_level`` inside the active window of an active day (with
|
|
410
|
+
an optional midday dip), 0 otherwise, multiplied by a random per-day
|
|
411
|
+
factor and perturbed by a within-day AR(1) term; random *exceptions*
|
|
412
|
+
(a day off, an overtime day) flip the day type.
|
|
413
|
+
"""
|
|
414
|
+
|
|
415
|
+
active_days: tuple[int, ...] = (0, 1, 2, 3, 4)
|
|
416
|
+
"""Weekdays with activity (0 = Monday ... 6 = Sunday)."""
|
|
417
|
+
|
|
418
|
+
start_hour: float = 8.0
|
|
419
|
+
end_hour: float = 18.0
|
|
420
|
+
ramp_h: float = 0.5
|
|
421
|
+
"""Duration of the ramps at the start and end of the active window [h]."""
|
|
422
|
+
|
|
423
|
+
base_level: float = 0.8
|
|
424
|
+
"""Activity level inside the window on a typical day."""
|
|
425
|
+
|
|
426
|
+
dip_hours: tuple[float, float] | None = (12.5, 13.5)
|
|
427
|
+
dip_level: float = 0.3
|
|
428
|
+
"""Optional midday dip (lunch break) with its level."""
|
|
429
|
+
|
|
430
|
+
day_factor_std: float = 0.15
|
|
431
|
+
"""Std of the per-day multiplicative factor (uniform-ish variety between days)."""
|
|
432
|
+
|
|
433
|
+
noise_std: float = 0.08
|
|
434
|
+
noise_autocorr: float = 0.8
|
|
435
|
+
"""Within-day AR(1) perturbation of the level."""
|
|
436
|
+
|
|
437
|
+
p_day_off: float = 0.05
|
|
438
|
+
"""Probability that an active day is unexpectedly inactive (holiday, breakdown)."""
|
|
439
|
+
|
|
440
|
+
p_extra_day: float = 0.10
|
|
441
|
+
"""Probability that an inactive day is unexpectedly active (overtime, Saturday opening)."""
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ---------------------------------------------------------------------------
|
|
445
|
+
# Domain: indoor air quality (CO2 in a classroom / office)
|
|
446
|
+
# ---------------------------------------------------------------------------
|
|
447
|
+
@dataclass
|
|
448
|
+
class IndoorAirConfig:
|
|
449
|
+
"""CO2 mass balance of a room driven by the activity schedule (occupancy).
|
|
450
|
+
|
|
451
|
+
dC/dt = G * N_max * occupancy(t) / V - lambda(t) * (C - C_out)
|
|
452
|
+
|
|
453
|
+
with ventilation ``lambda`` in air changes per hour, higher when the HVAC
|
|
454
|
+
runs (during the active window) and after a window-opening event.
|
|
455
|
+
"""
|
|
456
|
+
|
|
457
|
+
room_volume_m3: float = 150.0
|
|
458
|
+
max_occupants: float = 25.0
|
|
459
|
+
co2_per_person_l_h: float = 18.0
|
|
460
|
+
"""CO2 generation per person [L/h] (sedentary adult ~ 18 L/h)."""
|
|
461
|
+
|
|
462
|
+
outdoor_ppm: float = 420.0
|
|
463
|
+
ach_base: float = 0.6
|
|
464
|
+
"""Air changes per hour with the HVAC off (infiltration)."""
|
|
465
|
+
|
|
466
|
+
ach_hvac: float = 2.5
|
|
467
|
+
"""Air changes per hour with the HVAC running (active window)."""
|
|
468
|
+
|
|
469
|
+
window_events_per_day: float = 1.0
|
|
470
|
+
"""Poisson rate of window openings during activity; each raises the
|
|
471
|
+
ventilation to ``ach_window`` for ``window_duration_s``."""
|
|
472
|
+
|
|
473
|
+
ach_window: float = 8.0
|
|
474
|
+
window_duration_s: float = 20.0 * 60.0
|
|
475
|
+
|
|
476
|
+
process_noise_ppm: float = 5.0
|
|
477
|
+
|
|
478
|
+
ppm_min: float = 400.0
|
|
479
|
+
ppm_max: float = 2000.0
|
|
480
|
+
"""Range mapped to the normalised value [0, 1]."""
|
|
481
|
+
|
|
482
|
+
warning_ppm: float = 1000.0
|
|
483
|
+
critical_ppm: float = 1500.0
|
|
484
|
+
event_change_ppm: float = 120.0
|
|
485
|
+
|
|
486
|
+
initial_ppm_range: tuple[float, float] = (420.0, 700.0)
|
|
487
|
+
|
|
488
|
+
def quantity(self) -> QuantityConfig:
|
|
489
|
+
span = self.ppm_max - self.ppm_min
|
|
490
|
+
return QuantityConfig(
|
|
491
|
+
name="CO2 concentration",
|
|
492
|
+
unit="ppm",
|
|
493
|
+
physical_min=self.ppm_min,
|
|
494
|
+
physical_max=self.ppm_max,
|
|
495
|
+
warning_threshold=(self.warning_ppm - self.ppm_min) / span,
|
|
496
|
+
critical_threshold=(self.critical_ppm - self.ppm_min) / span,
|
|
497
|
+
critical_is_upper=True,
|
|
498
|
+
event_change_threshold=self.event_change_ppm / span,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@dataclass
|
|
503
|
+
class IndoorLightConfig:
|
|
504
|
+
"""Indoor photovoltaic harvesting.
|
|
505
|
+
|
|
506
|
+
P(t) = cell_power_w_at_ref * illuminance(t) / reference_lux * efficiency
|
|
507
|
+
illuminance(t) = artificial_lux * lights_on(t) + daylight_lux * daylight(t) * weather
|
|
508
|
+
|
|
509
|
+
Lights are on when the activity level is above ``lights_threshold``;
|
|
510
|
+
daylight follows a half-sine day scaled by ``daylight_lux`` (0 for a
|
|
511
|
+
windowless room) and a slowly varying weather factor.
|
|
512
|
+
"""
|
|
513
|
+
|
|
514
|
+
cell_power_w_at_ref: float = 200e-6
|
|
515
|
+
"""Cell output at ``reference_lux`` [W] (e.g. 20 cm2 of amorphous silicon,
|
|
516
|
+
~10 uW/cm2 at 500 lux)."""
|
|
517
|
+
|
|
518
|
+
reference_lux: float = 500.0
|
|
519
|
+
efficiency: float = 0.7
|
|
520
|
+
"""Harvesting-circuit efficiency (boost converter at very low power)."""
|
|
521
|
+
|
|
522
|
+
artificial_lux: float = 500.0
|
|
523
|
+
lights_threshold: float = 0.05
|
|
524
|
+
"""Activity level above which the lights are on."""
|
|
525
|
+
|
|
526
|
+
daylight_lux: float = 150.0
|
|
527
|
+
"""Peak daylight contribution at the cell position (0 = no window)."""
|
|
528
|
+
|
|
529
|
+
sunrise_hour: float = 7.0
|
|
530
|
+
sunset_hour: float = 19.0
|
|
531
|
+
daylight_autocorr: float = 0.9
|
|
532
|
+
daylight_noise_std: float = 0.25
|
|
533
|
+
measurement_noise_std: float = 0.05
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# ---------------------------------------------------------------------------
|
|
537
|
+
# Domain: industrial condition monitoring (bearing temperature of a motor)
|
|
538
|
+
# ---------------------------------------------------------------------------
|
|
539
|
+
@dataclass
|
|
540
|
+
class IndustrialConfig:
|
|
541
|
+
"""Thermal model of a motor bearing driven by the shift schedule (load).
|
|
542
|
+
|
|
543
|
+
C_th dT/dt = P_heat(load, health) - k (T - T_amb)
|
|
544
|
+
P_heat = heat_w_at_full_load * load * (1 + fault_heat_gain * (1 - health))
|
|
545
|
+
|
|
546
|
+
``health`` in (0, 1] degrades slowly while the machine runs; a random
|
|
547
|
+
*fault onset* accelerates the degradation until maintenance (triggered
|
|
548
|
+
some time after the temperature exceeds the critical threshold) restores it.
|
|
549
|
+
"""
|
|
550
|
+
|
|
551
|
+
ambient_c: float = 22.0
|
|
552
|
+
ambient_amplitude_c: float = 3.0
|
|
553
|
+
thermal_time_constant_s: float = 45.0 * 60.0
|
|
554
|
+
"""Time constant of the bearing/casing temperature."""
|
|
555
|
+
|
|
556
|
+
temp_rise_full_load_c: float = 45.0
|
|
557
|
+
"""Steady-state temperature rise above ambient at full load and full health."""
|
|
558
|
+
|
|
559
|
+
fault_heat_gain: float = 1.2
|
|
560
|
+
"""Extra heating at health 0 (relative)."""
|
|
561
|
+
|
|
562
|
+
wear_per_hour: float = 0.002
|
|
563
|
+
"""Health lost per hour of operation (normal wear)."""
|
|
564
|
+
|
|
565
|
+
fault_onsets_per_day: float = 0.15
|
|
566
|
+
"""Poisson rate (per active day) of a fault onset."""
|
|
567
|
+
|
|
568
|
+
fault_wear_per_hour: float = 0.04
|
|
569
|
+
"""Health lost per hour of operation after a fault onset."""
|
|
570
|
+
|
|
571
|
+
maintenance_delay_mean_s: float = 12.0 * HOUR_S
|
|
572
|
+
"""Mean delay of the maintenance intervention after the temperature exceeds
|
|
573
|
+
the critical threshold (exponential); maintenance restores health to 1."""
|
|
574
|
+
|
|
575
|
+
process_noise_c: float = 0.3
|
|
576
|
+
|
|
577
|
+
temp_min_c: float = 20.0
|
|
578
|
+
temp_max_c: float = 120.0
|
|
579
|
+
warning_c: float = 70.0
|
|
580
|
+
critical_c: float = 90.0
|
|
581
|
+
event_change_c: float = 5.0
|
|
582
|
+
|
|
583
|
+
initial_health_range: tuple[float, float] = (0.6, 1.0)
|
|
584
|
+
|
|
585
|
+
def quantity(self) -> QuantityConfig:
|
|
586
|
+
span = self.temp_max_c - self.temp_min_c
|
|
587
|
+
return QuantityConfig(
|
|
588
|
+
name="bearing temperature",
|
|
589
|
+
unit="degC",
|
|
590
|
+
physical_min=self.temp_min_c,
|
|
591
|
+
physical_max=self.temp_max_c,
|
|
592
|
+
warning_threshold=(self.warning_c - self.temp_min_c) / span,
|
|
593
|
+
critical_threshold=(self.critical_c - self.temp_min_c) / span,
|
|
594
|
+
critical_is_upper=True,
|
|
595
|
+
event_change_threshold=self.event_change_c / span,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
@dataclass
|
|
600
|
+
class ThermoelectricConfig:
|
|
601
|
+
"""Thermoelectric (TEG) harvesting from the warm casing.
|
|
602
|
+
|
|
603
|
+
P(t) = power_w_at_ref_dt * (dT(t) / reference_dt_c)^2 * efficiency
|
|
604
|
+
dT(t) = casing temperature - ambient
|
|
605
|
+
|
|
606
|
+
(open-circuit voltage proportional to dT, maximum power quadratic in dT).
|
|
607
|
+
"""
|
|
608
|
+
|
|
609
|
+
power_w_at_ref_dt: float = 1.2e-3
|
|
610
|
+
"""Electrical power at ``reference_dt_c`` [W] (a 30x30 mm module with a
|
|
611
|
+
small heat sink at 30 K gives one to a few mW)."""
|
|
612
|
+
|
|
613
|
+
reference_dt_c: float = 30.0
|
|
614
|
+
efficiency: float = 0.6
|
|
615
|
+
"""Boost-converter / MPPT efficiency."""
|
|
616
|
+
|
|
617
|
+
min_dt_c: float = 3.0
|
|
618
|
+
"""Below this temperature difference the converter does not start."""
|
|
619
|
+
|
|
620
|
+
measurement_noise_std: float = 0.05
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
# ---------------------------------------------------------------------------
|
|
624
|
+
# Radio presets
|
|
625
|
+
# ---------------------------------------------------------------------------
|
|
626
|
+
BLE_RADIO_MODES: tuple[RadioModeConfig, ...] = (
|
|
627
|
+
RadioModeConfig("fast", energy_j=0.0006, tx_power_dbm=0.0, sensitivity_dbm=-92.0), # LE 2M PHY-like
|
|
628
|
+
RadioModeConfig("standard", energy_j=0.0012, tx_power_dbm=0.0, sensitivity_dbm=-97.0), # LE 1M PHY-like
|
|
629
|
+
RadioModeConfig("robust", energy_j=0.0040, tx_power_dbm=0.0, sensitivity_dbm=-103.0), # LE Coded S8-like
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def ble_radio(path_loss_mean_db: float = 93.0) -> CommunicationConfig:
|
|
634
|
+
"""A short-range BLE-like radio (three PHY modes, sub-millijoule uplinks).
|
|
635
|
+
With the default path loss the mean margins are -1 / +4 / +10 dB."""
|
|
636
|
+
return CommunicationConfig(
|
|
637
|
+
modes=BLE_RADIO_MODES,
|
|
638
|
+
reference_mode=1,
|
|
639
|
+
path_loss_mean_db=path_loss_mean_db,
|
|
640
|
+
slow_fading_std_db=4.0,
|
|
641
|
+
slow_fading_autocorr=0.9,
|
|
642
|
+
fast_fading_std_db=3.0,
|
|
643
|
+
margin_scale_db=1.5,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
# ---------------------------------------------------------------------------
|
|
648
|
+
# Remote application (utility + priority)
|
|
649
|
+
# ---------------------------------------------------------------------------
|
|
650
|
+
@dataclass
|
|
651
|
+
class ApplicationConfig:
|
|
652
|
+
"""Information utility and interest model of the remote application.
|
|
653
|
+
|
|
654
|
+
See ``application.py`` for the formulas.
|
|
655
|
+
"""
|
|
656
|
+
|
|
657
|
+
tracking_weight: float = 0.10
|
|
658
|
+
"""Per-step weight of the tracking utility (max 0.1 * (1 + criticality_gain)
|
|
659
|
+
per step when the application's picture is perfect)."""
|
|
660
|
+
|
|
661
|
+
error_scale: float = 0.05
|
|
662
|
+
"""Moisture error at which the accuracy factor exp(-err/scale) drops to 1/e."""
|
|
663
|
+
|
|
664
|
+
tau_freshness_s: float = 2.0 * HOUR_S
|
|
665
|
+
"""Time constant discounting the packet bonus with the age of the
|
|
666
|
+
measurement it carries (sending old samples is worth less)."""
|
|
667
|
+
|
|
668
|
+
gain_scale: float = 0.05
|
|
669
|
+
"""Reduction of the application's estimation error giving tanh(1) ~ 76%
|
|
670
|
+
of the gain bonus (kept large w.r.t. sensor noise so that re-sampling noise
|
|
671
|
+
adds little reward variance)."""
|
|
672
|
+
|
|
673
|
+
w_gain: float = 0.30
|
|
674
|
+
"""Weight of the (signed) estimation-error gain bonus per delivered packet."""
|
|
675
|
+
|
|
676
|
+
w_event: float = 0.50
|
|
677
|
+
"""Weight of the bonus for reporting an environmental event the
|
|
678
|
+
application does not know about yet."""
|
|
679
|
+
|
|
680
|
+
criticality_gain: float = 2.0
|
|
681
|
+
"""Extra weight of information when the true moisture is at a threshold."""
|
|
682
|
+
|
|
683
|
+
criticality_scale: float = 0.08
|
|
684
|
+
"""Moisture distance to the nearest threshold over which the extra
|
|
685
|
+
weight decays."""
|
|
686
|
+
|
|
687
|
+
event_memory_s: float = 3.0 * HOUR_S
|
|
688
|
+
"""An environmental event remains 'unreported' (and worth reporting) for
|
|
689
|
+
at most this long."""
|
|
690
|
+
|
|
691
|
+
aoi_elevated_s: float = 8.0 * HOUR_S
|
|
692
|
+
"""Age of information after which the application raises priority to 1."""
|
|
693
|
+
|
|
694
|
+
aoi_urgent_s: float = 24.0 * HOUR_S
|
|
695
|
+
"""Age of information after which the application raises priority to 2."""
|
|
696
|
+
|
|
697
|
+
request_rate_per_day: float = 0.2
|
|
698
|
+
"""Rate of external 'high-resolution monitoring' requests (Poisson)."""
|
|
699
|
+
|
|
700
|
+
request_duration_range_s: tuple[float, float] = (2.0 * HOUR_S, 6.0 * HOUR_S)
|
|
701
|
+
request_urgent_fraction: float = 0.3
|
|
702
|
+
"""Fraction of external requests that are urgent (priority 2) instead of
|
|
703
|
+
elevated (priority 1)."""
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
# ---------------------------------------------------------------------------
|
|
707
|
+
# Reward shaping
|
|
708
|
+
# ---------------------------------------------------------------------------
|
|
709
|
+
@dataclass
|
|
710
|
+
class RewardConfig:
|
|
711
|
+
"""Weights of the reward components (all dimensionless).
|
|
712
|
+
|
|
713
|
+
reward = utility
|
|
714
|
+
- lambda_sense * E_sense / energy_ref_j
|
|
715
|
+
- lambda_tx * E_tx / energy_ref_j
|
|
716
|
+
- lambda_stale * w_priority * min(AoI / tau_stale_s, 1)
|
|
717
|
+
- lambda_battery * battery_risk
|
|
718
|
+
- lambda_depletion * [battery depleted]
|
|
719
|
+
- lambda_reject * [action rejected for lack of energy]
|
|
720
|
+
- lambda_waste * wasted_harvest / energy_ref_j
|
|
721
|
+
"""
|
|
722
|
+
|
|
723
|
+
lambda_sense: float = 0.10
|
|
724
|
+
lambda_tx: float = 0.10
|
|
725
|
+
lambda_stale: float = 0.05
|
|
726
|
+
lambda_battery: float = 0.50
|
|
727
|
+
lambda_depletion: float = 2.0
|
|
728
|
+
lambda_reject: float = 0.20
|
|
729
|
+
lambda_waste: float = 0.02
|
|
730
|
+
|
|
731
|
+
energy_ref_j: float = 1.0
|
|
732
|
+
"""Energy normalisation constant [J] (about one transmission)."""
|
|
733
|
+
|
|
734
|
+
tau_stale_s: float = 6.0 * HOUR_S
|
|
735
|
+
priority_weights: tuple[float, float, float] = (1.0, 2.0, 4.0)
|
|
736
|
+
"""Multiplier of the staleness penalty per application priority."""
|
|
737
|
+
|
|
738
|
+
safe_soc: float = 0.30
|
|
739
|
+
"""Below this SoC a quadratic battery-risk penalty is applied."""
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
# ---------------------------------------------------------------------------
|
|
743
|
+
# Observation normalisation
|
|
744
|
+
# ---------------------------------------------------------------------------
|
|
745
|
+
@dataclass
|
|
746
|
+
class ObservationConfig:
|
|
747
|
+
"""Constants used by the ObservationBuilder (shared with deployment)."""
|
|
748
|
+
|
|
749
|
+
harvest_ref_power_w: float = 0.003
|
|
750
|
+
"""Power that maps to 1.0 in the harvesting observations."""
|
|
751
|
+
|
|
752
|
+
age_scale_s: float = 24.0 * HOUR_S
|
|
753
|
+
"""Time that maps to 1.0 in the age / time-since observations."""
|
|
754
|
+
|
|
755
|
+
harvest_ewma_alpha: float = 0.2
|
|
756
|
+
"""Weight of the newest sample in the recent-harvest EWMA."""
|
|
757
|
+
|
|
758
|
+
link_ewma_alpha: float = 0.2
|
|
759
|
+
"""Weight of the newest ACK outcome in the link-quality EWMA."""
|
|
760
|
+
|
|
761
|
+
importance_scale: float = 0.10
|
|
762
|
+
"""Distance (moisture units) to the nearest threshold over which the
|
|
763
|
+
node-side importance indicator decays."""
|
|
764
|
+
|
|
765
|
+
path_loss_min_db: float = 110.0
|
|
766
|
+
path_loss_max_db: float = 170.0
|
|
767
|
+
"""Range mapped to [0, 1] in the path-loss observation (1 = unknown / worst)."""
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
# ---------------------------------------------------------------------------
|
|
771
|
+
# Domain randomisation
|
|
772
|
+
# ---------------------------------------------------------------------------
|
|
773
|
+
@dataclass
|
|
774
|
+
class DomainRandomizationConfig:
|
|
775
|
+
"""Multiplicative ranges applied to physical parameters at every reset.
|
|
776
|
+
|
|
777
|
+
Each entry is ``(low, high)``: the nominal value is multiplied by a factor
|
|
778
|
+
drawn uniformly in that interval. ``enabled=False`` disables everything.
|
|
779
|
+
"""
|
|
780
|
+
|
|
781
|
+
enabled: bool = False
|
|
782
|
+
sensor_noise: tuple[float, float] = (0.7, 1.5)
|
|
783
|
+
sensing_energy: tuple[float, float] = (0.8, 1.3)
|
|
784
|
+
tx_energy: tuple[float, float] = (0.8, 1.3)
|
|
785
|
+
path_loss_db: tuple[float, float] = (-3.0, 3.0)
|
|
786
|
+
"""Additive offset [dB] on the mean path loss (this one is additive, not multiplicative)."""
|
|
787
|
+
solar_intensity: tuple[float, float] = (0.6, 1.2)
|
|
788
|
+
cloud_variability: tuple[float, float] = (0.5, 1.5)
|
|
789
|
+
battery_capacity: tuple[float, float] = (0.8, 1.2)
|
|
790
|
+
baseline_power: tuple[float, float] = (0.7, 1.5)
|
|
791
|
+
random_start_weekday: bool = True
|
|
792
|
+
"""Domains with a weekly activity schedule only: draw the weekday of episode
|
|
793
|
+
day 0 uniformly at every reset, so that a policy also sees weeks that start
|
|
794
|
+
on a Saturday (no activity, no energy for two days). No effect on the
|
|
795
|
+
agriculture domain."""
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
# ---------------------------------------------------------------------------
|
|
799
|
+
# Top level
|
|
800
|
+
# ---------------------------------------------------------------------------
|
|
801
|
+
@dataclass
|
|
802
|
+
class EdgeEngineAwareConfig:
|
|
803
|
+
"""Complete configuration of an EdgeEngine AWARE environment."""
|
|
804
|
+
|
|
805
|
+
time: TimeConfig = field(default_factory=TimeConfig)
|
|
806
|
+
storage: EnergyStorageConfig = field(default_factory=EnergyStorageConfig)
|
|
807
|
+
mcu: MCUConfig = field(default_factory=MCUConfig)
|
|
808
|
+
harvesting: HarvestingConfig = field(default_factory=HarvestingConfig)
|
|
809
|
+
sensing: SensingConfig = field(default_factory=SensingConfig)
|
|
810
|
+
communication: CommunicationConfig = field(default_factory=CommunicationConfig)
|
|
811
|
+
agriculture: AgricultureConfig = field(default_factory=AgricultureConfig)
|
|
812
|
+
application: ApplicationConfig = field(default_factory=ApplicationConfig)
|
|
813
|
+
reward: RewardConfig = field(default_factory=RewardConfig)
|
|
814
|
+
observation: ObservationConfig = field(default_factory=ObservationConfig)
|
|
815
|
+
randomization: DomainRandomizationConfig = field(default_factory=DomainRandomizationConfig)
|
|
816
|
+
|
|
817
|
+
domain: str = "agriculture"
|
|
818
|
+
"""Which monitored process drives the episode: 'agriculture' (soil moisture,
|
|
819
|
+
``agriculture``), 'indoor_air' (CO2, ``indoor_air`` + ``schedule``) or
|
|
820
|
+
'industrial' (bearing temperature, ``industrial`` + ``schedule``)."""
|
|
821
|
+
|
|
822
|
+
harvesting_source: str = "solar"
|
|
823
|
+
"""Energy source: 'solar' (``harvesting``), 'indoor_light' (``indoor_light``)
|
|
824
|
+
or 'thermoelectric' (``thermoelectric``)."""
|
|
825
|
+
|
|
826
|
+
schedule: ScheduleConfig = field(default_factory=ScheduleConfig)
|
|
827
|
+
indoor_air: IndoorAirConfig = field(default_factory=IndoorAirConfig)
|
|
828
|
+
indoor_light: IndoorLightConfig = field(default_factory=IndoorLightConfig)
|
|
829
|
+
industrial: IndustrialConfig = field(default_factory=IndustrialConfig)
|
|
830
|
+
thermoelectric: ThermoelectricConfig = field(default_factory=ThermoelectricConfig)
|
|
831
|
+
|
|
832
|
+
terminate_on_depletion: bool = False
|
|
833
|
+
"""If True the episode *terminates* when the storage is fully depleted.
|
|
834
|
+
Default False: the node browns out, pays a penalty and keeps running once
|
|
835
|
+
energy is harvested again (closer to what happens in the field)."""
|
|
836
|
+
|
|
837
|
+
@property
|
|
838
|
+
def quantity(self) -> QuantityConfig:
|
|
839
|
+
"""Semantics of the monitored scalar for the active domain."""
|
|
840
|
+
if self.domain == "agriculture":
|
|
841
|
+
return self.agriculture.quantity()
|
|
842
|
+
if self.domain == "indoor_air":
|
|
843
|
+
return self.indoor_air.quantity()
|
|
844
|
+
if self.domain == "industrial":
|
|
845
|
+
return self.industrial.quantity()
|
|
846
|
+
raise ValueError(f"unknown domain {self.domain!r}")
|
|
847
|
+
|
|
848
|
+
@property
|
|
849
|
+
def uses_schedule(self) -> bool:
|
|
850
|
+
return self.domain in ("indoor_air", "industrial") or self.harvesting_source in ("indoor_light", "thermoelectric")
|
|
851
|
+
|
|
852
|
+
def copy(self) -> "EdgeEngineAwareConfig":
|
|
853
|
+
"""Deep copy (configs are small; ``dataclasses.replace`` is shallow)."""
|
|
854
|
+
import copy as _copy
|
|
855
|
+
|
|
856
|
+
return _copy.deepcopy(self)
|
|
857
|
+
|
|
858
|
+
def to_dict(self) -> dict[str, Any]:
|
|
859
|
+
return dataclasses.asdict(self)
|
|
860
|
+
|
|
861
|
+
def validate(self) -> None:
|
|
862
|
+
"""Raise ``ValueError`` on obviously inconsistent settings."""
|
|
863
|
+
if self.time.timestep_s <= 0 or self.time.episode_days <= 0:
|
|
864
|
+
raise ValueError("timestep_s and episode_days must be positive")
|
|
865
|
+
if self.storage.capacity_j <= 0:
|
|
866
|
+
raise ValueError("capacity_j must be positive")
|
|
867
|
+
if not 0.0 <= self.storage.reserve_soc < 1.0:
|
|
868
|
+
raise ValueError("reserve_soc must be in [0, 1)")
|
|
869
|
+
if not (len(self.sensing.energy_j) == len(self.sensing.noise_std) == len(self.sensing.bias) == 3):
|
|
870
|
+
raise ValueError("sensing energy_j, noise_std and bias must have 3 entries (none / low / high)")
|
|
871
|
+
if not 0.0 <= self.time.start_hour < 24.0:
|
|
872
|
+
raise ValueError("start_hour must be in [0, 24)")
|
|
873
|
+
if not 0.0 < self.storage.charge_efficiency <= 1.0:
|
|
874
|
+
raise ValueError("charge_efficiency must be in (0, 1]")
|
|
875
|
+
if self.communication.margin_scale_db <= 0:
|
|
876
|
+
raise ValueError("margin_scale_db must be positive")
|
|
877
|
+
if self.sensing.energy_j[0] != 0.0:
|
|
878
|
+
raise ValueError("sensing level 0 (no sensing) must have zero energy cost")
|
|
879
|
+
if self.communication.n_modes < 1:
|
|
880
|
+
raise ValueError("at least one radio mode is required")
|
|
881
|
+
if not 0 <= self.communication.reference_mode < self.communication.n_modes:
|
|
882
|
+
raise ValueError("reference_mode must index an existing radio mode")
|
|
883
|
+
if any(m.energy_j <= 0 for m in self.communication.modes):
|
|
884
|
+
raise ValueError("radio mode energies must be positive")
|
|
885
|
+
if self.communication.priority_update_mode not in ("immediate", "on_uplink"):
|
|
886
|
+
raise ValueError("priority_update_mode must be 'immediate' or 'on_uplink'")
|
|
887
|
+
if self.domain not in ("agriculture", "indoor_air", "industrial"):
|
|
888
|
+
raise ValueError("domain must be 'agriculture', 'indoor_air' or 'industrial'")
|
|
889
|
+
if self.harvesting_source not in ("solar", "indoor_light", "thermoelectric"):
|
|
890
|
+
raise ValueError("harvesting_source must be 'solar', 'indoor_light' or 'thermoelectric'")
|
|
891
|
+
if self.harvesting_source == "thermoelectric" and self.domain != "industrial":
|
|
892
|
+
raise ValueError("the thermoelectric source needs the industrial process (it harvests the casing heat)")
|
|
893
|
+
self.quantity.validate()
|
|
894
|
+
if not 0.0 < self.harvesting.efficiency <= 1.0:
|
|
895
|
+
raise ValueError("harvesting efficiency must be in (0, 1]")
|
|
896
|
+
if not 0 <= self.time.start_weekday < 7:
|
|
897
|
+
raise ValueError("start_weekday must be in [0, 7)")
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def default_config() -> EdgeEngineAwareConfig:
|
|
901
|
+
"""Return a fresh configuration with all default values."""
|
|
902
|
+
return EdgeEngineAwareConfig()
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def randomize_config(base: EdgeEngineAwareConfig, rng) -> EdgeEngineAwareConfig:
|
|
906
|
+
"""Return a copy of ``base`` with physical parameters perturbed according
|
|
907
|
+
to ``base.randomization`` (simple multiplicative domain randomisation).
|
|
908
|
+
|
|
909
|
+
The returned configuration is what the *node* experiences during the
|
|
910
|
+
episode: e.g. the (randomised) energy costs are the ones the node reports
|
|
911
|
+
in its observation, exactly like a real device would report its own
|
|
912
|
+
measured hardware profile.
|
|
913
|
+
"""
|
|
914
|
+
cfg = base.copy()
|
|
915
|
+
dr = cfg.randomization
|
|
916
|
+
if not dr.enabled:
|
|
917
|
+
return cfg
|
|
918
|
+
|
|
919
|
+
def f(rng_range: tuple[float, float]) -> float:
|
|
920
|
+
return float(rng.uniform(rng_range[0], rng_range[1]))
|
|
921
|
+
|
|
922
|
+
k = f(dr.sensor_noise)
|
|
923
|
+
cfg.sensing.noise_std = tuple(s * k for s in cfg.sensing.noise_std) # type: ignore[assignment]
|
|
924
|
+
k = f(dr.sensing_energy)
|
|
925
|
+
cfg.sensing.energy_j = tuple(e * k for e in cfg.sensing.energy_j) # type: ignore[assignment]
|
|
926
|
+
k = f(dr.tx_energy)
|
|
927
|
+
cfg.communication.modes = tuple(dataclasses.replace(m, energy_j=m.energy_j * k) for m in cfg.communication.modes)
|
|
928
|
+
cfg.communication.path_loss_mean_db += f(dr.path_loss_db)
|
|
929
|
+
k = f(dr.solar_intensity) # 'source intensity': panel, indoor cell or TEG output
|
|
930
|
+
cfg.harvesting.max_power_w *= k
|
|
931
|
+
cfg.indoor_light.cell_power_w_at_ref *= k
|
|
932
|
+
cfg.thermoelectric.power_w_at_ref_dt *= k
|
|
933
|
+
k = f(dr.cloud_variability)
|
|
934
|
+
cfg.harvesting.cloud_noise_std *= k
|
|
935
|
+
cfg.harvesting.clearness_std *= k
|
|
936
|
+
cfg.indoor_light.daylight_noise_std *= k
|
|
937
|
+
cfg.schedule.noise_std *= k
|
|
938
|
+
cfg.storage.capacity_j *= f(dr.battery_capacity)
|
|
939
|
+
cfg.mcu.baseline_power_w *= f(dr.baseline_power)
|
|
940
|
+
if dr.random_start_weekday and cfg.uses_schedule: # drawn last: the agriculture stream is unchanged
|
|
941
|
+
cfg.time.start_weekday = int(rng.integers(0, 7))
|
|
942
|
+
return cfg
|