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.
@@ -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: float = 3600
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.net_energy_balance_start_hour = elapsed_seconds_in_hour(timestamp())
41
- self.net_energy_balance_start_day = elapsed_seconds_in_day(timestamp())
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 hour electricity prices to spots list.
75
+ """Stores the received per slot electricity prices to spots list.
72
76
 
73
77
  Args:
74
- spot (list): list of hourly spot prices
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
- def get_prices(self, ts_prev: float, ts_now: float) -> tuple[float, float]:
92
- """Fetch the electricity prices for the given two subsequent time
93
- stamps.
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
- ts_prev (float): previous time
97
- ts_now (float): current time
100
+ ts (float): current time (epoch seconds)
101
+
98
102
  Returns:
99
- Electricity prices for the given interval
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
- prev_price = None
102
- current_price = None
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 ts_prev >= ts0 and ts_prev <= ts1:
110
- prev_price = r0["PriceWithTax"]
111
- if ts_now >= ts0 and ts_now <= ts1:
112
- current_price = r0["PriceWithTax"]
113
- if prev_price is not None and current_price is not None:
114
- return prev_price, current_price
115
- self.error("PANIC: run out of spot prices")
116
- return 0.0, 0.0
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
- """Given time interval as start and stop Calculate the cost over the
122
- given time period. Positive values indicate revenue, negative cost.
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 (timestamp): beginning time stamp of the interval
126
- ts_now (timestamp): end of the interval
127
- energy (float): energy consumed during the time interval
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
- Cost or revenue
158
+ float: Total cost/revenue for the interval
130
159
  """
131
- cost: float = 0
132
- prev = ts_prev
133
- while prev < ts_now:
134
- elapsed_seconds: float = ts_now - prev
135
- if elapsed_seconds > self.energy_balancing_interval:
136
- elapsed_seconds = self.energy_balancing_interval
137
- now = prev + elapsed_seconds
138
- start_per_kwh, stop_per_kwh = self.get_prices(prev, now)
139
- start_price = self.map_kwh_prices_to_joules(start_per_kwh)
140
- stop_price = self.map_kwh_prices_to_joules(stop_per_kwh)
141
- if abs(stop_price - start_price) < 1e-24:
142
- cost = cost + energy * elapsed_seconds * start_price
143
- else:
144
- # interpolate cost over energy balancing interval boundary
145
- elapsed = now - prev
146
- if elapsed < 0.00001:
147
- return 0.0
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.net_energy_balance_start_hour = quantize(
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
- ts_now - self.net_energy_balance_start_hour
206
- > self.energy_balancing_interval
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 hourly energy cost/revenue
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
- heating_hours_per_day: float = 4
43
- """ Number of hours per day the radiator is allowed to heat."""
41
+ heating_slots_per_day: float = 4
42
+ """ Number of slots per day the radiator is allowed to heat."""
44
43
 
45
- schedule_start_hour: float = 0
46
- """Start hour of the heating schedule."""
44
+ schedule_start_slot: float = 0
45
+ """Start slots of the heating schedule."""
47
46
 
48
- schedule_stop_hour: float = 0
49
- """Stop hour of the heating schedule. Heating is allowed only between these two hours."""
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: (40.0, 60.0), # April
68
- 5: (10.0, 50.0), # May
69
- 6: (10.0, 40.0), # June
70
- 7: (10.0, 40.0), # July
71
- 8: (35.0, 45.0), # August
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.heating_hours_per_day = num_hours
120
- self.start_hour = start_hour
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.current_heating_plan = 0
133
- self.current_relay_state = -1
134
- self.heating_plan: list[dict[str, int]] = []
135
- self.power_plan: list[dict[str, Any]] = []
136
- self.ranked_spot_prices: list[dict[Any, Any]] = []
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, hours: list[dict[str, Any]], ts_utc_now: float
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(hours, key=lambda x: x["Rank"])
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 > ts_utc_now:
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
- ranked_hours: list[dict[str, Any]] = []
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
- ranked_hours.append(h)
267
+ ranked_slots.append(h)
233
268
  self.debug(
234
- f"{self.name} forecast sorted for the next {str(len(ranked_hours))} hours"
269
+ f"{self.name} forecast sorted for the next {str(len(ranked_slots))} hours"
235
270
  )
236
- return ranked_hours
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(3600, ts - 3600)
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
- hour = timestamp_hour(ts)
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 = timestamp_hour(ppts)
409
- if h == hour:
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 {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
- hour: float,
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
- hour (float) : the hour of the day
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 is_hour_within_schedule(
454
- hour, self.schedule_start_hour, self.schedule_stop_hour
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(3600, timestamp() - 3600)
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
- powers: list[dict[str, Any]] = []
525
- for s in self.ranked_solarpower:
526
- if s["ts"] >= ts_utc_quantized:
527
- powers.append({"Timestamp": s["ts"], "Solarenergy": s["solarenergy"]})
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
- hour: float = 0
542
- if len(powers) >= 8: # at least 8 hours of solar energy forecast
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
- hour = timestamp_hour_local(ts, self.timezone)
551
- fom = self.compute_uoi(spotprice, hour)
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
- hour = timestamp_hour_local(ts, self.timezone)
565
- fom = self.compute_uoi(effective_price, hour)
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)} hours")
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, hour: float, spot: float, fom: float, end_hour: float
636
+ self, slot: int, spot: float, fom: float, end_slot: int
580
637
  ) -> bool:
581
638
  return (
582
- hour >= self.start_hour
583
- and hour < end_hour
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
- hour: int = 0
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
- end_hour: float = self.start_hour + self.heating_hours_per_day
603
- local_hour: float = timestamp_hour_local(ts, self.timezone)
604
- schedule_on: bool = is_hour_within_schedule(
605
- local_hour, self.schedule_start_hour, self.schedule_stop_hour
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(hour, spot, fom, end_hour) and schedule_on:
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
- hour = hour + 1
680
+ slot = slot + 1
624
681
 
625
- self.info(f"{self.name} heating plan of {len(heating_plan)} hours created", "")
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.2
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.git
34
- Project-URL: Bug Reports, https://gitlab.com/juham/juham/juham-automation.git
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.git
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.5
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. It consists of two main sub-modules:
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 work in terms of design and robustness. For example, electricity prices are currently hard-coded to use euros, but this should be configurable to support multiple currencies.
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=v30wxRpuY2gGBSMJifrFRTjsRU9t-iCiq33Vds7s3O8,10877
7
- juham_automation/automation/heatingoptimizer.py,sha256=cf3bKQfWvZoeo4eSWQKKKXwYZyZziGZPIwuuNrgW81g,24601
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.2.dist-info/licenses/LICENSE.rst,sha256=QVHD5V5_HSys2PdPdig_xKggDj8cGX33ALKqRsYyjtI,1089
21
- juham_automation-0.1.2.dist-info/METADATA,sha256=ar-n8K6GfYNwv0KccMCW8bjP71JxGYCvbnGxm9SxY50,7133
22
- juham_automation-0.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
23
- juham_automation-0.1.2.dist-info/entry_points.txt,sha256=h-KzuKjmGPd4_iX_oiGvxx4IEc97dVbGGlhdh5ctbpI,605
24
- juham_automation-0.1.2.dist-info/top_level.txt,sha256=jfohvtocvX_gfT21AhJk7Iay5ZiQsS3HzrDjF7S4Qp0,17
25
- juham_automation-0.1.2.dist-info/RECORD,,
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,,