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,222 @@
|
|
|
1
|
+
"""Remote monitoring application: information utility and priority.
|
|
2
|
+
|
|
3
|
+
The application is the *consumer* of the data. It has two faces:
|
|
4
|
+
|
|
5
|
+
1. **What it knows** - only the packets that were delivered. From them (and
|
|
6
|
+
from the age of the last one) it derives the *priority* it sends back to
|
|
7
|
+
the node. This is the realistic part: a real back-end would do exactly the
|
|
8
|
+
same and push the priority through a downlink.
|
|
9
|
+
|
|
10
|
+
2. **How valuable the information is** - the *utility*. Utility is an
|
|
11
|
+
evaluation quantity, so the simulator is allowed to compare what the
|
|
12
|
+
application believes with the hidden ground truth. This is used for the
|
|
13
|
+
reward only and never enters the policy observation.
|
|
14
|
+
|
|
15
|
+
Utility has two parts.
|
|
16
|
+
|
|
17
|
+
**Tracking utility (every step)** - the value of the application holding an
|
|
18
|
+
accurate picture of the field *right now*::
|
|
19
|
+
|
|
20
|
+
u_track(t) = tracking_weight * criticality(true_t) * exp(-|v_app - true_t| / error_scale)
|
|
21
|
+
|
|
22
|
+
where ``v_app`` is the last delivered value (0 utility before the first
|
|
23
|
+
packet). Stale information drifts away from the truth, noisy low-cost
|
|
24
|
+
readings sit further from it, and a rain event makes the old value suddenly
|
|
25
|
+
wrong: all three reduce ``u_track`` until a fresh accurate report arrives. A
|
|
26
|
+
report identical to the previous one changes nothing, so redundant
|
|
27
|
+
transmissions earn nothing and only cost energy.
|
|
28
|
+
|
|
29
|
+
**Packet bonus (on delivery)** - a shaping term that credits the packet that
|
|
30
|
+
fixes the picture, so the agent gets an immediate signal::
|
|
31
|
+
|
|
32
|
+
u_packet = criticality(true) * freshness * ( w_gain * tanh(gain / gain_scale)
|
|
33
|
+
+ w_event * event * accuracy )
|
|
34
|
+
gain = |v_app_before - true| - |v_reported - true| (signed! a worse report is penalised)
|
|
35
|
+
freshness = exp(-measurement_age_at_tx / tau_freshness_s)
|
|
36
|
+
event = 1 if an environmental event happened since the last delivered packet
|
|
37
|
+
accuracy = exp(-|v_reported - true| / error_scale)
|
|
38
|
+
|
|
39
|
+
Both parts share::
|
|
40
|
+
|
|
41
|
+
criticality = 1 + criticality_gain * exp(-dist(true, nearest threshold) / criticality_scale)
|
|
42
|
+
(saturates at 1 + criticality_gain below the critical threshold)
|
|
43
|
+
|
|
44
|
+
so information is worth more when the crop is close to water stress.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import math
|
|
50
|
+
from dataclasses import asdict, dataclass
|
|
51
|
+
|
|
52
|
+
import numpy as np
|
|
53
|
+
|
|
54
|
+
from .config import AgricultureConfig, ApplicationConfig, QuantityConfig
|
|
55
|
+
from .process import ProcessState
|
|
56
|
+
from .interfaces import Packet
|
|
57
|
+
from .observation import PRIORITY_ELEVATED, PRIORITY_ROUTINE, PRIORITY_URGENT
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class UtilityBreakdown:
|
|
62
|
+
"""Details of the packet bonus (for ``info`` and debugging)."""
|
|
63
|
+
|
|
64
|
+
total: float
|
|
65
|
+
accuracy: float
|
|
66
|
+
freshness: float
|
|
67
|
+
gain: float
|
|
68
|
+
event: float
|
|
69
|
+
criticality: float
|
|
70
|
+
|
|
71
|
+
def as_dict(self) -> dict[str, float]:
|
|
72
|
+
return asdict(self)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class TrackingStatus:
|
|
77
|
+
"""Per-step evaluation of the application's picture of the field."""
|
|
78
|
+
|
|
79
|
+
utility: float
|
|
80
|
+
error: float | None
|
|
81
|
+
"""|believed - true| (None before the first delivered packet)."""
|
|
82
|
+
accuracy: float
|
|
83
|
+
criticality: float
|
|
84
|
+
|
|
85
|
+
def as_dict(self) -> dict[str, float | None]:
|
|
86
|
+
return asdict(self)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RemoteMonitoringApplication:
|
|
90
|
+
"""Monitoring back-end: tracks one normalised quantity with two stress
|
|
91
|
+
thresholds (``QuantityConfig``: soil moisture, CO2, a bearing temperature...).
|
|
92
|
+
|
|
93
|
+
``quantity`` may also be an ``AgricultureConfig`` (its thresholds are then
|
|
94
|
+
read directly), for backwards compatibility.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, cfg: ApplicationConfig, quantity: QuantityConfig | AgricultureConfig, timestep_s: float):
|
|
98
|
+
self.cfg = cfg
|
|
99
|
+
self.q: QuantityConfig = quantity.quantity() if isinstance(quantity, AgricultureConfig) else quantity
|
|
100
|
+
self.agri = self.q # legacy name
|
|
101
|
+
self.dt = timestep_s
|
|
102
|
+
self._rng = np.random.default_rng()
|
|
103
|
+
self.reset(self._rng, start_time_s=0.0)
|
|
104
|
+
|
|
105
|
+
# -- lifecycle ----------------------------------------------------------
|
|
106
|
+
def reset(self, rng: np.random.Generator, start_time_s: float) -> None:
|
|
107
|
+
self._rng = rng
|
|
108
|
+
self._now = start_time_s
|
|
109
|
+
self._episode_start = start_time_s
|
|
110
|
+
self.last_packet: Packet | None = None
|
|
111
|
+
self.last_received_at_s: float | None = None
|
|
112
|
+
self._request_until_s: float | None = None
|
|
113
|
+
self._request_level = PRIORITY_ROUTINE
|
|
114
|
+
self._unreported_event_time_s: float | None = None
|
|
115
|
+
self._priority = PRIORITY_ROUTINE
|
|
116
|
+
self.packets_received = 0
|
|
117
|
+
|
|
118
|
+
# -- knowledge of the application -------------------------------------
|
|
119
|
+
@property
|
|
120
|
+
def believed_value(self) -> float | None:
|
|
121
|
+
return None if self.last_packet is None else self.last_packet.measurement.value
|
|
122
|
+
|
|
123
|
+
def age_of_information_s(self, now_s: float | None = None) -> float:
|
|
124
|
+
"""Age of the freshest information available at the application.
|
|
125
|
+
|
|
126
|
+
Before the first packet, AoI counts from the start of the episode
|
|
127
|
+
(the application has *no* information yet)."""
|
|
128
|
+
now = self._now if now_s is None else now_s
|
|
129
|
+
if self.last_packet is None or self.last_received_at_s is None:
|
|
130
|
+
return now - self._episode_start
|
|
131
|
+
return (now - self.last_received_at_s) + self.last_packet.measurement_age_s
|
|
132
|
+
|
|
133
|
+
def priority(self) -> int:
|
|
134
|
+
return self._priority
|
|
135
|
+
|
|
136
|
+
def _compute_priority(self, now_s: float) -> int:
|
|
137
|
+
c = self.cfg
|
|
138
|
+
prio = PRIORITY_ROUTINE
|
|
139
|
+
aoi = self.age_of_information_s(now_s)
|
|
140
|
+
v = self.believed_value
|
|
141
|
+
if v is not None:
|
|
142
|
+
zone = self.q.zone(v) # direction-aware (QuantityConfig.critical_is_upper)
|
|
143
|
+
if zone == 2:
|
|
144
|
+
prio = PRIORITY_URGENT
|
|
145
|
+
elif zone == 1:
|
|
146
|
+
prio = PRIORITY_ELEVATED
|
|
147
|
+
if aoi > c.aoi_urgent_s:
|
|
148
|
+
prio = PRIORITY_URGENT
|
|
149
|
+
elif aoi > c.aoi_elevated_s:
|
|
150
|
+
prio = max(prio, PRIORITY_ELEVATED)
|
|
151
|
+
if self._request_until_s is not None and now_s < self._request_until_s:
|
|
152
|
+
prio = max(prio, self._request_level)
|
|
153
|
+
return prio
|
|
154
|
+
|
|
155
|
+
# -- dynamics -----------------------------------------------------------
|
|
156
|
+
def step(self, now_s: float, field_state: ProcessState) -> None:
|
|
157
|
+
"""Advance the application clock; register external requests and
|
|
158
|
+
ground-truth events (the latter only for utility evaluation)."""
|
|
159
|
+
c = self.cfg
|
|
160
|
+
self._now = now_s
|
|
161
|
+
# external monitoring campaigns requested e.g. by an agronomist
|
|
162
|
+
if self._request_until_s is not None and now_s >= self._request_until_s:
|
|
163
|
+
self._request_until_s = None
|
|
164
|
+
if self._request_until_s is None and self._rng.random() < c.request_rate_per_day * self.dt / 86400.0:
|
|
165
|
+
dur = self._rng.uniform(*c.request_duration_range_s)
|
|
166
|
+
self._request_until_s = now_s + dur
|
|
167
|
+
self._request_level = PRIORITY_URGENT if self._rng.random() < c.request_urgent_fraction else PRIORITY_ELEVATED
|
|
168
|
+
# environmental events worth reporting (privileged bookkeeping)
|
|
169
|
+
if field_state.event_occurred:
|
|
170
|
+
self._unreported_event_time_s = now_s
|
|
171
|
+
if self._unreported_event_time_s is not None and now_s - self._unreported_event_time_s > c.event_memory_s:
|
|
172
|
+
self._unreported_event_time_s = None
|
|
173
|
+
self._priority = self._compute_priority(now_s)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def has_unreported_event(self) -> bool:
|
|
177
|
+
return self._unreported_event_time_s is not None
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def external_request_active(self) -> bool:
|
|
181
|
+
return self._request_until_s is not None
|
|
182
|
+
|
|
183
|
+
# -- utility (privileged) -----------------------------------------------
|
|
184
|
+
def criticality(self, true_value: float) -> float:
|
|
185
|
+
q, c = self.q, self.cfg
|
|
186
|
+
if q.beyond_critical(true_value):
|
|
187
|
+
return 1.0 + c.criticality_gain
|
|
188
|
+
dist = min(abs(true_value - q.warning_threshold), abs(true_value - q.critical_threshold))
|
|
189
|
+
return 1.0 + c.criticality_gain * math.exp(-dist / c.criticality_scale)
|
|
190
|
+
|
|
191
|
+
def tracking(self, field_state: ProcessState) -> TrackingStatus:
|
|
192
|
+
"""Per-step tracking utility of the application's current belief."""
|
|
193
|
+
crit = self.criticality(field_state.value)
|
|
194
|
+
v = self.believed_value
|
|
195
|
+
if v is None:
|
|
196
|
+
return TrackingStatus(0.0, None, 0.0, crit)
|
|
197
|
+
err = abs(v - field_state.value)
|
|
198
|
+
acc = math.exp(-err / self.cfg.error_scale)
|
|
199
|
+
return TrackingStatus(self.cfg.tracking_weight * crit * acc, err, acc, crit)
|
|
200
|
+
|
|
201
|
+
def receive(self, packet: Packet, now_s: float, field_state: ProcessState) -> UtilityBreakdown:
|
|
202
|
+
"""Register a delivered packet and return its bonus utility."""
|
|
203
|
+
c = self.cfg
|
|
204
|
+
m = packet.measurement
|
|
205
|
+
truth = field_state.value
|
|
206
|
+
|
|
207
|
+
err_after = abs(m.value - truth)
|
|
208
|
+
err_before = abs(self.believed_value - truth) if self.believed_value is not None else c.error_scale * 3.0
|
|
209
|
+
gain = err_before - err_after
|
|
210
|
+
accuracy = math.exp(-err_after / c.error_scale)
|
|
211
|
+
freshness = math.exp(-max(0.0, packet.measurement_age_s) / c.tau_freshness_s)
|
|
212
|
+
event = 1.0 if self._unreported_event_time_s is not None else 0.0
|
|
213
|
+
crit = self.criticality(truth)
|
|
214
|
+
total = crit * freshness * (c.w_gain * math.tanh(gain / c.gain_scale) + c.w_event * event * accuracy)
|
|
215
|
+
|
|
216
|
+
# update knowledge
|
|
217
|
+
self.last_packet = packet
|
|
218
|
+
self.last_received_at_s = now_s
|
|
219
|
+
self.packets_received += 1
|
|
220
|
+
self._unreported_event_time_s = None
|
|
221
|
+
self._priority = self._compute_priority(now_s)
|
|
222
|
+
return UtilityBreakdown(total, accuracy, freshness, gain, event, crit)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Abstract low-power long-range link (LoRa-like) with selectable modes.
|
|
2
|
+
|
|
3
|
+
Modelled at the level of *one uplink attempt* in a given *mode* (a spreading
|
|
4
|
+
factor / power setting): it costs ``modes[k].energy_j`` and is delivered with a
|
|
5
|
+
probability given by the link budget
|
|
6
|
+
|
|
7
|
+
margin_k(t) = tx_power_k - path_loss(t) - sensitivity_k [dB]
|
|
8
|
+
p_k(t) = 1 / (1 + exp(-margin_k(t) / margin_scale_db))
|
|
9
|
+
path_loss(t) = path_loss_mean_db + slow_fading(t) + fast_fading
|
|
10
|
+
|
|
11
|
+
where ``slow_fading`` is an AR(1) process (shadowing by vegetation, humidity,
|
|
12
|
+
gateway load) and ``fast_fading`` is redrawn at every attempt. On a delivered
|
|
13
|
+
packet the node measures the margin of the used mode from the ACK (with noise)
|
|
14
|
+
and can convert it into a *path-loss estimate* that is valid for every mode:
|
|
15
|
+
this is how it learns which mode is currently affordable.
|
|
16
|
+
|
|
17
|
+
Duty-cycle limits, collisions and multi-gateway reception are not modelled;
|
|
18
|
+
this class is where they belong.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import math
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from .config import CommunicationConfig
|
|
28
|
+
from .interfaces import Packet, TxResult
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SimulatedLoRaRadio:
|
|
32
|
+
"""``Radio`` implementation with a link-budget channel."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, cfg: CommunicationConfig):
|
|
35
|
+
self.cfg = cfg
|
|
36
|
+
self._rng = np.random.default_rng()
|
|
37
|
+
self.reset(self._rng)
|
|
38
|
+
|
|
39
|
+
def reset(self, rng: np.random.Generator) -> None:
|
|
40
|
+
self._rng = rng
|
|
41
|
+
self._slow_db = 0.0
|
|
42
|
+
self.last_result: TxResult | None = None
|
|
43
|
+
self.last_mode: int | None = None
|
|
44
|
+
self.last_packet: Packet | None = None
|
|
45
|
+
self.attempts = 0
|
|
46
|
+
self.successes = 0
|
|
47
|
+
self.attempts_per_mode = [0] * self.cfg.n_modes
|
|
48
|
+
self.successes_per_mode = [0] * self.cfg.n_modes
|
|
49
|
+
|
|
50
|
+
def update_channel(self) -> None:
|
|
51
|
+
"""Advance the slow-fading process by one timestep (called by the env)."""
|
|
52
|
+
c = self.cfg
|
|
53
|
+
rho = c.slow_fading_autocorr
|
|
54
|
+
self._slow_db = rho * self._slow_db + math.sqrt(max(0.0, 1 - rho**2)) * self._rng.normal(0.0, c.slow_fading_std_db)
|
|
55
|
+
|
|
56
|
+
# -- simulator-only ground truth ----------------------------------------
|
|
57
|
+
def path_loss_db(self) -> float:
|
|
58
|
+
"""Current path loss without the per-attempt fast fading [dB]."""
|
|
59
|
+
return self.cfg.path_loss_mean_db + self._slow_db
|
|
60
|
+
|
|
61
|
+
def margin_db(self, mode: int) -> float:
|
|
62
|
+
m = self.cfg.modes[mode]
|
|
63
|
+
return m.tx_power_dbm - self.path_loss_db() - m.sensitivity_dbm
|
|
64
|
+
|
|
65
|
+
def success_probability(self, mode: int | None = None) -> float:
|
|
66
|
+
"""Delivery probability of ``mode`` (default: the reference mode) given
|
|
67
|
+
the current slow fading, averaged over the fast fading."""
|
|
68
|
+
if mode is None:
|
|
69
|
+
mode = self.cfg.reference_mode
|
|
70
|
+
margin = self.margin_db(mode)
|
|
71
|
+
# logistic in margin, fast fading adds variance: average over a few points
|
|
72
|
+
z = margin + self.cfg.fast_fading_std_db * np.array([-1.5, -0.5, 0.0, 0.5, 1.5])
|
|
73
|
+
w = np.array([0.1, 0.25, 0.3, 0.25, 0.1])
|
|
74
|
+
return float(np.sum(w / (1.0 + np.exp(-z / self.cfg.margin_scale_db))))
|
|
75
|
+
|
|
76
|
+
# -- Radio protocol -----------------------------------------------------
|
|
77
|
+
def n_modes(self) -> int:
|
|
78
|
+
return self.cfg.n_modes
|
|
79
|
+
|
|
80
|
+
def tx_energy_j(self, mode: int) -> float:
|
|
81
|
+
return self.cfg.modes[mode].energy_j
|
|
82
|
+
|
|
83
|
+
def transmit(self, packet: Packet, mode: int) -> TxResult:
|
|
84
|
+
if not 0 <= mode < self.cfg.n_modes:
|
|
85
|
+
raise ValueError(f"radio mode must be in [0, {self.cfg.n_modes}), got {mode}")
|
|
86
|
+
c = self.cfg
|
|
87
|
+
self.attempts += 1
|
|
88
|
+
self.attempts_per_mode[mode] += 1
|
|
89
|
+
margin = self.margin_db(mode) + self._rng.normal(0.0, c.fast_fading_std_db)
|
|
90
|
+
z = float(np.clip(-margin / c.margin_scale_db, -60.0, 60.0))
|
|
91
|
+
p = 1.0 / (1.0 + math.exp(z))
|
|
92
|
+
ok = bool(self._rng.random() < p)
|
|
93
|
+
measured = (margin + self._rng.normal(0.0, c.ack_margin_noise_db)) if ok else None
|
|
94
|
+
result = TxResult(acked=ok, margin_db=measured)
|
|
95
|
+
self.last_result, self.last_mode, self.last_packet = result, mode, packet
|
|
96
|
+
if ok:
|
|
97
|
+
self.successes += 1
|
|
98
|
+
self.successes_per_mode[mode] += 1
|
|
99
|
+
return result
|