juham-automation 0.1.2__py3-none-any.whl → 0.1.4__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.
- juham_automation/automation/energycostcalculator.py +106 -63
- juham_automation/automation/heatingoptimizer.py +120 -63
- {juham_automation-0.1.2.dist-info → juham_automation-0.1.4.dist-info}/METADATA +11 -7
- {juham_automation-0.1.2.dist-info → juham_automation-0.1.4.dist-info}/RECORD +8 -8
- {juham_automation-0.1.2.dist-info → juham_automation-0.1.4.dist-info}/WHEEL +0 -0
- {juham_automation-0.1.2.dist-info → juham_automation-0.1.4.dist-info}/entry_points.txt +0 -0
- {juham_automation-0.1.2.dist-info → juham_automation-0.1.4.dist-info}/licenses/LICENSE.rst +0 -0
- {juham_automation-0.1.2.dist-info → juham_automation-0.1.4.dist-info}/top_level.txt +0 -0
|
@@ -6,6 +6,7 @@ from juham_core import Juham
|
|
|
6
6
|
from juham_core.timeutils import (
|
|
7
7
|
elapsed_seconds_in_day,
|
|
8
8
|
elapsed_seconds_in_hour,
|
|
9
|
+
elapsed_seconds_in_interval,
|
|
9
10
|
quantize,
|
|
10
11
|
timestamp,
|
|
11
12
|
)
|
|
@@ -28,17 +29,20 @@ class EnergyCostCalculator(Juham):
|
|
|
28
29
|
_kwh_to_joule_coeff: float = 1000.0 * 3600
|
|
29
30
|
_joule_to_kwh_coeff: float = 1.0 / _kwh_to_joule_coeff
|
|
30
31
|
|
|
31
|
-
energy_balancing_interval:
|
|
32
|
+
energy_balancing_interval: int = 900 # in seconds (15 minutes)
|
|
32
33
|
|
|
33
34
|
def __init__(self, name: str = "ecc") -> None:
|
|
34
35
|
super().__init__(name)
|
|
35
36
|
self.current_ts: float = 0
|
|
37
|
+
self.total_balance_interval : float = 0
|
|
36
38
|
self.total_balance_hour: float = 0
|
|
37
39
|
self.total_balance_day: float = 0
|
|
40
|
+
self.net_energy_balance_cost_interval: float = 0
|
|
38
41
|
self.net_energy_balance_cost_hour: float = 0
|
|
39
42
|
self.net_energy_balance_cost_day: float = 0
|
|
40
|
-
self.
|
|
41
|
-
self.
|
|
43
|
+
self.net_energy_balance_start_interval : float = elapsed_seconds_in_interval(timestamp(), self.energy_balancing_interval)
|
|
44
|
+
self.net_energy_balance_start_hour : float = elapsed_seconds_in_hour(timestamp())
|
|
45
|
+
self.net_energy_balance_start_day : float = elapsed_seconds_in_day(timestamp())
|
|
42
46
|
self.spots: list[dict[str, float]] = []
|
|
43
47
|
self.init_topics()
|
|
44
48
|
|
|
@@ -68,10 +72,10 @@ class EnergyCostCalculator(Juham):
|
|
|
68
72
|
self.error(f"Unknown event {msg.topic}")
|
|
69
73
|
|
|
70
74
|
def on_spot(self, spot: dict[Any, Any]) -> None:
|
|
71
|
-
"""Stores the received per
|
|
75
|
+
"""Stores the received per slot electricity prices to spots list.
|
|
72
76
|
|
|
73
77
|
Args:
|
|
74
|
-
spot (list): list of
|
|
78
|
+
spot (list): list of spot prices
|
|
75
79
|
"""
|
|
76
80
|
|
|
77
81
|
for s in spot:
|
|
@@ -88,76 +92,91 @@ class EnergyCostCalculator(Juham):
|
|
|
88
92
|
"""
|
|
89
93
|
return price * self._joule_to_kwh_coeff
|
|
90
94
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
95
|
+
|
|
96
|
+
def get_price_at(self, ts: float) -> float:
|
|
97
|
+
"""Return the spot price applicable at the given timestamp.
|
|
94
98
|
|
|
95
99
|
Args:
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
ts (float): current time (epoch seconds)
|
|
101
|
+
|
|
98
102
|
Returns:
|
|
99
|
-
|
|
103
|
+
float: PriceWithTax for the slot that contains ts. Returns the last
|
|
104
|
+
known price if ts is equal/after the last spot timestamp.
|
|
105
|
+
Returns 0.0 and logs an error if no matching slot is found.
|
|
100
106
|
"""
|
|
101
|
-
|
|
102
|
-
|
|
107
|
+
if not self.spots:
|
|
108
|
+
self.error(f"PANIC: no spot prices available; lookup ts={ts}")
|
|
109
|
+
return 0.0
|
|
110
|
+
|
|
111
|
+
# ensure spots sorted by timestamp (defensive)
|
|
112
|
+
try:
|
|
113
|
+
# cheap check — assumes list of dicts with "Timestamp"
|
|
114
|
+
if any(self.spots[i]["Timestamp"] > self.spots[i + 1]["Timestamp"] for i in range(len(self.spots) - 1)):
|
|
115
|
+
self.spots.sort(key=lambda r: r["Timestamp"])
|
|
116
|
+
except Exception:
|
|
117
|
+
# if unexpected structure, still try safe path below and log
|
|
118
|
+
self.debug("get_price_at: spot list structure unexpected while checking sort order", "")
|
|
103
119
|
|
|
104
120
|
for i in range(0, len(self.spots) - 1):
|
|
105
121
|
r0 = self.spots[i]
|
|
106
122
|
r1 = self.spots[i + 1]
|
|
107
123
|
ts0 = r0["Timestamp"]
|
|
108
124
|
ts1 = r1["Timestamp"]
|
|
109
|
-
if
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
125
|
+
if ts >= ts0 and ts < ts1:
|
|
126
|
+
return r0["PriceWithTax"]
|
|
127
|
+
|
|
128
|
+
# If timestamp is exactly equal to the last spot timestamp or beyond
|
|
129
|
+
last = self.spots[-1]
|
|
130
|
+
if ts >= last["Timestamp"]:
|
|
131
|
+
return last["PriceWithTax"]
|
|
132
|
+
|
|
133
|
+
# If we get here, ts is before the first spot timestamp
|
|
134
|
+
first = self.spots[0]
|
|
135
|
+
self.error(
|
|
136
|
+
f"PANIC: Timestamp {ts} out of bounds for spot price lookup; "
|
|
137
|
+
f"first=(ts={first['Timestamp']}, price={first.get('PriceWithTax')}), "
|
|
138
|
+
f"last=(ts={last['Timestamp']}, price={last.get('PriceWithTax')}), "
|
|
139
|
+
f"len(spots)={len(self.spots)}"
|
|
140
|
+
)
|
|
141
|
+
return 0.0
|
|
142
|
+
|
|
143
|
+
|
|
117
144
|
|
|
118
145
|
def calculate_net_energy_cost(
|
|
119
146
|
self, ts_prev: float, ts_now: float, energy: float
|
|
120
147
|
) -> float:
|
|
121
|
-
"""
|
|
122
|
-
|
|
148
|
+
"""
|
|
149
|
+
Calculate the cost (or revenue) of energy consumed/produced over the given time interval.
|
|
150
|
+
Positive values indicate revenue, negative values indicate cost.
|
|
123
151
|
|
|
124
152
|
Args:
|
|
125
|
-
ts_prev (
|
|
126
|
-
ts_now (
|
|
127
|
-
energy (float):
|
|
153
|
+
ts_prev (float): Start timestamp of the interval
|
|
154
|
+
ts_now (float): End timestamp of the interval
|
|
155
|
+
energy (float): Energy consumed during the interval (in watts or Joules)
|
|
156
|
+
|
|
128
157
|
Returns:
|
|
129
|
-
|
|
158
|
+
float: Total cost/revenue for the interval
|
|
130
159
|
"""
|
|
131
|
-
cost
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
ts_0 = quantize(self.energy_balancing_interval, now)
|
|
149
|
-
t1 = (ts_0 - prev) / elapsed
|
|
150
|
-
t2 = (now - ts_0) / elapsed
|
|
151
|
-
cost = (
|
|
152
|
-
cost
|
|
153
|
-
+ energy
|
|
154
|
-
* ((1.0 - t1) * start_price + t2 * stop_price)
|
|
155
|
-
* elapsed_seconds
|
|
156
|
-
)
|
|
157
|
-
|
|
158
|
-
prev = prev + elapsed_seconds
|
|
160
|
+
cost = 0.0
|
|
161
|
+
current = ts_prev
|
|
162
|
+
interval = self.energy_balancing_interval
|
|
163
|
+
|
|
164
|
+
while current < ts_now:
|
|
165
|
+
next_ts = min(ts_now, current + interval)
|
|
166
|
+
# Get spot price at start and end of interval
|
|
167
|
+
price_start = self.map_kwh_prices_to_joules(self.get_price_at(current))
|
|
168
|
+
price_end = self.map_kwh_prices_to_joules(self.get_price_at(next_ts))
|
|
169
|
+
|
|
170
|
+
# Trapezoidal integration: average price over interval
|
|
171
|
+
avg_price = (price_start + price_end) / 2.0
|
|
172
|
+
dt = next_ts - current
|
|
173
|
+
cost += energy * avg_price * dt
|
|
174
|
+
|
|
175
|
+
current = next_ts
|
|
176
|
+
|
|
159
177
|
return cost
|
|
160
178
|
|
|
179
|
+
|
|
161
180
|
def on_powerconsumption(self, ts_now: float, m: dict[Any, Any]) -> None:
|
|
162
181
|
"""Calculate net energy cost and update the hourly consumption attribute
|
|
163
182
|
accordingly.
|
|
@@ -170,21 +189,29 @@ class EnergyCostCalculator(Juham):
|
|
|
170
189
|
if not self.spots:
|
|
171
190
|
self.info("Waiting for electricity prices...")
|
|
172
191
|
elif self.current_ts == 0:
|
|
192
|
+
self.net_energy_balance_cost_interval = 0.0
|
|
173
193
|
self.net_energy_balance_cost_hour = 0.0
|
|
174
194
|
self.net_energy_balance_cost_day = 0.0
|
|
175
195
|
self.current_ts = ts_now
|
|
176
|
-
self.
|
|
196
|
+
self.net_energy_balance_start_interval = quantize(
|
|
177
197
|
self.energy_balancing_interval, ts_now
|
|
178
198
|
)
|
|
199
|
+
self.net_energy_balance_start_hour = quantize(
|
|
200
|
+
3600, ts_now
|
|
201
|
+
)
|
|
179
202
|
else:
|
|
180
203
|
# calculate cost of energy consumed/produced
|
|
181
204
|
dp: float = self.calculate_net_energy_cost(self.current_ts, ts_now, power)
|
|
205
|
+
self.net_energy_balance_cost_interval = self.net_energy_balance_cost_interval + dp
|
|
182
206
|
self.net_energy_balance_cost_hour = self.net_energy_balance_cost_hour + dp
|
|
183
207
|
self.net_energy_balance_cost_day = self.net_energy_balance_cost_day + dp
|
|
184
208
|
|
|
185
209
|
# calculate and publish energy balance
|
|
186
210
|
dt = ts_now - self.current_ts # time elapsed since previous call
|
|
187
211
|
balance = dt * power # energy consumed/produced in this slot in Joules
|
|
212
|
+
self.total_balance_interval = (
|
|
213
|
+
self.total_balance_interval + balance * self._joule_to_kwh_coeff
|
|
214
|
+
)
|
|
188
215
|
self.total_balance_hour = (
|
|
189
216
|
self.total_balance_hour + balance * self._joule_to_kwh_coeff
|
|
190
217
|
)
|
|
@@ -195,16 +222,32 @@ class EnergyCostCalculator(Juham):
|
|
|
195
222
|
self.publish_energy_cost(
|
|
196
223
|
ts_now,
|
|
197
224
|
self.name,
|
|
225
|
+
self.net_energy_balance_cost_interval,
|
|
198
226
|
self.net_energy_balance_cost_hour,
|
|
199
227
|
self.net_energy_balance_cost_day,
|
|
200
228
|
)
|
|
201
229
|
|
|
202
230
|
# Check if the current energy balancing interval has ended
|
|
203
231
|
# If so, reset the net_energy_balance attribute for the next interval
|
|
204
|
-
if
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
232
|
+
if ts_now - self.net_energy_balance_start_interval > self.energy_balancing_interval:
|
|
233
|
+
# publish average energy cost per hour
|
|
234
|
+
if abs(self.total_balance_interval) > 0:
|
|
235
|
+
msg = {
|
|
236
|
+
"name": self.name,
|
|
237
|
+
"average_interval": self.net_energy_balance_cost_interval
|
|
238
|
+
/ self.total_balance_interval,
|
|
239
|
+
"ts": ts_now,
|
|
240
|
+
}
|
|
241
|
+
self.publish(self.topic_out_energy_cost, json.dumps(msg), 0, False)
|
|
242
|
+
|
|
243
|
+
# reset for the next hour
|
|
244
|
+
self.total_balance_interval = 0
|
|
245
|
+
self.net_energy_balance_cost_interval = 0.0
|
|
246
|
+
self.net_energy_balance_start_interval = ts_now
|
|
247
|
+
|
|
248
|
+
# Check if the current energy balancing interval has ended
|
|
249
|
+
# If so, reset the net_energy_balance attribute for the next interval
|
|
250
|
+
if ts_now - self.net_energy_balance_start_hour > 3600:
|
|
208
251
|
# publish average energy cost per hour
|
|
209
252
|
if abs(self.total_balance_hour) > 0:
|
|
210
253
|
msg = {
|
|
@@ -253,9 +296,9 @@ class EnergyCostCalculator(Juham):
|
|
|
253
296
|
self.publish(self.topic_out_net_energy_balance, json.dumps(msg), 1, True)
|
|
254
297
|
|
|
255
298
|
def publish_energy_cost(
|
|
256
|
-
self, ts_now: float, site: str, cost_hour: float, cost_day: float
|
|
299
|
+
self, ts_now: float, site: str, cost_interval : float, cost_hour: float, cost_day: float
|
|
257
300
|
) -> None:
|
|
258
|
-
"""Publish daily and
|
|
301
|
+
"""Publish daily, hourly and per interval energy cost/revenue
|
|
259
302
|
|
|
260
303
|
Args:
|
|
261
304
|
ts_now (float): timestamp
|
|
@@ -263,5 +306,5 @@ class EnergyCostCalculator(Juham):
|
|
|
263
306
|
cost_hour (float): cost or revenue per hour.
|
|
264
307
|
cost_day (float) : cost or revenue per day
|
|
265
308
|
"""
|
|
266
|
-
msg = {"name": site, "cost_hour": cost_hour, "cost_day": cost_day, "ts": ts_now}
|
|
309
|
+
msg = {"name": site, "cost_interval": cost_interval, "cost_hour": cost_hour, "cost_day": cost_day, "ts": ts_now}
|
|
267
310
|
self.publish(self.topic_out_energy_cost, json.dumps(msg), 1, True)
|
|
@@ -8,13 +8,12 @@ from juham_core import Juham
|
|
|
8
8
|
from juham_core.timeutils import (
|
|
9
9
|
quantize,
|
|
10
10
|
timestamp,
|
|
11
|
-
timestamp_hour,
|
|
12
11
|
timestampstr,
|
|
13
|
-
is_hour_within_schedule,
|
|
14
|
-
timestamp_hour_local,
|
|
15
12
|
)
|
|
16
13
|
|
|
17
14
|
|
|
15
|
+
|
|
16
|
+
|
|
18
17
|
class HeatingOptimizer(Juham):
|
|
19
18
|
"""Automation class for optimized control of temperature driven home energy consumers e.g hot
|
|
20
19
|
water radiators. Reads spot prices, solar electricity forecast, power meter and
|
|
@@ -39,14 +38,14 @@ class HeatingOptimizer(Juham):
|
|
|
39
38
|
radiator_power: float = 6000 # W
|
|
40
39
|
"""Radiator power in Watts. This is the maximum power that the radiator can consume."""
|
|
41
40
|
|
|
42
|
-
|
|
43
|
-
""" Number of
|
|
41
|
+
heating_slots_per_day: float = 4
|
|
42
|
+
""" Number of slots per day the radiator is allowed to heat."""
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
"""Start
|
|
44
|
+
schedule_start_slot: float = 0
|
|
45
|
+
"""Start slots of the heating schedule."""
|
|
47
46
|
|
|
48
|
-
|
|
49
|
-
"""Stop
|
|
47
|
+
schedule_stop_slot: float = 0
|
|
48
|
+
"""Stop slot of the heating schedule. Heating is allowed only between start-stop slots."""
|
|
50
49
|
|
|
51
50
|
timezone: str = "Europe/Helsinki"
|
|
52
51
|
""" Timezone of the heating system. This is used to convert UTC timestamps to local time."""
|
|
@@ -64,11 +63,11 @@ class HeatingOptimizer(Juham):
|
|
|
64
63
|
1: (60.0, 65.0), # January
|
|
65
64
|
2: (55.0, 65.0), # February
|
|
66
65
|
3: (50.0, 64.0), # March
|
|
67
|
-
4: (
|
|
68
|
-
5: (10.0,
|
|
69
|
-
6: (10.0,
|
|
70
|
-
7: (10.0,
|
|
71
|
-
8: (35.0,
|
|
66
|
+
4: (20.0, 50.0), # April
|
|
67
|
+
5: (10.0, 40.0), # May
|
|
68
|
+
6: (10.0, 38.0), # June
|
|
69
|
+
7: (10.0, 38.0), # July
|
|
70
|
+
8: (35.0, 40.0), # August
|
|
72
71
|
9: (40.0, 50.0), # September
|
|
73
72
|
10: (45.0, 55.0), # October
|
|
74
73
|
11: (50.0, 58.0), # November
|
|
@@ -116,8 +115,8 @@ class HeatingOptimizer(Juham):
|
|
|
116
115
|
"""
|
|
117
116
|
super().__init__(name)
|
|
118
117
|
|
|
119
|
-
self.
|
|
120
|
-
self.
|
|
118
|
+
self.heating_slots_per_day = num_hours * ( 3600 / self.energy_balancing_interval)
|
|
119
|
+
self.start_slot = start_hour * (3600 / self.energy_balancing_interval)
|
|
121
120
|
self.spot_limit = spot_limit
|
|
122
121
|
|
|
123
122
|
self.topic_in_spot = self.make_topic_name("spot")
|
|
@@ -128,13 +127,12 @@ class HeatingOptimizer(Juham):
|
|
|
128
127
|
self.topic_out_energybalance = self.make_topic_name("energybalance/consumers")
|
|
129
128
|
self.topic_out_power = self.make_topic_name("power")
|
|
130
129
|
|
|
131
|
-
self.current_temperature = 100
|
|
132
|
-
self.
|
|
133
|
-
self.
|
|
134
|
-
self.
|
|
135
|
-
self.
|
|
136
|
-
self.
|
|
137
|
-
self.ranked_solarpower: list[dict[Any, Any]] = []
|
|
130
|
+
self.current_temperature : float = 100.0
|
|
131
|
+
self.current_relay_state : int = -1
|
|
132
|
+
self.heating_plan: list[dict[str, int]] = [] # in slots
|
|
133
|
+
self.power_plan: list[dict[str, Any]] = [] # in slots
|
|
134
|
+
self.ranked_spot_prices: list[dict[Any, Any]] = [] # in slots
|
|
135
|
+
self.ranked_solarpower: list[dict[Any, Any]] = [] # in hours
|
|
138
136
|
self.relay: bool = False
|
|
139
137
|
self.relay_started_ts: float = 0
|
|
140
138
|
self.net_energy_balance_mode: bool = False
|
|
@@ -149,6 +147,43 @@ class HeatingOptimizer(Juham):
|
|
|
149
147
|
self.subscribe(self.topic_in_energybalance)
|
|
150
148
|
self.register_as_consumer()
|
|
151
149
|
|
|
150
|
+
def is_slot_within_schedule(self, slot: int, start_slot: int, stop_slot: int) -> bool:
|
|
151
|
+
"""Check if the given slot is within the schedule.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
slot (int): slot to check
|
|
155
|
+
start_slot (int): start slot of the schedule
|
|
156
|
+
stop_slot (int): stop slot of the schedule
|
|
157
|
+
Returns:
|
|
158
|
+
bool: true if the slot is within the schedule
|
|
159
|
+
"""
|
|
160
|
+
if start_slot < stop_slot:
|
|
161
|
+
return slot >= start_slot and slot < stop_slot
|
|
162
|
+
else:
|
|
163
|
+
return slot >= start_slot or slot < stop_slot
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def slots_per_day(self) -> int:
|
|
167
|
+
return int(24 * 3600 / self.energy_balancing_interval)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def timestamp_slot(self, ts: float) -> int:
|
|
171
|
+
"""Get the time slot for the given timestamp and interval.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
ts (float): timestamp
|
|
175
|
+
interval (float): interval in seconds
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
float: time slot
|
|
179
|
+
"""
|
|
180
|
+
dt = datetime.utcfromtimestamp(ts)
|
|
181
|
+
total_seconds = dt.hour * 3600 + dt.minute * 60 + dt.second
|
|
182
|
+
slot : int = total_seconds // self.energy_balancing_interval
|
|
183
|
+
return slot
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
152
187
|
def register_as_consumer(self) -> None:
|
|
153
188
|
"""Register this device as a consumer to the energy balancer. The energy balancer will then add this device
|
|
154
189
|
to its list of consumers and will tell the device when to heat."""
|
|
@@ -177,7 +212,7 @@ class HeatingOptimizer(Juham):
|
|
|
177
212
|
return min_temp, max_temp
|
|
178
213
|
|
|
179
214
|
def sort_by_rank(
|
|
180
|
-
self,
|
|
215
|
+
self, slot: list[dict[str, Any]], ts_utc_now: float
|
|
181
216
|
) -> list[dict[str, Any]]:
|
|
182
217
|
"""Sort the given electricity prices by their rank value. Given a list
|
|
183
218
|
of electricity prices, return a sorted list from the cheapest to the
|
|
@@ -191,11 +226,11 @@ class HeatingOptimizer(Juham):
|
|
|
191
226
|
Returns:
|
|
192
227
|
list: sorted list of electricity prices
|
|
193
228
|
"""
|
|
194
|
-
sh = sorted(
|
|
229
|
+
sh = sorted(slot, key=lambda x: x["Rank"])
|
|
195
230
|
ranked_hours: list[dict[str, Any]] = []
|
|
196
231
|
for h in sh:
|
|
197
232
|
utc_ts = h["Timestamp"]
|
|
198
|
-
if utc_ts
|
|
233
|
+
if utc_ts >= ts_utc_now:
|
|
199
234
|
ranked_hours.append(h)
|
|
200
235
|
|
|
201
236
|
return ranked_hours
|
|
@@ -224,16 +259,16 @@ class HeatingOptimizer(Juham):
|
|
|
224
259
|
self.debug(
|
|
225
260
|
f"{self.name} sorted {len(sh)} days of forecast starting at {timestampstr(ts_utc)}"
|
|
226
261
|
)
|
|
227
|
-
|
|
262
|
+
ranked_slots: list[dict[str, Any]] = []
|
|
228
263
|
|
|
229
264
|
for h in sh:
|
|
230
265
|
utc_ts: float = float(h["ts"])
|
|
231
266
|
if utc_ts >= ts_utc:
|
|
232
|
-
|
|
267
|
+
ranked_slots.append(h)
|
|
233
268
|
self.debug(
|
|
234
|
-
f"{self.name} forecast sorted for the next {str(len(
|
|
269
|
+
f"{self.name} forecast sorted for the next {str(len(ranked_slots))} hours"
|
|
235
270
|
)
|
|
236
|
-
return
|
|
271
|
+
return ranked_slots
|
|
237
272
|
|
|
238
273
|
def on_spot(self, m: list[dict[str, Any]], ts_quantized: float) -> None:
|
|
239
274
|
"""Handle the spot prices.
|
|
@@ -268,7 +303,7 @@ class HeatingOptimizer(Juham):
|
|
|
268
303
|
def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
|
|
269
304
|
m = None
|
|
270
305
|
ts: float = timestamp()
|
|
271
|
-
ts_utc_quantized: float = quantize(
|
|
306
|
+
ts_utc_quantized: float = quantize(self.energy_balancing_interval, ts - self.energy_balancing_interval)
|
|
272
307
|
if msg.topic == self.topic_in_spot:
|
|
273
308
|
self.on_spot(json.loads(msg.payload.decode()), ts_utc_quantized)
|
|
274
309
|
return
|
|
@@ -399,19 +434,19 @@ class HeatingOptimizer(Juham):
|
|
|
399
434
|
)
|
|
400
435
|
return 1
|
|
401
436
|
|
|
402
|
-
|
|
437
|
+
slot : int = self.timestamp_slot(ts)
|
|
403
438
|
state: int = -1
|
|
404
439
|
|
|
405
440
|
# check if we are within the heating plan and see what the plan says
|
|
406
441
|
for pp in self.heating_plan:
|
|
407
442
|
ppts: float = pp["Timestamp"]
|
|
408
|
-
h: float =
|
|
409
|
-
if h ==
|
|
443
|
+
h: float = self.timestamp_slot(ppts)
|
|
444
|
+
if h == slot:
|
|
410
445
|
state = pp["State"]
|
|
411
446
|
break
|
|
412
447
|
|
|
413
448
|
if state == -1:
|
|
414
|
-
self.error(f"{self.name} cannot find heating plan for hour {
|
|
449
|
+
self.error(f"{self.name} cannot find heating plan for hour {slot}")
|
|
415
450
|
return 0
|
|
416
451
|
|
|
417
452
|
min_temp, max_temp = self.get_temperature_limits_for_current_month()
|
|
@@ -438,20 +473,20 @@ class HeatingOptimizer(Juham):
|
|
|
438
473
|
def compute_uoi(
|
|
439
474
|
self,
|
|
440
475
|
price: float,
|
|
441
|
-
|
|
476
|
+
slot: float,
|
|
442
477
|
) -> float:
|
|
443
478
|
"""Compute UOI - utilization optimization index.
|
|
444
479
|
|
|
445
480
|
Args:
|
|
446
481
|
price (float): effective price for this device
|
|
447
|
-
|
|
482
|
+
slot (float) : the slot of the day
|
|
448
483
|
|
|
449
484
|
Returns:
|
|
450
485
|
float: utilization optimization index
|
|
451
486
|
"""
|
|
452
487
|
|
|
453
|
-
if not
|
|
454
|
-
|
|
488
|
+
if not self.is_slot_within_schedule(
|
|
489
|
+
slot, self.schedule_start_slot, self.schedule_stop_slot
|
|
455
490
|
):
|
|
456
491
|
return 0.0
|
|
457
492
|
|
|
@@ -473,7 +508,6 @@ class HeatingOptimizer(Juham):
|
|
|
473
508
|
requested_power (float): requested power
|
|
474
509
|
available_solpower (float): current solar power forecast
|
|
475
510
|
spot (float): spot price
|
|
476
|
-
hour (float) : the hour of the day
|
|
477
511
|
|
|
478
512
|
Returns:
|
|
479
513
|
float: effective price for the requested power
|
|
@@ -490,13 +524,30 @@ class HeatingOptimizer(Juham):
|
|
|
490
524
|
|
|
491
525
|
return effective_spot
|
|
492
526
|
|
|
527
|
+
def align_forecast_to_slots(self, solar_forecast: list[dict]) -> list[dict]:
|
|
528
|
+
"""Resample hourly solar forecast to match slot interval."""
|
|
529
|
+
slots_per_hour = 3600 // self.energy_balancing_interval
|
|
530
|
+
expanded = []
|
|
531
|
+
|
|
532
|
+
for entry in solar_forecast: # each entry has "ts" (start of hour) and "solarenergy" (in kW)
|
|
533
|
+
start_ts = entry["Timestamp"]
|
|
534
|
+
for i in range(slots_per_hour):
|
|
535
|
+
slot_ts = start_ts + i * self.energy_balancing_interval
|
|
536
|
+
expanded.append({
|
|
537
|
+
"Timestamp": slot_ts,
|
|
538
|
+
"Solarenergy": entry["Solarenergy"] / slots_per_hour # split evenly
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
return expanded
|
|
542
|
+
|
|
543
|
+
|
|
493
544
|
def create_power_plan(self) -> list[dict[Any, Any]]:
|
|
494
545
|
"""Create power plan.
|
|
495
546
|
|
|
496
547
|
Returns:
|
|
497
548
|
list: list of utilization entries
|
|
498
549
|
"""
|
|
499
|
-
ts_utc_quantized = quantize(
|
|
550
|
+
ts_utc_quantized = quantize(self.energy_balancing_interval, timestamp() - self.energy_balancing_interval)
|
|
500
551
|
starts: str = timestampstr(ts_utc_quantized)
|
|
501
552
|
self.info(
|
|
502
553
|
f"{self.name} created power plan starting at {starts} with {len(self.ranked_spot_prices)} hours of spot prices",
|
|
@@ -521,10 +572,16 @@ class HeatingOptimizer(Juham):
|
|
|
521
572
|
f"Have spot prices for the next {len(spots)} hours",
|
|
522
573
|
"",
|
|
523
574
|
)
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
575
|
+
|
|
576
|
+
# Expand solar forecast to match spot price resolution
|
|
577
|
+
raw_powers = [
|
|
578
|
+
{"Timestamp": s["ts"], "Solarenergy": s["solarenergy"]}
|
|
579
|
+
for s in self.ranked_solarpower
|
|
580
|
+
if s["ts"] >= ts_utc_quantized
|
|
581
|
+
]
|
|
582
|
+
|
|
583
|
+
powers : list[dict[str, Any]] = self.align_forecast_to_slots(raw_powers)
|
|
584
|
+
|
|
528
585
|
|
|
529
586
|
num_powers: int = len(powers)
|
|
530
587
|
if num_powers == 0:
|
|
@@ -538,8 +595,8 @@ class HeatingOptimizer(Juham):
|
|
|
538
595
|
"",
|
|
539
596
|
)
|
|
540
597
|
hplan: list[dict[str, Any]] = []
|
|
541
|
-
|
|
542
|
-
if len(powers) >= 8: # at least 8
|
|
598
|
+
slot: int = 0
|
|
599
|
+
if len(powers) >= 8: # at least 8 slot of solar energy forecast
|
|
543
600
|
for spot, solar in zip(spots, powers):
|
|
544
601
|
ts = spot["Timestamp"]
|
|
545
602
|
solarenergy = solar["Solarenergy"] * 1000 # argh, this is in kW
|
|
@@ -547,8 +604,8 @@ class HeatingOptimizer(Juham):
|
|
|
547
604
|
effective_price: float = self.compute_effective_price(
|
|
548
605
|
self.radiator_power, solarenergy, spotprice
|
|
549
606
|
)
|
|
550
|
-
|
|
551
|
-
fom = self.compute_uoi(spotprice,
|
|
607
|
+
slot = self.timestamp_slot(ts)
|
|
608
|
+
fom = self.compute_uoi(spotprice, slot)
|
|
552
609
|
plan: dict[str, Any] = {
|
|
553
610
|
"Timestamp": ts,
|
|
554
611
|
"FOM": fom,
|
|
@@ -561,8 +618,8 @@ class HeatingOptimizer(Juham):
|
|
|
561
618
|
solarenergy = 0.0
|
|
562
619
|
spotprice = spot["PriceWithTax"]
|
|
563
620
|
effective_price = spotprice # no free energy available
|
|
564
|
-
|
|
565
|
-
fom = self.compute_uoi(effective_price,
|
|
621
|
+
slot = timestamp_slot(ts)
|
|
622
|
+
fom = self.compute_uoi(effective_price, slot)
|
|
566
623
|
plan = {
|
|
567
624
|
"Timestamp": spot["Timestamp"],
|
|
568
625
|
"FOM": fom,
|
|
@@ -572,15 +629,15 @@ class HeatingOptimizer(Juham):
|
|
|
572
629
|
|
|
573
630
|
shplan = sorted(hplan, key=lambda x: x["FOM"], reverse=True)
|
|
574
631
|
|
|
575
|
-
self.debug(f"{self.name} powerplan starts {starts} up to {len(shplan)}
|
|
632
|
+
self.debug(f"{self.name} powerplan starts {starts} up to {len(shplan)} slots")
|
|
576
633
|
return shplan
|
|
577
634
|
|
|
578
635
|
def enable_relay(
|
|
579
|
-
self,
|
|
636
|
+
self, slot: int, spot: float, fom: float, end_slot: int
|
|
580
637
|
) -> bool:
|
|
581
638
|
return (
|
|
582
|
-
|
|
583
|
-
and
|
|
639
|
+
slot >= self.start_slot
|
|
640
|
+
and slot < end_slot
|
|
584
641
|
and float(spot) < self.spot_limit
|
|
585
642
|
and fom > self.uoi_threshold
|
|
586
643
|
)
|
|
@@ -594,18 +651,18 @@ class HeatingOptimizer(Juham):
|
|
|
594
651
|
|
|
595
652
|
state = 0
|
|
596
653
|
heating_plan: list[dict[str, Any]] = []
|
|
597
|
-
|
|
654
|
+
slot: int = 0
|
|
598
655
|
for hp in self.power_plan:
|
|
599
656
|
ts: float = hp["Timestamp"]
|
|
600
657
|
fom = hp["FOM"]
|
|
601
658
|
spot = hp["Spot"]
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
schedule_on: bool =
|
|
605
|
-
|
|
659
|
+
end_slot: float = self.start_slot + self.heating_slots_per_day
|
|
660
|
+
slot: float = self.timestamp_slot(ts)
|
|
661
|
+
schedule_on: bool = self.is_slot_within_schedule(
|
|
662
|
+
slot, self.schedule_start_slot, self.schedule_stop_slot
|
|
606
663
|
)
|
|
607
664
|
|
|
608
|
-
if self.enable_relay(
|
|
665
|
+
if self.enable_relay(slot, spot, fom, end_slot) and schedule_on:
|
|
609
666
|
state = 1
|
|
610
667
|
else:
|
|
611
668
|
state = 0
|
|
@@ -620,7 +677,7 @@ class HeatingOptimizer(Juham):
|
|
|
620
677
|
|
|
621
678
|
self.publish(self.topic_powerplan, json.dumps(heat), 1, False)
|
|
622
679
|
heating_plan.append(heat)
|
|
623
|
-
|
|
680
|
+
slot = slot + 1
|
|
624
681
|
|
|
625
|
-
self.info(f"{self.name} heating plan of {len(heating_plan)}
|
|
682
|
+
self.info(f"{self.name} heating plan of {len(heating_plan)} slots created", "")
|
|
626
683
|
return heating_plan
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: juham-automation
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Juha's Ultimate Home Automation Masterpiece
|
|
5
5
|
Author-email: J Meskanen <juham.api@gmail.com>
|
|
6
6
|
Maintainer-email: "J. Meskanen" <juham.api@gmail.com>
|
|
@@ -30,11 +30,11 @@ License: LICENSE
|
|
|
30
30
|
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. **
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
Project-URL: Homepage, https://gitlab.com/juham/juham/juham-automation
|
|
34
|
-
Project-URL: Bug Reports, https://gitlab.com/juham/juham/juham-
|
|
33
|
+
Project-URL: Homepage, https://gitlab.com/juham/juham/juham-automation
|
|
34
|
+
Project-URL: Bug Reports, https://gitlab.com/juham/juham/juham-automationt
|
|
35
35
|
Project-URL: Funding, https://meskanen.com
|
|
36
36
|
Project-URL: Say Thanks!, http://meskanen.com
|
|
37
|
-
Project-URL: Source, https://gitlab.com/juham/juham/juham-automation
|
|
37
|
+
Project-URL: Source, https://gitlab.com/juham/juham/juham-automation
|
|
38
38
|
Keywords: home,automation,juham
|
|
39
39
|
Classifier: Development Status :: 3 - Alpha
|
|
40
40
|
Classifier: Intended Audience :: Developers
|
|
@@ -44,7 +44,7 @@ Classifier: Programming Language :: Python :: 3.8
|
|
|
44
44
|
Requires-Python: >=3.8
|
|
45
45
|
Description-Content-Type: text/markdown
|
|
46
46
|
License-File: LICENSE.rst
|
|
47
|
-
Requires-Dist: juham_core>=0.1.
|
|
47
|
+
Requires-Dist: juham_core>=0.1.6
|
|
48
48
|
Provides-Extra: dev
|
|
49
49
|
Requires-Dist: check-manifest; extra == "dev"
|
|
50
50
|
Requires-Dist: coverage>=7.0; extra == "dev"
|
|
@@ -56,9 +56,11 @@ Welcome to Juham™ - Juha's Ultimate Home Automation Masterpiece
|
|
|
56
56
|
Project Description
|
|
57
57
|
-------------------
|
|
58
58
|
|
|
59
|
-
This package extends the ``juham_core`` package, providing home automation building blocks that address most common needs.
|
|
59
|
+
This package extends the ``juham_core`` package, providing home automation building blocks that address most common needs.
|
|
60
|
+
It consists of two main sub-modules:
|
|
60
61
|
|
|
61
62
|
``automation``:
|
|
63
|
+
|
|
62
64
|
- **spothintafi**: Acquires electricity prices in Finland.
|
|
63
65
|
- **watercirculator**: Automates a water circulator pump based on hot water temperature and motion detection.
|
|
64
66
|
- **heatingoptimizer**: Controls hot water radiators based on temperature sensors and electricity price data.
|
|
@@ -66,6 +68,7 @@ This package extends the ``juham_core`` package, providing home automation build
|
|
|
66
68
|
- **energybalancer**: Handles real-time energy balancing and net billing.
|
|
67
69
|
|
|
68
70
|
``ts``:
|
|
71
|
+
|
|
69
72
|
- This folder contains time series recorders that listen for Juham™ topics and store the data in a time series database for later inspection.
|
|
70
73
|
|
|
71
74
|
Project Status
|
|
@@ -73,7 +76,8 @@ Project Status
|
|
|
73
76
|
|
|
74
77
|
**Current State**: **Alpha (Status 3)**
|
|
75
78
|
|
|
76
|
-
All classes have been tested to some extent, and no known bugs have been reported. However, the code still requires
|
|
79
|
+
All classes have been tested to some extent, and no known bugs have been reported. However, the code still requires
|
|
80
|
+
work in terms of design and robustness.
|
|
77
81
|
|
|
78
82
|
|
|
79
83
|
Project Links
|
|
@@ -3,8 +3,8 @@ juham_automation/japp.py,sha256=L2u1mfKvun2fiXhB3AEJD9zMDcdFZ3_doXZYJJzu9tg,1646
|
|
|
3
3
|
juham_automation/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
4
4
|
juham_automation/automation/__init__.py,sha256=uxkIrcRSp1cFikn-oBRtQ8XiT9cSf7xjm3CS1RN7lAQ,522
|
|
5
5
|
juham_automation/automation/energybalancer.py,sha256=Mf9bK-Xo4zNalePL5EIGvlFObMkjWgeh76gvcU-ydIk,11532
|
|
6
|
-
juham_automation/automation/energycostcalculator.py,sha256=
|
|
7
|
-
juham_automation/automation/heatingoptimizer.py,sha256=
|
|
6
|
+
juham_automation/automation/energycostcalculator.py,sha256=dYZxfnPtCw5FqIOAyBU0POf-ulTnZ8iUL45MEh4KLfE,13022
|
|
7
|
+
juham_automation/automation/heatingoptimizer.py,sha256=xFDalR86CGyyJ2z-MCDGUv0tOZW3_KSM-J39VIgJ404,26644
|
|
8
8
|
juham_automation/automation/powermeter_simulator.py,sha256=3WZcjByRTdqnC77l7LjP-TEjmZ8XBEO4hClYsrjxmBE,4549
|
|
9
9
|
juham_automation/automation/spothintafi.py,sha256=cZbi7w2fVweHX_fh1r5MTjGdesX9wDQta2mfVjtiwvw,4331
|
|
10
10
|
juham_automation/automation/watercirculator.py,sha256=a8meMNaONbHcIH3y0vP0UulJc1-gZiLZpw7H8kAOreY,6410
|
|
@@ -17,9 +17,9 @@ juham_automation/ts/log_ts.py,sha256=XsNaazuPmRUZLUqxU0DZae_frtT6kAFcXJTc598CtOA
|
|
|
17
17
|
juham_automation/ts/power_ts.py,sha256=e7bSeZjitY4C_gLup9L0NjvU_WnQsl3ayDhVShj32KY,1399
|
|
18
18
|
juham_automation/ts/powermeter_ts.py,sha256=gXzfK2S4SzrQ9GqM0tsLaV6z_vYmTkBatTcaivASSXs,2188
|
|
19
19
|
juham_automation/ts/powerplan_ts.py,sha256=LZeE7TnzPCDaugggKlaV-K48lDwwnC1ZNum50JYAWaY,1482
|
|
20
|
-
juham_automation-0.1.
|
|
21
|
-
juham_automation-0.1.
|
|
22
|
-
juham_automation-0.1.
|
|
23
|
-
juham_automation-0.1.
|
|
24
|
-
juham_automation-0.1.
|
|
25
|
-
juham_automation-0.1.
|
|
20
|
+
juham_automation-0.1.4.dist-info/licenses/LICENSE.rst,sha256=QVHD5V5_HSys2PdPdig_xKggDj8cGX33ALKqRsYyjtI,1089
|
|
21
|
+
juham_automation-0.1.4.dist-info/METADATA,sha256=ibE3-7LrwwYJygOyuLmrkWKFxfpTdoZPZtd3mlWoykQ,6992
|
|
22
|
+
juham_automation-0.1.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
23
|
+
juham_automation-0.1.4.dist-info/entry_points.txt,sha256=h-KzuKjmGPd4_iX_oiGvxx4IEc97dVbGGlhdh5ctbpI,605
|
|
24
|
+
juham_automation-0.1.4.dist-info/top_level.txt,sha256=jfohvtocvX_gfT21AhJk7Iay5ZiQsS3HzrDjF7S4Qp0,17
|
|
25
|
+
juham_automation-0.1.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|