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