Connecting Units in Series#
Prerequisites: 00d-00g (single unit tutorials)
Learning Objectives:
Connect multiple units to form a flowsheet
See how outlet streams become inlet streams
Solve multi-unit systems sequentially
Build a reactor-separator process
From Single Units to Flowsheets#
So far, we’ve solved individual units in isolation. Now we’ll connect them:
Feed ──► Reactor ──► Separator ──► Product
│
└──► Waste
Key insight: The reactor’s outlet stream becomes the separator’s inlet stream. This is called stream sharing.
# Setup
import jax.numpy as jnp
import jax
jax.config.update("jax_enable_x64", True)
import matplotlib.pyplot as plt
import matplotlib.patches as patches
from difflow import (CSTR, CSTRParams, Flash, FlashParams,
make_stream, get_flows, IdealThermo, SpeciesData)
WARNING:2026-01-10 21:09:00,780:jax._src.xla_bridge:852: An NVIDIA GPU may be present on this machine, but a CUDA-enabled jaxlib is not installed. Falling back to cpu.
# Define species: A (reactant, light/volatile) -> B (product, heavy)
species_data = {
'A': SpeciesData(
name='A', MW=100.0,
Cp_coeffs=(100.0, 0, 0, 0),
Hvap_coeffs=(30000.0, 0.38, 450.0),
antoine_coeffs=(10.0, 1500.0, -40.0), # More volatile (lower B = higher Psat)
Hf=0.0,
),
'B': SpeciesData(
name='B', MW=80.0,
Cp_coeffs=(80.0, 0, 0, 0),
Hvap_coeffs=(42000.0, 0.38, 550.0),
antoine_coeffs=(10.0, 2400.0, -40.0), # Less volatile (higher B = lower Psat)
Hf=-50000.0, # Exothermic reaction
),
}
thermo = IdealThermo(species_data)
species_order = ['A', 'B']
print("Process: A → B (first-order reaction)")
print("A is light (goes to vapor as waste), B is heavy (stays in liquid as product)")
Process: A → B (first-order reaction)
A is light (goes to vapor as waste), B is heavy (stays in liquid as product)
# Create unit operations
# Reactor: CSTR with first-order kinetics
def rate_fn(C, T, params):
k = params['k']
return jnp.array([k * C['A']])
stoich = jnp.array([[-1.0], [1.0]]) # A -> B
cstr_params = CSTRParams(
V=jnp.array(2.0), # 2 m³
rate_fn=rate_fn,
stoich=stoich,
rate_params={'k': jnp.array(0.5)},
species_order=species_order,
)
reactor = CSTR(cstr_params, thermo=thermo, mode='isothermal')
# Separator: Flash drum
flash_params = FlashParams(species_order=species_order)
flash = Flash(flash_params, thermo=thermo)
print("Units created:")
print(" - CSTR: V = 2 m³, k = 0.5 /s")
print(" - Flash drum")
Units created:
- CSTR: V = 2 m³, k = 0.5 /s
- Flash drum
Sequential Solution#
With no recycles, we can solve units in order:
Solve reactor: outlet = f(inlet, reactor params)
Solve flash: (liquid, vapor) = f(reactor outlet, flash params)
# Step 1: Define feed
feed = make_stream({'A': 10.0, 'B': 0.0}, T=350.0, P=101325.0)
Q_vol = 0.1 # m³/s volumetric flow
print("Step 1: Feed Stream")
print(f" F_A = {float(get_flows(feed)['A']):.2f} mol/s")
print(f" F_B = {float(get_flows(feed)['B']):.2f} mol/s")
print(f" T = {float(feed['T']):.0f} K")
Step 1: Feed Stream
F_A = 10.00 mol/s
F_B = 0.00 mol/s
T = 350 K
# Step 2: Solve reactor
reactor_out, reactor_info = reactor(feed, T_spec=350.0, volumetric_flow=Q_vol)
print("\nStep 2: Reactor Outlet (= Flash Inlet)")
print(f" F_A = {float(get_flows(reactor_out)['A']):.4f} mol/s")
print(f" F_B = {float(get_flows(reactor_out)['B']):.4f} mol/s")
print(f" Conversion = {float(reactor_info['conversion']['A'])*100:.1f}%")
Step 2: Reactor Outlet (= Flash Inlet)
F_A = 0.9091 mol/s
F_B = 9.0909 mol/s
Conversion = 90.9%
# Step 3: Solve flash (using reactor outlet as inlet)
# A is volatile (light), B is less volatile (heavy)
# Unreacted A goes to vapor (waste), product B stays in liquid (product)
# Use vacuum (10 kPa) to vaporize the volatile A impurity
liquid, vapor, flash_info = flash(reactor_out, T=350.0, P=10000.0)
print("\nStep 3: Flash Products")
print(f"\nVapor (Waste - unreacted A):")
print(f" F_A = {float(get_flows(vapor)['A']):.4f} mol/s")
print(f" F_B = {float(get_flows(vapor)['B']):.4f} mol/s")
print(f"\nLiquid (Product B):")
print(f" F_A = {float(get_flows(liquid)['A']):.4f} mol/s")
print(f" F_B = {float(get_flows(liquid)['B']):.4f} mol/s")
print(f"\nVapor fraction: {float(flash_info['V_frac'])*100:.1f}%")
Step 3: Flash Products
Vapor (Waste - unreacted A):
F_A = 0.2481 mol/s
F_B = 0.0043 mol/s
Liquid (Product B):
F_A = 0.6610 mol/s
F_B = 9.0866 mol/s
Vapor fraction: 2.5%
# Summary: Overall process performance
# Product B is in the LIQUID stream, waste A goes to VAPOR
F_A_feed = float(get_flows(feed)['A'])
F_B_product = float(get_flows(liquid)['B']) # Product is in liquid
F_A_in_product = float(get_flows(liquid)['A']) # Impurity in product
F_A_waste = float(get_flows(vapor)['A']) # A vented as waste
overall_yield = F_B_product / F_A_feed
product_purity = F_B_product / (F_B_product + F_A_in_product) * 100
print("\n" + "=" * 50)
print("OVERALL PROCESS PERFORMANCE")
print("=" * 50)
print(f"Feed A: {F_A_feed:.2f} mol/s")
print(f"Product B (liquid): {F_B_product:.4f} mol/s")
print(f"Waste A (vapor): {F_A_waste:.4f} mol/s")
print(f"")
print(f"Reactor conversion: {float(reactor_info['conversion']['A'])*100:.1f}%")
print(f"Yield of B: {overall_yield*100:.1f}%")
print(f"Product purity: {product_purity:.1f}% B")
==================================================
OVERALL PROCESS PERFORMANCE
==================================================
Feed A: 10.00 mol/s
Product B (liquid): 9.0866 mol/s
Waste A (vapor): 0.2481 mol/s
Reactor conversion: 90.9%
Yield of B: 90.9%
Product purity: 93.2% B
Visualizing the Flowsheet#
def draw_reactor_separator_flowsheet(feed, reactor_out, liquid, vapor, reactor_conv, vapor_frac):
fig, ax = plt.subplots(figsize=(14, 6))
# Reactor
reactor_box = patches.FancyBboxPatch(
(0.15, 0.35), 0.2, 0.3,
boxstyle="round,pad=0.02",
facecolor='lightblue',
edgecolor='black',
linewidth=2,
)
ax.add_patch(reactor_box)
ax.text(0.25, 0.5, f'CSTR\nX={reactor_conv*100:.0f}%', ha='center', va='center', fontsize=10, fontweight='bold')
# Flash drum
flash_box = patches.FancyBboxPatch(
(0.55, 0.25), 0.2, 0.5,
boxstyle="round,pad=0.02",
facecolor='lightyellow',
edgecolor='black',
linewidth=2,
)
ax.add_patch(flash_box)
ax.text(0.65, 0.5, f'FLASH\nV/F={vapor_frac*100:.0f}%', ha='center', va='center', fontsize=10, fontweight='bold')
# Feed arrow
ax.annotate('', xy=(0.15, 0.5), xytext=(0.02, 0.5),
arrowprops=dict(arrowstyle='->', lw=2, color='green'))
ax.text(0.02, 0.65, 'FEED', fontsize=9, fontweight='bold')
ax.text(0.02, 0.58, f'A: {float(get_flows(feed)["A"]):.1f} mol/s', fontsize=8)
ax.text(0.02, 0.52, f'B: {float(get_flows(feed)["B"]):.1f} mol/s', fontsize=8)
# Reactor to Flash
ax.annotate('', xy=(0.55, 0.5), xytext=(0.35, 0.5),
arrowprops=dict(arrowstyle='->', lw=2, color='blue'))
ax.text(0.38, 0.62, 'Reactor Out', fontsize=9, fontweight='bold')
ax.text(0.38, 0.55, f'A: {float(get_flows(reactor_out)["A"]):.2f}', fontsize=8)
ax.text(0.38, 0.48, f'B: {float(get_flows(reactor_out)["B"]):.2f}', fontsize=8)
# Vapor waste (now at top - unreacted A)
ax.annotate('', xy=(0.95, 0.65), xytext=(0.75, 0.65),
arrowprops=dict(arrowstyle='->', lw=2, color='gray'))
ax.text(0.82, 0.8, 'WASTE', fontsize=9, fontweight='bold', color='gray')
ax.text(0.82, 0.73, f'A: {float(get_flows(vapor)["A"]):.3f}', fontsize=8)
ax.text(0.82, 0.66, f'B: {float(get_flows(vapor)["B"]):.3f}', fontsize=8)
# Liquid product (now at bottom - product B)
ax.annotate('', xy=(0.95, 0.35), xytext=(0.75, 0.35),
arrowprops=dict(arrowstyle='->', lw=2, color='red'))
ax.text(0.82, 0.45, 'PRODUCT', fontsize=9, fontweight='bold', color='red')
ax.text(0.82, 0.38, f'A: {float(get_flows(liquid)["A"]):.3f}', fontsize=8)
ax.text(0.82, 0.31, f'B: {float(get_flows(liquid)["B"]):.2f}', fontsize=8)
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.axis('off')
ax.set_title('Reactor-Separator Flowsheet (No Recycle)', fontsize=14, fontweight='bold')
plt.tight_layout()
return fig
fig = draw_reactor_separator_flowsheet(
feed, reactor_out, liquid, vapor,
float(reactor_info['conversion']['A']),
float(flash_info['V_frac'])
)
Making it a Function#
Let’s wrap the entire flowsheet as a single function. This is the foundation for optimization!
def solve_reactor_separator(params, feed):
"""
Solve the reactor-separator flowsheet.
Product B is recovered in the LIQUID stream.
Unreacted A is vented in the VAPOR stream.
Args:
params: dict with 'V_reactor', 'T_reactor', 'k', 'T_flash', 'P_flash', 'Q_vol'
feed: Feed stream
Returns:
dict with all streams and performance metrics
"""
# Unpack parameters
V_reactor = params['V_reactor']
T_reactor = params['T_reactor']
k = params['k']
T_flash = params['T_flash']
P_flash = params.get('P_flash', jnp.array(10000.0)) # 10 kPa vacuum default
Q_vol = params['Q_vol']
# Create reactor
cstr_p = CSTRParams(
V=V_reactor,
rate_fn=rate_fn,
stoich=stoich,
rate_params={'k': k},
species_order=species_order,
)
reactor = CSTR(cstr_p, thermo=thermo, mode='isothermal')
# Solve reactor
reactor_out, reactor_info = reactor(feed, T_spec=float(T_reactor), volumetric_flow=float(Q_vol))
# Solve flash - product B goes to liquid, waste A goes to vapor
liquid, vapor, flash_info = flash(reactor_out, T=float(T_flash), P=float(P_flash))
# Calculate metrics - product is in LIQUID
F_A_feed = get_flows(feed)['A']
F_B_product = get_flows(liquid)['B'] # Product B in liquid
F_A_in_product = get_flows(liquid)['A'] # Impurity A in liquid
# Purity of product stream
purity = F_B_product / (F_B_product + F_A_in_product + 1e-10)
return {
'feed': feed,
'reactor_out': reactor_out,
'liquid': liquid,
'vapor': vapor,
'reactor_conversion': reactor_info['conversion']['A'],
'vapor_fraction': flash_info['V_frac'],
'product_B': F_B_product,
'yield': F_B_product / F_A_feed,
'purity': purity,
}
# Test
params = {
'V_reactor': jnp.array(2.0),
'T_reactor': jnp.array(350.0),
'k': jnp.array(0.5),
'T_flash': jnp.array(350.0),
'P_flash': jnp.array(10000.0), # 10 kPa vacuum
'Q_vol': jnp.array(0.1),
}
result = solve_reactor_separator(params, feed)
print(f"Yield of B: {float(result['yield'])*100:.1f}%")
print(f"Product purity: {float(result['purity'])*100:.1f}% B")
Yield of B: 90.9%
Product purity: 93.2% B
Effect of Operating Conditions#
Now we can easily explore how parameters affect performance.
# How does reactor volume affect yield and purity?
volumes = [0.5, 1.0, 2.0, 3.0, 4.0, 5.0]
yields = []
purities = []
conversions = []
for V in volumes:
params_test = {
'V_reactor': jnp.array(V),
'T_reactor': jnp.array(350.0),
'k': jnp.array(0.5),
'T_flash': jnp.array(350.0),
'P_flash': jnp.array(10000.0), # 10 kPa vacuum
'Q_vol': jnp.array(0.1),
}
result = solve_reactor_separator(params_test, feed)
yields.append(float(result['yield']))
purities.append(float(result['purity']))
conversions.append(float(result['reactor_conversion']))
fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(volumes, [c*100 for c in conversions], 'b-o', linewidth=2, markersize=8, label='Reactor Conversion')
ax.plot(volumes, [y*100 for y in yields], 'r-s', linewidth=2, markersize=8, label='Yield of B')
ax.plot(volumes, [p*100 for p in purities], 'g-^', linewidth=2, markersize=8, label='Product Purity')
ax.set_xlabel('Reactor Volume (m³)', fontsize=12)
ax.set_ylabel('Percentage (%)', fontsize=12)
ax.set_title('Effect of Reactor Volume on Process Performance', fontsize=12)
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
Try It Yourself!#
Exercise 1: Temperature Effect#
How does flash temperature affect the purity of product B in the vapor stream?
Exercise 2: Add a Heater#
Add a heater between the reactor and flash to increase the flash temperature. Does this improve separation?
Exercise 3: Multiple Reactors#
Replace the single CSTR with two CSTRs in series (each half the original volume). Does this improve conversion?
End-of-Tutorial Problems#
Problem 1#
For the reactor-separator process, calculate the minimum reactor volume needed to achieve 90% overall conversion (not reactor conversion, but process conversion including separation).
Problem 2#
If the unreacted A in the liquid has value (can be sold), at what price does it become better to use a smaller reactor (more waste but lower capital cost)?
Problem 3#
Design a process with a heater between reactor and flash that maximizes yield while keeping flash temperature below 400 K.
Key Takeaways#
Stream sharing: One unit’s outlet = another unit’s inlet
Sequential solution: For DAGs, solve units in order
Wrap as function: Enables parametric studies and optimization
Process metrics: Overall yield ≠ reactor conversion (separation matters!)
Next Steps#
In the next notebook (00i: Parallel and Bypass Configurations), we’ll explore:
Splitters and mixers
Parallel processing paths
Bypass streams for temperature control