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,411 @@
|
|
|
1
|
+
"""Simulation-to-real scaffolding.
|
|
2
|
+
|
|
3
|
+
Three things live here:
|
|
4
|
+
|
|
5
|
+
* :class:`NodeController` - the *firmware main loop* written against the
|
|
6
|
+
hardware protocols of ``interfaces.py``. It reuses ``NodeStateTracker``,
|
|
7
|
+
``ObservationBuilder`` and ``plan_execution`` from the simulator, so the
|
|
8
|
+
policy receives byte-for-byte the same observation vector it saw in
|
|
9
|
+
training and its actions are executed by the same feasibility rule.
|
|
10
|
+
|
|
11
|
+
* :class:`MockHardwareBackend` - stand-in drivers (a fuel gauge with a minimal
|
|
12
|
+
power-path emulation, a harvester monitor fed by a callable, a sensor driver
|
|
13
|
+
reading a callable with the profile's noise, a radio whose ACKs follow a
|
|
14
|
+
link budget at a fixed path loss). It has **no ground truth**: it only knows
|
|
15
|
+
what a real board would know. It exists to prove that the controller and the
|
|
16
|
+
policy run unchanged outside the simulator, and it is what a real port
|
|
17
|
+
replaces with ADC / I2C / radio-stack calls.
|
|
18
|
+
|
|
19
|
+
* :class:`PolicyBundle` - a JSON-serialisable description of everything that
|
|
20
|
+
must travel with a trained policy to the microcontroller: observation
|
|
21
|
+
ordering and normalisation constants, action encoding, model parameters and
|
|
22
|
+
metadata. Freezing this contract is the single most important step for a
|
|
23
|
+
credible sim-to-real transfer (see ``docs/deployment.md``).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import dataclasses
|
|
29
|
+
import json
|
|
30
|
+
import platform
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, Callable, Sequence
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
|
|
38
|
+
from . import __version__
|
|
39
|
+
from .actions import DEFAULT_N_MODES, SENSE_NONE, action_nvec, n_flat_actions, plan_execution
|
|
40
|
+
from .interfaces import Clock, EnergySource, EnergyStorage, Measurement, Packet, Policy, Radio, RemoteApplication, Sensor, TxResult
|
|
41
|
+
from .observation import OBSERVATION_FIELDS, NodeProfile, NodeStateTracker, ObservationBuilder
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Firmware-style controller
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
@dataclass
|
|
48
|
+
class HardwareBackend:
|
|
49
|
+
"""Bundle of drivers implementing the protocols of ``interfaces.py``."""
|
|
50
|
+
|
|
51
|
+
clock: Clock
|
|
52
|
+
storage: EnergyStorage
|
|
53
|
+
source: EnergySource
|
|
54
|
+
sensor: Sensor
|
|
55
|
+
radio: Radio
|
|
56
|
+
application: RemoteApplication
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class CycleReport:
|
|
61
|
+
"""What happened during one decision cycle (for logging / evaluation)."""
|
|
62
|
+
|
|
63
|
+
time_s: float
|
|
64
|
+
observation: np.ndarray
|
|
65
|
+
requested_action: tuple[int, int]
|
|
66
|
+
executed_sensing_level: int
|
|
67
|
+
executed_transmit: bool
|
|
68
|
+
tx_mode: int
|
|
69
|
+
"""Radio mode used (-1 when no transmission)."""
|
|
70
|
+
tx_success: bool | None
|
|
71
|
+
rejected: tuple[str, ...]
|
|
72
|
+
measurement: Measurement | None
|
|
73
|
+
energy_spent_j: float = 0.0
|
|
74
|
+
"""Nominal sensing + transmission energy of the executed operations [J]."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class NodeController:
|
|
78
|
+
"""Main loop of the deployed node.
|
|
79
|
+
|
|
80
|
+
::
|
|
81
|
+
|
|
82
|
+
controller = NodeController(backend, profile, policy)
|
|
83
|
+
while True:
|
|
84
|
+
report = controller.run_cycle()
|
|
85
|
+
sleep_until_next_wakeup(profile.timestep_s)
|
|
86
|
+
|
|
87
|
+
Nothing in this class knows whether ``backend`` is simulated or real. All
|
|
88
|
+
constants (energy profile, timestep, baseline, reserve, ACK availability,
|
|
89
|
+
normalisation) come from the :class:`NodeProfile`, i.e. from the exported
|
|
90
|
+
policy bundle, so simulator and firmware cannot drift apart silently.
|
|
91
|
+
``priority_update_mode`` mirrors ``CommunicationConfig.priority_update_mode``:
|
|
92
|
+
'immediate' reads the downlink priority at every wake-up, 'on_uplink' only
|
|
93
|
+
after an acknowledged uplink.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, backend: HardwareBackend, profile: NodeProfile, policy: Policy, priority_update_mode: str = "immediate"):
|
|
97
|
+
if priority_update_mode not in ("immediate", "on_uplink"):
|
|
98
|
+
raise ValueError("priority_update_mode must be 'immediate' or 'on_uplink'")
|
|
99
|
+
self.hw = backend
|
|
100
|
+
self.profile = profile
|
|
101
|
+
self.policy = policy
|
|
102
|
+
self.priority_update_mode = priority_update_mode
|
|
103
|
+
self.tracker = NodeStateTracker(profile)
|
|
104
|
+
self.obs_builder = ObservationBuilder(profile)
|
|
105
|
+
self.policy.reset()
|
|
106
|
+
|
|
107
|
+
def build_observation(self) -> np.ndarray:
|
|
108
|
+
"""Read the measurable quantities and produce the policy input."""
|
|
109
|
+
hw = self.hw
|
|
110
|
+
priority = hw.application.priority() if self.priority_update_mode == "immediate" else None
|
|
111
|
+
self.tracker.begin_step(
|
|
112
|
+
now_s=hw.clock.now_s(),
|
|
113
|
+
time_of_day_s=hw.clock.time_of_day_s(),
|
|
114
|
+
energy_j=hw.storage.energy_j(),
|
|
115
|
+
capacity_j=hw.storage.capacity_j(),
|
|
116
|
+
harvest_power_w=hw.source.measured_power_w(),
|
|
117
|
+
priority=priority,
|
|
118
|
+
)
|
|
119
|
+
return self.obs_builder.build(self.tracker.state())
|
|
120
|
+
|
|
121
|
+
def execute(self, action) -> CycleReport:
|
|
122
|
+
"""Apply the feasibility rule and drive the drivers."""
|
|
123
|
+
hw, p = self.hw, self.profile
|
|
124
|
+
now = hw.clock.now_s()
|
|
125
|
+
plan = plan_execution(
|
|
126
|
+
action,
|
|
127
|
+
stored_energy_j=hw.storage.energy_j(),
|
|
128
|
+
baseline_energy_j=p.baseline_energy_j,
|
|
129
|
+
reserve_energy_j=p.reserve_soc * hw.storage.capacity_j(),
|
|
130
|
+
sensing_energy_j=p.sensing_energy_j,
|
|
131
|
+
tx_energy_j=p.tx_energy_j,
|
|
132
|
+
has_measurement=self.tracker.measurement is not None,
|
|
133
|
+
)
|
|
134
|
+
measurement = None
|
|
135
|
+
if plan.sensing_level != SENSE_NONE:
|
|
136
|
+
measurement = hw.sensor.read(plan.sensing_level, now)
|
|
137
|
+
self.tracker.on_measurement(measurement)
|
|
138
|
+
tx_success: bool | None = None
|
|
139
|
+
if plan.transmit and self.tracker.measurement is not None:
|
|
140
|
+
packet = Packet(measurement=self.tracker.measurement, sent_at_s=now)
|
|
141
|
+
result = hw.radio.transmit(packet, plan.mode)
|
|
142
|
+
tx_success = result.acked if p.ack_available else None
|
|
143
|
+
self.tracker.on_transmission(packet, tx_success, now, mode=plan.mode, margin_db=result.margin_db)
|
|
144
|
+
if self.priority_update_mode == "on_uplink" and result.acked:
|
|
145
|
+
self.tracker.set_priority(hw.application.priority()) # downlink piggybacked on the ACK
|
|
146
|
+
a = np.asarray(action).reshape(-1)
|
|
147
|
+
report = CycleReport(
|
|
148
|
+
time_s=now,
|
|
149
|
+
observation=np.empty(0),
|
|
150
|
+
requested_action=(int(a[0]), int(a[1])),
|
|
151
|
+
executed_sensing_level=plan.sensing_level,
|
|
152
|
+
executed_transmit=plan.transmit,
|
|
153
|
+
tx_mode=plan.mode,
|
|
154
|
+
tx_success=tx_success,
|
|
155
|
+
rejected=plan.rejected,
|
|
156
|
+
measurement=measurement,
|
|
157
|
+
energy_spent_j=plan.sensing_energy_j + plan.tx_energy_j,
|
|
158
|
+
)
|
|
159
|
+
return report
|
|
160
|
+
|
|
161
|
+
def run_cycle(self) -> CycleReport:
|
|
162
|
+
obs = self.build_observation()
|
|
163
|
+
action = self.policy.act(obs)
|
|
164
|
+
report = self.execute(action)
|
|
165
|
+
report.observation = obs
|
|
166
|
+
return report
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# Mock hardware (no ground truth!)
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
class MockClock:
|
|
173
|
+
def __init__(self, start_s: float = 0.0, timestep_s: float = 900.0):
|
|
174
|
+
self._t = start_s
|
|
175
|
+
self.dt = timestep_s
|
|
176
|
+
|
|
177
|
+
def now_s(self) -> float:
|
|
178
|
+
return self._t
|
|
179
|
+
|
|
180
|
+
def time_of_day_s(self) -> float:
|
|
181
|
+
return self._t % 86400.0
|
|
182
|
+
|
|
183
|
+
def tick(self) -> None:
|
|
184
|
+
self._t += self.dt
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class MockFuelGauge:
|
|
188
|
+
"""Battery monitor over a scripted energy value."""
|
|
189
|
+
|
|
190
|
+
def __init__(self, capacity_j: float, energy_j: float):
|
|
191
|
+
self._cap = capacity_j
|
|
192
|
+
self._e = float(np.clip(energy_j, 0.0, capacity_j))
|
|
193
|
+
|
|
194
|
+
def capacity_j(self) -> float:
|
|
195
|
+
return self._cap
|
|
196
|
+
|
|
197
|
+
def energy_j(self) -> float:
|
|
198
|
+
return self._e
|
|
199
|
+
|
|
200
|
+
def apply_delta_j(self, delta_j: float) -> None:
|
|
201
|
+
"""Power-path emulation hook (charge > 0, discharge < 0)."""
|
|
202
|
+
self._e = float(np.clip(self._e + delta_j, 0.0, self._cap))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class MockHarvesterMonitor:
|
|
206
|
+
"""Reports the average power of the *elapsed* interval, like a coulomb
|
|
207
|
+
counter read at wake-up (``power_fn`` is a scripted or recorded profile)."""
|
|
208
|
+
|
|
209
|
+
def __init__(self, power_fn: Callable[[float], float], clock: MockClock, samples: int = 8):
|
|
210
|
+
self._fn = power_fn
|
|
211
|
+
self._clock = clock
|
|
212
|
+
self._n = samples
|
|
213
|
+
|
|
214
|
+
def measured_power_w(self) -> float:
|
|
215
|
+
end = self._clock.now_s()
|
|
216
|
+
ts = np.linspace(end - self._clock.dt, end, self._n, endpoint=False)
|
|
217
|
+
return max(0.0, float(np.mean([self._fn(t) for t in ts])))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class MockSensorDriver:
|
|
221
|
+
"""Returns values from a trace (e.g. a CSV recorded in the field)."""
|
|
222
|
+
|
|
223
|
+
def __init__(self, trace: Callable[[float], float], energy_j: Sequence[float], noise_std: Sequence[float], rng: np.random.Generator | None = None):
|
|
224
|
+
self._trace = trace
|
|
225
|
+
self._energy = tuple(energy_j)
|
|
226
|
+
self._noise = tuple(noise_std)
|
|
227
|
+
self._rng = rng or np.random.default_rng(0)
|
|
228
|
+
|
|
229
|
+
def energy_cost_j(self, level: int) -> float:
|
|
230
|
+
return self._energy[level]
|
|
231
|
+
|
|
232
|
+
def noise_std(self, level: int) -> float:
|
|
233
|
+
return self._noise[level]
|
|
234
|
+
|
|
235
|
+
def read(self, level: int, now_s: float) -> Measurement:
|
|
236
|
+
raw = self._trace(now_s) + self._rng.normal(0.0, self._noise[level])
|
|
237
|
+
return Measurement(float(np.clip(raw, 0.0, 1.0)), now_s, level, self._noise[level])
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class MockRadio:
|
|
241
|
+
"""Radio stub: fixed path loss, ACK probability from the link budget of
|
|
242
|
+
the requested mode, margin reported back like a LinkCheck answer."""
|
|
243
|
+
|
|
244
|
+
def __init__(self, profile: NodeProfile, path_loss_db: float = 139.0, rng: np.random.Generator | None = None):
|
|
245
|
+
self._profile = profile
|
|
246
|
+
self._pl = path_loss_db
|
|
247
|
+
self._rng = rng or np.random.default_rng(1)
|
|
248
|
+
self.sent: list[tuple[Packet, int]] = []
|
|
249
|
+
|
|
250
|
+
def n_modes(self) -> int:
|
|
251
|
+
return self._profile.n_modes
|
|
252
|
+
|
|
253
|
+
def tx_energy_j(self, mode: int) -> float:
|
|
254
|
+
return self._profile.tx_energy_j[mode]
|
|
255
|
+
|
|
256
|
+
def transmit(self, packet: Packet, mode: int) -> TxResult:
|
|
257
|
+
self.sent.append((packet, mode))
|
|
258
|
+
margin = self._profile.margin_for_mode(mode, self._pl) + self._rng.normal(0.0, 2.0)
|
|
259
|
+
ok = bool(self._rng.random() < 1.0 / (1.0 + np.exp(-margin / 1.5)))
|
|
260
|
+
return TxResult(acked=ok, margin_db=float(margin) if ok else None)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class MockDownlink:
|
|
264
|
+
"""Priority as received from the back-end (defaults to routine)."""
|
|
265
|
+
|
|
266
|
+
def __init__(self, priority: int = 0):
|
|
267
|
+
self._p = priority
|
|
268
|
+
|
|
269
|
+
def priority(self) -> int:
|
|
270
|
+
return self._p
|
|
271
|
+
|
|
272
|
+
def set_priority(self, p: int) -> None:
|
|
273
|
+
self._p = int(p)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@dataclass
|
|
277
|
+
class MockHardwareBackend(HardwareBackend):
|
|
278
|
+
"""A fake board: drivers plus a minimal power-path emulation.
|
|
279
|
+
|
|
280
|
+
Call :meth:`end_of_cycle` after every controller cycle to advance the clock
|
|
281
|
+
and to debit/credit the fuel gauge (baseline + executed operations - harvest).
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
profile: NodeProfile = None # type: ignore[assignment]
|
|
285
|
+
|
|
286
|
+
def end_of_cycle(self, report: CycleReport) -> None:
|
|
287
|
+
# same order as the simulator: the interval's load is drawn first, the
|
|
288
|
+
# energy harvested during the interval is credited afterwards
|
|
289
|
+
self.storage.apply_delta_j(-(self.profile.baseline_energy_j + report.energy_spent_j)) # type: ignore[attr-defined]
|
|
290
|
+
self.clock.tick() # type: ignore[attr-defined]
|
|
291
|
+
harvested_j = self.source.measured_power_w() * self.profile.timestep_s # the interval that just elapsed
|
|
292
|
+
self.storage.apply_delta_j(+harvested_j) # type: ignore[attr-defined]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def make_mock_backend(profile: NodeProfile, *, capacity_j: float = 300.0, energy_j: float = 150.0, peak_harvest_w: float = 0.003, seed: int = 0) -> MockHardwareBackend:
|
|
296
|
+
"""A complete fake board with a plausible diurnal harvest and a slowly
|
|
297
|
+
drying soil trace. Useful for tests and as a template for a real port."""
|
|
298
|
+
rng = np.random.default_rng(seed)
|
|
299
|
+
clock = MockClock(0.0, profile.timestep_s)
|
|
300
|
+
|
|
301
|
+
def harvest(t: float) -> float:
|
|
302
|
+
h = (t % 86400.0) / 3600.0
|
|
303
|
+
return peak_harvest_w * float(np.sin(np.pi * (h - 6.0) / 12.0)) if 6.0 < h < 18.0 else 0.0
|
|
304
|
+
|
|
305
|
+
def soil_trace(t: float) -> float:
|
|
306
|
+
return 0.6 - 0.05 * (t / 86400.0)
|
|
307
|
+
|
|
308
|
+
return MockHardwareBackend(
|
|
309
|
+
clock=clock,
|
|
310
|
+
storage=MockFuelGauge(capacity_j, energy_j),
|
|
311
|
+
source=MockHarvesterMonitor(harvest, clock),
|
|
312
|
+
sensor=MockSensorDriver(soil_trace, profile.sensing_energy_j, profile.sensing_noise_std, rng),
|
|
313
|
+
radio=MockRadio(profile, rng=rng),
|
|
314
|
+
application=MockDownlink(0),
|
|
315
|
+
profile=profile,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
# Policy export
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
@dataclass
|
|
323
|
+
class PolicyBundle:
|
|
324
|
+
"""Everything that must be preserved when moving a policy to a device."""
|
|
325
|
+
|
|
326
|
+
policy_type: str
|
|
327
|
+
"""'rule_based', 'mlp', 'q_table', ... (free-form identifier)."""
|
|
328
|
+
|
|
329
|
+
observation_names: list[str]
|
|
330
|
+
"""Ordering of the observation vector (index = position)."""
|
|
331
|
+
|
|
332
|
+
observation_normalisation: list[str]
|
|
333
|
+
"""Human-readable formula per component (documentation of the contract)."""
|
|
334
|
+
|
|
335
|
+
profile: dict[str, Any]
|
|
336
|
+
"""NodeProfile constants (energy costs, thresholds, normalisation scales)."""
|
|
337
|
+
|
|
338
|
+
action_encoding: dict[str, Any]
|
|
339
|
+
"""MultiDiscrete nvec, flat-index formula and semantics of each value."""
|
|
340
|
+
|
|
341
|
+
model: dict[str, Any] = field(default_factory=dict)
|
|
342
|
+
"""Parameters: thresholds for a rule-based policy, weights/biases/activations
|
|
343
|
+
for an MLP, table for a tabular policy, ... (lists, not ndarrays)."""
|
|
344
|
+
|
|
345
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def observation_dim(self) -> int:
|
|
349
|
+
return len(self.observation_names)
|
|
350
|
+
|
|
351
|
+
def to_json(self, indent: int = 2) -> str:
|
|
352
|
+
return json.dumps(dataclasses.asdict(self), indent=indent, default=_json_default)
|
|
353
|
+
|
|
354
|
+
def save(self, path: str | Path) -> Path:
|
|
355
|
+
path = Path(path)
|
|
356
|
+
path.write_text(self.to_json())
|
|
357
|
+
return path
|
|
358
|
+
|
|
359
|
+
@classmethod
|
|
360
|
+
def load(cls, path: str | Path) -> "PolicyBundle":
|
|
361
|
+
return cls(**json.loads(Path(path).read_text()))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _json_default(o):
|
|
365
|
+
if isinstance(o, np.ndarray):
|
|
366
|
+
return o.tolist()
|
|
367
|
+
if isinstance(o, (np.floating, np.integer)):
|
|
368
|
+
return o.item()
|
|
369
|
+
if dataclasses.is_dataclass(o):
|
|
370
|
+
return dataclasses.asdict(o)
|
|
371
|
+
raise TypeError(f"not JSON serialisable: {type(o)}")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def action_encoding_spec(n_modes: int = DEFAULT_N_MODES) -> dict[str, Any]:
|
|
375
|
+
transmit = {"0": "no transmission"}
|
|
376
|
+
transmit.update({str(k + 1): f"transmit latest stored measurement with radio mode {k}" for k in range(n_modes)})
|
|
377
|
+
return {
|
|
378
|
+
"type": "MultiDiscrete",
|
|
379
|
+
"nvec": list(action_nvec(n_modes)),
|
|
380
|
+
"n_flat": n_flat_actions(n_modes),
|
|
381
|
+
"flat_index": f"sensing_level * {1 + n_modes} + transmit",
|
|
382
|
+
"sensing_level": {"0": "no sensing", "1": "low-cost sensing", "2": "high-quality sensing"},
|
|
383
|
+
"transmit": transmit,
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def export_policy(policy: Any, profile: NodeProfile, *, policy_type: str, model: dict[str, Any] | None = None, notes: str = "") -> PolicyBundle:
|
|
388
|
+
"""Create a :class:`PolicyBundle` for ``policy``.
|
|
389
|
+
|
|
390
|
+
For the built-in rule-based policy the parameters are exported
|
|
391
|
+
automatically; for other policies pass ``model`` explicitly (e.g. the
|
|
392
|
+
weights of a small MLP as nested lists).
|
|
393
|
+
"""
|
|
394
|
+
if model is None:
|
|
395
|
+
params = getattr(policy, "p", None)
|
|
396
|
+
model = dataclasses.asdict(params) if dataclasses.is_dataclass(params) else {}
|
|
397
|
+
return PolicyBundle(
|
|
398
|
+
policy_type=policy_type,
|
|
399
|
+
observation_names=[f.name for f in OBSERVATION_FIELDS],
|
|
400
|
+
observation_normalisation=[f.normalisation for f in OBSERVATION_FIELDS],
|
|
401
|
+
profile=dataclasses.asdict(profile),
|
|
402
|
+
action_encoding=action_encoding_spec(profile.n_modes),
|
|
403
|
+
model=model,
|
|
404
|
+
metadata={
|
|
405
|
+
"edgeengine_aware_version": __version__,
|
|
406
|
+
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
407
|
+
"python": platform.python_version(),
|
|
408
|
+
"policy_class": type(policy).__name__,
|
|
409
|
+
"notes": notes,
|
|
410
|
+
},
|
|
411
|
+
)
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Application domains of EdgeEngine AWARE.
|
|
2
|
+
|
|
3
|
+
A *domain* is a monitored process (what the sensor samples and the
|
|
4
|
+
application wants to know), an energy source (where the harvested energy comes
|
|
5
|
+
from), a hardware profile (how much each operation costs) and a set of
|
|
6
|
+
benchmark scenarios. Three are provided:
|
|
7
|
+
|
|
8
|
+
============= ===================================== ========================== =================
|
|
9
|
+
domain monitored quantity (danger side) energy source radio
|
|
10
|
+
============= ===================================== ========================== =================
|
|
11
|
+
agriculture soil moisture (low) solar cell, weather LoRa-like, 3 SF
|
|
12
|
+
indoor_air CO2 of a room (high) indoor PV, lights + window BLE-like, 3 PHY
|
|
13
|
+
industrial bearing temperature of a motor (high) TEG on the warm casing LoRa-like, 3 SF
|
|
14
|
+
============= ===================================== ========================== =================
|
|
15
|
+
|
|
16
|
+
Whatever the domain, the node sees the same 18-number observation and returns
|
|
17
|
+
the same (sensing level, radio mode) action: the monitored quantity is
|
|
18
|
+
normalised to [0, 1] and the thresholds' direction is a profile constant. A
|
|
19
|
+
policy or a controller therefore runs unchanged in every domain - which is
|
|
20
|
+
what makes cross-domain evaluation possible (``examples/domains.ipynb``).
|
|
21
|
+
|
|
22
|
+
``build_world(cfg, dt)`` instantiates the schedule, process and source for a
|
|
23
|
+
configuration; ``domain_config(name)`` returns a domain's default
|
|
24
|
+
configuration; ``DOMAIN_SCENARIOS`` holds the benchmark scenarios of every
|
|
25
|
+
domain (``scenarios.get_scenario("indoor_air:no_window")``).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any, Callable
|
|
32
|
+
|
|
33
|
+
from .agriculture import FieldEnvironment
|
|
34
|
+
from .config import HOUR_S, EdgeEngineAwareConfig, ble_radio, default_config
|
|
35
|
+
from .energy import SolarEnergySource
|
|
36
|
+
from .indoor import IndoorAirProcess, IndoorLightSource
|
|
37
|
+
from .industrial import BearingProcess, ThermoelectricSource
|
|
38
|
+
from .process import ActivitySchedule
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Default configurations
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def agriculture_config() -> EdgeEngineAwareConfig:
|
|
46
|
+
"""The original smart-agriculture node (see ``config.default_config``)."""
|
|
47
|
+
return default_config()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def indoor_air_config() -> EdgeEngineAwareConfig:
|
|
51
|
+
"""CO2 monitoring in a classroom / office with an indoor PV cell.
|
|
52
|
+
|
|
53
|
+
Micro-power node: 60 J storage, 20 uW always-on, ~140 uW harvested under
|
|
54
|
+
the ceiling lights, sub-millijoule BLE uplinks; the CO2 sensor is the
|
|
55
|
+
energy hog (20 / 150 mJ per reading). Occupancy 08:00-18:00 on weekdays.
|
|
56
|
+
"""
|
|
57
|
+
cfg = default_config()
|
|
58
|
+
cfg.domain = "indoor_air"
|
|
59
|
+
cfg.harvesting_source = "indoor_light"
|
|
60
|
+
cfg.storage.capacity_j = 60.0
|
|
61
|
+
cfg.storage.initial_soc = 0.5
|
|
62
|
+
cfg.storage.reserve_soc = 0.05
|
|
63
|
+
cfg.mcu.baseline_power_w = 20e-6
|
|
64
|
+
cfg.sensing.energy_j = (0.0, 0.02, 0.15)
|
|
65
|
+
cfg.sensing.noise_std = (0.0, 0.05, 0.015) # 80 ppm / 24 ppm on a 1600 ppm span
|
|
66
|
+
cfg.communication = ble_radio()
|
|
67
|
+
cfg.observation.harvest_ref_power_w = 300e-6
|
|
68
|
+
cfg.application.aoi_elevated_s = 12.0 * HOUR_S
|
|
69
|
+
cfg.application.aoi_urgent_s = 36.0 * HOUR_S
|
|
70
|
+
cfg.schedule.active_days = (0, 1, 2, 3, 4)
|
|
71
|
+
cfg.schedule.start_hour, cfg.schedule.end_hour = 8.0, 18.0
|
|
72
|
+
cfg.schedule.dip_hours, cfg.schedule.dip_level = (12.5, 13.5), 0.3
|
|
73
|
+
cfg.schedule.base_level = 0.8
|
|
74
|
+
return cfg
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def industrial_config() -> EdgeEngineAwareConfig:
|
|
78
|
+
"""Bearing-temperature monitoring of a motor on two shifts with a TEG.
|
|
79
|
+
|
|
80
|
+
Same always-on load and radio as the agricultural node, a 120 J storage;
|
|
81
|
+
sensing is a cheap temperature reading (50 mJ) or a vibration burst with
|
|
82
|
+
on-board FFT (0.5 J). The machine runs 06:00-22:00 on weekdays, sometimes on
|
|
83
|
+
Saturday: energy is plentiful while it runs and absent over the weekend.
|
|
84
|
+
"""
|
|
85
|
+
cfg = default_config()
|
|
86
|
+
cfg.domain = "industrial"
|
|
87
|
+
cfg.harvesting_source = "thermoelectric"
|
|
88
|
+
cfg.storage.capacity_j = 120.0 # enough for a weekend of hourly reports, not more
|
|
89
|
+
cfg.sensing.energy_j = (0.0, 0.05, 0.50)
|
|
90
|
+
cfg.sensing.noise_std = (0.0, 0.03, 0.01) # 3 degC / 1 degC on a 100 degC span
|
|
91
|
+
cfg.schedule.active_days = (0, 1, 2, 3, 4)
|
|
92
|
+
cfg.schedule.start_hour, cfg.schedule.end_hour = 6.0, 22.0
|
|
93
|
+
cfg.schedule.dip_hours = None
|
|
94
|
+
cfg.schedule.base_level = 0.9
|
|
95
|
+
cfg.schedule.day_factor_std = 0.10
|
|
96
|
+
cfg.schedule.p_extra_day = 0.15
|
|
97
|
+
return cfg
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Scenarios per domain
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _indoor_no_window() -> EdgeEngineAwareConfig:
|
|
106
|
+
cfg = indoor_air_config()
|
|
107
|
+
cfg.indoor_light.daylight_lux = 0.0 # energy only while the lights are on
|
|
108
|
+
return cfg
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _indoor_weak_ventilation() -> EdgeEngineAwareConfig:
|
|
112
|
+
cfg = indoor_air_config()
|
|
113
|
+
cfg.indoor_air.ach_hvac = 1.5 # CO2 climbs well past the critical threshold every day
|
|
114
|
+
cfg.indoor_air.ach_base = 0.4
|
|
115
|
+
return cfg
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _indoor_long_hours() -> EdgeEngineAwareConfig:
|
|
119
|
+
cfg = indoor_air_config()
|
|
120
|
+
cfg.schedule.active_days = (0, 1, 2, 3, 4, 5) # Saturday too
|
|
121
|
+
cfg.schedule.end_hour = 21.0
|
|
122
|
+
cfg.schedule.base_level = 0.9
|
|
123
|
+
return cfg
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _indoor_dim_lights() -> EdgeEngineAwareConfig:
|
|
127
|
+
cfg = indoor_air_config()
|
|
128
|
+
cfg.indoor_light.artificial_lux = 250.0 # LED office at half the illuminance: half the energy
|
|
129
|
+
cfg.storage.initial_soc = 0.4
|
|
130
|
+
return cfg
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _industrial_single_shift() -> EdgeEngineAwareConfig:
|
|
134
|
+
cfg = industrial_config()
|
|
135
|
+
cfg.schedule.start_hour, cfg.schedule.end_hour = 8.0, 17.0 # half the running hours, half the energy
|
|
136
|
+
cfg.schedule.p_extra_day = 0.0
|
|
137
|
+
return cfg
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _industrial_degrading() -> EdgeEngineAwareConfig:
|
|
141
|
+
cfg = industrial_config()
|
|
142
|
+
cfg.industrial.fault_onsets_per_day = 0.6 # faults every couple of days
|
|
143
|
+
cfg.industrial.initial_health_range = (0.3, 0.7)
|
|
144
|
+
cfg.industrial.maintenance_delay_mean_s = 24.0 * HOUR_S
|
|
145
|
+
return cfg
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _industrial_continuous() -> EdgeEngineAwareConfig:
|
|
149
|
+
cfg = industrial_config()
|
|
150
|
+
cfg.schedule.active_days = (0, 1, 2, 3, 4, 5, 6) # 24/7 plant: energy-rich, no weekend
|
|
151
|
+
cfg.schedule.start_hour, cfg.schedule.end_hour = 0.0, 24.0
|
|
152
|
+
cfg.schedule.ramp_h = 0.01
|
|
153
|
+
cfg.schedule.p_day_off = 0.02
|
|
154
|
+
return cfg
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _industrial_weak_link() -> EdgeEngineAwareConfig:
|
|
158
|
+
cfg = industrial_config()
|
|
159
|
+
cfg.communication.path_loss_mean_db += 6.0 # metal hall, gateway far away
|
|
160
|
+
cfg.communication.slow_fading_autocorr = 0.98
|
|
161
|
+
return cfg
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
INDOOR_AIR_SCENARIOS: dict[str, Callable[[], EdgeEngineAwareConfig]] = {
|
|
165
|
+
"default": indoor_air_config,
|
|
166
|
+
"no_window": _indoor_no_window,
|
|
167
|
+
"weak_ventilation": _indoor_weak_ventilation,
|
|
168
|
+
"long_hours": _indoor_long_hours,
|
|
169
|
+
"dim_lights": _indoor_dim_lights,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
INDUSTRIAL_SCENARIOS: dict[str, Callable[[], EdgeEngineAwareConfig]] = {
|
|
173
|
+
"default": industrial_config,
|
|
174
|
+
"single_shift": _industrial_single_shift,
|
|
175
|
+
"degrading": _industrial_degrading,
|
|
176
|
+
"continuous": _industrial_continuous,
|
|
177
|
+
"weak_link": _industrial_weak_link,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class DomainInfo:
|
|
183
|
+
name: str
|
|
184
|
+
title: str
|
|
185
|
+
quantity: str
|
|
186
|
+
source: str
|
|
187
|
+
radio: str
|
|
188
|
+
description: str
|
|
189
|
+
config: Callable[[], EdgeEngineAwareConfig]
|
|
190
|
+
scenarios: dict[str, Callable[[], EdgeEngineAwareConfig]]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _agriculture_scenarios() -> dict[str, Callable[[], EdgeEngineAwareConfig]]:
|
|
194
|
+
from .scenarios import SCENARIOS # local import: scenarios.py imports this module
|
|
195
|
+
|
|
196
|
+
return SCENARIOS
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
DOMAINS: dict[str, DomainInfo] = {
|
|
200
|
+
"agriculture": DomainInfo(
|
|
201
|
+
"agriculture", "Smart agriculture", "soil moisture (danger: low)", "solar cell, weather-driven", "LoRa-like, 3 spreading factors",
|
|
202
|
+
"A soil-moisture node in a field: energy from the sun, information relevance from a slow soil process with rain and irrigation events.",
|
|
203
|
+
agriculture_config, {},
|
|
204
|
+
),
|
|
205
|
+
"indoor_air": DomainInfo(
|
|
206
|
+
"indoor_air", "Indoor air quality", "CO2 concentration (danger: high)", "indoor PV cell, lights and window", "BLE-like, 3 PHY modes",
|
|
207
|
+
"A CO2 node in a classroom: people raise the CO2 and switch the lights on - energy and relevance coincide; nights and weekends bring neither.",
|
|
208
|
+
indoor_air_config, INDOOR_AIR_SCENARIOS,
|
|
209
|
+
),
|
|
210
|
+
"industrial": DomainInfo(
|
|
211
|
+
"industrial", "Industrial condition monitoring", "bearing temperature (danger: high)", "thermoelectric generator on the casing", "LoRa-like, 3 spreading factors",
|
|
212
|
+
"A node on a motor bearing: the machine's heat is the energy source and the monitored variable; faults make it run hotter until maintenance.",
|
|
213
|
+
industrial_config, INDUSTRIAL_SCENARIOS,
|
|
214
|
+
),
|
|
215
|
+
}
|
|
216
|
+
DOMAIN_NAMES = tuple(DOMAINS)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def domain_config(name: str) -> EdgeEngineAwareConfig:
|
|
220
|
+
if name not in DOMAINS:
|
|
221
|
+
raise KeyError(f"unknown domain {name!r}; available: {DOMAIN_NAMES}")
|
|
222
|
+
return DOMAINS[name].config()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def domain_scenarios(name: str) -> dict[str, Callable[[], EdgeEngineAwareConfig]]:
|
|
226
|
+
if name == "agriculture":
|
|
227
|
+
return _agriculture_scenarios()
|
|
228
|
+
return DOMAINS[name].scenarios
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
# World factory used by the environment
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def build_world(cfg: EdgeEngineAwareConfig, timestep_s: float) -> tuple[ActivitySchedule | None, Any, Any]:
|
|
237
|
+
"""Instantiate ``(schedule, process, source)`` for ``cfg``.
|
|
238
|
+
|
|
239
|
+
The schedule is shared by the process and the source when the domain has
|
|
240
|
+
one; ``None`` for the agriculture domain, whose weather and soil have
|
|
241
|
+
their own stochastic models.
|
|
242
|
+
"""
|
|
243
|
+
schedule: ActivitySchedule | None = None
|
|
244
|
+
if cfg.uses_schedule:
|
|
245
|
+
schedule = ActivitySchedule(cfg.schedule, start_weekday=cfg.time.start_weekday, horizon_days=int(cfg.time.episode_days) + 2)
|
|
246
|
+
|
|
247
|
+
if cfg.domain == "agriculture":
|
|
248
|
+
process: Any = FieldEnvironment(cfg.agriculture, timestep_s)
|
|
249
|
+
elif cfg.domain == "indoor_air":
|
|
250
|
+
assert schedule is not None
|
|
251
|
+
process = IndoorAirProcess(cfg.indoor_air, timestep_s, schedule)
|
|
252
|
+
elif cfg.domain == "industrial":
|
|
253
|
+
assert schedule is not None
|
|
254
|
+
process = BearingProcess(cfg.industrial, timestep_s, schedule)
|
|
255
|
+
else:
|
|
256
|
+
raise ValueError(f"unknown domain {cfg.domain!r}")
|
|
257
|
+
|
|
258
|
+
if cfg.harvesting_source == "solar":
|
|
259
|
+
source: Any = SolarEnergySource(cfg.harvesting, timestep_s)
|
|
260
|
+
elif cfg.harvesting_source == "indoor_light":
|
|
261
|
+
assert schedule is not None
|
|
262
|
+
source = IndoorLightSource(cfg.indoor_light, timestep_s, schedule)
|
|
263
|
+
elif cfg.harvesting_source == "thermoelectric":
|
|
264
|
+
if not isinstance(process, BearingProcess):
|
|
265
|
+
raise ValueError("the thermoelectric source needs the industrial process")
|
|
266
|
+
source = ThermoelectricSource(cfg.thermoelectric, timestep_s, process)
|
|
267
|
+
else:
|
|
268
|
+
raise ValueError(f"unknown harvesting source {cfg.harvesting_source!r}")
|
|
269
|
+
return schedule, process, source
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
__all__ = [
|
|
273
|
+
"DOMAINS",
|
|
274
|
+
"DOMAIN_NAMES",
|
|
275
|
+
"DomainInfo",
|
|
276
|
+
"INDOOR_AIR_SCENARIOS",
|
|
277
|
+
"INDUSTRIAL_SCENARIOS",
|
|
278
|
+
"agriculture_config",
|
|
279
|
+
"indoor_air_config",
|
|
280
|
+
"industrial_config",
|
|
281
|
+
"domain_config",
|
|
282
|
+
"domain_scenarios",
|
|
283
|
+
"build_world",
|
|
284
|
+
]
|