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