Skip to the content.

Data Reconciliation and Steady-State Detection

The neqsim.process.util.reconciliation package provides:

  1. A weighted least squares (WLS) data reconciliation engine that adjusts plant measurements so that mass (and optionally energy) balance constraints are exactly satisfied.
  2. A steady-state detector (SSD) based on the R-statistic method that monitors process variables and determines when the plant has reached steady state — a prerequisite for meaningful reconciliation.

Running the Examples

Python blocks labeled complete examples run independently with neqsim installed. The live demonstration uses 35 synthetic scans and terminates. Helper-function blocks in the four-stage pipeline must be run in order before calling the helpers. They do not contact a historian or update plant controls.

Java snippets use neqsim.process.util.reconciliation.*, java.util.*, neqsim.thermo.system.*, neqsim.process.equipment.stream.Stream, neqsim.process.equipment.separator.Separator, and neqsim.process.processmodel.ProcessSystem. Put imports above the class and statements inside main(String[] args). Run alternatives in separate scopes. The named and coefficient-array constraints are alternative definitions.

Contents


Steady-State Detection

Why Detect Steady State?

Data reconciliation assumes that the measured values represent a single operating point governed by conservation laws. If the plant is actively transitioning (e.g., a rate change, startup, or upset), reconciling transient data produces meaningless results. Steady-state detection (SSD) answers the question: Are the current readings stable enough to reconcile?

The R-Statistic Method

The detector uses the Cao-Rhinehart R-statistic (Cao & Rhinehart, 1995) — a ratio of the filtered variance to the unfiltered variance:

\[R = \frac{\sigma^2_f}{\sigma^2_u}\]

Where:

Interpretation:

R value Meaning
R close to 1.0 White noise only — steady state
R much less than 1.0 Trend, drift, or step change — transient
R greater than 1.0 Oscillation or alternating pattern

The default threshold is R ≥ 0.5. Optional supplementary tests:

Test Purpose Default
Slope test Catches slow monotonic drift that R might miss Disabled (threshold = 0)
Std.dev test Rejects signals that are technically “steady” but too noisy Disabled (threshold = 0)

SSD Step-by-Step Usage

Step 1 — Create the detector

Java output uses Log4j2. Declare this field inside your example class: private static final org.apache.logging.log4j.Logger logger = org.apache.logging.log4j.LogManager.getLogger("OptimizationExample");.

// Window of 30 samples, R-threshold 0.5
SteadyStateDetector detector = new SteadyStateDetector(30);
detector.setRThreshold(0.5);

Step 2 — Register variables

// By name (uses default window size)
detector.addVariable("TI-2001");

// Flow variable with explicit window and uncertainty
SteadyStateVariable v = new SteadyStateVariable("FI-1001", 30);
v.setUnit("kg/hr");
v.setUncertainty(20.0); // needed if bridging to reconciliation
detector.addVariable(v);

Step 3 — Feed data (streaming loop)

// Synthetic scan; replace these values with your historian readings:
detector.updateVariable("FI-1001", 1000.0);
detector.updateVariable("TI-2001", 25.0);

Or update all at once:

Map<String, Double> snapshot = new LinkedHashMap<String, Double>();
snapshot.put("FI-1001", 1000.0);
snapshot.put("TI-2001", 25.0);
detector.updateAll(snapshot);

Step 4 — Evaluate

SteadyStateResult result = detector.evaluate();

if (result.isAtSteadyState()) {
    logger.info("Plant is at steady state — eligible for reconciliation checks");
} else {
    logger.info("Transient variables: " + result.getTransientVariables());
}

Or combine update + evaluate:

SteadyStateResult result = detector.updateAndEvaluate(snapshot);

Step 5 — Read results

// Per-variable diagnostics
for (SteadyStateVariable v : result.getVariables()) {
    logger.info(String.format("%-12s  R=%.3f  mean=%.1f  steady=%s%n",
        v.getName(), v.getRStatistic(), v.getMean(), v.isAtSteadyState()));
}

// Reports
logger.info(result.toReport()); // formatted table
String json = result.toJson();          // machine-readable

SSD Java Example

import neqsim.process.util.reconciliation.*;

// Create detector
SteadyStateDetector ssd = new SteadyStateDetector(30);
ssd.setRThreshold(0.5);
ssd.setSlopeThreshold(0.5);   // Optional: catch slow drifts

// Register variables with uncertainties
SteadyStateVariable feed = new SteadyStateVariable("feed_flow", 30);
feed.setUnit("kg/hr").setUncertainty(20.0);
ssd.addVariable(feed);

SteadyStateVariable gas = new SteadyStateVariable("gas_flow", 30);
gas.setUnit("kg/hr").setUncertainty(15.0);
ssd.addVariable(gas);

SteadyStateVariable liquid = new SteadyStateVariable("liquid_flow", 30);
liquid.setUnit("kg/hr").setUncertainty(10.0);
ssd.addVariable(liquid);

// Simulate 30 readings at steady state
java.util.Random noise = new java.util.Random(42);
for (int i = 0; i < 30; i++) {
    ssd.updateVariable("feed_flow", 1000.0 + (noise.nextDouble() - 0.5) * 2);
    ssd.updateVariable("gas_flow", 605.0 + (noise.nextDouble() - 0.5) * 2);
    ssd.updateVariable("liquid_flow", 398.0 + (noise.nextDouble() - 0.5) * 2);
}

SteadyStateResult ssResult = ssd.evaluate();
logger.info(ssResult.toReport());

if (ssResult.isAtSteadyState()) {
    // Bridge directly to reconciliation
    DataReconciliationEngine engine = ssd.createReconciliationEngine();
    engine.addMassBalanceConstraint("separator",
        new String[]{"feed_flow"},
        new String[]{"gas_flow", "liquid_flow"});
    ReconciliationResult recResult = engine.reconcile();
    logger.info(recResult.toReport());
}

SSD Python Example

from neqsim import jneqsim

SteadyStateDetector = jneqsim.process.util.reconciliation.SteadyStateDetector
SteadyStateVariable = jneqsim.process.util.reconciliation.SteadyStateVariable

# Create detector with window=30
ssd = SteadyStateDetector(30)
ssd.setRThreshold(0.5)

# Register variables
feed = SteadyStateVariable("feed_flow", 30)
feed.setUnit("kg/hr").setUncertainty(20.0)
ssd.addVariable(feed)

gas = SteadyStateVariable("gas_flow", 30)
gas.setUnit("kg/hr").setUncertainty(15.0)
ssd.addVariable(gas)

# Push 30 constant-ish readings
import random
random.seed(42)
for i in range(30):
    ssd.updateVariable("feed_flow", 1000.0 + random.uniform(-1, 1))
    ssd.updateVariable("gas_flow", 600.0 + random.uniform(-1, 1))

result = ssd.evaluate()
print(result.toReport())

if result.isAtSteadyState():
    engine = ssd.createReconciliationEngine()
    # This detector monitors feed and gas only; a liquid meter is needed for a
    # separator mass balance. The complete four-flow example below adds all outlets.

SSD API Reference

SteadyStateDetector

Method Description
SteadyStateDetector(int windowSize) Create detector with given default window size
addVariable(SteadyStateVariable v) Register a pre-configured variable
addVariable(String name) Register by name using default window; returns created variable
removeVariable(String name) Unregister a variable; returns true if found
getVariable(String name) Get variable by name (null if not found)
getVariableCount() Number of registered variables
updateVariable(String name, double value) Push a new sample for one variable
updateAll(Map name, Double value) Push new samples for all variables
evaluate() Evaluate all variables; returns SteadyStateResult
updateAndEvaluate(Map) Convenience: updateAll + evaluate
createReconciliationEngine() Build a DataReconciliationEngine from steady-state variables
setRThreshold(double) Set R-statistic threshold (default 0.5)
setSlopeThreshold(double) Set max absolute slope (0 = disabled)
setStdDevThreshold(double) Set max standard deviation (0 = disabled)
setRequiredFraction(double) Fraction of variables that must be steady (default 1.0)
setRequireFullWindow(boolean) Require window to be full before evaluating (default true)
clear() Remove all variables and reset

SteadyStateVariable

Method Description
SteadyStateVariable(String name, int windowSize) Create with name and sliding window size (min 3)
addValue(double value) Add a sample; recomputes statistics
clear() Clear all samples
getMean() Window mean
getStandardDeviation() Window standard deviation
getRStatistic() Cao-Rhinehart R-statistic
getSlope() Linear regression slope (per sample)
isAtSteadyState() Whether last evaluation flagged as steady
getCount() Number of samples in window
getWindowSize() Configured window size
setUnit(String) Set engineering unit (fluent)
setUncertainty(double) Set measurement uncertainty for reconciliation (fluent)

SteadyStateResult

Method Description
isAtSteadyState() Overall SSD verdict
getSteadyCount() Number of steady variables
getTransientCount() Number of transient variables
getVariables() All variables with their per-variable statistics
getTransientVariables() Only the variables that failed the SSD test
toReport() Human-readable formatted text report
toJson() Machine-readable JSON

Tuning the Detector

Parameter Typical range Guidance
Window size 20-60 Larger = more stable but slower response. 30 is a good default for 10-second scan intervals (~5 min window)
R-threshold 0.3-0.8 Lower = more tolerant of trends. 0.5 works well for most process variables
Slope threshold 0-1.0 Enable only if you need to catch very slow drifts. Units depend on your variable’s scale
Std.dev threshold 0-inf Enable to reject signals that are “steady” but too noisy to be useful
Required fraction 0.5-1.0 Set below 1.0 to allow reconciliation even if some variables are still settling

Bridging to Data Reconciliation

The createReconciliationEngine() method creates a DataReconciliationEngine pre-populated with variables that:

  1. Are at steady state (per R-statistic evaluation)
  2. Have defined uncertainty (from setUncertainty())

The bridge uses each variable’s window mean as the measurement value and the configured uncertainty as sigma. Transient variables and variables without uncertainty are excluded.

// Continue from SSD Java Example: the detector already has all three meters.
if (ssd.evaluate().isAtSteadyState()) {
    DataReconciliationEngine engine = ssd.createReconciliationEngine();
    engine.addMassBalanceConstraint("node1", new String[] {"feed_flow"},
        new String[] {"gas_flow", "liquid_flow"});
    ReconciliationResult result = engine.reconcile();
    logger.info(result.toReport());
}

Data Reconciliation

Overview

Plant instruments (flow meters, pressure transmitters, temperature sensors) always have measurement errors. Raw readings almost never satisfy the fundamental conservation laws — mass in rarely equals mass out when you add up the meter tags. Data reconciliation corrects these readings by finding the smallest statistically-weighted adjustments that make all balances close exactly.

Key Capabilities

Feature Description
Weighted Least Squares Adjustments weighted by 1/sigma² — uncertain meters move more
Linear Constraints Mass balance, energy balance, or any linear relation A·x = 0
Gross Error Detection Per-variable normalized residual test flags faulty sensors
Iterative Elimination Automatically removes worst sensor and re-reconciles
Chi-Square Global Test Detects if overall measurement quality is acceptable
JSON / Text Reports Machine-readable and human-readable output formats
EJML Matrix Engine Uses the EJML SimpleMatrix library already in NeqSim

Typical Workflow

Plant DCS/Historian ──► Python (collect tags) ──► Set measurements on Engine
                                                          │
                                                          ▼
                                                   Define constraints
                                                          │
                                                          ▼
                                                   engine.reconcile()
                                                          │
                                                          ▼
                                               Read reconciled values
                                                   Detect bad sensors
                                                   Update ProcessSystem

Online loop: The data collection and scheduling happen externally, typically in Python. The Java engine provides the reconciliation math — you feed it measurements via setters and read back reconciled values.


When to Use Data Reconciliation

Scenario Recommendation
Mass balance doesn’t close across separator/mixer Use reconciliation
Need to identify a faulty flow meter Use gross error detection
Calibrating model parameters to plant data Use reconciliation first, then BatchParameterEstimator
Adjusting a single variable for a target Use Adjuster instead
Streaming real-time data at high frequency Collect externally, call reconcile() at each interval

Mathematical Background

Given $n$ measurements $\mathbf{y}$ with diagonal covariance $\mathbf{V} = \text{diag}(\sigma_1^2, \ldots, \sigma_n^2)$ and $m$ linear constraints $\mathbf{A} \cdot \mathbf{x} = \mathbf{0}$, the WLS solution is:

\[\hat{\mathbf{x}} = \mathbf{y} - \mathbf{V} \mathbf{A}^T (\mathbf{A} \mathbf{V} \mathbf{A}^T)^{-1} \mathbf{A} \mathbf{y}\]

Objective minimized:

\[J = \sum_{i=1}^{n} \left(\frac{\hat{x}_i - y_i}{\sigma_i}\right)^2\]

Normalized residual for gross error detection:

\[r_i = \frac{\hat{x}_i - y_i}{\sqrt{V_{ii} - V_{ii}^{adj}}}\]
where $V^{adj} = V - V A^T (A V A^T)^{-1} A V$. If $ r_i $ exceeds a z-threshold (default 1.96 for 95% confidence), the measurement is flagged as a gross error.

Global test: The objective $J$ follows a chi-square distribution with $m$ degrees of freedom under the null hypothesis of no gross errors.


Architecture

neqsim.process.util.reconciliation
├── ReconciliationVariable    — One measured variable (value + sigma + result)
├── ReconciliationResult      — Full result with statistics, JSON, report
├── DataReconciliationEngine  — WLS solver, gross error detection
└── package-info.java         — Package documentation
Class Responsibility
ReconciliationVariable Holds a single measurement: name, measured value, uncertainty (sigma), reconciled value, unit, optional equipment/property link, normalized residual, gross error flag
DataReconciliationEngine Builds the problem (variables + constraints), solves the WLS system using EJML, runs statistical tests
ReconciliationResult Immutable result container: all variables, objective value, chi-square statistic, degrees of freedom, global test, gross errors list, constraint residuals before/after, compute time, JSON and text report output

Step-by-Step Usage

Step 1 — Define Measurements

Create a ReconciliationVariable for each plant measurement. The constructor takes (name, measuredValue, uncertainty):

// Each variable is one plant tag
ReconciliationVariable feed = new ReconciliationVariable("feed_flow", 1000.0, 20.0);
ReconciliationVariable gas  = new ReconciliationVariable("gas_flow",   620.0, 15.0);
ReconciliationVariable liq  = new ReconciliationVariable("liq_flow",   370.0, 10.0);

Parameters:

You can also link a variable to a specific equipment property in a ProcessSystem:

ReconciliationVariable feed = new ReconciliationVariable(
    "feed_flow",          // name
    "HP_Separator",       // equipmentName in ProcessSystem
    "massFlowRate",       // property name
    1000.0,               // measured value
    20.0                  // uncertainty (sigma)
);
feed.setUnit("kg/hr");

Step 2 — Set Measurement Uncertainties

The uncertainty (sigma) is the standard deviation of the measurement error. It controls how much each measurement is allowed to move during reconciliation:

// Precise Coriolis meter: sigma = 0.5% of reading
ReconciliationVariable precise = new ReconciliationVariable("coriolis_flow", 1000.0, 5.0);

// Less precise orifice meter: sigma = 2% of reading
ReconciliationVariable rough = new ReconciliationVariable("orifice_flow", 600.0, 12.0);

// Calculated/estimated value: sigma = 5% of reading
ReconciliationVariable estimated = new ReconciliationVariable("estimated_flow", 400.0, 20.0);

Typical uncertainty guidelines (see Uncertainty Guidelines for detailed values).

Step 3 — Define Balance Constraints

Add linear constraints of the form: $\sum_i a_i \cdot x_i = 0$

Option A — Raw coefficient array:

DataReconciliationEngine engine = new DataReconciliationEngine();
engine.addVariable(feed);   // index 0
engine.addVariable(gas);    // index 1
engine.addVariable(liq);    // index 2

// Constraint: feed - gas - liq = 0  (mass balance around separator)
engine.addConstraint(new double[]{1.0, -1.0, -1.0});

The coefficient array has one entry per variable, in the order they were added. Use +1 for inlets, -1 for outlets.

Option B — Named mass balance (recommended):

DataReconciliationEngine namedEngine = new DataReconciliationEngine();
namedEngine.addVariable(feed);
namedEngine.addVariable(gas);
namedEngine.addVariable(liq);

namedEngine.addMassBalanceConstraint("Separator balance",
    new String[]{"feed_flow"},                    // inlet names
    new String[]{"gas_flow", "liq_flow"});        // outlet names

Use either Option A (engine) or Option B (namedEngine); do not add the same variables or balance twice. Option B is equivalent to {1.0, -1.0, -1.0} but self-documenting and less error-prone for large networks.

Step 4 — Run Reconciliation

ReconciliationResult result = engine.reconcile();

if (result.isConverged()) {
    logger.info("Reconciliation successful");
    logger.info("Objective (weighted SSQ): " + result.getObjectiveValue());
} else {
    logger.info("Failed: " + result.getErrorMessage());
}

Step 5 — Read Reconciled Values

After reconciliation, each variable holds its adjusted value:

for (ReconciliationVariable v : result.getVariables()) {
    logger.info(String.format("%-15s  meas=%.1f  rec=%.1f  adj=%.2f %s%n",
        v.getName(),
        v.getMeasuredValue(),
        v.getReconciledValue(),
        v.getAdjustment(),
        v.getUnit()));
}

You can also look up individual variables by name:

double reconciledFeed = engine.getVariable("feed_flow").getReconciledValue();
double feedAdjustment = engine.getVariable("feed_flow").getAdjustment();

Step 6 — Detect Gross Errors

The engine computes a normalized residual for each variable. If $ r_i > \text{threshold}$ (default 1.96), the variable is flagged:
for (ReconciliationVariable v : result.getVariables()) {
    if (v.isGrossError()) {
        logger.info("GROSS ERROR: " + v.getName()
            + " |r|=" + Math.abs(v.getNormalizedResidual()));
    }
}

// Or check the global test
if (!result.isGlobalTestPassed()) {
    logger.info("WARNING: Global chi-square test failed — possible gross errors");
}

Working with Model (Tuned) Variables

In many online optimization workflows, you compare the reconciled plant values against model-predicted values from a tuned process simulation. The difference between reconciled and model-predicted values highlights where the simulation deviates from reality.

Setting Model Values

After running a NeqSim ProcessSystem simulation, set its predicted values on each variable:

// Build the model explicitly; metadata alone does not create measurement devices.
SystemSrkEos fluid = new SystemSrkEos(298.15, 60.0);
fluid.addComponent("methane", 0.70);
fluid.addComponent("n-decane", 0.30);
fluid.setMixingRule("classic");
Stream modelFeed = new Stream("Feed", fluid);
modelFeed.setFlowRate(1000.0, "kg/hr");
Separator modelSeparator = new Separator("HP Sep", modelFeed);
ProcessSystem process = new ProcessSystem();
process.add(modelFeed);
process.add(modelSeparator);
process.run();
engine.getVariable("feed_flow").setModelValue(modelFeed.getFlowRate("kg/hr"));
engine.getVariable("gas_flow").setModelValue(
    modelSeparator.getGasOutStream().getFlowRate("kg/hr"));
engine.getVariable("liq_flow").setModelValue(
    modelSeparator.getLiquidOutStream().getFlowRate("kg/hr"));

Reading Model vs Reconciled Comparison

for (ReconciliationVariable v : result.getVariables()) {
    if (v.hasModelValue()) {
        double modelDelta = v.getReconciledValue() - v.getModelValue();
        logger.info(String.format("%-15s  reconciled=%.1f  model=%.1f  delta=%.2f%n",
            v.getName(), v.getReconciledValue(), v.getModelValue(), modelDelta));
    }
}

Using Reconciled Values to Tune the Process Model

After reconciliation gives you balanced measurements that have passed the configured quality tests, use those values to update the simulation model parameters:

// After reconciliation:
double validatedFeed = engine.getVariable("feed_flow").getReconciledValue();
double validatedGas  = engine.getVariable("gas_flow").getReconciledValue();

// Update simulation inputs with reconciled values
Stream feedStream = (Stream) process.getUnit("Feed");
feedStream.setFlowRate(validatedFeed, "kg/hr");

// Re-run the simulation with corrected inputs
process.run();

// Compare model outputs to reconciled values to identify where model needs tuning
double modelLiqOut = ((Separator) process.getUnit("HP Sep")).getLiquidOutStream()
    .getFlowRate("kg/hr");
double reconciledLiq = engine.getVariable("liq_flow").getReconciledValue();
double modelError = reconciledLiq - modelLiqOut;
// A large gap may indicate fluid-assay, metering, phase-model, or boundary errors; diagnose before tuning

Full Reconciliation-then-Calibration Workflow

For a complete loop that reconciles measurements and then tunes model parameters, combine with BatchParameterEstimator:

 ┌─────────────────────────────────────────────────┐
 │ 1. Collect plant measurements (Python/DCS)      │
 │ 2. Set measurements on DataReconciliationEngine │
 │ 3. engine.reconcile()                           │
 │ 4. Check gross errors, remove bad sensors       │
 │ 5. Use reconciled values as "truth"             │
 │ 6. Feed into BatchParameterEstimator            │
 │    to tune model parameters (UA, efficiency,    │
 │    k-values, etc.)                              │
 │ 7. Update ProcessSystem with tuned parameters   │
 │ 8. Repeat at next time interval                 │
 └─────────────────────────────────────────────────┘

Complete Java Example

import neqsim.process.util.reconciliation.*;

public class SeparatorReconciliation {
    private static final org.apache.logging.log4j.Logger logger =
        org.apache.logging.log4j.LogManager.getLogger(SeparatorReconciliation.class);
    public static void main(String[] args) {
        // Create engine
        DataReconciliationEngine engine = new DataReconciliationEngine();

        // Add plant measurements: (name, measuredValue, uncertainty)
        engine.addVariable(
            new ReconciliationVariable("feed", 10000.0, 200.0).setUnit("kg/hr"));
        engine.addVariable(
            new ReconciliationVariable("gas", 3500.0, 100.0).setUnit("kg/hr"));
        engine.addVariable(
            new ReconciliationVariable("oil", 4800.0, 150.0).setUnit("kg/hr"));
        engine.addVariable(
            new ReconciliationVariable("water", 1900.0, 80.0).setUnit("kg/hr"));

        // Measurement imbalance: 10000 - 3500 - 4800 - 1900 = -200 kg/hr

        // Define mass balance: feed - gas - oil - water = 0
        engine.addMassBalanceConstraint("3-Phase Separator",
            new String[]{"feed"},
            new String[]{"gas", "oil", "water"});

        // Reconcile
        ReconciliationResult result = engine.reconcile();

        // Print text report
        logger.info(result.toReport());

        // Access individual reconciled values
        double recFeed = engine.getVariable("feed").getReconciledValue();
        double recGas  = engine.getVariable("gas").getReconciledValue();
        double recOil  = engine.getVariable("oil").getReconciledValue();
        double recWater = engine.getVariable("water").getReconciledValue();

        logger.info(String.format("Balance check: %.2f - %.2f - %.2f - %.2f = %.6f%n",
            recFeed, recGas, recOil, recWater,
            recFeed - recGas - recOil - recWater));

        // Check for gross errors
        if (result.hasGrossErrors()) {
            logger.info("*** Gross errors detected in: ");
            for (ReconciliationVariable ge : result.getGrossErrors()) {
                logger.info("  " + ge.getName());
            }
        }

        // Machine-readable output
        logger.info(result.toJson());
    }
}

Complete Python Example

from neqsim import jneqsim

# Import reconciliation classes
ReconciliationVariable = jneqsim.process.util.reconciliation.ReconciliationVariable
DataReconciliationEngine = jneqsim.process.util.reconciliation.DataReconciliationEngine

# Create engine
engine = DataReconciliationEngine()

# Add measurements from plant DCS/historian
engine.addVariable(ReconciliationVariable("feed", 10000.0, 200.0).setUnit("kg/hr"))
engine.addVariable(ReconciliationVariable("gas", 3500.0, 100.0).setUnit("kg/hr"))
engine.addVariable(ReconciliationVariable("oil", 4800.0, 150.0).setUnit("kg/hr"))
engine.addVariable(ReconciliationVariable("water", 1900.0, 80.0).setUnit("kg/hr"))

# Mass balance: feed - gas - oil - water = 0
engine.addMassBalanceConstraint("3-Phase Sep",
    ["feed"], ["gas", "oil", "water"])

# Run reconciliation
result = engine.reconcile()
print(result.toReport())

# Read reconciled values
for v in result.getVariables():
    print(f"{str(v.getName()):15s}  meas={v.getMeasuredValue():10.1f}  "
          f"rec={v.getReconciledValue():10.1f}  "
          f"adj={v.getAdjustment():+8.2f}  "
          f"|r|={abs(v.getNormalizedResidual()):6.3f}  "
          f"{'**GE**' if v.isGrossError() else 'ok'}")

# Check if measurements are globally consistent
if result.isGlobalTestPassed():
    print("All measurements consistent (chi-square test passed)")
else:
    print("WARNING: measurement quality issue detected")

Python Online Loop Pattern

import time
from neqsim import jneqsim

ReconciliationVariable = jneqsim.process.util.reconciliation.ReconciliationVariable
DataReconciliationEngine = jneqsim.process.util.reconciliation.DataReconciliationEngine

def get_plant_measurements():
    """Read current measurements from DCS/historian (user implementation)."""
    # Example: read from OPC-UA, PI, IP.21, or CSV
    return {
        "feed": (10050.0, 200.0),   # (value, sigma)
        "gas": (3520.0, 100.0),
        "oil": (4780.0, 150.0),
        "water": (1880.0, 80.0),
    }

# Periodic reconciliation loop
for scan in range(3):
    measurements = get_plant_measurements()

    engine = DataReconciliationEngine()
    for name, (value, sigma) in measurements.items():
        engine.addVariable(ReconciliationVariable(name, float(value), float(sigma)))

    engine.addMassBalanceConstraint("Sep",
        ["feed"], ["gas", "oil", "water"])

    result = engine.reconcile()

    if result.isConverged():
        print(f"OK  obj={result.getObjectiveValue():.3f}  "
              f"gross_errors={result.hasGrossErrors()}")
        # Use reconciled values downstream...
    else:
        print(f"FAILED: {result.getErrorMessage()}")

    # In a scheduled service, call one iteration per historian scan.

API Reference

ReconciliationVariable

Method Returns Description
ReconciliationVariable(name, value, sigma) Constructor: name, measured value, uncertainty
ReconciliationVariable(name, equip, prop, value, sigma) Constructor with equipment/property link
getName() String Variable identifier
getMeasuredValue() double Raw plant reading
getUncertainty() double Standard deviation (sigma)
getReconciledValue() double Adjusted value after reconciliation
getAdjustment() double reconciledValue − measuredValue
getNormalizedResidual() double Statistical test value for gross error detection
isGrossError() boolean True if flagged by normalized residual test
getModelValue() double Model-predicted value (NaN if not set)
setModelValue(double) void Set model prediction for comparison
hasModelValue() boolean Whether a model value was set
setUnit(String) this Engineering unit (fluent)
setEquipmentName(String) this Link to ProcessSystem equipment (fluent)
setPropertyName(String) this Property being measured (fluent)

DataReconciliationEngine

Method Returns Description
addVariable(var) this Register a measurement variable
addConstraint(double[]) this Add unnamed linear constraint A·x = 0
addConstraint(double[], name) this Add named linear constraint
addMassBalanceConstraint(name, inlets, outlets) this Named mass balance by variable names
reconcile() ReconciliationResult Run WLS reconciliation with gross error detection
reconcileWithGrossErrorElimination(max) ReconciliationResult Iterative elimination of worst sensor
getVariable(name) ReconciliationVariable Look up variable by name
getVariableCount() int Number of registered variables
getConstraintCount() int Number of registered constraints
setGrossErrorThreshold(z) this Set z-value (1.96=95%, 2.576=99%)
clear() void Remove all variables and constraints
clearConstraints() void Remove constraints only, keep variables

ReconciliationResult

Method Returns Description
isConverged() boolean Whether reconciliation succeeded
getObjectiveValue() double Weighted sum of squared adjustments
getChiSquareStatistic() double Same as objective — compared against chi-square distribution
getDegreesOfFreedom() int Number of constraints (redundancy)
isGlobalTestPassed() boolean True if objective within chi-square critical value
getVariables() List All variables with reconciled values
getGrossErrors() List Variables flagged as gross errors
hasGrossErrors() boolean Whether any gross errors were detected
getConstraintResidualsBefore() double[] Constraint residuals using raw measurements
getConstraintResidualsAfter() double[] Constraint residuals after reconciliation (near-zero)
getComputeTimeMs() long Execution time in milliseconds
getErrorMessage() String Error description if not converged
toJson() String Full JSON output
toReport() String Formatted text table

Uncertainty Guidelines

Choosing the right uncertainty (sigma) is critical. Here are typical values for common instrument types:

Instrument Type Typical Accuracy Sigma as % of Reading
Coriolis flow meter ±0.1–0.5% 0.2–0.5%
Ultrasonic flow meter ±0.5–1.0% 0.5–1.0%
Orifice plate ±1.0–2.0% 1.0–2.0%
Vortex flow meter ±0.5–1.5% 0.5–1.5%
Turbine meter ±0.25–0.5% 0.25–0.5%
Level-inferred flow ±3–5% 3–5%
RTD temperature ±0.1–0.3 °C Use absolute value
Thermocouple ±1.0–2.5 °C Use absolute value
Pressure transmitter ±0.1–0.25% FS 0.1–0.25% of full scale
Calculated/estimated ±5–10% 5–10%

Example: computing sigma from instrument spec:

double flowReading = 5000.0;  // kg/hr
double accuracy = 0.01;       // 1% orifice plate
double sigma = flowReading * accuracy;  // 50.0 kg/hr

ReconciliationVariable v = new ReconciliationVariable("FT-101", flowReading, sigma);

Rules of thumb:


Multi-Node Network Example

For process networks with multiple balance points, add one constraint per node:

DataReconciliationEngine engine = new DataReconciliationEngine();

// Node 1: Feed separator
engine.addVariable(new ReconciliationVariable("well_flow",  5000.0, 100.0));
engine.addVariable(new ReconciliationVariable("sep_gas",    2100.0,  50.0));
engine.addVariable(new ReconciliationVariable("sep_liquid", 2850.0,  70.0));

// Node 2: Compressor (gas path)
engine.addVariable(new ReconciliationVariable("comp_out",   2080.0,  50.0));

// Node 3: Pump (liquid path)
engine.addVariable(new ReconciliationVariable("pump_out",   2870.0,  70.0));

// Constraints: one per balance node
engine.addMassBalanceConstraint("Separator",
    new String[]{"well_flow"},
    new String[]{"sep_gas", "sep_liquid"});

engine.addMassBalanceConstraint("Compressor",
    new String[]{"sep_gas"},
    new String[]{"comp_out"});

engine.addMassBalanceConstraint("Pump",
    new String[]{"sep_liquid"},
    new String[]{"pump_out"});

// Solve all balances simultaneously
ReconciliationResult result = engine.reconcile();

// Degrees of freedom = 3 (three constraints, 5 variables → 2 DoF)
logger.info("DoF: " + result.getDegreesOfFreedom());
logger.info(result.toReport());

Important: The system must be over-determined (more variables than constraints) for reconciliation to work. If $n \leq m$, the system is exactly determined or under-determined and the engine returns an error.


Gross Error Elimination

When one sensor has a large systematic bias, simple reconciliation distorts all other readings. Use iterative elimination to automatically identify and downweight the faulty sensor:

// Set a stricter threshold (99% confidence)
engine.setGrossErrorThreshold(2.576);

// Iteratively eliminate up to 2 gross errors
ReconciliationResult result = engine.reconcileWithGrossErrorElimination(2);

// Check which sensors were flagged
for (ReconciliationVariable ge : result.getGrossErrors()) {
    logger.info("Faulty sensor: " + ge.getName()
        + " (normalized residual: " + ge.getNormalizedResidual() + ")");
}

How it works:

  1. Run standard reconciliation
  2. Find the variable with the largest normalized residual exceeding the threshold
  3. Set that variable’s uncertainty to a very large number (1×10¹²) — effectively removing it
  4. Re-reconcile
  5. Repeat until no more gross errors or max eliminations reached
  6. Restore original uncertainties and report all identified gross errors

Integration with ProcessSystem

The reconciliation engine is designed to work alongside a NeqSim process simulation. A typical pattern:

// 1. Build and run process simulation
SystemInterface fluid = new SystemSrkEos(273.15 + 25.0, 60.0);
fluid.addComponent("methane", 0.85);
fluid.addComponent("ethane", 0.10);
fluid.addComponent("propane", 0.05);
fluid.setMixingRule("classic");

Stream feed = new Stream("Feed", fluid);
feed.setFlowRate(10000.0, "kg/hr");

Separator sep = new Separator("HP Sep", feed);

ProcessSystem process = new ProcessSystem();
process.add(feed);
process.add(sep);
process.run();

// 2. Get model predictions
double modelFeed = feed.getFlowRate("kg/hr");
double modelGas  = sep.getGasOutStream().getFlowRate("kg/hr");
double modelLiq  = sep.getLiquidOutStream().getFlowRate("kg/hr");

// 3. Reconcile plant measurements
DataReconciliationEngine engine = new DataReconciliationEngine();
ReconciliationVariable vFeed = new ReconciliationVariable(
    "feed", "Feed", "massFlowRate", 10050.0, 200.0);
ReconciliationVariable vGas = new ReconciliationVariable(
    "gas", "HP Sep gas", "massFlowRate", 6180.0, 120.0);
ReconciliationVariable vLiq = new ReconciliationVariable(
    "liquid", "HP Sep liq", "massFlowRate", 3720.0, 80.0);

engine.addVariable(vFeed);
engine.addVariable(vGas);
engine.addVariable(vLiq);

engine.addMassBalanceConstraint("HP Sep balance",
    new String[]{"feed"}, new String[]{"gas", "liquid"});

ReconciliationResult result = engine.reconcile();

// 4. Compare reconciled vs model
vFeed.setModelValue(modelFeed);
vGas.setModelValue(modelGas);
vLiq.setModelValue(modelLiq);

for (ReconciliationVariable v : result.getVariables()) {
    if (v.hasModelValue()) {
        double gap = v.getReconciledValue() - v.getModelValue();
        logger.info(String.format("%s: reconciled=%.1f, model=%.1f, gap=%.1f%n",
            v.getName(), v.getReconciledValue(), v.getModelValue(), gap));
    }
}

// 5. Update simulation with reconciled inputs for model tuning
feed.setFlowRate(engine.getVariable("feed").getReconciledValue(), "kg/hr");
process.run();

Building Live NeqSim Models (Python)

A “live model” (or digital twin) continuously reads plant data, validates it, and keeps a NeqSim simulation synchronized with reality. In NeqSim the computation engine is Java, but the orchestration layer is Python — Python owns the scheduling, data acquisition (OPC-UA, PI, CSV, database), visualization, and alarm logic, while Java handles the thermodynamics, process simulation, and numerical optimization.

This section describes the optimal architecture for combining the SteadyStateDetector, DataReconciliationEngine, and ProcessSystem in a live Python application.

Architecture — Python Orchestrator, Java Engine

┌───────────────────────────────────────────────────────────────────┐
│  Python Application (scheduling, I/O, dashboards, alerts)        │
│                                                                   │
│  ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────────┐  │
│  │  Data     │──▶│  Steady  │──▶│  Data    │──▶│ NeqSim Model │  │
│  │  Source   │   │  State   │   │  Recon   │   │ Update +     │  │
│  │ (OPC/PI)  │   │  Detect  │   │  Engine  │   │ Optimizer    │  │
│  └──────────┘   └──────────┘   └──────────┘   └──────────────┘  │
│       ▲                                              │           │
│       │              via jneqsim (JPype JVM)         ▼           │
│       │                                       Reconciled +       │
│       │                                       Optimized Results  │
│       └──────────────────────────────────────────────────────────┘
│                                                                   │
│  Python libraries: pandas, schedule/APScheduler, opcua, matplotlib│
└───────────────────────────────────────────────────────────────────┘

Why this split?

Layer Python Java (via jneqsim)
Data acquisition OPC-UA, PI SDK, REST APIs, CSV/DB
Scheduling schedule, APScheduler, asyncio
SSD Pushes values to Java detector SteadyStateDetector (R-statistic)
Reconciliation Reads results as dicts DataReconciliationEngine (WLS)
Process simulation Sets inputs, calls run() ProcessSystem, EOS solvers
Optimization SciPy, or calls Java optimizer ProcessSensitivityAnalyzer, LM
Dashboards Plotly, Streamlit, Grafana
Alerting Email, Teams, PagerDuty

The Four-Stage Pipeline

Every scan cycle (typically 30-120 seconds) executes four stages:

  ┌───────────┐     ┌──────────┐     ┌──────────────┐     ┌────────────┐
  │ 1. COLLECT │────▶│ 2. SSD   │────▶│ 3. RECONCILE │────▶│ 4. UPDATE  │
  │   plant    │     │  gate    │     │   balance    │     │   model +  │
  │   tags     │     │ (R-stat) │     │   enforce    │     │  optimize  │
  └───────────┘     └──────────┘     └──────────────┘     └────────────┘
       │                 │                  │                    │
    raw tags       steady/transient   reconciled vals      model predictions
    + timestamps    per variable       + gross errors       + KPIs

Stage 2 is the gate — if the process is not at steady state, stages 3 and 4 are skipped and the previous good model state is retained. This prevents the model from chasing transients.

Stage 1 — Collect and Buffer Measurements

import time
from collections import OrderedDict

def read_plant_tags():
    """Read current tag values from your data source.
    Replace this with your OPC-UA / PI / historian reader."""
    return OrderedDict([
        ("FI-1001", 10050.0),   # feed flow, kg/hr
        ("FI-2001",  3520.0),   # gas out flow
        ("FI-3001",  4780.0),   # oil out flow
        ("FI-4001",  1880.0),   # water out flow
        ("TI-1001",    82.3),   # separator temperature, C
        ("PI-1001",    65.2),   # separator pressure, bara
    ])

The data source is entirely Python — OPC-UA (opcua or asyncua), OSIsoft PI (PIconnect), CSV polling, or a database query. NeqSim never touches the I/O layer directly.

Stage 2 — Steady-State Gate

Push each new reading into the SteadyStateDetector and evaluate:

from neqsim import jneqsim

SteadyStateDetector = jneqsim.process.util.reconciliation.SteadyStateDetector
SteadyStateVariable = jneqsim.process.util.reconciliation.SteadyStateVariable

# One-time setup (keep alive across scan cycles)
def create_ssd():
    ssd = SteadyStateDetector(30)   # 30-sample sliding window
    ssd.setRThreshold(0.5)

    # Register all monitored tags with instrument uncertainties
    tags = {
        "FI-1001": {"unit": "kg/hr", "sigma": 200.0},
        "FI-2001": {"unit": "kg/hr", "sigma": 100.0},
        "FI-3001": {"unit": "kg/hr", "sigma": 150.0},
        "FI-4001": {"unit": "kg/hr", "sigma":  80.0},
        "TI-1001": {"unit": "C",     "sigma":   0.5},
        "PI-1001": {"unit": "bara",  "sigma":   0.2},
    }
    for name, info in tags.items():
        v = SteadyStateVariable(name, 30)
        v.setUnit(info["unit"]).setUncertainty(info["sigma"])
        ssd.addVariable(v)

    return ssd

# Per-cycle call
def check_steady_state(ssd, plant_tags):
    """Push new readings and evaluate.
    Returns (is_steady, result_object)."""
    java_map = __import__("jpype").JClass("java.util.LinkedHashMap")()
    for tag, value in plant_tags.items():
        java_map.put(tag, float(value))
    result = ssd.updateAndEvaluate(java_map)
    return result.isAtSteadyState(), result

Key point: The SteadyStateDetector instance is long-lived — it accumulates history across scan cycles. Do not recreate it every cycle.

Stage 3 — Data Reconciliation

Once the SSD gate passes, bridge directly to reconciliation:

def reconcile_measurements(ssd):
    """Bridge SSD to reconciliation engine and solve."""
    engine = ssd.createReconciliationEngine()

    # Add mass balance constraints (separator: feed = gas + oil + water)
    engine.addMassBalanceConstraint(
        "3-Phase Sep",
        ["FI-1001"],                            # inlets
        ["FI-2001", "FI-3001", "FI-4001"]       # outlets
    )

    result = engine.reconcileWithGrossErrorElimination(2)

    if not result.isConverged():
        print(f"Reconciliation failed: {result.getErrorMessage()}")
        return None

    if not result.isGlobalTestPassed() or result.hasGrossErrors():
        print("Measurement quality gate failed; hold the previous model")
        return None
    return result

The createReconciliationEngine() bridge automatically:

Stage 4 — Model Update and Optimization

With reconciled (balanced) values, update the NeqSim process model:

from neqsim import jneqsim

SystemSrkEos = jneqsim.thermo.system.SystemSrkEos
ProcessSystem = jneqsim.process.processmodel.ProcessSystem
Stream = jneqsim.process.equipment.stream.Stream
Separator = jneqsim.process.equipment.separator.ThreePhaseSeparator

# One-time model build
def build_model():
    fluid = SystemSrkEos(273.15 + 80.0, 65.0)
    fluid.addComponent("methane", 0.70)
    fluid.addComponent("ethane", 0.10)
    fluid.addComponent("propane", 0.05)
    fluid.addComponent("n-decane", 0.10)
    fluid.addComponent("water", 0.05)
    fluid.setMixingRule("classic")
    fluid.setMultiPhaseCheck(True)

    feed = Stream("Feed", fluid)
    feed.setFlowRate(10000.0, "kg/hr")
    feed.setTemperature(80.0, "C")
    feed.setPressure(65.0, "bara")

    sep = Separator("HP Sep", feed)

    process = ProcessSystem()
    process.add(feed)
    process.add(sep)
    return process, feed, sep

# Per-cycle update
def update_model(process, feed, sep, rec_result):
    """Push reconciled values into the simulation and re-run."""
    rec_vars = {str(v.getName()): v for v in rec_result.getVariables()}

    # Get reconciled flows
    rec_feed  = rec_vars["FI-1001"].getReconciledValue()
    rec_temp  = rec_vars["TI-1001"].getReconciledValue()
    rec_press = rec_vars["PI-1001"].getReconciledValue()

    # Update simulation inputs
    feed.setFlowRate(float(rec_feed), "kg/hr")
    feed.setTemperature(float(rec_temp), "C")
    feed.setPressure(float(rec_press), "bara")

    # Re-run the process model
    process.run()

    # Extract model predictions for comparison
    model_gas  = sep.getGasOutStream().getFlowRate("kg/hr")
    model_oil  = sep.getOilOutStream().getFlowRate("kg/hr")
    model_water = sep.getWaterOutStream().getFlowRate("kg/hr")

    return {
        "model_gas": model_gas,
        "model_oil": model_oil,
        "model_water": model_water,
    }

Optimization (optional, Stage 4b): If model predictions diverge from reconciled plant values, use a parameter tuning step:

from scipy.optimize import minimize

def tune_model(process, feed, sep, rec_result):
    """Fit two composition fractions under mole-fraction and mass-flow constraints.

    This is a synthetic identifiability example, not proof of a unique fluid assay.
    The component order is methane, ethane, propane, n-decane, water.
    """
    rec_vars = {str(v.getName()): v for v in rec_result.getVariables()}
    original_fluid = feed.getFluid().clone()
    original_mass = feed.getFlowRate("kg/hr")
    rec_mass = rec_vars["FI-1001"].getReconciledValue()
    gas_variable = rec_vars["FI-2001"]
    oil_variable = rec_vars["FI-3001"]

    def objective(params):
        water_fraction = 0.85 - params[0] - params[1]
        if water_fraction < 0.0:
            return 1.0e20
        feed.getFluid().setMolarComposition(
            [params[0], 0.10, 0.05, params[1], water_fraction])
        # Changing molar composition changes molar mass; restore the measured mass rate.
        feed.setFlowRate(float(rec_mass), "kg/hr")
        process.run()
        gas_error = (sep.getGasOutStream().getFlowRate("kg/hr")
                     - gas_variable.getReconciledValue()) / gas_variable.getUncertainty()
        oil_error = (sep.getOilOutStream().getFlowRate("kg/hr")
                     - oil_variable.getReconciledValue()) / oil_variable.getUncertainty()
        return gas_error**2 + oil_error**2

    result = minimize(
        objective, x0=[0.70, 0.10], method="SLSQP",
        bounds=[(0.50, 0.80), (0.05, 0.20)],
        constraints=[{"type": "ineq", "fun": lambda x: 0.85 - x[0] - x[1]}],
        options={"maxiter": 30, "ftol": 1.0e-7})
    if result.success:
        objective(result.x)  # Restore the accepted optimum after numerical probes.
    else:
        feed.setThermoSystem(original_fluid)
        feed.setFlowRate(original_mass, "kg/hr")
        process.run()
    return result

Complete Python Live-Loop Example

This is the recommended end-to-end pattern for a live NeqSim model:

"""
Live NeqSim digital twin — complete four-stage pipeline.

This finite demonstration processes 35 synthetic scans with no delay.
For a service, replace the data reader and schedule one scan at a time.
"""
import time
import json
import logging
from collections import OrderedDict
from neqsim import jneqsim

# ---------- Java imports via jneqsim ----------
SteadyStateDetector = jneqsim.process.util.reconciliation.SteadyStateDetector
SteadyStateVariable = jneqsim.process.util.reconciliation.SteadyStateVariable
DataReconciliationEngine = jneqsim.process.util.reconciliation.DataReconciliationEngine
ReconciliationVariable = jneqsim.process.util.reconciliation.ReconciliationVariable
SystemSrkEos = jneqsim.thermo.system.SystemSrkEos
ProcessSystem = jneqsim.process.processmodel.ProcessSystem
Stream = jneqsim.process.equipment.stream.Stream
Separator = jneqsim.process.equipment.separator.ThreePhaseSeparator

log = logging.getLogger("live_model")
SCAN_INTERVAL = 0  # No delay for the finite demonstration; service interval is site-specific
logging.basicConfig(level=logging.INFO)

# --------- 1. TAG CONFIGURATION ---------
TAG_CONFIG = OrderedDict([
    ("FI-1001", {"desc": "Feed flow",  "unit": "kg/hr", "sigma": 200.0}),
    ("FI-2001", {"desc": "Gas out",    "unit": "kg/hr", "sigma": 100.0}),
    ("FI-3001", {"desc": "Oil out",    "unit": "kg/hr", "sigma": 150.0}),
    ("FI-4001", {"desc": "Water out",  "unit": "kg/hr", "sigma":  80.0}),
    ("TI-1001", {"desc": "Sep temp",   "unit": "C",     "sigma":   0.5}),
    ("PI-1001", {"desc": "Sep press",  "unit": "bara",  "sigma":   0.2}),
])

# --------- 2. BUILD OBJECTS (once) ---------

# SSD detector
ssd = SteadyStateDetector(30)
ssd.setRThreshold(0.5)
for tag, cfg in TAG_CONFIG.items():
    v = SteadyStateVariable(tag, 30)
    v.setUnit(cfg["unit"]).setUncertainty(cfg["sigma"])
    ssd.addVariable(v)

# Process model
fluid = SystemSrkEos(273.15 + 80.0, 65.0)
fluid.addComponent("methane", 0.70)
fluid.addComponent("ethane", 0.10)
fluid.addComponent("propane", 0.05)
fluid.addComponent("n-decane", 0.10)
fluid.addComponent("water", 0.05)
fluid.setMixingRule("classic")
fluid.setMultiPhaseCheck(True)

feed = Stream("Feed", fluid)
feed.setFlowRate(10000.0, "kg/hr")
feed.setTemperature(80.0, "C")
feed.setPressure(65.0, "bara")

sep = Separator("HP Sep", feed)

process = ProcessSystem()
process.add(feed)
process.add(sep)
process.run()  # initial steady-state solve

log.info("Live model initialized")

# --------- 3. MAIN LOOP ---------

synthetic_tags = {
    "FI-1001": feed.getFlowRate("kg/hr") * 1.005,
    "FI-2001": sep.getGasOutStream().getFlowRate("kg/hr") * 1.003,
    "FI-3001": sep.getOilOutStream().getFlowRate("kg/hr") * 0.997,
    "FI-4001": sep.getWaterOutStream().getFlowRate("kg/hr") * 1.006,
    "TI-1001": feed.getTemperature("C"),
    "PI-1001": feed.getPressure("bara"),
}

def read_plant_tags():
    """Synthetic readings derived once from the initial model, with meter offsets."""
    return dict(synthetic_tags)

last_good_result = None
successful_updates = 0

for scan in range(35):
    try:
        # Stage 1: Collect
        tags = read_plant_tags()

        # Stage 2: SSD gate
        java_map = __import__("jpype").JClass("java.util.LinkedHashMap")()
        for tag, value in tags.items():
            java_map.put(tag, float(value))
        ss_result = ssd.updateAndEvaluate(java_map)

        if not ss_result.isAtSteadyState():
            transient_names = [v.getName()
                               for v in ss_result.getTransientVariables()]
            log.info("Transient — skipping (%s)", ", ".join(str(name) for name in transient_names))
            time.sleep(SCAN_INTERVAL)
            continue

        # Stage 3: Reconcile
        engine = ssd.createReconciliationEngine()
        engine.addMassBalanceConstraint(
            "3-Phase Sep",
            ["FI-1001"],
            ["FI-2001", "FI-3001", "FI-4001"]
        )
        rec_result = engine.reconcileWithGrossErrorElimination(2)

        if not rec_result.isConverged():
            log.warning("Reconciliation failed: %s",
                        rec_result.getErrorMessage())
            time.sleep(SCAN_INTERVAL)
            continue

        if rec_result.hasGrossErrors():
            for ge in rec_result.getGrossErrors():
                log.warning("Gross error: %s |r|=%.2f",
                            ge.getName(),
                            abs(ge.getNormalizedResidual()))

        if not rec_result.isGlobalTestPassed() or rec_result.hasGrossErrors():
            log.warning("Measurement quality gate failed; holding the previous model")
            continue

        rec_vars = {str(v.getName()): v for v in rec_result.getVariables()}

        # Stage 4: Solve a copy; publish it only after conservation checks.
        candidate = process.copy()
        candidate_feed = candidate.getUnit("Feed")
        candidate_sep = candidate.getUnit("HP Sep")
        candidate_feed.setFlowRate(
            float(rec_vars["FI-1001"].getReconciledValue()),
            "kg/hr")
        candidate_feed.setTemperature(
            float(rec_vars["TI-1001"].getReconciledValue()),
            "C")
        candidate_feed.setPressure(
            float(rec_vars["PI-1001"].getReconciledValue()),
            "bara")
        candidate.run()
        candidate_out = sum(stream.getFlowRate("kg/hr") for stream in [
            candidate_sep.getGasOutStream(), candidate_sep.getOilOutStream(),
            candidate_sep.getWaterOutStream()])
        candidate_in = candidate_feed.getFlowRate("kg/hr")
        import math
        if not math.isfinite(candidate_out) or abs(candidate_in - candidate_out) > 1e-6 * max(1.0, candidate_in):
            raise RuntimeError("Candidate model mass balance failed")
        process, feed, sep = candidate, candidate_feed, candidate_sep

        # Compare model vs reconciled
        model_gas = sep.getGasOutStream().getFlowRate("kg/hr")
        rec_gas = rec_vars["FI-2001"].getReconciledValue()
        gap_pct = abs(model_gas - rec_gas) / max(abs(rec_gas), 1.0e-9) * 100

        log.info("OK  feed=%.0f  gas=%.0f (model=%.0f, gap=%.1f%%)",
                 rec_vars["FI-1001"].getReconciledValue(),
                 rec_gas, model_gas, gap_pct)

        last_good_result = rec_result
        successful_updates += 1

    except Exception as e:
        log.exception("Scan cycle error: %s", e)

    time.sleep(SCAN_INTERVAL)

assert successful_updates > 0, "The synthetic example must pass the steady-state gate"
print(f"Completed {successful_updates} validated model updates")

Design Guidelines for Live Models

1. Object lifetime

Object Lifetime Rationale
SteadyStateDetector Application lifetime Accumulates sliding window history across scans
ProcessSystem Application lifetime Expensive to build; re-run with updated inputs each cycle
DataReconciliationEngine Per-cycle (disposable) Created fresh from SSD bridge each cycle
ReconciliationResult Per-cycle Store last_good_result for fallback

2. Scan interval selection

The scan interval determines how often you push a new sample to the SSD and (if steady) reconcile + re-run the model.

Scenario Scan interval SSD window Effective detection window
Fast-changing platform 10 s 30 5 min
Typical offshore separator 30-60 s 30 15-30 min
Slow pipeline or storage 5 min 20 100 min

Rule of thumb: The SSD window should cover 3-5 process time constants to reliably detect transitions.

3. Keep the model simple

A live model should converge in under 2 seconds per cycle. Avoid:

If the model is complex, consider running the heavy simulation on a coarser schedule (every 5 min) and using a simplified proxy for the fast cycle.

4. Separate flow variables from condition variables

In the reconciliation step, only flow-rate tags participate in mass balance constraints. Temperature and pressure tags are “condition” variables — they do not enter the balance but are still useful for:

You can either reconcile them with separate energy balance constraints, or simply use their raw (or SSD-filtered mean) values as direct model inputs.

Choosing the Right Update Strategy

NeqSim provides several layers that can be combined. Choose based on your needs:

Strategy When to use NeqSim classes
SSD + Reconciliation only You trust the model structure; just need balanced inputs SteadyStateDetector + DataReconciliationEngine
SSD + Reconciliation + Model re-run Balanced inputs, then predict unmeasured outputs Above + ProcessSystem.run()
SSD + Reconciliation + Parameter tuning Model predictions diverge; tune composition, UA, etc. Above + SciPy minimize or ProcessSensitivityAnalyzer
SSD + Reconciliation + LM optimizer Formal model calibration with uncertainty Above + LevenbergMarquardtOptimizer (batch)
Direct EnKF (no SSD) Streaming updates without explicit SSD gate EnKFParameterEstimator handles both detection and update

Recommended starting point: SSD + Reconciliation + Model re-run. This gives you balanced measurements, a validated model, and predicted KPIs with minimal complexity. Add parameter tuning only when the model-vs-plant gap consistently exceeds 5-10%.

Failure Handling and Fallback

def run_cycle(ssd, process, feed, sep, tags):
    """Return a candidate model only after all data and simulation gates pass.

    Reuses check_steady_state, reconcile_measurements, and update_model above.
    The caller replaces its active process only for status == "ok".
    """
    is_steady, ss_result = check_steady_state(ssd, tags)
    if not is_steady:
        return {"status": "transient", "action": "hold_previous_model"}
    rec_result = reconcile_measurements(ssd)
    if rec_result is None or not rec_result.isGlobalTestPassed() or rec_result.hasGrossErrors():
        return {"status": "recon_failed", "action": "hold_previous_model"}

    # Never modify the active model before a successful candidate simulation.
    candidate = process.copy()
    candidate_feed = candidate.getUnit("Feed")
    candidate_sep = candidate.getUnit("HP Sep")
    try:
        outputs = update_model(candidate, candidate_feed, candidate_sep, rec_result)
        import math
        if not all(math.isfinite(value) for value in outputs.values()):
            raise ValueError("Non-finite model output")
        mass_out = sum(outputs.values())
        mass_in = candidate_feed.getFlowRate("kg/hr")
        if abs(mass_out - mass_in) > 1.0e-6 * max(1.0, abs(mass_in)):
            raise ValueError("Model mass balance did not close")
    except Exception:
        return {"status": "model_failed", "action": "hold_previous_model"}
    return {"status": "ok", "result": rec_result, "process": candidate,
            "feed": candidate_feed, "separator": candidate_sep, "outputs": outputs}

The golden rule: If any stage fails, hold the previous good model state. Never push a diverged or unconverged model to downstream consumers (dashboards, optimizers, MPC). Log the failure, alert if it persists for N consecutive cycles, and re-try next scan.


JSON and Text Reports

JSON Output

String json = result.toJson();
// Returns:
// {
//   "converged": true,
//   "objectiveValue": 0.4123,
//   "chiSquareStatistic": 0.4123,
//   "degreesOfFreedom": 1,
//   "globalTestPassed": true,
//   "computeTimeMs": 3,
//   "variables": [
//     {"name": "feed", "measured": 10000.0, "reconciled": 9985.2, ...},
//     {"name": "gas",  "measured": 3500.0,  "reconciled": 3507.1, ...},
//     ...
//   ]
// }

Text Report

String report = result.toReport();
// Returns:
// === Data Reconciliation Report ===
// Converged: true
// Objective (weighted SSQ): 0.4123
// Chi-square statistic: 0.4123 (df=1)
// Global test passed: true
// Compute time: 3 ms
//
// Variable               Measured   Reconciled   Adjustment |r_norm|     Flag
// --------               --------   ----------   ---------- --------     ----
// feed                 10000.0000   9985.2000     -14.8000    0.234       ok
// gas                   3500.0000   3507.1000       7.1000    0.156       ok
// oil                   4800.0000   4795.3000      -4.7000    0.098       ok
// water                 1900.0000   1882.8000     -17.2000    0.312       ok

Troubleshooting

Problem Cause Solution
“No variables added” Called reconcile() before adding any variables Add variables with addVariable() first
“No constraints added” Called reconcile() without constraints Add at least one constraint with addConstraint() or addMassBalanceConstraint()
“Need more variables than constraints” More constraints than variables (under-determined) Add more measurements or remove redundant constraints
All adjustments are zero Measurements already satisfy constraints exactly This is correct — no adjustment needed
One variable gets all adjustment Its uncertainty is much larger than others Review sigma values — ensure they reflect actual instrument accuracy
Global test fails Systematic bias or faulty sensor present Use reconcileWithGrossErrorElimination() to identify the culprit
IllegalArgumentException: Uncertainty must be positive Sigma ≤ 0 All uncertainties must be strictly positive
IllegalArgumentException: Constraint length does not match Coefficient array size ≠ number of variables Ensure constraint array has exactly one entry per registered variable
IllegalArgumentException: Variable not found Name in addMassBalanceConstraint doesn’t match any variable Check variable names match exactly (case-sensitive)
Matrix inversion fails Singular constraint matrix (redundant constraints) Check that constraints are linearly independent

References