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,411 @@
1
+ """Simulation-to-real scaffolding.
2
+
3
+ Three things live here:
4
+
5
+ * :class:`NodeController` - the *firmware main loop* written against the
6
+ hardware protocols of ``interfaces.py``. It reuses ``NodeStateTracker``,
7
+ ``ObservationBuilder`` and ``plan_execution`` from the simulator, so the
8
+ policy receives byte-for-byte the same observation vector it saw in
9
+ training and its actions are executed by the same feasibility rule.
10
+
11
+ * :class:`MockHardwareBackend` - stand-in drivers (a fuel gauge with a minimal
12
+ power-path emulation, a harvester monitor fed by a callable, a sensor driver
13
+ reading a callable with the profile's noise, a radio whose ACKs follow a
14
+ link budget at a fixed path loss). It has **no ground truth**: it only knows
15
+ what a real board would know. It exists to prove that the controller and the
16
+ policy run unchanged outside the simulator, and it is what a real port
17
+ replaces with ADC / I2C / radio-stack calls.
18
+
19
+ * :class:`PolicyBundle` - a JSON-serialisable description of everything that
20
+ must travel with a trained policy to the microcontroller: observation
21
+ ordering and normalisation constants, action encoding, model parameters and
22
+ metadata. Freezing this contract is the single most important step for a
23
+ credible sim-to-real transfer (see ``docs/deployment.md``).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import dataclasses
29
+ import json
30
+ import platform
31
+ from dataclasses import dataclass, field
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+ from typing import Any, Callable, Sequence
35
+
36
+ import numpy as np
37
+
38
+ from . import __version__
39
+ from .actions import DEFAULT_N_MODES, SENSE_NONE, action_nvec, n_flat_actions, plan_execution
40
+ from .interfaces import Clock, EnergySource, EnergyStorage, Measurement, Packet, Policy, Radio, RemoteApplication, Sensor, TxResult
41
+ from .observation import OBSERVATION_FIELDS, NodeProfile, NodeStateTracker, ObservationBuilder
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Firmware-style controller
46
+ # ---------------------------------------------------------------------------
47
+ @dataclass
48
+ class HardwareBackend:
49
+ """Bundle of drivers implementing the protocols of ``interfaces.py``."""
50
+
51
+ clock: Clock
52
+ storage: EnergyStorage
53
+ source: EnergySource
54
+ sensor: Sensor
55
+ radio: Radio
56
+ application: RemoteApplication
57
+
58
+
59
+ @dataclass
60
+ class CycleReport:
61
+ """What happened during one decision cycle (for logging / evaluation)."""
62
+
63
+ time_s: float
64
+ observation: np.ndarray
65
+ requested_action: tuple[int, int]
66
+ executed_sensing_level: int
67
+ executed_transmit: bool
68
+ tx_mode: int
69
+ """Radio mode used (-1 when no transmission)."""
70
+ tx_success: bool | None
71
+ rejected: tuple[str, ...]
72
+ measurement: Measurement | None
73
+ energy_spent_j: float = 0.0
74
+ """Nominal sensing + transmission energy of the executed operations [J]."""
75
+
76
+
77
+ class NodeController:
78
+ """Main loop of the deployed node.
79
+
80
+ ::
81
+
82
+ controller = NodeController(backend, profile, policy)
83
+ while True:
84
+ report = controller.run_cycle()
85
+ sleep_until_next_wakeup(profile.timestep_s)
86
+
87
+ Nothing in this class knows whether ``backend`` is simulated or real. All
88
+ constants (energy profile, timestep, baseline, reserve, ACK availability,
89
+ normalisation) come from the :class:`NodeProfile`, i.e. from the exported
90
+ policy bundle, so simulator and firmware cannot drift apart silently.
91
+ ``priority_update_mode`` mirrors ``CommunicationConfig.priority_update_mode``:
92
+ 'immediate' reads the downlink priority at every wake-up, 'on_uplink' only
93
+ after an acknowledged uplink.
94
+ """
95
+
96
+ def __init__(self, backend: HardwareBackend, profile: NodeProfile, policy: Policy, priority_update_mode: str = "immediate"):
97
+ if priority_update_mode not in ("immediate", "on_uplink"):
98
+ raise ValueError("priority_update_mode must be 'immediate' or 'on_uplink'")
99
+ self.hw = backend
100
+ self.profile = profile
101
+ self.policy = policy
102
+ self.priority_update_mode = priority_update_mode
103
+ self.tracker = NodeStateTracker(profile)
104
+ self.obs_builder = ObservationBuilder(profile)
105
+ self.policy.reset()
106
+
107
+ def build_observation(self) -> np.ndarray:
108
+ """Read the measurable quantities and produce the policy input."""
109
+ hw = self.hw
110
+ priority = hw.application.priority() if self.priority_update_mode == "immediate" else None
111
+ self.tracker.begin_step(
112
+ now_s=hw.clock.now_s(),
113
+ time_of_day_s=hw.clock.time_of_day_s(),
114
+ energy_j=hw.storage.energy_j(),
115
+ capacity_j=hw.storage.capacity_j(),
116
+ harvest_power_w=hw.source.measured_power_w(),
117
+ priority=priority,
118
+ )
119
+ return self.obs_builder.build(self.tracker.state())
120
+
121
+ def execute(self, action) -> CycleReport:
122
+ """Apply the feasibility rule and drive the drivers."""
123
+ hw, p = self.hw, self.profile
124
+ now = hw.clock.now_s()
125
+ plan = plan_execution(
126
+ action,
127
+ stored_energy_j=hw.storage.energy_j(),
128
+ baseline_energy_j=p.baseline_energy_j,
129
+ reserve_energy_j=p.reserve_soc * hw.storage.capacity_j(),
130
+ sensing_energy_j=p.sensing_energy_j,
131
+ tx_energy_j=p.tx_energy_j,
132
+ has_measurement=self.tracker.measurement is not None,
133
+ )
134
+ measurement = None
135
+ if plan.sensing_level != SENSE_NONE:
136
+ measurement = hw.sensor.read(plan.sensing_level, now)
137
+ self.tracker.on_measurement(measurement)
138
+ tx_success: bool | None = None
139
+ if plan.transmit and self.tracker.measurement is not None:
140
+ packet = Packet(measurement=self.tracker.measurement, sent_at_s=now)
141
+ result = hw.radio.transmit(packet, plan.mode)
142
+ tx_success = result.acked if p.ack_available else None
143
+ self.tracker.on_transmission(packet, tx_success, now, mode=plan.mode, margin_db=result.margin_db)
144
+ if self.priority_update_mode == "on_uplink" and result.acked:
145
+ self.tracker.set_priority(hw.application.priority()) # downlink piggybacked on the ACK
146
+ a = np.asarray(action).reshape(-1)
147
+ report = CycleReport(
148
+ time_s=now,
149
+ observation=np.empty(0),
150
+ requested_action=(int(a[0]), int(a[1])),
151
+ executed_sensing_level=plan.sensing_level,
152
+ executed_transmit=plan.transmit,
153
+ tx_mode=plan.mode,
154
+ tx_success=tx_success,
155
+ rejected=plan.rejected,
156
+ measurement=measurement,
157
+ energy_spent_j=plan.sensing_energy_j + plan.tx_energy_j,
158
+ )
159
+ return report
160
+
161
+ def run_cycle(self) -> CycleReport:
162
+ obs = self.build_observation()
163
+ action = self.policy.act(obs)
164
+ report = self.execute(action)
165
+ report.observation = obs
166
+ return report
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Mock hardware (no ground truth!)
171
+ # ---------------------------------------------------------------------------
172
+ class MockClock:
173
+ def __init__(self, start_s: float = 0.0, timestep_s: float = 900.0):
174
+ self._t = start_s
175
+ self.dt = timestep_s
176
+
177
+ def now_s(self) -> float:
178
+ return self._t
179
+
180
+ def time_of_day_s(self) -> float:
181
+ return self._t % 86400.0
182
+
183
+ def tick(self) -> None:
184
+ self._t += self.dt
185
+
186
+
187
+ class MockFuelGauge:
188
+ """Battery monitor over a scripted energy value."""
189
+
190
+ def __init__(self, capacity_j: float, energy_j: float):
191
+ self._cap = capacity_j
192
+ self._e = float(np.clip(energy_j, 0.0, capacity_j))
193
+
194
+ def capacity_j(self) -> float:
195
+ return self._cap
196
+
197
+ def energy_j(self) -> float:
198
+ return self._e
199
+
200
+ def apply_delta_j(self, delta_j: float) -> None:
201
+ """Power-path emulation hook (charge > 0, discharge < 0)."""
202
+ self._e = float(np.clip(self._e + delta_j, 0.0, self._cap))
203
+
204
+
205
+ class MockHarvesterMonitor:
206
+ """Reports the average power of the *elapsed* interval, like a coulomb
207
+ counter read at wake-up (``power_fn`` is a scripted or recorded profile)."""
208
+
209
+ def __init__(self, power_fn: Callable[[float], float], clock: MockClock, samples: int = 8):
210
+ self._fn = power_fn
211
+ self._clock = clock
212
+ self._n = samples
213
+
214
+ def measured_power_w(self) -> float:
215
+ end = self._clock.now_s()
216
+ ts = np.linspace(end - self._clock.dt, end, self._n, endpoint=False)
217
+ return max(0.0, float(np.mean([self._fn(t) for t in ts])))
218
+
219
+
220
+ class MockSensorDriver:
221
+ """Returns values from a trace (e.g. a CSV recorded in the field)."""
222
+
223
+ def __init__(self, trace: Callable[[float], float], energy_j: Sequence[float], noise_std: Sequence[float], rng: np.random.Generator | None = None):
224
+ self._trace = trace
225
+ self._energy = tuple(energy_j)
226
+ self._noise = tuple(noise_std)
227
+ self._rng = rng or np.random.default_rng(0)
228
+
229
+ def energy_cost_j(self, level: int) -> float:
230
+ return self._energy[level]
231
+
232
+ def noise_std(self, level: int) -> float:
233
+ return self._noise[level]
234
+
235
+ def read(self, level: int, now_s: float) -> Measurement:
236
+ raw = self._trace(now_s) + self._rng.normal(0.0, self._noise[level])
237
+ return Measurement(float(np.clip(raw, 0.0, 1.0)), now_s, level, self._noise[level])
238
+
239
+
240
+ class MockRadio:
241
+ """Radio stub: fixed path loss, ACK probability from the link budget of
242
+ the requested mode, margin reported back like a LinkCheck answer."""
243
+
244
+ def __init__(self, profile: NodeProfile, path_loss_db: float = 139.0, rng: np.random.Generator | None = None):
245
+ self._profile = profile
246
+ self._pl = path_loss_db
247
+ self._rng = rng or np.random.default_rng(1)
248
+ self.sent: list[tuple[Packet, int]] = []
249
+
250
+ def n_modes(self) -> int:
251
+ return self._profile.n_modes
252
+
253
+ def tx_energy_j(self, mode: int) -> float:
254
+ return self._profile.tx_energy_j[mode]
255
+
256
+ def transmit(self, packet: Packet, mode: int) -> TxResult:
257
+ self.sent.append((packet, mode))
258
+ margin = self._profile.margin_for_mode(mode, self._pl) + self._rng.normal(0.0, 2.0)
259
+ ok = bool(self._rng.random() < 1.0 / (1.0 + np.exp(-margin / 1.5)))
260
+ return TxResult(acked=ok, margin_db=float(margin) if ok else None)
261
+
262
+
263
+ class MockDownlink:
264
+ """Priority as received from the back-end (defaults to routine)."""
265
+
266
+ def __init__(self, priority: int = 0):
267
+ self._p = priority
268
+
269
+ def priority(self) -> int:
270
+ return self._p
271
+
272
+ def set_priority(self, p: int) -> None:
273
+ self._p = int(p)
274
+
275
+
276
+ @dataclass
277
+ class MockHardwareBackend(HardwareBackend):
278
+ """A fake board: drivers plus a minimal power-path emulation.
279
+
280
+ Call :meth:`end_of_cycle` after every controller cycle to advance the clock
281
+ and to debit/credit the fuel gauge (baseline + executed operations - harvest).
282
+ """
283
+
284
+ profile: NodeProfile = None # type: ignore[assignment]
285
+
286
+ def end_of_cycle(self, report: CycleReport) -> None:
287
+ # same order as the simulator: the interval's load is drawn first, the
288
+ # energy harvested during the interval is credited afterwards
289
+ self.storage.apply_delta_j(-(self.profile.baseline_energy_j + report.energy_spent_j)) # type: ignore[attr-defined]
290
+ self.clock.tick() # type: ignore[attr-defined]
291
+ harvested_j = self.source.measured_power_w() * self.profile.timestep_s # the interval that just elapsed
292
+ self.storage.apply_delta_j(+harvested_j) # type: ignore[attr-defined]
293
+
294
+
295
+ def make_mock_backend(profile: NodeProfile, *, capacity_j: float = 300.0, energy_j: float = 150.0, peak_harvest_w: float = 0.003, seed: int = 0) -> MockHardwareBackend:
296
+ """A complete fake board with a plausible diurnal harvest and a slowly
297
+ drying soil trace. Useful for tests and as a template for a real port."""
298
+ rng = np.random.default_rng(seed)
299
+ clock = MockClock(0.0, profile.timestep_s)
300
+
301
+ def harvest(t: float) -> float:
302
+ h = (t % 86400.0) / 3600.0
303
+ return peak_harvest_w * float(np.sin(np.pi * (h - 6.0) / 12.0)) if 6.0 < h < 18.0 else 0.0
304
+
305
+ def soil_trace(t: float) -> float:
306
+ return 0.6 - 0.05 * (t / 86400.0)
307
+
308
+ return MockHardwareBackend(
309
+ clock=clock,
310
+ storage=MockFuelGauge(capacity_j, energy_j),
311
+ source=MockHarvesterMonitor(harvest, clock),
312
+ sensor=MockSensorDriver(soil_trace, profile.sensing_energy_j, profile.sensing_noise_std, rng),
313
+ radio=MockRadio(profile, rng=rng),
314
+ application=MockDownlink(0),
315
+ profile=profile,
316
+ )
317
+
318
+
319
+ # ---------------------------------------------------------------------------
320
+ # Policy export
321
+ # ---------------------------------------------------------------------------
322
+ @dataclass
323
+ class PolicyBundle:
324
+ """Everything that must be preserved when moving a policy to a device."""
325
+
326
+ policy_type: str
327
+ """'rule_based', 'mlp', 'q_table', ... (free-form identifier)."""
328
+
329
+ observation_names: list[str]
330
+ """Ordering of the observation vector (index = position)."""
331
+
332
+ observation_normalisation: list[str]
333
+ """Human-readable formula per component (documentation of the contract)."""
334
+
335
+ profile: dict[str, Any]
336
+ """NodeProfile constants (energy costs, thresholds, normalisation scales)."""
337
+
338
+ action_encoding: dict[str, Any]
339
+ """MultiDiscrete nvec, flat-index formula and semantics of each value."""
340
+
341
+ model: dict[str, Any] = field(default_factory=dict)
342
+ """Parameters: thresholds for a rule-based policy, weights/biases/activations
343
+ for an MLP, table for a tabular policy, ... (lists, not ndarrays)."""
344
+
345
+ metadata: dict[str, Any] = field(default_factory=dict)
346
+
347
+ @property
348
+ def observation_dim(self) -> int:
349
+ return len(self.observation_names)
350
+
351
+ def to_json(self, indent: int = 2) -> str:
352
+ return json.dumps(dataclasses.asdict(self), indent=indent, default=_json_default)
353
+
354
+ def save(self, path: str | Path) -> Path:
355
+ path = Path(path)
356
+ path.write_text(self.to_json())
357
+ return path
358
+
359
+ @classmethod
360
+ def load(cls, path: str | Path) -> "PolicyBundle":
361
+ return cls(**json.loads(Path(path).read_text()))
362
+
363
+
364
+ def _json_default(o):
365
+ if isinstance(o, np.ndarray):
366
+ return o.tolist()
367
+ if isinstance(o, (np.floating, np.integer)):
368
+ return o.item()
369
+ if dataclasses.is_dataclass(o):
370
+ return dataclasses.asdict(o)
371
+ raise TypeError(f"not JSON serialisable: {type(o)}")
372
+
373
+
374
+ def action_encoding_spec(n_modes: int = DEFAULT_N_MODES) -> dict[str, Any]:
375
+ transmit = {"0": "no transmission"}
376
+ transmit.update({str(k + 1): f"transmit latest stored measurement with radio mode {k}" for k in range(n_modes)})
377
+ return {
378
+ "type": "MultiDiscrete",
379
+ "nvec": list(action_nvec(n_modes)),
380
+ "n_flat": n_flat_actions(n_modes),
381
+ "flat_index": f"sensing_level * {1 + n_modes} + transmit",
382
+ "sensing_level": {"0": "no sensing", "1": "low-cost sensing", "2": "high-quality sensing"},
383
+ "transmit": transmit,
384
+ }
385
+
386
+
387
+ def export_policy(policy: Any, profile: NodeProfile, *, policy_type: str, model: dict[str, Any] | None = None, notes: str = "") -> PolicyBundle:
388
+ """Create a :class:`PolicyBundle` for ``policy``.
389
+
390
+ For the built-in rule-based policy the parameters are exported
391
+ automatically; for other policies pass ``model`` explicitly (e.g. the
392
+ weights of a small MLP as nested lists).
393
+ """
394
+ if model is None:
395
+ params = getattr(policy, "p", None)
396
+ model = dataclasses.asdict(params) if dataclasses.is_dataclass(params) else {}
397
+ return PolicyBundle(
398
+ policy_type=policy_type,
399
+ observation_names=[f.name for f in OBSERVATION_FIELDS],
400
+ observation_normalisation=[f.normalisation for f in OBSERVATION_FIELDS],
401
+ profile=dataclasses.asdict(profile),
402
+ action_encoding=action_encoding_spec(profile.n_modes),
403
+ model=model,
404
+ metadata={
405
+ "edgeengine_aware_version": __version__,
406
+ "exported_at": datetime.now(timezone.utc).isoformat(),
407
+ "python": platform.python_version(),
408
+ "policy_class": type(policy).__name__,
409
+ "notes": notes,
410
+ },
411
+ )
@@ -0,0 +1,284 @@
1
+ """Application domains of EdgeEngine AWARE.
2
+
3
+ A *domain* is a monitored process (what the sensor samples and the
4
+ application wants to know), an energy source (where the harvested energy comes
5
+ from), a hardware profile (how much each operation costs) and a set of
6
+ benchmark scenarios. Three are provided:
7
+
8
+ ============= ===================================== ========================== =================
9
+ domain monitored quantity (danger side) energy source radio
10
+ ============= ===================================== ========================== =================
11
+ agriculture soil moisture (low) solar cell, weather LoRa-like, 3 SF
12
+ indoor_air CO2 of a room (high) indoor PV, lights + window BLE-like, 3 PHY
13
+ industrial bearing temperature of a motor (high) TEG on the warm casing LoRa-like, 3 SF
14
+ ============= ===================================== ========================== =================
15
+
16
+ Whatever the domain, the node sees the same 18-number observation and returns
17
+ the same (sensing level, radio mode) action: the monitored quantity is
18
+ normalised to [0, 1] and the thresholds' direction is a profile constant. A
19
+ policy or a controller therefore runs unchanged in every domain - which is
20
+ what makes cross-domain evaluation possible (``examples/domains.ipynb``).
21
+
22
+ ``build_world(cfg, dt)`` instantiates the schedule, process and source for a
23
+ configuration; ``domain_config(name)`` returns a domain's default
24
+ configuration; ``DOMAIN_SCENARIOS`` holds the benchmark scenarios of every
25
+ domain (``scenarios.get_scenario("indoor_air:no_window")``).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass
31
+ from typing import Any, Callable
32
+
33
+ from .agriculture import FieldEnvironment
34
+ from .config import HOUR_S, EdgeEngineAwareConfig, ble_radio, default_config
35
+ from .energy import SolarEnergySource
36
+ from .indoor import IndoorAirProcess, IndoorLightSource
37
+ from .industrial import BearingProcess, ThermoelectricSource
38
+ from .process import ActivitySchedule
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Default configurations
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ def agriculture_config() -> EdgeEngineAwareConfig:
46
+ """The original smart-agriculture node (see ``config.default_config``)."""
47
+ return default_config()
48
+
49
+
50
+ def indoor_air_config() -> EdgeEngineAwareConfig:
51
+ """CO2 monitoring in a classroom / office with an indoor PV cell.
52
+
53
+ Micro-power node: 60 J storage, 20 uW always-on, ~140 uW harvested under
54
+ the ceiling lights, sub-millijoule BLE uplinks; the CO2 sensor is the
55
+ energy hog (20 / 150 mJ per reading). Occupancy 08:00-18:00 on weekdays.
56
+ """
57
+ cfg = default_config()
58
+ cfg.domain = "indoor_air"
59
+ cfg.harvesting_source = "indoor_light"
60
+ cfg.storage.capacity_j = 60.0
61
+ cfg.storage.initial_soc = 0.5
62
+ cfg.storage.reserve_soc = 0.05
63
+ cfg.mcu.baseline_power_w = 20e-6
64
+ cfg.sensing.energy_j = (0.0, 0.02, 0.15)
65
+ cfg.sensing.noise_std = (0.0, 0.05, 0.015) # 80 ppm / 24 ppm on a 1600 ppm span
66
+ cfg.communication = ble_radio()
67
+ cfg.observation.harvest_ref_power_w = 300e-6
68
+ cfg.application.aoi_elevated_s = 12.0 * HOUR_S
69
+ cfg.application.aoi_urgent_s = 36.0 * HOUR_S
70
+ cfg.schedule.active_days = (0, 1, 2, 3, 4)
71
+ cfg.schedule.start_hour, cfg.schedule.end_hour = 8.0, 18.0
72
+ cfg.schedule.dip_hours, cfg.schedule.dip_level = (12.5, 13.5), 0.3
73
+ cfg.schedule.base_level = 0.8
74
+ return cfg
75
+
76
+
77
+ def industrial_config() -> EdgeEngineAwareConfig:
78
+ """Bearing-temperature monitoring of a motor on two shifts with a TEG.
79
+
80
+ Same always-on load and radio as the agricultural node, a 120 J storage;
81
+ sensing is a cheap temperature reading (50 mJ) or a vibration burst with
82
+ on-board FFT (0.5 J). The machine runs 06:00-22:00 on weekdays, sometimes on
83
+ Saturday: energy is plentiful while it runs and absent over the weekend.
84
+ """
85
+ cfg = default_config()
86
+ cfg.domain = "industrial"
87
+ cfg.harvesting_source = "thermoelectric"
88
+ cfg.storage.capacity_j = 120.0 # enough for a weekend of hourly reports, not more
89
+ cfg.sensing.energy_j = (0.0, 0.05, 0.50)
90
+ cfg.sensing.noise_std = (0.0, 0.03, 0.01) # 3 degC / 1 degC on a 100 degC span
91
+ cfg.schedule.active_days = (0, 1, 2, 3, 4)
92
+ cfg.schedule.start_hour, cfg.schedule.end_hour = 6.0, 22.0
93
+ cfg.schedule.dip_hours = None
94
+ cfg.schedule.base_level = 0.9
95
+ cfg.schedule.day_factor_std = 0.10
96
+ cfg.schedule.p_extra_day = 0.15
97
+ return cfg
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Scenarios per domain
102
+ # ---------------------------------------------------------------------------
103
+
104
+
105
+ def _indoor_no_window() -> EdgeEngineAwareConfig:
106
+ cfg = indoor_air_config()
107
+ cfg.indoor_light.daylight_lux = 0.0 # energy only while the lights are on
108
+ return cfg
109
+
110
+
111
+ def _indoor_weak_ventilation() -> EdgeEngineAwareConfig:
112
+ cfg = indoor_air_config()
113
+ cfg.indoor_air.ach_hvac = 1.5 # CO2 climbs well past the critical threshold every day
114
+ cfg.indoor_air.ach_base = 0.4
115
+ return cfg
116
+
117
+
118
+ def _indoor_long_hours() -> EdgeEngineAwareConfig:
119
+ cfg = indoor_air_config()
120
+ cfg.schedule.active_days = (0, 1, 2, 3, 4, 5) # Saturday too
121
+ cfg.schedule.end_hour = 21.0
122
+ cfg.schedule.base_level = 0.9
123
+ return cfg
124
+
125
+
126
+ def _indoor_dim_lights() -> EdgeEngineAwareConfig:
127
+ cfg = indoor_air_config()
128
+ cfg.indoor_light.artificial_lux = 250.0 # LED office at half the illuminance: half the energy
129
+ cfg.storage.initial_soc = 0.4
130
+ return cfg
131
+
132
+
133
+ def _industrial_single_shift() -> EdgeEngineAwareConfig:
134
+ cfg = industrial_config()
135
+ cfg.schedule.start_hour, cfg.schedule.end_hour = 8.0, 17.0 # half the running hours, half the energy
136
+ cfg.schedule.p_extra_day = 0.0
137
+ return cfg
138
+
139
+
140
+ def _industrial_degrading() -> EdgeEngineAwareConfig:
141
+ cfg = industrial_config()
142
+ cfg.industrial.fault_onsets_per_day = 0.6 # faults every couple of days
143
+ cfg.industrial.initial_health_range = (0.3, 0.7)
144
+ cfg.industrial.maintenance_delay_mean_s = 24.0 * HOUR_S
145
+ return cfg
146
+
147
+
148
+ def _industrial_continuous() -> EdgeEngineAwareConfig:
149
+ cfg = industrial_config()
150
+ cfg.schedule.active_days = (0, 1, 2, 3, 4, 5, 6) # 24/7 plant: energy-rich, no weekend
151
+ cfg.schedule.start_hour, cfg.schedule.end_hour = 0.0, 24.0
152
+ cfg.schedule.ramp_h = 0.01
153
+ cfg.schedule.p_day_off = 0.02
154
+ return cfg
155
+
156
+
157
+ def _industrial_weak_link() -> EdgeEngineAwareConfig:
158
+ cfg = industrial_config()
159
+ cfg.communication.path_loss_mean_db += 6.0 # metal hall, gateway far away
160
+ cfg.communication.slow_fading_autocorr = 0.98
161
+ return cfg
162
+
163
+
164
+ INDOOR_AIR_SCENARIOS: dict[str, Callable[[], EdgeEngineAwareConfig]] = {
165
+ "default": indoor_air_config,
166
+ "no_window": _indoor_no_window,
167
+ "weak_ventilation": _indoor_weak_ventilation,
168
+ "long_hours": _indoor_long_hours,
169
+ "dim_lights": _indoor_dim_lights,
170
+ }
171
+
172
+ INDUSTRIAL_SCENARIOS: dict[str, Callable[[], EdgeEngineAwareConfig]] = {
173
+ "default": industrial_config,
174
+ "single_shift": _industrial_single_shift,
175
+ "degrading": _industrial_degrading,
176
+ "continuous": _industrial_continuous,
177
+ "weak_link": _industrial_weak_link,
178
+ }
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class DomainInfo:
183
+ name: str
184
+ title: str
185
+ quantity: str
186
+ source: str
187
+ radio: str
188
+ description: str
189
+ config: Callable[[], EdgeEngineAwareConfig]
190
+ scenarios: dict[str, Callable[[], EdgeEngineAwareConfig]]
191
+
192
+
193
+ def _agriculture_scenarios() -> dict[str, Callable[[], EdgeEngineAwareConfig]]:
194
+ from .scenarios import SCENARIOS # local import: scenarios.py imports this module
195
+
196
+ return SCENARIOS
197
+
198
+
199
+ DOMAINS: dict[str, DomainInfo] = {
200
+ "agriculture": DomainInfo(
201
+ "agriculture", "Smart agriculture", "soil moisture (danger: low)", "solar cell, weather-driven", "LoRa-like, 3 spreading factors",
202
+ "A soil-moisture node in a field: energy from the sun, information relevance from a slow soil process with rain and irrigation events.",
203
+ agriculture_config, {},
204
+ ),
205
+ "indoor_air": DomainInfo(
206
+ "indoor_air", "Indoor air quality", "CO2 concentration (danger: high)", "indoor PV cell, lights and window", "BLE-like, 3 PHY modes",
207
+ "A CO2 node in a classroom: people raise the CO2 and switch the lights on - energy and relevance coincide; nights and weekends bring neither.",
208
+ indoor_air_config, INDOOR_AIR_SCENARIOS,
209
+ ),
210
+ "industrial": DomainInfo(
211
+ "industrial", "Industrial condition monitoring", "bearing temperature (danger: high)", "thermoelectric generator on the casing", "LoRa-like, 3 spreading factors",
212
+ "A node on a motor bearing: the machine's heat is the energy source and the monitored variable; faults make it run hotter until maintenance.",
213
+ industrial_config, INDUSTRIAL_SCENARIOS,
214
+ ),
215
+ }
216
+ DOMAIN_NAMES = tuple(DOMAINS)
217
+
218
+
219
+ def domain_config(name: str) -> EdgeEngineAwareConfig:
220
+ if name not in DOMAINS:
221
+ raise KeyError(f"unknown domain {name!r}; available: {DOMAIN_NAMES}")
222
+ return DOMAINS[name].config()
223
+
224
+
225
+ def domain_scenarios(name: str) -> dict[str, Callable[[], EdgeEngineAwareConfig]]:
226
+ if name == "agriculture":
227
+ return _agriculture_scenarios()
228
+ return DOMAINS[name].scenarios
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # World factory used by the environment
233
+ # ---------------------------------------------------------------------------
234
+
235
+
236
+ def build_world(cfg: EdgeEngineAwareConfig, timestep_s: float) -> tuple[ActivitySchedule | None, Any, Any]:
237
+ """Instantiate ``(schedule, process, source)`` for ``cfg``.
238
+
239
+ The schedule is shared by the process and the source when the domain has
240
+ one; ``None`` for the agriculture domain, whose weather and soil have
241
+ their own stochastic models.
242
+ """
243
+ schedule: ActivitySchedule | None = None
244
+ if cfg.uses_schedule:
245
+ schedule = ActivitySchedule(cfg.schedule, start_weekday=cfg.time.start_weekday, horizon_days=int(cfg.time.episode_days) + 2)
246
+
247
+ if cfg.domain == "agriculture":
248
+ process: Any = FieldEnvironment(cfg.agriculture, timestep_s)
249
+ elif cfg.domain == "indoor_air":
250
+ assert schedule is not None
251
+ process = IndoorAirProcess(cfg.indoor_air, timestep_s, schedule)
252
+ elif cfg.domain == "industrial":
253
+ assert schedule is not None
254
+ process = BearingProcess(cfg.industrial, timestep_s, schedule)
255
+ else:
256
+ raise ValueError(f"unknown domain {cfg.domain!r}")
257
+
258
+ if cfg.harvesting_source == "solar":
259
+ source: Any = SolarEnergySource(cfg.harvesting, timestep_s)
260
+ elif cfg.harvesting_source == "indoor_light":
261
+ assert schedule is not None
262
+ source = IndoorLightSource(cfg.indoor_light, timestep_s, schedule)
263
+ elif cfg.harvesting_source == "thermoelectric":
264
+ if not isinstance(process, BearingProcess):
265
+ raise ValueError("the thermoelectric source needs the industrial process")
266
+ source = ThermoelectricSource(cfg.thermoelectric, timestep_s, process)
267
+ else:
268
+ raise ValueError(f"unknown harvesting source {cfg.harvesting_source!r}")
269
+ return schedule, process, source
270
+
271
+
272
+ __all__ = [
273
+ "DOMAINS",
274
+ "DOMAIN_NAMES",
275
+ "DomainInfo",
276
+ "INDOOR_AIR_SCENARIOS",
277
+ "INDUSTRIAL_SCENARIOS",
278
+ "agriculture_config",
279
+ "indoor_air_config",
280
+ "industrial_config",
281
+ "domain_config",
282
+ "domain_scenarios",
283
+ "build_world",
284
+ ]