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,636 @@
|
|
|
1
|
+
"""Trace-driven backends: replay recorded weather / soil data instead of the
|
|
2
|
+
stochastic models.
|
|
3
|
+
|
|
4
|
+
The stochastic models in ``energy.py`` and ``agriculture.py`` are convenient
|
|
5
|
+
for training (unlimited, controllable variety) but they are *models*. This
|
|
6
|
+
module lets the same environment run on **recorded traces** - e.g. hourly
|
|
7
|
+
irradiance and soil moisture of a real site from an open weather archive
|
|
8
|
+
(``tools/fetch_open_meteo.py``) or from the user's own field loggers - so that
|
|
9
|
+
a policy trained on the synthetic models can be evaluated on data the models
|
|
10
|
+
never saw (a first sim-to-real check), or trained on real seasons directly.
|
|
11
|
+
|
|
12
|
+
Design
|
|
13
|
+
------
|
|
14
|
+
* :class:`Trace` - a time-indexed table. Each column is either an *instant*
|
|
15
|
+
series (soil moisture, temperature: linearly interpolated) or a
|
|
16
|
+
*preceding-interval mean* (irradiance, precipitation as delivered by weather
|
|
17
|
+
archives: piecewise constant over the interval that ends at the timestamp).
|
|
18
|
+
Time is in seconds since the trace's first local midnight, so the
|
|
19
|
+
environment's time-of-day matches the trace's.
|
|
20
|
+
* :class:`TraceSolarEnergySource` - drop-in replacement of
|
|
21
|
+
``energy.SolarEnergySource``: panel power = ``max_power_w * efficiency *
|
|
22
|
+
GHI / 1000 W/m²``, integrated over the decision interval.
|
|
23
|
+
* :class:`TraceFieldEnvironment` - drop-in replacement of
|
|
24
|
+
``agriculture.FieldEnvironment``: replays soil moisture (converted from
|
|
25
|
+
volumetric content to *fraction of field capacity*), temperature and
|
|
26
|
+
humidity; rain events are derived from the precipitation column.
|
|
27
|
+
* :class:`TraceDrivenEnv` - ``EdgeEngineAwareEnv`` whose harvesting and/or
|
|
28
|
+
field backends are trace-driven. Every episode replays a window of the trace
|
|
29
|
+
starting at a (random or fixed) day; everything else (battery, radio,
|
|
30
|
+
application, reward, observation contract) is unchanged, so **the policy
|
|
31
|
+
cannot tell the two worlds apart** - which is exactly the point.
|
|
32
|
+
|
|
33
|
+
The node still only sees measurable quantities: the trace feeds the *ground
|
|
34
|
+
truth*; the sensor and the harvester monitor add their noise as before.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import csv
|
|
40
|
+
import math
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any, Iterable, Mapping
|
|
44
|
+
|
|
45
|
+
import numpy as np
|
|
46
|
+
|
|
47
|
+
from .agriculture import FieldState
|
|
48
|
+
from .config import AgricultureConfig, EdgeEngineAwareConfig, HarvestingConfig
|
|
49
|
+
from .env import EdgeEngineAwareEnv
|
|
50
|
+
|
|
51
|
+
DAY_S = 86400.0
|
|
52
|
+
HOUR_S = 3600.0
|
|
53
|
+
|
|
54
|
+
# Column kinds: how a value at timestamp t is to be interpreted.
|
|
55
|
+
INSTANT = "instant" # sampled at t; linear interpolation between samples
|
|
56
|
+
PRECEDING_MEAN = "preceding_mean" # mean over (t - step, t]; piecewise constant
|
|
57
|
+
|
|
58
|
+
#: Default interpretation of the Open-Meteo / ERA5-Land hourly variables.
|
|
59
|
+
DEFAULT_KINDS: dict[str, str] = {
|
|
60
|
+
"shortwave_radiation": PRECEDING_MEAN, # W/m², mean of the preceding hour
|
|
61
|
+
"precipitation": PRECEDING_MEAN, # mm, sum of the preceding hour
|
|
62
|
+
"soil_moisture_0_to_7cm": INSTANT, # m³/m³
|
|
63
|
+
"temperature_2m": INSTANT, # °C
|
|
64
|
+
"relative_humidity_2m": INSTANT, # %
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Trace container
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
@dataclass
|
|
72
|
+
class TraceColumn:
|
|
73
|
+
values: np.ndarray
|
|
74
|
+
kind: str = INSTANT
|
|
75
|
+
unit: str = ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Trace:
|
|
80
|
+
"""A time-indexed table of recorded quantities.
|
|
81
|
+
|
|
82
|
+
``time_s`` is strictly increasing, in seconds since the first local
|
|
83
|
+
midnight of the recording (``time_s[0]`` may be > 0 if the recording does
|
|
84
|
+
not start at midnight). ``meta`` carries provenance (site, source, UTC
|
|
85
|
+
offset, ...) and is written back as ``# key=value`` header lines by
|
|
86
|
+
:meth:`to_csv`.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
time_s: np.ndarray
|
|
90
|
+
columns: dict[str, TraceColumn]
|
|
91
|
+
meta: dict[str, str] = field(default_factory=dict)
|
|
92
|
+
|
|
93
|
+
def __post_init__(self) -> None:
|
|
94
|
+
self.time_s = np.asarray(self.time_s, dtype=float)
|
|
95
|
+
if self.time_s.ndim != 1 or len(self.time_s) < 2:
|
|
96
|
+
raise ValueError("a trace needs at least two samples")
|
|
97
|
+
if np.any(np.diff(self.time_s) <= 0):
|
|
98
|
+
raise ValueError("trace timestamps must be strictly increasing")
|
|
99
|
+
for name, col in self.columns.items():
|
|
100
|
+
col.values = np.asarray(col.values, dtype=float)
|
|
101
|
+
if col.values.shape != self.time_s.shape:
|
|
102
|
+
raise ValueError(f"column {name!r} has {col.values.shape[0]} samples, expected {len(self.time_s)}")
|
|
103
|
+
if col.kind not in (INSTANT, PRECEDING_MEAN):
|
|
104
|
+
raise ValueError(f"unknown column kind {col.kind!r} for {name!r}")
|
|
105
|
+
|
|
106
|
+
# -- geometry -------------------------------------------------------------
|
|
107
|
+
@property
|
|
108
|
+
def start_s(self) -> float:
|
|
109
|
+
return float(self.time_s[0])
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def end_s(self) -> float:
|
|
113
|
+
return float(self.time_s[-1])
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def duration_s(self) -> float:
|
|
117
|
+
return self.end_s - self.start_s
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def n_days(self) -> int:
|
|
121
|
+
"""Number of complete days available from the first midnight."""
|
|
122
|
+
return int(self.end_s // DAY_S)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def step_s(self) -> float:
|
|
126
|
+
"""Median sampling interval."""
|
|
127
|
+
return float(np.median(np.diff(self.time_s)))
|
|
128
|
+
|
|
129
|
+
def __contains__(self, name: str) -> bool:
|
|
130
|
+
return name in self.columns
|
|
131
|
+
|
|
132
|
+
def __len__(self) -> int:
|
|
133
|
+
return len(self.time_s)
|
|
134
|
+
|
|
135
|
+
# -- access ---------------------------------------------------------------
|
|
136
|
+
def value_at(self, name: str, t: float | np.ndarray) -> float | np.ndarray:
|
|
137
|
+
"""Value of ``name`` at time ``t`` (clamped to the recording range).
|
|
138
|
+
|
|
139
|
+
Instant columns are linearly interpolated; preceding-mean columns are
|
|
140
|
+
piecewise constant: the value stamped ``t_k`` holds on ``(t_{k-1}, t_k]``.
|
|
141
|
+
"""
|
|
142
|
+
col = self.columns[name]
|
|
143
|
+
t_arr = np.clip(np.asarray(t, dtype=float), self.time_s[0], self.time_s[-1])
|
|
144
|
+
if col.kind == INSTANT:
|
|
145
|
+
out = np.interp(t_arr, self.time_s, col.values)
|
|
146
|
+
else:
|
|
147
|
+
idx = np.searchsorted(self.time_s, t_arr, side="left")
|
|
148
|
+
idx = np.clip(idx, 0, len(self.time_s) - 1)
|
|
149
|
+
out = col.values[idx]
|
|
150
|
+
return float(out) if np.ndim(t) == 0 else out
|
|
151
|
+
|
|
152
|
+
def interval_mean(self, name: str, t0: float, t1: float) -> float:
|
|
153
|
+
"""Time average of ``name`` over ``[t0, t1]`` (exact for both kinds)."""
|
|
154
|
+
if t1 <= t0:
|
|
155
|
+
return self.value_at(name, t0) # type: ignore[return-value]
|
|
156
|
+
lo, hi = self.time_s[0], self.time_s[-1]
|
|
157
|
+
a, b = float(np.clip(t0, lo, hi)), float(np.clip(t1, lo, hi))
|
|
158
|
+
if b <= a: # entirely outside the recording: hold the boundary value
|
|
159
|
+
return self.value_at(name, a) # type: ignore[return-value]
|
|
160
|
+
col = self.columns[name]
|
|
161
|
+
inner = self.time_s[(self.time_s > a) & (self.time_s < b)]
|
|
162
|
+
knots = np.concatenate(([a], inner, [b]))
|
|
163
|
+
if col.kind == INSTANT:
|
|
164
|
+
vals = np.interp(knots, self.time_s, col.values)
|
|
165
|
+
integral = float(np.sum(0.5 * (vals[1:] + vals[:-1]) * np.diff(knots)))
|
|
166
|
+
else:
|
|
167
|
+
# piecewise constant on (t_{k-1}, t_k]: evaluate just right of each left knot
|
|
168
|
+
mids = 0.5 * (knots[1:] + knots[:-1])
|
|
169
|
+
vals = np.asarray(self.value_at(name, mids), dtype=float)
|
|
170
|
+
integral = float(np.sum(vals * np.diff(knots)))
|
|
171
|
+
return integral / (b - a)
|
|
172
|
+
|
|
173
|
+
def interval_sum(self, name: str, t0: float, t1: float) -> float:
|
|
174
|
+
"""Integral of a *rate* column over ``[t0, t1]`` expressed in the
|
|
175
|
+
column's per-sample units (e.g. mm for hourly precipitation sums)."""
|
|
176
|
+
col = self.columns[name]
|
|
177
|
+
if col.kind != PRECEDING_MEAN:
|
|
178
|
+
raise ValueError("interval_sum is defined for preceding-mean columns only")
|
|
179
|
+
return self.interval_mean(name, t0, t1) * (t1 - t0) / self.step_s
|
|
180
|
+
|
|
181
|
+
def daily_means(self, name: str) -> np.ndarray:
|
|
182
|
+
"""Mean of ``name`` over each complete day ``[k*86400, (k+1)*86400)``."""
|
|
183
|
+
return np.array([self.interval_mean(name, k * DAY_S, (k + 1) * DAY_S) for k in range(self.n_days)])
|
|
184
|
+
|
|
185
|
+
def slice_days(self, start_day: int, n_days: int) -> "Trace":
|
|
186
|
+
"""Sub-trace covering days ``[start_day, start_day + n_days]`` re-based to day 0."""
|
|
187
|
+
t0, t1 = start_day * DAY_S, (start_day + n_days) * DAY_S
|
|
188
|
+
sel = (self.time_s >= t0) & (self.time_s <= t1)
|
|
189
|
+
if sel.sum() < 2:
|
|
190
|
+
raise ValueError("requested day range is outside the trace")
|
|
191
|
+
cols = {n: TraceColumn(c.values[sel].copy(), c.kind, c.unit) for n, c in self.columns.items()}
|
|
192
|
+
meta = dict(self.meta)
|
|
193
|
+
meta["sliced_from_day"] = str(start_day)
|
|
194
|
+
return Trace(self.time_s[sel] - t0, cols, meta)
|
|
195
|
+
|
|
196
|
+
# -- I/O ------------------------------------------------------------------
|
|
197
|
+
@classmethod
|
|
198
|
+
def from_csv(
|
|
199
|
+
cls,
|
|
200
|
+
path: str | Path,
|
|
201
|
+
*,
|
|
202
|
+
time_column: str = "time",
|
|
203
|
+
kinds: Mapping[str, str] | None = None,
|
|
204
|
+
columns: Iterable[str] | None = None,
|
|
205
|
+
) -> "Trace":
|
|
206
|
+
"""Load a trace from CSV.
|
|
207
|
+
|
|
208
|
+
The file may start with ``# key=value`` metadata lines. ``time`` is
|
|
209
|
+
either an ISO-8601 local timestamp (``2023-06-01T13:00``) or a number
|
|
210
|
+
of seconds. Column kinds default to :data:`DEFAULT_KINDS` (instant for
|
|
211
|
+
unknown names) and can be overridden with ``kinds``.
|
|
212
|
+
"""
|
|
213
|
+
path = Path(path)
|
|
214
|
+
meta: dict[str, str] = {}
|
|
215
|
+
rows: list[dict[str, str]] = []
|
|
216
|
+
with path.open(newline="") as fh:
|
|
217
|
+
header_lines = []
|
|
218
|
+
for line in fh:
|
|
219
|
+
if line.startswith("#"):
|
|
220
|
+
body = line[1:].strip()
|
|
221
|
+
if "=" in body:
|
|
222
|
+
k, v = body.split("=", 1)
|
|
223
|
+
meta[k.strip()] = v.strip()
|
|
224
|
+
continue
|
|
225
|
+
header_lines.append(line)
|
|
226
|
+
break
|
|
227
|
+
reader = csv.DictReader(header_lines + list(fh))
|
|
228
|
+
for row in reader:
|
|
229
|
+
if row.get(time_column) in (None, ""):
|
|
230
|
+
continue
|
|
231
|
+
rows.append(row)
|
|
232
|
+
if not rows:
|
|
233
|
+
raise ValueError(f"{path} contains no data rows")
|
|
234
|
+
names = [n for n in rows[0].keys() if n != time_column and n is not None]
|
|
235
|
+
if columns is not None:
|
|
236
|
+
names = [n for n in names if n in set(columns)]
|
|
237
|
+
time_raw = [r[time_column] for r in rows]
|
|
238
|
+
time_s = _parse_time(time_raw)
|
|
239
|
+
kinds_map = dict(DEFAULT_KINDS)
|
|
240
|
+
# ``# kind:<column>=<kind>`` header lines written by :meth:`to_csv`
|
|
241
|
+
for k in [k for k in meta if k.startswith("kind:")]:
|
|
242
|
+
kinds_map[k[len("kind:"):]] = meta.pop(k)
|
|
243
|
+
kinds_map.update(kinds or {})
|
|
244
|
+
cols = {}
|
|
245
|
+
for n in names:
|
|
246
|
+
vals = np.array([float(r[n]) if r[n] not in ("", None) else np.nan for r in rows], dtype=float)
|
|
247
|
+
if np.isnan(vals).any(): # fill gaps by interpolation (archives have a few)
|
|
248
|
+
ok = ~np.isnan(vals)
|
|
249
|
+
if ok.sum() < 2:
|
|
250
|
+
raise ValueError(f"column {n!r} has no usable data")
|
|
251
|
+
vals = np.interp(time_s, time_s[ok], vals[ok])
|
|
252
|
+
cols[n] = TraceColumn(vals, kinds_map.get(n, INSTANT))
|
|
253
|
+
meta.setdefault("file", path.name)
|
|
254
|
+
return cls(time_s, cols, meta)
|
|
255
|
+
|
|
256
|
+
def to_csv(self, path: str | Path, *, time_format: str = "seconds") -> None:
|
|
257
|
+
"""Write the trace (metadata as ``# key=value`` header lines)."""
|
|
258
|
+
path = Path(path)
|
|
259
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
with path.open("w", newline="") as fh:
|
|
261
|
+
for k, v in self.meta.items():
|
|
262
|
+
fh.write(f"# {k}={v}\n")
|
|
263
|
+
for n, c in self.columns.items():
|
|
264
|
+
fh.write(f"# kind:{n}={c.kind}\n")
|
|
265
|
+
w = csv.writer(fh)
|
|
266
|
+
w.writerow(["time", *self.columns])
|
|
267
|
+
start = self.meta.get("start_local")
|
|
268
|
+
for i, t in enumerate(self.time_s):
|
|
269
|
+
if time_format == "iso" and start:
|
|
270
|
+
ts = (np.datetime64(start) + np.timedelta64(int(round(t)), "s")).astype("datetime64[m]")
|
|
271
|
+
tcol: Any = str(ts)
|
|
272
|
+
else:
|
|
273
|
+
tcol = f"{t:.0f}"
|
|
274
|
+
w.writerow([tcol, *(f"{c.values[i]:.6g}" for c in self.columns.values())])
|
|
275
|
+
|
|
276
|
+
def describe(self) -> str:
|
|
277
|
+
lines = [f"Trace: {len(self)} samples, {self.n_days} days, step {self.step_s/3600:.2f} h"]
|
|
278
|
+
for k, v in self.meta.items():
|
|
279
|
+
lines.append(f" {k} = {v}")
|
|
280
|
+
for n, c in self.columns.items():
|
|
281
|
+
lines.append(f" {n:28s} [{c.kind:14s}] min {c.values.min():9.3f} mean {c.values.mean():9.3f} max {c.values.max():9.3f}")
|
|
282
|
+
return "\n".join(lines)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _parse_time(raw: list[str]) -> np.ndarray:
|
|
286
|
+
"""ISO timestamps → seconds since the first local midnight; numbers → as is."""
|
|
287
|
+
first = raw[0].strip()
|
|
288
|
+
try:
|
|
289
|
+
float(first)
|
|
290
|
+
return np.array([float(x) for x in raw], dtype=float)
|
|
291
|
+
except ValueError:
|
|
292
|
+
pass
|
|
293
|
+
ts = np.array([np.datetime64(x.strip()) for x in raw]).astype("datetime64[s]")
|
|
294
|
+
midnight = ts[0].astype("datetime64[D]").astype("datetime64[s]")
|
|
295
|
+
return (ts - midnight).astype("timedelta64[s]").astype(float)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
# Episode window shared by all trace-driven backends of one environment
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
class TraceWindow:
|
|
302
|
+
"""Which part of the trace the current episode replays.
|
|
303
|
+
|
|
304
|
+
``offset_s`` is added to the environment clock (which starts at day 0)
|
|
305
|
+
to index the trace; it is always a whole number of days so that the
|
|
306
|
+
time of day is preserved.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
def __init__(self, trace: Trace, episode_s: float, start_day: int | None = None):
|
|
310
|
+
self.trace = trace
|
|
311
|
+
self.episode_s = episode_s
|
|
312
|
+
self.start_day = start_day
|
|
313
|
+
# one extra day is needed because the harvesting source is started one
|
|
314
|
+
# interval before the episode and the field looks one step ahead
|
|
315
|
+
self.max_start_day = int(math.floor((trace.end_s - episode_s) / DAY_S)) - 1
|
|
316
|
+
if self.max_start_day < 0:
|
|
317
|
+
raise ValueError(
|
|
318
|
+
f"trace covers {trace.end_s/DAY_S:.1f} days, episodes need {episode_s/DAY_S:.1f} (+1) days"
|
|
319
|
+
)
|
|
320
|
+
if start_day is not None and not 0 <= start_day <= self.max_start_day:
|
|
321
|
+
raise ValueError(f"start_day must be in [0, {self.max_start_day}], got {start_day}")
|
|
322
|
+
self.current_day = start_day if start_day is not None else 0
|
|
323
|
+
|
|
324
|
+
def choose(self, rng: np.random.Generator, start_day: int | None = None) -> int:
|
|
325
|
+
if start_day is None:
|
|
326
|
+
start_day = self.start_day
|
|
327
|
+
if start_day is None:
|
|
328
|
+
start_day = int(rng.integers(0, self.max_start_day + 1))
|
|
329
|
+
elif not 0 <= start_day <= self.max_start_day:
|
|
330
|
+
raise ValueError(f"start_day must be in [0, {self.max_start_day}], got {start_day}")
|
|
331
|
+
self.current_day = int(start_day)
|
|
332
|
+
return self.current_day
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def offset_s(self) -> float:
|
|
336
|
+
return self.current_day * DAY_S
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
# Harvesting backend
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
class TraceSolarEnergySource:
|
|
343
|
+
"""``SolarEnergySource`` twin driven by a recorded irradiance series.
|
|
344
|
+
|
|
345
|
+
P(t) = max_power_w * efficiency * GHI(t) / reference_irradiance, averaged
|
|
346
|
+
over the decision interval. ``max_power_w`` keeps its meaning of *panel
|
|
347
|
+
output at the reference irradiance* (1000 W/m² = STC by default), so the
|
|
348
|
+
hardware profile and the domain randomisation of ``HarvestingConfig`` apply
|
|
349
|
+
unchanged; the cloud/clearness parameters are ignored (the trace *is* the
|
|
350
|
+
weather).
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
def __init__(
|
|
354
|
+
self,
|
|
355
|
+
trace: Trace,
|
|
356
|
+
cfg: HarvestingConfig,
|
|
357
|
+
timestep_s: float,
|
|
358
|
+
window: TraceWindow,
|
|
359
|
+
*,
|
|
360
|
+
column: str = "shortwave_radiation",
|
|
361
|
+
reference_irradiance_w_m2: float = 1000.0,
|
|
362
|
+
):
|
|
363
|
+
if column not in trace:
|
|
364
|
+
raise KeyError(f"trace has no column {column!r}")
|
|
365
|
+
self.trace = trace
|
|
366
|
+
self.cfg = cfg
|
|
367
|
+
self.dt = timestep_s
|
|
368
|
+
self.window = window
|
|
369
|
+
self.column = column
|
|
370
|
+
self.ref_irr = reference_irradiance_w_m2
|
|
371
|
+
self._rng = np.random.default_rng()
|
|
372
|
+
self._daily_irr = trace.daily_means(column)
|
|
373
|
+
self._daily_irr_max = float(self._daily_irr.max()) if len(self._daily_irr) else 1.0
|
|
374
|
+
self._power_w = 0.0
|
|
375
|
+
self._measured_w = 0.0
|
|
376
|
+
self._time_s = 0.0
|
|
377
|
+
|
|
378
|
+
def reset(self, rng: np.random.Generator, start_time_s: float) -> None:
|
|
379
|
+
self._rng = rng
|
|
380
|
+
self.update(start_time_s)
|
|
381
|
+
|
|
382
|
+
def irradiance_w_m2(self, time_s: float) -> float:
|
|
383
|
+
"""Mean GHI over the interval starting at environment time ``time_s``."""
|
|
384
|
+
t0 = time_s + self.window.offset_s
|
|
385
|
+
return self.trace.interval_mean(self.column, t0, t0 + self.dt)
|
|
386
|
+
|
|
387
|
+
def update(self, time_s: float) -> None:
|
|
388
|
+
self._time_s = time_s
|
|
389
|
+
c = self.cfg
|
|
390
|
+
self._power_w = c.max_power_w * c.efficiency * max(0.0, self.irradiance_w_m2(time_s)) / self.ref_irr
|
|
391
|
+
noise = 1.0 + self._rng.normal(0.0, c.measurement_noise_std)
|
|
392
|
+
self._measured_w = max(0.0, self._power_w * noise)
|
|
393
|
+
|
|
394
|
+
# -- EnergySource protocol ------------------------------------------------
|
|
395
|
+
def measured_power_w(self) -> float:
|
|
396
|
+
return self._measured_w
|
|
397
|
+
|
|
398
|
+
# -- simulator-only ground truth ------------------------------------------
|
|
399
|
+
def true_power_w(self) -> float:
|
|
400
|
+
return self._power_w
|
|
401
|
+
|
|
402
|
+
def harvested_energy_j(self) -> float:
|
|
403
|
+
return self._power_w * self.dt
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def daily_clearness(self) -> float:
|
|
407
|
+
"""Today's mean irradiance relative to the sunniest day of the trace
|
|
408
|
+
(a stand-in for the clearness index of the stochastic model)."""
|
|
409
|
+
day = int((self._time_s + self.window.offset_s) // DAY_S)
|
|
410
|
+
if not 0 <= day < len(self._daily_irr) or self._daily_irr_max <= 0:
|
|
411
|
+
return float("nan")
|
|
412
|
+
return float(self._daily_irr[day] / self._daily_irr_max)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ---------------------------------------------------------------------------
|
|
416
|
+
# Field backend
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
class TraceFieldEnvironment:
|
|
419
|
+
"""``FieldEnvironment`` twin replaying recorded soil / atmosphere series.
|
|
420
|
+
|
|
421
|
+
* soil moisture: volumetric content [m³/m³] divided by ``field_capacity``
|
|
422
|
+
→ fraction of field capacity in [0, ``max_moisture``] (the unit of the
|
|
423
|
+
whole simulator); already-normalised series use ``field_capacity=1``.
|
|
424
|
+
* temperature [°C], relative humidity [% or fraction] replayed as they are.
|
|
425
|
+
* ``event_occurred`` / ``rain_events``: a step change of the *replayed*
|
|
426
|
+
moisture above ``event_change_threshold`` or a zone crossing, exactly as
|
|
427
|
+
in the stochastic model; rain events (onsets of a rain spell, i.e. a
|
|
428
|
+
first interval with more than ``rain_threshold_mm``) are counted from the
|
|
429
|
+
precipitation column when present.
|
|
430
|
+
* irrigation is whatever the recorded field received (not modelled).
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
def __init__(
|
|
434
|
+
self,
|
|
435
|
+
trace: Trace,
|
|
436
|
+
cfg: AgricultureConfig,
|
|
437
|
+
timestep_s: float,
|
|
438
|
+
window: TraceWindow,
|
|
439
|
+
*,
|
|
440
|
+
moisture_column: str = "soil_moisture_0_to_7cm",
|
|
441
|
+
temperature_column: str | None = "temperature_2m",
|
|
442
|
+
humidity_column: str | None = "relative_humidity_2m",
|
|
443
|
+
precipitation_column: str | None = "precipitation",
|
|
444
|
+
field_capacity: float = 0.40,
|
|
445
|
+
rain_threshold_mm: float = 0.2,
|
|
446
|
+
):
|
|
447
|
+
if moisture_column not in trace:
|
|
448
|
+
raise KeyError(f"trace has no column {moisture_column!r}")
|
|
449
|
+
self.trace = trace
|
|
450
|
+
self.cfg = cfg
|
|
451
|
+
self.dt = timestep_s
|
|
452
|
+
self.window = window
|
|
453
|
+
self.moisture_column = moisture_column
|
|
454
|
+
self.temperature_column = temperature_column if temperature_column in trace else None
|
|
455
|
+
self.humidity_column = humidity_column if humidity_column in trace else None
|
|
456
|
+
self.precipitation_column = precipitation_column if precipitation_column in trace else None
|
|
457
|
+
if field_capacity <= 0:
|
|
458
|
+
raise ValueError("field_capacity must be positive")
|
|
459
|
+
self.field_capacity = field_capacity
|
|
460
|
+
self.rain_threshold_mm = rain_threshold_mm
|
|
461
|
+
self._rng = np.random.default_rng()
|
|
462
|
+
self.reset(self._rng, start_time_s=0.0)
|
|
463
|
+
|
|
464
|
+
# -- lifecycle --------------------------------------------------------------
|
|
465
|
+
def reset(self, rng: np.random.Generator, start_time_s: float) -> None:
|
|
466
|
+
self._rng = rng
|
|
467
|
+
t = start_time_s + self.window.offset_s
|
|
468
|
+
self.moisture = self._moisture_at(t)
|
|
469
|
+
self.temperature = self._temperature_at(t)
|
|
470
|
+
self.humidity = self._humidity_at(t)
|
|
471
|
+
self.last_change = 0.0
|
|
472
|
+
self.event_occurred = False
|
|
473
|
+
self.rain_events = 0
|
|
474
|
+
self.irrigation_events = 0
|
|
475
|
+
self.total_rain = 0.0
|
|
476
|
+
self.total_irrigation = 0.0
|
|
477
|
+
self._raining = False
|
|
478
|
+
|
|
479
|
+
# -- helpers ----------------------------------------------------------------
|
|
480
|
+
def _moisture_at(self, trace_t: float) -> float:
|
|
481
|
+
v = self.trace.value_at(self.moisture_column, trace_t) / self.field_capacity
|
|
482
|
+
return float(np.clip(v, 0.0, self.cfg.max_moisture))
|
|
483
|
+
|
|
484
|
+
def _temperature_at(self, trace_t: float) -> float:
|
|
485
|
+
if self.temperature_column is None:
|
|
486
|
+
return self.cfg.temp_mean_c
|
|
487
|
+
return float(self.trace.value_at(self.temperature_column, trace_t))
|
|
488
|
+
|
|
489
|
+
def _humidity_at(self, trace_t: float) -> float:
|
|
490
|
+
if self.humidity_column is None:
|
|
491
|
+
return self.cfg.humidity_mean
|
|
492
|
+
h = float(self.trace.value_at(self.humidity_column, trace_t))
|
|
493
|
+
if h > 1.5: # percent → fraction
|
|
494
|
+
h /= 100.0
|
|
495
|
+
return float(np.clip(h, 0.0, 1.0))
|
|
496
|
+
|
|
497
|
+
@property
|
|
498
|
+
def value(self) -> float:
|
|
499
|
+
return self.moisture
|
|
500
|
+
|
|
501
|
+
def zone(self, moisture: float | None = None) -> int:
|
|
502
|
+
m = self.moisture if moisture is None else moisture
|
|
503
|
+
if m < self.cfg.critical_threshold:
|
|
504
|
+
return 2
|
|
505
|
+
if m < self.cfg.warning_threshold:
|
|
506
|
+
return 1
|
|
507
|
+
return 0
|
|
508
|
+
|
|
509
|
+
# -- dynamics ---------------------------------------------------------------
|
|
510
|
+
def step(self, time_s: float) -> FieldState:
|
|
511
|
+
"""Advance to ``time_s + dt`` by reading the trace."""
|
|
512
|
+
prev = self.moisture
|
|
513
|
+
prev_zone = self.zone(prev)
|
|
514
|
+
t1 = time_s + self.dt + self.window.offset_s
|
|
515
|
+
self.moisture = self._moisture_at(t1)
|
|
516
|
+
self.temperature = self._temperature_at(t1)
|
|
517
|
+
self.humidity = self._humidity_at(t1)
|
|
518
|
+
self.last_change = self.moisture - prev
|
|
519
|
+
if self.precipitation_column is not None:
|
|
520
|
+
rain_mm = self.trace.interval_sum(self.precipitation_column, t1 - self.dt, t1)
|
|
521
|
+
raining = rain_mm > self.rain_threshold_mm
|
|
522
|
+
if raining:
|
|
523
|
+
self.total_rain += rain_mm
|
|
524
|
+
if not self._raining: # count the onset of a rain spell once
|
|
525
|
+
self.rain_events += 1
|
|
526
|
+
self._raining = raining
|
|
527
|
+
crossed = self.zone(self.moisture) != prev_zone
|
|
528
|
+
self.event_occurred = abs(self.last_change) >= self.cfg.event_change_threshold or crossed
|
|
529
|
+
return self.state()
|
|
530
|
+
|
|
531
|
+
def state(self) -> FieldState:
|
|
532
|
+
return FieldState(
|
|
533
|
+
soil_moisture=self.moisture,
|
|
534
|
+
air_temperature_c=self.temperature,
|
|
535
|
+
relative_humidity=self.humidity,
|
|
536
|
+
last_step_change=self.last_change,
|
|
537
|
+
event_occurred=self.event_occurred,
|
|
538
|
+
zone=self.zone(),
|
|
539
|
+
rain_events=self.rain_events,
|
|
540
|
+
irrigation_events=self.irrigation_events,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# ---------------------------------------------------------------------------
|
|
545
|
+
# Environment
|
|
546
|
+
# ---------------------------------------------------------------------------
|
|
547
|
+
class TraceDrivenEnv(EdgeEngineAwareEnv):
|
|
548
|
+
"""EdgeEngine AWARE environment replaying a recorded trace.
|
|
549
|
+
|
|
550
|
+
Parameters
|
|
551
|
+
----------
|
|
552
|
+
trace:
|
|
553
|
+
the recording (see :class:`Trace`).
|
|
554
|
+
config:
|
|
555
|
+
the usual configuration; ``time.episode_days`` sets the window length.
|
|
556
|
+
start_day:
|
|
557
|
+
first day of the trace to replay; ``None`` draws a random admissible
|
|
558
|
+
day at every reset (``reset(options={"start_day": k})`` overrides it
|
|
559
|
+
for one episode).
|
|
560
|
+
harvesting / field:
|
|
561
|
+
which backends to replace (both by default). Keeping the stochastic
|
|
562
|
+
field with a recorded irradiance is useful to isolate the weather
|
|
563
|
+
effect, and vice versa.
|
|
564
|
+
field_capacity:
|
|
565
|
+
volumetric soil moisture corresponding to "1.0" in the simulator.
|
|
566
|
+
source_kwargs / field_kwargs:
|
|
567
|
+
forwarded to the backends (column names, reference irradiance, ...).
|
|
568
|
+
"""
|
|
569
|
+
|
|
570
|
+
def __init__(
|
|
571
|
+
self,
|
|
572
|
+
trace: Trace,
|
|
573
|
+
config: EdgeEngineAwareConfig | None = None,
|
|
574
|
+
*,
|
|
575
|
+
start_day: int | None = None,
|
|
576
|
+
harvesting: bool = True,
|
|
577
|
+
field: bool = True,
|
|
578
|
+
field_capacity: float = 0.40,
|
|
579
|
+
source_kwargs: Mapping[str, Any] | None = None,
|
|
580
|
+
field_kwargs: Mapping[str, Any] | None = None,
|
|
581
|
+
render_mode: str | None = None,
|
|
582
|
+
):
|
|
583
|
+
if not (harvesting or field):
|
|
584
|
+
raise ValueError("at least one of harvesting/field must be trace-driven")
|
|
585
|
+
self.trace = trace
|
|
586
|
+
self.start_day = start_day
|
|
587
|
+
self.use_trace_harvesting = harvesting
|
|
588
|
+
self.use_trace_field = field
|
|
589
|
+
self.field_capacity = field_capacity
|
|
590
|
+
self.source_kwargs = dict(source_kwargs or {})
|
|
591
|
+
self.field_kwargs = dict(field_kwargs or {})
|
|
592
|
+
cfg = config if config is not None else EdgeEngineAwareConfig()
|
|
593
|
+
if cfg.domain != "agriculture" or cfg.harvesting_source != "solar":
|
|
594
|
+
raise ValueError("TraceDrivenEnv replays soil/weather traces: it needs the agriculture domain with the solar source")
|
|
595
|
+
self.window = TraceWindow(trace, cfg.time.episode_days * DAY_S + cfg.time.start_hour * HOUR_S, start_day)
|
|
596
|
+
super().__init__(cfg, render_mode=render_mode)
|
|
597
|
+
|
|
598
|
+
def _build_subsystems(self, cfg: EdgeEngineAwareConfig) -> None:
|
|
599
|
+
super()._build_subsystems(cfg)
|
|
600
|
+
dt = cfg.time.timestep_s
|
|
601
|
+
if self.use_trace_harvesting:
|
|
602
|
+
self.source = TraceSolarEnergySource(self.trace, cfg.harvesting, dt, self.window, **self.source_kwargs)
|
|
603
|
+
if self.use_trace_field:
|
|
604
|
+
self.process = TraceFieldEnvironment(
|
|
605
|
+
self.trace, cfg.agriculture, dt, self.window, field_capacity=self.field_capacity, **self.field_kwargs
|
|
606
|
+
)
|
|
607
|
+
self.field = self.process
|
|
608
|
+
# the sensor's ground-truth callable resolves ``self.process`` lazily
|
|
609
|
+
|
|
610
|
+
def reset(self, *, seed: int | None = None, options: dict[str, Any] | None = None):
|
|
611
|
+
if seed is not None:
|
|
612
|
+
super(EdgeEngineAwareEnv, self).reset(seed=seed) # seed np_random before drawing the window
|
|
613
|
+
requested = None if options is None else options.get("start_day")
|
|
614
|
+
self.window.choose(self.np_random, requested)
|
|
615
|
+
obs, info = super().reset(seed=seed, options=options)
|
|
616
|
+
info["trace_start_day"] = self.window.current_day
|
|
617
|
+
return obs, info
|
|
618
|
+
|
|
619
|
+
def _make_info(self, reward_components, utility_breakdown, rejected):
|
|
620
|
+
info = super()._make_info(reward_components, utility_breakdown, rejected)
|
|
621
|
+
info["trace_start_day"] = self.window.current_day
|
|
622
|
+
info["trace_day"] = self.window.current_day + self.clock.day_index()
|
|
623
|
+
return info
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
__all__ = [
|
|
627
|
+
"INSTANT",
|
|
628
|
+
"PRECEDING_MEAN",
|
|
629
|
+
"DEFAULT_KINDS",
|
|
630
|
+
"Trace",
|
|
631
|
+
"TraceColumn",
|
|
632
|
+
"TraceWindow",
|
|
633
|
+
"TraceSolarEnergySource",
|
|
634
|
+
"TraceFieldEnvironment",
|
|
635
|
+
"TraceDrivenEnv",
|
|
636
|
+
]
|