Skip to content

CC Tweaked API

denfry edited this page Oct 13, 2026 · 1 revision

CC: Tweaked API

Note

Design stage. This page describes planned mechanics from the design document. Not implemented yet — see Roadmap (milestone M6).

Prompt Critical exposes its plant equipment to CC: Tweaked computers as peripherals whose type names start with pc_. The design principle is "the mod gives you registers, not ready-made controllers": you write the regulators, sequencers and alarm logic yourself in Lua.

CC: Tweaked is an optional dependency. The whole mod.compat.cc package loads only when computercraft is present; without it the plant is run from analog control desks (see SCADA-and-HMI). In the automation ladder this is level A4, a computer on top of field I/O. Relays and analog blocks (A1–A2), PLCs and the safety instrumented system (A3) sit below it; see Automation-Platform.

Three hard rules

  1. The API is field I/O, not a cheat panel. Everything Lua reads goes through a sensor model: lag, noise, accuracy class, scale saturation, failures. True simulation state is never available to Lua. The only exception is computed values from the in-core monitoring system, and those carry their own model error.
  2. Everything Lua writes is a command, not an assignment. A command goes into the SimCommand queue, executes at the next FAST-step boundary and is carried out by an actuator at its real speed. The method returns a ticket; the result arrives as a pc_command_result event.
  3. Hardware protection is outside Lua's reach. Lua can request a scram, acknowledge, and run channel tests. It cannot block an automatic trip of the reactor protection system (RPS) or of a SIS logic solver (sis_logic_solver).

Peripheral types

All types are profiles of one device model (DeviceDescriptor). Every device also carries the additional type pc_device, so peripheral.find("pc_device") finds them all. Devices may report a subtype as an extra type, e.g. pc_valve + pc_valve_control.

Nuclear island and common types

Type Block(s) Connection Notes
pc_reactor reactor_controller Direct In-core monitoring; read-only (plus calibrateNis)
pc_rod_drive control_rod_drive Direct One drive = one rod bank
pc_protection protection_cabinet Hardware cabinet Setpoints and bypasses only physically
pc_pump primary_pump, feedwater_pump Direct
pc_valve valve_gate, valve_control, valve_relief Direct valve_check position only via io_module limit switch
pc_sensor instrument_*, io_module Direct (1 channel) or via io_module io_module: 16 channels, 16-bit ADC
pc_turbine turbine_governor Direct Single control point for the turbine-generator
pc_steam_generator steam_generator, steam_drum Direct Drum: subset of methods
pc_pressurizer pressurizer Direct
pc_rad_monitor instrument_dose_rate, stack_monitor, criticality_alarm, portal monitor Direct Subtypes AREA, PROCESS_LIQUID, PROCESS_GAS, STACK, CAAS, PORTAL
pc_cascade Centrifuge cascade controller Direct
pc_chem_process Chemical process units Direct Also used by petrochemistry and isotopes
pc_fuel_pool Spent fuel pool controller Direct
pc_annunciator annunciator_panel / software alarm server Direct ISA-18.2 alarm server
pc_containment containment_hatch Direct Any hatch is an access point
pc_watchdog watchdog_relay Direct Watchdog timer relay
pc_panel control_desk Direct Desk: keys, buttons, lamps, gauges
pc_activity Dosimeters and radiometers — Generic peripheral

Types added for other industries

Types Owner Devices
pc_plc, pc_sis, pc_rtu, pc_historian, pc_analyzer, pc_fieldbus Automation-Platform PLC, SIS logic solver, telemechanics RTU, historian, analyzers, fieldbus gateways
pc_generator, pc_breaker, pc_transformer, pc_grid, pc_boiler, pc_gas_turbine Energy-Grid Generators, breakers and protection relays, transformers, grid dispatcher, boilers, gas turbines
pc_mine, pc_furnace, pc_rolling_mill, pc_utility Coal-and-Metallurgy Mine automation, blast furnace, converter, furnaces, rolling mill, air separation, water treatment
pc_well, pc_drilling, pc_column, pc_compressor, pc_tank, pc_pipeline Oil-and-Gas Wells, drilling rig, columns, compressors, tanks, pipelines
pc_market Environment-and-Economy Exchange and contract terminal

New blocks introduced for I/O: io_module (field I/O cabinet), signal_cable (up to 64 m from sensor to io_module), instrument_vibration, instrument_acoustic, watchdog_relay, control_desk.

Networking and naming

Blocks connect with the standard wired_modem / wired_modem_full, and network names are the CC defaults (pc_pump_3). Every device also has a player-set plant tag (KKS-like code such as "1RCP-01" or "FW-CV-2"), returned by getDeviceInfo().label; pcui.dev("1RCP-01") finds a device by tag. The mod adds no artificial network latency. Delays come only from I/O scan time and actuator travel.

Call classes and threading

Class Thread Data source Latency Examples
R (read) Computer thread SimSnapshot (immutable, ≤ 1 tick old) 0 read, getState, getPosition
C (command) Computer thread Validated against snapshot, then queued 0–1 tick + actuator travel moveTo, open, setHeaterDemand
W (world) Main thread (mainThread = true) Minecraft world (inventories, NBT) 0–1 tick pc_chem_process.getInventory, pc_fuel_pool.getAssemblyDetails

One readMany call returns values from one snapshot (one tick). Two consecutive calls may land in different ticks; every answer carries t (simulation time, s) so a program can detect this.

Call budget

Each computer has a token bucket shared across all pc_* peripherals: refill cc.token_rate = 400 tokens/s, capacity cc.token_burst = 800. Running out throws E_RATE. There is also a limit of cc.commands_per_device_per_s = 10 commands per actuator.

Cost class Tokens Meaning
R0 1 Scalar read from snapshot
R1 4 Fixed-size table (≤ 32 fields)
RN 4 + 0.25·N Batch read of N tags
R2 32 Core map / rack map (≤ 4096 values), max once per second per device
C 8 Queued command
W 16 Main-thread call

A controller polling 40 tags 10 times per second via readMany uses 140 tokens/s, about a third of the budget. A read() loop without sleep hits the limit in 2 s.

Commands and tickets

Lua:  v.moveTo(1.2)  -> permission, bucket, range check against snapshot
                     -> queue.offer(SetRodTarget(..., ticket))   -> returns ticket
sim:  start of FAST step: drain -> apply -> CommandResult(ticket, ACCEPTED)
Lua:  os.pullEvent("pc_command_result")  ... later (ticket, "DONE", {pos=1.2})
From \ event Accepted Rejected (interlock) Target reached Newer command, same channel cancel(ticket) Actuator failure
QUEUED EXECUTING REJECTED — SUPERSEDED ABORTED —
EXECUTING — — DONE SUPERSEDED ABORTED FAILED

Final states are kept for 600 ticks (cc.ticket_retention_ticks).

Typical response times for controller design:

Mechanism Default response
Command accepted 0–1 tick (0–0.05 s)
Rod drive, normal travel 0.02 m/s (3.5 m full stroke ≈ 175 s)
Rod drop on scram ≤ 4 s full stroke
Motor-operated gate valve 10–60 s full stroke
Control valve 5–20 s
Main steam isolation valve ≤ 5 s
Pump start to rated speed 5–30 s
Pressure transmitter τ ≈ 0.2 s
RTD in a thermowell τ ≈ 4 s

Data types, errors and permissions

All numbers are SI; conversion helpers live in pcui.units (units.MPa(p), units.degC(T), units.pcm(rho)). Enums are upper-case strings. An infinite reactor period is math.huge. Signal quality is "GOOD" | "UNCERTAIN" | "BAD".

Errors are thrown as "<CODE>: <detail>" and caught with pcall:

Code Meaning
E_ACCESS Insufficient rights, or device in LOCAL mode
E_HARDWARE_ONLY Only possible physically (e.g. changing a scram setpoint)
E_RANGE, E_ARG Bad argument value / type
E_RATE Call or command budget exhausted
E_INTERLOCK Blocked by an interlock (e.g. rod withdrawal block on short period)
E_BUSY Incompatible operation in progress
E_FAULT Device failed (drive unpowered, sensor destroyed)
E_UNFORMED Multiblock not formed
E_UNLOADED Device is in an unloaded chunk
E_UNKNOWN_TAG Tag does not exist on this device

A command must pass three barriers:

  1. Device mode LOCAL / REMOTE / MAINTENANCE, switched with the operator_key item. Lua commands require REMOTE.
  2. Access card role. A computer gains write rights when an io_module or control_desk on its network holds an access_card. Roles: OPERATOR, SHIFT_SUPERVISOR, ENGINEER, CHEMIST, HEALTH_PHYSICIST, MAINTENANCE. Pull the card and the program loses write rights at once.
  3. Hardware-only actions that Lua can never do: change scram setpoints, set scram bypasses, change spring relief valve settings, switch device mode.
Action Minimum card role
Read any tag none
Valves, pumps, turbine, pressurizer heaters, rod motion OPERATOR (device REMOTE)
Acknowledge alarms OPERATOR
Request scram (requestTrip) none (any computer on the cabinet network)
Reset scram SHIFT_SUPERVISOR
Scram channel test ENGINEER
Alarm limits, shelving SHIFT_SUPERVISOR
Radiation monitor limits HEALTH_PHYSICIST
Chemical process commands CHEMIST
Fuel moves in the pool ENGINEER

All commands are written to the station EventJournal with computer id, card owner and ticket.

Common interface pc_device

Method Returns Class Cost
getDeviceInfo() {id, label, type, subtype, block, station, pos, firmware} R 1
getControlMode() "LOCAL" | "REMOTE" | "MAINTENANCE" R 1
getAccessLevel() role of the current session or "NONE" R 1
listTags() tags published by the device R 4
getTagInfo(tag) {unit, eu, lo, hi, accuracyClass, tau, kind, description} R 1
read(tag) value, quality, t R 1
readMany(tags) {[tag]={v, q, t}}, N ≤ 64, one snapshot R 4 + 0.25N
watch(tag, lo, hi, deadband [, onDelay [, offDelay]]) watchId → pc_setpoint_crossed events C 8
unwatch(watchId), cancel(ticket) boolean C 1 / 8
getCommandStatus(ticket) {state, err, t} R 1
simTime() simulation time, s R 1
readInputRegisters, readHoldingRegisters, readCoils, readDiscreteInputs Modbus-style reads R 4
writeRegister(addr, value), writeCoil(addr, bool) ticket C 8

Modbus-style address spaces: input registers (0–999 scaled 16-bit, 1000–2999 float32), holding registers (0–99 setpoints, 100–199 modes), discrete inputs, coils.

Methods by type (summary)

Type Key methods
pc_reactor getStatus, getThermalPower, getNeutronPower, getSourceRange, getIntermediateRange, getPeriod, getReactivity, getRodWorth(bank), getPoisons, predictXenon(hours, powerFraction), getDecayHeat, getBoron, getPeaking, getLimits, getCoreMap(quantity), getChannel(i, j), getLayout, calibrateNis
pc_rod_drive getState, getPosition, moveTo(target [, speed]), jog(direction, duration), stop, setSpeedLimit, getInterlocks, dropTest, relatch
pc_protection getState (with firstOut), getSetpoints, getChannelValues(reason), getPermissives, requestTrip(message), acknowledge, reset, startChannelTest, stopChannelTest, getTestResult, getEventLog(n) (sequence of events, 1-tick resolution), getBypasses
pc_pump getState (STOPPED, STARTING, RUNNING, COASTDOWN, TRIPPED, CAVITATING, SEIZED, NO_DRIVE), start, stop, setCoupling(x), getCurve, getVibrationSpectrum, resetTrip
pc_valve getState, open, close, stop, setPosition(x), getCharacteristic, getReliefSettings, openPorv / closePorv, getStrokeLog(n)
pc_sensor getChannels, readChannel(ch), readAll, getRaw(ch) (4–20 mA current), getCalibration(ch), setFilter(ch, tau), getHealth(ch), getScanPeriod
pc_turbine getState, setMode (bumpless), setSpeedTarget, setLoadTarget, setValveDemand, trip, resetTrip, getOverspeed, overspeedTest
pc_steam_generator getState (narrow/wide level), setBlowdown, getTubeBundle, getShrinkSwell
pc_pressurizer getState, setHeaterDemand(group, x), setHeater(group, on), setSprayDemand, openPorv / closePorv / setPorvBlock, getSaturationMargin
pc_rad_monitor getReading, getMonitorType, getAlarmLimits, setAlarmLimits, getNuclides, resetIntegrator, sourceCheck, getHistory(n)
pc_cascade getState, getStage(i), getUnit(i, k), setFeedRate, setCut(i, theta), isolateUnit / restoreUnit, startRamp(targetSpeed, rate), stop
pc_chem_process getState (incl. CRITICALITY_LOCKOUT), getRecipes, setRecipe, start / stop, setFlow, setTemperature, getCriticalitySafety, getInventory
pc_fuel_pool getState, getRack, getAssemblyDetails(id), requestMove(from, to) (rejected if rack k_eff would exceed the limit), setCooling
pc_annunciator getAlarms([filter]), acknowledge, silence, shelve(id, s) (≤ 8 h, not for priority 1) / unshelve, defineAlarm, deleteAlarm, raise / clear, setSuppression, getStatistics, setWindow, lampTest
pc_containment getState (NORMAL, PHASE_A, PHASE_B), getHatches, getPenetrations, isolate(phase), resetIsolation, setSpray, setHatchInterlock, startLeakTest
pc_watchdog kick, configure(timeout, owner) (0.5–60 s), arm / disarm, getState, reset, takeover, isOwner
pc_panel getElements, setLamp(slot, mode [, color]), setGauge, setDisplay (8 chars), getInput(slot)

Some state is deliberately hidden. For example, pc_steam_generator.getTubeBundle() does not report tube leaks: you find them through N-16 on the steam line, blowdown activity and the leak rate.

Events

Format: event, side, payload = os.pullEvent("pc_xxx"). Every payload has t (simulation time) and seq (per-device sequence number, to detect gaps). Flood protection: at most cc.events_per_device_per_s = 20 events per device per computer, "last value wins" events are coalesced within a tick, and dropped events are reported by pc_event_overflow (then re-read state with getState).

Event Source Payload
pc_command_result any {ticket, state, err, data}
pc_alarm, pc_alarm_clear, pc_alarm_ack pc_annunciator {id, tag, priority, message, value, limit, state}
pc_alarm_flood pc_annunciator {active, rate10min} (enter > 10 / 10 min, exit < 5 / 10 min)
pc_trip, pc_trip_clear pc_protection {reason, channel="A"|"B"|"C", value, setpoint} (one channel)
pc_scram pc_protection, pc_reactor, pc_rod_drive {reasons, firstOut, source, t}
pc_scram_reset, pc_bypass, pc_protection_test pc_protection
pc_rod_position, pc_rod_limit, pc_rod_interlock pc_rod_drive {bank, position, state} etc.
pc_setpoint_crossed any (watch) {watchId, tag, direction, threshold, value}
pc_valve_stroke_complete, pc_valve_fault, pc_relief_lift pc_valve, pc_pressurizer
pc_pump_state, pc_pump_cavitation pc_pump {state, prev, speed}, {npshMargin, intensity}
pc_turbine_trip, pc_turbine_overspeed pc_turbine {reason}, {speed, setpoint}
pc_cascade_fault pc_cascade {stage, unit, kind}
pc_rad_alarm pc_rad_monitor {level, value, limit, monitorType}
pc_criticality_alarm pc_rad_monitor (CAAS) {zone, peakDoseRate, t}; never coalesced, delayed or suppressed
pc_sensor_fault pc_sensor {ch, tag, fault} (detectable faults only)
pc_containment_state, pc_hatch_state pc_containment
pc_chem_state, pc_fuel_move pc_chem_process, pc_fuel_pool
pc_watchdog_timeout pc_watchdog {owner, sinceKick}
pc_panel_input pc_panel {slot, kind, value, by}
pc_event_overflow, pc_device_detached any

Default alarm/watch filtering (ISA-18.2 / EEMUA 191): flow 5 % deadband, 15 s on/off delay; level 5 %, 60 s; pressure 2 %, 15 s; temperature 1 %, 60 s; dose rate 10 %, 5 s on / 30 s off; neutron power and period 1 %, 0 s on / 5 s off.

Scram reasons (TripReason)

MANUAL, HIGH_FLUX, SHORT_PERIOD, HIGH_PRIMARY_PRESSURE, LOW_PRIMARY_PRESSURE, LOW_COOLANT_FLOW, HIGH_COOLANT_TEMP, LOW_DNBR (computed), LOW_SG_LEVEL, HIGH_CONTAINMENT_PRESSURE, LOSS_OF_DRIVE_POWER (loss of Create kinetics on rod drives or main coolant pumps), TURBINE_TRIP, SEISMIC (explosion or falling blocks nearby), LUA_REQUEST.

Independently of the reason, the trip source is recorded as TripSource: AUTOMATIC, OPERATOR_PANEL, LUA, WATCHDOG, SERVER_POLICY, ADMIN_COMMAND. An administrative server shutdown is MANUAL + SERVER_POLICY. Non-nuclear industries use SisCause (a row of the SIS cause and effect matrix) in place of TripReason.

Protection permissives: PERM_LOW_POWER (blocks single-loop LOW_COOLANT_FLOW and TURBINE_TRIP), PERM_SR_BLOCK (source-range detectors may be switched off), PERM_TURBINE_LOW_LOAD (turbine trip does not scram the reactor).

Signal tags

Format <system>.<component>.<quantity>, lower case, dots as separators. Instance indices are suffixes without a separator (loop1, sg2, bank4). Zone and unit labels are player-chosen [a-z0-9_]{1,16} (rad.area.cr01.dose_rate, chem.sx1.temp). Endings like .position, .flow, .level, .pressure, .temp, .speed, .dose_rate fix the physical type and unit. Each tag has an SI unit, scale range, accuracy class and sensor time constant.

The catalog has 123 tag templates in these groups:

Group Examples
Core and neutron monitoring core.power.thermal, core.power.neutron, core.flux.source, core.period, core.reactivity.total, core.reactivity.xenon, core.decay_heat, core.dnbr.min, core.axial_offset
Control rods rods.bank<n>.position, rods.bank<n>.bottom, rods.all_in, prot.tripped
Primary circuit rcs.pressurizer.pressure, rcs.pressurizer.level, rcs.loop<n>.flow, rcs.loop<n>.temp.hot, rcs.subcooling.margin, rcs.pump<n>.npsh.margin, rcs.leak.rate
Steam generators, secondary sg<n>.level.narrow, sg<n>.level.wide, sg<n>.blowdown.activity, rad.steamline<n>.n16, msl.header.pressure
Turbine, condenser, feedwater turbine.speed, turbine.power.el, turbine.eccentricity, cond.pressure, fw.flow.total
Containment and ECCS cnt.pressure, cnt.h2.fraction, cnt.sump.level, eccs.acc<n>.level, eccs.injection.flow
Radiation, fuel pool rad.area.<zone>.dose_rate, rad.stack.release, rad.caas.<zone>.alarm, pool<n>.temp
Enrichment and chemistry cascade<n>.product.assay, cascade<n>.vib.max, chem.<unit>.acidity, chem.<unit>.keff
Vibration and acoustics <equip>.vib.*, <equip>.bearing.*, <equip>.acoustic.* (see Acoustics)

Sensor model

Every connected channel runs this chain in the simulation (sim.instrument.SensorChannel):

x_true -> [first-order lag tau] -> [bias b + drift d + noise n] -> [NE43 saturation]
       -> [4-20 mA] -> [16-bit ADC] -> [I/O filter tau_f] -> (y, quality)
  • Lag: exact discretization α = 1 − exp(−Δt/τ), Δt = 0.05 s.
  • Bias and noise: bias drawn uniformly within ±0.5 of the accuracy class at installation/calibration; noise is coloured AR(1) with σ = 0.1 × class and 0.5 s correlation time, so needles don't jitter unrealistically.
  • Drift: a random walk tuned so the sensor drifts out of class by the end of the calibration interval (instruments.calibration_interval_days = 30 physics days). Calibration resets drift.
  • Counting channels: dead time. Source-range fission chambers are non-paralyzable (0.5 µs). Geiger-Müller tubes are paralyzable (190 µs) and fold over at high dose rates: at a true 10 mSv/h a GM channel reads about 41 µSv/h.
  • Failures: Poisson rate instruments.base_failure_rate_per_day = 0.002 plus Weibull radiation damage (D63 = 300 Gy commercial, 10 000 Gy rad-hard), × 50 in harsh (LOCA) conditions unless environmentally qualified.
Failure mode Share What Lua sees Self-detected
OPEN 0.35 current < 3.6 mA → BAD, pc_sensor_fault yes
SHORT 0.10 current > 21 mA → BAD yes
STUCK 0.25 last value, GOOD no: compare with redundant channels
DRIFT_STEP 0.20 shifted value, GOOD no: calibration or comparison
NOISY 0.10 noisy value, GOOD or UNCERTAIN partly

Quality flags: OVER_RANGE, UNDER_RANGE, CAL_DUE, RAD_DEGRADED, MAINTENANCE, NOISY_SUSPECT → UNCERTAIN; OPEN, SHORT, COMM_LOSS, SATURATED → BAD.

Lua examples

The examples ship in ROM as /rom/programs/pc/examples/*.lua. Gains are illustrative for the reference T2a unit; tuning them is the gameplay. Other examples in the design: three-element steam generator level control, pressurizer heaters and spray, an automatic reactor start-up sequencer, a prioritized alarm list with channel-disagreement alarms, and a centrifuge cascade controller.

Reactor power PID with anti-windup and reactivity rate limit

A cascade: the outer PI loop works on ln(P_sp/P) and outputs an inverse period, clamped to [−1/80, +1/60] s⁻¹. The one-group inhour relation converts that to demanded reactivity. The inner loop limits the insertion rate to 5 pcm/s and turns it into rod speed via differential rod worth.

-- power_pid.lua  —  usage: power_pid <rod_drive_name> <P_setpoint_fraction>
local args = { ... }
local reactor = peripheral.find("pc_reactor")
local rods = peripheral.wrap(args[1] or "pc_rod_drive_0")
if not reactor or not rods then error("need pc_reactor and pc_rod_drive", 0) end

local BETA, LAMBDA = 0.0065, 0.08   -- beta_eff, lambda_eff (1 group)
local P_SP   = tonumber(args[2]) or 0.80
local DT     = 0.25                 -- controller period, s (5 ticks)
local KP, KI = 0.010, 0.0005        -- [1/s per ln-unit], [1/s^2 per ln-unit]
local W_MAX, W_MIN = 1/60, -1/80    -- inverse period limits, 1/s
local RHO_RATE_MAX = 5e-5           -- 5 pcm/s
local TI_INNER = 2.0                -- s
local P_MIN = 1e-4                  -- below: source/intermediate range, PID off

local integ, tripped = 0, false

local function clamp(x, lo, hi)
  if x < lo then return lo elseif x > hi then return hi end
  return x
end

local function step()
  if tripped then return "TRIPPED" end
  local P, qP = reactor.getNeutronPower()
  local rho = reactor.getReactivity().total
  local st = rods.getState()
  if qP == "BAD" or P < P_MIN then return "LOW_OR_BAD_SIGNAL" end
  if st.state == "FAULT" or not st.drivePower or not st.latched then return "ROD_NOT_READY" end

  local e = math.log(P_SP / P)
  local iTent = integ + e * DT
  local wUnsat = KP * e + KI * iTent
  local w = clamp(wUnsat, W_MIN, W_MAX)
  -- anti-windup: conditional integration
  if wUnsat == w or (wUnsat > W_MAX and e < 0) or (wUnsat < W_MIN and e > 0) then
    integ = iTent
  end

  local rhoDemand = BETA * w / (w + LAMBDA)
  local rhoRate = clamp((rhoDemand - rho) / TI_INNER, -RHO_RATE_MAX, RHO_RATE_MAX)
  local worth = math.max(reactor.getRodWorth(st.bank).differential, 1e-4)
  local v = clamp(rhoRate / worth, -st.maxSpeed, st.maxSpeed)
  local target = clamp(st.position + v * DT * 2, 0, st.travel)
  if math.abs(target - st.position) > 0.002 then
    local ok, err = pcall(rods.moveTo, target, math.max(math.abs(v), 1e-4))
    if not ok then return tostring(err) end
  end
  return string.format("P=%.4f e=%+.4f T=%s rho=%+.1fpcm z=%.3f",
    P, e, w ~= 0 and string.format("%.0fs", 1 / w) or "inf", rho * 1e5, st.position)
end

local timer = os.startTimer(DT)
while true do
  local ev, a, b = os.pullEvent()
  if ev == "timer" and a == timer then
    local status = step()
    term.clearLine(); local _, y = term.getCursorPos(); term.setCursorPos(1, y)
    term.write(status)
    timer = os.startTimer(DT)
  elseif ev == "pc_scram" then
    tripped, integ = true, 0
    print("\nSCRAM: " .. tostring(b.firstOut) .. " - controller halted")
  elseif ev == "pc_scram_reset" then
    tripped = false
  elseif ev == "key" and a == keys.q then
    pcall(rods.stop); break
  end
end

Acceptance test: on a setpoint step 0.5 → 0.8 the period stays above 60 s, the reactivity rate never exceeds 5 pcm/s, overshoot is < 3 %, and the controller leaves saturation cleanly.

Trend logger to file

Writes CSV with deadband thinning (a simple form of historian compression), at least once every 60 s per tag, and rotates files by size.

-- trendlog.lua
local TAGS = {
  { tag = "core.power.neutron", db = 0.002 },
  { tag = "rcs.pressurizer.pressure", db = 0.01e6 },
  { tag = "rcs.pressurizer.level", db = 0.005 },
  { tag = "sg1.level.narrow", db = 0.005 },
  { tag = "turbine.speed", db = 0.5 },
}
local MAX_FILE, KEEP_FREE, HEARTBEAT = 64 * 1024, 32 * 1024, 60
local dir = "/logs"
if not fs.exists(dir) then fs.makeDir(dir) end

-- route each tag to the first device that publishes it
local devices = { peripheral.find("pc_device") }
local route = {}
for _, d in ipairs(devices) do
  local ok, tags = pcall(d.listTags)
  if ok then for _, t in ipairs(tags) do route[t] = route[t] or d end end
end
for _, t in ipairs(TAGS) do
  assert(route[t.tag], "no device publishes " .. t.tag)
  t.last, t.lastT = nil, -math.huge
end

local fileIdx, fh, curName = 0, nil, nil
local function openNext()
  if fh then fh.close() end
  local names = fs.list(dir)
  table.sort(names)
  while fs.getFreeSpace(dir) < KEEP_FREE and #names > 0 do
    fs.delete(fs.combine(dir, table.remove(names, 1)))
  end
  fileIdx = fileIdx + 1
  curName = fs.combine(dir, ("trend_%06d.csv"):format(fileIdx + os.day() * 1000))
  fh = fs.open(curName, "w")
  fh.writeLine("t,tag,value,quality")
end
openNext()

while true do
  -- group reads per device: one snapshot per device per cycle
  local byDev = {}
  for _, t in ipairs(TAGS) do
    local d = route[t.tag]
    byDev[d] = byDev[d] or {}
    table.insert(byDev[d], t.tag)
  end
  local values = {}
  for d, list in pairs(byDev) do
    local ok, r = pcall(d.readMany, list)
    if ok then for k, v in pairs(r) do values[k] = v end end
  end
  for _, t in ipairs(TAGS) do
    local r = values[t.tag]
    if r and (t.last == nil or math.abs(r.v - t.last) > t.db or r.t - t.lastT >= HEARTBEAT) then
      fh.writeLine(("%.2f,%s,%.6g,%s"):format(r.t, t.tag, r.v, r.q))
      t.last, t.lastT = r.v, r.t
    end
  end
  fh.flush()
  if fs.getSize(curName) > MAX_FILE then
    openNext()
  end
  sleep(1)
end

Cost: 1–2 readMany calls of 5 tags ≈ 10.5 tokens/s, 2.6 % of the computer's budget.

Java side

Classes Module / package
TagId, TagRegistry, TagDescriptor, DeviceDescriptor pc-sim / sim.instrument
SensorChannel, SensorModel, CountingChannel, SensorFault, Quality pc-sim / sim.instrument
Actuator, RateLimitedActuator, ValveCharacteristic pc-sim / sim.instrument
SimCommand, CommandTicket, CommandState pc-sim / sim.core
AlarmEngine pc-sim / sim.alarm
PcPeripheral and subclasses, CcEventPump, AccessControl, TokenBucket, LuaConvert pc-mod / mod.compat.cc

DeviceDescriptor is the single source of truth for a device type: peripheral type, additional types, tag templates, register bindings and method specs (call class, cost, minimum role, required mode). Documentation and Modbus maps are generated from it.

See also

Clone this wiki locally