- name
- neqsim-platform-modeling
- description
- Production platform topside modeling patterns for NeqSim (FPSO, fixed, semi-sub). USE WHEN: building full topside models from design documents, P&IDs or operating data - TBP-fraction fluids, multi-stage separation with recycles, recompression with compressor curves and anti-surge, export/injection compression, oil stabilization, scrubber liquid recovery, iteration strategies and result extraction. Derived from 15+ 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:
```
ProcessInput (pydantic) → simulate() function → ProcessSystem → calc_result() → ProcessOutput (pydantic)
```
| Component | Purpose | Pattern |
|-----------|---------|---------|
| `ProcessInput` | All operating conditions as typed fields | Pydantic BaseModel with 50-100+ fields |
| `simulate()` | Builds and runs the ProcessSystem | Single function, 500-1500 lines |
| `calc_result()` | Extracts structured results from solved system | Calls `simulate()`, builds ProcessOutput |
| `ProcessOutput` | All results in typed, serializable format | Pydantic BaseModel with response objects |
| `neqsim_responses.py` | Helper functions to extract equipment data | `get_separator_response()`, `get_compressor_response()`, etc. |
| `pydantic_classes.py` | Shared response types | `CompressorResponse`, `SeparatorResponse`, `HeaterResponse`, etc. |
### 1.2 Two Execution Strategies
**Strategy A — Manual Iteration (ASGA pattern):**
```python
NUMBER_OF_ITERATIONS = 25
operations = ProcessSystem()
# ... build entire flowsheet ...
for _ in range(NUMBER_OF_ITERATIONS):
operations.run_step()
```
Best for: models with many recycles that need controlled convergence.
**Strategy B — Single Blocking Run (Martin Linge pattern):**
```python
operations = ProcessSystem()
# ... build entire flowsheet ...
thread = operations.runAsThread()
thread.join(config.SYNC_REQUEST_TIMEOUT_MS) # e.g., 120000 ms
if thread.isAlive():
thread.interrupt()
raise CalculationTimeout("Timed out")
```
Best for: models with few recycles where NeqSim's internal solver handles convergence.
**Strategy C — `ProcessModel.runUntilConverged` with flow filters (recommended
for multi-area plants with stagnant legs):**
```python
plant = ProcessModel()
plant.add("sep train A", sep_a)
# ... add all areas ...
# Stagnant sections (e.g. an HT injection train at zero rate) are not solved at
# all. Manifold, ThrottlingValve, PipeBeggsAndBrills and MultiStreamHeatExchanger
# all honour this and still publish their outlet pressure at zero flow, so a
# downstream mixer keeps a valid pressure boundary.
if inp.injection_gas_rate_ht <= 0.0:
for area in ("HT injection process A", "HT injection process B"):
plant.get(area).setSectionLowFlowThreshold(50.0, "kg/hr")
# The plant gate is a MAX over RELATIVE boundary-stream errors, which a dead leg
# dominates (0.007 kg/hr on 0.1 kg/hr = 6.6e-02, vs a real 443 kg/hr on
# 138 t/hr = 3.2e-03). Filter it at the source:
plant.setBoundaryFlowFloor(1.0) # drop sub-1 kg/hr streams
converged = plant.runUntilConverged(15, 1e-3, 1.0) # rel 1e-3 OR abs 1 kg/hr
print(plant.getConvergenceSummary()) # prints absolute Δflow too
for e in plant.getNonConvergedBoundaryStreamErrors():
print(e.getStreamName(), e.getFlowError(), e.getAbsoluteFlowChange())
```
Best for: full-platform `ProcessModel`s where some trains are seasonally or
scenario-wise inactive.
> **Gotcha:** `setSectionLowFlowThreshold()` deactivates units for the rest of
> the solve pass. Never apply it to a section that is only dry on the first
> recycle iteration (e.g. a JT valve on a separator liquid outlet).
---
## 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:
```python
from neqsim import jneqsim
def fluid_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 in enumerate(names):
if molar_masses[i] is not None and rel_densities[i] is not None:
# 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
```
### 2.2 Typical NCS Gas Condensate Composition
```python
composition = {
"component_name": [
"water", "nitrogen", "CO2", "methane", "ethane",
"propane", "i-butane", "n-butane", "i-pentane", "n-pentane",
"2-methylpentane", # proxy for nC6 fraction
# TBP fractions for C10+ (named by carbon range)
"nC10-nC12", "nC13-nC15", "nC16-nC18", "nC19-nC22",
"nC23-nC26", "nC27-nC30", "nC31-nC34", "nC35-nC38", "nC39+"
],
"molar_composition[-]": [
0.058, 0.005, 0.037, 0.664, 0.086,
0.050, 0.008, 0.016, 0.005, 0.006,
0.006,
0.010, 0.009, 0.006, 0.011,
0.005, 0.004, 0.004, 0.003, 0.002
],
"molar_mass[kg/mol]": [
None, None, None, None, None,
None, None, None, None, None,
None,
0.091, 0.103, 0.117, 0.146,
0.181, 0.212, 0.248, 0.289, 0.330
],
"relative_density[-]": [
None, None, None, None, None,
None, None, None, None, None,
None,
0.741, 0.769, 0.789, 0.804,
0.825, 0.838, 0.849, 0.863, 0.875
],
}
```
### 2.3 Multi-Well Composition (Martin Linge Pattern)
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:
```python
# 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:
```python
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).
```python
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:
```python
# === 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.
```python
# After operations.run():
hp_separator.initMechanicalDesign()
hp_design = hp_separator.getMechanicalDesign()
hp_design.setMaxOperationPressure(85.0)
hp_design.setMaxOperationTemperature(273.15 + 80.0)
hp_design.setGasLoadFactor(0.107) # K-factor [m/s]
hp_design.setRetentionTime(180.0) # Liquid retention [s]
hp_design.setInletNozzleID(0.356) # 14-inch inlet [m]
hp_design.setDemisterType("wire_mesh")
Auf GitHub ansehen