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,285 @@
|
|
|
1
|
+
"""Policies implementing the Gymnasium-independent ``Policy`` protocol.
|
|
2
|
+
|
|
3
|
+
All policies here consume the *normalised observation vector* produced by
|
|
4
|
+
``ObservationBuilder`` and return a MultiDiscrete action array. Because they
|
|
5
|
+
only use the observation (never the environment object), the very same code
|
|
6
|
+
can drive the simulator or the deployment runtime in ``deployment.py``, and
|
|
7
|
+
the rule-based policy translates line by line into C on a microcontroller.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from .actions import DEFAULT_N_MODES, SENSE_HIGH, SENSE_LOW, SENSE_NONE, TX_NO, TX_YES, encode_action, n_flat_actions, unflatten_action
|
|
17
|
+
from .observation import OBSERVATION_FIELDS, NodeProfile
|
|
18
|
+
|
|
19
|
+
_IDX = {f.name: i for i, f in enumerate(OBSERVATION_FIELDS)}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RuleBasedParams:
|
|
24
|
+
"""Thresholds of the interpretable baseline (observation units unless noted)."""
|
|
25
|
+
|
|
26
|
+
soc_critical: float = 0.20
|
|
27
|
+
"""Below this SoC: deep economy - one report every ``deep_eco_interval_h``,
|
|
28
|
+
nothing else, unless the application is urgent and starving."""
|
|
29
|
+
|
|
30
|
+
soc_low: float = 0.50
|
|
31
|
+
"""Below this SoC: economy mode (report interval stretched, no checks).
|
|
32
|
+
Half of the storage is the trigger because a 300 J buffer covers only a
|
|
33
|
+
few cloudy days: waiting for the battery to be nearly empty is too late."""
|
|
34
|
+
|
|
35
|
+
deep_eco_interval_h: float = 8.0
|
|
36
|
+
"""Report interval below ``soc_critical`` (still high-quality samples: a
|
|
37
|
+
cheap noisy sample is worth little to the application)."""
|
|
38
|
+
|
|
39
|
+
soc_high: float = 0.70
|
|
40
|
+
"""Above this SoC (or under strong harvesting): generous mode."""
|
|
41
|
+
|
|
42
|
+
harvest_strong: float = 0.5
|
|
43
|
+
"""Normalised recent-harvest level considered 'strong sun'."""
|
|
44
|
+
|
|
45
|
+
report_interval_h: tuple[float, float, float] = (2.0, 1.0, 0.5)
|
|
46
|
+
"""Scheduled report interval per priority (routine / elevated / urgent)."""
|
|
47
|
+
|
|
48
|
+
report_interval_eco_factor: float = 2.0
|
|
49
|
+
"""Economy mode stretches the report interval by this factor."""
|
|
50
|
+
|
|
51
|
+
eco_sensing_level: int = 2
|
|
52
|
+
"""Sensing level used for reports in economy mode (2 = keep high quality)."""
|
|
53
|
+
|
|
54
|
+
report_interval_generous_factor: float = 0.75
|
|
55
|
+
"""Generous mode shrinks the routine report interval by this factor."""
|
|
56
|
+
|
|
57
|
+
check_interval_h: float = 1.0
|
|
58
|
+
"""Between reports, take a cheap low-cost sample this often to detect
|
|
59
|
+
sudden changes (disabled in economy mode)."""
|
|
60
|
+
|
|
61
|
+
event_delta: float = 0.08
|
|
62
|
+
"""A low-cost check deviating from the reported value by more than this
|
|
63
|
+
triggers an immediate high-quality report (2 sigma of the low-cost noise)."""
|
|
64
|
+
|
|
65
|
+
importance_immediate: float = 0.7
|
|
66
|
+
"""Report at once when the node-side importance exceeds this and the
|
|
67
|
+
stored value differs from the reported one by more than ``importance_delta``."""
|
|
68
|
+
|
|
69
|
+
importance_delta: float = 0.03
|
|
70
|
+
|
|
71
|
+
retry_age_h: float = 0.3
|
|
72
|
+
"""A high-quality sample younger than this that has not been acknowledged
|
|
73
|
+
is retransmitted."""
|
|
74
|
+
|
|
75
|
+
age_scale_h: float = 24.0
|
|
76
|
+
"""Must equal ObservationConfig.age_scale_s / 3600 (de-normalisation of ages)."""
|
|
77
|
+
|
|
78
|
+
link_margin_target_db: float = 4.0
|
|
79
|
+
"""Radio mode choice: the cheapest mode whose expected margin (from the
|
|
80
|
+
node's path-loss estimate) is at least this is used; the most robust mode
|
|
81
|
+
when none qualifies; the reference mode when there is no estimate yet."""
|
|
82
|
+
|
|
83
|
+
link_quality_escalate: float = 0.7
|
|
84
|
+
"""Below this ACK-EWMA the node escalates one mode (recent failures)."""
|
|
85
|
+
|
|
86
|
+
retry_min_link_quality: float = 0.5
|
|
87
|
+
"""Immediate retries are suppressed below this ACK-EWMA (link down: back off
|
|
88
|
+
to the scheduled reports instead of burning energy every step)."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class RuleBasedPolicy:
|
|
92
|
+
"""Interpretable heuristic controller.
|
|
93
|
+
|
|
94
|
+
The action space lets the node sense *and* transmit in the same step, and
|
|
95
|
+
the transmission always carries the freshest stored sample - so a
|
|
96
|
+
"scheduled report" is one action ``(SENSE_HIGH, TX_YES)``: wake up, take
|
|
97
|
+
a good sample, send it. Between reports the node may take cheap
|
|
98
|
+
low-cost *checks* without transmitting; if a check reveals a large change
|
|
99
|
+
the node confirms it with a high-quality sample and reports immediately.
|
|
100
|
+
|
|
101
|
+
Rules, in order:
|
|
102
|
+
1. **Deep economy** - SoC below ``soc_critical``: one high-quality report
|
|
103
|
+
every ``deep_eco_interval_h`` (urgent priority: every urgent interval),
|
|
104
|
+
nothing else.
|
|
105
|
+
2. **Retry** - a fresh high-quality sample that was not acknowledged is
|
|
106
|
+
retransmitted (no new sensing), unless the link looks down.
|
|
107
|
+
3. **Scheduled report** - when the estimated information age at the
|
|
108
|
+
application exceeds the report interval (shorter under higher
|
|
109
|
+
priority, stretched in economy mode, shrunk in generous mode):
|
|
110
|
+
high-quality sample + transmit. Economy mode (SoC below ``soc_low``)
|
|
111
|
+
keeps the sample quality and saves energy by reporting less often and
|
|
112
|
+
by skipping the checks - a cheap noisy sample is worth little to the
|
|
113
|
+
application, a missed hour is cheap.
|
|
114
|
+
4. **Event report** - the stored check differs from the reported value
|
|
115
|
+
by more than ``event_delta``, or the stored value is important
|
|
116
|
+
(near/below a threshold) and differs by more than ``importance_delta``:
|
|
117
|
+
high-quality sample + transmit.
|
|
118
|
+
5. **Check** - outside economy mode, a low-cost sample every
|
|
119
|
+
``check_interval_h`` (no transmission).
|
|
120
|
+
6. Otherwise sleep.
|
|
121
|
+
|
|
122
|
+
**Radio mode** (when the profile has several): the cheapest mode whose
|
|
123
|
+
expected margin, computed from the node's path-loss estimate and the
|
|
124
|
+
flash link-budget table, is at least ``link_margin_target_db``; one mode
|
|
125
|
+
up when recent uplinks failed; the reference mode before the first
|
|
126
|
+
estimate. Without a profile the policy always uses the default mode.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self, params: RuleBasedParams | None = None, profile: "NodeProfile | None" = None):
|
|
130
|
+
self.p = params or RuleBasedParams()
|
|
131
|
+
self.profile = profile
|
|
132
|
+
if profile is not None: # keep the de-normalisation constant in sync with the node profile
|
|
133
|
+
self.p.age_scale_h = profile.observation.age_scale_s / 3600.0
|
|
134
|
+
|
|
135
|
+
def _tx(self, o: np.ndarray) -> int:
|
|
136
|
+
"""Transmit action value: 1 + chosen radio mode."""
|
|
137
|
+
prof = self.profile
|
|
138
|
+
if prof is None or prof.n_modes == 1:
|
|
139
|
+
return TX_YES if prof is None else 1 + prof.reference_mode
|
|
140
|
+
oc = prof.observation
|
|
141
|
+
pl_norm = float(o[_IDX["path_loss_est"]])
|
|
142
|
+
if pl_norm >= 1.0: # no estimate yet
|
|
143
|
+
return 1 + prof.reference_mode
|
|
144
|
+
pl_db = oc.path_loss_min_db + pl_norm * (oc.path_loss_max_db - oc.path_loss_min_db)
|
|
145
|
+
order = sorted(range(prof.n_modes), key=lambda k: prof.tx_energy_j[k]) # cheapest first
|
|
146
|
+
chosen = order[-1]
|
|
147
|
+
for k in order:
|
|
148
|
+
if prof.margin_for_mode(k, pl_db) >= self.p.link_margin_target_db:
|
|
149
|
+
chosen = k
|
|
150
|
+
break
|
|
151
|
+
if float(o[_IDX["link_quality"]]) < self.p.link_quality_escalate:
|
|
152
|
+
pos = order.index(chosen)
|
|
153
|
+
chosen = order[min(pos + 1, len(order) - 1)]
|
|
154
|
+
return 1 + chosen
|
|
155
|
+
|
|
156
|
+
def reset(self) -> None: # stateless
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
def act(self, observation) -> np.ndarray:
|
|
160
|
+
o = np.asarray(observation, dtype=np.float32)
|
|
161
|
+
p = self.p
|
|
162
|
+
soc = float(o[_IDX["battery_soc"]])
|
|
163
|
+
harvest_recent = float(o[_IDX["harvest_recent"]])
|
|
164
|
+
meas = float(o[_IDX["measurement"]])
|
|
165
|
+
quality = float(o[_IDX["measurement_quality"]])
|
|
166
|
+
has_measurement = quality > 0.0
|
|
167
|
+
high_quality = quality > 0.5
|
|
168
|
+
meas_age_h = float(o[_IDX["measurement_age"]]) * p.age_scale_h
|
|
169
|
+
since_ack_h = float(o[_IDX["time_since_tx_success"]]) * p.age_scale_h
|
|
170
|
+
app_age_h = float(o[_IDX["app_info_age"]]) * p.age_scale_h
|
|
171
|
+
reported = float(o[_IDX["reported_value"]])
|
|
172
|
+
has_reported = float(o[_IDX["time_since_tx_success"]]) < 1.0
|
|
173
|
+
priority = int(round(float(o[_IDX["app_priority"]]) * 2)) # 0 / 1 / 2
|
|
174
|
+
importance = float(o[_IDX["importance"]])
|
|
175
|
+
urgent = priority >= 2
|
|
176
|
+
eco = soc < p.soc_low and not urgent
|
|
177
|
+
generous = (soc > p.soc_high or harvest_recent > p.harvest_strong) and not eco
|
|
178
|
+
unreported = has_measurement and (since_ack_h > meas_age_h + 1e-6)
|
|
179
|
+
delta = abs(meas - reported) if (has_measurement and has_reported) else (1.0 if has_measurement else 0.0)
|
|
180
|
+
|
|
181
|
+
interval_h = p.report_interval_h[priority]
|
|
182
|
+
if eco:
|
|
183
|
+
interval_h *= p.report_interval_eco_factor
|
|
184
|
+
elif generous and priority == 0:
|
|
185
|
+
interval_h *= p.report_interval_generous_factor
|
|
186
|
+
|
|
187
|
+
# 1. deep economy
|
|
188
|
+
if soc < p.soc_critical:
|
|
189
|
+
limit_h = p.report_interval_h[2] if urgent else p.deep_eco_interval_h
|
|
190
|
+
if app_age_h >= limit_h:
|
|
191
|
+
return encode_action(SENSE_HIGH, self._tx(o))
|
|
192
|
+
return encode_action(SENSE_NONE, TX_NO)
|
|
193
|
+
# 2. retry a fresh, unacknowledged *report* (high-quality sample); cheap
|
|
194
|
+
# low-cost checks are never retried, they are confirmed by rule 4.
|
|
195
|
+
# No retry while the link looks down (recent ACKs mostly missing):
|
|
196
|
+
# the next scheduled report will try again.
|
|
197
|
+
link_ok = float(o[_IDX["link_quality"]]) >= p.retry_min_link_quality
|
|
198
|
+
if unreported and high_quality and link_ok and meas_age_h < p.retry_age_h and app_age_h > interval_h:
|
|
199
|
+
return encode_action(SENSE_NONE, self._tx(o))
|
|
200
|
+
# 3. scheduled report
|
|
201
|
+
if app_age_h >= interval_h:
|
|
202
|
+
return encode_action(p.eco_sensing_level if eco else SENSE_HIGH, self._tx(o))
|
|
203
|
+
# 4. event / importance report
|
|
204
|
+
if unreported and (delta > p.event_delta or (importance > p.importance_immediate and delta > p.importance_delta)):
|
|
205
|
+
return encode_action(SENSE_HIGH, self._tx(o))
|
|
206
|
+
# 5. cheap check between reports
|
|
207
|
+
if not eco and (not has_measurement or meas_age_h >= p.check_interval_h):
|
|
208
|
+
return encode_action(SENSE_LOW, TX_NO)
|
|
209
|
+
return encode_action(SENSE_NONE, TX_NO)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class RandomPolicy:
|
|
213
|
+
"""Uniform random actions (lower bound reference)."""
|
|
214
|
+
|
|
215
|
+
def __init__(self, seed: int | None = None, n_modes: int = DEFAULT_N_MODES):
|
|
216
|
+
self.rng = np.random.default_rng(seed)
|
|
217
|
+
self.n_modes = n_modes
|
|
218
|
+
|
|
219
|
+
def reset(self) -> None:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
def act(self, observation) -> np.ndarray:
|
|
223
|
+
return unflatten_action(int(self.rng.integers(n_flat_actions(self.n_modes))), self.n_modes)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class PeriodicPolicy:
|
|
227
|
+
"""Sense (at a fixed level) and transmit every ``period_steps`` steps -
|
|
228
|
+
the classic duty-cycled firmware, oblivious to energy and application."""
|
|
229
|
+
|
|
230
|
+
def __init__(self, period_steps: int = 4, sensing_level: int = SENSE_LOW, tx: int = TX_YES):
|
|
231
|
+
self.period = max(1, int(period_steps))
|
|
232
|
+
self.level = sensing_level
|
|
233
|
+
self.tx = tx # 1 + radio mode
|
|
234
|
+
self._t = 0
|
|
235
|
+
|
|
236
|
+
def reset(self) -> None:
|
|
237
|
+
self._t = 0
|
|
238
|
+
|
|
239
|
+
def act(self, observation) -> np.ndarray:
|
|
240
|
+
fire = self._t % self.period == 0
|
|
241
|
+
self._t += 1
|
|
242
|
+
return encode_action(self.level if fire else SENSE_NONE, self.tx if fire else TX_NO)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class AlwaysOnPolicy:
|
|
246
|
+
"""High-quality sensing and transmission at every step (upper bound on
|
|
247
|
+
information, lower bound on energy prudence)."""
|
|
248
|
+
|
|
249
|
+
def __init__(self, tx: int = TX_YES):
|
|
250
|
+
self.tx = tx
|
|
251
|
+
|
|
252
|
+
def reset(self) -> None:
|
|
253
|
+
pass
|
|
254
|
+
|
|
255
|
+
def act(self, observation) -> np.ndarray:
|
|
256
|
+
return encode_action(SENSE_HIGH, self.tx)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@dataclass
|
|
260
|
+
class EpisodeResult:
|
|
261
|
+
total_reward: float
|
|
262
|
+
metrics: dict
|
|
263
|
+
log: object
|
|
264
|
+
infos: list = field(default_factory=list)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def run_episode(env, policy, *, seed: int | None = None, render: bool = False, render_every: int = 1, keep_infos: bool = False) -> EpisodeResult:
|
|
268
|
+
"""Roll out one episode of ``policy`` in ``env`` (Gymnasium loop)."""
|
|
269
|
+
obs, info = env.reset(seed=seed)
|
|
270
|
+
policy.reset()
|
|
271
|
+
total = 0.0
|
|
272
|
+
infos: list = []
|
|
273
|
+
done = False
|
|
274
|
+
step = 0
|
|
275
|
+
while not done:
|
|
276
|
+
action = policy.act(obs)
|
|
277
|
+
obs, reward, terminated, truncated, info = env.step(action)
|
|
278
|
+
total += reward
|
|
279
|
+
if keep_infos:
|
|
280
|
+
infos.append(info)
|
|
281
|
+
if render and step % render_every == 0:
|
|
282
|
+
env.render()
|
|
283
|
+
done = terminated or truncated
|
|
284
|
+
step += 1
|
|
285
|
+
return EpisodeResult(total_reward=total, metrics=env.metrics.as_dict(), log=env.log, infos=infos)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Domain-independent pieces of the hidden world: the state every monitored
|
|
2
|
+
process reports and the weekly activity schedule shared by the indoor and
|
|
3
|
+
industrial domains.
|
|
4
|
+
|
|
5
|
+
A *monitored process* is anything the node's sensor samples and the
|
|
6
|
+
application wants to track: soil moisture (``agriculture.FieldEnvironment``),
|
|
7
|
+
the CO2 concentration of a room (``indoor.IndoorAirProcess``), the temperature
|
|
8
|
+
of a motor bearing (``industrial.BearingProcess``). Each of them implements
|
|
9
|
+
|
|
10
|
+
reset(rng, start_time_s) -> None
|
|
11
|
+
step(time_s) -> ProcessState (advance over [t, t + dt])
|
|
12
|
+
state() -> ProcessState
|
|
13
|
+
value -> float (normalised, in [0, 1]; what the sensor samples)
|
|
14
|
+
|
|
15
|
+
and reports its physical extras in ``ProcessState.aux``. Everything is
|
|
16
|
+
simulator-only: the node sees the process through the noisy sensor only.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from .config import ScheduleConfig
|
|
28
|
+
|
|
29
|
+
DAY_S = 86400.0
|
|
30
|
+
HOUR_S = 3600.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ProcessState:
|
|
35
|
+
"""Snapshot of a monitored process (privileged information)."""
|
|
36
|
+
|
|
37
|
+
value: float
|
|
38
|
+
"""Normalised value of the monitored quantity in [0, 1]."""
|
|
39
|
+
|
|
40
|
+
last_step_change: float
|
|
41
|
+
"""Change of the value during the last step."""
|
|
42
|
+
|
|
43
|
+
event_occurred: bool
|
|
44
|
+
"""A sudden change or a zone crossing happened in the last step."""
|
|
45
|
+
|
|
46
|
+
zone: int
|
|
47
|
+
"""0 = normal, 1 = warning, 2 = critical (direction given by QuantityConfig)."""
|
|
48
|
+
|
|
49
|
+
aux: dict[str, Any] = field(default_factory=dict)
|
|
50
|
+
"""Domain-specific extras (temperatures, occupancy, health, counters, ...)."""
|
|
51
|
+
|
|
52
|
+
# backwards-compatible alias used by the agriculture code paths
|
|
53
|
+
@property
|
|
54
|
+
def soil_moisture(self) -> float:
|
|
55
|
+
return self.value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ActivitySchedule:
|
|
59
|
+
"""Hidden weekly activity level in [0, 1] (people in a room, a machine on
|
|
60
|
+
shift). Drives both the monitored process and the harvesting source of a
|
|
61
|
+
domain, so that energy and information relevance are coupled the way they
|
|
62
|
+
are in reality.
|
|
63
|
+
|
|
64
|
+
``level(t)`` is deterministic given the per-day draws made at ``reset``
|
|
65
|
+
(day factors, days off, extra days) plus a within-day AR(1) perturbation
|
|
66
|
+
advanced by ``step()`` once per environment step.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, cfg: ScheduleConfig, start_weekday: int = 0, horizon_days: int = 10):
|
|
70
|
+
self.cfg = cfg
|
|
71
|
+
self.start_weekday = int(start_weekday)
|
|
72
|
+
self.horizon_days = int(horizon_days)
|
|
73
|
+
self._rng = np.random.default_rng()
|
|
74
|
+
self._day_factor = np.ones(self.horizon_days + 2)
|
|
75
|
+
self._day_active = np.ones(self.horizon_days + 2, dtype=bool)
|
|
76
|
+
self._noise = 0.0
|
|
77
|
+
self.reset(self._rng, 0.0)
|
|
78
|
+
|
|
79
|
+
def reset(self, rng: np.random.Generator, start_time_s: float) -> None:
|
|
80
|
+
c = self.cfg
|
|
81
|
+
self._rng = rng
|
|
82
|
+
n = self.horizon_days + 2
|
|
83
|
+
first_day = int(start_time_s // DAY_S)
|
|
84
|
+
self._first_day = first_day
|
|
85
|
+
self._day_factor = np.clip(1.0 + rng.normal(0.0, c.day_factor_std, size=n), 0.3, 1.5)
|
|
86
|
+
active = np.array([self.weekday(first_day + k) in c.active_days for k in range(n)])
|
|
87
|
+
flips = rng.random(n)
|
|
88
|
+
self._day_active = np.where(active, flips >= c.p_day_off, flips < c.p_extra_day)
|
|
89
|
+
self._noise = 0.0
|
|
90
|
+
|
|
91
|
+
# -- calendar -----------------------------------------------------------------
|
|
92
|
+
def weekday(self, day_index: int) -> int:
|
|
93
|
+
return (self.start_weekday + day_index) % 7
|
|
94
|
+
|
|
95
|
+
def is_active_day(self, time_s: float) -> bool:
|
|
96
|
+
k = int(time_s // DAY_S) - self._first_day
|
|
97
|
+
if not 0 <= k < len(self._day_active):
|
|
98
|
+
return self.weekday(int(time_s // DAY_S)) in self.cfg.active_days
|
|
99
|
+
return bool(self._day_active[k])
|
|
100
|
+
|
|
101
|
+
def _day_factor_at(self, time_s: float) -> float:
|
|
102
|
+
k = int(time_s // DAY_S) - self._first_day
|
|
103
|
+
if not 0 <= k < len(self._day_factor):
|
|
104
|
+
return 1.0
|
|
105
|
+
return float(self._day_factor[k])
|
|
106
|
+
|
|
107
|
+
# -- level --------------------------------------------------------------------
|
|
108
|
+
def nominal_level(self, time_s: float) -> float:
|
|
109
|
+
"""Deterministic window shape (ramps and dip) for the day type of ``time_s``."""
|
|
110
|
+
c = self.cfg
|
|
111
|
+
if not self.is_active_day(time_s):
|
|
112
|
+
return 0.0
|
|
113
|
+
h = (time_s % DAY_S) / HOUR_S
|
|
114
|
+
if h <= c.start_hour - c.ramp_h or h >= c.end_hour + c.ramp_h:
|
|
115
|
+
return 0.0
|
|
116
|
+
ramp_in = min(1.0, max(0.0, (h - (c.start_hour - c.ramp_h)) / max(c.ramp_h, 1e-6)))
|
|
117
|
+
ramp_out = min(1.0, max(0.0, ((c.end_hour + c.ramp_h) - h) / max(c.ramp_h, 1e-6)))
|
|
118
|
+
shape = min(ramp_in, ramp_out)
|
|
119
|
+
if c.dip_hours is not None and c.dip_hours[0] <= h < c.dip_hours[1]:
|
|
120
|
+
shape *= c.dip_level / max(c.base_level, 1e-6)
|
|
121
|
+
return c.base_level * shape * self._day_factor_at(time_s)
|
|
122
|
+
|
|
123
|
+
def step(self) -> None:
|
|
124
|
+
"""Advance the within-day perturbation by one environment step."""
|
|
125
|
+
c = self.cfg
|
|
126
|
+
rho = c.noise_autocorr
|
|
127
|
+
self._noise = rho * self._noise + math.sqrt(max(0.0, 1 - rho**2)) * self._rng.normal(0.0, c.noise_std)
|
|
128
|
+
|
|
129
|
+
def level(self, time_s: float) -> float:
|
|
130
|
+
nominal = self.nominal_level(time_s)
|
|
131
|
+
if nominal <= 0.0:
|
|
132
|
+
return 0.0
|
|
133
|
+
return float(np.clip(nominal * (1.0 + self._noise), 0.0, 1.0))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
__all__ = ["ProcessState", "ActivitySchedule"]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Matplotlib dashboard renderer.
|
|
2
|
+
|
|
3
|
+
One figure is created lazily and updated in place at every ``render()`` call.
|
|
4
|
+
``human`` shows the figure interactively (no-op on a non-interactive backend,
|
|
5
|
+
so headless training scripts are never blocked); ``rgb_array`` draws on an
|
|
6
|
+
Agg canvas and returns an ``(H, W, 3)`` uint8 array; ``ansi`` returns a
|
|
7
|
+
compact text dashboard. Only privileged information that the *user* is
|
|
8
|
+
allowed to see is plotted (true moisture, true harvest); nothing here feeds
|
|
9
|
+
the policy.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
19
|
+
from .env import EdgeEngineAwareEnv
|
|
20
|
+
|
|
21
|
+
PRIORITY_NAMES = ("routine", "elevated", "URGENT")
|
|
22
|
+
SENSE_NAMES = ("-", "low", "HIGH")
|
|
23
|
+
_MODE_COLORS = ("#eda100", "#2a78d6", "#4a3aa7", "#1baf7a", "#e87ba4", "#008300")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def plt_mode_color(k: int, n_modes: int) -> str:
|
|
27
|
+
return _MODE_COLORS[k % len(_MODE_COLORS)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def text_dashboard(env: "EdgeEngineAwareEnv") -> str:
|
|
31
|
+
"""Compact one-screen textual status of the environment."""
|
|
32
|
+
gt = env.ground_truth()
|
|
33
|
+
ns = env.node_state()
|
|
34
|
+
last = env._last_step
|
|
35
|
+
if not last["tx_attempted"]:
|
|
36
|
+
tx = "-"
|
|
37
|
+
else:
|
|
38
|
+
mode_name = env.cfg.communication.modes[last["tx_mode"]].name if last["tx_mode"] >= 0 else "?"
|
|
39
|
+
tx = f"{mode_name}:{'ok' if last['tx_success'] else 'FAIL'}"
|
|
40
|
+
meas = f"{ns.measurement_value:.3f}" if ns.has_measurement else " n/a"
|
|
41
|
+
app = f"{gt['app_last_value']:.3f}" if gt["app_last_value"] is not None else " n/a"
|
|
42
|
+
day, hour = env.clock.day_index(), env.clock.hour_of_day()
|
|
43
|
+
q = env.quantity
|
|
44
|
+
activity = f" activity {gt['activity']:.2f}" if gt.get("activity") is not None else ""
|
|
45
|
+
lines = [
|
|
46
|
+
f"EdgeEngine AWARE [{env.cfg.domain}] | step {env._step_count:4d} | day {day} {int(hour):02d}:{int((hour % 1) * 60):02d}{activity}",
|
|
47
|
+
f" battery SoC : {ns.soc():6.1%} ({ns.stored_energy_j:7.1f} J / {ns.capacity_j:.0f} J)",
|
|
48
|
+
f" harvest (meas/true): {ns.harvest_power_w*1e3:6.3f} / {gt['harvest_power_true_w']*1e3:6.3f} mW recent {ns.harvest_power_recent_w*1e3:.3f} mW last step {env._last_harvested_j:.3f} J",
|
|
49
|
+
f" {q.name[:19]:19s}: true {gt['value']:.3f} ({gt['physical_value']:.0f} {q.unit[:12]}) | node {meas} (age {ns.measurement_age_s/3600:.1f} h) | app {app} (AoI {gt['app_aoi_s']/3600:.1f} h)",
|
|
50
|
+
f" zone / priority : {('normal','warning','CRITICAL')[gt['zone']]} / {PRIORITY_NAMES[gt['app_priority']]} path loss {gt['path_loss_db']:.0f} dB (node est. {ns.path_loss_est_db:.0f} dB)",
|
|
51
|
+
f" action : sense={SENSE_NAMES[last['sensing_level']]} tx={tx}",
|
|
52
|
+
f" reward : {last['reward']:+.3f} (utility {last['utility']:.3f})",
|
|
53
|
+
]
|
|
54
|
+
return "\n".join(lines)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DashboardRenderer:
|
|
58
|
+
def __init__(self, env: "EdgeEngineAwareEnv"):
|
|
59
|
+
self.env = env
|
|
60
|
+
self.fig = None
|
|
61
|
+
self._interactive = False
|
|
62
|
+
self._pyplot_managed = False
|
|
63
|
+
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
def _ensure_figure(self, mode: str):
|
|
66
|
+
import matplotlib
|
|
67
|
+
|
|
68
|
+
if self.fig is not None:
|
|
69
|
+
return
|
|
70
|
+
if mode == "rgb_array":
|
|
71
|
+
# Do not touch the global backend; draw on a private Agg canvas.
|
|
72
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
73
|
+
from matplotlib.figure import Figure
|
|
74
|
+
|
|
75
|
+
self.fig = Figure(figsize=(12, 10), dpi=80)
|
|
76
|
+
FigureCanvasAgg(self.fig)
|
|
77
|
+
else:
|
|
78
|
+
import matplotlib.pyplot as plt
|
|
79
|
+
|
|
80
|
+
self._interactive = matplotlib.get_backend().lower() not in ("agg", "pdf", "svg", "ps", "template")
|
|
81
|
+
if self._interactive:
|
|
82
|
+
plt.ion()
|
|
83
|
+
self.fig = plt.figure(figsize=(12, 10), dpi=80)
|
|
84
|
+
self._pyplot_managed = True
|
|
85
|
+
self.axes = self.fig.subplots(4, 2, sharex=True)
|
|
86
|
+
self.fig.subplots_adjust(hspace=0.4, wspace=0.25, top=0.84, bottom=0.06)
|
|
87
|
+
self._title = self.fig.text(0.02, 0.985, "", fontsize=9, family="monospace", ha="left", va="top")
|
|
88
|
+
|
|
89
|
+
def _draw(self) -> None:
|
|
90
|
+
env = self.env
|
|
91
|
+
log = env.log
|
|
92
|
+
cfg = env.cfg
|
|
93
|
+
t = np.asarray(log.time_s) / 86400.0 if len(log) else np.zeros(0)
|
|
94
|
+
axes = self.axes
|
|
95
|
+
for ax in axes.ravel():
|
|
96
|
+
ax.cla()
|
|
97
|
+
ax.grid(alpha=0.3)
|
|
98
|
+
|
|
99
|
+
def step_series(values):
|
|
100
|
+
return np.asarray(values, dtype=float)
|
|
101
|
+
|
|
102
|
+
# battery
|
|
103
|
+
ax = axes[0, 0]
|
|
104
|
+
ax.plot(t, step_series(log.soc), color="tab:blue")
|
|
105
|
+
ax.axhline(cfg.reward.safe_soc, color="tab:red", ls="--", lw=0.8)
|
|
106
|
+
ax.set_ylim(0, 1.02)
|
|
107
|
+
ax.set_title("Battery SoC")
|
|
108
|
+
|
|
109
|
+
# harvest
|
|
110
|
+
ax = axes[0, 1]
|
|
111
|
+
ax.plot(t, step_series(log.harvest_power_w) * 1e3, color="tab:orange", lw=0.9)
|
|
112
|
+
ax.set_title(f"Measured harvesting power [mW] ({cfg.harvesting_source})")
|
|
113
|
+
|
|
114
|
+
# monitored quantity
|
|
115
|
+
q = env.quantity
|
|
116
|
+
ax = axes[1, 0]
|
|
117
|
+
ax.plot(t, step_series(log.true_moisture), color="k", lw=1.0, label="true")
|
|
118
|
+
ax.plot(t, step_series(log.measured_moisture), color="tab:green", lw=0.9, label="node", drawstyle="steps-post")
|
|
119
|
+
ax.plot(t, step_series(log.app_moisture), color="tab:purple", lw=0.9, label="application", drawstyle="steps-post")
|
|
120
|
+
ax.axhline(q.warning_threshold, color="tab:orange", ls=":", lw=0.8)
|
|
121
|
+
ax.axhline(q.critical_threshold, color="tab:red", ls=":", lw=0.8)
|
|
122
|
+
ax.set_ylim(0, 1)
|
|
123
|
+
ax.legend(loc="upper right", fontsize=7, ncol=3)
|
|
124
|
+
ax.set_title(f"{q.name} (true / node / application), normalised; danger {'above' if q.critical_is_upper else 'below'} the dotted lines")
|
|
125
|
+
|
|
126
|
+
# events
|
|
127
|
+
ax = axes[1, 1]
|
|
128
|
+
if len(log):
|
|
129
|
+
lvl = step_series(log.sensing_level)
|
|
130
|
+
att = step_series(log.tx_attempt)
|
|
131
|
+
suc = step_series(log.tx_success)
|
|
132
|
+
ax.vlines(t[lvl == 1], 0, 0.8, color="tab:green", alpha=0.5, lw=0.8, label="sense low")
|
|
133
|
+
ax.vlines(t[lvl == 2], 0, 1.0, color="darkgreen", alpha=0.8, lw=0.8, label="sense high")
|
|
134
|
+
mode = step_series(log.tx_mode)
|
|
135
|
+
n_modes = max(1, len(cfg.communication.modes))
|
|
136
|
+
for k in range(n_modes):
|
|
137
|
+
sel = (att == 1) & (suc == 1) & (mode == k)
|
|
138
|
+
if sel.any():
|
|
139
|
+
ax.scatter(t[sel], np.full(int(sel.sum()), 1.15 + 0.15 * k), marker="^", s=12, color=plt_mode_color(k, n_modes), label=f"tx ok ({cfg.communication.modes[k].name})")
|
|
140
|
+
fail = (att == 1) & (suc == 0)
|
|
141
|
+
ax.scatter(t[fail], np.full(int(fail.sum()), 1.15 + 0.15 * np.clip(mode[fail], 0, n_modes - 1)), marker="x", s=14, color="tab:red", label="tx fail")
|
|
142
|
+
ax.legend(loc="upper center", fontsize=6.5, ncol=3)
|
|
143
|
+
ax.set_ylim(0, 2.4)
|
|
144
|
+
ax.set_yticks([])
|
|
145
|
+
ax.set_title("Sensing and transmission events (tx markers by radio mode)")
|
|
146
|
+
|
|
147
|
+
# AoI
|
|
148
|
+
ax = axes[2, 0]
|
|
149
|
+
ax.plot(t, step_series(log.aoi_s) / 3600.0, color="tab:purple")
|
|
150
|
+
ax.set_title("Age of information at the application [h]")
|
|
151
|
+
|
|
152
|
+
# priority
|
|
153
|
+
ax = axes[2, 1]
|
|
154
|
+
ax.step(t, step_series(log.priority), where="post", color="tab:red")
|
|
155
|
+
ax.set_yticks([0, 1, 2])
|
|
156
|
+
ax.set_yticklabels(PRIORITY_NAMES, fontsize=7)
|
|
157
|
+
ax.set_ylim(-0.2, 2.2)
|
|
158
|
+
ax.set_title("Application priority")
|
|
159
|
+
|
|
160
|
+
# reward
|
|
161
|
+
ax = axes[3, 0]
|
|
162
|
+
ax.plot(t, step_series(log.reward), color="tab:gray", lw=0.7, label="reward")
|
|
163
|
+
ax.plot(t, step_series(log.utility), color="tab:blue", lw=0.9, label="utility")
|
|
164
|
+
ax.legend(loc="upper right", fontsize=7)
|
|
165
|
+
ax.set_title("Instantaneous reward")
|
|
166
|
+
ax.set_xlabel("time [days]")
|
|
167
|
+
|
|
168
|
+
ax = axes[3, 1]
|
|
169
|
+
if len(log):
|
|
170
|
+
ax.plot(t, np.cumsum(step_series(log.reward)), color="tab:gray")
|
|
171
|
+
ax.set_title("Cumulative reward")
|
|
172
|
+
ax.set_xlabel("time [days]")
|
|
173
|
+
|
|
174
|
+
self._title.set_text(text_dashboard(env))
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
def render(self, mode: str):
|
|
178
|
+
if mode == "ansi":
|
|
179
|
+
return text_dashboard(self.env)
|
|
180
|
+
self._ensure_figure(mode)
|
|
181
|
+
self._draw()
|
|
182
|
+
if mode == "rgb_array":
|
|
183
|
+
self.fig.canvas.draw()
|
|
184
|
+
buf = np.asarray(self.fig.canvas.buffer_rgba())
|
|
185
|
+
return buf[..., :3].copy()
|
|
186
|
+
# human
|
|
187
|
+
self.fig.canvas.draw_idle()
|
|
188
|
+
if self._interactive:
|
|
189
|
+
import matplotlib.pyplot as plt
|
|
190
|
+
|
|
191
|
+
plt.pause(1.0 / self.env.metadata["render_fps"])
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
def close(self) -> None:
|
|
195
|
+
if self.fig is not None and self._pyplot_managed:
|
|
196
|
+
import matplotlib.pyplot as plt
|
|
197
|
+
|
|
198
|
+
plt.close(self.fig)
|
|
199
|
+
self.fig = None
|