Reliable solves and phase-preserving streams¶
A finite outlet temperature doesn't establish that a calculation converged. Fugacio provides numerical reports for roots, recycles, rigorous columns, equation-oriented flowsheets, energy-balanced units, and optimization. Use these reports when accepting a design or presenting a result.
The failure contract¶
No public call returns a plausible number from a failed solve. Every equilibrium calculation comes in two forms:
- A checked
*_with_infoform returns the best iterate and aSolveReport. Its derivatives are nonfinite unless the report converged, so an optimizer can't consume a failed sensitivity. - A value-only form returns NaN whenever that report fails.
This covers the cubic, gamma-phi, liquid-liquid, three-phase, and PC-SAFT
flashes and saturation calls, reaction equilibrium, and every
property-package method (flash_pt, bubble_pressure,
dew_temperature, flash_ph, and so on).
import jax
import jax.numpy as jnp
from fugacio.sim import package_for
pkg = package_for(("methane", "n-butane"))
x = jnp.array([0.6, 0.4])
solved = pkg.bubble_pressure_with_info(330.0, x)
solved.report.to_dict()["status"] # "trivial": the iteration found y = x
pkg.bubble_pressure(330.0, x).value # nan
# Above the cricondenbar there's no bubble point, and no usable derivative.
jax.grad(lambda p: pkg.bubble_temperature(p, x).value)(200e5) # nan
SolveStatus names the outcome:
| Status | Meaning |
|---|---|
CONVERGED (0) |
The independently checked residual passed its tolerance. |
MAX_ITERATIONS (1) |
The iteration cap was reached first. |
NONFINITE (2) |
A NaN or infinite value appeared. |
SINGULAR (3) |
A linear system was singular. |
STALLED (4) |
The residual stopped decreasing. |
INVALID_INPUT (5) |
The specification is invalid or violates an operating limit. |
INFEASIBLE (6) |
No physical solution satisfies the specification, such as one that needs negative amounts. |
OUT_OF_DOMAIN (7) |
A well-posed request lies outside the model's domain, such as a vapor pressure above the critical temperature. |
TRIVIAL (8) |
An equilibrium iteration collapsed onto identical phases where a distinct phase was required. |
Unit operations share one contract (see fugacio.sim.units). Every unit is a
compiled kernel that returns its outlets with a report, and a failed kernel's
outlets are NaN:
- An eager call raises
ConvergenceErrorfor a failed solve, orValueErrorfor a violated operating limit fromfugacio.sim.unit_limits: a valve, mixer, drum, or turbine that would raise pressure, a pump or compressor that would lower it, split fractions outside[0, 1]or not summing to one, or a pump with a vapor inlet. - A traced call (inside
jax.jit, a flowsheet recycle, or an optimizer) can't raise. It returns NaN outlets with nonfinite derivatives; the unit'sreport, and each NaN outlet'sStream.report, record the failure.
from fugacio.sim import Stream, valve
feed = Stream.from_fractions(("methane", "propane"), jnp.array([0.9, 0.1]), 10.0, 300.0, 20e5)
valve(feed, 30e5) # ValueError: a valve can't raise pressure
jax.jit(lambda p: valve(feed, p).t)(30e5) # nan: the traced limit fails the report
The saved-case audit (fugacio.sim.cases) checks solved streams against the
same unit_limits predicates, so a limit can't be enforced in one place and
forgotten in another.
rigorous_column, stoichiometric_reactor, the flow reactors
(equilibrium_reactor, cstr, and pfr), and reactive_flash follow the same
contract; a failed reactor also keeps its report, balances, and profiles for
diagnosis. Where a unit offers check=False, it returns its best iterate
instead, with a failed report and nonfinite derivatives, so check
result.report (or result.converged) before using it.
Read a solve report¶
import jax.numpy as jnp
from fugacio.thermo import newton_system_with_info, require_converged
result = newton_system_with_info(
lambda x, target: x**2 - target,
jnp.array([1.0]),
jnp.array([4.0]),
lower=jnp.array([0.0]),
)
require_converged(result.report, "design equation", ("target relation",))
result.value # [2.0]
result.report.to_dict() # Strict JSON on the host
Reports contain a status, an iteration count, the maximum absolute scaled
residual, the last scaled step, and the index of the largest residual. A small
step alone doesn't establish convergence. ConvergenceError retains the report
and calculation context. Units that independently verify an energy balance
report zero numerical iterations for that verification; this isn't a count of
their internal flash iterations. nan_unless_converged(value, report) applies
the value-only contract to your own calculations, and
fugacio.thermo.diagnostics.with_status overrides a report's status where a
condition fails.
newton_system_with_info accepts characteristic variable and equation scales and
optional bounds. Its Newton search reduces the actual residual and keeps trial
variables within the bounds. Bounds aid the search; they don't replace any
original equation. The scalar bracketed_root_with_info checks both a valid
bracket and the resulting residual: a bracket without a sign change is
INVALID_INPUT, and a sign change across a pole, whose residual stays large as
the bracket shrinks, fails. scanned_root_with_info first scans a wide bracket
for the first finite sign change, so a residual that's undefined over part of
the bracket still works. bracketed_root returns the same checked root without
its report; its derivative is nonfinite when the check fails, so use the
reporting variant to accept a value.
Access results by attribute: most carry a report field alongside their
values, and a unit result also exposes its outlets as outlets.
Test stability first¶
A converged two-phase flash can still be wrong: it considers one liquid and one
vapor, so a feed that splits into two liquids can return a converged
vapor-liquid answer. Fugacio checks stability with one shared tangent-plane
search, fugacio.thermo.stability.tpd_search, which starts from the feed, the
Wilson-like estimates, and an enrichment toward every component, on both the
liquid and the vapor branch:
pkg.stability(t, p, z)on every property package returns aStabilityResult(stable, tpd, trial, branch, converged).flash_lleanddecantertest stability before they split a liquid.- An eager
flash_drumwarns withPhysicalAcceptanceWarningwhen an outlet is unstable, andfugacio.sim.acceptance.flash_drum_checkedaudits both outlets; itscheck()raisesPhysicalAcceptanceError. flash_pt_checked,audit_stream, and flowsheet audits reject a state with a negative tangent-plane distance, andPhysicalReport.failures()explains each rejection in plain language.
A finite-start search can't prove a global minimum. A negative distance
establishes instability; a verdict of stability also requires every trial to
reach a stationary point (converged).
Keep a stream's phase state¶
Temperature and pressure don't determine the quality of a pure saturated fluid.
Stream therefore carries optional vapor component flows, vapor_n, in addition
to total component flows. Heater, valve, machine, flash, and rigorous-column
outputs retain their resolved phase inventories. Splitters scale both inventories.
Stream enthalpy, entropy, and volume use those inventories instead of repeating a
PT flash that would discard saturation quality.
from fugacio.sim import Stream, package_for, splitter
steam = package_for(["water"], "iapws")
wet = Stream.from_ph(
("water",), jnp.ones(1), 2.0, 1e5, 25000.0, model=steam,
)
a, b = splitter(wet, jnp.array([0.3, 0.7]))
wet.check()
Stream.from_ps accepts pressure and molar entropy. For known saturated endpoints,
Stream.from_fractions(..., phase="liquid") or phase="vapor" selects the branch.
The default constructor retains PT behavior. Empty streams have zero extensive
properties and use a finite trial composition during property evaluation.
Use the same property package and energy reference throughout an energy balance.
The stream doesn't store a property package. Named package factories bind the
component order and reject a mismatched stream. stream.reordered(components)
reorders material and phase inventories together. Packages constructed directly
from arrays can't infer component names; the caller must keep their order aligned.
Pure-fluid PH and PS flashes on mixture packages solve quality and temperature together inside the saturation region. The reference-fluid package uses its published PH and PS state functions. Equation-oriented pure-fluid streams use molar enthalpy as their thermal coordinate so that quality remains an unknown when saturation temperature is fixed.
For the same reason, heater(feed, t_out=...) on a pure fluid raises
ValueError when t_out is exactly its saturation temperature at the outlet
pressure: the quality is undetermined. Specify vapor_fraction (0 for
saturated liquid, 1 for saturated vapor, or any quality in between) or duty
instead. adiabatic_flash(feed, p, duty=...) separates a letdown or a heated
pure fluid on its saturation line, where a temperature can't fix the phase
amounts.
Accept a flowsheet calculation¶
result = fs.solve_with_info(parameters)
result.check()
streams = result.streams
for name, report in result.reports.items():
print(name, report.to_dict())
Sequential flowsheet reports identify recycle blocks, units, and invalid named
streams, and result.units keeps the heat, work, and report each unit returned.
fs.solve() checks those reports by default. solve_with_info retains best
iterates for diagnosis; a unit that fails outside a recycle iteration can still
raise a ConvergenceError, naming the unit, before a complete flowsheet result
exists. Recycles converge with Broyden's method unless you pass another
method.
With a property package, Flowsheet(model=pkg) audits every solved stream for
equilibrium, stability, and parameter applicability after convergence.
result.accepted combines convergence with those audits, result.audits holds
each stream's PhysicalReport, and fs.solve() raises
PhysicalAcceptanceError for a stream that fails. The audits run once, outside
the recycle iterations, so they don't slow the solve itself.
EOFlowsheet.solve() returns an EOSolution with a report and checks concrete
failures by default. check=False exposes its best iterate. Errors identify the
responsible block equation or design specification. EO flash blocks impose zero
flow for an absent phase instead of imposing equifugacity on a phase that doesn't
exist. Rigorous-column errors identify stage material, equilibrium, or energy
rows and column specifications.
Heat exchangers retain their documented second-law cap for an infeasible request.
Their report marks a missed specification as infeasible, and result.check()
raises. This lets an interactive caller inspect the capped result explicitly.
The copilot checks this report and returns structured errors instead of using the
capped result as a successful specification. Tool inputs and outputs must be
strict JSON; nonfinite numbers cause an error.
Exchanger temperature curves preserve the supplied inlet states. Pressure drops are distributed linearly along each stream's flow direction, and each outlet's PH state closes the shared duty. Single-phase bulk properties differentiate the existing phase directly, so an absent phase's equilibrium derivative can't invalidate an otherwise regular property sensitivity.
Single-phase PH and PS results retain absent-phase trial compositions and K values for inspection, but those trial values don't carry derivatives. Present phase amounts follow the material balance, and energy sensitivities follow the existing phase's property equations.
Improve convergence and inspect specifications¶
EO solves reuse their most recent concrete converged state by default.
warm_start=False disables reuse, and guess=... supplies named stream guesses.
The property-package parameters remain dynamic inputs to the cached solve.
Sequential solves accept guess=previous.streams for their recycle seeds.
A rigorous column's warm_start() method returns the full stage state accepted
by its guess argument. If a direct NRTL column solve fails, the solver can
start at ideal activity and restore the activity parameters in increments.
homotopy=False disables this fallback. Each increment has its own Newton
iteration limit; the final report verifies the requested model's equations.
Both flowsheet engines support this host-side continuation method. Failed trials
reduce the step and retain the previous accepted state; successful steps advance
toward the requested endpoint. The result records every trial and its report.
If the endpoint isn't reached, the result isn't marked converged, even when the
last accepted intermediate point solved successfully. continuation_solve
accepts a custom solver callback for other problems. solve_path accepts
recycle methods and tolerances through solve_options.
Differentiate a separate endpoint solve, using the accepted state as its guess;
the adaptive path selection itself isn't differentiable.
EOFlowsheet.degrees_of_freedom() checks equation counts. A square system can
still be singular. EOFlowsheet.diagnose(parameters, guess=...) evaluates the
scaled Jacobian and reports numerical rank, conditioning, zero equation rows, and
unconstrained variables. This is a local numerical diagnostic at the supplied
state, not a global proof of structural solvability.
Differentiate only valid solutions¶
The shared vector Newton, fixed-point, and recycle solves use implicit JVP rules. JAX transposes the linearized residual solve for reverse derivatives; the adjoint doesn't repeat a possibly divergent fixed-point iteration. The rules support JVP, VJP, JIT, batching, and higher derivatives at smooth, nonsingular solutions. Nested thermodynamic models must also support the requested transformation.
argmin differentiates a fixed-size KKT system containing equalities, active
inequalities, and active bounds. Inactive multiplier equations have identity
rows, preventing inactive constraints from making the matrix singular. Optimizer
reports check constrained stationarity and feasibility. Failed optimization can't
supply a valid solution sensitivity.
Host-side checked calculations raise on failure. Inside compiled calculations,
checked flowsheet values and failed implicit sensitivities become nonfinite, and
reporting APIs retain the failure status. Carry the report through JIT and inspect
it before accepting results. For an acceptance check of your own, made after a
solve, fugacio.thermo.implicit.gate_derivative(value, valid) keeps the value
but makes its derivative nonfinite unless valid; gate_tree applies it to
every floating leaf of a pytree. Phase transitions, active-set changes, redundant
constraints, and singular roots can make a derivative undefined even when the
primal residual is small. A successful residual check doesn't prove smoothness
or global optimality.
Run the plant acceptance cases¶
The plant tests exercise public stream properties across unit boundaries. They check the converged process, including its external material and energy balances.
| Case | Acceptance checks |
|---|---|
| HDA with hydrogen recycle | Carbon and hydrogen closure, reaction formation enthalpy, furnace and cooler duties, pressure letdown, and benzene recovery. |
| Depropanizer with feed preheat | Purity and recovery specifications, column and exchanger energy closure, recycle closure, and heat-recovery sensitivity against finite differences. |
| Ethanol/water with NRTL | A rigorous column inside a converged economizer loop, product composition, heat recovery, and the whole-train energy balance. |
| Phase-changing water utility | Preserved quality through heating and letdown, condensation and pumping energy closure on PR and IAPWS, and an operating-point optimization checked against finite differences. |
uv run pytest packages/fugacio-sim/tests/test_plants.py
uv run pytest packages/fugacio-sim/tests/test_utility_reliability.py
These cases carry the plant marker and run in CI. Their nested JAX solvers can
take several minutes to compile on the first run. The HDA case uses a specified
conversion and an ideal component separator; its energy accounting includes the
heat required by that separator.