Equation-Oriented (EO) Solver#

This notebook demonstrates difflow’s equation-oriented solver, an alternative to the default sequential modular (SM) approach.

SM vs EO: Key Differences#

Feature

Sequential Modular (SM)

Equation-Oriented (EO)

Strategy

Evaluate units one-by-one, iterate on tear streams

Assemble all equations into F(x)=0, solve simultaneously

Solver

Fixed-point iteration (Wegstein/Anderson)

Newton’s method via optimistix

Best for

Simple flowsheets, few recycles

Tightly coupled recycles, sensitivity analysis

Gradients

Implicit differentiation through fixed-point

Implicit differentiation through root_find

Setup#

import jax
import jax.numpy as jnp

jax.config.update("jax_enable_x64", True)

from difflow import (
    CSTR, CSTRParams,
    Flash, FlashParams,
    Mixer, Splitter,
    Heater, HeaterParams,
    Flowsheet, Unit,
    EOSolver, EOSolveResult,
    IdealThermo, SpeciesData,
    make_stream, get_flows,
)

print("JAX version:", jax.__version__)
JAX version: 0.8.0

Define thermodynamics and kinetics#

species_data = {
    "A": SpeciesData(
        "A", MW=100.0,
        Cp_coeffs=(75.0, 0.0, 0.0, 0.0),
        Hvap_coeffs=(35000.0, 0.38, 500.0),
        antoine_coeffs=(10.0, 3000.0, -50.0),
    ),
    "B": SpeciesData(
        "B", MW=100.0,
        Cp_coeffs=(75.0, 0.0, 0.0, 0.0),
        Hvap_coeffs=(30000.0, 0.38, 450.0),
        antoine_coeffs=(10.0, 2800.0, -40.0),
    ),
}
thermo = IdealThermo(species_data)

def rate_fn(C, T, params):
    k = params["A"] * jnp.exp(-params["Ea"] / (8.314 * T))
    return jnp.array([k * C["A"]])

stoich = jnp.array([[-1.0], [+1.0]])
rate_params = {"A": jnp.array(1e6), "Ea": jnp.array(50000.0)}
print("Thermodynamics and kinetics defined.")
Thermodynamics and kinetics defined.

Example 1: Simple CSTR — SM vs EO#

First, let’s solve a simple CSTR (no recycle) with both approaches and verify they agree.

cstr_params = CSTRParams(
    V=jnp.array(1.0),
    rate_fn=rate_fn,
    stoich=stoich,
    rate_params=rate_params,
    species_order=["A", "B"],
)
cstr = CSTR(cstr_params, thermo=thermo, mode="isothermal")

fs = Flowsheet(species_order=["A", "B"])
feed = make_stream({"A": 10.0, "B": 0.0}, T=300.0, P=101325.0)
fs.add_feed("feed", feed)
fs.add_unit(Unit("reactor", cstr, ["feed"], ["reactor_out"],
                 params={"T_spec": 350.0}))

# SM solution
sm_streams = fs.solve()

# EO solution
eo_streams = fs.solve_eo(use_sm_init=False)

print("SM solution:")
sm_flows = get_flows(sm_streams["reactor_out"])
print(f"  F_A = {float(sm_flows['A']):.6f}, F_B = {float(sm_flows['B']):.6f}")

print("\nEO solution:")
eo_flows = get_flows(eo_streams["reactor_out"])
print(f"  F_A = {float(eo_flows['A']):.6f}, F_B = {float(eo_flows['B']):.6f}")

print(f"\nMax difference: {max(abs(float(sm_flows[s] - eo_flows[s])) for s in ['A', 'B']):.2e}")
SM solution:
  F_A = 8.529305, F_B = 1.470695

EO solution:
  F_A = 8.529305, F_B = 1.470695

Max difference: 0.00e+00

Example 2: CSTR + Splitter Recycle#

Now let’s solve a flowsheet with a recycle loop using both solvers.

mixer = Mixer(species_order=["A", "B"])
splitter = Splitter(species_order=["A", "B"])

fs2 = Flowsheet(species_order=["A", "B"])
fs2.add_feed("feed", feed)
fs2.add_unit(Unit("mixer", mixer, ["feed", "recycle"], ["mixed"]))
fs2.add_unit(Unit("reactor", cstr, ["mixed"], ["reactor_out"],
                  params={"T_spec": 350.0}))
fs2.add_unit(Unit("splitter", splitter, ["reactor_out"],
                  ["product", "recycle"],
                  params={"split_frac": 0.7}))
fs2.add_recycle("recycle", "recycle")

# SM solution
sm2 = fs2.solve(tol=1e-8, max_iter=200)

# EO solution (initialized from SM for reliability)
eo2 = fs2.solve_eo(use_sm_init=True, tol=1e-8)

print("Product stream comparison:")
sm_prod = get_flows(sm2["product"])
eo_prod = get_flows(eo2["product"])
print(f"  SM:  F_A = {float(sm_prod['A']):.6f}, F_B = {float(sm_prod['B']):.6f}")
print(f"  EO:  F_A = {float(eo_prod['A']):.6f}, F_B = {float(eo_prod['B']):.6f}")
print(f"  Max diff: {max(abs(float(sm_prod[s] - eo_prod[s])) for s in ['A', 'B']):.2e}")
Product stream comparison:
  SM:  F_A = 8.529305, F_B = 1.470695
  EO:  F_A = 8.529305, F_B = 1.470695
  Max diff: 7.27e-10

Example 3: Differentiation Through the EO Solution#

The EO solver supports automatic differentiation via optimistix’s implicit differentiation. Let’s compute how product B flow changes with reactor volume.

def product_B_vs_volume(V):
    """Compute product B flow as a function of reactor volume."""
    params = CSTRParams(
        V=V,
        rate_fn=rate_fn,
        stoich=stoich,
        rate_params=rate_params,
        species_order=["A", "B"],
    )
    cstr_v = CSTR(params, thermo=thermo, mode="isothermal")

    fs = Flowsheet(species_order=["A", "B"])
    fs.add_feed("feed", feed)
    fs.add_unit(Unit("reactor", cstr_v, ["feed"], ["reactor_out"],
                     params={"T_spec": 350.0}))
    streams = fs.solve_eo(use_sm_init=False)
    return streams["reactor_out"]["F_B"]

V = jnp.array(1.0)
F_B = product_B_vs_volume(V)
dFB_dV = jax.grad(product_B_vs_volume)(V)

print(f"At V = {float(V):.1f} m^3:")
print(f"  F_B = {float(F_B):.6f} mol/s")
print(f"  dF_B/dV = {float(dFB_dV):.6f} mol/s per m^3")
print(f"\nInterpretation: Increasing volume by 0.1 m^3 increases B production by ~{float(dFB_dV)*0.1:.4f} mol/s")
At V = 1.0 m^3:
  F_B = 1.470695 mol/s
  dF_B/dV = 1.254401 mol/s per m^3

Interpretation: Increasing volume by 0.1 m^3 increases B production by ~0.1254 mol/s

Example 4: Using the EOSolver Directly#

For more control, use EOSolver directly to get convergence diagnostics.

fs3 = Flowsheet(species_order=["A", "B"])
fs3.add_feed("feed", feed)
fs3.add_unit(Unit("reactor", cstr, ["feed"], ["reactor_out"],
                  params={"T_spec": 350.0}))

solver = EOSolver(fs3)
result = solver.solve(use_sm_init=False, tol=1e-10)

print(f"Converged: {result.converged}")
print(f"Residual norm: {result.residual_norm:.2e}")
print(f"Newton iterations: {result.n_iterations}")
print(f"Wall time: {result.wall_time:.4f} s")
print(f"\nStreams solved: {list(result.streams.keys())}")
Converged: True
Residual norm: 0.00e+00
Newton iterations: 1
Wall time: 0.4981 s

Streams solved: ['feed', 'reactor_out']

When to Use EO vs SM#

Use SM (default solve()) when:

  • Flowsheet has no recycles or simple recycles

  • Units are expensive to evaluate (EO calls them many times per Newton step)

  • You want simplicity

Use EO (solve_eo()) when:

  • Flowsheet has tightly coupled recycles that SM struggles with

  • You need the full system Jacobian

  • You want implicit differentiation through the entire flowsheet

  • Newton’s quadratic convergence is beneficial (fewer iterations near solution)