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