In [1]:
    
# Configure Jupyter so figures appear in the notebook
%matplotlib inline
# Configure Jupyter to display the assigned value after an assignment
%config InteractiveShell.ast_node_interactivity='last_expr_or_assign'
# import functions from the modsim.py module
from modsim import *
    
In [2]:
    
def update_func(state, t, system):
    """Update the thermal transfer model.
    
    state: State (temp)
    t: time
    system: System object
    
    returns: State (temp)
    """
    r, T_env, dt = system.r, system.T_env, system.dt
    
    T = state.T
    T += -r * (T - T_env) * dt
    
    return State(T=T)
    
In [3]:
    
def run_simulation(system, update_func):
    """Runs a simulation of the system.
    
    Add a TimeFrame to the System: results
    
    system: System object
    update_func: function that updates state
    """
    init = system.init
    t_0, t_end, dt = system.t_0, system.t_end, system.dt
    
    frame = TimeFrame(columns=init.index)
    frame.row[t_0] = init
    ts = linrange(t_0, t_end, dt)
    
    for t in ts:
        frame.row[t+dt] = update_func(frame.row[t], t, system)
    
    return frame
    
In [4]:
    
def make_system(T_init, r, volume, t_end):
    """Makes a System object with the given parameters.
    T_init: initial temperature in degC
    r: heat transfer rate, in 1/min
    volume: volume of liquid in mL
    t_end: end time of simulation
    
    returns: System object
    """
    init = State(T=T_init)
                   
    return System(init=init,
                  r=r, 
                  volume=volume,
                  temp=T_init,
                  t_0=0, 
                  t_end=t_end, 
                  dt=1,
                  T_env=22)
    
In [5]:
    
def func(x):
    return (x-1) * (x-2) * (x-3)
    
modsim.py provides root_bisect, which searches for a root by bisection.  The first argument is the function whose roots we want.  The second argument is an interval that contains a root.
In [6]:
    
res = root_bisect(func, [0.5, 1.5])
    
The result is an object that contains the root that was found and other information.
In [7]:
    
res.root
    
If we provide a different interval, we find a different root.
In [8]:
    
res = root_bisect(func, [1.5, 2.5])
    
In [9]:
    
res.root
    
If the interval doesn't contain a root, the results explain the error.
In [10]:
    
res = root_bisect(func, [4, 5])
    
We want to find the value of r that makes the final temperature 70, so we define an "error function" that takes r as a parameter and returns the difference between the final temperature and the goal.
In [11]:
    
def error_func1(r):
    """Runs a simulation and returns the `error`.
    
    r: heat transfer rate, in 1/min
    
    returns: difference between final temp and 70 C
    """
    system = make_system(T_init=90, r=r, volume=300, t_end=30)
    results = run_simulation(system, update_func)
    T_final = get_last_value(results.T)
    return T_final - 70
    
With r=0.01, we end up a little too warm.
In [12]:
    
error_func1(r=0.01)
    
With r=0.02, we end up too cold.
In [13]:
    
error_func1(r=0.02)
    
The return value from root_bisect is an array with a single element, the estimated value of r.
In [14]:
    
res = root_bisect(error_func1, [0.01, 0.02])
    
In [15]:
    
r_coffee = res.root
    
If we run the simulation with the estimated value of r, the final temperature is 70 C, as expected.
In [16]:
    
coffee = make_system(T_init=90, r=r_coffee, volume=300, t_end=30)
results = run_simulation(coffee, update_func)
T_final = get_last_value(results.T)
    
Exercise:  When you call root_bisect, it calls error_func1 several times.  To see how this works, add a print statement to error_func1 and run root_bisect again.
Exercise: Repeat this process to estimate r_milk, given that it starts at 5 C and reaches 20 C after 15 minutes.
Before you use root_bisect, you might want to try a few values for r_milk and see how close you can get by trial and error.  Here's an initial guess to get you started:
In [17]:
    
r_milk = 0.1
milk = make_system(T_init=5, r=r_milk, volume=50, t_end=15)
results = run_simulation(milk, update_func)
T_final = get_last_value(results.T)
    
In [18]:
    
# Solution goes here
    
In [19]:
    
# Solution goes here
    
In [20]:
    
# Solution goes here
    
In [21]:
    
# Solution goes here
    
In [22]:
    
# Solution goes here
    
In [23]:
    
# Solution goes here
    
The following function takes System objects that represent two liquids, computes the temperature of the mixture, and returns a new System object that represents the mixture.
In [24]:
    
def mix(system1, system2):
    """Simulates the mixture of two liquids.
    
    system1: System representing coffee
    system2: System representing milk
    
    returns: System representing the mixture
    """
    assert system1.t_end == system2.t_end
    
    V1, V2 = system1.volume, system2.volume
    T1, T2 = system1.temp, system2.temp
    
    V_mix = V1 + V2
    T_mix = (V1 * T1 + V2 * T2) / V_mix
    
    return make_system(T_init=T_mix,
                       r=system1.r,
                       volume=V_mix,
                       t_end=30)
    
mix requires the System objects to have temp as a system variable.  make_system initializes this variable;
the following function makes sure it gets updated when we run a simulation.
In [25]:
    
def run_and_set(system):
    """Run a simulation and set the final temperature.
    
    system: System
    
    returns: TimeFrame
    """
    results = run_simulation(system, update_func)
    system.temp = get_last_value(results.T)
    return results
    
In [26]:
    
coffee = make_system(T_init=90, r=r_coffee, volume=300, t_end=30)
coffee.temp
    
In [27]:
    
milk = make_system(T_init=5, r=r_milk, volume=50, t_end=30)
milk.temp
    
In [28]:
    
mix_first = mix(coffee, milk)
mix_first.temp
    
In [29]:
    
mix_results = run_and_set(mix_first)
mix_first.temp
    
In [30]:
    
coffee_results = run_and_set(coffee)
coffee.temp
    
In [31]:
    
milk_results = run_and_set(milk)
milk.temp
    
Here's what the results look like.
In [32]:
    
plot(coffee_results.T, label='coffee')
plot(milk_results.T, '--', label='milk')
decorate(xlabel='Time (minutes)',
         ylabel='Temperature (C)',
         loc='center left')
savefig('figs/chap16-fig01.pdf')
    
Here's what happens when we mix them.
In [33]:
    
mix_last = mix(coffee, milk)
mix_last.temp
    
In [34]:
    
mix_last.temp - mix_first.temp
    
The following function takes t_add, which is the time when the milk is added, and returns the final temperature.
In [35]:
    
def run_and_mix(t_add, t_total):
    """Simulates two liquids and them mixes them at t_add.
    
    t_add: time in minutes
    t_total: total time to simulate, min
    
    returns: final temperature
    """
    coffee = make_system(T_init=90, r=r_coffee, volume=300, t_end=t_add)
    coffee_results = run_and_set(coffee)
    
    milk = make_system(T_init=5, r=r_milk, volume=50, t_end=t_add)
    milk_results = run_and_set(milk)
    
    mixture = mix(coffee, milk)
    mixture.t_end = t_total - t_add
    results = run_and_set(mixture)
    return mixture.temp
    
We can try it out with a few values.
In [36]:
    
run_and_mix(t_add=0, t_total=30)
    
In [37]:
    
run_and_mix(t_add=15, t_total=30)
    
In [38]:
    
run_and_mix(t_add=30, t_total=30)
    
And then sweep a range of values for t_add
In [39]:
    
sweep = SweepSeries()
for t_add in linspace(0, 30, 11):
    sweep[t_add] = run_and_mix(t_add, 30)
    
Here's what the result looks like.
In [40]:
    
plot(sweep, label='final temp', color='C2')
decorate(xlabel='Time added (min)',
         ylabel='Final temperature (C)')
savefig('figs/chap16-fig02.pdf')
    
Now we can use the analytic result to compute temperature as a function of time.  The following function is similar to run_simulation.
In [41]:
    
def run_analysis(system):
    """Computes temperature using the analytic solution.
        
    system: System object
    
    returns: TimeFrame
    """
    T_env, r = system.T_env, system.r
    
    T_init = system.init.T    
    ts = linspace(0, system.t_end)
    
    T_array = T_env + (T_init - T_env) * exp(-r * ts)
    
    # to be consistent with run_simulation,
    # we put the array into a TimeFrame
    return TimeFrame(T_array, index=ts, columns=['T'])
    
Here's how we run it.  From the analysis (see chap16sympy.ipynb), we have the computed value of r_coffee2
In [42]:
    
r_coffee2 = 0.011610223142273859
coffee2 = make_system(T_init=90, r=r_coffee2, volume=300, t_end=30)
    
In [43]:
    
results = run_analysis(coffee2)
T_final_analysis = get_last_value(results.T)
    
And we can compare to the results from simulation.
In [44]:
    
coffee = make_system(T_init=90, r=r_coffee, volume=300, t_end=30)
results = run_simulation(coffee, update_func)
T_final_simulation = get_last_value(results.T)
    
They are identical except for a small roundoff error.
In [45]:
    
T_final_analysis - T_final_simulation
    
Exercise: Suppose the coffee shop won't let me take milk in a separate container, but I keep a bottle of milk in the refrigerator at my office. In that case is it better to add the milk at the coffee shop, or wait until I get to the office?
Hint: Think about the simplest way to represent the behavior of a refrigerator in this model. The change you make to test this variation of the problem should be very small!
In [46]:
    
# Solution goes here
    
In [ ]: