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
//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