neqsim-platform-modeling · git:20260714.fb85e05 · 2026-07-14 · sha256 b9ffa5a78c91a0c8

neqsim-platform-modeling git:20260714.fb85e05A

Immutable. This exact content is served forever at /api/v1/blob/b9ffa5a78c91a0c8.

---
name: neqsim-platform-modeling
description: "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:

```
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.

---

## 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")
hp_design.readDesignSpecifications()
hp_design.calcDesign()

# Repeat for other separators
mp_separator.initMechanicalDesign()
mp_design = mp_separator.getMechanicalDesign()
mp_design.setMaxOperationPressure(25.0)
mp_design.setGasLoadFactor(0.107)
mp_design.setRetentionTime(120.0)
mp_design.readDesignSpecifications()
mp_design.calcDesign()
```

### 3.3 Heater as T/P Setter

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):

```python
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:

```python
# 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:

```python
def close_scrubber_recycle(ops, scrubber, seed_stream, pressure, temperature_C, tag):
    """Return scrubber liquid to the matching-pressure separator via a Recycle tear."""
    tp_setter = Heater("%s scrubber liq TP set" % tag, scrubber.getLiquidOutStream())
    tp_setter.setOutTemperature(temperature_C, "C")
    tp_setter.setOutPressure(pressure, "bara")
    ops.add(tp_setter)
    recycle = Recycle("%s scrubber liq recycle" % tag)
    recycle.addStream(tp_setter.getOutletStream())
    recycle.setOutletStream(seed_stream)   # the pre-created seed mixed into the stage inlet
    recycle.setTolerance(1.0e-4)
    ops.add(recycle)
    return recycle

# Matching-pressure mapping (HP=stage1, MP=stage2, LP=stage3):
close_scrubber_recycle(ops, lp_scrubber,     seed_stage3, lp_pressure, lp_temperature, "recomp LP")
close_scrubber_recycle(ops, mp_scrubber,     seed_stage2, mp_pressure, mp_temperature, "recomp MP")
close_scrubber_recycle(ops, export_scrubber, seed_stage1, hp_pressure, hp_temperature, "export")
```


### 4.2 Export/Injection Scrubber Liquid Recycle

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:

```python
# 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:

```python
# === Anti-surge seed stream (pre-created for Recycle object) ===
asv_seed = lp_separator.getGasOutStream().clone()
asv_seed.setName("ASV seed R1")
asv_seed.setFlowRate(0.1, "kg/hr")  # Tiny flow for anti-surge recycle
asv_seed.setPressure(process_input.lp_pressure)
operations.add(asv_seed)

# === Mixer: main gas + anti-surge recycle ===
inlet_mixer = StaticMixer("Inlet cooler R1")
inlet_mixer.addStream(asv_seed)
inlet_mixer.addStream(lp_separator.getGasOutStream())
operations.add(inlet_mixer)

# === Pressure drop (piping + cooler shell-side) ===
pdrop = PressureDrop("PD R1 Cooler")
pdrop.setInletStream(inlet_mixer.getOutletStream())
pdrop.setPressureDrop(process_input.r1_cooler_dp, "bara")
operations.add(pdrop)

# === Aftercooler ===
cooler = Heater("R1 Cooler", pdrop.getOutletStream())
cooler.setOutTemperature(process_input.r1_scrubber_temp, "C")
operations.add(cooler)

# === Hydrate temperature measurement ===
hydrate_analyser = HydrateEquilibriumTemperatureAnalyser("Hydrate R1", cooler.getOutletStream())
operations.add(hydrate_analyser)

# === TP Setter before scrubber (ensures correct inlet conditions) ===
tp_set = Heater("TP set R1", cooler.getOutletStream())
tp_set.setOutTemperature(process_input.r1_scrubber_temp, "C")
tp_set.setOutPressure(process_input.r1_scrubber_pressure)
operations.add(tp_set)

# === Scrubber ===
scrubber = Separator("R1 Scrubber", tp_set.getOutletStream())
scrubber.setInternalDiameter(1.9)
operations.add(scrubber)

# === Compressor (T/P control version) ===
compressor = Compressor("R1 Compressor", scrubber.getGasOutStream())
compressor.setUsePolytropicCalc(True)
compressor.setOutletPressure(process_input.r1_outlet_pressure)
compressor.setOutTemperature(process_input.r1_outlet_temp + 273.15)  # Kelvin!
operations.add(compressor)

# === Anti-surge split + valve + recycle ===
asv_split = Splitter("ASV split R1", compressor.getOutletStream())
asv_split.setSplitNumber(2)
asv_split.setFlowRates([-1, asv_mass_flow], "kg/hr")  # -1 = remainder
operations.add(asv_split)

asv_valve = ThrottlingValve("ASV R1", asv_split.getSplitStream(1))
asv_valve.setOutletPressure(process_input.lp_pressure)
operations.add(asv_valve)

recycle_asv = Recycle("Recycle ASV R1")
recycle_asv.addStream(asv_valve.getOutletStream())
recycle_asv.setOutletStream(asv_seed)
operations.add(recycle_asv)
```

### 5.2 Compressor Performance Curves (Dual Object Pattern)

Production models use **two compressor objects per stage**:
1. **Control compressor**: sets outlet P and T (for converging the process)
2. **Curve compressor**: uses actual performance map (for monitoring/reporting)

```python
# Performance curves — separate object using the SAME inlet stream
comp_curves = Compressor("R1 Compressor Curves", scrubber.getGasOutStream())
comp_curves.setUsePolytropicCalc(True)

chart_conditions = [1.0, 1.0, 1.0, 1.0]  # Reference conditions multiplier
speeds = [4421, 5684, 6632]               # RPM

# flow[speed_index][point_index] in m3/hr (actual volume flow at suction)
flow = [
    [5758, 6429, 6679],
    [7616, 10079, 11344],
    [10722, 13295, 15046],
]

# head[speed_index][point_index] in kJ/kg (polytropic head)
head = [
    [51.4, 48.5, 47.7],
    [90.5, 84, 75],
    [123, 115, 94],
]

# efficiency[speed_index][point_index] in % (polytropic efficiency)
poly_eff = [
    [80, 80, 79.7],
    [77.5, 80.3, 77.91],
    [75.3, 78.24, 73],
]

comp_curves.getCompressorChart().setCurves(
    chart_conditions, speeds, flow, head, poly_eff
)
comp_curves.setSpeed(process_input.r1_speed)
comp_curves.getCompressorChart().setHeadUnit("kJ/kg")
operations.add(comp_curves)
```

**Optional: Surge curve definition** (Martin Linge pattern):
```python
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:
```python
comp_curves.addChart("R1-design", design_chart)
comp_curves.addChart("R1-fieldfit", fieldfit_chart)
comp_curves.selectChart("R1-fieldfit")   # sets + enables it, turns on polytropic calc
# comp_curves.getAvailableCharts(); comp_curves.getSelectedChartName()
# comp_curves.getChartLibrary().saveToFile("R1_charts.json")  # shared vendor-curve DB
```

### 5.3 Anti-Surge Valve Flow Calculation (Cv-based)

Compute anti-surge valve mass flow from Cv and valve opening:

```python
import math

def get_gas_valve_mass_flow(
    p_upstream_pa: float,
    p_downstream_pa: float,
    density_kgm3: float,
    cv_value: float,
    valve_opening_pct: float,
) -> float:
    """Gas valve mass flow using ISA/IEC valve sizing equation.

    Returns mass flow in kg/hr.
    """
    if valve_opening_pct < 10:  # MIN_VALVE_OPENING
        return 0.1  # Tiny seed flow

    dp = abs(p_upstream_pa - p_downstream_pa)
    n8 = 94.8  # ISA constant for mass flow

    mass_flow = (
        n8 * (valve_opening_pct / 100.0) * cv_value
        * math.sqrt(dp * density_kgm3)
    )
    return max(mass_flow, 0.1)
```

**Apply after first `operations.run_step()` call** (needs actual pressures/densities):
```python
# After initial run, calculate ASV flows from actual conditions
mass_r1 = get_gas_valve_mass_flow(
    operations.getUnit("ASV R1").getInletStream().getPressure("Pa"),
    operations.getUnit("ASV R1").getOutletStream().getPressure("Pa"),
    operations.getUnit("ASV R1").getInletStream().getFluid().getDensity("kg/m3"),
    process_input.cv_asv_r1,
    process_input.asv_opening_r1,
)
operations.getUnit("ASV split R1").setFlowRates([-1, mass_r1], "kg/hr")
operations.getUnit("ASV seed R1").setFlowRate(mass_r1, "kg/hr")
```

---

## 6. Export and Injection Gas Processing

### 6.1 Production Split

Gas from the recompression train goes to export and/or injection via a splitter:

```python
export_manifold.addStream(r3_output)  # All sources to manifold
operations.add(export_manifold)

# Manifold commingled outlet is getMixedStream() (not getOutletStream()).
production_split = Splitter("Prod split", export_manifold.getSplitStream(0))
production_split.setSplitFactors([split_export, split_injection])
operations.add(production_split)
```

### 6.2 Conditional Export/Injection Sections

Production models typically support switching export/injection on/off:

```python
MIN_SPLIT = 0.05

if split_export > MIN_SPLIT:
    # Export cooler → scrubber → compressor → aftercooler
    # Same pattern as recompression stage (§5.1) with anti-surge
    ...

if split_injection > MIN_SPLIT:
    # Multi-stage injection compression (2+ stages, same pattern)
    ...
```

### 6.3 Booster Compressor (Optional)

Some platforms have a booster between HP separation and the export manifold:

```python
MIN_BOOSTER_SPEED = 1000  # rpm threshold

if process_input.booster_speed > MIN_BOOSTER_SPEED:
    booster_mixer.addStream(hp_gas)
    operations.add(booster_mixer)
    # Same cooler → scrubber → compressor → ASV pattern
    ...
    export_manifold.addStream(booster_output)
else:
    # HP gas goes directly to export manifold
    export_manifold.addStream(hp_gas)
```

---

## 7. Measurement Devices

### 7.1 Hydrate Temperature Monitoring

Add at every cooler outlet to check hydrate risk:

```python
HydrateAnalyser = jneqsim.process.measurementdevice.HydrateEquilibriumTemperatureAnalyser

hydrate_mon = HydrateAnalyser("Hydrate R1 cooler", cooler.getOutletStream())
operations.add(hydrate_mon)

# Read after running:
hydrate_temp_C = operations.getMeasurementDevice("Hydrate R1 cooler").getMeasuredValue("C")
```

### 7.2 Well Allocators

For multi-well platforms, track each well's contribution to exports:

```python
WellAllocator = jneqsim.process.measurementdevice.WellAllocator

allocator = WellAllocator("Well A-3", well_stream_a3)
allocator.setExportGasStream(export_gas)
allocator.setExportOilStream(stable_oil)
operations.add(allocator)

# Read allocated rates per well
gas_alloc = allocator.getMeasuredValue("gas export rate", "kg/hr")
oil_alloc = allocator.getMeasuredValue("oil export rate", "kg/hr")
```

---

## 8. Result Extraction

### 8.1 Structured Response Helpers

Use standardized helper functions to extract equipment results into typed objects:

```python
def get_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


def get_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

```python
# 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() / 1000
    for 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.

```python
def check_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 > 0 else float("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)
        )
    return True, 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:

```python
assert plant.run() or plant.solved(), "ProcessModel did not converge"
```

---

## 9. ProcessInput Configuration Pattern

### 9.1 Pydantic Model for All Operating Conditions

```python
from pydantic import BaseModel, Field

class ProcessInput(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

```python
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
- [ ] **Identify compression trains**: recompression (LP→HP), export, injection, booster
- [ ] **Map out all recycle loops**: scrubber liquids, export liquids, anti-surge valves
- [ ] **Pre-create all StaticMixers** before building equipment
- [ ] **Pre-create all recycle seed streams** (cloned, small flow rate, correct T/P)
- [ ] **Use Heater as T/P setter** before scrubbers and at recycle return points
- [ ] **Add hydrate temperature monitors** after every cooler
- [ ] **Add PressureDrop** elements before coolers (piping/shell losses)
- [ ] **Compressor curves**: dual object (control + curves) per stage
- [ ] **Anti-surge**: Splitter(2) → valve → Recycle back to suction seed
- [ ] **Conditional sections**: check booster speed, export/injection split fractions
- [ ] **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:

```python
from neqsim import jneqsim

# Standard equipment
ProcessSystem = jneqsim.process.processmodel.ProcessSystem
Stream = jneqsim.process.equipment.stream.Stream
ThreePhaseSeparator = jneqsim.process.equipment.separator.ThreePhaseSeparator
Separator = jneqsim.process.equipment.separator.Separator
Compressor = jneqsim.process.equipment.compressor.Compressor
Heater = jneqsim.process.equipment.heatexchanger.Heater
ThrottlingValve = jneqsim.process.equipment.valve.ThrottlingValve
Splitter = jneqsim.process.equipment.splitter.Splitter
StaticMixer = jneqsim.process.equipment.mixer.StaticMixer
Mixer = jneqsim.process.equipment.mixer.Mixer
Pump = jneqsim.process.equipment.pump.Pump
Recycle = jneqsim.process.equipment.util.Recycle
PressureDrop = jneqsim.process.equipment.util.PressureDrop

# Measurement devices
HydrateAnalyser = jneqsim.process.measurementdevice.HydrateEquilibriumTemperatureAnalyser
WellAllocator = jneqsim.process.measurementdevice.WellAllocator
```