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