diff --git a/scada-common/types.lua b/scada-common/types.lua index b7f115b..b1b5e8c 100644 --- a/scada-common/types.lua +++ b/scada-common/types.lua @@ -22,6 +22,15 @@ local types = {} ---@field reactor integer ---@field rsio table|nil +-- ENUMERATION TYPES -- + +---@alias TRI_FAIL integer +types.TRI_FAIL = { + OK = 0, + PARTIAL = 1, + FULL = 2 +} + -- STRING TYPES -- ---@alias rtu_t string diff --git a/supervisor/session/plc.lua b/supervisor/session/plc.lua index f799987..bf79f23 100644 --- a/supervisor/session/plc.lua +++ b/supervisor/session/plc.lua @@ -422,7 +422,7 @@ plc.new_session = function (id, for_reactor, in_queue, out_queue) end end - -- get the reactor structure + -- get the reactor status public.get_status = function () if self.received_status_cache then return self.sDB.mek_status @@ -431,6 +431,23 @@ plc.new_session = function (id, for_reactor, in_queue, out_queue) end end + -- get the reactor RPS status + public.get_rps = function () + return self.sDB.rps_status + end + + -- get the general status information + public.get_general_status = function () + return { + last_status_update = self.sDB.last_status_update, + control_state = self.sDB.control_state, + overridden = self.sDB.overridden, + degraded = self.sDB.degraded, + rps_tripped = self.sDB.rps_tripped, + rps_trip_cause = self.sDB.rps_trip_cause + } + end + -- check if a timer matches this session's watchdog public.check_wd = function (timer) return self.plc_conn_watchdog.is_timer(timer) and self.connected diff --git a/supervisor/session/rtu/unit_session.lua b/supervisor/session/rtu/unit_session.lua index 4f50120..83a0766 100644 --- a/supervisor/session/rtu/unit_session.lua +++ b/supervisor/session/rtu/unit_session.lua @@ -146,6 +146,9 @@ unit_session.new = function (unit_id, advert, out_queue, log_tag, txn_tags) log.debug("template unit_session.update() called", true) end + -- get the unit session database + public.get_db = function () return {} end + return protected end diff --git a/supervisor/unit.lua b/supervisor/unit.lua index 0afd7e5..628de67 100644 --- a/supervisor/unit.lua +++ b/supervisor/unit.lua @@ -3,11 +3,20 @@ local util = require "scada-common.util" local unit = {} ----@alias TRI_FAIL integer -local TRI_FAIL = { - OK = 0, - PARTIAL = 1, - FULL = 2 +local TRI_FAIL = types.TRI_FAIL +local DUMPING_MODE = types.DUMPING_MODE + +local DT_KEYS = { + ReactorTemp = "RTP", + ReactorFuel = "RFL", + ReactorWaste = "RWS", + ReactorCCool = "RCC", + ReactorHCool = "RHC", + BoilerWater = "BWR", + BoilerSteam = "BST", + BoilerCCool = "BCC", + BoilerHCool = "BHC", + TurbineSteam = "TST" } -- create a new reactor unit @@ -21,12 +30,8 @@ unit.new = function (for_reactor, num_boilers, num_turbines) counts = { boilers = num_boilers, turbines = num_turbines }, turbines = {}, boilers = {}, - energy_storage = {}, redstone = {}, - deltas = { - last_reactor_temp = nil, - last_reactor_temp_time = 0 - }, + deltas = {}, db = { ---@class annunciator annunciator = { @@ -38,55 +43,137 @@ unit.new = function (for_reactor, num_boilers, num_turbines) RCSFlowLow = false, ReactorTempHigh = false, ReactorHighDeltaT = false, + FuelInputRateLow = false, + WasteLineOcclusion = false, HighStartupRate = false, -- boiler BoilerOnline = TRI_FAIL.OK, - HeatingRateLow = false, + HeatingRateLow = {}, BoilRateMismatch = false, CoolantFeedMismatch = false, -- turbine TurbineOnline = TRI_FAIL.OK, SteamFeedMismatch = false, - SteamDumpOpen = false, - TurbineOverSpeed = false, - TurbineTrip = false + MaxWaterReturnFeed = false, + SteamDumpOpen = {}, + TurbineOverSpeed = {}, + TurbineTrip = {} } } } + -- init boiler table fields + for _ = 1, self.num_boilers do + table.insert(self.db.annunciator.HeatingRateLow, false) + end + + -- init turbine table fields + for _ = 1, self.num_turbines do + table.insert(self.db.annunciator.SteamDumpOpen, TRI_FAIL.OK) + table.insert(self.db.annunciator.TurbineOverSpeed, false) + table.insert(self.db.annunciator.TurbineTrip, false) + end + ---@class reactor_unit local public = {} -- PRIVATE FUNCTIONS -- + -- compute a change with respect to time of the given value + ---@param key string value key + ---@param value number value + local _compute_dt = function (key, value) + if self.deltas[key] then + local data = self.deltas[key] + + data.dt = (value - data.last_v) / (util.time_s() - data.last_t) + + data.last_v = value + data.last_t = util.time_s() + else + self.deltas[key] = { + last_t = util.time_s(), + last_v = value, + dt = 0.0 + } + end + end + + -- clear a delta + ---@param key string value key + local _reset_dt = function (key) + self.deltas[key] = nil + end + + -- get the delta t of a value + ---@param key string value key + ---@return number + local _get_dt = function (key) + if self.deltas[key] then + return self.deltas[key].dt + else + return 0.0 + end + end + + -- update all delta computations + local _dt__compute_all = function () + if self.plc_s ~= nil then + local plc_db = self.plc_s.get_db() + + -- @todo Meknaism 10.1+ will change fuel/waste to need _amnt + _compute_dt(DT_KEYS.ReactorTemp, plc_db.mek_status.temp) + _compute_dt(DT_KEYS.ReactorFuel, plc_db.mek_status.fuel) + _compute_dt(DT_KEYS.ReactorWaste, plc_db.mek_status.waste) + _compute_dt(DT_KEYS.ReactorCCool, plc_db.mek_status.ccool_amnt) + _compute_dt(DT_KEYS.ReactorHCool, plc_db.mek_status.hcool_amnt) + end + + for i = 1, #self.boilers do + local boiler = self.boilers[i] ---@type unit_session + local db = boiler.get_db() ---@type boiler_session_db + + -- @todo Meknaism 10.1+ will change water/steam to need .amount + _compute_dt(DT_KEYS.BoilerWater .. boiler.get_device_idx(), db.tanks.water) + _compute_dt(DT_KEYS.BoilerSteam .. boiler.get_device_idx(), db.tanks.steam) + _compute_dt(DT_KEYS.BoilerCCool .. boiler.get_device_idx(), db.tanks.ccool.amount) + _compute_dt(DT_KEYS.BoilerHCool .. boiler.get_device_idx(), db.tanks.hcool.amount) + end + + for i = 1, #self.turbines do + local turbine = self.turbines[i] ---@type unit_session + local db = turbine.get_db() ---@type turbine_session_db + + _compute_dt(DT_KEYS.TurbineSteam .. turbine.get_device_idx(), db.tanks.steam) + -- @todo Mekanism 10.1+ needed + -- _compute_dt(DT_KEYS.TurbinePower .. turbine.get_device_idx(), db.?) + end + end + -- update the annunciator local _update_annunciator = function () + -- update deltas + _dt__compute_all() + + ------------- + -- REACTOR -- + ------------- + -- check PLC status self.db.annunciator.PLCOnline = (self.plc_s ~= nil) and (self.plc_s.open) if self.plc_s ~= nil then - ------------- - -- REACTOR -- - ------------- - local plc_db = self.plc_s.get_db() - -- compute deltas - local reactor_delta_t = 0 - if self.deltas.last_reactor_temp ~= nil then - reactor_delta_t = (plc_db.mek_status.temp - self.deltas.last_reactor_temp) / (util.time_s() - self.deltas.last_reactor_temp_time) - else - self.deltas.last_reactor_temp = plc_db.mek_status.temp - self.deltas.last_reactor_temp_time = util.time_s() - end - -- update annunciator self.db.annunciator.ReactorTrip = plc_db.rps_tripped self.db.annunciator.ManualReactorTrip = plc_db.rps_trip_cause == types.rps_status_t.manual self.db.annunciator.RCPTrip = plc_db.rps_tripped and (plc_db.rps_status.ex_hcool or plc_db.rps_status.no_cool) self.db.annunciator.RCSFlowLow = plc_db.mek_status.ccool_fill < 0.75 or plc_db.mek_status.hcool_fill > 0.25 self.db.annunciator.ReactorTempHigh = plc_db.mek_status.temp > 1000 - self.db.annunciator.ReactorHighDeltaT = reactor_delta_t > 100 + self.db.annunciator.ReactorHighDeltaT = _get_dt(DT_KEYS.ReactorTemp) > 100 + self.db.annunciator.FuelInputRateLow = _get_dt(DT_KEYS.ReactorFuel) < 0.0 or plc_db.mek_status.fuel_fill <= 0.01 + self.db.annunciator.WasteLineOcclusion = _get_dt(DT_KEYS.ReactorWaste) > 0.0 or plc_db.mek_status.waste_fill >= 0.99 -- @todo this is dependent on setup, i.e. how much coolant is buffered and the turbine setup self.db.annunciator.HighStartupRate = not plc_db.control_state and plc_db.mek_status.burn_rate > 40 end @@ -105,33 +192,52 @@ unit.new = function (for_reactor, num_boilers, num_turbines) self.db.annunciator.BoilerOnline = TRI_FAIL.OK end + -- compute aggregated statistics local total_boil_rate = 0.0 - local no_boil_count = 0 + local boiler_steam_dt_sum = 0.0 + local boiler_water_dt_sum = 0.0 for i = 1, #self.boilers do local boiler = self.boilers[i].get_db() ---@type boiler_session_db - local boil_rate = boiler.state.boil_rate - if boil_rate == 0 then - no_boil_count = no_boil_count + 1 - else - total_boil_rate = total_boil_rate + boiler.state.boil_rate - end - end - - if no_boil_count == 0 and self.num_boilers > 0 then - self.db.annunciator.HeatingRateLow = TRI_FAIL.FULL - elseif no_boil_count > 0 and no_boil_count ~= self.num_boilers then - self.db.annunciator.HeatingRateLow = TRI_FAIL.PARTIAL - else - self.db.annunciator.HeatingRateLow = TRI_FAIL.OK + total_boil_rate = total_boil_rate + boiler.state.boil_rate + boiler_steam_dt_sum = _get_dt(DT_KEYS.BoilerSteam .. self.boilers[i].get_device_idx()) + boiler_water_dt_sum = _get_dt(DT_KEYS.BoilerWater .. self.boilers[i].get_device_idx()) end + -- check heating rate low if self.plc_s ~= nil then + -- check for inactive boilers while reactor is active + for i = 1, #self.boilers do + local boiler = self.boilers[i] ---@type unit_session + local idx = boiler.get_device_idx() + local db = boiler.get_db() ---@type boiler_session_db + + if self.plc_s.get_db().mek_status.status then + self.db.annunciator.HeatingRateLow[idx] = db.state.boil_rate == 0 + else + self.db.annunciator.HeatingRateLow[idx] = false + end + end + + -- check for rate mismatch local expected_boil_rate = self.plc_s.get_db().mek_status.heating_rate / 10.0 self.db.annunciator.BoilRateMismatch = math.abs(expected_boil_rate - total_boil_rate) > 25.0 - else - self.db.annunciator.BoilRateMismatch = false end + -- check coolant feed mismatch + local cfmismatch = false + for i = 1, #self.boilers do + local boiler = self.boilers[i] ---@type unit_session + local idx = boiler.get_device_idx() + local db = boiler.get_db() ---@type boiler_session_db + + -- gaining heated coolant + cfmismatch = cfmismatch or _get_dt(DT_KEYS.BoilerHCool .. idx) > 0 or db.tanks.hcool_fill == 1 + -- losing cooled coolant + cfmismatch = cfmismatch or _get_dt(DT_KEYS.BoilerCCool .. idx) < 0 or db.tanks.ccool_fill == 0 + end + + self.db.annunciator.CoolantFeedMismatch = cfmismatch + -------------- -- TURBINES -- -------------- @@ -146,19 +252,62 @@ unit.new = function (for_reactor, num_boilers, num_turbines) self.db.annunciator.TurbineOnline = TRI_FAIL.OK end - --[[ - Turbine Under/Over Speed - ]]-- + -- compute aggregated statistics + local total_flow_rate = 0 + local total_input_rate = 0 + local max_water_return_rate = 0 + for i = 1, #self.turbines do + local turbine = self.turbines[i].get_db() ---@type turbine_session_db + total_flow_rate = total_flow_rate + turbine.state.flow_rate + total_input_rate = total_input_rate + turbine.state.steam_input_rate + max_water_return_rate = max_water_return_rate + turbine.build.max_water_output + end + + -- check for steam feed mismatch and max return rate + local sfmismatch = math.abs(total_flow_rate - total_input_rate) > 10 + sfmismatch = sfmismatch or boiler_steam_dt_sum > 0 or boiler_water_dt_sum < 0 + self.db.annunciator.SteamFeedMismatch = sfmismatch + self.db.annunciator.MaxWaterReturnFeed = max_water_return_rate == total_flow_rate + + -- check if steam dumps are open + for i = 1, #self.turbines do + local turbine = self.turbines[i] ---@type unit_session + local db = turbine.get_db() ---@type turbine_session_db + local idx = turbine.get_device_idx() + + if db.state.dumping_mode == DUMPING_MODE.IDLE then + self.db.annunciator.SteamDumpOpen[idx] = TRI_FAIL.OK + elseif db.state.dumping_mode == DUMPING_MODE.DUMPING_EXCESS then + self.db.annunciator.SteamDumpOpen[idx] = TRI_FAIL.PARTIAL + else + self.db.annunciator.SteamDumpOpen[idx] = TRI_FAIL.FULL + end + end + + -- check if turbines are at max speed but not keeping up + for i = 1, #self.turbines do + local turbine = self.turbines[i] ---@type unit_session + local db = turbine.get_db() ---@type turbine_session_db + local idx = turbine.get_device_idx() + + self.db.annunciator.TurbineOverSpeed[idx] = (db.state.flow_rate == db.build.max_flow_rate) and (_get_dt(DT_KEYS.TurbineSteam .. idx) > 0) + end --[[ Turbine Trip - a turbine trip is when the turbine stops, which means we are no longer receiving water and lose the ability to cool + a turbine trip is when the turbine stops, which means we are no longer receiving water and lose the ability to cool. this can be identified by these conditions: - the current flow rate is 0 mB/t and it should not be - - it should not be if the boiler or reactor has a non-zero heating rate - can initially catch this by detecting a 0 flow rate with a non-zero input rate, but eventually the steam will fill up - can later identified by presence of steam in tank with a 0 flow rate ]]-- + for i = 1, #self.turbines do + local turbine = self.turbines[i] ---@type unit_session + local db = turbine.get_db() ---@type turbine_session_db + + local has_steam = db.state.steam_input_rate > 0 or db.tanks.steam_fill > 0.01 + self.db.annunciator.TurbineTrip[turbine.get_device_idx()] = has_steam and db.state.flow_rate == 0 + end end -- unlink disconnected units @@ -173,20 +322,47 @@ unit.new = function (for_reactor, num_boilers, num_turbines) ---@param plc_session plc_session_struct public.link_plc_session = function (plc_session) self.plc_s = plc_session - self.deltas.last_reactor_temp = self.plc_s.get_db().mek_status.temp - self.deltas.last_reactor_temp_time = util.time_s() + + -- reset deltas + _reset_dt(DT_KEYS.ReactorTemp) + _reset_dt(DT_KEYS.ReactorFuel) + _reset_dt(DT_KEYS.ReactorWaste) + _reset_dt(DT_KEYS.ReactorCCool) + _reset_dt(DT_KEYS.ReactorHCool) end -- link a turbine RTU session ---@param turbine unit_session public.add_turbine = function (turbine) - table.insert(self.turbines, turbine) + if #self.turbines < self.num_turbines and turbine.get_device_idx() <= self.num_turbines then + table.insert(self.turbines, turbine) + + -- reset deltas + _reset_dt(DT_KEYS.TurbineSteam .. turbine.get_device_idx()) + _reset_dt(DT_KEYS.TurbinePower .. turbine.get_device_idx()) + + return true + else + return false + end end -- link a boiler RTU session ---@param boiler unit_session public.add_boiler = function (boiler) - table.insert(self.boilers, boiler) + if #self.boilers < self.num_boilers and boiler.get_device_idx() <= self.num_boilers then + table.insert(self.boilers, boiler) + + -- reset deltas + _reset_dt(DT_KEYS.BoilerWater .. boiler.get_device_idx()) + _reset_dt(DT_KEYS.BoilerSteam .. boiler.get_device_idx()) + _reset_dt(DT_KEYS.BoilerCCool .. boiler.get_device_idx()) + _reset_dt(DT_KEYS.BoilerHCool .. boiler.get_device_idx()) + + return true + else + return false + end end -- link a redstone RTU capability @@ -200,7 +376,7 @@ unit.new = function (for_reactor, num_boilers, num_turbines) table.insert(self.redstone[field], accessor) end - -- update (iterate) this session + -- update (iterate) this unit public.update = function () -- unlink PLC if session was closed if not self.plc_s.open then @@ -215,6 +391,66 @@ unit.new = function (for_reactor, num_boilers, num_turbines) _update_annunciator() end + -- get build properties of all machines + public.get_build = function () + local build = {} + + if self.plc_s ~= nil then + build.reactor = self.plc_s.get_struct() + end + + build.boilers = {} + for i = 1, #self.boilers do + table.insert(build.boilers, self.boilers[i].get_db().build) + end + + build.turbines = {} + for i = 1, #self.turbines do + table.insert(build.turbines, self.turbines[i].get_db().build) + end + + return build + end + + -- get reactor status + public.get_reactor_status = function () + local status = {} + + if self.plc_s ~= nil then + local reactor = self.plc_s + status.mek = reactor.get_status() + status.rps = reactor.get_rps() + status.general = reactor.get_general_status() + end + + return status + end + + -- get RTU statuses + public.get_rtu_statuses = function () + local status = {} + + -- status of boilers (including tanks) + status.boilers = {} + for i = 1, #self.boilers do + table.insert(status.boilers, { + state = self.boilers[i].get_db().state, + tanks = self.boilers[i].get_db().tanks, + }) + end + + -- status of turbines (including tanks) + status.turbines = {} + for i = 1, #self.turbines do + table.insert(status.turbines, { + state = self.turbines[i].get_db().state, + tanks = self.turbines[i].get_db().tanks, + }) + end + + return status + end + -- get the annunciator status public.get_annunciator = function () return self.db.annunciator end