Isotherm Fitting¶
Skarstrom fits isotherm models to measured equilibrium points with the engine's own isotherm code, so the fitted parameters mean exactly what they will mean in a simulation. It needs no column, cycle or deck: it is the quick way from a table of uptake data to model parameters. From Python the fitter is skarstrom.fit_isotherm; in C++ it is adsim::isofit::Fit (adsim/solver/isotherm_fit.h). The executed notebook examples/notebooks/isotherm_fit.ipynb walks through single- and multi-temperature fits.
Data¶
Each point is a pressure \(p\) [Pa], a loading \(q\) [mol/kg] and a temperature \(T\) [K], optionally with a mole fraction \(y\) (the component's partial pressure is then \(p\thinspace y\); pure-component data use \(y = 1\)). Everything is in SI base units: convert first (1 bar = 10⁵ Pa, 1 mmol/g = 1 mol/kg; 22.414 cm³(STP)/g = 1 mmol/g at 0 °C and 1 atm).
import numpy as np
import skarstrom
p = np.array([0.1, 0.25, 0.5, 1.0, 2.0, 4.0]) * 1e5 # Pa
q = np.array([1.06, 1.94, 2.76, 3.47, 4.01, 4.36]) # mol/kg
fit = skarstrom.fit_isotherm("single_site_langmuir_p", p, q, temperature=298.15)
fit["params"], fit["std_errors"], fit["r2"], fit["rmse"]
To fit the temperature dependence of a model, pass data at several temperatures (one temperature per point):
fit = skarstrom.fit_isotherm("dual_site_langmuir_p", p_all, q_all, temperature=T_all)
Models¶
skarstrom.isotherm_kinds() lists the kinds the fitter offers: Henry; single- and dual-site Langmuir (pressure and concentration forms); extended single- and dual-site Langmuir; Freundlich; Sips; dual-site Sips; Toth; the temperature-dependent Sips 2, extended Sips 2 and Toth 2; and the GAB water isotherms gab and gab_young. skarstrom.isotherm_parameters(kind) gives a kind's parameter names, which are the names used in deck files and in parameter paths (adsorbent[<id>].isotherm[<component>].<name>). The Isotherms page has the definitions.
skarstrom.evaluate_isotherm(kind, params, pressure, temperature, mole_fraction=1.0) evaluates any kind with given parameters (unspecified parameters keep the engine default), for plotting or checking a fit. Pure-component fits are exactly what the IAST mixing rule needs; skarstrom.evaluate_iast(kinds, params, pressure, temperature, mole_fraction) gives the mixture loadings they predict.
GAB (water and other vapours). The GAB kinds are written in the relative pressure \(x = p\thinspace y / p_{sat}(T)\), so they need to know which vapour the data are for: component= names it (a CoolProp name such as "Ethanol"; default water) in evaluate_isotherm and fit_isotherm, and components=[...] does the same per component in evaluate_iast. Pass the data as (partial) pressures in Pa, as for every kind. The default set-up for gab fits \(q_{m0}\), \(C_0\) and \(K_0\), and their energies \(\Delta H_C\), \(\Delta H_K\) only with data at several temperatures (\(\Delta H_m\) stays at 0 unless you free it). For gab_young, one temperature fixes only \(C\) and \(K\), so the set-up fits \(q_m\), \(C_E\) and \(F\), holds \(D\) and \(G\) at the Young et al. (2021) values (\(G\) is freed with several temperatures), and holds \(E_{10}\) at the line for liquid water.
x = np.array([0.1, 0.2, 0.4, 0.6, 0.8]) # relative humidity
fit = skarstrom.fit_isotherm("gab", x * 3169.9, q_water, temperature=298.15) # water at 25 degC
q = skarstrom.evaluate_isotherm("gab", fit["params"], x * 7870.0, 298.15, component="Ethanol")
Starting values and bounds¶
The fitter derives a starting value and search bounds for every parameter from the data. You can override them:
fit = skarstrom.fit_isotherm(
"sips", p, q, temperature=303.15,
fixed={"v": 1.0}, # hold parameters at given values
initial={"q_sat": 5.0}, # starting guesses
bounds={"b": (1e-7, 1e-3)}, # search bounds
)
- Bounds span several decades around what the data can resolve. Positive scale parameters, such as capacities and affinities, are searched on a log scale; exponents and energies on a linear scale.
- Arrhenius pairs. For terms of the form \(b\thinspace e^{Q/RT}\) (Henry \(k\) / \(Q\), Langmuir \(b_k\) / \(q_k\), Sips 2 \(B_1\) / \(B_2\)), the pre-exponential is searched as its value at the reference temperature, \(b\thinspace e^{Q/(R T_{ref})}\), and its bounds apply to that value. \(T_{ref}\) is the mean data temperature (returned as
t_ref). This separates the affinity from the heat and makes the search much better conditioned. - Single temperature. With data at one temperature the temperature-dependence parameters (\(Q\), \(q_k\), \(B_2\), \(A_2\), \(C_2\), \(x\), \(\Delta H_0\), \(\alpha\)) cannot be determined, so they are fixed at 0, which makes the affinity at the data temperature equal to the fitted pre-exponential. If you know the heat from elsewhere, pass it in
fixed. - The reference temperatures
T_ref(Sips 2) andT_0(Toth 2) are fixed at the mean data temperature.
The fit¶
The fitter minimises the sum of squared loading residuals
over the free parameters. It first runs a global search over the bounded box (dlib's MaxLIPO with a trust region; max_global_evals sets its budget, by default 100 + 80 per free parameter), then refines the best few candidates with a bounded Levenberg–Marquardt least-squares search. The model \(q^{*}\) is evaluated by the same C++ isotherm functions the simulator uses.
The result dictionary holds:
paramsandstd_errors— each parameter with its ± 1σ standard error. This is the usual linearised estimate \(\mathrm{Cov} = s^2 (J^\top J)^{-1}\), where \(s^2 = \mathrm{SSE}/(n-p)\) and \(J\) is the Jacobian of the model with respect to the free parameters at the optimum. Treat it as indicative: it assumes independent errors of equal variance and a model that is close to linear near the optimum. It isnanwhen the parameters are too strongly correlated to separate.r2\(= 1 - \mathrm{SSE}/\sum_i (q_i - \bar q)^2\),rmse\(= \sqrt{\mathrm{SSE}/n}\) in mol/kg,sse, the number of pointsnand the degrees of freedomdof\(= n - p\).q_fit, the model at each input point, andt_ref.warning, for example a parameter ending on a bound. If that happens, widen the bound and fit again.
Using the result in a deck¶
Write the fitted values into an adsorbent of a deck through its parameter paths (the component's isotherm must already be of the fitted kind):
sim = skarstrom.Simulation("my_deck.json")
for name, value in fit["params"].items():
sim[f"adsorbent[1].isotherm[CarbonDioxide].{name}"] = value
sim.save("my_deck_fitted.json")
Only the isotherm is fitted. The heat of adsorption used by the energy balance is a separate adsorbent property (adsorbent[<id>].heat_of_adsorption[<component>]); set it from the fitted energy or from a calorimetric value.