Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

EnergyInstruction can expire and can be sent when Scada is just back up #302

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions gw_spaceheat/actors/atn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion gw_spaceheat/actors/scada.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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():
Expand Down
27 changes: 25 additions & 2 deletions gw_spaceheat/actors/synth_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 -------------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions gw_spaceheat/named_types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -45,6 +46,7 @@
"PriceQuantityUnitless",
"RemainingElec",
"RemainingElecEvent",
"RequestEnergyInstruction",
"ScadaInit",
"ScadaParams",
"SendLayout",
Expand Down
12 changes: 12 additions & 0 deletions gw_spaceheat/named_types/request_energy_instruction.py
Original file line number Diff line number Diff line change
@@ -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"
Loading