Low-Flow Section Bypass
NeqSim can automatically (or manually) bypass parts of a flowsheet that are
receiving negligible flow, so that turning off a parallel train, a recycle,
or a seasonal export route does not destabilise the rest of a
ProcessSystem or multi-area ProcessModel.
This is essential for full-platform models such as
task_solve/.../process_model.ipynb where a duty
compressor train (for example ht_injection_compressors) is sometimes
inactive while export and recompression continue at full rate.
Why low-flow bypass exists
Equipment routines (compressors, heaters, separators, columns) make
implicit assumptions that the inlet stream carries enough fluid for the
property model to behave numerically — for example dividing by mass flow,
solving an isentropic head curve, or computing a UA-based duty. When a
feed of 1e-20 kg/hr is presented:
- A compressor curve evaluated at zero flow produces NaN head.
- A heater UA solver divides by zero in
Q = UA · LMTD. - A separator may settle on a degenerate single-phase solution that poisons downstream recycles.
Rather than introducing per-equipment guards everywhere, the framework
exposes one mechanism — a minimum-flow threshold per unit — and a small
amount of glue (ProcessSystem.deactivateSection,
ProcessModel.deactivateSection, sticky isActive state) that lets a
whole section be auto-bypassed cleanly.
The three building blocks
1. unit.setMinimumFlow(kgPerHour) and unit.isActive()
Every ProcessEquipmentBaseClass carries a minimumFlow field
(default 1e-20 kg/hr) and a transient isActive flag.
Equipment classes that have wired the bypass call
checkAndHandleLowFlow(inlet, id) (or an inline equivalent) at the top of
their run():
- If
inlet.getFlowRate("kg/hr") < getMinimumFlow()thenisActive(false), the outlet is left at zero, andrun()returns immediately. - Otherwise
isActive(true)and normal execution proceeds.
Currently wired with inline auto-bypass:
| Equipment | Behaviour when bypassed |
|---|---|
Splitter |
All split outlets forced to 0 kg/hr. |
Separator |
Both gas and liquid outlets forced to 0 kg/hr. |
Heater (cooler / electric heater) |
Outlet inherits inlet at the set pressure with Q = 0. |
Compressor |
Outlet inherits inlet at the set pressure with power = 0. |
Mixer |
Handles zero-flow inlets natively (no explicit guard needed). |
2. ProcessSystem.setSectionLowFlowThreshold(threshold)
Convenience: sets the same minimumFlow on every unit in a process area.
Typical usage on a process area that may or may not be on:
ProcessSystem htTrain = buildCompressorTrain("ht_injection_compressors", htFeed, 250.0);
htTrain.setSectionLowFlowThreshold(1.0); // bypass entire train when feed < 1 kg/hr
The full plant can also be set in one call via
ProcessModel.setSectionLowFlowThreshold(threshold).
3. Manual lock: setLockedInactive(true) / deactivateSection(...)
For “this train is definitively shut in”, a sticky lock survives every
run() call regardless of the actual feed flow:
plant.deactivateSection("ht_injection_compressors", "ht_K1");
// later
plant.activateSection("ht_injection_compressors", "ht_K1");
// or unlock everything
plant.activateAll();
deactivateSection walks downstream following two paths:
- Explicit
ProcessConnection.MATERIALedges added viaprocess.connect(...). - Stream-wiring reachability (object-reference equality between an
upstream unit’s outlet
StreamInterfaceand a downstream unit’s inletStreamInterface).
Both walks stop at a Mixer whose other inlet is still served by an
active source, so the active part of the flowsheet keeps running.
Sticky-inactive vs transient-inactive
The framework distinguishes two states deliberately:
| State | Set by | Cleared by | Survives ProcessSystem.run() ? |
|---|---|---|---|
Transient isActive=false |
checkAndHandleLowFlow inside unit.run() |
The next unit.run() call with sufficient flow |
Yes — resetActiveStates only touches locked units |
lockedInactive=true |
setLockedInactive(true) / deactivateSection |
setLockedInactive(false) / activateSection / activateAll |
Yes — resetActiveStates forces isActive=false |
ProcessSystem.resetActiveStates() is invoked at the start of every
run() overload (runSequential, runOptimized, runParallel,
runHybrid, runDataflow, and the public run(UUID)). It deliberately
does not blanket-reset isActive=true on unlocked units — doing so
would clobber the transient bypass set by an earlier run() overload in
the same solve pass while the recalculation cache skips the unit (so its
own run() never fires to re-evaluate the inlet flow). The current
contract: unlocked units keep whatever isActive they had until their
run() is invoked, at which point the unit itself decides based on its
inlet.
If you want the next solve to reconsider a transiently-bypassed unit,
just increase the feed and call process.run() again — the scheduler
will invoke unit.run() because the recalculation cache invalidates on
upstream changes.
How the scheduler skips inactive units
ProcessSystem.runUnitProfiled is the single chokepoint:
if (unit.isLockedInactive() || !unit.isActive()) {
unit.setCalculationIdentifier(id);
return;
}
unit.run(id);
So both the manual lock and a transient low-flow bypass produce the same
effect — the unit appears solved (calculationIdentifier advances) but
no thermodynamic work happens.
Dynamic mode (runTransient)
The dynamic stepping loops in
ProcessSystem.runTransient(dt, id) honor the same lockedInactive /
isActive skip gate via the private helper
runUnitTransientSkippingInactive. This applies to:
- The sequential Euler integration loop.
- The semi-implicit corrector second pass.
- The parallel transient executor (
runEquipmentTransientParallel).
A unit that is auto-bypassed or manually locked keeps its current state
during every timestep — it is not re-integrated, so compressor curves
are never evaluated at zero flow and heaters never hit Q = UA · LMTD
divisions by zero during dynamic simulation.
Pattern: warm-start before runTransient
checkAndHandleLowFlow (the auto-bypass evaluator) only fires inside the
steady run() of each equipment subclass. Dynamic simulations therefore
follow the standard NeqSim “warm start” pattern:
htTrain.setSectionLowFlowThreshold(1.0);
process.run(); // 1) steady warmup — marks low-flow units inactive
for (int step = 0; step < nSteps; step++) {
process.runTransient(dt); // 2) inactive units are skipped each step
}
To bypass a section purely for dynamic mode without a steady warmup, set the flag directly:
htTrain.getUnit("ht_K1").setLockedInactive(true);
process.runTransient(dt); // K1 is skipped from the first step onwards
To re-enable units mid-simulation (for example after a step change
restores the feed flow), call unit.setLockedInactive(false) or
process.activateAll() and the next runTransient step will integrate
them again.
ProcessModel convergence
ProcessModel.calculateConvergenceErrors skips any boundary stream whose
magnitude is below 1e-9 kg/hr before computing relative error, so a
bypassed section does not prevent the active areas from converging.
Feed-flow configuration patterns
When you intentionally turn off a downstream section, you also have to decide what the feed to that section should be. The patterns below mirror real platform-scale process models.
Pattern A — setFlowRates with negative remainder
A Splitter with setFlowRates([...], unit) accepts -1.0 on exactly
one outlet to mean “absorb the remainder”:
manifold.setFlowRates(new double[] {-1.0, smallFlow}, "MSm3/day");
htTrain.setSectionLowFlowThreshold(1.0);
Effect: export gets (feed - smallFlow), the HT train gets smallFlow.
Set smallFlow = 0.0 (or any value below the train threshold) to bypass
the train without changing piping.
Pattern B — Fixed split factors
manifold.setSplitFactors(new double[] {1.0, 0.0});
The HT train sees exactly zero feed. Combine with
htTrain.setSectionLowFlowThreshold(1.0) so the train auto-bypasses
cleanly.
Pattern C — Per-equipment minimum flow
htK1.setMinimumFlow(1.0);
htIC.setMinimumFlow(1.0);
htK2.setMinimumFlow(1.0);
Useful when only one or two units in a section have numerical issues at low flow. The rest of the train still runs.
Pattern D — Per-area threshold
htTrain.setSectionLowFlowThreshold(1.0);
Equivalent to applying Pattern C to every unit in htTrain.
Pattern E — Manual deactivation (no feed change required)
plant.deactivateSection("ht_injection_compressors", "ht_K1");
plant.run();
// ... later ...
plant.activateSection("ht_injection_compressors", "ht_K1");
plant.run();
The lock is sticky; subsequent plant.run() calls keep the section
bypassed regardless of upstream changes. Use this for “shut-in for
maintenance” scenarios.
Drop-in snippet for parallel HT injection trains
A typical platform model wires the manifold using Pattern A:
tex_gas_splitter.setFlowRates([-1.0, inp.injection_gas_rate_ht + 0.000001], "MSm3/day")
manifold_upstream_ht_injection_compressors.getUnit("manifold").setSplitFactors(
[input_parameters.injection_gas_rate_ht_split_to_train_A, -1]
)
With injection_gas_rate_ht = 0.001 MSm3/day and the A/B split at
0.9999 / 0.0001, train B receives ~1e-7 MSm3/day — numerically zero and
guaranteed to upset compressor curves and intercoolers if left
un-bypassed. Add two lines immediately after the trains are built and
before they are added to the plant ProcessModel:
# --- Auto-bypass the HT injection trains when their feed is effectively zero ---
# Threshold = 1 kg/hr (any train receiving less than this is skipped this run).
ht_injection_process_A.setSectionLowFlowThreshold(1.0)
ht_injection_process_B.setSectionLowFlowThreshold(1.0)
That single change makes the whole plant model robust across the entire
operating range of injection_gas_rate_ht (0 → design capacity) without
touching downstream wiring or recycles. Downstream consumers of
ht_injection_process_A/B (e.g. exportgasprocess(...)) see the
compressor outlet at the inlet state with zero flow, which mixers
already handle natively.
Where to put each configuration pattern in your platform notebook
| Goal | Pattern | Where |
|---|---|---|
| Set the fraction of feed routed to HT injection | A (setFlowRates([-1.0, x], "MSm3/day")) |
In the upstream-process builder, on the export/injection splitter |
| Set the A/B split inside the HT manifold | B (setSplitFactors) |
On the manifold splitter inside the HT manifold area |
| Auto-bypass an entire HT train when its feed is below threshold | D (setSectionLowFlowThreshold) |
Immediately after each HT train ProcessSystem is built |
| Manually shut in a train regardless of feed | E (plant.deactivateSection) |
After all areas are added to the ProcessModel, before plant.run() |
Example for full manual lock of train B:
plant.deactivateSection("HT injection process B", "ht 1st stage compressor")
plant.run()
# Later, to re-enable:
plant.activateAll()
plant.run()
A unit test mirroring this exact dual-train + manifold structure lives in
ProcessModelLowFlowBypassParallelTrainsTest.java
under dualHtTrainsMirrorsPlatformProcessModelStructure().
End-to-end example (parallel compressor trains)
SystemInterface feedFluid = new SystemSrkEos(298.15, 30.0);
feedFluid.addComponent("methane", 0.88);
feedFluid.addComponent("ethane", 0.08);
feedFluid.addComponent("propane", 0.04);
feedFluid.setMixingRule("classic");
Stream feed = new Stream("feed", feedFluid);
feed.setFlowRate(200_000.0, "kg/hr");
feed.setTemperature(298.15, "K");
feed.setPressure(30.0, "bara");
feed.run();
ProcessSystem manifoldArea = new ProcessSystem("manifold");
Splitter manifold = new Splitter("manifold", feed);
manifold.setSplitFactors(new double[] {1.0 - 1e-6, 1e-6}); // ~0% to HT train
manifoldArea.add(manifold);
ProcessSystem exportTrain = buildCompressorTrain("export", manifold.getSplitStream(0), 90.0);
ProcessSystem htTrain = buildCompressorTrain("ht_injection_compressors",
manifold.getSplitStream(1), 250.0);
htTrain.setSectionLowFlowThreshold(1.0); // auto-bypass when feed < 1 kg/hr
ProcessModel plant = new ProcessModel();
plant.add("manifold", manifoldArea);
plant.add("export", exportTrain);
plant.add("ht_injection_compressors", htTrain);
plant.run();
// Verify: HT train is bypassed, export train ran normally.
assert !htTrain.getUnit("ht_injection_compressors_K1").isActive();
assert exportTrain.getUnit("export_K2").isActive();
The runnable counterpart of this snippet lives in ProcessModelLowFlowBypassParallelTrainsTest.java and the single-area suite in ProcessSystemLowFlowBypassTest.java.
Limitations and gotchas
- The bypass uses the primary inlet of each equipment. For multi-inlet units (mixers, multi-stream heat exchangers) the convention is “sum of active inlets”; in practice this is handled by upstream units already zeroing their outlets.
- The auto-bypass is currently wired for
Splitter,Separator,Heater,Compressor. Pumps, valves, columns, and reactors will run normally at very low flow — for those, prefer manualdeactivateSection. setMinimumFlowdoes not changeisActiveimmediately; the nextunit.run(id)re-evaluates and may bypass.- After
activateSection, the nextrun()will not automatically re-converge the previously-bypassed section if the recalculation cache thinks nothing changed. If unsure, mutate an upstream stream (e.g.feed.setFlowRate(...)) or callprocess.clearCache()if available. ProcessModel.deactivateSection(unitName)(no area name) deactivates in the first area that contains the name; use the two-argument overloaddeactivateSection(areaName, unitName)for deterministic behaviour when names overlap.
Related documentation
- ProcessModel guide — multi-area flowsheets.
- ProcessSystem guide — single-area flowsheets.
- Controllers — for adjusting feed splits at runtime.
- Process automation API — string-addressable
access for setting
minimumFlowprogrammatically.
Interaction with recycles
deactivateSection walks downstream from the named unit and stops at any
Mixer or Recycle node, because both represent a point where another
active train may inject material that should keep the rest of the flowsheet
alive. Without this guard, locking out one parallel train would also lock
out the shared header it feeds into.
Worked example
A splitter sends 90% of the feed to a duty train and 10% into a recycle loop that returns to a feed mixer:
freshFeed ───┐
├─► mix ─► sep ─► split ─┬─► duty (90%) ─►
recycleSeed ─┘ └─► recycleHeater (10%) ─► recycle ─► (back to mix via recycleSeed)
Deactivating recycleHeater should bypass only the recycle leg; the duty
train and the shared mixer must keep running on freshFeed:
ps.deactivateSection("recycleHeater");
ps.run();
assertFalse(freshFeed.isLockedInactive()); // upstream feed untouched
assertFalse(mix.isLockedInactive()); // shared mixer kept alive
assertTrue(recycleHeater.isLockedInactive());
assertTrue(ps.getBypassedUnits().contains("recycleHeater"));
getBypassedUnits() returns the names of units that are currently bypassed
(either lockedInactive or !isActive()), which is the recommended way
to log or assert what the engine actually skipped on a given run. On a
ProcessModel, the same method returns area::unit qualified names.
This behaviour is covered by ProcessSystemBypassRecycleTest.java.
Per-unit and fractional thresholds
In addition to the section-wide setSectionLowFlowThreshold(kgPerHour),
two finer-grained helpers are available:
setSectionLowFlowThreshold(unitName, threshold)— set the threshold on a single named unit. ThrowsIllegalArgumentExceptionif the unit does not exist, which protects against silent typos in long flowsheets.setSectionLowFlowThresholdFraction(fraction)— set every unit’s threshold tofraction × inletMassFlowKgPerHourbased on its current first inlet. Useful when feed rate spans several decades and a single absolute value is not appropriate. Returns the number of units that were updated.
Both helpers are also exposed on ProcessModel, with the per-unit variant
returning true if any area matched the unit name.