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,490 @@
|
|
|
1
|
+
"""Node-side state tracking and policy-observation construction.
|
|
2
|
+
|
|
3
|
+
This module is the heart of the simulation-to-real design. It contains:
|
|
4
|
+
|
|
5
|
+
* :class:`NodeProfile` - the constants a firmware would keep in flash
|
|
6
|
+
(hardware energy profile, application thresholds, normalisation constants);
|
|
7
|
+
* :class:`NodeState` - the *hardware-measurable state*: every field can be
|
|
8
|
+
obtained on a microcontroller from a fuel gauge, an ADC, a timer, the sensor
|
|
9
|
+
driver, the radio ACKs and the last downlink;
|
|
10
|
+
* :class:`NodeStateTracker` - bookkeeping that turns raw events (a new
|
|
11
|
+
measurement, an ACK, a downlink) into a :class:`NodeState`;
|
|
12
|
+
* :class:`ObservationBuilder` - the *only* place where a :class:`NodeState` is
|
|
13
|
+
turned into the float32 policy observation vector.
|
|
14
|
+
|
|
15
|
+
The Gymnasium environment (``env.py``) and the deployment runtime
|
|
16
|
+
(``deployment.py``) both use exactly these classes, so the policy sees the
|
|
17
|
+
same quantities, normalised the same way, in simulation and on hardware.
|
|
18
|
+
Nothing in this module has access to simulator ground truth.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import math
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Iterable
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
from .config import EdgeEngineAwareConfig, ObservationConfig
|
|
30
|
+
from .interfaces import Measurement, Packet
|
|
31
|
+
|
|
32
|
+
PRIORITY_ROUTINE, PRIORITY_ELEVATED, PRIORITY_URGENT = 0, 1, 2
|
|
33
|
+
N_PRIORITY_LEVELS = 3
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Constants stored on the node
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class NodeProfile:
|
|
41
|
+
"""Hardware/application constants known to the node (flash constants)."""
|
|
42
|
+
|
|
43
|
+
sensing_energy_j: tuple[float, ...]
|
|
44
|
+
"""Energy per sensing level [J]; index 0 is 'no sensing' (0 J)."""
|
|
45
|
+
|
|
46
|
+
sensing_noise_std: tuple[float, ...]
|
|
47
|
+
"""Nominal noise per sensing level (quality tags)."""
|
|
48
|
+
|
|
49
|
+
tx_energy_j: tuple[float, ...]
|
|
50
|
+
"""Energy per radio mode [J] (index = mode)."""
|
|
51
|
+
|
|
52
|
+
tx_power_dbm: tuple[float, ...]
|
|
53
|
+
sensitivity_dbm: tuple[float, ...]
|
|
54
|
+
"""Link-budget constants per radio mode, used to turn a measured ACK
|
|
55
|
+
margin into a mode-independent path-loss estimate."""
|
|
56
|
+
|
|
57
|
+
reference_mode: int
|
|
58
|
+
"""Mode whose energy is reported in the observation."""
|
|
59
|
+
|
|
60
|
+
warning_threshold: float
|
|
61
|
+
critical_threshold: float
|
|
62
|
+
timestep_s: float = 900.0
|
|
63
|
+
"""Decision period [s] (wake-up interval of the firmware)."""
|
|
64
|
+
|
|
65
|
+
baseline_power_w: float = 200e-6
|
|
66
|
+
"""Always-on consumption used by the feasibility rule."""
|
|
67
|
+
|
|
68
|
+
reserve_soc: float = 0.02
|
|
69
|
+
"""Brown-out reserve used by the feasibility rule."""
|
|
70
|
+
|
|
71
|
+
ack_available: bool = True
|
|
72
|
+
"""Whether uplinks are confirmed (see CommunicationConfig.ack_available)."""
|
|
73
|
+
|
|
74
|
+
critical_is_upper: bool = False
|
|
75
|
+
"""Side of the danger: False = the monitored quantity is critical when *low*
|
|
76
|
+
(soil moisture), True = when *high* (CO2, temperature). Read by the
|
|
77
|
+
importance indicator; the thresholds are in normalised units."""
|
|
78
|
+
|
|
79
|
+
observation: ObservationConfig = field(default_factory=ObservationConfig)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_config(cls, cfg: EdgeEngineAwareConfig) -> "NodeProfile":
|
|
83
|
+
return cls(
|
|
84
|
+
sensing_energy_j=tuple(cfg.sensing.energy_j),
|
|
85
|
+
sensing_noise_std=tuple(cfg.sensing.noise_std),
|
|
86
|
+
tx_energy_j=tuple(m.energy_j for m in cfg.communication.modes),
|
|
87
|
+
tx_power_dbm=tuple(m.tx_power_dbm for m in cfg.communication.modes),
|
|
88
|
+
sensitivity_dbm=tuple(m.sensitivity_dbm for m in cfg.communication.modes),
|
|
89
|
+
reference_mode=cfg.communication.reference_mode,
|
|
90
|
+
warning_threshold=cfg.quantity.warning_threshold,
|
|
91
|
+
critical_threshold=cfg.quantity.critical_threshold,
|
|
92
|
+
timestep_s=cfg.time.timestep_s,
|
|
93
|
+
baseline_power_w=cfg.mcu.baseline_power_w,
|
|
94
|
+
reserve_soc=cfg.storage.reserve_soc,
|
|
95
|
+
ack_available=cfg.communication.ack_available,
|
|
96
|
+
critical_is_upper=cfg.quantity.critical_is_upper,
|
|
97
|
+
observation=cfg.observation,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def baseline_energy_j(self) -> float:
|
|
102
|
+
"""Baseline energy of one decision interval [J]."""
|
|
103
|
+
return self.baseline_power_w * self.timestep_s
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def n_modes(self) -> int:
|
|
107
|
+
return len(self.tx_energy_j)
|
|
108
|
+
|
|
109
|
+
def path_loss_from_margin(self, mode: int, margin_db: float) -> float:
|
|
110
|
+
"""Path loss implied by a margin measured with ``mode`` [dB]."""
|
|
111
|
+
return self.tx_power_dbm[mode] - self.sensitivity_dbm[mode] - margin_db
|
|
112
|
+
|
|
113
|
+
def margin_for_mode(self, mode: int, path_loss_db: float) -> float:
|
|
114
|
+
"""Expected margin of ``mode`` for a given path-loss estimate [dB]."""
|
|
115
|
+
return self.tx_power_dbm[mode] - path_loss_db - self.sensitivity_dbm[mode]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Hardware-measurable state
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
@dataclass
|
|
122
|
+
class NodeState:
|
|
123
|
+
"""Everything the node knows about itself at decision time.
|
|
124
|
+
|
|
125
|
+
All quantities are in physical units; normalisation happens in
|
|
126
|
+
:class:`ObservationBuilder`. The docstring of each field names the
|
|
127
|
+
hardware source that would provide it on a real device.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
time_of_day_s: float
|
|
131
|
+
"""Seconds since midnight (RTC)."""
|
|
132
|
+
|
|
133
|
+
stored_energy_j: float
|
|
134
|
+
"""Stored energy (fuel gauge / voltage-based estimate)."""
|
|
135
|
+
|
|
136
|
+
capacity_j: float
|
|
137
|
+
"""Usable capacity (flash constant, or fuel-gauge full-charge value)."""
|
|
138
|
+
|
|
139
|
+
harvest_power_w: float
|
|
140
|
+
"""Average harvesting power over the interval that just elapsed, as
|
|
141
|
+
integrated by the harvester monitor since the previous wake-up."""
|
|
142
|
+
|
|
143
|
+
harvest_power_recent_w: float
|
|
144
|
+
"""EWMA of the measured harvesting power (computed in firmware)."""
|
|
145
|
+
|
|
146
|
+
has_measurement: bool
|
|
147
|
+
"""Whether a measurement is stored in RAM."""
|
|
148
|
+
|
|
149
|
+
measurement_value: float
|
|
150
|
+
"""Latest stored measurement (0 when none)."""
|
|
151
|
+
|
|
152
|
+
measurement_noise_std: float
|
|
153
|
+
"""Quality tag of the latest stored measurement (from the profile)."""
|
|
154
|
+
|
|
155
|
+
measurement_age_s: float
|
|
156
|
+
"""now - timestamp of the latest measurement (timer)."""
|
|
157
|
+
|
|
158
|
+
has_reported: bool
|
|
159
|
+
"""Whether at least one uplink has been acknowledged."""
|
|
160
|
+
|
|
161
|
+
time_since_tx_success_s: float
|
|
162
|
+
"""now - time of the last acknowledged uplink (timer)."""
|
|
163
|
+
|
|
164
|
+
app_info_age_s: float
|
|
165
|
+
"""Node-side estimate of the age of information at the application:
|
|
166
|
+
time since last ACK + age of the measurement that was in that packet.
|
|
167
|
+
Exact when ACKs are available; with unconfirmed uplinks the node assumes
|
|
168
|
+
delivery, so the estimate is optimistic (see
|
|
169
|
+
``CommunicationConfig.ack_available``)."""
|
|
170
|
+
|
|
171
|
+
reported_value: float
|
|
172
|
+
"""Value contained in the last acknowledged uplink (0 when none)."""
|
|
173
|
+
|
|
174
|
+
app_priority: int
|
|
175
|
+
"""Latest priority received from the application (downlink), 0/1/2."""
|
|
176
|
+
|
|
177
|
+
link_quality: float
|
|
178
|
+
"""EWMA of ACK outcomes in [0, 1] (1 = every recent uplink delivered)."""
|
|
179
|
+
|
|
180
|
+
has_link_estimate: bool
|
|
181
|
+
"""Whether at least one ACK carried a usable margin measurement."""
|
|
182
|
+
|
|
183
|
+
path_loss_est_db: float
|
|
184
|
+
"""Path loss implied by the margin of the last ACK, converted with the
|
|
185
|
+
profile's link-budget constants (mode independent)."""
|
|
186
|
+
|
|
187
|
+
sensing_energy_low_j: float
|
|
188
|
+
sensing_energy_high_j: float
|
|
189
|
+
tx_energy_j: float
|
|
190
|
+
"""Hardware energy profile (flash constants or on-line measurements);
|
|
191
|
+
``tx_energy_j`` is the energy of the reference radio mode."""
|
|
192
|
+
|
|
193
|
+
def soc(self) -> float:
|
|
194
|
+
return 0.0 if self.capacity_j <= 0 else self.stored_energy_j / self.capacity_j
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# Tracker: raw events -> NodeState
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
class NodeStateTracker:
|
|
201
|
+
"""Firmware-style bookkeeping shared by simulation and deployment.
|
|
202
|
+
|
|
203
|
+
Usage per decision step::
|
|
204
|
+
|
|
205
|
+
tracker.begin_step(now_s, time_of_day_s, energy_j, capacity_j,
|
|
206
|
+
harvest_power_w, priority)
|
|
207
|
+
state = tracker.state() # -> policy observation
|
|
208
|
+
... execute the action ...
|
|
209
|
+
tracker.on_measurement(measurement) # if sensing happened
|
|
210
|
+
tracker.on_transmission(packet, acked, now_s, mode, margin_db) # if a tx happened
|
|
211
|
+
# (pass the mode used and the ACK margin, or the path-loss estimate cannot be updated correctly)
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
def __init__(self, profile: NodeProfile):
|
|
215
|
+
self.profile = profile
|
|
216
|
+
self.reset()
|
|
217
|
+
|
|
218
|
+
# -- lifecycle ----------------------------------------------------------
|
|
219
|
+
def reset(self) -> None:
|
|
220
|
+
self._now_s = 0.0
|
|
221
|
+
self._time_of_day_s = 0.0
|
|
222
|
+
self._energy_j = 0.0
|
|
223
|
+
self._capacity_j = 1.0
|
|
224
|
+
self._harvest_w = 0.0
|
|
225
|
+
self._harvest_recent_w = 0.0
|
|
226
|
+
self._harvest_initialised = False
|
|
227
|
+
self._measurement: Measurement | None = None
|
|
228
|
+
self._last_ack_time_s: float | None = None
|
|
229
|
+
self._last_ack_packet: Packet | None = None
|
|
230
|
+
self._priority = PRIORITY_ROUTINE
|
|
231
|
+
self._link_quality = 1.0
|
|
232
|
+
self._path_loss_est_db: float | None = None
|
|
233
|
+
|
|
234
|
+
def begin_step(
|
|
235
|
+
self,
|
|
236
|
+
now_s: float,
|
|
237
|
+
time_of_day_s: float,
|
|
238
|
+
energy_j: float,
|
|
239
|
+
capacity_j: float,
|
|
240
|
+
harvest_power_w: float,
|
|
241
|
+
priority: int | None,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Update the periodically sampled quantities.
|
|
244
|
+
|
|
245
|
+
``priority=None`` means 'no new downlink received' (keep the old one).
|
|
246
|
+
"""
|
|
247
|
+
self._now_s = now_s
|
|
248
|
+
self._time_of_day_s = time_of_day_s
|
|
249
|
+
self._energy_j = energy_j
|
|
250
|
+
self._capacity_j = capacity_j
|
|
251
|
+
self._harvest_w = max(0.0, harvest_power_w)
|
|
252
|
+
alpha = self.profile.observation.harvest_ewma_alpha
|
|
253
|
+
if not self._harvest_initialised:
|
|
254
|
+
self._harvest_recent_w = self._harvest_w
|
|
255
|
+
self._harvest_initialised = True
|
|
256
|
+
else:
|
|
257
|
+
self._harvest_recent_w = (1 - alpha) * self._harvest_recent_w + alpha * self._harvest_w
|
|
258
|
+
if priority is not None:
|
|
259
|
+
self._priority = int(priority)
|
|
260
|
+
|
|
261
|
+
def on_measurement(self, measurement: Measurement) -> None:
|
|
262
|
+
self._measurement = measurement
|
|
263
|
+
|
|
264
|
+
def on_transmission(self, packet: Packet, acked: bool | None, now_s: float, mode: int = 0, margin_db: float | None = None) -> None:
|
|
265
|
+
"""Register an uplink attempt made with radio ``mode``.
|
|
266
|
+
|
|
267
|
+
``acked`` is the ACK outcome, or ``None`` when the link gives no
|
|
268
|
+
confirmation (``profile.ack_available`` is False): the node then
|
|
269
|
+
assumes delivery for its age bookkeeping and leaves the link-quality
|
|
270
|
+
indicator untouched. ``margin_db`` is the link margin measured from the
|
|
271
|
+
ACK (if any); it is converted into a mode-independent path-loss estimate.
|
|
272
|
+
"""
|
|
273
|
+
if acked is None or not self.profile.ack_available:
|
|
274
|
+
self._last_ack_time_s = now_s
|
|
275
|
+
self._last_ack_packet = packet
|
|
276
|
+
return
|
|
277
|
+
alpha = self.profile.observation.link_ewma_alpha
|
|
278
|
+
self._link_quality = (1 - alpha) * self._link_quality + alpha * (1.0 if acked else 0.0)
|
|
279
|
+
if acked:
|
|
280
|
+
self._last_ack_time_s = now_s
|
|
281
|
+
self._last_ack_packet = packet
|
|
282
|
+
if margin_db is not None:
|
|
283
|
+
self._path_loss_est_db = self.profile.path_loss_from_margin(mode, margin_db)
|
|
284
|
+
else:
|
|
285
|
+
# A lost uplink in ``mode`` says the margin of that mode was about
|
|
286
|
+
# zero or negative, i.e. the path loss is at least the mode's link
|
|
287
|
+
# budget: raise the estimate accordingly (a firmware-friendly,
|
|
288
|
+
# conservative update that makes the node escalate after failures).
|
|
289
|
+
floor_db = self.profile.path_loss_from_margin(mode, 0.0)
|
|
290
|
+
self._path_loss_est_db = floor_db if self._path_loss_est_db is None else max(self._path_loss_est_db, floor_db)
|
|
291
|
+
|
|
292
|
+
def set_priority(self, priority: int) -> None:
|
|
293
|
+
"""Explicit downlink handling (used when priority arrives with an ACK)."""
|
|
294
|
+
self._priority = int(priority)
|
|
295
|
+
|
|
296
|
+
# -- accessors ----------------------------------------------------------
|
|
297
|
+
@property
|
|
298
|
+
def measurement(self) -> Measurement | None:
|
|
299
|
+
return self._measurement
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def last_acked_packet(self) -> Packet | None:
|
|
303
|
+
return self._last_ack_packet
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def priority(self) -> int:
|
|
307
|
+
return self._priority
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def path_loss_est_db(self) -> float | None:
|
|
311
|
+
return self._path_loss_est_db
|
|
312
|
+
|
|
313
|
+
def state(self) -> NodeState:
|
|
314
|
+
p = self.profile
|
|
315
|
+
age_cap = p.observation.age_scale_s
|
|
316
|
+
if self._measurement is None:
|
|
317
|
+
has_meas, m_value, m_noise, m_age = False, 0.0, 0.0, age_cap
|
|
318
|
+
else:
|
|
319
|
+
has_meas = True
|
|
320
|
+
m_value = self._measurement.value
|
|
321
|
+
m_noise = self._measurement.noise_std
|
|
322
|
+
m_age = self._now_s - self._measurement.timestamp_s
|
|
323
|
+
if self._last_ack_time_s is None or self._last_ack_packet is None:
|
|
324
|
+
has_rep, t_since, app_age, rep_value = False, age_cap, age_cap, 0.0
|
|
325
|
+
else:
|
|
326
|
+
has_rep = True
|
|
327
|
+
t_since = self._now_s - self._last_ack_time_s
|
|
328
|
+
app_age = t_since + self._last_ack_packet.measurement_age_s
|
|
329
|
+
rep_value = self._last_ack_packet.measurement.value
|
|
330
|
+
return NodeState(
|
|
331
|
+
time_of_day_s=self._time_of_day_s,
|
|
332
|
+
stored_energy_j=self._energy_j,
|
|
333
|
+
capacity_j=self._capacity_j,
|
|
334
|
+
harvest_power_w=self._harvest_w,
|
|
335
|
+
harvest_power_recent_w=self._harvest_recent_w,
|
|
336
|
+
has_measurement=has_meas,
|
|
337
|
+
measurement_value=m_value,
|
|
338
|
+
measurement_noise_std=m_noise,
|
|
339
|
+
measurement_age_s=m_age,
|
|
340
|
+
has_reported=has_rep,
|
|
341
|
+
time_since_tx_success_s=t_since,
|
|
342
|
+
app_info_age_s=app_age,
|
|
343
|
+
reported_value=rep_value,
|
|
344
|
+
app_priority=self._priority,
|
|
345
|
+
link_quality=self._link_quality,
|
|
346
|
+
has_link_estimate=self._path_loss_est_db is not None,
|
|
347
|
+
path_loss_est_db=self._path_loss_est_db if self._path_loss_est_db is not None else p.observation.path_loss_max_db,
|
|
348
|
+
sensing_energy_low_j=p.sensing_energy_j[1],
|
|
349
|
+
sensing_energy_high_j=p.sensing_energy_j[2],
|
|
350
|
+
tx_energy_j=p.tx_energy_j[p.reference_mode],
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
# Observation builder
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
@dataclass(frozen=True)
|
|
358
|
+
class ObservationField:
|
|
359
|
+
name: str
|
|
360
|
+
description: str
|
|
361
|
+
normalisation: str
|
|
362
|
+
hardware_source: str
|
|
363
|
+
low: float = 0.0
|
|
364
|
+
high: float = 1.0
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
OBSERVATION_FIELDS: tuple[ObservationField, ...] = (
|
|
368
|
+
ObservationField("battery_soc", "State of charge of the energy storage", "E / E_max", "fuel gauge / ADC"),
|
|
369
|
+
ObservationField("harvest_power", "Harvesting power measured in the last interval", "P / harvest_ref_power_w, clipped to 1", "harvester current monitor"),
|
|
370
|
+
ObservationField("harvest_recent", "EWMA of recent harvesting power", "P_ewma / harvest_ref_power_w, clipped to 1", "computed in firmware"),
|
|
371
|
+
ObservationField("time_of_day_sin", "Time of day, sine component", "(sin(2*pi*t/86400) + 1) / 2", "RTC"),
|
|
372
|
+
ObservationField("time_of_day_cos", "Time of day, cosine component", "(cos(2*pi*t/86400) + 1) / 2", "RTC"),
|
|
373
|
+
ObservationField("measurement", "Latest locally stored soil-moisture measurement (0 if none)", "moisture units (already in [0, 1])", "sensor driver + RAM"),
|
|
374
|
+
ObservationField("measurement_quality", "Quality tag of the latest measurement", "1 - 0.8*noise/noise_low (0 none, 0.2 low, 0.8 high)", "hardware profile"),
|
|
375
|
+
ObservationField("measurement_age", "Time since the latest measurement", "age / age_scale_s, clipped to 1 (1 if none)", "timer"),
|
|
376
|
+
ObservationField("time_since_tx_success", "Time since the last acknowledged uplink", "t / age_scale_s, clipped to 1 (1 if none)", "timer + radio ACK"),
|
|
377
|
+
ObservationField("app_info_age", "Node-side estimate of the information age at the application", "age / age_scale_s, clipped to 1 (1 if none)", "timer + radio ACK"),
|
|
378
|
+
ObservationField("reported_value", "Value in the last acknowledged uplink (0 if none)", "moisture units", "RAM"),
|
|
379
|
+
ObservationField("app_priority", "Priority requested by the application", "priority / 2", "downlink message"),
|
|
380
|
+
ObservationField("importance", "Node-side importance of the stored measurement (proximity to thresholds)", "exp(-dist/importance_scale), 1 at/beyond critical (side given by critical_is_upper), 0 if none", "computed in firmware from flash thresholds"),
|
|
381
|
+
ObservationField("link_quality", "EWMA of recent ACK outcomes", "already in [0, 1]", "radio ACK"),
|
|
382
|
+
ObservationField("path_loss_est", "Path-loss estimate from the margin of the last ACK (mode independent)", "(PL - path_loss_min_db) / (path_loss_max_db - path_loss_min_db), clipped; 1 if none", "radio ACK SNR/RSSI + flash link-budget table"),
|
|
383
|
+
ObservationField("sense_low_cost", "Energy of a low-cost sensing operation", "E / E_max, clipped to 1", "hardware profile"),
|
|
384
|
+
ObservationField("sense_high_cost", "Energy of a high-quality sensing operation", "E / E_max, clipped to 1", "hardware profile"),
|
|
385
|
+
ObservationField("tx_cost", "Energy of one transmission attempt in the reference radio mode", "E / E_max, clipped to 1", "hardware profile"),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class ObservationBuilder:
|
|
390
|
+
"""Turns a :class:`NodeState` into the float32 policy observation.
|
|
391
|
+
|
|
392
|
+
The builder is stateless; all normalisation constants come from the
|
|
393
|
+
:class:`NodeProfile`, so a firmware port only needs the same constants.
|
|
394
|
+
"""
|
|
395
|
+
|
|
396
|
+
fields: tuple[ObservationField, ...] = OBSERVATION_FIELDS
|
|
397
|
+
|
|
398
|
+
def __init__(self, profile: NodeProfile):
|
|
399
|
+
self.profile = profile
|
|
400
|
+
|
|
401
|
+
# -- metadata -----------------------------------------------------------
|
|
402
|
+
@property
|
|
403
|
+
def dim(self) -> int:
|
|
404
|
+
return len(self.fields)
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def names(self) -> tuple[str, ...]:
|
|
408
|
+
return tuple(f.name for f in self.fields)
|
|
409
|
+
|
|
410
|
+
@property
|
|
411
|
+
def low(self) -> np.ndarray:
|
|
412
|
+
return np.array([f.low for f in self.fields], dtype=np.float32)
|
|
413
|
+
|
|
414
|
+
@property
|
|
415
|
+
def high(self) -> np.ndarray:
|
|
416
|
+
return np.array([f.high for f in self.fields], dtype=np.float32)
|
|
417
|
+
|
|
418
|
+
def index(self, name: str) -> int:
|
|
419
|
+
return self.names.index(name)
|
|
420
|
+
|
|
421
|
+
def describe(self) -> str:
|
|
422
|
+
"""Human-readable table of the observation vector."""
|
|
423
|
+
lines = [f"{'idx':>3} {'name':<22} {'normalisation':<45} source", "-" * 100]
|
|
424
|
+
for i, f in enumerate(self.fields):
|
|
425
|
+
lines.append(f"{i:>3} {f.name:<22} {f.normalisation:<45} {f.hardware_source}")
|
|
426
|
+
return "\n".join(lines)
|
|
427
|
+
|
|
428
|
+
def to_dict(self, observation: np.ndarray) -> dict[str, float]:
|
|
429
|
+
return {name: float(v) for name, v in zip(self.names, observation)}
|
|
430
|
+
|
|
431
|
+
# -- construction -------------------------------------------------------
|
|
432
|
+
def importance(self, value: float, has_measurement: bool) -> float:
|
|
433
|
+
"""Node-side importance of the stored measurement in [0, 1]."""
|
|
434
|
+
if not has_measurement:
|
|
435
|
+
return 0.0
|
|
436
|
+
p = self.profile
|
|
437
|
+
beyond = value >= p.critical_threshold if p.critical_is_upper else value <= p.critical_threshold
|
|
438
|
+
if beyond:
|
|
439
|
+
return 1.0
|
|
440
|
+
dist = min(abs(value - p.warning_threshold), abs(value - p.critical_threshold))
|
|
441
|
+
return math.exp(-dist / p.observation.importance_scale)
|
|
442
|
+
|
|
443
|
+
def quality_tag(self, noise_std: float, has_measurement: bool) -> float:
|
|
444
|
+
"""Map the nominal noise of a measurement to a quality in [0, 1].
|
|
445
|
+
|
|
446
|
+
The low-cost level maps to a small positive value and the best level
|
|
447
|
+
to a value close to 1, so the policy can tell the modes apart. A node
|
|
448
|
+
with no stored measurement reports 0.
|
|
449
|
+
"""
|
|
450
|
+
if not has_measurement:
|
|
451
|
+
return 0.0
|
|
452
|
+
ref = self.profile.sensing_noise_std[1] # low-cost noise as reference
|
|
453
|
+
if ref <= 0:
|
|
454
|
+
return 1.0
|
|
455
|
+
return float(np.clip(1.0 - 0.8 * noise_std / ref, 0.0, 1.0))
|
|
456
|
+
|
|
457
|
+
def build(self, s: NodeState) -> np.ndarray:
|
|
458
|
+
o = self.profile.observation
|
|
459
|
+
cap = max(s.capacity_j, 1e-9)
|
|
460
|
+
age = o.age_scale_s
|
|
461
|
+
href = o.harvest_ref_power_w
|
|
462
|
+
phase = 2.0 * math.pi * s.time_of_day_s / 86400.0
|
|
463
|
+
obs = np.array(
|
|
464
|
+
[
|
|
465
|
+
s.stored_energy_j / cap,
|
|
466
|
+
s.harvest_power_w / href,
|
|
467
|
+
s.harvest_power_recent_w / href,
|
|
468
|
+
(math.sin(phase) + 1.0) / 2.0,
|
|
469
|
+
(math.cos(phase) + 1.0) / 2.0,
|
|
470
|
+
s.measurement_value if s.has_measurement else 0.0,
|
|
471
|
+
self.quality_tag(s.measurement_noise_std, s.has_measurement),
|
|
472
|
+
s.measurement_age_s / age if s.has_measurement else 1.0,
|
|
473
|
+
s.time_since_tx_success_s / age if s.has_reported else 1.0,
|
|
474
|
+
s.app_info_age_s / age if s.has_reported else 1.0,
|
|
475
|
+
s.reported_value if s.has_reported else 0.0,
|
|
476
|
+
s.app_priority / (N_PRIORITY_LEVELS - 1),
|
|
477
|
+
self.importance(s.measurement_value, s.has_measurement),
|
|
478
|
+
s.link_quality,
|
|
479
|
+
(s.path_loss_est_db - o.path_loss_min_db) / (o.path_loss_max_db - o.path_loss_min_db) if s.has_link_estimate else 1.0,
|
|
480
|
+
s.sensing_energy_low_j / cap,
|
|
481
|
+
s.sensing_energy_high_j / cap,
|
|
482
|
+
s.tx_energy_j / cap,
|
|
483
|
+
],
|
|
484
|
+
dtype=np.float32,
|
|
485
|
+
)
|
|
486
|
+
return np.clip(obs, self.low, self.high).astype(np.float32)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def observation_names() -> Iterable[str]:
|
|
490
|
+
return (f.name for f in OBSERVATION_FIELDS)
|