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,93 @@
1
+ """EdgeEngine AWARE - application- and energy-aware simulation environment
2
+ for reinforcement learning in energy-harvesting Edge IoT systems.
3
+
4
+ Quick start::
5
+
6
+ import gymnasium as gym
7
+ import edgeengine_aware # registers "EdgeEngineAware-v0"
8
+
9
+ env = gym.make("EdgeEngineAware-v0")
10
+ obs, info = env.reset(seed=0)
11
+ obs, reward, terminated, truncated, info = env.step(env.action_space.sample())
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ __version__ = "0.4.0"
17
+
18
+ from gymnasium.envs.registration import register
19
+
20
+ from .actions import Action, decode_action, describe_action, encode_action, flatten_action, unflatten_action
21
+ from .config import (
22
+ AgricultureConfig,
23
+ ApplicationConfig,
24
+ CommunicationConfig,
25
+ DomainRandomizationConfig,
26
+ EdgeEngineAwareConfig,
27
+ EnergyStorageConfig,
28
+ HarvestingConfig,
29
+ MCUConfig,
30
+ ObservationConfig,
31
+ RewardConfig,
32
+ SensingConfig,
33
+ TimeConfig,
34
+ default_config,
35
+ )
36
+ from .env import EdgeEngineAwareEnv
37
+ from .interfaces import Policy
38
+ from .metrics import EpisodeMetrics
39
+ from .observation import OBSERVATION_FIELDS, NodeProfile, NodeState, ObservationBuilder
40
+ from .policies import AlwaysOnPolicy, PeriodicPolicy, RandomPolicy, RuleBasedParams, RuleBasedPolicy, run_episode
41
+ from .domains import DOMAINS, DOMAIN_NAMES, domain_config
42
+ from .scenarios import SCENARIOS, get_scenario, scenario_names
43
+ from .traces import Trace, TraceDrivenEnv
44
+
45
+ register(
46
+ id="EdgeEngineAware-v0",
47
+ entry_point="edgeengine_aware.env:EdgeEngineAwareEnv",
48
+ max_episode_steps=None, # the environment truncates itself at config.time.max_steps
49
+ )
50
+
51
+ __all__ = [
52
+ "__version__",
53
+ "EdgeEngineAwareEnv",
54
+ "EdgeEngineAwareConfig",
55
+ "default_config",
56
+ "TimeConfig",
57
+ "EnergyStorageConfig",
58
+ "MCUConfig",
59
+ "HarvestingConfig",
60
+ "SensingConfig",
61
+ "CommunicationConfig",
62
+ "AgricultureConfig",
63
+ "ApplicationConfig",
64
+ "RewardConfig",
65
+ "ObservationConfig",
66
+ "DomainRandomizationConfig",
67
+ "Policy",
68
+ "RuleBasedPolicy",
69
+ "RuleBasedParams",
70
+ "RandomPolicy",
71
+ "PeriodicPolicy",
72
+ "AlwaysOnPolicy",
73
+ "run_episode",
74
+ "SCENARIOS",
75
+ "get_scenario",
76
+ "scenario_names",
77
+ "DOMAINS",
78
+ "DOMAIN_NAMES",
79
+ "domain_config",
80
+ "Trace",
81
+ "TraceDrivenEnv",
82
+ "EpisodeMetrics",
83
+ "ObservationBuilder",
84
+ "OBSERVATION_FIELDS",
85
+ "NodeProfile",
86
+ "NodeState",
87
+ "Action",
88
+ "encode_action",
89
+ "decode_action",
90
+ "flatten_action",
91
+ "unflatten_action",
92
+ "describe_action",
93
+ ]
@@ -0,0 +1,194 @@
1
+ """Action encoding shared by the simulator and the deployment runtime.
2
+
3
+ The action is a pair ``(sensing_level, transmit)``:
4
+
5
+ ============ ===== =========================================================
6
+ component value meaning on the hardware
7
+ ============ ===== =========================================================
8
+ sensing_level 0 keep the previous measurement (sensor stays powered off)
9
+ 1 low-cost acquisition (single sample, short warm-up)
10
+ 2 high-quality acquisition (averaging, longer warm-up)
11
+ transmit 0 radio stays off
12
+ k transmit the latest *stored* measurement with radio mode
13
+ ``k - 1`` (k = 1..n_modes; default modes: 1 fast, 2 standard,
14
+ 3 robust)
15
+ ============ ===== =========================================================
16
+
17
+ Gymnasium represents it as ``MultiDiscrete([3, 1 + n_modes])``; on a
18
+ microcontroller it is two ``uint8`` values or a single flat index
19
+ ``sensing_level * (1 + n_modes) + transmit`` (see ``flatten_action``). With a
20
+ single radio mode the encoding reduces to the binary ``MultiDiscrete([3, 2])``.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+
27
+ import numpy as np
28
+
29
+ N_SENSING_LEVELS = 3
30
+ DEFAULT_N_MODES = 3
31
+ N_TRANSMIT_OPTIONS = 1 + DEFAULT_N_MODES
32
+ N_FLAT_ACTIONS = N_SENSING_LEVELS * N_TRANSMIT_OPTIONS
33
+ ACTION_NVEC = (N_SENSING_LEVELS, N_TRANSMIT_OPTIONS)
34
+
35
+ SENSE_NONE, SENSE_LOW, SENSE_HIGH = 0, 1, 2
36
+ TX_NO = 0
37
+ TX_YES = 2
38
+ """Default transmit value used by the simple policies: the *standard* mode
39
+ (mode index 1) of the default three-mode radio."""
40
+
41
+
42
+ def action_nvec(n_modes: int = DEFAULT_N_MODES) -> tuple[int, int]:
43
+ """``MultiDiscrete`` sizes for a radio with ``n_modes`` transmission modes."""
44
+ return (N_SENSING_LEVELS, 1 + int(n_modes))
45
+
46
+
47
+ def n_flat_actions(n_modes: int = DEFAULT_N_MODES) -> int:
48
+ return N_SENSING_LEVELS * (1 + int(n_modes))
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Action:
53
+ """Decoded, validated action."""
54
+
55
+ sensing_level: int
56
+ transmit: int
57
+ """0 = no transmission, k >= 1 = transmit with radio mode k - 1."""
58
+
59
+ def __post_init__(self) -> None:
60
+ if not 0 <= self.sensing_level < N_SENSING_LEVELS:
61
+ raise ValueError(f"sensing_level must be in [0, {N_SENSING_LEVELS}), got {self.sensing_level}")
62
+ if self.transmit < 0:
63
+ raise ValueError(f"transmit must be >= 0, got {self.transmit}")
64
+
65
+ @property
66
+ def transmits(self) -> bool:
67
+ return self.transmit > 0
68
+
69
+ @property
70
+ def mode(self) -> int:
71
+ """Radio mode index (valid only when ``transmits``)."""
72
+ return self.transmit - 1
73
+
74
+ def to_array(self) -> np.ndarray:
75
+ return np.array([self.sensing_level, self.transmit], dtype=np.int64)
76
+
77
+
78
+ def decode_action(action, n_modes: int | None = None) -> Action:
79
+ """Convert a MultiDiscrete array-like (or an ``Action``) into an ``Action``.
80
+ With ``n_modes`` the transmit component is range-checked."""
81
+ a = action if isinstance(action, Action) else None
82
+ if a is None:
83
+ arr = np.asarray(action).reshape(-1)
84
+ if arr.shape[0] != 2:
85
+ raise ValueError(f"expected an action with 2 components, got shape {arr.shape}")
86
+ a = Action(int(arr[0]), int(arr[1]))
87
+ if n_modes is not None and a.transmit > n_modes:
88
+ raise ValueError(f"transmit must be in [0, {n_modes}], got {a.transmit}")
89
+ return a
90
+
91
+
92
+ def encode_action(sensing_level: int, transmit: int) -> np.ndarray:
93
+ """Build the MultiDiscrete array from its two components."""
94
+ return Action(sensing_level, transmit).to_array()
95
+
96
+
97
+ def flatten_action(action, n_modes: int = DEFAULT_N_MODES) -> int:
98
+ """Map ``(sensing_level, transmit)`` to a single index:
99
+ ``index = sensing_level * (1 + n_modes) + transmit``."""
100
+ a = decode_action(action, n_modes)
101
+ return a.sensing_level * (1 + n_modes) + a.transmit
102
+
103
+
104
+ def unflatten_action(index: int, n_modes: int = DEFAULT_N_MODES) -> np.ndarray:
105
+ """Inverse of :func:`flatten_action`."""
106
+ n_tx = 1 + n_modes
107
+ if not 0 <= index < N_SENSING_LEVELS * n_tx:
108
+ raise ValueError(f"flat action index must be in [0, {N_SENSING_LEVELS * n_tx}), got {index}")
109
+ return encode_action(index // n_tx, index % n_tx)
110
+
111
+
112
+ def describe_action(action, mode_names: tuple[str, ...] | None = None) -> str:
113
+ a = decode_action(action)
114
+ sense = ("no sensing", "low-cost sensing", "high-quality sensing")[a.sensing_level]
115
+ if not a.transmits:
116
+ tx = "no transmission"
117
+ elif mode_names is not None and a.mode < len(mode_names):
118
+ tx = f"transmit ({mode_names[a.mode]})"
119
+ else:
120
+ tx = f"transmit (mode {a.mode})"
121
+ return f"{sense} / {tx}"
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Energy-feasibility rule (shared by simulator and firmware loop)
126
+ # ---------------------------------------------------------------------------
127
+ @dataclass(frozen=True)
128
+ class ExecutionPlan:
129
+ """Which parts of a requested action the node will actually execute."""
130
+
131
+ sensing_level: int
132
+ """Executed sensing level (0 if rejected or not requested)."""
133
+
134
+ transmit: bool
135
+ """Whether a transmission will be executed."""
136
+
137
+ mode: int
138
+ """Radio mode of the transmission (-1 when none)."""
139
+
140
+ sensing_energy_j: float
141
+ tx_energy_j: float
142
+ rejected: tuple[str, ...]
143
+ """Reasons of rejected sub-actions, e.g. 'sensing:insufficient_energy'."""
144
+
145
+
146
+ def plan_execution(
147
+ action,
148
+ *,
149
+ stored_energy_j: float,
150
+ baseline_energy_j: float,
151
+ reserve_energy_j: float,
152
+ sensing_energy_j: tuple[float, ...],
153
+ tx_energy_j: tuple[float, ...] | float,
154
+ has_measurement: bool,
155
+ ) -> ExecutionPlan:
156
+ """Apply the 'execute the feasible subset' rule.
157
+
158
+ The node first sets aside the baseline consumption of the coming interval
159
+ and the brown-out reserve; sensing is served first, then transmission.
160
+ A transmission with nothing to send (no stored measurement and no sensing
161
+ in this step) is rejected without spending energy. ``tx_energy_j`` is the
162
+ energy per radio mode (a scalar is treated as a single-mode radio).
163
+ """
164
+ tx_energies = (float(tx_energy_j),) if np.isscalar(tx_energy_j) else tuple(tx_energy_j)
165
+ a = decode_action(action, n_modes=len(tx_energies))
166
+ available = stored_energy_j - baseline_energy_j - reserve_energy_j
167
+ rejected: list[str] = []
168
+
169
+ level = a.sensing_level
170
+ sense_j = 0.0
171
+ if level != SENSE_NONE:
172
+ cost = sensing_energy_j[level]
173
+ if cost <= available:
174
+ sense_j = cost
175
+ available -= cost
176
+ else:
177
+ rejected.append("sensing:insufficient_energy")
178
+ level = SENSE_NONE
179
+
180
+ transmit = False
181
+ tx_j = 0.0
182
+ mode = -1
183
+ if a.transmits:
184
+ cost = tx_energies[a.mode]
185
+ if not has_measurement and level == SENSE_NONE:
186
+ rejected.append("transmit:no_measurement")
187
+ elif cost <= available:
188
+ transmit = True
189
+ tx_j = cost
190
+ mode = a.mode
191
+ else:
192
+ rejected.append("transmit:insufficient_energy")
193
+
194
+ return ExecutionPlan(level, transmit, mode, sense_j, tx_j, tuple(rejected))
@@ -0,0 +1,191 @@
1
+ """Hidden ground-truth model of the agricultural field.
2
+
3
+ Everything in this module is *simulator-only*. The node never reads these
4
+ variables directly; it can only sample them through a ``Sensor`` (which adds
5
+ noise) and the reward/renderer use them for evaluation.
6
+
7
+ Soil moisture ``theta`` is dimensionless (1 = field capacity). Its dynamics
8
+ per step of length ``dt`` are
9
+
10
+ theta(t+1) = clip(theta(t) - ET(t) * dt + rain(t) + irrigation(t) + noise, 0, max)
11
+
12
+ ET(t) = et_rate_per_day / 86400 * (1 + et_temp_coeff * (T(t) - T_mean))
13
+ * ((1 - a) + a * (pi/2) * (24 / daylight_hours) * daylight_factor(t))
14
+ with a = et_diurnal_amplitude
15
+
16
+ (the factor (pi/2) * (24 / daylight_hours) makes the 24-hour mean of the
17
+ modulation equal to 1, so ``et_rate_per_day`` is the actual mean daily ET at
18
+ the reference temperature; with 12 h of daylight the factor is pi)
19
+
20
+ Rain events follow a Poisson process; irrigation is applied by an external
21
+ actor (the farmer's system) some random time after the *true* moisture drops
22
+ below ``irrigation_trigger``. Temperature follows a daily cosine with AR(1)
23
+ noise, and humidity is linearly anti-correlated with the temperature anomaly.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import math
29
+ from dataclasses import dataclass
30
+
31
+ import numpy as np
32
+
33
+ from .config import AgricultureConfig
34
+
35
+ DAY_S = 86400.0
36
+
37
+
38
+ @dataclass
39
+ class FieldState:
40
+ """Snapshot of the ground truth (privileged information)."""
41
+
42
+ soil_moisture: float
43
+ air_temperature_c: float
44
+ relative_humidity: float
45
+ last_step_change: float
46
+ """Change of soil moisture during the last step (positive after rain)."""
47
+ event_occurred: bool
48
+ """True if a rain/irrigation event or threshold crossing happened in the last step."""
49
+ zone: int
50
+ """0 = normal, 1 = warning (below warning_threshold), 2 = critical."""
51
+ rain_events: int = 0
52
+ irrigation_events: int = 0
53
+
54
+ # -- ProcessState-compatible view (see process.py) -------------------------
55
+ @property
56
+ def value(self) -> float:
57
+ return self.soil_moisture
58
+
59
+ @property
60
+ def aux(self) -> dict:
61
+ return {
62
+ "air_temperature_c": self.air_temperature_c,
63
+ "relative_humidity": self.relative_humidity,
64
+ "rain_events": self.rain_events,
65
+ "irrigation_events": self.irrigation_events,
66
+ }
67
+
68
+
69
+ class FieldEnvironment:
70
+ """Stochastic soil / atmosphere simulator."""
71
+
72
+ def __init__(self, cfg: AgricultureConfig, timestep_s: float):
73
+ self.cfg = cfg
74
+ self.dt = timestep_s
75
+ self._rng = np.random.default_rng()
76
+ self.reset(self._rng, start_time_s=0.0)
77
+
78
+ # -- lifecycle ----------------------------------------------------------
79
+ def reset(self, rng: np.random.Generator, start_time_s: float) -> None:
80
+ c = self.cfg
81
+ self._rng = rng
82
+ if c.initial_moisture_range is not None:
83
+ self.moisture = float(rng.uniform(*c.initial_moisture_range))
84
+ else:
85
+ self.moisture = c.initial_moisture
86
+ self._temp_dev = 0.0
87
+ self.temperature = self._temperature_base(start_time_s)
88
+ self.humidity = c.humidity_mean
89
+ self.last_change = 0.0
90
+ self.event_occurred = False
91
+ self.irrigation_pending_s: float | None = None
92
+ self.rain_events = 0
93
+ self.irrigation_events = 0
94
+ self.total_rain = 0.0
95
+ self.total_irrigation = 0.0
96
+
97
+ # -- helpers ------------------------------------------------------------
98
+ def _temperature_base(self, time_s: float) -> float:
99
+ c = self.cfg
100
+ h = (time_s % DAY_S) / 3600.0
101
+ return c.temp_mean_c + c.temp_amplitude_c * math.cos(2 * math.pi * (h - c.temp_peak_hour) / 24.0)
102
+
103
+ def _daylight_factor(self, time_s: float) -> float:
104
+ """Half-sine daylight shape in [0, 1] (0 at night)."""
105
+ c = self.cfg
106
+ h = (time_s % DAY_S) / 3600.0
107
+ if not c.sunrise_hour < h < c.sunset_hour:
108
+ return 0.0
109
+ return math.sin(math.pi * (h - c.sunrise_hour) / (c.sunset_hour - c.sunrise_hour))
110
+
111
+ @property
112
+ def value(self) -> float:
113
+ """Normalised monitored value (the process interface of ``process.py``)."""
114
+ return self.moisture
115
+
116
+ def zone(self, moisture: float | None = None) -> int:
117
+ m = self.moisture if moisture is None else moisture
118
+ if m < self.cfg.critical_threshold:
119
+ return 2
120
+ if m < self.cfg.warning_threshold:
121
+ return 1
122
+ return 0
123
+
124
+ # -- dynamics -----------------------------------------------------------
125
+ def step(self, time_s: float) -> FieldState:
126
+ """Advance the field by one timestep starting at ``time_s``."""
127
+ c, rng, dt = self.cfg, self._rng, self.dt
128
+ prev = self.moisture
129
+ prev_zone = self.zone(prev)
130
+
131
+ # atmosphere
132
+ self._temp_dev = c.temp_autocorr * self._temp_dev + math.sqrt(max(0.0, 1 - c.temp_autocorr**2)) * rng.normal(
133
+ 0.0, c.temp_noise_std
134
+ )
135
+ self.temperature = self._temperature_base(time_s) + self._temp_dev
136
+ self.humidity = float(
137
+ np.clip(
138
+ c.humidity_mean + c.humidity_temp_coeff * (self.temperature - c.temp_mean_c) + rng.normal(0.0, c.humidity_noise_std),
139
+ 0.05,
140
+ 1.0,
141
+ )
142
+ )
143
+
144
+ # evapotranspiration
145
+ et_per_s = c.et_rate_per_day / DAY_S * max(0.0, 1.0 + c.et_temp_coeff * (self.temperature - c.temp_mean_c))
146
+ # 24 h mean of daylight_factor = (daylight_h / 24) * (2 / pi); the prefactor
147
+ # below normalises the modulation to a mean of exactly 1 over the day.
148
+ daylight_h = c.sunset_hour - c.sunrise_hour
149
+ modulation = (math.pi / 2.0) * (24.0 / daylight_h) * self._daylight_factor(time_s)
150
+ et_per_s *= (1.0 - c.et_diurnal_amplitude) + c.et_diurnal_amplitude * modulation
151
+ delta = -et_per_s * dt
152
+
153
+ # rain (Poisson process)
154
+ n_rain = rng.poisson(c.rain_events_per_day * dt / DAY_S)
155
+ if n_rain > 0:
156
+ amount = float(sum(rng.uniform(*c.rain_amount_range) for _ in range(n_rain)))
157
+ delta += amount
158
+ self.rain_events += int(n_rain)
159
+ self.total_rain += amount
160
+
161
+ # external irrigation reacting to the true moisture with a random delay
162
+ if c.irrigation_enabled:
163
+ if self.irrigation_pending_s is None and prev < c.irrigation_trigger:
164
+ self.irrigation_pending_s = float(rng.exponential(c.irrigation_delay_mean_s))
165
+ if self.irrigation_pending_s is not None:
166
+ self.irrigation_pending_s -= dt
167
+ if self.irrigation_pending_s <= 0.0:
168
+ delta += c.irrigation_amount
169
+ self.irrigation_events += 1
170
+ self.total_irrigation += c.irrigation_amount
171
+ self.irrigation_pending_s = None
172
+
173
+ delta += rng.normal(0.0, c.process_noise_std)
174
+ self.moisture = float(np.clip(prev + delta, 0.0, c.max_moisture))
175
+ self.last_change = self.moisture - prev
176
+
177
+ crossed = self.zone(self.moisture) != prev_zone
178
+ self.event_occurred = abs(self.last_change) >= c.event_change_threshold or crossed
179
+ return self.state()
180
+
181
+ def state(self) -> FieldState:
182
+ return FieldState(
183
+ soil_moisture=self.moisture,
184
+ air_temperature_c=self.temperature,
185
+ relative_humidity=self.humidity,
186
+ last_step_change=self.last_change,
187
+ event_occurred=self.event_occurred,
188
+ zone=self.zone(),
189
+ rain_events=self.rain_events,
190
+ irrigation_events=self.irrigation_events,
191
+ )