From 41838ee3403cdad4b93680df4b1e1ba529a31b70 Mon Sep 17 00:00:00 2001 From: Mikayla Fischler Date: Tue, 3 Jan 2023 16:50:31 -0500 Subject: [PATCH] #102 #20 #19 #21 work in progress on auto control, added control loop, started auto scram checks, implemented limiting and balancing, re-organized for priority groups --- scada-common/util.lua | 46 +++ supervisor/session/coordinator.lua | 2 +- supervisor/session/facility.lua | 369 ++++++++++++++--- supervisor/session/plc.lua | 51 ++- supervisor/session/unit.lua | 639 ++++------------------------- supervisor/session/unitlogic.lua | 547 ++++++++++++++++++++++++ supervisor/startup.lua | 2 +- 7 files changed, 1040 insertions(+), 616 deletions(-) create mode 100644 supervisor/session/unitlogic.lua diff --git a/scada-common/util.lua b/scada-common/util.lua index e02d236..979172e 100644 --- a/scada-common/util.lua +++ b/scada-common/util.lua @@ -208,6 +208,52 @@ function util.round(x) return math.floor(x + 0.5) end +-- get a new moving average object +---@param length integer history length +---@param default number value to fill history with for first call to compute() +function util.mov_avg(length, default) + local data = {} + local index = 1 + local last_t = 0 ---@type number|nil + + ---@class moving_average + local public = {} + + -- reset all to a given value + ---@param x number value + function public.reset(x) + data = {} + for _ = 1, length do table.insert(data, x) end + end + + -- record a new value + ---@param x number new value + ---@param t number? optional last update time to prevent duplicated entries + function public.record(x, t) + if type(t) == "number" and last_t == t then + return + end + + data[index] = x + last_t = t + + index = index + 1 + if index > length then index = 1 end + end + + -- compute the moving average + ---@return number average + function public.compute() + local sum = 0 + for i = 1, length do sum = sum + data[i] end + return sum + end + + public.reset(default) + + return public +end + -- TIME -- -- current time diff --git a/supervisor/session/coordinator.lua b/supervisor/session/coordinator.lua index 9a19ce2..2e5fdb0 100644 --- a/supervisor/session/coordinator.lua +++ b/supervisor/session/coordinator.lua @@ -253,7 +253,7 @@ function coordinator.new_session(id, in_queue, out_queue, facility) end elseif cmd == UNIT_COMMANDS.SET_GROUP then if pkt.length == 3 then - unit.set_group(pkt.data[3]) + facility.set_group(unit.get_id(), pkt.data[3]) _send(SCADA_CRDN_TYPES.UNIT_CMD, { cmd, uid, pkt.data[3] }) else log.debug(log_header .. "CRDN command unit set group missing group id") diff --git a/supervisor/session/facility.lua b/supervisor/session/facility.lua index acb101b..d7ceadc 100644 --- a/supervisor/session/facility.lua +++ b/supervisor/session/facility.lua @@ -8,51 +8,12 @@ local unit = require("supervisor.session.unit") local HEATING_WATER = 20000 local HEATING_SODIUM = 200000 --- 7.14 kJ per blade for 1 mB of fissile fuel +-- 7.14 kJ per blade for 1 mB of fissile fuel
+-- 2856 FE per blade per 1 mB, 285.6 FE per blade per 0.1 mB (minimum) local POWER_PER_BLADE = util.joules_to_fe(7140) -local function m_avg(length, default) - local data = {} - local index = 1 - local last_t = 0 ---@type number|nil - - ---@class moving_average - local public = {} - - -- reset all to a given value - ---@param x number value - function public.reset(x) - data = {} - for _ = 1, length do table.insert(data, x) end - end - - -- record a new value - ---@param x number new value - ---@param t number? optional last update time to prevent duplicated entries - function public.record(x, t) - if type(t) == "number" and last_t == t then - return - end - - data[index] = x - last_t = t - - index = index + 1 - if index > length then index = 1 end - end - - -- compute the moving average - ---@return number average - function public.compute() - local sum = 0 - for i = 1, length do sum = sum + data[i] end - return sum - end - - public.reset(default) - - return public -end +local MAX_CHARGE = 0.99 +local RE_ENABLE_CHARGE = 0.95 ---@alias PROCESS integer local PROCESS = { @@ -63,6 +24,20 @@ local PROCESS = { BURN_RATE = 5 } +local AUTO_SCRAM = { + NONE = 0, + MATRIX_DC = 1, + MATRIX_FILL = 2 +} + +local charge_Kp = 1.0 +local charge_Ki = 0.0 +local charge_Kd = 0.0 + +local rate_Kp = 1.0 +local rate_Ki = 0.00001 +local rate_Kd = 0.0 + ---@class facility_management local facility = {} @@ -73,30 +48,37 @@ facility.PROCESS_MODES = PROCESS ---@param cooling_conf table cooling configurations of reactor units function facility.new(num_reactors, cooling_conf) local self = { - -- components units = {}, induction = {}, redstone = {}, -- process control mode = PROCESS.INACTIVE, - charge_target = 0, -- FE - charge_rate = 0, -- FE/t - charge_limit = 0.99, -- percentage - burn_rate_set = 0, - unit_limits = {}, + last_mode = PROCESS.INACTIVE, + burn_target = 0.0, -- burn rate target for aggregate burn mode + charge_target = 0, -- FE charge target + charge_rate = 0, -- FE/t charge rate target + group_map = { 0, 0, 0, 0 }, -- units -> group IDs + prio_defs = { {}, {}, {}, {} }, -- priority definitions (each level is a table of units) + ascram = false, + ascram_reason = AUTO_SCRAM.NONE, + -- closed loop control + charge_conversion = 1.0, + time_start = 0.0, + initial_ramp = true, + waiting_on_ramp = false, + accumulator = 0.0, + last_error = 0.0, + last_time = 0.0, -- statistics im_stat_init = false, - avg_charge = m_avg(10, 0.0), - avg_inflow = m_avg(10, 0.0), - avg_outflow = m_avg(10, 0.0) + avg_charge = util.mov_avg(10, 0.0), + avg_inflow = util.mov_avg(10, 0.0), + avg_outflow = util.mov_avg(10, 0.0) } -- create units for i = 1, num_reactors do table.insert(self.units, unit.new(i, cooling_conf[i].BOILERS, cooling_conf[i].TURBINES)) - - local u_lim = { burn_rate = -1.0, temp = 1100 } ---@class unit_limit - table.insert(self.unit_limits, u_lim) end -- init redstone RTU I/O controller @@ -108,6 +90,60 @@ function facility.new(num_reactors, cooling_conf) util.filter_table(sessions, function (u) return u.is_connected() end) end + -- check if all auto-controlled units completed ramping + local function _all_units_ramped() + local all_ramped = true + + for i = 1, #self.prio_defs do + local units = self.prio_defs[i] + for u = 1, #units do + all_ramped = all_ramped and units[u].a_ramp_complete() + end + end + + return all_ramped + end + + -- split a burn rate among the reactors + ---@param burn_rate number burn rate assignment + ---@param ramp boolean true to ramp, false to set right away + local function _allocate_burn_rate(burn_rate, ramp) + local unallocated = math.floor(burn_rate * 10) + + -- go through alll priority groups + for i = 1, #self.prio_defs and (unallocated > 0) do + local units = self.prio_defs[i] + local split = math.floor(unallocated / #units) + + local splits = {} + for u = 1, #units do splits[u] = split end + splits[#units] = splits[#units] + (unallocated % #units) + + -- go through all reactor units in this group + for u = 1, #units do + local ctl = units[u].get_control_inf() ---@type unit_control + local last = ctl.br10 + + if splits[u] <= ctl.lim_br10 then + ctl.br10 = splits[u] + else + ctl.br10 = ctl.lim_br10 + + if u < #units then + local remaining = #units - u + split = math.floor(unallocated / remaining) + for x = (u + 1), #units do splits[x] = split end + splits[#units] = splits[#units] + (unallocated % remaining) + end + end + + unallocated = unallocated - ctl.br10 + + if last ~= ctl.br10 then units[u].a_commit_br10(ramp) end + end + end + end + -- PUBLIC FUNCTIONS -- ---@class facility @@ -141,8 +177,209 @@ function facility.new(num_reactors, cooling_conf) -- unlink RTU unit sessions if they are closed _unlink_disconnected_units(self.induction) _unlink_disconnected_units(self.redstone) + + -- calculate moving averages for induction matrix + if self.induction[1] ~= nil then + local matrix = self.induction[1] ---@type unit_session + local db = matrix.get_db() ---@type imatrix_session_db + + if (db.state.last_update > 0) and (db.tanks.last_update > 0) then + if self.im_stat_init then + self.avg_charge.record(db.tanks.energy, db.tanks.last_update) + self.avg_inflow.record(db.state.last_input, db.state.last_update) + self.avg_outflow.record(db.state.last_output, db.state.last_update) + else + self.im_stat_init = true + self.avg_charge.reset(db.tanks.energy) + self.avg_inflow.reset(db.state.last_input) + self.avg_outflow.reset(db.state.last_output) + end + end + else + self.im_stat_init = false + end + + ------------------------- + -- Run Process Control -- + ------------------------- + + local avg_charge = self.avg_charge.compute() + local avg_inflow = self.avg_inflow.compute() + + local now = util.time_s() + + local state_changed = self.mode ~= self.last_mode + + -- once auto control is started, sort the priority sublists by limits + if state_changed then + if self.last_mode == PROCESS.INACTIVE then + local blade_count = 0 + for i = 1, #self.prio_defs do + table.sort(self.prio_defs[i], + ---@param a reactor_unit + ---@param b reactor_unit + function (a, b) return a.get_control_inf().lim_br10 < b.get_control_inf().lim_br10 end + ) + + for _, u in pairs(self.prio_defs[i]) do + blade_count = blade_count + u.get_db().blade_count + u.a_engage() + end + end + + self.charge_conversion = blade_count * POWER_PER_BLADE + elseif self.mode == PROCESS.INACTIVE then + for i = 1, #self.prio_defs do + for _, u in pairs(self.prio_defs[i]) do + u.a_disengage() + end + end + end + + self.initial_ramp = true + self.waiting_on_ramp = false + else + self.initial_ramp = false + end + + if self.mode == PROCESS.SIMPLE then + -- run units at their last configured set point + if state_changed then + self.time_start = now + end + elseif self.mode == PROCESS.CHARGE then + -- target a level of charge + local error = (self.charge_target - avg_charge) / self.charge_conversion + + if state_changed then + -- nothing special to do + elseif self.waiting_on_ramp and _all_units_ramped() then + self.waiting_on_ramp = false + + self.time_start = now + self.accumulator = 0 + end + + if not self.waiting_on_ramp then + self.accumulator = self.accumulator + (avg_charge / self.charge_conversion) + + local runtime = now - self.time_start + local integral = self.accumulator / runtime + local derivative = (error - self.last_error) / (now - self.last_time) + + local P = (charge_Kp * error) + local I = (charge_Ki * integral) + local D = (charge_Kd * derivative) + + local setpoint = P + I + D + local sp_r = util.round(setpoint * 10.0) / 10.0 + + log.debug(util.sprintf("PROC_CHRG[%f] { CHRG[%f] ERR[%f] INT[%f] => SP[%f] SP_R[%f] <= P[%f] I[%f] D[%d] }", + runtime, avg_charge, error, integral, setpoint, sp_r, P, I, D)) + + _allocate_burn_rate(sp_r, self.initial_ramp) + + if self.initial_ramp then + self.waiting_on_ramp = true + end + end + elseif self.mode == PROCESS.GEN_RATE then + -- target a rate of generation + local error = (self.charge_rate - avg_inflow) / self.charge_conversion + local setpoint = 0.0 + + if state_changed then + -- estimate an initial setpoint + setpoint = error / self.charge_conversion + + local sp_r = util.round(setpoint * 10.0) / 10.0 + + _allocate_burn_rate(sp_r, true) + elseif self.waiting_on_ramp and _all_units_ramped() then + self.waiting_on_ramp = false + + self.time_start = now + self.accumulator = 0 + end + + if not self.waiting_on_ramp then + self.accumulator = self.accumulator + (avg_inflow / self.charge_conversion) + + local runtime = util.time_s() - self.time_start + local integral = self.accumulator / runtime + local derivative = (error - self.last_error) / (now - self.last_time) + + local P = (rate_Kp * error) + local I = (rate_Ki * integral) + local D = (rate_Kd * derivative) + + setpoint = P + I + D + + local sp_r = util.round(setpoint * 10.0) / 10.0 + + log.debug(util.sprintf("PROC_RATE[%f] { RATE[%f] ERR[%f] INT[%f] => SP[%f] SP_R[%f] <= P[%f] I[%f] D[%f] }", + runtime, avg_inflow, error, integral, setpoint, sp_r, P, I, D)) + + _allocate_burn_rate(sp_r, false) + end + elseif self.mode == PROCESS.BURN_RATE then + -- a total aggregate burn rate + if state_changed then + -- nothing special to do + elseif self.waiting_on_ramp and _all_units_ramped() then + self.waiting_on_ramp = false + self.time_start = now + end + + if not self.waiting_on_ramp then + _allocate_burn_rate(self.burn_target, self.initial_ramp) + end + end + + ------------------------------ + -- Evaluate Automatic SCRAM -- + ------------------------------ + + if self.mode ~= PROCESS.INACTIVE then + local scram = false + + if self.induction[1] ~= nil then + local matrix = self.induction[1] ---@type unit_session + local db = matrix.get_db() ---@type imatrix_session_db + + if self.ascram_reason == AUTO_SCRAM.MATRIX_DC then + self.ascram_reason = AUTO_SCRAM.NONE + end + + if (db.tanks.energy_fill > MAX_CHARGE) or + (self.ascram_reason == AUTO_SCRAM.MATRIX_FILL and db.tanks.energy_fill > RE_ENABLE_CHARGE) then + scram = true + + if self.ascram_reason == AUTO_SCRAM.NONE then + self.ascram_reason = AUTO_SCRAM.MATRIX_FILL + end + end + else + scram = true + if self.ascram_reason == AUTO_SCRAM.NONE then + self.ascram_reason = AUTO_SCRAM.MATRIX_DC + end + end + + -- SCRAM all units + if not self.ascram and scram then + for i = 1, #self.prio_defs do + for _, u in pairs(self.prio_defs[i]) do + u.a_scram() + end + end + + self.ascram = true + end + end end + -- call the update function of all units in the facility function public.update_units() for i = 1, #self.units do local u = self.units[i] ---@type reactor_unit @@ -150,6 +387,28 @@ function facility.new(num_reactors, cooling_conf) end end + -- SETTINGS -- + + -- set the automatic control group of a unit + ---@param unit_id integer unit ID + ---@param group integer group ID or 0 for independent + function public.set_group(unit_id, group) + if group >= 0 and group <= 4 and self.mode == PROCESS.INACTIVE then + -- remove from old group if previously assigned + local old_group = self.group_map[unit_id] + if old_group ~= 0 then + util.filter_table(self.prio_defs[old_group], function (u) return u.get_id() ~= unit_id end) + end + + self.group_map[unit] = group + + -- add to group if not independent + if group > 0 then + table.insert(self.prio_defs[group], self.units[unit_id]) + end + end + end + -- READ STATES/PROPERTIES -- -- get build properties of all machines diff --git a/supervisor/session/plc.lua b/supervisor/session/plc.lua index 316a584..9027e5f 100644 --- a/supervisor/session/plc.lua +++ b/supervisor/session/plc.lua @@ -31,7 +31,8 @@ local PLC_S_CMDS = { local PLC_S_DATA = { BURN_RATE = 1, - RAMP_BURN_RATE = 2 + RAMP_BURN_RATE = 2, + AUTO_BURN_RATE = 3 } plc.PLC_S_CMDS = PLC_S_CMDS @@ -58,6 +59,7 @@ function plc.new_session(id, for_reactor, in_queue, out_queue) commanded_burn_rate = 0.0, ramping_rate = false, auto_scram = false, + auto_lock = false, -- connection properties seq_num = 0, r_seq_num = nil, @@ -511,6 +513,20 @@ function plc.new_session(id, for_reactor, in_queue, out_queue) } end + -- lock out some manual operator actions during automatic control + ---@param engage boolean true to engage the lockout + function public.auto_lock(engage) + self.auto_lock = engage + end + + -- set the burn rate on behalf of automatic control + ---@param rate number burn rate + ---@param ramp boolean true to ramp, false to not + function public.auto_set_burn(rate, ramp) + self.ramping_rate = ramp + self.in_q.push_data(PLC_S_DATA.AUTO_BURN_RATE, rate) + end + -- check if a timer matches this session's watchdog function public.check_wd(timer) return self.plc_conn_watchdog.is_timer(timer) and self.connected @@ -547,7 +563,9 @@ function plc.new_session(id, for_reactor, in_queue, out_queue) local cmd = message.message if cmd == PLC_S_CMDS.ENABLE then -- enable reactor - _send(RPLC_TYPES.RPS_ENABLE, {}) + if not self.auto_lock then + _send(RPLC_TYPES.RPS_ENABLE, {}) + end elseif cmd == PLC_S_CMDS.SCRAM then -- SCRAM reactor self.auto_scram = false @@ -571,20 +589,33 @@ function plc.new_session(id, for_reactor, in_queue, out_queue) local cmd = message.message ---@type queue_data if cmd.key == PLC_S_DATA.BURN_RATE then -- update burn rate - cmd.val = math.floor(cmd.val * 10) / 10 -- round to 10ths place - if cmd.val > 0 and cmd.val <= self.sDB.mek_struct.max_burn then - self.commanded_burn_rate = cmd.val - self.ramping_rate = false - self.acks.burn_rate = false - self.retry_times.burn_rate_req = util.time() + INITIAL_WAIT - _send(RPLC_TYPES.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate }) + if not self.auto_lock then + cmd.val = math.floor(cmd.val * 10) / 10 -- round to 10ths place + if cmd.val > 0 and cmd.val <= self.sDB.mek_struct.max_burn then + self.commanded_burn_rate = cmd.val + self.ramping_rate = false + self.acks.burn_rate = false + self.retry_times.burn_rate_req = util.time() + INITIAL_WAIT + _send(RPLC_TYPES.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate }) + end end elseif cmd.key == PLC_S_DATA.RAMP_BURN_RATE then -- ramp to burn rate + if not self.auto_lock then + cmd.val = math.floor(cmd.val * 10) / 10 -- round to 10ths place + if cmd.val > 0 and cmd.val <= self.sDB.mek_struct.max_burn then + self.commanded_burn_rate = cmd.val + self.ramping_rate = true + self.acks.burn_rate = false + self.retry_times.burn_rate_req = util.time() + INITIAL_WAIT + _send(RPLC_TYPES.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate }) + end + end + elseif cmd.key == PLC_S_DATA.AUTO_BURN_RATE then + -- set automatic burn rate cmd.val = math.floor(cmd.val * 10) / 10 -- round to 10ths place if cmd.val > 0 and cmd.val <= self.sDB.mek_struct.max_burn then self.commanded_burn_rate = cmd.val - self.ramping_rate = true self.acks.burn_rate = false self.retry_times.burn_rate_req = util.time() + INITIAL_WAIT _send(RPLC_TYPES.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate }) diff --git a/supervisor/session/unit.lua b/supervisor/session/unit.lua index c61a661..076036e 100644 --- a/supervisor/session/unit.lua +++ b/supervisor/session/unit.lua @@ -3,6 +3,8 @@ local rsio = require("scada-common.rsio") local types = require("scada-common.types") local util = require("scada-common.util") +local logic = require("supervisor.session.unitlogic") +local plc = require("supervisor.session.plc") local rsctl = require("supervisor.session.rsctl") ---@class reactor_control_unit @@ -17,6 +19,8 @@ local ALARM_STATE = types.ALARM_STATE local TRI_FAIL = types.TRI_FAIL local DUMPING_MODE = types.DUMPING_MODE +local PLC_S_CMDS = plc.PLC_S_CMDS + local IO = rsio.IO local FLOW_STABILITY_DELAY_MS = 15000 @@ -45,22 +49,6 @@ local AISTATE = { RING_BACK_TRIPPING = 5 } -local aistate_string = { - "INACTIVE", - "TRIPPING", - "TRIPPED", - "ACKED", - "RING_BACK", - "RING_BACK_TRIPPING" -} - --- check if an alarm is active (tripped or ack'd) ----@param alarm table alarm entry ----@return boolean active -local function is_active(alarm) - return alarm.state == AISTATE.TRIPPED or alarm.state == AISTATE.ACKED -end - ---@class alarm_def ---@field state ALARM_INT_STATE internal alarm state ---@field trip_time integer time (ms) when first tripped @@ -73,13 +61,20 @@ end ---@param num_boilers integer number of boilers expected ---@param num_turbines integer number of turbines expected function unit.new(for_reactor, num_boilers, num_turbines) + ---@class _unit_self local self = { r_id = for_reactor, plc_s = nil, ---@class plc_session_struct plc_i = nil, ---@class plc_session + num_boilers = num_boilers, + num_turbines = num_turbines, + types = { DT_KEYS = DT_KEYS, AISTATE = AISTATE }, + defs = { FLOW_STABILITY_DELAY_MS = FLOW_STABILITY_DELAY_MS }, turbines = {}, boilers = {}, redstone = {}, + -- auto control + ramp_target_br10 = 0, -- state tracking deltas = {}, last_heartbeat = 0, @@ -89,9 +84,6 @@ function unit.new(for_reactor, num_boilers, num_turbines) damage_est_last = 0, waste_mode = WASTE_MODE.AUTO, status_text = { "UNKNOWN", "awaiting connection..." }, - -- auto control - group = 0, - limit = 0.0, -- logic for alarms had_reactor = false, start_ms = 0, @@ -138,6 +130,7 @@ function unit.new(for_reactor, num_boilers, num_turbines) -- reactor PLCOnline = false, PLCHeartbeat = false, -- alternate true/false to blink, each time there is a keep_alive + AutoControl = false, ReactorSCRAM = false, ManualReactorSCRAM = false, AutoReactorSCRAM = false, @@ -177,6 +170,13 @@ function unit.new(for_reactor, num_boilers, num_turbines) ALARM_STATE.INACTIVE, ALARM_STATE.INACTIVE, ALARM_STATE.INACTIVE + }, + -- fields for facility control + ---@class unit_control + control = { + blade_count = 0, + br10 = 0, + lim_br10 = 0 } } } @@ -232,116 +232,13 @@ function unit.new(for_reactor, num_boilers, num_turbines) -- get the delta t of a value ---@param key string value key ---@return number - local function _get_dt(key) + function self._get_dt(key) if self.deltas[key] then return self.deltas[key].dt else return 0.0 end end - --#endregion - - --#region redstone I/O - - local __rs_w = rs_rtu_io_ctl.digital_write - local __rs_r = rs_rtu_io_ctl.digital_read - - -- waste valves - local waste_pu = { open = function () __rs_w(IO.WASTE_PU, true) end, close = function () __rs_w(IO.WASTE_PU, false) end } - local waste_sna = { open = function () __rs_w(IO.WASTE_PO, true) end, close = function () __rs_w(IO.WASTE_PO, false) end } - local waste_po = { open = function () __rs_w(IO.WASTE_POPL, true) end, close = function () __rs_w(IO.WASTE_POPL, false) end } - local waste_sps = { open = function () __rs_w(IO.WASTE_AM, true) end, close = function () __rs_w(IO.WASTE_AM, false) end } - - --#endregion - - --#region task helpers - - -- update an alarm state given conditions - ---@param tripped boolean if the alarm condition is still active - ---@param alarm alarm_def alarm table - local function _update_alarm_state(tripped, alarm) - local int_state = alarm.state - local ext_state = self.db.alarm_states[alarm.id] - - -- alarm inactive - if int_state == AISTATE.INACTIVE then - if tripped then - alarm.trip_time = util.time_ms() - if alarm.hold_time > 0 then - alarm.state = AISTATE.TRIPPING - self.db.alarm_states[alarm.id] = ALARM_STATE.INACTIVE - else - alarm.state = AISTATE.TRIPPED - self.db.alarm_states[alarm.id] = ALARM_STATE.TRIPPED - log.info(util.c("UNIT ", self.r_id, " ALARM ", alarm.id, " (", types.alarm_string[alarm.id], "): TRIPPED [PRIORITY ", - types.alarm_prio_string[alarm.tier + 1],"]")) - end - else - alarm.trip_time = util.time_ms() - self.db.alarm_states[alarm.id] = ALARM_STATE.INACTIVE - end - -- alarm condition met, but not yet for required hold time - elseif (int_state == AISTATE.TRIPPING) or (int_state == AISTATE.RING_BACK_TRIPPING) then - if tripped then - local elapsed = util.time_ms() - alarm.trip_time - if elapsed > (alarm.hold_time * 1000) then - alarm.state = AISTATE.TRIPPED - self.db.alarm_states[alarm.id] = ALARM_STATE.TRIPPED - log.info(util.c("UNIT ", self.r_id, " ALARM ", alarm.id, " (", types.alarm_string[alarm.id], "): TRIPPED [PRIORITY ", - types.alarm_prio_string[alarm.tier + 1],"]")) - end - elseif int_state == AISTATE.RING_BACK_TRIPPING then - alarm.trip_time = 0 - alarm.state = AISTATE.RING_BACK - self.db.alarm_states[alarm.id] = ALARM_STATE.RING_BACK - else - alarm.trip_time = 0 - alarm.state = AISTATE.INACTIVE - self.db.alarm_states[alarm.id] = ALARM_STATE.INACTIVE - end - -- alarm tripped and alarming - elseif int_state == AISTATE.TRIPPED then - if tripped then - if ext_state == ALARM_STATE.ACKED then - -- was acked by coordinator - alarm.state = AISTATE.ACKED - end - else - alarm.state = AISTATE.RING_BACK - self.db.alarm_states[alarm.id] = ALARM_STATE.RING_BACK - end - -- alarm acknowledged but still tripped - elseif int_state == AISTATE.ACKED then - if not tripped then - alarm.state = AISTATE.RING_BACK - self.db.alarm_states[alarm.id] = ALARM_STATE.RING_BACK - end - -- alarm no longer tripped, operator must reset to clear - elseif int_state == AISTATE.RING_BACK then - if tripped then - alarm.trip_time = util.time_ms() - if alarm.hold_time > 0 then - alarm.state = AISTATE.RING_BACK_TRIPPING - else - alarm.state = AISTATE.TRIPPED - self.db.alarm_states[alarm.id] = ALARM_STATE.TRIPPED - end - elseif ext_state == ALARM_STATE.INACTIVE then - -- was reset by coordinator - alarm.state = AISTATE.INACTIVE - alarm.trip_time = 0 - end - else - log.error(util.c("invalid alarm state for unit ", self.r_id, " alarm ", alarm.id), true) - end - - -- check for state change - if alarm.state ~= int_state then - local change_str = util.c(aistate_string[int_state + 1], " -> ", aistate_string[alarm.state + 1]) - log.debug(util.c("UNIT ", self.r_id, " ALARM ", alarm.id, " (", types.alarm_string[alarm.id], "): ", change_str)) - end - end - -- update all delta computations local function _dt__compute_all() - if self.plc_s ~= nil then + if self.plc_i ~= nil then local plc_db = self.plc_i.get_db() local last_update_s = plc_db.last_status_update / 1000.0 @@ -379,303 +276,16 @@ function unit.new(for_reactor, num_boilers, num_turbines) --#endregion - --#region alarms and annunciator + --#region redstone I/O - -- update the annunciator - local function _update_annunciator() - -- update deltas - _dt__compute_all() + local __rs_w = rs_rtu_io_ctl.digital_write + local __rs_r = rs_rtu_io_ctl.digital_read - -- variables for boiler, or reactor if no boilers used - local total_boil_rate = 0.0 - - ------------- - -- REACTOR -- - ------------- - - -- check PLC status - self.db.annunciator.PLCOnline = (self.plc_s ~= nil) and (self.plc_s.open) - - if self.plc_i ~= nil then - local plc_db = self.plc_i.get_db() - - -- record reactor start time (some alarms are delayed during reactor heatup) - if self.start_ms == 0 and plc_db.mek_status.status then - self.start_ms = util.time_ms() - elseif not plc_db.mek_status.status then - self.start_ms = 0 - end - - -- record reactor stats - self.plc_cache.active = plc_db.mek_status.status - self.plc_cache.ok = not (plc_db.rps_status.fault or plc_db.rps_status.sys_fail or plc_db.rps_status.force_dis) - self.plc_cache.rps_trip = plc_db.rps_tripped - self.plc_cache.rps_status = plc_db.rps_status - self.plc_cache.damage = plc_db.mek_status.damage - self.plc_cache.temp = plc_db.mek_status.temp - self.plc_cache.waste = plc_db.mek_status.waste_fill - - -- track damage - if plc_db.mek_status.damage > 0 then - if self.damage_start == 0 then - self.damage_start = util.time_s() - self.damage_initial = plc_db.mek_status.damage - end - else - self.damage_start = 0 - self.damage_initial = 0 - self.damage_last = 0 - self.damage_est_last = 0 - end - - -- heartbeat blink about every second - if self.last_heartbeat + 1000 < plc_db.last_status_update then - self.db.annunciator.PLCHeartbeat = not self.db.annunciator.PLCHeartbeat - self.last_heartbeat = plc_db.last_status_update - end - - -- update other annunciator fields - self.db.annunciator.ReactorSCRAM = plc_db.rps_tripped - self.db.annunciator.ManualReactorSCRAM = plc_db.rps_trip_cause == types.rps_status_t.manual - self.db.annunciator.AutoReactorSCRAM = plc_db.rps_trip_cause == types.rps_status_t.automatic - 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 = _get_dt(DT_KEYS.ReactorTemp) > 100 - self.db.annunciator.FuelInputRateLow = _get_dt(DT_KEYS.ReactorFuel) < -1.0 or plc_db.mek_status.fuel_fill <= 0.01 - self.db.annunciator.WasteLineOcclusion = _get_dt(DT_KEYS.ReactorWaste) > 1.0 or plc_db.mek_status.waste_fill >= 0.85 - ---@todo this is dependent on setup, i.e. how much coolant is buffered and the turbine setup - self.db.annunciator.HighStartupRate = not plc_db.mek_status.status and plc_db.mek_status.burn_rate > 40 - - -- if no boilers, use reactor heating rate to check for boil rate mismatch - if num_boilers == 0 then - total_boil_rate = plc_db.mek_status.heating_rate - end - else - self.plc_cache.ok = false - end - - ------------- - -- BOILERS -- - ------------- - - -- clear boiler online flags - for i = 1, num_boilers do self.db.annunciator.BoilerOnline[i] = false end - - -- aggregated statistics - local boiler_steam_dt_sum = 0.0 - local boiler_water_dt_sum = 0.0 - - if num_boilers > 0 then - -- go through boilers for stats and online - for i = 1, #self.boilers do - local session = self.boilers[i] ---@type unit_session - local boiler = session.get_db() ---@type boilerv_session_db - - 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()) - - self.db.annunciator.BoilerOnline[session.get_device_idx()] = true - end - - -- check heating rate low - if self.plc_s ~= nil and #self.boilers > 0 then - local r_db = self.plc_i.get_db() - - -- 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 boilerv_session_db - - if r_db.mek_status.status then - self.db.annunciator.HeatingRateLow[idx] = db.state.boil_rate == 0 - else - self.db.annunciator.HeatingRateLow[idx] = false - end - end - end - else - boiler_steam_dt_sum = _get_dt(DT_KEYS.ReactorHCool) - boiler_water_dt_sum = _get_dt(DT_KEYS.ReactorCCool) - end - - --------------------------- - -- COOLANT FEED MISMATCH -- - --------------------------- - - -- check coolant feed mismatch if using boilers, otherwise calculate with reactor - local cfmismatch = false - - if num_boilers > 0 then - 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 boilerv_session_db - - local gaining_hc = _get_dt(DT_KEYS.BoilerHCool .. idx) > 10.0 or db.tanks.hcool_fill == 1 - - -- gaining heated coolant - cfmismatch = cfmismatch or gaining_hc - -- losing cooled coolant - cfmismatch = cfmismatch or _get_dt(DT_KEYS.BoilerCCool .. idx) < -10.0 or (gaining_hc and db.tanks.ccool_fill == 0) - end - elseif self.plc_s ~= nil then - local r_db = self.plc_i.get_db() - - local gaining_hc = _get_dt(DT_KEYS.ReactorHCool) > 10.0 or r_db.mek_status.hcool_fill == 1 - - -- gaining heated coolant (steam) - cfmismatch = cfmismatch or gaining_hc - -- losing cooled coolant (water) - cfmismatch = cfmismatch or _get_dt(DT_KEYS.ReactorCCool) < -10.0 or (gaining_hc and r_db.mek_status.ccool_fill == 0) - end - - self.db.annunciator.CoolantFeedMismatch = cfmismatch - - -------------- - -- TURBINES -- - -------------- - - -- clear turbine online flags - for i = 1, num_turbines do self.db.annunciator.TurbineOnline[i] = false end - - -- aggregated statistics - local total_flow_rate = 0 - local total_input_rate = 0 - local max_water_return_rate = 0 - - -- go through turbines for stats and online - for i = 1, #self.turbines do - local session = self.turbines[i] ---@type unit_session - local turbine = session.get_db() ---@type turbinev_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 - - self.db.annunciator.TurbineOnline[session.get_device_idx()] = true - end - - -- check for boil rate mismatch (either between reactor and turbine or boiler and turbine) - self.db.annunciator.BoilRateMismatch = math.abs(total_boil_rate - total_input_rate) > 4 - - -- 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 > 2.0 or boiler_water_dt_sum < -2.0 - self.db.annunciator.SteamFeedMismatch = sfmismatch - self.db.annunciator.MaxWaterReturnFeed = max_water_return_rate == total_flow_rate and total_flow_rate ~= 0 - - -- 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 turbinev_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 turbinev_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.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. - this can be identified by these conditions: - - the current flow rate is 0 mB/t and it should not be - - 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 turbinev_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 - - -- evaluate alarm conditions - local function _update_alarms() - local annunc = self.db.annunciator - local plc_cache = self.plc_cache - - -- Containment Breach - -- lost plc with critical damage (rip plc, you will be missed) - _update_alarm_state((not plc_cache.ok) and (plc_cache.damage > 99), self.alarms.ContainmentBreach) - - -- Containment Radiation - ---@todo containment radiation alarm - _update_alarm_state(false, self.alarms.ContainmentRadiation) - - -- Reactor Lost - _update_alarm_state(self.had_reactor and self.plc_s == nil, self.alarms.ReactorLost) - - -- Critical Damage - _update_alarm_state(plc_cache.damage >= 100, self.alarms.CriticalDamage) - - -- Reactor Damage - _update_alarm_state(plc_cache.damage > 0, self.alarms.ReactorDamage) - - -- Over-Temperature - _update_alarm_state(plc_cache.temp >= 1200, self.alarms.ReactorOverTemp) - - -- High Temperature - _update_alarm_state(plc_cache.temp > 1150, self.alarms.ReactorHighTemp) - - -- Waste Leak - _update_alarm_state(plc_cache.waste >= 0.99, self.alarms.ReactorWasteLeak) - - -- High Waste - _update_alarm_state(plc_cache.waste > 0.50, self.alarms.ReactorHighWaste) - - -- RPS Transient (excludes timeouts and manual trips) - local rps_alarm = false - if plc_cache.rps_status.manual ~= nil then - if plc_cache.rps_trip then - for key, val in pairs(plc_cache.rps_status) do - if key ~= "manual" and key ~= "timeout" then rps_alarm = rps_alarm or val end - end - end - end - - _update_alarm_state(rps_alarm, self.alarms.RPSTransient) - - -- RCS Transient - local any_low = annunc.CoolantLevelLow - local any_over = false - for i = 1, #annunc.WaterLevelLow do any_low = any_low or annunc.WaterLevelLow[i] end - for i = 1, #annunc.TurbineOverSpeed do any_over = any_over or annunc.TurbineOverSpeed[i] end - - local rcs_trans = any_low or any_over or annunc.RCPTrip or annunc.RCSFlowLow or annunc.MaxWaterReturnFeed - - -- annunciator indicators for these states may not indicate a real issue when: - -- > flow is ramping up right after reactor start - -- > flow is ramping down after reactor shutdown - if (util.time_ms() - self.start_ms > FLOW_STABILITY_DELAY_MS) and plc_cache.active then - rcs_trans = rcs_trans or annunc.BoilRateMismatch or annunc.CoolantFeedMismatch or annunc.SteamFeedMismatch - end - - _update_alarm_state(rcs_trans, self.alarms.RCSTransient) - - -- Turbine Trip - local any_trip = false - for i = 1, #annunc.TurbineTrip do any_trip = any_trip or annunc.TurbineTrip[i] end - _update_alarm_state(any_trip, self.alarms.TurbineTrip) - end + -- waste valves + local waste_pu = { open = function () __rs_w(IO.WASTE_PU, true) end, close = function () __rs_w(IO.WASTE_PU, false) end } + local waste_sna = { open = function () __rs_w(IO.WASTE_PO, true) end, close = function () __rs_w(IO.WASTE_PO, false) end } + local waste_po = { open = function () __rs_w(IO.WASTE_POPL, true) end, close = function () __rs_w(IO.WASTE_POPL, false) end } + local waste_sps = { open = function () __rs_w(IO.WASTE_AM, true) end, close = function () __rs_w(IO.WASTE_AM, false) end } --#endregion @@ -755,6 +365,51 @@ function unit.new(for_reactor, num_boilers, num_turbines) util.filter_table(self.redstone, function (s) return s.get_session_id() ~= session end) end + -- AUTO CONTROL -- + + -- engage automatic control + function public.a_engage() + self.db.annunciator.AutoControl = true + if self.plc_i ~= nil then + self.plc_i.auto_lock(true) + end + end + + -- disengage automatic control + function public.a_disengage() + self.db.annunciator.AutoControl = false + if self.plc_i ~= nil then + self.plc_i.auto_lock(false) + end + end + + -- set the automatic burn rate based on the last set br10 + ---@param ramp boolean true to ramp to rate, false to set right away + function public.a_commit_br10(ramp) + if self.db.annunciator.AutoControl then + if self.plc_i ~= nil then + self.plc_i.auto_set_burn(self.db.control.br10 / 10, ramp) + + if ramp then self.ramp_target_br10 = self.db.control.br10 / 10 end + end + end + end + + -- check if ramping is complete (burn rate is same as target) + ---@return boolean complete + function public.a_ramp_complete() + if self.plc_i ~= nil then + return (math.floor(self.plc_i.get_db().mek_status.burn_rate * 10) == self.ramp_target_br10) or (self.ramp_target_br10 == 0) + else return false end + end + + -- perform an automatic SCRAM + function public.a_scram() + if self.plc_s ~= nil then + self.plc_s.in_queue.push_command(PLC_S_CMDS.ASCRAM) + end + end + -- UPDATE SESSION -- -- update (iterate) this unit @@ -763,6 +418,7 @@ function unit.new(for_reactor, num_boilers, num_turbines) if self.plc_s ~= nil and not self.plc_s.open then self.plc_s = nil self.plc_i = nil + self.db.control.lim_br10 = 0 end -- unlink RTU unit sessions if they are closed @@ -770,125 +426,17 @@ function unit.new(for_reactor, num_boilers, num_turbines) _unlink_disconnected_units(self.turbines) _unlink_disconnected_units(self.redstone) + -- update deltas + _dt__compute_all() + -- update annunciator logic - _update_annunciator() + logic.update_annunciator(self) -- update alarm status - _update_alarms() + logic.update_alarms(self) - -- update status text (what the reactor doin?) - if is_active(self.alarms.ContainmentBreach) then - -- boom? or was boom disabled - if self.plc_i ~= nil and self.plc_i.get_rps().force_dis then - self.status_text = { "REACTOR FORCE DISABLED", "meltdown would have occured" } - else - self.status_text = { "CORE MELTDOWN", "reactor destroyed" } - end - elseif is_active(self.alarms.CriticalDamage) then - -- so much for it being a "routine turbin' trip"... - self.status_text = { "MELTDOWN IMMINENT", "evacuate facility immediately" } - elseif is_active(self.alarms.ReactorDamage) then - -- attempt to determine when a chance of a meltdown will occur - self.status_text[1] = "CONTAINMENT TAKING DAMAGE" - if self.plc_cache.damage >= 100 then - self.status_text[2] = "damage critical" - elseif (self.plc_cache.damage - self.damage_initial) > 0 then - if self.plc_cache.damage > self.damage_last then - self.damage_last = self.plc_cache.damage - local rate = (self.plc_cache.damage - self.damage_initial) / (util.time_s() - self.damage_start) - self.damage_est_last = (100 - self.plc_cache.damage) / rate - end - - self.status_text[2] = util.c("damage critical in ", util.sprintf("%.1f", self.damage_est_last), "s") - else - self.status_text[2] = "estimating time to critical..." - end - elseif is_active(self.alarms.ContainmentRadiation) then - self.status_text = { "RADIATION DETECTED", "radiation levels above normal" } - -- elseif is_active(self.alarms.RPSTransient) then - -- RPS status handled when checking reactor status - elseif is_active(self.alarms.RCSTransient) then - self.status_text = { "RCS TRANSIENT", "check coolant system" } - elseif is_active(self.alarms.ReactorOverTemp) then - self.status_text = { "CORE OVER TEMP", "reactor core temperature >=1200K" } - elseif is_active(self.alarms.ReactorWasteLeak) then - self.status_text = { "WASTE LEAK", "radioactive waste leak detected" } - elseif is_active(self.alarms.ReactorHighTemp) then - self.status_text = { "CORE TEMP HIGH", "reactor core temperature >1150K" } - elseif is_active(self.alarms.ReactorHighWaste) then - self.status_text = { "WASTE LEVEL HIGH", "waste accumulating in reactor" } - elseif is_active(self.alarms.TurbineTrip) then - self.status_text = { "TURBINE TRIP", "turbine stall occured" } - -- connection dependent states - elseif self.plc_i ~= nil then - local plc_db = self.plc_i.get_db() - if plc_db.mek_status.status then - self.status_text[1] = "ACTIVE" - - if self.db.annunciator.ReactorHighDeltaT then - self.status_text[2] = "core temperature rising" - elseif self.db.annunciator.ReactorTempHigh then - self.status_text[2] = "core temp high, system nominal" - elseif self.db.annunciator.FuelInputRateLow then - self.status_text[2] = "insufficient fuel input rate" - elseif self.db.annunciator.WasteLineOcclusion then - self.status_text[2] = "insufficient waste output rate" - elseif (util.time_ms() - self.start_ms) <= FLOW_STABILITY_DELAY_MS then - if num_turbines > 1 then - self.status_text[2] = "turbines spinning up" - else - self.status_text[2] = "turbine spinning up" - end - else - self.status_text[2] = "system nominal" - end - elseif plc_db.rps_tripped then - local cause = "unknown" - - if plc_db.rps_trip_cause == "ok" then - -- hmm... - elseif plc_db.rps_trip_cause == "dmg_crit" then - cause = "core damage critical" - elseif plc_db.rps_trip_cause == "high_temp" then - cause = "core temperature high" - elseif plc_db.rps_trip_cause == "no_coolant" then - cause = "insufficient coolant" - elseif plc_db.rps_trip_cause == "full_waste" then - cause = "excess waste" - elseif plc_db.rps_trip_cause == "heated_coolant_backup" then - cause = "excess heated coolant" - elseif plc_db.rps_trip_cause == "no_fuel" then - cause = "insufficient fuel" - elseif plc_db.rps_trip_cause == "fault" then - cause = "hardware fault" - elseif plc_db.rps_trip_cause == "timeout" then - cause = "connection timed out" - elseif plc_db.rps_trip_cause == "manual" then - cause = "manual operator SCRAM" - elseif plc_db.rps_trip_cause == "automatic" then - cause = "automated system SCRAM" - elseif plc_db.rps_trip_cause == "sys_fail" then - cause = "PLC system failure" - elseif plc_db.rps_trip_cause == "force_disabled" then - cause = "reactor force disabled" - end - - self.status_text = { "RPS SCRAM", cause } - else - self.status_text[1] = "IDLE" - - local temp = plc_db.mek_status.temp - if temp < 350 then - self.status_text[2] = "core cold" - elseif temp < 600 then - self.status_text[2] = "core warm" - else - self.status_text[2] = "core hot" - end - end - else - self.status_text = { "Reactor Off-line", "awaiting connection..." } - end + -- update status text + logic.update_status_text(self) end -- OPERATIONS -- @@ -950,23 +498,15 @@ function unit.new(for_reactor, num_boilers, num_turbines) end end - -- set the automatic control group of this unit - ---@param group integer group ID or 0 for independent - function public.set_group(group) - if group >= 0 and group <= 4 then - self.group = group - end - end - -- set the automatic control max burn rate for this unit ---@param limit number burn rate limit for auto control function public.set_burn_limit(limit) if limit >= 0 then - self.limit = limit + self.db.control.lim_br10 = math.floor(limit * 10) if self.plc_i ~= nil then if limit > self.plc_i.get_struct().max_burn then - self.limit = self.plc_i.get_struct().max_burn + self.db.control.lim_br10 = math.floor(self.plc_i.get_struct().max_burn * 10) end end end @@ -978,7 +518,7 @@ function unit.new(for_reactor, num_boilers, num_turbines) function public.get_build() local build = {} - if self.plc_s ~= nil then + if self.plc_i ~= nil then build.reactor = self.plc_i.get_struct() end @@ -1000,10 +540,8 @@ function unit.new(for_reactor, num_boilers, num_turbines) -- get reactor status function public.get_reactor_status() local status = {} - - if self.plc_s ~= nil then - local reactor = self.plc_i - status = { reactor.get_status(), reactor.get_rps(), reactor.get_general_status() } + if self.plc_i ~= nil then + status = { self.plc_i.get_status(), self.plc_i.get_rps(), self.plc_i.get_general_status() } end return status @@ -1048,6 +586,9 @@ function unit.new(for_reactor, num_boilers, num_turbines) -- get the alarm states function public.get_alarms() return self.db.alarm_states end + -- get information required for automatic reactor control + function public.get_control_inf() return self.db.control end + -- get unit state (currently only waste mode) function public.get_state() return { self.status_text[1], self.status_text[2], self.waste_mode } diff --git a/supervisor/session/unitlogic.lua b/supervisor/session/unitlogic.lua new file mode 100644 index 0000000..86617ac --- /dev/null +++ b/supervisor/session/unitlogic.lua @@ -0,0 +1,547 @@ +local log = require("scada-common.log") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local ALARM_STATE = types.ALARM_STATE + +local TRI_FAIL = types.TRI_FAIL +local DUMPING_MODE = types.DUMPING_MODE + +local aistate_string = { + "INACTIVE", + "TRIPPING", + "TRIPPED", + "ACKED", + "RING_BACK", + "RING_BACK_TRIPPING" +} + +---@class unit_logic_extension +local logic = {} + +-- update the annunciator +---@param self _unit_self +function logic.update_annunciator(self) + local DT_KEYS = self.types.DT_KEYS + local _get_dt = self._get_dt + + local num_boilers = self.num_boilers + local num_turbines = self.num_turbines + + -- variables for boiler, or reactor if no boilers used + local total_boil_rate = 0.0 + + ------------- + -- REACTOR -- + ------------- + + -- check PLC status + self.db.annunciator.PLCOnline = self.plc_i ~= nil + + if self.db.annunciator.PLCOnline then + local plc_db = self.plc_i.get_db() + + -- update auto control limit + if self.db.control.limit == 0.0 or self.db.control.limit > plc_db.mek_struct.max_burn then + self.db.control.limit = plc_db.mek_struct.max_burn + end + + -- record reactor start time (some alarms are delayed during reactor heatup) + if self.start_ms == 0 and plc_db.mek_status.status then + self.start_ms = util.time_ms() + elseif not plc_db.mek_status.status then + self.start_ms = 0 + end + + -- record reactor stats + self.plc_cache.active = plc_db.mek_status.status + self.plc_cache.ok = not (plc_db.rps_status.fault or plc_db.rps_status.sys_fail or plc_db.rps_status.force_dis) + self.plc_cache.rps_trip = plc_db.rps_tripped + self.plc_cache.rps_status = plc_db.rps_status + self.plc_cache.damage = plc_db.mek_status.damage + self.plc_cache.temp = plc_db.mek_status.temp + self.plc_cache.waste = plc_db.mek_status.waste_fill + + -- track damage + if plc_db.mek_status.damage > 0 then + if self.damage_start == 0 then + self.damage_start = util.time_s() + self.damage_initial = plc_db.mek_status.damage + end + else + self.damage_start = 0 + self.damage_initial = 0 + self.damage_last = 0 + self.damage_est_last = 0 + end + + -- heartbeat blink about every second + if self.last_heartbeat + 1000 < plc_db.last_status_update then + self.db.annunciator.PLCHeartbeat = not self.db.annunciator.PLCHeartbeat + self.last_heartbeat = plc_db.last_status_update + end + + -- update other annunciator fields + self.db.annunciator.ReactorSCRAM = plc_db.rps_tripped + self.db.annunciator.ManualReactorSCRAM = plc_db.rps_trip_cause == types.rps_status_t.manual + self.db.annunciator.AutoReactorSCRAM = plc_db.rps_trip_cause == types.rps_status_t.automatic + 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 = _get_dt(DT_KEYS.ReactorTemp) > 100 + self.db.annunciator.FuelInputRateLow = _get_dt(DT_KEYS.ReactorFuel) < -1.0 or plc_db.mek_status.fuel_fill <= 0.01 + self.db.annunciator.WasteLineOcclusion = _get_dt(DT_KEYS.ReactorWaste) > 1.0 or plc_db.mek_status.waste_fill >= 0.85 + ---@todo this is dependent on setup, i.e. how much coolant is buffered and the turbine setup + self.db.annunciator.HighStartupRate = not plc_db.mek_status.status and plc_db.mek_status.burn_rate > 40 + + -- if no boilers, use reactor heating rate to check for boil rate mismatch + if num_boilers == 0 then + total_boil_rate = plc_db.mek_status.heating_rate + end + else + self.plc_cache.ok = false + end + + ------------- + -- BOILERS -- + ------------- + + -- clear boiler online flags + for i = 1, num_boilers do self.db.annunciator.BoilerOnline[i] = false end + + -- aggregated statistics + local boiler_steam_dt_sum = 0.0 + local boiler_water_dt_sum = 0.0 + + if num_boilers > 0 then + -- go through boilers for stats and online + for i = 1, #self.boilers do + local session = self.boilers[i] ---@type unit_session + local boiler = session.get_db() ---@type boilerv_session_db + + 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()) + + self.db.annunciator.BoilerOnline[session.get_device_idx()] = true + end + + -- check heating rate low + if self.plc_i ~= nil and #self.boilers > 0 then + local r_db = self.plc_i.get_db() + + -- 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 boilerv_session_db + + if r_db.mek_status.status then + self.db.annunciator.HeatingRateLow[idx] = db.state.boil_rate == 0 + else + self.db.annunciator.HeatingRateLow[idx] = false + end + end + end + else + boiler_steam_dt_sum = _get_dt(DT_KEYS.ReactorHCool) + boiler_water_dt_sum = _get_dt(DT_KEYS.ReactorCCool) + end + + --------------------------- + -- COOLANT FEED MISMATCH -- + --------------------------- + + -- check coolant feed mismatch if using boilers, otherwise calculate with reactor + local cfmismatch = false + + if num_boilers > 0 then + 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 boilerv_session_db + + local gaining_hc = _get_dt(DT_KEYS.BoilerHCool .. idx) > 10.0 or db.tanks.hcool_fill == 1 + + -- gaining heated coolant + cfmismatch = cfmismatch or gaining_hc + -- losing cooled coolant + cfmismatch = cfmismatch or _get_dt(DT_KEYS.BoilerCCool .. idx) < -10.0 or (gaining_hc and db.tanks.ccool_fill == 0) + end + elseif self.plc_i ~= nil then + local r_db = self.plc_i.get_db() + + local gaining_hc = _get_dt(DT_KEYS.ReactorHCool) > 10.0 or r_db.mek_status.hcool_fill == 1 + + -- gaining heated coolant (steam) + cfmismatch = cfmismatch or gaining_hc + -- losing cooled coolant (water) + cfmismatch = cfmismatch or _get_dt(DT_KEYS.ReactorCCool) < -10.0 or (gaining_hc and r_db.mek_status.ccool_fill == 0) + end + + self.db.annunciator.CoolantFeedMismatch = cfmismatch + + -------------- + -- TURBINES -- + -------------- + + -- clear turbine online flags + for i = 1, num_turbines do self.db.annunciator.TurbineOnline[i] = false end + + -- aggregated statistics + local total_flow_rate = 0 + local total_input_rate = 0 + local max_water_return_rate = 0 + + -- recompute blade count on the chance that it may have changed + self.db.blade_count = 0 + + -- go through turbines for stats and online + for i = 1, #self.turbines do + local session = self.turbines[i] ---@type unit_session + local turbine = session.get_db() ---@type turbinev_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 + self.db.blade_count = self.db.blade_count + turbine.build.blades + + self.db.annunciator.TurbineOnline[session.get_device_idx()] = true + end + + -- check for boil rate mismatch (either between reactor and turbine or boiler and turbine) + self.db.annunciator.BoilRateMismatch = math.abs(total_boil_rate - total_input_rate) > 4 + + -- 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 > 2.0 or boiler_water_dt_sum < -2.0 + self.db.annunciator.SteamFeedMismatch = sfmismatch + self.db.annunciator.MaxWaterReturnFeed = max_water_return_rate == total_flow_rate and total_flow_rate ~= 0 + + -- 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 turbinev_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 turbinev_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.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. + this can be identified by these conditions: + - the current flow rate is 0 mB/t and it should not be + - 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 turbinev_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 + +-- update an alarm state given conditions +---@param self _unit_self unit instance +---@param tripped boolean if the alarm condition is still active +---@param alarm alarm_def alarm table +local function _update_alarm_state(self, tripped, alarm) + local AISTATE = self.types.AISTATE + local int_state = alarm.state + local ext_state = self.db.alarm_states[alarm.id] + + -- alarm inactive + if int_state == AISTATE.INACTIVE then + if tripped then + alarm.trip_time = util.time_ms() + if alarm.hold_time > 0 then + alarm.state = AISTATE.TRIPPING + self.db.alarm_states[alarm.id] = ALARM_STATE.INACTIVE + else + alarm.state = AISTATE.TRIPPED + self.db.alarm_states[alarm.id] = ALARM_STATE.TRIPPED + log.info(util.c("UNIT ", self.r_id, " ALARM ", alarm.id, " (", types.alarm_string[alarm.id], "): TRIPPED [PRIORITY ", + types.alarm_prio_string[alarm.tier + 1],"]")) + end + else + alarm.trip_time = util.time_ms() + self.db.alarm_states[alarm.id] = ALARM_STATE.INACTIVE + end + -- alarm condition met, but not yet for required hold time + elseif (int_state == AISTATE.TRIPPING) or (int_state == AISTATE.RING_BACK_TRIPPING) then + if tripped then + local elapsed = util.time_ms() - alarm.trip_time + if elapsed > (alarm.hold_time * 1000) then + alarm.state = AISTATE.TRIPPED + self.db.alarm_states[alarm.id] = ALARM_STATE.TRIPPED + log.info(util.c("UNIT ", self.r_id, " ALARM ", alarm.id, " (", types.alarm_string[alarm.id], "): TRIPPED [PRIORITY ", + types.alarm_prio_string[alarm.tier + 1],"]")) + end + elseif int_state == AISTATE.RING_BACK_TRIPPING then + alarm.trip_time = 0 + alarm.state = AISTATE.RING_BACK + self.db.alarm_states[alarm.id] = ALARM_STATE.RING_BACK + else + alarm.trip_time = 0 + alarm.state = AISTATE.INACTIVE + self.db.alarm_states[alarm.id] = ALARM_STATE.INACTIVE + end + -- alarm tripped and alarming + elseif int_state == AISTATE.TRIPPED then + if tripped then + if ext_state == ALARM_STATE.ACKED then + -- was acked by coordinator + alarm.state = AISTATE.ACKED + end + else + alarm.state = AISTATE.RING_BACK + self.db.alarm_states[alarm.id] = ALARM_STATE.RING_BACK + end + -- alarm acknowledged but still tripped + elseif int_state == AISTATE.ACKED then + if not tripped then + alarm.state = AISTATE.RING_BACK + self.db.alarm_states[alarm.id] = ALARM_STATE.RING_BACK + end + -- alarm no longer tripped, operator must reset to clear + elseif int_state == AISTATE.RING_BACK then + if tripped then + alarm.trip_time = util.time_ms() + if alarm.hold_time > 0 then + alarm.state = AISTATE.RING_BACK_TRIPPING + else + alarm.state = AISTATE.TRIPPED + self.db.alarm_states[alarm.id] = ALARM_STATE.TRIPPED + end + elseif ext_state == ALARM_STATE.INACTIVE then + -- was reset by coordinator + alarm.state = AISTATE.INACTIVE + alarm.trip_time = 0 + end + else + log.error(util.c("invalid alarm state for unit ", self.r_id, " alarm ", alarm.id), true) + end + + -- check for state change + if alarm.state ~= int_state then + local change_str = util.c(aistate_string[int_state + 1], " -> ", aistate_string[alarm.state + 1]) + log.debug(util.c("UNIT ", self.r_id, " ALARM ", alarm.id, " (", types.alarm_string[alarm.id], "): ", change_str)) + end +end + +-- evaluate alarm conditions +---@param self _unit_self unit instance +function logic.update_alarms(self) + local annunc = self.db.annunciator + local plc_cache = self.plc_cache + + -- Containment Breach + -- lost plc with critical damage (rip plc, you will be missed) + _update_alarm_state(self, (not plc_cache.ok) and (plc_cache.damage > 99), self.alarms.ContainmentBreach) + + -- Containment Radiation + ---@todo containment radiation alarm + _update_alarm_state(self, false, self.alarms.ContainmentRadiation) + + -- Reactor Lost + _update_alarm_state(self, self.had_reactor and self.plc_i == nil, self.alarms.ReactorLost) + + -- Critical Damage + _update_alarm_state(self, plc_cache.damage >= 100, self.alarms.CriticalDamage) + + -- Reactor Damage + _update_alarm_state(self, plc_cache.damage > 0, self.alarms.ReactorDamage) + + -- Over-Temperature + _update_alarm_state(self, plc_cache.temp >= 1200, self.alarms.ReactorOverTemp) + + -- High Temperature + _update_alarm_state(self, plc_cache.temp > 1150, self.alarms.ReactorHighTemp) + + -- Waste Leak + _update_alarm_state(self, plc_cache.waste >= 0.99, self.alarms.ReactorWasteLeak) + + -- High Waste + _update_alarm_state(self, plc_cache.waste > 0.50, self.alarms.ReactorHighWaste) + + -- RPS Transient (excludes timeouts and manual trips) + local rps_alarm = false + if plc_cache.rps_status.manual ~= nil then + if plc_cache.rps_trip then + for key, val in pairs(plc_cache.rps_status) do + if key ~= "manual" and key ~= "timeout" then rps_alarm = rps_alarm or val end + end + end + end + + _update_alarm_state(self, rps_alarm, self.alarms.RPSTransient) + + -- RCS Transient + local any_low = annunc.CoolantLevelLow + local any_over = false + for i = 1, #annunc.WaterLevelLow do any_low = any_low or annunc.WaterLevelLow[i] end + for i = 1, #annunc.TurbineOverSpeed do any_over = any_over or annunc.TurbineOverSpeed[i] end + + local rcs_trans = any_low or any_over or annunc.RCPTrip or annunc.RCSFlowLow or annunc.MaxWaterReturnFeed + + -- annunciator indicators for these states may not indicate a real issue when: + -- > flow is ramping up right after reactor start + -- > flow is ramping down after reactor shutdown + if (util.time_ms() - self.start_ms > self.defs.FLOW_STABILITY_DELAY_MS) and plc_cache.active then + rcs_trans = rcs_trans or annunc.BoilRateMismatch or annunc.CoolantFeedMismatch or annunc.SteamFeedMismatch + end + + _update_alarm_state(self, rcs_trans, self.alarms.RCSTransient) + + -- Turbine Trip + local any_trip = false + for i = 1, #annunc.TurbineTrip do any_trip = any_trip or annunc.TurbineTrip[i] end + _update_alarm_state(self, any_trip, self.alarms.TurbineTrip) +end + +-- update the two unit status text messages +---@param self _unit_self unit instance +function logic.update_status_text(self) + local AISTATE = self.types.AISTATE + + -- check if an alarm is active (tripped or ack'd) + ---@param alarm table alarm entry + ---@return boolean active + local function is_active(alarm) + return alarm.state == AISTATE.TRIPPED or alarm.state == AISTATE.ACKED + end + + -- update status text (what the reactor doin?) + if is_active(self.alarms.ContainmentBreach) then + -- boom? or was boom disabled + if self.plc_i ~= nil and self.plc_i.get_rps().force_dis then + self.status_text = { "REACTOR FORCE DISABLED", "meltdown would have occured" } + else + self.status_text = { "CORE MELTDOWN", "reactor destroyed" } + end + elseif is_active(self.alarms.CriticalDamage) then + -- so much for it being a "routine turbin' trip"... + self.status_text = { "MELTDOWN IMMINENT", "evacuate facility immediately" } + elseif is_active(self.alarms.ReactorDamage) then + -- attempt to determine when a chance of a meltdown will occur + self.status_text[1] = "CONTAINMENT TAKING DAMAGE" + if self.plc_cache.damage >= 100 then + self.status_text[2] = "damage critical" + elseif (self.plc_cache.damage - self.damage_initial) > 0 then + if self.plc_cache.damage > self.damage_last then + self.damage_last = self.plc_cache.damage + local rate = (self.plc_cache.damage - self.damage_initial) / (util.time_s() - self.damage_start) + self.damage_est_last = (100 - self.plc_cache.damage) / rate + end + + self.status_text[2] = util.c("damage critical in ", util.sprintf("%.1f", self.damage_est_last), "s") + else + self.status_text[2] = "estimating time to critical..." + end + elseif is_active(self.alarms.ContainmentRadiation) then + self.status_text = { "RADIATION DETECTED", "radiation levels above normal" } + -- elseif is_active(self.alarms.RPSTransient) then + -- RPS status handled when checking reactor status + elseif is_active(self.alarms.RCSTransient) then + self.status_text = { "RCS TRANSIENT", "check coolant system" } + elseif is_active(self.alarms.ReactorOverTemp) then + self.status_text = { "CORE OVER TEMP", "reactor core temperature >=1200K" } + elseif is_active(self.alarms.ReactorWasteLeak) then + self.status_text = { "WASTE LEAK", "radioactive waste leak detected" } + elseif is_active(self.alarms.ReactorHighTemp) then + self.status_text = { "CORE TEMP HIGH", "reactor core temperature >1150K" } + elseif is_active(self.alarms.ReactorHighWaste) then + self.status_text = { "WASTE LEVEL HIGH", "waste accumulating in reactor" } + elseif is_active(self.alarms.TurbineTrip) then + self.status_text = { "TURBINE TRIP", "turbine stall occured" } + -- connection dependent states + elseif self.plc_i ~= nil then + local plc_db = self.plc_i.get_db() + if plc_db.mek_status.status then + self.status_text[1] = "ACTIVE" + + if self.db.annunciator.ReactorHighDeltaT then + self.status_text[2] = "core temperature rising" + elseif self.db.annunciator.ReactorTempHigh then + self.status_text[2] = "core temp high, system nominal" + elseif self.db.annunciator.FuelInputRateLow then + self.status_text[2] = "insufficient fuel input rate" + elseif self.db.annunciator.WasteLineOcclusion then + self.status_text[2] = "insufficient waste output rate" + elseif (util.time_ms() - self.start_ms) <= self.defs.FLOW_STABILITY_DELAY_MS then + if self.num_turbines > 1 then + self.status_text[2] = "turbines spinning up" + else + self.status_text[2] = "turbine spinning up" + end + else + self.status_text[2] = "system nominal" + end + elseif plc_db.rps_tripped then + local cause = "unknown" + + if plc_db.rps_trip_cause == "ok" then + -- hmm... + elseif plc_db.rps_trip_cause == "dmg_crit" then + cause = "core damage critical" + elseif plc_db.rps_trip_cause == "high_temp" then + cause = "core temperature high" + elseif plc_db.rps_trip_cause == "no_coolant" then + cause = "insufficient coolant" + elseif plc_db.rps_trip_cause == "full_waste" then + cause = "excess waste" + elseif plc_db.rps_trip_cause == "heated_coolant_backup" then + cause = "excess heated coolant" + elseif plc_db.rps_trip_cause == "no_fuel" then + cause = "insufficient fuel" + elseif plc_db.rps_trip_cause == "fault" then + cause = "hardware fault" + elseif plc_db.rps_trip_cause == "timeout" then + cause = "connection timed out" + elseif plc_db.rps_trip_cause == "manual" then + cause = "manual operator SCRAM" + elseif plc_db.rps_trip_cause == "automatic" then + cause = "automated system SCRAM" + elseif plc_db.rps_trip_cause == "sys_fail" then + cause = "PLC system failure" + elseif plc_db.rps_trip_cause == "force_disabled" then + cause = "reactor force disabled" + end + + self.status_text = { "RPS SCRAM", cause } + else + self.status_text[1] = "IDLE" + + local temp = plc_db.mek_status.temp + if temp < 350 then + self.status_text[2] = "core cold" + elseif temp < 600 then + self.status_text[2] = "core warm" + else + self.status_text[2] = "core hot" + end + end + else + self.status_text = { "Reactor Off-line", "awaiting connection..." } + end +end + +return logic diff --git a/supervisor/startup.lua b/supervisor/startup.lua index 2c340dc..e408ffb 100644 --- a/supervisor/startup.lua +++ b/supervisor/startup.lua @@ -14,7 +14,7 @@ local svsessions = require("supervisor.session.svsessions") local config = require("supervisor.config") local supervisor = require("supervisor.supervisor") -local SUPERVISOR_VERSION = "beta-v0.9.2" +local SUPERVISOR_VERSION = "beta-v0.9.3" local print = util.print local println = util.println