Skip to content

Latest commit

 

History

History
460 lines (356 loc) · 20.7 KB

File metadata and controls

460 lines (356 loc) · 20.7 KB

Value semantics: what the API does with what you send it

Measured live-probe at build V 2.2.25.220 on a cold-shutdown plant, sweeping every variable previously marked "range unconfirmed" with in-range, out-of-range, negative, fractional and non-numeric values.

The short version: there is no API-side range validation, and the _ORDERED/_ACTUAL pair does not mean what it looks like.

1. _ORDERED stores raw. Nothing is clamped on the way in

Posted CHEM_BORON_DOSAGE_ORDERED reads back
-1 -1
0 0
100 100
101 101
150 150
1000 1000

Same for STEAM_EJECTOR_STARTUP_MOTIVE_VALVE_ORDERED and ROD_BANK_POS_0_ORDERED, which happily stored 1000.

So "what is the valid range" is the wrong question to ask the API. It will accept and store nonsense. The range is enforced downstream, if at all.

Do not treat a successful _ORDERED read-back as proof the value was sensible. It only proves it was received.

2. Clamping happens at the actuator

ROD_BANK_POS_0_ORDERED was driven to 101, 150 and 1000. ROD_BANK_POS_0_ACTUAL stayed at 100 throughout.

So the physical limit is real, it is just applied by the equipment rather than by the endpoint. _ACTUAL is where the constraint lives.

3. _ACTUAL slews. It is not a read-back

This is the one most likely to produce a wrong conclusion.

_ACTUAL ramps toward _ORDERED at a rate limit rather than jumping. Observed on STEAM_EJECTOR_STARTUP_MOTIVE_VALVE at roughly 10 units per 2 s, with _ORDERED set to -1:

ACTUAL: 100 -> 90 -> 80 -> 70 ...

OPEN CONTRADICTION, flagged 2026-07-28, not resolved: a live POST to STEAM_EJECTOR_STARTUP_MOTIVE_VALVE_ORDERED during a real startup returned HTTP 404, The writable variable 'STEAM_EJECTOR_STARTUP_MOTIVE_VALVE_ORDERED' does not exist, the same variable name this worked example above uses for a successful write. Both cannot be true of the same build. This is recorded as open rather than resolved, on purpose: the 404 has been observed exactly once, and on that occasion the valve still reached its target value, driven by something other than this write, so the failure was not blocking. The worked example above is left in place rather than deleted, flagged instead: deleting it would silently discard the only recorded evidence that the write once worked. See writable-variables.md, which lists the bare name STEAM_EJECTOR_STARTUP_MOTIVE_VALVE without the _ORDERED suffix as the writable one, a second data point for whichever side of this contradiction eventually turns out to be right.

and on the global rod command:

RODS_ALL_POS_ORDERED <- 95    RODS_POS_ACTUAL: 100 -> 96.67
RODS_ALL_POS_ORDERED <- 100   RODS_POS_ACTUAL: 96.67 -> 99.17 -> 100

Consequences for a client:

  • To confirm a write was accepted, read _ORDERED.
  • To learn where the equipment is, read _ACTUAL.
  • Sampling _ACTUAL immediately after a write measures the slew, not the outcome. A probe with a short settle window will report a partial value and, worse, will report a different partial value each run.

This retires a plausible-sounding but wrong rule of thumb: "read back the _ACTUAL twin to confirm the write." That confirms nothing on any rate-limited actuator.

4. null means that fuel position is empty

ROD_BANK_POS_{n}_ACTUAL across all nine banks on a fresh save:

Bank (API) _ORDERED _ACTUAL
0 100 100
1-8 100 null

The reason is the reactor layout, not missing hardware. The core has 9 fuel positions with 8 control rods each. RODS_QUANTITY reads 8, which is rods per position, not a count of banks.

A bank reports a rod position only if its fuel position is loaded. Cross-checked directly:

CORE_BAY_1_STATE = INTERIOR   (fuelled)  ->  ROD_BANK_POS_0_ACTUAL = 100
CORE_BAY_2..9    = VACIO      (empty)    ->  ROD_BANK_POS_1..8     = null

The two families are offset by one. The fuel bay variables are 1-indexed and the rod bank variables are 0-indexed:

CORE_BAY_{n}  <->  ROD_BANK_POS_{n-1}

The in-game panel labels the banks BANK 1 through BANK 9, so UI BANK 1 is API ROD_BANK_POS_0. This is the same off-by-one that applies elsewhere in this API: the UI is 1-indexed, the API is 0-indexed.

Verified against the reactor core panel, which draws each of the 9 bank positions as a hub with 8 petals, and lights only BANK 1 with a digital readout of 100 while BANK 2 through BANK 9 read 000.

Why this matters

null is a distinct read state, alongside a value, an empty string, and the does-not-exist sentence. Treat it as "not applicable", never as zero. A client that coerces it to 0 will read an empty fuel position as a fully withdrawn rod bank, which is the most dangerous possible misreading of that variable.

Note the asymmetry it creates on the write side: a write to bank 3 on an empty bay is indistinguishable from a successful write by every signal the API gives you. HTTP 200, _ORDERED stores and reads back, no error anywhere. Only _ACTUAL being null, or CORE_BAY_4_STATE being VACIO, reveals that the command drove nothing.

CORE_BAY_{n}_STATE is also the read-back twin for the write-only CORE_BAY_{n}_FUEL_LOADING. Observed values: INTERIOR (fuelled), VACIO (empty).

5. Fractional handling is inconsistent between variables

Variable Posted Stored
STEAM_EJECTOR_STARTUP_MOTIVE_VALVE 12.5 12 (truncated)
STEAM_EJECTOR_CONDENSER_RETURN_VALVE 33.7 34
ROD_BANK_POS_0_ORDERED 100.7 100.7 (preserved)

So some variables are integer-backed and some are float-backed, and you cannot tell which from the manifest. If precision matters, verify per variable.

6. Type errors are inconsistently signalled

Posting a non-numeric string to a numeric variable:

Variable Result
CHEM_BORON_DOSAGE_ORDERED_RATE HTTP 500
CHEM_BORON_FILTER_ORDERED_SPEED HTTP 500
STEAM_EJECTOR_*_VALVE HTTP 500
STEAM_TURBINE_2_BYPASS_ORDERED HTTP 500
ROD_BANK_POS_{n}_ORDERED HTTP 200, value silently unchanged

HTTP 500 is therefore a real status in the taxonomy: a type error, and one of the few honest error signals this API produces. But it is not universal, so a client cannot rely on it. Rod banks discard bad input silently with a 200.

Full status taxonomy is in wire-format.md.

7. Variables that accept writes and do nothing

Two found so far, both returning HTTP 200 with no state change under a verified-working harness and a matched null:

  • CHEM_BORON_FILTER_ORDERED_SPEED: _ORDERED never moved off 0 for any value tried.
  • EMERGENCY_BATTERIES_MODE: see emergency-controls.md.

Both may require a precondition not present on the test plant (equipment installed, plant mode, or a running system). Recorded as observations, not as defects.

Method note

The ambiguous cases were resolved with ../tools/probe.py, which measures a multi-sample matched null before each write and requires a drifting variable to exceed its own observed spread before counting as an effect. STEAM_TURBINE_2_BYPASS_ORDERED looked like noise by eye and returned a clean EFFECT verdict with an empty drift set once measured properly.

8. Indexing: API index = physical unit minus one (mostly)

Confirmed across three families at build V 2.2.25.220. Getting this wrong puts a client one unit off from the panel the operator is looking at.

Family API Physical / UI label
CORE_BAY_{n}_* 1-indexed, 1-9 Bay 1-9
ROD_BANK_POS_{n}_* 0-indexed, 0-8 BANK 1-BANK 9
STEAM_TURBINE_{n}_* 0-indexed, 0-2 Turbine 1-3

So STEAM_TURBINE_2_RPM is the third turbine, and ROD_BANK_POS_0_ORDERED is the panel's BANK 1. The fuel bays are the exception that breaks the rule: they are 1-indexed and line up directly.

The same split appears inside a single record in maintenance_summary.attention_items, where the display name is 1-indexed and the object_id beside it is 0-indexed:

{"label": "GENERATOR (GE_Generador03)", "object_id": 2}
{"label": "TURBINE (TG_2)",             "object_id": 2}

GE_Generador03 and object_id: 2 are the same unit. Turbine labels use the raw index, generator labels use the physical number, in the same payload.

Do not infer the convention from a name. Check _INSTALLED, or match against attention_items, or confirm against the in-game panel.

Also remember attention_items lives inside maintenance_summary, which is a stale snapshot, not live telemetry. See diagnostics-endpoint.md, "The staleness trap", before quoting an object_id or a wear figure from it as current.

9. GENERATOR_{n}_KW is trustworthy exactly when GENERATOR_{n}_A is greater than 0

Two things are true about this variable, from two different regimes, and both are measured. The earlier warning that it is "misleading" needs qualifying, not deleting.

At zero amps, GENERATOR_{n}_KW reports a fabricated potential figure. Observed 33702 kW at 15.14 Hz with 0 amps. See ../tools/README.md and tools/checklist.py for the bug this caused: an earlier check used kw > 0 and reported a successful grid sync for a generator delivering nothing.

With amps above 0, GENERATOR_{n}_KW equals GENERATOR_{n}_V times GENERATOR_{n}_A, divided by 1000, exactly. Verified across three consecutive samples on a synced machine, GENERATOR_2_V pinned at 22001.4 V throughout: residuals of -0.88, +1.06 and +0.16 kW against readings of about 26,500 kW, which is rounding.

The rule: trust GENERATOR_{n}_KW if and only if GENERATOR_{n}_A is greater than 0. Both observations are true, they just describe different regimes.

10. GENERATOR_{n}_V exists and is readable

Not previously documented anywhere in this repository. Reads a constant 22001.4 V, at least across the sampling window that established section 9 above. It is the other input, alongside GENERATOR_{n}_A, to the exact relationship in that section.

11. POWER_FROM_TURBINE_KW does not track generation

This is a correction, not an addition. plant-mechanics.md and diagnostics-endpoint.md previously cited a POWER_FROM_TURBINE_KW reading as if it represented the turbine's delivered output at that moment, and tools/checklist.py cited it the same way. That framing is wrong.

Measured: it read 223.2 early in a session, then 238.2, and then held at 238.2 across every subsequent sample for the rest of the session, while generator output swung from 20,949 kW to 26,548 kW and later to roughly 50,000 kW. A change in real output of more than twofold produced no change in this variable.

Be precise about the claim: it is not literally frozen, it did change once, from 223.2 to 238.2. The correct claim is that it does not track generation, not that it never moves.

What this variable actually represents is unknown. It is not delivered power, and it must not be used for any power-fraction calculation, including a P-7 analog. No further speculation beyond that it is unknown.

12. The rated-power constants are resolved: 400 is this plant's rated output

POWER_MAX_THEORETICAL_PLANT_OUTPUT_MW reads 400 and POWER_MAX_THEORETICAL_FINAL_PLANT_OUTPUT_MW reads 1200, both constant across repeated sampling. The ratio is exactly 3.000, and this plant has three secondary loops.

Only one turbine is installed: STEAM_TURBINE_0_INSTALLED and STEAM_TURBINE_1_INSTALLED both read False, STEAM_TURBINE_2_INSTALLED reads True. 1200 divided by 3 is 400, one rating per loop.

400 is this plant's rated output in its installed configuration, and is the correct denominator for a percent-of-rated-power calculation here. 1200 is the full three-loop buildout, not this plant's current rating. This closes the probe unexplored.md previously listed as open.

Two honest limits on that conclusion:

  1. It is not proven that 400 tracks installed equipment. Confirming that would require installing a second turbine and observing 400 become 800. Both readings are equally consistent with 400 being a fixed per-loop constant that does not respond to installation state at all.
  2. These are electrical MW. The real Westinghouse P-7 permissive is 10 percent of rated thermal power, a different quantity this API does not expose. Any gate built on 400 is a proxy, and must be labelled as one, not treated as the real setpoint. See protection-system.md, "The calibration gap".

13. TIME_STAMP is cumulative minutes since game start, not since midnight

Measured, two observations:

  • Early in a session: TIME read 13:24 while TIME_STAMP read 804, and 13 times 60 plus 24 equals 804.
  • Later, after crossing midnight: TIME read 01:27, TIME_STAMP read 1527, and TIME_DAY read 1. 1527 is above 1440, and 1527 mod 1440 is 87, which is 01:27.

Observation 1 alone does not distinguish "minutes since midnight" from "cumulative minutes since game start": both hypotheses predict 804 on day zero. Only crossing midnight discriminates between them, and it does so decisively. See "Test hypotheses where they disagree, not where they agree" in CONTRIBUTING.md for the general form of this trap; the rated-power constants in section 12 above were resolved the same way.

TIME_STAMP is monotonic and does not wrap. It counts in-game minutes since the start of the game.

TIME is the derived wall clock, equal to TIME_STAMP mod 1440, formatted HH:MM.

TIME_DAY is the day counter. It is present in the manifest and was not previously documented here. It read 1 after the first in-game midnight, in step with TIME_STAMP crossing 1440.

This is the only correct clock for any rate limit on plant actions, for two independent reasons. Real wall-clock time is wrong whenever the game is paused or time-accelerated, and a client rate-limiting against time.time() will be wrong by exactly the game's speed multiplier. TIME is additionally wrong for this purpose because it wraps: a rate-limit window measured against TIME can straddle midnight and read as if less time had passed than actually did. TIME_STAMP has neither problem. Being monotonic is what makes it correct here: a counter that never resets cannot make a rate-limit window appear to restart on its own.

14. COOLANT_CORE_VESSEL_TEMPERATURE reads identically to CORE_TEMP

Measured live-probe: COOLANT_CORE_VESSEL_TEMPERATURE read 309.4696 at the same instant CORE_TEMP read 309.4696. Byte identical, one sample.

That single sample does not prove the two names are the same signal under all conditions, only that they agree at this one instant. tools/monitor.py now records both every snapshot and warns if they ever diverge by more than 1.0 (an uncalibrated heuristic, not traceable to any measured spread), so that question can be answered from this run's own data rather than assumed from one sample.

See unexplored.md for why this variable is being carried at all: it is the closest named thing in the manifest to VESSEL INLET TEMPERATURE, the value the human operator actually reads off an in-game gauge to make primary-pump-speed decisions with. No inlet or outlet temperature variable exists anywhere in the manifest, and this is not confirmed to be that missing variable in disguise.

15. The *_STATUS enum: 2 is installed and running, 4 is absent or disabled, for a 30-name subset of the 35 that exist

Undocumented until now. Measured live-probe against the running plant, two independent sources agreeing.

The manifest has 35 names ending _STATUS. Only 30 of them return the numeric enum described below. The other five are not numeric at all, MEASURED:

Variable Value
AO_AGENT_STATUS a JSON object, not numeric
RODS_STATUS empty / unreadable
EMERGENCY_GENERATOR_1_STATUS INACTIVO
EMERGENCY_GENERATOR_2_STATUS INACTIVO
CONDENSER_CIRCULATION_PUMP_OVERLOAD_STATUS False

Everything below this point describes that 30-name numeric subset only, not all 35 *_STATUS names.

Exactly three of the numeric subset read 2: COOLANT_CORE_CIRCULATION_PUMP_2_STATUS, COOLANT_SEC_CIRCULATION_PUMP_2_STATUS and STEAM_GEN_2_STATUS. Those are precisely the components INSTALLED_LOOPS_JSON marks installed on this plant, loop 2 only, matching the _INSTALLED pattern documented in plant-mechanics.md, "Uninstalled equipment reports confident values".

The remaining 27 numeric *_STATUS variables observed reading 4 fall into two groups: the chemical pumps, whose subsystem the operator has disabled, and loops 0 and 1, which were never installed on this plant. Two different reasons for the same reading, both consistent with 4 meaning "not running", not with any single specific cause.

Working reading, scoped to the numeric subset: 2 = installed and running, 4 = absent or disabled. Honestly incomplete in two ways: only these two values were observed, and this covers only the 30 of 35 *_STATUS names that return a number at all. The other 5 names use their own non-numeric conventions entirely, listed above, and the rest of the numeric enum, whatever a fault state or a starting-up state reads as, is unknown.

16. Undocumented readable variables: CORE_WEAR, CORE_FACTOR, CORE_FACTOR_CHANGE, and the CHEM_BORON_* family

All readable, none previously documented anywhere in this repository.

CORE_WEAR read 28.41 rising to 29.01 over the sampling window, a rate of +0.783 per game-hour. If 100 is the wear limit, the same one that applies to every other component in this repository's wear-versus-integrity model (see plant-mechanics.md), that rate implies roughly 3.8 game-days to reach it. That limit is not confirmed for the core specifically. It is an assumption carried over from the general wear model, not a measured ceiling for this variable.

CORE_FACTOR read 3.00 rising to 3.25, +0.325 per game-hour. CORE_FACTOR_CHANGE read 2.85 rising to 3.06, +0.273 per game-hour. Both readable, both moving, and their meaning is unknown. No further speculation beyond that.

The CHEM_BORON_* readable family, CHEM_BORON_PPM, CHEM_BORON_DOSAGE_ACTUAL, CHEM_BORON_DOSAGE_ORDERED, CHEM_BORON_FILTER_ACTUAL and CHEM_BORON_FILTER_ORDERED, all read 0 on this plant. That is not a finding about boron chemistry, it is a finding about this plant's configuration: the operator has the chemicals subsystem disabled. Do not treat a 0 reading here as evidence about how boron behaves when the subsystem is enabled. It is an off switch, not a measurement.

NAME-BASED HYPOTHESIS, no measurement behind it: the two boron writables might command a rate rather than a target. CHEM_BORON_DOSAGE_ORDERED_RATE and CHEM_BORON_FILTER_ORDERED_SPEED (both listed in writable-variables.md, rated confirmed (name only) / range unconfirmed in the "Chemical treatment" table) carry RATE and SPEED in their names, which reads as a rate of change rather than a concentration or a position.

That naming argument is weak, and here is why: it is contradicted seven times over by this same writable surface. COOLANT_CORE_CIRCULATION_PUMP_0_ORDERED_SPEED, COOLANT_CORE_CIRCULATION_PUMP_1_ORDERED_SPEED, COOLANT_CORE_CIRCULATION_PUMP_2_ORDERED_SPEED, COOLANT_SEC_CIRCULATION_PUMP_0_ORDERED_SPEED, COOLANT_SEC_CIRCULATION_PUMP_1_ORDERED_SPEED, COOLANT_SEC_CIRCULATION_PUMP_2_ORDERED_SPEED and CONDENSER_CIRCULATION_PUMP_ORDERED_SPEED are all documented in writable-variables.md as 0-100 int percent, an ordinary level target the actuator slews toward, exactly the pattern section 3 above describes for _ORDERED variables generally. SPEED in a name is therefore already used for a level target seven times over on this API, which undercuts, rather than supports, reading the boron SPEED/RATE pair as something structurally different just because of what the name says.

This cannot be tested on this plant. The chemicals subsystem is disabled here (see above, this section), so no write to either boron variable can be observed producing an effect one way or the other. The one boron write that was tried, CHEM_BORON_FILTER_ORDERED_SPEED, never moved its own _ORDERED off 0 for any value posted (see section 7 above), which is consistent with the subsystem being off and is not evidence for or against the rate-versus-target question either way.

Treat this as an open, unmeasured naming hypothesis, not a documented interface difference, until a plant with the chemistry subsystem enabled can be probed.