Coffee Drum Roasting

Quick Start

Roasting coffee to perfection requires control of a complex set of processes. Here we explore some of the main effects in your choice of a small, medium or large drum roaster.

Sure, it's complicated. But take it one step at a time and it will all make sense. See my demo on YouTube .

Credits

The app was created via discussions with the team at Barista Hustle. and has benefitted greatly from expert input from Mark Al-Shemmeri.

Coffee Drum Roasting

Bean weight g
Bean weight kg
Bean weight kg
Bean moisture %
MJ/hour @ 100%
MJ/hour @ 100%
MJ/hour @ 100%
TBean-Start °C
TInlet-Start °C
TFirst Crack °C
tDrop min
TCharge °C
TYellow °C
Rel. Drum/Air factor
Rel. Response
Post FC Factor
Pstart
T,t,P%1
T,t,P%2
T,t,P%3
T,t,P%4
T,t,P%5
S M L
Info
Bean D mm
Bean Cp kJ/kgK
Bean ρ kg/m³
# Beans
SurfArea m²
J thru roast
J for beans
Drum rpm
Drum D mm
Drum L mm
Drum D mm
Drum L mm
Drum D mm
Drum L mm
Drum ε
Bean ε
Froude
J from Radiative
//One universal basic required here to get things going once loaded
window.onload = function () {
    if (document.getElementById('Small').checked) setSmall()
    if (document.getElementById('Med').checked) setMed()
    if (document.getElementById('Large').checked) setLarge()
    //restoreDefaultValues(); //Un-comment this if you want to start with defaults
    Main();
    document.getElementById('Restore').addEventListener("click", doRestore)
    document.getElementById('Small').addEventListener("click", setSmall)
    document.getElementById('Med').addEventListener("click", setMed)
    document.getElementById('Large').addEventListener("click", setLarge)
};
//The small, med, large ranges cause chaos when you restore defaults
//So lots of tedious code to sort things out
function doRestore(){
    const wasSmall=document.getElementById('Small').checked
    const wasMed=document.getElementById('Med').checked
    const wasLarge=document.getElementById('Large').checked
    restoreDefaultValues()
    if (wasSmall) setSmall()
    if (wasMed) setMed()
    if (wasLarge) setLarge()
}
function setSmall(){
    document.getElementById('Small').checked=true
    document.getElementById('Ranges').style.display="block"
    document.getElementById('Rangem').style.display="none"
    document.getElementById('Rangel').style.display="none"
    document.getElementById('MJs').style.display="block"
    document.getElementById('MJm').style.display="none"
    document.getElementById('MJl').style.display="none"
    document.getElementById('Ddrums').style.display="block"
    document.getElementById('Ddrumm').style.display="none"
    document.getElementById('Ddruml').style.display="none"
    document.getElementById('Ldrums').style.display="block"
    document.getElementById('Ldrumm').style.display="none"
    document.getElementById('Ldruml').style.display="none"
}
function setMed(){
    document.getElementById('Med').checked=true
    document.getElementById('Ranges').style.display="none"
    document.getElementById('Rangem').style.display="block"
    document.getElementById('Rangel').style.display="none"
    document.getElementById('MJs').style.display="none"
    document.getElementById('MJm').style.display="block"
    document.getElementById('MJl').style.display="none"
    document.getElementById('Ddrums').style.display="none"
    document.getElementById('Ddrumm').style.display="block"
    document.getElementById('Ddruml').style.display="none"
    document.getElementById('Ldrums').style.display="none"
    document.getElementById('Ldrumm').style.display="block"
    document.getElementById('Ldruml').style.display="none"
}
function setLarge(){
    document.getElementById('Large').checked=true
    document.getElementById('Ranges').style.display="none"
    document.getElementById('Rangem').style.display="none"
    document.getElementById('Rangel').style.display="block"
    document.getElementById('MJs').style.display="none"
    document.getElementById('MJm').style.display="none"
    document.getElementById('MJl').style.display="block"
    document.getElementById('Ddrums').style.display="none"
    document.getElementById('Ddrumm').style.display="none"
    document.getElementById('Ddruml').style.display="block"
    document.getElementById('Ldrums').style.display="none"
    document.getElementById('Ldrumm').style.display="none"
    document.getElementById('Ldruml').style.display="block"
}
//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();

    //Send all the inputs as a structured object
    //If you need to convert to, say, SI units, do it here!
    let theKg=sliders.Slidekgs.value,theMJ=sliders.SlideMJs.value
    let theDdrum=sliders.SlideDdrums.value , theLdrum=sliders.SlideLdrums.value
    if ( document.getElementById('Med').checked) {theKg=sliders.Slidekgm.value;theMJ=sliders.SlideMJm.value;theDdrum=sliders.SlideDdrumm.value;theLdrum=sliders.SlideLdrumm.value}
    if ( document.getElementById('Large').checked) {theKg=sliders.Slidekgl.value;theMJ=sliders.SlideMJl.value;theDdrum=sliders.SlideDdruml.value;theLdrum=sliders.SlideLdruml.value}
    const inputs = {
        kg: theKg,
        water: sliders.SlideH2O.value / 100,
        MJ: theMJ,
        Tair: sliders.SlideTair.value,
        Tbeans: sliders.SlideTbeans.value,
        Toffset: sliders.SlideToffset.value,
        TFC: sliders.SlideTFC.value,
        PostFC: sliders.SlidePostFC.value,
        tmax: sliders.Slidetmax.value,
        Speed: sliders.SlideSpeed.value,
        Respv: sliders.SlideResp.value,
        TDry: sliders.SlideTDry.value,
        //This is the list of power settings
        P0: document.getElementById('P0').value,
        TP1: document.getElementById('TP1').value,
        TP2: document.getElementById('TP2').value,
        TP3: document.getElementById('TP3').value,
        TP4: document.getElementById('TP4').value,
        TP5: document.getElementById('TP5').value,
        D: sliders.SlideD.value/1e3, //mm to m
        rho: sliders.Sliderho.value,
        Cp: sliders.SlideCp.value,
        RPM: sliders.SlideRPM.value,
        Ddrum: theDdrum/1000, //mm to m
        Ldrum: theLdrum/1000, //mm to m
        Deta: sliders.SlideDeta.value,
        Beta: sliders.SlideBeta.value,
    }

    //Get all the resonses as a structure
    let start = window.performance.now();

    const result = CalcIt(inputs)
    let end = window.performance.now();
    console.log(`Execution time: ${end - start} ms`)
    document.getElementById('Info').value = result.Info;
    document.getElementById('NB').value = result.NB;
    document.getElementById('SA').value = result.SA;
    document.getElementById('kJb').value = result.kJb;
    document.getElementById('kJr').value = result.kJr;
    document.getElementById('Froude').value = result.Froude;
    document.getElementById('kJRad').value = result.kJRad;
   copyTextToClipboard(result.Info)
    //Set all the text box outputs
    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({ kg, water, MJ, Tair, Tbeans, TFC, Toffset, TDry, PostFC, tmax, Speed, Respv, P0, TP1, TP2, TP3, TP4, TP5, D, rho, Cp, RPM, Ddrum, Ldrum, Deta, Beta}) {
    //The structure automatically has the names provided from input
    //By convention the values are provided with the correct units within CalcIt
    if (document.getElementById('Small').checked) kg/=1000 //g to kg
    if (Speed > 3) Speed = 6 - Speed //A crude way to reflect optimum drum and air flows
    let i = 0, tmp = [], ttmp = [], tnow = 0, Tnew = 0, Pnew = 0, Pold = 0, tstep = 0, Tbnow = Tbeans, kgnow = kg, HTCFnow = 0.019 + (Speed - 3) * 0.0005, swap = 0, tDry = 0, AmDry = false
    let PPts = [], WPts = [], OPts = [], BPts = [], TBPts = [], KPts = [], ROR = [], ITPts = []
    const Power = "Power: " + (MJ * 948 / 1000).toFixed(1) + " kBTU, " + (MJ * 1e3 / 3600).toFixed(1) + " kW"
    Ldrum *= 0.75 //Because the beans don't fill the whole length of the drum
    const DrumArea=Math.PI*Ddrum*Ldrum, Stefan=5.6703e-8
    const BeanArea=2*Math.PI*Math.sqrt(2*kg/1000/(Math.PI*Ldrum))*Ldrum //A crude density of 0.5
    const Froude = Math.pow(RPM/60*2*Math.PI,2)*Ddrum/2/9.8
   let PastTFC = false
    let T = [], P = [], tv = [], thet = 0, theT = 0
    P0 = parseFloat(P0)
    if (isNaN(P0) || P0 < 10 || P0 > 100) P0 = 100
    T.push(0), tv.push(0), P.push(P0)
    function sortOut(TtP) {
        TempP = TtP.trim()
        TempP = TempP.replaceAll(" ", "")
        TempP = TempP.replaceAll(";", ",")
        TempP = TempP.replaceAll(" ,", ",")
        TempP = TempP.replaceAll(", ", ",")
        tmp = TempP.split(",")
        if (tmp.length > 2) {
            ttmp = tmp[1].split(":")
            thet = parseFloat(ttmp[0])
            if (ttmp.length > 1) thet += parseFloat(ttmp[1]) / 60
            theT = parseFloat(tmp[0])
            if (!isNaN(theT) && !isNaN(thet) && (theT > 0 || thet > 0)) {
                T.push(parseFloat(tmp[0]))
                tv.push(thet)
                P.push(parseFloat(tmp[2]))
            }
        }
    }
    sortOut(TP1); sortOut(TP2); sortOut(TP3); sortOut(TP4); sortOut(TP5)
    //Sort them (crudely) because we need T's in ascending order for the calculations
    for (i = 1; i < T.length; i++) {
        for (j = 1; j < T.length - 1; j++) {
            if ((T[j] * T[j + 1] > 0 && T[j] > T[j + 1]) || (tv[j] * tv[j + 1] > 0 && tv[j] > tv[j + 1])) {
                swap = T[j]; T[j] = T[j + 1]; T[j + 1] = swap
                swap = tv[j]; tv[j] = tv[j + 1]; tv[j + 1] = swap
                swap = P[j]; P[j] = P[j + 1]; P[j + 1] = swap
            }
        }
    }
    PPts.push({ x: 0, y: P[0] })
    Pold = P[0]
    const nsteps = 200, RoRCorrection = 0.5
    const Wfact = 0.0012 * kg / 10, WEV = 750, Resp = 10 + (3 - Respv) * 3, PostFCfact = 600, PostFCJfact = 50
    let kgCoffee = kg * (1 - water)
    let Pnow = 1, Toven = Toffset, Toveneq = Toven, MJnow = MJ, MJeq = MJ, DeltaT = 0, DeltaTM = 0, Wnow = water * kgCoffee, Wloss = 0, WMJ = 0
    let PostFCloss = 0, PostFCJ = 0
    tnow = 0; tstep = tmax / nsteps
    let TbMeasure = Toven
    OPts.push({ x: tnow, y: Toven })
    BPts.push({ x: tnow, y: Toven })
    let tTurn = 0, Tprev = 999, tFC = 0
    let Toveneqold = 0, MJTot=0
    ITPts.push({ x: 0, y: Tair })
    let Radiative = 0
    //Step through the total time. nsteps between 100 and 500 work well, so 200 is OK
    for (i = 0; i <= nsteps; i++) {
        tnow += tstep
        if ((Tbnow > 80 && T[Pnow] > 0 && TbMeasure > T[Pnow]) || (tv[Pnow] > 0 && tnow > tv[Pnow])) {
            Pnew = P[Pnow]
            PPts.push({ x: tnow, y: Pold })
            PPts.push({ x: tnow, y: Pnew })
            Pold = Pnew
            Pnow += 1
        }
        Toveneq = Tair * (1 - (1 - P[Math.max(0, Pnow - 1)] / P[0]) * 0.2)
        if (Toveneqold == 0) Toveneqold = Toveneq //Necessary at start
        Toven += (Tbnow - (Toven - 40 + (Tair - Toveneq))) / (Resp * 5)
        ITPts.push({ x: tnow, y: Toveneq })
        OPts.push({ x: tnow, y: Toven })
        MJeq = MJ * P[Pnow - 1] / 100
        MJnow = MJnow + (MJeq - MJnow) / Resp
        MJTot+=MJnow
        Wloss = Math.max(0, (Tbnow - 100) * Wfact * tstep)
        //Check for First Crack
        if (Tbnow >= TFC) {
            if (tFC == 0) tFC = tnow
            PastTFC = true
        }
        if (Tbnow >= TDry) {
            if (tDry == 0) tDry = tnow
            AmDry = true
        }
        if (PastTFC) {

            Wloss = (Wnow - 0.01 * kgCoffee) / 10
            Wnow = Wnow - Wloss
            WMJ = WEV * Wloss
        } else {
            if (Wnow > 0 && Wloss > 0) {
                Wloss = Math.min(Wloss, Wnow - 0.01 * kgCoffee)
                Wnow = Wnow - Wloss
                WMJ = WEV * Wloss
            } else {
                WMJ = 0
            }
        }
        WPts.push({ x: tnow, y: Wnow * 100 / kgCoffee })
        if (PostFC > 0 && PastTFC) {
            PostFCloss = kgCoffee * PostFC / PostFCfact
            kgCoffee -= PostFCloss
            PostFCJ = Math.pow(Tbnow + 1 - TFC, 2) * PostFCloss * PostFCJfact
        }
        //Now we work out the current weight ready for the T calculation
        kgnow = kgCoffee + Wnow
        KPts.push({ x: tnow, y: 100 * kgnow / kg })
        DeltaT = ((Toveneqold) - Tbnow) * (MJnow - WMJ + PostFCJ) / kgnow * HTCFnow * tstep
        Toveneqold += (Toveneq - Toveneqold) / 100
        Tbnow += DeltaT
        //Tbnow is the real value, TbMeasure is what the bean probe records
        DeltaTM = (Tbnow - TbMeasure) / (Resp)
        TbMeasure += DeltaTM
        BPts.push({ x: tnow, y: TbMeasure })
        TBPts.push({ x: tnow, y: Tbnow })
        if (TbMeasure > Tprev && tTurn == 0) tTurn = tnow
        Tprev = TbMeasure
        ROR.push({ x: tnow, y: DeltaTM / tstep * RoRCorrection })
        Radiative+=Stefan*(Math.pow(Toveneq+273,4)-Math.pow(Tbnow+273,4))
    }
    Radiative *= BeanArea/(1/Beta+(BeanArea/DrumArea)*(1/Deta-1)) * tstep * 60 //Min to seconds
    //console.log((Radiative/1e3).toFixed(1)+"kJ",DrumArea,BeanArea, Froude)
    MJTot*=tmax/60/nsteps
    PPts.push({ x: tmax, y: Pold })
    //Find the values to report, then sort out the graphs
    const TFinal=TbMeasure
    const tTurnm = Math.floor(tTurn)
    const tTurnms = "t_Turn: " + tTurnm.toFixed(0) + "m:" + ((tTurn - tTurnm) * 60).toFixed(0) + "s"
    const tFCm = Math.floor(tFC), tDrop = Math.floor(tmax), tDryms = Math.floor(tDry)
    let tFCms = "-"
    tFCms = " t_Yellow: " + tDry.toFixed(0) + "m:" + (Math.min(59, (tDry - tDryms) * 60)).toFixed(0) + "s"
    if (tFC > 0) tFCms += ", t_FC: " + tFCm.toFixed(0) + "m:" + (Math.min(59, (tFC - tFCm) * 60)).toFixed(0) + "s"
    tFCms += " , t_Drop: " + tDrop.toFixed(0) + "m:" + (Math.min(59, (tmax - tDrop) * 60)).toFixed(0) + "s"
    const TDrop = "T_Drop: " + TbMeasure.toFixed(0) + "°C"
    let Ratios = "-"
    if (tFC > 0 && tDry > 0) {
        const Brown = tFC - tDry, Dev = tDrop - tFC
        Ratios = "Yellow: " + (tDry * 100 / tDrop).toFixed(1) + "%, Brown: " + (Brown * 100 / tDrop).toFixed(1) + "%, Dev: " + (Dev * 100 / tDrop).toFixed(1) + "%"
    }
    
    //Can we get a 2-step version
    let t2step = 0, TBPts2 = [], TBPts2opt = [], TDiff = 0, TDiffMax = 99999, Popt = 0, topt = 0, MaxDiff = 0, TheMaxDiff = 0
    for (let t2 = Math.max(1, tv[1]); t2 < tmax; t2 += tstep) {
        for (let Pv = 20; Pv < P0; Pv += 0.5) {
            //Restore everything
            TBPts2 = []
            tnow = 0; Tbnow = Tbeans; PostFCloss = 0, PostFCJ = 0; AmDry = false; Toveneqold = 0; tFC = 0; PastTFC = false;
            Toven = Toffset; Toveneq = Toven; MJnow = MJ; MJeq = MJ; DeltaT = 0; DeltaTM = 0; kgCoffee = kg * (1 - water); Wnow = water * kgCoffee; Wloss = 0; WMJ = 0;

            P[1] = Pv
            t2step = t2
            Pnow = 0
            for (i = 0; i <= nsteps; i++) {
                tnow += tstep
                if (Pnow == 0 && tnow > t2step) {
                    Pnow = 1
                }
                Toveneq = Tair * (1 - (1 - P[Pnow] / P[0]) * 0.2)
                if (Toveneqold == 0) Toveneqold = Toveneq //Necessary at start
                Toven += (Tbnow - (Toven - 40 + (Tair - Toveneq))) / (Resp * 5)
                MJeq = MJ * P[Pnow] / 100
                MJnow = MJnow + (MJeq - MJnow) / Resp
                Wloss = Math.max(0, (Tbnow - 100) * Wfact * tstep)
                //Check for First Crack
                if (Tbnow >= TFC) {
                    if (tFC == 0) tFC = tnow
                    PastTFC = true
                }
                if (Tbnow >= TDry) {
                    if (tDry == 0) tDry = tnow
                    AmDry = true
                }
                if (PastTFC) {
                    Wloss = (Wnow - 0.01 * kgCoffee) / 10
                    Wnow = Wnow - Wloss
                    WMJ = WEV * Wloss
                } else {
                    if (Wnow > 0 && Wloss > 0) {
                        Wloss = Math.min(Wloss, Wnow - 0.01 * kgCoffee)
                        Wnow = Wnow - Wloss
                        WMJ = WEV * Wloss
                    } else {
                        WMJ = 0
                    }
                }
                if (PostFC > 0 && PastTFC) {
                    PostFCloss = kgCoffee * PostFC / PostFCfact
                    kgCoffee -= PostFCloss
                    PostFCJ = Math.pow(Tbnow + 1 - TFC, 2) * PostFCloss * PostFCJfact
                }
                //Now we work out the current weight ready for the T calculation
                kgnow = kgCoffee + Wnow
                DeltaT = ((Toveneqold) - Tbnow) * (MJnow - WMJ + PostFCJ) / kgnow * HTCFnow * tstep
                Toveneqold += (Toveneq - Toveneqold) / 100
                Tbnow += DeltaT
                //Tbnow is the real value, TbMeasure is what the bean probe records
                DeltaTM = (Tbnow - TbMeasure) / (Resp)
                TbMeasure += DeltaTM
                TBPts2.push({ x: tnow, y: Tbnow })
            }
            TDiff = 0, MaxDiff = 0
            for (i = 0; i < TBPts.length; i++) {
                TDiff += Math.pow(TBPts[i].y - TBPts2[i].y, 2)
                MaxDiff = Math.max(Math.abs(TBPts[i].y - TBPts2[i].y), MaxDiff)
            }
            if (TDiff < TDiffMax) {
                TDiffMax = TDiff
                TheMaxDiff = MaxDiff
                Popt = Pv
                topt = t2step
                for (i = 0; i < TBPts.length; i++) {
                    TBPts2opt[i] = TBPts2[i]
                }
            }
        }
    }
    const toptm = Math.floor(topt)
    let StepText = "2-Step match with: Power=" + Popt.toFixed(1) + "% at t=" + toptm.toFixed(0) + "m:" + ((topt - toptm) * 60).toFixed(0) + "s with Max ΔT: " + TheMaxDiff.toFixed(1) + " and RMS Error=" + Math.sqrt(TDiffMax / nsteps).toFixed(2)
    //End of 2-step tests
    //Bean calcs
    const BMass = 4/3*Math.PI*Math.pow(D/2,3)*rho
    const NBeans = kg/BMass
    const SA = NBeans*4*Math.PI*Math.pow(D/2,2)
    const kJb = kg*Cp*(TFinal-Tbeans)
    let Jb=kJb.toFixed(0)+ " kJ"
    if (kJb > 1000) Jb = (kJb/1000).toPrecision(3)+" MJ"
    let Jr=MJTot.toPrecision(3)+ " MJ"
    if (MJTot < 1) Jr = (MJTot*1000).toPrecision(3)+" kJ"
   //console.log(BMass*1000,NBeans,kJ, MJTot, TFinal,Tbeans)
    const prmap = {
        plotData: [ITPts, OPts, BPts, TBPts, TBPts2opt, ROR], //An array of 1 or more datasets
        lineLabels: ["IT", "ET", "BT", "TBT", "TBT2", "ROR"], //An array of labels for each dataset
        colors: ["red", "orange", "brown", "chocolate", "cyan", "skyblue"],
        hideLegend: false,
        xLabel: 't&min', //Label for the x axis, with an & to separate the units
        yLabel: 'T&°C', //Label for the y axis, with an & to separate the units
        y2Label: "ROR&°C/min", //Label for the y2 axis, null if not needed
        yAxisL1R2: [1, 1, 1, 1, 1, 2], //Array to say which axis each dataset goes on. Blank=Left=1
        logX: false, //Is the x-axis in log form?
        isStraight: [true, true, false, false, false, false],
        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: [0, tmax], //Set min and max, e.g. [-10,100], leave one or both blank for auto
        yMinMax: [60, 260], //Set min and max, e.g. [-10,100], leave one or both blank for auto
        y2MinMax: [-2,], //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 prmap1 = {
        plotData: [PPts, KPts, WPts], //An array of 1 or more datasets
        lineLabels: ["Power", "Weight", "Water"], //An array of labels for each dataset
        colors: ["orange", "brown", "skyblue"],
        hideLegend: false,
        xLabel: 't&min', //Label for the x axis, with an & to separate the units
        yLabel: 'Power, kg&%', //Label for the y axis, with an & to separate the units
        y2Label: 'Water&%', //Label for the y2 axis, null if not needed
        yAxisL1R2: [1, 1, 2], //Array to say which axis each dataset goes on. Blank=Left=1
        logX: false, //Is the x-axis in log form?
        isStraight: [true, false, false],
        xTicks: undefined, //We can define a tick function if we're being fancy
        logY: false, //Is the y-axis in log form?
        yTicks: function (val, index) { return index % 2 === 0 ? val : ''; },

        legendPosition: 'top', //Where we want the legend - top, bottom, left, right
        xMinMax: [0, tmax], //Set min and max, e.g. [-10,100], leave one or both blank for auto
        yMinMax: [0, 100], //Set min and max, e.g. [-10,100], leave one or both blank for auto
        y2MinMax: [0, 12], //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 {
        plots: [prmap, prmap1],
        canvas: ['canvas', 'canvas1'],
        // PowerInfo: Power,
        Info: tTurnms + ", " + tFCms + ", " + TDrop + ", " + Ratios + ", " + Power + "\n" + StepText,
        NB: NBeans.toFixed(0),
        SA: SA.toPrecision(3),
        kJb: Jb,
        kJr: Jr,
        Froude: Froude.toFixed(1),
        kJRad: (Radiative/1000).toFixed(0) + "kJ"
    };
}
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) {
    });
}
            

Small, Medium or Large

Before you start, select S for Small, M for Medium or L for Large depending on your roaster size. The sliders for the weight of beans and for MJ/hour of heat are chosen accordingly.

Being in control

To get the best out of each roast you need to be in control of a complex set of variables. Without good temperature probes and good control of the energy inputs, air flows and drum rotation you will never reliably achieve the best that any given green bean can provide - however you define "best".

Assuming you have data-logging capabilities via probes connected to software like Artisan or Cropster then you can start to see what happens as you change settings.

In this app we have a simplified, virtual roasting setup which is not trying to do first-principle calculations, but to show how the various parameters affect the bean temperature, for good or ill. The reason that a first principles calculation is not viable for your roaster is that it is an "ill-posed" problem. Without knowing the mass of your drum, the Biot number of the heat flow, the positions and masses of your temperature probes, what you do with the heat in the exhaust gases, the heat transfer coefficients and much, much more, meaningful calculations are impossible.

You have the Power

To control a roast you need to adjust the power setting (flame, burner ...) during the run, usually starting off at high power to get the roasting process under way, then you might step down the power settings at intervals to avoid runaway over-roasting. To give you control in the app I've provided a Pstart input, the power at the start then 5 T,t,P%n boxes where you enter Temperature, time, Power% using commas to separate them. You can express time as min:sec (e.g. 5:48) or decimal minutes (e.g. 5.80).

Go into any box, edit the values then press Return to activate it. Spaces are ignored.

Some people like to work by T alone, some by t alone. If in any box t=0 then the T is used. If T=0 then t is used. If you have both then the power is set whenever the first of them is reached. Sorry that it's not so simple, but it means that each user can get what they prefer.

You don't have to set all of them. Just leave a blank or a "-" in any box you don't want. If you accidentally have a blank or erroneous Pstart value, the app uses 100% power.

Driven by the IT

The calculation is driven by an input that rarely appears in roast charts: the Inlet Temperature, IT. There is a great reason why I'm using this: it's the only value that makes scientific sense. There is a great reason why most users don't chart it: the value is super-dependent on the inlet probe position. The real IT (i.e. a proper average of the different IT values across the diameter of the drum) is the true driver of the roast because it is this hot air that is delivering the heat (if we discount conductive heat from the drum). The Environment (or Exhaust) Temperature, ET, is a complex mix of effects and cannot be a driver of the roast because we often see the Bean Temperature, BT, exceed it! Even the BT is not a true measure of the bean temperature - it's a value sent out by the bean probe: that's why it starts high when the beans are cold. The Turning Point is not a measure of when the beans start getting hotter (they get hotter the instant they are added!) but when the beans have overcome the thermal inertia of the probe.

Let's now look at all the inputs before discussing the outputs. American users of the app should know that my app policy is to use only SI units, so they will need to do conversions to their units.

The key parameters

  • Weight of beans. Obviously the larger the weight, the slower the temperature rise for a given amount of heat.
  • Bean moisture %. It takes energy to evaporate the water, slowing down the roast, but once it's gone the mass of beans is smaller, speeding up the roast. At some point, the roasting starts to produce its own moisture - this is not included in the graph of % moisture, but is taken into account by the Mass-->Water factor below.
  • MJ/hr @ 100%. You have some sort of maximum energy your machine can deliver (often specified in 1000s of kJ/hr, i.e. MJ/hr), and you tend to operate at some % values less than that maximum - specified below. This value provides a scale for your simulation. Change it so that the general temperature curve looks OK.
  • TBean-Start. The temperature of your beans at loading time.
  • TInlet-Start. This is the inlet temperature, IT, at the start. It is assumed that this is the "real" temperature of the air experienced by the beans. Although IT is difficult to measure (it is an average of lots of ITs as air flows into the drum at one end), it is the most important value given that BT and ET are not "real" values.
  • TFirst Crack. In the simulation, water is slowly released as the temperature rises (as found in published research) but any remaining water in the bean is released at a much faster rate at this temperature, creating a cooling spike. The time duration of this effect is not generally known, so a reasonable approximation is provided.
  • tDrop. The time for your simulation. At this time it is assumed that you drop your beans and no attempt is made to model temperatures after this point. Because the slider is in fractions of a minute, its value in min/s is shown in the tFS, tDrop output.
  • TCharge. With no beans, but with thermal losses through the system, there should be no difference between the IT and the ET. To match your curves to this simulation, set up a TInlet-Start to give you the typical roasting times, then change TCharge so that ET starts at what you consider to be the charge T.
  • Rel. Drum/Air factor. Drum RPM or air velocity impact on the heat transfer coefficient - the rate at which energy in the air gets transferred to the beans. This control is a relative value, on the 1-6 scale, 3 is "normal". As has often been noticed, increasing drum speed above normal decreases heat transfer as the beans get pinned to the drum wall. And more airflow beyond the normal setting drags in cooling air.
  • Rel. Response. Depending on the thermal inertia of your system, the temperature response to any change is slower or faster. This is a relative value, on the 1-6 scale, 3 is "normal". If you like to think in terms of thermocouples, this is going from 1=3.5mm to 6=1mm,
  • Post FC Factor. With the beans more open as FC proceeds, there is clearly reaction of the beans to produce CO2 and water. It is debated whether this is net endo- (evaporation of water) or exo- (burning of the beans)thermic, but as the bean mass decreases the ROR will automatically increase. As you really can burn beans, at some point the exothermic effect kicks in. If you set this factor to 0, none of these effects is included. The higher the setting, the more mass loss as CO2/water and, eventually, the more exotherm. Clearly this needs to be refined for future versions of the app.
  • Restore Defaults. It's easy to get lost in roaster space. Because, like all my apps, it remembers your last settings when you exit, if you exit and restart, you're still lost. Clicking this button takes you to the safe starting default values.
  • TYellow. Your chosen definition of the Yellow (sometimes called "Dry") point of your beans. This is used only for calculating the % time spent in the 3 roasting phases.
  • Temperature,Power%. During the roast you will change the power, as a % of its 100% capability, usually decreasing it at strategic temperatures, T. You enter pairs of Temperature,% values, the temperature T and the % power to which you change when the beans reach that T. The starting power is the first entry, e.g. 0,90 for 90% starting power. You can add as many pairs of values as you wish to send the power down or up at any time. To change these values, once you've added, deleted, or changed the entries - simply press Return/Enter to see the new situation. Although the app expects comma-separated pairs separated by a space, it allows some flexibility in user inputs. There is currently no option to provide a "soak" phase at the start.
  • Info. This provides a list of key bits of information.
    • tTurn. The time for the BT to turn from falling to rising.
    • tDry. The time to reach your Dry point, defined by TDry
    • tFC. The time to reach your First Crack, defined by TFC
    • tDrop. Your tDrop temperature translated from fractions to minutes and seconds.
    • Ratios. The % time spent in the Dry, Brown and Development phases
    • Power. The MJ/hr power translated to BTU and kW. If you know your power in either of these units, slide the MJ/hr slider till you get (close to) your value.

So what do we see?

In the top graph:

  • The IT curve shows the Inlet Temperature that is driving your roast. As you change the power setting, this temperature is shown as changing rapdily - which, in the timescale of the app, is realistic.
  • The ET curve shows the "Environment" or "Exhaust" temperature, whose meaning is somewhat imprecise as it depends on factors such as probe placement and the thermal losses through the system. Because it's a very indirect measure, its response to changes to other settings is indirect. Comparing ET curves to other machines is usually unhelpful. Comparing between runs on the same machine does provide useful information, which, once you take into account which factors affect it, can help to understand what's going on.
  • The BT curve shows the temperature measured by the bean probe which is only indirectly related to the real temperature of the beans - which starts cold and heats up, without a "turning point". Realising how indirect the measure is may help you avoid trying to over-interpret it and to stop fiddling too much with input parameters to which BT will respond both slowly and indirectly. The app gives you tTurn, the time when the turning point is reached, as well as tFC, the time to reach your chosen First Crack temperature.
  • The TBT curve shows the true bean temperature - something that's seldom measured.
  • The Rate of Rise, ROR, curve brings out small changes that the human eye cannot pickout easily from the BT curve. This is where you search for signs of things going well or badly. If you have plenty of water left at the First Crack temperature, you see a big negative spike. If you carry on after that with a rising temperature and a high value for exothermy, you see something like a flick appear. Try too hard and you get a spike when your beans start to burn.

In the lower graph:

  • The Power curve shows your power settings relative to 100%.
  • The Weight curve shows the weight of the beans (relative to 100% at the start), which decreases slowly as water is lost, then faster at the First Crack, then maybe faster if an exotherm kicks in.
  • The Water curve shows the % water over time.

Using the app to build your intuition

The great thing about an app is that you get instant feedback on which input parameters affect those output values that you monitor during your real roasts. Go ahead and change the water content, or the mass of beans, or the power settings ... and quickly see the good and bad things that happen. Focus on the general shapes of the curves before worrying about crashes, Flicks of Death etc. And try to find settings that give you results broadly comparable to your own roasting setup.

Once you're happy that the app captures the process in general, feel free to get involved in the debate about how much moisture loss changes the ROR before first crack, and how much the (presumed) sudden loss of water and CO2 at FC then triggers a potential flick. And, of course, play with your Power settings to get a nice, steady ROR curve without crash or flick.

But ...

Yes, I know. It's only an app, and I've put in various fiddle factors to get things looking OK and I've not properly taken into account X, Y or Z.

But here's the thing. Up till now, the roasting community has had lots of opinions, but without a model like this, it's been hard to disentangle opinions from facts. Now we have an app, and everyone can look at the code, we can start making key aspects realistic.

But in my view, the first step is to get some roast curves that define the pure machine-beans interactions, without the complexities of water loss, cracks etc. As a community we need sacks of dummy-beans that are cheap, safe, heat-resistant, odour-free and have thermal properties comparable to green beans in their initial heating phase. We can then see what a "roast" looks like without the complications of real beans. A comparison of real and pseudo-beans will allow us to clearly see whether water loss makes a difference up to FC and what the impact of water and mass loss at FC has on the overall measured curve (remembering that it is not an accurate reflection of real temperatures).

So, yes, I'm happy to update the app whenever anyone can provide some better ideas or approximations. Just let me know.

2-Step Roasting

There is a minority opinion, which I share, that instead of fiddling with roasting parameters, you just do a 2-step roast: start at reasonable full power (no soak) then do just one switch to a lower power at a given time.

The app finds an optimum 2-step process telling you which power to switch to at which time. It also gives you the maximum temperature deviation (ΔT) from your original plus a root mean square (RMS) error.

Maybe you will be encouraged to try your own 2-step roasts and enjoy the relaxed calm of only having to do one adjustment per roast.

Extra Info

These calculations take some info from the main app but do not affect the main calculation.

It's handy to know how many beans are in your batch, what their total surface area is, how many kJ were used to raise their temperature from start to finish, and how many kJ were provided by your heating system (to give an idea of the efficiency of the roasting process). These outputs need knowledge of the equivalent bean diameter, D, the density of the bean itself (not the density of a bag of beans) and the heat capacity.

If your drum speed is super high then the beans are too pinned to the drum, if it is super low, gravity keeps the beans at the bottom of the drum. The Froude number, Fr, is the ratio of centrifugal to gravitational forces. A value somewhat below 1 is considered optimal. The calculation for a drum radius, R, is `Fr=(ω^2R)/g` where ω is in units of "radians per second" calculated from the rotation speed in RPM via `ω=(2πRPM)/60`.

The % radiative heat transfer is interesting to estimate. Radiative transfer is a bit tricky to calculate because it involves emissivities (the % of the "black body radiation" given out or absorbed) and temperatures (in °K) to the 4th power, as described in the Radiative heat transfer app. It also involves Stefan's constant σ = 5.67e-8, and the effective surface areas, A, of the interior of the drum and a pseudo-cylinder made of the beans - an odd notion but an important one. The amount of heat, q, in W, that flows at any given Tdrum and Tbeans is given by:

`q=(A_(beans)σ(T_(drum)^4-T_(beans)^4))/(1/ε_(drum)+A_(drum)/A_(beans)(1/ε_(beans)-1))`

So at any moment we know the W flowing into the beans. We also know that Tdrum and Tbeans are changing so we need to calculate q which decreases throughout the roast. Because W=J/s we can work out how many J are being absorbed at any second and sum them over the roast. We can compare this sum to the total energy needed to raise the bean temperature to get an idea of whether radiative transfer is a significant factor.

As little seems to be known about Tdrum, the current IT is used. This can be changed once we know more about real drum temperatures.

How to estimate Abeans, the effective surface area of the beans? From the mass and a density assumed to be 0.5 we know the volume. Assuming that volume is a cylinder the length of the drum (actually, 75% of the length as the beans tend to concentrate), we calculate the radius, and from the radius and length we can calculate the surface area. Unconvinced? Well, it's the best I can do. If all the beans were fully separated, then Abeans would approach the total surface area of every bean, but this is unrealistic.

What about those emissivity values? We can assume that beans are close to 1, so 0.95 is a good default. A shiny steel drum might well have ε ~ 0.15 but a well-used drum might be up around 0.4 and a blackened one could be up to 0.9. If anyone in the community has noticed differences in roasting times between shiny and dull drums, please let me know.

Version Information

  • 4 Jan 2023 - public launch
  • 6 Jan 2023 - fix for bug at low weight roasting. Small version launched.
  • 28 Feb 2023 - water evaporation effects more realistic and output information improved.
  • 29 Mar 2023 - power settings revised, tweaks to water evolution and mass loss, various interface upgrades.
  • 29 Apr 2023 - 2-step roasting added.
  • 7 Jun 2023 - Extra Info added.
  • 24 Jun 2023 - Froude and Radiative transfer added.