Source profileQuality 86/100

equinor/neqsim/.github/skills/neqsim-agentic-process-optimization/SKILL.md

neqsim-agentic-process-optimization

Agentic, closed-loop optimization of large multi-area NeqSim ProcessModel plants using the newest automation, convergence-gating, and equipment-introspection APIs. USE WHEN: an agent must optimize a live full-plant flowsheet (operating setpoints, compressor pressures, heater temperatures, routing fractions) across one or more years/scenarios, with robust convergence handling, surge/RVP/spec constraints, and per-trial feasibility gating. Covers ProcessAutomation.getAdjustableParameters, ProcessMo

Source repository stars
136
Declared platforms
0
Static risk flags
0
Last source update
2026-08-05
Source checked
2026-08-05

Decision brief

What it does—and where it fits

This skill is the recipe for an agent that optimizes a large, already-built multi-area plant (e.g. an offshore separation + recompression + export train) by turning the newest NeqSim automation and introspection APIs into a robust optimization loop. It assumes the flowsheet is a…

Best for

  • USE WHEN: an agent must optimize a live full-plant flowsheet (operating setpoints, compressor pressures, heater temperatures, routing fractions) across one or more years/scenarios, with robust convergence handling, surg…

Not for

  • Tasks that require unconfirmed production actions or broad system permissions.
  • Environments where the pinned source and install steps cannot be inspected.

Compatibility matrix

Platform support, with evidence labels

PlatformStatusEvidenceWhat to check
CodexNot declaredNo explicit evidencePortability before use
Claude CodeNot declaredNo explicit evidencePortability before use
CursorNot declaredNo explicit evidencePortability before use
Gemini CLINot declaredNo explicit evidencePortability before use
Open the compatibility checker

Installation

Inspect first. Install second.

The source command is displayed only when detected. A safe inspection prompt is always available so your agent can explain every action before execution.

Source-detected install commandSource
npx skills add https://github.com/equinor/neqsim --skill ".github/skills/neqsim-agentic-process-optimization"
Safe inspection promptEditorial

Inspect the Agent Skill "neqsim-agentic-process-optimization" from https://github.com/equinor/neqsim/blob/9e8d44a141bba600026d2229969b49af50f34237/.github/skills/neqsim-agentic-process-optimization/SKILL.md at commit 9e8d44a141bba600026d2229969b49af50f34237. List every install step, command, network request, credential, file read/write, external action, and rollback step. Explain whether it fits my task. Do not install or execute anything until I approve.

Workflow

What the source asks the agent to do

  1. 01

    8. Picking up brand-new NeqSim functionality (updates workflow)

    New Java methods (like getOperatingPoint, runUntilConverged, getAdjustableParameters, RvpResult) are only callable once the classes are on the Python classpath. Two supported paths:

    New Java methods (like getOperatingPoint, runUntilConverged, getAdjustableParameters, RvpResult) are only callable once the classes are on the Python classpath. Two supported paths:A. Devtools (workspace classes, no repackaging) — best for repo task notebooks; picks up target/classes ahead of the shaded JAR:Rebuild first if you changed Java: mvnw.cmd compile (or package -DskipTests). This MUST be the first NeqSim-touching cell — JPype allows one JVM per process, so any earlier from neqsim import jneqsim locks out the overr…
  2. 02

    1. The four pillars (all verified in NeqSim ≥ 3.13.0)

    Why these matter for an agent: they replace the fragile "call .run() twice and hope" pattern with explicit did-it-converge and did-any-unit-fail signals, and they expose objective/constraint numbers (compression power, surge distance, RVP spec) as structured JSON the agent can p…

    Why these matter for an agent: they replace the fragile "call .run() twice and hope" pattern with explicit did-it-converge and did-any-unit-fail signals, and they expose objective/constraint numbers (compression power,…
  3. 03

    2. Discover the decision space

    python from neqsim import jneqsim or devtools ns (see §8) import json

    python from neqsim import jneqsim or devtools ns (see §8) import jsonauto = plant.getAutomation() plant = ProcessModel
  4. 04

    NOTE: jpype returns java.lang.String, not Python str — json.loads needs str(...).

    params = json.loads(str(auto.getAdjustableParametersJson())) for p in params["parameters"]: print(p["name"], p["address"], p["unit"], p["lowerBound"], p["upperBound"], p["source"]) python def converge(plant, maxiter=30, tol=5e-3, settlepasses=2, softmaxerr=0.05): """Run the coup…

    [ ] json.loads(str(...)) ALWAYS. jpype returns java.lang.String, not Python[ ] Did you runUntilConverged (not a single .run()) before reading the objective?[ ] Did you gate on unit failure (failedUnitName) plus a relaxed maxError
  5. 05

    3. Robust single-trial evaluation (the core helper)

    Always gate on convergence and run status. A trial that diverges or throws in one unit must return a large penalty, never a misleading objective.

    Penalty, not crash. Optimizers must keep exploring after a bad trial.maxError is a relative boundary error. Near-zero anti-surge recyclesTwice-run is obsolete as a gate — runUntilConverged drives convergence;

Permission review

Static risk signals and limitations

No configured static risk pattern was detected

This is not proof of safety. Runtime behavior, indirect dependencies, and hidden external systems are outside the static scan.

Evidence record

Why each signal appears

EvidenceSourceComputedTestedEditorial
SignalValueEvidence typeMeaning
Quality score86/100ComputedDocumentation, specificity, maintenance, and trust rules
Repository stars136SourceRepository attention, not individual Skill quality
Compatibility0 platformsSourceDeclared in the catalog source record
Usage guideautomated source guideEditorialGenerated or reviewed according to the visible evidence level

Pinned source

Provenance and original SKILL.md

Repository
equinor/neqsim
Skill path
.github/skills/neqsim-agentic-process-optimization/SKILL.md
Commit
9e8d44a141bba600026d2229969b49af50f34237
License
Apache-2.0
Collected
2026-08-05
Default branch
master
View the original SKILL.md

Agentic Process-Model Optimization

This skill is the recipe for an agent that optimizes a large, already-built multi-area plant (e.g. an offshore separation + recompression + export train) by turning the newest NeqSim automation and introspection APIs into a robust optimization loop. It assumes the flowsheet is a ProcessModel assembled from several named ProcessSystem areas (see neqsim-platform-modeling).

Use neqsim-optimization-and-doe for the algorithm (SQP, PSO, BatchStudy, ProcessSimulationEvaluator → SciPy/Pyomo). Use this skill for the plumbing: how to read the decision space, evaluate one trial robustly, gate feasibility, and score the objective from real equipment results.


1. The four pillars (all verified in NeqSim ≥ 3.13.0)

NeedAPIReturns
Decision space (bounded knobs)ProcessAutomation.getAdjustableParameters() / getAdjustableParametersJson()List<AdjustableParameter> with name/address/unit/lowerBound/upperBound/source
Robust convergence of a coupled plantProcessModel.runUntilConverged(int maxIterations, double tolerance)boolean converged; pair with getConvergenceReportJson()
Per-trial feasibility / failure gatingProcessModel.getRunStatus() / getRunStatusJson(), ProcessSystem.getRunStatus()RunStatus (completed/success/failedUnitName/failedUnitError)
Objective + constraints from equipmentCompressor.getOperatingPoint(), Standard_ASTM_D6377.RvpResultpower, surge/stonewall margins; certified RVP

Why these matter for an agent: they replace the fragile "call .run() twice and hope" pattern with explicit did-it-converge and did-any-unit-fail signals, and they expose objective/constraint numbers (compression power, surge distance, RVP spec) as structured JSON the agent can parse without walking Java object trees.


2. Discover the decision space

from neqsim import jneqsim   # or devtools `ns` (see §8)
import json

auto = plant.getAutomation()                      # plant = ProcessModel
# NOTE: jpype returns java.lang.String, not Python str — json.loads needs str(...).
params = json.loads(str(auto.getAdjustableParametersJson()))
for p in params["parameters"]:
    print(p["name"], p["address"], p["unit"], p["lowerBound"], p["upperBound"], p["source"])
  • source = "INPUT_VARIABLE" → a settable equipment input (compressor outlet pressure, heater outlet temperature, valve outlet pressure, …).
  • source = "ADJUSTER" → a knob already wired to an Adjuster; let the model solve it, do not also optimize it (double control = divergence).
  • UNBOUNDED_THRESHOLD = 1.0e9 → bounds at/above this are "no real bound"; the agent MUST supply a physically meaningful [lo, hi] before optimizing.

getAdjustableParameters() surfaces model-side inputs with bounds. If your decision variables live in a Python driver (e.g. a ProcessInput dataclass or a year selector), map each one explicitly to a NeqSim address or a rebuild argument — the registry will not invent them for you.

Prerequisite — the model must already carry the optimization data basis. getAdjustableParameters() only surfaces knobs the model has, and capacity constraints only fire if the equipment has design limits. Before optimizing, confirm the model was built with: line sizes (ID, length, roughness, elevation) and manifold/header sizes for hydraulics; valve/choke Cv (and choke Cv-vs-opening); compressor/pump performance maps + speeds; separator dimensions + design K; equipment design limits (rated power, surge margin, NPSH, erosional velocity, design P/T, MAWP, valve max Cv); manipulable setpoints with physical bounds; and the objective + economic/spec basis. Use enterprise-process-model-build-verify with target_fidelity="optimization_ready" (its OPTIMIZATION_DATA_BASIS / optimization_data_basis output) to gather and gate this basis, and neqsim-process-modeling for the community build checklist.


3. Robust single-trial evaluation (the core helper)

Always gate on convergence and run status. A trial that diverges or throws in one unit must return a large penalty, never a misleading objective.

def converge(plant, max_iter=30, tol=5e-3, settle_passes=2, soft_maxerr=0.05):
    """Run the coupled ProcessModel until boundary streams stop moving.

    Recycle-heavy plants have near-zero-flow anti-surge recycles that inflate
    the *relative* boundary-error metric (a tiny stream gives a large % error
    even when physically settled), so a strict `tol=1e-4` rarely passes. Accept
    a trial when NO unit threw AND the model either strictly converged or
    reached a relaxed boundary band; keep genuine unit failures infeasible.
    """
    converged = bool(plant.runUntilConverged(int(max_iter), float(tol)))
    for _ in range(settle_passes):                # settle slow recompression loops
        try:
            plant.run()
        except Exception:
            break
    report = json.loads(str(plant.getConvergenceReportJson()))   # str() — jpype String
    status = json.loads(str(plant.getRunStatusJson()))           # str() — jpype String
    failed = status.get("failedUnitName") not in (None, "", "null")
    max_err = report.get("maxError", float("inf"))
    soft_ok = (max_err == max_err) and (max_err < soft_maxerr)   # not NaN and small
    ok = (not failed) and (converged or soft_ok)
    return ok, report, status

def evaluate(plant, setpoints, *, max_iter=30, tol=5e-3):
    """Apply setpoints, converge, score. Returns (objective, record)."""
    try:
        apply_setpoints(plant, setpoints)          # see §4
        ok, report, status = converge(plant, max_iter, tol)
        if not ok:
            failed = status.get("failedUnitName") or report.get("maxError")
            return 1e9, {"feasible": False, "reason": f"non-converged/{failed}", **setpoints}
        obj, parts = objective_and_constraints(plant)   # see §5
        return obj, {"feasible": True, **parts, **setpoints}
    except Exception as exc:                        # JVM exception from a unit
        return 1e9, {"feasible": False, "reason": f"exception:{exc}", **setpoints}

Key rules:

  • Penalty, not crash. Optimizers must keep exploring after a bad trial.
  • maxError is a relative boundary error. Near-zero anti-surge recycles inflate it to ~0.8 even when settled — don't demand tol=1e-4. Gate on unit failure (failedUnitName) plus a relaxed maxError band, and add a couple of settling run() passes.
  • Twice-run is obsolete as a gaterunUntilConverged drives convergence; the extra passes only damp slow recompression loops. Raise max_iter, not hacks.
  • Record everything. Keep a list of record dicts → tornado/trace plots and results.json later. Report the best feasible trial (min objective among feasible=True), never the last evaluate() result — the final call can land on a non-converged retry and write null power/RVP/surge into results.json.
  • One lever may be infeasible. If a single setpoint (e.g. oil-heater T) can't satisfy two coupled constraints (RVP and compressor surge), say so and add the relieving lever (recompression suction pressure) to DECISION_VARS; switch the optimizer from minimize_scalar to scipy.minimize(Nelder-Mead, bounds=…).

4. Apply setpoints (string-addressable, dirty-tracked)

Prefer ProcessAutomation string addresses over walking the object graph:

def apply_setpoints(plant, sp):
    auto = plant.getAutomation()
    updates = {}                                   # address -> value (one unit family)
    if "export_P_bara" in sp:
        updates["Compression::export compressor.outletPressure"] = sp["export_P_bara"]
    auto.setValues(updates, "bara", False)         # batch, no run yet
    # temperatures use a different unit -> separate batch
    if "oil_heater_T_C" in sp:
        auto.setVariableValue("Sep train A::oil heater second stage.outletTemperature",
                              sp["oil_heater_T_C"], "C")
        auto.setVariableValue("Sep train B::oil heater second stage.outletTemperature",
                              sp["oil_heater_T_C"], "C")
    # convergence happens in converge() via runUntilConverged

If addresses are uncertain, self-heal: auto.setVariableValueSafe(addr, val, unit) returns JSON with an auto-corrected address instead of throwing. Use auto.validateAddress(addr) (returns None when valid) as a pre-flight check.

When the plant is built imperatively (handles in Python scope), it is equally valid to set levers directly on the unit and let converge() settle them: train_A.getUnit("oil heater second stage").setOutTemperature(T, "C").


5. Objective and constraints from real equipment

Compression power + surge/stonewall margin

def compressor_metrics(plant):
    total_power_MW = 0.0
    min_surge_margin = float("inf")
    within_chart_all = True
    for area in plant.getAllProcesses():           # each ProcessSystem
        for u in area.getUnitOperations():
            if u.getClass().getSimpleName() != "Compressor":
                continue
            op = json.loads(str(u.getOperatingPointJson()))   # str() — jpype String
            p = op.get("power_MW")
            if p == p:                              # not NaN
                total_power_MW += p
            d = op.get("distanceToSurge")           # fraction; NaN if no chart
            if d == d:
                min_surge_margin = min(min_surge_margin, d)
            if op.get("withinChart") is False:
                within_chart_all = False
    return total_power_MW, min_surge_margin, within_chart_all

getOperatingPoint() / getOperatingPointJson() fields: power_MW, polytropicEfficiency, head_kJkg, flow_m3hr, speed_rpm, distanceToSurge, distanceToStoneWall, surgeFlowRateMargin_m3hr, withinChart, limitingConstraint (none/surge/stonewall/no_chart). Uncomputable margins are NaN — always test x == x before using them.

Compressor control mode: solve-speed vs predictive (choose per objective)

A charted compressor can run two ways, and the mode decides what is an input vs an output in your optimization — pick it deliberately:

ModeSetupFixed (input)Computed (output)Use for
Solve-speed (default for most plants)setOutletPressure(P) + setUseCompressorChart(True) + setSolveSpeed(True)discharge pressurespeed, power, surge marginspec/capacity/max-throughput studies where the network has fixed pressure boundaries
PredictivesetUseCompressorChart(True) + setSpeed(rpm) + setSolveSpeed(False) (do not pin outlet P)shaft speeddischarge pressure, head, power, surge marginspeed-as-decision-variable optimization, surge/turndown and dynamic studies

Rules of thumb:

  • If the discharge feeds a fixed pressure boundary (export pipeline pressure, a process level held by a downstream scrubber/level controller, an import header), solve-speed is the physically correct steady-state control mode — the machine trims speed to hold that pressure, and speed / power / surge margin are the outputs you optimize against. This is the right mode for "max production while meeting RVP / dew-point / cricondenbar", because product specs are set by separation temperatures/pressures, not by throughput, so the binding limit is compressor power/surge or separator gas load — all of which fall out of a solve-speed run.
  • If you want speed itself as a decision variable ("what speed gives the lowest power while still delivering P_export?"), use predictive mode: fix the speed, let the chart compute discharge pressure and power. Sweeping speed then traces the head/power curve directly (e.g. 90 %→95 %→100 %→105 % speed maps to a monotonically rising discharge pressure and head).
  • Making a whole recycle flowsheet predictive requires letting the internal pressure nodes float (e.g. a recompressor whose discharge mixes back into an upstream stage): fix all speeds, drop the interior setOutletPressure calls, and let a pressure-node/recycle pass settle the mixer pressures. Do this behind a PREDICTIVE_PRESSURE toggle so the default solve-speed model (used for spec and capacity studies) stays intact. Mixing the two modes on the same recycle loop without a pressure-node pass will diverge.
  • Either way keep anti-surge active (getAntiSurge().setActive(True), setSurgeControlFactor(...)) so a turned-down point recycles onto the control line instead of falling outside the map and returning NaN margins.

Export-oil RVP (certified spec) — use RvpResult

D6377 = jneqsim.standards.oilquality.Standard_ASTM_D6377
def export_oil_rvp_bara(stream, ref_T_C=37.8):
    std = D6377(stream.getFluid())
    std.setReferenceTemperature(ref_T_C, "C")      # if available on the build
    res = std.getRvpResult(D6377.RvpMethod.RVP_ASTM_D6377)
    r = json.loads(str(res.toJson()))              # str() — jpype String; {value, unit, method, referenceTemperatureC, valid}
    return r["value"], r["valid"]

The convenience stream.getRVP(37.8, "C", "bara", "RVP_ASTM_D6377") still works and is fine inside a hot loop; switch to RvpResult when you need the method label, reference temperature, and valid flag for the report.

Assemble objective with penalties

def objective_and_constraints(plant, rvp_target=0.79, surge_floor=0.10):
    power_MW, surge_margin, within = compressor_metrics(plant)
    rvp, rvp_valid = export_oil_rvp_bara(export_oil_stream())
    penalty = 0.0
    if not rvp_valid or rvp > rvp_target:
        penalty += 50.0 * max(0.0, rvp - rvp_target) + (0.0 if rvp_valid else 25.0)
    if surge_margin < surge_floor or not within:
        penalty += 100.0 * max(0.0, surge_floor - surge_margin) + (0.0 if within else 50.0)
    objective = power_MW + penalty                 # minimise compression power, feasibly
    return objective, {"power_MW": power_MW, "surge_margin": surge_margin,
                       "rvp_bara": rvp, "penalty": penalty}

Pick the objective from the task: minimise compression power, maximise gas/oil export, minimise fuel gas, or a weighted blend. Express every constraint as a soft penalty so gradient-free optimizers degrade gracefully.


6. Choosing the optimizer (defer to neqsim-optimization-and-doe)

Each full-plant evaluation is expensive (seconds–minutes) and the response is noisy (recycle drift) and non-smooth (regime switches, flares). Therefore:

Decision spaceRecommendedNotes
1–2 knobs1-D/2-D sweep + interpolationcheapest, most transparent (see notebook RVP-vs-heater sweep)
3–6 continuous knobsscipy.optimize.minimize(method="Powell") or Nelder-Mead, or NeqSim SQPoptimizerbound via penalties; small maxiter
Global / many local minimaNeqSim Particle Swarm, or coordinate descent restartsuse when sweeps show multimodality
Pareto (power vs export)MultiObjectiveOptimizertrade-off front
Screening / DoEBatchStudy + ProcessSystem.copy()parallel, see §7

Bridge to SciPy/Pyomo/BoTorch via ProcessSimulationEvaluator when you need algorithms NeqSim lacks (Bayesian, MINLP). Do not claim NeqSim has Bayesian optimization or LHS — it does not.

Built-in shortcut — AgenticProcessOptimizer (NeqSim ≥ 3.13.0). Before hand-rolling the §3 evaluate-helper + §6 SciPy loop, consider auto.newOptimizer(): a ready-made bounded Nelder–Mead search that already does the per-trial evaluate() gating, penalty folding, trajectory logging, and never-throw JSON contract described in this skill. Build the problem straight from string addresses:

opt = auto.newOptimizer()
opt.addVariable("Compression::Export Compressor.outletPressure", 80.0, 200.0, "bara")
opt.minimize("Compression::Export Compressor.power", "kW")
opt.addConstraintLessOrEqual("Export Oil.RVP", 0.79, "bara", 1.0e4)
opt.setSeed(42).setMaxEvaluations(80)
result = json.loads(str(opt.optimizeToJson()))  # never throws; includes trajectory
readiness = json.loads(str(opt.getReadinessJson()))  # ML/agentic self-rating

Use the manual SciPy/Pyomo bridge only when you need an algorithm it lacks (Bayesian, MINLP, true multi-objective Pareto) or parallel deep-copy sweeps (§7).


7. Multi-year / multi-scenario sweeps with deep copies

ProcessSystem.copy() (and ProcessModel rebuild) produce independent deep copies — verified independent so parallel trials cannot cross-contaminate.

years = [2033, 2034, 2035, 2036]
best = {}
for year in years:
    plant = build_plant(year)          # rebuild the full ProcessModel for the year
    x0 = initial_setpoints(plant)
    res = minimize(lambda x: evaluate(plant, vec_to_sp(x))[0], x0,
                   method="Powell", options={"maxiter": 30, "xtol": 0.5})
    best[year] = collect(plant, res)
  • Rebuild per year because feed composition/rate (and thus the whole heat/mass balance) changes with Year. Optimizing operating levers on a stale build gives the wrong answer.
  • For embarrassingly parallel screening at a fixed year, copy() each area and evaluate trials in separate threads/processes (BatchStudy, MonteCarloSimulator, or the neqsim_runner subprocess bridge).
  • Cache values that don't change between trials (base CAPEX, fixed-duty units); classify knobs as "technical" (need a rebuild/converge) vs "operating" (only re-converge) to cut runtime.

8. Picking up brand-new NeqSim functionality (updates workflow)

New Java methods (like getOperatingPoint, runUntilConverged, getAdjustableParameters, RvpResult) are only callable once the classes are on the Python classpath. Two supported paths:

A. Devtools (workspace classes, no repackaging) — best for repo task notebooks; picks up target/classes ahead of the shaded JAR:

import os, sys
from pathlib import Path
PROJECT_ROOT = Path(r"C:\Users\ESOL\Documents\GitHub\neqsim")
os.environ["NEQSIM_PROJECT_ROOT"] = str(PROJECT_ROOT)
sys.path.insert(0, str(PROJECT_ROOT / "devtools"))
from neqsim_dev_setup import neqsim_init, neqsim_classes
ns = neqsim_init(project_root=PROJECT_ROOT, recompile=False, verbose=True)
ns = neqsim_classes(ns)

Rebuild first if you changed Java: mvnw.cmd compile (or package -DskipTests). This MUST be the first NeqSim-touching cell — JPype allows one JVM per process, so any earlier from neqsim import jneqsim locks out the override (restart the kernel if so).

B. Repackage the JAR into the pip neqsim — best when an existing notebook already uses from neqsim import jneqsim everywhere and you don't want to touch 100+ cells:

.\mvnw.cmd package -DskipTests
Copy-Item target\neqsim-<version>.jar `
  "$env:APPDATA\Python\Python312\site-packages\neqsim\lib\java11\neqsim-<version>.jar" -Force

Verify in a fresh process before relying on it:

from neqsim import jneqsim
c = jneqsim.process.equipment.compressor.Compressor("c")
assert hasattr(c, "getOperatingPointJson")
assert "runUntilConverged" in dir(jneqsim.process.processmodel.ProcessModel)

8.5 Unified equipment-utilization roll-up + inlet-pressure lower-limit search

To "maximize production within utilization limits" you need a single view that puts compressors, gas-turbine drivers, and separators/scrubbers on the same 0-1 scale, plus a way to push an operating variable (e.g. inlet pressure) until the first constraint binds. Two complementary approaches:

A. NeqSim-native (preferred). Activate every unit's CapacityConstraint, then read one side-effect-free snapshot:

  • Compressors: attach a chart (surge/stonewall/speed constraints) or, if chartless, comp.getMechanicalDesign().setMaxDesignPower(driverSiteRatedKW) so the power constraint has a basis.
  • Separators/scrubbers: sep.initMechanicalDesign() -> SeparatorMechanicalDesign.setGasLoadFactor(K) -> setRetentionTime(t) -> readDesignSpecifications() -> calcDesign().
  • Then json.loads(str(process.getUtilizationSnapshotJson())) gives per-unit maxUtilization, limitingConstraint, feasible, power_kW, and a plant bottleneck + anyOverloaded. Drive the search with ProcessAutomation.evaluate() or AgenticProcessOptimizer.

Native max-throughput-at-capacity (one call, both ProcessSystem and ProcessModel). ProcessAutomation.findMaxThroughputJson(feedAddresses, minRate, maxRate, rateUnit, utilizationLimit) enables the separator capacity constraints, then bisects the total feed rate (all feeds scaled proportionally to their base rate) until the first unit's maxUtilization reaches utilizationLimit (a 0-1 fraction). It leaves the model at the feasible maximum and returns {maxRate, rateUnit, feasibleAtMin, bindingUnit, bindingConstraint, bindingUtilizationPercent}. This is the string-addressable, never-throwing replacement for a hand-rolled inlet/feed bisection loop. Pair with enableCapacityConstraints() / prepareForCapacityStudyJson() / validateForOptimizationJson() / getBottleneckRankingJson(topN) for the setup and diagnostics.

Product-quality observables (spec constraints). getProductQualityJson(address) (optionally with a reference temperature) returns, for a resolved stream (area-qualified Area::Unit, unit.port, or a bare unit -> its first outlet), the export-oil RVP/TVP (rvp_bara, tvp_bara via Standard_ASTM_D6377) and the gas cricondenbar_bara / cricondentherm_K (via calcPTphaseEnvelope), each computed on a cloned fluid so the live flowsheet is untouched. It never throws — a metric that cannot be computed is reported as rvpError / envelopeError. Use these as the spec side of a maximise-throughput-subject-to-RVP/cricondenbar optimisation.

Capacity for ALL equipment types (not just separators). ProcessAutomation.enableCapacityConstraints() now enables the capacity constraints on every CapacityConstrainedEquipment in the flowsheet (pumps, valves, pipelines, heaters/coolers, heat exchangers, manifolds, ...), not only separators — so any of them can become the binding bottleneck in getUtilizationSnapshot() / getBottleneckRankingJson() / findMaxThroughputJson(). It deliberately preserves the chartless-compressor gating (surge/speed stay disabled so utilisation stays smooth and power-driven) by calling reinitializeCapacityConstraints() on compressors instead of a blind enableAllConstraints(), and still adds the separator Souders-Brown gas-load constraint. Set the design basis for each type first (compressor setMaxDesignPower, pump/valve/pipe design limits, separator setGasLoadFactor) so the utilisation has a meaningful denominator.

Routing / feed-scale as decision variables (#7). Feed streams already expose flowRate as a writable INPUT (feed-scale). Splitters now also expose one bounded splitFactor_i (0-1) INPUT per outlet in getAdjustableParameters() — the routing decision variables. Reading <Splitter>.splitFactor_i returns the current fraction; writing it sets that branch's relative weight and the splitter renormalises the factors to sum to 1. Because they carry [0,1] bounds, AgenticProcessOptimizer.useAdjustableParameters() picks them up automatically, so the optimizer can redistribute flow between trains/branches as part of a maximise-production search. (Give feed flowRate explicit bounds if you want the optimizer to treat feed-scale as a decision variable too.)

Parallel batch evaluation for ML / DoE (#6). ProcessAutomation.evaluateBatchJson(candidates, unit, readbacks, maxParallel) (and the full overload with readbackUnit, maxIterations, tolerance) scores a list of setpoint maps in one call. For a ProcessSystem with maxParallel > 1 it evaluates each candidate on an independent ProcessSystem.copy() on its own thread, so the batch is genuinely parallel and the live model is left untouched — ideal for SciPy/BoTorch/GA/agent populations. For a ProcessModel (no copy()), or maxParallel == 1, it runs sequentially on the live facade. Each result carries the full evaluate payload (converged, iterations, maxError, failedUnitName, failedUnitError) plus its index; the root reports parallel, feasibleCount, and firstFeasibleIndex.

Maximise production and manage emissions. Combine the pieces: (1) decision space = getAdjustableParameters() (bounded setpoints + splitter routing; bound feed flowRate for feed-scale); (2) feasibility = enableCapacityConstraints() + the capacity snapshot across all equipment; (3) objective = an AgenticProcessOptimizer.setObjectiveFunction reward such as production − λ·(total compressor power) (compression shaft power is a direct CO2 proxy for turbine-driven trains), or a ProductionOptimizer.optimizePareto with [MAXIMIZE production, MINIMIZE Σ compressor power] to get the production-vs-emissions trade-off front. evaluateBatchJson read-backs of each compressor's power give the emissions term per candidate for an external Python optimizer.

B. Transparent Python roll-up (API-risk-free, good when the separator capacity path is not yet wired). Merge three families into equipment_utilization(tags, sizes):

  • compressor util = shaft power / driver site-rated kW (+ anti-surge OK flag from the chart operating point);
  • turbine util = load_pct/100 vs ISO-derated site-rated power (GasTurbineVendorPerformance);
  • separator/scrubber util = Souders-Brown gas load vs a size fixed once at base with a ~1.15 margin (see neqsim-separator-modelling), recomputing vg_max at the current density.
  • Return per-unit util, limiting_unit, max_utilization, all_antisurge_ok, and a single feasible flag.

Inlet-pressure lower-limit search. Parametrize the flowsheet by process/inlet pressure (cleanest trick in a builder: add PROC_P = proc_p as a local at the top of build_model(proc_p=PROC_P) so every internal reference picks up the argument with one edit). Size separators once at the base (highest) pressure, then rebuild+run at each lower pressure, evaluate equipment_utilization, and return the lowest feasible pressure. Physics: lowering inlet pressure raises the export compression ratio and the actual gas volume, so a scrubber gas-load or a compressor surge/power constraint becomes the binding limit. The rebuild-per-point sweep is slow — gate it behind an env flag and do NOT re-read live plant data inside the loop.


9. Reporting (defer to neqsim-professional-reporting)

Persist a trace of every trial and the optimum to results.json:

results = {
  "key_results": {"year": 2035, "min_compression_power_MW": power_MW,
                  "export_oil_rvp_bara": rvp, "surge_margin": surge_margin},
  "validation": {"converged": True, "rvp_spec_met": rvp <= 0.79,
                 "all_compressors_within_chart": within},
  "optimum_setpoints": best_setpoints,
  "tables": [{"title": "Per-year optimum", "headers": ["Year", "Power MW", "RVP bara", "Surge margin"],
              "rows": rows}],
}

Always plot: objective convergence trace, the active-constraint (RVP or surge) vs the binding knob, and a per-year optimum summary. Add a discussion block per figure (observation → mechanism → implication → recommendation).


10. Pitfalls checklist

  • json.loads(str(...)) ALWAYS. jpype returns java.lang.String, not Python str. json.loads(comp.getOperatingPointJson()) raises "the JSON object must be str, bytes or bytearray, not java.lang.String". If that error is swallowed by a bare except, every trial silently reports power_MW=0.0 and surge_margin=NaN — the objective collapses to the RVP/spec penalty and the optimizer "succeeds" on a meaningless metric. Wrap every getXxxJson() in str(...).
  • Did you runUntilConverged (not a single .run()) before reading the objective?
  • Did you gate on unit failure (failedUnitName) plus a relaxed maxError band — not a strict tol=1e-4? Near-zero anti-surge recycles inflate the relative boundary error; demand convergence too tightly and every trial is "non-converged" even though the plant is physically settled.
  • Does results.json report the best feasible trial from the trace, not the last evaluate() call? The final call can hit a non-converged retry → null power/RVP/surge in key_results.
  • If one lever can't satisfy two coupled constraints (RVP and surge), did you add the relieving lever and switch to a multivariate optimizer?
  • Did you test compressor margins for NaN before comparing?
  • Are ADJUSTER-sourced knobs left to the model (not also optimized)?
  • Did you supply real bounds for any UNBOUNDED_THRESHOLD parameter?
  • Did you rebuild per year (composition changes), not reuse a stale build?
  • Did failed trials return a penalty (never crash the optimizer)?
  • Did you verify the new APIs are on the classpath (devtools or repackaged JAR)?
  • Java helpers (if any) compile on Java 8 — no var, List.of, String.repeat.

Alternatives

Compare before choosing