neqsim-unisim-reader · git:20260907.9ca9ea2 · 2026-09-07 · sha256 a25e9dec3bdb7687

neqsim-unisim-reader git:20260907.9ca9ea2A

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

---
name: neqsim-unisim-reader
description: "Reads Honeywell UniSim Design / Aspen HYSYS .usc files via COM automation and converts them to NeqSim ProcessSystem / ProcessModule structures. USE WHEN: a user has UniSim/HYSYS simulation files and wants to recreate or compare the model in NeqSim. Covers COM API navigation, column AttachedFeeds/AttachedProducts connectivity, component mapping, E300 fluid transfer, operation-handler registry strategy, topology reconstruction, sub-flowsheet handling, batch regression against the UniSim sample library, and result verification."
last_verified: "2026-09-07"
---

# UniSim Design / HYSYS → NeqSim Conversion Skill

Convert Honeywell UniSim Design (.usc) files into NeqSim ProcessSystem or
ProcessModule structures using Windows COM automation.

## Prerequisites

- **Windows only** — UniSim Design must be installed (COM server)
- **Python packages**: `pywin32` (`pip install pywin32`)
- **UniSim Design R510+** (R460+ should also work)
- COM ProgID: `UnisimDesign.Application`

## Core Module

The `devtools/unisim_reader.py` module provides three main classes:

| Class | Purpose |
|-------|---------|
| `UniSimReader` | Opens .usc files via COM, extracts all data |
| `UniSimToNeqSim` | Converts extracted model to NeqSim JSON builder format or standalone Python code |
| `UniSimComparator` | Compares UniSim vs NeqSim results for verification |

Supporting tools:

| Tool | Purpose |
|------|---------|
| `devtools/unisim_batch_check.py` | Convert a whole corpus, one subprocess per case; timings, unmapped types, warnings, compile + undefined-name checks |
| `devtools/unisim_run_generated.py` | Execute every generated model in its own subprocess and report how far it gets |
| `devtools/unisim_probe_ops.py` | Dump the readable COM properties of an operation type |
| `devtools/unisim_probe_column.py` | Dump a column's attached streams and `ColumnFlowsheet` internals |
| `devtools/test_unisim_outputs.py` | Pure-Python regression tests (no COM needed) |

---

## 1. UniSim COM Object Model

UniSim's COM automation exposes the following hierarchy:

```
Application
├── SimulationCases
│   └── Case
│       ├── Solver (CanSolve, Converge)
│       ├── BasisManager
│       │   └── FluidPackages[]
│       │       ├── name, PropertyPackageName
│       │       └── Components[]
│       │           └── name
│       └── Flowsheet (main)
│           ├── MaterialStreams[]
│           │   ├── name
│           │   ├── Temperature.GetValue("C")
│           │   ├── Pressure.GetValue("bar")
│           │   ├── MassFlow.GetValue("kg/h")
│           │   ├── MolarFlow.GetValue("kgmole/h")
│           │   ├── MassDensity.GetValue("kg/m3")
│           │   ├── MolecularWeight.GetValue()
│           │   ├── VapourFraction.GetValue()
│           │   ├── MassEnthalpy.GetValue("kJ/kg")
│           │   ├── ComponentMolarFraction.GetValues()
│           │   └── ComponentMolarFraction.SetValues([...])
│           ├── EnergyStreams[]
│           │   ├── name
│           │   └── HeatFlow.GetValue("kW")
│           ├── Operations[]
│           │   ├── name, TypeName
│           │   ├── Feeds[] (multi-feed ops: mixers, separators)
│           │   ├── Products[] (multi-product ops: tee/splitter)
│           │   ├── FeedStream / ProductStream (single-stream ops)
│           │   ├── Product (singular, for mixers)
│           │   ├── VapourProduct, LiquidProduct, WaterProduct (separators)
│           │   ├── EnergyFeeds[], EnergyProducts[]
│           │   └── Type-specific: DutyValue, AdiabaticEfficiency,
│           │       PolytropicEfficiency, PressureDrop, Length, Diameter
│           └── Flowsheets[] (sub-flowsheets, recursive)
```

### CRITICAL: Operation Connectivity Patterns

UniSim COM uses **different** property names for different operation types.
You MUST check multiple patterns to extract feed/product connections:

| Operation Type | Feed Source | Product Source |
|---|---|---|
| **compressor** | `FeedStream` (single) | `ProductStream` (single) |
| **coolerop / heaterop** | `FeedStream` (single) | `ProductStream` (single) |
| **valveop** | `FeedStream` (single) | `ProductStream` (single) |
| **recycle** | `FeedStream` (single) | `ProductStream` (single) |
| **pumpop / expandop** | `FeedStream` (single) | `ProductStream` (single) |
| **mixerop** | `Feeds[]` (array) | `Product` (singular!) |
| **teeop** | `FeedStream` (single) | `Products[]` (array) |
| **flashtank** | `Feeds[]` (array) | `VapourProduct`, `LiquidProduct` |
| **sep3op** | `Feeds[]` (array) | `VapourProduct`, `LiquidProduct`, `WaterProduct` |
| **heatexop** | Has shell-side / tube-side sub-objects | |
| **columns** (see below) | `AttachedFeeds[]` | `AttachedProducts[]` |

**WARNING**: `op.Products` does NOT exist on mixers and separators — it throws
`AttributeError`. You must use `op.Product` (singular) for mixers and
`op.VapourProduct` / `op.LiquidProduct` for separators.

### CRITICAL: Columns use AttachedFeeds / AttachedProducts

A UniSim column (`distillation`, `columnop`, `absorber`, `reboiledabsorber`,
`refluxedabsorber`, `ratedistillation`) exposes **none** of `Feeds`,
`FeedStream`, `Products`, `Product` or `ProductStream` — every one raises
`AttributeError`. Its external connections live on `AttachedFeeds` /
`AttachedProducts`, and those collections **mix material and energy streams**:

```text
DePropanizer (TUTOR1, type=distillation)
  AttachedFeeds     ['TowerFeed', 'RebDuty']            <- RebDuty is ENERGY
  AttachedProducts  ['LiquidProd', 'Ovhd', 'CondDuty']  <- CondDuty is ENERGY
```

Because the generic feed extractor found nothing, **every column in every case
was silently dropped from the converted flowsheet** (an unfed NeqSim column
throws on `run()`, so the converter skipped it). Always extract columns through
their own path.

The column's `ColumnFlowsheet` supplies everything else:

```python
cfs = column_op.ColumnFlowsheet
cfs.EnergyStreams      # ['CondDuty', 'RebDuty'] -> classify material vs energy
cfs.MaterialStreams    # incl. the external feed/products; read VapourFraction,
                       # Pressure off these items
cfs.RefluxRatio        # 0.9999 — direct scalar, no Specifications parsing
cfs.Specifications     # ['Reflux Ratio', 'Propane Fraction', 'Ovhd Vap Rate', ...]
cfs.Operations         # ['Main TS', 'Condenser', 'Reboiler']
#   traysection        -> NumberOfTrays=10, FeedStages=['5__Main TS']
#   partialcondenser / totalcondenser / condenser3op -> hasCondenser
#   bpreboiler                                       -> hasReboiler
```

**Product order is not physical.** `AttachedProducts` lists bottoms before
overhead in TUTOR1, but the converter maps product index 0 to the column gas
outlet. Classify instead:

- distillate = condenser internal's `AttachedProducts` ∩ column products
- bottoms = reboiler internal's `AttachedProducts` ∩ column products
- anything left over (and absorber feeds) is ordered by `VapourFraction`,
  vapour-rich first — an absorber's gas feed must be index 0

`CondenserPressure` / `ReboilerPressure` do **not** exist on the operation; read
top/bottom pressure off the distillate/bottoms product streams instead.

**Tray-index translation.** UniSim numbers tray-section stages from the TOP
(stage 1 = top tray). NeqSim numbers trays from the BOTTOM, with index 0 the
reboiler when present and the condenser last, and the constructor argument
excludes reboiler/condenser:

```text
neqsim_index = (1 if hasReboiler else 0) + (n_trays - unisim_stage)
TUTOR1: n_trays=10, stage 5  ->  tray 6 of DistillationColumn(name, 10, True, True)
```

An absorber has neither, so gas enters tray `0` and lean solvent tray `n-1`.
Feeding at `n` throws `IllegalArgumentException: Feed tray index must be
between 0 and n-1`.

The recommended extraction order (as implemented in `unisim_reader.py`):
1. Try `Feeds[]` array first (multi-feed ops)
2. Fall back to `FeedStream` (single-stream ops)
3. Try `Products[]` array first (multi-product ops)
4. Try `VapourProduct` / `LiquidProduct` / `WaterProduct` (separators)
5. Try `Product` singular (mixers)
6. Fall back to `ProductStream` (single-stream ops)
```

### Key COM Patterns

```python
import win32com.client
import time

# Start UniSim
app = win32com.client.dynamic.Dispatch('UnisimDesign.Application')
app.Visible = True  # or False for headless

# Open a case
case = app.SimulationCases.Open(r'C:\path\to\file.usc')
time.sleep(3)  # Wait for loading

# Pause solver during extraction
solver = case.Solver
solver.CanSolve = False

# Access data
fs = case.Flowsheet
stream = fs.MaterialStreams.Item(0)
temp_C = stream.Temperature.GetValue('C')
pres_bar = stream.Pressure.GetValue('bar')
flow_kgh = stream.MassFlow.GetValue('kg/h')
comp_fracs = stream.ComponentMolarFraction.GetValues()

# Unit operations
op = fs.Operations.Item(0)
op_type = op.TypeName  # e.g. "compressor", "valveop", "sep3op"
op_name = op.name

# Feed/product streams — varies by operation type!
# For single-stream ops (compressor, valve, cooler, pump, heater):
feed_name = op.FeedStream.name
prod_name = op.ProductStream.name

# For mixers: Feeds[] array + Product singular
for i in range(op.Feeds.Count):
    feed_name = op.Feeds.Item(i).name
prod_name = op.Product.name  # singular!

# For separators: Feeds[] + VapourProduct / LiquidProduct
for i in range(op.Feeds.Count):
    feed_name = op.Feeds.Item(i).name
vap_name = op.VapourProduct.name
liq_name = op.LiquidProduct.name

# For tee/splitter: FeedStream + Products[]
feed_name = op.FeedStream.name
for i in range(op.Products.Count):
    prod_name = op.Products.Item(i).name

# Close
case.Close()
app.Quit()
```

### Important Notes

- Always call `solver.CanSolve = False` before reading to prevent recalculation
- UniSim uses -32767 for empty/unset values — filter these out
- Property values accessed via `.GetValue(unit_string)`
- Composition accessed via `.GetValues()` returning a sequence

### GOTCHA: the UniSim COM server is a SINGLETON

`Dispatch('UnisimDesign.Application')` attaches to the **one** running UniSim
instance, it does not start a private one. Consequences:

- Running a second COM script while a batch runs gives
  `com_error: The RPC server is unavailable`.
- `reader.close()` calls `app.Quit()`, which closes UniSim for **every** other
  script using it.
- **Never run two UniSim COM scripts concurrently.** Subprocess isolation makes
  a crash survivable, but it does not make concurrency safe.

### Prefer readiness polling over fixed sleeps

UniSim needs an unpredictable time to publish its automation object model. A
fixed `time.sleep(3)` is both slower than needed on small cases and unsafe on
large ones. `UniSimReader._wait_ready(probe, timeout, description)` polls a
cheap COM property instead (`app.SimulationCases.Count`,
`case.Flowsheet.MaterialStreams.Count`). Removing the two blind sleeps cut the
TUTOR1 read from 18.3 s to 6.8 s and the 66-case corpus from 816 s to 656 s.

### A refused Open is usually a missing module, not a bad file

`SimulationCases.Open` raises a bare `com_error ... E_ACCESSDENIED
(-2147024891)` for cases needing a UniSim extension that is not installed (the
R510 `CCC Series 5\*` controls cases and the EO electrical case). The files are
not read-only — verified. `UniSimReader._open_case()` retries once on a fresh
session (which does fix a genuinely wedged shared session) and then raises a
`RuntimeError` naming the likely cause.

### 1.1 Extracting Binary Interaction Parameters (BIPs / kij)

UniSim stores the full BIP (kij) matrix on the PropertyPackage object. The
correct COM access pattern is:

```python
fp = case.Flowsheet.FluidPackage
pp = fp.PropertyPackage

kij_obj = pp.Kij          # Returns CDispatch (RealFlexVariable)
raw = kij_obj.Values      # Returns tuple-of-tuples (n×n matrix)

# IMPORTANT: The .Values property returns the full symmetric matrix.
# Diagonal values are -32767.0 (sentinel for "self-interaction").
# Replace with 0.0 when parsing.
n = fp.Components.Count
comp_names = [fp.Components.Item(i).Name for i in range(n)]

bic = []
for i in range(n):
    row = []
    for j in range(n):
        val = float(raw[i][j])
        if abs(val + 32767.0) < 1.0:  # diagonal sentinel
            val = 0.0
        row.append(val)
    bic.append(row)
```

**Key discoveries:**
- `pp.Kij` returns a `CDispatch` (UniSim RealFlexVariable), NOT a Python-iterable
- `kij_obj.Values` is the correct access pattern — returns tuple-of-tuples
- `kij_obj.GetValues()` fails with "Invalid number of parameters"
- `kij_obj.__call__(i,j)` fails with "Does not support a collection"
- `pp.GetInteractionParameter(i,j)` returns 0.0 for PR-LK (correlation-based
  BIPs are stored internally, not as user-defined parameters)
- The matrix is symmetric: `kij[i][j] == kij[j][i]`
- For PR-LK, BIPs are generated from the Lee-Kesler correlation — they are
  non-zero even if never explicitly tuned by the user

**Common BIP patterns (PR-LK, hydrocarbon system):**
- H2O–HC: +0.48 to +0.50 (strong positive interaction)
- H2O–N2: -2.24 (strong negative)
- H2O–CO2: -0.56
- CO2–C1: +0.105
- N2–C1: +0.025
- HC–HC (light–heavy): small values, typically < 0.01

### 1.2 Extracting Component Thermodynamic Properties

For pseudo-components and library components, extract critical properties
and other thermodynamic data:

```python
fp = case.Flowsheet.FluidPackage
pp = fp.PropertyPackage

n = fp.Components.Count
for i in range(n):
    comp = fp.Components.Item(i)
    name = comp.Name
    mw = comp.MolecularWeight.GetValue()
    # Prefer native UniSim units and convert explicitly.
    # Some COM surfaces return misleading values when requesting alternate units.
    tc = comp.CriticalTemperature.GetValue("C") + 273.15   # K
    pc = comp.CriticalPressure.GetValue("kPa") * 0.01      # bara
    nbp = comp.NormalBoilingPt.GetValue("C") + 273.15      # K
    vc = comp.CriticalVolume.GetValue("m3/kgmole") # m3/kmol
    # Acentric factor: the UniSim COM attribute is `Acentricity` (NOT
    # `AcentricFactor`). Only fall back to Edmister if it is absent.
    omega = comp.AcentricityValue
```

**Notes:**
- UniSim component collections can be 0-based or 1-based depending on the COM
   collection surface. If `Components.Item(i)` fails, retry `Components.Item(i+1)`
   before dropping the component.
- For component critical properties, request `CriticalTemperature` and
   `NormalBoilingPoint` in `C` first and convert to K; request `CriticalPressure`
   in `kPa` first and convert to bara. Sanity-check known components after export:
   methane Tc ≈ 190.7 K and Pc ≈ 46.4 bara; water Tc ≈ 647.3 K and Pc ≈ 221 bara.
- **Acentric factor: read `comp.Acentricity` / `comp.AcentricityValue`.** This is
   the attribute UniSim actually exposes; `AcentricFactor` / `Omega` do NOT exist on
   the component COM surface. Reading the Edmister estimate instead silently changes
   the EOS alpha function and shifts every bubble point / TVP of the converted fluid
   (measured: 12–15 % low on a 23-component SRK-Peneloux oil, and `0.0` for the
   heaviest pseudos where the Edmister value exceeded the `omega > 2` sanity guard).
   With `Acentricity` read correctly, a NeqSim E300 round-trip reproduces UniSim
   bubble points to < 0.5 %. Keep the property-package vectors
   (`Acentricity`, `AcentricFactor`, `Omega`, `ACF`) and Edmister only as fallbacks.
- Parachor can sometimes be read via `pp.Parachor.Values` (same pattern as Kij).
- Volume shift: `pp.VolumShift.Values` (note the UniSim spelling: "VolumShift",
  not "VolumeShift").

### 1.2.1 Reading TVP / RVP (Cold Properties) off a stream

Vapour-pressure results live on the **material stream**, in **kPa** (temperatures
in **°C**). Missing values return the sentinel `-32767`.

```python
s = case.Flowsheet.MaterialStreams.Item(0)
s.TrueVPValue                 # TVP, evaluated at 37.8 C
s.RVP_37_8_DegCValue          # Reid VP at 37.8 C
s.RVPASTM_D323_73_79Value     # ASTM / API RVP correlations
s.RVPAPI_5B_1_1Value, s.RVPAPI_5B_1_2Value
cp = s.ColdProperty           # Cold Properties utility
cp.TrueVapourPressureValue, cp.ReidVapourPressureValue
cp.FlashPointValue, cp.PourPointValue, cp.D86CurveValue
```

For TVP at **any other temperature** (e.g. a 30 °C export spec), flash a duplicate —
this never touches the case:

```python
f = s.DuplicateFluid()
f.TVFlash(30.0, 0.0)          # (T [C], vapour fraction) -> bubble point
tvp_bara = f.PressureValue * 0.01
```

Water-free basis: assign a renormalised `f.MolarFractionsValue` with H2O zeroed
*before* the flash (the setter works); `f.MassFractionsValue` gives water wt%
directly. Free water only adds its own saturation pressure (~0.04 bara at 30 °C).

> **NeqSim comparison gotcha:** `bubblePointPressureFlash` is not three-phase aware.
> With free water in the feed it returns a grossly inflated TVP (measured 5–6.5 bara
> instead of 2.2) because water is treated as dissolved in the oil, and
> `getPhase("oil")` then returns the aqueous phase. Strip water first, or use
> `Standard_ASTM_D6377`'s `VPCR4_no_water` / `RVP_ASTM_D323_73_79` variants.
>
> A TVP/RVP ratio far above ~1.3 is usually **physical**, not an error: a bubble
> point is hypersensitive to trace dissolved light ends (N2/C1/CO2) while the
> V/L = 4, 80 vol %-vaporised RVP test is not. It signals incompletely stabilised oil.

### 1.3 Generating E300 Fluid Files from UniSim Data

To create an Eclipse E300-format fluid file from UniSim-extracted properties
for loading into NeqSim via `EclipseFluidReadWrite.read()`:

**Required E300 sections** (NeqSim reader will crash without these):
- `CNAMES` — component names
- `TCRIT` — critical temperatures (K)
- `PCRIT` — critical pressures (bar/bara as used by NeqSim's E300 writer)
- `ACF` — acentric factors
- `MW` — molecular weights (g/mol)
- `TBOIL` — normal boiling points (K)
- `VCRIT` — critical volumes (m3/kg-mol)
- `PARACHOR` — parachor values (if unknown: `4.0 * MW^0.77`)
- `SSHIFT` — volume shift parameters (can be all zeros)
- `BIC` — binary interaction coefficients (lower triangular)
- `ZI` — mole fractions

**Optional E300 sections** (NeqSim supports these):
- `BICS` — volume-corrected BICs at surface conditions (parsed but same format as BIC)
- `OMEGAA` — per-component OmegaA override values (one value per line + `/` terminator)
- `OMEGAB` — per-component OmegaB override values (same format)
- `SSHIFTS` — volume shift at surface conditions (same format as SSHIFT)
- `PEDERSEN` — keyword (no values) → activates Pedersen viscosity correlation

**EOS Selection via E300:**
- `EOS\nSRK /` → `SystemSrkEos`
- `EOS\nPR /\nPRCORR` → `SystemPrEos1978` (PR1978 correction)
- `EOS\nPR /\nPRLKCORR` → **`SystemPrLeeKeslerEos`** (PR-LK, PR76 alpha for all ω)
- `EOS\nPR /` → `SystemPrEos` (base PR)

**CRITICAL**: If the BIC section is omitted, NeqSim's `EclipseFluidReadWrite`
will crash with a NullPointerException. Always include BIC, even if all zeros.

**Loading with a forced EOS (ignores EOS keyword in file):**
```python
from neqsim import jneqsim
EclipseFluidReadWrite = jneqsim.thermo.util.readwrite.EclipseFluidReadWrite
SystemPrLeeKeslerEos = jneqsim.thermo.system.SystemPrLeeKeslerEos

# Force PR-LK regardless of EOS in file
fluid = SystemPrLeeKeslerEos(288.15, 1.01325)
fluid = EclipseFluidReadWrite.read(e300_path, fluid)
```

**⚠️ CRITICAL WARNING — Water BIPs and OmegaA:**

When water is present, the water–hydrocarbon BIPs (kij) interact dangerously
with OmegaA. **NEVER mix BIP conventions with OmegaA modifications:**

- Standard E300 BIPs for water–HC are typically **+0.48 to +0.50**
- PR-LK correlation BIPs for H2O–N2 are typically **−2.24** (negative!)
- H2O–CO2 is typically **−0.557**, H2O–H2S is typically **−0.390**

These negative BIPs **intentionally** increase cross-attraction and keep water
in the liquid phase. If you simultaneously set `OMEGAA` for water to a
non-standard value (e.g., 0.42748), the phase behavior will be wrong —
HP Separator vapour fraction can jump from 0.41 to 0.81 (catastrophic).

**Rule**: Only use `OMEGAA` for water together with its matched BIP set.
Default (no OMEGAA section) is safer unless you have a PVTsim-generated file
that was specifically fitted with both OMEGAA and BICs together.

**NeqSim E300 component name mapping:**
| E300 Name | NeqSim Maps To |
|-----------|---------------|
| `C1` | `methane` |
| `C2` | `ethane` |
| `C3` | `propane` |
| `iC4` | `i-butane` |
| `C4` | `n-butane` |
| `iC5` | `i-pentane` |
| `C5` | `n-pentane` |
| `C6` | `n-hexane` |
| `N2` | `nitrogen` |
| `CO2` | `CO2` |
| `H2O` | `water` |
| All others | TBP pseudo-fraction (via `addTBPfraction()`) |

**Note:** Aromatics (Benzene, Toluene, E-Benzene, m-Xylene, etc.) are NOT in
NeqSim's E300 recognized name map — they will be treated as TBP pseudo-fractions
with estimated density.

### E300 Fluid Export (DEFAULT — Recommended Route)

**The E300 export route is the default and preferred method for transferring
fluid definitions from UniSim to NeqSim.** It preserves all critical properties
(Tc, Pc, acentric factor, MW, BIPs, volume shifts, parachors) for both standard
and hypothetical/pseudo components — including C7+ fractions that cannot be
accurately recreated by component name mapping alone.

When `UniSimReader.read()` is called with `export_e300=True` (the default),
it extracts critical properties from each component in each fluid package via COM,
then writes an E300 file per fluid package to the output directory.

**COM properties extracted per component:**
- `component.CriticalTemperature` → Tc (request C first, convert to K)
- `component.CriticalPressure` → Pc (request kPa first, convert to bara)
- `component.AcentricFactor` / package vector / Edmister fallback → omega
- `component.MolecularWeight` → MW (g/mol)
- `component.NormalBoilingPoint` → Tboil (request C first, convert to K)
- `component.CriticalVolume` → Vcrit (m³/kgmol)

**BIPs extracted via:** `FluidPackage.PropertyPackage.GetBIP(i, j)` or
`PropertyPackage.BinaryInteractionParameters` (matrix fallback).

**E300 file format keywords:**
`METRIC`, `NCOMPS`, `EOS`, `PRCORR`, `RTEMP`, `STCOND`, `CNAMES`, `TCRIT`,
`PCRIT`, `ACF`, `MW`, `TBOIL`, `VCRIT`, `SSHIFT`, `PARACHOR`, `ZI`, `BIC`

**NeqSim loading:** Use `EclipseFluidReadWrite.read(e300Path)` in Java, or
via Python: `jneqsim.thermo.util.readwrite.EclipseFluidReadWrite.read(path)`.

**Automatic integration:** When `build_and_run()` detects an E300 file in the
fluid section, it loads the fluid via `EclipseFluidReadWrite.read()` and passes
it to `ProcessSystem.fromJsonAndRun(json, fluid)`, bypassing component name
mapping entirely.

```python
# Example: Full E300 workflow
reader = UniSimReader()
model = reader.read(r'C:\path\to\model.usc')  # auto-exports E300 files

# E300 files now available:
for fp in model.fluid_packages:
    print(f"  {fp.name}: {fp.e300_file_path}")

# Convert to NeqSim and run — E300 fluid used automatically
converter = UniSimToNeqSim(model)
result = converter.build_and_run()
```

---

## 2. Operation Type Mapping

UniSim internal operation type names (from `op.TypeName`) mapped to NeqSim types:

### Mapping Architecture

`devtools/unisim_reader.py` uses a typed `UniSimOperationHandler` registry. Do
not add scattered local skip lists or one physical NeqSim class per UniSim name.
The registry records:

| Field | Meaning |
|-------|---------|
| `neqsim_type` | Target NeqSim type or converter pseudo-type |
| `strategy` | `native`, `adapter`, `reference`, `control`, `column_internal`, or `skip` |
| `stream_role` | `material`, `reference`, or `none` for topology reconstruction |
| `note` | Human-readable rationale written to JSON mapping summaries |

**Policy:** Native physical UniSim operations map to native NeqSim equipment.
UniSim-specific topology placeholders use `UnisimCalculator`; spreadsheets and
set/adjust logic are reference objects; controllers and logical operations do
not create material topology edges; column internals configure the column rather
than becoming standalone equipment. Generated JSON includes
`_unisim_operation_mapping` so imported cases can audit the strategy used for
each UniSim operation type present in the model.

### Core Process Equipment

| UniSim TypeName | NeqSim Type | Description |
|-----------------|-------------|-------------|
| `valveop` | `ThrottlingValve` | Pressure letdown valve, choke |
| `sep3op` | `ThreePhaseSeparator` | Three-phase separator |
| `flashtank` | `Separator` / `GasScrubber` | Two-phase separator. Auto-promoted to `ThreePhaseSeparator` if `WaterProduct` connected. Vertical orientation → `GasScrubber`. |
| `mixerop` | `Mixer` | Stream mixer/junction |
| `teeop` | `Splitter` | Stream splitter/tee |
| `compressor` | `Compressor` | Gas compressor |
| `coolerop` | `Cooler` | Cooler/aftercooler |
| `heaterop` | `Heater` | Heater/pre-heater |
| `pumpop` | `Pump` | Liquid pump |
| `expandop` | `Expander` | Turboexpander |
| `heatexop` | `HeatExchanger` | Shell-and-tube / plate HX |
| `firedheaterop` | `FiredHeater` | Fired heater / process furnace |
| `pipeseg` | `AdiabaticPipe` | Pipe segment |
| `olgapipe` | `AdiabaticPipe` | OLGA-link pipe; upgraded to `PipeBeggsAndBrills` when segment geometry is extracted |
| `sep1op` / `sep2op` | `Separator` | Separator variants |
| `pemelectrolyzer` | `Electrolyzer` | PEM electrolyzer (`setTechnology(PEM)`) |
| `alkalineelectrolyzer` | `Electrolyzer` | Alkaline electrolyzer (`ALKALINE`) |
| `soecelectrolyzer` | `Electrolyzer` | Solid-oxide electrolyzer (`SOEC`) |
| `recycle` | `Recycle` | Recycle convergence block |
| `adjust` | `Adjuster` | Process variable adjuster |
| `setop` | `SetPoint` | Set variable/propagation |
| `saturateop` | `StreamSaturatorUtil` | Stream saturator |
| `spreadsheetop` | `SpreadsheetBlock` | Spreadsheet calculator/reference block; formulas need explicit import/export cell extraction |
| `templateop` | `SubFlowsheet` / `UnisimCalculator` | Sub-flowsheet template or interface placeholder; JSON factory aliases placeholder builds to `UnisimCalculator` |
| `fluidizedcatalyticcrackertemplate` | `SubFlowsheet` | FCC template |
| `isomerizationtemplate` | `SubFlowsheet` | Isomerization template |
| `virtualstreamop` | `UnisimCalculator` | Virtual stream/topology adapter with pass-through outlet |
| `streamcutterop` | `UnisimCalculator` | Assay stream cutter; pass-through adapter |
| `fluidizedcatalyticcrackerop` | `UnisimCalculator` | FCC — no NeqSim equivalent; pass-through keeps downstream topology |
| `isomerizationreactorop` | `UnisimCalculator` | Isomerization reactor — pass-through adapter |

### Columns & Absorbers

| UniSim TypeName | NeqSim Type | Description |
|-----------------|-------------|-------------|
| `fractop` | `DistillationColumn` | Fractionation column |
| `distillation` | `DistillationColumn` | Distillation column |
| `columnop` | `DistillationColumn` | Generic column |
| `reboiledabsorber` | `DistillationColumn` | Reboiled absorber |
| `refluxedabsorber` | `DistillationColumn` | Refluxed absorber |
| `ratedistillation` | `DistillationColumn` | Rate-based column (equilibrium-stage approximation) |
| `threephasedistillation` | `DistillationColumn` | Three-phase column |
| `absorberop` | `Absorber` | Absorption column (see glycol note below) |
| `absorber` | `Absorber` | Absorber (see glycol note below) |

> **Glycol/TEG Contactor Rule**: When an Absorber operation has a name
> containing "glyc", "teg", or "dehydrat" (case-insensitive), the code
> generator produces a `ComponentSplitter` instead of a `DistillationColumn`.
> This removes water from the gas stream using the standard pattern:
> `setSplitFactors([1.0] * (N-1) + [0.0])` where water is the last component.
> Stream 0 = dry gas, stream 1 = removed water. Port resolution uses
> `split0`/`split1` instead of `gasOut`/`liquidOut`. Non-glycol absorbers
> still use `DistillationColumn`.

### Column Internals (Sub-parts, Not Standalone)

| UniSim TypeName | NeqSim Type | Description |
|-----------------|-------------|-------------|
| `partialcondenser` | `ColumnInternals` | Partial condenser (skipped) |
| `totalcondenser` | `ColumnInternals` | Total condenser (skipped) |
| `condenser3op` | `ColumnInternals` | Three-outlet condenser (skipped) |
| `traysection` | `ColumnInternals` | Tray section (skipped) |
| `bpreboiler` | `ColumnInternals` | Reboiler (skipped) |

### Reactors

| UniSim TypeName | NeqSim Type | Description |
|-----------------|-------------|-------------|
| `reactorop` | `GibbsReactor` | Generic reactor → Gibbs |
| `gibbsreactorop` | `GibbsReactor` | Gibbs reactor |
| `eqreactorop` | `GibbsReactor` | Equilibrium reactor → Gibbs |
| `equilibriumreactorop` | `GibbsReactor` | Equilibrium reactor (alternate) |
| `convreactorop` | `GibbsReactor` | Conversion reactor → Gibbs |
| `conversionreactorop` | `GibbsReactor` | Conversion reactor (alternate) |
| `pfreactorop` | `PlugFlowReactor` | Plug flow reactor |
| `kineticreactorop` | `PlugFlowReactor` | Kinetic reactor → PFR |
| `cstrop` | `StirredTankReactor` | CSTR |
| `gasifierop` / `gasifieroppy` / `gsfrxsecop` | `GibbsReactor` | Gasifier blocks; the solid (coal/biomass) feed cannot be flashed, so the fluid feed is equilibrated |

### Controllers & Logic

| UniSim TypeName | NeqSim Type | Description |
|-----------------|-------------|-------------|
| `pidfbcontrolop` | `PIDController` | PID feedback controller |
| `surgecontroller` | `SurgeController` | Surge controller (skipped) |
| `selectionop` / `fanoutop` | `LogicalOp` | Signal selector / fan-out; no material topology |
| `genesimop` / `machinelearningtoolop` | skipped | Non-physical utilities |
| `balanceop` | `UnisimCalculator` | Balance/topology adapter with pass-through outlet and source-operation metadata |
| `logicalop` | `LogicalOp` | Logic operation (skipped) |
| `selectop` | `LogicalOp` | Selector (skipped) |

### Handler Strategies and Skipped Operations

The handler registry determines whether material topology is created. The
following types are recognized but do not become standalone material equipment:

- `SurgeController` — Compressor surge control logic
- `ColumnInternals` — Sub-parts of column operations (condenser, reboiler, tray sections)
- `LogicalOp` — logical/select operations produce comments or controller metadata
- `BlowdownGeneSim` — non-physical UniSim utility operation

Use `UniSimReader.is_material_stream_operation(type_name)` when modifying
topology reconstruction. Unknown operation types are treated as material stream
operations so skipped stream-carrying blocks remain visible as warnings.

---

## 3. Component Name Mapping

UniSim component names to NeqSim database names:

| UniSim Name | NeqSim Name |
|-------------|-------------|
| `Nitrogen` | `nitrogen` |
| `CO2` | `CO2` |
| `Methane` | `methane` |
| `Ethane` | `ethane` |
| `Propane` | `propane` |
| `i-Butane` | `i-butane` |
| `n-Butane` | `n-butane` |
| `i-Pentane` | `i-pentane` |
| `n-Pentane` | `n-pentane` |
| `n-Hexane` | `n-hexane` |
| `n-Heptane` | `n-heptane` |
| `n-Octane` | `n-octane` |
| `n-Nonane` | `n-nonane` |
| `n-Decane` | `nC10` |
| `H2O` | `water` |
| `EGlycol` | `MEG` |
| `TEGlycol` | `TEG` |
| `DEGlycol` | `DEG` |
| `MeOH` | `methanol` |
| `Hydrogen` | `hydrogen` |
| `H2S` | `H2S` |
| `Oxygen` | `oxygen` |
| `Argon` | `argon` |
| `Helium` | `helium` |
| `nC11`–`nC24` | `nC11`–`nC24` |
| `Benzene` | `benzene` |
| `Toluene` | `toluene` |
| `E-Benzene` | `ethylbenzene` |
| `m-Xylene` | `m-Xylene` |
| `o-Xylene` | `o-Xylene` |
| `p-Xylene` | `p-Xylene` |
| `COS` | `COS` |
| `SO2` | `SO2` |
| `NH3` / `Ammonia` | `ammonia` |
| `Ethylene` / `Ethene` | `ethylene` |
| `Propylene` / `Propene` | `propene` |
| `1-Butene` | `1-butene` |
| `cis-2-Butene` | `c2-butene` |
| `trans-2-Butene` | `t2-butene` |
| `Isobutene` | `isobutene` |
| `Cyclohexane` | `cyclohexane` |
| `CO` / `CarbonMonoxide` | `CO` |
| `DEAmine` | `DEA` |
| `MEAmine` | `MEA` |
| `MDEAmine` | `MDEA` |
| `AceticAcid` | `acetic acid` |
| `Ethanol` | `ethanol` |
| `c-Hexane` | `c-hexane` |

**Alternate aliases**: The map also includes short-form aliases like `C1`→methane, `C2`→ethane, `N2`→nitrogen, `H2`→hydrogen, `O2`→oxygen, `Ar`→argon, `He`→helium, `iC4`→i-butane, `nC4`→n-butane, `iC5`→i-pentane, `nC5`→n-pentane, `nC6`→n-hexane, etc.

**Unmapped components**: `12C3Oxide` (propylene oxide) maps to `None` — it is not in the NeqSim database and will be skipped with a warning.

### Hypothetical Components

UniSim components ending with `*` are hypothetical (pseudo-components), e.g.:
- `C6 GRAND*`, `C7 GRAND*`, ..., `C55-C80 GRAND*`
- `251116-01*`, `251115-01*` (numbered hypos)

These require C7+ characterization in NeqSim. Strategies:
1. **Skip**: Remove hypos from composition, re-normalize known components
2. **Approximate**: Map to nearest real component by molecular weight
3. **Characterize**: Use NeqSim's `characterisePlusFraction()` with MW and density data

---

## 4. Property Package Mapping

The code variable is `PROPERTY_PACKAGE_MAP` (not EOS_MAP). It maps UniSim
property package names (including common spelling variants) to NeqSim EOS
model strings:

### Primary Mappings

| UniSim Property Package | NeqSim EOS Model | Mixing Rule | Notes |
|-------------------------|------------------|-------------|-------|
| `Peng-Robinson` / `PengRobinson` / `Peng Robinson` | `PR` | `classic` | |
| `Peng-Robinson - LK` / `Peng Robinson - LK` | `PR` | `classic` | |
| `SRK` / `Soave-Redlich-Kwong` | `SRK` | `classic` | |
| `CPA` / `CPA-SRK` | `CPA` | `10` | For polar systems (water, glycols, amines) |
| `Glycol Package` | `CPA` | `10` | Maps to CPA for MEG/TEG |
| `GERG 2008` | `GERG2008` | (built-in) | Natural gas |
| `Sour PR` / `SourPR` | `PR` | `classic` | H2S/CO2 systems |
| `Sour SRK` | `SRK` | `classic` | H2S/CO2 systems |

### Fallback Mappings (Approximated as SRK)

These UniSim packages have no direct NeqSim equivalent and fall back to `SRK`:

| UniSim Property Package | NeqSim Fallback | Notes |
|-------------------------|-----------------|-------|
| `ASME Steam` | `SRK` | Water only |
| `MBWR` | `SRK` | NeqSim has BWRS but limited components |
| `Lee-Kesler-Plocker` | `SRK` | No LKP model |
| `NRTL` / `UNIQUAC` / `UNIQUAC - Ideal` / `Wilson` | `SRK` | Activity models |
| `Zudkevitch Joffee` / `Kabadi Danner` | `SRK` | Specialized EOS |
| `Antoine` / `Chao Seader` / `Grayson Streed` | `SRK` | Legacy correlations |
| `DBR Amine Package` | `SRK` | DBR proprietary |
| `OLI` | `SRK` | Electrolyte package |
| `COMPropertyPkg` | `SRK` | COM extension package |

A warning is logged when a fallback mapping is used.

---

## 5. Workflow: From .usc File to Running NeqSim Model

### Full Mode (Default — Recommended)

All four output methods (`to_json()`, `build_and_run()`, `to_python()`,
`to_notebook()`) default to **`full_mode=True`**. This means:

1. **Sub-flowsheet auto-classification**: Sub-flowsheets are classified as
   either "process" (shares streams with the main flowsheet) or "utility"
   (isolated). Only process sub-flowsheets are included.
2. **ProcessModel architecture**: The main flowsheet and each process
   sub-flowsheet become separate `ProcessSystem` objects composed inside a
   `ProcessModel` (multi-area plant model).
3. **E300 fluid loading**: When the `UniSimReader.read(export_e300=True)`
   option was used (default), the converter uses `EclipseFluidReadWrite.read()`
   to load the fluid with exact Tc, Pc, ω, MW, and BIPs from UniSim.
4. **Recycle convergence**: Auto-generated `Recycle` objects (in `to_python()` /
   `to_notebook()`) get a **real tolerance (`recycle_tolerance`, default `1e-2`)
   plus Wegstein acceleration (`recycle_acceleration`, default `"WEGSTEIN"`)**,
   and the generated run tail **iterates** (`setRunStep(True)` loop, then a final
   run) so the tear streams actually converge. A very large tolerance (the old
   `1e6`) accepted the seeded tear on the first pass, so recycle-fed streams
   never updated and deviated strongly from UniSim. Tune via:

   ```python
   converter = UniSimToNeqSim(model)
   converter.recycle_tolerance = 1e-3        # tighter tear
   converter.recycle_acceleration = "WEGSTEIN"  # or None to disable
   python_code = converter.to_python()
   ```
5. **Unfed columns/absorbers are skipped**: A `DistillationColumn`/`Absorber`
   whose feed stream could not be extracted from UniSim (UniSim `fractop`/
   `absorber` COM connectivity is not always resolvable) is **omitted** rather
   than emitted as a bare, feed-less column. A feed-less column **throws on
   `run()` and aborts the whole `process.run()`**, so every downstream unit
   stops executing and stays at its seed value — this was the single biggest
   cause of a "runs but nothing matches" result. The skipped column is reported
   in `converter.warnings`; reconnect its feed manually to include it.

> **Verification vehicle — `to_python()` is still the most faithful path, but
> the JSON/MCP path now iterates recycles.** The JSON path (`build_and_run()` /
> `ProcessSystem.fromJsonAndRun` / MCP `runProcess`) emits recycles with only an
> `inlet` and relies on `JsonProcessBuilder`'s Pass-2 iterative wiring to close
> forward-referenced tears. Since the multi-pass auto-run fix, when the built
> process `hasRecycles()` the builder loops `process.run()` up to
> `MAX_AUTORUN_PASSES` (15), **guarding each pass** (a unit throwing on an early
> pass no longer aborts the whole auto-run) and stopping early on
> `process.solved()` — so nested/forward-referenced recycle loops seeded at ~0
> flow now get the outer passes they need to converge on the JSON/MCP path too.
> The **generated Python** (`to_python()`) additionally seeds forward-reference
> placeholders + auto-`Recycle`, so it remains the most robust vehicle for a
> recycle-heavy plant; compare with `UniSimComparator(model, process)` after
> `exec()`-ing the generated script.
>
> **Residual JSON-path failures are model-specific topology gaps, not recycle
> convergence.** If the full plant still stops (e.g. a pipe `Failed to run … —
> Total mass cannot be zero`), trace the dead branch: the cause is usually a
> genuinely external UniSim stream with no producer (a `Valve leak`-type feed),
> or a zero-feed pipe/riser fed by a scrubber whose own inlet mixer is only
> partially wired (`Mixer … wired with N of M inlets`). These need the missing
> inlet wired manually — no number of recycle passes fixes a stream that no unit
> produces. `ProcessSystem.run()` still aborts a pass at the first throwing
> unit, so a single unfeedable unit blocks everything downstream in that pass.

To disable full mode and get only the main flowsheet operations:

```python
converter = UniSimToNeqSim(model)
python_code = converter.to_python(full_mode=False)
```

### Quick Usage (Python)

```python
from devtools.unisim_reader import UniSimReader, UniSimToNeqSim, UniSimComparator

# Step 1: Read the UniSim file (export_e300=True is default)
with UniSimReader(visible=False) as reader:
    model = reader.read(r"path\to\file.usc")

# Step 2: Inspect what was extracted
print(model.summary())

# Step 3: Convert to NeqSim JSON (full_mode=True by default)
converter = UniSimToNeqSim(model)
neqsim_json = converter.to_json()

# Step 4: View warnings and assumptions
for w in converter.warnings:
    print(f"WARNING: {w}")

# Step 5: Build and run in NeqSim
import json
from neqsim import jneqsim
ProcessSystem = jneqsim.process.processmodel.ProcessSystem
result = ProcessSystem.fromJsonAndRun(json.dumps(neqsim_json))

# Check for partial success (tolerant error handling)
if result.hasWarnings():
    print(f"Warnings: {len(list(result.getWarnings()))}")
    for w in result.getWarnings():
        print(f"  [{w.getCode()}] {w.getMessage()}")

if not result.isError():
    process = result.getProcessSystem()
    print(f"Units built: {process.size()}")
```

### Generate Python Code (Human-Readable Alternative)

Instead of JSON, generate a standalone Python script with explicit `jneqsim` API calls:

```python
converter = UniSimToNeqSim(model)
python_code = converter.to_python()  # full_mode=True by default

# Save to file
with open("process.py", "w") as f:
    f.write(python_code)
print(f"Generated {len(python_code.splitlines())} lines of Python")
```

The generated script is a **complete, runnable Python file** that includes:
1. All `jneqsim` imports (thermo systems, equipment classes)
2. Fluid/EOS definition with mapped composition and mixing rule
3. Feed streams created with temperature, pressure, and flow rate from UniSim
4. All equipment in **topological order** (upstream before downstream)
5. Equipment properties set via `jneqsim` API calls (efficiency, outlet pressure, etc.)
6. Stream wiring through outlet stream references (e.g., `separator.getGasOutStream()`)
7. Sub-flowsheet operations (included by default in `full_mode=True`)
8. `process.run()` call at the end

The generated code uses **direct NeqSim Java API calls** — no JSON intermediate.
Equipment that cannot be mapped (reactors, controllers, spreadsheets) is commented
out with a skip reason. This output is ideal for:
- **Code review** — every connection is visible and auditable
- **Manual editing** — users can modify equipment parameters, add controllers
- **Learning** — shows the exact NeqSim API mapping for each UniSim operation

**Example for a large platform model:** `to_python()` generates ~850 lines
covering ~180 operations including Splitters, ThreePhaseSeparators, Compressors,
Coolers, Mixers, ThrottlingValves, and sub-flowsheet equipment.

### Generate Jupyter Notebook (Interactive Version)

The notebook uses the **exact same shared code generators** as `to_python()` —
so the resulting process logic is always identical — but wraps equipment in
separate cells with markdown explanations (descriptions, feed tables, model
overview):

```python
converter = UniSimToNeqSim(model)
converter.save_notebook("process.ipynb")  # full_mode=True by default
```

Or get the raw dict (nbformat v4):
```python
nb_dict = converter.to_notebook()  # full_mode=True by default
```

The notebook contains:
1. **Title & overview table** — components, operations, feed count, EOS
2. **Setup cell** — `from neqsim import jneqsim` + class aliases
3. **Fluid markdown + code** — composition table and fluid creation
4. **Feed streams markdown + code** — T/P/flow table and stream creation
5. **Equipment cells** — one markdown description + one code cell per unit
6. **Run cell** — `process.run()`
7. **Results cell** — prints T, P for every unit operation
8. **Warnings cell** — any conversion assumptions or skipped items

### Generate EOT / ProcessPilot Simulator

Generate a `BaseSimulator` subclass for the ProcessPilot-NeqSimInterface
framework (reinforcement learning / optimization):

```python
converter = UniSimToNeqSim(model)
converter.save_eot_simulator("my_simulator.py", class_name="MySimulator")
```

The generated code:
- Subclasses `eot.simulators.base_simulator.BaseSimulator`
- Uses `eot.components` factory functions (`get_stream`, `get_compressor`, etc.)
- Implements `build_process()` with all equipment
- Reports `name` property from the UniSim file name
- Falls back to raw `jneqsim` calls for equipment not covered by EOT factories

**EOT demo notebook** — shows how to instantiate, run, and step the simulator:
```python
nb = converter.to_eot_notebook(class_name="MySimulator")
import json
with open("eot_demo.ipynb", "w") as f:
    json.dump(nb, f, indent=1)
```

### Code Sharing Architecture

The `to_python()`, `to_notebook()`, and `to_eot_simulator()` methods all share
the same internal code generators:

| Shared Method | Purpose |
|--------------|---------|
| `_prepare_topology()` | Topological sort, variable naming, stream resolution |
| `_gen_fluid_lines()` | Fluid creation code (EOS, components, mixing rule) |
| `_gen_feed_lines()` | Feed stream setup (T, P, flow, `process.add()`) |
| `_gen_equipment_lines()` | Per-equipment code (properties, wiring, `process.add()`) |
| `_to_pyvar()` | Convert arbitrary name to valid Python identifier |
| `_unique_var()` | Assign unique variable name avoiding collisions |
| `_outlet_ref()` | Resolve dot-notation outlet references |

This ensures that **Python scripts, notebooks, and EOT simulators always
produce the same process logic** — only the wrapping differs.

### For Complex Models (Sub-Flowsheets → ProcessModule)

Large UniSim models with sub-flowsheets should be decomposed:

```python
# The converter produces sub_flowsheets as separate process sections
neqsim_json = converter.to_json(include_subflowsheets=True)

# Main process
main_process = neqsim_json['process']

# Sub-flowsheets (each is a separate ProcessSystem)
for sf_name, sf_process in neqsim_json.get('sub_flowsheets', {}).items():
    print(f"Sub-flowsheet '{sf_name}': {len(sf_process)} operations")
```

---

## 6. Result Verification

Always compare UniSim and NeqSim results after conversion:

```python
# After building NeqSim process
comparator = UniSimComparator(model, neqsim_process)
comparisons = comparator.compare_streams()
comparator.print_report(comparisons)
```

### The R510 sample library is the converter's regression bench

`C:\Program Files (x86)\Honeywell\UniSim Design R510\Samples` ships **66 .usc
cases** (0.11–6.96 MB) spanning tutorials, refinery (FCC, isomerization),
gasifiers, electrolyzers, amine, rate-based columns, OLGA link, CCC controls and
EO. Converting the whole library exercises far more of the converter than any
single plant model, and it is how the column defect above was found.

```bash
# Convert every case, one subprocess each (a COM failure cannot abort the batch)
python devtools/unisim_batch_check.py \
    --samples-dir "C:\Program Files (x86)\Honeywell\UniSim Design R510\Samples" \
    --out report.json --work-dir %TEMP%\unisim_batch\run1

# Then RUN every generated model — static checks are not enough
python devtools/unisim_run_generated.py --dir %TEMP%\unisim_batch\run1 \
    --out runs.json --timeout 300

# Discover COM attribute names of an operation type the reader mishandles
python devtools/unisim_probe_ops.py --usc <case.usc> --types columnop,absorber
python devtools/unisim_probe_column.py <case.usc>
```

`unisim_batch_check.py` records read/convert timings, the operation-type
histogram, unmapped types, converter warnings, and an AST *undefined-name* check
plus a compile check on the generated module. Track these four numbers; all
should be zero on a healthy converter:

- unmapped operation types
- "Skipped column" / "Skipped absorber" warnings
- cases whose generated module does not compile
- cases with undefined names in the generated module

**Do not edit `unisim_reader.py` while a batch is running** — every subprocess
re-imports it, so a mid-run edit silently mixes old and new behaviour (and a
half-written file makes the remaining cases fail with "no result file").

**Reference scores on the R510 library (2026-09-07)** — use these as the bar a
converter change must not fall below:

| Metric | Value |
|---|---|
| Cases converted | 62 / 66 (4 need an uninstalled UniSim module) |
| Unmapped operation types | 0 |
| Skipped columns / absorbers / unsupported ops | 0 / 0 / 0 |
| Generated modules that compile | 62 / 62 |
| Generated modules with undefined names | 0 / 62 |
| **Generated models that run to completion** | **60 / 62** |
| Read time, 66 cases | 530 s total, 7.3 s median |

### RUNTIME failures that static checks miss

A model can convert, compile and contain no undefined names, and still abort on
`run()` — and because `ProcessSystem.run()` stops at the first throwing unit,
one bad unit leaves the whole flowsheet at its seed values. Executing the whole
corpus surfaced five distinct classes:

| Symptom | Cause | Rule |
|---|---|---|
| `'DistillationColumn' object has no attribute 'getOutletStream'` | tear-closing used `getOutletStream()` on a multi-outlet unit | use the per-type accessor map |
| `NullPointerException ... "inStream" is null` | `Type("name", None)` emitted for a unit whose feed COM did not expose | synthesise a boundary feed from the unit's product |
| `IllegalArgumentException: Feed tray index must be between 0 and N-1` | absorber fed at tray `n` with gas/liquid swapped | gas -> 0, solvent -> n-1 |
| TIMEOUT | converted column iterates without converging | `setMaxNumberOfIterations(30, True)` (hard-cap overload) |
| `'ComponentSplitter' object has no attribute 'getOutletStream'` | same as row 1 | see accessor table below |

**Outlet accessor by type** (audited against the released jar — do not assume
`getOutletStream()` exists):

| Type | Outlet accessor |
|---|---|
| `Splitter`, `ComponentSplitter` | `getSplitStream(int(i))` |
| `Separator`, `GasScrubber` | `getGasOutStream()` / `getLiquidOutStream()` |
| `ThreePhaseSeparator` | `getGasOutStream()` / `getOilOutStream()` / `getWaterOutStream()` |
| `HeatExchanger` | `getOutStream(int(i))` |
| `DistillationColumn` | `getGasOutStream()` / `getLiquidOutStream()` |
| `Electrolyzer` | `getHydrogenOutStream()` / `getOxygenOutStream()` |
| everything else converted | `getOutletStream()` |

Keep this as data (`TEAR_OUTLET_ACCESSORS`, `TEAR_SKIP_TYPES`,
`_placeholder_ports`), not as duplicated `if` chains: the port list for
forward-reference placeholders was duplicated between registration and emission,
and the two drifted apart, producing `NameError: name '_fwd_X_liquidOut' is not
defined`.

### Expected Deviations

| Property | Typical Deviation | Acceptable | Notes |
|----------|------------------|------------|-------|
| Temperature | < 1 °C | < 3 °C | Flash calculation differences |
| Pressure | 0% | 0% | Should match exactly (input) |
| Mass flow | < 0.1% | < 1% | Mass balance differences |
| Density | < 2% | < 5% | EOS differences (PR vs SRK) |
| Vapour fraction | < 0.02 | < 0.05 | Phase split sensitivity |
| Compressor power | < 5% | < 10% | Efficiency model differences |
| Heat duty | < 5% | < 10% | Enthalpy model differences |

### Factors Causing Deviations

1. **EOS differences**: UniSim PR-LK uses PR76 alpha (`m = 0.37464 + 1.54226ω − 0.26992ω²`)
   for ALL components, including those with ω > 0.49 (where PR78 uses a different cubic).
   **Fix**: Use `SystemPrLeeKeslerEos` in NeqSim — add `PRLKCORR` line after `EOS PR` in E300 file.
   This typically reduces vapour-fraction bias from −1.6% to < 0.3%.
2. **BIP (binary interaction parameters)**: UniSim PR-LK correlation BIPs are non-zero even for
   pure-prediction (never user-tuned). Extract via `pp.Kij.Values` (Section 1.1) and include in
   E300 BIC section. Without correct BIPs, vapour fraction can differ by 5%+ and oil MW by 100%+.
3. **Water BIPs**: PR-LK BIPs for H2O–N2 (≈−2.24), H2O–CO2 (≈−0.557), H2O–H2S (≈−0.390)
   are **negative** (increase cross-attraction → keep water in liquid). If a file has both
   water BIPs AND OMEGAA overrides, they must be used as a matched set. Using standard BIPs
   with modified OmegaA causes catastrophic phase split errors.
4. **Water distribution (multi-stage)**: In UniSim with recycles, water may split across
   multiple separators. NeqSim forward-flow models capture water mainly at the first stage.
   +70% water flow error at secondary stages is structurally expected if recycles are missing.
5. **Hypothetical components**: Pseudo-component property estimation differs between simulators.
   Critical properties and acentric factor estimation methods vary.
   **First check the acentric factor was transferred, not estimated** — compare the exported
   `ACF` block against `comp.AcentricityValue`. Estimated (or `0.0`) values biased a
   23-component SRK-Peneloux oil's bubble point / TVP by 12–15 %; reading `Acentricity`
   brought it to < 0.5 % (Section 1.2).
6. **Compressor efficiency**: UniSim COM sometimes returns `None` for `AdiabaticEfficiency`.
   Always check extracted efficiency values; default to 75% isentropic with a warning.
7. **Mixing rules**: UniSim may use advanced mixing rules not available in NeqSim.
8. **Transport properties**: Different viscosity/thermal conductivity correlations.
9. **Convergence**: Different solver algorithms, tolerance settings, and recycle initialization.

---

## 7. Stream Topology Reconstruction

UniSim operations reference streams by name via `.Feeds[]` and `.Products[]`.
The converter reconstructs the process topology:

1. **Identify external feeds**: Streams not produced by any operation
2. **Build producer map**: `stream_name → (operation_name, port)`
3. **Topological sort**: Kahn's algorithm to order operations by dependency
4. **Handle cycles**: Recycle blocks break dependency loops

### Dot-notation for NeqSim wiring

| Producer Type | Stream | NeqSim Reference |
|--------------|--------|------------------|
| Separator (gas) | 1st product | `"Sep.gasOut"` |
| Separator (liquid) | 2nd product | `"Sep.liquidOut"` |
| 3-Phase Sep (gas) | 1st product | `"Sep.gasOut"` |
| 3-Phase Sep (oil) | 2nd product | `"Sep.oilOut"` |
| 3-Phase Sep (water) | 3rd product | `"Sep.waterOut"` |
| Compressor | output | `"Comp.outlet"` |
| Cooler/Heater | output | `"Cooler.outlet"` |
| Valve | output | `"Valve.outlet"` |
| Mixer | output | `"Mixer.outlet"` |

### Forward Reference Handling (Recycle Loops)

When the topological sort detects cycles (equipment B referenced before it is
defined because B depends on equipment A which depends on B), the converter
creates **forward reference placeholders** — temporary Stream objects that
stand in for the not-yet-created equipment outlets.

#### How It Works

1. **Cycle detection** (`_prepare_topology`): Operations in a cycle are
   identified. The back-edge producers become forward references stored in
   `topo['fwd_ref_placeholders']`.

2. **Placeholder registration** (`_register_fwd_placeholders`): For each
   forward-referenced producer, a `_fwd_XXX` variable is created. For
   multi-outlet equipment (Separator, ThreePhaseSeparator), **port-specific
   placeholders** are also created:

   | Equipment | Placeholders Created |
   |-----------|---------------------|
   | `V-100` (Separator) | `_fwd_V_100` (generic), `_fwd_V_100_gasOut`, `_fwd_V_100_liquidOut` |
   | `S-200` (ThreePhaseSeparator) | `_fwd_S_200` (generic), `_fwd_S_200_gasOut`, `_fwd_S_200_oilOut`, `_fwd_S_200_waterOut` |
   | `K-100` (Compressor) | `_fwd_K_100` (generic only) |

3. **Placeholder stream creation** (in `to_python` / `to_notebook` /
   `to_eot_simulator`): Each placeholder is created as a `Stream` with
   temperature, pressure, and flow rate from the UniSim stream data for
   that product outlet. Port-specific placeholders use the stream data for
   each individual outlet.

4. **Outlet resolution** (`_outlet_ref`): When downstream equipment
   references a forward-referenced separator's liquid outlet (e.g.
   `"V-100.liquidOut"`), the resolver first checks for a port-specific key
   (`V-100.liquidOut`) in `fwd_ref_vars` before falling back to the
   generic placeholder.

5. **Auto-Recycle wiring** (`_gen_equipment_lines`): After the actual
   separator is created and added to the process, Recycle objects are
   automatically generated to wire each actual outlet back to its
   forward reference placeholder:

   ```python
   # Auto-recycle: wire V-100 gasOut back to forward ref placeholder
   _rcy_V_100_gasOut = Recycle("V-100_gasOut_loop")
   _rcy_V_100_gasOut.addStream(V_100.getGasOutStream())
   _rcy_V_100_gasOut.setOutletStream(_fwd_V_100_gasOut)
   process.add(_rcy_V_100_gasOut)
   ```

#### Why Port-Specific Placeholders Matter

Without port-specific placeholders, a valve downstream of a separator's
liquid outlet would incorrectly receive the combined feed placeholder
(with gas + liquid flow). This caused 500%+ flow deviations in early
versions. Port-specific placeholders ensure each downstream unit gets
the correct phase with approximately correct T, P, and flow.

#### HeatExchanger Outlet Port Resolution

HeatExchanger has two feed/product sides indexed 0 (Shell) and 1 (Tube).
The converter maps downstream references to `getOutStream(int(0))` or
`getOutStream(int(1))` based on the product's position in the products
list (set by `_extract_heatexchanger`: ShellSideProduct = index 0,
TubeSideProduct = index 1).

Port naming convention: `hx0` and `hx1` (analogous to `gasOut`/`liquidOut`
for separators). Forward-reference placeholders are created per-side when
a HeatExchanger is in a recycle loop.

**Generated code example:**

```python
E_100 = HeatExchanger("E-100")
E_100.setFeedStream(0, shell_feed)   # Shell side
E_100.setFeedStream(1, tube_feed)    # Tube side
process.add(E_100)

# Downstream: Cooler on shell product (index 0)
C_100 = Cooler("C-100", E_100.getOutStream(int(0)))

# Downstream: Valve on tube product (index 1)
VLV_100 = ThrottlingValve("VLV-100", E_100.getOutStream(int(1)))
```

**Important:** Do NOT use `getOutletStream()` for HeatExchanger — it only
returns one side. Always use `getOutStream(int(index))` for explicit
side selection.

#### Compressor Efficiency Defaults

When compressor efficiency is not available from the UniSim COM extraction
(returns `None`), the code applies these rules:

1. If the extracted value is > 1.0, it's treated as a percentage and
   converted to a fraction (e.g., 75 → 0.75)
2. If adiabatic efficiency is available (0 < eff ≤ 1), it's set via
   `setIsentropicEfficiency()`
3. If only polytropic efficiency is available, it's set via
   `setPolytropicEfficiency()` + `setUsePolytropicCalc(True)`
4. If **neither** efficiency is available, a 75% isentropic default is
   applied with a warning comment in the generated code

This matters because NeqSim defaults to 100% isentropic efficiency,
which produces unrealistically low outlet temperatures.

#### Separator Phase Detection and Entrainment Extraction

The reader now detects 2-phase vs 3-phase separators by two mechanisms:

1. **UniSim TypeName**: `flashtank` → `Separator` (2-phase),
   `sep3op` → `ThreePhaseSeparator` (3-phase)
2. **WaterProduct heuristic**: If a `flashtank` has a `WaterProduct`
   connected, it is automatically promoted to `ThreePhaseSeparator`

#### Orientation Detection (Vertical → GasScrubber)

The reader extracts separator orientation from UniSim COM attributes
(`Orientation`, `VesselOrientation`, `SeparatorOrientation`). When a
`flashtank` is detected as **vertical**, the NeqSim type is mapped to
`GasScrubber` instead of `Separator`.

| UniSim Type | Orientation | NeqSim Type |
|---|---|---|
| `flashtank` | horizontal (default) | `Separator` |
| `flashtank` | vertical | `GasScrubber` |
| `flashtank` + WaterProduct | any | `ThreePhaseSeparator` |
| `sep3op` | any | `ThreePhaseSeparator` |

`GasScrubber` extends `Separator` in NeqSim — it is a vertical vessel
optimised for removing liquid droplets from a gas stream, with K-value
sizing constraints and a default 10% liquid level.

**Entrainment** is extracted from UniSim COM and mapped to NeqSim
`setEntrainment()` calls. The reader tries multiple COM attribute names
for each entrainment direction:

| Entrainment Direction | UniSim COM Attributes (tried in order) | NeqSim `setEntrainment` Args |
|---|---|---|
| Liquid in gas (oil carryover) | `LiqCarryOverMolFrac`, `LiqCarryOverFrac`, `LiquidInVapourFraction`, `LiqInVap`, `LiquidCarryover` | `(val, "volume", "product", "oil", "gas")` |
| Gas in liquid (gas carry-under) | `VapCarryUnderMolFrac`, `VapCarryUnderFrac`, `VapourInLiquidFraction`, `VapInLiq`, `VapourCarryunder` | `(val, "volume", "product", "gas", "liquid")` |
| Water in oil (3-phase) | `WaterInOilFraction`, `WaterInOil`, `AqInOil`, `AqueousInOilFraction` | `(val, "volume", "product", "aqueous", "oil")` |
| Oil in water (3-phase) | `OilInWaterFraction`, `OilInWater`, `OilInAq`, `OilInAqueousFraction` | `(val, "volume", "product", "oil", "aqueous")` |

Generated Python code example:
```python
# Three-phase separator with entrainment from UniSim
mp_sep = ThreePhaseSeparator("20VA102", heater_mp.getOutletStream())
mp_sep.setEntrainment(0.084, "volume", "product", "aqueous", "oil")  # water in oil
mp_sep.setEntrainment(0.002, "volume", "product", "oil", "aqueous")  # oil in water
process.add(mp_sep)
```

**Note:** If the UniSim COM does not expose entrainment attributes (some
model versions or configurations may not), the extraction silently skips
them — the separator will use NeqSim defaults (zero entrainment).

---

## 8. Handling Sub-Flowsheets

UniSim uses sub-flowsheets (template operations) for modular process sections.
In NeqSim, these map to either:

1. **Separate ProcessSystem objects** composed in a `ProcessModel`
2. **Flattened into the main ProcessSystem** (simpler but may not handle inter-area recycles)

### Auto-Classification (full_mode=True, Default)

When `full_mode=True` (the default), the converter automatically classifies
sub-flowsheets into **process** (shares material streams with the main
flowsheet) and **utility** (isolated, e.g., heating/cooling medium loops).
Only process sub-flowsheets are included in the generated code.

Classification is done by `classify_subflowsheets()`:

```python
classification = converter.classify_subflowsheets()
# Returns: {'TPL1': 'process', 'TPL3': 'utility', 'COL1': 'process', ...}
process_sfs = converter.get_process_subflowsheets()
# Returns: ['TPL1', 'TPL2', 'TPL5', 'TPL7', 'COL1']
```

A sub-flowsheet is classified as **process** if any of its operations produce
or consume a stream that also appears in the main flowsheet or another process
sub-flowsheet. Otherwise it is **utility** (typically heating/cooling medium,
flare, utility water systems).

### Architecture Decision

| Model Complexity | Strategy |
|-----------------|----------|
| Main + 1-2 small sub-flowsheets | Flatten into single ProcessSystem |
| Main + 3+ sub-flowsheets | ProcessModel with separate ProcessSystems |
| Sub-flowsheet has own fluid package | Must be separate ProcessSystem |

### Sub-Flowsheet to ProcessModel Mapping

In `full_mode=True`, each process sub-flowsheet becomes its own
`ProcessSystem`, all composed inside a `ProcessModel`:

```python
from neqsim import jneqsim
ProcessModel = jneqsim.process.processmodel.ProcessModel

plant = ProcessModel("Platform")
plant.add("Main", main_process)
plant.add("TPL1", tpl1_process)
plant.add("TPL2", tpl2_process)
plant.add("COL1", col1_process)
# Utility sub-flowsheets (TPL3, TPL4, TPL6) are excluded
plant.run()
```

---

## 9. Command-Line Interface

```bash
# Print model summary
python devtools/unisim_reader.py path/to/file.usc --summary

# Output NeqSim JSON
python devtools/unisim_reader.py path/to/file.usc --json

# Generate standalone Python script
python devtools/unisim_reader.py path/to/file.usc --python process.py

# Generate Jupyter notebook
python devtools/unisim_reader.py path/to/file.usc --notebook process.ipynb

# Generate EOT / ProcessPilot simulator
python devtools/unisim_reader.py path/to/file.usc --eot my_sim.py --eot-class MySimulator

# Generate EOT demo notebook
python devtools/unisim_reader.py path/to/file.usc --eot-notebook eot_demo.ipynb

# Save full extracted model
python devtools/unisim_reader.py path/to/file.usc --save extracted.json

# Fast extraction (topology only, no stream properties)
python devtools/unisim_reader.py path/to/file.usc --no-streams --summary

# Combined: Python + notebook + EOT in one command
python devtools/unisim_reader.py path/to/file.usc --python p.py --notebook n.ipynb --eot s.py

# Show UniSim GUI window during extraction (useful for debugging)
python devtools/unisim_reader.py path/to/file.usc --visible --summary
```

---

## 10. Known Limitations

1. **Windows only** — COM automation requires Windows + UniSim installed
2. **Hypothetical components** — pseudo-components need manual C7+ characterization
3. **Tuned BIPs** — Extractable via `pp.Kij.Values` (see Section 1.1). The
   `unisim_reader.py` does not yet automate this, but manual extraction is
   straightforward. The returned matrix uses -32767.0 as a diagonal sentinel.
4. **Column internals** — tray count, condenser/reboiler presence, feed stage,
   reflux ratio and top/bottom pressure ARE transferred (see the column section
   in Part 1). Tray hydraulics, packing details, side draws, pumparounds and
   multi-spec column control are not.
5. **Dynamic models** — only steady-state data extracted
6. **Control logic** — PID controllers produce TODO comments, not functional controllers
7. **Custom correlations** — UniSim's user-defined correlations not transferred
8. **Performance curves** — compressor/pump performance maps not extracted
9. **Multiple fluid packages** — the reader exports one E300 file per fluid
   package, but most generated ProcessSystem builds still use the default
   process fluid unless per-stream or per-sub-flowsheet package assignment is
   explicitly wired. Treat multi-package models as verified only after the
   generated JSON/Python shows the expected `e300FilePath` for each area and
   the build route loads E300 through `EclipseFluidReadWrite.read(...)`.
10. **Absorber columns** — two-feed absorbers are wired (vapour-rich feed to the
    bottom tray, lean solvent to the top); a single-feed absorber still emits a
    TODO for the missing solvent. Glycol/TEG contactors (name contains "glyc",
    "teg", or "dehydrat") are modeled as `ComponentSplitter` for water removal
    instead of `DistillationColumn`.
11. **SetPoint / Adjuster wiring** — generates skeleton code but wiring is often incomplete
12. **Recycle convergence** — heavily circular models (5+ forward references) may
    not converge with placeholder initial values; multiple `process.run()` calls
    or manual tuning may be needed
13. **Compressor efficiency** — COM extraction sometimes returns `None` even when
    the UniSim model has efficiency data; defaults to 75% isentropic
14. **Spreadsheet operations** — represented as `SpreadsheetBlock`, but formula
   fidelity depends on extracting import/export cells and formulas from COM.
15. **Logical operations** — produce comments/controller metadata; no executable
   process logic is transferred.
16. **Balance, virtual-stream, and template placeholders** — represented through
   `UnisimCalculator`/`SubFlowsheet` adapters to preserve topology. This solves
   structural build gaps but not detailed specification or formula semantics.
17. **DistillationColumn solver divergence** — NeqSim's sequential-substitution and
    inside-out column solvers diverge for C3/C4-rich (NGL-range) feeds at low
    pressure. Feeds with < 30% methane and significant C3+ fractions will not
    converge. Lighter feeds (e.g. deethanizer with 51% CH4) converge reliably.
    Every generated column therefore carries a hard iteration cap
    (`setMaxNumberOfIterations(30, True)`) so a non-converging column cannot
    hang the whole `process.run()`; the trade-off is a column that stops short
    of its own convergence criterion. Build the column **outside** the
    `ProcessSystem` when you need a converged column.
    See the TUTOR1 notebook for a worked example.
18. **HeatExchanger pressure drops** — NeqSim `HeatExchanger` does not model
    pressure drops; outlet pressures equal inlet pressures. UniSim models
    typically include 0.5–1.0 bar pressure drop per side. This causes small
    temperature deviations in downstream equipment.
19. **HeatExchanger UA tuning** — The `HeatExchanger.setUAvalue()` parameter
    must be tuned to match UniSim's heat duty. Counter-current heat balance
    differences between UniSim and NeqSim typically produce 1–2°C deviation
    on outlet temperatures.
20. **Full ProcessModel timeout** — Large models with 5+ sub-flowsheets, 10+
    recycles, and Adjusters may time out during `plant.run()` even with relaxed
    recycle tolerances. Root cause is typically the combination of Adjuster
    iteration, absorber column convergence, and multi-area coordination.
    **Workaround**: Test individual ProcessSystem areas or connected sub-paths
    first, then build up incrementally. The connected main-path approach (no
    recycles, manual feed data) runs in < 1 second for even large models.
    Measured on the R510 sample library (60 of 62 converted models run to
    completion): the only two that still exceed 240 s are the cases with
    **several columns inside recycle loops** — Soybean-Biofuel (3 columns,
    4 recycles) and the eNRTL amine plant (absorber + column). Each outer
    recycle pass re-solves every column from scratch, so cost scales as
    `n_columns x column_iterations x outer_passes`. The column iteration cap
    bounds a single column, not this product.
21. **Separator liquid MW deviation** — When UniSim separators have
    `has_water_product=False` (2-product), the NeqSim `Separator` includes
    water in the liquid phase. This causes liquid MW to be lower than UniSim's
    value (water dilutes the MW). Using `ThreePhaseSeparator` overcorrects by
    removing ALL water. Expect 20-40% liquid MW deviation at low/medium
    pressures. Gas MW and temperatures are much more accurate.
22. **Utility sub-flowsheets excluded** — In `full_mode=True`, sub-flowsheets
    classified as "utility" (no shared streams with process flowsheet) are
    excluded. This is correct for heating/cooling medium loops but may
    miss utility systems that interact with process streams through
    non-standard connections.
23. **E300 fluid parity is necessary but not sufficient** — A full-fluid E300
   route fixes component-property transfer, but it does not reconcile UniSim
   virtual streams, spreadsheet/balance calculations, template units, or
   sub-flowsheet interface wiring. Keep the verification report split into
   separate statuses for fluid export/use, structural build, and numerical
   stream matching.
24. **Synthesised boundary feeds** — when UniSim COM exposes no inlet for a
   block, the converter seeds a boundary feed from that block's own product
   stream so the unit (and everything downstream) survives. The unit then runs
   at roughly the right operating point but is no longer connected to its real
   upstream source. Every such feed is reported as a converter warning
   ("Synthesised a boundary feed for ...") — review them before trusting a
   mass balance.
25. **Cases that need a UniSim module you do not have** cannot be opened at all
   (`E_ACCESSDENIED` from `SimulationCases.Open`). In the R510 library this is
   the 3 `CCC Series 5` controls cases and the EO electrical case: 62 of 66
   convert, 4 are environment-limited, not converter-limited.

---

## 11. Data Class Reference

The extracted UniSim model uses these Python dataclasses:

### UniSimComponent

```python
@dataclass
class UniSimComponent:
    name: str             # e.g. "Methane", "CO2", "C7 GRAND*"
    index: int            # Position in fluid package component list
    is_hypothetical: bool # True if name ends with '*'
```

### UniSimFluidPackage

```python
@dataclass
class UniSimFluidPackage:
    name: str              # e.g. "Basis-1"
    property_package: str  # e.g. "Peng-Robinson", "SRK", "CPA"
    components: List[UniSimComponent]

    @property
    def component_names(self) -> List[str]: ...
```

### UniSimStreamData

```python
@dataclass
class UniSimStreamData:
    name: str
    temperature_C: Optional[float]      # Celsius
    pressure_bara: Optional[float]       # bara
    mass_flow_kgh: Optional[float]       # kg/h
    molar_flow_kgmolh: Optional[float]  # kgmol/h
    vapour_fraction: Optional[float]     # 0-1
    mass_density_kgm3: Optional[float]   # kg/m3
    molecular_weight: Optional[float]    # g/mol
    enthalpy_kJkg: Optional[float]       # kJ/kg
    composition: Optional[Dict[str, float]]  # component_name -> mole fraction
    n_phases: Optional[int]
    viscosity_cP: Optional[float]
    thermal_conductivity: Optional[float]
    specific_heat_kJkgC: Optional[float]
```

### UniSimEnergyStream

```python
@dataclass
class UniSimEnergyStream:
    name: str
    heat_flow_kW: Optional[float]  # kW
```

### UniSimOperation

```python
@dataclass
class UniSimOperation:
    name: str                              # Equipment name e.g. "K-100"
    type_name: str                         # UniSim internal type e.g. "compressor"
    feeds: List[str]                       # Feed stream names
    products: List[str]                    # Product stream names
    energy_feeds: List[str]                # Energy feed stream names
    energy_products: List[str]             # Energy product stream names
    properties: Dict[str, Any]             # Type-specific extracted properties
```

Common `properties` keys by operation type:

| Operation | Property Keys |
|-----------|--------------|
| Compressor | `outlet_pressure_bara`, `adiabatic_efficiency`, `polytropic_efficiency` |
| Valve | `outlet_pressure_bara` |
| Cooler / Heater | `outlet_temperature_C`, `duty_kW` |
| HeatExchanger | `UA`, `duty_kW` |
| Pipe | `length_m`, `diameter_m`, `roughness_m` |
| Pump | `outlet_pressure_bara`, `efficiency` |

### UniSimFlowsheet

```python
@dataclass
class UniSimFlowsheet:
    name: str
    material_streams: List[UniSimStreamData]
    energy_streams: List[UniSimEnergyStream]
    operations: List[UniSimOperation]
    sub_flowsheets: List['UniSimFlowsheet']  # Recursive
```

### UniSimModel

```python
@dataclass
class UniSimModel:
    file_path: str
    file_name: str
    fluid_packages: List[UniSimFluidPackage]
    flowsheet: Optional[UniSimFlowsheet]

    def summary(self) -> str: ...          # Human-readable overview
    def all_operations(self) -> List[UniSimOperation]: ...  # Flat list incl. sub-FS
    def all_streams(self) -> List[UniSimStreamData]: ...    # Flat list incl. sub-FS
    def to_dict(self) -> Dict: ...         # JSON-serializable
```

---

## 12. Driving the native UniSim case instead of converting it

Conversion is not always the right answer. If the converted NeqSim flowsheet
does not close its recycles, any capacity or tie-in conclusion drawn from it is
unsafe. For **capacity studies, tie-back evaluations and bottleneck ranking on a
large as-built case**, drive the original `.usc` through COM and read the
results back. This keeps the fluid package, every adjust and every recycle
exactly as built. Verified on a 151-component, 366-stream, 8-recycle,
42-adjust platform case.

### Operation TypeName values — a wrong filter fails silently

`compressor` (**not** `compressop`), `expandop`, `pumpop`, `coolerop`,
`heaterop`, `mixerop`, `teeop`, `valveop`, `flashtank`, `sep3op`, `fractop`,
`recycle`, `adjust`, `spreadsheetop`, `templateop`.

Filtering rotating equipment on `compressop` returns **zero** compressors and
the duty table silently comes back exchangers-only. Always enumerate first:

```python
from collections import Counter
Counter(str(sheet.Operations.Item(i).TypeName)
        for i in range(sheet.Operations.Count))
```

### Component properties: use the `*Value` float accessors

| Use | Not |
|-----|-----|
| `comp.MolecularWeightValue` | `comp.MolecularWeight` (returns `RealVariable`) |
| `comp.StdLiquidDensityValue` | `IdealLiqDensityValue`, `MassDensityValue` (both `None` for pseudo components) |
| `comp.AcentricityValue`, `comp.NormalBoilingPointValue` (°C) | — |

`float(comp.MolecularWeight)` raises, and a `try/except → None` around it
silently drops the entire pseudo-component slate. If a composition mapping
reports the C7+ fractions as unmapped, this is the cause.

### Topology can be modified, not just specifications

On a licensed case retrieved from a document system these all succeed:

```python
sheet.MaterialStreams.Add("NewFeed")            # create a stream
feed.Temperature.SetValue(35.1, "C")
feed.Pressure.SetValue(70.3, "bar")
feed.MassFlow.SetValue(58080.0, "kg/h")
feed.ComponentMolarFraction.SetValues(fractions, "")  # ordered by FluidPackage.Components
mixer.Feeds.Add(feed)                            # connect it
solver.CanSolve = True                           # re-solve, typically < 1 s
```

`E_ACCESSDENIED` on a write means the variable is **calculated**, not that the
licence blocks writing. Probe `variable.CanModify` before concluding otherwise.

### Process lifetime and reproducibility

When the Python process exits, the COM server closes the case; a follow-up
script doing `SimulationCases.Item(i)` gets `IndexError`. Every script must
`app.SimulationCases.Open(path)` itself. Start each run by closing any open work
case and `shutil.copy2`-ing a fresh copy from the source, otherwise repeated
runs stack duplicate feeds onto the same mixer.

### Interpreting a tie-in result

- **Rank every stream by relative change**, not just the ones you expected to
  move. A gas-rich tie-back loads the gas path far harder than the bulk mass
  suggests — one verified case added 6.6% to plant feed but 80.9% to
  first-stage flash gas.
- **Compute actual volumetric rate** (`mass / MassDensity`) for gas outlets.
  Separator and gas-train capacity scales with actual m³/h, not kg/h.
- **Fixed pressure ratios are not a capacity check.** The model specifies
  compressor pressure ratios and solves for power, with no performance maps and
  no driver ratings. The output is the *duty required*, not whether the machine
  can deliver it. Say so explicitly in any capacity report.
- Check the product mass balance closes against the added feed increment before
  trusting the deltas.

---

## 13. Troubleshooting

| Issue | Cause | Fix |
|-------|-------|-----|
| `pywintypes.com_error` | UniSim not installed or wrong version | Verify UniSim installation |
| Empty stream values | Stream not solved in UniSim | Open file in UniSim GUI, run solver first |
| `-32767` values | UniSim empty marker | Filter with `val > -30000` check |
| COM timeout | Large model loading | Increase `time.sleep()` after `.Open()` |
| Wrong component count | Multiple fluid packages | Check which FP the stream uses |
| Missing operations | Sub-flowsheet not recursed | Use `model.all_operations()` |
| Composition doesn't sum to 1 | Hypothetical components excluded | Re-normalize after filtering |
| Compressor outlet T too low | Efficiency = 100% (ideal) | Check for missing efficiency; defaults to 75% |
| Valve flow deviation 500%+ | Wrong forward ref placeholder | Port-specific placeholders now fix this |
| Separator downstream wrong phase | Generic fwd ref placeholder used | Ensure `_register_fwd_placeholders` ran |
| Recycle not converging | Too many forward references | Try multiple `process.run()` calls or tune placeholders |
| `AttributeError` on COM property | Operation type doesn't have that property | Wrap in try/except or check `TypeName` |
| Column diverges / mass runaway | C3/C4-rich feed at low P | Known solver limitation; build column outside ProcessSystem; use flash separation as fallback |
| HX outlet T differs 1–2°C | UA mismatch or no ΔP modeled | Tune UA value; add note that NeqSim HX has no pressure drop |
| SalesGas T off by 5°C | Propagated HX deviation | CoolGas deviation amplified through counter-current HX; adjust UA |

---

## 14. Verified Reference Cases

| Case | File | Components | Operations | Converged | Notes |
|------|------|-----------|------------|-----------|-------|
| TUTOR1 | `TUTOR1.usc` | 7 (N₂, CO₂, C₁–nC₄) | 13 | 11/13 streams | DePropanizer column diverges; upstream matches within 1°C. Notebook: `examples/notebooks/tutor1_gas_processing.ipynb` |
| R510 SG Cond | R510 model | 31 (lumped pseudo-C7+ through C30P*) | 185 main + 8 sub-flowsheets (~250 total) | 78% isolated, 71% connected | PR-LK EOS with E300 BIPs. Full mode: 5 process + 3 utility sub-flowsheets |

### TUTOR1 Lessons Learned

The TUTOR1 gas processing tutorial was the first end-to-end verified conversion.
Key findings that apply to any UniSim conversion:

1. **Recycle loops converge well**: The Gas/Gas HX ↔ LTS recycle loop converged
   in 3 iterations using placeholder streams seeded from UniSim data.

2. **HeatExchanger UA tuning**: Setting `UA = 35000 W/K` produced CoolGas ≈ 5.5°C
   vs UniSim's 6.6°C. The deviation propagates to SalesGas (15°C vs 10°C). The
   UA value needs case-by-case tuning against UniSim's heat duty.

3. **Column solver limitation**: The DePropanizer (7 components, 24% CH4,
   27% C₂, 24% C₃, 24% C₄ at 14 bara) diverged with all three solver types
   (DIRECT_SUBSTITUTION, INSIDE_OUT, DAMPED_SUBSTITUTION) and multiple
   configurations (1–5 trays). This is a known NeqSim limitation for NGL-range
   feeds. **Workaround**: Build the column outside the ProcessSystem to avoid
   re-run divergence affecting upstream convergence.

4. **Pressure drops not modeled**: UniSim's Gas/Gas HX has ~0.7 bar ΔP per side;
   NeqSim passes pressure through unchanged. This is cosmetic for most
   comparisons but compounds through multi-stage processes.

5. **DewPoint and Balance operations**: UniSim's DewPoint (balance op) and
   ADJ-1 (adjuster) are not needed for mass balance; safe to skip.

6. **Heating Value spreadsheet**: Property-only calculations; safe to skip.

### R510 SG Condensation Model — Lessons Learned

The R510 SG Condensation model is a large-scale offshore processing model with
31 components (including lumped pseudo-components C10-C11* through C30P*, aromatics,
and glycols), 8 sub-flowsheets, 13+ recycle loops, and ~250 total operations.
This is the first full-mode verified conversion with sub-flowsheet classification.

**Model characteristics:**
- **Fluid package**: PR-LK (Peng-Robinson Lee-Kesler) with 31 components
- **Feed streams**: 3 feeds — "Reservoir oil" (MW=58.5, 1,950,684 kg/h),
  "Formation water" (MW=18.0, 398,728 kg/h), "Res gas" (MW=19.6, 30,271 kg/h)
- **Sub-flowsheets**: 5 process (TPL1, TPL2, TPL5, TPL7, COL1) +
  3 utility (TPL3, TPL4, TPL6)
- **Generated code**: ~2000 lines of Python with ProcessModel architecture

#### Comparison Results

**Isolated unit-by-unit** (each unit fed with correct UniSim inlet data):
- 97 GOOD (< 5% deviation), 9 WARN (5-15%), 30 BAD (> 15%), 66 SKIPPED
- **78% match rate** across 136 comparable stream properties

**Connected main-path model** (17 units, no recycles, error propagation):
- 11 OK, 1 WARN, 5 BAD — **71% match rate** in 0.5 seconds
- Temperature accuracy: excellent (< 0.3°C for most streams)
- Gas MW matching: good (< 5%)
- Liquid MW: moderate deviation due to water handling (see below)

#### Key Findings

1. **E300 fluid loading is essential for pseudo-components.** The `read(export_e300=True)`
   option extracts Tc, Pc, ω, MW, and BIPs for all 31 components including lumped
   pseudo-components (C10-C11*, C12-C13*, etc.). Without E300 BIPs, phase split
   deviations exceed 100% for heavy components.

2. **Component name mapping for lumped pseudo-components.** UniSim uses names like
   `C10-C11*`, `C12-C13*`, `C14-C15*`, `C16-C18*`, `C19-C20*`, `C21-C23*`,
   `C24-C29*`, `C30P*`. These map 1:1 to the same names in the E300 file.
   Do NOT assume individual components (C10, C11, C12, ...) — the model uses
   lumped groups.

3. **Separator water handling (2-phase vs 3-phase).**
   When UniSim's `sep3op` has `has_water_product: False` (all separators in R510),
   use NeqSim `Separator` (2-phase), NOT `ThreePhaseSeparator`. The 2-phase Separator
   gives combined oil+water liquid matching UniSim's 2-product behavior.
   `ThreePhaseSeparator` removes ALL water, overcorrecting liquid MW
   (e.g., 201.9 vs target 98.2, while 2-phase gives 66.2 — closer overall).

4. **Compressor efficiency extraction fails for some models.** All 6 compressors
   in R510 returned `None` for `AdiabaticEfficiency` from COM. The 75% isentropic
   default causes 10-32°C outlet temperature deviations. To improve accuracy,
   back-calculate efficiency from UniSim inlet/outlet data.

5. **Recycle convergence.** Even with `setTolerance(1e6)` on all 13 recycles,
   the full ProcessModel with 8 areas still times out. Root cause is likely the
   combination of Adjusters, absorber columns, and multi-area iteration.
   **Workaround**: Test individual process areas or connected sub-paths first
   (the 17-unit connected model runs in 0.5s). Build up to full model incrementally.

6. **JSON key format.** The extracted JSON uses `pressure_bara` (not `pressure_kPa`)
   and `mass_flow_kgh` (not `mass_flow_kg_h`). When writing comparison scripts,
   use these exact keys.

7. **Sub-flowsheet mapping uses positional order.** When the JSON has sub-flowsheet
   operations that cannot be matched by name or stream overlap, the reader falls
   back to positional matching based on the original JSON order. This handles
   cases where sub-flowsheet operation names are generic (e.g., `MIX-100` appears
   in both main and sub-flowsheet).

8. **Temperature matching is the strongest comparison metric.** Temperatures
   showed < 0.3°C deviation across the connected path. Gas-phase MW was within
   5% for most equipment. Use temperature as the primary validation metric;
   MW and flow deviations are often caused by liquid-phase water handling
   rather than thermodynamic model errors.

### Operation Handler Registry Lessons Learned

For large UniSim conversions, do **not** implement every UniSim operation as a
separate UniSim-named NeqSim equipment class. Keep the core physical API native
to NeqSim and extend the converter registry instead:

1. Add or update the `UniSimOperationHandler` entry with the correct
   `strategy` and `stream_role`.
2. Use native NeqSim equipment for real process physics.
3. Use `UnisimCalculator` for stream-carrying balance, virtual-stream, and
   template-interface placeholders when equations are not yet extracted.
4. Use `SpreadsheetBlock` for spreadsheet formulas once import/export cells are
   known.
5. Promote an adapter to a real NeqSim equipment class only when equations,
   ports, properties, and regression tests are clear.

Validate registry changes with `python devtools/test_unisim_outputs.py`. The
suite includes pure-Python checks for output modes, E300 transfer, operation
handler strategy, and JSON `_unisim_operation_mapping` summaries.

## Reverse direction: NeqSim JSON → UniSim (`devtools/unisim_writer.py`)

`UniSimWriter` builds a wired `.usc` case from the same JSON that
`ProcessSystem.fromJsonAndRun()` consumes, so one JSON file can drive both
tools and the results can be compared directly.

```python
from devtools.unisim_writer import UniSimWriter

writer = UniSimWriter(
    visible=True,
    template_path="licensed_case.usc",  # copy first — it is modified
    clear_template=True,                # empty flowsheet + fluid package
)
writer.build_from_json(json_str, save_path="gas_compression.usc")
writer.close()
```

### Verified COM rules (UniSim Design R510)

1. **The template must come from a licensed UniSim session.** A `.usc` saved
   from a case created by `SimulationCases.Add()` inherits restricted write
   access: reads and `MaterialStreams.Add` succeed, but every
   `Operations.Add` and `variable.SetValue` fails with E_ACCESSDENIED
   (`-2147024891`). Symptom: "Operations created: 0" with 10+ access-denied
   warnings. Fix: point `template_path` at a real saved case.
2. **Fluid-package edits require a basis change.** Wrap component add/remove in
   `BasisManager.StartBasisChange()` … `EndBasisChange()` (or the
   `…Invisibly` variants). Outside a basis change, `Components.Add` returns
   E_ACCESSDENIED on a live case.
3. **Collections use `Remove(i)` / `RemoveAll()`, not `Item(i).Delete()`.**
   This applies to `Flowsheet.Operations`, `MaterialStreams`, `EnergyStreams`
   and `FluidPackage.Components`.
4. **Compressor efficiency members are `CompPolytropicEff` and
   `CompAdiabaticEff`** (percent), not `PolytropicEfficiency`. Related useful
   members: `ProductPressure`, `ProductTemperature`, `FeedPressure`, `Energy`,
   `PolytropicHead`, `EfficiencyType`, `UsingCurves`, `Curves`.
5. **Duty-consuming operations need an attached energy stream.** Without
   `flowsheet.EnergyStreams.Add(name)` + `op.EnergyStream = …`, a compressor,
   pump, expander, cooler or heater never closes its energy balance: discharge
   temperature and power read back as the empty sentinel **`-32767`**. Seeing
   `-32767` in results is the signature of a missing energy stream, not a
   convergence failure.
6. `Pressure.Calculate()` can be denied even when `Pressure.SetValue(v, 'bar')`
   works — always try `SetValue` first, `Calculate` as fallback.

Discover member names for an unfamiliar operation with early binding
(`win32com.client.gencache.EnsureDispatch`) in a throwaway process, then keep
the writer itself on `win32com.client.dynamic.Dispatch`.

### Round-trip accuracy

A three-stage export compression train (25 → 200 bara, SRK, polytropic
efficiency 0.78, interstage cooling to 35 °C, knock-out scrubbers) built from
one JSON gave:

| Quantity | NeqSim | UniSim | Deviation |
|---|---|---|---|
| Stage 1 shaft power | 4216.4 kW | 4206.4 kW | −0.24 % |
| Stage 2 shaft power | 3571.7 kW | 3555.1 kW | −0.46 % |
| Stage 3 shaft power | 2824.0 kW | 2802.7 kW | −0.75 % |
| Total shaft power | 10 612.1 kW | 10 564.2 kW | −0.45 % |
| Stage 1 discharge T | 97.56 °C | 97.24 °C | −0.32 °C |
| Export gas flow | 117 593 kg/h | 117 623 kg/h | +0.03 % |

Sub-1 % agreement on power is the expected level for identical SRK setups; the
residual comes from small differences in the polytropic path integration.