A lot of Americans are addicted to coffee. I have one cup a day. I know someone has a few cups every day. Weird enough, I know someone who needs coffee to go to bed.

Coffee is for sipping, but we do want to enjoy it as soon as possible at a right temperature. Whether we add sugar cubes at the beginning or the end can make a lot of difference. In general, coffee cooling follows empirically the Newton’s law of cooling:

$\frac{dT}{dt} = -k (T-T_r)$,

where $T_r$ is the room temperature, and $k$ is the cooling parameter. It is a simple ordinary differential equation (ODE) that bears analytical solution. However, let’s solve it numerically while we demonstrate the NumPy/SciPy capabilities. The function we are using is odeint. Let’s import necessary packages:

import numpy as np
from scipy.integrate import odeint
from functools import partial


We will need the partial function from functools shortly. Let’s define the right-hand-side of the ODE above as the cooling function in terms of a lambda expression:

coolingfcn = lambda temp, t0, k, roomtemp: -k*(temp-roomtemp)


And we need a set of cooling parameters that makes sense from our daily experience:

cooling_parameters = {'init_temp': 94.0, # initial temperature of the coffee (degrees Celcius)
'cube_dectemp': 5.0, # temperature decrease due to the cube
'roomtemp': 25.0, # room temperature
'k': 1.0/15.0 # cooling parameter (inverse degrees Celcius)
}


The fresh-brewed coffee is of 94 degrees Celcius. The room temperature is 25 degrees Celcius. Whenever a whole cube is added to the coffee, its temperature drops by 5 degrees Celcius, which is not a good assumption when the coffee is totally cooled down to room temperature. The cooling parameter $k=\frac{1}{15} \text{min}^{-1}$, meaning in 15 minutes, the coffee was cooled by $e^{-1}$ of its temperature difference from the environment.

From the SciPy documentation of odeint, the first three parameters are important:

scipy.integrate.odeint(func, y0, t, args=(), Dfun=None, col_deriv=0, full_output=0, ml=None, mu=None, rtol=None,atol=None, tcrit=None, h0=0.0, hmax=0.0, hmin=0.0, ixpr=0, mxstep=0, mxhnil=0, mxordn=12, mxords=5,printmessg=0)

The first parameter is the cooling function, and the second is the initial value of the dependent function (temperature in this case), and the third is the ndarray of times of the temperatures to be calculated. We can easily write the functions that return the ndarray of the temperatures for the cases of adding sugar cube before and after the cooling process respectively:

# adding sugar cube before cooling
def cube_before_cooling(t, init_temp, cube_dectemp, roomtemp, k):
y0 = init_temp - cube_dectemp
temps = odeint(partial(coolingfcn, k=k, roomtemp=roomtemp), y0, t)
return temps

# adding sugar cube after cooling
def cube_after_cooling(t, init_temp, cube_dectemp, roomtemp, k):
temps = odeint(partial(coolingfcn, k=k, roomtemp=roomtemp), init_temp, t)
temps[-1] -= cube_dectemp
return temps


We can plot the temperature changes on graphs using matplotlib.

import matplotlib.pyplot as plt


And we are interested in the first 20 minutes:

times = np.linspace(0, 20, 201)


For adding a sugar cube before cooling,

temps_beforecooling = cube_before_cooling(times, **cooling_parameters)
plt.plot(times, temps_beforecooling)
plt.xlabel('Time (min)')
plt.ylabel('Coffee Temperature (degrees Celcius)')


(If you are typing in an iPython shell, you need to put plt.show() to show the plot.) This gives the following plot:

And for adding the sugar cube after cooling,

temps_aftercooling = cube_after_cooling(times, **cooling_parameters)
plt.plot(times, temps_aftercooling)
plt.xlabel('Time (min)')
plt.ylabel('Coffee Temperature (degrees Celcius)')


This gives the following plot:

Obviously, adding the sugar cube after cooling gives a lower temperature.

How about we add sugar continuously throughout the 20-minute time span?  We need to adjust our differential equation to be:

$\frac{dT}{dt} = -k (T-T_r) - \frac{T_{\text{drop}}}{\delta t}$,

which can be implemented by the following code:

# adding sugar continuously
def continuous_sugar_cooling(t, init_temp, cube_dectemp, roomtemp, k):
timespan = t[-1] - t[0]
newcoolingfcn = lambda temp, t0: coolingfcn(temp, t0, k, roomtemp) - cube_dectemp / timespan
temps = odeint(newcoolingfcn, init_temp, t)
return temps


or we can divide the cube to be added to the cup at a few time spots, which can be implemented by the following code that employs our previously defined functions:

# adding sugar at a specified time(s)
def sugar_specifiedtime_cooling(t, sugar_time_indices, init_temp, cube_dectemp, roomtemp, k):
sorted_sugar_time_indices = np.sort(sugar_time_indices)
num_portions = len(sorted_sugar_time_indices)

temps = np.array([])
temp = init_temp
t_segments = np.split(t, sorted_sugar_time_indices)
num_segments = len(t_segments)
for i in range(num_segments):
temp_segment = cube_after_cooling(t_segments[i],
temp,
float(cube_dectemp)/num_portions,
roomtemp,
k)
temps = np.append(temps, temp_segment)
temp = temp_segment[-1]
temps[-1] += float(cube_dectemp)/num_portions

return temps


Let’s calculate all the temperatures:

temps_cont = continuous_sugar_cooling(times, **cooling_parameters)
temps_n = sugar_specifiedtime_cooling(times, [50, 100, 150], **cooling_parameters)


And plot all the graphs together:

plt.plot(times, temps_beforecooling, label='cube before cooling')
plt.plot(times, temps_aftercooling, label='cube after cooling')
plt.plot(times, temps_cont, label='continuous')
plt.plot(times, temps_n, label='3 times of dropping cube')
plt.xlabel('Time (min)')
plt.ylabel('Coffee Temperature (degrees Celcius)')
plt.legend()


Complete code demonstration can be found in my github (GitHub/stephenhky/CoffeeCooling)