Skip to content

Commit

Permalink
Adding latest changes from ScadaBlind
Browse files Browse the repository at this point in the history
  • Loading branch information
thdfw committed Jan 8, 2025
1 parent f677947 commit 55ffc99
Show file tree
Hide file tree
Showing 7 changed files with 116 additions and 72 deletions.
25 changes: 20 additions & 5 deletions gw_spaceheat/actors/api_tank_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from pydantic import BaseModel
from result import Ok, Result
from actors.scada_actor import ScadaActor
from named_types import PicoMissing
from named_types import PicoMissing, ChannelFlatlined

R_FIXED_KOHMS = 5.65 # The voltage divider resistors in the TankModule
THERMISTOR_T0 = 298 # i.e. 25 degrees
Expand Down Expand Up @@ -79,6 +79,13 @@ def __init__(
self.last_heard_a = time.time()
self.last_heard_b = time.time()
self.last_error_report = time.time()
try:
self.depth1_channel = self.layout.data_channels[f"{self.name}-depth1"]
self.depth2_channel = self.layout.data_channels[f"{self.name}-depth2"]
self.depth3_channel = self.layout.data_channels[f"{self.name}-depth3"]
self.depth4_channel = self.layout.data_channels[f"{self.name}-depth4"]
except KeyError as e:
raise Exception(f"Problem setting up ApiTankModule channels! {e}")

@cached_property
def microvolts_path(self) -> str:
Expand Down Expand Up @@ -279,7 +286,7 @@ def flatline_seconds(self) -> int:
for cfg in self._component.gt.ConfigList
if cfg.ChannelName == f"{self.name}-depth1"
)
return cfg.CapturePeriodS * 2.1
return cfg.CapturePeriodS

@property
def monitored_names(self) -> Sequence[MonitoredName]:
Expand All @@ -303,7 +310,11 @@ async def main(self):
)
self._send_to(
self.synth_generator,
PicoMissing(ActorName=self.name, PicoHwUid=self.pico_a_uid),
ChannelFlatlined(FromName=self.name, Channel=self.depth1_channel)
)
self._send_to(
self.synth_generator,
ChannelFlatlined(FromName=self.name, Channel=self.depth2_channel)
)
# self._send_to(self.primary_scada, Problems(warnings=[f"{self.pico_a_uid} down"]).problem_event(summary=self.name))
self.last_error_report = time.time()
Expand All @@ -314,11 +325,15 @@ async def main(self):
)
self._send_to(
self.synth_generator,
PicoMissing(ActorName=self.name, PicoHwUid=self.pico_b_uid),
ChannelFlatlined(FromName=self.name, Channel=self.depth3_channel)
)
self._send_to(
self.synth_generator,
ChannelFlatlined(FromName=self.name, Channel=self.depth4_channel)
)
# self._send_to(self.primary_scada, Problems(warnings=[f"{self.pico_b_uid} down"]).problem_event(summary=self.name))
self.last_error_report = time.time()
await asyncio.sleep(self.flatline_seconds())
await asyncio.sleep(10)

def simple_beta_for_pico(self, volts: float, fahrenheit=False) -> float:
"""
Expand Down
82 changes: 45 additions & 37 deletions gw_spaceheat/actors/home_alone.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import asyncio
from typing import List, Sequence
from typing import Sequence
from enum import auto
import uuid
import time
from datetime import datetime, timedelta
import pytz
Expand All @@ -15,13 +14,11 @@
from result import Ok, Result
from transitions import Machine
from data_classes.house_0_names import H0N, H0CN
from gwproto.enums import ChangeAquastatControl, ChangeHeatPumpControl, ChangeRelayState
from gwproto.named_types import Alert

from actors.scada_actor import ScadaActor
from named_types import GoDormant, Ha1Params, NewCommandTree, SingleMachineState, WakeUp, HeatingForecast
from enums import HomeAloneTopState
from pydantic import ValidationError


class HomeAloneState(GwStrEnum):
Expand Down Expand Up @@ -312,10 +309,6 @@ async def main(self):
self.log(f"Top state: {self.top_state}")
self.log(f"State: {self.state}")

if self.state == HomeAloneState.Initializing:
self.initialize_relays()
await asyncio.sleep(3)

# Update top state
if self.top_state == HomeAloneTopState.Normal:
if self.house_is_cold_onpeak() and self.is_buffer_empty(really_empty=True) and self.is_storage_empty():
Expand All @@ -332,10 +325,22 @@ async def main(self):

# Update state
if self.state != HomeAloneState.Dormant:
self.engage_brain()
self.engage_brain(waking_up=(self.state==HomeAloneState.Initializing))
await asyncio.sleep(self.MAIN_LOOP_SLEEP_SECONDS)

def engage_brain(self) -> None:
def engage_brain(self, waking_up: bool = False) -> None:
"""
Manages the logic for the Normal top state, (ie. self.state)
"""
if self.top_state != HomeAloneTopState.Normal:
raise Exception(f"brain is only for Normal top state, not {self.top_state}")

if waking_up:
if self.state == HomeAloneState.Dormant:
self.trigger_normal_event(HomeAloneEvent.WakeUp)
self.initialize_relays()
self.time_since_blind = None

previous_state = self.state

if self.is_onpeak():
Expand All @@ -355,6 +360,8 @@ def engage_brain(self) -> None:
self.log("Scada is missing forecasts and/or critical temperatures since at least 5 min.")
self.log("Moving into ScadaBlind top state")
self.trigger_missing_data()
elif self.time_since_blind is not None and self.top_state==HomeAloneTopState.Normal:
self.log(f"Blind since {int(time.time() - self.time_since_blind)} seconds")
else:
if self.time_since_blind is not None:
self.time_since_blind = None
Expand Down Expand Up @@ -436,7 +443,7 @@ def engage_brain(self) -> None:


def update_relays(self, previous_state) -> None:
if self.state==HomeAloneState.Dormant.value or self.state==HomeAloneState.Initializing.value:
if self.state == HomeAloneState.Dormant or self.state == HomeAloneState.Initializing:
return
if "HpOn" not in previous_state and "HpOn" in self.state:
self.turn_on_HP()
Expand All @@ -450,6 +457,29 @@ def update_relays(self, previous_state) -> None:
self.valved_to_charge_store()
else:
self.valved_to_discharge_store()

def initialize_relays(self):
if self.state == HomeAloneState.Dormant:
self.log("Not initializing relays! Dormant!")
self.log("Initializing relays")
my_relays = {
relay
for relay in self.my_actuators()
if relay.ActorClass == ActorClass.Relay and self.is_boss_of(relay)
}
for relay in my_relays - {
self.store_charge_discharge_relay, # keep as it was
self.hp_failsafe_relay,
self.hp_scada_ops_relay, # keep as it was unless on peak
self.aquastat_control_relay
}:
self.de_energize(relay)
self.hp_failsafe_switch_to_scada()
self.aquastat_ctrl_switch_to_scada()

if self.is_onpeak():
self.log("Is on peak: turning off HP")
self.turn_off_HP()

def trigger_just_offpeak(self):
"""
Expand Down Expand Up @@ -546,9 +576,10 @@ def process_message(self, message: Message) -> Result[bool, BaseException]:
self.TopWakeUp()
# Command tree should already be set. No trouble doing it again
self.set_normal_command_tree()
# WakeUp: Dormant -> Initializing, but rename that ..
self.trigger_normal_event(HomeAloneEvent.WakeUp)
self.initialize_relays()
# engage brain will WakeUp: Dormant -> Initializing
# run the appropriate relay initialization and then
# evaluate if it can move into a known state
self.engage_brain(waking_up = True)
case HeatingForecast():
self.log("Received heating forecast")
self.forecasts: HeatingForecast = message.Payload
Expand Down Expand Up @@ -617,29 +648,6 @@ def get_latest_temperatures(self):
required_storage = self.data.latest_channel_values[H0N.required_energy]
if total_usable_kwh is None or required_storage is None:
self.temperatures_available = False

def initialize_relays(self):
if self.state == HomeAloneState.Dormant:
self.log("Not initializing relays! Dormant!")
self.log("Initializing relays")
my_relays = {
relay
for relay in self.my_actuators()
if relay.ActorClass == ActorClass.Relay and self.is_boss_of(relay)
}
for relay in my_relays - {
self.store_charge_discharge_relay, # keep as it was
self.hp_failsafe_relay,
self.hp_scada_ops_relay, # keep as it was unless on peak
self.aquastat_control_relay
}:
self.de_energize(relay)
self.hp_failsafe_switch_to_scada()
self.aquastat_ctrl_switch_to_scada()

if self.is_onpeak():
self.log("Is on peak: turning off HP")
self.turn_off_HP()

def is_onpeak(self) -> bool:
time_now = datetime.now(self.timezone)
Expand Down
11 changes: 7 additions & 4 deletions gw_spaceheat/actors/scada.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,10 @@

from data_classes.house_0_names import H0N
from enums import MainAutoState, TopState
from named_types import (AdminDispatch, AdminKeepAlive, AdminReleaseControl, DispatchContractGoDormant,
DispatchContractGoLive, EnergyInstruction, FsmEvent, GoDormant, HeatingForecast,
from named_types import (AdminDispatch, AdminKeepAlive, AdminReleaseControl, ChannelFlatlined, DispatchContractGoDormant,
DispatchContractGoLive, EnergyInstruction, FsmEvent, GoDormant,
LayoutLite, NewCommandTree, PicoMissing, RemainingElec, RemainingElecEvent,
ScadaInit, ScadaParams, SendLayout, SingleMachineState, WakeUp)
ScadaInit, ScadaParams, SendLayout, SingleMachineState, WakeUp, HeatingForecast)

ScadaMessageDecoder = create_message_model(
"ScadaMessageDecoder",
Expand Down Expand Up @@ -413,7 +413,7 @@ def send_report(self):
self._data.reports_to_store[report.Id] = report
self.generate_event(ReportEvent(Report=report))
self._publish_to_local(self._node, report)
self._data.flush_latest_readings()
self._data.flush_recent_readings()

def send_snap(self):
snapshot = self._data.make_snapshot()
Expand Down Expand Up @@ -497,6 +497,9 @@ def _derived_process_message(self, message: Message):
self.get_communicator(message.Header.Dst).process_message(message)
except Exception as e:
self.logger.error(f"Problem with {message.Header}: {e}")
case ChannelFlatlined():
self.logger.error(f"Channel {message.Payload.Channel.Name} flatlined - flusing from latest!")
self.data.flush_channel_from_latest(message.Payload.Channel.Name)
case ChannelReadings():
if message.Header.Dst == self.name:
path_dbg |= 0x00000004
Expand Down
11 changes: 9 additions & 2 deletions gw_spaceheat/actors/scada_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,22 @@ def __init__(self, settings: ScadaSettings, hardware_layout: HardwareLayout):
ch.Name: [] for ch in self.my_channels
}
self.recent_fsm_reports = {}
self.flush_latest_readings()
self.flush_recent_readings()

def get_my_data_channels(self) -> List[DataChannel]:
return list(self.layout.data_channels.values())

def get_my_synth_channels(self) -> List[SynthChannel]:
return list(self.layout.synth_channels.values())

def flush_latest_readings(self):
def flush_channel_from_latest(self, channel_name: str) -> None:
"""
A data channel has flatlined; set its dict value to None
"""
self.latest_channel_values[channel_name] = None
self.latest_channel_unix_ms[channel_name] = None

def flush_recent_readings(self):
self.recent_channel_values = {ch.Name: [] for ch in self.my_channels}
self.recent_channel_unix_ms = {ch.Name: [] for ch in self.my_channels}
self.recent_fsm_reports = {}
Expand Down
32 changes: 8 additions & 24 deletions gw_spaceheat/actors/synth_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import numpy as np
from typing import Optional, Sequence
from result import Ok, Result
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from actors.scada_data import ScadaData
from gwproto import Message

Expand All @@ -16,9 +16,8 @@

from actors.scada_actor import ScadaActor
from data_classes.house_0_names import H0CN
from named_types import (EnergyInstruction, Ha1Params, RemainingElec, PicoMissing,
from named_types import (EnergyInstruction, Ha1Params, RemainingElec,
WeatherForecast, HeatingForecast, PriceForecast, ScadaParams)
from gwproto.enums import ActorClass


class SynthGenerator(ScadaActor):
Expand All @@ -39,12 +38,6 @@ def __init__(self, name: str, services: ServicesInterface):
self.previous_time = None
self.temperatures_available = False

self.pico_tanks = [node for node in self.layout.nodes.values() if node.ActorClass==ActorClass.ApiTankModule]
self.ab_by_pico = {}
for node in self.pico_tanks:
self.ab_by_pico[node.component.gt.PicoAHwUid] = 'a'
self.ab_by_pico[node.component.gt.PicoBHwUid] = 'b'

# House parameters in the .env file
self.is_simulated = self.settings.is_simulated
self.timezone = pytz.timezone(self.settings.timezone_str)
Expand Down Expand Up @@ -144,17 +137,6 @@ def process_message(self, message: Message) -> Result[bool, BaseException]:
case PowerWatts():
self.update_remaining_elec()
self.previous_watts = message.Payload.Watts
case PicoMissing():
self.log(f"Pico missing: {message.Payload.ActorName}-{self.ab_by_pico[message.Payload.PicoHwUid]}")
channel_names = []
if self.ab_by_pico[message.Payload.PicoHwUid]=='a':
channel_names = [f'{message.Payload.ActorName}-depth1',f'{message.Payload.ActorName}-depth2']
elif self.ab_by_pico[message.Payload.PicoHwUid]=='b':
channel_names = [f'{message.Payload.ActorName}-depth3',f'{message.Payload.ActorName}-depth4']
for channel_name in channel_names:
if channel_name in self.data.latest_channel_values:
self.log(f"Deleting the latest value for {channel_name} in latest_channel_values")
self.data.latest_channel_values[channel_name] = None
case ScadaParams():
self.log("Received new parameters, time to recompute forecasts!")
self.get_weather()
Expand Down Expand Up @@ -359,7 +341,7 @@ async def get_weather(self, session: aiohttp.ClientSession) -> None:
}
forecasts_48h = dict(list(forecasts_all.items())[:48])
weather = {
'time': list(forecasts_48h.keys()),
'time': [int(x.astimezone(timezone.utc).timestamp()) for x in list(forecasts_all.keys())],
'oat': list(forecasts_48h.values()),
'ws': [0]*len(forecasts_48h)
}
Expand All @@ -368,7 +350,7 @@ async def get_weather(self, session: aiohttp.ClientSession) -> None:
# Save 96h weather forecast to a local file
forecasts_96h = dict(list(forecasts_all.items())[:96])
weather_96h = {
'time': [x.timestamp() for x in list(forecasts_96h.keys())],
'time': [int(x.astimezone(timezone.utc).timestamp()) for x in list(forecasts_96h.keys())],
'oat': list(forecasts_96h.values()),
'ws': [0]*len(forecasts_96h)
}
Expand All @@ -381,27 +363,29 @@ async def get_weather(self, session: aiohttp.ClientSession) -> None:
# Try reading an old forecast from local file
with open(weather_file, 'r') as f:
weather_96h = json.load(f)
weather_96h_time_backup = weather_96h['time'].copy()
weather_96h['time'] = [datetime.fromtimestamp(x, tz=self.timezone) for x in weather_96h['time']]
if weather_96h['time'][-1] >= datetime.fromtimestamp(time.time(), tz=self.timezone)+timedelta(hours=48):
self.log("A valid weather forecast is available locally.")
time_late = weather_96h['time'][0] - datetime.now(self.timezone)
hours_late = int(time_late.total_seconds()/3600)
weather_96h['time'] = weather_96h_time_backup
weather = weather_96h
for key in weather:
weather[key] = weather[key][hours_late:hours_late+48]
else:
self.log("No valid weather forecasts available locally. Using coldest of the current month.")
current_month = datetime.now().month-1
weather = {
'time': [datetime.now(tz=self.timezone)+timedelta(hours=1+x) for x in range(48)],
'time': [int(time.time()+(1+x)*3600) for x in range(48)],
'oat': [self.coldest_oat_by_month[current_month]]*48,
'ws': [0]*48,
}
except Exception as e:
self.log("No valid weather forecasts available locally. Using coldest of the current month.")
current_month = datetime.now().month-1
weather = {
'time': [datetime.now(tz=self.timezone)+timedelta(hours=1+x) for x in range(48)],
'time': [int(time.time()+(1+x)*3600) for x in range(48)],
'oat': [self.coldest_oat_by_month[current_month]]*48,
'ws': [0]*48,
}
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 @@ -4,6 +4,7 @@
from named_types.admin_keep_alive import AdminKeepAlive
from named_types.admin_release_control import AdminReleaseControl
from named_types.atn_bid import AtnBid
from named_types.channel_flatlined import ChannelFlatlined
from named_types.dispatch_contract_go_dormant import DispatchContractGoDormant
from named_types.dispatch_contract_go_live import DispatchContractGoLive
from named_types.energy_instruction import EnergyInstruction
Expand Down Expand Up @@ -33,6 +34,7 @@
"AdminKeepAlive",
"AdminReleaseControl",
"AtnBid",
"ChannelFlatlined",
"DispatchContractGoDormant",
"DispatchContractGoLive",
"EnergyInstruction",
Expand Down
Loading

0 comments on commit 55ffc99

Please sign in to comment.