Mod Simpy
Mod Simpy
Version 1.0.2
Modeling and Simulation in Python
Version 1.0.2
Allen B. Downey
Permission is granted to copy, distribute, transmit and adapt this work under a
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
License: http://modsimpy.com/license.
The LATEX source and code for this book is available from
https://github.com/AllenDowney/ModSimPy
iv
Contents
Preface xi
0.1 Can modeling be taught? . . . . . . . . . . . . . . . . . . . . xiii
0.2 How much programming do I need? . . . . . . . . . . . . . . xiv
0.3 How much math and science do I need? . . . . . . . . . . . . xv
0.4 Getting started . . . . . . . . . . . . . . . . . . . . . . . . . xvi
0.5 Installing Python and the libraries . . . . . . . . . . . . . . . xvi
0.6 Copying my files . . . . . . . . . . . . . . . . . . . . . . . . . xvii
0.7 Running Jupyter . . . . . . . . . . . . . . . . . . . . . . . . xviii
1 Modeling 1
1.1 The falling penny myth . . . . . . . . . . . . . . . . . . . . . 1
1.2 Computation . . . . . . . . . . . . . . . . . . . . . . . . . . 3
1.3 Modeling a bike share system . . . . . . . . . . . . . . . . . 4
1.4 Plotting . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
1.5 Defining functions . . . . . . . . . . . . . . . . . . . . . . . . 7
1.6 Parameters . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
1.7 Print statements . . . . . . . . . . . . . . . . . . . . . . . . . 10
1.8 If statements . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
1.9 Optional parameters . . . . . . . . . . . . . . . . . . . . . . 14
1.10 For loops . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
vi CONTENTS
1.11 Debugging . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
2 Simulation 17
2.1 Iterative modeling . . . . . . . . . . . . . . . . . . . . . . . . 17
2.2 More than one System object . . . . . . . . . . . . . . . . . 18
2.3 Documentation . . . . . . . . . . . . . . . . . . . . . . . . . 20
2.4 Negative bikes . . . . . . . . . . . . . . . . . . . . . . . . . . 21
2.5 Comparison operators . . . . . . . . . . . . . . . . . . . . . . 23
2.6 Metrics . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
2.7 Functions that return values . . . . . . . . . . . . . . . . . . 26
2.8 Two kinds of parameters . . . . . . . . . . . . . . . . . . . . 27
2.9 Loops and arrays . . . . . . . . . . . . . . . . . . . . . . . . 28
2.10 Sweeping parameters . . . . . . . . . . . . . . . . . . . . . . 29
2.11 Debugging . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
3 Explanation 31
3.1 World population data . . . . . . . . . . . . . . . . . . . . . 31
3.2 Series . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
3.3 Constant growth model . . . . . . . . . . . . . . . . . . . . . 34
3.4 Simulation . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36
3.5 Now with System objects . . . . . . . . . . . . . . . . . . . . 38
3.6 Proportional growth model . . . . . . . . . . . . . . . . . . . 39
3.7 Factoring out the update function . . . . . . . . . . . . . . . 41
3.8 Combining birth and death . . . . . . . . . . . . . . . . . . . 42
3.9 Quadratic growth . . . . . . . . . . . . . . . . . . . . . . . . 43
3.10 Equilibrium . . . . . . . . . . . . . . . . . . . . . . . . . . . 45
3.11 Disfunctions . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
CONTENTS vii
4 Prediction 51
4.1 Generating projections . . . . . . . . . . . . . . . . . . . . . 51
4.2 Comparing projections . . . . . . . . . . . . . . . . . . . . . 54
4.3 Recurrence relations . . . . . . . . . . . . . . . . . . . . . . 55
4.4 Differential equations . . . . . . . . . . . . . . . . . . . . . . 57
4.5 Analysis and simulation . . . . . . . . . . . . . . . . . . . . 59
4.6 Analysis with WolframAlpha . . . . . . . . . . . . . . . . . . 60
4.7 Analysis with SymPy . . . . . . . . . . . . . . . . . . . . . . 61
4.8 Differential equations in SymPy . . . . . . . . . . . . . . . . 62
4.9 Solving the quadratic growth model . . . . . . . . . . . . . . 63
4.10 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64
5 Design 65
5.1 The Freshman Plague . . . . . . . . . . . . . . . . . . . . . . 65
5.2 The SIR model . . . . . . . . . . . . . . . . . . . . . . . . . 66
5.3 The SIR equations . . . . . . . . . . . . . . . . . . . . . . . 67
5.4 Implementation . . . . . . . . . . . . . . . . . . . . . . . . . 68
5.5 The update function . . . . . . . . . . . . . . . . . . . . . . 69
5.6 Running the simulation . . . . . . . . . . . . . . . . . . . . . 71
5.7 Collecting the results . . . . . . . . . . . . . . . . . . . . . . 71
5.8 Now with a TimeFrame . . . . . . . . . . . . . . . . . . . . . 73
5.9 Metrics . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 75
5.10 Immunization . . . . . . . . . . . . . . . . . . . . . . . . . . 76
5.11 Hand washing . . . . . . . . . . . . . . . . . . . . . . . . . . 79
5.12 Optimization . . . . . . . . . . . . . . . . . . . . . . . . . . 82
6 Analysis 85
viii CONTENTS
6.1 Unpack . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 85
6.2 Sweeping beta . . . . . . . . . . . . . . . . . . . . . . . . . . 86
6.3 Sweeping gamma . . . . . . . . . . . . . . . . . . . . . . . . 88
6.4 Nondimensionalization . . . . . . . . . . . . . . . . . . . . . 90
6.5 Contact number . . . . . . . . . . . . . . . . . . . . . . . . . 92
6.6 Analysis and simulation . . . . . . . . . . . . . . . . . . . . 94
7 Thermal systems 97
7.1 The coffee cooling problem . . . . . . . . . . . . . . . . . . . 97
7.2 Temperature and heat . . . . . . . . . . . . . . . . . . . . . 98
7.3 Heat transfer . . . . . . . . . . . . . . . . . . . . . . . . . . 99
7.4 Newtons law of cooling . . . . . . . . . . . . . . . . . . . . . 100
7.5 Implementation . . . . . . . . . . . . . . . . . . . . . . . . . 101
7.6 Using fsolve . . . . . . . . . . . . . . . . . . . . . . . . . . . 103
7.7 Mixing liquids . . . . . . . . . . . . . . . . . . . . . . . . . . 106
7.8 Mix first or last? . . . . . . . . . . . . . . . . . . . . . . . . 107
7.9 Optimization . . . . . . . . . . . . . . . . . . . . . . . . . . 108
7.10 Analysis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
8 Pharmacokinetics 113
8.1 The glucose-insulin system . . . . . . . . . . . . . . . . . . . 113
8.2 The glucose minimal model . . . . . . . . . . . . . . . . . . . 114
8.3 Data . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 117
8.4 Interpolation . . . . . . . . . . . . . . . . . . . . . . . . . . . 117
8.5 Implementation . . . . . . . . . . . . . . . . . . . . . . . . . 119
8.6 Numerical solution of differential equations . . . . . . . . . . 122
8.7 Least squares . . . . . . . . . . . . . . . . . . . . . . . . . . 124
CONTENTS ix
9 Projectiles 131
9.1 Newtons second law of motion . . . . . . . . . . . . . . . . . 131
9.2 Dropping pennies . . . . . . . . . . . . . . . . . . . . . . . . 133
9.3 Onto the sidewalk . . . . . . . . . . . . . . . . . . . . . . . . 136
9.4 With air resistance . . . . . . . . . . . . . . . . . . . . . . . 137
9.5 Implementation . . . . . . . . . . . . . . . . . . . . . . . . . 138
9.6 Dropping quarters . . . . . . . . . . . . . . . . . . . . . . . . 140
11 Rotation 159
11.1 The physics of toilet paper . . . . . . . . . . . . . . . . . . . 160
11.2 Implementation . . . . . . . . . . . . . . . . . . . . . . . . . 161
11.3 Analysis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 165
11.4 Torque . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 166
11.5 Moment of inertia . . . . . . . . . . . . . . . . . . . . . . . . 167
11.6 Unrolling . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 169
11.7 Simulating a yo-yo . . . . . . . . . . . . . . . . . . . . . . . 173
Index 177
x CONTENTS
Preface
This book is about modeling and simulation of physical systems. The following
diagram shows what I mean by modeling:
Prediction
Model
Analysis
Simulation
Abstraction
Validation
System
Data
Measurement
Starting in the lower left, the system is something in the real world we are
interested in. Often, it is something complicated, so we have to decide which
details can be simplified or abstracted away.
The result of abstraction is a model that includes the features we think are
essential. A model can be represented in the form of diagrams and equations,
which can be used for mathematical analysis. It can also be implemented in
the form of a computer program, which can run simulations.
The result of analysis and simulation can be a prediction about what the
xii Chapter 0 Preface
system will do, an explanation of why it behaves the way it does, or a design
intended to achieve a purpose.
This process is almost always iterative: for any physical system, there are
many possible models, each one including and excluding different features, or
including different levels of detail. The goal of the modeling process is to find
the model best suited to its purpose (prediction, explanation, or design).
Sometimes the best model is the most detailed. If we include more features,
the model is more realistic, and we expect its predictions to be more accurate.
But often a simpler model is better. If we include only the essential features
and leave out the rest, we get models that are easier to work with, and the
explanations they provide can be clearer and more compelling.
As an example, suppose someone asked you why the orbit of the Earth is nearly
elliptical. If you model the Earth and Sun as point masses (ignoring their
actual size), compute the gravitational force between them using Newtons
law of universal gravitation, and compute the resulting orbit using Newtons
laws of motion, you can show that the result is an ellipse.
Of course, the actual orbit of Earth is not a perfect ellipse, because of the
gravitational forces of the Moon, Jupiter, and other objects in the solar system,
and because Newtons laws of motion are only approximately true (they dont
take into account relativistic effects).
But adding these features to the model would not improve the explanation;
more detail would only be a distraction from the fundamental cause. However,
if the goal is to predict the position of the Earth with great precision, including
more details might be necessary.
So choosing the best model depends on what the model is for. It is usually a
good idea to start with a simple model, even if it is likely to be too simple,
and test whether it is good enough for its purpose. Then you can add features
gradually, starting with the ones you expect to be most essential.
0.1 Can modeling be taught? xiii
At Olin College, we use this book in a class called Modeling and Simulation,
which all students take in their first semester. My colleagues, John Geddes
and Mark Somerville, and I developed this class and taught it for the first time
in 2009.
It is based on our belief that modeling should be taught explicitly, early, and
throughout the curriculum. It is also based on our conviction that computation
is an essential part of this process.
If students are limited to the mathematical analysis they can do by hand, they
are restricted to a small number of simple physical systems, like a projectile
moving in a vacuum or a block on a frictionless plane.
And they will only work with bad models; that is, models that are too simple
for their purpose. In nearly every mechanical system, air resistance and friction
are essential features; if we ignore them, our predictions will be wrong and our
designs wont work.
If you have programmed before, you will have an easier time getting started,
but you might be uncomfortable in some places. I take an approach to pro-
gramming you have probably not seen before.
1. They go bottom up, starting with basic language features and gradu-
ally adding more powerful tools. As a result, it takes a long time before
students can do anything more interesting than convert Fahrenheit to
Celsius.
In this book, you learn to program with an immediate goal in mind: writing
simulations of physical systems. And we proceed top down, by which I mean
we use professional-strength data structures and language features right away.
In particular, we use the following Python libraries:
These tools let you work on more interesting programs sooner, but there are
some drawbacks: they can be hard to use, and it can be challenging to keep
track of which library does what and how they interact.
Some features in the modsim library are like training wheels; at some point you
will probably stop using them and start working with the underlying libraries
directly. Other features you might find useful the whole time you are working
through the book, or even later.
I encourage you to read the the modsim library code. Most of it is not com-
plicated, and I tried to make it readable. Particularly if you have some pro-
gramming experience, you might learn something by reverse-engineering my
designs.
More importantly you should understand what those concepts mean; but if
you dont, this book might help you figure it out.
As for science, we will cover topics from a variety of fields, including demogra-
phy, epidemiology, medicine, thermodynamics, and mechanics. For the most
part, I dont assume you know anything about these topics. In fact, one of the
2
And if you noticed that those two questions answer each other, even better.
xvi Chapter 0 Preface
skills you need to do modeling is the ability to learn enough about new fields
to develop models and simulations.
I think thats everything you need, but if you find that I left something out,
please let me know.
1. Install Python on your computer, along with the libraries we will use.
3. Run Jupyter, which is a tool for running and writing programs, and load
a notebook, which is a file that contains code and text.
The next three sections provide details for these steps. I wish there were an
easier way to get started; its regrettable that you have to do so much work
before you write your first program. Be persistent!
You could update Python and install these libraries, but I strongly recom-
mend that you dont go down that road. I think you will find it easier to use
0.6 Copying my files xvii
Anaconda, which is a free Python distribution that includes all the libraries
you need for this book (and lots more).
Anaconda is available for Linux, macOS, and Windows. By default, it puts all
files in your home directory, so you dont need administrator (root) permission
to install it, and if you have a version of Python already, Anaconda will not
remove or modify it.
There are several ways you can copy the files from my repository to your
computer.
If you dont want to use Git at all, you can download my files in a Zip archive
from http://modsimpy.com/zip. Then you need a program like WinZip or
gzip to unpack the Zip file.
To use Git, you need a Git client, which is a program that manages git repos-
itories. If you have not used Git before, I recommend GitHub Desktop, which
is a simple graphical Git client. You can download it from https://desktop.
github.com. Currently, GitHub Desktop is not available for Linux. On Linux,
I suggest using the Git command-line client. Installation instructions are at
http://modsimpy.com/git.
Once you have a Git client, you can use it to copy files from my repository to
your computer, which is called cloning in Gits vocabulary. If you are using
a Command-line git client, type
You dont need a GitHub account to do this, but you wont be able to write
your changes back to GitHub.
If you want to use GitHub to keep track of the code you write while you are
using this book, you can make of a copy of my repository on GitHub, which
is called forking. If you dont already have a GitHub account, youll need to
create one.
Contributor List
If you have a suggestion or correction, send it to downey@allendowney.com.
Or if you are a Git use, send me a pull request!
If I make a change based on your feedback, I will add you to the contributor
list, unless you ask to be omitted.
If you include at least part of the sentence the error appears in, that makes it
easy for me to search. Page and section numbers are fine, too, but not as easy
to work with. Thanks!
0.7 Running Jupyter xix
I am grateful to John Geddes and Mark Somerville for their early col-
laboration with me to create Modeling at Simulation, the class at Olin
College this book is based on.
Modeling
The world is a complicated place. In order to make sense of it, we use models,
which are generally smaller and simpler than the thing we want to study. The
word model means different things in different contexts, so it is hard to
define except by example.
Some models are actual objects, like a scale model of a car, which has the
same shape as the car, but smaller. Scale models are often useful for testing
properties of mechanical systems, like air resistance.
This book is about mathematical models, which are ideas, not objects. If
you studied Newtons laws of motion, what you learned is a mathematical
model of how objects move in space when forces are applied to them.
We can test this myth by making and analyzing a model. To get started, Ill
assume that the effect of air resistance is small. This will turn out to be a bad
assumption, but bear with me. If air resistance is negligible, the primary force
2 Chapter 1 Modeling
acting on the penny is gravity, which causes the penny to accelerate downward.
If the initial velocity is 0, the velocity after t seconds is at, and the height the
penny has dropped at t is
h = at2 /2
Using algebra, we can solve for t:
p
t= 2h/a
Plugging in the acceleration of gravity, a = 9.8 m/s2 and the height of the
Empire State Building, h = 381 m, we get t = 8.8 s. Then computing v = at
we get a velocity on impact of 86 m/s, which is about 190 miles per hour. That
sounds like it could hurt.
Of course, these results are not exact because the model is based on simplifi-
cations. For example, we assume that gravity is constant. In fact, the force
of gravity is different on different parts of the globe, and gets weaker as you
move away from the surface. But these differences are small, so ignoring them
is probably a good choice for this scenario.
On the other hand, ignoring air resistance is not a good choice. Once the penny
gets to about 18 m/s, the upward force of air resistance equals the downward
force of gravity, so the penny stops accelerating. After that, it doesnt matter
how far the penny falls; it hits the sidewalk (or your head) at about 18 m/s,
much less than 86 m/s, as the simple model predicts.
The statistician George Box famously said All models are wrong, but some
are useful. He was talking about statistical models, but his wise words apply
to all kinds of models. Our first model, which ignores air resistance, is very
wrong, and probably not useful. The second model is also wrong, but much
better, and probably good enough to refute the myth.
The television show Mythbusters has tested the myth of the falling penny more
carefully; you can view the results at http://modsimpy.com/myth. Their work
is based on a mathematical model of motion, measurements to determine the
force of air resistance on a penny, and a physical model of a human head.
1.2 Computation 3
1.2 Computation
There are (at least) two ways to work with mathematical models, analysis
and simulation. Analysis often involves algebra and other kinds of symbolic
manipulation. Simulation often involves computers.
In this book we do some analysis and a lot of simulation; along the way, I
discuss the pros and cons of each. The primary tools we use for simulation are
the Python programming language and Jupyter, which is an environment for
writing and running programs.
As a first example, Ill show you how I computed the results from the previous
section using Python. You can view this example, and the other code in this
chapter, at http://modsimpy.com/chap01. For instructions for downloading
and running the code, see Section 0.4.
Operation Symbol
Addition +
Subtraction -
Multiplication *
Division /
Exponentiation **
Next, we can compute the time it takes for the penny to drop 381 m, the height
of the Empire State Building.
h = 381 * meter
t = sqrt(2 * h / a)
4 Chapter 1 Modeling
These lines create two more variables: h gets the height of the building in
meters; t gets the time, in seconds, for the penny to fall to the sidewalk. sqrt
is a function that computes square roots. Python keeps track of units, so the
result, t, has the correct units, seconds.
v = a * t
This example demonstrates the features of Python well use to develop com-
putational simulations of real-world systems. Along the way, I will make deci-
sions about how to model the system. In the next chapter well review these
decisions.
Suppose the system contains 12 bikes and two bike racks, one at Olin and one
at Wellesley, each with the capacity to hold 12 bikes.
As students arrive, check out a bike, and ride to the other campus, the number
of bikes in each location changes. In the simulation, well need to keep track of
where the bikes are. To do that, Ill create a System object, which is defined
in the modsim library.
This line of code is an import statement that tells Python to read the file
modsim.py and make the functions it defines available.
1.3 Modeling a bike share system 5
Functions in the modsim.py library include sqrt, which we used in the previous
section, and System, which we are using now. System creates a System object,
which is a collection of system variables.
In this example, the system variables are olin and wellesley and they repre-
sent the number of bikes at Olin and Wellesley. The initial values are 10 and
2, indicating that there are 10 bikes at Olin and 2 at Wellesley. The System
object created by System is assigned to a new variable named bikeshare.
We can read the variables inside a System object using the dot operator,
like this:
bikeshare.olin
bikeshare.wellesley
The result is 2. If you forget what variables a system object has, you can just
type the name:
bikeshare
The result looks like a table with the variable names and their values:
value
olin 10
wellesley 2
The system variables and their values make up the state of the system. We
can update the state by assigning new values to the variables. For example,
if a student moves a bike from Olin to Wellesley, we can figure out the new
values and assign them:
bikeshare.olin = 9
bikeshare.wellesley = 3
6 Chapter 1 Modeling
bikeshare.olin -= 1
bikeshare.wellesley += 1
1.4 Plotting
As the state of the system changes, it is often useful to plot the values of the
variables over time. The modsim library provides a functions that creates a
new figure:
newfig()
These commands are not actually Python; they are so-called magic com-
mands that control the behavior of Jupyter.
plot(bikeshare.olin, 'rs-')
plot(bikeshare.wellesley, 'bo-')
The first argument is the variable to plot. In this example, its a number,
but well see later that plot can handle other objects, too.
The second argument is a style string that determines what the plot
should look like. In general, a string is a sequence of letters, numbers,
and punctuation that appear in quotation marks. The style string 'rs-'
means we want red squares with lines between them; 'bo-' means we
want blue circles with lines.
The plotting functions in the modsim library are based on Matplotlib, which is
a Python library for generating figures. To learn more about these functions,
you can read the Matplotlib documentation. For more about style strings, see
http://modsimpy.com/plot.
When you are developing code in Jupyter, it is often efficient to write 12 lines
in each cell, test them to confirm they do what you intend, and then use them
to define a new function. For example, these lines move a bike from Olin to
Wellesley:
bikeshare.olin -= 1
bikeshare.wellesley += 1
Rather than repeat them every time a bike moves, we can define a new func-
tion:
def bike_to_wellesley():
bikeshare.olin -= 1
bikeshare.wellesley += 1
def is a special word in Python that indicates we are defining a new function.
The name of the function is bike_to_wellesley. The empty parentheses indi-
cate that this function takes no arguments. The colon indicates the beginning
of an indented code block.
8 Chapter 1 Modeling
The next two lines are the body of the function. They have to be indented;
by convention, the indentation is 4 spaces.
When you define a function, it has no immediate effect. The body of the func-
tion doesnt run until you call the function. Heres how to call this function:
bike_to_wellesley()
When you call this function, it updates the variables of the bikeshare object;
you can check by displaying or plotting the new state.
When you call a function that takes no arguments, you have to include the
empty parentheses. If you leave them out, like this:
bike_to_wellesley
<function __main__.bike_to_wellesley>
1.6 Parameters
Similarly, we can define a function that moves a bike from Wellesley to Olin:
def bike_to_olin():
bikeshare.wellesley -= 1
bikeshare.olin += 1
bike_to_olin()
1.6 Parameters 9
One benefit of defining functions is that you avoid repeating chunks of code,
which makes programs smaller. Another benefit is that the name you give the
function documents what it does, which makes programs more readable.
In this example, there is one other benefit that might be even more important.
Putting these lines in a function makes the program more reliable because it
guarantees that when we decrease the number of bikes at Olin, we increase
the number of bikes at Wellesley. That way, we guarantee that the bikes in
the model are neither created nor destroyed!
However, now we have two functions that are nearly identical except for a
change of sign. Repeated code makes programs harder to work with, because
if we make a change, we have to make it in several places.
We can avoid that by defining a more general function that moves any number
of bikes in either direction:
def move_bike(n):
bikeshare.olin -= n
bikeshare.wellesley += n
move_bike(1)
It assigns the value of the argument, 1, to the parameter, n, and then runs the
body of the function. So the effect is the same as:
n = 1
bikeshare.olin -= n
bikeshare.wellesley += n
move_bike(-1)
10 Chapter 1 Modeling
n = -1
bikeshare.olin -= n
bikeshare.wellesley += n
Which moves a bike from Wellesley to Olin. Now that we have move_bike, we
can rewrite the other two functions to use it:
def bike_to_wellesley():
move_bike(1)
def bike_to_olin():
move_bike(-1)
If you define the same function name more than once, the new definition
replaces the old one.
Normally when Jupyter runs the code in a cell, it displays the value of the last
line of code. For example, if you run:
bikeshare.olin
bikeshare.wellesley
Jupyter runs both lines of code, but it only displays the value of the second
line.
If you want to display more than one value, you can use print statements:
print(bikeshare.olin)
print(bikeshare.wellesley)
1.8 If statements 11
print(bikeshare.olin, bikeshare.wellesley)
In this example, the two values appear on the same line, separated by a space.
Print statements are also useful for debugging functions. For example, if you
add a print statement to move_bike, like this:
def move_bike(n):
print('Running move_bike with n =', n)
bikeshare.olin -= n
bikeshare.wellesley += n
The first argument of print is a string; the second is the value of n, which is
a number. Each time you run move_bike, it displays a message and the value
n.
1.8 If statements
The modsim library provides a function called flip that takes as an argument
a probability between 0 and 1:
flip(0.7)
The result is one of two values: True with probability 0.7 or False with
probability 0.3. If you run this function 100 times, you should to get True
about 70 times and False about 30 times. But the results are random, so
they might differ from these expectations.
True and False are special values defined by Python. Note that they are
not strings. There is a difference between True, which is a special value, and
'True', which is a string.
True and False are called boolean values because they are related to Boolean
algebra (http://modsimpy.com/boolean).
12 Chapter 1 Modeling
We can use boolean values to control the behavior of the program using an if
statement:
if flip(0.5):
print('heads')
If the result from flip is True, the program displays the string 'heads'.
Otherwise it does nothing.
Optionally, you can add an else clause to indicate what should happen if the
result is False:
if flip(0.5):
print('heads')
else:
print('tails')
Now we can use flip to simulate the arrival of students who want to borrow
a bike. Suppose we have data from previous observations about how many
students arrive at a particular time of day. If students arrive every 2 minutes,
on average, then during any one-minute period, there is a 50% chance a student
will arrive:
if flip(0.5):
bike_to_wellesley()
if flip(0.5):
bike_to_olin()
We can combine these snippets of code into a function that simulates a time
step, which is an interval of time, like one minute:
1.8 If statements 13
def step():
if flip(0.5):
bike_to_wellesley()
if flip(0.5):
bike_to_olin()
Then we can run a time step, and update the plot, like this:
step()
plot_state()
In reality, the probability of an arrival will vary over the course of a day, and
might be higher or lower, at any point in time, at Olin or Wellesley. So instead
of putting the constant value 0.5 in step we can replace it with a parameter,
like this:
def step(p1, p2):
if flip(p1):
bike_to_wellesley()
if flip(p2):
bike_to_olin()
Now when you call step, you have to provide two arguments:
step(0.4, 0.2)
The arguments you provide, 0.4 and 0.2, get assigned to the parameters, p1
and p2, in order. So running this function has the same effect as:
p1 = 0.4
p2 = 0.2
if flip(p1):
bike_to_wellesley()
if flip(p2):
bike_to_olin()
14 Chapter 1 Modeling
if flip(p2):
bike_to_olin()
Because they have default values, these parameters are optional; if you run
step with no arguments, and dont forget the parentheses, like this:
step()
The parameters get the default values, so p1 and p2 are both 0.5. If you
provide one argument, like this:
step(0.4)
The value you provide overrides the default value of p1, but p2 still gets the
default. If you provide two arguments, it overrides both.
step(0.4, 0.2)
If you want to override p2 only, and accept the default for p1, you have to
provide the name of the parameter explicitly:
step(p2=0.2)
step(p1=0.4, p2=0.2)
Providing parameters names makes programs more readable and less error-
prone, because you dont have to worry about the order of the arguments.
1.10 For loops 15
For example, if you reverse the order, it still assigns the right value to each
parameter:
step(p2=0.2, p1=0.4)
for i in range(4):
bike_to_wellesley()
plot_state()
The punctuation here should look familiar; the first line ends with a colon,
and the lines inside the for loop are indented. The other elements of the for
loop are:
The words for and in are special words we have to use in a for loop.
i is a loop variable that gets created when the for loop runs. In this
example we dont actually use i; we will see examples later where we
use the loop variable inside the loop.
When this loop runs, it runs the statements inside the loop four times, which
moves one bike at a time from Olin to Wellesley, and plots the updated state
of the system after each move.
1.11 Debugging
The goal of this chapter is to give you the minimal set of tools to get you
started. At this point, you know enough to write simple simulations of systems
16 Chapter 1 Modeling
like the OlinWellesley bikeshare. Along with each chapter, I provide a Jupyter
notebook that contains the code from the chapter, so you can run it, see how
it works, modify it, and see how it breaks.
When something goes wrong, Python provides error messages with information
about the problem. This information can help with debugging, but error
messages are often hard to understand.
Simulation
To paraphrase two Georges, All models are wrong, but some models are more
wrong than others. In this chapter, I demonstrate the process we use to make
models less wrong.
As an example, well review the bikeshare model from the previous chapter,
consider its strengths and weaknesses, and gradually improve it. Well also see
ways to use the model to understand the behavior of the system and evaluate
designed intended to make it work better.
You can view the code for this chapter at http://modsimpy.com/chap02. For
instructions for downloading and running the code, see Section 0.4.
The model does not account for travel time from one bike station to
another.
The model does not check whether a bike is available, so its possible for
the number of bikes to be negative (as you might have noticed in some
of your simulations).
Some of these modeling decisions are better than others. For example, the first
assumption might be reasonable if we simulate the system for a short period
of time, like one hour.
The second assumption is not very realistic, but it might not affect the results
very much, depending on what we use the model for.
On the other hand, the third assumption seems problematic, and it is relatively
easy to fix. In Section 2.4, we will.
This process, starting with a simple model, identifying the most important
problems, and making gradual improvements, is called iterative modeling.
For any physical system, there are many possible models, based on different
assumptions and simplifications. It often takes several iterations to develop a
model that is good enough for the intended purpose, but no more complicated
than necessary.
Here are two functions from the previous chapter, move_bike and plot_state:
2.2 More than one System object 19
def move_bike(n):
bikeshare.olin -= n
bikeshare.wellesley += n
def plot_state():
plot(bikeshare.olin, 'rs-', label='Olin')
plot(bikeshare.wellesley, 'bo-', label='Wellesley')
One problem with these functions is that they always use bikeshare, which
is a System object. As long as there is only one System object, thats fine,
but these functions would be more flexible if they took a System object as a
parameter. Heres what that looks like:
def plot_state(system):
plot(system.olin, 'rs-', label='Olin')
plot(system.wellesley, 'bo-', label='Wellesley')
bike_to_olin(bikeshare1)
bike_to_wellesley(bikeshare2)
Changes in bikeshare1 do not affect bikeshare2, and vice versa. This be-
havior will be useful later in the chapter when we create a series of System
objects to simulate different scenarios.
20 Chapter 2 Simulation
2.3 Documentation
Another problem with the code we have so far is that it contains no documen-
tation. Documentation is text we add to programs to help other programmers
read and understand them. It has no effect on the program when it runs.
The first line is a single sentence that describes what the function does.
At this point we have more documentation than code, which is not unusual
for short functions.
The changes weve made so far improve the quality of the code, but we havent
done anything to improve the quality of the model yet. Lets do that now.
Currently the code does not check whether a bike is available when a customer
arrives, so the number of bikes at a location can be negative. Thats not very
realistic. Heres an updated version of move_bike that fixes the problem:
22 Chapter 2 Simulation
wellesley_temp = system.wellesley + n
if wellesley_temp < 0:
return
The comments explain the two sections of the function: the first checks to
make sure a bike is available; the second updates the state.
The first line creates a variable named olin_temp that gets the number of
bikes that would be at Olin if n bikes were moved. I added the suffix _temp to
the name to indicate that I am using it as a temporary variable.
In that case I use a return statement, which causes the function to end
immediately, without running the rest of the statements. So if there are not
enough bikes at Olin, we return from move_bike without changing the state.
We do the same if there are not enough bikes at Wellesley.
If both of these tests pass, we run the last two lines, which assigns the values
from the temporary variables to the corresponding system variables.
In contrast, the system variables olin and wellesley belong to system, the
System object that was passed to this function as a parameter. When we
2.5 Comparison operators 23
change system variables inside a function, those changes are visible in other
parts of the program.
This version of move_bike makes sure we never have negative bikes at either
station. But what about bike_to_wellesley and bike_to_olin; do we have
to update them, too? Here they are:
def bike_to_wellesley(system):
move_bike(system, 1)
def bike_to_olin(system):
move_bike(system, -1)
Because these functions use move_bike, they take advantage of the new feature
automatically. We dont have to update them.
Operation Symbol
Less than <
Greater than >
Less than or equal <=
Greater than or equal >=
Equal ==
Not equal !=
The equals operator, ==, compares two values and returns True if they are
equal and False otherwise. It is easy to confuse with the assignment op-
erator, =, which assigns a value to a variable. For example, the following
statement uses the assignment operator, which creates x if it doesnt already
exist and gives it the value 5
24 Chapter 2 Simulation
x = 5
On the other hand, the following statement checks whether x is 5 and returns
True or False. It does not create x or change its value.
x == 5
if x == 5:
print('yes, x is 5')
If you make a mistake and use = in the first line of an if statement, like this:
if x = 5:
print('yes, x is 5')
Thats a syntax error, which means that the structure of the program is
invalid. Python will print an error message and the program wont run.
2.6 Metrics
Getting back to the bike share system, at this point we have the ability to
simulate the behavior of the system. Since the arrival of customers is random,
the state of the system is different each time we run a simulation. Models like
this are called stochastic; models that do the same thing every time they run
are deterministic.
Suppose we want to use our model to predict how well the bike share system
will work, or to design a system that works better. First, we have to decide
what we mean by how well and better.
From the customers point of view, we might like to know the probability of
finding an available bike. From the system-owners point of view, we might
want to minimize the number of customers who dont get a bike when they
want one, or maximize the number of bikes in use. Statistics like these that
are intended to quantify how well the system works are called metrics.
2.6 Metrics 25
wellesley_temp = system.wellesley + n
if wellesley_temp < 0:
system.wellesley_empty += 1
return
system.olin = olin_temp
system.wellesley = wellesley_temp
print(bikeshare.olin_empty, bikeshare.wellesley_empty)
Because the simulation is stochastic, the results are different each time it runs.
26 Chapter 2 Simulation
t = sqrt(2 * h / a)
Not all functions have return values. For example, when you run plot, it adds
a point to the current graph, but it doesnt return a value. The functions we
wrote so far are like plot; they have an effect, like changing the state of the
system, but they dont return values.
To write functions that return values, we can use a second form of the return
statement, like this:
def add_five(x):
return x + 5
add_five(3)
As a more useful example, heres a new function that creates a System object,
runs a simulation, and then returns the System object as a result:
def run_simulation():
system = System(olin=10, wellesley=2,
olin_empty=0, wellesley_empty=0)
run_steps(system, 60, 0.4, 0.2, plot=False)
return system
system = run_simulation()
It assigns to system a new System object, which we can use to check the
metrics we are interested in:
print(system.olin_empty, system.wellesley_empty)
This version of run_simulation always starts with the same initial condition,
10 bikes at Olin and 2 bikes at Wellesley, and the same values of p1, p2, and
num_steps. Taken together, these five values are the parameters of the
model, which are values that determine the behavior of the model.
Then we can see how the metrics, like the number of unhappy customers,
depend on the parameters of the model. But before we do that, we need a
new version of a for loop.
28 Chapter 2 Simulation
for i in range(4):
bike_to_wellesley()
plot_state()
The loop variable, i, gets created when the loop runs, but it is not used for
anything. Now heres a loop that actually uses the loop variable:
for p1 in p1_array:
print(p1)
0.0
0.25
0.5
0.75
1.0
But prediction is not the only goal; models like this are also used to explain why
systems behave as they do and to evaluate alternative designs. For example, if
we observe the system and notice that we often run out of bikes at a particular
time, we could use the model to figure out why that happens. And if we are
considering adding more bikes, or another station, we could evaluate the effect
of various what if scenarios.
As an example, suppose we have enough data to estimate that p2 is about 0.2,
but we dont have any information about p1. We could run simulations with
a range of values for p1 and see how the results vary. This process is called
sweeping a parameter, in the sense that the value of the parameter sweeps
through a range of possible values.
Now that we know about loops and arrays, we can use them like this:
for p1 in p1_array:
system = run_simulation(p1=p1)
print(p1, system.olin_empty)
Each time through the loop, we run a simulation with a the given value of p1
and the default value of p2. Then we print p1 and the number of unhappy
customers at Olin.
To visualize the results, we can run the same loop, replacing print with plot:
newfig()
for p1 in p1_array:
system = run_simulation(p1=p1)
plot(p1, system.olin_empty, 'rs', label='olin')
When you run the notebook for this chapter, you will see the results and have
a chance to try additional experiments.
30 Chapter 2 Simulation
2.11 Debugging
When you start writing programs that are more than a few lines, you might
find yourself spending more and more time debugging. The more code you
write before you start debugging, the harder it is to find the problem.
3. Run the program and see if the change worked. If so, go back to Step 2.
If not, you will have to do some debugging, but if the change you made
was small, it shouldnt take long to find the problem.
When this process works, your changes usually work the first time, or if they
dont, the problem is obvious. In practice, there are two problems with incre-
mental development:
Sometimes you have to write extra code to generate visible output that
you can check. This extra code is called scaffolding because you use it
to build the program and then remove it when you are done. That might
seem like a waste, but time you spend on scaffolding is almost always
time you save on debugging.
When you are getting started, it might not be obvious how to choose
the steps that get from x=5 to the program you are trying to write. You
will see more examples of this process as we go along, and you will get
better with experience.
If you find yourself writing more than a few lines of code before you start
testing, and you are spending a lot of time debugging, try incremental devel-
opment.
Chapter 3
Explanation
But world population growth is still a topic of concern, and it is an open ques-
tion how many people the earth can sustain while maintaining and improving
our quality of life.
In this chapter and the next, we use tools from the previous chapters to explain
world population growth since 1950 and generate predictions for the next 50
100 years.
You can view the code for this chapter at http://modsimpy.com/chap03. For
instructions for downloading and running the code, see Section 0.4.
To work with this data in Python, we will use functions from Pandas, which
is a library that provides functions for working with data. It also defines two
objects we will use extensively, Series and DataFrame.
The Pandas function well use is read_html, which can read a web page and
extract data from any tables it contains. Before we can use it, we have to
import it. You have already seen this import statement:
which imports all functions from the modsim library. To import read_html,
the statement we need is:
from pandas import read_html
filename: The name of the file (including the directory its in) as a
string. This argument can also be a URL starting with http.
To select one of the DataFrames in tables, we can use the bracket operator
like this:
table2 = tables[2]
table2.head()
The headings at the tops of the columns are long strings, which makes them
hard to work. We can replace them with shorter strings like this:
3.2 Series
Now we can select a column from the DataFrame using the dot operator, like
selecting a system variable from a System object:
census = table2.census
The result is a Series, which is another object defined by the Pandas library.
Each Series object contains variables called index and values. In this exam-
ple, the index is a sequence of years, from 1950 to 2015; values is a sequence
of population estimates.
34 Chapter 3 Explanation
values is an array like the ones we saw in Section 2.9. index is an Int64Index,
which is yet another object defined by Pandas, but in all ways we care about
for now, it behaves like an array.
plot knows how to work with Series objects, so we can plot the estimates
like this:
def plot_estimates():
un = table2.un / 1e9
census = table2.census / 1e9
The first two lines select the estimates generated by the United Nations De-
partment of Economic and Social Affairs (UN DESA) and the United States
Census Bureau.
At the same time we divide by one billion, in order to display the estimates in
terms of billions. The number 1e9 is a shorter (and less error-prone) way to
write 1000000000 or one billion. When we divide a Series by a number, it
divides all of the elements of the Series.
The next two lines plot the Series objects. The strings ':' and '--' indicate
dotted and dashed lines. The color argument can be any of the color strings
described at http://modsimpy.com/color. The label argument provides the
string that appears in the legend.
Figure 3.1 shows the result. The lines overlap almost completely; for most
dates the difference between the two estimates is less than 1%.
7 USCensus
UNDESA
Worldpopulation(billion) 6
In the next few sections I demonstrate this process starting with simple models,
which turn out to be unrealistic, and gradually improving them.
Although there is some curvature in the plotted estimates, it looks like world
population growth has been close to linear since 1960 or so. As a starting
place, well build a model with constant growth.
To fit the model to the data, well compute the average annual growth from
1950 to 2015. Since the UN and Census data are so close, Ill use the Census
data, again in terms of billions:
census[1950]
So we can get the total growth during the interval like this:
The values 2015 and 1950 are part of the data, so it would be better not to
make them part of the program. Putting values like these in the program is
called hard coding; it is considered bad practice because if the data changes
in the future, we have to modify the program (see http://modsimpy.com/
hardcode).
We can get the first and last year from the index like this:
first_year = census.index[0]
last_year = census.index[-1]
In brackets, 0 selects the first element from census.index, and -1 selects the
last element. Now we can compute total_growth and annual_growth:
The next step is to use this estimate to simulate population growth since 1950.
3.4 Simulation
The result from the simulation is a time series, which is a sequence of dates or
times and an associated series of values (see http://modsimpy.com/timeser).
In this case, we have a sequence of year and an associated sequence of popu-
lation estimates with associated year.
results = TimeSeries()
We can set the first value in the new TimeSeries by copying the first value
from census (well get rid of the hard-coded dates soon):
3.4 Simulation 37
results[1950] = census[1950]
Then we can set the rest of the values by simulating annual growth:
It returns a NumPy array of values from start to stop, where step is the
difference between successive values. The default value of step is 1.
This function works best if the space between start and stop is divisible by
step; otherwise the results might be surprising.
By default, the last value in the array is stop (at least approximately). If
you provide the argument endpoint=False, the last value in the array is
stop-step.
In this example, the result from linrange is an array of values from 1950 to
2015 (including both).
Each time through the loop, the loop variable t gets the next value from the
array. Inside the loop, we compute the population for each year by adding the
population for the previous year and annual_growth.
Figure 3.2 shows the result. The model does not fit the data particularly well
from 1950 to 1990, but after that, its pretty good. Nevertheless, there are
problems:
There is no obvious mechanism that could cause population growth to
be constant from year to year. Changes in population are determined by
the fraction of people who die and the fraction of people who give birth,
so they should depend on the current population.
According to this model, we would expect the population to keep growing
at the same rate forever, and that does not seem reasonable.
Well try out some different models in the next few sections, but first lets
clean up the code.
38 Chapter 3 Explanation
7 USCensus
UNDESA
model
Worldpopulation(billion)
system = System(t0=first_year,
t_end=last_year,
p0=census[first_year],
annual_growth=annual_growth)
t0 and t_end are the first and last years; p0 is the initial population, and
annual_growth is the estimated annual growth.
Next well wrap the code from the previous section in a function:
def run_simulation1(system):
results = TimeSeries()
results[system.t0] = system.p0
for t in linrange(system.t0, system.t_end):
results[t+1] = results[t] + system.annual_growth
system.results = results
3.6 Proportional growth model 39
def plot_results(system):
newfig()
plot_estimates()
plot(system.results, '--', color='gray', label='model')
decorate(xlabel='Year',
ylabel='World population (billion)',
ylim=[0, 8])
This function uses decorate, which is defined in the modsim library. decorate
takes arguments that specify the title of the figure, labels for the x-axis and
y-axis, and limits for the axes. The notebook for this chapter provides more
details.
run_simulation1(system)
plot_results(system)
The biggest problem with the constant growth model is that it doesnt make
any sense. It is hard to imagine how people all over the world could conspire
to keep population growth constant from year to year.
On the other hand, if some fraction of the population dies each year, and some
fraction gives birth, we can compute the net change in the population like this:
40 Chapter 3 Explanation
Proportionalmodel
USCensus
7 UNDESA
model
Worldpopulation(billion)
def run_simulation2(system):
results = TimeSeries()
results[system.t0] = system.p0
for t in linrange(system.t0, system.t_end):
births = system.birth_rate * results[t]
deaths = system.death_rate * results[t]
results[t+1] = results[t] + births - deaths
system.results = results
Now we can choose the values of birth_rate and death_rate that best fit
the data. Without trying too hard, I chose:
system.death_rate = 0.01
system.birth_rate = 0.027
run_simulation2(system)
plot_results(system)
3.7 Factoring out the update function 41
Figure 3.3 shows the results. The proportional model fits the data well from
1950 to 1965, but not so well after that. Overall, the quality of fit is not as
good as the constant growth model, which is surprising, because it seems like
the proportional model is more realistic.
Maybe net growth depends on the current population, but the relation-
ship is quadratic, not linear.
We will try out these variations, but first, lets clean up the code one more
time.
Rather than repeat identical code, we can separate the things that change
from the things that dont. First, well pull out the body of the loop and make
it a function:
def update_func1(pop, t, system):
births = system.birth_rate * pop
deaths = system.death_rate * pop
return pop + births - deaths
This function takes as arguments the current population, current year, and a
System object; it returns the computed population for the next year.
run_simulation(system, update_func1)
When you calls run_simulation, it calls update_func1 once for each year
between t0 and t_end. The result is the same as Figure 3.3.
run_simulation(system, update_func1b)
Now we need two parameters. I chose the following values by trial and error;
we will see better ways to do it later.
system.alpha = 0.025
system.beta = -0.0018
run_simulation(system, update_func2)
Figure 3.4 shows the result. The model fits the data well over the whole range,
with just a bit of daylight between them in the 1960s.
Of course, we should expect the quadratic model to fit better than the linear
or proportional model because it has two parameters we can choose, where
the other models have only one. In general, the more parameters you have to
play with, the better you should expect the model to fit.
But fitting the data is not the only reason to think the quadratic model might
be a good choice. It also makes sense; that is, there is a legitimate reason to
expect the relationship between growth and population to have this form.
44 Chapter 3 Explanation
Quadraticmodel
USCensus
7 UNDESA
model
Worldpopulation(billion)
pop_array contains 100 equally spaced values from near 0 to 15. net_growth_array
contains the corresponding 100 values of net growth. We can plot the results
like this:
Figure 3.5 shows the result. Note that the x-axis is not time, as in the previous
figures, but population. We can divide this curve into four regimes of behavior:
When the population is less than 3-4 billion, net growth is proportional to
population, as in the proportional model. In this regime, the population
grows slowly because the population is small.
3.10 Equilibrium 45
0.08
0.06
Netgrowth(billions)
0.04
0.02
0.00
0.02
0 2 4 6 8 10 12 14
Population(billions)
Above 14 billion, resources are so limited that the death rate exceeds the
birth rate and net growth becomes negative.
Just below 14 billion, there is a population where net growth is 0, which means
that the population does not change. At this point, the birth and death rates
are equal, so the population is in equilibrium.
3.10 Equilibrium
To find this equilibrium point, we can find the roots, or zeros, of this equation:
p = p + p2 = 0
46 Chapter 3 Explanation
where p is net growth, p is current population, and and are the param-
eters of the model. We can rewrite the right hand side like this:
p = p( + p) = 0
p = rp(1 p/K)
This is the same model; its just a different way to parameterize it. Given
and , we can compute r = and K = /.
In the next chapter we use the models we have developed to generate predic-
tions.
3.11 Disfunctions
When people first learn about functions, there are a few things they often find
confusing. In this section I present and explain some common problems with
functions.
As an example, suppose you want a function that takes a System object, with
variables alpha and beta, as a parameter and computes the carrying capacity,
-alpha/beta. Heres a good solution:
3.11 Disfunctions 47
def carrying_capacity(system):
K = -system.alpha / system.beta
return K
Disfunction #1: Not using parameters. In the following version, the function
doesnt take any parameters; when system appears inside the function, it
refers to the object we created outside the function.
def carrying_capacity():
K = -system.alpha / system.beta
return K
This version actually works, but it is not are versatile as it could be. If there
are several System objects, this function can only work with one of them, and
only if it is named system.
Disfunction #2: Clobbering the parameters. When people first learn about
parameters, they often write functions like this:
# WRONG
def carrying_capacity(system):
system = System(alpha=0.025, beta=-0.0018)
K = -system.alpha / system.beta
return K
In this example, we have a System object named sys1 that gets passed as
an argument to carrying_capacity. But when the function runs, it ignores
the argument and immediately replaces it with a new System object. As a
result, this function always returns the same value, no matter what argument
is passed.
When you write a function, you generally dont know what the values of the
parameters will be. Your job is to write a function that works for any valid
values. If you assign your own values to the parameters, you defeat the whole
purpose of functions.
Disfunction #3: No return value. Heres a version that computes the value of
K but doesnt return it.
# WRONG
def carrying_capacity(system):
K = -system.alpha / system.beta
Disfunction #4: Ignoring the return value. Finally, heres a version where the
function is correct, but the way its used is not.
# WRONG
def carrying_capacity(system):
K = -system.alpha / system.beta
return K
In this example, carrying_capacity runs and returns K, but the return value
is dropped.
When you call a function that returns a value, you should do something with
the result. Often you assign it to a variable, as in the previous examples, but
you can also use it as part of an expression. For example, you could eliminate
the temporary variable pop like this:
print(carrying_capacity(sys1))
Or if you had more than one system, you could compute the total carrying
capacity like this:
In the notebook for this chapter, you can try out each of these examples and
see what happens.
50 Chapter 3 Explanation
Chapter 4
Prediction
In this chapter well use the quadratic model to generate projections of future
growth, and compare our results to projections from actual demographers.
Also, well represent the models from the previous chapters as differential
equations and solve them analytically.
You can view the code for this chapter at http://modsimpy.com/chap04. For
instructions for downloading and running the code, see Section 0.4.
As we saw in the previous chapter, we can get the start date, end date, and
initial population from census, which is a series that contains world population
estimates generated by the U.S. Census:
52 Chapter 4 Prediction
t0 = census.index[0]
t_end = census.index[-1]
p0 = census[t0]
system = System(t0=t0,
t_end=t_end,
p0=p0,
alpha=0.025,
beta=-0.0018)
run_simulation(system, update_func2)
We have already seen the results in Figure 3.4. Now, to generate a projection,
the only thing we have to change is t_end:
system.t_end = 2250
run_simulation(system, update_func2)
Figure 4.1 shows the result, with a projection until 2250. According to this
model, population growth will continue almost linearly for the next 50100
years, then slow over the following 100 years, approaching 13.9 billion by 2250.
Using projection leaves open the possibility that there are important things
in the real world that are not captured in the model. It also suggests that,
even if the model is good, the parameters we estimate based on the past might
be different in the future.
4.1 Generating projections 53
Worldpopulationprojection
14
USCensus
UNDESA
12 model
Worldpopulation(billion)
10
Figure 4.1: Quadratic model of world population growth, with projection from
2016 to 2250.
The quadratic model weve been working with is based on the assumption that
population growth is limited by the availability of resources; in that scenario,
as the population approaches carrying capacity, birth rates fall and death rates
rise because resources become scarce.
But in the case of world population growth, those conditions dont apply.
Over the last 50 years, the net growth rate has leveled off, but not yet started
to fall, so we dont have enough data to make a credible estimate of carrying
capacity. And resource limitations are probably not the primary reason growth
has slowed. As evidence, consider:
First, the death rate is not increasing; rather, it has declined from 1.9%
in 1950 to 0.8% now (see http://modsimpy.com/mortality). So the
decrease in net growth is due entirely to declining birth rates.
Second, the relationship between resources and birth rate is the opposite
of what the model assumes; as nations develop and people become more
wealthy, birth rates tend to fall.
54 Chapter 4 Prediction
We should not take too seriously the idea that this model can estimate carrying
capacity. But the predictions of a model can be credible even if the assump-
tions of the model are not strictly true. For example, population growth might
behave as if it is resource limited, even if the actual mechanism is something
else.
In fact, demographers who study population growth often use models similar
to ours. In the next section, well compare our projections to theirs.
table3 = tables[3]
For some years, one agency or the other has not published a projection, so some
elements of table3 contain the special value NaN, stands for not a number.
NaN is often used to indicate missing data.
Pandas provides functions that deal with missing data, including dropna,
which removes any elements in a series that contain NaN. Using dropna, we
can plot the projections like this:
def plot_projections(table):
census = table.census / 1e9
un = table.un / 1e9
plot(census.dropna(), ':',
color='darkblue', label='US Census')
plot(un.dropna(), '--',
color='green', label='UN DESA')
system.t_end = 2100
run_simulation(system, update_func2)
4.3 Recurrence relations 55
Worldpopulationprojections
12 USCensus
UNDESA
Worldpopulation(billion) model
10
And compare our projections to theirs. Figure 4.2 shows the results. Real
demographers expect world population to grow more slowly than our model
projects, probably because their models are broken down by region and coun-
try, where conditions are different, and they take into account expected eco-
nomic development.
The population models in the previous chapter and this one are simple enough
that we didnt really need to run simulations. We could have solved them
mathematically. For example, we wrote the constant growth model like this:
xn+1 = xn + c
xn+1 = xn + xn
xn+1 = xn (1 + )
xn = x0 (1 + )n
xn+1 = xn + xn + x2n
4.4 Differential equations 57
xn+1 = xn + c
If we define x to be the change in x from one time step to the next, we can
write:
x = xn+1 xn = c
If we define t to be the time step, which is one year in the example, we can
write the rate of change per unit of time like this:
x
=c
t
This model is discrete, which means it is only defined at integer values of n
and not in between. But in reality, people are born and die all the time, not
once a year, so a continuous model might be more realistic.
We can make this model continuous by writing the rate of change in the form
of a derivative:
dx
=c
dt
This way of representing the model is a differential equation; see http:
//modsimpy.com/diffeq.
We can solve this differential equation if we multiply both sides by dt:
dx = cdt
x(t) = ct + x0
58 Chapter 4 Prediction
x
= x
t
And as a differential equation like this:
dx
= x
dt
If we multiply both sides by dt and divide by x, we get
1
dx = dt
x
Now we integrate both sides, yielding:
ln x = t + K
exp(log(x)) = x = exp(t + K)
But there are several things we can do with analysis that are harder or impos-
sible with simulations:
Analysis can provide insight into models and the systems they describe;
for example, sometimes we can identify regimes of qualitatively different
behavior and key parameters that control those behaviors.
When people see what analysis can do, they sometimes get drunk with power,
and imagine that it gives them a special ability to see past the veil of the
material world and discern the laws of mathematics that govern the universe.
When they analyze a model of a physical system, they talk about the math
60 Chapter 4 Prediction
Each of these languages is good for the purposes it was designed for and less
good for other purposes. But they are often complementary, and one of the
goals of this book is to show how they can be used together.
f (t) = c1 exp(t)
f (t) = p0 exp(t)
SymPy defines many functions, and some of them conflict with functions de-
fined in modsim and the other libraries were using, I suggest that you do
symbolic computation with SymPy in a separate notebook.
The code for this section and the next is in this notebook: http://modsimpy.
com/sympy04.
SymPy defines a Symbol object that represents symbolic variable names, func-
tions, and other mathematical entities. object
SymPy provides a function called symbols that takes a string and returns a
Symbol object. So if we run this assignment:
t = symbols('t')
expr = t + 1
subs, which substitutes a value for a symbol. This example substitutes 2 for
t:
expr.subs(t, 2)
f = Function('f')
dfdt = diff(f(t), t)
alpha = symbols('alpha')
eq1 = Eq(dfdt, alpha*f(t))
solution_eq = dsolve(eq1)
4.9 Solving the quadratic growth model 63
f (t) = C1 exp(t)
The result is
f (t) = p0 exp(t)
This function is called the exponential growth curve; see http://modsimpy.
com/expo.
Finally, we substitute this value of C1 into the general solution, which yields:
Kp0 exp(rt)
f (t) =
K + p0 exp(rt) p0
64 Chapter 4 Prediction
If you would like to see this differential equation solved by hand, you might
like this video: http://modsimpy.com/khan2
4.10 Summary
The following tables summarize the results so far:
What Ive been calling the constant growth model is more commonly called
linear growth because the solution is a line. Similarly, what Ive called
proportional is commonly called exponential, and what Ive called quadratic
is commonly called logistic.
I avoided the more common terms until now because I thought it would be
strange to use them before we solved the equations and discovered the func-
tional form of the solutions.
Chapter 5
Design
My presentation of the SIR model in this chapter, and the analysis in the next
chapter, is based on an excellent article by David Smith and Lang Moore1 .
You can view the code for this chapter at http://modsimpy.com/chap05. For
instructions for downloading and running the code, see Section 0.4.
Every year at Olin College, about 90 new students come to campus from
around the country and the world. Most of them arrive healthy and happy,
but usually at least one brings with them some kind of infectious disease. A
few weeks later, predictably, some fraction of the incoming class comes down
with what we call The Freshman Plague.
1
Smith and Moore, The SIR Model for Spread of Disease, Journal of Online Mathe-
matics and its Applications, December 2001, at http://modsimpy.com/sir.
66 Chapter 5 Design
So far we have done our own modeling; that is, weve chosen physical sys-
tems, identified factors that seem important, and made decisions about how
to represent them. In this chapter we start with an existing model and reverse-
engineer it. Along the way, we consider the modeling decisions that went into
it and identify its capabilities and limitations.
S: People who are susceptible, that is, capable of contracting the dis-
ease if they come into contact with someone who is infected.
In the basic version of the model, people who have recovered are considered to
be immune to reinfection. That is a reasonable model for some diseases, but
not for others, so it should be on the list of assumptions to reconsider later.
Lets think about how the number of people in each category changes over time.
Suppose we know that people with the disease are infectious for a period of
4 days, on average. If 100 people are infectious at a particular point in time,
and we ignore the particular time each one became infected, we expect about
1 out of 4 to recover on any particular day.
Putting that a different way, if the time between recoveries is 4 days, the
recovery rate is about 0.25 recoveries per day, which well denote with the
parameter (the Greek letter gamma). If the total number of people in the
5.3 The SIR equations 67
Now lets think about the number of new infections. Suppose we know that
each susceptible person comes into contact with 1 person every 3 days, on
average, in such a way that they would contract the disease if the other person
is infected. Well denote this contact rate with the parameter (the Greek
letter beta).
Its probably not reasonable to assume that we know ahead of time, but
later well see how to estimate it based on data from previous semesters.
In summary:
This model assumes that the population is closed, that is, no one arrives or
departs. So the size of the population, N , is constant.
ds
= si
dt
di
= si i
dt
dr
= i
dt
68 Chapter 5 Design
In this example, there are three stocks susceptible, infected, and recov-
ered and two flows new infections and recoveries. Compartment mod-
els are often represented visually using stock-and-flow diagrams (see http:
//modsimpy.com/stock. Figure 5.1 shows the stock and flow diagram for an
SIR model.
5.4 Implementation
For a given physical system, there are many possible models, and for a given
model, there are many ways to represent it. For example, we can represent
an SIR model as a stock-and-flow diagram, as a set of differential equations,
or as a Python program. The process of representing a model in these forms
is called implementation. In this section, we implement the SIR model in
Python.
As a first step, Ill represent the initial state of the system using a State object,
which is defined in the modsim library. A State object is a collection of state
variables; each state variable represents information about the system that
changes over time. In this example, the state variables are S, I, and R; they
represent the fraction of the population in each compartment.
init /= sum(init)
For now, lets assume we know the time between contacts and time between
recoveries:
Now we need a System object to store the parameters and initial conditions.
The following function takes the system parameters as function parameters
and returns a new System object:
t0 = 0
t_end = 7 * 14
The default value for t_end is 14 weeks, about the length of a semester.
takes the State object as a parameter, along with the System object that
contains the parameters, and computes the state of the system during the
next time step:
infected = system.beta * i * s
recovered = system.gamma * i
s -= infected
i += infected - recovered
r += recovered
The first line uses a feature we have not seen before, multiple assignment.
The value on the right side is a State object that contains three values. The
left side is a sequence of three variable names. The assignment does just
what we want: it assigns the three values from the State object to the three
variables, in order.
value
S 0.985388
I 0.011865
R 0.002747
5.6 Running the simulation 71
The parameters of run_simulation are the System object and the update
function. The System object contains the parameters, initial conditions, and
values of t0 and t_end.
The outline of this function should look familiar; it is similar to the function
we used for the population model in Section 3.5.
We can call run_simulation like this:
system = make_system(beta, gamma)
run_simulation(system, update1)
value
S 0.520819
I 0.000676
R 0.478505
This result indicates that after 14 weeks (98 days), about 52% of the population
is still susceptible, which means they were never infected, less than 1% are
actively infected, and 48% have recovered, which means they were infected at
some point.
do that: first, using three TimeSeries objects, then using a new object called
a TimeFrame.
state = system.init
t0 = system.t0
S[t0], I[t0], R[t0] = state
system.S = S
system.I = I
system.R = R
First, we create TimeSeries objects to store the results. Notice that the
variables S, I, and R are TimeSeries objects now.
Inside the loop, we use update_func to compute the state of the system at
the next time step, then use multiple assignment to unpack the elements of
state, assigning each to the corresponding TimeSeries.
1.0
Susceptible
Infected
Fractionofpopulation 0.8 Resistant
0.6
0.4
0.2
0.0
0 20 40 60 80 100
Time(days)
Figure 5.2: Time series for S, I, and R over the course of 98 days.
Figure 5.2 shows the result. Notice that it takes about three weeks (21 days)
for the outbreak to get going, and about six weeks (42 days) before it peaks.
The fraction of the population thats infected is never very high, but it adds
up. In total, almost half the population gets sick.
system.results = frame
The first line creates an empty TimeFrame with one column for each state
variable. Then, before the loop starts, we store the initial conditions in the
TimeFrame at t0. Based on the way weve been using TimeSeries objects, it
is tempting to write:
frame[system.t0] = system.init
But when you use the bracket operator with a TimeFrame or DataFrame, it
selects a column, not a row. For example, to select a column, we could write:
frame['S']
To select a row, we have to use loc, which stands for location. frame.loc[0]
reads a row from a TimeFrame and
frame.loc[system.t0] = system.init
assigns the values from system.init to the first row of df. Since the value on
the right side is a State, the assignment matches up the index of the State
with the columns of the TimeFrame; it assigns the S value from system.init
to the S column of the TimeFrame, and likewise with I and R.
5.9 Metrics 75
We can use the same feature to write the loop more concisely, assigning the
State we get from update_func directly to the next row of df.
Finally, we store frame as a new system variable. We can call this version of
run_simulation like this:
run_simulation(system, update1)
frame = system.results
plot_results(frame.S, frame.I, frame.R)
As with a DataFrame, we can use the dot operator to select columns from a
TimeFrame.
5.9 Metrics
When we plot a time series, we get a view of everything that happened when
the model ran, but often we want to boil it down to a few numbers that
summarize the outcome. These summary statistics are called metrics.
In the SIR model, we might want to know the time until the peak of the
outbreak, the number of people who are sick at the peak, the number of
students who will still be sick at the end of the semester, or the total number
of students get sick at any point.
As an example I will focus on the last one the total number of sick students
and we will consider interventions intended to minimize it.
When a person gets infected, they move from S to I, so we can compute the
total number of infections like this:
def calc_total_infected(system):
frame = system.results
return frame.S[system.t0] - frame.S[system.t_end]
76 Chapter 5 Design
In the notebook that accompanies this chapter, you will have a chance to write
functions that compute other metrics. Two functions you might find useful
are max and idxmax.
If you have a Series called s, you can compute the largest value of the series
like this:
largest_value = s.max()
time_of_largest_value = s.idxmax()
If the Series is a TimeSeries, the label you get from idxmax is a time or
date. You can read more about these functions in the Series documentation
at http://modsimpy.com/series.
5.10 Immunization
Models like this are useful for testing what if? scenarios. As an example,
well consider the effect of immunization.
One option is to treat immunization as a short cut from S to R, that is, from
susceptible to resistant2 . We can implement this feature like this:
Noimmunization
10%immunization
Fractionsusceptible 0.9
0.8
0.7
0.6
0 20 40 60 80 100
Time(days)
semester, and the vaccine is 100% effective, we can simulate the effect like
this:
For comparison, we can run the same model without immunization and plot
the results. Figure 5.3 shows the results, plotting S as a function of time, with
and without immunization.
def sweep_immunity(immunize_array):
sweep = SweepSeries()
for fraction in immunize_array:
sir = make_system(beta, gamma)
add_immunization(sir, fraction)
run_simulation(sir, update1)
sweep[fraction] = calc_total_infected(sir)
return sweep
Figure 5.4 shows a plot of the SweepSeries. Notice that the x-axis is the
immunization rate, not time.
The steepness of the curve in Figure 5.4 is a blessing and a curse. Its a
blessing because it means we dont have to immunize everyone, and vaccines
can protect the herd even if they are not 100% effective.
But its a curse because a small decrease in immunization can cause a big
increase in infections. In this example, if we drop from 80% immunization to
60%, that might not be too bad. But if we drop from 40% to 20%, that would
trigger a major outbreak, affecting more than 15% of the population. For a
serious disease like measles, just to name one, that would be a public health
catastrophe.
5.11 Hand washing 79
Fractioninfectedvs.immunizationrate
0.4
Totalfractioninfected
0.3
0.2
0.1
0.0
0.0 0.2 0.4 0.6 0.8 1.0
Fractionimmunized
One use of models like this is to demonstrate phenomena like herd immunity
and to predict the effect of interventions like vaccination. Another use is to
evaluate alternatives and guide decision making. Well see an example in the
next section.
We have already seen how we can model the effect of vaccination. Now lets
think about the hand-washing campaign. Well have to answer two questions:
80 Chapter 5 Design
For the sake of simplicity, lets assume that we have data from a similar cam-
paign at another school, showing that a well-funded campaign can change
student behavior enough to reduce the infection rate by 20%.
In terms of the model, hand washing has the effect of reducing beta. Thats
not the only way we could incorporate the effect, but it seems reasonable and
its easy to implement.
Now we have to model the relationship between the money we spend and the
effectiveness of the campaign. Again, lets suppose we have data from another
school that suggests:
In the notebook for this chapter you will see how I used a logistic curve to
fit this data. The result is the following function, which takes spending as a
parameter and returns factor, which is the factor by which beta is reduced:
def compute_factor(spending):
return logistic(spending, M=500, K=0.2, B=0.01)
Effectofhandwashingontotalinfections
0.45
0.40
Totalfractioninfected
0.35
0.30
0.25
0.20
0 200 400 600 800 1000 1200
Handwashingcampaignspending(USD)
Now we can sweep a range of values for spending and use the simulation to
compute the effect:
def sweep_hand_washing(spending_array):
sweep = SweepSeries()
for spending in spending_array:
sir = make_system(beta, gamma)
add_hand_washing(sir, spending)
run_simulation(sir, update1)
sweep[spending] = calc_total_infected(sir)
return sweep
Figure 5.5 shows the result. Below $200, the campaign has little effect. At
$800 it has a substantial effect, reducing total infections from 46% to 20%.
Above $800, the additional benefit is small.
82 Chapter 5 Design
5.12 Optimization
Now we can put it all together. With a fixed budget of $1200, we have to
decide how many doses of vaccine to buy and how much to spend on the
hand-washing campaign.
num_students = 90
budget = 1200
price_per_dose = 100
max_doses = int(budget / price_per_dose)
dose_array = linrange(max_doses)
def sweep_doses(dose_array):
sweep = SweepSeries()
for doses in dose_array:
fraction = doses / num_students
spending = budget - doses * price_per_dose
run_simulation(sir, update1)
sweep[doses] = calc_total_infected(sir)
return sweep
For each number of doses, we compute the fraction of students we can im-
munize, fraction and the remaining budget we can spend on the campaign,
spending. Then we run the simulation with those quantities and store the
number of infections.
5.12 Optimization 83
Totalinfectionsvs.doses
0.28
0.26
Totalfractioninfected
0.24
0.22
0.20
0.18
0.16
0 2 4 6 8 10 12
Dosesofvaccine
Figure 5.6 shows the result. If we buy no doses of vaccine and spend the entire
budget on the campaign, the fraction infected is around 19%. At 4 doses, we
have $800 left for the campaign, and this is the optimal point that minimizes
the number of students who get sick.
In the notebook for this chapter, youll have a chance to run this optimization
for a few other scenarios.
84 Chapter 5 Design
Chapter 6
Analysis
But we assumed that the parameters of the model, contact rate and recovery
rate, were known. In this chapter, we explore the behavior of the model as we
vary these parameters, use analysis to understand these relationships better,
and propose a method for using data to estimate parameters.
You can view the code for this chapter at http://modsimpy.com/chap06. For
instructions for downloading and running the code, see Section 0.4.
6.1 Unpack
Before we analyze the SIR model, I want to make a few improvements to the
code. In the previous chapter, we used this version of run_simulation:
86 Chapter 6 Analysis
system.results = frame
Because we read so many variables from system, this code is a bit cluttered.
We can clean it up using unpack, which is defined in the modsim library.
unpack takes a System object as a parameter and makes the system variables
available without using the dot operator. So we can rewrite run_simulation
like this:
frame = TimeFrame(columns=init.index)
frame.loc[t0] = init
system.results = frame
I think that makes the code easier to read. In the notebook for this chapter,
you can use unpack to clean up update1.
Then run the simulation for each value and print the results.
We can wrap that code in a function and store the results in a SweepSeries
object:
The first line uses string operations to assemble a label for the plotted line:
gamma=0.25
0.8
Fractioninfected
0.6
0.4
0.2
0.0
0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9
Contactsperday(beta)
If the value of gamma is 0.25, the value of label is the string 'gamma = 0.25'.
Figure 6.1 shows the results. Remember that this figure is a parameter sweep,
not a time series, so the x-axis is the parameter beta, not time.
When beta is small, the contact rate is low and the outbreak never really takes
off; the total number of infected students is near zero. As beta increases, it
reaches a threshold near 0.3 where the fraction of infected students increases
quickly. When beta exceeds 0.5, more than 80% of the population gets sick.
1.0
gamma=0.1
gamma=0.3
0.8 gamma=0.5
gamma=0.7
Fractioninfected
0.6
0.4
0.2
0.0
0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9
Contactsperday(beta)
Figure 6.2 shows the results. When gamma is low, the recovery rate is low,
which means people are infectious longer. In that case, even low a contact
rate (beta) results in an epidemic.
When gamma is high, beta has to be even higher to get things going. That
observation suggests that there might be a relationship between gamma and
beta that determines the outcome of the model. In fact, there is. I will
demonstrate it first by running simulations, then derive it by analysis.
90 Chapter 6 Analysis
6.4 Nondimensionalization
Before we go on, lets wrap the code from the previous section in a function:
It creates a SweepFrame to store the results, with one column for each value
of gamma and one row for each value of beta. A SweepFrame is a kind of
DataFrame, defined in the modsim library. Its purpose is to store results from
a two-dimensional parameter sweep.
Each time through the loop, we run sweep_beta. The result is a SweepSeries
object with one element for each value of gamma. The assignment
stores the values from the SweepSeries object as a new column in the SweepFrame,
corresponding to the current value of gamma.
At the end, the SweepFrame stores the fraction of students infected for each
pair of parameters, beta and gamma.
This is the first example weve seen with one for loop inside another:
Each time the outer loop runs, it selects a value of gamma from the
columns of the DataFrame and extracts the corresponding column as a
Series.
Each time the inner loop runs, it selects a value of beta from the Series
and selects the corresponding element, which is the fraction of student
infected.
In this example, frame has 4 columns, one for each value of gamma, and 11
rows, one for each value of beta. So these loops print 44 lines, one for each
pair of parameters.
Now lets think about possible relationships between beta and gamma:
When beta exceeds gamma, that means there are more contacts (that is,
potential infections) than recoveries. The difference between beta and
gamma might be called the excess contact rate, in units of contacts per
day.
As an alternative, we might consider the ratio beta/gamma, which is the
number of contacts per recovery. Because the numerator and denomina-
tor are in the same units, this ratio is dimensionless, which means it
has no units.
Describing physical systems using dimensionless parameters is often a useful
move in the modeling and simulation game. It is so useful, in fact, that it has
a name: nondimensionalization (see http://modsimpy.com/nondim).
So well try the second option first. In the notebook for this chapter, you can
explore the first option as an exercise.
The following function wraps the previous loops and plots the fraction infected
as a function of the ratio beta/gamma:
def plot_sweep_frame(frame):
for gamma in frame.columns:
series = frame[gamma]
for beta in series.index:
frac_infected = series[beta]
plot(beta/gamma, frac_infected, 'ro')
92 Chapter 6 Analysis
1.0
0.8
Fractioninfected
0.6
0.4
0.2
0.0
0 2 4 6 8
Contactnumber(beta/gamma)
Figure 6.3 shows that the results fall neatly on a single curve, at least approx-
imately. That means that we can predict the fraction of students who will be
infected based on a single parameter, the ratio beta/gamma. We dont need to
know the values of beta and gamma separately.
The results in the previous section suggest that there is a relationship be-
6.5 Contact number 93
tween c and the total number of infections. We can derive this relationship by
analyzing the differential equations from Section 5.3:
ds
= si
dt
di
= si i
dt
dr
= i
dt
In the same way we divided the contact rate by the infection rate to get the
dimensionless quantity c, now well divide di/dt by ds/dt to get a ratio of
rates:
di 1
= 1 +
ds cs
Dividing one differential equation by another is not an obvious move, but in
this case it is useful because it gives us a relationship between i, s and c that
does not depend on time. We can multiply both sides of the equation by ds:
1
di = 1 + ds
cs
1
q =0+1+ log 1
c
Since log 1 = 0, we get q = 1.
Now, at the end of the epidemic, lets assume that i() = 0 and s() is an
94 Chapter 6 Analysis
To get the total infected, we compute the difference between s(0) and s(),
then store the results in a Series:
frac_infected = 1 - s_inf_array
frac_infected_series = Series(frac_infected, index=c_array)
Recall from Section 3.2 that a Series object contains an index and a corre-
sponding sequence of values. In this case, the index is c_array and the values
are from frac_infected.
plot(frac_infected_series)
Figure 6.4 compares the analytic results from this section with the simulation
results from Section 6.4. Over most of the range they are consistent with each
other, with one discrepancy: when the contact number is less than 1, analysis
indicates there should be no infections; but in the simulations a small part of
the population is affected even when c < 1.
6.6 Analysis and simulation 95
1.0
Fractioninfected 0.8
0.6
0.4
0.2
Simulation
Analysis
0.0
0 2 4 6 8
Contactnumber(c)
The reason for the discrepancy is that the simulation divides time into a dis-
crete series of days, whereas the analysis treats time as a continuous quantity.
In other words, the two methods are actually based on different models. So
which model is better?
Probably neither. When the contact number is small, the early progress of
the epidemic depends on details of the scenario. If we are lucky, the original
infected person, patient zero, infects no one and there is no epidemic. If we
are unlucky, patient zero might have a large number of close friends, or might
work in the dining hall (and fail to observe safe food handling procedures).
For contact numbers near or less than 1, we might need a more detailed model.
But for higher contact numbers the SIR model might be good enough.
Figure 6.4 shows that if we know the contact number, we can compute the
fraction infected. But we can also read the figure the other way; that is, at
the end of an epidemic, if we can estimate the fraction of the population that
was ever infected, we can use it to estimate the contact number.
Well, at least in theory. In practice, it might not work very well, because of
the shape of the curve. When the contact number is near 2, the curve is quite
96 Chapter 6 Analysis
steep, which means that small changes in c yield big changes in the number
of infections. If we observe that the total fraction infected is anywhere from
20% to 80%, we would conclude that c is near 2.
On the other hand, for larger contact numbers, nearly the entire population
is infected, so the curve is quite flat. In that case we would not be able to
estimate c precisely, because any value greater than 3 would yield effectively
the same results. Fortunately, this is unlikely to happen in the real world; very
few epidemics affect anything like 90% of the population.
Thermal systems
So far the systems we have studied have been physical, in the sense that they
exist in the world, but they have not been physics, in the sense of what physics
classes are usually about. In this chapter, well do some physics, starting with
thermal systems, that is, systems where the temperature of objects changes
as heat transfers from one to another.
You can view the code for this chapter at http://modsimpy.com/chap07. For
instructions for downloading and running the code, see Section 0.4.
To help answer this question, I made a trial run with the milk and coffee in
separate containers and took some measurements:
The coffee is served in a well insulated cup. When I arrive at work after
30 minutes, the temperature of the coffee has fallen to 70 C.
To use this data and answer the question, we have to know something about
temperature and heat, and we have to make some modeling decisions.
When particles in a hot object contact particles in a cold object, the hot object
gets cooler and the cold object gets warmer, as energy is transferred from one
to the other. The transferred energy is called heat; in SI units it is measured
in joules (J).
how much energy it takes to heat or cool it. In SI units, thermal mass is
measured in joules per degree Celsius (J/C).
For objects made primarily from one material, thermal mass can be computed
like this:
C = mcp
where m is the mass of the object and cp is the specific heat capacity of the
material (see http://modsimpy.com/specheat).
We can use these equations to estimate the thermal mass of a cup of coffee.
The specific heat capacity of coffee is probably close to that of water, which
is 4.2 J/(g C). Assuming that the density of coffee is close to that of water,
which is 1 g/mL, the mass of 300 mL of coffee is 300 g, and the thermal mass
is 1260 J/C.
To give you a sense of how much energy that is, if you were able to harness
all of that heat to do work (which you cannot2 ), you could use it to lift a cup
of coffee from sea level to 8571 m, just shy of the height of Mount Everest,
8848 m.
Assuming that the cup has less mass than the coffee, and is made from a
material with lower a specific heat, we can ignore the thermal mass of the cup.
For objects like coffee in a car, thermal radiation is much smaller than heat
transfer due to conduction and convection, so we will ignore it.
dT
= r(T Tenv )
dt
where T , the temperature of the object, is a function of time, t; Tenv is the
temperature of the environment, and r is a constant that characterizes how
quickly heats is transferred between the system and the environment.
However, for a situation like the coffee cooling problem, we expect Newtons
model to be quite good.
7.5 Implementation
To get started, lets forget about the milk temporarily and focus on the coffee.
Ill create a State object to represent the initial temperature:
init = State(temp=90)
The values of T_env, t0, and t_end come from the statement of the problem.
I chose the value of r arbitrarily for now; we will figure out how to estimate it
soon.
dt is the time step we use to simulate the cooling process. Strictly speaking,
Newtons law is a differential equation, but over a short period of time we can
approximate it with a difference equation:
T = r(T Tenv )t
T = state.temp
T += -r * (T - T_env) * dt
return State(temp=T)
Now if we run
update(init, coffee)
we see that the temperature after one minute is 89.3 C, so the temperature
drops by about 0.7 C/min, at least for this value of r.
frame = TimeFrame(columns=init.index)
frame.loc[t0] = init
ts = linrange(t0, t_end-dt, dt)
for t in ts:
frame.loc[t+dt] = update_func(frame.loc[t], system)
system.results = frame
run_simulation(coffee, update)
The result is a TimeFrame object with one row per time step and just one
column, temp. The temperature after 30 minutes is 72.3 C, which is a little
higher than stated in the problem, 70 C. We could adjust r by hand and find
the right value by trial and error, but we can do better, as well see in the next
section.
system = System(init=init,
volume=volume,
r=r,
T_env=22,
t0=0,
t_end=t_end,
dt=1)
return system
make_system takes the system parameters and packs them into a System ob-
ject. Now we can simulate the coffee like this:
coffee = make_system()
run_simulation(coffee, update)
where root means a value of x that makes f (x) = 0. Because of the way I
wrote the polynomial, we can see that if x = 1, the first factor is 0; if x = 2,
the second factor is 0; and if x = 3, the third factor is 0, so those are the roots.
But usually its not that easy. In that case fsolve can help. First, we have
to write a function that evaluates f :
def func(x):
return (x-1) * (x-2) * (x-3)
fsolve(func, x0=0)
The first argument is the function whose roots we want. The second argument,
x0, is an initial guess about where a root might be. Generally, the closer the
initial guess is to an actual root, the faster fsolve runs. In this case, with the
initial guess x0=0, the result is 1.
Often fsolve finds the root thats closest to the initial guess. In this example,
when x0=1.9, fsolve returns 2, and when x0=2.9, fsolve returns 3. But
this behavior can be unpredictable; with x0=1.5, fsolve returns 3.
def error_func1(r):
system = make_system(r=r)
run_simulation(system, update)
return final_temp(system) - 70
I call a function like this an error function because it returns the difference
between what we got and what we wanted, that is, the error. When we find
the right value of r, this error will be 0.
We can test error_func1 like this, using our initial guess for r:
7.6 Using fsolve 105
90
80
Temperature(C) 70
60
50 coffee
milk
40
30
20
10
0 5 10 15 20 25 30
Time(minutes)
error_func1(r=0.01)
The result is an error of 2.3 C, because the final temperature with this value
of r is too high.
The return value from fsolve is an array with a single element, which is the
root fsolve found. In this example, r_coffee turns out to be about 0.012,
in units of 1/min.
As one of the exercises for this chapter, you will use the same process to
estimate r_milk.
With the correct values of r_coffee and r_milk, the simulation results should
look like Figure 7.1, which shows the temperature of the coffee and milk over
time.
106 Chapter 7 Thermal systems
Assuming there are no chemical reactions between the liquids that either pro-
duce or consume heat, the total thermal energy of the system is the same
before and after mixing; in other words, thermal energy is conserved.
C1 (T T1 ) + C2 (T T2 ) = 0
C1 T1 + C2 T2
T =
C1 + C2
For the coffee cooling problem, we have the volume of each liquid; if we also
know the density, , and the specific heat capacity, cp , we can compute thermal
mass:
C = V cp
If we assume that the density and specific heat of the milk and coffee are equal,
they drop out of the equation, and we can write:
V1 T1 + V2 T2
T =
V1 + V2
where V1 and V2 are the volumes of the liquids. As an exercise, you can look
up the density and specific heat of milk to see how good this approximation
is.
The following function takes two System objects that represent the coffee and
milk, and creates a new System to represent the mixture:
7.8 Mix first or last? 107
mixture = make_system(T_init=temp,
volume=volume,
r=s1.r)
return mixture
The first line is an assert statement, which is a way of checking for errors. It
compares t_end for the two systems to confirm that they have been cooling
for the same time. If not, assert displays an error message and stops the
program.
The next two statements compute the total volume of the mixture and its
temperature. Fianlly, mix makes a new System object and returns it.
This function uses the value of r from s1 as the value of r for the mixture. If s1
represents the coffee, and we are adding the milk to the coffee, this is probably
a reasonable choice. On the other hand, when we increase the amount of liquid
in the coffee cup, that might change r. So this is an assumption to we might
want to revisit when.
Now we have everything we need to solve the problem. First Ill create objects
to represent the coffee and cream, and run for 30 minutes.
108 Chapter 7 Thermal systems
The final temperatures, before mixing, are 70 C and 21.7 C. Then we mix
them:
To find out, Ill create new objects for the coffee and milk:
The final temperature is only 61.6 C. So it looks like adding the milk at the
end is better, by about 1.5 C. But is that the best we can do?
7.9 Optimization
Adding the milk after 30 minutes is better than adding immediately, but maybe
theres something in between thats even better. To find out, Ill use the
following function, which takes t_add as a parameter:
7.9 Optimization 109
return final_temp(mixture)
sweep = SweepSeries()
for t_add in linrange(0, 30, 2):
temp = run_and_mix(t_add)
sweep[t_add] = temp
Figure 7.2 shows the result. Again, note that this is a parameter sweep, not a
time series. The x-axis is the time when we add the milk, not the index of a
TimeSeries.
The final temperature is maximized when t_add=30, so adding the milk at the
end is optimal.
In the notebook for this chapter you will have a chance to explore this solution
and try some variations. For example, suppose the coffee shop wont 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?
110 Chapter 7 Thermal systems
63.0
62.8
Finaltemperature(C)
62.6
62.4
62.2
62.0
61.8
61.6
61.4
0 5 10 15 20 25 30
Timeadded(min)
Figure 7.2: Final temperature as a function of the time the milk is added.
7.10 Analysis
Simulating Newtons law of cooling is almost silly, because we can solve the
differential equation analytically. If
dT
= r(T Tenv )
dt
the general solution is
You can see how I got this solution using SymPy at http://modsimpy.com/
sympy07; if you would like to see it done by hand, you can watch this video:
http://modsimpy.com/khan3.
Now we can use the observed data to estimate the parameter r. If we observe
T (tend ) = Tend , we can plug tend and Tend into the particular solution and solve
7.10 Analysis 111
def run_analysis(system):
unpack(system)
T_init = init.temp
ts = linrange(t0, t_end, dt)
init = State(temp=90)
coffee2 = System(init=init, T_env=22, r=r_coffee2,
t0=0, t_end=30)
run_analysis(coffee2)
The final temperature is 70 C, as it should be. In fact, the results are identical
to what we got by simulation, with very small differences due to round off
errors.
112 Chapter 7 Thermal systems
Chapter 8
Pharmacokinetics
We will use this model to fit data collected from a patient, and use the param-
eters of the fitted model to quantify the patients ability to produce insulin
and process glucose.
You can view the code for this chapter at http://modsimpy.com/chap08. For
instructions for downloading and running the code, see Section 0.4.
But if the pancreas does not produce enough insulin, or if the cells that should
respond to insulin become insensitive, blood sugar can become elevated, a
condition called hyperglycemia. Long term, severe hyperglycemia is the
defining symptom of diabetes mellitus, a serious disease that affects almost
10% of the population in the U.S. (see http://modsimpy.com/cdc).
One of the most-used tests for hyperglycemia and diabetes is the frequently
sampled intravenous glucose tolerance test (FSIGT), in which glucose is in-
jected into the blood stream of a fasting subject (someone who has not eaten
recently), and then blood samples are collected at intervals of 210 minutes for
3 hours. The samples are analyzed to measure the concentrations of glucose
and insulin.
The original model was developed in the 1970s; since then, many variations
and extensions have been proposed. Bergmans comments on the development
of the model provide insight into their process:
The most useful models are the ones that achieve this balance: including
enough realism to capture the essential features of the system without too
much complexity to be practical. In this case the practical limit is the ability
to estimate the parameters of the model using data, and to interpret the
parameters meaningfully.
Bergman discusses the features he and his colleagues thought were essential:
At the time, the remote compartment was a modeling abstraction that might,
or might not, reflect something physical. Later, according to Bergman, it was
shown to be interstitial fluid, that is, the fluid that surrounds tissue cells. In
the history of mathematical modeling, it is common for hypothetical entities,
added to models to achieve particular effects, to be found later to correspond
to physical entities.
dG
= k1 [G(t) Gb ] X(t)G(t)
dt
116 Chapter 8 Pharmacokinetics
dX
= k3 [I(t) Ib ] k2 X(t)
dt
where
k3 [I(t) Ib ] is the rate at which insulin diffuses between blood and tissue
fluid. When I(t) is above basal level, insulin diffuses from the blood into
the tissue fluid. When I(t) is below basal level, insulin diffuses from
tissue to the blood.
8.3 Data
To develop and test the model, Ill use data from Pacini and Bergman2 . The
dataset is in a file in the repository for this book, which we can read into a
DataFrame:
data = pd.read_csv('glucose_insulin.csv', index_col='time')
Figure 8.1 shows glucose and insulin concentrations over 182 min for a subject
with normal insulin production and sensitivity.
8.4 Interpolation
Before we are ready to implement the model, theres one problem we have to
solve. In the differential equations, I is a function that can be evaluated at
any time, t. But in the DataFrame, we only have measurements at discrete
times. This is a job for interpolation!
glucose
300
mg/dL
200
100
0 50 100 150
insulin
100
U/mL
50
0 50 100 150
Time(min)
I = interpolate(data.insulin)
I(18)
The result is 31.66, which is a linear interpolation between the actual mea-
surements at t=16 and t=19. We can also pass an array as an argument to
I:
ts = linrange(0, 182, 2)
I(ts)
8.5 Implementation
To get started, well assume that the parameters of the model are known. Well
implement the model and use it to generate time series for G and X. Then well
see how to find parameter values that generate series that best fit the data.
Taking advantage of estimates from prior work, Ill start with these values:
k1 = 0.03
k2 = 0.02
k3 = 1e-05
G0 = 290
Gb = data.glucose[0]
Ib = data.insulin[0]
system = System(init=init,
k1=k1, k2=k2, k3=k3,
I=I, Gb=Gb, Ib=Ib,
t0=0, t_end=182, dt=2)
G += dGdt * dt
X += dXdt * dt
As usual, the update function takes a State object and a System as parame-
ters, but theres one difference from previous examples: this update function
also takes t. Thats because this system of differential equations is time de-
pendent; that is, time appears in the right-hand side of at least one equation.
The first line of update uses multiple assignment to extract the current values
of G and X. The second line uses unpack so we can read the system variables
without using the dot operator.
Then, to perform the update, we multiply each derivative by the discrete time
step dt, which is 2 min in this example. The return value is a State object
with the new values of G and X.
Before running the simulation, it is always a good idea to run the update
function with the initial conditions:
update_func(init, 0, system)
If there are no errors, and the results seem reasonable, we are ready to run the
simulation. Heres one more version of run_simulation. It is almost the same
as in Section 7.5, with one change: it passes t as an argument to update_func.
8.5 Implementation 121
simulation
300 glucosedata
mg/dL
200
100
0 50 100 150
0.006 remoteinsulin
Arbitraryunits
0.004
0.002
0.000
0 50 100 150
Time(min)
frame = TimeFrame(columns=init.index)
frame.loc[t0] = init
ts = linrange(t0, t_end-dt, dt)
for t in ts:
frame.loc[t+dt] = update_func(frame.loc[t], t, system)
system.results = frame
run_simulation(system, update_func)
The results are shown in Figure 8.2. The top plot shows simulated glucose
levels from the model along with the measured data. The bottom plot shows
122 Chapter 8 Pharmacokinetics
simulated insulin levels in tissue fluid, which is in unspecified units, and not
to be confused with measured concentration of insulin in the blood.
With the parameters I chose, the model fits the data reasonably well. We can
do better, but first, I want to replace run_simulation with a better differential
equation solver.
dG
= k1 [G(t) Gb ] X(t)G(t)
dt
dX
= k3 [I(t) Ib ] k2 X(t)
dt
If we multiply both sides by dt, we have:
This method, evaluating derivatives at discrete time steps and assuming that
they are constant in between, is called Eulers method (see http://modsimpy.
com/euler).
Eulers method is good enough for some simple problems, but there are many
better ways to solve differential equations, including an entire family of meth-
ods called linear multistep methods (see http://modsimpy.com/lmm).
Rather than implement these methods ourselves, we will use functions from
SciPy. The modsim library provides a function called run_odeint, which is a
8.6 Numerical solution of differential equations 123
system2 = System(init=init,
k1=k1, k2=k2, k3=k3,
I=I, Gb=Gb, Ib=Ib,
ts=data.index)
run_odeint(system2, slope_func)
The results are similar to what we saw in Figure 8.2. The difference is about
1% on average and never more than 2%.
1. First well define an error function that takes a set of possible parame-
ters, simulates the system with the given parameters, and computes the
errors, that is, the differences between the simulation results and the
data.
2. Then well use a SciPy function, leastsq, to search for the parameters
that minimize mean squared error (MSE).
Each time the error function runs, it creates a System object with the given
parameters, so lets wrap that operation in a function:
error_func uses make_system to create the System object. This line demon-
strates a feature we have not seen before, the scatter operator, *. Applied
to params, the scatter operator unpacks the sequence, so instead of being
considered a single value, it is treated as four separate values.
126 Chapter 8 Pharmacokinetics
error_func calls run_odeint using the same slope function we saw in Sec-
tion 8.6. Then it computes the difference between the simulation results and
the data. Since system.results.G and data.glucose are both Series ob-
jects, the result of subtraction is also a Series.
k1 = 0.03
k2 = 0.02
k3 = 1e-05
G0 = 290
params = G0, k1, k2, k3
best_params = fit_leastsq(error_func, params, data)
Actually, the third parameter can be any object we like. fit_leastsq and
leastsq dont do anything with this parameter except to pass it along to
error_func, so in general it contains whatever information error_func needs
to do its job.
Figure 8.3 shows the results. The simulation matches the measurements well,
except during the first few minutes after the injection. But we dont expect
the model to do well in this regime.
The reason is that the model is non-spatial; that is, it does not take into
account different concentrations in different parts of the body. Instead, it
8.8 Interpreting parameters 127
350
simulation
glucosedata
Concentration(mg/dL) 300
250
200
150
100
Figure 8.3: Simulation of the glucose minimal model with parameters that
minimize MSE.
assumes that the concentrations of glucose and insulin in blood, and insulin in
tissue fluid, are the same throughout the body. This way of representing the
body is known among experts as the bag of blood model.
Immediately after injection, it takes time for the injected glucose to circulate.
During that time, we dont expect a non-spatial model to be accurate. For this
reason, we should not take the estimated value of G0 too seriously; it is useful
for fitting the model, but not meant to correspond to a physical, measurable
quantity.
On the other hand, the other parameters are meaningful; in fact, they are the
reason the model is useful. Using the best-fit parameters, we can estimate two
quantities of interest:
G
E
G
where G is shorthand for dG/dt. Taking the derivative of dG/dt with respect
to G, we get
E = k1 + X
The glucose effectiveness index, SG , is the value of E in when blood insulin
is near its basal level, Ib . In that case, X approaches 0 and E approaches k1 .
So we can use the best-fit value of k1 as an estimate of SG .
E
S
I
The insulin sensitivity index, SI , is the value of S when E and I are at
steady state:
ESS
SI
ISS
E and I are at steady state when dG/dt and dX/dt are 0, but we dont actually
have to solve those equations to find SI . If we set dX/dt = 0 and solve for X,
we find the relation:
k3
XSS = ISS
k2
And since E = k1 + X, we have:
ESS XSS
SI = =
ISS ISS
Taking the derivative of XSS with respect to ISS , we have:
SI = k3 /k2
So if we find parameters that make the model fit the data, we can use k3 /k2
as an estimate of SI .
For the example data, the estimated values of SG and SI are 0.029 and for
8.9 104 . According to Pacini and Bergman, these values are within the
normal range.
8.9 The insulin minimal model 129
dI
= kI(t) + [G(t) GT ] t
dt
where
The parameters of this model can be used to estimate, 1 and 2 , which are
values that describe the sensitivity to glucose of the first and second phase
pancreatic responsivity. They are related to the parameters as follows:
Imax Ib
1 =
k(G0 Gb )
2 = 104
where Imax is the maximum measured insulin level, and Ib and Gb are the basal
levels of insulin and glucose.
In the notebook for this chapter, you will have a chance to implement this
model, find the parameters that best fit the data, and estimate these values.
130 Chapter 8 Pharmacokinetics
Chapter 9
Projectiles
So far the differential equations weve worked with have been first order,
which means they involve only first derivatives. In this chapter, we turn
our attention to second order ODEs, which can involve both first and sec-
ond derivatives.
Well revisit the falling penny example from Chapter 1, and use odeint to
find the position and velocity of the penny as it falls, with and without air
resistance.
You can view the code for this chapter at http://modsimpy.com/chap09. For
instructions for downloading and running the code, see Section 0.4.
F = ma
Newtons law might not look like a differential equation, until we realize that
acceleration, a, is the second derivative of position, y, with respect to time, t.
With the substitution
d2 y
a= 2
dt
Newtons law can be written
d2 y
= F/m
dt2
And thats definitely a second order ODE. In general, F can be a function of
time, position, and velocity.
It is not a good model for very small things, which are better described
by another model, quantum mechanics.
And it is not a good model for things moving very fast, which are better
described by relativistic mechanics.
But for medium to large things with constant mass, moving at speeds that are
medium to slow, Newtons model is phenomenally useful. If we can quantify
the forces that act on such an object, we can figure out how it will move.
9.2 Dropping pennies 133
Given that the Empire State Building is 381 m high, and assuming that the
penny is dropped with velocity zero, the initial conditions are:
init = State(y=381 * m,
v=0 * m/s)
where y is height above the sidewalk and v is velocity. The units m and s are
from the UNITS object provided by Pint:
m = UNITS.meter
s = UNITS.second
kg = UNITS.kilogram
g = 9.8 * m/s**2
In addition, well specify the sequence of times where we want to solve the
differential equation:
duration = 10 * s
dt = 1 * s
ts = linrange(0, duration, dt)
Now we need a slope function, and heres where things get tricky. As we have
seen, odeint can solve systems of first order ODEs, but Newtons law is a
second order ODE. However, if we recognize that
134 Chapter 9 Projectiles
dy
=v
dt
dv
=a
dt
And we can translate those equations into a slope function:
dydt = v
dvdt = -g
The first parameter, state, contains the position and velocity of the penny.
The last parameter, system, contains the system parameter g, which is the
magnitude of acceleration due to gravity.
The second parameter, t, is time. It is not used in this slope function because
none of the factors of the model are time dependent (see Section 8.5). I include
it anyway because this function will be called by odeint, and odeint always
provides the same arguments, whether they are needed or not.
Before calling run_odeint, it is always a good idea to test the slope function
with the initial conditions:
slope_func(init, 0, system)
9.2 Dropping pennies 135
y
300
200
Position(m)
100
100
0 2 4 6 8 10
Time(s)
Figure 9.1: Height of the penny versus time, with no air resistance.
The result is a sequence containing 0 m/s for velocity and 9.8 m/s2 for accel-
eration. Now we can run odeint like this:
run_odeint(system, slope_func)
plot_position, which is defined in the notebook for this chapter, plots height
versus time:
plot_position(system.results)
Figure 9.1 shows the result. Since acceleration is constant, velocity increases
linearly and position decreases quadratically; as a result, the height curve is a
parabola.
In this model, height can be negative, because we have not included the effect
of the sidewalk!
136 Chapter 9 Projectiles
From the results, we can extract y, which is a Series that represents height
as a function of time.
y = system.results.y
Y = interpolate(y)
T = interp_inverse(y, kind='cubic')
The result is a function that maps from height to time. Now we can evaluate
the interpolating function at y=0
T_sidewalk = T(0)
The result is 8.8179 m, which agrees with the exact answer to 4 decimal places.
One caution about interpolate and interp_inverse: the function you pro-
vide has to be single valued (see http://modsimpy.com/singval).
The direction of this drag force is opposite the direction of travel, and its
magnitude is given by the drag equation (see http://modsimpy.com/drageq):
1
Fd = v 2 Cd A
2
where
v is velocity in m/s.
For objects moving at moderate speeds through air, typical drag coefficients
are between 0.1 and 1.0, with blunt objects at the high end of the range and
streamlined objects at the low end (see http://modsimpy.com/dragco).
For simple geometric objects we can sometimes guess the drag coefficient with
reasonable accuracy; for more complex objects we usually have to take mea-
surements and estimate Cd from the data.
Of course, the drag equation is itself a model, based on the assumption that
Cd does not depend on the other terms in the equation: density, velocity, and
area. For objects moving in air at moderate speeds (below 45 mph or 20 m/s),
138 Chapter 9 Projectiles
this model might be good enough, but we should remember to revisit this
assumption.
9.5 Implementation
def make_system(condition):
unpack(condition)
But for now, you might have to take my word that this is a good idea (see
http://modsimpy.com/zen).
140 Chapter 9 Projectiles
system = make_system(condition)
dydt = v
dvdt = -g + a_drag
f_drag is force due to drag, based on the drag equation. a_drag is acceleration
due to drag, based on Newtons second law.
To compute total acceleration, we add accelerations due to gravity and drag,
where g is negated because it is in the direction of decreasing y, and a_drag is
positive because it is in the direction of increasing y. In the next chapter we
will use Vector objects to keep track of the direction of forces and add them
up in a less error-prone way.
After we test the slope function, we can run the simulation like this:
run_odeint(system, slope_func)
Figure 9.2 shows the result. It only takes a few seconds for the penny to
accelerate up to terminal velocity; after that, velocity is constant, so height as
a function of time is a straight line.
350 y
300
250
Position(m)
200
150
100
50
0
0 5 10 15 20
Time(s)
Figure 9.2: Height of the penny versus time, with air resistance.
suppose that we are given flight time, instead. Can we use that to estimate
Cd and terminal velocity?
Suppose I drop a quarter off the Empire State Building and find that it takes
19.1 s to reach the sidewalk. Heres a Condition object with the parameters of
a quarter (from http://modsimpy.com/quarter) and the measured duration:
def make_system(condition):
unpack(condition)
This version does not expect the Condition object to contain v_term, but it
does expect C_d. We dont know what C_d is, so well start with an initial
guess and go from there:
condition.set(C_d=0.4)
system = make_system(condition)
run_odeint(system, slope_func)
Condition objects provide a function called set that we can use to modify
the condition variables. With C_d=0.4, the height of the quarter after 19.1 s
is 11 m. That means the quarter is moving a bit too fast, which means our
estimate for the drag coefficient is too low. We could improve the estimate by
trial and error, or we could get fsolve to do it for us.
When we get the value of C_d right, the result should be 0, so we can use
fsolve to find it:
9.6 Dropping quarters 143
As we saw in Section 7.6, fsolve takes the error function and the initial guess
as parameters. Any additional parameters we give to fsolve are passed along
to height_func. In this case, we provide condition as an argument to fsolve
so we can use it inside height_func.
The result from fsolve is 0.43, which is very close to the drag coefficient we
computed for the penny, 0.44. Although it is plausible that different coins
would have similar drag coefficients, we should not take this result too seri-
ously. Remember that I chose the terminal velocity of the penny arbitrarily
from the estimated range. And I completely fabricated the flight time for the
quarter.
These methods demonstrate two ways to use models to solve problems, some-
times called forward and inverse problems. In a forward problem, you are
given the parameters of the system and asked to predict how it will behave.
In an inverse problem, you are given the behavior of the system and asked to
infer the parameters. See http://modsimpy.com/inverse.
144 Chapter 9 Projectiles
Chapter 10
Two dimensions
You can view the code for this chapter at http://modsimpy.com/chap10. For
instructions for downloading and running the code, see Section 0.4.
What is the minimum effort required to hit a home run in Fenway Park?
We want to find the minimum velocity at which a ball can leave home plate
and still go over the Green Monster. Well proceed in the following steps:
146 Chapter 10 Two dimensions
2. For a given velocity, well find the optimal launch angle, that is, the
angle the ball should leave home plate to maximize its height when it
reaches the wall.
3. Then well find the minimal velocity that clears the wall, given that it
has the optimal launch angle.
As usual, well have to make some modeling decisions. To get started, well
ignore any spin that might be on the ball, and the resulting Magnus force (see
http://modsimpy.com/magnus).
As a result, well assume that the ball travels in the plane above the left field
line, so well run simulations in two dimensions, rather than three.
But we will take into account air resistance. Based on what we learned about
falling coins, it seems likely that air resistance has a substantial effect on the
flight of a baseball.
To model air resistance, well need the mass, frontal area, and drag coefficient
of a baseball. Mass and diameter are easy to find (see http://modsimpy.com/
baseball). Drag coefficient is only a little harder; according to a one-pager
from NASA1 , the drag coefficient of a baseball is approximately 0.3.
To get started, Ill assume that the drag coefficient does not depend on velocity,
but this is an issue we might want to revisit.
10.2 Vectors
Now that we are working in two dimensions, we will find it useful to work with
vector quantities, that is, quantities that represent both a magnitude and a
1
Drag on a Baseball, available from http://modsimpy.com/nasa
2
Adair, The Physics of Baseball, Third Edition, Perennial, 2002
10.2 Vectors 147
The modsim library provides a Vector object that represents a vector quantity.
A Vector object is a like a NumPy array; it contains elements that represent
the components of the vector. For example, in a Vector that represents
a position in space, the components are the x and y coordinates (and a z
coordinate in 3-D). A Vector object also has units, like the quantities weve
seen in previous chapters.
You can create a Vector by specifying its components. The following Vector
represents a point 3 m to the right (or east) and 4 m up (or north) from an
implicit origin:
A = Vector(3, 4) * m
You can access the components of a Vector by name, using the dot operator;
for example, A.x or A.y. You can also access them by index, using brackets,
like A[0] or A[1].
Similarly, you can get the magnitude and angle using the dot operator, A.mag
and A.angle. Magnitude is the length of the vector: if the Vector rep-
resents position, magnitude is the distance from the origin; if it represents
velocity, magnitude is speed, that is, how fast the object is moving, regardless
of direction.
The angle of a Vector is its direction, expressed as the angle in radians from
the positive x-axis. In the Cartesian plane, the angle 0 rad is due east, and
the angle rad is due west.
B = Vector(1, 2) * m
A + B
A - B
For the definition and graphical interpretation of these operations, see http:
//modsimpy.com/vecops.
148 Chapter 10 Two dimensions
When you add and subtract Vector objects, the modsim library uses NumPy
and Pint to check that the operands have the same number of dimensions and
units. The notebook for this chapter shows examples for working with Vector
objects.
As an example, Ill get the degree unit from UNITS, and create a quantity
that represents 45 degrees:
degree = UNITS.degree
angle = 45 * degree
radian = UNITS.radian
rads = angle.to(radian)
If you are given an angle and velocity, you can make a Vector by converting to
Cartesian coordinates using pol2cart. To demonstrate, Ill extract the angle
and magnitude of A:
mag = A.mag
angle = A.angle
x, y = pol2cart(angle, mag)
Vector(x, y)
10.3 Modeling baseball flight 149
condition = Condition(x = 0 * m,
y = 1 * m,
g = 9.8 * m/s**2,
mass = 145e-3 * kg,
diameter = 73e-3 * m,
rho = 1.2 * kg/m**3,
C_d = 0.3,
angle = 45 * degree,
velocity = 40 * m / s,
duration = 5.1 * s)
Using the center of home plate as the origin, the initial height is about 1 m. The
initial velocity is 40 m/s at a launch angle of 45. The mass, diameter, and drag
coefficient of the baseball are from the sources in Section 10.1. The acceleration
of gravity, g, is a well-known quantity, and the density of air, rho, is based on
a temperature of 20 C at sea level (see http://modsimpy.com/tempress). I
chose the value of duration to run the simulation long enough for the ball to
land on the ground.
The following function uses the Condition object to make a System object.
Again, this two-step process reduces the number of variables in the System
object, and makes it easier to work with functions like fsolve.
def make_system(condition):
unpack(condition)
theta = np.deg2rad(angle)
vx, vy = pol2cart(theta, velocity)
init = State(x=x, y=y, vx=vx, vy=vy)
area = np.pi * (diameter/2)**2
ts = linspace(0, duration, 101)
v = Vector(vx, vy)
f_drag = -rho * v.mag * v * C_d * area / 2
a_drag = f_drag / mass
a = a_grav + a_drag
As usual, the parameters of the slope function are a State object, time, and
a System object. In this example, we dont use t, but we cant leave it out
because when odeint calls the slope function, it always provides the same
arguments, whether they are needed or not.
The State object has four variables: x and y are the components of position;
vx and vy are the components of velocity.
The return values from the slope function are the derivatives of these compo-
nents. The derivative of position is velocity, so the first two return values are
just vx and vy, the values we extracted from the State object. The derivative
of velocity is acceleration, and thats what we have to compute.
The total acceleration of the baseball is the sum of accelerations due to gravity
and drag. These quantities have both magnitude and direction, so well use
Vector objects to represent them. Heres how it works:
10.3 Modeling baseball flight 151
a is total acceleration due to all forces, from which we can extract the
components a.x and a.y.
The last thing I have to explain is the vector form of the drag equation. In
Section 9.4 we saw the scalar form (a scalar is a single value, as contrasted
with a vector, which contains components):
1
Fd = v 2 Cd A
2
This form was sufficient when v represented the magnitude of velocity and all
we wanted was the magnitude of drag force. But now v is a Vector and we
want both the magnitude and angle of drag.
We can do that by replacing v 2 , in the equation, with a vector that has the
opposite direction as v and magnitude v.mag**2. In math notation, we can
write
1
F~d = |v| ~v Cd A
2
Where |v| indicates the magnitude of v and the arrows on F~d and ~v indicate
that they are vectors. Negation reverses the direction of a vector, so F~d is in
the opposite direction of ~v .
Using vectors to represent forces and accelerations makes the code concise,
readable, and less error-prone. In particular, when we add a_grav and a_drag,
the directions are likely to be correct, because they are encoded in the Vector
objects. And the units are certain to be correct, because otherwise Pint would
report an error.
As always, we can test the slope function by running it with the initial condi-
tions:
slope_func(system.init, 0, system)
10.4 Trajectories
Now were ready to run the simulation:
run_odeint(system, slope_func)
The result is a TimeFrame object with one column for each of the state vari-
ables, x, y, vx, and vy. We can extract the x and y components like this:
xs = system.results.x
ys = system.results.y
plot(xs, label='x')
plot(ys, label='y')
Figure 10.1 shows the result. As expected, the x component increases mono-
tonically, with decreasing velocity. And the y position climbs initially and
then descends, falling slightly below 0 m after 5.1 s.
Another way to view the same data is to plot the x component on the x-axis
and the y component on the y-axis, so the plotted line follows the trajectory
of the ball through the plane:
10.4 Trajectories 153
100 x
y
Position(m) 80
60
40
20
0
0 1 2 3 4 5
Time(s)
Figure 10.2 shows this way of visualizing the results, which is called a trajec-
tory plot (see http://modsimpy.com/trajec).
A trajectory plot can be easier to interpret than a time series plot, because
it shows what the motion of the projectile would look like (at least from one
point of view). Both plots can be useful, but dont get them mixed up! If you
are looking at a time series plot and interpreting it as a trajectory, you will be
very confused.
Another useful way to visualize the results is animation. You can create an-
imations using the plot function from the modsim library, with the update
argument, which tells plot that the coordinates you provide should update
the old coordinates. Heres an example:
30 trajectory
25
20
yposition(m)
15
10
0
0 20 40 60 80 100
xposition(m)
zip takes two sequences (in this case they are Series objects) and zips
them together; that is, it loops through both of them at the same time,
selecting corresponding elements from each. So each time through the
loop, x and y get the next elements from xs and ys.
You can see what the results look like in the notebook for this chapter.
1. Find the time when the height of the ball is maximized and select the
part of the trajectory where the ball is falling. We have to do this in
order to avoid problems with interpolation (see Section 9.3).
10.5 Finding the range 155
2. Then we use interpolate to find the time when the ball hits the ground,
t_land.
def interpolate_range(results):
xs = results.x
ys = results.y
t_peak = ys.idxmax()
descent = ys.loc[t_peak:]
T = interp_inverse(descent)
t_land = T(0)
X = interpolate(xs, kind='cubic')
return X(t_land)
interpolate_range uses idxmax to find the time when the ball hits its peak
(see Section 5.9).
Then it uses ys.loc to extract only the points from t_peak to the end. The
index in brackets includes a colon (:), which indicates that it is a slice index.
In general, a slice selects a range of elements from a Series, specifying the
start and end indices. For example, the following slice selects elements from
t_peak to t_land, including both:
ys.loc[t_peak:t_land]
ys.loc[:t_land]
we get everything from the beginning to t_land. If we omit the second index,
as in interpolate_range, we get everything from t_peak to the end of the
Series. For more about indexing with loc, see http://modsimpy.com/loc.
156 Chapter 10 Two dimensions
interpolate_range(system.results)
10.6 Optimization
To find the angle that optimizes range, we need a function that takes launch
angle as a parameter and returns range:
range_func(45, condition)
110.0
107.5
105.0
102.5
Range(m)
100.0
97.5
95.0
92.5
30 35 40 45 50 55 60
Launchangle(degree)
Figure 10.3: Distance from home plate as a function of launch angle, with
fixed velocity.
Figure 10.3 shows the results. It looks like the optimal angle is between 40
and 45 (at least for this definition of optimal).
We can find the optimal angle more precisely and more efficiently using max_bounded,
which is a function in the modsim library that uses scipy.opimize.minimize_scalar
(see http://modsimpy.com/minimize).
The first parameter is the function we want to maximize. The second is the
range of values we want to search; in this case its the range of angles from 0
to 90. The third argument is the Condition object, which gets passed along
as an argument when max_bounded calls range_func.
In the previous section the optimal launch angle is the one that max-
imizes range, but thats not what we want. Rather, we want the angle
that maximizes the height of the ball when it gets to the wall (310 feet
from home plate). So youll have to write a height function to compute
it, and then use max_bounded to find the revised optimum.
Once you can find the optimal angle for any velocity, you have to find the
minimum velocity that gets the ball over the wall. Youll write a function
that takes a velocity as a parameter, computes the optimal angle for that
velocity, and returns the height of the ball, at the wall, using the optimal
angle.
Finally, youll use fsolve to find the velocity that makes the height, at
the wall, just barely 37 feet.
The notebook provides some additional hints, but at this point you should
have everything you need. Good luck!
If you enjoy this exercise, you might be interested in this paper: How to
hit home runs: Optimum baseball bat swing parameters for maximum range
trajectories, by Sawicki, Hubbard, and Stronge, at http://aapt.scitation.
org/doi/abs/10.1119/1.1604384.
Chapter 11
Rotation
And when you apply a twisting force to a rotating object, the effect is often
contrary to intuition. For an example, see this video on gyroscopic precession
http://modsimpy.com/precess.
In this chapter, we will not take on the physics of rotation in all its glory.
Rather, we will focus on simple scenarios where all rotation and all twisting
forces are around a single axis. In that case, we can treat some vector quantities
as if they were scalars (in the same way that we sometimes treat velocity as a
scalar with an implicit direction).
The fundamental ideas in this chapter are angular velocity, angular accel-
eration, torque, and moment of inertia. If you are not already familiar
160 Chapter 11 Rotation
d r d
r
Figure 11.1: Diagram of a roll of toilet paper, showing change in paper length
as a result of a small rotation, d.
with these concepts, I will define them as we go along, and I will point to
additional reading.
As an exercise at the end of the chapter, you will use these tools to simulate
the behavior of a yo-yo (see http://modsimpy.com/yoyo). But well work our
way up to it gradually, starting with toilet paper.
You can view the code for this chapter at http://modsimpy.com/chap11. For
instructions for downloading and running the code, see Section 0.4.
Figure 11.1 shows a diagram of the system: r represents the radius of the roll
at a point in time. Initially, r is the radius of the cardboard core, Rmin . When
the roll is complete, r is Rmax .
11.2 Implementation 161
Ill use to represent the total rotation of the roll in radians. In the diagram,
d represents a small increase in , which corresponds to a distance along the
circumference of the roll of r d.
Finally, Ill use y to represent the total length of paper thats been rolled.
Initially, = 0 and y = 0. For each small increase in , there is a corresponding
increase in y:
dy = r d
If we divide both sides by a small increase in time, dt, we get a differential
equation for y as a function of time.
dy d
=r
dt dt
As we roll up the paper, r increases, too. Assuming that r increases by a fixed
amount per revolution, we can write
dr = k d
Where k is an unknown constant well have to figure out. Again, we can divide
both sides by dt to get a differential equation in time:
dr d
=k
dt dt
Finally, lets assume that increases at a constant rate of 10 rad/s (about 95
revolutions per minute):
d
= 10
dt
This rate of change is called an angular velocity. Now we have a system of
three differential equations we can use to simulate the system.
11.2 Implementation
At this point we have a pretty standard process for writing simulations like
this. First, well get the units we need from Pint:
162 Chapter 11 Rotation
radian = UNITS.radian
m = UNITS.meter
s = UNITS.second
Rmin and Rmax are the initial and final values for the radius, r. L is the total
length of the paper, and duration is the length of the simulation in time.
def make_system(condition):
unpack(condition)
k = estimate_k(condition)
ts = linspace(0, duration, 101)
To get started, well estimate a reasonable value for k; then in Section 11.3
well figure it out exactly. Heres how we compute the estimate:
11.2 Implementation 163
def estimate_k(condition):
unpack(condition)
Ravg is the average radius, half way between Rmin and Rmax, so Cavg is the
circumference of the roll when r is Ravg.
Finally, k is the change in r for each radian of revolution. For these parameters,
k is about 2.8e-5 m/rad.
Now we can use the differential equations from Section 11.1 to write a slope
function:
omega = 10 * radian / s
dydt = r * omega
drdt = k * omega
As usual, the slope function takes a State object, a time, and a System object.
The State object contains the hypothetical values of theta, y, and r at time
t. The job of the slope function is to compute the time derivatives of these
values. The time derivative of theta is angular velocity, which is often denoted
omega.
theta
1000
Angle(rad)
500
0
0 20 40 60 80 100 120
y
40
Length(m)
20
0
0 20 40 60 80 100 120
r
50
Radius(mm)
40
30
20
0 20 40 60 80 100 120
Time(s)
Figure 11.2: Results from paper rolling simulation, showing rotation, length,
and radius over time.
run_odeint(system, slope_func)
Figure 11.2 shows the results. theta grows linearly over time, as we should
expect. As a result, r also grows linearly. But since the derivative of y depends
on r, and r is increasing, y grows with increasing slope.
In order to get the simulation working, we have to get the units right, which can
help catch conceptual errors early. And by plugging in realistic parameters,
we can detect errors that cause unrealistic results. For example, in this system
we can check:
The total time for the simulation is about 2 minutes, which seems plau-
sible for the time it would take to roll 47 m of paper.
11.3 Analysis 165
The final value of theta is about 1250 rad, which corresponds to about
200 revolutions, which also seems plausible.
The initial and final values for r are consistent with Rmin and Rmax, as
we intended when we chose k.
But now that we have a working simulation, it is also useful to do some analysis.
11.3 Analysis
The differential equations in Section 11.1 are simple enough that we can just
solve them. Since angular velocity is constant:
d
=
dt
We can find as a function of time by integrating both sides:
(t) = t + C1
dr
= k (11.1)
dt
So
r(t) = kt + C2
With the initial condition r(0) = Rmin , we find C2 = Rmin . Then we can plug
the solution for r into the equation for y:
dy
= r (11.2)
dt
= [kt + Rmin ]
We can also use these equations to find the relationship between y and r,
independent of time, which we can use to compute k. Using a move we saw in
Section 6.5, Ill divide Equations 11.1 and 11.2, yielding
dr k
=
dy r
r2 /2 = ky + C
When y = 0, r = Rmin , so
1 2
C = Rmin
2
Solving for y, we have
1 2 2
y= (r Rmin ) (11.3)
2k
When y = L, r = Rmax ; substituting in those values yields
1
L= (R2 Rmin
2
)
2k max
Solving for k yields
1 2 2
k= (Rmax Rmin ) (11.4)
2L
Plugging in the values of the parameters yields 2.8e-5 m/rad, the same as
the estimate we computed in Section 11.2. In this case the estimate turns
out to be exact.
11.4 Torque
Now that weve rolled up the paper, lets unroll it. Specifically, lets simulate
a kitten unrolling toilet paper. As reference material, see this video: http:
//modsimpy.com/kitten.
The interactions of the kitten and the paper roll are complex. To keep things
simple, lets assume that the kitten pulls down on the free end of the roll with
11.5 Moment of inertia 167
constant force. Also, we will neglect the friction between the roll and the axle.
This will not be a particularly realistic model, but it will allow us to explore
two new concepts, angular acceleration and torque.
However, for the problems in this chapter, we only need the magnitude of
torque; we dont care about the direction. In that case, we can compute
= rF sin
Figure 11.3 shows the paper roll with r, F , and . As a vector quantity, the
direction of is into the page, but we only care about its magnitude for now.
r
F
Figure 11.3: Diagram of a roll of toilet paper, showing a force, lever arm, and
the resulting torque.
In the most general case, a 3-D object rotating around an arbitrary axis,
moment of inertia is a tensor, which is a function that takes a vector as a
parameter and returns a vector as a result.
Fortunately, in a system where all rotation and torque happens around a single
axis, we dont have to deal with the most general case. We can treat moment
of inertia as a scalar quantity.
For a small point with mass m, rotating around a point at distance r, the
moment of inertia is I = mr2 , in SI units kg m2 . For more complex objects, we
can compute I by dividing the object into small masses, computing moments
of inertia for each mass, and adding them up.
However, for most simple shapes, people have already done the calculations;
you can just look up the answers.
1
That might sound like a dumb way to describe mass, but its actually one of the funda-
mental definitions.
11.6 Unrolling 169
11.6 Unrolling
Lets work on simulating the kitten unrolling the toilet paper. Heres the
Condition object with the parameters well need:
As before, Rmin is the minimum radius and Rmax is the maximum. L is the
length of the paper. Mcore is the mass of the cardboard tube at the center of
the roll; Mroll is the mass of the paper. tension is the force applied by the
kitten, in N. I chose a value that yields plausible results.
The moment of inertia for a thin shell is just mr2 , where m is the mass and r
is the radius of the shell.
Since the outer diameter changes as the kitten unrolls the paper, we have to
compute the moment of inertia, at each point in time, as a function of the
current radius, r. Heres the function that does it:
170 Chapter 11 Rotation
rho_h is the product of density and height, h, which is the mass per area.
rho_h is computed in make_system:
def make_system(condition):
unpack(condition)
make_system also computes k, using Equation 11.4. Now we can write the
slope function:
11.6 Unrolling 171
r = sqrt(2*k*y + Rmin**2)
I = moment_of_inertia(r, system)
tau = r * tension
alpha = tau / I
dydt = -r * omega
This slope function is similar to the previous one (Section 11.2), with a few
changes:
slope_func returns omega, alpha, and dydt, which are the derivatives of
theta, omega, and y, respectively.
Now were ready to run the simulation. Figure 11.4 shows the results. Again,
we can check the results to see if they seem plausible:
2
Actually, this is more of a problem than I have made it seem. In the same way that
F = ma only applies when m is constant, = I only applies when I is constant. When
I varies, we usually have to use a more general version of Newtons law. However, in this
example, mass and moment of inertia vary together in a way that makes the simple approach
work out.
172 Chapter 11 Rotation
theta
400
Angle(rad)
200
0
0 25 50 75 100 125 150 175
Angularvelocity(rad/s)
omega
6
0
0 25 50 75 100 125 150 175
y
40
Length(m)
30
Figure 11.4: Simulation results showing rotation, angular velocity, and length
over time.
The final value of omega is 7.4 rad/s, which is close to one revolution per
second, so that seems plausible.
The final value of y is 21 m of paper left on the roll, which means the
kitten pulled off 26 m in two minutes. That doesnt seem impossible,
although it is based on a level of consistency and focus that is unlikely
in a kitten.
This model yields results that seem reasonable, but remember that I chose the
force applied by the kitten arbitrarily, and the model ignores friction between
11.7 Simulating a yo-yo 173
mg
Figure 11.5: Diagram of a yo-yo showing forces due to gravity and tension in
the string, the lever arm of tension, and the resulting torque.
the paper roll and the axle. This example is not meant to be particularly
serious, but it is good preparation for the next problem, which is a little more
interesting.
Figure 11.5 is a diagram of the forces on the yo-yo and the resulting torque.
The outer shaded area shows the body of the yo-yo. The inner shaded area
shows the rolled up string, the radius of which changes as the yo-yo unrolls.
In this model, we cant figure out the linear and angular acceleration indepen-
dently; we have to solve a system of equations:
X
F = ma
X
= I
174 Chapter 11 Rotation
where the summations indicate that we are adding up forces and torques.
As in the previous examples, linear and angular velocity are related because
of the way the string unrolls:
dy d
= r
dt dt
In this example, the linear and angular accelerations have opposite sign. As
the yo-yo rotates counter-clockwise, increases and y, which is the length of
the rolled part of the string, decreases.
Taking the derivative of both sides yields a similar relationship between linear
and angular acceleration:
d2 y d2
= r
dt2 dt2
Which we can write more concisely:
a = r
Because of the way weve set up the problem, y actually has two meanings: it
represents the length of the rolled string and the height of the yo-yo, which
decreases as the yo-yo falls. Similarly, a represents acceleration in the length
of the rolled string and the height of the yo-yo.
We can compute the acceleration of the yo-yo by adding up the linear forces:
X
F = T mg = ma
Where T is positive because the tension force points up, and mg is negative
because gravity points down.
Because gravity acts on the center of mass, it creates no torque, so the only
torque is due to tension: X
= T r = I
Positive (upward) tension yields positive (counter-clockwise) angular acceler-
ation.
11.7 Simulating a yo-yo 175
T = mgI/I
a = mgr2 /I
= mgr/I
To simulate the system, we dont really need T ; we can plug a and directly
into the slope function.
At this point you have everything you need to simulate the descent of a yo-
yo. In the notebook for this chapter you will have a chance to finish off the
exercise.
176 Chapter 11 Rotation
Index
101, 119, 123, 125, 133, 139, update function, 41, 70, 119
149, 162 update operator, 6
system of equations, 134, 173
system variable, 5, 75 vaccine, 76
validation, xii
temperature, 98 internal, xiii
temporary variable, 22 value, 3
tensor, 168 values, 33
terminal velocity, 138, 140 variable, 3
testable change, 30 loop, 28
thermal mass, 99 temporary, 22
thermal system, 97 variable mass, 132
time dependent, 120, 134 variables
time series, 36 local, 22
time step, 12, 57, 59, 7072, 101 vector, 167
103, 120, 122, 171 Vector object, 147
TimeFrame object, 72, 74, 103, 124, vector operation, 147
135, 152 velocity, 132, 137, 145, 151
TimeSeries object, 36, 39, 72 volume, 106
toilet paper, 160
Walker, Jearl, 97
top down, xiv
Wellesley College, 4
torque, 167, 173
what if scenario, 29
trajectory plot, 153
Wikipedia, 31
True, 11
WolframAlpha, 60
unit, 4, 133, 147 world population, 31
United Nations, 34 yo-yo, 173
United States Census Bureau, 34
unpack, 86, 102, 111, 120, 123, 134, Zen of Python, 139
139141, 150, 162, 169 zip, 154