Production platform process modeling patterns for NeqSim. USE WHEN: building full topside process models for oil & gas platforms (FPSO, fixed, semi-sub) from design documents, P&IDs, or operational data. Covers fluid creation with TBP fractions, multi-stage separation with recycles, recompression trains with compressor curves and anti-surge, export/injection compression, oil stabilization, scrubber liquid recovery, iteration strategies, and structured result extraction. Derived from 15+ production NCS platform models.
Production platform process modeling patterns for NeqSim. USE WHEN: building full topside process models for oil & gas platforms (FPSO, fixed, semi-sub) from design documents, P&IDs, or operational data. Covers fluid creation with TBP fractions, multi-stage separation with recycles, recompression trains with compressor curves and anti-surge, export/injection compression, oil stabilization, scrubber liquid recovery, iteration strategies, and structured result extraction. Derived from 15+ production NCS platform models.
last_verified
2026-07-04
NeqSim Production Platform Modeling
Comprehensive patterns for building complete topside process simulations from
platform design documents. Derived from production-grade models of 15+ NCS
platforms (Åsgard A/B, Troll A/B, Grane, Castberg, Martin Linge, Gudrun, etc.)
in the NeqSim-dev-environment.
1. Architecture Overview
1.1 Standard Model Structure
Every production platform model follows the same architecture:
Best for: models with few recycles where NeqSim's internal solver handles convergence.
2. Fluid Creation
2.1 Composition with TBP Fractions (Recommended)
Production fluids almost always include heavy ends characterized as TBP
(True Boiling Point) fractions. The fluid_creator() pattern accepts a
composition dictionary with optional molar mass and density for TBP fractions:
from neqsim import jneqsim
deffluid_creator(composition: dict) -> "SystemInterface":
"""Create a NeqSim fluid from a composition dictionary.
Args:
composition: dict with keys:
- "component_name": list of component names
- "molar_composition[-]": list of mole fractions
- "molar_mass[kg/mol]": list (None for defined components)
- "relative_density[-]": list (None for defined components)
"""
fluid = jneqsim.thermo.system.SystemSrkEos(273.15 + 15.0, 1.01325)
names = composition["component_name"]
molfracs = composition["molar_composition[-]"]
molar_masses = composition["molar_mass[kg/mol]"]
rel_densities = composition["relative_density[-]"]
for i, name inenumerate(names):
if molar_masses[i] isnotNoneand rel_densities[i] isnotNone:
# TBP fraction — heavy hydrocarbon pseudo-component
fluid.addTBPfraction(
name, molfracs[i], molar_masses[i], rel_densities[i]
)
else:
# Defined component (methane, ethane, CO2, water, etc.)
fluid.addComponent(name, molfracs[i])
fluid.setMixingRule("classic")
fluid.setMultiPhaseCheck(True)
fluid.useVolumeCorrection(True)
return fluid
When a platform receives fluid from multiple wells/fields, define a base
fluid template with ALL possible TBP fractions, then clone and adjust per well:
# Base template with all TBP fraction definitions
base_fluid = jneqsim.thermo.system.SystemPrEos(273.15 + 30.0, 65.0)
base_fluid.addComponent("nitrogen", 0.08)
base_fluid.addComponent("CO2", 3.38)
base_fluid.addComponent("methane", 69.83)
# ... light HCs ...
base_fluid.addTBPfraction("C6_FieldA", 0.24, 84.99/1000, 695/1000)
base_fluid.addTBPfraction("C6_FieldB", 0.0, 84.0/1000, 684/1000)
# ... more TBP fractions for each field source ...
base_fluid.setMixingRule("classic")
# Per-well fluids via clone + setMolarComposition
well_fluid_A = base_fluid.clone()
well_fluid_A.setMolarComposition([0.108, 3.379, 69.5, ...]) # FieldA composition
well_fluid_B = base_fluid.clone()
well_fluid_B.setMolarComposition([0.095, 2.100, 72.1, ...]) # FieldB composition
Key rule: All wells must share the same component list (same TBP fraction
definitions). Use setMolarComposition() to set zero fractions for components
not present in a particular well.
Prefer a shared E300 characterization for multi-reservoir mixing. When two
or more reservoirs (e.g. an oil and a gas-condensate field) feed a common
topside, build BOTH fluids from a SINGLE PVTsim/E300 characterization file and
apply different compositions via setMolarComposition(), rather than
hand-building two separate addTBPfraction templates:
e300 = jneqsim.thermo.util.readwrite.EclipseFluidReadWrite
oil = e300.read("shared_characterization.e300")
oil.setMolarComposition([...oil-heavy fractions...]) # same component order as the E300 CNAMES
oil.setMixingRule(2); oil.setMultiPhaseCheck(True)
gas = e300.read("shared_characterization.e300")
gas.setMolarComposition([...condensate-light fractions...])
gas.setMixingRule(2); gas.setMultiPhaseCheck(True)
Why this matters: well-defined pseudo-components from a real characterization
carry full ideal-gas Cp coefficients, so they keep finite enthalpy after the
clone() + re-flash that happens inside WellFlow/PipeBeggsAndBrills and
isenthalpic chokes. Hand-built addTBPfraction pseudo-components can have
degenerate Cp coefficients that lose enthalpy on re-flash and produce NaN
downstream (e.g. NaN compressor power). A shared file also makes topside mixing
consistent because every stream uses the same component basis. Set a nonzero
water mole fraction directly in the composition (H2O is a CNAMES entry) instead
of calling addWater().
3. Process Building Patterns
3.1 Pre-create Mixers Before Adding Streams
A critical pattern in production models: create StaticMixer objects
at the start, then add streams to them as equipment is built. This solves
the forward-reference problem (downstream mixer needs to exist before
upstream equipment creates its outlet stream).
operations = ProcessSystem()
# Pre-create all mixers/manifolds FIRST (no streams yet)
inlet_oil_mixer = jneqsim.process.equipment.mixer.StaticMixer("Inlet oil mixer")
recomp_gas_mixer = jneqsim.process.equipment.mixer.StaticMixer("Recomp gas mixer")
# A production/gathering/export MANIFOLD is modelled with the Manifold class,# NOT a Mixer/StaticMixer: it carries header/branch diameters and ALWAYS routes# downstream through a split stream. For a single destination give it one split# (setSplitFactors([1.0])) and route getSplitStream(0); for several destinations# set setSplitFactors([...]) and read getSplitStream(i). getMixedStream() is the# internal commingled stream (before the split) - for inspection only.
export_manifold = jneqsim.process.equipment.manifold.Manifold("Export manifold")
export_manifold.setHeaderInnerDiameter(0.9)
export_manifold.setSplitFactors([1.0])
recycle_oil_mixer = jneqsim.process.equipment.mixer.StaticMixer("Recycle from export")
# Build upstream equipment...# Then add their outlets to the pre-created mixers:
inlet_oil_mixer.addStream(oil_heater_test.getOutStream())
inlet_oil_mixer.addStream(oil_heater_prod.getOutStream())
# ... later, when you're ready to use the mixer:
operations.add(inlet_oil_mixer)
Important: Add the mixer to ProcessSystem AFTER all its inlet streams are
connected. All addStream() calls must happen before operations.add(mixer).
3.2 Multi-Stage Separation Train
Standard NCS platform separation: HP → MP → LP with oil heating/letdown between stages:
# === First Stage (HP) Separator ===
well_stream = Stream("Well Stream", well_fluid)
well_stream.setFlowRate(process_input.flow_rate, "kg/hr")
well_stream.setTemperature(process_input.inlet_temp, "C")
well_stream.setPressure(process_input.hp_pressure, "bara")
operations.add(well_stream)
hp_separator = ThreePhaseSeparator("HP Separator", well_stream)
operations.add(hp_separator)
# === Oil letdown to Second Stage ===
oil_heater_to_mp = Heater("Oil heater to MP", hp_separator.getLiquidOutStream())
oil_heater_to_mp.setOutTemperature(process_input.mp_temperature, "C")
oil_heater_to_mp.setOutPressure(process_input.mp_pressure, "bara")
operations.add(oil_heater_to_mp)
# Collect oil from multiple sources (see pre-created mixer pattern §3.1)
inlet_oil_mixer.addStream(oil_heater_to_mp.getOutStream())
# === Second Stage (MP) Separator ===
mp_separator = ThreePhaseSeparator("MP Separator", inlet_oil_mixer.getOutletStream())
operations.add(mp_separator)
# === Oil letdown to Third Stage ===
oil_to_lp = Heater("Oil to LP", mp_separator.getLiquidOutStream())
oil_to_lp.setOutTemperature(process_input.lp_temperature, "C")
oil_to_lp.setOutPressure(process_input.lp_pressure, "bara")
operations.add(oil_to_lp)
# === Third Stage (LP) Separator ===
lp_separator = Separator("LP Separator", oil_to_lp.getOutletStream())
operations.add(lp_separator)
# Oil export pump
oil_pump = Pump("Oil Export Pump", lp_separator.getLiquidOutStream())
oil_pump.setOutletPressure(process_input.oil_export_pressure + 1.01325) # barg to bara
operations.add(oil_pump)
3.2.1 Separator Physical Configuration via MechanicalDesign
Physical dimensions (vessel ID, nozzle sizes), internals (demister type,
inlet device), and design parameters (K-factor, retention time) are set via
SeparatorMechanicalDesign — NOT directly on the Separator. This follows
the same pattern used for wells, pipelines, and compressors in NeqSim.
Call initMechanicalDesign()after the process has been run() so the
design calculation has access to process conditions.
A common pattern uses a Heater with both outlet T and P set. This acts as
a T/P setter — useful for ensuring streams enter equipment at the correct
conditions (especially after mixing or before scrubbers):
tp_setter = Heater("TP Setter 3rd Stage", mixed_stream)
tp_setter.setOutTemperature(process_input.lp_temperature, "C")
tp_setter.setOutPressure(process_input.lp_pressure)
operations.add(tp_setter)
# Use a *GasScrubber class (vertical by default) for scrubbers, NOT Separator# (horizontal by default). A horizontal vessel derates the gas area by the design# liquid level (~80%), over-reading getGasLoadFactor() by ~5x for a vertical scrubber.
scrubber = GasScrubber("LP Scrubber", tp_setter.getOutletStream()) # vertical# (or, if you keep Separator/ThreePhaseSeparator: scrubber.setOrientation("vertical"))
operations.add(scrubber)
Separator class ↔ orientation:Separator / ThreePhaseSeparator default to
horizontal; GasScrubber / GasScrubberSimple / NeqGasScrubber (2-phase)
and ThreePhaseGasScrubber (3-phase) default to vertical. Pick the class to
match the datasheet (VA-tag horizontal, VG-tag vertical) or override with
setOrientation(...). Wrong orientation only affects gas-capacity / getGasLoadFactor(),
not the flash split, but it silently skews utilisation studies.
4. Recycle Patterns
DEFAULT (do this automatically for every recompression setup): whenever you
build a recompression and/or export-compression train, you MUST close the
liquid recycle from every suction/export/injection scrubber back to a
separator — never leave a scrubber.getLiquidOutStream() unconnected. A
dropped scrubber-liquid stream silently discards knocked-out condensate, which
under-counts oil/condensate recovery and mis-states the compressor gas load.
Apply this without being asked; only deviate if the user explicitly says the
scrubber liquid is routed elsewhere (e.g. to a dedicated condensate handling
system).
Matching-pressure routing rule (default): route each scrubber's liquid back
to the separator whose operating pressure matches that scrubber's suction
pressure, via a TP-setter Heater + Recycle tear. This mirrors real plants,
where each knock-out drum drains to the stage operating at the same pressure:
Scrubber
Suction pressure
Return destination
Export / injection scrubber
HP / stage-1 header
Stage-1 (HP) separator inlet
MP recompression scrubber
MP / stage-2 header
Stage-2 (MP) separator inlet
LP recompression scrubber
LP / stage-3 header
Stage-3 (LP) separator inlet
Returning every scrubber to the MP separator (as some legacy models do) is only
acceptable when all suction scrubbers share that pressure; otherwise use the
matching-pressure mapping above so the recycled liquid re-flashes at the correct
conditions.
4.1 Scrubber Liquid Recycle (Oil Recovery)
Liquid knocked out in recompression scrubbers is recycled back to the
separation train at the matching pressure. The standard pattern creates clone
seed streams:
# Pre-create seed streams (cloned from the separator liquid for same composition)
recycle_seed_1 = mp_separator.getLiquidOutStream().clone()
recycle_seed_1.setName("Recycle from 1st stage scrubber")
recycle_seed_1.setFlowRate(1, "kg/hr") # Small initial flow for convergence
recycle_seed_1.setPressure(process_input.lp_pressure)
recycle_seed_1.setTemperature(process_input.lp_temperature, "C")
recycle_seed_2 = mp_separator.getLiquidOutStream().clone()
recycle_seed_2.setName("Recycle from 2nd stage scrubber")
recycle_seed_2.setFlowRate(1, "kg/hr")
recycle_seed_2.setPressure(process_input.lp_pressure)
recycle_seed_2.setTemperature(process_input.lp_temperature, "C")
# Add seeds to operations AND to the mixer that feeds the LP separator
operations.add(recycle_seed_1)
operations.add(recycle_seed_2)
rec_oil_mixer = StaticMixer("Recycle oil mixer")
rec_oil_mixer.addStream(oil_to_lp.getOutletStream()) # Main oil flow
rec_oil_mixer.addStream(recycle_seed_1)
rec_oil_mixer.addStream(recycle_seed_2)
operations.add(rec_oil_mixer)
# ... later, after building the recompression scrubbers:# Wire actual scrubber liquid to a Heater (TP setter), then to Recycle object
tp_set_scrub_liq_1 = Heater("TP set scrub liq 1", first_scrubber.getLiquidOutStream())
tp_set_scrub_liq_1.setOutTemperature(process_input.lp_temperature, "C")
tp_set_scrub_liq_1.setOutPressure(process_input.lp_pressure)
operations.add(tp_set_scrub_liq_1)
recycle_oil_1 = Recycle("Recycle oil 1")
recycle_oil_1.addStream(tp_set_scrub_liq_1.getOutletStream())
recycle_oil_1.setOutletStream(recycle_seed_1)
operations.add(recycle_oil_1)
Critical details:
Seed streams need small but non-zero flow (1 kg/hr) for numerical stability
Seed T, P must match the mixer/separator they feed into
Clone the composition from the appropriate location in the process
The Heater before the Recycle object acts as a T/P equalizer
Reusable helper (recommended): wrap the tear logic so every scrubber is
closed the same way. Pre-create one seed per separator stage, mix each seed into
that stage's separator inlet, then call the helper for each scrubber:
By the matching-pressure routing rule above, export/injection scrubber liquids
are returned to the stage-1 (HP) separator (their suction sits at the HP
header). The shared-mixer-to-MP variant below is a legacy simplification — use
it only when the export/injection scrubbers genuinely operate at the MP
pressure. For the default behavior, point the TP-setter and the seed stream at
the HP/stage-1 separator conditions instead of MP:
# Pre-create mixer for all high-pressure scrubber liquids
mixer_recycle_from_export = StaticMixer("Recycle from export line")
# ... later, add scrubber liquid streams from export/injection/booster:if has_booster:
mixer_recycle_from_export.addStream(booster_scrubber.getLiquidOutStream())
if has_export:
mixer_recycle_from_export.addStream(export_scrubber.getLiquidOutStream())
if has_injection:
mixer_recycle_from_export.addStream(inj_scrubber_1.getLiquidOutStream())
mixer_recycle_from_export.addStream(inj_scrubber_2.getLiquidOutStream())
operations.add(mixer_recycle_from_export)
# Recycle back to MP separator via TP setter
tp_set_export_rec = Heater("TP set export rec", mixer_recycle_from_export.getOutletStream())
tp_set_export_rec.setOutTemperature(process_input.mp_temperature, "C")
tp_set_export_rec.setOutPressure(process_input.mp_pressure)
operations.add(tp_set_export_rec)
# Seed stream was added to inlet_oil_mixer earlier
recycle_from_export = Recycle("Recycle from export")
recycle_from_export.addStream(tp_set_export_rec.getOutletStream())
recycle_from_export.setOutletStream(export_recycle_seed) # Pre-created seed
operations.add(recycle_from_export)
4.3 Recycle Topology Summary
Typical NCS platform has 4-6 recycle loops:
Recycle
Source
Destination
Purpose
LP scrubber liquid
LP recompression scrubber liquid
LP (stage-3) separator inlet
Oil recovery (matching pressure)
MP scrubber liquid
MP recompression scrubber liquid
MP (stage-2) separator inlet
Oil recovery (matching pressure)
Export/injection recycle
Export/injection/booster scrubber liquid
HP (stage-1) separator inlet
Oil recovery (matching pressure)
Anti-surge R1
R1 compressor outlet
R1 compressor suction
Surge protection
Anti-surge R2
R2 compressor outlet
R2 compressor suction
Surge protection
Anti-surge R3
R3 compressor outlet
R3 compressor suction
Surge protection
Anti-surge export
Export compressor outlet
Export compressor suction
Surge protection
5. Recompression Train Pattern
5.1 Standard Stage (Cooler → Scrubber → Compressor → Anti-surge)
Each recompression stage follows this repeating pattern:
surge_flow = [2770.39, 3199.03, 4395.44] # m3/hr at each speed
surge_head = [97.63, 135.65, 235.06] # kJ/kg at surge
comp_curves.getCompressorChart().getSurgeCurve().setCurve(
chart_conditions, surge_flow, surge_head
)
comp_curves.getAntiSurge().setActive(True)
comp_curves.getAntiSurge().setSurgeControlFactor(1.05) # 5% safety margin
Optional: Multiple named charts per compressor (vendor vs as-tested vs
field-fitted). A Compressor can carry several charts in a
CompressorChartLibrary and switch the active one with a single call — useful
for revamp/what-if studies and digital twins that keep both the datasheet curve
and a historian-fitted curve on the same machine:
Use standardized helper functions to extract equipment results into typed objects:
defget_separator_response(separator) -> dict:
"""Extract separator state into structured dict."""
result = {
"name": str(separator.getName()),
"pressure_bara": float(separator.getPressure("bara")),
"temperature_C": float(separator.getTemperature("C")),
"mass_flow_kghr": float(separator.getFluid().getFlowRate("kg/hr")),
"gas_load_factor": float(separator.getGasLoadFactor()),
}
if separator.getThermoSystem().hasPhaseType("gas"):
result["gas_flow_kghr"] = float(separator.getGasOutStream().getFlowRate("kg/hr"))
if separator.getThermoSystem().hasPhaseType("oil"):
result["oil_flow_kghr"] = float(
separator.getThermoSystem().phaseToSystem("oil").getFlowRate("kg/hr")
)
return result
defget_compressor_response(compressor, asv_valve=None, curves=None) -> dict:
"""Extract compressor state with anti-surge and curve data."""
result = {
"name": str(compressor.getName()),
"suction_P_bara": float(compressor.getInletStream().getPressure("bara")),
"discharge_P_bara": float(compressor.getOutletStream().getPressure("bara")),
"suction_T_C": float(compressor.getInletStream().getTemperature("C")),
"discharge_T_C": float(compressor.getOutletStream().getTemperature("C")),
"power_kW": float(compressor.getPower("kW")),
"polytropic_head": float(compressor.getPolytropicFluidHead()),
"polytropic_efficiency": float(compressor.getPolytropicEfficiency()),
"mass_flow_kghr": float(compressor.getInletStream().getFlowRate("kg/hr")),
"suction_vol_flow_m3hr": float(compressor.getInletStream().getFlowRate("m3/hr")),
}
if asv_valve:
result["asv_flow_kghr"] = float(asv_valve.getOutletStream().getFlowRate("kg/hr"))
result["net_flow_kghr"] = result["mass_flow_kghr"] - result["asv_flow_kghr"]
if curves:
result["curve_head"] = float(curves.getPolytropicHead())
result["curve_efficiency"] = float(curves.getPolytropicEfficiency())
result["speed"] = float(curves.getSpeed())
return result
8.2 Key Output Extraction
# Oil export
oil_rate_m3day = operations.getUnit("LP Separator").getLiquidOutStream().getFlowRate("m3/hr") * 24
oil_tvp = operations.getUnit("LP Separator").getLiquidOutStream().TVP(20.0, "C")
oil_density = operations.getUnit("LP Separator").getLiquidOutStream().getFluid().getDensity("kg/m3")
# Gas export
gas_rate_MSm3day = operations.getUnit("Export aftercooler").getOutletStream().getFlowRate("MSm3/day")
# Total power
total_power_kW = sum(
operations.getUnit(name).getPower("kW")
for name in ["R1 Compressor", "R2 Compressor", "R3 Compressor", "Export compressor"]
)
# Total cooling duty
total_cooling_kW = sum(
operations.getUnit(name).getDuty() / 1000for name in ["R1 Cooler", "R2 Cooler", "R3 Cooler", "Export cooler"]
)
8.3 Mass-Balance Acceptance Gate (MANDATORY)
Never accept or report a platform-model solution until the overall mass balance
closes. Sum the kg/hr of every feed entering the model and every product/export
stream leaving it; the closure error must be below 0.1 %. A larger imbalance almost
always means a stream was silently dropped (an unconnected scrubber liquid out-stream
is the most common cause — see Section 4), a Recycle tear did not converge, or a
Splitter fraction is wrong.
defcheck_mass_balance(feeds, products, tol=1.0e-3):
"""Verify overall mass balance before accepting the solution.
feeds, products: lists of StreamInterface (all model inlets / all model outlets).
Returns (ok, closure_error_fraction). Raises if the model is unbalanced.
"""
m_in = sum(float(s.getFlowRate("kg/hr")) for s in feeds)
m_out = sum(float(s.getFlowRate("kg/hr")) for s in products)
closure = abs(m_in - m_out) / m_in if m_in > 0elsefloat("inf")
if closure > tol:
raise AssertionError(
"Mass balance not closed: in=%.3f out=%.3f kg/hr (%.3f%% error). ""Check for dropped scrubber liquid, non-converged recycle, or bad split."
% (m_in, m_out, 100.0 * closure)
)
returnTrue, closure
# feeds = [well-stream(s) / reservoir feed(s)]# products = [oil export, gas export, gas injection, produced water, fuel/flare, ...]
check_mass_balance(feeds, products)
For a multi-area ProcessModel, also confirm convergence before the balance check:
assert plant.run() or plant.solved(), "ProcessModel did not converge"
9. ProcessInput Configuration Pattern
9.1 Pydantic Model for All Operating Conditions
from pydantic import BaseModel, Field
classProcessInput(BaseModel):
"""All operating conditions for a platform process simulation."""# Feed conditions
flow_rate_prod: float = Field(description="Production flow rate [kg/hr]")
flow_rate_test: float = Field(0.0, description="Test separator flow [kg/hr]")
# First stage separation
pressure_prod_separator: float = Field(description="HP separator pressure [bara]")
temperature_prod_separator: float = Field(description="HP separator temp [C]")
# Second stage separation
second_stage_pressure: float = Field(description="MP separator pressure [bara]")
second_stage_temperature: float = Field(description="MP separator temp [C]")
# Third stage separation
third_stage_pressure: float = Field(description="LP separator pressure [bara]")
third_stage_temperature: float = Field(description="LP separator temp [C]")
# Recompression (R1)
first_stage_recompressor_out_pressure: float = Field(description="R1 outlet P [bara]")
first_stage_recompressor_out_temperature: float = Field(description="R1 outlet T [C]")
first_stage_recompressor_scrubber_pressure: float = Field(description="R1 scrubber P [bara]")
first_stage_recompressor_scrubber_temperature: float = Field(description="R1 scrubber T [C]")
first_stage_recompressor_cooler_pressure_drop: float = Field(description="R1 cooler dP [bar]")
first_stage_recompressor_speed: float = Field(description="R1 compressor speed [rpm]")
# Anti-surge valves
antisurge_valve_opening_r1: float = Field(0.0, description="R1 ASV opening [%]")
Cv_value_antisurge_valve_r1: float = Field(description="R1 ASV Cv value")
# ... repeat for R2, R3, booster, export, injection stages# Export
export_compressor_outlet_pressure: float = Field(description="Export comp P [bara]")
export_compressor_outlet_temperature: float = Field(description="Export comp T [C]")
export_speed: float = Field(description="Export comp speed [rpm]")
# Oil export
export_oil_pressure: float = Field(description="Oil export P [barg]")
# Production split
split_export: float = Field(1.0, description="Fraction of gas to export [0-1]")
split_injection: float = Field(0.0, description="Fraction of gas to injection [0-1]")
10. Common Constants
MIN_VALVE_OPENING = 10# % — below this, valve is treated as closed
MIN_BOOSTER_SPEED = 1000# rpm — below this, booster is bypassed
MIN_SPLIT_PRODUCTION = 0.05# fraction — below this, export/injection path skipped
NUMBER_OF_ITERATIONS = 25# run_step iterations for convergence
SYNC_REQUEST_TIMEOUT_MS = 120_000# ms timeout for blocking run
11. Checklist: Building a Platform Model
When building a new platform model from design documents:
Identify separation stages: HP, MP, LP pressures and temperatures
Three-phase separators for HP/MP (water), two-phase Separator for LP scrubbers
Oil TV P measurement: stream.TVP(20.0, "C") for true vapor pressure at 20°C
Run iterations: 25 run_step() calls or single threaded runAsThread() with timeout
Extract results: structured response helpers for every equipment type
Validate mass balance FIRST (acceptance gate): sum feed kg/hr vs all
product/export kg/hr; closure error must be < 0.1 % before accepting the
solution (see Section 8.3). An imbalance means a dropped stream, non-converged
recycle, or bad split — fix and re-run.
Validate: energy balance, hydrate temperatures above dewpoint
12. Notebook Template
For a Jupyter notebook implementation, see the complete starter in the
neqsim-notebook-patterns skill. The platform model follows the same
dual-boot setup cell pattern. Key additional imports: