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