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