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,195 @@
|
|
|
1
|
+
"""Simulated clock, energy storage and solar harvesting.
|
|
2
|
+
|
|
3
|
+
The storage update implemented by the environment is
|
|
4
|
+
|
|
5
|
+
E(t+1) = clip(E(t) + harvested(t) - baseline(t) - sensing(t) - comm(t), 0, E_max)
|
|
6
|
+
|
|
7
|
+
with all terms in joules. This module provides the building blocks; the
|
|
8
|
+
ordering of the terms and the feasibility rule are handled in ``env.py``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from .config import EnergyStorageConfig, HarvestingConfig, TimeConfig
|
|
18
|
+
|
|
19
|
+
DAY_S = 86400.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Clock
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
class SimulatedClock:
|
|
26
|
+
"""Discrete clock advancing by a fixed timestep."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, cfg: TimeConfig):
|
|
29
|
+
self.cfg = cfg
|
|
30
|
+
self.reset()
|
|
31
|
+
|
|
32
|
+
def reset(self) -> None:
|
|
33
|
+
self._t = self.cfg.start_hour * 3600.0
|
|
34
|
+
|
|
35
|
+
def advance(self) -> None:
|
|
36
|
+
self._t += self.cfg.timestep_s
|
|
37
|
+
|
|
38
|
+
def now_s(self) -> float:
|
|
39
|
+
return self._t
|
|
40
|
+
|
|
41
|
+
def time_of_day_s(self) -> float:
|
|
42
|
+
return self._t % DAY_S
|
|
43
|
+
|
|
44
|
+
def hour_of_day(self) -> float:
|
|
45
|
+
return self.time_of_day_s() / 3600.0
|
|
46
|
+
|
|
47
|
+
def day_index(self) -> int:
|
|
48
|
+
return int(self._t // DAY_S)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Energy storage
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
class SimulatedEnergyStorage:
|
|
55
|
+
"""Ideal (lossless) finite energy buffer with bookkeeping.
|
|
56
|
+
|
|
57
|
+
Battery ageing, temperature effects and charge/discharge efficiency are
|
|
58
|
+
intentionally omitted; ``charge_efficiency`` is the single knob left to
|
|
59
|
+
approximate a real cell (1.0 = ideal). Real hardware replaces this class by
|
|
60
|
+
a fuel-gauge reading (``energy_j``) - the bookkeeping methods are only used
|
|
61
|
+
by the simulator.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, cfg: EnergyStorageConfig, charge_efficiency: float = 1.0):
|
|
65
|
+
self.cfg = cfg
|
|
66
|
+
self.charge_efficiency = charge_efficiency
|
|
67
|
+
self._energy = cfg.capacity_j * cfg.initial_soc
|
|
68
|
+
self.wasted_j = 0.0 # harvested energy that could not be stored (full buffer)
|
|
69
|
+
|
|
70
|
+
def reset(self, rng: np.random.Generator) -> None:
|
|
71
|
+
if self.cfg.initial_soc_range is not None:
|
|
72
|
+
soc = rng.uniform(*self.cfg.initial_soc_range)
|
|
73
|
+
else:
|
|
74
|
+
soc = self.cfg.initial_soc
|
|
75
|
+
self._energy = float(np.clip(soc, 0.0, 1.0)) * self.cfg.capacity_j
|
|
76
|
+
self.wasted_j = 0.0
|
|
77
|
+
|
|
78
|
+
# -- EnergyStorage protocol -------------------------------------------
|
|
79
|
+
def capacity_j(self) -> float:
|
|
80
|
+
return self.cfg.capacity_j
|
|
81
|
+
|
|
82
|
+
def energy_j(self) -> float:
|
|
83
|
+
return self._energy
|
|
84
|
+
|
|
85
|
+
def soc(self) -> float:
|
|
86
|
+
return self._energy / self.cfg.capacity_j
|
|
87
|
+
|
|
88
|
+
def reserve_j(self) -> float:
|
|
89
|
+
return self.cfg.reserve_soc * self.cfg.capacity_j
|
|
90
|
+
|
|
91
|
+
# -- simulator-only bookkeeping -----------------------------------------
|
|
92
|
+
def charge(self, energy_j: float) -> float:
|
|
93
|
+
"""Add harvested energy; returns the amount actually stored."""
|
|
94
|
+
usable = max(0.0, energy_j) * self.charge_efficiency
|
|
95
|
+
room = self.cfg.capacity_j - self._energy
|
|
96
|
+
stored = min(usable, room)
|
|
97
|
+
self.wasted_j += usable - stored
|
|
98
|
+
self._energy += stored
|
|
99
|
+
return stored
|
|
100
|
+
|
|
101
|
+
def discharge(self, energy_j: float) -> float:
|
|
102
|
+
"""Remove energy; returns the amount actually delivered (the buffer
|
|
103
|
+
cannot go negative, so a depleted buffer delivers less than asked)."""
|
|
104
|
+
delivered = min(max(0.0, energy_j), self._energy)
|
|
105
|
+
self._energy -= delivered
|
|
106
|
+
return delivered
|
|
107
|
+
|
|
108
|
+
def can_afford(self, energy_j: float, keep_reserve: bool = True) -> bool:
|
|
109
|
+
floor = self.reserve_j() if keep_reserve else 0.0
|
|
110
|
+
return self._energy - energy_j >= floor
|
|
111
|
+
|
|
112
|
+
def is_depleted(self) -> bool:
|
|
113
|
+
return self._energy <= 1e-12
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Solar harvesting
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
class SolarEnergySource:
|
|
120
|
+
"""Stochastic daily solar cycle with cloud attenuation.
|
|
121
|
+
|
|
122
|
+
ground-truth power: P(t) = P_max * profile(t) * cloud(t) * efficiency
|
|
123
|
+
profile(t) = sin(pi * (h - sunrise) / (sunset - sunrise)) during daylight, else 0
|
|
124
|
+
cloud(t) = clip(k_day + c(t), 0, 1) where
|
|
125
|
+
k_day ~ AR(1) across days around ``clearness_mean``
|
|
126
|
+
c(t) ~ AR(1) within the day (fast cloud passages)
|
|
127
|
+
|
|
128
|
+
The node observes a *noisy* measurement of P(t) (``measured_power_w``),
|
|
129
|
+
never the future profile.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(self, cfg: HarvestingConfig, timestep_s: float):
|
|
133
|
+
self.cfg = cfg
|
|
134
|
+
self.dt = timestep_s
|
|
135
|
+
self._rng = np.random.default_rng()
|
|
136
|
+
self.reset(self._rng, start_time_s=0.0)
|
|
137
|
+
|
|
138
|
+
def reset(self, rng: np.random.Generator, start_time_s: float) -> None:
|
|
139
|
+
self._rng = rng
|
|
140
|
+
self._day = int(start_time_s // DAY_S)
|
|
141
|
+
self._k_day = self._draw_clearness(prev=None)
|
|
142
|
+
self._cloud_dev = 0.0
|
|
143
|
+
self._power_w = 0.0
|
|
144
|
+
self._measured_w = 0.0
|
|
145
|
+
self.update(start_time_s)
|
|
146
|
+
|
|
147
|
+
def _draw_clearness(self, prev: float | None) -> float:
|
|
148
|
+
c = self.cfg
|
|
149
|
+
if prev is None:
|
|
150
|
+
k = self._rng.normal(c.clearness_mean, c.clearness_std)
|
|
151
|
+
else:
|
|
152
|
+
k = c.clearness_mean + c.clearness_autocorr * (prev - c.clearness_mean) + math.sqrt(
|
|
153
|
+
max(0.0, 1 - c.clearness_autocorr**2)
|
|
154
|
+
) * self._rng.normal(0.0, c.clearness_std)
|
|
155
|
+
return float(np.clip(k, 0.05, 1.0))
|
|
156
|
+
|
|
157
|
+
def solar_profile(self, time_s: float) -> float:
|
|
158
|
+
"""Deterministic clear-sky shape in [0, 1] for a given wall-clock time."""
|
|
159
|
+
h = (time_s % DAY_S) / 3600.0
|
|
160
|
+
c = self.cfg
|
|
161
|
+
if h <= c.sunrise_hour or h >= c.sunset_hour:
|
|
162
|
+
return 0.0
|
|
163
|
+
return math.sin(math.pi * (h - c.sunrise_hour) / (c.sunset_hour - c.sunrise_hour))
|
|
164
|
+
|
|
165
|
+
def update(self, time_s: float) -> None:
|
|
166
|
+
"""Advance the stochastic processes and compute the power for the
|
|
167
|
+
interval starting at ``time_s``."""
|
|
168
|
+
c = self.cfg
|
|
169
|
+
day = int(time_s // DAY_S)
|
|
170
|
+
if day != self._day:
|
|
171
|
+
self._k_day = self._draw_clearness(prev=self._k_day)
|
|
172
|
+
self._day = day
|
|
173
|
+
self._cloud_dev = c.cloud_autocorr * self._cloud_dev + math.sqrt(
|
|
174
|
+
max(0.0, 1 - c.cloud_autocorr**2)
|
|
175
|
+
) * self._rng.normal(0.0, c.cloud_noise_std)
|
|
176
|
+
cloud = float(np.clip(self._k_day + self._cloud_dev, 0.0, 1.0))
|
|
177
|
+
self._power_w = c.max_power_w * self.solar_profile(time_s) * cloud * c.efficiency
|
|
178
|
+
noise = 1.0 + self._rng.normal(0.0, c.measurement_noise_std)
|
|
179
|
+
self._measured_w = max(0.0, self._power_w * noise)
|
|
180
|
+
|
|
181
|
+
# -- EnergySource protocol (measurable) --------------------------------
|
|
182
|
+
def measured_power_w(self) -> float:
|
|
183
|
+
return self._measured_w
|
|
184
|
+
|
|
185
|
+
# -- simulator-only ground truth ----------------------------------------
|
|
186
|
+
def true_power_w(self) -> float:
|
|
187
|
+
return self._power_w
|
|
188
|
+
|
|
189
|
+
def harvested_energy_j(self) -> float:
|
|
190
|
+
"""Energy delivered to the storage during the current interval."""
|
|
191
|
+
return self._power_w * self.dt
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def daily_clearness(self) -> float:
|
|
195
|
+
return self._k_day
|
edgeengine_aware/env.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Gymnasium environment of EdgeEngine AWARE.
|
|
2
|
+
|
|
3
|
+
Timeline of one ``step(action)`` call (decision taken at time t, step dt):
|
|
4
|
+
|
|
5
|
+
1. **Feasibility** - using the energy stored at t (what a fuel gauge would
|
|
6
|
+
report) the node checks which requested operations it can afford after
|
|
7
|
+
reserving the baseline consumption of the interval and the brown-out
|
|
8
|
+
reserve. Operations it cannot afford are *rejected* (not executed, no
|
|
9
|
+
energy spent, small penalty). Sensing has priority over transmission.
|
|
10
|
+
2. **Sensing** - the sensor samples the *true* field at t with the noise of
|
|
11
|
+
the selected level; the measurement is stored on the node.
|
|
12
|
+
3. **Transmission** - the latest stored measurement is put in a packet and
|
|
13
|
+
sent; energy is spent regardless of the outcome. On delivery the remote
|
|
14
|
+
application updates its picture of the field and credits a packet bonus.
|
|
15
|
+
4. **Energy update** - E(t+dt) = clip(E(t) + harvested - baseline - sensing - comm, 0, E_max).
|
|
16
|
+
Consumption is drawn first and the energy harvested during the interval
|
|
17
|
+
is stored afterwards (a conservative choice: the interval's own harvest
|
|
18
|
+
cannot pay for the interval's load). If the baseline load cannot be
|
|
19
|
+
served the node *browns out* (depletion).
|
|
20
|
+
5. **World update** - clock, field, harvesting process, channel and
|
|
21
|
+
application priority advance to t + dt.
|
|
22
|
+
6. **Reward** - tracking utility of the application's (possibly stale)
|
|
23
|
+
picture against the new ground truth, plus the packet bonus, minus costs
|
|
24
|
+
and penalties (see ``application.py`` and ``reward.py``).
|
|
25
|
+
7. **Observation** - the node-side tracker is refreshed with measurable
|
|
26
|
+
quantities only and the ObservationBuilder produces the vector for t + dt.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import math
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
import gymnasium as gym
|
|
35
|
+
import numpy as np
|
|
36
|
+
from gymnasium import spaces
|
|
37
|
+
|
|
38
|
+
from .actions import SENSE_NONE, action_nvec, decode_action, plan_execution
|
|
39
|
+
from .application import RemoteMonitoringApplication
|
|
40
|
+
from .communication import SimulatedLoRaRadio
|
|
41
|
+
from .config import EdgeEngineAwareConfig, default_config, randomize_config
|
|
42
|
+
from .domains import build_world
|
|
43
|
+
from .energy import SimulatedClock, SimulatedEnergyStorage
|
|
44
|
+
from .interfaces import Packet
|
|
45
|
+
from .metrics import EpisodeLog, EpisodeMetrics
|
|
46
|
+
from .observation import NodeProfile, NodeStateTracker, ObservationBuilder
|
|
47
|
+
from .reward import RewardCalculator
|
|
48
|
+
from .sensing import SimulatedSoilMoistureSensor
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class EdgeEngineAwareEnv(gym.Env):
|
|
52
|
+
"""Energy-harvesting Edge IoT node in a smart-agriculture field.
|
|
53
|
+
|
|
54
|
+
The hidden world (monitored process and energy source) is chosen by
|
|
55
|
+
``config.domain`` / ``config.harvesting_source`` - see ``domains.py``.
|
|
56
|
+
|
|
57
|
+
Observation: ``Box(0, 1, (18,), float32)`` - see ``ObservationBuilder``.
|
|
58
|
+
Action: ``MultiDiscrete([3, 1 + n_modes])`` - (sensing level, transmit /
|
|
59
|
+
radio mode); ``[3, 4]`` with the default three-mode radio.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
metadata = {"render_modes": ["human", "rgb_array", "ansi"], "render_fps": 10}
|
|
63
|
+
|
|
64
|
+
def __init__(self, config: EdgeEngineAwareConfig | None = None, render_mode: str | None = None):
|
|
65
|
+
super().__init__()
|
|
66
|
+
self.base_config = config if config is not None else default_config()
|
|
67
|
+
self.base_config.validate()
|
|
68
|
+
if render_mode is not None and render_mode not in self.metadata["render_modes"]:
|
|
69
|
+
raise ValueError(f"unsupported render_mode {render_mode!r}")
|
|
70
|
+
self.render_mode = render_mode
|
|
71
|
+
|
|
72
|
+
# Spaces are independent from the (possibly randomised) physical parameters.
|
|
73
|
+
self.cfg = self.base_config.copy()
|
|
74
|
+
self.profile = NodeProfile.from_config(self.cfg)
|
|
75
|
+
self.obs_builder = ObservationBuilder(self.profile)
|
|
76
|
+
self.observation_space = spaces.Box(self.obs_builder.low, self.obs_builder.high, dtype=np.float32)
|
|
77
|
+
self.action_space = spaces.MultiDiscrete(np.array(action_nvec(self.cfg.communication.n_modes), dtype=np.int64))
|
|
78
|
+
|
|
79
|
+
self._build_subsystems(self.cfg)
|
|
80
|
+
self._renderer = None
|
|
81
|
+
self._step_count = 0
|
|
82
|
+
self._last_info: dict[str, Any] = {}
|
|
83
|
+
self._last_measured_harvest_w = 0.0
|
|
84
|
+
self._last_harvested_j = 0.0
|
|
85
|
+
self._last_step: dict[str, Any] = self._empty_last_step()
|
|
86
|
+
self.metrics = EpisodeMetrics()
|
|
87
|
+
self.log = EpisodeLog()
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _empty_last_step() -> dict[str, Any]:
|
|
91
|
+
return {"sensing_level": SENSE_NONE, "tx_attempted": False, "tx_mode": -1, "tx_success": None, "reward": 0.0, "utility": 0.0}
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
# construction helpers
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
def _build_subsystems(self, cfg: EdgeEngineAwareConfig) -> None:
|
|
97
|
+
dt = cfg.time.timestep_s
|
|
98
|
+
self.clock = SimulatedClock(cfg.time)
|
|
99
|
+
self.storage = SimulatedEnergyStorage(cfg.storage, charge_efficiency=cfg.storage.charge_efficiency)
|
|
100
|
+
# the hidden world of the active domain: (weekly schedule or None, monitored process, energy source)
|
|
101
|
+
self.schedule, self.process, self.source = build_world(cfg, dt)
|
|
102
|
+
self.field = self.process # legacy name (the agriculture FieldEnvironment)
|
|
103
|
+
self.quantity = cfg.quantity
|
|
104
|
+
self.sensor = SimulatedSoilMoistureSensor(cfg.sensing, true_value=lambda: self.process.value)
|
|
105
|
+
self.radio = SimulatedLoRaRadio(cfg.communication)
|
|
106
|
+
self.application = RemoteMonitoringApplication(cfg.application, self.quantity, dt)
|
|
107
|
+
self.reward_fn = RewardCalculator(cfg.reward)
|
|
108
|
+
self.profile = NodeProfile.from_config(cfg)
|
|
109
|
+
self.obs_builder = ObservationBuilder(self.profile)
|
|
110
|
+
self.tracker = NodeStateTracker(self.profile)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def max_steps(self) -> int:
|
|
114
|
+
return self.cfg.time.max_steps
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def timestep_s(self) -> float:
|
|
118
|
+
return self.cfg.time.timestep_s
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
# Gymnasium API
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
def reset(self, *, seed: int | None = None, options: dict[str, Any] | None = None):
|
|
124
|
+
super().reset(seed=seed)
|
|
125
|
+
# (Re)build the physical parameters, applying domain randomisation.
|
|
126
|
+
self.cfg = randomize_config(self.base_config, self.np_random)
|
|
127
|
+
self._build_subsystems(self.cfg)
|
|
128
|
+
|
|
129
|
+
start = self.cfg.time.start_hour * 3600.0
|
|
130
|
+
dt = self.cfg.time.timestep_s
|
|
131
|
+
rngs = [np.random.default_rng(int(self.np_random.integers(0, 2**63 - 1))) for _ in range(5)]
|
|
132
|
+
app_rng = np.random.default_rng(int(self.np_random.integers(0, 2**63 - 1)))
|
|
133
|
+
if self.schedule is not None: # domains with a weekly activity schedule draw one more stream
|
|
134
|
+
self.schedule.reset(np.random.default_rng(int(self.np_random.integers(0, 2**63 - 1))), start_time_s=start)
|
|
135
|
+
self.storage.reset(rngs[0])
|
|
136
|
+
self.process.reset(rngs[2], start_time_s=start)
|
|
137
|
+
# The harvesting process is started one interval early so that the node's
|
|
138
|
+
# first reading is the power of the interval that *preceded* the episode
|
|
139
|
+
# (what a harvester monitor reports at boot), never the upcoming one.
|
|
140
|
+
self.source.reset(rngs[1], start_time_s=start - dt)
|
|
141
|
+
previous_interval_measured_w = self.source.measured_power_w()
|
|
142
|
+
self.source.update(start)
|
|
143
|
+
self.sensor.reset(rngs[3])
|
|
144
|
+
self.radio.reset(rngs[4])
|
|
145
|
+
self.application.reset(app_rng, start_time_s=start)
|
|
146
|
+
self.clock.reset()
|
|
147
|
+
self.tracker.reset()
|
|
148
|
+
|
|
149
|
+
self._step_count = 0
|
|
150
|
+
self._last_measured_harvest_w = previous_interval_measured_w
|
|
151
|
+
self._last_harvested_j = 0.0
|
|
152
|
+
self.metrics = EpisodeMetrics()
|
|
153
|
+
self.log = EpisodeLog()
|
|
154
|
+
self._last_step = self._empty_last_step()
|
|
155
|
+
|
|
156
|
+
self.application.step(self.clock.now_s(), self.process.state())
|
|
157
|
+
self.tracker.begin_step(
|
|
158
|
+
now_s=self.clock.now_s(),
|
|
159
|
+
time_of_day_s=self.clock.time_of_day_s(),
|
|
160
|
+
energy_j=self.storage.energy_j(),
|
|
161
|
+
capacity_j=self.storage.capacity_j(),
|
|
162
|
+
harvest_power_w=self._last_measured_harvest_w,
|
|
163
|
+
priority=self.application.priority(), # initial downlink assumed at join
|
|
164
|
+
)
|
|
165
|
+
obs = self.obs_builder.build(self.tracker.state())
|
|
166
|
+
info = self._make_info(reward_components=None, utility_breakdown=None, rejected=[])
|
|
167
|
+
self._last_info = info
|
|
168
|
+
return obs, info
|
|
169
|
+
|
|
170
|
+
def step(self, action):
|
|
171
|
+
act = decode_action(action, n_modes=self.cfg.communication.n_modes)
|
|
172
|
+
cfg = self.cfg
|
|
173
|
+
dt = cfg.time.timestep_s
|
|
174
|
+
now = self.clock.now_s()
|
|
175
|
+
truth_before = self.process.state()
|
|
176
|
+
|
|
177
|
+
# 1. feasibility (same rule as the firmware loop, see actions.plan_execution)
|
|
178
|
+
baseline_j = cfg.mcu.baseline_power_w * dt
|
|
179
|
+
plan = plan_execution(
|
|
180
|
+
act,
|
|
181
|
+
stored_energy_j=self.storage.energy_j(),
|
|
182
|
+
baseline_energy_j=baseline_j,
|
|
183
|
+
reserve_energy_j=self.storage.reserve_j(),
|
|
184
|
+
sensing_energy_j=tuple(self.sensor.energy_cost_j(l) for l in range(cfg.sensing.n_levels)),
|
|
185
|
+
tx_energy_j=tuple(self.radio.tx_energy_j(k) for k in range(self.radio.n_modes())),
|
|
186
|
+
has_measurement=self.tracker.measurement is not None,
|
|
187
|
+
)
|
|
188
|
+
rejected = list(plan.rejected)
|
|
189
|
+
sensing_level, sensing_j = plan.sensing_level, plan.sensing_energy_j
|
|
190
|
+
tx_executed, tx_j, tx_mode = plan.transmit, plan.tx_energy_j, plan.mode
|
|
191
|
+
|
|
192
|
+
# 2. sensing ----------------------------------------------------------
|
|
193
|
+
measurement = None
|
|
194
|
+
if sensing_level != SENSE_NONE:
|
|
195
|
+
measurement = self.sensor.read(sensing_level, now)
|
|
196
|
+
self.tracker.on_measurement(measurement)
|
|
197
|
+
|
|
198
|
+
# 3. transmission -----------------------------------------------------
|
|
199
|
+
tx_success: bool | None = None
|
|
200
|
+
utility = 0.0
|
|
201
|
+
utility_breakdown = None
|
|
202
|
+
if tx_executed:
|
|
203
|
+
packet = Packet(measurement=self.tracker.measurement, sent_at_s=now) # type: ignore[arg-type]
|
|
204
|
+
result = self.radio.transmit(packet, tx_mode)
|
|
205
|
+
tx_success = result.acked
|
|
206
|
+
# without confirmations the node cannot know the outcome (None)
|
|
207
|
+
self.tracker.on_transmission(packet, tx_success if cfg.communication.ack_available else None, now, mode=tx_mode, margin_db=result.margin_db)
|
|
208
|
+
if tx_success:
|
|
209
|
+
utility_breakdown = self.application.receive(packet, now, truth_before)
|
|
210
|
+
utility = utility_breakdown.total
|
|
211
|
+
if cfg.communication.priority_update_mode == "on_uplink":
|
|
212
|
+
self.tracker.set_priority(self.application.priority())
|
|
213
|
+
|
|
214
|
+
# 4. energy update ----------------------------------------------------
|
|
215
|
+
requested = baseline_j + sensing_j + tx_j
|
|
216
|
+
delivered = self.storage.discharge(requested)
|
|
217
|
+
depleted = delivered + 1e-9 < requested # brown-out: the load could not be served
|
|
218
|
+
harvested_j = self.source.harvested_energy_j()
|
|
219
|
+
wasted_before = self.storage.wasted_j
|
|
220
|
+
self.storage.charge(harvested_j)
|
|
221
|
+
wasted_j = self.storage.wasted_j - wasted_before
|
|
222
|
+
self._last_measured_harvest_w = self.source.measured_power_w()
|
|
223
|
+
self._last_harvested_j = harvested_j
|
|
224
|
+
|
|
225
|
+
# 5. world update -----------------------------------------------------
|
|
226
|
+
self.clock.advance()
|
|
227
|
+
t_next = self.clock.now_s()
|
|
228
|
+
if self.schedule is not None:
|
|
229
|
+
self.schedule.step() # within-day perturbation of the activity level
|
|
230
|
+
truth_after = self.process.step(now) # dynamics over [now, now + dt]
|
|
231
|
+
self.source.update(t_next)
|
|
232
|
+
self.radio.update_channel()
|
|
233
|
+
self.application.step(t_next, truth_after)
|
|
234
|
+
self._step_count += 1
|
|
235
|
+
|
|
236
|
+
# 6. reward -----------------------------------------------------------
|
|
237
|
+
aoi = self.application.age_of_information_s(t_next)
|
|
238
|
+
tracking = self.application.tracking(truth_after) # privileged evaluation
|
|
239
|
+
utility += tracking.utility
|
|
240
|
+
comps = self.reward_fn.compute(
|
|
241
|
+
utility=utility,
|
|
242
|
+
sensing_energy_j=sensing_j,
|
|
243
|
+
communication_energy_j=tx_j,
|
|
244
|
+
aoi_s=aoi,
|
|
245
|
+
priority=self.application.priority(),
|
|
246
|
+
soc_after=self.storage.soc(),
|
|
247
|
+
depleted=depleted,
|
|
248
|
+
n_rejected=len(rejected),
|
|
249
|
+
wasted_energy_j=wasted_j,
|
|
250
|
+
)
|
|
251
|
+
reward = float(comps.total)
|
|
252
|
+
|
|
253
|
+
# 7. observation ------------------------------------------------------
|
|
254
|
+
if cfg.communication.priority_update_mode == "immediate":
|
|
255
|
+
prio_downlink: int | None = self.application.priority()
|
|
256
|
+
else:
|
|
257
|
+
prio_downlink = None # only refreshed with an ACK (handled above)
|
|
258
|
+
self.tracker.begin_step(
|
|
259
|
+
now_s=t_next,
|
|
260
|
+
time_of_day_s=self.clock.time_of_day_s(),
|
|
261
|
+
energy_j=self.storage.energy_j(),
|
|
262
|
+
capacity_j=self.storage.capacity_j(),
|
|
263
|
+
harvest_power_w=self._last_measured_harvest_w,
|
|
264
|
+
priority=prio_downlink,
|
|
265
|
+
)
|
|
266
|
+
obs = self.obs_builder.build(self.tracker.state())
|
|
267
|
+
|
|
268
|
+
# 8. bookkeeping ------------------------------------------------------
|
|
269
|
+
self._update_metrics(
|
|
270
|
+
harvested_j=harvested_j,
|
|
271
|
+
baseline_j=baseline_j,
|
|
272
|
+
sensing_j=sensing_j,
|
|
273
|
+
tx_j=tx_j,
|
|
274
|
+
wasted_j=wasted_j,
|
|
275
|
+
sensing_level=sensing_level,
|
|
276
|
+
tx_executed=tx_executed,
|
|
277
|
+
tx_mode=tx_mode,
|
|
278
|
+
tx_success=tx_success,
|
|
279
|
+
rejected=len(rejected),
|
|
280
|
+
depleted=depleted,
|
|
281
|
+
aoi=aoi,
|
|
282
|
+
utility=utility,
|
|
283
|
+
reward=reward,
|
|
284
|
+
comps=comps,
|
|
285
|
+
)
|
|
286
|
+
self._last_step = {
|
|
287
|
+
"sensing_level": sensing_level,
|
|
288
|
+
"tx_attempted": tx_executed,
|
|
289
|
+
"tx_mode": tx_mode,
|
|
290
|
+
"tx_success": tx_success,
|
|
291
|
+
"reward": reward,
|
|
292
|
+
"utility": utility,
|
|
293
|
+
"requested_action": (act.sensing_level, act.transmit),
|
|
294
|
+
}
|
|
295
|
+
self._append_log(truth_after, aoi, sensing_level, tx_executed, tx_mode, tx_success, reward, utility)
|
|
296
|
+
|
|
297
|
+
terminated = bool(cfg.terminate_on_depletion and depleted)
|
|
298
|
+
truncated = self._step_count >= self.max_steps
|
|
299
|
+
info = self._make_info(comps, utility_breakdown, rejected)
|
|
300
|
+
info["tracking"] = tracking.as_dict()
|
|
301
|
+
self._last_info = info
|
|
302
|
+
return obs, reward, terminated, truncated, info
|
|
303
|
+
|
|
304
|
+
def render(self):
|
|
305
|
+
if self.render_mode is None:
|
|
306
|
+
gym.logger.warn("render() called without a render_mode; nothing will be produced.")
|
|
307
|
+
return None
|
|
308
|
+
from .rendering import DashboardRenderer # lazy: keeps matplotlib optional for training
|
|
309
|
+
|
|
310
|
+
if self._renderer is None:
|
|
311
|
+
self._renderer = DashboardRenderer(self)
|
|
312
|
+
return self._renderer.render(self.render_mode)
|
|
313
|
+
|
|
314
|
+
def close(self):
|
|
315
|
+
if self._renderer is not None:
|
|
316
|
+
self._renderer.close()
|
|
317
|
+
self._renderer = None
|
|
318
|
+
|
|
319
|
+
# ------------------------------------------------------------------
|
|
320
|
+
# privileged accessors (evaluation / rendering only)
|
|
321
|
+
# ------------------------------------------------------------------
|
|
322
|
+
def ground_truth(self) -> dict[str, Any]:
|
|
323
|
+
"""Simulator-only state. Never feed this to a policy."""
|
|
324
|
+
fs = self.process.state()
|
|
325
|
+
aux = dict(fs.aux)
|
|
326
|
+
return {
|
|
327
|
+
"time_s": self.clock.now_s(),
|
|
328
|
+
"domain": self.cfg.domain,
|
|
329
|
+
"value": fs.value, # normalised monitored quantity
|
|
330
|
+
"physical_value": self.quantity.to_physical(fs.value),
|
|
331
|
+
"quantity_name": self.quantity.name,
|
|
332
|
+
"quantity_unit": self.quantity.unit,
|
|
333
|
+
"soil_moisture": fs.value, # legacy name of the normalised value
|
|
334
|
+
"air_temperature_c": aux.get("air_temperature_c", aux.get("temperature_c")), # None when the domain has none
|
|
335
|
+
"relative_humidity": aux.get("relative_humidity"),
|
|
336
|
+
"activity": self.schedule.level(self.clock.now_s()) if self.schedule is not None else None,
|
|
337
|
+
"process": aux,
|
|
338
|
+
"zone": fs.zone,
|
|
339
|
+
"event_occurred": fs.event_occurred,
|
|
340
|
+
"harvest_power_true_w": self.source.true_power_w(),
|
|
341
|
+
"daily_clearness": self.source.daily_clearness,
|
|
342
|
+
"path_loss_db": self.radio.path_loss_db(),
|
|
343
|
+
"tx_success_probability": self.radio.success_probability(), # reference mode
|
|
344
|
+
"tx_success_probability_per_mode": [self.radio.success_probability(k) for k in range(self.radio.n_modes())],
|
|
345
|
+
"stored_energy_j": self.storage.energy_j(),
|
|
346
|
+
"app_aoi_s": self.application.age_of_information_s(),
|
|
347
|
+
"app_priority": self.application.priority(),
|
|
348
|
+
"app_last_value": None if self.application.last_packet is None else self.application.last_packet.measurement.value,
|
|
349
|
+
"unreported_event": self.application.has_unreported_event,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
def node_state(self):
|
|
353
|
+
"""Hardware-measurable state (what a real node knows)."""
|
|
354
|
+
return self.tracker.state()
|
|
355
|
+
|
|
356
|
+
# ------------------------------------------------------------------
|
|
357
|
+
# internals
|
|
358
|
+
# ------------------------------------------------------------------
|
|
359
|
+
def _make_info(self, reward_components, utility_breakdown, rejected: list[str]) -> dict[str, Any]:
|
|
360
|
+
info: dict[str, Any] = {
|
|
361
|
+
"step": self._step_count,
|
|
362
|
+
"time_s": self.clock.now_s(),
|
|
363
|
+
"hour_of_day": self.clock.hour_of_day(),
|
|
364
|
+
"day": self.clock.day_index(),
|
|
365
|
+
"battery_soc": self.storage.soc(),
|
|
366
|
+
"stored_energy_j": self.storage.energy_j(),
|
|
367
|
+
"harvested_energy_j": self._last_harvested_j,
|
|
368
|
+
"executed_action": (self._last_step["sensing_level"], self._last_step["tx_mode"] + 1 if self._last_step["tx_attempted"] else 0),
|
|
369
|
+
"sensing_level": self._last_step["sensing_level"],
|
|
370
|
+
"tx_attempted": self._last_step["tx_attempted"],
|
|
371
|
+
"tx_mode": self._last_step["tx_mode"],
|
|
372
|
+
"tx_success": self._last_step["tx_success"],
|
|
373
|
+
"rejected": list(rejected),
|
|
374
|
+
"app_priority": self.application.priority(), # what the application wants now
|
|
375
|
+
"node_priority": self.tracker.priority, # what the node has been told (differs in 'on_uplink' mode)
|
|
376
|
+
"app_aoi_s": self.application.age_of_information_s(),
|
|
377
|
+
"reward_components": reward_components.as_dict() if reward_components is not None else {},
|
|
378
|
+
"utility_breakdown": utility_breakdown.as_dict() if utility_breakdown is not None else {},
|
|
379
|
+
"metrics": self.metrics.as_dict(),
|
|
380
|
+
"ground_truth": self.ground_truth(), # privileged: evaluation only
|
|
381
|
+
"observation_names": self.obs_builder.names,
|
|
382
|
+
}
|
|
383
|
+
return info
|
|
384
|
+
|
|
385
|
+
def _update_metrics(self, *, harvested_j, baseline_j, sensing_j, tx_j, wasted_j, sensing_level, tx_executed, tx_mode, tx_success, rejected, depleted, aoi, utility, reward, comps) -> None:
|
|
386
|
+
m = self.metrics
|
|
387
|
+
m.steps += 1
|
|
388
|
+
m.total_harvested_energy_j += harvested_j
|
|
389
|
+
m.total_consumed_energy_j += baseline_j + sensing_j + tx_j
|
|
390
|
+
m.baseline_energy_j += baseline_j
|
|
391
|
+
m.sensing_energy_j += sensing_j
|
|
392
|
+
m.communication_energy_j += tx_j
|
|
393
|
+
m.wasted_harvest_energy_j += wasted_j
|
|
394
|
+
if sensing_level != SENSE_NONE:
|
|
395
|
+
m.n_sensing += 1
|
|
396
|
+
if sensing_level == 2:
|
|
397
|
+
m.n_high_quality_sensing += 1
|
|
398
|
+
if tx_executed:
|
|
399
|
+
m.n_transmissions += 1
|
|
400
|
+
m.transmissions_per_mode[tx_mode] = m.transmissions_per_mode.get(tx_mode, 0) + 1
|
|
401
|
+
if tx_success:
|
|
402
|
+
m.n_successful_transmissions += 1
|
|
403
|
+
m.deliveries_per_mode[tx_mode] = m.deliveries_per_mode.get(tx_mode, 0) + 1
|
|
404
|
+
m.n_rejected_actions += rejected
|
|
405
|
+
if depleted:
|
|
406
|
+
m.battery_depletion_events += 1
|
|
407
|
+
soc = self.storage.soc()
|
|
408
|
+
m._soc_sum += soc
|
|
409
|
+
m.min_battery_soc = min(m.min_battery_soc, soc)
|
|
410
|
+
if soc < self.cfg.reward.safe_soc:
|
|
411
|
+
m.steps_low_battery += 1
|
|
412
|
+
m._aoi_sum_s += aoi
|
|
413
|
+
m.max_aoi_s = max(m.max_aoi_s, aoi)
|
|
414
|
+
m.total_application_utility += utility
|
|
415
|
+
m.total_reward += reward
|
|
416
|
+
for k, v in comps.as_dict().items():
|
|
417
|
+
m.reward_components[k] = m.reward_components.get(k, 0.0) + v
|
|
418
|
+
|
|
419
|
+
def _append_log(self, truth, aoi, sensing_level, tx_executed, tx_mode, tx_success, reward, utility) -> None:
|
|
420
|
+
meas = self.tracker.measurement
|
|
421
|
+
app_pkt = self.application.last_packet
|
|
422
|
+
self.log.append(
|
|
423
|
+
time_s=self.clock.now_s(),
|
|
424
|
+
soc=self.storage.soc(),
|
|
425
|
+
harvest_power_w=self._last_measured_harvest_w,
|
|
426
|
+
harvested_energy_j=self._last_harvested_j,
|
|
427
|
+
true_moisture=truth.value,
|
|
428
|
+
measured_moisture=meas.value if meas is not None else math.nan,
|
|
429
|
+
app_moisture=app_pkt.measurement.value if app_pkt is not None else math.nan,
|
|
430
|
+
sensing_level=int(sensing_level),
|
|
431
|
+
tx_attempt=int(tx_executed),
|
|
432
|
+
tx_mode=int(tx_mode),
|
|
433
|
+
tx_success=int(bool(tx_success)),
|
|
434
|
+
path_loss_db=self.radio.path_loss_db(),
|
|
435
|
+
aoi_s=aoi,
|
|
436
|
+
priority=int(self.application.priority()),
|
|
437
|
+
reward=reward,
|
|
438
|
+
utility=utility,
|
|
439
|
+
temperature_c=truth.aux.get("air_temperature_c", truth.aux.get("temperature_c", math.nan)),
|
|
440
|
+
humidity=truth.aux.get("relative_humidity", truth.aux.get("occupancy", truth.aux.get("load", math.nan))),
|
|
441
|
+
)
|