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,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