Introduction
What the Tennessee Eastman Process is
The Tennessee Eastman Process is the chemical engineering profession's standard open benchmark for process control and for fault detection. Downs and Vogel published it in 1993 as a challenge problem: a model of an industrial chemical plant whose components are identified only by the letters A to H, distributed as Fortran so that competing control schemes and competing detectors could be compared on identical dynamics rather than on identical prose descriptions. Thirty years of published results rest on that code.
The plant makes two liquid products, G and H, from four gaseous feeds, A, C, D
and E, with an inert B and a liquid byproduct F. Four irreversible, exothermic
gas-phase reactions run in the reactor's vapour space (teprob.f:503-528):
1: A + C + D -> G 2: A + C + E -> H
3: A + E -> F 4: 3 D -> 2 F
B takes part in none of them. It arrives with the mixed feed and leaves only
through the purge, which is the entire reason the plant has a purge. Around
those reactions sit five unit operations, described one page each later in this
book: a feed mixing zone, the reactor with its cooling coil and agitator, a
condenser and vapour-liquid separator, a compressor with a recycle loop, and a
steam stripper. The whole plant is fifty integrated states (teprob.f:24-26),
forty-one measurements, twelve manipulated variables, and twenty programmed
disturbances (teprob.f:340, which loops DO 500 I=1,20).
Those five units, the four feeds and the thirteen streams between them are the
whole plant, and it is worth seeing them laid out once before reading anything
else. The diagram below was drawn from teprob.f rather than from the paper's
figure, so every stream carries both of the numbers it goes by: the FTM index
the source uses, and, where one exists, the stream number Downs and Vogel print.
Those two disagree on three of the four feeds, which is the most common way to
wire up a reimplementation incorrectly.
teprob.f wires it. Solid lines are the thirteen internal streams, each labelled with its Fortran FTM index and, where one exists, the stream number of the paper. Dashed lines are utilities: cooling water through the reactor coil and the condenser, and steam to the stripper reboiler. Beside each vessel are the XMEAS instruments and XMV valves that sit on it, and the three AT bubbles mark the streams the composition analysers sample.Two properties make it a good benchmark and a hard one. It is open-loop
unstable, so a run with the valves held still does not merely drift, it trips:
this port measures that trip at 3.060 simulated hours on reactor pressure (LOG
entry B-0041). And its composition analysers report their previous sample
rather than the current one (teprob.f:711-761), so the measurements a
controller sees carry real dead time.
What this port is
tepsim is a pure-Rust reimplementation of that Fortran, ported from the
original source rather than from any later reimplementation. The reference
material is the Braatz group release, vendored unmodified under reference/:
teprob.f, 1594 lines holding the plant model and eight utility routines, and
temain_mod.f, 1413 lines holding the Euler driver and the decentralised PI
control suite.
Two design commitments shape everything. The first is that the port is checked
against the Fortran continuously rather than at the end: a development-only
crate, tepsim-oracle, compiles the unmodified Fortran with gfortran and links
it into the test binary, so a Rust test can force both implementations into the
same state and compare them in the same process. The second is determinism. The
core crate is no_std, forbids unsafe, uses no f32, no SIMD, no clock and
no randomness outside the model's own generator, and answers exp, pow and
ln from a vendored pure-Rust libm, so that the same scenario produces the
same bits on x86-64, on aarch64 and in a browser.
Every place the port knowingly departs from the original is written down, with its class, its measured effect and the test that measured it, in the quirk and delta register.
The headline result
A complete 48-hour closed-loop run, 172,800 integrator steps at a one-second
step, is bit-identical to the Fortran in all 41 measurements and all 12
manipulated variables, at every one of those steps, when both are given the
same exp and pow.
That measurement is from the LOG.org entry for B-0041, "Phase 4 acceptance:
the driver's full 48-hour run", with gfortran 15.2.0:
libm | worst XMEAS | worst XMV (fraction of range) | within XNS |
|---|---|---|---|
| platform | 0 | 0 | 48.000 h |
| vendored | 1.705e-10 at XMEAS(1) | 1.246e-11 at XMV(10) | 48.000 h |
The two rows are the two halves of one claim. Under libm-system, where the
transcendental functions are the ones gfortran itself calls, the port and the
original agree exactly: not to a tolerance, to the bit. Under the vendored
libm that actually ships, the only difference is transcendental rounding, and
after two simulated days it has not reached a tenth of a billionth of any
instrument's noise standard deviation.
The size of that transcendental difference is measured too. The first
transcendental call in the model is the Antoine vapour pressure at
teprob.f:485 and teprob.f:488; over the whole range of arguments this model
reaches, the vendored libm and gfortran's disagree on 9.945% of them, by
exactly one ULP each time (LOG entry B-0018). Everything downstream of a
condensible partial pressure therefore carries about 1.1e-16 of relative
difference that no care in the algebra removes. Rather than accept a tolerance
and lose the sharper claim, every differential test runs twice, once against
each libm, and the second run is held to zero ULP.
Since B-0052 that claim attaches to the public API and not only to the test
harness: tepsim::Simulation reproduces the validated loop bit for bit over the
nominal scenario and IDV(1), IDV(6), IDV(13) and IDV(20), on all 60
samples and all 53 channels, in both libm configurations.
Run it in the browser first
TEP Studio is this simulator compiled to WebAssembly, running entirely in your browser with no server and nothing to install. Start the plant, switch a disturbance on, and watch the controllers fight it. Every scenario is shareable as a URL, because the whole scenario is in the link.
It is the same code the rest of this book documents, not a demonstration model:
the page prints a determinism digest that matches the one the native build
computes, and apps/studio/node/deployed.test.mjs asserts that on the module
that actually ships.
How to read this book
Getting started is the working Rust API and the command
line tool, and is the fastest way to a CSV of plant data.
The Python package is the same ground for the tepsim wheel, with
installation, a quickstart and the API reference, and the four
tutorials are the narrative around the executed
notebooks in notebooks/. The plant
and the right-hand side explain how the fifty states, the
thirteen internal streams and the six hundred lines of TEFUNC are organised
here. The five unit-operation pages carry the governing equations, the variable
tables and the teprob.f line ranges they were derived from.
Validation is the ten-tier ladder and the numbers actually
measured at each rung. Status says what is finished and what is
not, which for a port in progress is the page to read second.
Every claim about the model in this book cites the teprob.f or temain_mod.f
line range it came from, so a sceptical reader can check it against the
vendored source. Every validation number cites the LOG.org iteration that
measured it.
Status
Everything planned is built. Every phase has landed, every validation tier has a harness, and the four decisions that were once open have been taken. The backlog is 85 items done, none open and none blocked.
That makes this page shorter than it used to be, and its job is now to say
where the limits are rather than where the edge of construction is. Numbers
here come from the current-state block of BACKLOG.org and the closing entries
of LOG.org, and each names the iteration that measured it.
What exists
The plant model, the control layer, the public API, the command line, the
Python wheel and the browser app are all complete and validated.
tepsim-core ports the whole of TEFUNC (teprob.f:196-816);
tepsim-control ports the twenty control loops and the driver's own
scheduling; tepsim is the API most callers want; tep drives it from a
terminal, including tep dataset for generating d00-d21 shaped files; the
wheel carries no C dependency; and TEP Studio runs the whole
simulator in a browser tab with no server.
The differential harness is tepsim-oracle, which compiles and links the
unmodified Fortran, and tepsim-stats, which implements every statistic the
ladder needs in Rust with known-answer tests, so no part of the validation
depends on numpy or scipy.
All ten tiers have a harness and all ten have run. Tier 8, differential
fuzzing with shrinking, and Tier 9, cross-platform determinism, were the last
two and landed together. The validation chapters are
generated from the suite's own output for the tiers that have a generator; the
narrative in Validation is hand-written and transcribed from
LOG.org, and each number there names the iteration that measured it so the
transcription can be checked.
What is genuinely still missing
These are limits, not unfinished work, and most of them are limits of the machine this was built on rather than of the code.
Tier 9 has no x86-64 leg and no real-browser leg. Six committed digests are
identical on aarch64 and on wasm32 under Node, across three build profiles, and
the browser app's transport path reproduces them independently. Nobody has run
them on x86-64, Windows or aarch64 Linux, and Node is not a browser. The table
is committed constants rather than a value computed twice in one process,
precisely so that running cargo xtask tier9 on another machine completes the
claim with no code change.
Tier 8 has one open counterexample. In five million tuples, fuzz#863105
misses the 1e-12 gate at 4.607e-12 of the scale of its terms. It is recorded
and attributed rather than fixed, because there is nothing to fix: it is an
accepted one-ULP exp difference amplified by 973 simulated hours of
IDV(13)'s kinetic drift, and it is bit-identical under libm-system.
Two IDV faults miss the Tier 5 margin under the vendored libm. IDV(14)
and IDV(19), on exactly the valves those faults stick, and bit-identical
under the platform libm. The cause is teprob.f:801, a discontinuous branch on
a floating-point comparison. Those valves are judged on their distribution
rather than on a mean, because a series of plateaux has no meaningful centre.
No margin was widened.
Apache Arrow and Parquet sinks are not implemented. The recorder sinks that
exist are Columnar, Csv, Ring, Decimating and Selecting, and
Simulation::run_into streams into one rather than collecting. Arrow's
dependency cost was judged not to be worth paying until someone has a dataset
large enough that CSV is the bottleneck; the right home would be tepsim-cli,
never tepsim, which is no_std and compiles to wasm32 under a size budget.
A historian export does not exist. Everything is wide tabular. A long-format
tag,timestamp,value,quality record would need a caller-supplied epoch, since
the core may not read a clock, a decision about what quality code a dead-time
analyser and a frozen channel deserve, and units promoted from prose in
measurements.rs to data on a channel.
The step size is not adapted. Three integrators exist as of B-0053: fixed step explicit Euler, classical RK4, and Dormand-Prince 5(4) with an embedded error estimate. Only Euler reproduces the original, and it is the default. A variable step changes when the discrete phases run, which is a decision about fidelity rather than about numerics, so it has not been taken.
That comparison produced a result about the original rather than about the port. RK4 and Dormand-Prince agree with each other to 1.5e-6 while both differ from Euler by about 1.1e-2, so the published Tennessee Eastman data carries roughly one percent of integration error against an accurate solution of the same equations. Reproducing that is the point, but it means "the TEP" names a particular discretisation and not only a set of differential equations.
The two Class C quirks, and what a default Scenario now does
Both were signed off on 2026-08-28, so a default Scenario fixes them and
Scenario::faithful() reproduces them.
A trip ends the run (delta D-007, teprob.f:807-811). The original freezes the
plant and keeps reporting, which is where four of the forty-four published files
get their frozen tails: 1,832 rows in total, and 363 of d06.dat's 480. The fix
is pure truncation, since every sample before the trip is bit-identical either
way, and the argument for keeping the freeze was that it preserved an option it
does not preserve, because the plant cannot be restarted in either case.
The driver does not force IDV(12) at hour eight (delta D-011,
temain_mod.f:366-368). Tier 7 established that the published files were
generated with that line replaced rather than kept: every dNN_te except
d12_te sits at the nominal operating point straight across row 160.
Every comparison against the Fortran or against published data runs
Scenario::faithful(), and tier5::run_port pins it so no differential can
lose it. From the command line the flags are tep run --faithful, or
--force-idv12 and --freeze-on-trip individually.
Getting started
This page is the Rust API and the command line tool. If you want to drive the
simulator from Python, The Python package is the equivalent page
for the tepsim wheel, and the tutorials and the
notebooks they link to are all Python.
Building
The workspace pins its toolchain in rust-toolchain.toml, so a rustup
install picks the right compiler on its own. Nothing in the shipping crates
needs Fortran; gfortran is required only to build tepsim-oracle, the
development-only differential harness, which sits behind the oracle feature
and is never a dependency of tepsim.
$ cargo build --release
$ cargo xtask ci # fmt, clippy, tests, docs, cargo-deny
The library
The whole public surface is three types. A Scenario describes a run, a
Simulation performs one, and a Run holds the output as columns.
use tepsim::{Scenario, Simulation};
// Twenty-four hours with disturbance IDV(4), the reactor cooling water
// inlet temperature step.
let run = Simulation::new(Scenario::fault(4).with_hours(24.0)).run();
// One measurement across the run, one-based as XMEAS(n) is.
let reactor_pressure = run.measurement(7);
// One manipulated variable, one-based as XMV(n) is.
let coolant_valve = run.manipulated(10);
assert!(run.outcome.is_completed());
Scenario is a plain Copy struct, so a caller can build one, tweak it, and
keep both. Its builders are with_seed, with_hours, sampling_every,
with_fault and open_loop, and the two constructors are
Scenario::baseline() and Scenario::fault(n).
| Field | Default | Meaning |
|---|---|---|
seed | 4651207995 | the generator word compiled into teprob.f:1187 |
hours | 48.0 | NPTS = 172800 at a one-second step, the run temain_mod.f was written to do |
step_hours | 1/3600 | the step the original's INTGTR uses |
sample_every | 180 | temain_mod.f:401 writes every 180 seconds, and d00 through d21 are at that spacing |
disturbances | all off | twenty flags, one-based as IDV(n) is |
controlled | true | closed loop under the published control scheme, or open loop with the valves held |
quirks | all fixed | which Class C quirks are fixed rather than reproduced |
driver_forces_idv12 | false | whether the driver switches IDV(12) on at hour eight, as temain_mod.f:366-368 does |
Three of those defaults deserve a sentence. Open loop is not a useful operating mode, it is a diagnostic one: the plant trips on reactor pressure after about three hours, and the difference between the two settings is the clearest single statement of what the control layer does.
The other two are the Class C quirks, signed off on 2026-08-28 and fixed by
default: a trip ends the run (delta D-007) and the driver does not force
IDV(12) (delta D-011). Both are one call away.
Scenario::faithful() gives the original's behaviour on both, and it is what
every comparison against the Fortran or against published data runs. See
the delta register for the evidence behind each.
A Run holds samples, each carrying step, hours, measurements
(XMEAS(1..41)), manipulated (XMV(1..12)) and labels. Run::column,
Run::columns, Run::measurement and Run::manipulated reshape it into the
columns a statistic or a detector wants, and Sample::row gives all 53 channels
in one array, measurements first, which is the layout every downstream consumer
uses. channel_names() returns matching names in the same order.
Run::outcome is how a run ended: Completed, Tripped with the step, the
hour and the first shutdown condition that fired, or SolveFailed with the step
at which a temperature solve failed to converge. A trip ends the run by
default, so a tripped run is shorter than its scenario planned.
teprob.f:807-811 instead freezes the plant and keeps reporting, which is what
Scenario::faithful() gives and what the constant tails in d06 and d18
are.
Labels is ground truth: which disturbances were active at that instant, and
how long each had been. The original records nothing of the sort, so every
detection-delay figure in the literature is computed against an onset the author
assumed.
Stepping a run by hand
Simulation::run() is the whole scenario in one call. For an online detector, a
live display or a reinforcement learning loop, Simulation::step() advances one
integrator step and hands back a Sample on the steps where one is due.
use tepsim::{Scenario, Simulation};
let mut sim = Simulation::new(Scenario::baseline().with_hours(2.0));
while let Some(sample) = sim.step() {
println!("{:.3} h pressure {:.1}", sample.hours, sample.measurements[6]);
}
The order inside a step is temain_mod.f's: force IDV(12) if it is due, run
the controllers on the previous step's measurements, integrate, then clamp.
That one plant step of dead time in every loop is delta D-010, and getting it
backwards leaves XMEAS(14) 23% out after four hours.
The command line
$ tep run --fault 4 --hours 24 --labels
tep has four subcommands, run, dataset, faults and help, and run
takes these flags:
| Flag | Default | Effect |
|---|---|---|
--fault <1-20> | none | inject a disturbance |
--hours <h> | 48 | simulated duration |
--seed <n> | 4651207995 | generator word |
--every <steps> | 180 | sample every N steps, so three minutes at the default step |
--open-loop | off | hold the valves instead of controlling |
--force-idv12 | off | switch IDV(12) on at hour eight whatever was asked for, as temain_mod.f:367 does |
--freeze-on-trip | off | on a trip, freeze the plant and keep reporting rather than ending the run, as teprob.f:807-811 does |
--faithful | off | both of the above: reproduce every Class C quirk |
--labels | off | include the ground-truth columns |
tep faults prints the twenty disturbances with the shape of each and the
teprob.f line it acts on; the same table appears in
The twenty disturbances.
Output is CSV on stdout, with progress and the outcome on stderr, so that
redirecting stdout to a file gives a clean file. The header is step,hours
followed by the 53 channel names, plus fault and hours_since_onset when
--labels is given. Values are written with seventeen significant digits, which
round-trips an f64 exactly, so a CSV written here is reproducible rather than
approximately reproducible.
A trip is reported on stderr and exits successfully, because a trip is a result rather than a malfunction; the message says whether the run ended there or the plant froze and carried on. Only a failed temperature solve is an error exit.
Generating a dataset
$ tep dataset --out ./data
$ tep dataset --set all --faults 0,4,6 --format csv --out ./small
$ tep dataset --list
tep dataset writes files with the same geometry as the published d00
through d21: 960 rows of 52 columns for the _te half, 480 (500 for d00)
for the training half, at the seeds teprob.f:1187-1256 records, in the same
fixed sixteen-column layout the shipped files use. --set chooses te (the
default), training or all; --faults takes all or a comma list;
--format is dat or csv, the latter at full precision with a header.
It is emphatically not the published data, and it says so on stderr every
run. Four things stand in the way and none is a matter of trying harder: the
toolchain that made the shipped files is unrecorded and its exp was not this
one's, the output was rounded to five significant figures before anyone saw it,
IDV(21) is not in this revision of the model so d21 cannot be generated at
all, and the protocol behind the twenty-two training files is written down
nowhere. Tier 7 measures the gap rather than closing it.
Reproducibility
A run is a pure function of its Scenario. There is no clock, no thread-local
state and no global, so the same scenario gives the same bits on x86-64,
aarch64 and wasm32. That is what makes a recorded dataset reproducible from its
description rather than from a file, and it is the property Tier 9 will assert
across the CI matrix.
The Python package
The simulator ships as a Python package called tepsim. It is not a
reimplementation and not a wrapper around a subprocess: it is the same Rust core
the rest of this book documents, compiled into an extension module with PyO3.
There is no C in the build, no Fortran, and no runtime dependency except NumPy.
A run started from Python is bit-identical to the same run started from Rust or from the browser, because all three call the same code with the same scenario. That is what makes the worked examples in the notebooks usable as evidence rather than as illustrations.
Installing it
It is not on PyPI. The version is still 0.0.0 and no release tag has been
pushed, so nothing has ever been published. The publish job in
.github/workflows/wheels.yml is wired up and gated on a v* tag, and the name
is free, so pip install tepsim will be the line once there is a release. It is
not the line today.
Install it from the repository instead:
$ pip install "git+https://github.com/jkitchin/tep-rust#subdirectory=crates/tepsim-py"
Two things about that command, both of which will bite otherwise.
It builds from source, so you need a Rust toolchain. There is no published
wheel to fall back on, so pip clones the repository, invokes maturin, and
compiles the whole core in release mode. That takes a couple of minutes on a
warm machine and it fails partway through if cargo is not on your PATH.
Install Rust from rustup.rs first. NumPy is pulled in
automatically as a declared dependency, so nothing else is needed.
The quotes are not optional. Most shells treat # as the start of a
comment, and without the quotes the #subdirectory= fragment is silently
dropped, at which point pip tries to build the workspace root and fails with
an error that does not mention the fragment at all.
The result is a cp39-abi3 wheel, so one build covers every GIL-enabled CPython
from 3.9 upwards. Free-threaded interpreters have no stable ABI to target and
need a version-specific build; 3.14t is the earliest that works, because PyO3
0.29 does not support the free-threaded build of anything below 3.14.
From a checkout
If you have the repository already, cargo xtask python is the supported path.
It builds a release wheel, creates a throwaway virtualenv at
.xtask-python/venv, installs the wheel and NumPy into it, proves that
import tepsim resolves inside that virtualenv rather than somewhere else on
the machine, and runs the binding's pytest suite against it.
$ cargo xtask python
$ .xtask-python/venv/bin/python -c "import tepsim; print(tepsim.__version__)"
That interpreter is the one the book's own tests use to check the quickstart
transcript below. Note that cargo xtask python deletes and rebuilds the
virtualenv every time it runs, so anything else installed into it, jupyter and
matplotlib for the notebooks in particular, has to be reinstalled afterwards.
To build a wheel without installing it, maturin build --release -m crates/tepsim-py/Cargo.toml writes one and prints the path.
Quickstart
from concurrent.futures import ThreadPoolExecutor
import numpy as np
import tepsim as tep
print("tepsim %s: XMEAS(1..%d), XMV(1..%d), %d channels, IDV(1..%d)"
% (tep.__version__, tep.MEASUREMENTS, tep.MANIPULATED, tep.CHANNELS,
tep.DISTURBANCES))
# A Scenario says what to simulate, a Simulation does it, a Run holds what
# came out. That is the whole API.
run = tep.Simulation(tep.Scenario.baseline(seed=42, hours=48)).run()
print()
print("run: %r" % run)
print("matrix: %s %s" % (run.to_numpy().shape, run.to_numpy().dtype))
print("outcome: %s" % run.outcome)
print("XMEAS(7): mean %.2f kPa over %.0f h"
% (run.measurement(7).mean(), run.hours[-1]))
# The twenty disturbances say what they do, not only what the original header
# called them. Five of the published descriptions are the word "Unknown".
print()
print("the five the original leaves unexplained")
for fault in tep.faults():
if fault.published == "Unknown":
print(" IDV(%2d) %-9s %s" % (fault.index, fault.shape, fault.effect))
# Ground truth travels with the data, which the original records nowhere.
faulted = tep.Simulation(tep.Scenario.fault(1, hours=8)).run()
labels = faulted.labels()
print()
print("IDV(1) over 8 h: active at the last sample %s, %.2f h since onset"
% (labels["active"][-1, 0], labels["since_onset"][-1, 0]))
# run() releases the GIL, so an ensemble is a thread pool and nothing has to be
# pickled.
sims = [tep.Simulation(tep.Scenario.fault(n, hours=8)) for n in range(1, 21)]
with ThreadPoolExecutor() as pool:
runs = list(pool.map(tep.Simulation.run, sims))
print()
print("twenty 8-hour faulted runs: %d completed, %d tripped"
% (sum(r.outcome == "completed" for r in runs),
sum(r.outcome == "tripped" for r in runs)))
# A run is a pure function of its scenario, and a scenario is one line of text
# that parses back to an equal scenario.
scenario = tep.Scenario.fault(4, hours=8)
print()
print("digest: %s" % scenario.digest)
print("text: %s" % scenario.to_text())
print("parses back equal: %s"
% (tep.Scenario.from_text(scenario.to_text()) == scenario))
print("two runs bit-identical: %s"
% np.array_equal(tep.Simulation(scenario).run().to_numpy(),
tep.Simulation(scenario).run().to_numpy()))
tepsim 0.0.0: XMEAS(1..41), XMV(1..12), 53 channels, IDV(1..20)
run: <tepsim.Run 960 samples x 53 channels, 48.0 h, completed>
matrix: (960, 53) float64
outcome: completed
XMEAS(7): mean 2706.16 kPa over 48 h
the five the original leaves unexplained
IDV(16) random enables walk channel 9, the stripper steam valve capacity
IDV(17) random enables spike channel 10, the reactor coolant duty
IDV(18) random enables spike channel 11, the condenser coolant duty
IDV(19) sticking sticks valves 5, 7, 8 and 9; touches no equation in the model
IDV(20) random enables spike channel 12, the reactor outlet flow
IDV(1) over 8 h: active at the last sample True, 8.00 h since onset
twenty 8-hour faulted runs: 19 completed, 1 tripped
digest: 7f9accc04bb61e7f
text: tepsim.scenario.v1;seed=4651207995;hours=8;step=2.777777777777778e-4;every=180;faults=4;controlled=1;idv12=0;trip=1;continuous=0;integrator=euler;events=
parses back equal: True
two runs bit-identical: True
The API
Three classes carry the whole surface, and they divide the way the problem
does. A Scenario is a description and holds no state. A Simulation is the
machinery. A Run is the result.
Module level
| Name | Value | Meaning |
|---|---|---|
MEASUREMENTS | 41 | XMEAS(1..41) |
MANIPULATED | 12 | XMV(1..12) |
CHANNELS | 53 | a row: measurements then manipulated variables |
DISTURBANCES | 20 | IDV(1..20) |
DEFAULT_SEED | 4651207995.0 | the generator word compiled into teprob.f:1187 |
DEFAULT_STEP_HOURS | 1/3600 | one simulated second, the step INTGTR uses |
DEFAULT_SAMPLE_EVERY | 180 | the three-minute spacing of d00 through d21 |
FORCED_DISTURBANCE_STEP | 28800 | the step the driver forces IDV(12) on at |
channel_names() returns the 53 names in row order, so a CSV header and a
matrix column cannot disagree about which is which. faults() returns the
twenty Fault records.
Scenario
Immutable and cheap to copy, so a caller can build one, derive variants from it, and keep all of them.
There are four constructors: the bare Scenario(...), Scenario.baseline(...),
Scenario.fault(n, ...) and Scenario.from_text(text). All but the last take
the same keyword arguments, which are seed, hours, step_hours,
sample_every, controlled, driver_forces_idv12 and trip_ends_the_run,
plus faults on the bare one.
| Member | What it is |
|---|---|
seed, hours, step_hours, sample_every | the four numbers a run is measured in |
controlled | closed loop under the published control scheme, or open loop with the valves held |
driver_forces_idv12 | whether the driver switches IDV(12) on at hour eight, delta D-011 |
trip_ends_the_run | whether a shutdown stops the run, delta D-007 |
faults | the active disturbances, one-based and ascending |
steps, samples | how many integrator steps and how many recorded rows |
digest | a content hash over everything affecting the run, sixteen hex characters |
to_text(), from_text() | the canonical one-line form, and its strict parser |
with_seed, with_hours, with_fault, sampling_every, open_loop | derive a variant |
The two defaults worth knowing are that a trip ends the run and that the driver
does not force IDV(12). Both are Class C quirks of the original that this port
fixes by default and can reproduce on request; see
the delta register.
Simulation
Simulation(scenario) holds a plant, a controller stack and an integrator
state, all of it owned. The original keeps its whole working set in six Fortran
COMMON blocks, which allows exactly one simulation per process and no
reentrancy at all. This allows as many as there are threads.
run() runs the whole scenario and returns a Run. It runs a copy, so
calling it twice gives two equal runs rather than one run and one empty one, and
it never raises for a plant that misbehaves: a trip or a failed temperature
solve is reported through Run.outcome, because a run that ended early is data.
run() releases the GIL for the entire integration, which is where all the time
goes, so an ensemble is a ThreadPoolExecutor and not a ProcessPoolExecutor.
Nothing has to be pickled, and the plant does no I/O and touches no Python
object while the GIL is down.
Run
| Member | What it is |
|---|---|
to_numpy() | the whole run, one (n_samples, 53) float64 array, C-contiguous |
hours, steps | simulated time and integrator step per row |
measurement(n), manipulated(n) | one channel, one-based as XMEAS(n) and XMV(n) are |
column(i), columns() | one channel by zero-based position, or all 53 keyed by name |
labels() | ground truth: 'active' and 'since_onset', both (n_samples, 20) |
outcome | 'completed', 'tripped' or 'solve_failed' |
tripped_at, tripped_hours, trip_cause | where and why the plant shut down, or None |
solve_failed_at | the step a temperature solve failed at, or None; delta D-001 |
scenario | what was asked for |
Every array is a read-only view over one buffer, which is filled once when the
run finishes and moved into NumPy rather than copied. to_numpy() returns the
same object every call, and measurement, manipulated, column and columns
are strided views into it. Call .copy() for something writable, or
numpy.ascontiguousarray if contiguity matters. columns() is a dict in
channel order, which makes it a pandas.DataFrame constructor argument as it
stands.
labels() is the part with no counterpart in the original. A published
Tennessee Eastman dataset is a matrix and a filename, so every detection-delay
figure in the literature is computed against whatever onset its author assumed.
Here the onset is in the data.
Fault
faults() returns twenty of these. published is the description at
teprob.f:172-191 verbatim, five of which say only "Unknown". effect is what
the source actually does, which for those five is perfectly explicit: only the
physical interpretation was withheld. shape is 'step', 'random' or
'sticking', line names the teprob.f line the fault acts on, and
affects_the_plant is False for the three sticking faults, which touch no
equation in the model at all. The same table is in
The twenty disturbances.
What Scenario(...) cannot say
Three of a scenario's eleven fields are out of the constructors' reach: the
event schedule, the continuous extension that allows a fault at a fraction of
its full strength, and the choice of integrator. Scenario.from_text is the way
to them from Python, and since the text is the scenario's real serialisation,
the thing the digest is taken over and the browser app puts in a URL fragment,
building through it is building in the form the run will be described by.
text = tep.Scenario.baseline(hours=18).to_text()
scheduled = tep.Scenario.from_text(text.replace("events=", "events=6:start:4,12:stop:4"))
repr() knows about this. A scenario the constructor can express is printed as
a constructor call, because that is what is readable at a prompt, and anything
else is printed as Scenario.from_text(...). Both round-trip, so
eval(repr(s)) == s holds for either shape. The alternative, printing only the
constructor's arguments, produced valid Python that evaluated to a different
scenario with a different digest and said nothing about it.
notebooks/04-custom-scenarios.ipynb works through all three fields, with a
helper that rebuilds the text line from a dictionary rather than patching it, so
that a mistyped field name is an error rather than a silently different run.
What is not in the package
tepsim-stats, the crate the repository's own validation ladder runs its
statistics on, is development-only. cargo xtask ci asserts that no shipped
crate so much as names it, and it is therefore not reachable from Python. That
is deliberate: the ladder's statistics have to be free of any dependency the
port itself does not have, so they can never be the reason a validation number
changed.
For monitoring work the notebooks use notebooks/pcamon.py instead, which is
PCA, both monitoring statistics and their control limits in NumPy and the Python
standard library alone, with no SciPy and no scikit-learn. Where the two could
differ they agree: 02-fault-detection-pca.ipynb checks every printed digit of
its detector against the Rust one.
Types
The package ships py.typed, and _tepsim.pyi beside __init__.py describes
the compiled half, so mypy and an editor see the real signatures rather than
Any.
Worked examples
The four notebooks in notebooks/ are where the package is actually used. They
are executed and committed with their outputs and plots, and rendered copies are
published beside this book:
Those four links resolve on the published site, where
.github/workflows/pages.yml renders the notebooks beside the book with
jupyter nbconvert. In a local mdbook build they point at files that are not
there; the sources are notebooks/*.ipynb in the repository, and
notebooks/README.md says how to run them.
The tutorials in this book are the narrative around them.
The twenty disturbances
IDV(1..20) is a bare integer array in the original, and the header at
teprob.f:172-191 names each entry in prose. Nothing there connects a name to
the line that implements it, and five of the twenty are called only "Unknown".
This page makes the connection: every fault names the teprob.f line it acts
on, where it is injected, and what shape it has.
There are twenty, not the twenty-one of the later literature. teprob.f:340 is
DO 500 I=1,20. The extra one comes from later versions of the model, and this
port follows the vendored source.
Three shapes, and the third is not a plant disturbance at all
Step faults change a feed condition the moment they are switched on and hold
it. Seven of the twenty, at teprob.f:407-414, 567 and 568.
Random faults enable a walk channel through IDVWLK (teprob.f:347-358).
Ten of the twenty, of which the last three drive spike trains rather than
walks.
Sticking faults do not touch the model. They set IVST
(teprob.f:793-798), which widens the dead band a valve command must cross
before the valve follows it. Three of the twenty.
That third kind matters more than its size. A sticking fault is not a
disturbance to the plant; it is a disturbance to the controller's authority over
the plant. In an open-loop run, where the command never moves, it does nothing
whatever, and a scenario engine that treated it as a plant fault would report an
injected disturbance with no effect and look broken. Tier 4 confirmed this from
a direction that could not have been arranged: over a four-hour run, IDV(14),
IDV(15) and IDV(19) report worst errors identical to the nominal case to
every digit, because with the command held still their trajectory is the
nominal trajectory.
The table
IDV | Published description (teprob.f:172-191, verbatim) | What the source does | Shape | Line |
|---|---|---|---|---|
| 1 | A/C Feed Ratio, B Composition Constant (Stream 4) | steps the mixed feed's A fraction down by 0.03 | step | teprob.f:407 |
| 2 | B Composition, A/C Ratio Constant (Stream 4) | steps B up by 0.005 and A down by 2.43719e-3, on two lines | step | teprob.f:408-409 |
| 3 | D Feed Temperature (Stream 2) | steps the D feed temperature up by 5 C | step | teprob.f:411 |
| 4 | Reactor Cooling Water Inlet Temperature | steps the reactor coolant inlet up by 5 C | step | teprob.f:413 |
| 5 | Condenser Cooling Water Inlet Temperature | steps the condenser coolant inlet up by 5 C | step | teprob.f:414 |
| 6 | A Feed Loss (Stream 1) | shuts the A feed off entirely, not partially | step | teprob.f:567 |
| 7 | C Header Pressure Loss - Reduced Availability (Stream 4) | reduces the mixed feed's capacity by 20% | step | teprob.f:568 |
| 8 | A, B, C Feed Composition (Stream 4) | enables two walk channels, on A and on B | random, channels 1 and 2 | teprob.f:347-348 |
| 9 | D Feed Temperature (Stream 2) | enables the D feed temperature walk | random, channel 3 | teprob.f:349 |
| 10 | C Feed Temperature (Stream 4) | enables the mixed feed temperature walk | random, channel 4 | teprob.f:350 |
| 11 | Reactor Cooling Water Inlet Temperature | enables the reactor coolant inlet walk | random, channel 5 | teprob.f:351 |
| 12 | Condenser Cooling Water Inlet Temperature | enables the condenser coolant inlet walk | random, channel 6 | teprob.f:352 |
| 13 | Reaction Kinetics | enables two walks, one per rate constant of reactions 1 and 2 | random, channels 7 and 8 | teprob.f:353-354 |
| 14 | Reactor Cooling Water Valve | sticks valve 10; touches no equation in the model | sticking | teprob.f:793 |
| 15 | Condenser Cooling Water Valve | sticks valve 11; touches no equation in the model | sticking | teprob.f:794 |
| 16 | Unknown | enables walk channel 9, the stripper steam valve capacity | random, channel 9 | teprob.f:355 |
| 17 | Unknown | enables spike channel 10, the reactor coolant duty | random, spiking, channel 10 | teprob.f:356 |
| 18 | Unknown | enables spike channel 11, the condenser coolant duty | random, spiking, channel 11 | teprob.f:357 |
| 19 | Unknown | sticks valves 5, 7, 8 and 9; touches no equation in the model | sticking | teprob.f:795-798 |
| 20 | Unknown | enables spike channel 12, the reactor outlet flow | random, spiking, channel 12 | teprob.f:358 |
The channel column is not decoration. A test asserts that this table agrees with
the code that maps IDV flags to channel flags, that every one of the twelve
channels is driven by exactly one fault, that the spiking flag is set exactly for
channels 10 and above, and that exactly IDV(14), IDV(15) and IDV(19) fail
to reach the plant. Two statements of one fact, which is the point.
The five "Unknown" faults are not unknown
The header calls IDV(16) through IDV(20) unknown, and every paper on TEP
repeats it. The source is perfectly explicit about what they do; only their
physical interpretation was withheld. They enter the model at these points:
| Fault | Where it lands |
|---|---|
IDV(16) | the stripper steam valve capacity, UAC at teprob.f:572 |
IDV(17) | the reactor coil duty, the drift factor at teprob.f:673 |
IDV(18) | the condenser duty, the drift factor at teprob.f:676 |
IDV(19) | sticks valves 5, 7, 8 and 9 |
IDV(20) | the reactor outlet flow resistance, at teprob.f:582-583 |
So IDV(19) is a sticking fault and the other four are not, which the shared
label hides. Three of the four are the spike channels, which is why they are
reported in the literature as the hardest to detect: they are intermittent
rather than sustained.
Nine walks and three spike trains
The twelve channels are not the same kind of object, which the shared array
names hide (teprob.f:340-406).
Channels 1 to 9 are random walks. When one runs out, teprob.f:359-371
evaluates the old segment at its endpoint, takes the value and the slope there,
and builds a segment that continues smoothly from them. TESUB5
(teprob.f:1506-1537) chooses the next knot value and slope from the uniform
generator and fits a Hermite cubic; TESUB8 (teprob.f:1300-1359) evaluates
the cubic at the current time.
Channels 10 to 12 are spike trains, and teprob.f:372-396 gives them their
own rule. They alternate between two states. Dwelling: the channel sits at zero
for a randomly drawn interval, its segment being a parabola rising from zero,
and the dwell ends when the value reaches 0.1. Spiking: once the value exceeds
0.1, the channel is given a cubic through the current value and slope that lasts
exactly 0.1 hours, with coefficients that drive it hard and then back down. So a
spike channel is off, off, off, then briefly on.
The flag scales the dwell, not the schedule. CDIST(I) = IDVWLK(I) / h^2 at
teprob.f:391 is the only place the flag enters a spike channel. With the
disturbance off it is zero, the parabola is flat at zero, the channel never
reaches 0.1, and it dwells forever, drawing a fresh interval each time. With it
on, the parabola climbs and the channel eventually spikes. Either way it keeps
drawing at the same rate.
That last property is load-bearing and is easy to optimise away. IDVWLK
multiplies the two endpoint draws at teprob.f:1529-1530 but not the
duration draw at teprob.f:1528, so an inactive walk channel still consumes
all three draws. Skipping the two endpoint draws when the flag is zero produces
identical segment values, because an inactive channel lands on SZERO with
zero slope either way, and leaves the generator two steps behind. Every
subsequent draw in the run then differs: the noise, the other channels,
everything. That mutation was implemented deliberately to check the tests have
teeth, and it was caught on the stream position rather than on any value. It
is the entire argument for Tier 3 demonstrated at Tier 1.
Using them
$ tep faults # the table above, from the source
$ tep run --fault 4 --hours 24 --labels
or, from Rust, Scenario::fault(4) and Scenario::baseline().with_fault(4).
Faults can be combined; the flags are independent.
One caveat, though it is no longer the default. The original driver switches
IDV(12) on at eight hours whatever the scenario asked for
(temain_mod.f:366-368). That is delta D-011, and it is off here: Tier 7
established that the published files were generated with that line replaced
rather than kept, so a request for IDV(4) gets IDV(4) and nothing else.
Scenario::faithful() and the driver_forces_idv12 field turn it back on, and
the ground-truth labels record it either way, so a run carrying it is visibly
fault-free for eight hours and then not.
A first run, and the 53 channels
The worked example is notebook 1, Getting started. It runs the plant, plots what normal operation looks like, walks the twenty faults, injects one, reads the ground truth back, checks reproducibility from a seed, and finishes with a trip. Source:
notebooks/01-getting-started.ipynb.
The whole library is three objects. A Scenario says what to simulate, a
Simulation does it, and a Run holds what came out. The notebook uses all
three in its first cell; this page is the part of the story that is worth
stating once in prose rather than re-deriving from a plot.
The examples are Python, and The Python package is how to install it. The simulator itself is Rust, and its Rust API is Getting started, but a run started from either binding is bit-identical, because both call the same core.
Why forty samples in two hours, and not 7200
The integrator takes one step per simulated second, so two hours is 7200 steps.
A sample is written every 180 of them, which is the three-minute spacing
temain_mod.f:401 writes at and the spacing of the published d00 through
d21 files. Two hours is therefore forty rows, and a 48-hour run is 960.
The first sample of a run is at 0.0497 hours rather than at 0.05, and that is
not an off-by-one. run.steps[0] on that row is 180, and run.hours[0] is the
time at which step 180 began, which is 179 seconds. The simulated clock is
advanced at the end of a step, after the row has been written, because that is
the order temain_mod.f writes in, and a row that carried the post-step time
would be labelled with a clock the plant had not reached when it was measured.
The last row of a two-hour run is at 1.9997 hours for the same reason. If you
want the initial condition itself, it is the model's nominal state and not a row
of the run.
What the 53 channels are
A row is the plant as an operator sees it: 41 measurements and then the 12 valve
positions the controllers are holding. run.to_numpy() returns exactly that as
one (n_samples, 53) array, measurements first, and channel_names() returns
names in the same order, so a CSV header and a matrix column can never disagree
about which is which.
The 41 measurements split into two groups that behave quite differently.
XMEAS(1) through XMEAS(22) are continuous instruments: flows, pressures,
levels, temperatures, the compressor work. They are read every step and carry
Gaussian measurement noise whose standard deviation is the XNS table in
teprob.f.
XMEAS(23) through XMEAS(41) are the three gas chromatographs, and they are
not instruments in the same sense at all. They sample on a schedule, take time
to run, and then hold the answer until the next result arrives. The notebook
plots this, and it is the single most surprising thing about the data the first
time you see it: a composition channel is a staircase where a flow meter is a
curve. The reactor feed and purge analysers run every 0.1 hours and the product
analyser every 0.25, so at the three-minute output cadence each answer appears
twice or five times. A detector that treats those repeats as independent
observations is counting the same measurement several times, and that is worth
knowing before it produces a p-value.
Both 0.1 literals in teprob.f are single precision, so the gas interval is
really 0.10000000149011612 and a step landing on exactly 0.1 does not sample.
That is faithfully reproduced here; see the delta register.
XMV(1) through XMV(12) are the manipulated variables. Eleven of them move.
XMV(12), the agitator speed, sits at 50 forever, because the published control
scheme never touches it. A statistical model fitted to all 53 channels has to
cope with that constant column, and the detector tutorial shows
what a PCA model has to do about it.
Getting at the data
run.to_numpy() is the record itself, one read-only (n_samples, 53) array of
float64. Three views on top of it cover most uses. run.measurement(n) and
run.manipulated(n) take the one-based indices of the original, so
run.measurement(7) is XMEAS(7) and needs no mental arithmetic.
run.column(i) takes the zero-based row position, and run.columns() returns
all 53 at once keyed by name, which is the shape a covariance matrix, a chart or
a pandas.DataFrame wants. Alongside them, run.hours and run.steps are the
simulated time and the integrator step each row was written at.
Every one of those is a view into the same buffer rather than a copy, so
.copy() is needed before writing into one.
The digest
scenario.digest is a content hash over everything the run's output depends on:
the seed, the duration, the step, the cadence, the disturbances, the control
mode, the quirk flags, the schedule and the integrator. Two scenarios that
describe the same experiment produce the same sixteen characters, and two that
differ in any respect do not.
It is worth putting in a filename or a file header, because it turns "this is
the fault 4 data, I think" into something checkable. scenario.to_text() writes
the whole scenario out in one line that Scenario.from_text reads back, so a
dataset can carry its own description rather than a memory of one. The notebook
checks both properties rather than asserting them: the same scenario run twice
is bit-identical, a different seed is not, and the text round-trips.
What a trip looks like
The plant has eight shutdown conditions, on reactor pressure, reactor level,
reactor temperature, separator level and stripper level. Cross one and the
simulation is over. run() never raises for a plant that misbehaves, because a
run that ended early is data, and throwing it away would hide the difference
between a port that trips where the original does and one that does not. The
outcome, the step, the hour and the condition that fired are all on the Run.
The notebook's survey of all twenty faults at 48 hours is the useful version of
this: at the default seed exactly one of them trips, IDV(6), the total loss of
the A feed. That is a property of the seed as much as of the fault, which is the
kind of thing worth measuring before designing an experiment around it.
The same run from the command line
$ tep run --hours 2
writes the same 40 rows as CSV on stdout, with seventeen significant digits, so
the file round-trips an f64 exactly.
Injecting a fault, and finding it in the data
The worked example is the second half of notebook 1, Getting started, which switches
IDV(4)on eight hours into a 24-hour run, plots the reactor temperature and the cooling water valve side by side, and reads the ground truth back. Source:notebooks/01-getting-started.ipynb.
A disturbance is one line: Scenario.fault(n) is the baseline with IDV(n)
switched on for the whole run, one-based exactly as the Fortran's IDV(n) is.
That is what the original does, and it is the right thing when what you want is
a faulted record. It is the wrong thing when what you want is to watch the fault
arrive, and arrival is what a detector is judged on, so the notebook schedules
it instead: IDV(4) off for eight hours and on afterwards, which is the layout
the published test sets use.
IDV(4) is a five degree step in the reactor cooling water inlet temperature.
The interesting part is that it is nearly invisible where you would first look.
The fault is in the valve, not in the temperature
Over the fourteen hours after the fault settles, the mean reactor temperature is 120.399542 degrees on the fault-free run and 120.399514 degrees on the faulted one. The difference is 28 millionths of a degree, against a fault-free standard deviation of 0.018728 degrees: two thousandths of one standard deviation. To any instrument, and to any detector watching that channel, nothing happened.
The cooling water valve tells the other half of the story. It sits at 41.103%
open without the fault and 44.868% with it, a shift of 3.766 percentage points
of valve travel. That is the whole of IDV(4): the cooling water arrives
hotter, the temperature controller notices immediately, and it opens the valve
until the temperature comes back. The controller has converted a disturbance in
a measured variable into a disturbance in a manipulated one, which is what a
controller is for.
The lesson generalises well past this fault, and it is the single most important
thing to understand before building a monitor for a plant under closed-loop
control. The evidence of a disturbance often sits in the manipulated variables
rather than in the measurements, because the controller has been busy cleaning
up the measurements. Both halves are in the array run.to_numpy() returns, and
the detector in the next tutorial uses all 53 channels for
exactly this reason.
Ground truth
run.labels() records what was actually true at each instant. It returns two
(n_samples, 20) arrays indexed by IDV(n) - 1: active says whether that
disturbance was on at that sample, and since_onset says how many hours it had
been on, nan where it was not on at all.
The original records nothing of the sort. A published Tennessee Eastman dataset
is a matrix and a filename, so every detection delay in the literature is
measured against an onset its author knew from the experimental protocol rather
than from the data. That is fine until two papers disagree about where sample
160 falls. Here the onset is in the data, and finding it is
int(np.argmax(labels["active"][:, 3])) rather than a constant somebody has to
remember.
The precision is deliberate too. A fault live from the first step reports 0.04972222222222225 hours at the first sample, not "about 0.05": the label carries the same simulated clock the row does, so it is 179 seconds. That is what lets a delay of one sample be distinguished from a delay of zero.
The disturbance you did not ask for, and no longer get
Pass driver_forces_idv12=True and run for longer than eight hours, and a
second fault appears at hour eight without being asked for. That is not a bug in
the scheduler. It is temain_mod.f:366-368, which switches IDV(12) on at
eight hours whatever the scenario said, and both IDV(4) and IDV(12) act on
cooling water, so a run nominally labelled IDV(4) was really the two together.
It was the default here until 2026-08-28, on the belief that reproducing d01
through d21 required it. Tier 7 showed the opposite: every dNN_te file
except d12_te sits at the nominal operating point straight across row 160,
which is hour eight, so the published files were made with that line replaced.
The default now follows the files.
It is delta D-011 in the register, and it used to be the single
most common way a comparison against the published files went quietly wrong,
because it was the default. It is not any more: a request for IDV(4) gets
IDV(4), and driver_forces_idv12=True is how to ask for the driver's version.
The Rust equivalent is Scenario::faithful(), which sets that quirk and the
freeze-on-trip one together, and the command line spelling is
tep run --force-idv12. Say which one you used when you report numbers. The
labels make the difference visible either way, which is the point of recording
ground truth rather than assuming it.
From the command line
$ tep run --fault 4 --hours 8 --labels
--labels adds the fault and hours_since_onset columns to the CSV, so the
ground truth travels with the data.
Building a detector, and measuring it
The worked example is notebook 2, Fault detection with PCA, which fits the model, plots both statistics against their limits on fault-free and faulted records, scores eight disturbances, and then diagnoses the one limit that does not work. Its sequel, notebook 3, The three hard faults, measures the benchmark's detectability floor three separate ways. Sources:
notebooks/02-fault-detection-pca.ipynbandnotebooks/03-hard-faults.ipynb.
This is the canonical Tennessee Eastman monitoring experiment. Fit a principal component model to a record of the fault-free plant, watch two statistics on new data, and raise an alarm when either leaves its control limit. Hotelling's T-squared measures distance from the training mean inside the subspace the model retained. The squared prediction error, written SPE or Q, measures how much of an observation the model could not reconstruct at all.
The detector is notebooks/pcamon.py, about four hundred lines of NumPy and the
Python standard library with no SciPy and no scikit-learn: the
eigendecomposition is numpy.linalg.eigh, the normal quantile is
statistics.NormalDist, and the F quantile the T-squared limit needs is a
continued-fraction incomplete beta with a bisection on top.
That file, rather than a crate, is deliberate. The repository has a Rust
implementation of exactly this detector in tepsim-stats, and this page used to
show it. tepsim-stats is development-only, and cargo xtask ci asserts that
no shipped crate so much as names it, so a reader who installed tepsim and
followed the page could not build what the page was teaching. Everything the
notebooks do runs against the package you can install.
The two implementations agree. Notebook 2 opens by reproducing the Rust transcript, and every printed digit matches: the same 33 components, the same limits to three decimals, and the same four detection rates and delays, from a hand-written cyclic Jacobi sweep on one side and LAPACK on the other. What the notebook measures after that is therefore a property of the plant and the method, not of the linear algebra.
The measurement is the point
Any detector can be made to look good by reporting only the faults it catches. Every detection rate in the notebook is reported next to the false alarm rate on held-out fault-free data the model never saw, and one of the two statistics comes out badly.
Three numbers do the work, and each has a trap in it.
The fault detection rate is the fraction of post-onset samples that raised an alarm. It is a rate over samples, not a per-run yes or no, so a detector that catches a fault and then loses it scores badly. That is the intent: a statistic that drops back inside its limit while the fault is still running is flickering, not detecting. Its complement, the missed detection rate, is what the tables in the literature report.
The false alarm rate is the same fraction over the pre-onset samples, and it is the number that makes a detection rate meaningful. A detector with a 20% false alarm rate that achieves a 20% detection rate has detected nothing.
The detection delay is the number of samples from the onset to the first run of three consecutive alarms. The persistence requirement is not decoration. With a run length of one the delay is just the first alarm after the onset, and on a detector with any false alarm rate at all that is mostly luck: at a 3% false alarm rate the first post-onset sample alarms by chance one time in thirty, and calling that a delay of zero flatters the detector. The literature uses three and six and does not agree, so the run length travels with the number. Delays are in samples, and one sample is three minutes.
What the model does with a constant column
pcamon.fit standardises each column to zero mean and unit sample variance
before forming the correlation matrix. Standardisation rather than mere centring
is not optional here: reactor pressure lives near 2705 kPa and a composition is
a percentage, so a covariance model would be a model of the pressure and nothing
else.
How many components to keep is passed as a named rule rather than a bare k,
because two detectors that retain different numbers of components are different
detectors and a result that does not say which rule produced it cannot be
reproduced. At 90% of the variance the notebook keeps 33 of 52 components. That
is two thirds of them, and it is worth pausing on: the largest eigenvalue is
5.910, only 11.4% of the total of 52, so this plant has no small handful of
dominant directions and a monitoring scheme on it works in a fairly
high-dimensional retained subspace.
XMV(12), the agitator speed, never moves, so its standard deviation is zero
and it cannot be standardised. Rather than divide by zero or quietly drop the
column, pcamon records its index, zeroes its row and column of the correlation
matrix, and says so when asked. A silent drop here is how a 53-variable model
becomes a 52-variable model that nobody can reproduce.
The number the detector would rather you did not see
Both limits are drawn at 99% confidence, so the nominal false alarm rate is 1%. On a fresh 48-hour fault-free run the model never saw, T-squared alarms on 32 of 960 samples, a rate of 0.0333, which is high but recognisable. SPE alarms on 174 of 960, a rate of 0.1812. That is a factor of eighteen.
It is not an arithmetic error. pcamon.spe_limit computes the
Jackson-Mudholkar expression exactly as stated. It is the assumption underneath
the expression failing: the limit is derived for residuals that are normal and
independent, and the plant's are neither. Several feed conditions are driven by
slow random walks that never stop, so a 48-hour record wanders somewhere a
25-hour training record did not go, and when it does, every sample in the
excursion alarms together. The notebook's plot shows exactly that shape, with
the SPE alarms arriving in long blocks rather than as isolated points.
The tempting response is to lower the confidence level until the number looks right. The notebook does the experiment instead, holding everything else fixed and lengthening the training record:
| Training record | Samples | SPE false alarm rate |
|---|---|---|
| 25 hours | 500 | 0.1812 |
| 100 hours | 2000 | 0.0219 |
| 200 hours | 4000 | 0.0177 |
| 500 hours | 10000 | 0.0115 |
That settles it. With enough fault-free training data both statistics land on the nominal 1%, so Jackson-Mudholkar was never the problem. A 25-hour record simply does not contain the tails of a process whose feed conditions are driven by random walks that never stop, and the limit it produces is therefore too tight.
This has a direct consequence for the literature, and not a comfortable one. The
published training file d00 holds 500 samples, which at a three-minute
interval is exactly 25 hours: the first row of that table. Every SPE false alarm
rate reported for static PCA on the published Tennessee Eastman data inherits
this.
Estimating the limit empirically from the training residuals is the usual advice, and the notebook checks that too rather than assuming it. It does not help: the empirical SPE limit gives 0.1448 against the analytic 0.1812, and the empirical T-squared limit is markedly worse than the analytic one at 0.1042 against 0.0333. Both are drawn from the same 25 hours that were too short in the first place, and neither can know about the excursions it never saw.
The fix is more data, or a method that models the serial correlation rather than assuming it away. Dynamic PCA, which augments each observation with lagged copies of itself, and canonical variate analysis are the two the literature reaches for, and Russell, Chiang and Braatz's 2000 comparison of both against static PCA is on exactly these files.
The floor under the benchmark
Notebook 3 is about the best known empirical result on this problem: IDV(3),
IDV(9) and IDV(15) are effectively undetectable by these methods. It
measures that on the published d00 through d21 files, on simulated runs at
the seeds those files record, and over a ten-seed ensemble, and the three
measurements agree.
The plainest form of the result is distributional. The median post-onset T-squared for those three faults is within one percent of the fault-free median, and their median SPE within four percent, on quantities whose fault-free values span two orders of magnitude. No threshold separates them because there is nothing to separate.
That is worth stating carefully, because it reads like a criticism of PCA and is not one. The plant really is running normally under those three disturbances. A detector that alarmed on them would be reporting a fault with no consequence, and the correct behaviour for a monitoring scheme is what it does. What the result measures is that this benchmark has a detectability floor set by the process rather than by the method, which is part of what makes it a good benchmark: any paper claiming to detect all twenty is claiming something about its false alarm rate that it has probably not measured.
A fault that arrives, and clears
The worked example is notebook 4, Custom scenarios, which schedules
IDV(4)between hours six and twelve and plots the valve moving and coming back, composes two faults, sweeps a fault's magnitude from zero to one, and compares the three integrators. Source:notebooks/04-custom-scenarios.ipynb.
Every published Tennessee Eastman dataset is the same shape of experiment: set
some IDV flags before the run, then leave them. That is all the original
admits, which is why the literature's fault onsets are all in the same place and
why almost nobody studies a fault that arrives, persists, and then goes away.
A Scenario here carries a schedule of events, each at a time and each doing
one thing, so a disturbance can arrive at a stated hour, several can arrive
independently of each other, and any of them can clear. Two further things the
original cannot express sit beside it: a fault applied at a fraction of its full
strength, and a choice of integrator. All three live in the scenario's canonical
text form, which is where Python reaches them, and none of it would be worth
much if the description of a run could not travel with the run.
The schedule is text, and that is the point
The Python constructors cover eight of a scenario's eleven fields. The other
three, events, continuous and integrator, are reached by rendering a
scenario to text, editing a field, and parsing it back. That sounds like a
workaround and is closer to the opposite. The text is the scenario's real
serialisation: it is what the digest is taken over, what the browser app puts in
a URL fragment, and what the Rust and wasm sides read and write, so building a
schedule through it is building it in the form the run will be described by.
An event is time:verb followed by the verb's own fields, so an events field of
6:start:4,12:stop:4 switches IDV(4) on at hour six and off at hour twelve.
The notebook's helper rebuilds the whole line from a dictionary rather than
patching it with a string replacement, so a mistyped field name is a KeyError
and not a silently different run.
An event is applied on the first step whose time is at or past its own. The
window is half-open at the start and closed at the end, so an event is applied
exactly once however the step size divides its time, and an event at time zero
is applied on the first step rather than never. Two events at the same instant
keep the order they were written, because stop then start on one fault is a
different scenario from the reverse and the digest has to be able to tell them
apart.
What the trace shows
The reactor cooling water valve sits at 41.063% open before the fault. Half an hour after it arrives the valve is at 45.492%, it holds near 44.5% for the six hours the fault is live, and half an hour after it clears it is back at 41.045%, within a fiftieth of a percentage point of where it started. The controller does not know a fault happened in either direction; it is rejecting a disturbance both times.
That return is worth noticing rather than assuming. It is this loop doing its job on a step disturbance that was removed, and it is not guaranteed in general: a fault that shifts an inventory leaves the plant somewhere else even after it clears, and the plant after a fault is not the plant before it, because the controllers have moved. The recovery half of the problem is expressible here and barely studied, which is most of the reason the schedule exists.
The labels follow the schedule exactly. Once the fault clears, since_onset
goes back to nan, which means the ground truth describes the plant's current
condition rather than its history. A study of recovery has to look at
transitions in active, not at that column.
Composition
Several disturbances can be described independently, each with its own arrival
time. The original allows more than one IDV flag to be set, but not when each
arrives, and interactions are usually where the interesting behaviour is.
IDV(1) steps the A/C feed ratio and IDV(8) enables random walks on the A and
B feed compositions. Individually the plant absorbs both: reactor pressure means
of 2709.58 and 2702.19 kPa against a fault-free 2706.89. Together the mean is
2731.92 with a standard deviation of 113.07, where adding the two individual
shifts would predict 2704.87. The effect of the pair is not the sum of the
effects of the parts, which is the whole reason to be able to compose them.
Half a fault
teprob.f:341-346 opens TEFUNC by forcing every IDV to zero or one, so the
original has exactly two states per disturbance. The disturbances are then used
multiplicatively, XST(1,4) = TESUB8(1,TIME) - IDV(1)*0.03 at teprob.f:407
being the pattern, so a magnitude between zero and one scales a fault smoothly
and needs no other change to the model.
That is the continuous extension, and it is off by default. With it on, a
sweep of IDV(4) from magnitude 0 to 1 moves the mean cooling water valve from
41.023% to 44.779% in steps of about 0.9 percentage points, linear in the
magnitude to within 0.042 percentage points of valve travel, while the reactor
temperature it is defending stays at 120.400 degrees at every magnitude. A
detection threshold study is then a sweep over one number rather than an
argument about what "harder" means.
Two things are worth saying plainly. A run with the extension on is not comparable to any published dataset, and none of the validation ladder applies to it. And a fractional magnitude without the extension is refused rather than rounded, with an error naming the line of Fortran that makes it impossible. Silently turning a request for half a fault into a whole one would produce a run that does not match its own description, which is exactly what the content hash exists to prevent. A magnitude of exactly 1.0 is bit-identical to the faithful path, which is asserted, so the extension can be left on for a study that mixes full and partial faults.
The trap in refining the step
The notebook also compares Euler, RK4 and Dormand-Prince, and the result is about the original rather than about the port: RK4 and Dormand-Prince agree with each other to about 2e-6 relative while both differ from Euler by about 1.5e-2 on the worst channel. Two independent methods agreeing that closely and disagreeing with the third is what convergence looks like. Euler is not the accurate choice, it is the faithful one, and everything the validation ladder claims is a claim about Euler.
The obvious next move, keeping Euler and halving the step, does not do what you expect. Halving the step moves the answer by more than changing the method does, and it does not converge as the step shrinks. That is neither a bug nor stiffness: the disturbance walks and the measurement noise advance once per step, so a run at half the step draws twice as many random numbers and is a different realisation of the stochastic forcing. The step size is part of the disturbance model in this plant and not only a numerical parameter. Change the method to integrate the same realisation more accurately; change the step only when you mean to change the noise.
The description travels with the run
A scenario, schedule included, writes out as one line of text, that line parses back to an equal scenario, and the digest is unchanged across the round trip. A dataset generated from a scenario can carry the line in its header, and a reader can reconstruct the exact run rather than the description of a run. The same line is what TEP Studio puts in a URL fragment, so a scheduled experiment can be handed to somebody as a link.
The format is versioned and its parser is strict: a missing field, an unknown field, a value out of range or a version this build does not know is an error that says what was wrong, rather than a default quietly substituted.
repr() handles the two shapes separately, and knowing why is useful. A
scenario the constructors can express prints as a constructor call, because that
is what is readable at a prompt. Anything else prints as
Scenario.from_text(...), which round-trips by construction. Both satisfy
eval(repr(s)) == s. The obvious implementation, printing only the
constructor's arguments, produced valid Python that evaluated to a different
scenario with a different digest and said nothing about it.
A schedule holds up to 32 events, which is far more than any experiment in the literature uses. The published datasets have exactly one event each: the fault, switched on before the run.
The plant
This page is the vocabulary the rest of the process chapters use: the eight
components, the fifty states, and the thirteen internal streams. All three are
recovered from teprob.f rather than from the 1993 paper, because on two of the
three the paper and the source disagree.
The flowsheet is the map for all of it. The mixing zone, the reactor, the
separator and the stripper are the four vessels that hold state, and the state
table below is their contents; the condenser and the compressor hold none.
Every solid line is one of the thirteen FTM entries the stream table
enumerates, and every tag is an XMEAS or XMV index whose teprob.f line is
cited on the instrumentation page. Nothing on the
drawing was taken from the paper's figure. Where a stream carries two numbers
they are both shown, because that disagreement is the subject of the third
section of this page.
teprob.f wires it. Solid lines are the thirteen internal streams, each labelled with its Fortran FTM index and, where one exists, the stream number of the paper. Dashed lines are utilities: cooling water through the reactor coil and the condenser, and steam to the stripper reboiler. Beside each vessel are the XMEAS instruments and XMV valves that sit on it, and the three AT bubbles mark the streams the composition analysers sample.Three things on it are worth reading twice, because each is a place the source
and the received description of the plant part company. The mixed A and C feed,
stream 4, does not reach the mixing zone at all: it enters the stripper base as
the stripping gas (teprob.f:614-662). The pressure published as stripper
pressure, XMEAS(16), is the mixing zone's PTV (teprob.f:694), because the
model carries no separate stripper vapour space. And FTM(12), the liquid that
fails to strip out and falls into the stripper sump, has no number in the paper
at all.
Components
Eight, A through H. A, B and C are non-condensible and are treated as
ideal gases throughout (teprob.f:478). D through H are condensible and get
an Antoine vapour pressure (teprob.f:484). B is the inert: it appears in
none of the four reactions, arrives with the mixed feed, and leaves only through
the purge.
A, B and C have no real liquid density correlation. AD is 1.0 with BD
and CD zero for all three (teprob.f:973, 983, 993), so they contribute a
flat, temperature-independent term. That is a placeholder keeping the mixing
rule finite rather than a fitted number, because the model never puts them in a
liquid phase in quantity.
The fifty states
The original carries the state in a bare YY(50) and unpacks it by index
arithmetic inside TEFUNC (teprob.f:417-440). Recovering that mapping is the
first act of the port, and here it becomes typed structure, pinned by a test
that reads the corresponding COMMON/TEPROC/ variables back out of the Fortran
rather than trusting a comment.
YY (1-based) | Fortran | Meaning | Count |
|---|---|---|---|
| 1-3 | UCVR(1:3) | reactor vapour holdup, A, B, C | 3 |
| 4-8 | UCLR(4:8) | reactor liquid holdup, D through H | 5 |
| 9 | ETR | reactor internal energy | 1 |
| 10-12 | UCVS(1:3) | separator vapour holdup, A, B, C | 3 |
| 13-17 | UCLS(4:8) | separator liquid holdup, D through H | 5 |
| 18 | ETS | separator internal energy | 1 |
| 19-26 | UCLC(1:8) | stripper liquid holdup, all eight | 8 |
| 27 | ETC | stripper internal energy | 1 |
| 28-35 | UCVV(1:8) | mixing zone vapour holdup, all eight | 8 |
| 36 | ETV | mixing zone internal energy | 1 |
| 37 | TWR | reactor cooling water outlet temperature | 1 |
| 38 | TWS | condenser cooling water outlet temperature | 1 |
| 39-50 | VPOS(1:12) | valve positions, one first-order lag each | 12 |
The eight slots do not mean the same thing in every vessel, and this is the
part that is easy to get wrong. For the reactor and the separator the array is
split by phase: slots 1 to 3 are the vapour holdups of A, B and C, and slots 4
to 8 are the liquid holdups of D through H. UCLR(1..3) is set to zero at
teprob.f:420-421 because the non-condensibles never form a liquid, and
UCVR(4..8) does not come from the state at all: it is derived from the
vapour-liquid equilibrium later in the same call (teprob.f:500-501). For the
stripper all eight slots are liquid, and for the mixing zone all eight are
vapour.
The four temperatures are state, not derived quantities
TESUB2 takes its temperature argument as both the initial guess and the result
(teprob.f:1432, 1438), and the four call sites at teprob.f:460-465 pass
TCR, TCS, TCC and TCV straight out of COMMON. Every evaluation
therefore starts its Newton solves from the previous evaluation's answers, and
since the iteration stops on a step below 1e-12 the converged value depends on
where it started.
That is not a detail. B-0015 measured the cost of getting it wrong: seeding the
solves from a different point on the nominal trajectory moves up to 21 of the 50
derivatives. A port that solved from a fixed guess would be tidier and would not
be bit-exact. The warm-start temperatures are carried explicitly here, and B-0034
found the same thing again from the other end, where a trajectory started from
the nominal literals instead of from the values TEINIT's own evaluation leaves
behind is a different trajectory rather than a rounding of the same one.
| vessel | after TEINIT | nominal literal |
|---|---|---|
| reactor | 120.3999996050374 | 120.4 |
| separator | 80.1094039945582 | 80.109 |
| stripper | 65.7310297718018 | 65.731 |
| mixing zone | 86.1201119771066 | 86.120 |
Those four values are asserted against the oracle bit for bit; they are from the
LOG.org entry for B-0052.
The thirteen internal streams
The Fortran's stream indices are not the stream numbers in the paper.
FTM(1) is the D feed, which Downs and Vogel call stream 2. FTM(3) is the A
feed, which they call stream 1. Nothing in the source says so, and every
reimplementation of TEP has to rediscover it; getting it wrong produces a plant
that runs, looks plausible, and is wired up incorrectly.
| Internal | Paper | Stream |
|---|---|---|
| 1 | 2 | D feed |
| 2 | 3 | E feed |
| 3 | 1 | A feed |
| 4 | 4 | A and C feed |
| 5 | 5 | stripper overhead vapour to the mixing zone |
| 6 | 6 | mixing zone outlet to the reactor |
| 7 | 6 | reactor inlet, an alias of 6 |
| 8 | 7 | reactor outlet to the condenser and separator |
| 9 | 8 | separator vapour through the compressor, the recycle |
| 10 | 9 | purge |
| 11 | 10 | separator liquid underflow to the stripper |
| 12 | none | stripper liquid downflow, internal only |
| 13 | 11 | product |
The mapping was established from the source, not from the paper: teprob.f:565
drives FTM(1) from valve 1 and XMV(1) is documented as "D Feed Flow (stream
2)"; teprob.f:567 gates FTM(3) on IDV(6), documented as "A Feed Loss
(Stream 1)"; teprob.f:688 reports FTM(10) as XMEAS(10), "Purge Rate
(stream 9)"; teprob.f:683 reports FTM(9) as XMEAS(5), "Recycle Flow
(stream 8)"; and so on for the remaining six.
Streams 6 and 7 are the same fluid. teprob.f:656-661 copies flow, enthalpy,
temperature, composition and component flows from 6 to 7 wholesale, with no
mixing, no pressure drop and no heat loss. Stream 7 exists so that the reactor's
balance at teprob.f:763-772 can name its own inlet.
Vessel volumes
Four vessels, four fixed total volumes, all four written in the original as
single-precision literals (teprob.f:1118-1121):
| Vessel | Fortran | Value, cubic feet |
|---|---|---|
| reactor | VTR | 1300 |
| separator | VTS | 3500 |
| stripper | VTC | 156.5 |
| mixing zone | VTV | 5000 |
The reactor and the separator hold two phases, so their vapour space is whatever the liquid does not occupy. The stripper is treated as liquid only and the mixing zone as vapour only.
Precision is a property of each literal
The line above is not pedantry. 182 of the assignments in TEINIT are
single-precision literals, and a literal written without a D suffix is stored
by gfortran as a single-precision value widened to double, which differs from
the decimal number by up to about 6e-8 relative. Since the port must reproduce
the original's arithmetic bit for bit, every constant has to be transcribed
according to the suffix on its own line.
The original is not consistent, so the precision cannot be inferred from
elsewhere in the file. teprob.f:1411 writes 273.15 and teprob.f:594 writes
273.15D0. The 1.8 at teprob.f:790 and 792 is single while every other
occurrence in the file (1396, 1404, 1464, 1471) is 1.8D0. The gas
constant at teprob.f:475 is RG=998.9, single, and it multiplies six of the
eight partial pressures in every vessel.
The canary the constants table was built around is XMW(2), which must come out
25.399999618530273 and not 25.4.
The right-hand side
TEFUNC occupies teprob.f:196-816, six hundred lines that present themselves
as a derivative evaluation. They are not one. Understanding why is the single
most consequential structural decision in this port, so it comes before the unit
operations rather than after them.
Three phases, because TEFUNC is not a pure function of (t, y)
Inside what looks like a right-hand side, the original also advances the disturbance random walks, draws measurement noise, ticks the three sampled analysers, and latches the valve commands. That is harmless for the fixed-step Euler integrator the original uses, which evaluates the right-hand side exactly once per step. It is wrong for anything else: an RK4 step would advance the walks four times and draw four sets of noise.
The impure work does not sit in one place, which is the part that is not obvious
until you look. Some of it must happen before the derivative and some can only
happen after it. The walks are read at teprob.f:407-416, so they must be
advanced first. The measurement vector is assembled at teprob.f:679-701 out of
flows the evaluation computes, so noise cannot be added until afterwards. One
impure call cannot sit on both sides.
The port therefore splits the routine in three:
| Phase | teprob.f | What it does |
|---|---|---|
advance_discrete | 341-406, 793-804 | the IDV clamp, the IDVWLK mapping, the walk advance and spike draws, the TIME = 0 initialisation, and the valve-command latch |
derivatives | 407-710, 762-792, 805 | the entire physical model, the noise-free measurements, the shutdown test, and the fifty balances |
sample_measurements | 711-761 | additive measurement noise, and the three sampled analysers with their dead time |
The pure phase hands back the signals it computed alongside the derivative, so the post-phase does not have to re-run the model to find out what to add noise to.
The valve latch is hoisted, and the hoist is proved mechanically
teprob.f:793-798 sets IVST from IDV and 799-804 latches VCV from
XMV, at the very end of the routine. That block reads only XMV, VST,
IVST, IDV and TIME, and nothing in 345-792 writes any of them, so moving
it into the pre-phase changes no number.
That is a claim about four hundred and fifty lines of Fortran, which is too
large to check by eye, so it is checked by machine instead: a dedicated oracle
test drives both orderings and asserts they agree. The latch shares the DO 9020 loop with the valve derivative at teprob.f:805, so the port splits that
loop, sending the latch to the pre-phase and leaving YP(I+38) in the pure one.
What each stage of the pure phase does
The pure phase runs in the original's order, and each block is a module in
tepsim-core with its own page or section in this chapter.
teprob.f | Stage | Where it is documented |
|---|---|---|
| 417-472 | unpack the state into per-vessel inventories, fractions, temperatures, densities and volumes | The plant |
| 473-502 | vapour-liquid equilibrium and the three vessel pressures | reactor, separator, mixing zone |
| 503-528 | the four reactions, their rates and the heat of reaction | The reactor |
| 529-564 | the stream table: compositions, molecular weights, temperatures, enthalpies | The plant |
| 565-613 | valve-lagged flows, pressure-driven flows, the compressor | The condenser and separator |
| 614-662 | the stripper, and the reactor-inlet alias | The stripper |
| 663-678 | the reactor coil, the condenser, the stripper reboiler | the three vessel pages |
| 679-710 | the twenty-two continuous measurements and the shutdown detector | Instrumentation |
| 762-811 | the fifty balances | below |
The fifty balances
Everything above exists to feed teprob.f:762-811. Four vessels, each with
eight component balances and one energy balance, plus two cooling-water wall
temperatures and twelve valve lags.
\[ \frac{dn_i}{dt} = \sum_{\text{in}} \dot n_i - \sum_{\text{out}} \dot n_i + r_i \]
\[ \frac{dE}{dt} = \sum_{\text{in}} h F - \sum_{\text{out}} h F + Q \]
The reactor is the only vessel with a reaction term. The cooling water walls follow
\[ \frac{dT_w}{dt} = \frac{F_w \times 500.53 \times (T_{in} - T_w) - Q \times 10^6 / 1.8}{H_w} \]
where 500.53 converts a cooling water flow to a heat capacity rate and the
factor \(10^6 / 1.8\) undoes the scaling the enthalpy correlations carry
(teprob.f:789-792). The 1.8 on those two lines is single precision and is
the only such occurrence in the file, which is why it has to be read off the
line rather than inferred from teprob.f:1396, 1404, 1464 or 1471, where
it is written 1.8D0.
Each valve is a first-order lag toward its latched command (teprob.f:805):
\[ \frac{dv_i}{dt} = \frac{c_i - v_i}{\tau_i} \]
YP | Vessel |
|---|---|
| 1-8, 9 | reactor: component balances, then energy |
| 10-17, 18 | separator |
| 19-26, 27 | stripper |
| 28-35, 36 | mixing zone |
| 37, 38 | cooling water outlet temperatures |
| 39-50 | valve lags |
A shutdown freezes the plant
teprob.f:807-811 zeroes all fifty derivatives whenever any shutdown condition
holds. That does not stop the plant, it freezes it: the state stops moving,
the clock keeps running, and nothing in the original says so.
PLAN.org classes this as a Class C quirk, "behaviour-defining and
benchmark-relevant", so the fix needed a measured delta and a sign-off. Both
arrived on 2026-08-28, and a default Scenario now ends the run at the
trip. It is delta D-007. The port reports the trip and its cause either way,
rather than leaving a caller to infer a freeze from a vector of zeros.
Scenario::faithful() reproduces the freeze, and Tier 2 needs it to: the
adversarial sampling pool contains states that trip, and a port that did not
freeze would disagree with the oracle on all fifty components for every one of
them. So does any comparison against d06 or d18, whose published files are
between 45% and 76% frozen tail.
Determinism, and the two libm builds
tepsim-core is no_std, forbids unsafe, and contains no f32, no SIMD, no
rayon, no reordered reductions and no source of time or randomness outside the
model's own generator. exp, pow and ln come from a vendored pure-Rust
libm so that the answer does not depend on the host's C library.
That choice costs something measurable, and the project measures it rather than
hoping. Over the range of Antoine arguments this model reaches, the vendored
libm and gfortran's disagree on 9.945% of them, by exactly one ULP (LOG entry
B-0018). So a differential against the default build can only assert a
tolerance. Every Tier 2 comparison therefore runs a second time under a
libm-system feature, where exp is the one gfortran calls, and is held to
zero ULP there. Both runs are in the CI gate. Without the second, a
reassociation worth one or two ULP would pass silently.
Integer powers are a related trap and are not routed through pow at all.
gfortran expands X**4 into multiplications, and the shape of the expansion
is load-bearing. Measured over 200,000 values with this project's pinned flags
(LOG entry B-0023): (x*x)*(x*x) matches gfortran on 200,000 of 200,000,
((x*x)*x)*x on 132,040, and pow(x, 4.0) on 99,523. So it is binary
exponentiation, squaring twice, and the two plausible alternatives are each
wrong a third to half of the time.
The mixing zone
The feed mixing zone is a single vapour volume in which the three pure feeds, the compressor recycle and the stripper overhead combine before entering the reactor. It is the simplest of the four vessels: one phase, a fixed volume, no reaction, no heat duty.
Source: teprob.f:428 and 434 for its states, teprob.f:465-466 for its
temperature, teprob.f:492 for its pressure, teprob.f:576-579 for its outlet
flow, and teprob.f:783-788 for its energy balance.
Equations
All eight components are vapour and the volume is fixed at VTV, so the
equilibrium block needs only the ideal gas law applied to the mixture
(teprob.f:492):
\[ P_v = \frac{N_v R T_K}{V_{tv}} \]
The temperature comes from the specific internal energy, not from a state
directly. The block totals the holdups, forms the mole fractions and the energy
per mole, and then solves for whatever temperature makes the mixture's specific
internal energy equal to it (teprob.f:465-466):
\[ N = \sum_i n_i, \qquad x_i = \frac{n_i}{N}, \qquad e = \frac{E}{N} \]
That solve is Newton's method in TESUB2 (teprob.f:1415-1442), warm-started
from the previous evaluation's answer, which is why TCV is state rather than a
derived quantity. See The plant.
Flow out of the mixing zone is not valve-driven. It is a square-root resistance
across the pressure difference to the reactor, converted from mass to moles by
the stream's mean molecular weight (teprob.f:576-579):
\[ F_6 = \frac{1937.6 \, \sqrt{\max(P_v - P_r,\, 0)}}{\overline{M}_6} \]
The clamp at zero is what stops a reversed pressure gradient from producing a
NaN out of the square root, and it is reachable from an adversarial state
though not from the nominal trajectory.
The component and energy balances have five inlets and one outlet
(teprob.f:762-770 and 783-788):
\[ \frac{dn_i}{dt} = \dot n_{i,1} + \dot n_{i,2} + \dot n_{i,3}
- \dot n_{i,5} + \dot n_{i,9} - \dot n_{i,6} \]
\[ \frac{dE}{dt} = h_1 F_1 + h_2 F_2 + h_3 F_3 + h_5 F_5 + h_9 F_9 - h_6 F_6 \]
There is no Q term: the mixing zone is adiabatic.
Variables
| Fortran | Meaning | Where |
|---|---|---|
UCVV(1:8) | vapour component holdup, YY(28..35) | teprob.f:428 |
ETV | internal energy, YY(36) | teprob.f:434 |
UTVV | total vapour moles | teprob.f:443-449 |
XVV | vapour mole fractions | teprob.f:450-455 |
ESV | specific internal energy | teprob.f:459 |
TCV | temperature, degrees Celsius | teprob.f:465-466 |
PTV | total pressure, mmHg | teprob.f:492 |
VTV | vessel volume, 5000 cubic feet, single precision | teprob.f:1121 |
FTM(6) | outlet molar flow | teprob.f:576-579 |
YP(28..35), YP(36) | the nine derivatives | teprob.f:762-770, 783-788 |
Three things the source settles
The mixed A/C feed does not pass through here. Stream 4 goes directly to the
stripper, and it appears in the stripper's energy balance at teprob.f:778-782
and in the stripper's feed at teprob.f:614-662, never in the mixing zone's.
The three feeds that do enter are streams 1, 2 and 3, the D, E and A feeds.
Stream 7 is an alias of stream 6, made in the stripper block.
teprob.f:656-661 copies flow, enthalpy, temperature, composition and component
flows from 6 to 7 wholesale. There is no mixing, no pressure drop and no heat
loss between them; stream 7 exists so that the reactor's balance at
teprob.f:763-772 can name its own inlet.
The pressure published as stripper pressure is this vessel's. teprob.f:694
reports PTV as XMEAS(16), which Downs and Vogel's measurement table names
stripper pressure. The model carries no separate stripper vapour space: the
stripper's overhead discharges into the mixing zone as stream 5, and PTV is
the pressure of that shared vapour node. The mixing zone's own outlet flow is
reported as XMEAS(6), the reactor feed rate (teprob.f:684).
The reactor
The reactor is a two-phase vessel with an internal cooling coil and an agitator. Four exothermic gas-phase reactions run in its vapour space, its liquid holdup sets how much of the coil is wetted, and it is the only vessel in the plant with a reaction term in its balances.
Source: teprob.f:473-502 for the vapour-liquid equilibrium, teprob.f:503-528
for the kinetics, teprob.f:663-673 for the coil, and teprob.f:762-772 for
the balances.
Vapour-liquid equilibrium
The vapour space is what the liquid does not occupy (teprob.f:473):
\[ V_{vr} = V_{tr} - V_{lr} \]
A, B and C are non-condensible and are treated as ideal gases, so their partial
pressures come from the holdup directly (teprob.f:478-483):
\[ p_i = \frac{n_i R T_K}{V_{vr}}, \qquad i \in \{A, B, C\} \]
D through H are condensible, so their partial pressures come from Raoult's law
with an Antoine vapour pressure in degrees Celsius (teprob.f:484-491):
\[ p_i = x_i \exp\!\left(A_i + \frac{B_i}{T_c + C_i}\right), \qquad i \in \{D \ldots H\} \]
The total is the sum of all eight, the vapour composition is \(y_i = p_i / P\)
(teprob.f:493-496), and the vapour holdup follows from the ideal gas law
applied to the mixture (teprob.f:497 and 500):
\[ N_v = \frac{P V_{vr}}{R T_K}, \qquad n_i = N_v y_i \]
UCVR is both an input and an output
teprob.f:418 fills UCVR(1..3) from the state and teprob.f:500 fills
UCVR(4..8) from the equilibrium computed here. It is one Fortran array written
by two different mechanisms, and the halves are not interchangeable: the
non-condensibles are integrated, the condensibles are derived. Read in that
order it also explains why the A, B and C partial pressures are computed first,
at teprob.f:478-483: they need UCVR before it is overwritten.
This is where bit equality with gfortran ends
DEXP at teprob.f:485 and teprob.f:488 is the model's first transcendental
call. The port answers it from the vendored pure-Rust libm rather than the
platform's, for the determinism reason set out in The right-hand
side. Measured over the whole Antoine range this model
reaches, the two disagree on 9.945% of arguments, by exactly one ULP.
Everything downstream of a condensible partial pressure therefore carries about
1.1e-16 of relative difference from the Fortran that no care in the algebra
removes. The bit-exactness claim does not disappear, it moves: under the
libm-system feature the transcendental is the one gfortran calls, and the port
is bit-identical again, so the algebra is still held to zero ULP rather than to
a tolerance.
Kinetics
1: A + C + D -> G 2: A + C + E -> H
3: A + E -> F 4: 3 D -> 2 F
Rates 1 and 2 are Arrhenius in reactor temperature with fractional pressure
orders on A and C, multiplied by a disturbance drift factor
(teprob.f:503-504, 508-511):
\[ r_1 = f_1 \, e^{\,a_1 - E_1/T_K} \; p_A^{1.1544} \, p_C^{0.3735} \, p_D \, V_{vr} \]
\[ r_2 = f_2 \, e^{\,a_2 - E_2/T_K} \; p_A^{1.1544} \, p_C^{0.3735} \, p_E \, V_{vr} \]
Both are guarded: teprob.f:507 requires \(p_A > 0\) and \(p_C > 0\), and
sets \(r_1 = r_2 = 0\) otherwise. Rates 3 and 4 are first order in each
reactant, and rate 4 shares rate 3's exponential rather than having one of its
own (teprob.f:505-506, 516-517):
\[ r_3 = e^{\,a_3 - E_3/T_K} \, p_A \, p_E \, V_{vr}, \qquad r_4 = 0.767488334 \; e^{\,a_3 - E_3/T_K} \, p_A \, p_D \, V_{vr} \]
All four are multiplied by the vapour volume at teprob.f:518-520, so a rate is
an extent in moles per hour rather than a volumetric rate. Net production per
species follows the stoichiometry (teprob.f:521-527), and the heat release
comes from reactions 1 and 2 only (teprob.f:528):
\[ Q_{rxn} = r_1 h_1 + r_2 h_2 \]
with \(h_1 = 0.06899381054\) and \(h_2 = 0.05\) (teprob.f:1122-1123).
Reactions 3 and 4 contribute no heat: the original simply does not include them
in RH, and HTR(3) is declared but never assigned or read.
R1F and R2F are two different quantities under one name
They arrive from TESUB8(7) and TESUB8(8) at teprob.f:415-416 as the
IDV(13) kinetics-drift multipliers, are consumed at teprob.f:503-504, and
are then reassigned in place at teprob.f:508-509 to hold the fractional
pressure powers. The two meanings share nothing but the storage.
Reading teprob.f:510 as though R1F were still the drift factor gives a
plausible and completely wrong rate law, so the port gives the two roles
separate names. That is delta D-002: no numerical effect, and the only defence
against a misreading no test would catch, because a wrong-but-consistent reading
still reproduces itself.
CRXR(2) is never assigned
Seven of the eight slots are written at teprob.f:521-527. CRXR(2), the
inert, is not, and it is read anyway at teprob.f:763. It works because
COMMON is zero-initialised and nothing ever writes it, so B's net production is
zero by static initialisation rather than by statement. That is delta D-003,
class A: the value is right, the mechanism is an accident. Here the slot is
explicitly zero and a test asserts the oracle agrees.
Precision hazards in this range
Every literal except 0.767488334D0 and 1.5D0 is single precision: the three
pre-exponentials, the three activation energies, the gas constant 1.987, and
both fractional exponents.
Worse, 40000.0/1.987 at teprob.f:503 is a quotient of two
single-precision literals, so Fortran evaluates the division itself in single
precision. Widening the operands first and dividing in double is wrong by 4e-9
relative, inside a DEXP argument.
The cooling coil
The coil's effective area ramps with liquid level, because the coil is only
wetted over part of its height. VLR/7.8 is the level as a percentage, and the
ramp is piecewise linear between 10% and 50% (teprob.f:663-669):
\[ \lambda = \begin{cases} 1 & \ell > 50 \\ 0 & \ell < 10 \\ 0.025\,\ell - 0.25 & \text{otherwise} \end{cases} \qquad \ell = V_{lr} / 7.8 \]
The overall coefficient is quadratic in agitator speed (teprob.f:670-671), and
the duty is the coefficient times the driving temperature difference, scaled by
a disturbance drift factor (teprob.f:672-673):
\[ U A_r = \lambda \left(-0.5\,\omega^2 + 2.75\,\omega - 2.5\right) \times 855490 \times 10^{-6} \]
\[ Q_r = U A_r \, (T_w - T_c) \, (1 - 0.35 \, d_{10}) \]
That parabola peaks at \(\omega = 2.75\), which is above the agitator's whole
range: teprob.f:575 puts it between 1.5 and 2.5. So the coefficient rises
monotonically with speed everywhere the plant can go, from 0.5 to 1.25 times the
scale, and the falling half of the parabola is unreachable. Its roots are at
1.149 and 4.351, both outside that range too, so the coefficient never reaches
zero from the agitator alone. The model is a fit, not a mechanism.
The ramp does not quite meet its flat sections
0.025 at teprob.f:668 is single precision, so it is stored as
0.02500000037252903 and the ramp misses both of its endpoints:
| level | ramp gives | flat section gives | gap |
|---|---|---|---|
| 10 | 3.725290298461914e-9 | 0 | 3.7e-9 |
| 50 | 1.0000000186264515 | 1 | 1.9e-8 |
Both breakpoint comparisons are strict, so a level of exactly 10 or exactly 50
takes the ramp, and UARLEV is discontinuous by those amounts as the level
crosses either one. This is faithful reproduction rather than a delta: it is
what the original computes, the gaps are eight orders below the quantity itself,
and the coefficient they scale is an empirical fit in the first place. It is
written down because "the ramp meets the flat sections" is the obvious
assumption, it is false, and a test asserting it would fail for a reason that
looks like a porting error.
Balances
Inlet is stream 7, outlet is stream 8, and the reaction term is the only one of
its kind in the plant (teprob.f:762-772):
\[ \frac{dn_i}{dt} = \dot n_{i,7} - \dot n_{i,8} + r_i, \qquad \frac{dE}{dt} = h_7 F_7 - h_8 F_8 + Q_{rxn} + Q_r \]
QUR is heat removed, so it enters positive here because \(U A_r (T_w -
T_c)\) is already negative when the coil is cooling. The reactor's cooling
water wall temperature is YP(37) (teprob.f:789-790).
Variables
| Fortran | Meaning | Where |
|---|---|---|
VTR, VLR, VVR | total, liquid and vapour volume | teprob.f:1118, 470, 473 |
PPR(1:8), PTR | partial and total pressure, mmHg | teprob.f:479, 486, 487 |
XVR, XLR | vapour and liquid mole fractions | teprob.f:494, 451 |
UTVR, UCVR | total and per-component vapour moles | teprob.f:497, 500 |
TCR, TKR | temperature, Celsius and kelvin | teprob.f:460-461 |
RR(1:4) | extent of each reaction | teprob.f:503-520 |
CRXR(1:8) | net production per species | teprob.f:521-527 |
RH | heat of reaction | teprob.f:528 |
HTR(1:2) | heats of reactions 1 and 2 | teprob.f:1122-1123 |
AGSP | agitator speed, fraction of nominal | teprob.f:575 |
UARLEV, UAR, QUR | wetted fraction, coefficient, duty | teprob.f:663-673 |
TWR | cooling water outlet temperature, YY(37) | teprob.f:435 |
YP(1..8), YP(9) | component and energy derivatives | teprob.f:762-772 |
Three of the eight shutdown conditions belong to this vessel: reactor pressure
above 3000 kPa gauge (teprob.f:703), liquid volume outside 2 to 24 cubic
metres (teprob.f:704-705), and temperature above 175 degrees Celsius
(teprob.f:706). See Instrumentation.
The condenser and separator
The condenser cools the reactor effluent and the separator splits it into a vapour and a liquid. The vapour leaves through two paths, the compressor recycle and the purge; the liquid goes to the stripper. The compressor and its recycle valve belong here too, because their whole job is deciding how much of the separator's vapour goes back around the loop.
Source: teprob.f:473-502 for the equilibrium, teprob.f:585-601 for the flow
network and the compressor, teprob.f:674-676 for the condenser duty, and
teprob.f:773-777 for the balances.
Vapour-liquid equilibrium
The separator's equilibrium has exactly the shape of the reactor's
and shares its code path. The vapour space is VTS less the liquid volume
(teprob.f:474); A, B and C get ideal gas partial pressures (teprob.f:481);
D through H get Raoult's law with an Antoine vapour pressure (teprob.f:488-490);
the composition is \(y_i = p_i / P\) (teprob.f:495); and the vapour holdup
comes back out of the ideal gas law at teprob.f:498 and 501.
The one number worth keeping in mind is that PTS floors around 811 mmHg over
the whole sampled domain. That matters for the purge clamp below.
The condenser
A smooth saturating function of reactor outlet flow, approaching 0.404655 as the
flow grows (teprob.f:674), with the duty taken against the stream 8
temperature rather than the separator's own, and scaled by a disturbance drift
factor (teprob.f:675-676):
\[ U A_s = 0.404655 \left(1 - \frac{1}{1 + (F_8/3528.73)^4}\right) \]
\[ Q_s = U A_s \, (T_{ws} - T_{st,8}) \, (1 - 0.25 \, d_{11}) \]
**2 and **4 are integer powers and must not go through pow
gfortran expands an integer exponent into multiplications rather than calling libm, and the shape of that expansion is load-bearing. Measured over 200,000 values with this project's pinned flags:
candidate for X**4 | matches gfortran |
|---|---|
(x*x)*(x*x) | 200,000 of 200,000 |
((x*x)*x)*x | 132,040 |
pow(x, 4.0) | 99,523 |
So it is binary exponentiation, squaring twice, and the two plausible
alternatives are each wrong about a third and a half of the time. X**2 is
x*x on all 200,000, which is the only thing it could be.
Three ways out
The underflow to the stripper is valve-lagged, and is the simple case
(teprob.f:570):
\[ F_{11} = \frac{v_7 R_7}{100} \]
The purge is pressure-driven to atmosphere through valve 6
(teprob.f:585-588):
\[ F_{10} = \frac{v_6 \times 0.151169 \times \sqrt{\max(P_s - 760,\, 0)}}{\overline{M}_{10}} \]
That clamp at zero is the one clamp in the whole flow network that cannot be
reached. PTS is a sum of eight partial pressures in a vessel that always
holds material, and it floors around 811 mmHg against a threshold of 760, so no
trajectory state, no random perturbation and no adversarial boundary takes that
branch. It is therefore covered by a unit test at a composition chosen to reach
it, rather than by the differential, on the principle that a branch no test
enters is indistinguishable from a branch that is wrong.
The recycle goes through the compressor. The operating point on the curve is
the pressure ratio between the mixing zone and the separator, clamped at both
ends (teprob.f:589-591), and the machine is fixed-speed with a cubic
pressure-ratio curve (teprob.f:592-593). The recycle valve then bleeds flow
back and the result has a floor (teprob.f:596-599):
\[ \dot m = \max\!\left( F_{\max}\left(1 + \frac{1 - r^3}{1.197}\right) - v_5 \times 53.349 \times \sqrt{\max(P_v - P_s,\, 0)}, \; 10^{-3} \right) \]
with \(r = \mathrm{clamp}(P_v / P_s,\, 1,\, 1.3)\), \(F_{\max} = 280275\)
(teprob.f:1170) and the ratio ceiling CPPRMX at 1.3 (teprob.f:1171). The
floor at \(10^{-3}\) exists so that the division at teprob.f:600-601 cannot
blow up.
PR**3 at teprob.f:593 is an integer power, for the same reason **4 is
above, and is written out as three multiplications rather than routed through
pow.
The compressor work appears twice: as an enthalpy bump on the recycle stream,
and as measurement 20 (teprob.f:594-595, 601, 699):
\[ W = \dot m \, (T_{cs} + 273.15) \times 1.8 \times 10^{-6} \times 1.9872 \times \frac{P_v - P_s}{\overline{M}_9 P_s} \]
Note that the 273.15 on that line is written 273.15D0, double precision,
unlike the one in TESUB2 at teprob.f:1411. The original is not consistent
about that constant and each occurrence has to be read off its own line.
HST(10) = HST(9) is a snapshot, not an alias
teprob.f:562 copies the separator vapour enthalpy into the purge. Both streams
leave the separator vapour space, so at that moment they are the same fluid at
the same temperature and the copy is exact.
Then teprob.f:601 adds the compressor work to HST(9). The recycle gains the
work; the purge does not, because it was copied first. Reading line 562 as an
alias rather than as a copy would give the purge a share of compressor work it
never receives, and the two lines are seventy apart, so the ordering is easy to
miss. Both energy balances that read stream 9 come after line 601 and therefore
see the bumped value.
Balances
One inlet, three outlets, and the condenser duty (teprob.f:762-770 and
773-777):
\[ \frac{dn_i}{dt} = \dot n_{i,8} - \dot n_{i,9} - \dot n_{i,10} - \dot n_{i,11} \]
\[ \frac{dE}{dt} = h_8 F_8 - h_9 F_9 - h_{10} F_{10} - h_{11} F_{11} + Q_s \]
The condenser cooling water wall temperature is YP(38) (teprob.f:791-792).
Variables
| Fortran | Meaning | Where |
|---|---|---|
VTS, VLS, VVS | total, liquid and vapour volume | teprob.f:1119, 471, 474 |
PPS(1:8), PTS | partial and total pressure, mmHg | teprob.f:481, 489, 490 |
XVS, XLS | vapour and liquid mole fractions | teprob.f:495, 452 |
UTVS, UCVS | total and per-component vapour moles | teprob.f:498, 501 |
TCS, TKS | temperature, Celsius and kelvin | teprob.f:462-463 |
DLS | liquid molar density | teprob.f:468 |
UAS, QUS | condenser coefficient and duty | teprob.f:674-676 |
PR, CPPRMX | compressor pressure ratio and its ceiling | teprob.f:589-591, 1171 |
CPFLMX | maximum compressor flow | teprob.f:1170 |
CPDH | compressor enthalpy bump | teprob.f:594-595 |
FTM(9), FTM(10), FTM(11) | recycle, purge, underflow | teprob.f:600, 588, 570 |
TWS | cooling water outlet temperature, YY(38) | teprob.f:436 |
YP(10..17), YP(18) | component and energy derivatives | teprob.f:762-770, 773-777 |
Two of the eight shutdown conditions belong to this vessel: separator liquid
volume above 12 or below 1 cubic metre (teprob.f:707-708).
The stripper
Steam strips light components out of the separator liquid. What leaves overhead rejoins the mixing zone as stream 5; what does not becomes the stripper's own liquid, stream 12, which leaves as the product, stream 13. The vessel is treated as liquid only: it has no vapour space of its own in the model.
Source: teprob.f:614-662 for the column, teprob.f:677-678 for the reboiler,
and teprob.f:778-782 for the energy balance.
Equations
The feed is the mixed A/C feed plus the separator underflow (teprob.f:635-639).
Note that stream 4 arrives here directly rather than through the mixing zone:
\[ f_i = \dot n_{i,4} + \dot n_{i,11} \]
A vapour-to-liquid ratio sets how hard the column strips, scaled by a
temperature factor (teprob.f:622):
\[ \Lambda = \frac{F_4}{F_{11}} \, \tau(T_c) \]
Each condensible then strips according to a Langmuir-shaped saturating function
of that ratio (teprob.f:623-627):
\[ s_i = \frac{k_i \Lambda}{1 + k_i \Lambda}, \qquad i \in \{D \ldots H\} \]
| species | \(k_i\) |
|---|---|
| D | 8.5010 |
| E | 11.402 |
| F | 11.795 |
| G | 0.0480 |
| H | 0.0242 |
and the split is simply (teprob.f:643-644)
\[ \dot n_{i,5} = s_i f_i, \qquad \dot n_{i,12} = f_i - \dot n_{i,5} \]
Both product streams leave at the stripper's own temperature (teprob.f:652-653),
and their enthalpies are taken on different bases: stream 5 with ITY = 1, the
vapour basis, and stream 12 with ITY = 0, the liquid one (teprob.f:654-655).
The temperature factor has a pole at 177 C
\[ \tau(T) = \begin{cases} T - 120.262 & T > 170 \\ 0.1 & T < 5.292 \\ \dfrac{363.744}{177 - T} - 2.22579488 & \text{otherwise} \end{cases} \]
from teprob.f:615-621. The middle branch diverges at 177 C, which is inside
the range the two outer branches leave for it only if TCC exceeds 170, and it
does not: the T > 170 branch takes over first. So the pole is unreachable by
seven degrees, and the two branches are continuous to within 0.1% at 170.
The adversarial state catalogue built for Tier 2 places a state at 176 C anyway, to sit near the pole and confirm that it stays on the linear branch. That state is coverage of the guard, not of the pole.
FTM(11) > 0.1 switches the whole block
Below that threshold the column is not really running, and teprob.f:629-633
substitutes five fixed stripping factors (0.9999, 0.999, 0.999, 0.99, 0.98)
rather than evaluating the correlation. The reason is visible in the arithmetic:
\(\Lambda = F_4 / F_{11}\) diverges as \(F_{11} \to 0\).
Both sides are covered by the adversarial catalogue, which places a state
exactly on FTM(11) = 0.1. Since the test at teprob.f:614 is .GT., that
state takes the fixed-factor branch.
SFR(1..3) are never recomputed
teprob.f:623-627 and 629-633 both write slots 4 through 8 only. Slots 1, 2
and 3 are set once in TEINIT (teprob.f:1126-1128) and are read at
teprob.f:643 on every evaluation, so A, B and C strip at a fixed 99.5%, 99.1%
and 99.0% no matter what the column is doing.
That is the intended physics rather than an oversight: the non-condensibles are
gases, they leave overhead essentially completely, and no temperature or flow
ratio in the plant's range would change that. It is worth stating because the
loop at teprob.f:643 runs I=1,8 and looks as though all eight factors come
from the branch above it.
The reboiler
Steam is at 100 C, so above that there is nothing to transfer and the original
sets the duty to zero rather than letting it go negative (teprob.f:677-678):
\[ Q_c = \begin{cases} U A_c \, (100 - T_c) & T_c < 100 \\ 0 & \text{otherwise} \end{cases} \]
The coefficient UAC is not computed here. It is a valve-lagged capacity with a
disturbance drift factor, set at teprob.f:572:
\[ U A_c = \frac{v_9 R_9 (1 + d_9)}{100} \]
which is the point at which IDV(16), published as "Unknown", enters the model.
The nominal trajectory sits near 65 C, so the cutoff is the branch at risk of never being exercised. B-0021 measured 300 of 300 nominal states below 100.
The reactor inlet is an alias, and this is where it is made
teprob.f:656-661 copies flow, enthalpy, temperature, composition and component
flows from stream 6 to stream 7 wholesale. There is no mixing, no pressure drop
and no heat loss between them: stream 7 exists so that the reactor's balance at
teprob.f:763-772 can name its own inlet. It lives in the stripper block for no
reason other than that is where the original put it.
Balances, and an asymmetry between them
The component balances are a straight pass-through of the two streams the column
produced (teprob.f:762-770), while the energy balance names the column's
inputs instead (teprob.f:778-782):
\[ \frac{dn_i}{dt} = \dot n_{i,12} - \dot n_{i,13} \]
\[ \frac{dE}{dt} = h_4 F_4 + h_{11} F_{11} - h_5 F_5 - h_{13} F_{13} + Q_c \]
That is what the source does, and the two forms describe the same vessel: the
component split at teprob.f:643-644 conserves moles by construction, so
streams 4 and 11 in, less stream 5 out, is stream 12.
Variables
| Fortran | Meaning | Where |
|---|---|---|
VTC, VLC | vessel and liquid volume | teprob.f:1120, 472 |
UCLC(1:8) | liquid component holdup, YY(19..26) | teprob.f:427 |
ETC | internal energy, YY(27) | teprob.f:433 |
XLC | liquid mole fractions | teprob.f:453 |
TCC | temperature, degrees Celsius | teprob.f:464 |
DLC | liquid molar density | teprob.f:469 |
TMPFAC | temperature scaling | teprob.f:615-621 |
VOVRL | vapour-to-liquid ratio | teprob.f:622 |
SFR(1:8) | fraction of each species stripped | teprob.f:623-633, 1126-1128 |
FIN(1:8) | combined feed to the column | teprob.f:635-639 |
UAC, QUC | reboiler coefficient and duty | teprob.f:572, 677-678 |
YP(19..26), YP(27) | component and energy derivatives | teprob.f:762-770, 778-782 |
Two of the eight shutdown conditions belong to this vessel: stripper liquid
volume above 8 or below 1 cubic metre (teprob.f:709-710). Its level
measurement is also the odd one out among the three, because its span is the
vessel volume VTC itself rather than a separately hard-coded range
(teprob.f:693).
Instrumentation
Forty-one measurements come out of the plant, and they are not all the same kind
of thing. XMEAS(1..22) are continuous instruments, read every step, computed at
teprob.f:679-701 and given additive noise at teprob.f:711-735.
XMEAS(23..41) are composition analysers, read on a schedule and reporting the
composition from their previous sample (teprob.f:736-761).
Nothing in the continuous block is physics. Every one of the twenty-two lines takes a quantity the model has already computed and converts it into the unit an operator's instrument would read. Getting a conversion wrong changes no state and no derivative; it changes only what the controller sees, which is worse, because the plant then runs correctly and is controlled wrongly.
The conversion factors
| Factor | Where | Meaning |
|---|---|---|
0.359 | 679, 682-684, 688 | standard cubic feet per lbmol |
35.3145 | the same lines, and 692, 695, 704-710 | cubic feet per cubic metre |
0.454 | 680, 681, 697 | kilograms per pound |
760 | 685, 691, 694 | mmHg per atmosphere |
101.325 | the same three | kPa per atmosphere |
So FTM * 0.359 / 35.3145 is lbmol/h to standard cubic metres per hour, and
(P - 760)/760 * 101.325 is mmHg absolute to kPa gauge. Levels are reported as
a percentage of a span, and the three vessels do not do it the same way: the
reactor and the separator carry hard-coded ranges (teprob.f:686, 690) while
the stripper's span is the vessel volume VTC itself (teprob.f:693).
XMEAS(20) is assigned twice
XMEAS(20)=CPDH*0.0003927D6
XMEAS(20)=CPDH*0.29307D3
at teprob.f:698-699. The first is dead, and the two factors are not equal:
392.7 against 293.07, a third apart. So this is not a harmless duplicate but a
superseded conversion, and a port that took the first line would report
compressor work 34% high. That is delta D-006.
The shutdown detector
Eight limits, checked at teprob.f:702-710:
| Condition | Limit | Line |
|---|---|---|
| reactor pressure high | above 3000 kPa gauge | 703 |
| reactor level high | above 24 cubic metres | 704 |
| reactor level low | below 2 cubic metres | 705 |
| reactor temperature high | above 175 C | 706 |
| separator level high | above 12 cubic metres | 707 |
| separator level low | below 1 cubic metre | 708 |
| stripper level high | above 8 cubic metres | 709 |
| stripper level low | below 1 cubic metre | 710 |
The original records only that something tripped, in a single integer ISD.
This port reports which, because "the plant tripped" without a reason is nearly
useless to a caller and the information is free.
All eight comparisons are strict, so a state exactly on a limit does not trip. That matters for how the adversarial sampling pool is built: states placed on the limits exercise the not-tripped side, and the tripping side needs states past them. Both were built.
Two of the eight are phrased in terms of XMEAS rather than the underlying
quantity. teprob.f:703 tests the converted reactor pressure against 3000 kPa
gauge, and teprob.f:706 tests XMEAS(9), which is TCR unconverted. Testing
PTR against an equivalent mmHg threshold instead would be arithmetically
different in the last bits.
What a trip does is described in The right-hand side: it
freezes all fifty derivatives (teprob.f:807-811), which is delta D-007.
Noise and dead time
Noise is drawn by TESUB6 (teprob.f:1538-1546), twelve uniform draws summed
and scaled, and is skipped entirely at TIME = 0 and on a tripped plant
(teprob.f:711). Only the continuous noise is skipped, though. The analyser
blocks at teprob.f:744-761 have no such guard, so a tripped plant still draws:
258 draws in a tripped evaluation against 522 in a healthy one, measured in
B-0027. A port that silenced everything on a trip would leave the generator 264
steps behind and desynchronise every later draw.
The dead time is a latch, and the order of two lines makes it:
XMEAS(I)=XDEL(I)
CALL TESUB6(XNS(I),XMNS)
XMEAS(I)=XMEAS(I)+XMNS
XDEL(I)=XCMP(I)
The reported value is taken from the store before the store is updated. Swapping those two lines gives an analyser with no dead time at all, which produces entirely plausible numbers and a plant that is much easier to control than the real one.
Three further details are about when rather than about what. The schedules
advance from their own previous value rather than from the current time (TGAS = TGAS + 0.1 at teprob.f:751), so a step arriving late does not shift the
schedule. Both 0.1 literals, at teprob.f:741 and 751, are single
precision, so the gas interval is 0.10000000149011612 and a step landing on
exactly 0.1 does not sample; 0.25 is exactly representable, so the product
analyser is unaffected, which is precisely the kind of inconsistency that has to
be read off the line rather than inferred from its neighbour. And at TIME = 0
the analysers are primed rather than sampled (teprob.f:736-743): the store and
the reported value are both set to the current composition, with no noise and no
draw.
The 53 channels
Measurements and manipulated variables together are the 53 columns every downstream consumer sees, in this order. The measurement names follow Downs and Vogel's Table 4 and the manipulated ones their Table 3.
| # | XMEAS | # | XMEAS |
|---|---|---|---|
| 1 | A feed | 22 | condenser cooling water outlet |
| 2 | D feed | 23 | reactor feed, A |
| 3 | E feed | 24 | reactor feed, B |
| 4 | total feed | 25 | reactor feed, C |
| 5 | recycle flow | 26 | reactor feed, D |
| 6 | reactor feed rate | 27 | reactor feed, E |
| 7 | reactor pressure | 28 | reactor feed, F |
| 8 | reactor level | 29 | purge, A |
| 9 | reactor temperature | 30 | purge, B |
| 10 | purge rate | 31 | purge, C |
| 11 | separator temperature | 32 | purge, D |
| 12 | separator level | 33 | purge, E |
| 13 | separator pressure | 34 | purge, F |
| 14 | separator underflow | 35 | purge, G |
| 15 | stripper level | 36 | purge, H |
| 16 | stripper pressure | 37 | product, D |
| 17 | stripper underflow | 38 | product, E |
| 18 | stripper temperature | 39 | product, F |
| 19 | stripper steam flow | 40 | product, G |
| 20 | compressor work | 41 | product, H |
| 21 | reactor cooling water outlet |
XMEAS(23..28) and XMEAS(29..36) are the two gas analysers, on a 0.1 hour
schedule; XMEAS(37..41) is the product analyser, on 0.25 hours.
| # | XMV | # | XMV |
|---|---|---|---|
| 1 | D feed flow | 7 | separator underflow |
| 2 | E feed flow | 8 | stripper underflow |
| 3 | A feed flow | 9 | stripper steam |
| 4 | total feed flow | 10 | reactor cooling water flow |
| 5 | compressor recycle | 11 | condenser cooling water flow |
| 6 | purge valve | 12 | agitator speed |
XMV(12), the agitator, is never written by any controller the driver calls, so
in a closed-loop run it has zero variance. That is a fact about the control
scheme rather than about the plant, and it is the reason the Tier 5 harness had
to learn to report a degenerate ensemble as NaN rather than as a number.
Validation
This is the part of the project that determines whether anyone trusts the result, so it gets the most care. The strategy is a ladder: prove the pieces exactly, prove the derivative to near machine precision, prove the stochastic call order exactly, then prove the long-run behaviour statistically and, finally, prove it on the downstream task people actually care about.
Every number on this page was measured, and each one names the LOG.org
iteration that measured it. None of them is a target, a plan, or a rounded
recollection. The project's operating rule is to record numbers rather than
verdicts, precisely so that a degradation inside tolerance is still visible: if
a maximum relative error moves from 3e-14 to 8e-13, both pass a 1e-12 gate and
something has broken, and the only place that is visible is the logged history.
The figures are held to the same rule. Each one is drawn by cargo xtask validate from the run's own output, committed alongside the generated
chapters, and captioned with the condition that would make it false. Nothing in
one is placed by hand: a marker is orange because its value is on the wrong side
of a gate, never because a test's name was recognised, so a genuine regression
and a deliberate positive control are drawn identically and the caption is what
tells them apart.
All numbers below were produced with gfortran 15.2.0 and the pinned rustc
1.97.1. The oracle's compiler flags are fixed in build.rs and asserted by a
test; changing them invalidates every Tier 1 and Tier 2 number here, so it is a
logged re-baseline rather than a casual edit.
The oracle harness
tepsim-oracle is a development-only crate whose build.rs compiles the
unmodified teprob.f and temain_mod.f with gfortran and links them through a
small C shim exposing TEINIT, TEFUNC, each TESUBn, and read and write
access to every COMMON block. A Rust test can therefore set the Fortran into
an arbitrary state, call it, and compare against the Rust implementation in the
same process.
That crate is never a dependency of tepsim, tepsim-py or tepsim-wasm. It
runs on Linux and macOS runners where gfortran is available, and building it
first is what converts the whole port from "read carefully and hope" into a
differential testing exercise.
Alongside it sits a cheaper check that needs no Fortran at all. cargo xtask fidelity runs the port forward 100 steps from the nominal state and diffs
states, derivatives, measurements and the generator word against a golden trace
committed to the repository. It takes about a second and runs at the top of
every session. Since B-0026 it has reported 100 of 100 steps diffed, worst
3.521e-14 at YP(12) step 77 against a 1e-12 gate, and that same number has
now been recorded unchanged for eleven consecutive iterations, most recently in
the entry for B-0052.
Tier 1: the utility routines, exactly
TESUB1 (enthalpy), TESUB2 (temperature from enthalpy by Newton), TESUB3
(heat capacity) and TESUB4 (liquid density) are swept over a simplex grid, ten
million random Dirichlet samples, and a boundary pool, at every temperature in
the physical range, for each of the three ITY modes. The gate PLAN.org sets
is a maximum relative error below 1e-13 with a ULP histogram reported rather
than a pass or fail.
The measured result is not "inside 1e-13". It is zero.
| routine | ITY | cases | max relative error | max ULP | histogram | from |
|---|---|---|---|---|---|---|
TESUB1 | 0 | 9,987,490 | 0.000e0 | 0 | 0:9987490 | B-0009 |
TESUB1 | 1 | 9,987,490 | 0.000e0 | 0 | 0:9987490 | B-0009 |
TESUB1 | 2 | 9,987,490 | 0.000e0 | 0 | 0:9987490 | B-0009 |
TESUB3 | 0 | 9,987,490 | 0.000e0 | 0 | 0:9987490 | B-0009 |
TESUB3 | 1 | 9,987,490 | 0.000e0 | 0 | 0:9987490 | B-0009 |
TESUB3 | 2 | 9,987,490 | 0.000e0 | 0 | 0:9987490 | B-0009 |
TESUB4 | n/a | 9,987,490 | 0.000e0 | 0 | 0:9987490 | B-0010 |
That is 59,924,940 evaluations across TESUB1 and TESUB3 with zero differing
bits, p50 = p90 = p99 = p100 = 0 in all six sweeps. A separate test asserts bit
equality directly, because a 1e-13 threshold would let a drift to 1e-15 pass
unnoticed forever.
The 1e-13 gate is therefore not what is holding the line, and the entry for B-0009 says why that is the right expectation: both routines are straight-line arithmetic over constants already proved bit-identical, so once the association and the literal precisions are right there is nothing left to differ by. Any future Tier 1 routine that lands at 1e-15 rather than at zero has something wrong with it that a tolerance would hide.
The sabotage check. Substituting a double-precision 273.15 for the widened
f32 at teprob.f:1411, and changing nothing else, gives a maximum relative
error of 1.597e-5 at grid#704 T=21.875, 103,098,852,352 ULP (B-0009). That
is the size of the error a single mis-transcribed literal produces, and it is why
constants in this project are transcribed and asserted rather than retyped.
TESUB2 is the Newton solve, and it is measured from two starting
strategies (B-0011). A warm start, which is what every call site actually does,
converges in one step and recovers the answer exactly: round-trip error 0e0. A
cold start from the far end of the range is what exercises the iteration, and its
worst round trip is 5.12e-13 C, at dirichlet#720561 T=171.86,
face#1309309 T=146.56 and face#96 T=144.14. Over 59,924,940 solves there
were zero differing bits and zero abandoned iterations, which is the
measurement that makes delta D-001's effect zero on the physical domain.
TESUB7, the random generator, must be exact and is (B-0005): 10^7 draws
from the compiled-in seed with the XOR fold and the final state both matching;
10^6 draws each from five dataset seeds; draw-by-draw comparison over 200,000
draws for four seeds with every draw and every intermediate state bit-identical;
and interleaved output modes over 50,000 draws on an irregular pattern. Its
exactness rests on reproducing the rounding rather than removing it: the product
exceeds 2^53 on 0.7716 of draws, against 0.7728 predicted from the arithmetic,
so a "fixed" integer recurrence diverges at draw 0.
TESUB5 and TESUB6 are exact in both libm configurations, since neither
touches a transcendental, and their draw counts were recovered independently by
stepping a port-side generator from the word before each call to the word after:
3 draws for TESUB5, 12 for TESUB6, on every case and for both flag
values (B-0028).
Tier 2: single-step derivative equivalence
Both implementations are forced into an identical state, all fifty states, the
twelve manipulated variables, the twenty IDV flags, the full walk state, the
generator word and the nineteen held analyser readings, evaluated once, and
compared on all fifty components. Sampling is from three pools: states along the
nominal closed-loop trajectory, random perturbations of those states scaled
across several orders of magnitude, and adversarial states placed deliberately
at every discontinuity and clamp in the model.
The tolerance is relative to the scale of the terms, not to the result, and
that is a decision with a measurement behind it rather than a relaxation. A
balance is inflow minus outflow, and near steady state those nearly agree.
YP(2), the inert's reactor balance, is a difference of two flows around 660
whose result is a few parts in ten thousand of either. One ULP of difference in
each term, which is all the vendored libm costs, is 1e-16 of the terms and
1e-4 of the result. Measured against the result, 28 of the 50 components exceed
1e-12 while the whole right-hand side is bit-identical to gfortran under
libm-system. Each balance therefore reports the magnitude of its largest term
alongside its value, and the gate is the error over that.
Acceptance, from B-0026: all fifty components, 2,412 running states, all
three pools, worst 6.093e-14 at YP(7), perturbed#300, against a 1e-12
gate.
The whole tier fits in one picture. Every comparison it makes, run twice, once
against each libm, plotted at its own maximum relative error:
libm-system build, where both sides call the same exp and the claim is bit equality rather than a tolerance; every one of them is in the zero lane, and one leaving it would falsify that claim. Grey dots are the vendored libm, and every one of them is inside the gate, though one in tier2_heat sits close enough to it to be worth hovering over: a table of maxima hides which comparison is nearest the line, and this does not. Any dot crossing into the shaded region would be a failure. The figure is written by cargo xtask validate whenever it runs Tier 2, from that run's own output; the measurements are the ones recorded for B-0026 in LOG.org, and the generated Tier 2 chapter carries the same figure with the exact command and commit that drew it, plus the same data as a table.YP | error / scale | error / value | ratio |
|---|---|---|---|
| 2 | 3.811e-14 | 1.393e-4 | 3.7e9 |
| 30 | 4.552e-15 | 2.487e-6 | 5.5e8 |
| 38 | 8.696e-15 | 1.187e-6 | 1.4e8 |
| 14 | 3.113e-14 | 2.618e-6 | 8.4e7 |
| 7 | 6.093e-14 | 7.835e-7 | 1.3e7 |
| 19-27, 37, 39-50 | 0.000e0 | 0.000e0 | 1x |
28 of 50 components cancel by more than 100x, and the 22 that do not are exactly 0.000e0: bit-identical rather than merely inside the gate. The acceptance test asserts that contrast and fails if the count of heavily cancelling components drops below twenty, so the reasoning behind the decision expires loudly if the model ever stops cancelling.
Tier 3: RNG call-order equivalence
Both sides are instrumented to emit every draw, and the traces are diffed. This test exists specifically because it is the one the existing Python port would fail: its documented divergence is attributed to "the exact sequence of calls differs due to implementation details", which is exactly the defect a trace diff catches on the first run instead of after a 48-hour statistical comparison.
From B-0029, draw counts at four points in a run, with exact agreement at every one:
| time | draws | what is drawing |
|---|---|---|
| 0 | 0 | noise skipped, walks reset |
| 1e-6 | 264 | noise only |
| 0.15 | 462 | noise, walk advance, gas analysers |
| 0.30 | 522 | and the product analyser |
Scalings in one real evaluation: 30 signed and 432 unit. The 30 is 9 x 3 + 3, the walk advance; the 432 is 36 compositions at twelve draws each. Worst evaluation is 462 draws against a trace buffer capacity of 4096. The instrument is checked against the generator word rather than only against the values, which covers completeness, ordering and fidelity at once, and it holds over 113,088 draws.
Two independent methods agree on the counts. B-0027 measured them with no instrumentation at all, by stepping a port-side generator from the word before a call to the word after, and the trace lengths match that census exactly. Either method alone would be a claim; the two agreeing is evidence.
Tier 4: trajectory equivalence, diagnostic
Tier 4 is a diagnostic, not a gate. Long-horizon divergence between two programs
that use different exp implementations is expected. PLAN.org asks for two
things: that the error stay below the corresponding measurement noise standard
deviation XNS(i) for at least the first several hours, and that the onset of
divergence be explained by showing that switching libm moves it.
Open loop, nominal, 8 simulated hours (28,800 steps), from B-0034:
libm | worst error, as a fraction of XNS(i) | ever outside XNS |
|---|---|---|
| vendored | 5.149e-5 | never |
| platform | 0.000e0 | never |
All 21 scenarios at 4 hours each stay within XNS for the whole run in both
configurations, and every one is exactly 0.00e0 on the platform libm. Worst
error by scenario on the vendored libm at 4 hours: nominal 2.45e-5, IDV(10)
3.66e-8, IDV(8) 1.42e-8, IDV(13) 1.05e-8, and the rest below 1e-8.
XNS(i) of the channel it happened on, so the reference line is not zero but the point at which the plant's own instruments could resolve the difference. Filled dots are the vendored libm; rings are the platform one, and all twenty-one of those sit in the exactly-zero lane. The claim is false if a marker reaches the shaded band at one, and the explanation is false if a ring ever leaves the zero lane: that would mean the divergence is something other than transcendental rounding. Written by cargo xtask validate --tiers 4 from that run's own output, at the sweep's own horizon of four hours rather than the eight-hour nominal run tabulated above; the same sweep is recorded for B-0034 in LOG.org, and the generated index names the run that drew this copy.The explanation is better than the one asked for. With identical transcendentals
the two trajectories are not merely close, they are bit-identical for 28,800
steps on all 41 measurements. So the divergence is transcendental rounding and
nothing else, and the residual under the vendored libm is twenty thousand
times below what the instruments could resolve after eight hours.
Closed loop, 48 hours, 172,800 steps, nominal scenario with the driver's
forced IDV(12) from hour eight, from B-0041:
libm | worst XMEAS | worst XMV (fraction of range) | within XNS | at the end |
|---|---|---|---|---|
| platform | 0 | 0 | 48.000 h | 0 |
| vendored | 1.705e-10 at XMEAS(1) | 1.246e-11 at XMV(10) | 48.000 h | 7.882e-11 x XNS(13) |
Bit-identical for all 172,800 closed-loop steps under the platform libm. Not
one measurement, not one valve, not one step. Under the vendored libm the
error never reaches a tenth of a billionth of any instrument's noise over two
simulated days.
Compare the open-loop figure above: nominal at 8 hours ends at 5.149e-5 of
XNS, six orders of magnitude worse. Closing the loop suppresses the
amplification, which is what a controller pulling toward a setpoint should do
and is worth having measured rather than assumed.
The same run measures what the control layer is actually buying. Open loop from the same start, with the valves held, trips at step 11,017 (3.060 h) on reactor pressure high. So "48 hours without tripping" is a statement about the control layer, not about a placid plant. B-0052 reproduced that trip from the public API and the CLI, at exactly the same step, without either being told about the other.
Tier 5: statistical equivalence, the real gate
Tier 5 tests equivalence rather than difference, because a failure to reject
the null of no difference is not evidence of equivalence and two one-sided tests
are. Every statistic ships in Rust, in tepsim-stats, with known-answer tests:
Welch's t and the TOST wrapper, the two-sample Kolmogorov-Smirnov statistic,
energy distance, autocorrelation, Welch power spectra and the Pearson
correlation matrix. Nothing calls out to numpy or scipy.
The harness
From B-0047a: a 48-hour run produces 960 samples of 53 variables, a port run
takes 766 ms in release, and a full battery of 2100 runs takes about 27
minutes per source. Over three scenarios the two sources agree bit-identically
(0) under the platform libm and to 2.056e-13 under the vendored one, while
five nominal seeds spread by 1.366e1, which is the scale that makes those first
two numbers meaningful.
The same entry checked that the scenarios are not vacuous. All twenty
disturbances move the plant within two hours; the smallest departures are
IDV(3) at 4.076e-3, IDV(9) at 3.531e-3 and IDV(15) at 9.839e-3, and the
largest are IDV(6) at 3.467e0 and IDV(1) at 2.456e0.
The battery
Four of the six statistics have margins that are measured rather than chosen
(B-0047b). The reference's seeds are split in half, the statistic is computed
against itself over twenty deterministic splits, and the cross-source value is
treated as one more draw from that null, giving p = (1 + #{within >= cross}) / (K + 1) gated at 0.05. With twenty splits the cross-source value fails exactly
when it is the strict maximum of the twenty-one.
The smoke battery in the CI gate is 3 scenarios, 4 seeds, 2 hours, 12 runs per source, 14 seconds. The full battery is partial: 3 of 21 scenarios at 100 seeds by 48 hours, stopped by direction after about 25 minutes.
| scenario | worst mean power | Frobenius, cross | within, max | p |
|---|---|---|---|---|
| nominal | 1.59 | 1.186e-2 | 7.595 | 1.0000 |
IDV(1) | 1.61 | 7.080e-3 | 5.703 | 1.0000 |
IDV(2) | 0.23 | 2.827e-5 | 1.924 | 1.0000 |
Every calibrated statistic passed at p = 1.0000 on all three: the cross-source value was never the maximum of its null. The correlation matrix's Frobenius distance is three to five orders of magnitude inside the within-source spread, which matters because that matrix is exactly what a PCA-based detector consumes.
That relationship, rather than any single number, is what Tier 5 claims, so it is worth drawing:
cargo xtask validate --tiers 5 --smoke, so the points are the smoke battery's, 3 scenarios by 4 seeds by 2 h and fourteen seconds of running, not the full one: the figure's own subtitle says which, and the full-battery numbers are the ones tabulated above and recorded for B-0047b in LOG.org. At smoke size the permutation tests cannot reject at all, which is why the picture shows the gap between the cross-source value and its null rather than a verdict.That plot answers "are the two sources closer to each other than the reference is to itself", which is the calibrated question. It does not answer "how much room is left", and neither does a TOST verdict: a p-value reports that a test did not reject, not by what distance. The margin is a stated quantity, a tenth of the reference's standard deviation for each variable, so the distance can simply be drawn.
5.590e-12 of its margin, which is about eleven orders of magnitude of headroom. Hollow markers are the variables the moment gate does not apply to, drawn rather than dropped, because omitting them would quietly shrink the denominator a reader counts against. A constant reference has no margin at all and so has no ratio, and sits in the zero lane; a valve stuck by the scenario's own fault is judged on its distribution instead, which is the decision recorded as B-0047d. Written by cargo xtask validate --tiers 5 --smoke, at the same smoke size as the figure above and for the same reason.TOST power against battery size, measured in the same entry:
| battery | worst power |
|---|---|
| 4 seeds, 2 h (smoke) | 11.93 |
| 8 seeds, 12 h | 3.38 |
| 8 seeds, 48 h | 1.04 |
| 100 seeds, 48 h, nominal | 1.59 |
100 seeds, 48 h, IDV(2) | 0.23 |
A power above 1 means the battery is underpowered for the margin, not that
the sources differ. PLAN.org sets the mean margin at a tenth of the pooled
standard deviation, and a disturbed plant has a much larger pooled spread than a
quiet one, so the same absolute run-to-run wander sits comfortably inside the
margin under IDV(2) and outside it at the nominal operating point. At the
nominal plant the margin needs about 255 seeds, not 100. The variables that
run out of power are the manipulated ones: an integrating controller's output
has a random-walk component, so its mean over 48 hours varies between seeds by
much more than a tenth of its own within-run spread. That is a fact about the
plant, not about the port, and the battery reports it as undecided rather than
as a difference while still asserting on the measured gap.
Physics invariants
These are the only tests in the whole ladder that can catch an error the port faithfully inherited, because every other tier compares against the Fortran and an error in the Fortran is invisible to all of them. From B-0046:
| invariant | Fortran | port | gate |
|---|---|---|---|
| I-1 reaction mass, 200 states | 4.664e-16 | 3.498e-16 | 1e-14 |
| I-2 plant mass balance, nominal | 2.079e-16 | 2.344e-16 | 1e-13 |
| I-2 along 2 h open loop | 6.556e-16 | not run | 1e-13 |
| I-3 inert reaction term | exactly 0 | exactly 0 | exact |
| I-4 per-component moles, 1 h | 7.436e-15 | 2.892e-15 | 1e-13 |
All four reactions balance exactly with the published molecular weights, `2 + 28
- 32 = 62
,2 + 28 + 46 = 76,2 + 46 = 48and3 x 32 = 2 x 48`, which is why I-1 is an equality rather than a tolerance.
The invariants' teeth were checked by mutation, and one result is worth repeating because it is not obvious. Deleting the reaction term from the reactor's component balance leaves the total mass balance passing at 2e-16. That is not a weak test, it is a true fact about the invariant: I-2 is the molecular-weight-weighted sum of I-4, and I-1 says the reaction is mass-neutral, so the reaction term cancels out of I-2 exactly. Total mass conservation is blind to stoichiometry by construction. The unweighted per-component balance, I-4, sees it immediately. An invariant that is a sum of other invariants is weaker than the set, and how much weaker is not obvious from reading it.
Tiers 6 through 10
These have not run. They are listed here so that the shape of the remaining claim is visible rather than implied.
Tier 6, downstream-task equivalence, is the operational definition of
"practically equivalent" for this project's research audience: train a detector
suite on Fortran d00 data, evaluate on Fortran and on Rust test data, then
reverse it, and compare detection rate, false alarm rate and detection delay.
The claim to be able to make is that cross-source performance matches
within-source performance within its own run-to-run variability. Backlog item
B-0050.
Tier 7, published dataset reproduction, attempts direct reproduction of the
bundled d00 through d21 files under the documented generation protocol,
reporting per-file agreement with the Tier 5 machinery. The groundwork exists:
teprob.f:1187-1256 carries fifty-four generator words in comments, one per
published dataset, and B-0047a transcribed and asserted them. Three facts about
that table are worth knowing in advance. Thirty-four of the fifty-four exceed
2^32, which is not a transcription error because TESUB7 reduces any seed on
the first draw. Twenty-seven are even, and a multiplicative generator modulo a
power of two keeps the factors of two its seed has, so those runs have a shorter
period and low bits that never move. Reproducing the published files means doing
the same rather than fixing it. Backlog item B-0051.
Tier 8 is differential fuzzing, Tier 9 is cross-platform determinism by golden BLAKE3 digest including wasm in a real browser, and Tier 10 requires that every quirk fix ship with a measured delta from the full Tier 5 battery with the fix on and off. None has started.
Two bugs that only long runs found
Worth recording because they are the argument for running the battery long rather than often (B-0047b).
TRCN, the Tier 3 trace counter, is a Fortran INTEGER that nothing clears
between evaluations. At about 264 draws per step over 172,800 steps it passes
2^31 after roughly fifty runs, goes negative, passes the capacity guard, and
writes outside the array. The symptom was a SIGSEGV deep inside the Fortran,
tens of millions of steps into the battery, with nothing nearby to suggest why:
the same seed ran perfectly in isolation, and starting at seed 40 moved the
crash to seed 90, which is what identified it as cumulative rather than
data-dependent. Nothing in Tiers 1 to 4 was ever affected, because no shorter run
approached the overflow.
welch_t computed (n - 1) on a summary with no observations. In debug that
panics; in release it wraps and returns a degrees-of-freedom figure that looks
like a number. The case is XMV(12), the agitator, which no controller ever
writes, so every run has zero variance and the log-variance ensemble is empty.
Both are the same lesson. A validation harness is code, it has bugs, and the bugs it has are the ones only its longest runs reach.
Validation, measured
This page is generated.
cargo xtask validatewrote it from commit03ec3d6-dirty. Every number on it was captured from that run's own output. To change what it says, change what the suite measures and run the command again.
The narrative version of this material, with the reasoning behind each tier and the history of what it caught, is in Validation. This section is the other half: the numbers, written by the command that ran the suite, from the suite's own output. Nothing here was transcribed.
A tier with no chapter has not been generated. That is stated rather than left to be inferred from an absence, because a missing page and a page nobody updated look the same from the table of contents.
Toolchain on the machine that ran this: rustc 1.97.1 (8bab26f4f 2026-07-14), gfortran 15.2.0.
Fidelity preflight
cargo xtask fidelity runs the port forward from the nominal state and diffs states,
derivatives, measurements and the generator word against a golden oracle trace
committed to the repository. It needs no Fortran toolchain, so it runs everywhere in
about a second.
| steps diffed | worst | where | gate | trace recorded with |
|---|---|---|---|---|
| 100 of 100 | 3.521323734565789e-14 | YP(12) at step 77 | 1e-12 | gfortran 15.2.0 |
What the long tiers look like
Tiers 4 and 5 run but write no chapter yet, for the reason GENERATED_TIERS
gives in xtask. Their figures are here, each drawn by the run named under it.
XNS(i) of the channel it happened on. The reference line is therefore not zero but the point at which the plant's own instruments could resolve the difference. The claim is false if any marker reaches the shaded band, and the explanation is false if the platform libm markers ever leave the = 0 lane: that would mean the divergence is something other than transcendental rounding. Drawn by cargo xtask validate --tiers 4 at commit b6ef4ee-dirty; the measurement it repeats was first recorded in B-0034 (LOG.org).cargo xtask validate --tiers 5 --smoke at commit 03ec3d6-dirty; the measurement it repeats was first recorded in B-0047b (LOG.org).cargo xtask validate --tiers 5 --smoke at commit 03ec3d6-dirty; the measurement it repeats was first recorded in B-0047b (LOG.org).The ladder
| tier | what it proves | chapter |
|---|---|---|
| 1 | TESUB1 to TESUB8 match the oracle | tier 1, from b6ef4ee-dirty by cargo xtask validate --tiers 1,2,3 --smoke |
| 2 | single-step derivatives match, over all three pools | tier 2, from b6ef4ee-dirty by cargo xtask validate --tiers 1,2,3 --smoke |
| 3 | the generator call order matches, draw for draw | tier 3, from b6ef4ee-dirty by cargo xtask validate --tiers 1,2,3 --smoke |
| 4 | trajectories stay inside the measurement noise (diagnostic) | runs, but writes no chapter yet |
| 5 | statistical equivalence: TOST, KS, ACF, spectra, correlations | runs, but writes no chapter yet |
| 6 | downstream detectors cannot tell the two sources apart | runs, but writes no chapter yet |
| 7 | the published d00 to d21 files are reproduced | runs, but writes no chapter yet |
| 8 | differential fuzzing finds no counterexample | no harness yet |
| 9 | identical digests across platforms, wasm included | runs, but writes no chapter yet |
| 10 | every quirk fix ships with a measured delta | runs, but writes no chapter yet |
This run selected tier(s) [5] and wrote chapter(s) for []. Tiers 4 to 7, 9
and 10 run but do not write a chapter yet; tier 8 has no harness. The delta
index is generated separately, by cargo xtask deltas.
Tier 1: the utility routines
This page is generated.
cargo xtask validate --tiers 1,2,3 --smokewrote it from commitb6ef4ee-dirty. Every number on it was captured from that run's own output. To change what it says, change what the suite measures and run the command again.
TESUB1 (enthalpy), TESUB2 (temperature from enthalpy by Newton), TESUB3
(heat capacity) and TESUB4 (liquid density) are swept against the Fortran over
a simplex grid, a Dirichlet sample and a boundary pool, at every temperature in
the physical range, for each of the three ITY modes. PLAN.org sets the gate
at a maximum relative error below 1e-13, with a ULP histogram reported rather
than a verdict.
Reduced volume. This run passed --smoke, so the sweeps are the short ones the CI
gate uses rather than the full ones PLAN.org specifies. The case counts in the
tables below are what actually ran. Drop --smoke for the gate volume.
Produced with rustc 1.97.1 (8bab26f4f 2026-07-14), gfortran 15.2.0. The oracle's compiler flags are fixed in
crates/tepsim-oracle/build.rs and asserted by a test; changing them invalidates
every number on this page, which is why it is a logged re-baseline and not an edit.
What ran
2 test binaries: 7 test(s) passed, 0 failed, 0 ignored.
| target | libm | passed | failed | ignored |
|---|---|---|---|---|
tier1_enthalpy | vendored | 5 | 0 | 0 |
tier1_temperature | vendored | 2 | 0 | 0 |
Figures
tier1 TESUB1 ity=2, offset as f64, and nothing else. Any other dot crossing the line is a failure. The bit-equality half of the claim is falsified separately, by any dot leaving the = 0 lane under the platform libm, where the two sides call the same exp. Drawn by cargo xtask validate --tiers 1,2,3 --smoke at commit b6ef4ee-dirty; the measurement it repeats was first recorded in B-0009, B-0010 and B-0011 (LOG.org).cargo xtask validate --tiers 1,2,3 --smoke at commit b6ef4ee-dirty; the measurement it repeats was first recorded in B-0009, B-0010 and B-0011 (LOG.org).Measurements
15 block(s), lifted from the transcripts below. The columns are whatever fields the run printed, so a new field in the reporter becomes a new column here rather than data this page drops.
The test column matters as much as the numbers, because not every row is the port
being measured. Some tests deliberately mis-type a constant, or solve from the wrong
guess, to show what that would cost; a row from one of those is supposed to be
enormous, and its test name says so. The what column carries a tier1 prefix in
every tier, because it is the shared comparison reporter's own label rather than a
claim about which tier printed it.
| target | from test | what | cases | max rel err | max ulp | ulp percentiles | ulp histogram | non-finite | abandoned | round trip | elapsed | Fortran | port |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
tier1_enthalpy | reading_the_offset_as_double_precision_would_fail_the_gate_by_orders | tier1 TESUB1 ity=2, offset as f64 | 7950 | 1.597e-5 at grid#704 T=21.875 | 103098852352 at grid#704 T=21.875 | p50>=16 p90>=16 p99>=16 p100>=16 | >=16:7950 | 0 seen, 0 mismatched | |||||
tier1_enthalpy | tesub1_matches_the_fortran_over_the_full_sweep | tier1 TESUB1 ity=0 | 7950 | 0.000e0 at grid#0 T=0 | 0 at grid#0 T=0 | p50=0 p90=0 p99=0 p100=0 | 0:7950 | 0 seen, 0 mismatched [0.0 s] | |||||
tier1_enthalpy | tesub1_matches_the_fortran_over_the_full_sweep | tier1 TESUB1 ity=1 | 7950 | 0.000e0 at grid#0 T=0 | 0 at grid#0 T=0 | p50=0 p90=0 p99=0 p100=0 | 0:7950 | 0 seen, 0 mismatched [0.0 s] | |||||
tier1_enthalpy | tesub1_matches_the_fortran_over_the_full_sweep | tier1 TESUB1 ity=2 | 7950 | 0.000e0 at grid#0 T=0 | 0 at grid#0 T=0 | p50=0 p90=0 p99=0 p100=0 | 0:7950 | 0 seen, 0 mismatched [0.0 s] | |||||
tier1_enthalpy | tesub3_matches_the_fortran_over_the_full_sweep | tier1 TESUB3 ity=0 | 7950 | 0.000e0 at grid#0 T=0 | 0 at grid#0 T=0 | p50=0 p90=0 p99=0 p100=0 | 0:7950 | 0 seen, 0 mismatched [0.0 s] | |||||
tier1_enthalpy | tesub3_matches_the_fortran_over_the_full_sweep | tier1 TESUB3 ity=1 | 7950 | 0.000e0 at grid#0 T=0 | 0 at grid#0 T=0 | p50=0 p90=0 p99=0 p100=0 | 0:7950 | 0 seen, 0 mismatched [0.0 s] | |||||
tier1_enthalpy | tesub3_matches_the_fortran_over_the_full_sweep | tier1 TESUB3 ity=2 | 7950 | 0.000e0 at grid#0 T=0 | 0 at grid#0 T=0 | p50=0 p90=0 p99=0 p100=0 | 0:7950 | 0 seen, 0 mismatched [0.0 s] | |||||
tier1_enthalpy | tesub4_matches_the_fortran_over_the_full_sweep | tier1 TESUB4 | 7950 | 0.000e0 at grid#0 T=0 | 0 at grid#0 T=0 | p50=0 p90=0 p99=0 p100=0 | 0:7950 | 0 seen, 0 mismatched [0.0 s] | |||||
tier1_temperature | tesub2_matches_the_fortran_and_the_silent_failure_never_fires | tier1 TESUB2 ity=0 start=warm | 7950 | 0.000e0 at grid#0 T=0 | 0 at grid#0 T=0 | p50=0 p90=0 p99=0 p100=0 | 0:7950 | 0 seen, 0 mismatched | 0 (delta D-001) | max |solved - true| = 0e0 C | 0.0 s | ||
tier1_temperature | tesub2_matches_the_fortran_and_the_silent_failure_never_fires | tier1 TESUB2 ity=0 start=cold | 7950 | 0.000e0 at grid#0 T=0 | 0 at grid#0 T=0 | p50=0 p90=0 p99=0 p100=0 | 0:7950 | 0 seen, 0 mismatched | 0 (delta D-001) | max |solved - true| = 5.684341886080802e-14 C at grid#3637 T=131.25 | 0.0 s | ||
tier1_temperature | tesub2_matches_the_fortran_and_the_silent_failure_never_fires | tier1 TESUB2 ity=1 start=warm | 7950 | 0.000e0 at grid#0 T=0 | 0 at grid#0 T=0 | p50=0 p90=0 p99=0 p100=0 | 0:7950 | 0 seen, 0 mismatched | 0 (delta D-001) | max |solved - true| = 0e0 C | 0.0 s | ||
tier1_temperature | tesub2_matches_the_fortran_and_the_silent_failure_never_fires | tier1 TESUB2 ity=1 start=cold | 7950 | 0.000e0 at grid#0 T=0 | 0 at grid#0 T=0 | p50=0 p90=0 p99=0 p100=0 | 0:7950 | 0 seen, 0 mismatched | 0 (delta D-001) | max |solved - true| = 1.9895196601282805e-13 C at grid#4617 T=170 | 0.0 s | ||
tier1_temperature | tesub2_matches_the_fortran_and_the_silent_failure_never_fires | tier1 TESUB2 ity=2 start=warm | 7950 | 0.000e0 at grid#0 T=0 | 0 at grid#0 T=0 | p50=0 p90=0 p99=0 p100=0 | 0:7950 | 0 seen, 0 mismatched | 0 (delta D-001) | max |solved - true| = 0e0 C | 0.0 s | ||
tier1_temperature | tesub2_matches_the_fortran_and_the_silent_failure_never_fires | tier1 TESUB2 ity=2 start=cold | 7950 | 0.000e0 at grid#0 T=0 | 0 at grid#0 T=0 | p50=0 p90=0 p99=0 p100=0 | 0:7950 | 0 seen, 0 mismatched | 0 (delta D-001) | max |solved - true| = 5.115907697472721e-13 C at face#96 T=144.14305056234923 | 0.0 s | ||
tier1_temperature | the_fortran_silently_returns_the_guess_where_the_port_reports_failure | D-001 demonstrated: | returned 120.4 silently | Newton did not converge in 100 iterations from a guess of 120.4 C: reached -48598544002334720 C with a final step of 24299272000832196, which is not below 1e-12 |
Transcripts
Each block below is a test binary's own output, verbatim, with the command that produced it. The summary above is derived from these; they are not derived from it.
tier1_enthalpy, vendored libm
cargo test -p tepsim-oracle --features oracle --release --test tier1_enthalpy -- --nocapture --test-threads 1
running 5 tests
test both_routines_are_bit_identical_to_the_fortran ... sweep: SMOKE, 7950 cases. The 9987490-case gate is `cargo xtask validate --tiers 1`.
ok
test reading_the_offset_as_double_precision_would_fail_the_gate_by_orders ... tier1 TESUB1 ity=2, offset as f64
cases : 7950
max rel err : 1.597e-5 at grid#704 T=21.875
max ulp : 103098852352 at grid#704 T=21.875
ulp percentiles: p50>=16 p90>=16 p99>=16 p100>=16
ulp histogram : >=16:7950
non-finite : 0 seen, 0 mismatched
ok
test tesub1_matches_the_fortran_over_the_full_sweep ... sweep: SMOKE, 7950 cases. The 9987490-case gate is `cargo xtask validate --tiers 1`.
tier1 TESUB1 ity=0
cases : 7950
max rel err : 0.000e0 at grid#0 T=0
max ulp : 0 at grid#0 T=0
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7950
non-finite : 0 seen, 0 mismatched [0.0 s]
tier1 TESUB1 ity=1
cases : 7950
max rel err : 0.000e0 at grid#0 T=0
max ulp : 0 at grid#0 T=0
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7950
non-finite : 0 seen, 0 mismatched [0.0 s]
tier1 TESUB1 ity=2
cases : 7950
max rel err : 0.000e0 at grid#0 T=0
max ulp : 0 at grid#0 T=0
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7950
non-finite : 0 seen, 0 mismatched [0.0 s]
ok
test tesub3_matches_the_fortran_over_the_full_sweep ... sweep: SMOKE, 7950 cases. The 9987490-case gate is `cargo xtask validate --tiers 1`.
tier1 TESUB3 ity=0
cases : 7950
max rel err : 0.000e0 at grid#0 T=0
max ulp : 0 at grid#0 T=0
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7950
non-finite : 0 seen, 0 mismatched [0.0 s]
tier1 TESUB3 ity=1
cases : 7950
max rel err : 0.000e0 at grid#0 T=0
max ulp : 0 at grid#0 T=0
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7950
non-finite : 0 seen, 0 mismatched [0.0 s]
tier1 TESUB3 ity=2
cases : 7950
max rel err : 0.000e0 at grid#0 T=0
max ulp : 0 at grid#0 T=0
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7950
non-finite : 0 seen, 0 mismatched [0.0 s]
ok
test tesub4_matches_the_fortran_over_the_full_sweep ... sweep: SMOKE, 7950 cases. The 9987490-case gate is `cargo xtask validate --tiers 1`.
tier1 TESUB4
cases : 7950
max rel err : 0.000e0 at grid#0 T=0
max ulp : 0 at grid#0 T=0
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7950
non-finite : 0 seen, 0 mismatched [0.0 s]
ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
tier1_temperature, vendored libm
cargo test -p tepsim-oracle --features oracle --release --test tier1_temperature -- --nocapture --test-threads 1
running 2 tests
test tesub2_matches_the_fortran_and_the_silent_failure_never_fires ... sweep: SMOKE, 7950 cases. The 9987490-case gate is `cargo xtask validate --tiers 1`.
tier1 TESUB2 ity=0 start=warm
cases : 7950
max rel err : 0.000e0 at grid#0 T=0
max ulp : 0 at grid#0 T=0
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7950
non-finite : 0 seen, 0 mismatched
abandoned : 0 (delta D-001)
round trip : max |solved - true| = 0e0 C
elapsed : 0.0 s
tier1 TESUB2 ity=0 start=cold
cases : 7950
max rel err : 0.000e0 at grid#0 T=0
max ulp : 0 at grid#0 T=0
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7950
non-finite : 0 seen, 0 mismatched
abandoned : 0 (delta D-001)
round trip : max |solved - true| = 5.684341886080802e-14 C at grid#3637 T=131.25
elapsed : 0.0 s
tier1 TESUB2 ity=1 start=warm
cases : 7950
max rel err : 0.000e0 at grid#0 T=0
max ulp : 0 at grid#0 T=0
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7950
non-finite : 0 seen, 0 mismatched
abandoned : 0 (delta D-001)
round trip : max |solved - true| = 0e0 C
elapsed : 0.0 s
tier1 TESUB2 ity=1 start=cold
cases : 7950
max rel err : 0.000e0 at grid#0 T=0
max ulp : 0 at grid#0 T=0
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7950
non-finite : 0 seen, 0 mismatched
abandoned : 0 (delta D-001)
round trip : max |solved - true| = 1.9895196601282805e-13 C at grid#4617 T=170
elapsed : 0.0 s
tier1 TESUB2 ity=2 start=warm
cases : 7950
max rel err : 0.000e0 at grid#0 T=0
max ulp : 0 at grid#0 T=0
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7950
non-finite : 0 seen, 0 mismatched
abandoned : 0 (delta D-001)
round trip : max |solved - true| = 0e0 C
elapsed : 0.0 s
tier1 TESUB2 ity=2 start=cold
cases : 7950
max rel err : 0.000e0 at grid#0 T=0
max ulp : 0 at grid#0 T=0
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7950
non-finite : 0 seen, 0 mismatched
abandoned : 0 (delta D-001)
round trip : max |solved - true| = 5.115907697472721e-13 C at face#96 T=144.14305056234923
elapsed : 0.0 s
ok
test the_fortran_silently_returns_the_guess_where_the_port_reports_failure ... D-001 demonstrated:
Fortran: returned 120.4 silently
port: Newton did not converge in 100 iterations from a guess of 120.4 C: reached -48598544002334720 C with a final step of 24299272000832196, which is not below 1e-12
ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
Tier 2: the plant model
This page is generated.
cargo xtask validate --tiers 1,2,3 --smokewrote it from commitb6ef4ee-dirty. Every number on it was captured from that run's own output. To change what it says, change what the suite measures and run the command again.
Both implementations are forced into an identical state, evaluated once, and compared on all fifty derivative components. Sampling is from three pools: states along the nominal closed-loop trajectory, random perturbations of those states, and adversarial states placed at every discontinuity and clamp in the model.
Every comparison runs twice. The vendored libm disagrees with gfortran's by an
ULP on about a tenth of exp and pow calls, so the default build can only be
held to 1e-12; the libm-system build removes the transcendental from the
comparison and is held to bit equality. Both runs are below, and the libm
column says which is which.
The tolerance is relative to the scale of the terms rather than to the result. A balance is inflow minus outflow, and near steady state those nearly cancel, so an error that is 1e-16 of either term can be 1e-4 of their difference.
Reduced volume. This run passed --smoke, so the sweeps are the short ones the CI
gate uses rather than the full ones PLAN.org specifies. The case counts in the
tables below are what actually ran. Drop --smoke for the gate volume.
Produced with rustc 1.97.1 (8bab26f4f 2026-07-14), gfortran 15.2.0. The oracle's compiler flags are fixed in
crates/tepsim-oracle/build.rs and asserted by a test; changing them invalidates
every number on this page, which is why it is a logged re-baseline and not an edit.
What ran
20 test binaries: 94 test(s) passed, 0 failed, 0 ignored.
| target | libm | passed | failed | ignored |
|---|---|---|---|---|
tier2_unpack | vendored | 3 | 0 | 0 |
tier2_equilibrium | vendored | 3 | 0 | 0 |
tier2_kinetics | vendored | 3 | 0 | 0 |
tier2_streams | vendored | 3 | 0 | 0 |
tier2_flows | vendored | 5 | 0 | 0 |
tier2_stripper | vendored | 4 | 0 | 0 |
tier2_heat | vendored | 4 | 0 | 0 |
tier2_measurements | vendored | 4 | 0 | 0 |
tier2_balances | vendored | 4 | 0 | 0 |
tier4_closed_loop | platform | 5 | 0 | 0 |
tier5_invariants | platform | 9 | 0 | 0 |
tier5_runs | platform | 10 | 0 | 0 |
tier2_equilibrium | platform | 3 | 0 | 0 |
tier2_kinetics | platform | 4 | 0 | 0 |
tier2_streams | platform | 4 | 0 | 0 |
tier2_flows | platform | 6 | 0 | 0 |
tier2_stripper | platform | 5 | 0 | 0 |
tier2_heat | platform | 5 | 0 | 0 |
tier2_measurements | platform | 5 | 0 | 0 |
tier2_balances | platform | 5 | 0 | 0 |
Figures
tier1 YP(1..50), relative to the derivative (reported), and nothing else. Any other dot crossing the line is a failure. The bit-equality half of the claim is falsified separately, by any dot leaving the = 0 lane under the platform libm, where the two sides call the same exp. Drawn by cargo xtask validate --tiers 1,2,3 --smoke at commit b6ef4ee-dirty; the measurement it repeats was first recorded in B-0026 (LOG.org).libm, where both sides call the same exp, the distribution is one bar at zero, and every comparison in the tier is identical to the last bit. Under the vendored libm a tail appears, and it is the transcendentals rather than the algebra. The claim is false the moment the platform group grows a second bar. Drawn by cargo xtask validate --tiers 1,2,3 --smoke at commit b6ef4ee-dirty; the measurement it repeats was first recorded in B-0026 (LOG.org).Measurements
143 block(s), lifted from the transcripts below. The columns are whatever fields the run printed, so a new field in the reporter becomes a new column here rather than data this page drops.
The test column matters as much as the numbers, because not every row is the port
being measured. Some tests deliberately mis-type a constant, or solve from the wrong
guess, to show what that would cost; a row from one of those is supposed to be
enormous, and its test name says so. The what column carries a tier1 prefix in
every tier, because it is the shared comparison reporter's own label rather than a
claim about which tier printed it.
| target | from test | what | cases | max rel err | max ulp | ulp percentiles | ulp histogram | non-finite | worst XMEAS | worst XMV | within XNS | first split, port | first split, fortran | worst over the run | worst at the end |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
tier2_unpack | solving_from_a_fixed_guess_instead_of_the_seed_breaks_bit_equality | tier1 TCV with the carried seed | 100 | 0.000e0 at nominal#0[4] | 0 at nominal#0[4] | p50=0 p90=0 p99=0 p100=0 | 0:100 | 0 seen, 0 mismatched | |||||||
tier2_unpack | solving_from_a_fixed_guess_instead_of_the_seed_breaks_bit_equality | tier1 TCV from a fixed guess | 100 | 9.901e-16 at nominal#25[4] | 6 at nominal#25[4] | p50=1 p90=4 p99=6 p100=6 | 0:24 1:28 2:18 3:19 4:7 5:1 6:3 | 0 seen, 0 mismatched | |||||||
tier2_unpack | the_unpacking_matches_the_fortran_over_all_three_pools | tier1 unpack UCLR | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_unpack | the_unpacking_matches_the_fortran_over_all_three_pools | tier1 unpack UCLS | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_unpack | the_unpacking_matches_the_fortran_over_all_three_pools | tier1 unpack UCLC | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_unpack | the_unpacking_matches_the_fortran_over_all_three_pools | tier1 unpack UCVV | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_unpack | the_unpacking_matches_the_fortran_over_all_three_pools | tier1 unpack UTLR/UTLS/UTLC/UTVV | 9700 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:9700 | 0 seen, 0 mismatched | |||||||
tier2_unpack | the_unpacking_matches_the_fortran_over_all_three_pools | tier1 unpack XLR | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_unpack | the_unpacking_matches_the_fortran_over_all_three_pools | tier1 unpack XLS | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_unpack | the_unpacking_matches_the_fortran_over_all_three_pools | tier1 unpack XLC | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_unpack | the_unpacking_matches_the_fortran_over_all_three_pools | tier1 unpack XVV | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_unpack | the_unpacking_matches_the_fortran_over_all_three_pools | tier1 unpack ESR/ESS/ESC/ESV | 9700 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:9700 | 0 seen, 0 mismatched | |||||||
tier2_unpack | the_unpacking_matches_the_fortran_over_all_three_pools | tier1 unpack TCR/TCS/TCC/TCV | 9700 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:9700 | 0 seen, 0 mismatched | |||||||
tier2_unpack | the_unpacking_matches_the_fortran_over_all_three_pools | tier1 unpack TKR/TKS/TKV | 7275 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:7275 | 0 seen, 0 mismatched | |||||||
tier2_unpack | the_unpacking_matches_the_fortran_over_all_three_pools | tier1 unpack DLR/DLS/DLC | 7275 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:7275 | 0 seen, 0 mismatched | |||||||
tier2_unpack | the_unpacking_matches_the_fortran_over_all_three_pools | tier1 unpack VLR/VLS/VLC | 7275 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:7275 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_equilibrium_matches_the_fortran_over_all_three_pools | tier1 equilibrium VVR/VVS | 4850 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:4850 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_equilibrium_matches_the_fortran_over_all_three_pools | tier1 equilibrium PPR | 19400 | 2.802e-16 at perturbed#1264[4] | 2 at nominal#21[4] | p50=0 p90=0 p99=1 p100=2 | 0:17917 1:1305 2:178 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_equilibrium_matches_the_fortran_over_all_three_pools | tier1 equilibrium PPS | 19400 | 2.784e-16 at perturbed#609[8] | 2 at nominal#26[8] | p50=0 p90=0 p99=1 p100=2 | 0:18322 1:899 2:179 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_equilibrium_matches_the_fortran_over_all_three_pools | tier1 equilibrium PTR/PTS/PTV | 7275 | 3.578e-16 at perturbed#576[2] | 2 at nominal#188[1] | p50=0 p90=0 p99=1 p100=2 | 0:7130 1:133 2:12 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_equilibrium_matches_the_fortran_over_all_three_pools | tier1 equilibrium XVR | 19400 | 4.843e-16 at perturbed#88[4] | 3 at nominal#44[7] | p50=0 p90=0 p99=2 p100=3 | 0:17705 1:1305 2:368 3:22 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_equilibrium_matches_the_fortran_over_all_three_pools | tier1 equilibrium XVS | 19400 | 4.302e-16 at perturbed#1121[7] | 3 at nominal#287[3] | p50=0 p90=0 p99=2 p100=3 | 0:18174 1:978 2:238 3:10 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_equilibrium_matches_the_fortran_over_all_three_pools | tier1 equilibrium UTVR/UTVS | 4850 | 4.443e-16 at perturbed#169[2] | 3 at nominal#303[2] | p50=0 p90=0 p99=1 p100=3 | 0:4733 1:82 2:32 3:3 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_equilibrium_matches_the_fortran_over_all_three_pools | tier1 equilibrium UCVR | 19400 | 5.077e-16 at perturbed#1281[8] | 4 at perturbed#1281[8] | p50=0 p90=0 p99=2 p100=4 | 0:18105 1:920 2:330 3:44 4:1 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_equilibrium_matches_the_fortran_over_all_three_pools | tier1 equilibrium UCVS | 19400 | 6.230e-16 at perturbed#169[6] | 3 at perturbed#169[6] | p50=0 p90=0 p99=1 p100=3 | 0:18585 1:713 2:98 3:4 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_ideal_gas_partial_pressures_are_bit_identical_under_the_vendored_libm | tier1 PPR/PPS for A, B and C | 1200 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:1200 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_ideal_gas_partial_pressures_are_bit_identical_under_the_vendored_libm | tier1 PTV | 200 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:200 | 0 seen, 0 mismatched | |||||||
tier2_kinetics | the_inert_has_no_net_production_in_either_implementation | tier1 CRXR(2), never assigned | 200 | 0.000e0 at nominal#0[2] | 0 at nominal#0[2] | p50=0 p90=0 p99=0 p100=0 | 0:200 | 0 seen, 0 mismatched | |||||||
tier2_kinetics | the_kinetics_match_the_fortran_over_all_three_pools | tier1 kinetics RR | 9700 | 8.492e-16 at perturbed#618[2] | 7 at perturbed#1279[1] | p50=0 p90=2 p99=3 p100=7 | 0:7255 1:1447 2:732 3:235 4:22 5:7 6:1 7:1 | 0 seen, 0 mismatched | |||||||
tier2_kinetics | the_kinetics_match_the_fortran_over_all_three_pools | tier1 kinetics CRXR | 19400 | 8.492e-16 at perturbed#618[8] | 7 at perturbed#1279[4] | p50=0 p90=2 p99=3 p100=7 | 0:14063 1:3030 2:1763 3:450 4:72 5:16 6:4 7:2 | 0 seen, 0 mismatched | |||||||
tier2_kinetics | the_kinetics_match_the_fortran_over_all_three_pools | tier1 kinetics RH | 2425 | 7.419e-16 at perturbed#293[1] | 6 at perturbed#293[1] | p50=0 p90=2 p99=3 p100=6 | 0:1594 1:472 2:299 3:50 4:8 5:1 6:1 | 0 seen, 0 mismatched | |||||||
tier2_streams | the_stream_table_matches_the_fortran_over_all_three_pools | tier1 streams XST (10 streams x 8) | 194000 | 4.736e-16 at perturbed#1378[44] | 4 at perturbed#1923[47] | p50=0 p90=0 p99=1 p100=4 | 0:189849 1:3316 2:804 3:30 4:1 | 0 seen, 0 mismatched | |||||||
tier2_streams | the_stream_table_matches_the_fortran_over_all_three_pools | tier1 streams XMWS (the 6 that exist) | 14550 | 4.370e-16 at perturbed#791[4] | 3 at perturbed#431[4] | p50=0 p90=0 p99=1 p100=3 | 0:14073 1:392 2:79 3:6 | 0 seen, 0 mismatched | |||||||
tier2_streams | the_stream_table_matches_the_fortran_over_all_three_pools | tier1 streams TST | 24250 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:24250 | 0 seen, 0 mismatched | |||||||
tier2_streams | the_stream_table_matches_the_fortran_over_all_three_pools | tier1 streams HST | 24250 | 5.050e-16 at perturbed#1923[6] | 4 at perturbed#1923[6] | p50=0 p90=0 p99=1 p100=4 | 0:23683 1:461 2:94 3:10 4:2 | 0 seen, 0 mismatched | |||||||
tier2_flows | the_flow_network_matches_the_fortran_over_all_three_pools | tier1 flows FTM (the 10 assembled streams) | 24250 | 2.246e-14 at perturbed#1790[7] | 125 at perturbed#1453[6] | p50=0 p90=0 p99=5 p100>=16 | 0:23639 1:224 2:85 3:47 4:12 5:13 6:17 7:17 8:10 9:7 10:5 11:6 12:3 13:2 15:1 >=16:162 | 0 seen, 0 mismatched | |||||||
tier2_flows | the_flow_network_matches_the_fortran_over_all_three_pools | tier1 flows FCM (10 streams x 8) | 194000 | 2.268e-14 at perturbed#1790[53] | 178 at perturbed#1106[43] | p50=0 p90=0 p99=4 p100>=16 | 0:187622 1:2738 2:1090 3:426 4:288 5:119 6:88 7:62 8:43 9:57 10:38 11:31 12:25 13:47 14:66 15:90 >=16:1170 | 0 seen, 0 mismatched | |||||||
tier2_flows | the_flow_network_matches_the_fortran_over_all_three_pools | tier1 flows FWR/FWS/AGSP | 7275 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:7275 | 0 seen, 0 mismatched | |||||||
tier2_flows | the_flow_network_matches_the_fortran_over_all_three_pools | tier1 flows CPDH | 2425 | 2.034e-15 at perturbed#1790[1] | 18 at perturbed#1790[1] | p50=0 p90=0 p99=2 p100>=16 | 0:2312 1:71 2:20 3:6 4:5 5:6 6:1 7:3 >=16:1 | 0 seen, 0 mismatched | |||||||
tier2_flows | the_flow_network_matches_the_fortran_over_all_three_pools | tier1 flows HST(9) after the compressor bump | 2425 | 6.522e-15 at perturbed#1790[1] | 42 at perturbed#1790[1] | p50=0 p90=0 p99=2 p100>=16 | 0:2253 1:133 2:24 3:10 4:1 5:1 6:1 9:1 >=16:1 | 0 seen, 0 mismatched | |||||||
tier2_flows | the_steam_coefficient_matches_through_the_condenser_duty | tier1 UAC, via QUC | 300 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:300 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper SFR | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper FTM (5, 12): the column's own outlets | 4850 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:4850 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper FTM (7): the reactor-inlet alias | 2425 | 1.484e-15 at perturbed#817[1] | 12 at perturbed#591[1] | p50=0 p90=0 p99=5 p100=12 | 0:2322 1:1 2:19 3:46 4:5 5:8 6:15 7:6 8:1 12:2 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper FCM (5, 12): the column's own outlets | 38800 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:38800 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper FCM (7): the reactor-inlet alias | 19400 | 1.610e-15 at perturbed#817[3] | 13 at perturbed#591[8] | p50=0 p90=0 p99=4 p100=13 | 0:18576 1:6 2:100 3:313 4:228 5:103 6:33 7:15 8:11 9:8 10:1 11:3 12:2 13:1 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper XST (5, 12): the column's own outlets | 38800 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:38800 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper XST (7): the reactor-inlet alias | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper TST (5, 7, 12) | 7275 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:7275 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper HST (5, 12): the column's own outlets | 4850 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:4850 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper HST (7): the reactor-inlet alias | 2425 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:2425 | 0 seen, 0 mismatched | |||||||
tier2_heat | heat_transfer_matches_the_fortran_over_all_three_pools | tier1 heat UAR | 2425 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:2425 | 0 seen, 0 mismatched | |||||||
tier2_heat | heat_transfer_matches_the_fortran_over_all_three_pools | tier1 heat QUR | 2425 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:2425 | 0 seen, 0 mismatched | |||||||
tier2_heat | heat_transfer_matches_the_fortran_over_all_three_pools | tier1 heat QUS | 2425 | 9.572e-13 at perturbed#50[1] | 8009 at perturbed#50[1] | p50=0 p90=0 p99>=16 p100>=16 | 0:2196 1:23 2:14 3:10 4:21 5:9 6:3 7:10 8:4 9:2 10:3 11:1 12:3 13:3 14:6 15:1 >=16:116 | 0 seen, 0 mismatched | |||||||
tier2_heat | heat_transfer_matches_the_fortran_over_all_three_pools | tier1 heat QUC | 2425 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:2425 | 0 seen, 0 mismatched | |||||||
tier2_measurements | the_measurements_match_the_fortran_over_all_three_pools | tier1 XMEAS(1..22), noise-free | 53350 | 8.774e-15 at perturbed#1851[5] | 78 at perturbed#1851[5] | p50=0 p90=0 p99=1 p100>=16 | 0:52801 1:226 2:132 3:58 4:44 5:23 6:7 7:7 8:3 9:4 13:1 14:3 >=16:41 | 0 seen, 0 mismatched | |||||||
tier2_measurements | the_shutdown_detector_agrees_with_the_fortran_on_every_state | tier1 ISD as 0 or 1 | 2425 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:2425 | 0 seen, 0 mismatched | |||||||
tier2_balances | all_fifty_derivatives_match_the_fortran_over_all_three_pools | tier1 YP(1..50), relative to the derivative (reported) | 120600 | 1.393e-4 at nominal#303[2] | 738734374912 at nominal#303[2] | p50=0 p90=2 p99>=16 p100>=16 | 0:107101 1:935 2:1289 3:101 4:1512 5:22 6:176 7:14 8:1354 9:19 10:35 11:7 12:166 13:4 14:21 15:11 >=16:7833 | 0 seen, 0 mismatched | |||||||
tier2_balances | all_fifty_derivatives_match_the_fortran_over_all_three_pools | tier1 YP(1..50), relative to the scale of the terms (the gate) | 120600 | 6.093e-14 at perturbed#300[7] | 738734374912 at nominal#303[2] | p50=0 p90=2 p99>=16 p100>=16 | 0:107101 1:935 2:1289 3:101 4:1512 5:22 6:176 7:14 8:1354 9:19 10:35 11:7 12:166 13:4 14:21 15:11 >=16:7833 | 0 seen, 0 mismatched | |||||||
tier2_balances | all_fifty_derivatives_match_the_fortran_over_all_three_pools | tier1 YP(1..50), plant frozen | 650 | 0.000e0 at perturbed#369[1] | 0 at perturbed#369[1] | p50=0 p90=0 p99=0 p100=0 | 0:650 | 0 seen, 0 mismatched | |||||||
tier2_balances | tier2_acceptance_table | tier1 YP(1..50), relative to the scale of the terms (the gate) | 120600 | 6.093e-14 at perturbed#300[7] | 206158430208 at nominal#296[2] | p50=0 p90=2 p99>=16 p100>=16 | 0:107280 1:890 2:1293 3:113 4:1468 5:32 6:163 7:17 8:1240 9:19 10:42 11:11 12:166 13:7 14:26 15:11 >=16:7822 | 0 seen, 0 mismatched | |||||||
tier4_closed_loop | the_closed_loop_plant_matches_the_fortran_driver | closed loop, platform libm, 10 h (36000 steps) | 0.000e0 at XMEAS(0) | 0.000e0 %range at XMV(0) | 10.000 h of 10, ending at 0.000e0 x XNS(0) | ||||||||||
tier4_closed_loop | the_forced_disturbance_changes_the_plant_measurably | D-011, 10 h, faithful against fixed: | Some((29390, 22)) | Some((29390, 22)) | 1.092e-1 at XMEAS(37) | 3.262e-2 at XMEAS(38) | |||||||||
tier2_equilibrium | the_algebra_is_bit_identical_once_exp_agrees | tier1 equilibrium VVR/VVS | 1450 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:1450 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_algebra_is_bit_identical_once_exp_agrees | tier1 equilibrium PPR | 5800 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:5800 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_algebra_is_bit_identical_once_exp_agrees | tier1 equilibrium PPS | 5800 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:5800 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_algebra_is_bit_identical_once_exp_agrees | tier1 equilibrium PTR/PTS/PTV | 2175 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:2175 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_algebra_is_bit_identical_once_exp_agrees | tier1 equilibrium XVR | 5800 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:5800 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_algebra_is_bit_identical_once_exp_agrees | tier1 equilibrium XVS | 5800 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:5800 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_algebra_is_bit_identical_once_exp_agrees | tier1 equilibrium UTVR/UTVS | 1450 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:1450 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_algebra_is_bit_identical_once_exp_agrees | tier1 equilibrium UCVR | 5800 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:5800 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_algebra_is_bit_identical_once_exp_agrees | tier1 equilibrium UCVS | 5800 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:5800 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_equilibrium_matches_the_fortran_over_all_three_pools | tier1 equilibrium VVR/VVS | 4850 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:4850 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_equilibrium_matches_the_fortran_over_all_three_pools | tier1 equilibrium PPR | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_equilibrium_matches_the_fortran_over_all_three_pools | tier1 equilibrium PPS | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_equilibrium_matches_the_fortran_over_all_three_pools | tier1 equilibrium PTR/PTS/PTV | 7275 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:7275 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_equilibrium_matches_the_fortran_over_all_three_pools | tier1 equilibrium XVR | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_equilibrium_matches_the_fortran_over_all_three_pools | tier1 equilibrium XVS | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_equilibrium_matches_the_fortran_over_all_three_pools | tier1 equilibrium UTVR/UTVS | 4850 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:4850 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_equilibrium_matches_the_fortran_over_all_three_pools | tier1 equilibrium UCVR | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_equilibrium | the_equilibrium_matches_the_fortran_over_all_three_pools | tier1 equilibrium UCVS | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_kinetics | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 kinetics RR | 2900 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:2900 | 0 seen, 0 mismatched | |||||||
tier2_kinetics | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 kinetics CRXR | 5800 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:5800 | 0 seen, 0 mismatched | |||||||
tier2_kinetics | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 kinetics RH | 725 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:725 | 0 seen, 0 mismatched | |||||||
tier2_kinetics | the_inert_has_no_net_production_in_either_implementation | tier1 CRXR(2), never assigned | 200 | 0.000e0 at nominal#0[2] | 0 at nominal#0[2] | p50=0 p90=0 p99=0 p100=0 | 0:200 | 0 seen, 0 mismatched | |||||||
tier2_kinetics | the_kinetics_match_the_fortran_over_all_three_pools | tier1 kinetics RR | 9700 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:9700 | 0 seen, 0 mismatched | |||||||
tier2_kinetics | the_kinetics_match_the_fortran_over_all_three_pools | tier1 kinetics CRXR | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_kinetics | the_kinetics_match_the_fortran_over_all_three_pools | tier1 kinetics RH | 2425 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:2425 | 0 seen, 0 mismatched | |||||||
tier2_streams | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 streams XST (10 streams x 8) | 58000 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:58000 | 0 seen, 0 mismatched | |||||||
tier2_streams | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 streams XMWS (the 6 that exist) | 4350 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:4350 | 0 seen, 0 mismatched | |||||||
tier2_streams | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 streams TST | 7250 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:7250 | 0 seen, 0 mismatched | |||||||
tier2_streams | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 streams HST | 7250 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:7250 | 0 seen, 0 mismatched | |||||||
tier2_streams | the_stream_table_matches_the_fortran_over_all_three_pools | tier1 streams XST (10 streams x 8) | 194000 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:194000 | 0 seen, 0 mismatched | |||||||
tier2_streams | the_stream_table_matches_the_fortran_over_all_three_pools | tier1 streams XMWS (the 6 that exist) | 14550 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:14550 | 0 seen, 0 mismatched | |||||||
tier2_streams | the_stream_table_matches_the_fortran_over_all_three_pools | tier1 streams TST | 24250 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:24250 | 0 seen, 0 mismatched | |||||||
tier2_streams | the_stream_table_matches_the_fortran_over_all_three_pools | tier1 streams HST | 24250 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:24250 | 0 seen, 0 mismatched | |||||||
tier2_flows | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 flows FTM (the 10 assembled streams) | 7250 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:7250 | 0 seen, 0 mismatched | |||||||
tier2_flows | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 flows FCM (10 streams x 8) | 58000 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:58000 | 0 seen, 0 mismatched | |||||||
tier2_flows | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 flows FWR/FWS/AGSP | 2175 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:2175 | 0 seen, 0 mismatched | |||||||
tier2_flows | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 flows CPDH | 725 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:725 | 0 seen, 0 mismatched | |||||||
tier2_flows | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 flows HST(9) after the compressor bump | 725 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:725 | 0 seen, 0 mismatched | |||||||
tier2_flows | the_flow_network_matches_the_fortran_over_all_three_pools | tier1 flows FTM (the 10 assembled streams) | 24250 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:24250 | 0 seen, 0 mismatched | |||||||
tier2_flows | the_flow_network_matches_the_fortran_over_all_three_pools | tier1 flows FCM (10 streams x 8) | 194000 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:194000 | 0 seen, 0 mismatched | |||||||
tier2_flows | the_flow_network_matches_the_fortran_over_all_three_pools | tier1 flows FWR/FWS/AGSP | 7275 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:7275 | 0 seen, 0 mismatched | |||||||
tier2_flows | the_flow_network_matches_the_fortran_over_all_three_pools | tier1 flows CPDH | 2425 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:2425 | 0 seen, 0 mismatched | |||||||
tier2_flows | the_flow_network_matches_the_fortran_over_all_three_pools | tier1 flows HST(9) after the compressor bump | 2425 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:2425 | 0 seen, 0 mismatched | |||||||
tier2_flows | the_steam_coefficient_matches_through_the_condenser_duty | tier1 UAC, via QUC | 300 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:300 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 stripper SFR | 5800 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:5800 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 stripper FTM (5, 12): the column's own outlets | 1450 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:1450 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 stripper FTM (7): the reactor-inlet alias | 725 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:725 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 stripper FCM (5, 12): the column's own outlets | 11600 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:11600 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 stripper FCM (7): the reactor-inlet alias | 5800 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:5800 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 stripper XST (5, 12): the column's own outlets | 11600 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:11600 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 stripper XST (7): the reactor-inlet alias | 5800 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:5800 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 stripper TST (5, 7, 12) | 2175 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:2175 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 stripper HST (5, 12): the column's own outlets | 1450 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:1450 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 stripper HST (7): the reactor-inlet alias | 725 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:725 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper SFR | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper FTM (5, 12): the column's own outlets | 4850 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:4850 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper FTM (7): the reactor-inlet alias | 2425 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:2425 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper FCM (5, 12): the column's own outlets | 38800 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:38800 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper FCM (7): the reactor-inlet alias | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper XST (5, 12): the column's own outlets | 38800 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:38800 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper XST (7): the reactor-inlet alias | 19400 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:19400 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper TST (5, 7, 12) | 7275 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:7275 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper HST (5, 12): the column's own outlets | 4850 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:4850 | 0 seen, 0 mismatched | |||||||
tier2_stripper | the_stripper_matches_the_fortran_over_all_three_pools | tier1 stripper HST (7): the reactor-inlet alias | 2425 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:2425 | 0 seen, 0 mismatched | |||||||
tier2_heat | heat_transfer_matches_the_fortran_over_all_three_pools | tier1 heat UAR | 2425 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:2425 | 0 seen, 0 mismatched | |||||||
tier2_heat | heat_transfer_matches_the_fortran_over_all_three_pools | tier1 heat QUR | 2425 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:2425 | 0 seen, 0 mismatched | |||||||
tier2_heat | heat_transfer_matches_the_fortran_over_all_three_pools | tier1 heat QUS | 2425 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:2425 | 0 seen, 0 mismatched | |||||||
tier2_heat | heat_transfer_matches_the_fortran_over_all_three_pools | tier1 heat QUC | 2425 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:2425 | 0 seen, 0 mismatched | |||||||
tier2_heat | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 heat UAR | 725 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:725 | 0 seen, 0 mismatched | |||||||
tier2_heat | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 heat QUR | 725 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:725 | 0 seen, 0 mismatched | |||||||
tier2_heat | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 heat QUS | 725 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:725 | 0 seen, 0 mismatched | |||||||
tier2_heat | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 heat QUC | 725 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:725 | 0 seen, 0 mismatched | |||||||
tier2_measurements | the_algebra_is_bit_identical_once_exp_and_pow_agree | tier1 XMEAS(1..22), noise-free | 15950 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:15950 | 0 seen, 0 mismatched | |||||||
tier2_measurements | the_measurements_match_the_fortran_over_all_three_pools | tier1 XMEAS(1..22), noise-free | 53350 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:53350 | 0 seen, 0 mismatched | |||||||
tier2_measurements | the_shutdown_detector_agrees_with_the_fortran_on_every_state | tier1 ISD as 0 or 1 | 2425 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:2425 | 0 seen, 0 mismatched | |||||||
tier2_balances | all_fifty_derivatives_match_the_fortran_over_all_three_pools | tier1 YP(1..50), relative to the derivative (reported) | 120600 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:120600 | 0 seen, 0 mismatched | |||||||
tier2_balances | all_fifty_derivatives_match_the_fortran_over_all_three_pools | tier1 YP(1..50), relative to the scale of the terms (the gate) | 120600 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:120600 | 0 seen, 0 mismatched | |||||||
tier2_balances | all_fifty_derivatives_match_the_fortran_over_all_three_pools | tier1 YP(1..50), plant frozen | 650 | 0.000e0 at perturbed#369[1] | 0 at perturbed#369[1] | p50=0 p90=0 p99=0 p100=0 | 0:650 | 0 seen, 0 mismatched | |||||||
tier2_balances | the_whole_right_hand_side_is_bit_identical_once_exp_and_pow_agree | tier1 YP(1..50), relative to the derivative (reported) | 35850 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:35850 | 0 seen, 0 mismatched | |||||||
tier2_balances | tier2_acceptance_table | tier1 YP(1..50), relative to the scale of the terms (the gate) | 120600 | 0.000e0 at nominal#0[1] | 0 at nominal#0[1] | p50=0 p90=0 p99=0 p100=0 | 0:120600 | 0 seen, 0 mismatched |
Transcripts
Each block below is a test binary's own output, verbatim, with the command that produced it. The summary above is derived from these; they are not derived from it.
tier2_unpack, vendored libm
cargo test -p tepsim-oracle --features oracle --release --test tier2_unpack -- --nocapture --test-threads 1
running 3 tests
test solving_from_a_fixed_guess_instead_of_the_seed_breaks_bit_equality ... tier1 TCV with the carried seed
cases : 100
max rel err : 0.000e0 at nominal#0[4]
max ulp : 0 at nominal#0[4]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:100
non-finite : 0 seen, 0 mismatched
tier1 TCV from a fixed guess
cases : 100
max rel err : 9.901e-16 at nominal#25[4]
max ulp : 6 at nominal#25[4]
ulp percentiles: p50=1 p90=4 p99=6 p100=6
ulp histogram : 0:24 1:28 2:18 3:19 4:7 5:1 6:3
non-finite : 0 seen, 0 mismatched
ok
test the_unpacking_is_bit_identical_to_the_fortran ... ok
test the_unpacking_matches_the_fortran_over_all_three_pools ... tier1 unpack UCLR
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
tier1 unpack UCLS
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
tier1 unpack UCLC
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
tier1 unpack UCVV
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
tier1 unpack UTLR/UTLS/UTLC/UTVV
cases : 9700
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:9700
non-finite : 0 seen, 0 mismatched
tier1 unpack XLR
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
tier1 unpack XLS
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
tier1 unpack XLC
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
tier1 unpack XVV
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
tier1 unpack ESR/ESS/ESC/ESV
cases : 9700
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:9700
non-finite : 0 seen, 0 mismatched
tier1 unpack TCR/TCS/TCC/TCV
cases : 9700
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:9700
non-finite : 0 seen, 0 mismatched
tier1 unpack TKR/TKS/TKV
cases : 7275
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7275
non-finite : 0 seen, 0 mismatched
tier1 unpack DLR/DLS/DLC
cases : 7275
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7275
non-finite : 0 seen, 0 mismatched
tier1 unpack VLR/VLS/VLC
cases : 7275
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7275
non-finite : 0 seen, 0 mismatched
states skipped because the port would not converge: 0
ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s
tier2_equilibrium, vendored libm
cargo test -p tepsim-oracle --features oracle --release --test tier2_equilibrium -- --nocapture --test-threads 1
running 3 tests
test the_equilibrium_matches_the_fortran_over_all_three_pools ... exp comes from the vendored libm
tier1 equilibrium VVR/VVS
cases : 4850
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:4850
non-finite : 0 seen, 0 mismatched
tier1 equilibrium PPR
cases : 19400
max rel err : 2.802e-16 at perturbed#1264[4]
max ulp : 2 at nominal#21[4]
ulp percentiles: p50=0 p90=0 p99=1 p100=2
ulp histogram : 0:17917 1:1305 2:178
non-finite : 0 seen, 0 mismatched
tier1 equilibrium PPS
cases : 19400
max rel err : 2.784e-16 at perturbed#609[8]
max ulp : 2 at nominal#26[8]
ulp percentiles: p50=0 p90=0 p99=1 p100=2
ulp histogram : 0:18322 1:899 2:179
non-finite : 0 seen, 0 mismatched
tier1 equilibrium PTR/PTS/PTV
cases : 7275
max rel err : 3.578e-16 at perturbed#576[2]
max ulp : 2 at nominal#188[1]
ulp percentiles: p50=0 p90=0 p99=1 p100=2
ulp histogram : 0:7130 1:133 2:12
non-finite : 0 seen, 0 mismatched
tier1 equilibrium XVR
cases : 19400
max rel err : 4.843e-16 at perturbed#88[4]
max ulp : 3 at nominal#44[7]
ulp percentiles: p50=0 p90=0 p99=2 p100=3
ulp histogram : 0:17705 1:1305 2:368 3:22
non-finite : 0 seen, 0 mismatched
tier1 equilibrium XVS
cases : 19400
max rel err : 4.302e-16 at perturbed#1121[7]
max ulp : 3 at nominal#287[3]
ulp percentiles: p50=0 p90=0 p99=2 p100=3
ulp histogram : 0:18174 1:978 2:238 3:10
non-finite : 0 seen, 0 mismatched
tier1 equilibrium UTVR/UTVS
cases : 4850
max rel err : 4.443e-16 at perturbed#169[2]
max ulp : 3 at nominal#303[2]
ulp percentiles: p50=0 p90=0 p99=1 p100=3
ulp histogram : 0:4733 1:82 2:32 3:3
non-finite : 0 seen, 0 mismatched
tier1 equilibrium UCVR
cases : 19400
max rel err : 5.077e-16 at perturbed#1281[8]
max ulp : 4 at perturbed#1281[8]
ulp percentiles: p50=0 p90=0 p99=2 p100=4
ulp histogram : 0:18105 1:920 2:330 3:44 4:1
non-finite : 0 seen, 0 mismatched
tier1 equilibrium UCVS
cases : 19400
max rel err : 6.230e-16 at perturbed#169[6]
max ulp : 3 at perturbed#169[6]
ulp percentiles: p50=0 p90=0 p99=1 p100=3
ulp histogram : 0:18585 1:713 2:98 3:4
non-finite : 0 seen, 0 mismatched
states skipped because the port would not converge: 0
ok
test the_ideal_gas_partial_pressures_are_bit_identical_under_the_vendored_libm ... tier1 PPR/PPS for A, B and C
cases : 1200
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:1200
non-finite : 0 seen, 0 mismatched
tier1 PTV
cases : 200
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:200
non-finite : 0 seen, 0 mismatched
ok
test the_vendored_and_platform_exp_differ_only_by_rounding ... exp over the Antoine range [0.5868, 13.0825]: 15000 arguments, 1507 differ (10.047%), worst -1 ulp
ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.09s
tier2_kinetics, vendored libm
cargo test -p tepsim-oracle --features oracle --release --test tier2_kinetics -- --nocapture --test-threads 1
running 3 tests
test the_inert_has_no_net_production_in_either_implementation ... tier1 CRXR(2), never assigned
cases : 200
max rel err : 0.000e0 at nominal#0[2]
max ulp : 0 at nominal#0[2]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:200
non-finite : 0 seen, 0 mismatched
ok
test the_kinetics_match_the_fortran_over_all_three_pools ... exp and pow come from the vendored libm
tier1 kinetics RR
cases : 9700
max rel err : 8.492e-16 at perturbed#618[2]
max ulp : 7 at perturbed#1279[1]
ulp percentiles: p50=0 p90=2 p99=3 p100=7
ulp histogram : 0:7255 1:1447 2:732 3:235 4:22 5:7 6:1 7:1
non-finite : 0 seen, 0 mismatched
tier1 kinetics CRXR
cases : 19400
max rel err : 8.492e-16 at perturbed#618[8]
max ulp : 7 at perturbed#1279[4]
ulp percentiles: p50=0 p90=2 p99=3 p100=7
ulp histogram : 0:14063 1:3030 2:1763 3:450 4:72 5:16 6:4 7:2
non-finite : 0 seen, 0 mismatched
tier1 kinetics RH
cases : 2425
max rel err : 7.419e-16 at perturbed#293[1]
max ulp : 6 at perturbed#293[1]
ulp percentiles: p50=0 p90=2 p99=3 p100=6
ulp histogram : 0:1594 1:472 2:299 3:50 4:8 5:1 6:1
non-finite : 0 seen, 0 mismatched
states skipped because the port would not converge: 0
ok
test the_vendored_and_platform_pow_differ_only_by_rounding ... pow over [0.25, 5000] at orders 1.1544 and 0.3735: 40000 cases, 3992 differ (9.980%), worst -1 ulp
ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.09s
tier2_streams, vendored libm
cargo test -p tepsim-oracle --features oracle --release --test tier2_streams -- --nocapture --test-threads 1
running 3 tests
test the_compressor_makes_the_recycle_enthalpy_differ_from_the_purge ... HST(9) differs from HST(10) on 200 of 200 nominal states
ok
test the_stream_table_matches_the_fortran_over_all_three_pools ... exp and pow come from the vendored libm
tier1 streams XST (10 streams x 8)
cases : 194000
max rel err : 4.736e-16 at perturbed#1378[44]
max ulp : 4 at perturbed#1923[47]
ulp percentiles: p50=0 p90=0 p99=1 p100=4
ulp histogram : 0:189849 1:3316 2:804 3:30 4:1
non-finite : 0 seen, 0 mismatched
tier1 streams XMWS (the 6 that exist)
cases : 14550
max rel err : 4.370e-16 at perturbed#791[4]
max ulp : 3 at perturbed#431[4]
ulp percentiles: p50=0 p90=0 p99=1 p100=3
ulp histogram : 0:14073 1:392 2:79 3:6
non-finite : 0 seen, 0 mismatched
tier1 streams TST
cases : 24250
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:24250
non-finite : 0 seen, 0 mismatched
tier1 streams HST
cases : 24250
max rel err : 5.050e-16 at perturbed#1923[6]
max ulp : 4 at perturbed#1923[6]
ulp percentiles: p50=0 p90=0 p99=1 p100=4
ulp histogram : 0:23683 1:461 2:94 3:10 4:2
non-finite : 0 seen, 0 mismatched
states skipped because the port would not converge: 0
ok
test the_unweighed_streams_are_zero_in_both_implementations ... 7 unweighed streams, all zero over 200 states
ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s
tier2_flows, vendored libm
cargo test -p tepsim-oracle --features oracle --release --test tier2_flows -- --nocapture --test-threads 1
running 5 tests
test component_flows_sum_to_the_stream_total ... ok
test no_sampled_state_reaches_the_purge_clamp ... lowest PTS over the whole pool: 19153.09 mmHg against a 760 threshold
ok
test the_compressor_ratio_clamps_are_exercised_by_the_adversarial_pool ... VLR at the 10% heat-transfer breakpoint None
VLR at the 50% heat-transfer breakpoint None
TCC at the lower stripping-factor branch None
TCC below the lower stripping-factor branch None
TCC at the upper stripping-factor branch None
TCC approaching the 177 C pole None
TCR at the shutdown limit None
TCR above the shutdown limit None
FTM(11) at the stripping-factor threshold None
VLR at the upper shutdown limit None
VLR at the lower shutdown limit None
VLS at the upper shutdown limit None
VLS at the lower shutdown limit None
VLC at the upper shutdown limit None
VLC at the lower shutdown limit None
PTR at the reactor pressure shutdown limit None
PTV = PTR, the mixing-to-reactor flow clamp None
PTR = PTS, the reactor-to-separator flow clamp None
PTV = PTS, the recycle flow clamp Low
PR = 1, the compressor reverse-flow clamp Low
PR = CPPRMX, the compressor maximum-ratio clamp None
PR above CPPRMX, inside the clamped region High
VLR below the lower shutdown limit None
VLS below the lower shutdown limit None
VLC below the lower shutdown limit None
ok
test the_flow_network_matches_the_fortran_over_all_three_pools ... exp, pow and sqrt come from the vendored libm
tier1 flows FTM (the 10 assembled streams)
cases : 24250
max rel err : 2.246e-14 at perturbed#1790[7]
max ulp : 125 at perturbed#1453[6]
ulp percentiles: p50=0 p90=0 p99=5 p100>=16
ulp histogram : 0:23639 1:224 2:85 3:47 4:12 5:13 6:17 7:17 8:10 9:7 10:5 11:6 12:3 13:2 15:1 >=16:162
non-finite : 0 seen, 0 mismatched
tier1 flows FCM (10 streams x 8)
cases : 194000
max rel err : 2.268e-14 at perturbed#1790[53]
max ulp : 178 at perturbed#1106[43]
ulp percentiles: p50=0 p90=0 p99=4 p100>=16
ulp histogram : 0:187622 1:2738 2:1090 3:426 4:288 5:119 6:88 7:62 8:43 9:57 10:38 11:31 12:25 13:47 14:66 15:90 >=16:1170
non-finite : 0 seen, 0 mismatched
tier1 flows FWR/FWS/AGSP
cases : 7275
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7275
non-finite : 0 seen, 0 mismatched
tier1 flows CPDH
cases : 2425
max rel err : 2.034e-15 at perturbed#1790[1]
max ulp : 18 at perturbed#1790[1]
ulp percentiles: p50=0 p90=0 p99=2 p100>=16
ulp histogram : 0:2312 1:71 2:20 3:6 4:5 5:6 6:1 7:3 >=16:1
non-finite : 0 seen, 0 mismatched
tier1 flows HST(9) after the compressor bump
cases : 2425
max rel err : 6.522e-15 at perturbed#1790[1]
max ulp : 42 at perturbed#1790[1]
ulp percentiles: p50=0 p90=0 p99=2 p100>=16
ulp histogram : 0:2253 1:133 2:24 3:10 4:1 5:1 6:1 9:1 >=16:1
non-finite : 0 seen, 0 mismatched
states skipped because the port would not converge: 0
ok
test the_steam_coefficient_matches_through_the_condenser_duty ... tier1 UAC, via QUC
cases : 300
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:300
non-finite : 0 seen, 0 mismatched
300 states below 100 C, 0 at or above and excluded
ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.16s
tier2_stripper, vendored libm
cargo test -p tepsim-oracle --features oracle --release --test tier2_stripper -- --nocapture --test-threads 1
running 4 tests
test every_stripper_branch_is_exercised_by_the_pool ... VLR at the 10% heat-transfer breakpoint Hyperbolic
VLR at the 50% heat-transfer breakpoint Hyperbolic
TCC at the lower stripping-factor branch Hyperbolic
TCC below the lower stripping-factor branch Pinned
TCC at the upper stripping-factor branch Hyperbolic
TCC approaching the 177 C pole Linear
TCR at the shutdown limit Hyperbolic
TCR above the shutdown limit Hyperbolic
FTM(11) at the stripping-factor threshold Idle
VLR at the upper shutdown limit Hyperbolic
VLR at the lower shutdown limit Hyperbolic
VLS at the upper shutdown limit Hyperbolic
VLS at the lower shutdown limit Hyperbolic
VLC at the upper shutdown limit Hyperbolic
VLC at the lower shutdown limit Hyperbolic
PTR at the reactor pressure shutdown limit Hyperbolic
PTV = PTR, the mixing-to-reactor flow clamp Hyperbolic
PTR = PTS, the reactor-to-separator flow clamp Hyperbolic
PTV = PTS, the recycle flow clamp Hyperbolic
PR = 1, the compressor reverse-flow clamp Hyperbolic
PR = CPPRMX, the compressor maximum-ratio clamp Hyperbolic
PR above CPPRMX, inside the clamped region Hyperbolic
VLR below the lower shutdown limit Hyperbolic
VLS below the lower shutdown limit Hyperbolic
VLC below the lower shutdown limit Hyperbolic
branches reached: {"hyperbolic", "idle", "linear", "pinned"}
ok
test the_non_condensible_factors_never_move_in_the_fortran_either ... SFR(1..3) fixed at [0.9950000047683716, 0.9909999966621399, 0.9900000095367432] across 300 nominal and 20 adversarial states
ok
test the_reactor_inlet_is_an_alias_in_the_fortran_too ... ok
test the_stripper_matches_the_fortran_over_all_three_pools ... transcendentals come from the vendored libm
tier1 stripper SFR
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
tier1 stripper FTM (5, 12): the column's own outlets
cases : 4850
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:4850
non-finite : 0 seen, 0 mismatched
tier1 stripper FTM (7): the reactor-inlet alias
cases : 2425
max rel err : 1.484e-15 at perturbed#817[1]
max ulp : 12 at perturbed#591[1]
ulp percentiles: p50=0 p90=0 p99=5 p100=12
ulp histogram : 0:2322 1:1 2:19 3:46 4:5 5:8 6:15 7:6 8:1 12:2
non-finite : 0 seen, 0 mismatched
tier1 stripper FCM (5, 12): the column's own outlets
cases : 38800
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:38800
non-finite : 0 seen, 0 mismatched
tier1 stripper FCM (7): the reactor-inlet alias
cases : 19400
max rel err : 1.610e-15 at perturbed#817[3]
max ulp : 13 at perturbed#591[8]
ulp percentiles: p50=0 p90=0 p99=4 p100=13
ulp histogram : 0:18576 1:6 2:100 3:313 4:228 5:103 6:33 7:15 8:11 9:8 10:1 11:3 12:2 13:1
non-finite : 0 seen, 0 mismatched
tier1 stripper XST (5, 12): the column's own outlets
cases : 38800
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:38800
non-finite : 0 seen, 0 mismatched
tier1 stripper XST (7): the reactor-inlet alias
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
tier1 stripper TST (5, 7, 12)
cases : 7275
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7275
non-finite : 0 seen, 0 mismatched
tier1 stripper HST (5, 12): the column's own outlets
cases : 4850
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:4850
non-finite : 0 seen, 0 mismatched
tier1 stripper HST (7): the reactor-inlet alias
cases : 2425
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:2425
non-finite : 0 seen, 0 mismatched
states skipped because the port would not converge: 0
ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.14s
tier2_heat, vendored libm
cargo test -p tepsim-oracle --features oracle --release --test tier2_heat -- --nocapture --test-threads 1
running 4 tests
test both_sides_of_the_steam_cutoff_are_exercised ... steam on: 423 states, off: 2 states
ok
test every_level_branch_is_exercised_by_the_pool ... VLR at the 10% heat-transfer breakpoint dry
VLR at the 50% heat-transfer breakpoint ramp
TCC at the lower stripping-factor branch fully wetted
TCC below the lower stripping-factor branch fully wetted
TCC at the upper stripping-factor branch fully wetted
TCC approaching the 177 C pole fully wetted
TCR at the shutdown limit fully wetted
TCR above the shutdown limit fully wetted
FTM(11) at the stripping-factor threshold fully wetted
VLR at the upper shutdown limit fully wetted
VLR at the lower shutdown limit dry
VLS at the upper shutdown limit fully wetted
VLS at the lower shutdown limit fully wetted
VLC at the upper shutdown limit fully wetted
VLC at the lower shutdown limit fully wetted
PTR at the reactor pressure shutdown limit fully wetted
PTV = PTR, the mixing-to-reactor flow clamp fully wetted
PTR = PTS, the reactor-to-separator flow clamp fully wetted
PTV = PTS, the recycle flow clamp fully wetted
PR = 1, the compressor reverse-flow clamp fully wetted
PR = CPPRMX, the compressor maximum-ratio clamp fully wetted
PR above CPPRMX, inside the clamped region fully wetted
VLR below the lower shutdown limit dry
VLS below the lower shutdown limit fully wetted
VLC below the lower shutdown limit fully wetted
level branches reached: {"dry", "fully wetted", "ramp"}
ok
test heat_transfer_matches_the_fortran_over_all_three_pools ... transcendentals come from the vendored libm
tier1 heat UAR
cases : 2425
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:2425
non-finite : 0 seen, 0 mismatched
tier1 heat QUR
cases : 2425
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:2425
non-finite : 0 seen, 0 mismatched
tier1 heat QUS
cases : 2425
max rel err : 9.572e-13 at perturbed#50[1]
max ulp : 8009 at perturbed#50[1]
ulp percentiles: p50=0 p90=0 p99>=16 p100>=16
ulp histogram : 0:2196 1:23 2:14 3:10 4:21 5:9 6:3 7:10 8:4 9:2 10:3 11:1 12:3 13:3 14:6 15:1 >=16:116
non-finite : 0 seen, 0 mismatched
tier1 heat QUC
cases : 2425
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:2425
non-finite : 0 seen, 0 mismatched
states skipped because the port would not converge: 0
ok
test the_condenser_driving_difference_is_large_enough_to_discriminate ... smallest gap between TST(8) and TCS as the driving temperature: 40.2906 C
ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.17s
tier2_measurements, vendored libm
cargo test -p tepsim-oracle --features oracle --release --test tier2_measurements -- --nocapture --test-threads 1
running 4 tests
test the_measurements_match_the_fortran_over_all_three_pools ... transcendentals come from the vendored libm
tier1 XMEAS(1..22), noise-free
cases : 53350
max rel err : 8.774e-15 at perturbed#1851[5]
max ulp : 78 at perturbed#1851[5]
ulp percentiles: p50=0 p90=0 p99=1 p100>=16
ulp histogram : 0:52801 1:226 2:132 3:58 4:44 5:23 6:7 7:7 8:3 9:4 13:1 14:3 >=16:41
non-finite : 0 seen, 0 mismatched
states skipped because the port would not converge: 0
ok
test the_pool_reaches_several_distinct_shutdown_causes ... TCR at the shutdown limit ["reactor pressure high"]
TCR above the shutdown limit ["reactor pressure high", "reactor temperature high"]
VLR at the upper shutdown limit ["reactor pressure high", "reactor level high"]
VLS at the upper shutdown limit ["separator level high"]
VLC at the upper shutdown limit ["stripper level high"]
VLR below the lower shutdown limit ["reactor level low"]
VLS below the lower shutdown limit ["separator level low"]
VLC below the lower shutdown limit ["stripper level low"]
shutdown causes reached: {"reactor level high", "reactor level low", "reactor pressure high", "reactor temperature high", "separator level high", "separator level low", "stripper level high", "stripper level low"}
ok
test the_shutdown_detector_agrees_with_the_fortran_on_every_state ... tier1 ISD as 0 or 1
cases : 2425
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:2425
non-finite : 0 seen, 0 mismatched
12 states trip, 2413 do not
ok
test time_zero_is_what_suppresses_the_noise ... 22 of 22 measurements differ with the clock running
ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s
tier2_balances, vendored libm
cargo test -p tepsim-oracle --features oracle --release --test tier2_balances -- --nocapture --test-threads 1
running 4 tests
test all_fifty_derivatives_match_the_fortran_over_all_three_pools ... transcendentals come from the vendored libm
tier1 YP(1..50), relative to the derivative (reported)
cases : 120600
max rel err : 1.393e-4 at nominal#303[2]
max ulp : 738734374912 at nominal#303[2]
ulp percentiles: p50=0 p90=2 p99>=16 p100>=16
ulp histogram : 0:107101 1:935 2:1289 3:101 4:1512 5:22 6:176 7:14 8:1354 9:19 10:35 11:7 12:166 13:4 14:21 15:11 >=16:7833
non-finite : 0 seen, 0 mismatched
tier1 YP(1..50), relative to the scale of the terms (the gate)
cases : 120600
max rel err : 6.093e-14 at perturbed#300[7]
max ulp : 738734374912 at nominal#303[2]
ulp percentiles: p50=0 p90=2 p99>=16 p100>=16
ulp histogram : 0:107101 1:935 2:1289 3:101 4:1512 5:22 6:176 7:14 8:1354 9:19 10:35 11:7 12:166 13:4 14:21 15:11 >=16:7833
non-finite : 0 seen, 0 mismatched
tier1 YP(1..50), plant frozen
cases : 650
max rel err : 0.000e0 at perturbed#369[1]
max ulp : 0 at perturbed#369[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:650
non-finite : 0 seen, 0 mismatched
2412 states running, 13 frozen, 0 skipped
ok
test every_derivative_slot_actually_moves_somewhere_in_the_pool ... 50 of 50 slots move somewhere in the pool
ok
test the_quirk_fix_changes_only_the_frozen_states ... the fix changes 8 tripping boundaries and leaves 17 alone
ok
test tier2_acceptance_table ... Tier 2 acceptance, 2412 running states
gate: error / scale-of-terms < 1e-12
YP err/scale err/value ratio
1 2.537e-14 8.181e-8 3224593x
2 3.826e-14 3.401e-5 889000607x
3 2.187e-14 7.030e-8 3214801x
4 5.557e-15 2.377e-8 4277976x
5 2.688e-14 5.845e-8 2174388x
6 4.370e-14 2.889e-7 6610686x
7 6.093e-14 1.920e-5 315124861x
8 5.039e-14 8.304e-7 16478948x
9 3.115e-14 1.445e-8 463960x
10 2.910e-14 3.896e-8 1338742x
11 2.899e-14 2.981e-7 10282117x
12 2.918e-14 2.702e-8 925989x
13 2.755e-14 3.689e-8 1339161x
14 3.113e-14 1.779e-7 5713202x
15 3.415e-14 1.068e-7 3128911x
16 5.759e-14 1.020e-6 17702099x
17 4.644e-14 7.407e-9 159498x
18 5.136e-14 2.280e-8 444028x
19 0.000e0 0.000e0 1x
20 0.000e0 0.000e0 1x
21 0.000e0 0.000e0 1x
22 0.000e0 0.000e0 1x
23 0.000e0 0.000e0 1x
24 0.000e0 0.000e0 1x
25 0.000e0 0.000e0 1x
26 0.000e0 0.000e0 1x
27 0.000e0 0.000e0 1x
28 5.276e-15 3.361e-8 6370459x
29 7.713e-15 1.852e-7 24013923x
30 4.567e-15 3.551e-8 7774685x
31 1.241e-15 4.665e-9 3760128x
32 5.087e-15 3.403e-8 6689516x
33 7.093e-15 3.078e-7 43390364x
34 6.921e-15 2.810e-8 4059475x
35 7.220e-15 6.372e-9 882549x
36 5.298e-15 9.721e-7 183496207x
37 0.000e0 0.000e0 1x
38 7.969e-15 5.112e-7 64146903x
39 0.000e0 0.000e0 1x
40 0.000e0 0.000e0 1x
41 0.000e0 0.000e0 1x
42 0.000e0 0.000e0 1x
43 0.000e0 0.000e0 1x
44 0.000e0 0.000e0 1x
45 0.000e0 0.000e0 1x
46 0.000e0 0.000e0 1x
47 0.000e0 0.000e0 1x
48 0.000e0 0.000e0 1x
49 0.000e0 0.000e0 1x
50 0.000e0 0.000e0 1x
worst component: YP(7) at 6.093e-14 of its own scale
tier1 YP(1..50), relative to the scale of the terms (the gate)
cases : 120600
max rel err : 6.093e-14 at perturbed#300[7]
max ulp : 206158430208 at nominal#296[2]
ulp percentiles: p50=0 p90=2 p99>=16 p100>=16
ulp histogram : 0:107280 1:890 2:1293 3:113 4:1468 5:32 6:163 7:17 8:1240 9:19 10:42 11:11 12:166 13:7 14:26 15:11 >=16:7822
non-finite : 0 seen, 0 mismatched
28 of 50 components cancel by more than 100x
ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.20s
tier4_closed_loop, platform libm
cargo test -p tepsim-oracle --features oracle,libm-system --release --test tier4_closed_loop -- --nocapture --test-threads 1
running 5 tests
test the_closed_loop_plant_matches_the_fortran_driver ... closed loop, platform libm, 10 h (36000 steps)
worst XMEAS : 0.000e0 at XMEAS(0)
worst XMV : 0.000e0 %range at XMV(0)
within XNS : 10.000 h of 10, ending at 0.000e0 x XNS(0)
first split : never
ok
test the_controlled_plant_runs_the_full_horizon_without_tripping ... 10 h (36000 steps): fortran up, port closed-loop up, port open-loop tripped at step 11017 (3.060 h) on Some(ReactorPressureHigh)
ok
test the_controllers_read_the_previous_steps_measurements ... XMV(7) on the first fire: previous-step 34.147823842, current-step 35.623679189, fortran 34.147823842
ok
test the_driver_forces_idv12_at_the_eight_hour_mark ... ok
test the_forced_disturbance_changes_the_plant_measurably ... D-011, 10 h, faithful against fixed:
first split, port : Some((29390, 22))
first split, fortran: Some((29390, 22))
worst over the run : 1.092e-1 at XMEAS(37)
worst at the end : 3.262e-2 at XMEAS(38)
ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.29s
tier5_invariants, platform libm
cargo test -p tepsim-oracle --features oracle,libm-system --release --test tier5_invariants -- --nocapture --test-threads 1
running 9 tests
test each_reaction_balances_on_paper ... ok
test every_component_balances_on_moles ... I-4 on the Fortran over 1 h: worst 7.436e-15 relative, on F at step 627; largest reaction term seen 457.5 lbmol/h
ok
test every_component_balances_on_moles_in_the_port ... I-4 on the port at the nominal state: worst 2.892e-15 relative, on F
ok
test the_fortran_conserves_mass_across_the_reaction ... I-1 on the Fortran: 200 states, worst residual 4.664e-16 of the term scale, at pool index 130
ok
test the_inert_has_no_reaction_term ... I-3: CRXR(2) is zero across 100 reacting states
ok
test the_plant_balances_mass_away_from_steady_state ... I-2 along 2 h open loop: worst 6.556e-16 relative at step 6570, where the accumulation term is 0.1 lb/h
accumulation 0.0613 lb/h against a residual of about 2.10e-11 lb/h: a factor of 2.9e9
ok
test the_port_conserves_mass_across_the_reaction ... I-1 on the port: 200 states, worst residual 4.664e-16 of the term scale, at pool index 22
ok
test the_ported_plant_balances_mass ... I-2 on the port at the nominal state: residual 6.693535e-12 lb/h against 32191.8 lb/h, 2.079e-16 relative
ok
test the_whole_plant_balances_mass ... I-2 on the Fortran at the nominal state: residual 6.693535e-12 lb/h against a throughput of 32191.8 lb/h, 2.079e-16 relative
ok
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.29s
tier5_runs, platform libm
cargo test -p tepsim-oracle --features oracle,libm-system --release --test tier5_runs -- --nocapture --test-threads 1
running 10 tests
test a_run_is_reproducible_from_its_scenario_and_seed ... ok
test both_sources_produce_the_same_shape ... 2 h gives 40 samples of 53 variables
ok
test different_seeds_give_different_runs ... five nominal seeds: worst relative spread 1.366e1
ok
test every_disturbance_moves_the_plant ... IDV(1) worst departure from nominal 2.456e0
IDV(2) worst departure from nominal 5.964e-1
IDV(3) worst departure from nominal 4.076e-3
IDV(4) worst departure from nominal 1.396e-1
IDV(5) worst departure from nominal 3.522e-1
IDV(6) worst departure from nominal 3.467e0
IDV(7) worst departure from nominal 7.595e-1
IDV(8) worst departure from nominal 3.927e-1
IDV(9) worst departure from nominal 3.531e-3
IDV(10) worst departure from nominal 1.801e-2
IDV(11) worst departure from nominal 1.798e-1
IDV(12) worst departure from nominal 1.413e-1
IDV(13) worst departure from nominal 1.956e-1
IDV(14) worst departure from nominal 2.626e-1
IDV(15) worst departure from nominal 9.839e-3
IDV(16) worst departure from nominal 7.772e-2
IDV(17) worst departure from nominal 7.203e-2
IDV(18) worst departure from nominal 3.782e-1
IDV(19) worst departure from nominal 7.098e-2
IDV(20) worst departure from nominal 9.324e-2
ok
test every_seed_is_a_valid_generator_word ... ok
test the_battery_sizes_are_what_they_claim ... full battery: 2100 runs per source, 960 samples each, about 27 min per source at 766 ms per 48 h run
ok
test the_published_seed_table_is_transcribed_correctly ... published seeds: 34 of 54 exceed 2^32, 27 of 54 are even
ok
test the_sticking_valve_divergence_is_the_transcendentals ... IDV(14), XMV(10): worst absolute difference 0.000e0 with the platform libm
ok
test the_two_sources_agree_over_a_whole_run ... nominal: worst 0.000e0 at sample 0 variable 1 (platform libm)
IDV(1): worst 0.000e0 at sample 0 variable 1 (platform libm)
IDV(13): worst 0.000e0 at sample 0 variable 1 (platform libm)
IDV(14): worst 0.000e0 at sample 0 variable 1 (platform libm)
IDV(19): worst 0.000e0 at sample 0 variable 1 (platform libm)
ok
test there_are_twenty_faults_and_twenty_one_scenarios ... ok
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.87s
tier2_equilibrium, platform libm
cargo test -p tepsim-oracle --features oracle,libm-system --release --test tier2_equilibrium -- --nocapture --test-threads 1
running 3 tests
test the_algebra_is_bit_identical_once_exp_agrees ... tier1 equilibrium VVR/VVS
cases : 1450
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:1450
non-finite : 0 seen, 0 mismatched
tier1 equilibrium PPR
cases : 5800
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:5800
non-finite : 0 seen, 0 mismatched
tier1 equilibrium PPS
cases : 5800
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:5800
non-finite : 0 seen, 0 mismatched
tier1 equilibrium PTR/PTS/PTV
cases : 2175
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:2175
non-finite : 0 seen, 0 mismatched
tier1 equilibrium XVR
cases : 5800
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:5800
non-finite : 0 seen, 0 mismatched
tier1 equilibrium XVS
cases : 5800
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:5800
non-finite : 0 seen, 0 mismatched
tier1 equilibrium UTVR/UTVS
cases : 1450
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:1450
non-finite : 0 seen, 0 mismatched
tier1 equilibrium UCVR
cases : 5800
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:5800
non-finite : 0 seen, 0 mismatched
tier1 equilibrium UCVS
cases : 5800
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:5800
non-finite : 0 seen, 0 mismatched
ok
test the_equilibrium_matches_the_fortran_over_all_three_pools ... exp comes from the platform libm
tier1 equilibrium VVR/VVS
cases : 4850
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:4850
non-finite : 0 seen, 0 mismatched
tier1 equilibrium PPR
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
tier1 equilibrium PPS
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
tier1 equilibrium PTR/PTS/PTV
cases : 7275
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7275
non-finite : 0 seen, 0 mismatched
tier1 equilibrium XVR
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
tier1 equilibrium XVS
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
tier1 equilibrium UTVR/UTVS
cases : 4850
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:4850
non-finite : 0 seen, 0 mismatched
tier1 equilibrium UCVR
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
tier1 equilibrium UCVS
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
states skipped because the port would not converge: 0
ok
test the_vendored_and_platform_exp_differ_only_by_rounding ... exp over the Antoine range [0.5868, 13.0825]: 15000 arguments, 0 differ (0.000%), worst 0 ulp
ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s
tier2_kinetics, platform libm
cargo test -p tepsim-oracle --features oracle,libm-system --release --test tier2_kinetics -- --nocapture --test-threads 1
running 4 tests
test the_algebra_is_bit_identical_once_exp_and_pow_agree ... tier1 kinetics RR
cases : 2900
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:2900
non-finite : 0 seen, 0 mismatched
tier1 kinetics CRXR
cases : 5800
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:5800
non-finite : 0 seen, 0 mismatched
tier1 kinetics RH
cases : 725
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:725
non-finite : 0 seen, 0 mismatched
ok
test the_inert_has_no_net_production_in_either_implementation ... tier1 CRXR(2), never assigned
cases : 200
max rel err : 0.000e0 at nominal#0[2]
max ulp : 0 at nominal#0[2]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:200
non-finite : 0 seen, 0 mismatched
ok
test the_kinetics_match_the_fortran_over_all_three_pools ... exp and pow come from the platform libm
tier1 kinetics RR
cases : 9700
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:9700
non-finite : 0 seen, 0 mismatched
tier1 kinetics CRXR
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
tier1 kinetics RH
cases : 2425
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:2425
non-finite : 0 seen, 0 mismatched
states skipped because the port would not converge: 0
ok
test the_vendored_and_platform_pow_differ_only_by_rounding ... pow over [0.25, 5000] at orders 1.1544 and 0.3735: 40000 cases, 0 differ (0.000%), worst 0 ulp
ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s
tier2_streams, platform libm
cargo test -p tepsim-oracle --features oracle,libm-system --release --test tier2_streams -- --nocapture --test-threads 1
running 4 tests
test the_algebra_is_bit_identical_once_exp_and_pow_agree ... tier1 streams XST (10 streams x 8)
cases : 58000
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:58000
non-finite : 0 seen, 0 mismatched
tier1 streams XMWS (the 6 that exist)
cases : 4350
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:4350
non-finite : 0 seen, 0 mismatched
tier1 streams TST
cases : 7250
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7250
non-finite : 0 seen, 0 mismatched
tier1 streams HST
cases : 7250
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7250
non-finite : 0 seen, 0 mismatched
ok
test the_compressor_makes_the_recycle_enthalpy_differ_from_the_purge ... HST(9) differs from HST(10) on 200 of 200 nominal states
ok
test the_stream_table_matches_the_fortran_over_all_three_pools ... exp and pow come from the platform libm
tier1 streams XST (10 streams x 8)
cases : 194000
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:194000
non-finite : 0 seen, 0 mismatched
tier1 streams XMWS (the 6 that exist)
cases : 14550
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:14550
non-finite : 0 seen, 0 mismatched
tier1 streams TST
cases : 24250
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:24250
non-finite : 0 seen, 0 mismatched
tier1 streams HST
cases : 24250
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:24250
non-finite : 0 seen, 0 mismatched
states skipped because the port would not converge: 0
ok
test the_unweighed_streams_are_zero_in_both_implementations ... 7 unweighed streams, all zero over 200 states
ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s
tier2_flows, platform libm
cargo test -p tepsim-oracle --features oracle,libm-system --release --test tier2_flows -- --nocapture --test-threads 1
running 6 tests
test component_flows_sum_to_the_stream_total ... ok
test no_sampled_state_reaches_the_purge_clamp ... lowest PTS over the whole pool: 19153.09 mmHg against a 760 threshold
ok
test the_algebra_is_bit_identical_once_exp_and_pow_agree ... tier1 flows FTM (the 10 assembled streams)
cases : 7250
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7250
non-finite : 0 seen, 0 mismatched
tier1 flows FCM (10 streams x 8)
cases : 58000
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:58000
non-finite : 0 seen, 0 mismatched
tier1 flows FWR/FWS/AGSP
cases : 2175
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:2175
non-finite : 0 seen, 0 mismatched
tier1 flows CPDH
cases : 725
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:725
non-finite : 0 seen, 0 mismatched
tier1 flows HST(9) after the compressor bump
cases : 725
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:725
non-finite : 0 seen, 0 mismatched
ok
test the_compressor_ratio_clamps_are_exercised_by_the_adversarial_pool ... VLR at the 10% heat-transfer breakpoint None
VLR at the 50% heat-transfer breakpoint None
TCC at the lower stripping-factor branch None
TCC below the lower stripping-factor branch None
TCC at the upper stripping-factor branch None
TCC approaching the 177 C pole None
TCR at the shutdown limit None
TCR above the shutdown limit None
FTM(11) at the stripping-factor threshold None
VLR at the upper shutdown limit None
VLR at the lower shutdown limit None
VLS at the upper shutdown limit None
VLS at the lower shutdown limit None
VLC at the upper shutdown limit None
VLC at the lower shutdown limit None
PTR at the reactor pressure shutdown limit None
PTV = PTR, the mixing-to-reactor flow clamp None
PTR = PTS, the reactor-to-separator flow clamp None
PTV = PTS, the recycle flow clamp Low
PR = 1, the compressor reverse-flow clamp Low
PR = CPPRMX, the compressor maximum-ratio clamp None
PR above CPPRMX, inside the clamped region High
VLR below the lower shutdown limit None
VLS below the lower shutdown limit None
VLC below the lower shutdown limit None
ok
test the_flow_network_matches_the_fortran_over_all_three_pools ... exp, pow and sqrt come from the platform libm
tier1 flows FTM (the 10 assembled streams)
cases : 24250
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:24250
non-finite : 0 seen, 0 mismatched
tier1 flows FCM (10 streams x 8)
cases : 194000
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:194000
non-finite : 0 seen, 0 mismatched
tier1 flows FWR/FWS/AGSP
cases : 7275
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7275
non-finite : 0 seen, 0 mismatched
tier1 flows CPDH
cases : 2425
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:2425
non-finite : 0 seen, 0 mismatched
tier1 flows HST(9) after the compressor bump
cases : 2425
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:2425
non-finite : 0 seen, 0 mismatched
states skipped because the port would not converge: 0
ok
test the_steam_coefficient_matches_through_the_condenser_duty ... tier1 UAC, via QUC
cases : 300
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:300
non-finite : 0 seen, 0 mismatched
300 states below 100 C, 0 at or above and excluded
ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.19s
tier2_stripper, platform libm
cargo test -p tepsim-oracle --features oracle,libm-system --release --test tier2_stripper -- --nocapture --test-threads 1
running 5 tests
test every_stripper_branch_is_exercised_by_the_pool ... VLR at the 10% heat-transfer breakpoint Hyperbolic
VLR at the 50% heat-transfer breakpoint Hyperbolic
TCC at the lower stripping-factor branch Hyperbolic
TCC below the lower stripping-factor branch Pinned
TCC at the upper stripping-factor branch Hyperbolic
TCC approaching the 177 C pole Linear
TCR at the shutdown limit Hyperbolic
TCR above the shutdown limit Hyperbolic
FTM(11) at the stripping-factor threshold Idle
VLR at the upper shutdown limit Hyperbolic
VLR at the lower shutdown limit Hyperbolic
VLS at the upper shutdown limit Hyperbolic
VLS at the lower shutdown limit Hyperbolic
VLC at the upper shutdown limit Hyperbolic
VLC at the lower shutdown limit Hyperbolic
PTR at the reactor pressure shutdown limit Hyperbolic
PTV = PTR, the mixing-to-reactor flow clamp Hyperbolic
PTR = PTS, the reactor-to-separator flow clamp Hyperbolic
PTV = PTS, the recycle flow clamp Hyperbolic
PR = 1, the compressor reverse-flow clamp Hyperbolic
PR = CPPRMX, the compressor maximum-ratio clamp Hyperbolic
PR above CPPRMX, inside the clamped region Hyperbolic
VLR below the lower shutdown limit Hyperbolic
VLS below the lower shutdown limit Hyperbolic
VLC below the lower shutdown limit Hyperbolic
branches reached: {"hyperbolic", "idle", "linear", "pinned"}
ok
test the_algebra_is_bit_identical_once_exp_and_pow_agree ... tier1 stripper SFR
cases : 5800
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:5800
non-finite : 0 seen, 0 mismatched
tier1 stripper FTM (5, 12): the column's own outlets
cases : 1450
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:1450
non-finite : 0 seen, 0 mismatched
tier1 stripper FTM (7): the reactor-inlet alias
cases : 725
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:725
non-finite : 0 seen, 0 mismatched
tier1 stripper FCM (5, 12): the column's own outlets
cases : 11600
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:11600
non-finite : 0 seen, 0 mismatched
tier1 stripper FCM (7): the reactor-inlet alias
cases : 5800
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:5800
non-finite : 0 seen, 0 mismatched
tier1 stripper XST (5, 12): the column's own outlets
cases : 11600
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:11600
non-finite : 0 seen, 0 mismatched
tier1 stripper XST (7): the reactor-inlet alias
cases : 5800
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:5800
non-finite : 0 seen, 0 mismatched
tier1 stripper TST (5, 7, 12)
cases : 2175
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:2175
non-finite : 0 seen, 0 mismatched
tier1 stripper HST (5, 12): the column's own outlets
cases : 1450
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:1450
non-finite : 0 seen, 0 mismatched
tier1 stripper HST (7): the reactor-inlet alias
cases : 725
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:725
non-finite : 0 seen, 0 mismatched
ok
test the_non_condensible_factors_never_move_in_the_fortran_either ... SFR(1..3) fixed at [0.9950000047683716, 0.9909999966621399, 0.9900000095367432] across 300 nominal and 20 adversarial states
ok
test the_reactor_inlet_is_an_alias_in_the_fortran_too ... ok
test the_stripper_matches_the_fortran_over_all_three_pools ... transcendentals come from the platform libm
tier1 stripper SFR
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
tier1 stripper FTM (5, 12): the column's own outlets
cases : 4850
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:4850
non-finite : 0 seen, 0 mismatched
tier1 stripper FTM (7): the reactor-inlet alias
cases : 2425
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:2425
non-finite : 0 seen, 0 mismatched
tier1 stripper FCM (5, 12): the column's own outlets
cases : 38800
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:38800
non-finite : 0 seen, 0 mismatched
tier1 stripper FCM (7): the reactor-inlet alias
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
tier1 stripper XST (5, 12): the column's own outlets
cases : 38800
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:38800
non-finite : 0 seen, 0 mismatched
tier1 stripper XST (7): the reactor-inlet alias
cases : 19400
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:19400
non-finite : 0 seen, 0 mismatched
tier1 stripper TST (5, 7, 12)
cases : 7275
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:7275
non-finite : 0 seen, 0 mismatched
tier1 stripper HST (5, 12): the column's own outlets
cases : 4850
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:4850
non-finite : 0 seen, 0 mismatched
tier1 stripper HST (7): the reactor-inlet alias
cases : 2425
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:2425
non-finite : 0 seen, 0 mismatched
states skipped because the port would not converge: 0
ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.17s
tier2_heat, platform libm
cargo test -p tepsim-oracle --features oracle,libm-system --release --test tier2_heat -- --nocapture --test-threads 1
running 5 tests
test both_sides_of_the_steam_cutoff_are_exercised ... steam on: 423 states, off: 2 states
ok
test every_level_branch_is_exercised_by_the_pool ... VLR at the 10% heat-transfer breakpoint dry
VLR at the 50% heat-transfer breakpoint ramp
TCC at the lower stripping-factor branch fully wetted
TCC below the lower stripping-factor branch fully wetted
TCC at the upper stripping-factor branch fully wetted
TCC approaching the 177 C pole fully wetted
TCR at the shutdown limit fully wetted
TCR above the shutdown limit fully wetted
FTM(11) at the stripping-factor threshold fully wetted
VLR at the upper shutdown limit fully wetted
VLR at the lower shutdown limit dry
VLS at the upper shutdown limit fully wetted
VLS at the lower shutdown limit fully wetted
VLC at the upper shutdown limit fully wetted
VLC at the lower shutdown limit fully wetted
PTR at the reactor pressure shutdown limit fully wetted
PTV = PTR, the mixing-to-reactor flow clamp fully wetted
PTR = PTS, the reactor-to-separator flow clamp fully wetted
PTV = PTS, the recycle flow clamp fully wetted
PR = 1, the compressor reverse-flow clamp fully wetted
PR = CPPRMX, the compressor maximum-ratio clamp fully wetted
PR above CPPRMX, inside the clamped region fully wetted
VLR below the lower shutdown limit dry
VLS below the lower shutdown limit fully wetted
VLC below the lower shutdown limit fully wetted
level branches reached: {"dry", "fully wetted", "ramp"}
ok
test heat_transfer_matches_the_fortran_over_all_three_pools ... transcendentals come from the platform libm
tier1 heat UAR
cases : 2425
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:2425
non-finite : 0 seen, 0 mismatched
tier1 heat QUR
cases : 2425
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:2425
non-finite : 0 seen, 0 mismatched
tier1 heat QUS
cases : 2425
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:2425
non-finite : 0 seen, 0 mismatched
tier1 heat QUC
cases : 2425
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:2425
non-finite : 0 seen, 0 mismatched
states skipped because the port would not converge: 0
ok
test the_algebra_is_bit_identical_once_exp_and_pow_agree ... tier1 heat UAR
cases : 725
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:725
non-finite : 0 seen, 0 mismatched
tier1 heat QUR
cases : 725
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:725
non-finite : 0 seen, 0 mismatched
tier1 heat QUS
cases : 725
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:725
non-finite : 0 seen, 0 mismatched
tier1 heat QUC
cases : 725
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:725
non-finite : 0 seen, 0 mismatched
ok
test the_condenser_driving_difference_is_large_enough_to_discriminate ... smallest gap between TST(8) and TCS as the driving temperature: 40.2906 C
ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.20s
tier2_measurements, platform libm
cargo test -p tepsim-oracle --features oracle,libm-system --release --test tier2_measurements -- --nocapture --test-threads 1
running 5 tests
test the_algebra_is_bit_identical_once_exp_and_pow_agree ... tier1 XMEAS(1..22), noise-free
cases : 15950
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:15950
non-finite : 0 seen, 0 mismatched
ok
test the_measurements_match_the_fortran_over_all_three_pools ... transcendentals come from the platform libm
tier1 XMEAS(1..22), noise-free
cases : 53350
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:53350
non-finite : 0 seen, 0 mismatched
states skipped because the port would not converge: 0
ok
test the_pool_reaches_several_distinct_shutdown_causes ... TCR at the shutdown limit ["reactor pressure high"]
TCR above the shutdown limit ["reactor pressure high", "reactor temperature high"]
VLR at the upper shutdown limit ["reactor pressure high", "reactor level high"]
VLS at the upper shutdown limit ["separator level high"]
VLC at the upper shutdown limit ["stripper level high"]
VLR below the lower shutdown limit ["reactor level low"]
VLS below the lower shutdown limit ["separator level low"]
VLC below the lower shutdown limit ["stripper level low"]
shutdown causes reached: {"reactor level high", "reactor level low", "reactor pressure high", "reactor temperature high", "separator level high", "separator level low", "stripper level high", "stripper level low"}
ok
test the_shutdown_detector_agrees_with_the_fortran_on_every_state ... tier1 ISD as 0 or 1
cases : 2425
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:2425
non-finite : 0 seen, 0 mismatched
12 states trip, 2413 do not
ok
test time_zero_is_what_suppresses_the_noise ... 22 of 22 measurements differ with the clock running
ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s
tier2_balances, platform libm
cargo test -p tepsim-oracle --features oracle,libm-system --release --test tier2_balances -- --nocapture --test-threads 1
running 5 tests
test all_fifty_derivatives_match_the_fortran_over_all_three_pools ... transcendentals come from the platform libm
tier1 YP(1..50), relative to the derivative (reported)
cases : 120600
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:120600
non-finite : 0 seen, 0 mismatched
tier1 YP(1..50), relative to the scale of the terms (the gate)
cases : 120600
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:120600
non-finite : 0 seen, 0 mismatched
tier1 YP(1..50), plant frozen
cases : 650
max rel err : 0.000e0 at perturbed#369[1]
max ulp : 0 at perturbed#369[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:650
non-finite : 0 seen, 0 mismatched
2412 states running, 13 frozen, 0 skipped
ok
test every_derivative_slot_actually_moves_somewhere_in_the_pool ... 50 of 50 slots move somewhere in the pool
ok
test the_quirk_fix_changes_only_the_frozen_states ... the fix changes 8 tripping boundaries and leaves 17 alone
ok
test the_whole_right_hand_side_is_bit_identical_once_exp_and_pow_agree ... tier1 YP(1..50), relative to the derivative (reported)
cases : 35850
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:35850
non-finite : 0 seen, 0 mismatched
ok
test tier2_acceptance_table ... Tier 2 acceptance, 2412 running states
gate: error / scale-of-terms < 1e-12
YP err/scale err/value ratio
1 0.000e0 0.000e0 1x
2 0.000e0 0.000e0 1x
3 0.000e0 0.000e0 1x
4 0.000e0 0.000e0 1x
5 0.000e0 0.000e0 1x
6 0.000e0 0.000e0 1x
7 0.000e0 0.000e0 1x
8 0.000e0 0.000e0 1x
9 0.000e0 0.000e0 1x
10 0.000e0 0.000e0 1x
11 0.000e0 0.000e0 1x
12 0.000e0 0.000e0 1x
13 0.000e0 0.000e0 1x
14 0.000e0 0.000e0 1x
15 0.000e0 0.000e0 1x
16 0.000e0 0.000e0 1x
17 0.000e0 0.000e0 1x
18 0.000e0 0.000e0 1x
19 0.000e0 0.000e0 1x
20 0.000e0 0.000e0 1x
21 0.000e0 0.000e0 1x
22 0.000e0 0.000e0 1x
23 0.000e0 0.000e0 1x
24 0.000e0 0.000e0 1x
25 0.000e0 0.000e0 1x
26 0.000e0 0.000e0 1x
27 0.000e0 0.000e0 1x
28 0.000e0 0.000e0 1x
29 0.000e0 0.000e0 1x
30 0.000e0 0.000e0 1x
31 0.000e0 0.000e0 1x
32 0.000e0 0.000e0 1x
33 0.000e0 0.000e0 1x
34 0.000e0 0.000e0 1x
35 0.000e0 0.000e0 1x
36 0.000e0 0.000e0 1x
37 0.000e0 0.000e0 1x
38 0.000e0 0.000e0 1x
39 0.000e0 0.000e0 1x
40 0.000e0 0.000e0 1x
41 0.000e0 0.000e0 1x
42 0.000e0 0.000e0 1x
43 0.000e0 0.000e0 1x
44 0.000e0 0.000e0 1x
45 0.000e0 0.000e0 1x
46 0.000e0 0.000e0 1x
47 0.000e0 0.000e0 1x
48 0.000e0 0.000e0 1x
49 0.000e0 0.000e0 1x
50 0.000e0 0.000e0 1x
worst component: YP(0) at 0.000e0 of its own scale
tier1 YP(1..50), relative to the scale of the terms (the gate)
cases : 120600
max rel err : 0.000e0 at nominal#0[1]
max ulp : 0 at nominal#0[1]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:120600
non-finite : 0 seen, 0 mismatched
0 of 50 components cancel by more than 100x
ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.23s
Tier 3: the generator stream
This page is generated.
cargo xtask validate --tiers 1,2,3 --smokewrote it from commitb6ef4ee-dirty. Every number on it was captured from that run's own output. To change what it says, change what the suite measures and run the command again.
Both sides are instrumented to emit every generator draw, and the traces are diffed. This is the tier that catches a port whose arithmetic is right and whose call order is not, which no statistical comparison would find until after a 48-hour run.
Reduced volume. This run passed --smoke, so the sweeps are the short ones the CI
gate uses rather than the full ones PLAN.org specifies. The case counts in the
tables below are what actually ran. Drop --smoke for the gate volume.
Produced with rustc 1.97.1 (8bab26f4f 2026-07-14), gfortran 15.2.0. The oracle's compiler flags are fixed in
crates/tepsim-oracle/build.rs and asserted by a test; changing them invalidates
every number on this page, which is why it is a logged re-baseline and not an edit.
What ran
7 test binaries: 34 test(s) passed, 0 failed, 0 ignored.
| target | libm | passed | failed | ignored |
|---|---|---|---|---|
rng_call_order | vendored | 7 | 0 | 0 |
tier3_harness | vendored | 6 | 0 | 0 |
tier1_disturbance | vendored | 4 | 0 | 0 |
tier3_walk | vendored | 4 | 0 | 0 |
tier3_walk_inputs | vendored | 4 | 0 | 0 |
tier3_analysers | vendored | 4 | 0 | 0 |
fault_table | vendored | 5 | 0 | 0 |
Figures
Every one of this tier's comparisons is bit-identical to the Fortran, so there is nothing to place on a logarithmic error axis and no error figure is drawn. The figure below states the same result in the units that suit it.
cargo xtask validate --tiers 1,2,3 --smoke at commit b6ef4ee-dirty; the measurement it repeats was first recorded in B-0028 and B-0029 (LOG.org).Measurements
6 block(s), lifted from the transcripts below. The columns are whatever fields the run printed, so a new field in the reporter becomes a new column here rather than data this page drops.
The test column matters as much as the numbers, because not every row is the port
being measured. Some tests deliberately mis-type a constant, or solve from the wrong
guess, to show what that would cost; a row from one of those is supposed to be
enormous, and its test name says so. The what column carries a tier1 prefix in
every tier, because it is the shared comparison reporter's own label rather than a
claim about which tier printed it.
| target | from test | what | cases | max rel err | max ulp | ulp percentiles | ulp histogram | non-finite |
|---|---|---|---|---|---|---|---|---|
tier1_disturbance | tesub5_matches_the_fortran_over_the_state_space | tier1 TESUB5 ADIST | 20000 | 0.000e0 at seed#0[ADIST] | 0 at seed#0[ADIST] | p50=0 p90=0 p99=0 p100=0 | 0:20000 | 0 seen, 0 mismatched |
tier1_disturbance | tesub5_matches_the_fortran_over_the_state_space | tier1 TESUB5 BDIST | 20000 | 0.000e0 at seed#0[BDIST] | 0 at seed#0[BDIST] | p50=0 p90=0 p99=0 p100=0 | 0:20000 | 0 seen, 0 mismatched |
tier1_disturbance | tesub5_matches_the_fortran_over_the_state_space | tier1 TESUB5 CDIST | 20000 | 0.000e0 at seed#0[CDIST] | 0 at seed#0[CDIST] | p50=0 p90=0 p99=0 p100=0 | 0:20000 | 0 seen, 0 mismatched |
tier1_disturbance | tesub5_matches_the_fortran_over_the_state_space | tier1 TESUB5 DDIST | 20000 | 0.000e0 at seed#0[DDIST] | 0 at seed#0[DDIST] | p50=0 p90=0 p99=0 p100=0 | 0:20000 | 0 seen, 0 mismatched |
tier1_disturbance | tesub5_matches_the_fortran_over_the_state_space | tier1 TESUB5 TNEXT | 20000 | 0.000e0 at seed#0[TNEXT] | 0 at seed#0[TNEXT] | p50=0 p90=0 p99=0 p100=0 | 0:20000 | 0 seen, 0 mismatched |
tier1_disturbance | tesub6_matches_the_fortran_over_the_state_space | tier1 TESUB6 noise sample | 20000 | 0.000e0 at seed#0[X] | 0 at seed#0[X] | p50=0 p90=0 p99=0 p100=0 | 0:20000 | 0 seen, 0 mismatched |
Transcripts
Each block below is a test binary's own output, verbatim, with the command that produced it. The summary above is derived from these; they are not derived from it.
rng_call_order, vendored libm
cargo test -p tepsim-oracle --features oracle --release --test rng_call_order -- --nocapture --test-threads 1
running 7 tests
test a_time_zero_evaluation_draws_far_less_than_a_running_one ... draws at TIME=0: 0; at the scenario's own time: 264
ok
test a_tripped_plant_skips_the_noise_and_so_the_stream_position_depends_on_it ... tripped: [258, 258, 258, 258, 258, 258, 258, 258]
healthy: [522, 522, 522, 522, 522, 522, 522, 522, 522, 522, 522, 522, 522, 522, 522, 522, 522]
ok
test the_analysers_add_their_own_draws_on_schedule ... draws: t=0.05 264, t=0.15 462, t=0.30 522
ok
test the_draw_count_varies_across_the_trajectory ... draw counts over 400 nominal states: {0: 1, 264: 359, 294: 1, 462: 39}
ok
test the_draw_counter_agrees_with_the_oracle_generator ... ok
test the_measurement_noise_costs_exactly_264_draws ... with noise 264, without 0, difference 264
ok
test the_walk_advance_costs_thirty_draws_of_which_three_are_conditional ... t=0.15 with the walk frozen: 432; running: 462
ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
tier3_harness, vendored libm
cargo test -p tepsim-oracle --features oracle --release --test tier3_harness -- --nocapture --test-threads 1
running 6 tests
test both_scalings_appear_in_a_real_evaluation ... 30 signed draws, 432 unit draws
ok
test replaying_the_trace_reproduces_the_generator_word ... 113088 draws traced and replayed across 400 evaluations
ok
test the_differ_reports_the_first_divergence_and_its_kind ... ok
test the_trace_capacity_has_real_headroom ... worst evaluation: 462 draws against a capacity of 4096
ok
test the_trace_length_agrees_with_the_uninstrumented_census ... t=0: 0 draws, TIME=0: noise skipped, walks reset
t=0.000001: 264 draws, noise only
t=0.15: 462 draws, noise, walk advance and the gas analysers
t=0.3: 522 draws, and the product analyser
ok
test the_tracing_generator_records_without_disturbing ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
tier1_disturbance, vendored libm
cargo test -p tepsim-oracle --features oracle --release --test tier1_disturbance -- --nocapture --test-threads 1
running 4 tests
test an_inactive_channel_lands_exactly_on_its_centre_in_the_fortran ... ok
test tesub5_matches_the_fortran_over_the_state_space ... tier1 TESUB5 ADIST
cases : 20000
max rel err : 0.000e0 at seed#0[ADIST]
max ulp : 0 at seed#0[ADIST]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:20000
non-finite : 0 seen, 0 mismatched
tier1 TESUB5 BDIST
cases : 20000
max rel err : 0.000e0 at seed#0[BDIST]
max ulp : 0 at seed#0[BDIST]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:20000
non-finite : 0 seen, 0 mismatched
tier1 TESUB5 CDIST
cases : 20000
max rel err : 0.000e0 at seed#0[CDIST]
max ulp : 0 at seed#0[CDIST]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:20000
non-finite : 0 seen, 0 mismatched
tier1 TESUB5 DDIST
cases : 20000
max rel err : 0.000e0 at seed#0[DDIST]
max ulp : 0 at seed#0[DDIST]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:20000
non-finite : 0 seen, 0 mismatched
tier1 TESUB5 TNEXT
cases : 20000
max rel err : 0.000e0 at seed#0[TNEXT]
max ulp : 0 at seed#0[TNEXT]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:20000
non-finite : 0 seen, 0 mismatched
ok
test tesub6_matches_the_fortran_over_the_state_space ... tier1 TESUB6 noise sample
cases : 20000
max rel err : 0.000e0 at seed#0[X]
max ulp : 0 at seed#0[X]
ulp percentiles: p50=0 p90=0 p99=0 p100=0
ulp histogram : 0:20000
non-finite : 0 seen, 0 mismatched
ok
test the_flag_changes_the_segment_but_never_the_draw_count ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
tier3_walk, vendored libm
cargo test -p tepsim-oracle --features oracle --release --test tier3_walk -- --nocapture --test-threads 1
running 4 tests
test every_walk_disturbance_matches_the_fortran_over_a_long_run ... IDV(8): 1572 walk draws over 30 hours
IDV(9): 1572 walk draws over 30 hours
IDV(10): 1572 walk draws over 30 hours
IDV(11): 1572 walk draws over 30 hours
IDV(12): 1572 walk draws over 30 hours
IDV(13): 1572 walk draws over 30 hours
IDV(16): 1572 walk draws over 30 hours
IDV(17): 1563 walk draws over 30 hours
IDV(18): 1543 walk draws over 30 hours
IDV(20): 1588 walk draws over 30 hours
ok
test the_spike_branch_is_reached_by_an_active_disturbance ... channel 10 over 75 hours: 66 fired segments, 1432 dwells
ok
test the_time_zero_reset_matches_including_its_draws ... the t=0 reset still drew 30 times
ok
test the_walk_advance_matches_the_fortran_along_the_nominal_trajectory ... 40 evaluations advanced a channel, 360 did not
ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.36s
tier3_walk_inputs, vendored libm
cargo test -p tepsim-oracle --features oracle --release --test tier3_walk_inputs -- --nocapture --test-threads 1
running 4 tests
test every_disturbance_matches_over_a_long_run ... all twenty disturbances matched over 10 simulated hours each
ok
test the_generator_moves_once_per_step_not_once_per_evaluation ... ok
test the_two_composition_faults_move_different_amounts ... nominal [0.485, 0.005, 0.51]
IDV(1) [0.45499999999999996, 0.005, 0.54]
IDV(2) [0.48256281, 0.01, 0.50743719]
ok
test the_walk_driven_inputs_match_along_the_nominal_trajectory ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s
tier3_analysers, vendored libm
cargo test -p tepsim-oracle --features oracle --release --test tier3_analysers -- --nocapture --test-threads 1
running 4 tests
test a_trip_silences_the_continuous_noise_and_not_the_analysers ... tripped 258 draws, healthy 522
ok
test a_whole_step_matches_the_fortran_along_the_nominal_trajectory ... 113088 draws over 400 whole steps; worst measurement 8.141509710325246e-15
ok
test a_whole_step_matches_with_every_disturbance_active ... all twenty disturbances, 6 simulated hours each; worst 0e0
ok
test the_port_stays_in_step_when_it_carries_its_own_state ... 2,000 steps carried on both sides, generators in lockstep throughout
ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s
fault_table, vendored libm
cargo test -p tepsim-oracle --features oracle --release --test fault_table -- --nocapture --test-threads 1
running 5 tests
test a_sticking_fault_changes_nothing_when_the_command_never_moves ... ok
test step_faults_act_at_once_and_random_ones_do_not ... ok
test the_claimed_channels_are_the_ones_the_fortran_enables ... ok
test the_claimed_valves_are_the_ones_the_fortran_sticks ... ok
test the_five_unknown_faults_are_not_one_kind ... IDV(16) 'Unknown': channels [9], valves [] -- enables walk channel 9, the stripper steam valve capacity
IDV(17) 'Unknown': channels [10], valves [] -- enables spike channel 10, the reactor coolant duty
IDV(18) 'Unknown': channels [11], valves [] -- enables spike channel 11, the condenser coolant duty
IDV(19) 'Unknown': channels [], valves [5, 7, 8, 9] -- sticks valves 5, 7, 8 and 9; touches no equation in the model
IDV(20) 'Unknown': channels [12], valves [] -- enables spike channel 12, the reactor outlet flow
ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Quirk and delta register
The two halves of this register are cross-checked.
cargo xtask deltascollects every@deltamarker in the source, matches it against the## D-0NNheadings below, and fails if an entry has no marker, if a marker has no entry, or if the two disagree about the class. The collected table is the generated delta marker index. The prose below is still written by hand, which is what the closing paragraph of the next section is about; the cross-check and the index are not.
Every deliberate deviation from the original Fortran gets an entry here: what the original does, what this port does instead, the class, the measured effect, and the test that measures it. An entry with a description but no number is not finished.
The classes come from PLAN.org, "Quirk and delta register", and they decide
how much caution an entry warrants:
| Class | Meaning | Disposition |
|---|---|---|
| A | No numerical effect. Dead code, naming confusion, reentrancy. | Fixed without discussion. |
| B | Numerically observable, semantically clearly wrong. | Fixed, with a measured delta. Expected to measure as zero under normal operation, which is itself the thing to demonstrate. |
| C | Behaviour-defining and benchmark-relevant. | Implemented behind a flag, never the default, and released only on explicit sign-off after a full Tier 5 and Tier 6 delta report. |
Each entry is anchored in the source by a @delta comment immediately above
the item it applies to, in the same spirit as the @port claims that
cargo xtask provenance collects:
#![allow(unused)]
fn main() {
// @port teprob.f:1415-1442
// @delta D-001 class=B teprob.f:1439-1440
pub fn temperature_from_enthalpy(/* ... */) { /* ... */ }
}
PLAN.org calls for this page to be generated from those annotations. It is
written by hand for now; the generator is a Phase 9 item, and the annotation
convention exists from the first entry so that there is something to generate
from when it arrives.
Open questions
Read this section before re-litigating a quirk. It is the list of things that have been noticed but not yet decided.
- Nothing is open. D-001 through D-011 are all decided and measured. The last three decisions were taken on 2026-08-28: D-007 (B-0065), D-011 (B-0066), and what the Tier 5 battery does about a stuck valve (B-0067).
- D-007's measure question, which was open here, was settled on 2026-08-27:
Tier 2 measures error against the scale of the terms entering a balance, not
against the balance's result, because a balance is inflow minus outflow and
cancels. See B-0026a and the module documentation of
crates/tepsim-oracle/tests/tier2_balances.rs, which carries the numbers.
D-001 — TESUB2 reports success after failing to converge
Class B. teprob.f:1439-1440, in TESUB2 (teprob.f:1415-1442).
What the original does
The Newton loop is written so that the convergence test is the loop-terminal statement:
TIN=T
DO 250 J=1,100
...
T=T+DT
250 IF(DABS(DT).LT.1.D-12)GO TO 300
T=TIN
300 RETURN
On convergence, GO TO 300 leaves the loop with the solved temperature. On
failure the loop simply runs out, control falls through to T=TIN, and the
routine restores the caller's original guess and returns exactly as it does
on success. There is no error code, no status flag and no output argument
that distinguishes the two. A caller receiving a temperature cannot tell
whether it solved the problem or is holding its own input back.
The consequence is not confined to one number. TESUB2 is what converts the
reactor, separator and stripper energy states into temperatures
(teprob.f:460-465), so a silently abandoned solve seeds every downstream
pressure, flow and heat-transfer term for that step with a stale temperature,
and the run continues as though nothing happened.
What this port does
temperature_from_enthalpy returns Result<f64, TemperatureError>.
Convergence returns Ok; exhausting the hundred iterations returns
TemperatureError::DidNotConverge, carrying the guess, the last iterate and
the final step, so a caller can report or recover. The iteration itself is
unchanged: same evaluation order, same step, same criterion, same cap.
Measured effect
Zero. Across the full Tier 1 sweep the non-convergence path never fires, so the two behaviours never differ on the physical domain.
| Basis | Start | Cases | Abandoned | Rust vs Fortran |
|---|---|---|---|---|
ITY=0 | warm | 9,987,490 | 0 | 0 ULP |
ITY=0 | cold | 9,987,490 | 0 | 0 ULP |
ITY=1 | warm | 9,987,490 | 0 | 0 ULP |
ITY=1 | cold | 9,987,490 | 0 | 0 ULP |
ITY=2 | warm | 9,987,490 | 0 | 0 ULP |
ITY=2 | cold | 9,987,490 | 0 | 0 ULP |
59,924,940 solves, none abandoned, every returned temperature bit-identical to the Fortran's. "Warm" starts Newton from the temperature the target enthalpy was built from, which is what every call site in the plant does; "cold" starts it from the opposite end of the 0-175 °C range, which nothing in the plant does and which is there to make the iteration work for its answer.
Round-trip accuracy, as a diagnostic rather than a gate: the warm start recovers the original temperature exactly, and the cold start lands within 3.7e-13 °C of it. Newton is quadratic on this problem, so the 1e-12 criterion on the step certifies an error far below itself.
Disposition
Adopted as the default. The fix is free: it changes no number the model can reach, and it converts a failure mode that is invisible by construction into one the type system forces a caller to handle.
The measurement is not a one-off. It is the assertion
total_abandoned == 0 in the Tier 1 test below, so if a future change to the
sweep, the constants or the iteration ever reaches the non-convergence path,
it fails there rather than going unnoticed.
Measured by
crates/tepsim-oracle/tests/tier1_temperature.rs, run at full volume by
cargo xtask validate --tiers 1. Two tests: one sweeps and counts, the other
demonstrates the divergence directly on an unreachable target, asserting that
the Fortran hands back the guess verbatim while the port reports an error.
D-002 — R1F and R2F name two unrelated quantities
Class A. teprob.f:503-511, in TEFUNC.
What the original does
R1F and R2F are set at teprob.f:415-416 from TESUB8(7, TIME) and
TESUB8(8, TIME): the IDV(13) slow-drift multipliers on the reaction kinetics.
They are used in that meaning at 503-504.
Five lines later they are reassigned in place:
RR(1)=DEXP(31.5859536-40000.0/1.987/TKR)*R1F ! drift factor
RR(2)=DEXP(3.00094014-20000.0/1.987/TKR)*R2F ! drift factor
...
R1F=PPR(1)**1.1544 ! now a pressure power
R2F=PPR(3)**0.3735 ! now a pressure power
RR(1)=RR(1)*R1F*R2F*PPR(4)
RR(2)=RR(2)*R1F*R2F*PPR(5)
The two roles share nothing but the storage. RR(1) at line 510 carries the
drift factor once, from line 503, and the pressure powers once, from line 508.
Why it matters
There is no numerical effect, but there is a large reading effect. Taking
R1F at line 510 to still be the drift factor gives
r1 = f1 · e^(a1 - E1/T) · f1 · pC^0.3735 · pD · Vv
which is a coherent-looking rate law, second order in the disturbance and missing the pressure order on A entirely. Nothing in the source contradicts it locally, and a port written that way is self-consistent: it reproduces its own answer on every run.
Measured: reading it that way moves RR by 100% relative on the
adversarial pool, so the differential does catch it. But only because there is
a differential. This is the kind of misreading that would have propagated
through every later item in a port without one.
What this port does
Gives the two roles separate names. The drift factors arrive as
kinetics::ReactionDrift, and the pressure powers are locals called
order_a and order_c.
Measured effect
None. The arithmetic is identical; only the names differ.
The visible consequence is for harnesses, not for the model: after TEFUNC
returns, COMMON's R1F holds a pressure power. Tier 2 therefore fetches the
drift from TESUB8 directly, which is sound because nothing after
teprob.f:406 writes the walk state.
Measured by
crates/tepsim-oracle/tests/tier2_kinetics.rs. Under
--features oracle,libm-system the whole range is bit-identical to the
Fortran, which is what confirms the reading: a wrong-but-consistent reading
cannot be bit-identical to a right one.
D-003 — CRXR(2) is read but never assigned
Class A. teprob.f:521-527 and teprob.f:763.
What the original does
Seven of the eight net-production slots are written:
CRXR(1)=-RR(1)-RR(2)-RR(3)
CRXR(3)=-RR(1)-RR(2)
CRXR(4)=-RR(1)-1.5D0*RR(4)
CRXR(5)=-RR(2)-RR(3)
CRXR(6)=RR(3)+RR(4)
CRXR(7)=RR(1)
CRXR(8)=RR(2)
CRXR(2) is not among them. It is read anyway, in the reactor component
balance at teprob.f:763:
YP(I)=FCM(I,7)-FCM(I,8)+CRXR(I)
for I = 1..8.
Why it works
CRXR lives in COMMON/TEPROC/, which is static storage and therefore
zero-initialised, and nothing anywhere in the file ever writes slot 2. So B's
net production is zero for the life of the process.
That is the correct physics: B is inert and takes part in none of the four reactions. But it is correct by static initialisation, not by statement. The guarantee comes from the linker, not from the model.
What this port does
States it. Kinetics::production is built from an explicit array of zeros and
B is simply never written, which reproduces the value while making the reason
local.
Measured effect
None, and that is asserted rather than assumed: the oracle is required to
report exactly 0.0 for CRXR(2) on every sampled state. If it ever did not,
the benign reading here would be wrong and the reactor's B balance would be
picking up whatever was last left in that word.
Measured by
the_inert_has_no_net_production_in_either_implementation in
crates/tepsim-oracle/tests/tier2_kinetics.rs. 200 nominal states, all exactly
zero.
D-004 — the mixed feed carries 1e-10 lbmol/h that nothing accounts for
Class B. teprob.f:568-569, in TEFUNC.
What the original does
Every other valve-lagged flow is a clean proportionality:
FTM(1)=VPOS(1)*VRNG(1)/100.0
The mixed A and C feed is not:
FTM(4)=VPOS(4)*(1.D0-IDV(7)*0.2D0)
.*VRNG(4)/100.0+1.D-10
The trailing +1.D-10 has no physical meaning. It is there so that FTM(4)
is never exactly zero.
Why it is there
teprob.f:606 computes FCM(I,4)=XST(I,4)*FTM(4), and the mixing zone
balance at teprob.f:783-788 sums those component flows. None of that divides
by FTM(4), so the guard is not protecting a division in this range.
It protects the composition measurement path. With the valve shut and no epsilon, stream 4 contributes exactly nothing, and a controller reading a composition ratio off it would see 0/0. The epsilon keeps the stream infinitely dilute rather than absent.
Why it is Class B and not Class A
Because it is numerically observable, permanently. The plant receives 1e-10
lbmol/h of A, B and C that no feed valve delivers and no mass balance
accounts for, for the entire run. Under normal operation FTM(4) is around
9.35 lbmol/h, so the addend is 1.1e-11 relative and utterly negligible; with
the valve shut it is the entire flow.
That is the shape of a Class B quirk exactly: wrong in principle, invisible in practice, and the thing to demonstrate is that removing it measures as zero rather than assuming so.
What this port does
Reproduces it, as [tepsim_core::flows::FEED_FLOW_EPSILON], on the faithful
path. The eventual fix belongs in Phase 6 behind a flag, with a Tier 5 delta
report, like every other Class B item.
Measured effect
Not yet. The delta is measured when the fix lands (Phase 6, Tier 10). What is
pinned now is the behaviour it produces: with valve 4 shut, FTM(4) is exactly
1e-10 and not zero.
Measured by
the_mixed_feed_never_reaches_exactly_zero in
crates/tepsim-core/src/flows.rs, and the Tier 2 differential in
crates/tepsim-oracle/tests/tier2_flows.rs, which is bit-identical to the
Fortran under libm-system and would not be if the addend were dropped.
D-005 — SFR(4..8)'s initial values are dead
Class A. teprob.f:1129-1133, set in TEINIT.
What the original does
TEINIT sets all eight stripping factors:
SFR(1)=0.99500
SFR(2)=0.99100
SFR(3)=0.99000
SFR(4)=0.91600
SFR(5)=0.93600
SFR(6)=0.93800
SFR(7)=5.80000D-02
SFR(8)=3.01000D-02
The first three are load-bearing: nothing ever writes them again, and
teprob.f:643 reads all eight on every evaluation, so A, B and C strip at
those fixed fractions for the life of the run.
The last five are dead. teprob.f:614-634 writes SFR(4) through SFR(8)
unconditionally, through whichever of its two branches it takes, before
teprob.f:643 reads them. So the values on lines 1129-1133 are overwritten on
the first evaluation and never observed.
Why it is worth an entry
Because the block looks uniform and is not. A reader checking the eight
TEINIT lines against the eight slots would reasonably conclude that all
eight are initial conditions, and a port that omitted lines 1129-1133 as dead
would be correct while a port that omitted 1126-1128 would be silently wrong
in the third decimal place of every product composition.
The split is also the only thing that explains why the loop at teprob.f:643
runs I=1,8 when the branch above it writes only five slots.
Note the precision changes across the boundary too: SFR(1..6) are single
precision and SFR(7..8) are written 5.80000D-02 and 3.01000D-02, in
double. Since the last five are dead, that inconsistency has no effect, which
is itself worth knowing before someone spends time on it.
What this port does
Carries the three live values as
[tepsim_core::stripper::NON_CONDENSIBLE_STRIPPING] and does not carry the
five dead ones, with the reasoning stated where the constant is defined.
Measured effect
None. The five values are unobservable.
The three live ones are asserted rather than assumed: the oracle must report
exactly the TEINIT constants for SFR(1..3) on every sampled state. If it
ever did not, they would be recomputed somewhere the port has not found.
Measured by
the_non_condensible_factors_never_move_in_the_fortran_either in
crates/tepsim-oracle/tests/tier2_stripper.rs. 300 nominal states and all 21
adversarial boundaries, bit-identical.
D-006 — XMEAS(20) is assigned twice, with different factors
Class A. teprob.f:698-699, in TEFUNC.
What the original does
XMEAS(20)=CPDH*0.0003927D6
XMEAS(20)=CPDH*0.29307D3
The first assignment is dead. The second overwrites it on the next line, before anything reads the slot.
Why it is not a harmless duplicate
The two factors are not the same number. 0.0003927D6 is 392.7 and
0.29307D3 is 293.07: a ratio of 1.34. So this is a superseded conversion
rather than a repeated one, and a port that transcribed the first line would
report compressor work 34% high on every sample, forever, while every other
measurement stayed correct.
Both are plausible as unit conversions, which is what makes it a trap.
Compressor duty in the model's internal units times 293.07 gives kilowatts,
and XMEAS(20) is documented as "Compressor Work (kW)". The dead factor
appears to be an earlier attempt at the same conversion.
What this port does
Takes the second, which is what the original computes, and states in
[tepsim_core::measurements] that the first is dead and why the difference
matters.
Measured effect
None on the model: the value is overwritten before use, so the derivative and every state are untouched. The effect would be entirely on a controller reading measurement 20, and the delta against the correct value would be 34%.
Measured by
the_compressor_work_uses_the_second_conversion_factor in
crates/tepsim-core/src/measurements.rs, which also asserts the two factors
are far enough apart that taking the dead one would be visible. The Tier 2
differential in crates/tepsim-oracle/tests/tier2_measurements.rs covers it
against the oracle, and is bit-identical under libm-system.
D-007 — a shutdown freezes the plant instead of stopping it
Class C. teprob.f:807-811, in TEFUNC. Fixed by default since the
sign-off of 2026-08-28 (B-0065); reproduced by QuirkFixes::faithful.
What the original does
IF(ISD.NE.0)THEN
DO 9030 I=1,NN
YP(I)=0.0
9030 CONTINUE
ENDIF
When any of the eight shutdown conditions holds, all fifty derivatives become
zero. The state stops moving, the clock keeps running, and the caller is told
nothing: ISD is a local, and the returned vector is indistinguishable from a
plant at perfect steady state.
Why it is Class C rather than B
Because published results depend on it. Every d00-d21 dataset was generated
by a driver that kept integrating through a trip, and a run that ended instead
would produce a shorter, different file. Changing this changes
benchmark comparability, which is the definition PLAN.org gives for the class.
What this port does
Reproduces it, and says so. [tepsim_core::balances::Balances] carries the
trip and a frozen flag alongside the derivative, so a caller never has to
infer a freeze from a vector of zeros. B-0024a's typed
[tepsim_core::measurements::ShutdownCause] means it can also say which
limit fired, which the original cannot.
The fix is [tepsim_core::balances::QuirkFixes::trip_ends_the_run], on by
default since 2026-08-28. PLAN.org required "a full Tier 5 and Tier 6 delta
report and an explicit sign-off before it becomes the default"; both are below.
Reproducing the quirk is one call: QuirkFixes::faithful(), or
Scenario::faithful() for a whole run, or tep run --freeze-on-trip. Every
comparison against the Fortran or against published data uses it, and
tier5::run_port pins it so no differential can accidentally run the fix.
The sign-off, and what decided it
Two facts, one from the delta measurement and one from the published files.
The fix is pure truncation. d007_changes_nothing_before_the_trip shows
every sample up to the trip is bit-identical either way, so the flag decides
how many numbers there are and never what they are. That removes the usual
worry about a Class C fix, which is that it quietly changes results.
And the frozen tail is not a small artefact. Four of the forty-four published files carry one, 1,832 rows in total:
| file | rows | frozen tail | share | from row |
|---|---|---|---|---|
d06_te.dat | 960 | 682 | 71.0% | 278 |
d06.dat | 480 | 363 | 75.6% | 117 |
d18_te.dat | 960 | 571 | 59.5% | 389 |
d18.dat | 480 | 216 | 45.0% | 264 |
Across those rows twenty-one continuous channels repeat the same five-digit
values without any change at all, because the measurement noise at
teprob.f:711-716 is inside the shutdown guard and stops with everything else.
Only the three dead-time analysers keep moving. So three quarters of d06.dat
is a stopped plant that reads as an unusually steady one, and a detector
trained on that file spends most of its evidence on it. Data that cannot be
told apart from good data, and is not, is worse than no data.
The counter-argument was restartability, and it does not survive contact: the plant cannot be restarted after a freeze either. The freeze does not preserve an option, it only withholds the fact that the run is over.
Note that B-0025's backlog entry originally said the freeze should not be the
default. That is the opposite of what PLAN.org says, and PLAN.org is the
design of record. It is also the only reading Tier 2 can live with: the
adversarial pool contains thirteen states that trip, and a port that did not
freeze would disagree with the oracle on all fifty components for each of them.
Measured effect
- The freeze fires on exactly the states the Fortran freezes, over all three pools: 2,412 running and 13 frozen, with no disagreement.
- Turning the fix on changes the derivative on all 8 tripping adversarial boundaries and on none of the 17 that do not trip.
- Every sample before the trip is bit-identical with the fix on and off, so
the delta is entirely truncation.
tier10_quirk_deltas.rstabulates which scenarios trip, at which step, and what fraction of the run is discarded. - The Tier 5 battery cannot measure this delta, and that is a property of the delta rather than of the battery: every statistic compares two ensembles of the same shape, and the fix makes one of them shorter. A KS statistic between a 960-sample run and a 278-sample one measures the truncation.
A second, open question this exposed
Comparing the assembled derivative revealed that 28 of the 50 components
exceed Tier 2's 1e-12 relative gate under the vendored libm, worst YP(2) at
1.393e-4 — while the whole right-hand side is bit-identical to the Fortran
under libm-system.
That is cancellation, not error: a balance is inflow minus outflow, and near steady state those nearly agree, so a one-ULP difference in each term is 1e-16 of the terms and 1e-4 of the result. The 22 components that do meet 1e-12 are exactly the ones that do not cancel.
Choosing what Tier 2 should measure against for a cancelling quantity changes what Tier 2 means, so it is recorded here and left open. B-0026a.
Measured by
crates/tepsim-oracle/tests/tier2_balances.rs, and
the_fix_is_off_by_default_and_changes_the_answer_when_on in
crates/tepsim-core/src/balances.rs.
D-008 — CONTRL22 is defined, tuned, and never called
Class A. temain_mod.f:1295-1332, with its constants at 246-317.
What the original does
Twenty controller subroutines are defined. The main loop calls nineteen of
them. CONTRL22 is not among the calls.
It is not a stub. The main program initialises its full tuning alongside every other loop's:
SETPT(12)=2633.7
GAIN22=-1.0 * 5.
TAUI22=1000./3600.
ERROLD22=0.0
and the subroutine is a complete PI controller reading XMEAS(13), the
separator pressure, and writing XMV(6), the purge valve.
What it looks like it was
XMV(6) is the valve CONTRL6 already owns, and CONTRL6 carries a latching
pressure override (temain_mod.f:710-731) that does the same job by a
different mechanism. The override's release threshold is 2633.7, which is
exactly CONTRL22's setpoint.
CONTRL22 is also the only one of the twenty whose error is not normalised by
a span: every other loop computes (SETPT - XMEAS) * 100 / span, and this one
computes the raw difference.
Two loops on one valve, one of them differently shaped from all the others, and
one of them disconnected. The straightforward reading is that CONTRL22 was
the separator-pressure controller, that the override in CONTRL6 replaced it,
and that it was left in place with its tuning intact rather than deleted.
Why it matters
Because "nineteen control loops" and "twenty control subroutines" are both true and a port has to pick. Counting the subroutines gives a plant with two controllers fighting over the purge valve. Counting the calls gives the published behaviour.
It also shifts the reading of the control structure. Separator pressure is not under continuous control in this scheme; it is under an on-off override with a 650 kPa deadband. Anyone reasoning about why the plant behaves as it does around the pressure limits needs that.
What this port does
Ports it, and does not schedule it. It is in
[tepsim_control]'s tuning table and in the Tier 1 differential, because
covering it costs nothing and it is the only check that will ever exercise it,
but the scheduler does not call it.
Measured effect
None: it is not called, so it computes nothing.
The differential covers it anyway, at 0 ULP over 500 tunings, which is the only evidence anywhere that the subroutine is correct.
Measured by
every_controller_matches_the_fortran_over_a_sweep in
crates/tepsim-oracle/tests/tier1_control.rs includes CONTRL22.
crates/tepsim-oracle/tests/driver_binding.rs shows it is callable and that
the driver's setpoint array has a slot for it.
D-009 — the driver starts from rounded valve positions, not TEINIT's
Class A. temain_mod.f:322-332.
What the original does
TEINIT leaves the twelve valve commands at the values YY(39..50) carries,
which are written to eight significant figures:
DATA YY /
. ...
. 6.3053638D+01, 5.3980356D+01, 2.4644630D+01, ...
The driver then overwrites eleven of the twelve, by hand, at five:
XMV(1) = 63.053 + 0.
XMV(2) = 53.980 + 0.
XMV(3) = 24.644 + 0.
...
XMV(11)= 18.114 + 0.
XMV(12), the agitator, is not in the list and keeps TEINIT's exact 50.
Every literal is fixed-form Fortran with no exponent letter, so each is a
REAL(4) widened to double. 63.053 reaches the plant as
63.053001403808594, not as 63.053.
Why it matters
A closed-loop run and an open-loop run do not start from the same plant. The
difference is in the fourth decimal place of ten valve commands, which is far
too small to see in any plot and far too large to ignore in a differential: a
port that starts a closed-loop run from TEINIT's values disagrees with the
Fortran from step 1, and the disagreement then grows through the controllers.
Ten of the eleven, not all eleven: TEINIT leaves YY(43) = 22.21000000,
which is already five significant figures, so 22.210 rounds to the same
f32. That coincidence is the clearest available evidence that these numbers
are TEINIT's rounded rather than an independently chosen operating point.
The + 0. on every line appears to be a placeholder for a perturbation. It
changes nothing, and it is transcribed rather than simplified away, because
single(63.053 + 0.) and single(63.053) + single(0.) are the same value only
because the addend is zero.
What this port does
Reproduces it, as [tepsim_control::DRIVER_INITIAL_VALVES], with each literal
passed through single(). Driver::new starts there.
Measured effect
Ten of the twelve valve commands differ from TEINIT's, all by less than
one part in a thousand of range:
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| gap | 3.70e-4 | 2.94e-4 | 4.41e-4 | 7.63e-5 | 0 | 2.52e-4 | 3.43e-4 | 1.56e-4 | 2.63e-4 | 1.87e-4 | 5.09e-4 | 0 |
The largest is XMV(11) at 5.09e-4; the smallest non-zero is XMV(4) at
7.63e-5. XMV(5) and XMV(12) are bit-identical, for the two different
reasons above.
Measured by
the_driver_starts_from_rounded_valve_positions in
crates/tepsim-control/src/lib.rs, which asserts the count of differing valves
and the two exceptions individually, and
the_rounding_gaps_are_recorded beside it, which prints the table above.
D-010 — the controllers read the previous step's measurements
Class A, and load-bearing. temain_mod.f:366-411.
What the original does
The main loop, in order:
DO 1000 I = 1, NPTS
TEST=MOD(I,3)
IF (TEST.EQ.0) THEN
CALL CONTRL1
...
ENDIF
...
CALL INTGTR(NN,TIME,DELTAT,YY,YP)
CALL CONSHAND
1000 CONTINUE
XMEAS is written by TEFUNC, which INTGTR calls. So on iteration I the
controllers read the measurements iteration I - 1 produced. Every loop in the
scheme carries one plant step of dead time that is nowhere in any controller.
CONSHAND, the valve clamp, likewise runs after the integration rather than
after the controllers.
Why it matters
This is not a subtlety that costs a few ULP. Feeding the controllers the
measurements of the step they are about to cause makes every loop one sample
tighter than the original. B-0039 measured it: XMV(7) lands on 35.62 instead
of 34.15 on the very first controller fire, a 1.5% of range error before the
plant has run three seconds, and XMEAS(14) is 23% out four hours later. That
is a different plant, not a rounding of the same one.
The clamp's placement is subtler. TEFUNC clamps its own copy of the valve
positions at teprob.f:803-804, so with no sticking fault active it makes no
observable difference where CONSHAND runs. It stops being unobservable under
IDV(14), IDV(15) or IDV(19): teprob.f:801 only moves VCV toward XMV
when the two differ by more than the stick threshold, and an unclamped XMV of
105 crosses that threshold at a different moment than a clamped 100 does.
What this port does
Reproduces both, in [tepsim_control::Driver] rather than in Scheme.
Driver::control takes the previous step's measurements and does not clamp;
Driver::settle is CONSHAND and is called with the plant already advanced.
The split exists so that the ordering is a thing the type makes explicit rather
than a convention a caller has to remember.
Measured effect
With the two orderings otherwise identical, the first CONTRL7 fire gives
34.147823842 (previous-step, matching the Fortran bit for bit) against
35.623679189 (current-step).
Measured by
the_controllers_read_the_previous_steps_measurements in
crates/tepsim-oracle/tests/tier4_closed_loop.rs, which requires the two
orderings to give different answers before checking which one is right.
D-011 — the driver switches IDV(12) on eight hours in, whatever you asked for
Class C. Not reproduced by default since the sign-off of 2026-08-28 (B-0066);
reproduced by Scenario::faithful. temain_mod.f:366-368, with SSPTS at
line 226.
What the original does
The first statement in the simulation loop body, before any controller:
DO 1000 I = 1, NPTS
IF (I.GE.SSPTS) THEN
IDV(12)=1
ENDIF
with
SSPTS = 3600 * 8
At a one-second step that is eight simulated hours. IDV(12) is the condenser
cooling water inlet temperature random variation. The assignment is
unconditional: it does not consult the scenario, and there is no way to run
this driver past eight hours without it.
The header comment at line 98 explains SSPTS as "the number of data points to
simulate in steady state operation before implementing the disturbance", so a
disturbance was clearly meant. But which disturbance is not a parameter; it is
IDV(12), written into the loop.
Why it matters
Every published closed-loop dataset generated with this driver and longer than
eight hours carries IDV(12), including the runs nominally labelled
fault-free. Anyone comparing against published data needs the quirk; anyone
running a controlled experiment on some other disturbance needs it gone,
because after hour eight they are running two disturbances and reporting one.
It also interacts with the fault the caller asked for. If the scenario is
IDV(4) (a step in reactor cooling water inlet temperature), then from hour
eight the run is IDV(4) and IDV(12), and both act on cooling water.
The effect does not begin at hour eight
IDV(12) reaches the plant through IDVWLK(6) (teprob.f:351), and the walk
that gates is only redrawn when TIME passes that channel's TNEXT
(teprob.f:359-360). So switching the flag on at step 28,800 changes nothing
until channel 6's next segment boundary, which for the nominal seed is step
29,390, about ten minutes later. Both the port and the Fortran part from
their fixed counterparts at exactly that step.
That matters for anyone trying to locate the quirk in a dataset by eye: the visible onset is not at the eight-hour mark, and it moves with the seed.
What this port does
Can do either, and does not by default. [tepsim_control::Driver] forces
IDV(12) on at [tepsim_control::STEADY_STATE_STEPS], and
[tepsim_control::DriverQuirks::only_the_requested_disturbances] turns that
off; [tepsim::Scenario::driver_forces_idv12] is the facade's control and is
false by default. Scenario::faithful() and tep run --force-idv12 reproduce
the driver as shipped, and tier5::run_port pins it on so every differential
against the Fortran carries it.
[tepsim_control::Driver::scenario_is_overridden] reports when the driver has
gone beyond what was asked for, so a caller never has to infer it.
Measured effect
Ten simulated hours, nominal scenario, one-second Euler, faithful against fixed, everything else identical:
| first difference | step 29,390, XMEAS(22) (condenser cooling water outlet temperature) |
| identical before that | every bit of all 41 measurements, all 29,389 steps |
| worst over the run | 1.092e-1 relative, at XMEAS(37) |
| worst at hour ten | 3.262e-2 relative, at XMEAS(38) |
Ten percent on a product-composition measurement is not a rounding difference. It is a different experiment.
The sign-off, and what decided it
The question was whether the shipped default should be temain_mod.f's
behaviour or the caller's scenario. Decided on 2026-08-28: the caller's
scenario.
One of the two original arguments had already been shown to be false. The
case for keeping the quirk was that reproducing published data requires it.
Tier 7 (B-0051) established that the published d00 through d21 files carry
IDV(12) only in d12 and d12_te: they were generated with
temain_mod.f:367 replaced, not kept.
Two independent predictions support that. d12_te's spread jumps at row 160,
where IDV(12) would arrive, by at least 5.3 times what d00_te's does on
every channel the disturbance drives. And keeping the line inflates the port's
spread against d00 by up to 9.9 times, against 1.55 times for replacing it.
Removing it roughly triples the agreement across the whole comparison table,
for example d17_te from a Kolmogorov-Smirnov median of 0.658 to 0.044.
So the decision is now between two different goals rather than between fidelity and convenience:
- Reproduce the source.
temain_mod.fas shipped does forceIDV(12), and this project is a port of that source. Keeping the default means the port does what the code it was ported from does. - Reproduce the data. Most people who use the Tennessee Eastman problem use the published datasets, not the driver. Matching them means turning it off.
The second was chosen, on three grounds. The evidence is one-sided: the only
argument for forcing was that the shipped line exists, and the published bytes
say the line was replaced when the data was made. The prose at
temain_mod.f:101-102 agrees, telling the reader to "go to line 367" and put
their own disturbance there, which is an instruction to replace. And the cost
of being wrong is asymmetric: a caller who asks for IDV(4) and silently gets
IDV(4) and IDV(12) has no way to notice, whereas a caller who wants the
shipped driver's behaviour asks for it by name and gets exactly that.
Both remain reachable, which was the one firm constraint: Scenario::faithful,
tep run --force-idv12, driver_forces_idv12=True from Python, and the
idv12 field of the scenario text, which still parses old links to the
scenario they always named.
The numbers above are Tier 4. The Tier 5 measurement PLAN.org asks for on a
Class C delta is d011_the_forced_disturbance_moves_the_plant_by_a_tenth in
crates/tepsim-oracle/tests/tier10_quirk_deltas.rs, which runs the paired
battery with the flag on and off and requires the shift to exceed a tenth of a
margin on some channel: forcing IDV(12) is not cosmetic, and the test fails if
it ever looks that way.
Measured by
the_driver_forces_idv12_at_the_eight_hour_mark and
the_forced_disturbance_changes_the_plant_measurably in
crates/tepsim-oracle/tests/tier4_closed_loop.rs. The second cross-checks the
onset step against two Fortran runs that differ in the same way, so 29,390 is
ground truth rather than an artifact of the port.
the_published_files_were_not_generated_with_the_forced_idv12 in
crates/tepsim-oracle/tests/tier7_published.rs is what established that the
published datasets do not contain it.
Delta marker index
This page is generated.
cargo xtask deltaswrote it from commit7c13d33-dirty. Every row was collected from the source and from the register by that run.
Every deliberate deviation from the original Fortran carries two things: an entry
in the quirk and delta register, and a @delta marker on the code
it applies to. This table is collected from the markers and checked against the
register. cargo xtask deltas fails if an entry has no marker, if a marker has no
entry, or if the two disagree about the class.
11 register entries, 11 markers across 11 source location(s): 7 class A, 2 class B, 2 class C.
| delta | class | deviates from | marked at | register |
|---|---|---|---|---|
| D-001 | B | teprob.f:1439-1440 | crates/tepsim-core/src/thermo.rs:370 | TESUB2 reports success after failing to converge |
| D-002 | A | teprob.f:503-511 | crates/tepsim-core/src/kinetics.rs:217 | R1F and R2F name two unrelated quantities |
| D-003 | A | teprob.f:521-527 | crates/tepsim-core/src/kinetics.rs:218 | CRXR(2) is read but never assigned |
| D-004 | B | teprob.f:568-569 | crates/tepsim-core/src/flows.rs:255 | the mixed feed carries 1e-10 lbmol/h that nothing accounts for |
| D-005 | A | teprob.f:1129-1133 | crates/tepsim-core/src/stripper.rs:121 | SFR(4..8)'s initial values are dead |
| D-006 | A | teprob.f:698 | crates/tepsim-core/src/measurements.rs:238 | XMEAS(20) is assigned twice, with different factors |
| D-007 | C | teprob.f:807-811 | crates/tepsim-core/src/balances.rs:298 | a shutdown freezes the plant instead of stopping it |
| D-008 | A | temain_mod.f:1295-1332 | crates/tepsim-control/src/lib.rs:942 | CONTRL22 is defined, tuned, and never called |
| D-009 | A | temain_mod.f:322-332 | crates/tepsim-control/src/lib.rs:1076 | the driver starts from rounded valve positions, not TEINIT's |
| D-010 | A | temain_mod.f:366-411 | crates/tepsim-control/src/lib.rs:1135 | the controllers read the previous step's measurements |
| D-011 | C | temain_mod.f:366-368 | crates/tepsim-control/src/lib.rs:1274 | the driver switches IDV(12) on eight hours in, whatever you asked for |
The class column comes from the register. A row reading unmarked or undocumented is not a formatting artefact: it is a half of the register that is missing, and the run that wrote this page exited non-zero saying so. The page is written before the cross-check so that the evidence survives the failure.
Class A has no numerical effect, class B is numerically observable and fixed with a
measured delta, and class C is behaviour-defining, reproduced by default and changed
only behind a flag on explicit sign-off. PLAN.org defines them.