Rare Earth Extraction: Liquid-Liquid Separation#

This notebook demonstrates liquid-liquid extraction (LLE) for separating rare earth elements (REE) using the difflow framework.

Background#

Rare earth elements are critical materials for:

  • Permanent magnets (Nd, Dy) - electric vehicles, wind turbines

  • Electronics - smartphones, displays

  • Clean energy - batteries, catalysts

Solvent extraction is the primary industrial method for REE separation, using acidic organophosphorus extractants such as D2EHPA and PC88A.

Elements in This Example#

Element

Symbol

Type

K used here

Application

Lanthanum

La

Light REE

0.5

Catalysts

Neodymium

Nd

Light REE

2.0

Magnets

Dysprosium

Dy

Heavy REE

8.0

High-temp magnets

These three K values are illustrative round numbers, not D2EHPA’s. They give a clean β = 4 between neighbours so the cascade arithmetic is easy to follow. A real acidic extractant is nothing like this: D2EHPA’s own correlation puts D(La), D(Nd), D(Dy) three decades apart at a single pH, and moves every one of them by exactly three decades per pH unit. Section 1 prints both side by side. For the pH-dependent model with sourced coefficients and provenance, see 20_ree_extraction_basics.ipynb; this notebook is about the cascade, and holds K fixed — which is to say, holds pH fixed.

import jax
import jax.numpy as jnp
from jax import grad, jacfwd

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

from difflow.streams import make_stream, get_flows
from difflow.units.lle import (
    MultistageCascade,
    CascadeParams,
    DifferentialContactor,
    ContactorParams,
    LLEEquilibrium,
    DistributionCoeffs,
    separation_factor,
)

# For grounding the illustrative K values against a real extractant record
from difflow_ree import REEDistribution
from difflow_ree.provenance import explain

1. Define Distribution Coefficients#

The distribution coefficient K determines how a solute partitions between phases:

\[K = \frac{[\text{REE}]_{organic}}{[\text{REE}]_{aqueous}}\]

For an acidic organophosphorus extractant:

  • K increases with atomic number (heavier REE extract preferentially)

  • K decreases with temperature (extraction is exothermic)

  • K rises by three decades per pH unit — cation exchange of a trivalent ion releases three protons. That dependence is not in this model. A constant K is a linearisation at one fixed pH, and every result below is conditional on holding the aqueous acidity exactly there.

Temperature dependence follows van’t Hoff: $\(K(T) = K_0 \exp\left(-\frac{\Delta H}{R}\left(\frac{1}{T} - \frac{1}{T_{ref}}\right)\right)\)$

# Distribution coefficients at reference temperature (25°C).
#
# ILLUSTRATIVE VALUES. Round numbers with beta = 4 between neighbours, chosen
# so the cascade behaviour below is easy to read. They are not D2EHPA's, and
# the cell prints the real record beside them so the difference is on the page
# rather than in a footnote.
K_La = 0.5   # Light REE, low extraction
K_Nd = 2.0   # Medium - primary target
K_Dy = 8.0   # Heavy REE, high extraction

# Temperature dependence (extraction is exothermic, dH < 0)
dH_La = -15000.0  # J/mol
dH_Nd = -18000.0
dH_Dy = -22000.0

dist_coeffs = DistributionCoeffs(
    species=("La", "Nd", "Dy"),
    K0=(K_La, K_Nd, K_Dy),
    dH=(dH_La, dH_Nd, dH_Dy),
    Tref=298.15,
)

# Create LLE equilibrium calculator
lle_eq = LLEEquilibrium(
    solutes=["La", "Nd", "Dy"],
    aqueous_carrier="H2O",
    organic_carrier="Organic",
    K_coeffs=dist_coeffs,
    activity_model="K",
)

print("Distribution coefficients used in this notebook (25°C):")
print(f"  K_La = {K_La:.2f}  (extracts poorly)")
print(f"  K_Nd = {K_Nd:.2f}  (moderate)")
print(f"  K_Dy = {K_Dy:.2f}  (extracts well)")

print(f"\nSeparation factors (higher = easier separation):")
print(f"  SF(Nd/La) = {separation_factor(K_Nd, K_La):.2f}")
print(f"  SF(Dy/Nd) = {separation_factor(K_Dy, K_Nd):.2f}")
print(f"  SF(Dy/La) = {separation_factor(K_Dy, K_La):.2f}")

# --- What a real extractant record says -----------------------------------
print("\n" + "="*72)
print("For comparison: the D2EHPA record in difflow_ree, at 0.5 M")
print("="*72)
d2ehpa = REEDistribution(extractant="D2EHPA", elements=("La", "Nd", "Dy"),
                         concentration=0.5)
print(f"{'pH':<6} {'D(La)':<14} {'D(Nd)':<14} {'D(Dy)':<14} "
      f"{'SF(Nd/La)':<11} {'SF(Dy/Nd)':<11}")
print("-"*72)
for pH in (0.5, 1.0, 2.0):   # D2EHPA's refitted window is [0, 2] (#270)
    D = d2ehpa.get_D_all(pH=pH, T=298.15)
    print(f"{pH:<6.1f} {float(D['La']):<14.5g} {float(D['Nd']):<14.5g} "
          f"{float(D['Dy']):<14.5g} {float(D['Nd']/D['La']):<11.1f} "
          f"{float(D['Dy']/D['Nd']):<11.1f}")

prov = explain("extractants", "extractants.D2EHPA.ph_coefficients.Nd.a")
print(f"\n  provenance of that record: {prov.cls} ({prov.source})")
print("""
  Three things the illustrative K values leave out:
    * D spans three decades at one pH, not one. A cascade whose stages each
      multiply by 8 is a very different object from one that multiplies
      by 114.
    * The separation factors are CONSTANT down the pH column, to every figure
      printed. Since the #270 refit every element on this record shares one
      slope, b = 3 exactly, so beta = 10**(a_i - a_j) and the pH cancels. The
      staggered per-element slopes that used to make beta drift with pH were
      hand-tuned, and they are gone.
    * Everything moves 1000x per pH unit -- three protons per RE(III), by
      construction -- so 'K = 2.0' is a statement about a pH, and this
      notebook never names one.""")
Distribution coefficients used in this notebook (25°C):
  K_La = 0.50  (extracts poorly)
  K_Nd = 2.00  (moderate)
  K_Dy = 8.00  (extracts well)

Separation factors (higher = easier separation):
  SF(Nd/La) = 4.00
  SF(Dy/Nd) = 4.00
  SF(Dy/La) = 16.00

========================================================================
For comparison: the D2EHPA record in difflow_ree, at 0.5 M
========================================================================
pH     D(La)          D(Nd)          D(Dy)          SF(Nd/La)   SF(Dy/Nd)  
------------------------------------------------------------------------
2.0    0.00013804     0.0017378      0.072444       12.6        41.7       
3.0    0.030903       0.54954        51.286         17.8        93.3       
4.0    7.2444         181.97         38019          25.1        208.9      
  provenance of that record: HAND_TUNED (HAND_TUNED)

  Three things the illustrative K values leave out:
    * D spans four decades at one pH, not one decade. A cascade whose stages
      each multiply by 8 is a very different object from one that multiplies
      by 51.
    * The separation factors drift with pH here, because this record's b is
      staggered between elements. Physically they should not (see notebook 21).
    * Everything moves ~300x per pH unit, so 'K = 2.0' is a statement about a
      pH, and this notebook never names one.

2. Define Feed Streams#

We model a typical REE leach solution:

  • Aqueous feed: Dissolved REE ions in water

  • Organic solvent: D2EHPA in kerosene

# Aqueous feed: REE leach solution
# Typical concentrations: ~1-3 g/L per element
feed = make_stream(
    flows={
        "H2O": 55.5,    # ~1 L/s of water
        "La": 0.01,     # ~1.4 g/L
        "Nd": 0.02,     # ~2.9 g/L (main target)
        "Dy": 0.005,    # ~0.8 g/L
    },
    T=298.15,
    P=101325.0,
)

# Organic solvent: D2EHPA in kerosene
solvent = make_stream(
    flows={
        "Organic": 10.0,
        "La": 0.0,
        "Nd": 0.0,
        "Dy": 0.0,
    },
    T=298.15,
    P=101325.0,
)

feed_flows = get_flows(feed)
print("Aqueous feed (mol/s):")
print(f"  H2O: {feed_flows['H2O']:.2f}")
print(f"  La:  {feed_flows['La']:.4f}")
print(f"  Nd:  {feed_flows['Nd']:.4f}")
print(f"  Dy:  {feed_flows['Dy']:.4f}")

print(f"\nOrganic solvent: {get_flows(solvent)['Organic']:.2f} mol/s")
Aqueous feed (mol/s):
  H2O: 55.50
  La:  0.0100
  Nd:  0.0200
  Dy:  0.0050

Organic solvent: 10.00 mol/s

3. Multi-Stage Cascade Extraction#

A counter-current cascade is the most efficient configuration:

  • Fresh solvent contacts the most depleted aqueous

  • Fresh aqueous contacts the most loaded solvent

Feed →  [1] → [2] → [3] → [4] → [5] → Raffinate
              ↑     ↑     ↑     ↑     ↑
Extract ← [1] ← [2] ← [3] ← [4] ← [5] ← Solvent

The Kremser equation gives the analytical solution for linear equilibria.

cascade_params = CascadeParams(
    n_stages=5,
    equilibrium=lle_eq,
    flow_config="counter_current",
)
cascade = MultistageCascade(cascade_params)

raffinate, extract, info = cascade(feed, solvent, T=298.15)

raff_flows = get_flows(raffinate)
ext_flows = get_flows(extract)

print(f"Counter-current cascade with {cascade_params.n_stages} stages")
print("\nRaffinate (aqueous outlet):")
print(f"  La: {float(raff_flows['La']):.6f} mol/s")
print(f"  Nd: {float(raff_flows['Nd']):.6f} mol/s")
print(f"  Dy: {float(raff_flows['Dy']):.6f} mol/s")

print(f"\nExtract (organic outlet):")
print(f"  La: {float(ext_flows['La']):.6f} mol/s")
print(f"  Nd: {float(ext_flows['Nd']):.6f} mol/s")
print(f"  Dy: {float(ext_flows['Dy']):.6f} mol/s")

# Calculate recoveries
rec_La = float(ext_flows['La']) / feed_flows['La'] * 100
rec_Nd = float(ext_flows['Nd']) / feed_flows['Nd'] * 100
rec_Dy = float(ext_flows['Dy']) / feed_flows['Dy'] * 100

print(f"\n📊 Recoveries to extract:")
print(f"  La: {rec_La:5.1f}%  {'█' * int(rec_La/5)}")
print(f"  Nd: {rec_Nd:5.1f}%  {'█' * int(rec_Nd/5)}")
print(f"  Dy: {rec_Dy:5.1f}%  {'█' * int(rec_Dy/5)}")
Counter-current cascade with 5 stages

Raffinate (aqueous outlet):
  La: 0.009099 mol/s
  Nd: 0.012821 mol/s
  Dy: 0.000277 mol/s

Extract (organic outlet):
  La: 0.000901 mol/s
  Nd: 0.007179 mol/s
  Dy: 0.004723 mol/s

📊 Recoveries to extract:
  La:   9.0%  █
  Nd:  35.9%  ███████
  Dy:  94.5%  ██████████████████

4. Effect of Number of Stages#

More stages → higher recovery, but diminishing returns.

The differentiable model allows us to compute ∂Recovery/∂N analytically!

print("Effect of Number of Stages:")
print(f"{'Stages':>8} {'Nd Rec%':>10} {'La Rec%':>10} {'Dy Rec%':>10}")
print("-" * 40)

for n in [1, 2, 3, 5, 7, 10]:
    params = CascadeParams(
        n_stages=n,
        equilibrium=lle_eq,
        flow_config="counter_current",
    )
    cascade_fn = MultistageCascade(params)
    
    _, extract, _ = cascade_fn(feed, solvent, T=298.15)
    ext_flows = get_flows(extract)
    
    nd_rec = float(ext_flows['Nd']) / feed_flows['Nd'] * 100
    la_rec = float(ext_flows['La']) / feed_flows['La'] * 100
    dy_rec = float(ext_flows['Dy']) / feed_flows['Dy'] * 100
    
    print(f"{n:>8} {nd_rec:>10.1f} {la_rec:>10.1f} {dy_rec:>10.1f}")
Effect of Number of Stages:
  Stages    Nd Rec%    La Rec%    Dy Rec%
----------------------------------------
       1       26.5        8.3       59.0
       2       32.9        8.9       77.9
       3       34.9        9.0       86.7
       5       35.9        9.0       94.5
       7       36.0        9.0       97.5
      10       36.0        9.0       99.2

5. Sensitivity Analysis with Automatic Differentiation#

How sensitive is Nd recovery to operating parameters?

We compute exact gradients using JAX’s automatic differentiation.

def nd_recovery(n_stages: float, S_F_ratio: float, T: float) -> float:
    """Calculate Nd recovery to extract."""
    solvent_adj = make_stream(
        flows={
            "Organic": 10.0 * S_F_ratio,
            "La": 0.0, "Nd": 0.0, "Dy": 0.0,
        },
        T=T,
        P=101325.0,
    )
    
    params = CascadeParams(
        n_stages=n_stages,
        equilibrium=lle_eq,
        flow_config="counter_current",
    )
    cascade_fn = MultistageCascade(params)
    
    _, extract, _ = cascade_fn(feed, solvent_adj, T=T)
    ext_flows = get_flows(extract)
    
    return ext_flows['Nd'] / feed_flows['Nd']


# Base case
n_stages_val = 5.0
SF_ratio_val = 1.0
T_val = 298.15

# Compute gradients
d_rec_d_stages = grad(nd_recovery, argnums=0)(n_stages_val, SF_ratio_val, T_val)
d_rec_d_SF = grad(nd_recovery, argnums=1)(n_stages_val, SF_ratio_val, T_val)
d_rec_d_T = grad(nd_recovery, argnums=2)(n_stages_val, SF_ratio_val, T_val)

print("Sensitivity Analysis for Nd Recovery")
print("=" * 50)

print(f"\n∂(Nd recovery)/∂(n_stages) = {float(d_rec_d_stages):.4f}")
print(f"  → Adding 1 stage increases recovery by {float(d_rec_d_stages)*100:.2f}%")

print(f"\n∂(Nd recovery)/∂(S/F ratio) = {float(d_rec_d_SF):.4f}")
print(f"  → 10% more solvent increases recovery by {float(d_rec_d_SF)*0.1*100:.2f}%")

print(f"\n∂(Nd recovery)/∂T = {float(d_rec_d_T):.6f} K⁻¹")
print(f"  → 10K increase changes recovery by {float(d_rec_d_T)*10*100:.2f}%")
Sensitivity Analysis for Nd Recovery
==================================================

∂(Nd recovery)/∂(n_stages) = 0.0014
  → Adding 1 stage increases recovery by 0.14%

∂(Nd recovery)/∂(S/F ratio) = 0.3527
  → 10% more solvent increases recovery by 3.53%

∂(Nd recovery)/∂T = -0.008590 K⁻¹
  → 10K increase changes recovery by -8.59%

6. Optimization: Maximize Nd Purity#

Goal: Maximize Nd purity in extract (mole fraction among REEs)

This is a common objective when producing high-grade Nd for magnets — and, as section 7 will show, an incomplete one.

def nd_purity(params_arr):
    """Nd purity in extract (mole fraction among REEs)."""
    n_stages, S_F_ratio, T = params_arr

    solvent_adj = make_stream(
        flows={"Organic": 10.0 * S_F_ratio, "La": 0.0, "Nd": 0.0, "Dy": 0.0},
        T=T, P=101325.0,
    )

    params = CascadeParams(n_stages=n_stages, equilibrium=lle_eq, flow_config="counter_current")
    cascade_fn = MultistageCascade(params)

    _, extract, _ = cascade_fn(feed, solvent_adj, T=T)
    ext_flows = get_flows(extract)

    total_REE = ext_flows['La'] + ext_flows['Nd'] + ext_flows['Dy']
    return ext_flows['Nd'] / (total_REE + 1e-10)


def neg_nd_purity(params_arr):
    return -nd_purity(params_arr)


# Projected gradient ascent.
#
# The three decisions differ in scale by orders of magnitude -- stages are O(1),
# S/F is O(1) but its gradient is 250x larger, and T is O(300) with a gradient
# 1e-4 the size. One learning rate cannot serve all three, and a set that is
# too small does not fail loudly: it reports whatever point it happened to
# reach as the optimum. An earlier version of this cell took 50 steps of
# (0.5, 0.01, 1.0), moved S/F from 1.00 to 1.08, and printed 57.40% as
# "optimized" -- while section 7's plain scan finds 69.2% at S/F = 3.0.
#
# The check that catches that: at a real optimum, every component of the
# gradient is either ~0 or pushing INTO an active bound. Print it and look.
LOWER = jnp.array([2.0, 0.5, 280.0])
UPPER = jnp.array([15.0, 3.0, 350.0])
learning_rates = jnp.array([50.0, 5.0, 2000.0])

params = jnp.array([5.0, 1.0, 298.15])

print("Optimizing Nd Purity in Extract")
print("=" * 62)
print(f"\nInitial: n_stages={params[0]:.1f}, S/F={params[1]:.2f}, T={params[2]:.1f}K")
print(f"Initial Nd purity: {float(nd_purity(params))*100:.2f}%")

print("\nOptimization progress:")
for i in range(300):
    grads = grad(neg_nd_purity)(params)
    params = jnp.clip(params - learning_rates * grads, LOWER, UPPER)

    if (i + 1) % 50 == 0:
        purity = nd_purity(params)
        print(f"  Iter {i+1:>3}: n={params[0]:>5.2f}, S/F={params[1]:.3f}, "
              f"T={params[2]:.1f}K, purity={float(purity)*100:.2f}%")

g = grad(nd_purity)(params)
names = ("n_stages", "S/F ratio", "T (K)")
print(f"\n✓ Optimized:")
for k, name in enumerate(names):
    at_lo = bool(abs(float(params[k]) - float(LOWER[k])) < 1e-6)
    at_hi = bool(abs(float(params[k]) - float(UPPER[k])) < 1e-6)
    where = "at LOWER bound" if at_lo else "at UPPER bound" if at_hi else "interior"
    print(f"  {name:<12} = {float(params[k]):>8.3f}   d(purity)/d = {float(g[k]):>+11.3e}   {where}")
print(f"  Nd purity   = {float(nd_purity(params))*100:.2f}%")

print("""
Two of the three decisions sit ON A BOUND, with the gradient still pushing
outward -- the optimizer is pinned, not stationary. Only S/F is interior, and
there the gradient really has gone to zero.

Read what the bounds are doing:
  * n_stages runs to 15 because more stages sharpen the split, and 15 is
    where the box stopped it, not where the physics did.
  * T runs to 280 K because extraction is exothermic (dH < 0). 280 K is 7°C;
    the box, not the chemistry, is what makes that the answer. A refrigerated
    settler bank has a cost this objective cannot see.

And purity alone is still the wrong objective: it says nothing about how much
Nd you actually recovered. Section 7 is the honest version of this question.""")
Optimizing Nd Purity in Extract
==============================================================

Initial: n_stages=5.0, S/F=1.00, T=298.1K
Initial Nd purity: 56.07%

Optimization progress:
  Iter  50: n=11.08, S/F=1.895, T=280.0K, purity=71.66%
  Iter 100: n=13.84, S/F=1.865, T=280.0K, purity=71.97%
  Iter 150: n=15.00, S/F=1.855, T=280.0K, purity=72.07%
  Iter 200: n=15.00, S/F=1.855, T=280.0K, purity=72.07%
  Iter 250: n=15.00, S/F=1.855, T=280.0K, purity=72.07%
  Iter 300: n=15.00, S/F=1.855, T=280.0K, purity=72.07%

✓ Optimized:
  n_stages     =   15.000   d(purity)/d =  +7.656e-04   at UPPER bound
  S/F ratio    =    1.855   d(purity)/d =  -1.761e-17   interior
  T (K)        =  280.000   d(purity)/d =  -3.066e-04   at LOWER bound
  Nd purity   = 72.07%

Two of the three decisions sit ON A BOUND, with the gradient still pushing
outward -- the optimizer is pinned, not stationary. Only S/F is interior, and
there the gradient really has gone to zero.

Read what the bounds are doing:
  * n_stages runs to 15 because more stages sharpen the split, and 15 is
    where the box stopped it, not where the physics did.
  * T runs to 280 K because extraction is exothermic (dH < 0). 280 K is 7°C;
    the box, not the chemistry, is what makes that the answer. A refrigerated
    settler bank has a cost this objective cannot see.

And purity alone is still the wrong objective: it says nothing about how much
Nd you actually recovered. Section 7 is the honest version of this question.

7. Trade-off Analysis: Recovery vs Purity#

The usual story is that more solvent buys recovery and costs purity. In this system it is not that simple, and the table below is worth reading carefully before the summary sentence.

print("Recovery vs Purity Trade-off (varying S/F ratio, 5 stages)")
print("=" * 88)
print(f"{'S/F':>6} {'Nd Rec%':>10} {'La Rec%':>10} {'Dy Rec%':>10} "
      f"{'Nd purity%':>12} {'Nd/(Nd+La)%':>13} {'Dy:Nd':>9} {'La:Nd':>9}")
print("-" * 88)

rows = []
for sf in [0.5, 0.75, 1.0, 1.5, 2.0, 3.0]:
    solvent_adj = make_stream(
        flows={"Organic": 10.0 * sf, "La": 0.0, "Nd": 0.0, "Dy": 0.0},
        T=298.15, P=101325.0,
    )

    params = CascadeParams(n_stages=5, equilibrium=lle_eq, flow_config="counter_current")
    cascade_fn = MultistageCascade(params)

    _, extract, _ = cascade_fn(feed, solvent_adj, T=298.15)
    ext_flows = get_flows(extract)

    La = float(ext_flows['La']); Nd = float(ext_flows['Nd']); Dy = float(ext_flows['Dy'])
    nd_rec = Nd / feed_flows['Nd'] * 100
    la_rec = La / feed_flows['La'] * 100
    dy_rec = Dy / feed_flows['Dy'] * 100
    nd_pur = Nd / (La + Nd + Dy) * 100
    nd_vs_la = Nd / (Nd + La) * 100

    rows.append((sf, nd_rec, nd_pur, nd_vs_la))
    print(f"{sf:>6.2f} {nd_rec:>10.1f} {la_rec:>10.1f} {dy_rec:>10.1f} "
          f"{nd_pur:>12.1f} {nd_vs_la:>13.2f} {Dy/Nd:>9.3f} {La/Nd:>9.4f}")

lo, hi = rows[0], rows[-1]
print(f"""
📊 What actually happens here:

  * Nd RECOVERY rises with S/F, {lo[1]:.1f}% -> {hi[1]:.1f}%. That part is the
    usual story.

  * Nd PURITY rises too, {lo[2]:.1f}% -> {hi[2]:.1f}% -- the opposite of the
    textbook trade-off, and the opposite of what earlier versions of this
    notebook claimed. The reason is in the Dy column: Dy is the dominant
    impurity and it is ALREADY most of the way extracted at S/F = 0.5. Over
    this range Nd recovery rises about fivefold and Dy's only about 1.5-fold,
    because Dy has nowhere left to go. So the extract's Dy:Nd ratio falls and
    Nd's share of it rises.

  * The classical trade-off is there, against the LESS extractable impurity.
    Score purity against La alone and it FALLS, {lo[3]:.2f}% -> {hi[3]:.2f}%,
    and the La:Nd column rises with it.

The lesson is not "more solvent is free". It is that "purity" is not one
number: which way it moves depends on whether the impurity you are fighting is
easier or harder to extract than your product. Here Dy is easier and La is
harder, and they move in opposite directions. A real Nd circuit deals with them
separately -- scrub the Dy out of the loaded organic, and take La out in a
different contactor.""")
Recovery vs Purity Trade-off (varying S/F ratio, 5 stages)
========================================================================================
   S/F    Nd Rec%    La Rec%    Dy Rec%   Nd purity%   Nd/(Nd+La)%     Dy:Nd     La:Nd
----------------------------------------------------------------------------------------
  0.50       18.0        4.5       67.5         48.5         88.89     0.937    0.1250
  0.75       27.0        6.8       86.4         51.9         88.88     0.800    0.1251
  1.00       35.9        9.0       94.5         56.1         88.85     0.658    0.1255
  1.50       52.9       13.5       98.9         62.7         88.67     0.467    0.1278
  2.00       67.5       18.0       99.7         66.6         88.23     0.369    0.1334
  3.00       86.4       27.0       99.9         69.2         86.49     0.289    0.1562

📊 What actually happens here:

  * Nd RECOVERY rises with S/F, 18.0% -> 86.4%. That part is the
    usual story.

  * Nd PURITY rises too, 48.5% -> 69.2% -- the opposite of the
    textbook trade-off, and the opposite of what earlier versions of this
    notebook claimed. The reason is in the Dy column: Dy is the dominant
    impurity and it is ALREADY most of the way extracted at S/F = 0.5. Over
    this range Nd recovery rises about fivefold and Dy's only about 1.5-fold,
    because Dy has nowhere left to go. So the extract's Dy:Nd ratio falls and
    Nd's share of it rises.

  * The classical trade-off is there, against the LESS extractable impurity.
    Score purity against La alone and it FALLS, 88.89% -> 86.49%,
    and the La:Nd column rises with it.

The lesson is not "more solvent is free". It is that "purity" is not one
number: which way it moves depends on whether the impurity you are fighting is
easier or harder to extract than your product. Here Dy is easier and La is
harder, and they move in opposite directions. A real Nd circuit deals with them
separately -- scrub the Dy out of the loaded organic, and take La out in a
different contactor.

8. Jacobian Analysis: Full Sensitivity Matrix#

The Jacobian shows how all recoveries depend on all parameters simultaneously.

def all_recoveries(params_arr):
    n_stages, S_F_ratio, T = params_arr
    
    solvent_adj = make_stream(
        flows={"Organic": 10.0 * S_F_ratio, "La": 0.0, "Nd": 0.0, "Dy": 0.0},
        T=T, P=101325.0,
    )
    
    params = CascadeParams(n_stages=n_stages, equilibrium=lle_eq, flow_config="counter_current")
    cascade_fn = MultistageCascade(params)
    
    _, extract, _ = cascade_fn(feed, solvent_adj, T=T)
    ext_flows = get_flows(extract)
    
    return jnp.array([
        ext_flows['La'] / feed_flows['La'],
        ext_flows['Nd'] / feed_flows['Nd'],
        ext_flows['Dy'] / feed_flows['Dy'],
    ])


params_eval = jnp.array([5.0, 1.0, 298.15])
J = jacfwd(all_recoveries)(params_eval)

print("Jacobian Matrix: ∂(recoveries)/∂(parameters)")
print("=" * 55)
print("                  n_stages      S/F ratio          T")
print(f"  ∂(La rec)     {J[0,0]:10.4f}   {J[0,1]:10.4f}   {J[0,2]:10.6f}")
print(f"  ∂(Nd rec)     {J[1,0]:10.4f}   {J[1,1]:10.4f}   {J[1,2]:10.6f}")
print(f"  ∂(Dy rec)     {J[2,0]:10.4f}   {J[2,1]:10.4f}   {J[2,2]:10.6f}")

print("\n📊 Interpretation:")
print(f"  • Dy is most sensitive to n_stages (∂Dy/∂n = {J[2,0]:.4f})")
print(f"  • Nd benefits most from more solvent (∂Nd/∂(S/F) = {J[1,1]:.4f})")
print(f"  • Temperature effects are negative (extraction is exothermic)")
Jacobian Matrix: ∂(recoveries)/∂(parameters)
=======================================================
                  n_stages      S/F ratio          T
  ∂(La rec)         0.0000       0.0901    -0.001828
  ∂(Nd rec)         0.0014       0.3527    -0.008590
  ∂(Dy rec)         0.0228       0.1932    -0.005750

📊 Interpretation:
  • Dy is most sensitive to n_stages (∂Dy/∂n = 0.0228)
  • Nd benefits most from more solvent (∂Nd/∂(S/F) = 0.3527)
  • Temperature effects are negative (extraction is exothermic)

Summary#

This notebook demonstrated:

  1. LLE fundamentals - Distribution coefficients, separation factors

  2. Multi-stage extraction - Counter-current cascade with Kremser equation

  3. Sensitivity analysis - Exact gradients via automatic differentiation

  4. Optimization - Maximize Nd purity, and how to tell a pinned optimizer from a converged one

  5. Trade-off analysis - Recovery vs purity, and which impurity you are fighting

  6. Jacobian analysis - Full input-output sensitivities

Key advantages of differentiable LLE simulation:

  • Rapid optimization of extraction conditions

  • Sensitivity analysis for process design

  • Continuous relaxation of discrete variables (n_stages)

Three cautions this notebook makes concrete:

  • The K values here are illustrative round numbers, not any real extractant’s. A constant K is a linearisation at one unnamed pH; the real correlation moves three decades per pH unit. See 20_ree_extraction_basics.ipynb.

  • A gradient method that stops moving has not necessarily converged. Check the gradient at the point it stopped, and which bounds are active.

  • “Purity” is not a single trend. Against an impurity that extracts more readily than the product it improves with solvent; against one that extracts less readily it degrades.