Repository navigation
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.
- 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.
-
Everything Lua writes is a command, not an assignment. A command goes into the
SimCommandqueue, executes at the next FAST-step boundary and is carried out by an actuator at its real speed. The method returns aticket; the result arrives as apc_command_resultevent. -
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).
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.
| 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 | 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.
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.
| 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.
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.
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 |
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:
-
Device mode
LOCAL/REMOTE/MAINTENANCE, switched with theoperator_keyitem. Lua commands requireREMOTE. -
Access card role. A computer gains write rights when an
io_moduleorcontrol_deskon its network holds anaccess_card. Roles:OPERATOR,SHIFT_SUPERVISOR,ENGINEER,CHEMIST,HEALTH_PHYSICIST,MAINTENANCE. Pull the card and the program loses write rights at once. - 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.
| 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.
| 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.
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.
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).
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) |
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 = 30physics 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.002plus 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.
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.
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
endAcceptance 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.
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)
endCost: 1–2 readMany calls of 5 tags ≈ 10.5 tokens/s, 2.6 % of the computer's budget.
| 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.
-
SCADA-and-HMI —
pcuilibrary, alarm handling, control desks - Automation-Platform — relays, PLCs, SIS, fieldbuses
- Create-Integration — rod drives, pumps, turbine mechanics
- Reactor-Physics — what the tags measure
- Acoustics — vibration and acoustic tags
- Multiplayer — station keys, roles, event journal
Prompt Critical · © 2026 denfry · Repository · Report an issue
Start here
Design
Nuclear
Industries
Control and automation
World and server
Development