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,93 @@
|
|
|
1
|
+
"""EdgeEngine AWARE - application- and energy-aware simulation environment
|
|
2
|
+
for reinforcement learning in energy-harvesting Edge IoT systems.
|
|
3
|
+
|
|
4
|
+
Quick start::
|
|
5
|
+
|
|
6
|
+
import gymnasium as gym
|
|
7
|
+
import edgeengine_aware # registers "EdgeEngineAware-v0"
|
|
8
|
+
|
|
9
|
+
env = gym.make("EdgeEngineAware-v0")
|
|
10
|
+
obs, info = env.reset(seed=0)
|
|
11
|
+
obs, reward, terminated, truncated, info = env.step(env.action_space.sample())
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
__version__ = "0.4.0"
|
|
17
|
+
|
|
18
|
+
from gymnasium.envs.registration import register
|
|
19
|
+
|
|
20
|
+
from .actions import Action, decode_action, describe_action, encode_action, flatten_action, unflatten_action
|
|
21
|
+
from .config import (
|
|
22
|
+
AgricultureConfig,
|
|
23
|
+
ApplicationConfig,
|
|
24
|
+
CommunicationConfig,
|
|
25
|
+
DomainRandomizationConfig,
|
|
26
|
+
EdgeEngineAwareConfig,
|
|
27
|
+
EnergyStorageConfig,
|
|
28
|
+
HarvestingConfig,
|
|
29
|
+
MCUConfig,
|
|
30
|
+
ObservationConfig,
|
|
31
|
+
RewardConfig,
|
|
32
|
+
SensingConfig,
|
|
33
|
+
TimeConfig,
|
|
34
|
+
default_config,
|
|
35
|
+
)
|
|
36
|
+
from .env import EdgeEngineAwareEnv
|
|
37
|
+
from .interfaces import Policy
|
|
38
|
+
from .metrics import EpisodeMetrics
|
|
39
|
+
from .observation import OBSERVATION_FIELDS, NodeProfile, NodeState, ObservationBuilder
|
|
40
|
+
from .policies import AlwaysOnPolicy, PeriodicPolicy, RandomPolicy, RuleBasedParams, RuleBasedPolicy, run_episode
|
|
41
|
+
from .domains import DOMAINS, DOMAIN_NAMES, domain_config
|
|
42
|
+
from .scenarios import SCENARIOS, get_scenario, scenario_names
|
|
43
|
+
from .traces import Trace, TraceDrivenEnv
|
|
44
|
+
|
|
45
|
+
register(
|
|
46
|
+
id="EdgeEngineAware-v0",
|
|
47
|
+
entry_point="edgeengine_aware.env:EdgeEngineAwareEnv",
|
|
48
|
+
max_episode_steps=None, # the environment truncates itself at config.time.max_steps
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"__version__",
|
|
53
|
+
"EdgeEngineAwareEnv",
|
|
54
|
+
"EdgeEngineAwareConfig",
|
|
55
|
+
"default_config",
|
|
56
|
+
"TimeConfig",
|
|
57
|
+
"EnergyStorageConfig",
|
|
58
|
+
"MCUConfig",
|
|
59
|
+
"HarvestingConfig",
|
|
60
|
+
"SensingConfig",
|
|
61
|
+
"CommunicationConfig",
|
|
62
|
+
"AgricultureConfig",
|
|
63
|
+
"ApplicationConfig",
|
|
64
|
+
"RewardConfig",
|
|
65
|
+
"ObservationConfig",
|
|
66
|
+
"DomainRandomizationConfig",
|
|
67
|
+
"Policy",
|
|
68
|
+
"RuleBasedPolicy",
|
|
69
|
+
"RuleBasedParams",
|
|
70
|
+
"RandomPolicy",
|
|
71
|
+
"PeriodicPolicy",
|
|
72
|
+
"AlwaysOnPolicy",
|
|
73
|
+
"run_episode",
|
|
74
|
+
"SCENARIOS",
|
|
75
|
+
"get_scenario",
|
|
76
|
+
"scenario_names",
|
|
77
|
+
"DOMAINS",
|
|
78
|
+
"DOMAIN_NAMES",
|
|
79
|
+
"domain_config",
|
|
80
|
+
"Trace",
|
|
81
|
+
"TraceDrivenEnv",
|
|
82
|
+
"EpisodeMetrics",
|
|
83
|
+
"ObservationBuilder",
|
|
84
|
+
"OBSERVATION_FIELDS",
|
|
85
|
+
"NodeProfile",
|
|
86
|
+
"NodeState",
|
|
87
|
+
"Action",
|
|
88
|
+
"encode_action",
|
|
89
|
+
"decode_action",
|
|
90
|
+
"flatten_action",
|
|
91
|
+
"unflatten_action",
|
|
92
|
+
"describe_action",
|
|
93
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Action encoding shared by the simulator and the deployment runtime.
|
|
2
|
+
|
|
3
|
+
The action is a pair ``(sensing_level, transmit)``:
|
|
4
|
+
|
|
5
|
+
============ ===== =========================================================
|
|
6
|
+
component value meaning on the hardware
|
|
7
|
+
============ ===== =========================================================
|
|
8
|
+
sensing_level 0 keep the previous measurement (sensor stays powered off)
|
|
9
|
+
1 low-cost acquisition (single sample, short warm-up)
|
|
10
|
+
2 high-quality acquisition (averaging, longer warm-up)
|
|
11
|
+
transmit 0 radio stays off
|
|
12
|
+
k transmit the latest *stored* measurement with radio mode
|
|
13
|
+
``k - 1`` (k = 1..n_modes; default modes: 1 fast, 2 standard,
|
|
14
|
+
3 robust)
|
|
15
|
+
============ ===== =========================================================
|
|
16
|
+
|
|
17
|
+
Gymnasium represents it as ``MultiDiscrete([3, 1 + n_modes])``; on a
|
|
18
|
+
microcontroller it is two ``uint8`` values or a single flat index
|
|
19
|
+
``sensing_level * (1 + n_modes) + transmit`` (see ``flatten_action``). With a
|
|
20
|
+
single radio mode the encoding reduces to the binary ``MultiDiscrete([3, 2])``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
N_SENSING_LEVELS = 3
|
|
30
|
+
DEFAULT_N_MODES = 3
|
|
31
|
+
N_TRANSMIT_OPTIONS = 1 + DEFAULT_N_MODES
|
|
32
|
+
N_FLAT_ACTIONS = N_SENSING_LEVELS * N_TRANSMIT_OPTIONS
|
|
33
|
+
ACTION_NVEC = (N_SENSING_LEVELS, N_TRANSMIT_OPTIONS)
|
|
34
|
+
|
|
35
|
+
SENSE_NONE, SENSE_LOW, SENSE_HIGH = 0, 1, 2
|
|
36
|
+
TX_NO = 0
|
|
37
|
+
TX_YES = 2
|
|
38
|
+
"""Default transmit value used by the simple policies: the *standard* mode
|
|
39
|
+
(mode index 1) of the default three-mode radio."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def action_nvec(n_modes: int = DEFAULT_N_MODES) -> tuple[int, int]:
|
|
43
|
+
"""``MultiDiscrete`` sizes for a radio with ``n_modes`` transmission modes."""
|
|
44
|
+
return (N_SENSING_LEVELS, 1 + int(n_modes))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def n_flat_actions(n_modes: int = DEFAULT_N_MODES) -> int:
|
|
48
|
+
return N_SENSING_LEVELS * (1 + int(n_modes))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Action:
|
|
53
|
+
"""Decoded, validated action."""
|
|
54
|
+
|
|
55
|
+
sensing_level: int
|
|
56
|
+
transmit: int
|
|
57
|
+
"""0 = no transmission, k >= 1 = transmit with radio mode k - 1."""
|
|
58
|
+
|
|
59
|
+
def __post_init__(self) -> None:
|
|
60
|
+
if not 0 <= self.sensing_level < N_SENSING_LEVELS:
|
|
61
|
+
raise ValueError(f"sensing_level must be in [0, {N_SENSING_LEVELS}), got {self.sensing_level}")
|
|
62
|
+
if self.transmit < 0:
|
|
63
|
+
raise ValueError(f"transmit must be >= 0, got {self.transmit}")
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def transmits(self) -> bool:
|
|
67
|
+
return self.transmit > 0
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def mode(self) -> int:
|
|
71
|
+
"""Radio mode index (valid only when ``transmits``)."""
|
|
72
|
+
return self.transmit - 1
|
|
73
|
+
|
|
74
|
+
def to_array(self) -> np.ndarray:
|
|
75
|
+
return np.array([self.sensing_level, self.transmit], dtype=np.int64)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def decode_action(action, n_modes: int | None = None) -> Action:
|
|
79
|
+
"""Convert a MultiDiscrete array-like (or an ``Action``) into an ``Action``.
|
|
80
|
+
With ``n_modes`` the transmit component is range-checked."""
|
|
81
|
+
a = action if isinstance(action, Action) else None
|
|
82
|
+
if a is None:
|
|
83
|
+
arr = np.asarray(action).reshape(-1)
|
|
84
|
+
if arr.shape[0] != 2:
|
|
85
|
+
raise ValueError(f"expected an action with 2 components, got shape {arr.shape}")
|
|
86
|
+
a = Action(int(arr[0]), int(arr[1]))
|
|
87
|
+
if n_modes is not None and a.transmit > n_modes:
|
|
88
|
+
raise ValueError(f"transmit must be in [0, {n_modes}], got {a.transmit}")
|
|
89
|
+
return a
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def encode_action(sensing_level: int, transmit: int) -> np.ndarray:
|
|
93
|
+
"""Build the MultiDiscrete array from its two components."""
|
|
94
|
+
return Action(sensing_level, transmit).to_array()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def flatten_action(action, n_modes: int = DEFAULT_N_MODES) -> int:
|
|
98
|
+
"""Map ``(sensing_level, transmit)`` to a single index:
|
|
99
|
+
``index = sensing_level * (1 + n_modes) + transmit``."""
|
|
100
|
+
a = decode_action(action, n_modes)
|
|
101
|
+
return a.sensing_level * (1 + n_modes) + a.transmit
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def unflatten_action(index: int, n_modes: int = DEFAULT_N_MODES) -> np.ndarray:
|
|
105
|
+
"""Inverse of :func:`flatten_action`."""
|
|
106
|
+
n_tx = 1 + n_modes
|
|
107
|
+
if not 0 <= index < N_SENSING_LEVELS * n_tx:
|
|
108
|
+
raise ValueError(f"flat action index must be in [0, {N_SENSING_LEVELS * n_tx}), got {index}")
|
|
109
|
+
return encode_action(index // n_tx, index % n_tx)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def describe_action(action, mode_names: tuple[str, ...] | None = None) -> str:
|
|
113
|
+
a = decode_action(action)
|
|
114
|
+
sense = ("no sensing", "low-cost sensing", "high-quality sensing")[a.sensing_level]
|
|
115
|
+
if not a.transmits:
|
|
116
|
+
tx = "no transmission"
|
|
117
|
+
elif mode_names is not None and a.mode < len(mode_names):
|
|
118
|
+
tx = f"transmit ({mode_names[a.mode]})"
|
|
119
|
+
else:
|
|
120
|
+
tx = f"transmit (mode {a.mode})"
|
|
121
|
+
return f"{sense} / {tx}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Energy-feasibility rule (shared by simulator and firmware loop)
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class ExecutionPlan:
|
|
129
|
+
"""Which parts of a requested action the node will actually execute."""
|
|
130
|
+
|
|
131
|
+
sensing_level: int
|
|
132
|
+
"""Executed sensing level (0 if rejected or not requested)."""
|
|
133
|
+
|
|
134
|
+
transmit: bool
|
|
135
|
+
"""Whether a transmission will be executed."""
|
|
136
|
+
|
|
137
|
+
mode: int
|
|
138
|
+
"""Radio mode of the transmission (-1 when none)."""
|
|
139
|
+
|
|
140
|
+
sensing_energy_j: float
|
|
141
|
+
tx_energy_j: float
|
|
142
|
+
rejected: tuple[str, ...]
|
|
143
|
+
"""Reasons of rejected sub-actions, e.g. 'sensing:insufficient_energy'."""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def plan_execution(
|
|
147
|
+
action,
|
|
148
|
+
*,
|
|
149
|
+
stored_energy_j: float,
|
|
150
|
+
baseline_energy_j: float,
|
|
151
|
+
reserve_energy_j: float,
|
|
152
|
+
sensing_energy_j: tuple[float, ...],
|
|
153
|
+
tx_energy_j: tuple[float, ...] | float,
|
|
154
|
+
has_measurement: bool,
|
|
155
|
+
) -> ExecutionPlan:
|
|
156
|
+
"""Apply the 'execute the feasible subset' rule.
|
|
157
|
+
|
|
158
|
+
The node first sets aside the baseline consumption of the coming interval
|
|
159
|
+
and the brown-out reserve; sensing is served first, then transmission.
|
|
160
|
+
A transmission with nothing to send (no stored measurement and no sensing
|
|
161
|
+
in this step) is rejected without spending energy. ``tx_energy_j`` is the
|
|
162
|
+
energy per radio mode (a scalar is treated as a single-mode radio).
|
|
163
|
+
"""
|
|
164
|
+
tx_energies = (float(tx_energy_j),) if np.isscalar(tx_energy_j) else tuple(tx_energy_j)
|
|
165
|
+
a = decode_action(action, n_modes=len(tx_energies))
|
|
166
|
+
available = stored_energy_j - baseline_energy_j - reserve_energy_j
|
|
167
|
+
rejected: list[str] = []
|
|
168
|
+
|
|
169
|
+
level = a.sensing_level
|
|
170
|
+
sense_j = 0.0
|
|
171
|
+
if level != SENSE_NONE:
|
|
172
|
+
cost = sensing_energy_j[level]
|
|
173
|
+
if cost <= available:
|
|
174
|
+
sense_j = cost
|
|
175
|
+
available -= cost
|
|
176
|
+
else:
|
|
177
|
+
rejected.append("sensing:insufficient_energy")
|
|
178
|
+
level = SENSE_NONE
|
|
179
|
+
|
|
180
|
+
transmit = False
|
|
181
|
+
tx_j = 0.0
|
|
182
|
+
mode = -1
|
|
183
|
+
if a.transmits:
|
|
184
|
+
cost = tx_energies[a.mode]
|
|
185
|
+
if not has_measurement and level == SENSE_NONE:
|
|
186
|
+
rejected.append("transmit:no_measurement")
|
|
187
|
+
elif cost <= available:
|
|
188
|
+
transmit = True
|
|
189
|
+
tx_j = cost
|
|
190
|
+
mode = a.mode
|
|
191
|
+
else:
|
|
192
|
+
rejected.append("transmit:insufficient_energy")
|
|
193
|
+
|
|
194
|
+
return ExecutionPlan(level, transmit, mode, sense_j, tx_j, tuple(rejected))
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Hidden ground-truth model of the agricultural field.
|
|
2
|
+
|
|
3
|
+
Everything in this module is *simulator-only*. The node never reads these
|
|
4
|
+
variables directly; it can only sample them through a ``Sensor`` (which adds
|
|
5
|
+
noise) and the reward/renderer use them for evaluation.
|
|
6
|
+
|
|
7
|
+
Soil moisture ``theta`` is dimensionless (1 = field capacity). Its dynamics
|
|
8
|
+
per step of length ``dt`` are
|
|
9
|
+
|
|
10
|
+
theta(t+1) = clip(theta(t) - ET(t) * dt + rain(t) + irrigation(t) + noise, 0, max)
|
|
11
|
+
|
|
12
|
+
ET(t) = et_rate_per_day / 86400 * (1 + et_temp_coeff * (T(t) - T_mean))
|
|
13
|
+
* ((1 - a) + a * (pi/2) * (24 / daylight_hours) * daylight_factor(t))
|
|
14
|
+
with a = et_diurnal_amplitude
|
|
15
|
+
|
|
16
|
+
(the factor (pi/2) * (24 / daylight_hours) makes the 24-hour mean of the
|
|
17
|
+
modulation equal to 1, so ``et_rate_per_day`` is the actual mean daily ET at
|
|
18
|
+
the reference temperature; with 12 h of daylight the factor is pi)
|
|
19
|
+
|
|
20
|
+
Rain events follow a Poisson process; irrigation is applied by an external
|
|
21
|
+
actor (the farmer's system) some random time after the *true* moisture drops
|
|
22
|
+
below ``irrigation_trigger``. Temperature follows a daily cosine with AR(1)
|
|
23
|
+
noise, and humidity is linearly anti-correlated with the temperature anomaly.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import math
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
|
|
33
|
+
from .config import AgricultureConfig
|
|
34
|
+
|
|
35
|
+
DAY_S = 86400.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class FieldState:
|
|
40
|
+
"""Snapshot of the ground truth (privileged information)."""
|
|
41
|
+
|
|
42
|
+
soil_moisture: float
|
|
43
|
+
air_temperature_c: float
|
|
44
|
+
relative_humidity: float
|
|
45
|
+
last_step_change: float
|
|
46
|
+
"""Change of soil moisture during the last step (positive after rain)."""
|
|
47
|
+
event_occurred: bool
|
|
48
|
+
"""True if a rain/irrigation event or threshold crossing happened in the last step."""
|
|
49
|
+
zone: int
|
|
50
|
+
"""0 = normal, 1 = warning (below warning_threshold), 2 = critical."""
|
|
51
|
+
rain_events: int = 0
|
|
52
|
+
irrigation_events: int = 0
|
|
53
|
+
|
|
54
|
+
# -- ProcessState-compatible view (see process.py) -------------------------
|
|
55
|
+
@property
|
|
56
|
+
def value(self) -> float:
|
|
57
|
+
return self.soil_moisture
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def aux(self) -> dict:
|
|
61
|
+
return {
|
|
62
|
+
"air_temperature_c": self.air_temperature_c,
|
|
63
|
+
"relative_humidity": self.relative_humidity,
|
|
64
|
+
"rain_events": self.rain_events,
|
|
65
|
+
"irrigation_events": self.irrigation_events,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class FieldEnvironment:
|
|
70
|
+
"""Stochastic soil / atmosphere simulator."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, cfg: AgricultureConfig, timestep_s: float):
|
|
73
|
+
self.cfg = cfg
|
|
74
|
+
self.dt = timestep_s
|
|
75
|
+
self._rng = np.random.default_rng()
|
|
76
|
+
self.reset(self._rng, start_time_s=0.0)
|
|
77
|
+
|
|
78
|
+
# -- lifecycle ----------------------------------------------------------
|
|
79
|
+
def reset(self, rng: np.random.Generator, start_time_s: float) -> None:
|
|
80
|
+
c = self.cfg
|
|
81
|
+
self._rng = rng
|
|
82
|
+
if c.initial_moisture_range is not None:
|
|
83
|
+
self.moisture = float(rng.uniform(*c.initial_moisture_range))
|
|
84
|
+
else:
|
|
85
|
+
self.moisture = c.initial_moisture
|
|
86
|
+
self._temp_dev = 0.0
|
|
87
|
+
self.temperature = self._temperature_base(start_time_s)
|
|
88
|
+
self.humidity = c.humidity_mean
|
|
89
|
+
self.last_change = 0.0
|
|
90
|
+
self.event_occurred = False
|
|
91
|
+
self.irrigation_pending_s: float | None = None
|
|
92
|
+
self.rain_events = 0
|
|
93
|
+
self.irrigation_events = 0
|
|
94
|
+
self.total_rain = 0.0
|
|
95
|
+
self.total_irrigation = 0.0
|
|
96
|
+
|
|
97
|
+
# -- helpers ------------------------------------------------------------
|
|
98
|
+
def _temperature_base(self, time_s: float) -> float:
|
|
99
|
+
c = self.cfg
|
|
100
|
+
h = (time_s % DAY_S) / 3600.0
|
|
101
|
+
return c.temp_mean_c + c.temp_amplitude_c * math.cos(2 * math.pi * (h - c.temp_peak_hour) / 24.0)
|
|
102
|
+
|
|
103
|
+
def _daylight_factor(self, time_s: float) -> float:
|
|
104
|
+
"""Half-sine daylight shape in [0, 1] (0 at night)."""
|
|
105
|
+
c = self.cfg
|
|
106
|
+
h = (time_s % DAY_S) / 3600.0
|
|
107
|
+
if not c.sunrise_hour < h < c.sunset_hour:
|
|
108
|
+
return 0.0
|
|
109
|
+
return math.sin(math.pi * (h - c.sunrise_hour) / (c.sunset_hour - c.sunrise_hour))
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def value(self) -> float:
|
|
113
|
+
"""Normalised monitored value (the process interface of ``process.py``)."""
|
|
114
|
+
return self.moisture
|
|
115
|
+
|
|
116
|
+
def zone(self, moisture: float | None = None) -> int:
|
|
117
|
+
m = self.moisture if moisture is None else moisture
|
|
118
|
+
if m < self.cfg.critical_threshold:
|
|
119
|
+
return 2
|
|
120
|
+
if m < self.cfg.warning_threshold:
|
|
121
|
+
return 1
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
# -- dynamics -----------------------------------------------------------
|
|
125
|
+
def step(self, time_s: float) -> FieldState:
|
|
126
|
+
"""Advance the field by one timestep starting at ``time_s``."""
|
|
127
|
+
c, rng, dt = self.cfg, self._rng, self.dt
|
|
128
|
+
prev = self.moisture
|
|
129
|
+
prev_zone = self.zone(prev)
|
|
130
|
+
|
|
131
|
+
# atmosphere
|
|
132
|
+
self._temp_dev = c.temp_autocorr * self._temp_dev + math.sqrt(max(0.0, 1 - c.temp_autocorr**2)) * rng.normal(
|
|
133
|
+
0.0, c.temp_noise_std
|
|
134
|
+
)
|
|
135
|
+
self.temperature = self._temperature_base(time_s) + self._temp_dev
|
|
136
|
+
self.humidity = float(
|
|
137
|
+
np.clip(
|
|
138
|
+
c.humidity_mean + c.humidity_temp_coeff * (self.temperature - c.temp_mean_c) + rng.normal(0.0, c.humidity_noise_std),
|
|
139
|
+
0.05,
|
|
140
|
+
1.0,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# evapotranspiration
|
|
145
|
+
et_per_s = c.et_rate_per_day / DAY_S * max(0.0, 1.0 + c.et_temp_coeff * (self.temperature - c.temp_mean_c))
|
|
146
|
+
# 24 h mean of daylight_factor = (daylight_h / 24) * (2 / pi); the prefactor
|
|
147
|
+
# below normalises the modulation to a mean of exactly 1 over the day.
|
|
148
|
+
daylight_h = c.sunset_hour - c.sunrise_hour
|
|
149
|
+
modulation = (math.pi / 2.0) * (24.0 / daylight_h) * self._daylight_factor(time_s)
|
|
150
|
+
et_per_s *= (1.0 - c.et_diurnal_amplitude) + c.et_diurnal_amplitude * modulation
|
|
151
|
+
delta = -et_per_s * dt
|
|
152
|
+
|
|
153
|
+
# rain (Poisson process)
|
|
154
|
+
n_rain = rng.poisson(c.rain_events_per_day * dt / DAY_S)
|
|
155
|
+
if n_rain > 0:
|
|
156
|
+
amount = float(sum(rng.uniform(*c.rain_amount_range) for _ in range(n_rain)))
|
|
157
|
+
delta += amount
|
|
158
|
+
self.rain_events += int(n_rain)
|
|
159
|
+
self.total_rain += amount
|
|
160
|
+
|
|
161
|
+
# external irrigation reacting to the true moisture with a random delay
|
|
162
|
+
if c.irrigation_enabled:
|
|
163
|
+
if self.irrigation_pending_s is None and prev < c.irrigation_trigger:
|
|
164
|
+
self.irrigation_pending_s = float(rng.exponential(c.irrigation_delay_mean_s))
|
|
165
|
+
if self.irrigation_pending_s is not None:
|
|
166
|
+
self.irrigation_pending_s -= dt
|
|
167
|
+
if self.irrigation_pending_s <= 0.0:
|
|
168
|
+
delta += c.irrigation_amount
|
|
169
|
+
self.irrigation_events += 1
|
|
170
|
+
self.total_irrigation += c.irrigation_amount
|
|
171
|
+
self.irrigation_pending_s = None
|
|
172
|
+
|
|
173
|
+
delta += rng.normal(0.0, c.process_noise_std)
|
|
174
|
+
self.moisture = float(np.clip(prev + delta, 0.0, c.max_moisture))
|
|
175
|
+
self.last_change = self.moisture - prev
|
|
176
|
+
|
|
177
|
+
crossed = self.zone(self.moisture) != prev_zone
|
|
178
|
+
self.event_occurred = abs(self.last_change) >= c.event_change_threshold or crossed
|
|
179
|
+
return self.state()
|
|
180
|
+
|
|
181
|
+
def state(self) -> FieldState:
|
|
182
|
+
return FieldState(
|
|
183
|
+
soil_moisture=self.moisture,
|
|
184
|
+
air_temperature_c=self.temperature,
|
|
185
|
+
relative_humidity=self.humidity,
|
|
186
|
+
last_step_change=self.last_change,
|
|
187
|
+
event_occurred=self.event_occurred,
|
|
188
|
+
zone=self.zone(),
|
|
189
|
+
rain_events=self.rain_events,
|
|
190
|
+
irrigation_events=self.irrigation_events,
|
|
191
|
+
)
|