Moisture Sorption

Quick Start

We need to know how much moisture a food powder such as starch, a dried beverage or a sugar absorbs at a given water activity aw. This dependency is described via a water sorption isotherm, conveniently fitted to the GAB (Guggenheim-Anderson-de Boer) model. The amount absorbed affects the glass transition temperature Tg, which in turn affects whether the powder will stick together as calculated via the Powders-Moisture app.

Credits

This app is the first part of a chain of logic1 from a Nestlé & U Sheffield team.

Sorption Isotherms

M
C
K
Measured RH
% Water
//One universal basic required here to get things going once loaded
let fromFile = false,theIsotherm=[], updating = false
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);

    Main();
};
//Any global variables go here


//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() {
    if (updating) return
    saveSettings();

    //Send all the inputs as a structured object
    //If you need to convert to, say, SI units, do it here!
    const inputs = {
        M: sliders.SlideM.value,
        C: sliders.SlideC.value,
        K: sliders.SlideK.value,
        RH: sliders.SlideRH.value,
    }

    //Get all the resonses as a structure
    const result = CalcIt(inputs)

    //Set all the text box outputs
    document.getElementById('PercW').value = result.PercW
    if (result.plots) {
        for (let i = 0; i < result.plots.length; i++) {
            plotIt(result.plots[i], result.canvas[i]);
        }
    }

}

//Here's the real app calculation
function CalcIt({ M,C,K,RH }) {
    //The structure automatically has the names provided from input
    //By convention the values are provided with the correct units within CalcIt
    if (updating) return
    if (fromFile){
        let x=[],y=[]
        for (let i=0; i=RH && ! GotRH){
             GotRH=true
             PercW=M1
         }
    }

    //Now we return everything - text boxes, plot and the name of the canvas, which is 'canvas' for a single plot
    let plotData=[WPts], showLines=[true], showPoints=[false]
    if (fromFile) {
        plotData=[WPts,theIsotherm]
        showLines = [true,false]
        showPoints = [false,true]
 
        fromFile=false
    }

    const prmap = {
        plotData: plotData, //An array of 1 or more datasets
        lineLabels: ["%Water","Data"], //An array of labels for each dataset
        showLines: showLines,
        showPoints: showPoints,
        borderWidth: [3, 10],
        hideLegend: true,
        xLabel: 'a_w& ', //Label for the x axis, with an & to separate the units
        yLabel: '%Water& ', //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

    }
    return {
        PercW: PercW.toFixed(1),
        plots: [prmap],
        canvas: ['canvas'],
    };
}
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() {
    var theData = arguments[0]
    fromFile=false
    theIsotherm = [];
    if (theData.data.length < 3) return //Not a valid data file
    let maxX = 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) })
            theIsotherm.push({ x: parseFloat(theRow.a), y: parseFloat(theRow.n) })
            maxX=Math.max(parseFloat(theRow.a),maxX)
        }
    }

    if (theIsotherm.length < 4) return //Invalid data
    //Accept 0-->1 as well as 0-->100
    if (maxX<1) {
        for (i=0;i 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
};            

A water sorption isotherm shows the volume fraction of water absorbed versus the water activity, aw or, if you prefer, %RH/100. To get these isotherms you need to be able to measure the weight increase as the %RH of the air is changed, allowing enough time for equilibrium to be reached before stepping to the next level. There is often hysteresis (not shown) between the values measured going from 0 to 100% and 100% going down to 0, with the descending isotherm showing more sorption that the ascending one.

The GAB model has three parameters: M which is supposed to what would be expected from a monolayer coverage, typically in the 0.05 to 0.2 range, but with foods we have absorption as well as adsorption, then there's C which is in the 1-50 range, and K which is an adjustment parameter, typically in the 0.5 to 1 range. When K=1 the equation is formally equivalent to the well-known BET isotherm. The GAB model calculates the Sorption, S for a given water activity, aw. Because experimental data and GAB both have problems above 0.95 activity, the graph stops at that point.

`S=(MCKa_w)/((1-Ka_w)(1-Ka_w+CKa_w))`

When you set your actual RH the fraction at that RH is calculated and you can use the value in the Tg-Moisture app.

Real isotherm science

When you think of the assumptions behind GAB they make no sense for anything other than highly abstract surfaces. It turns out that the fitting is "right for the wrong reasons" and the real parameters, ABC, can be found via assumption-free thermodynamics. All this is explained in the STSA app in the Practical Solubility pages. If you (next section) load your own isotherms you can see them here in GAB and then in ABC, i.e. although the Stat Therm approach is fundamental and powerful, you start with the same basic raw data.

Your own data

If you have your own isotherm then you can load it and fit it to GAB. The file can be tab-separated or comma-separted text. The first row says Item, a, n and then each subsequent row says Data followed by the activity (which can be 0-to-1 or 0-to-100) and % moisture (i.e. always 0-100) values. The format conforms to modern ways to structure data for apps. To help make this clear, you can download the .csv versions of a few food isotherms, Sorption-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.

1Christine I. Haider, Gerhard Niederreiter, Stefan Palzer, Michael J. Hounslow, Agba D. Salman, Unwanted agglomeration of industrial amorphous food powder from a particle perspective, Chem. Eng. Res. & Design, 132, 2018, 1160–1169