diff --git a/supervisor/facility.lua b/supervisor/facility.lua index 17011ae..3ef7eb5 100644 --- a/supervisor/facility.lua +++ b/supervisor/facility.lua @@ -1,40 +1,18 @@ -local audio = require("scada-common.audio") -local const = require("scada-common.constants") -local log = require("scada-common.log") -local rsio = require("scada-common.rsio") -local types = require("scada-common.types") -local util = require("scada-common.util") +local log = require("scada-common.log") +local types = require("scada-common.types") +local util = require("scada-common.util") -local unit = require("supervisor.unit") +local fac_update = require("supervisor.facility_update") -local qtypes = require("supervisor.session.rtu.qtypes") +local unit = require("supervisor.unit") -local rsctl = require("supervisor.session.rsctl") +local rsctl = require("supervisor.session.rsctl") -local TONE = audio.TONE - -local ALARM = types.ALARM -local PRIO = types.ALARM_PRIORITY -local ALARM_STATE = types.ALARM_STATE -local CONTAINER_MODE = types.CONTAINER_MODE -local PROCESS = types.PROCESS -local PROCESS_NAMES = types.PROCESS_NAMES -local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE -local WASTE_MODE = types.WASTE_MODE -local WASTE = types.WASTE_PRODUCT - -local IO = rsio.IO - -local DTV_RTU_S_DATA = qtypes.DTV_RTU_S_DATA - --- 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_rf(7140) - -local FLOW_STABILITY_DELAY_S = const.FLOW_STABILITY_DELAY_MS / 1000 - -local ALARM_LIMS = const.ALARM_LIMITS +local PROCESS = types.PROCESS +local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE +local WASTE = types.WASTE_PRODUCT +---@enum AUTO_SCRAM local AUTO_SCRAM = { NONE = 0, MATRIX_DC = 1, @@ -44,20 +22,13 @@ local AUTO_SCRAM = { GEN_FAULT = 5 } +---@enum START_STATUS local START_STATUS = { OK = 0, NO_UNITS = 1, BLADE_MISMATCH = 2 } -local charge_Kp = 0.15 -local charge_Ki = 0.0 -local charge_Kd = 0.6 - -local rate_Kp = 2.45 -local rate_Ki = 0.4825 -local rate_Kd = -1.0 - ---@class facility_management local facility = {} @@ -67,8 +38,10 @@ local facility = {} ---@param cooling_conf sv_cooling_conf cooling configurations of reactor units ---@param check_rtu_id function ID checking function for RTUs attempting to be linked function facility.new(config, cooling_conf, check_rtu_id) + ---@class _facility_self local self = { units = {}, + types = { AUTO_SCRAM = AUTO_SCRAM, START_STATUS = START_STATUS }, status_text = { "START UP", "initializing..." }, all_sys_ok = false, allow_testing = false, @@ -143,6 +116,9 @@ function facility.new(config, cooling_conf, check_rtu_id) imtx_faulted_times = { 0, 0, 0 } } + -- provide self to facility update functions + local f_update = fac_update(self) + -- create units for i = 1, config.UnitCount do table.insert(self.units, unit.new(i, cooling_conf.r_cool[i].BoilerCount, cooling_conf.r_cool[i].TurbineCount, check_rtu_id, config.ExtChargeIdling)) @@ -162,87 +138,6 @@ function facility.new(config, cooling_conf, check_rtu_id) table.insert(self.test_tone_states, false) end - -- PRIVATE FUNCTIONS -- - - -- check if all auto-controlled units completed ramping - ---@nodiscard - 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].auto_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 - ---@param abort_on_fault boolean? true to exit if one device has an effective burn rate different than its limit - ---@return integer unallocated_br100, boolean? aborted - local function _allocate_burn_rate(burn_rate, ramp, abort_on_fault) - local unallocated = math.floor(burn_rate * 100) - - -- go through all priority groups - for i = 1, #self.prio_defs do - local units = self.prio_defs[i] - - if #units > 0 then - 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 id = 1, #units do - local u = units[id] ---@type reactor_unit - - local ctl = u.get_control_inf() - local lim_br100 = u.auto_get_effective_limit() - - if abort_on_fault and (lim_br100 ~= ctl.lim_br100) then - -- effective limit differs from set limit, unit is degraded - return unallocated, true - end - - local last = ctl.br100 - - if splits[id] <= lim_br100 then - ctl.br100 = splits[id] - else - ctl.br100 = lim_br100 - - if id < #units then - local remaining = #units - id - split = math.floor(unallocated / remaining) - for x = (id + 1), #units do splits[x] = split end - splits[#units] = splits[#units] + (unallocated % remaining) - end - end - - unallocated = math.max(0, unallocated - ctl.br100) - - if last ~= ctl.br100 then u.auto_commit_br100(ramp) end - end - end - end - - return unallocated, false - end - - -- set idle state of all assigned reactors - ---@param idle boolean idle state - local function _set_idling(idle) - for i = 1, #self.prio_defs do - for _, u in pairs(self.prio_defs[i]) do u.auto_set_idle(idle) end - end - end - -- PUBLIC FUNCTIONS -- ---@class facility @@ -304,709 +199,20 @@ function facility.new(config, cooling_conf, check_rtu_id) -- update (iterate) the facility management function public.update() - -- unlink RTU sessions if they are closed - for _, v in pairs(self.rtu_list) do util.filter_table(v, function (u) return u.is_connected() end) end + -- run process control and evaluate automatic SCRAM + f_update.pre_auto() + f_update.auto_control(config.ExtChargeIdling) + f_update.auto_safety() + f_update.post_auto() - -- check if test routines are allowed right now - self.allow_testing = true - for i = 1, #self.units do - local u = self.units[i] ---@type reactor_unit - self.allow_testing = self.allow_testing and u.is_safe_idle() - end + -- handle redstone I/O + f_update.redstone(public.ack_all) - -- current state for process control - local charge_update = 0 - local rate_update = 0 + -- unit tasks + f_update.unit_mgmt(cooling_conf) - -- 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 - - local build_update = db.build.last_update - rate_update = db.state.last_update - charge_update = db.tanks.last_update - - local has_data = build_update > 0 and rate_update > 0 and charge_update > 0 - - if matrix.is_faulted() then - -- a fault occured, cannot reliably update stats - has_data = false - self.im_stat_init = false - self.imtx_faulted_times = { build_update, rate_update, charge_update } - elseif not self.im_stat_init then - -- prevent operation with partially invalid data - -- all fields must have updated since the last fault - has_data = self.imtx_faulted_times[1] < build_update and - self.imtx_faulted_times[2] < rate_update and - self.imtx_faulted_times[3] < charge_update - end - - if has_data then - local energy = util.joules_to_fe_rf(db.tanks.energy) - local input = util.joules_to_fe_rf(db.state.last_input) - local output = util.joules_to_fe_rf(db.state.last_output) - - if self.im_stat_init then - self.avg_charge.record(energy, charge_update) - self.avg_inflow.record(input, rate_update) - self.avg_outflow.record(output, rate_update) - - if charge_update ~= self.imtx_last_charge_t then - local delta = (energy - self.imtx_last_charge) / (charge_update - self.imtx_last_charge_t) - - self.imtx_last_charge = energy - self.imtx_last_charge_t = charge_update - - -- if the capacity changed, toss out existing data - if db.build.max_energy ~= self.imtx_last_capacity then - self.imtx_last_capacity = db.build.max_energy - self.avg_net.reset() - else - self.avg_net.record(delta, charge_update) - end - end - else - self.im_stat_init = true - - self.avg_charge.reset(energy) - self.avg_inflow.reset(input) - self.avg_outflow.reset(output) - self.avg_net.reset() - - self.imtx_last_capacity = db.build.max_energy - self.imtx_last_charge = energy - self.imtx_last_charge_t = charge_update - end - else - -- prevent use by control systems - rate_update = 0 - charge_update = 0 - end - else - self.im_stat_init = false - end - - self.all_sys_ok = true - for i = 1, #self.units do - self.all_sys_ok = self.all_sys_ok and not self.units[i].get_control_inf().degraded - end - - ------------------------- - -- Run Process Control -- - ------------------------- - - --#region - - local avg_charge = self.avg_charge.compute() - local avg_inflow = self.avg_inflow.compute() - local avg_outflow = self.avg_outflow.compute() - - local now = os.clock() - - local state_changed = self.mode ~= self.last_mode - local next_mode = self.mode - - -- once auto control is started, sort the priority sublists by limits - if state_changed then - self.saturated = false - - log.debug(util.c("FAC: state changed from ", PROCESS_NAMES[self.last_mode + 1], " to ", PROCESS_NAMES[self.mode + 1])) - - if (self.last_mode == PROCESS.INACTIVE) or (self.last_mode == PROCESS.GEN_RATE_FAULT_IDLE) then - self.start_fail = START_STATUS.OK - - if (self.mode ~= PROCESS.MATRIX_FAULT_IDLE) and (self.mode ~= PROCESS.SYSTEM_ALARM_IDLE) then - -- auto clear ASCRAM - self.ascram = false - self.ascram_reason = AUTO_SCRAM.NONE - end - - local blade_count = nil - self.max_burn_combined = 0.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_br100 < b.get_control_inf().lim_br100 end - ) - - for _, u in pairs(self.prio_defs[i]) do - local u_blade_count = u.get_control_inf().blade_count - - if blade_count == nil then - blade_count = u_blade_count - elseif (u_blade_count ~= blade_count) and (self.mode == PROCESS.GEN_RATE) then - log.warning("FAC: cannot start GEN_RATE process with inconsistent unit blade counts") - next_mode = PROCESS.INACTIVE - self.start_fail = START_STATUS.BLADE_MISMATCH - end - - if self.start_fail == START_STATUS.OK then u.auto_engage() end - - self.max_burn_combined = self.max_burn_combined + (u.get_control_inf().lim_br100 / 100.0) - end - end - - log.debug(util.c("FAC: computed a max combined burn rate of ", self.max_burn_combined, "mB/t")) - - if blade_count == nil then - -- no units - log.warning("FAC: cannot start process control with 0 units assigned") - next_mode = PROCESS.INACTIVE - self.start_fail = START_STATUS.NO_UNITS - else - self.charge_conversion = blade_count * POWER_PER_BLADE - end - elseif self.mode == PROCESS.INACTIVE then - for i = 1, #self.prio_defs do - -- disable reactors and disengage auto control - for _, u in pairs(self.prio_defs[i]) do - u.disable() - u.auto_set_idle(false) - u.auto_disengage() - end - end - - log.info("FAC: disengaging auto control (now inactive)") - end - - self.initial_ramp = true - self.waiting_on_ramp = false - self.waiting_on_stable = false - else - self.initial_ramp = false - end - - -- update unit ready state - local assign_count = 0 - self.units_ready = true - for i = 1, #self.prio_defs do - for _, u in pairs(self.prio_defs[i]) do - assign_count = assign_count + 1 - self.units_ready = self.units_ready and u.get_control_inf().ready - end - end - - -- perform mode-specific operations - if self.mode == PROCESS.INACTIVE then - if not self.units_ready then - self.status_text = { "NOT READY", "assigned units not ready" } - else - -- clear ASCRAM once ready - self.ascram = false - self.ascram_reason = AUTO_SCRAM.NONE - - if self.start_fail == START_STATUS.NO_UNITS and assign_count == 0 then - self.status_text = { "START FAILED", "no units were assigned" } - elseif self.start_fail == START_STATUS.BLADE_MISMATCH then - self.status_text = { "START FAILED", "turbine blade count mismatch" } - else - self.status_text = { "IDLE", "control disengaged" } - end - end - elseif self.mode == PROCESS.MAX_BURN then - -- run units at their limits - if state_changed then - self.time_start = now - self.saturated = true - - self.status_text = { "MONITORED MODE", "running reactors at limit" } - log.info("FAC: MAX_BURN process mode started") - end - - _allocate_burn_rate(self.max_burn_combined, true) - elseif self.mode == PROCESS.BURN_RATE then - -- a total aggregate burn rate - if state_changed then - self.time_start = now - self.status_text = { "BURN RATE MODE", "running" } - log.info("FAC: BURN_RATE process mode started") - end - - local unallocated = _allocate_burn_rate(self.burn_target, true) - self.saturated = self.burn_target == self.max_burn_combined or unallocated > 0 - elseif self.mode == PROCESS.CHARGE then - -- target a level of charge - if state_changed then - self.time_start = now - self.last_time = now - self.last_error = 0 - self.accumulator = 0 - - -- enabling idling on all assigned units - _set_idling(true) - - self.status_text = { "CHARGE MODE", "running control loop" } - log.info("FAC: CHARGE mode starting PID control") - elseif self.last_update < charge_update then - -- convert to kFE to make constants not microscopic - local error = util.round((self.charge_setpoint - avg_charge) / 1000) / 1000 - - -- stop accumulator when saturated to avoid windup - if not self.saturated then - self.accumulator = self.accumulator + (error * (now - self.last_time)) - end - - -- local runtime = now - self.time_start - local integral = self.accumulator - 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 output = P + I + D - - -- clamp at range -> output clamped (out_c) - local out_c = math.max(0, math.min(output, self.max_burn_combined)) - - self.saturated = output ~= out_c - - if not config.ExtChargeIdling then - -- stop idling early if the output is zero, we are at or above the setpoint, and are not losing charge - _set_idling(not ((out_c == 0) and (error <= 0) and (avg_outflow <= 0))) - end - - -- log.debug(util.sprintf("CHARGE[%f] { CHRG[%f] ERR[%f] INT[%f] => OUT[%f] OUT_C[%f] <= P[%f] I[%f] D[%f] }", - -- runtime, avg_charge, error, integral, output, out_c, P, I, D)) - - _allocate_burn_rate(out_c, true) - - self.last_time = now - self.last_error = error - end - - self.last_update = charge_update - elseif self.mode == PROCESS.GEN_RATE then - -- target a rate of generation - if state_changed then - -- estimate an initial output - local output = self.gen_rate_setpoint / self.charge_conversion - - local unallocated = _allocate_burn_rate(output, true) - - self.saturated = output >= self.max_burn_combined or unallocated > 0 - self.waiting_on_ramp = true - - self.status_text = { "GENERATION MODE", "starting up" } - log.info(util.c("FAC: GEN_RATE process mode initial ramp started (initial target is ", output, " mB/t)")) - elseif self.waiting_on_ramp then - if _all_units_ramped() then - self.waiting_on_ramp = false - self.waiting_on_stable = true - - self.time_start = now - - self.status_text = { "GENERATION MODE", "holding ramped rate" } - log.info("FAC: GEN_RATE process mode initial ramp completed, holding for stablization time") - end - elseif self.waiting_on_stable then - if (now - self.time_start) > FLOW_STABILITY_DELAY_S then - self.waiting_on_stable = false - - self.time_start = now - self.last_time = now - self.last_error = 0 - self.accumulator = 0 - - self.status_text = { "GENERATION MODE", "running control loop" } - log.info("FAC: GEN_RATE process mode initial hold completed, starting PID control") - end - elseif self.last_update < rate_update then - -- convert to MFE (in rounded kFE) to make constants not microscopic - local error = util.round((self.gen_rate_setpoint - avg_inflow) / 1000) / 1000 - - -- stop accumulator when saturated to avoid windup - if not self.saturated then - self.accumulator = self.accumulator + (error * (now - self.last_time)) - end - - -- local runtime = now - self.time_start - local integral = self.accumulator - 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 - - -- velocity (rate) (derivative of charge level => rate) feed forward - local FF = self.gen_rate_setpoint / self.charge_conversion - - local output = P + I + D + FF - - -- clamp at range -> output clamped (sp_c) - local out_c = math.max(0, math.min(output, self.max_burn_combined)) - - self.saturated = output ~= out_c - - -- log.debug(util.sprintf("GEN_RATE[%f] { RATE[%f] ERR[%f] INT[%f] => OUT[%f] OUT_C[%f] <= P[%f] I[%f] D[%f] }", - -- runtime, avg_inflow, error, integral, output, out_c, P, I, D)) - - _allocate_burn_rate(out_c, false) - - self.last_time = now - self.last_error = error - end - - self.last_update = rate_update - elseif self.mode == PROCESS.MATRIX_FAULT_IDLE then - -- exceeded charge, wait until condition clears - if self.ascram_reason == AUTO_SCRAM.NONE then - next_mode = self.return_mode - log.info("FAC: exiting matrix fault idle state due to fault resolution") - elseif self.ascram_reason == AUTO_SCRAM.CRIT_ALARM then - next_mode = PROCESS.SYSTEM_ALARM_IDLE - log.info("FAC: exiting matrix fault idle state due to critical unit alarm") - end - elseif self.mode == PROCESS.SYSTEM_ALARM_IDLE then - -- do nothing, wait for user to confirm (stop and reset) - elseif self.mode == PROCESS.GEN_RATE_FAULT_IDLE then - -- system faulted (degraded/not ready) while running generation rate mode - -- mode will need to be fully restarted once everything is OK to re-ramp to feed-forward - if self.units_ready then - log.info("FAC: system ready after faulting out of GEN_RATE process mode, switching back...") - next_mode = PROCESS.GEN_RATE - end - elseif self.mode ~= PROCESS.INACTIVE then - log.error(util.c("FAC: unsupported process mode ", self.mode, ", switching to inactive")) - next_mode = PROCESS.INACTIVE - end - - --#endregion - - ------------------------------ - -- Evaluate Automatic SCRAM -- - ------------------------------ - - --#region - - local astatus = self.ascram_status - - if self.induction[1] ~= nil then - local db = self.induction[1].get_db() ---@type imatrix_session_db - - -- clear matrix disconnected - if astatus.matrix_dc then - astatus.matrix_dc = false - log.info("FAC: induction matrix reconnected, clearing ASCRAM condition") - end - - -- check matrix fill too high - local was_fill = astatus.matrix_fill - astatus.matrix_fill = (db.tanks.energy_fill >= ALARM_LIMS.CHARGE_HIGH) or (astatus.matrix_fill and db.tanks.energy_fill > ALARM_LIMS.CHARGE_RE_ENABLE) - - if was_fill and not astatus.matrix_fill then - log.info(util.c("FAC: charge state of induction matrix entered acceptable range <= ", ALARM_LIMS.CHARGE_RE_ENABLE * 100, "%")) - end - - -- check for critical unit alarms - astatus.crit_alarm = false - for i = 1, #self.units do - local u = self.units[i] ---@type reactor_unit - - if u.has_alarm_min_prio(PRIO.CRITICAL) then - astatus.crit_alarm = true - break - end - end - - -- check for facility radiation - if #self.envd > 0 then - local max_rad = 0 - - for i = 1, #self.envd do - local envd = self.envd[i] ---@type unit_session - local e_db = envd.get_db() ---@type envd_session_db - if e_db.radiation_raw > max_rad then max_rad = e_db.radiation_raw end - end - - astatus.radiation = max_rad >= ALARM_LIMS.FAC_HIGH_RAD - else - -- don't clear, if it is true then we lost it with high radiation, so just keep alarming - -- operator can restart the system or hit the stop/reset button - end - - -- system not ready, will need to restart GEN_RATE mode - -- clears when we enter the fault waiting state - astatus.gen_fault = self.mode == PROCESS.GEN_RATE and not self.units_ready - else - astatus.matrix_dc = true - end - - if (self.mode ~= PROCESS.INACTIVE) and (self.mode ~= PROCESS.SYSTEM_ALARM_IDLE) then - local scram = astatus.matrix_dc or astatus.matrix_fill or astatus.crit_alarm or astatus.gen_fault - - if scram and not self.ascram then - -- SCRAM all units - for i = 1, #self.prio_defs do - for _, u in pairs(self.prio_defs[i]) do - u.auto_scram() - end - end - - if astatus.crit_alarm then - -- highest priority alarm - next_mode = PROCESS.SYSTEM_ALARM_IDLE - self.ascram_reason = AUTO_SCRAM.CRIT_ALARM - self.status_text = { "AUTOMATIC SCRAM", "critical unit alarm tripped" } - - log.info("FAC: automatic SCRAM due to critical unit alarm") - log.warning("FAC: emergency exit of process control due to critical unit alarm") - elseif astatus.radiation then - next_mode = PROCESS.SYSTEM_ALARM_IDLE - self.ascram_reason = AUTO_SCRAM.RADIATION - self.status_text = { "AUTOMATIC SCRAM", "facility radiation high" } - - log.info("FAC: automatic SCRAM due to high facility radiation") - elseif astatus.matrix_dc then - next_mode = PROCESS.MATRIX_FAULT_IDLE - self.ascram_reason = AUTO_SCRAM.MATRIX_DC - self.status_text = { "AUTOMATIC SCRAM", "induction matrix disconnected" } - - if self.mode ~= PROCESS.MATRIX_FAULT_IDLE then self.return_mode = self.mode end - - log.info("FAC: automatic SCRAM due to induction matrix disconnection") - elseif astatus.matrix_fill then - next_mode = PROCESS.MATRIX_FAULT_IDLE - self.ascram_reason = AUTO_SCRAM.MATRIX_FILL - self.status_text = { "AUTOMATIC SCRAM", "induction matrix fill high" } - - if self.mode ~= PROCESS.MATRIX_FAULT_IDLE then self.return_mode = self.mode end - - log.info("FAC: automatic SCRAM due to induction matrix high charge") - elseif astatus.gen_fault then - -- lowest priority alarm - next_mode = PROCESS.GEN_RATE_FAULT_IDLE - self.ascram_reason = AUTO_SCRAM.GEN_FAULT - self.status_text = { "GENERATION MODE IDLE", "paused: system not ready" } - - log.info("FAC: automatic SCRAM due to unit problem while in GEN_RATE mode, will resume once all units are ready") - end - end - - self.ascram = scram - - if not self.ascram then - self.ascram_reason = AUTO_SCRAM.NONE - - -- reset PLC RPS trips if we should - for i = 1, #self.units do - local u = self.units[i] ---@type reactor_unit - u.auto_cond_rps_reset() - end - end - end - - --#endregion - - -- update last mode and set next mode - self.last_mode = self.mode - self.mode = next_mode - - ------------------------- - -- Handle Redstone I/O -- - ------------------------- - - --#region - - if #self.redstone > 0 then - -- handle facility SCRAM - if self.io_ctl.digital_read(IO.F_SCRAM) then - for i = 1, #self.units do - local u = self.units[i] ---@type reactor_unit - u.cond_scram() - end - end - - -- handle facility ack - if self.io_ctl.digital_read(IO.F_ACK) then public.ack_all() end - - -- update facility alarm outputs - local has_prio_alarm, has_any_alarm = false, false - for i = 1, #self.units do - local u = self.units[i] ---@type reactor_unit - - if u.has_alarm_min_prio(PRIO.EMERGENCY) then - has_prio_alarm, has_any_alarm = true, true - break - elseif u.has_alarm_min_prio(PRIO.TIMELY) then - has_any_alarm = true - end - end - - self.io_ctl.digital_write(IO.F_ALARM, has_prio_alarm) - self.io_ctl.digital_write(IO.F_ALARM_ANY, has_any_alarm) - - -- update induction matrix related outputs - if self.induction[1] ~= nil then - local db = self.induction[1].get_db() ---@type imatrix_session_db - - self.io_ctl.digital_write(IO.F_MATRIX_LOW, db.tanks.energy_fill < const.RS_THRESHOLDS.IMATRIX_CHARGE_LOW) - self.io_ctl.digital_write(IO.F_MATRIX_HIGH, db.tanks.energy_fill > const.RS_THRESHOLDS.IMATRIX_CHARGE_HIGH) - self.io_ctl.analog_write(IO.F_MATRIX_CHG, db.tanks.energy_fill, 0, 1) - end - end - - --#endregion - - ---------------- - -- Unit Tasks -- - ---------------- - - --#region - - local insufficent_po_rate = false - local need_emcool = false - - for i = 1, #self.units do - local u = self.units[i] ---@type reactor_unit - - -- update auto waste processing - if u.get_control_inf().waste_mode == WASTE_MODE.AUTO then - if (u.get_sna_rate() * 10.0) < u.get_burn_rate() then - insufficent_po_rate = true - end - end - - -- check if unit activated emergency coolant & uses facility tanks - if (cooling_conf.fac_tank_mode > 0) and u.is_emer_cool_tripped() and (cooling_conf.fac_tank_defs[i] == 2) then - need_emcool = true - end - end - - -- update waste product - - self.current_waste_product = self.waste_product - - if (not self.sps_low_power) and (self.waste_product == WASTE.ANTI_MATTER) and (self.induction[1] ~= nil) then - local db = self.induction[1].get_db() ---@type imatrix_session_db - - if db.tanks.energy_fill >= 0.15 then - self.disabled_sps = false - elseif self.disabled_sps or ((db.tanks.last_update > 0) and (db.tanks.energy_fill < 0.1)) then - self.disabled_sps = true - self.current_waste_product = WASTE.POLONIUM - end - else - self.disabled_sps = false - end - - if self.pu_fallback and insufficent_po_rate then - self.current_waste_product = WASTE.PLUTONIUM - end - - -- make sure dynamic tanks are allowing outflow if required - -- set all, rather than trying to determine which is for which (simpler & safer) - -- there should be no need for any to be in fill only mode - if need_emcool then - for i = 1, #self.tanks do - local session = self.tanks[i] ---@type unit_session - local tank = session.get_db() ---@type dynamicv_session_db - - if tank.state.container_mode == CONTAINER_MODE.FILL then - session.get_cmd_queue().push_data(DTV_RTU_S_DATA.SET_CONT_MODE, CONTAINER_MODE.BOTH) - end - end - end - - --#endregion - - ------------------------ - -- Update Alarm Tones -- - ------------------------ - - --#region - - local allow_test = self.allow_testing and self.test_tone_set - - local alarms = { false, false, false, false, false, false, false, false, false, false, false, false } - - -- reset tone states before re-evaluting - for i = 1, #self.tone_states do self.tone_states[i] = false end - - if allow_test then - alarms = self.test_alarm_states - else - -- check all alarms for all units - for i = 1, #self.units do - local u = self.units[i] ---@type reactor_unit - for id, alarm in pairs(u.get_alarms()) do - alarms[id] = alarms[id] or (alarm == ALARM_STATE.TRIPPED) - end - end - - if not self.test_tone_reset then - -- clear testing alarms if we aren't using them - for i = 1, #self.test_alarm_states do self.test_alarm_states[i] = false end - end - end - - -- Evaluate Alarms -- - - -- containment breach is worst case CRITICAL alarm, this takes priority - if alarms[ALARM.ContainmentBreach] then - self.tone_states[TONE.T_1800Hz_Int_4Hz] = true - else - -- critical damage is highest priority CRITICAL level alarm - if alarms[ALARM.CriticalDamage] then - self.tone_states[TONE.T_660Hz_Int_125ms] = true - else - -- EMERGENCY level alarms + URGENT over temp - if alarms[ALARM.ReactorDamage] or alarms[ALARM.ReactorOverTemp] or alarms[ALARM.ReactorWasteLeak] then - self.tone_states[TONE.T_544Hz_440Hz_Alt] = true - -- URGENT level turbine trip - elseif alarms[ALARM.TurbineTrip] then - self.tone_states[TONE.T_745Hz_Int_1Hz] = true - -- URGENT level reactor lost - elseif alarms[ALARM.ReactorLost] then - self.tone_states[TONE.T_340Hz_Int_2Hz] = true - -- TIMELY level alarms - elseif alarms[ALARM.ReactorHighTemp] or alarms[ALARM.ReactorHighWaste] or alarms[ALARM.RCSTransient] then - self.tone_states[TONE.T_800Hz_Int] = true - end - end - - -- check RPS transient URGENT level alarm - if alarms[ALARM.RPSTransient] then - self.tone_states[TONE.T_1000Hz_Int] = true - -- disable really painful audio combination - self.tone_states[TONE.T_340Hz_Int_2Hz] = false - end - end - - -- radiation is a big concern, always play this CRITICAL level alarm if active - if alarms[ALARM.ContainmentRadiation] then - self.tone_states[TONE.T_800Hz_1000Hz_Alt] = true - -- we are going to disable the RPS trip alarm audio due to conflict, and if it was enabled - -- then we can re-enable the reactor lost alarm audio since it doesn't painfully combine with this one - if self.tone_states[TONE.T_1000Hz_Int] and alarms[ALARM.ReactorLost] then self.tone_states[TONE.T_340Hz_Int_2Hz] = true end - -- it sounds *really* bad if this is in conjunction with these other tones, so disable them - self.tone_states[TONE.T_745Hz_Int_1Hz] = false - self.tone_states[TONE.T_800Hz_Int] = false - self.tone_states[TONE.T_1000Hz_Int] = false - end - - -- add to tone states if testing is active - if allow_test then - for i = 1, #self.tone_states do - self.tone_states[i] = self.tone_states[i] or self.test_tone_states[i] - end - - self.test_tone_reset = false - else - if not self.test_tone_reset then - -- clear testing tones if we aren't using them - for i = 1, #self.test_tone_states do self.test_tone_states[i] = false end - end - - -- flag that tones were reset - self.test_tone_set = false - self.test_tone_reset = true - end - - --#endregion + -- update alarm tones + f_update.update_alarms() end -- call the update function of all units in the facility
diff --git a/supervisor/facility_update.lua b/supervisor/facility_update.lua new file mode 100644 index 0000000..94255b2 --- /dev/null +++ b/supervisor/facility_update.lua @@ -0,0 +1,831 @@ +local audio = require("scada-common.audio") +local const = require("scada-common.constants") +local log = require("scada-common.log") +local rsio = require("scada-common.rsio") +local types = require("scada-common.types") +local util = require("scada-common.util") + +local qtypes = require("supervisor.session.rtu.qtypes") + +local TONE = audio.TONE + +local ALARM = types.ALARM +local PRIO = types.ALARM_PRIORITY +local ALARM_STATE = types.ALARM_STATE +local CONTAINER_MODE = types.CONTAINER_MODE +local PROCESS = types.PROCESS +local PROCESS_NAMES = types.PROCESS_NAMES +local WASTE_MODE = types.WASTE_MODE +local WASTE = types.WASTE_PRODUCT + +local IO = rsio.IO + +local ALARM_LIMS = const.ALARM_LIMITS + +local DTV_RTU_S_DATA = qtypes.DTV_RTU_S_DATA + +-- 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_rf(7140) + +local FLOW_STABILITY_DELAY_S = const.FLOW_STABILITY_DELAY_MS / 1000 + +local CHARGE_Kp = 0.15 +local CHARGE_Ki = 0.0 +local CHARGE_Kd = 0.6 + +local RATE_Kp = 2.45 +local RATE_Ki = 0.4825 +local RATE_Kd = -1.0 + +local self = nil ---@type _facility_self +local next_mode = 0 +local charge_update = 0 +local rate_update = 0 + +local update = {} + +--#region PRIVATE FUNCTIONS + +-- check if all auto-controlled units completed ramping +---@nodiscard +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].auto_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 +---@param abort_on_fault boolean? true to exit if one device has an effective burn rate different than its limit +---@return integer unallocated_br100, boolean? aborted +local function allocate_burn_rate(burn_rate, ramp, abort_on_fault) + local unallocated = math.floor(burn_rate * 100) + + -- go through all priority groups + for i = 1, #self.prio_defs do + local units = self.prio_defs[i] + + if #units > 0 then + 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 id = 1, #units do + local u = units[id] ---@type reactor_unit + + local ctl = u.get_control_inf() + local lim_br100 = u.auto_get_effective_limit() + + if abort_on_fault and (lim_br100 ~= ctl.lim_br100) then + -- effective limit differs from set limit, unit is degraded + return unallocated, true + end + + local last = ctl.br100 + + if splits[id] <= lim_br100 then + ctl.br100 = splits[id] + else + ctl.br100 = lim_br100 + + if id < #units then + local remaining = #units - id + split = math.floor(unallocated / remaining) + for x = (id + 1), #units do splits[x] = split end + splits[#units] = splits[#units] + (unallocated % remaining) + end + end + + unallocated = math.max(0, unallocated - ctl.br100) + + if last ~= ctl.br100 then u.auto_commit_br100(ramp) end + end + end + end + + return unallocated, false +end + +-- set idle state of all assigned reactors +---@param idle boolean idle state +local function set_idling(idle) + for i = 1, #self.prio_defs do + for _, u in pairs(self.prio_defs[i]) do u.auto_set_idle(idle) end + end +end + +--#endregion + +--#region PUBLIC FUNCTIONS + +-- automatic control pre-update logic +function update.pre_auto() + -- unlink RTU sessions if they are closed + for _, v in pairs(self.rtu_list) do util.filter_table(v, function (u) return u.is_connected() end) end + + -- check if test routines are allowed right now + self.allow_testing = true + for i = 1, #self.units do + local u = self.units[i] ---@type reactor_unit + self.allow_testing = self.allow_testing and u.is_safe_idle() + end + + -- current state for process control + charge_update = 0 + rate_update = 0 + + -- 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 + + local build_update = db.build.last_update + rate_update = db.state.last_update + charge_update = db.tanks.last_update + + local has_data = build_update > 0 and rate_update > 0 and charge_update > 0 + + if matrix.is_faulted() then + -- a fault occured, cannot reliably update stats + has_data = false + self.im_stat_init = false + self.imtx_faulted_times = { build_update, rate_update, charge_update } + elseif not self.im_stat_init then + -- prevent operation with partially invalid data + -- all fields must have updated since the last fault + has_data = self.imtx_faulted_times[1] < build_update and + self.imtx_faulted_times[2] < rate_update and + self.imtx_faulted_times[3] < charge_update + end + + if has_data then + local energy = util.joules_to_fe_rf(db.tanks.energy) + local input = util.joules_to_fe_rf(db.state.last_input) + local output = util.joules_to_fe_rf(db.state.last_output) + + if self.im_stat_init then + self.avg_charge.record(energy, charge_update) + self.avg_inflow.record(input, rate_update) + self.avg_outflow.record(output, rate_update) + + if charge_update ~= self.imtx_last_charge_t then + local delta = (energy - self.imtx_last_charge) / (charge_update - self.imtx_last_charge_t) + + self.imtx_last_charge = energy + self.imtx_last_charge_t = charge_update + + -- if the capacity changed, toss out existing data + if db.build.max_energy ~= self.imtx_last_capacity then + self.imtx_last_capacity = db.build.max_energy + self.avg_net.reset() + else + self.avg_net.record(delta, charge_update) + end + end + else + self.im_stat_init = true + + self.avg_charge.reset(energy) + self.avg_inflow.reset(input) + self.avg_outflow.reset(output) + self.avg_net.reset() + + self.imtx_last_capacity = db.build.max_energy + self.imtx_last_charge = energy + self.imtx_last_charge_t = charge_update + end + else + -- prevent use by control systems + rate_update = 0 + charge_update = 0 + end + else + self.im_stat_init = false + end + + self.all_sys_ok = true + for i = 1, #self.units do + self.all_sys_ok = self.all_sys_ok and not self.units[i].get_control_inf().degraded + end +end + +-- run auto control +---@param ExtChargeIdling boolean ExtChargeIdling config field +function update.auto_control(ExtChargeIdling) + local AUTO_SCRAM = self.types.AUTO_SCRAM + local START_STATUS = self.types.START_STATUS + + local avg_charge = self.avg_charge.compute() + local avg_inflow = self.avg_inflow.compute() + local avg_outflow = self.avg_outflow.compute() + + local now = os.clock() + + local state_changed = self.mode ~= self.last_mode + next_mode = self.mode + + -- once auto control is started, sort the priority sublists by limits + if state_changed then + self.saturated = false + + log.debug(util.c("FAC: state changed from ", PROCESS_NAMES[self.last_mode + 1], " to ", PROCESS_NAMES[self.mode + 1])) + + if (self.last_mode == PROCESS.INACTIVE) or (self.last_mode == PROCESS.GEN_RATE_FAULT_IDLE) then + self.start_fail = START_STATUS.OK + + if (self.mode ~= PROCESS.MATRIX_FAULT_IDLE) and (self.mode ~= PROCESS.SYSTEM_ALARM_IDLE) then + -- auto clear ASCRAM + self.ascram = false + self.ascram_reason = AUTO_SCRAM.NONE + end + + local blade_count = nil + self.max_burn_combined = 0.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_br100 < b.get_control_inf().lim_br100 end + ) + + for _, u in pairs(self.prio_defs[i]) do + local u_blade_count = u.get_control_inf().blade_count + + if blade_count == nil then + blade_count = u_blade_count + elseif (u_blade_count ~= blade_count) and (self.mode == PROCESS.GEN_RATE) then + log.warning("FAC: cannot start GEN_RATE process with inconsistent unit blade counts") + next_mode = PROCESS.INACTIVE + self.start_fail = START_STATUS.BLADE_MISMATCH + end + + if self.start_fail == START_STATUS.OK then u.auto_engage() end + + self.max_burn_combined = self.max_burn_combined + (u.get_control_inf().lim_br100 / 100.0) + end + end + + log.debug(util.c("FAC: computed a max combined burn rate of ", self.max_burn_combined, "mB/t")) + + if blade_count == nil then + -- no units + log.warning("FAC: cannot start process control with 0 units assigned") + next_mode = PROCESS.INACTIVE + self.start_fail = START_STATUS.NO_UNITS + else + self.charge_conversion = blade_count * POWER_PER_BLADE + end + elseif self.mode == PROCESS.INACTIVE then + for i = 1, #self.prio_defs do + -- disable reactors and disengage auto control + for _, u in pairs(self.prio_defs[i]) do + u.disable() + u.auto_set_idle(false) + u.auto_disengage() + end + end + + log.info("FAC: disengaging auto control (now inactive)") + end + + self.initial_ramp = true + self.waiting_on_ramp = false + self.waiting_on_stable = false + else + self.initial_ramp = false + end + + -- update unit ready state + local assign_count = 0 + self.units_ready = true + for i = 1, #self.prio_defs do + for _, u in pairs(self.prio_defs[i]) do + assign_count = assign_count + 1 + self.units_ready = self.units_ready and u.get_control_inf().ready + end + end + + -- perform mode-specific operations + if self.mode == PROCESS.INACTIVE then + if not self.units_ready then + self.status_text = { "NOT READY", "assigned units not ready" } + else + -- clear ASCRAM once ready + self.ascram = false + self.ascram_reason = AUTO_SCRAM.NONE + + if self.start_fail == START_STATUS.NO_UNITS and assign_count == 0 then + self.status_text = { "START FAILED", "no units were assigned" } + elseif self.start_fail == START_STATUS.BLADE_MISMATCH then + self.status_text = { "START FAILED", "turbine blade count mismatch" } + else + self.status_text = { "IDLE", "control disengaged" } + end + end + elseif self.mode == PROCESS.MAX_BURN then + -- run units at their limits + if state_changed then + self.time_start = now + self.saturated = true + + self.status_text = { "MONITORED MODE", "running reactors at limit" } + log.info("FAC: MAX_BURN process mode started") + end + + allocate_burn_rate(self.max_burn_combined, true) + elseif self.mode == PROCESS.BURN_RATE then + -- a total aggregate burn rate + if state_changed then + self.time_start = now + self.status_text = { "BURN RATE MODE", "running" } + log.info("FAC: BURN_RATE process mode started") + end + + local unallocated = allocate_burn_rate(self.burn_target, true) + self.saturated = self.burn_target == self.max_burn_combined or unallocated > 0 + elseif self.mode == PROCESS.CHARGE then + -- target a level of charge + if state_changed then + self.time_start = now + self.last_time = now + self.last_error = 0 + self.accumulator = 0 + + -- enabling idling on all assigned units + set_idling(true) + + self.status_text = { "CHARGE MODE", "running control loop" } + log.info("FAC: CHARGE mode starting PID control") + elseif self.last_update < charge_update then + -- convert to kFE to make constants not microscopic + local error = util.round((self.charge_setpoint - avg_charge) / 1000) / 1000 + + -- stop accumulator when saturated to avoid windup + if not self.saturated then + self.accumulator = self.accumulator + (error * (now - self.last_time)) + end + + -- local runtime = now - self.time_start + local integral = self.accumulator + 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 output = P + I + D + + -- clamp at range -> output clamped (out_c) + local out_c = math.max(0, math.min(output, self.max_burn_combined)) + + self.saturated = output ~= out_c + + if not ExtChargeIdling then + -- stop idling early if the output is zero, we are at or above the setpoint, and are not losing charge + set_idling(not ((out_c == 0) and (error <= 0) and (avg_outflow <= 0))) + end + + -- log.debug(util.sprintf("CHARGE[%f] { CHRG[%f] ERR[%f] INT[%f] => OUT[%f] OUT_C[%f] <= P[%f] I[%f] D[%f] }", + -- runtime, avg_charge, error, integral, output, out_c, P, I, D)) + + allocate_burn_rate(out_c, true) + + self.last_time = now + self.last_error = error + end + + self.last_update = charge_update + elseif self.mode == PROCESS.GEN_RATE then + -- target a rate of generation + if state_changed then + -- estimate an initial output + local output = self.gen_rate_setpoint / self.charge_conversion + + local unallocated = allocate_burn_rate(output, true) + + self.saturated = output >= self.max_burn_combined or unallocated > 0 + self.waiting_on_ramp = true + + self.status_text = { "GENERATION MODE", "starting up" } + log.info(util.c("FAC: GEN_RATE process mode initial ramp started (initial target is ", output, " mB/t)")) + elseif self.waiting_on_ramp then + if all_units_ramped() then + self.waiting_on_ramp = false + self.waiting_on_stable = true + + self.time_start = now + + self.status_text = { "GENERATION MODE", "holding ramped rate" } + log.info("FAC: GEN_RATE process mode initial ramp completed, holding for stablization time") + end + elseif self.waiting_on_stable then + if (now - self.time_start) > FLOW_STABILITY_DELAY_S then + self.waiting_on_stable = false + + self.time_start = now + self.last_time = now + self.last_error = 0 + self.accumulator = 0 + + self.status_text = { "GENERATION MODE", "running control loop" } + log.info("FAC: GEN_RATE process mode initial hold completed, starting PID control") + end + elseif self.last_update < rate_update then + -- convert to MFE (in rounded kFE) to make constants not microscopic + local error = util.round((self.gen_rate_setpoint - avg_inflow) / 1000) / 1000 + + -- stop accumulator when saturated to avoid windup + if not self.saturated then + self.accumulator = self.accumulator + (error * (now - self.last_time)) + end + + -- local runtime = now - self.time_start + local integral = self.accumulator + 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 + + -- velocity (rate) (derivative of charge level => rate) feed forward + local FF = self.gen_rate_setpoint / self.charge_conversion + + local output = P + I + D + FF + + -- clamp at range -> output clamped (sp_c) + local out_c = math.max(0, math.min(output, self.max_burn_combined)) + + self.saturated = output ~= out_c + + -- log.debug(util.sprintf("GEN_RATE[%f] { RATE[%f] ERR[%f] INT[%f] => OUT[%f] OUT_C[%f] <= P[%f] I[%f] D[%f] }", + -- runtime, avg_inflow, error, integral, output, out_c, P, I, D)) + + allocate_burn_rate(out_c, false) + + self.last_time = now + self.last_error = error + end + + self.last_update = rate_update + elseif self.mode == PROCESS.MATRIX_FAULT_IDLE then + -- exceeded charge, wait until condition clears + if self.ascram_reason == AUTO_SCRAM.NONE then + next_mode = self.return_mode + log.info("FAC: exiting matrix fault idle state due to fault resolution") + elseif self.ascram_reason == AUTO_SCRAM.CRIT_ALARM then + next_mode = PROCESS.SYSTEM_ALARM_IDLE + log.info("FAC: exiting matrix fault idle state due to critical unit alarm") + end + elseif self.mode == PROCESS.SYSTEM_ALARM_IDLE then + -- do nothing, wait for user to confirm (stop and reset) + elseif self.mode == PROCESS.GEN_RATE_FAULT_IDLE then + -- system faulted (degraded/not ready) while running generation rate mode + -- mode will need to be fully restarted once everything is OK to re-ramp to feed-forward + if self.units_ready then + log.info("FAC: system ready after faulting out of GEN_RATE process mode, switching back...") + next_mode = PROCESS.GEN_RATE + end + elseif self.mode ~= PROCESS.INACTIVE then + log.error(util.c("FAC: unsupported process mode ", self.mode, ", switching to inactive")) + next_mode = PROCESS.INACTIVE + end +end + +-- update automatic safety logic +function update.auto_safety() + local AUTO_SCRAM = self.types.AUTO_SCRAM + + local astatus = self.ascram_status + + if self.induction[1] ~= nil then + local db = self.induction[1].get_db() ---@type imatrix_session_db + + -- clear matrix disconnected + if astatus.matrix_dc then + astatus.matrix_dc = false + log.info("FAC: induction matrix reconnected, clearing ASCRAM condition") + end + + -- check matrix fill too high + local was_fill = astatus.matrix_fill + astatus.matrix_fill = (db.tanks.energy_fill >= ALARM_LIMS.CHARGE_HIGH) or (astatus.matrix_fill and db.tanks.energy_fill > ALARM_LIMS.CHARGE_RE_ENABLE) + + if was_fill and not astatus.matrix_fill then + log.info(util.c("FAC: charge state of induction matrix entered acceptable range <= ", ALARM_LIMS.CHARGE_RE_ENABLE * 100, "%")) + end + + -- check for critical unit alarms + astatus.crit_alarm = false + for i = 1, #self.units do + local u = self.units[i] ---@type reactor_unit + + if u.has_alarm_min_prio(PRIO.CRITICAL) then + astatus.crit_alarm = true + break + end + end + + -- check for facility radiation + if #self.envd > 0 then + local max_rad = 0 + + for i = 1, #self.envd do + local envd = self.envd[i] ---@type unit_session + local e_db = envd.get_db() ---@type envd_session_db + if e_db.radiation_raw > max_rad then max_rad = e_db.radiation_raw end + end + + astatus.radiation = max_rad >= ALARM_LIMS.FAC_HIGH_RAD + else + -- don't clear, if it is true then we lost it with high radiation, so just keep alarming + -- operator can restart the system or hit the stop/reset button + end + + -- system not ready, will need to restart GEN_RATE mode + -- clears when we enter the fault waiting state + astatus.gen_fault = self.mode == PROCESS.GEN_RATE and not self.units_ready + else + astatus.matrix_dc = true + end + + if (self.mode ~= PROCESS.INACTIVE) and (self.mode ~= PROCESS.SYSTEM_ALARM_IDLE) then + local scram = astatus.matrix_dc or astatus.matrix_fill or astatus.crit_alarm or astatus.gen_fault + + if scram and not self.ascram then + -- SCRAM all units + for i = 1, #self.prio_defs do + for _, u in pairs(self.prio_defs[i]) do + u.auto_scram() + end + end + + if astatus.crit_alarm then + -- highest priority alarm + next_mode = PROCESS.SYSTEM_ALARM_IDLE + self.ascram_reason = AUTO_SCRAM.CRIT_ALARM + self.status_text = { "AUTOMATIC SCRAM", "critical unit alarm tripped" } + + log.info("FAC: automatic SCRAM due to critical unit alarm") + log.warning("FAC: emergency exit of process control due to critical unit alarm") + elseif astatus.radiation then + next_mode = PROCESS.SYSTEM_ALARM_IDLE + self.ascram_reason = AUTO_SCRAM.RADIATION + self.status_text = { "AUTOMATIC SCRAM", "facility radiation high" } + + log.info("FAC: automatic SCRAM due to high facility radiation") + elseif astatus.matrix_dc then + next_mode = PROCESS.MATRIX_FAULT_IDLE + self.ascram_reason = AUTO_SCRAM.MATRIX_DC + self.status_text = { "AUTOMATIC SCRAM", "induction matrix disconnected" } + + if self.mode ~= PROCESS.MATRIX_FAULT_IDLE then self.return_mode = self.mode end + + log.info("FAC: automatic SCRAM due to induction matrix disconnection") + elseif astatus.matrix_fill then + next_mode = PROCESS.MATRIX_FAULT_IDLE + self.ascram_reason = AUTO_SCRAM.MATRIX_FILL + self.status_text = { "AUTOMATIC SCRAM", "induction matrix fill high" } + + if self.mode ~= PROCESS.MATRIX_FAULT_IDLE then self.return_mode = self.mode end + + log.info("FAC: automatic SCRAM due to induction matrix high charge") + elseif astatus.gen_fault then + -- lowest priority alarm + next_mode = PROCESS.GEN_RATE_FAULT_IDLE + self.ascram_reason = AUTO_SCRAM.GEN_FAULT + self.status_text = { "GENERATION MODE IDLE", "paused: system not ready" } + + log.info("FAC: automatic SCRAM due to unit problem while in GEN_RATE mode, will resume once all units are ready") + end + end + + self.ascram = scram + + if not self.ascram then + self.ascram_reason = AUTO_SCRAM.NONE + + -- reset PLC RPS trips if we should + for i = 1, #self.units do + local u = self.units[i] ---@type reactor_unit + u.auto_cond_rps_reset() + end + end + end +end + +-- update last mode and set next mode +function update.post_auto() + self.last_mode = self.mode + self.mode = next_mode +end + +-- update alarm audio control +function update.alarm_audio() + local allow_test = self.allow_testing and self.test_tone_set + + local alarms = { false, false, false, false, false, false, false, false, false, false, false, false } + + -- reset tone states before re-evaluting + for i = 1, #self.tone_states do self.tone_states[i] = false end + + if allow_test then + alarms = self.test_alarm_states + else + -- check all alarms for all units + for i = 1, #self.units do + local u = self.units[i] ---@type reactor_unit + for id, alarm in pairs(u.get_alarms()) do + alarms[id] = alarms[id] or (alarm == ALARM_STATE.TRIPPED) + end + end + + if not self.test_tone_reset then + -- clear testing alarms if we aren't using them + for i = 1, #self.test_alarm_states do self.test_alarm_states[i] = false end + end + end + + -- Evaluate Alarms -- + + -- containment breach is worst case CRITICAL alarm, this takes priority + if alarms[ALARM.ContainmentBreach] then + self.tone_states[TONE.T_1800Hz_Int_4Hz] = true + else + -- critical damage is highest priority CRITICAL level alarm + if alarms[ALARM.CriticalDamage] then + self.tone_states[TONE.T_660Hz_Int_125ms] = true + else + -- EMERGENCY level alarms + URGENT over temp + if alarms[ALARM.ReactorDamage] or alarms[ALARM.ReactorOverTemp] or alarms[ALARM.ReactorWasteLeak] then + self.tone_states[TONE.T_544Hz_440Hz_Alt] = true + -- URGENT level turbine trip + elseif alarms[ALARM.TurbineTrip] then + self.tone_states[TONE.T_745Hz_Int_1Hz] = true + -- URGENT level reactor lost + elseif alarms[ALARM.ReactorLost] then + self.tone_states[TONE.T_340Hz_Int_2Hz] = true + -- TIMELY level alarms + elseif alarms[ALARM.ReactorHighTemp] or alarms[ALARM.ReactorHighWaste] or alarms[ALARM.RCSTransient] then + self.tone_states[TONE.T_800Hz_Int] = true + end + end + + -- check RPS transient URGENT level alarm + if alarms[ALARM.RPSTransient] then + self.tone_states[TONE.T_1000Hz_Int] = true + -- disable really painful audio combination + self.tone_states[TONE.T_340Hz_Int_2Hz] = false + end + end + + -- radiation is a big concern, always play this CRITICAL level alarm if active + if alarms[ALARM.ContainmentRadiation] then + self.tone_states[TONE.T_800Hz_1000Hz_Alt] = true + -- we are going to disable the RPS trip alarm audio due to conflict, and if it was enabled + -- then we can re-enable the reactor lost alarm audio since it doesn't painfully combine with this one + if self.tone_states[TONE.T_1000Hz_Int] and alarms[ALARM.ReactorLost] then self.tone_states[TONE.T_340Hz_Int_2Hz] = true end + -- it sounds *really* bad if this is in conjunction with these other tones, so disable them + self.tone_states[TONE.T_745Hz_Int_1Hz] = false + self.tone_states[TONE.T_800Hz_Int] = false + self.tone_states[TONE.T_1000Hz_Int] = false + end + + -- add to tone states if testing is active + if allow_test then + for i = 1, #self.tone_states do + self.tone_states[i] = self.tone_states[i] or self.test_tone_states[i] + end + + self.test_tone_reset = false + else + if not self.test_tone_reset then + -- clear testing tones if we aren't using them + for i = 1, #self.test_tone_states do self.test_tone_states[i] = false end + end + + -- flag that tones were reset + self.test_tone_set = false + self.test_tone_reset = true + end +end + +-- update facility redstone +---@param ack_all function acknowledge all alarms +function update.redstone(ack_all) + if #self.redstone > 0 then + -- handle facility SCRAM + if self.io_ctl.digital_read(IO.F_SCRAM) then + for i = 1, #self.units do + local u = self.units[i] ---@type reactor_unit + u.cond_scram() + end + end + + -- handle facility ack + if self.io_ctl.digital_read(IO.F_ACK) then ack_all() end + + -- update facility alarm outputs + local has_prio_alarm, has_any_alarm = false, false + for i = 1, #self.units do + local u = self.units[i] ---@type reactor_unit + + if u.has_alarm_min_prio(PRIO.EMERGENCY) then + has_prio_alarm, has_any_alarm = true, true + break + elseif u.has_alarm_min_prio(PRIO.TIMELY) then + has_any_alarm = true + end + end + + self.io_ctl.digital_write(IO.F_ALARM, has_prio_alarm) + self.io_ctl.digital_write(IO.F_ALARM_ANY, has_any_alarm) + + -- update induction matrix related outputs + if self.induction[1] ~= nil then + local db = self.induction[1].get_db() ---@type imatrix_session_db + + self.io_ctl.digital_write(IO.F_MATRIX_LOW, db.tanks.energy_fill < const.RS_THRESHOLDS.IMATRIX_CHARGE_LOW) + self.io_ctl.digital_write(IO.F_MATRIX_HIGH, db.tanks.energy_fill > const.RS_THRESHOLDS.IMATRIX_CHARGE_HIGH) + self.io_ctl.analog_write(IO.F_MATRIX_CHG, db.tanks.energy_fill, 0, 1) + end + end +end + +-- update unit tasks +---@param cooling_conf sv_cooling_conf cooling configuration +function update.unit_mgmt(cooling_conf) + local insufficent_po_rate = false + local need_emcool = false + + for i = 1, #self.units do + local u = self.units[i] ---@type reactor_unit + + -- update auto waste processing + if u.get_control_inf().waste_mode == WASTE_MODE.AUTO then + if (u.get_sna_rate() * 10.0) < u.get_burn_rate() then + insufficent_po_rate = true + end + end + + -- check if unit activated emergency coolant & uses facility tanks + if (cooling_conf.fac_tank_mode > 0) and u.is_emer_cool_tripped() and (cooling_conf.fac_tank_defs[i] == 2) then + need_emcool = true + end + end + + -- update waste product + + self.current_waste_product = self.waste_product + + if (not self.sps_low_power) and (self.waste_product == WASTE.ANTI_MATTER) and (self.induction[1] ~= nil) then + local db = self.induction[1].get_db() ---@type imatrix_session_db + + if db.tanks.energy_fill >= 0.15 then + self.disabled_sps = false + elseif self.disabled_sps or ((db.tanks.last_update > 0) and (db.tanks.energy_fill < 0.1)) then + self.disabled_sps = true + self.current_waste_product = WASTE.POLONIUM + end + else + self.disabled_sps = false + end + + if self.pu_fallback and insufficent_po_rate then + self.current_waste_product = WASTE.PLUTONIUM + end + + -- make sure dynamic tanks are allowing outflow if required + -- set all, rather than trying to determine which is for which (simpler & safer) + -- there should be no need for any to be in fill only mode + if need_emcool then + for i = 1, #self.tanks do + local session = self.tanks[i] ---@type unit_session + local tank = session.get_db() ---@type dynamicv_session_db + + if tank.state.container_mode == CONTAINER_MODE.FILL then + session.get_cmd_queue().push_data(DTV_RTU_S_DATA.SET_CONT_MODE, CONTAINER_MODE.BOTH) + end + end + end +end + +--#endregion + +---@param _self _facility_self +return function (_self) + self = _self + return update +end