From 6d5104f72d0e87fac0544e0e2d336d80bae8b0cb Mon Sep 17 00:00:00 2001
From: thdfw
Date: Mon, 6 Jan 2025 12:08:37 +0100
Subject: [PATCH] EnergyInstruction can expire and can be sent when Scada is
just back up
---
gw_spaceheat/actors/atn.py | 27 ++++++++++++++-----
gw_spaceheat/actors/scada.py | 8 +++++-
gw_spaceheat/actors/synth_generator.py | 27 +++++++++++++++++--
gw_spaceheat/named_types/__init__.py | 2 ++
.../named_types/request_energy_instruction.py | 12 +++++++++
5 files changed, 67 insertions(+), 9 deletions(-)
create mode 100644 gw_spaceheat/named_types/request_energy_instruction.py
diff --git a/gw_spaceheat/actors/atn.py b/gw_spaceheat/actors/atn.py
index 17a3ab2e..abce671e 100644
--- a/gw_spaceheat/actors/atn.py
+++ b/gw_spaceheat/actors/atn.py
@@ -34,7 +34,7 @@
from named_types import (AtnBid, DispatchContractGoDormant,
DispatchContractGoLive, EnergyInstruction, FloParamsHouse0,
Ha1Params, LatestPrice, LayoutLite, PriceQuantityUnitless,
- ScadaParams, SendLayout)
+ ScadaParams, SendLayout, RequestEnergyInstruction)
from paho.mqtt.client import MQTTMessageInfo
from pydantic import BaseModel
@@ -151,6 +151,7 @@ def __init__(
self.longitude = self.settings.longitude
self.sent_bid = False
self.latest_bid = None
+ self.latest_energy_instruction = None
self.weather_forecast = None
self.coldest_oat_by_month = [-3, -7, 1, 21, 30, 31, 46, 47, 28, 24, 16, 0]
self.price_forecast = None
@@ -272,6 +273,17 @@ def _derived_process_mqtt_message(
case Report():
path_dbg |= 0x00000004
self._process_report(decoded.Payload)
+ case RequestEnergyInstruction():
+ self.log("Scada requested an EnergyInstruction")
+ if self.latest_energy_instruction is not None:
+ expriation_time = self.latest_energy_instruction.SlotStartS + self.latest_energy_instruction.SlotDurationMinutes*60
+ if time.time() < expriation_time:
+ self.log("Sending latest EnergyInstruction")
+ self.send_energy_instr(self.latest_energy_instruction.AvgPowerWatts, scada_initializing=True)
+ else:
+ self.log("Latest EnergyInstruction is expired")
+ else:
+ self.log("No EnergyInstruction in memory")
case ScadaParams():
path_dbg |= 0x00000008
self._process_scada_params(decoded.Payload)
@@ -282,7 +294,7 @@ def _derived_process_mqtt_message(
self.log("Scada is on!")
if self.latest_remaining_elec is not None:
self.log("Sending energy instruction with the latest remaining electricity")
- self.send_energy_instr(self.latest_remaining_elec)
+ self.send_energy_instr(self.latest_remaining_elec, scada_initializing=True)
case EventBase():
path_dbg |= 0x00000020
self._process_event(decoded.Payload)
@@ -955,19 +967,22 @@ async def get_thermocline_and_centroids(self) -> Optional[Tuple[float, int]]:
# TODO: post top_centroid, thermocline, bottom_centroid as synthetic channels
return top_centroid_f, thermocline
- def send_energy_instr(self, watthours: int, slot_minutes: int = 60):
+ def send_energy_instr(self, watthours: int, slot_minutes: int = 60, scada_initializing: bool = False):
t = int(time.time())
slot_start_s = int(t - (t % 300))
- # EnergyInstructions must be sent within 10 seconds of the top of 5 minutes
- if t - slot_start_s < 10:
+ if scada_initializing and slot_minutes==60:
+ slot_minutes = 60 - datetime.fromtimestamp(slot_start_s).minute
+ # EnergyInstructions must be sent within 10 seconds of the top of 5 minutes, unless the scada is initializing
+ if t - slot_start_s < 10 or scada_initializing:
payload = EnergyInstruction(
FromGNodeAlias=self.layout.atn_g_node_alias,
SlotStartS=slot_start_s,
SlotDurationMinutes=slot_minutes,
- SendTimeMs=int(time.time() * 1000),
+ SendTimeMs=int((slot_start_s if scada_initializing else time.time()) * 1000),
AvgPowerWatts=int(watthours),
)
self.payload = payload
+ self.latest_energy_instruction = payload
self.log(f"Sent EnergyInstruction: {payload}")
self.send_threadsafe(
Message(
diff --git a/gw_spaceheat/actors/scada.py b/gw_spaceheat/actors/scada.py
index 574f6105..0f64ce13 100644
--- a/gw_spaceheat/actors/scada.py
+++ b/gw_spaceheat/actors/scada.py
@@ -55,7 +55,7 @@
from named_types import (AdminDispatch, AdminKeepAlive, AdminReleaseControl, DispatchContractGoDormant,
DispatchContractGoLive, EnergyInstruction, FsmEvent, GoDormant,
LayoutLite, NewCommandTree, PicoMissing, RemainingElec, RemainingElecEvent,
- ScadaInit, ScadaParams, SendLayout, SingleMachineState, WakeUp)
+ ScadaInit, ScadaParams, SendLayout, SingleMachineState, WakeUp, RequestEnergyInstruction)
ScadaMessageDecoder = create_message_model(
"ScadaMessageDecoder",
@@ -538,6 +538,12 @@ def _derived_process_message(self, message: Message):
case PicoMissing():
path_dbg |= 0x00000800
self.get_communicator(message.Header.Dst).process_message(message)
+ case RequestEnergyInstruction():
+ try:
+ self._links.publish_upstream(message.Payload, QOS.AtMostOnce)
+ self.log("Sent RequestEnergyInstruction to ATN")
+ except Exception as e:
+ self.logger.error(f"Problem with {message.Header}: {e}")
case SingleMachineState():
self.single_machine_state_received(message.Payload)
case SingleReading():
diff --git a/gw_spaceheat/actors/synth_generator.py b/gw_spaceheat/actors/synth_generator.py
index 66dbf2e1..4fd73ca9 100644
--- a/gw_spaceheat/actors/synth_generator.py
+++ b/gw_spaceheat/actors/synth_generator.py
@@ -16,7 +16,7 @@
from actors.scada_actor import ScadaActor
from data_classes.house_0_names import H0CN
-from named_types import EnergyInstruction, Ha1Params, RemainingElec, ScadaInit
+from named_types import EnergyInstruction, Ha1Params, RemainingElec, ScadaInit, GoDormant, WakeUp, RequestEnergyInstruction
from pydantic import Field
# -------------- TODO: move to named_types -------------
@@ -57,7 +57,9 @@ def __init__(self, name: str, services: ServicesInterface):
]
self.elec_assigned_amount = None
self.previous_time = None
+ self.energy_instruction_expiration_utc = None
self.temperatures_available = False
+ self.asked_atn_for_energyinstruction = False
# House parameters in the .env file
self.is_simulated = self.settings.is_simulated
@@ -141,6 +143,22 @@ async def main_loop(self, session: aiohttp.ClientSession) -> None:
if self.temperatures_available:
self.update_energy()
+ if self.energy_instruction_expiration_utc is not None:
+ if time.time() >= self.energy_instruction_expiration_utc + 60:
+ self.log("EnergyInstruction has expired since at least a minute!")
+ if not self.asked_atn_for_energyinstruction:
+ self.log("Asking ATN for an EnergyInstruction")
+ self.asked_atn_for_energyinstruction = True
+ self._send_to(self.primary_scada, RequestEnergyInstruction())
+ else:
+ self.log("ATN has failed to provide an EnergyInstruction. Giving control to HomeAlone")
+ self._send_to(self.atomic_ally, GoDormant(FromName=self.name, ToName=self.atomic_ally))
+ self._send_to(self.home_alone, WakeUp(ToName=self.home_alone))
+ self.energy_instruction_expiration_utc = None
+ self.elec_assigned_amount = None
+ self.previous_time = None
+ self.asked_atn_for_energyinstruction = False
+
self.update_remaining_elec()
await asyncio.sleep(self.MAIN_LOOP_SLEEP_SECONDS)
@@ -203,6 +221,7 @@ def get_latest_temperatures(self):
self.temperatures_available = True
def process_energy_instruction(self, payload: EnergyInstruction) -> None:
+ self.energy_instruction_expiration_utc = payload.SlotStartS + payload.SlotDurationMinutes*60
self.elec_assigned_amount = payload.AvgPowerWatts * payload.SlotDurationMinutes/60
self.elec_used_since_assigned_time = 0
self.log(f"Received an EnergyInstruction for {self.elec_assigned_amount} Watts average power")
@@ -221,7 +240,11 @@ def update_remaining_elec(self) -> None:
self.log(f"This corresponds to an additional {round(elec_watthours,1)} Wh of electricity used")
self.elec_used_since_assigned_time += elec_watthours
self.log(f"Electricity used since EnergyInstruction: {round(self.elec_used_since_assigned_time,1)} Wh")
- remaining_wh = int(self.elec_assigned_amount - self.elec_used_since_assigned_time)
+ if self.energy_instruction_expiration_utc is not None and time.time() > self.energy_instruction_expiration_utc:
+ self.log("Energy instruction has expired!")
+ remaining_wh=0
+ else:
+ remaining_wh = int(self.elec_assigned_amount - self.elec_used_since_assigned_time)
self.log(f"Remaining electricity to be used from EnergyInstruction: {remaining_wh} Wh")
remaining = RemainingElec(
FromGNodeAlias=self.layout.atn_g_node_alias,
diff --git a/gw_spaceheat/named_types/__init__.py b/gw_spaceheat/named_types/__init__.py
index a1e2f416..7835c1ad 100644
--- a/gw_spaceheat/named_types/__init__.py
+++ b/gw_spaceheat/named_types/__init__.py
@@ -18,6 +18,7 @@
from named_types.pico_missing import PicoMissing
from named_types.price_quantity_unitless import PriceQuantityUnitless
from named_types.remaining_elec import RemainingElec
+from named_types.request_energy_instruction import RequestEnergyInstruction
from named_types.events import RemainingElecEvent
from named_types.scada_init import ScadaInit
from named_types.scada_params import ScadaParams
@@ -45,6 +46,7 @@
"PriceQuantityUnitless",
"RemainingElec",
"RemainingElecEvent",
+ "RequestEnergyInstruction",
"ScadaInit",
"ScadaParams",
"SendLayout",
diff --git a/gw_spaceheat/named_types/request_energy_instruction.py b/gw_spaceheat/named_types/request_energy_instruction.py
new file mode 100644
index 00000000..acdd5bc2
--- /dev/null
+++ b/gw_spaceheat/named_types/request_energy_instruction.py
@@ -0,0 +1,12 @@
+"""Type energy.instruction, version 000"""
+
+from typing import Literal
+
+from gwproto.property_format import LeftRightDotStr, UTCMilliseconds, UTCSeconds
+from pydantic import BaseModel, PositiveInt, StrictInt, field_validator, model_validator
+from typing_extensions import Self
+
+
+class RequestEnergyInstruction(BaseModel):
+ TypeName: Literal["request.energy.instruction"] = "request.energy.instruction"
+ Version: Literal["000"] = "000"
\ No newline at end of file