Stat Therm Surface Area

Quick Start

The specific surface areas of particles are often measured using BET theory. As this app shows, the Statistical Thermodynamic Surface Area is a more reliable measure, simple to implement and removes the need for the unrealistic assumptions behind BET.

Credits

The app is based on the core paper by Shimizu and Matubayasi1.

Stat Therm Surface Area

a2min
a2max
Fit to ⟨n2⟩
Demo data
⟨n2⟩ Units
Data
Sorbate; MWt; Ų
//One universal basic required here to get things going once loaded
window.onload = function () {
    //restoreDefaultValues(); //Un-comment this if you want to start with defaults
    document.getElementById('Load').addEventListener('click', clearOldName, false);
    document.getElementById('Load').addEventListener('change', handleFileSelect, false);
    document.getElementById('Demo').addEventListener('change', GetDemo, false);
    GetDemo()
    Main();
};
//Any global variables go here
let amUpdating = false, lastFit = "", newFit = false, lastQ = 0, lastUnits = "", lastProbe = "", lastT = 0, divBy = 1, ModelLabel = "Model Parameters", G22conv = 1
let fitData = [], naFitData = [], anFitData = [], scaleData = [], Loading = false, xmin = 0, xmax = 1

//Main is hard wired as THE place to start calculating when input changes
//It does no calculations itself, it merely sets them up, sends off variables, gets results and, if necessary, plots them.

function Main() {
    saveSettings();
    if (Loading) return
    //Send all the inputs as a structured object
    //If you need to convert to, say, SI units, do it here!
    const inputs = {
        amin: sliders.Slideamin.value,
        amax: sliders.Slideamax.value,
        Q: 2,//sliders.SlideQ.value,
        Probe: document.getElementById('Probe').value,
        Units: document.getElementById('Units').value,
        Fitn2: document.getElementById('Fitn2').checked,
    }

    //Send inputs off to CalcIt where the names are instantly available
    //Get all the resonses as an object, result
    if (!amUpdating) console.time("Calc")
    const result = CalcIt(inputs)
    if (!amUpdating) console.timeEnd("Calc")

    //Set all the text box outputs
    // document.getElementById('Langmuir').value = result.Lang
    document.getElementById('Values').value = result.Values

    //Do all relevant plots by calling plotIt - if there's no plot, nothing happens
    //plotIt is part of the app infrastructure in app.new.js
    if (result.plots) {
        for (let i = 0; i < result.plots.length; i++) {
            plotIt(result.plots[i], result.canvas[i]);
        }
    }

    //You might have some other stuff to do here, but for most apps that's it for CalcIt!
}

//Here's the app calculation
//The inputs are just the names provided - their order in the curly brackets is unimportant!
//By convention the input values are provided with the correct units within Main
function CalcIt({ amin, amax, Q, Probe, Units, Fitn2 }) {
    const FQ = 2000 + 2000 * Q * Q
    const ProbeData = Probe.split(";")
    const MWt = parseFloat(ProbeData[1])
    const Area = parseFloat(ProbeData[2])

    if (amUpdating) return { Values: "-" }

    const RT = 8.314 * 298
    let ICurve = [], GCurve = [], NCurve = [], anCurve = [], PCurve = [], naCurve = [], G22 = 0, n = 0, A = 0, B = 0, C = 0, aM = 0, n2M = 0, STSA = 0
    let Values = "-"
    const a2inc = 0.001
    let conv=1
    xmin = amin; xmax = amax
    if (fitData.length > 3) {
        let x = [], y = []
        naFitData = []
        anFitData = []
        scaleData = []
        for (let i = 0; i < fitData.length; i++) {
            if (fitData[i].x >= amin && fitData[i].x <= amax) {
                if (!Fitn2 && fitData[i].y > 0) {
                    x.push(fitData[i].x)
                    y.push(fitData[i].x / fitData[i].y)
                } else {
                    x.push(fitData[i].x)
                    y.push(fitData[i].y)

                }
                scaleData.push({ x: fitData[i].x, y: fitData[i].y * divBy })

                if (fitData[i].x > 0.015) {
                    naFitData.push({ x: fitData[i].x, y: fitData[i].y * divBy / fitData[i].x })
                    if (fitData[i].y > 0) anFitData.push({ x: fitData[i].x, y: fitData[i].x / (fitData[i].y * divBy) })
                }
            }
        }
        if (amax < xmax) amax = Math.min(1, xmax + 0.05) // Users can choose to have a higher value but not a lower one
        if (true) { //Redundant, clean later
            lastQ = Q; lastUnits = Units; lastProbe = Probe
            amUpdating = true
            if (true) { //Redundant, clean later
                if (Fitn2) {
                    PFunc = function (x, P) {
                        return x.map(function (xi) {
                            return xi / (P[0] - P[1] * xi - P[2] / 2 * xi * xi)
                        })
                    }

                } else {
                    PFunc = function (x, P) {
                        return x.map(function (xi) {
                            return P[0] - P[1] * xi - P[2] / 2 * xi * xi
                        })
                    }
                }
                const Parms = fminsearch(PFunc, [0.1, -1, 1], x, y, {
                    maxIter: FQ
                })
                A = Parms[0]; B = Parms[1]; C = Parms[2]

            }
            amUpdating = false
            conv = 1
            if (!Units.includes("mol")) conv *= MWt
            if (Units.includes("/g") & !Units.includes("mmol")  & !Units.includes("μmol")) conv /= 1000
            if (Units.includes("μmol")) conv /= 1000
            if (Units.includes("/100g")) conv /= 10
            A/=conv; B/=conv; C/=conv
            const AvoCalc = 6.02 //Avogadro number, scaled appropriates.
            if (fitData.length > 3) {
                aM = (-2 * A / B) * (Math.sqrt(1 + (B * B / (2 * A * C))) - 1)
                n2M = (-1 / B) / (1 + (2 * A * C / (B * B))) * divBy
                STSA = Area * AvoCalc * n2M
                Values = "A=\t" + (A / divBy).toPrecision(3) + "\tB=\t" + (B / divBy).toPrecision(3) + "\tC=\t" + (C / divBy).toPrecision(3) + "\n"
                Values += "STSA=\t" + STSA.toPrecision(3) + "m²/g\ta2M=\t" + aM.toPrecision(3) + "\t⟨n2⟩M=\t" + (n2M*conv).toPrecision(3)
                copyTextToClipboard(Values)
            }

            G22conv = conv
            newFit = false
        }
    } else {
        document.getElementById('Slideamax').style.visibility = "visible"
        lastFit = ""; divBy = 1
    }
    let N22min = 999, a2atmin = 0, nmax = 0, nmin = 999
    //Undo conv for plot
    A*=conv; B*=conv; C*=conv
    for (let a2 = amin; a2 <= amax; a2 += a2inc) {

        n = a2 / (A - C * Math.pow(a2, 2) / 2 - B * a2)
        nmin = Math.min(n, nmin)
        nmax = Math.max(n, nmax)
        G22 = C * a2 + B
        //Note that N22 does NOT change with units
        tmp = G22conv * G22 * n * divBy/conv
        if (tmp < N22min) {
            N22min = tmp
            a2atmin = a2
        }
        //if (n > 1 || n < -0.02) break
        ICurve.push({ x: a2, y: Math.max(0, n * divBy) })
        GCurve.push({ x: a2, y: G22conv * G22 / divBy })
        NCurve.push({ x: a2, y: G22conv/conv * G22 * n })
        if (a2 > 0.015) naCurve.push({ x: a2, y: Math.max(0, n * divBy) / a2 })
        if (n * divBy > 0.025) anCurve.push({ x: a2, y: a2 / (n * divBy) })
    }
    let MCurve = [], MNCurve = []

    MCurve.push({ x: aM, y: nmin * divBy / 1.1 }); MCurve.push({ x: aM, y: nmax * divBy })
    MNCurve.push({ x: aM, y: Math.max(N22min / divBy - 0.05, -1) }); MNCurve.push({ x: aM, y: 0 })
    if (Values != "-") {
        Values += "\tN22min=\t" + (N22min / divBy).toPrecision(3)
        const s = Math.min(Math.max(0, Math.round((-0.5 - N22min / divBy) / 0.05)), 10)
        let comment = "Multi-layer <...........> Microporous"
        comment = comment.split('');
        comment[s + 13] = '|';
        comment = comment.join('');
        Values += "\n" + comment
    }

    //Rounding error issues for the plot
    n = amax / (A - C * Math.pow(amax, 2) / 2 - B * amax)

    if (xmax + 0.05 >= amax && n <= 1 && n > 0) ICurve.push({ x: amax, y: n * divBy })



    let plotData = [ICurve]
    let lineLabels = ["Stat Therm"]
    let theColors = ['#edc240']
    let thenaColors = ['#edc240', '#afd8ff']
    let showLines = [true, true], showPoints = [0, 0]
    let naData = [naCurve, anCurve]
    let naLineLabels = ["⟨n2⟩/a2", "a2/⟨n2⟩"]
    let G2y2Label = "a2/⟨n2⟩& "
    let G2yAxisL1R2 = [1, 2]

    plotData = [scaleData, ICurve, MCurve]
    lineLabels = ["Raw", "⟨n2⟩", "M"]
    theColors = ['#6f98ff', '#edc240', "gray"]
    theGNColors = ['#edc240', '#afd8ff', 'gray']
    thenaColors = ['#6f98ff', '#edc240', '#6f98ff', '#afd8ff']
    showLines = [false, true, true], showPoints = [2, 0, 0]
    naData = [naFitData, naCurve, anFitData, anCurve]
    naLineLabels = ["", "⟨n2⟩/a2", "", "a2/⟨n2⟩"]
    G2yAxisL1R2 = [1, 1, 2, 2]

    //Now set up all the graphing data detail by detail.
    const prmap = {
        plotData: plotData, //An array of 1 or more datasets
        lineLabels: lineLabels, //An array of labels for each dataset
        dottedLine: [false, false, true],
        borderWidth: [3, 3, 1],
        colors: theColors,
        showLines: showLines,
        showPoints: showPoints,
        xLabel: "a2& ", //Label for the x axis, with an & to separate the units
        yLabel: "⟨n2⟩&" + Units, //Label for the y axis, with an & to separate the units
        y2Label: null, //Label for the y2 axis, null if not needed
        yAxisL1R2: [], //Array to say which axis each dataset goes on. Blank=Left=1
        logX: false, //Is the x-axis in log form?
        xTicks: undefined, //We can define a tick function if we're being fancy
        logY: false, //Is the y-axis in log form?
        yTicks: undefined, //We can define a tick function if we're being fancy
        legendPosition: 'top', //Where we want the legend - top, bottom, left, right
        xMinMax: [,], //Set min and max, e.g. [-10,100], leave one or both blank for auto
        yMinMax: [,], //Set min and max, e.g. [-10,100], leave one or both blank for auto
        y2MinMax: [,], //Set min and max, e.g. [-10,100], leave one or both blank for auto
        xSigFigs: 'P3', //These are the sig figs for the Tooltip readout. A wide choice!
        ySigFigs: 'P3', //F for Fixed, P for Precision, E for exponential
    };
    let ShowUnits = " "
    if (fitData.length > 3) ShowUnits = "kg/mol"
    const prmap1 = {
        plotData: [GCurve, NCurve, MNCurve], //An array of 1 or more datasets
        lineLabels: ["G22 / v", "N22", "M"], //An array of labels for each dataset
        colors: theGNColors,
        borderWidth: [3, 3, 1],
        dottedLine: [false, false, true],
        xLabel: "a2& ", //Label for the x axis, with an & to separate the units
        yLabel: "G22 / v&" + ShowUnits, //Label for the y axis, with an & to separate the units
        y2Label: "N22& ", //Label for the y2 axis, null if not needed
        yAxisL1R2: [1, 2, 2], //Array to say which axis each dataset goes on. Blank=Left=1
        logX: false, //Is the x-axis in log form?
        xTicks: undefined, //We can define a tick function if we're being fancy
        logY: false, //Is the y-axis in log form?
        yTicks: undefined, //We can define a tick function if we're being fancy
        legendPosition: 'top', //Where we want the legend - top, bottom, left, right
        xMinMax: [,], //Set min and max, e.g. [-10,100], leave one or both blank for auto
        yMinMax: [,], //Set min and max, e.g. [-10,100], leave one or both blank for auto
        y2MinMax: [,], //Set min and max, e.g. [-10,100], leave one or both blank for auto
        xSigFigs: 'P3', //These are the sig figs for the Tooltip readout. A wide choice!
        ySigFigs: 'P3', //F for Fixed, P for Precision, E for exponential
    };

    const prmap2 = {
        plotData: naData, //An array of 1 or more datasets
        lineLabels: naLineLabels, //An array of labels for each dataset
        colors: thenaColors,
        showLines: [false, true, false, true],
        showPoints: [2, 0, 2, 0],
        xLabel: "a2& ", //Label for the x axis, with an & to separate the units
        yLabel: "⟨n2⟩ / a2&" + Units, //Label for the y axis, with an & to separate the units
        y2Label: G2y2Label, //Label for the y2 axis, null if not needed
        yAxisL1R2: G2yAxisL1R2, //Array to say which axis each dataset goes on. Blank=Left=1
        logX: false, //Is the x-axis in log form?
        xTicks: undefined, //We can define a tick function if we're being fancy
        logY: false, //Is the y-axis in log form?
        yTicks: undefined, //We can define a tick function if we're being fancy
        legendPosition: 'top', //Where we want the legend - top, bottom, left, right
        xMinMax: [,], //Set min and max, e.g. [-10,100], leave one or both blank for auto
        yMinMax: [0,], //Set min and max, e.g. [-10,100], leave one or both blank for auto
        y2MinMax: [,], //Set min and max, e.g. [-10,100], leave one or both blank for auto
        xSigFigs: 'P3', //These are the sig figs for the Tooltip readout. A wide choice!
        ySigFigs: 'P3', //F for Fixed, P for Precision, E for exponential
    };


    //Now we return everything - text boxes, plot and the name of the canvas, which is 'canvas' for a single plot
    return {
        Values: Values,
        plots: [prmap, prmap1, prmap2],
        canvas: ['canvas', 'canvas1', 'canvas2'],
    };

}

//The fitting routine
fminsearch = function (fun, Parm0, x, y, Opt) {
    if (!Opt) {
        Opt = {}
    };
    if (!Opt.maxIter) {
        Opt.maxIter = 1000
    };
    if (!Opt.step) { // initial step is 1/100 of initial value (remember not to use zero in Parm0)
        Opt.step = Parm0.map(function (p) {
            return p / 100
        });
        Opt.step = Opt.step.map(function (si) {
            if (si == 0) {
                return 1
            } else {
                return si
            }
        }); // convert null steps into 1's
    };

    if (!Opt.objFun) {
        Opt.objFun = function (y, yp) {
            return y.map(function (yi, i) {
                return Math.pow((yi - yp[i]), 2)
            }).reduce(function (a, b) {
                return a + b
            })
        }
    } //SSD

    var cloneVector = function (V) {
        return V.map(function (v) {
            return v
        })
    };
    var P0 = cloneVector(Parm0),
        P1 = cloneVector(Parm0);
    var n = P0.length;
    var step = Opt.step;
    var funParm = function (P) {
        return Opt.objFun(y, fun(x, P))
    } //function (of Parameters) to minimize multi-univariate screening
    let lastfP = 1e-19, newfP = 0, lasti = 0
    const Q = Math.sqrt((Opt.maxIter - 2000) / 2000)
    const eps = Math.pow(10, -3 - Q)
    for (var i = 0; i < Opt.maxIter; i++) {
        for (var j = 0; j < n; j++) { // take a step for each parameter
            P1 = cloneVector(P0);
            P1[j] += step[j];
            if ((!Opt.noNeg || P1[j] > 0) && funParm(P1) < funParm(P0)) { // if parm value going in the righ direction
                step[j] = 1.2 * step[j]; // then go a little faster
                P0 = cloneVector(P1);
            } else {
                step[j] = -(0.5 * step[j]); // otherwiese reverse and go slower
            }
        }
        //Stop trying too hard
        newfP = funParm(P0)
        if (i > 1000 && newfP < lastfP && Math.abs((newfP - lastfP) / lastfP) < eps) break
        lastfP = newfP
        lasti = i
    }
    //console.log(lasti)
    return P0
};
function clearOldName() { //This is needed because otherwise you can't re-load the same file name!
    Loading = true
    document.getElementById('Load').value = ""
}
function handleFileSelect(evt) {
    var f = evt.target.files[0];
    if (f) {
        var r = new FileReader();
        r.onload = function (e) {
            var data = e.target.result;
            LoadData(data)
        }
        r.readAsText(f);
    } else {
        return;
    }
}

//Load data from a chosen file
function LoadData(S) {
    Papa.parse(S, {
        download: false,
        header: true,
        skipEmptyLines: true,
        complete: papaCompleteFn,
        error: papaErrorFn
    })
}
function papaErrorFn(error, file) {
    console.log("Error:", error, file)
}
function papaCompleteFn() {
    document.getElementById('Demo').value="From File"
    var theData = arguments[0]
    fitData = []; scaleData = []; lastFit = "", newFit = true
    if (theData.data.length < 3) return //Not a valid data file
    let maxY = 0
    for (i = 0; i < theData.data.length; i++) {
        theRow = theData.data[i]
        if (theRow.Item.toUpperCase() == "DATA") {
            // fitData.push({ x: parseFloat(theRow.a), y: parseFloat(theRow.n) })
            l = fitData.length
            if (l == 0 || parseFloat(theRow.a) != fitData[l - 1].x) fitData.push({ x: parseFloat(theRow.a), y: parseFloat(theRow.n) })
            let y = parseFloat(theRow.n)
            if (y > maxY) maxY = y
        }
    }
    if (maxY == 0) return //Invalid data
    divBy = 1
    if (maxY > 1) { //The general fitting is easier if scaled from ~0-1
        if (maxY <= 10000) divBy = 10000
        if (maxY <= 1000) divBy = 1000
        if (maxY <= 100) divBy = 100
        if (maxY <= 10) divBy = 10
        for (i = 0; i < fitData.length; i++) {
            fitData[i].y /= divBy
        }
    }
    Loading = false
    Main()
}
function GetDemo() {
    clearOldName()
     let Demo = document.getElementById('Demo').value
     if ( Demo == "From File") {document.getElementById('Demo').value = "CB Ethanol";Demo="CB Ethanol"}
     const CN2 = [[0.000201, 0.248540179], [0.0025, 0.493482143], [0.00917, 0.668125], [0.0186, 0.772723214], [0.0383, 0.887901786], [0.0514, 0.940267857], [0.0666, 0.990758929], [0.0828, 1.037142857], [0.099, 1.079196429], [0.1188, 1.126651786], [0.1396, 1.17375], [0.1604, 1.2190625], [0.1813, 1.262901786], [0.2022, 1.305848214], [0.2228, 1.347901786], [0.2428, 1.3884375], [0.2634, 1.429955357], [0.2834, 1.470535714], [0.3036, 1.511607143], [0.3237, 1.552901786], [0.3439, 1.5946875], [0.3639, 1.636607143], [0.3839, 1.678705357], [0.404, 1.721116071], [0.4241, 1.764151786], [0.4442, 1.807633929], [0.4639, 1.852098214], [0.4841, 1.897366071], [0.5041, 1.943348214], [0.5239, 1.989821429], [0.5438, 2.037276786], [0.5637, 2.085982143], [0.5836, 2.13625], [0.6033, 2.1875], [0.6232, 2.24], [0.643, 2.294598214], [0.6627, 2.351517857], [0.6827, 2.410758929], [0.7023, 2.471785714], [0.7219, 2.535803571], [0.7415, 2.60375], [0.761, 2.675401786], [0.7805, 2.751473214], [0.8, 2.833258929], [0.8191, 2.924375], [0.8382, 3.022232143], [0.8569, 3.13125], [0.8819, 3.305133929], [0.902, 3.480446429], [0.92, 3.675848214], [0.936, 3.915044643], [0.953, 4.204553571], [0.957, 4.309732143], [0.969, 4.643303571], [0.975, 4.866071429], [0.983, 5.267857143], [0.988, 5.535714286], [0.99, 5.758928571]]
    const CH2O = [[0.0029, 0.260889645], [0.008, 0.519003868], [0.0166, 0.827630767], [0.031, 1.176223739], [0.0419, 1.362732081], [0.0516, 1.50427859], [0.082, 1.874519852], [0.0941, 1.998303662], [0.148, 2.488443144], [0.207, 2.945277564], [0.2529, 3.272777331], [0.3045, 3.633027075], [0.3538, 3.952755661], [0.4018, 4.263047813], [0.4479, 4.563903531], [0.515, 5.007971011], [0.5465, 5.21834798], [0.6125, 5.695720522], [0.6561, 6.062631277], [0.6853, 6.374033598], [0.6979, 6.544999578], [0.7415, 7.100083929], [0.7943, 7.931045202], [0.8138, 8.194710268], [0.8465, 8.730366667], [0.8815, 9.435878876], [0.8917, 9.646810929], [0.9256, 10.47166627], [0.9474, 11.14942427], [0.9686, 11.91155508]]
    const Ethanol = [[0, 0], [0.0001, 2.888365448], [0.0003, 24.67209844], [0.0005, 39.20145191], [0.00070922, 58.09167085], [0.001182033, 71.16911869], [0.001654846, 85.69847215], [0.002836879, 106.0508939], [0.004018913, 127.8552213], [0.005673759, 151.1217516], [0.007565012, 171.4896191], [0.009692671, 193.3145409], [0.013238771, 220.9779769], [0.016548463, 245.7324529], [0.019385343, 264.6690093], [0.023404255, 286.5351199], [0.028132388, 308.4166763], [0.03286052, 327.3944215], [0.037825059, 349.2811265], [0.043026005, 369.7210745], [0.05106383, 398.9342395], [0.060992908, 428.1885933], [0.069267139, 453.05119], [0.075177305, 469.1508669], [0.08321513, 488.2006925], [0.094562648, 516.0340323], [0.101182033, 533.6010606], [0.111111111, 554.1439806], [0.126477541, 587.8724691], [0.138061466, 609.903335], [0.149408983, 633.3809579], [0.169739953, 665.7656614], [0.178250591, 680.4700673], [0.190307329, 699.6074191], [0.19929078, 712.8702166]]
    const Toluene = [[0, 0], [0.0001, 2], [0.0002, 15.51724138], [0.000329045, 25.86206897], [0.00135787, 38.79310345], [0.002386695, 51.72413793], [0.004104133, 68.53448276], [0.006168483, 82.75862069], [0.008232089, 98.27586207], [0.012018343, 121.5517241], [0.014772795, 137.0689655], [0.018560538, 157.7586207], [0.024073909, 181.0344828], [0.027518462, 197.8448276], [0.030275892, 208.1896552], [0.034411292, 225], [0.038892115, 241.8103448], [0.044062295, 261.2068966], [0.054751802, 293.5344828], [0.062335477, 320.6896552], [0.06750938, 333.6206897], [0.075096778, 354.3103448], [0.084409803, 377.5862069], [0.095103776, 402.1551724], [0.121325412, 455.1724138], [0.134438092, 478.4482759], [0.142719314, 493.9655172], [0.154109344, 509.4827586], [0.165844053, 526.2931034], [0.178961199, 541.8103448], [0.191042821, 556.0344828], [0.198983831, 562.5], [0.22, 582], [0.24, 595], [0.26, 607], [0.28, 618], [0.3, 626], [0.32, 633], [0.34, 640], [0.36, 646], [0.38, 652], [0.4, 657]]
    const Zeolite = [[1.84E-06, 0], [3.93E-06, 0.024156275], [5.79E-06, 0], [1.02E-05, 0.048312638], [1.23E-05, 0.072468956], [2.27E-05, 0.144937913], [3.51E-05, 0.265719066], [4.65E-05, 0.362344342], [5.78E-05, 0.483126376], [6.72E-05, 0.555596653], [8.17E-05, 0.676376046], [9.32E-05, 0.821312197], [0.000103959, 0.942095993], [0.000113675, 1.038718626], [0.000222492, 2.488097754], [0.000336167, 4.009942756], [0.000446927, 5.145310436], [0.000567403, 5.797490092], [0.000675249, 6.15984148], [0.000786009, 6.425583443], [0.00091523, 6.691281374], [0.000981297, 6.81206517], [0.001117318, 6.932848965], [0.002283216, 7.705856451], [0.003371387, 8.068207838], [0.004372116, 8.285601057], [0.005557445, 8.430559225], [0.006606752, 8.551343021], [0.007694923, 8.647952444], [0.008763663, 8.720431528], [0.00997882, 8.76873624], [0.010884042, 8.817040951], [0.021329998, 9.155217966], [0.027673257, 9.22769705], [0.031522468, 9.300176134], [0.042712946, 9.42095993], [0.051925383, 9.493439014], [0.061770318, 9.565874064], [0.073480107, 9.638353148], [0.080141948, 9.662527521], [0.085534224, 9.68665786], [0.0932889, 9.710832232], [0.103977654, 9.735006605], [0.12917367, 9.807441656], [0.153664319, 9.87992074], [0.182797182, 9.952399824], [0.203756133, 10.02487891], [0.22710906, 10.07318362], [0.253139665, 10.12148833], [0.288340053, 10.16979304], [0.307745446, 10.21809775], [0.328457615, 10.26640247], [0.35823658, 10.29057684], [0.374130678, 10.31475121], [0.399295604, 10.33888155], [0.426169541, 10.38718626], [0.464808356, 10.41136063], [0.506967209, 10.45966535], [0.541087199, 10.50797006], [0.565112461, 10.5563188], [0.629905271, 10.62875385], [0.67231965, 10.70123294], [0.717592422, 10.77371202], [0.749481661, 10.84619111], [0.799949478, 10.91862616], [0.835468545, 10.9669749], [0.853845033, 11.01527961], [0.892, 11.2568472], [0.911836774, 11.47424042], [0.931290746, 11.06358432], [0.932272043, 11.81241744]]
    let theData = []
    if (Demo == "Cement N2") theData = CN2
    if (Demo == "Cement H20") theData = CH2O
    if (Demo == "Zeolite Ar") theData = Zeolite
    if (Demo == "CB Ethanol") theData = Ethanol
    if (Demo == "CB Toluene") theData = Toluene
    fitData = []
    if (theData.length < 3) return
    let maxY = 0
    for (i = 0; i < theData.length; i++) {
        fitData.push({ x: theData[i][0], y: theData[i][1] })
        let y = theData[i][1]
        if (y > maxY) maxY = y
    }
    if (maxY == 0) return //Invalid data
    divBy = 1
    if (maxY > 1) { //The general fitting is easier if scaled from ~0-1
        if (maxY <= 10000) divBy = 10000
        if (maxY <= 1000) divBy = 1000
        if (maxY <= 100) divBy = 100
        if (maxY <= 10) divBy = 10
        for (i = 0; i < fitData.length; i++) {
            fitData[i].y /= divBy
        }
    }

    Loading = false
    Main()

}
function fallbackCopyTextToClipboard(text) { //Necessary if async not in browser
    var textArea = document.createElement("textarea");
    textArea.value = text;
    document.body.appendChild(textArea);
    textArea.focus();
    textArea.select();
    var elmnt = document.getElementById("network");
    elmnt.scrollIntoView();
    try {
        var successful = document.execCommand('copy');
        var msg = successful ? 'successful' : 'unsuccessful';
    } catch (err) {
    }

    document.body.removeChild(textArea);
}
function copyTextToClipboard(text) {
    if (!navigator.clipboard) {
        fallbackCopyTextToClipboard(text);
        return;
    }
    navigator.clipboard.writeText(text).then(function () {
    }, function (err) {
    });
}
                        

STSA: A reliable surface area determination

It has been known for decades that the BET surface area technique is unreliable at best and meaningless in many cases, and deceptively cumbersome to calculate properly. But there has never been a viable alternative.

Happily, the STSA, Statistical Thermodynamics Surface Area is not just reliable for well-behaved cases but provides an indication of when the surfaces are unlikely to give a reliable value. The demo data files let you see a variety of isotherms which cover a range of good and bad isotherm behaviours so you can explore all these ideas live. You can analyze your own isotherm data, as discussed below.

Although stat therm may be seen generally as intimidating, in this case it is remarkably simple. First, it tells us that typical isotherms can be described accurately with a 3-parameter formula which has no BET-like assumptions. It doesn't assume a planar surface, or monolayer coverage or low multi-layer interactions energies. These assumptions not only complicate the understanding of adsorption but also make fitting extremely difficult and cumbersome.

Using stat therm terminology where ⟨n2⟩ is identical to the IUPAC "n" and a2 is the IUPAC "p/p0" we have two equivalent forms of the isotherm equation:

`(⟨n_2⟩)=a_2/(A-Ba_2-Ca_2^2)"`

or

`a_2/(⟨n_2⟩)=A-Ba_2-Ca_2^2`

Via any convenient fitting algorithm you can derive the 3 parameters, A, B & C. In the app the default fit is the second equation, you can choose the Fit to ⟨n2⟩ option to use the first - generally the differences are minor. The equations have fundamental meanings but for the moment let's find the STSA. The key is that the "knee" of the isotherm can be defined as a point M (shown with dotted lines in the graphs) where the derived parameter N22 is a minimum. At M you have a value a2M where something like a monolayer coverage is complete at a value of ⟨n2M⟩. If you multiply ⟨n2M⟩ by the area of your probe (e.g. 16 Ų for N2) you have the STSA. That's it. Clean and simple.

You can see N22 in the middle plot and can read off a2 at that value and manually read off ⟨n2M⟩. But, of course, the values are calculated for you from A, B & C via

`a_(2M)=-(2A)/B(sqrt(1+B^2/(2AC))-1)`

and

`(⟨n_(2M)⟩)=-1/B(1/(1+(2AC)/B^2))`

All key values are shown in the Data box and placed on the Clipboard for you to paste into your own documents.

How to tell if the STSA is meaningful

The parameter N22 tells you how many other probe molecules are associated with any given probe on the surface. A glance at the graph shows that N22 is negative, i.e., where one probe is, another probe isn't. We have two extremes for the minimum value, N22M, where we are away from classic surface area measurement:

  • If you had a perfect Langmuir-like monolayer coverage then at point M, N22M is exactly -1, you can't have any other probe nearby. In practice it seems that values near -1 indicate, most commonly, a pore-filling case for a microporous sample rather than a BET-like surface.
  • If you are tending towards multilayer coverage well before monomer coverage, e.g., with a probe like water where there is a lot of water-water attraction, then N22M might be -0.5. This is a clear sign that you cannot calculate a meaningful monolayer coverage so the calculated STSA is only a guide.

In between these extremes, you can be fairly confident that your STSA is a reliable estimate of the specific surface area of your sample. In the Data box there is a visual indication of where you are between the extremes of multilayer and microporous.

The other check is to vary the range of a2 over which you fit the data. This has always been a problem for BET because objective requirements are complicated and routinely ignored. For STSA you simply slide the a2min and a2max sliders and look at the reported a2M and N22M values. If these are moving a lot then the fitted values are going to be unreliable. You will find that such large movements coincide with N22M away from the meaningful central zone.

Deeper meaning

One of the many problems with BET is that it assumes layer-by-layer adsorption onto a plane. But in reality, the BET has been applied to samples that break this assumption. The famous BET constant CB is (supposed to be) restricted in its parameter range and turns out to be a mix of two effects because `C_B=2-B/A`. So it been not only been difficult to fit isotherms but also impossible to interpret the isotherm meaningfully. Indeed, the main use of CB has been as one of the criteria (often ignored) to say whether a BET surface area is valid. In contrast to the unnaturally restricted 2-parameter fit of BET, the stat therm isotherm has 3 parameters, so fitting by stat therm is much easier than the BET. (How difficult and cumbersome it is to fit the BET to isotherm data is very well known.) In addition to the simplicity and clarity of the STSA and the guidance from N22M, the ABC parameters tell us:

  • A: This is a measure of how strongly a probe molecule is attracted to the bare surface - a smaller value meaning a larger attraction
  • B: This is a measure of how much the surface affects the attraction of a second probe to one already on the surface. B is negative, meaning that the presence of one probe discourages a second. The less negative, the more likely it is for a second probe to be around, making a monolayer less likely, making the STSA value less reliable.
  • C: This is a measure of surface-probe-probe-probe interactions. It is more relevant for describing the isotherm in the higher a2 regions and is of less direct relevance to understanding the region involved with surface area measurement.

Moving away from BET surface area to STSA

BET surface areas have always been a problem, and we now know that it is a combination of implausible assumptions (monolayers, planar surface, ...) and a 2-parameter model with an ill-defined BET constant CB. To move away from it to STSA is as simple as loading the isotherm into this app (or your own implementation of the approach). If your systems were relatively straightforward, your new STSA will not differ much from your previous BET surface area. If not, then the new analysis will reveal why your BET surface area wasn't valid and will tell you more about what is going on.

To load your own data you need a simple .csv file. The first row says Item, a, n and then each subsequent row says Data followed by the a2 and ⟨n2⟩ values. The format conforms to modern ways to structure data for apps. To help make this clear, you can download the .csv versions of the Demo files used in the app from STSA-Examples.zip. Using any of these files as a template makes it easy to create, and then load, the files with your own isotherm data. Note that because this is a 'Client side' app, none of your data is exposed to the internet.

1

Seishi Shimizu and Nobuyuki Matubayasi, Surface Area Estimation: Replacing the BET Model with the Statistical Thermodynamic Fluctuation Theory, Langmuir, 2022, https://doi.org/10.1021/acs.langmuir.2c00753