CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
AllenDowney

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: AllenDowney/ModSimPy
Path: blob/master/book/book.tex
Views: 531
1
% LaTeX source for ``Modeling and Simulation in Python''
2
% Copyright 2017 Allen B. Downey.
3
4
% License: Creative Commons Attribution-NonCommercial 4.0 Unported License.
5
% https://creativecommons.org/licenses/by-nc/4.0/
6
%
7
8
\documentclass[12pt]{book}
9
10
\title{Modeling and Simulation in Python}
11
\author{Allen B. Downey}
12
13
\newcommand{\thetitle}{Modeling and Simulation in Python}
14
\newcommand{\thesubtitle}{}
15
\newcommand{\theauthors}{Allen B. Downey}
16
\newcommand{\theversion}{3.4.3}
17
18
19
%%%% Both LATEX and PLASTEX
20
21
\usepackage{graphicx}
22
\usepackage{hevea}
23
\usepackage{makeidx}
24
\usepackage{setspace}
25
\usepackage{upquote}
26
\usepackage{xcolor}
27
\usepackage[listings]{tcolorbox}
28
29
30
% to get siunitx
31
% sudo apt-get install texlive-science
32
\usepackage{siunitx}
33
\sisetup{per-mode=symbol}
34
35
\definecolor{light-gray}{gray}{0.95}
36
37
\newtcblisting{python}{
38
skin=standard,
39
boxrule=0.4pt,
40
%colback=light-gray,
41
listing only,
42
top=0pt,
43
bottom=0pt,
44
left=0pt,
45
right=0pt,
46
boxsep=2pt,
47
listing options={
48
basicstyle=\ttfamily,
49
language=python,
50
showstringspaces=false,
51
},
52
}
53
54
\newtcblisting{result}{
55
skin=standard,
56
boxrule=0.0pt,
57
colback=white,
58
listing only,
59
top=0pt,
60
bottom=0pt,
61
left=0pt,
62
right=0pt,
63
boxsep=2pt,
64
listing options={
65
basicstyle=\ttfamily,
66
language=python,
67
showstringspaces=false,
68
},
69
}
70
71
\makeindex
72
73
% automatically index glossary terms
74
\newcommand{\term}[1]{%
75
\item[#1:]\index{#1}}
76
77
\usepackage{amsmath}
78
\usepackage{amsthm}
79
80
% format end of chapter excercises
81
\newtheoremstyle{exercise}
82
{12pt} % space above
83
{12pt} % space below
84
{} % body font
85
{} % indent amount
86
{\bfseries} % head font
87
{} % punctuation
88
{12pt} % head space
89
{} % custom head
90
\theoremstyle{exercise}
91
\newtheorem{exercise}{Exercise}[chapter]
92
93
\usepackage{afterpage}
94
95
\newcommand\blankpage{%
96
\null
97
\thispagestyle{empty}%
98
\addtocounter{page}{-1}%
99
\newpage}
100
101
\newif\ifplastex
102
\plastexfalse
103
104
%%%% PLASTEX ONLY
105
\ifplastex
106
107
\usepackage{localdef}
108
109
\usepackage{url}
110
111
\newcount\anchorcnt
112
\newcommand*{\Anchor}[1]{%
113
\@bsphack%
114
\Hy@GlobalStepCount\anchorcnt%
115
\edef\@currentHref{anchor.\the\anchorcnt}%
116
\Hy@raisedlink{\hyper@anchorstart{\@currentHref}\hyper@anchorend}%
117
\M@gettitle{}\label{#1}%
118
\@esphack%
119
}
120
121
% code listing environments:
122
% we don't need these for plastex because they get replaced
123
% by preprocess.py
124
%\newenvironment{code}{\begin{code}}{\end{code}}
125
%\newenvironment{stdout}{\begin{code}}{\end{code}}
126
127
% inline syntax formatting
128
\newcommand{\py}{\verb}%}
129
130
%%%% LATEX ONLY
131
\else
132
133
\input{latexonly}
134
135
\fi
136
137
%%%% END OF PREAMBLE
138
\begin{document}
139
140
\frontmatter
141
142
%%%% PLASTEX ONLY
143
\ifplastex
144
145
\maketitle
146
147
%%%% LATEX ONLY
148
\else
149
150
\begin{latexonly}
151
152
%-half title--------------------------------------------------
153
%\thispagestyle{empty}
154
%
155
%\begin{flushright}
156
%\vspace*{2.0in}
157
%
158
%\begin{spacing}{3}
159
%{\huge \thetitle}
160
%\end{spacing}
161
%
162
%\vspace{0.25in}
163
%
164
%Version \theversion
165
%
166
%\vfill
167
%
168
%\end{flushright}
169
170
%--verso------------------------------------------------------
171
172
%\afterpage{\blankpage}
173
174
%\newpage
175
%\newpage
176
%\clearemptydoublepage
177
%\pagebreak
178
%\thispagestyle{empty}
179
%\vspace*{6in}
180
181
%--title page--------------------------------------------------
182
\pagebreak
183
\thispagestyle{empty}
184
185
\begin{flushright}
186
\vspace*{2.0in}
187
188
\begin{spacing}{3}
189
{\huge \thetitle}
190
\end{spacing}
191
192
\vspace{0.25in}
193
194
Version \theversion
195
196
\vspace{1in}
197
198
199
{\Large
200
\theauthors \\
201
}
202
203
204
\vspace{0.5in}
205
206
{\Large Green Tea Press}
207
208
{\small Needham, Massachusetts}
209
210
%\includegraphics[width=1in]{figs/logo1.eps}
211
\vfill
212
213
\end{flushright}
214
215
216
217
%--copyright--------------------------------------------------
218
\pagebreak
219
\thispagestyle{empty}
220
221
Copyright \copyright ~2017 \theauthors.
222
223
224
225
\vspace{0.2in}
226
227
\begin{flushleft}
228
Green Tea Press \\
229
9 Washburn Ave \\
230
Needham MA 02492
231
\end{flushleft}
232
233
Permission is granted to copy, distribute, transmit and adapt this work under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License: \url{http://modsimpy.com/license}.
234
235
% TODO: get the shortened URLs working with https
236
237
If you are interested in distributing a commercial version of this
238
work, please contact the author.
239
240
The \LaTeX\ source and code for this book is available from
241
242
\begin{code}
243
https://github.com/AllenDowney/ModSimPy
244
\end{code}
245
246
247
Cover design by Tim Sauder.
248
249
%--table of contents------------------------------------------
250
251
\cleardoublepage
252
\setcounter{tocdepth}{1}
253
\tableofcontents
254
255
\end{latexonly}
256
257
258
% HTML title page------------------------------------------
259
260
\begin{htmlonly}
261
262
\vspace{1em}
263
264
{\Large \thetitle}
265
266
{\large \theauthors}
267
268
Version \theversion
269
270
\vspace{1em}
271
272
Copyright \copyright ~2017 \theauthors.
273
274
Permission is granted to copy, distribute, and/or modify this work
275
under the terms of the Creative Commons
276
Attribution-NonCommercial-ShareAlike 4.0 International License, which is
277
available at \url{http://modsimpy.com/license}.
278
279
\vspace{1em}
280
281
\setcounter{chapter}{-1}
282
283
\end{htmlonly}
284
285
% END OF THE PART WE SKIP FOR PLASTEX
286
\fi
287
288
\chapter{Preface}
289
\label{preface}
290
291
292
\section{Can modeling be taught?}
293
294
The essential skills of modeling --- abstraction, analysis, simulation, and validation --- are central in engineering, natural sciences, social sciences, medicine, and many other fields. Some students learn these skills implicitly, but in most schools they are not taught explicitly, and students get little practice. That's the problem this book is meant to address.
295
296
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.
297
298
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.
299
300
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.
301
302
And they will only work with bad models; that is, models that are too simple for their intended 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 won't work.
303
304
In most freshman physics classes, students don't make modeling decisions; sometimes they are not even aware of the decisions that have been made for them. Our goal is to teach the entire modeling process and give students a chance to practice it.
305
306
307
\section{How much programming do I need?}
308
309
If you have never programmed before, you should be able to read this book, understand it, and do the exercises. I will do my best to explain everything you need to know; in particular, I have chosen carefully the vocabulary I introduce, and I try to define each term the first time it is used. If you find that I have used a term without defining it, let me know.
310
311
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 programming you have probably not seen before.
312
313
Most programming classes\footnote{Including many I have taught.} have two big problems:
314
315
\begin{enumerate}
316
317
\item They go ``bottom up", starting with basic language features and gradually adding more powerful tools. As a result, it takes a long time before students can do anything more interesting than convert Fahrenheit to Celsius.
318
319
\index{bottom up}
320
321
\item They have no context. Students learn to program with no particular goal in mind, so the exercises span an incoherent collection of topics, and the exercises tend to be unmotivated.
322
323
\end{enumerate}
324
325
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 {\bf libraries}:
326
327
\index{top down}
328
329
\begin{itemize}
330
331
\item NumPy for basic numerical computation (see \url{https://www.numpy.org/}).
332
333
\index{NumPy}
334
335
\item SciPy for scientific computation (see \url{https://www.scipy.org/}).
336
337
\index{SciPy}
338
339
\item Matplotlib for visualization (see \url{https://matplotlib.org/}).
340
341
\index{Matplotlib}
342
343
\item Pandas for working with data (see \url{https://pandas.pydata.org/}).
344
345
\index{Pandas}
346
347
\item SymPy for symbolic computation, (see \url{https://www.sympy.org}).
348
349
\index{SymPy}
350
351
\item Pint for units like kilograms and meters (see \url{https://pint.readthedocs.io}).
352
353
\index{Pint}
354
355
\item Jupyter for reading, running, and developing code (see \url{https://jupyter.org}).
356
357
\index{Jupyter}
358
359
\end{itemize}
360
361
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.
362
363
I have tried to mitigate these problems by providing a library, called \py{modsim}, that makes it easier to get started with these tools, and provides some additional capabilities.
364
365
\index{ModSim library}
366
367
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, and later.
368
369
I encourage you to read the ModSim library code. Most of it is not complicated, and I tried to make it readable. Particularly if you have some programming experience, you might learn something by reverse engineering my design decisions.
370
371
372
\section{How much math and science do I need?}
373
374
I assume that you know what derivatives and integrals are, but that's about all. In particular, you don't need to know (or remember) much about finding derivatives or integrals of functions analytically. If you know the derivative of $x^2$ and you can integrate $2x~dx$, that will do it\footnote{And if you noticed that those two questions answer each other, even better.}. More importantly, you should understand what those concepts {\em mean}; but if you don't, this book might help you figure it out.
375
376
\index{calculus}
377
378
You don't have to know anything about differential equations.
379
380
As for science, we will cover topics from a variety of fields, including demography, epidemiology, medicine, thermodynamics, and mechanics. For the most part, I don't assume you know anything about these topics. In fact, one of the skills you need to do modeling is the ability to learn enough about new fields to develop models and simulations.
381
382
When we get to mechanics, I assume you understand the relationship between position, velocity, and acceleration, and that you are familiar with Newton's laws of motion, especially the second law, which is often expressed as $F = ma$ (force equals mass times acceleration).
383
384
\index{science}
385
\index{mechanics}
386
387
I think that's everything you need, but if you find that I left something out, please let me know.
388
389
390
\section{Getting started}
391
\label{code}
392
393
To run the examples and work on the exercises in this book, you have to:
394
395
\begin{enumerate}
396
397
\item Install Python on your computer, along with the libraries we will use.
398
399
\item Copy my files onto your computer.
400
401
\item Run Jupyter, which is a tool for running and writing programs, and load a {\bf notebook}, which is a file that contains code and text.
402
403
\end{enumerate}
404
405
The next three sections provide details for these steps. I wish there were an easier way to get started; it's regrettable that you have to do so much work before you write your first program. Be persistent!
406
407
408
\section{Installing Python}
409
410
You might already have Python installed on your computer, but you might not have the latest version. To use the code in this book, you need Python 3.6 or later. Even if you have the latest version, you probably don't have all of the libraries we need.
411
412
\index{installing Python}
413
414
You could update Python and install these libraries, but I strongly recommend that you don't go down that road. I think you will find it easier to use {\bf Anaconda}, which is a free Python distribution that includes all the libraries you need for this book (and more).
415
416
\index{Anaconda}
417
418
Anaconda is available for Linux, macOS, and Windows. By default, it puts all files in your home directory, so you don't need administrator (root) permission to install it, and if you have a version of Python already, Anaconda will not remove or modify it.
419
420
Start at \url{https://www.anaconda.com/download}. Download the installer for your system and run it. You don't need administrative privileges to install Anaconda, so I recommend you run the installer as a normal user, not as administrator or root.
421
422
I suggest you accept the recommended options.
423
On Windows you have the option to install Visual Studio Code, which is an interactive environment for writing programs. You won't need it for this book, but you might want it for other projects.
424
425
By default, Anaconda installs most of the packages you need, but there are a few more you have to add. Once the installation is complete, open a command window. On macOS or Linux, you can use Terminal. On Windows, open the Anaconda Prompt that should be in your Start menu.
426
427
Run the following command (copy and paste it if you can, to avoid typos):
428
429
\begin{code}
430
conda install jupyterlab pandas seaborn sympy
431
conda install beautifulsoup4 lxml html5lib pytables
432
\end{code}
433
434
Some of these packages might already be installed. To install Pint, run this command:
435
436
\begin{code}
437
conda install -c conda-forge pint
438
\end{code}
439
440
And to install the ModSim library, run this command:
441
442
\begin{code}
443
pip install modsimpy
444
\end{code}
445
446
That should be everything you need.
447
448
449
\section{Copying my files}
450
451
The simplest way to get the files for this book is to download a Zip archive from \url{https://github.com/AllenDowney/ModSimPy/archive/master.zip}. You will need a program like WinZip or gzip to unpack the Zip file. Make a note of the location of the files you unpack.
452
453
If you download the Zip file, you can skip the rest of this section, which explains how to use Git.
454
455
The code for this book is available from
456
\url{https://github.com/AllenDowney/ModSimPy}, which is a {\bf Git repository}. Git is a software tool that helps you keep track of the programs and other files that make up a project. A collection of files under Git's control is called a repository (the cool kids call it a ``repo"). GitHub is a hosting service that provides storage for Git repositories and a convenient web interface.
457
458
\index{repository}
459
\index{Git}
460
\index{GitHub}
461
462
Before you download these files, I suggest you copy my repository on GitHub, which is called {\bf forking}. If you don't already have a GitHub account, you'll need to create one.
463
464
Use a browser to view the homepage of my repository at \url{https://github.com/AllenDowney/ModSimPy}. You should see a gray button in the upper right that says {\sf Fork}. If you press it, GitHub will create a copy of my repository that belongs to you.
465
466
Now, the best way to download the files is to use a {\bf Git client}, which is a program that manages git repositories. You can get installation instructions for Windows, macOS, and Linux at \url{http://modsimpy.com/getgit}.
467
468
In Windows, I suggest you accept the options recommended by the installer, with two exceptions:
469
470
\begin{itemize}
471
472
\item As the default editor, choose \py{nano} instead of \py{vim}.
473
474
\item For ``Configuring line ending conversions", select ``Check out as is, commit as is".
475
476
\end{itemize}
477
478
For macOS and Linux, I suggest you accept the recommended options.
479
480
Once the installation is complete, open a command window. On Windows, open Git Bash, which should be in your Start menu. On macOS or Linux, you can use Terminal.
481
482
To find out what directory you are in, type \py{pwd}, which stands for ``print working directory". On Windows, most likely you are in \py{Users\\yourusername}. On MacOS or Linux, you are probably in your home directory, \py{/home/yourusername}.
483
484
The next step is to copy files from your repository on GitHub to your computer; in Git vocabulary, this process is called {\bf cloning}. Run this command:
485
486
\begin{python}
487
git clone https://github.com/YourGitHubUserName/ModSimPy
488
\end{python}
489
490
Of course, you should replace \py{YourGitHubUserName} with your GitHub user name. After cloning, you should have a new directory called \py{ModSimPy}.
491
492
\section{Running Jupyter}
493
494
The code for each chapter, and starter code for the exercises, is in
495
Jupyter notebooks. If you have not used Jupyter before, you can read
496
about it at \url{https://jupyter.org}.
497
498
\index{Jupyter}
499
500
To start Jupyter on macOS or Linux, open a Terminal; on Windows, open Git Bash. Use \py{cd} to ``change directory" into the directory in the repository that contains the notebooks. If you downloaded the Zip file, it's probably:
501
502
\begin{code}
503
cd ModSimPy-master/notebooks
504
\end{code}
505
506
If you cloned it with Git, it's probably:
507
508
\begin{code}
509
cd ModSimPy/notebooks
510
\end{code}
511
512
Then launch the Jupyter notebook server:
513
514
\begin{code}
515
jupyter notebook
516
\end{code}
517
518
Jupyter should open a window in a browser, and you should see the list of notebooks in my repository. Click on the first notebook, \py{chap01.ipynb} and follow the instructions to run the first few ``cells". The first time you run a notebook, it might take several seconds to start while some Python files get initialized. After that, it should run faster.
519
520
Feel free to read through the notebook, but it might not make sense until you read Chapter~\ref{chap01}.
521
522
You can also launch Jupyter from the Start menu on Windows, the Dock on macOS, or the Anaconda Navigator on any system. If you do that, Jupyter might start in your home directory or somewhere else in your file system, so you might have to navigate to find the \py{ModSimPy} directory.
523
524
525
\section*{Contributor List}
526
527
If you have a suggestion or correction, send it to
528
{\tt downey@allendowney.com}. Or if you are a Git user, send me a pull request!
529
530
If I make a change based on your feedback, I will add you to the contributor list, unless you ask to be omitted.
531
\index{contributors}
532
533
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!
534
535
\begin{itemize}
536
537
\item I am grateful to John Geddes and Mark Somerville for their early collaboration with me to create Modeling and Simulation, the class at Olin College this book is based on.
538
539
\item My early work on this book benefited from conversations with
540
my amazing colleagues at Olin College, including John Geddes, Alison
541
Wood, Chris Lee, and Jason Woodard.
542
543
\item I am grateful to Lisa Downey and Jason Woodard for their thoughtful and careful copy editing.
544
545
\item Thanks to Alessandra Ferzoco, Erhardt Graeff, Emily Tow,
546
Kelsey Houston-Edwards, Linda Vanasupa, Matt Neal, Joanne Pratt, and Steve Matsumoto for their helpful suggestions.
547
548
\item Special thanks to Tim Sauder for the cover design.
549
550
% ENDCONTRIB
551
552
\end{itemize}
553
554
555
556
\normalsize
557
558
\cleardoublepage
559
560
% TABLE OF CONTENTS
561
\begin{latexonly}
562
563
% \tableofcontents
564
565
\cleardoublepage
566
567
\end{latexonly}
568
569
% START THE BOOK
570
\mainmatter
571
572
573
\chapter{Modeling}
574
\label{chap01}
575
576
This book is about modeling and simulation of physical systems.
577
The following diagram shows what I mean by ``modeling":
578
579
\index{modeling}
580
581
\vspace{0.2in}
582
\centerline{\includegraphics[height=3in]{figs/modeling_framework.pdf}}
583
584
Starting in the lower left, the {\bf 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 left out; removing details is called {\bf abstraction}.
585
586
\index{system}
587
588
The result of abstraction is a {\bf model}, which is a description of the system that includes only the features we think are essential. A model can be represented in the form of diagrams and equations, which can be used for mathematical {\bf analysis}. It can also be implemented in the form of a computer program, which can run {\bf simulations}.
589
590
\index{model}
591
\index{abstraction}
592
\index{analysis}
593
594
The result of analysis and simulation might be a {\bf prediction} about what the system will do, an {\bf explanation} of why it behaves the way it does, or a {\bf design} intended to achieve a purpose.
595
596
\index{prediction}
597
\index{explanation}
598
\index{design}
599
600
We can {\bf validate} predictions and test designs by taking {\bf measurements} from the real world and comparing the {\bf data} we get with the results from analysis and simulation.
601
602
\index{validation}
603
\index{data}
604
605
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).
606
607
\index{iterative modeling}
608
609
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.
610
611
\index{realism}
612
613
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.
614
615
\index{simplicity}
616
617
As an example, suppose someone asks you why the orbit of the Earth is elliptical. If you model the Earth and Sun as point masses (ignoring their actual size), compute the gravitational force between them using Newton's law of universal gravitation, and compute the resulting orbit using Newton's laws of motion, you can show that the result is an ellipse.
618
619
\index{orbit}
620
\index{ellipse}
621
622
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 Newton's laws of motion are only approximately true (they don't take into account relativistic effects).
623
624
\index{Newton}
625
\index{relativity}
626
627
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.
628
629
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. This process is called {\bf iterative modeling}.
630
631
Comparing results of successive models provides a form of {\bf internal validation}, so you can catch conceptual, mathematical, and software errors. And by adding and removing features, you can tell which ones have the biggest effect on the results, and which can be ignored.
632
633
\index{internal validation}
634
\index{validation!internal}
635
\index{external validation}
636
\index{validation!external}
637
638
Comparing results to data from the real world provides {\bf external validation}, which is generally the strongest test.
639
640
641
\section{The falling penny myth}
642
\label{penny}
643
644
Let's see an example of how models are used. You might have heard that a penny dropped from the top of the Empire State Building would be going so fast when it hit the pavement that it would be embedded in the concrete; or if it hit a person, it would break their skull.
645
646
\index{Empire State Building}
647
\index{penny}
648
\index{myth}
649
650
We can test this myth by making and analyzing a model. To get started, we'll assume that the effect of air resistance is small. This will turn out to be a bad assumption, but bear with me.
651
652
If air resistance is negligible, the primary force acting on the penny is gravity, which causes the penny to accelerate downward.
653
\index{air resistance}
654
655
If the initial velocity is 0, the velocity after $t$ seconds is $a t$, and the distance the penny has dropped is
656
%
657
\[ h = a t^2 / 2 \]
658
%
659
Using algebra, we can solve for $t$:
660
%
661
\[ t = \sqrt{ 2 h / a} \]
662
%
663
Plugging in the acceleration of gravity, $a = \SI{9.8}{\meter\per\second\squared}$, and the height of the Empire State Building, $h=\SI{381}{\meter}$, we get $t = \SI{8.8}{\second}$. Then computing $v = a t$ we get a velocity on impact of $\SI{86}{\meter\per\second}$, which is about 190 miles per hour. That sounds like it could hurt.
664
665
Of course, these results are not exact because the model is based on simplifications. 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.
666
\index{gravity}
667
668
On the other hand, ignoring air resistance is not a good choice. Once the penny gets to about \SI{18}{\meter\per\second}, the upward force of air resistance equals the downward force of gravity, so the penny stops accelerating. After that, it doesn't matter how far the penny falls; it hits the sidewalk (or your head) at about \SI{18}{\meter\per\second}, much less than \SI{86}{\meter\per\second}, as the simple model predicts.
669
670
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. In the notebook for this chapter, you will see another model, which assumes that acceleration is constant until the penny reaches terminal velocity. This model is also wrong, but it's better, and it's good enough to refute the myth.
671
672
\index{Box, George}
673
674
The television show {\it Mythbusters} has tested the myth of the falling penny more carefully; you can view the results at \url{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.
675
676
\index{Mythbusters}
677
678
679
\section{Computation}
680
\label{computation}
681
682
There are (at least) two ways to work with mathematical models, {\bf analysis} and {\bf simulation}. Analysis often involves algebra and other kinds of symbolic manipulation. Simulation often involves computers.
683
\index{analysis}
684
\index{simulation}
685
686
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.
687
688
As a first example, I'll show you how I computed the results from the previous section using Python.
689
690
First I create a {\bf variable} to represent acceleration.
691
692
\index{variable}
693
\index{value}
694
695
\begin{python}
696
a = 9.8 * meter / second**2
697
\end{python}
698
699
A variable is a name that corresponds to a value. In this example, the name is \py{a} and the value is the number \py{9.8} multiplied by the units \py{meter / second**2}. This example demonstrates some of the symbols Python uses to perform mathematical operations:
700
\index{operator!mathematical}
701
702
\begin{tabular}{l|c}
703
{\bf Operation} & {\bf Symbol} \\
704
\hline
705
Addition & \py{+} \\
706
Subtraction & \py{-} \\
707
Multiplication & \py{*} \\
708
Division & \py{/} \\
709
Exponentiation & \py{**} \\
710
\end{tabular}
711
712
Next, we compute the time it takes for the penny to drop \SI{381}{\meter}, the height of the Empire State Building.
713
714
\begin{python}
715
h = 381 * meter
716
t = sqrt(2 * h / a)
717
\end{python}
718
719
These lines create two more variables: \py{h} gets the height of the building in meters; \py{t} gets the time, in seconds, for the penny to fall to the sidewalk. \py{sqrt} is a {\bf function} that computes square roots. Python keeps track of units, so the result, \py{t}, has the correct units, seconds.
720
\index{unit}
721
\index{function}
722
\index{sqrt}
723
724
Finally, we compute the velocity of the penny after $t$ seconds:
725
726
\begin{python}
727
v = a * t
728
\end{python}
729
730
The result is about \SI{86}{\meter\per\second}, again with the correct units.
731
732
This example demonstrates analysis and computation using Python. In the next chapter, we'll see an example of simulation.
733
734
Before you go on, you might want to read the notebook for this chapter, \py{chap01.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
735
736
737
\chapter{Bike share}
738
\label{chap02}
739
740
This chapter presents a simple model of a bike share system and demonstrates the features of Python we'll use to develop simulations of real-world systems.
741
742
Along the way, we'll make decisions about how to model the system. In the next chapter we'll review these decisions and gradually improve the model.
743
744
745
\section{Modeling}
746
\label{modeling}
747
748
Imagine a bike share system for students traveling between Olin College and Wellesley College, which are about 3 miles apart in eastern Massachusetts.
749
750
\index{Wellesley College}
751
\index{Olin College}
752
753
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.
754
755
\index{bike share system}
756
757
As students arrive, check out a bike, and ride to the other campus, the number of bikes in each location changes. In the simulation, we'll need to keep track of where the bikes are. To do that, I'll create a \py{State} object, which is defined in the ModSim library.
758
759
\index{State object}
760
761
Before we can use the library, we have to \py{import} it:
762
763
\begin{python}
764
from modsim import *
765
\end{python}
766
767
This line of code is an {\bf import statement} that tells Python
768
to read the file {\tt modsim.py} and make the functions it defines available.
769
770
\index{import statement}
771
772
Functions in the \py{modsim.py} library include \py{sqrt}, which we used in the previous section, and \py{State}, which we are using now. \py{State} creates a \py{State} object, which is a collection of {\bf state variables}.
773
774
\index{state variable}
775
776
\begin{python}
777
bikeshare = State(olin=10, wellesley=2)
778
\end{python}
779
780
The state variables, \py{olin} and \py{wellesley}, represent the number of bikes at each location. The initial values are 10 and 2, indicating that there are 10 bikes at Olin and 2 at Wellesley. The \py{State} object created by \py{State} is assigned to a new variable named \py{bikeshare}.
781
782
\index{dot operator}
783
\index{operator!dot}
784
785
We can read the variables inside a \py{State} object using the {\bf dot operator}, like this:
786
787
\begin{python}
788
bikeshare.olin
789
\end{python}
790
791
The result is the value 10. Similarly, for:
792
793
\begin{python}
794
bikeshare.wellesley
795
\end{python}
796
797
The result is 2. If you forget what variables a state object has, you can just type the name:
798
799
\begin{python}
800
bikeshare
801
\end{python}
802
803
The result looks like a table with the variable names and their values:
804
805
\begin{tabular}{lr}
806
& {\bf \sf value} \\
807
\hline
808
{\bf \sf olin} & 10 \\
809
{\bf \sf wellesley} & 2 \\
810
\end{tabular}
811
812
The state variables and their values make up the {\bf 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:
813
814
\index{state}
815
816
\begin{python}
817
bikeshare.olin = 9
818
bikeshare.wellesley = 3
819
\end{python}
820
821
Or we can use {\bf update operators}, \py{-=} and \py{+=}, to subtract 1 from \py{olin} and add 1 to \py{wellesley}:
822
823
\index{update operator}
824
\index{operator!update}
825
826
\begin{python}
827
bikeshare.olin -= 1
828
bikeshare.wellesley += 1
829
\end{python}
830
831
The result is the same either way, but the second version is more versatile.
832
833
834
\section{Defining functions}
835
836
So far we have used functions defined in ModSim and other libraries. Now we're going to define our own functions.
837
838
\index{function}
839
\index{defining functions}
840
841
When you are developing code in Jupyter, it is often efficient to
842
write a few lines of code, test them to confirm they do what
843
you intend, and then use them to define a new function. For
844
example, these lines move a bike from Olin to Wellesley:
845
846
\begin{python}
847
bikeshare.olin -= 1
848
bikeshare.wellesley += 1
849
\end{python}
850
851
Rather than repeat them every time a bike moves, we can define a
852
new function:
853
854
\begin{python}
855
def bike_to_wellesley():
856
bikeshare.olin -= 1
857
bikeshare.wellesley += 1
858
\end{python}
859
860
\py{def} is a special word in Python that indicates we are defining a new
861
function. The name of the function is \py{bike_to_wellesley}. The empty parentheses indicate that this function requires no additional information when it runs. The colon indicates the beginning of an indented
862
{\bf code block}.
863
\index{def}
864
\index{code block}
865
\index{body}
866
\index{indentation}
867
868
The next two lines are the {\bf body} of the function. They have to be indented; by convention, the indentation is 4 spaces.
869
870
When you define a function, it has no immediate effect. The body
871
of the function doesn't run until you {\bf call} the function.
872
Here's how to call this function:
873
\index{call}
874
875
\begin{python}
876
bike_to_wellesley()
877
\end{python}
878
879
When you call the function, it runs the statements in the body, which update the variables of the {\tt bikeshare} object; you can check by displaying the new state.
880
881
When you call a function, you have to include the parentheses. If you leave them out, like this:
882
\index{parentheses}
883
884
\begin{python}
885
bike_to_wellesley
886
\end{python}
887
888
Python looks up the name of the function and displays:
889
890
\begin{python}
891
<function __main__.bike_to_wellesley>
892
\end{python}
893
894
This result indicates that \py{bike_to_wellesley} is a function. You don't have to know what \py{__main__} means, but if you see something like this, it probably means that you looked up a function but you didn't
895
actually call it. So don't forget the parentheses.
896
897
Just like \py{bike_to_wellesley}, we can define a function that moves a bike from Wellesley to Olin:
898
899
\begin{python}
900
def bike_to_olin():
901
bikeshare.wellesley -= 1
902
bikeshare.olin += 1
903
\end{python}
904
905
And call it like this:
906
907
\begin{python}
908
bike_to_olin()
909
\end{python}
910
911
One benefit of defining functions is that you avoid repeating chunks
912
of code, which makes programs smaller. Another benefit is that the
913
name you give the function documents what it does, which makes programs
914
more readable.
915
916
917
\section{Print statements}
918
919
As you write more complicated programs, it is easy to lose track of what is going on. One of the most useful tools for debugging is the {\bf print statement}, which displays text in the Jupyter notebook.
920
\index{print statement}
921
\index{statement!print}
922
923
Normally when Jupyter runs the code in a cell, it displays the value of the last line of code. For example, if you run:
924
925
\begin{python}
926
bikeshare.olin
927
bikeshare.wellesley
928
\end{python}
929
930
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:
931
932
\begin{python}
933
print(bikeshare.olin)
934
print(bikeshare.wellesley)
935
\end{python}
936
937
When you call the \py{print} function, you can put a variable name in parentheses, as in the previous example, or you can provide a sequence of variables separated by commas, like this:
938
939
\begin{python}
940
print(bikeshare.olin, bikeshare.wellesley)
941
\end{python}
942
943
Python looks up the values of the variables and displays them; in this example, it displays two values on the same line, with a space between them.
944
945
Print statements are useful for debugging functions. For example, we can add a print statement to \py{move_bike}, like this:
946
947
\begin{python}
948
def bike_to_wellesley():
949
print('Moving a bike to Wellesley')
950
bikeshare.olin -= 1
951
bikeshare.wellesley += 1
952
\end{python}
953
954
Each time we call this version of the function, it displays a message, which can help us keep track of what the program is doing.
955
956
The message in this example is a {\bf string}, which is a sequence of letters and other symbols in quotes.
957
\index{string}
958
959
960
\section{If statements}
961
962
The ModSim library provides a function called \py{flip}; when you call it, you provide a value between 0 and 1; in this example, it's \py{0.7}:
963
964
\begin{python}
965
flip(0.7)
966
\end{python}
967
968
The result is one of two values: \py{True} with probability 0.7 or \py{False} with probability 0.3. If you run \py{flip} like this 100 times, you should get \py{True} about 70 times and \py{False} about 30 times. But the results are random, so they might differ from these expectations.
969
\index{flip}
970
\index{True}
971
\index{False}
972
973
\py{True} and \py{False} are special values defined by Python. Note
974
that they are not strings. There is a difference between \py{True},
975
which is a special value, and \py{'True'}, which is a string.
976
\index{string}
977
\index{boolean}
978
979
\py{True} and \py{False} are called {\bf boolean} values because
980
they are related to Boolean algebra (\url{http://modsimpy.com/boolean}).
981
982
We can use boolean values to control the behavior of the program, using
983
an {\bf if statement}:
984
\index{if statement}
985
\index{statement!if}
986
987
\begin{python}
988
if flip(0.5):
989
print('heads')
990
\end{python}
991
992
If the result from \py{flip} is \py{True}, the program displays the string \py{'heads'}. Otherwise it does nothing.
993
994
The punctuation for \py{if} statements is similar to the punctuation for function definitions: the first line has to end with a colon, and the lines inside the \py{if} statement have to be indented.
995
\index{indentation}
996
\index{else clause}
997
998
Optionally, you can add an {\bf else clause} to indicate what should happen if the result is \py{False}:
999
1000
\begin{python}
1001
if flip(0.5):
1002
print('heads')
1003
else:
1004
print('tails')
1005
\end{python}
1006
1007
Now we can use \py{flip} to simulate the arrival of students who want to borrow a bike. Suppose students arrive at the Olin station every 2 minutes, on average. In that case, the chance of an arrival during any one-minute period is 50\%, and we can simulate it like this:
1008
1009
\begin{python}
1010
if flip(0.5):
1011
bike_to_wellesley()
1012
\end{python}
1013
1014
If students arrive at the Wellesley station every 3 minutes, on average, the chance of an arrival during any one-minute period is 33\%, and we can simulate it like this:
1015
1016
\begin{python}
1017
if flip(0.33):
1018
bike_to_olin()
1019
\end{python}
1020
1021
We can combine these snippets into a function that simulates a {\bf time step}, which is an interval of time, in this case one minute:
1022
1023
\index{time step}
1024
1025
\begin{python}
1026
def step():
1027
if flip(0.5):
1028
bike_to_wellesley()
1029
1030
if flip(0.33):
1031
bike_to_olin()
1032
\end{python}
1033
1034
Then we can simulate a time step like this:
1035
1036
\begin{python}
1037
step()
1038
\end{python}
1039
1040
Even though there are no values in parentheses, we have to include them.
1041
1042
1043
\section{Parameters}
1044
1045
The previous version of \py{step} is fine if the arrival probabilities never change, but in reality, these probabilities vary over time.
1046
1047
So instead of putting the constant values 0.5 and 0.33 in \py{step} we can replace them with {\bf parameters}. Parameters are variables whose values are set when a function is called.
1048
1049
Here's a version of \py{step} that takes two parameters, \py{p1} and \py{p2}:
1050
1051
\index{probability}
1052
1053
\begin{python}
1054
def step(p1, p2):
1055
if flip(p1):
1056
bike_to_wellesley()
1057
1058
if flip(p2):
1059
bike_to_olin()
1060
\end{python}
1061
1062
The values of \py{p1} and \py{p2} are not set inside this function; instead, they are provided when the function is called, like this:
1063
1064
\begin{python}
1065
step(0.5, 0.33)
1066
\end{python}
1067
1068
The values you provide when you call the function are called {\bf arguments}.
1069
The arguments, \py{0.5} and \py{0.33} in this example, get assigned to the parameters, \py{p1} and \py{p2}, in order. So running this function has the same effect as:
1070
1071
\begin{python}
1072
p1 = 0.5
1073
p2 = 0.33
1074
1075
if flip(p1):
1076
bike_to_wellesley()
1077
1078
if flip(p2):
1079
bike_to_olin()
1080
\end{python}
1081
1082
The advantage of using parameters is that you can call the same function many times, providing different arguments each time.
1083
1084
Adding parameters to a function is called {\bf generalization}, because it makes the function more general, that is, less specialized.
1085
1086
\index{generalization}
1087
1088
1089
\section{For loops}
1090
\label{forloop}
1091
1092
At some point you will get sick of running cells over and over. Fortunately, there is an easy way to repeat a chunk of code, the {\bf for loop}. Here's an example:
1093
\index{for loop}
1094
\index{loop}
1095
1096
\begin{python}
1097
for i in range(4):
1098
bike_to_wellesley()
1099
\end{python}
1100
1101
The punctuation here should look familiar; the first line ends with a colon, and the lines inside the \py{for} loop are indented. The other elements of the loop are:
1102
\index{range}
1103
1104
\begin{itemize}
1105
1106
\item The words \py{for} and \py{in} are special words we have to use in a for loop.
1107
1108
\item \py{range} is a Python function we're using here to control the number of times the loop runs.
1109
\index{range}
1110
1111
\item \py{i} is a {\bf loop variable} that gets created when the for loop runs.
1112
\index{loop variable}
1113
1114
\end{itemize}
1115
1116
In this example we don't actually use \py{i}; we will see examples later where we use the loop variable inside the loop.
1117
1118
When this loop runs, it runs the statements inside the loop four times,
1119
which moves one bike at a time from Olin to Wellesley.
1120
1121
1122
\section{TimeSeries}
1123
\label{timeseries}
1124
1125
When we run a simulation, we usually want to save the results for later analysis. The ModSim library provides a \py{TimeSeries} object for this purpose. A \py{TimeSeries} contains a sequence of time stamps and a corresponding sequence of values. In this example, the time stamps are integers representing minutes, and the values are the number of bikes at one location.
1126
1127
%TODO: index modsim library functions
1128
\index{ModSim library}
1129
\index{TimeSeries}
1130
1131
We can create a new, empty \py{TimeSeries} like this:
1132
1133
\begin{python}
1134
results = TimeSeries()
1135
\end{python}
1136
1137
And we can add a value to a \py{TimeSeries} like this:
1138
1139
\begin{python}
1140
results[0] = bikeshare.olin
1141
\end{python}
1142
1143
The number in brackets is the time stamp, also called a {\bf label}.
1144
\index{label}
1145
1146
We can use a \py{TimeSeries} inside a for loop to store the results of the simulation:
1147
1148
\begin{python}
1149
for i in range(10):
1150
step(0.3, 0.2)
1151
results[i] = bikeshare.olin
1152
\end{python}
1153
1154
Each time through the loop, we call \py{step}, which updates \py{bikeshare}. Then we store the number of bikes at Olin in \py{results}. We use the loop variable, \py{i}, as the time stamp.
1155
1156
\index{loop}
1157
\index{loop variable}
1158
\index{time stamp}
1159
1160
When the loop exits, \py{results} contains 10 time stamps, from 0 through 9, and the number of bikes at Olin at the end of each time step.
1161
\index{loop variable}
1162
1163
\py{TimeSeries} is a specialized version of \py{Series}, which is defined by Pandas, one of the libraries we'll be using extensively. The \py{Series} object provides many functions; one example is \py{mean}, which we can call like this:
1164
1165
\begin{python}
1166
results.mean()
1167
\end{python}
1168
1169
You can read the documentation of \py{Series} at \url{http://modsimpy.com/series}.
1170
1171
\index{Pandas}
1172
\index{Series}
1173
\index{TimeSeries}
1174
\index{mean}
1175
1176
1177
\section{Plotting}
1178
\label{plotting}
1179
1180
The ModSim library provides a function called \py{plot} we can use to plot \py{results}:
1181
1182
\begin{python}
1183
plot(results)
1184
\end{python}
1185
1186
\py{plot} can take an additional argument that gives the line a label; this label will appear in the legend of the plot, if we create one.
1187
1188
\begin{python}
1189
plot(results, label='Olin')
1190
\end{python}
1191
1192
\py{label} is an example of a {\bf keyword argument}, so called because we provide a ``keyword'', which is \py{label} in this case, along with its value. Arguments without keywords are called {\bf positional arguments} because they are assigned to parameters according to their position. It is good to know these terms because they appear in Python error messages.
1193
1194
\index{keyword argument}
1195
\index{positional argument}
1196
\index{argument}
1197
1198
Whenever you make a figure, you should label the axes. The ModSim library provides \py{decorate}, which labels the axes and gives the figure a title and legend:
1199
1200
\begin{python}
1201
decorate(title='Olin-Wellesley Bikeshare',
1202
xlabel='Time step (min)',
1203
ylabel='Number of bikes')
1204
\end{python}
1205
1206
\begin{figure}
1207
\centerline{\includegraphics[height=3in]{figs/chap02-fig01.pdf}}
1208
\caption{Simulation of a bikeshare system showing number of bikes at Olin over time.}
1209
\label{chap02-fig01}
1210
\end{figure}
1211
1212
Figure~\ref{chap02-fig01} shows the result.
1213
1214
\py{plot} and \py{decorate} are based on Pyplot, which is a Python library for generating figures. You can read more about \py{plot} and the arguments it takes at \url{http://modsimpy.com/plot}.
1215
1216
\index{Pyplot}
1217
\index{plot}
1218
\index{decorate}
1219
1220
Before you go on, you might want to read the notebook for this chapter, \py{chap02.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
1221
1222
1223
1224
\chapter{Iterative modeling}
1225
\label{chap03}
1226
1227
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.
1228
1229
\index{Box, George}
1230
\index{Orwell, George}
1231
1232
As an example, we'll review the bikeshare model from the previous chapter, consider its strengths and weaknesses, and gradually improve it. We'll also see ways to use the model to understand the behavior of the system and evaluate designs intended to make it work better.
1233
1234
\index{bikeshare}
1235
1236
1237
\section{Iterative modeling}
1238
1239
The model we have so far is simple, but it is based on unrealistic assumptions. Before you go on, take a minute to review the model from the previous chapters. What assumptions is it based on? Make a list of ways this model might be unrealistic; that is, what are the differences between the model and the real world?
1240
1241
Here are some of the differences on my list:
1242
1243
\begin{itemize}
1244
1245
\item In the model, a student is equally likely to arrive during any one-minute period. In reality, this probability varies depending on time of day, day of the week, etc.
1246
1247
\index{probability}
1248
1249
\item The model does not account for travel time from one bike station to another.
1250
1251
\item The model does not check whether a bike is available, so it's possible for the number of bikes to be negative (as you might have noticed in some of your simulations).
1252
1253
\end{itemize}
1254
1255
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.
1256
1257
The second assumption is not very realistic, but it might not affect the results very much, depending on what we use the model for.
1258
1259
\index{realism}
1260
1261
On the other hand, the third assumption seems problematic, and it is relatively easy to fix. In Section~\ref{negativebikes}, we will.
1262
1263
This process, starting with a simple model, identifying the most important problems, and making gradual improvements, is called {\bf iterative modeling}.
1264
1265
\index{iterative modeling}
1266
1267
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.
1268
1269
1270
\section{More than one State object}
1271
1272
Before we go on, I want to make a few changes to the code from the previous chapter. First I'll generalize the functions we wrote so they take a \py{State} object as a parameter. Then, I'll make the code more readable by adding documentation.
1273
1274
\index{parameter}
1275
1276
Here is one of the functions from the previous chapter, \py{bike_to_wellesley}:
1277
1278
\begin{python}
1279
def bike_to_wellesley():
1280
bikeshare.olin -= 1
1281
bikeshare.wellesley += 1
1282
\end{python}
1283
1284
When this function is called, it modifies \py{bikeshare}. As long as there is only one \py{State} object, that's fine, but what if there is more than one bike share system in the world? Or what if we want to run more than one simulation?
1285
1286
This function would be more flexible if it took a \py{State} object as a parameter. Here's what that looks like:
1287
1288
\index{State object}
1289
1290
\begin{python}
1291
def bike_to_wellesley(state):
1292
state.olin -= 1
1293
state.wellesley += 1
1294
\end{python}
1295
1296
The name of the parameter is \py{state} rather than \py{bikeshare} as a reminder that the value of \py{state} could be any \py{State} object, not just \py{bikeshare}.
1297
1298
This version of \py{bike_to_wellesley} requires a \py{State} object as a parameter, so we have to provide one when we call it:
1299
1300
\begin{python}
1301
bike_to_wellesley(bikeshare)
1302
\end{python}
1303
1304
Again, the argument we provide gets assigned to the parameter, so this function call has the same effect as:
1305
1306
\begin{code}
1307
state = bikeshare
1308
state.olin -= 1
1309
state.wellesley += 1
1310
\end{code}
1311
1312
Now we can create as many \py{State} objects as we want:
1313
1314
\begin{python}
1315
bikeshare1 = State(olin=10, wellesley=2)
1316
bikeshare2 = State(olin=2, wellesley=10)
1317
\end{python}
1318
1319
And update them independently:
1320
1321
\begin{python}
1322
bike_to_wellesley(bikeshare1)
1323
bike_to_wellesley(bikeshare2)
1324
\end{python}
1325
1326
Changes in \py{bikeshare1} do not affect \py{bikeshare2}, and vice versa. So we can simulate different bike share systems, or run multiple simulations of the same system.
1327
1328
1329
\section{Documentation}
1330
\label{documentation}
1331
1332
Another problem with the code we have so far is that it contains no {\bf documentation}. Documentation is text we add to a program to help other programmers read and understand it. It has no effect on the program when it runs.
1333
1334
\index{documentation}
1335
\index{docstring}
1336
\index{comment}
1337
1338
There are two forms of documentation, {\bf docstrings} and {\bf comments}.
1339
A docstring is a string in triple-quotes that appears at the beginning of a function, like this:
1340
1341
\begin{python}
1342
def run_simulation(state, p1, p2, num_steps):
1343
"""Simulate the given number of time steps.
1344
1345
state: State object
1346
p1: probability of an Olin->Wellesley customer arrival
1347
p2: probability of a Wellesley->Olin customer arrival
1348
num_steps: number of time steps
1349
"""
1350
results = TimeSeries()
1351
for i in range(num_steps):
1352
step(state, p1, p2)
1353
results[i] = state.olin
1354
1355
plot(results, label='Olin')
1356
\end{python}
1357
1358
Docstrings follow a conventional format:
1359
1360
\begin{itemize}
1361
1362
\item The first line is a single sentence that describes what the function does.
1363
1364
\item The following lines explain what each of the parameters are.
1365
1366
\end{itemize}
1367
1368
A function's docstring should include the information someone needs to know to {\em use} the function; it should not include details about how the function works. That's what comments are for.
1369
1370
A comment is a line of text that begins with a hash symbol, \py{#}. It usually appears inside a function to explain something that would not be obvious to someone reading the program.
1371
1372
\index{comment}
1373
\index{hash symbol}
1374
1375
For example, here is a version of \py{bike_to_olin} with a docstring and a comment.
1376
1377
\begin{python}
1378
def bike_to_olin(state):
1379
"""Move one bike from Wellesley to Olin.
1380
1381
state: State object
1382
"""
1383
# We decrease one state variable and increase the
1384
# other, so the total number of bikes is unchanged.
1385
state.wellesley -= 1
1386
state.olin += 1
1387
\end{python}
1388
1389
At this point we have more documentation than code, which is not unusual for short functions.
1390
1391
1392
\section{Negative bikes}
1393
\label{negativebikes}
1394
1395
The changes we've made so far improve the quality of the code, but we haven't done anything to improve the quality of the model yet. Let's do that now.
1396
1397
\index{code quality}
1398
1399
Currently the simulation does not check whether a bike is available when a customer arrives, so the number of bikes at a location can be negative. That's not very realistic. Here's an updated version of \py{bike_to_olin} that fixes the problem:
1400
1401
\begin{python}
1402
def bike_to_olin(state):
1403
if state.wellesley == 0:
1404
return
1405
state.wellesley -= 1
1406
state.olin += 1
1407
\end{python}
1408
1409
The first line checks whether the number of bikes at Wellesley is zero. If so, it uses a {\bf return statement}, which causes the function to end immediately, without running the rest of the statements. So if there are no bikes at Wellesley, we ``return" from \py{bike_to_olin} without changing the state.
1410
1411
\index{return statement}
1412
\index{statement!return}
1413
1414
We can update \py{bike_to_wellesley} the same way.
1415
1416
1417
\section{Comparison operators}
1418
1419
The version of \py{bike_to_olin} in the previous section uses the equals operator, \py{==}, which compares two values and returns \py{True} if they are equal and \py{False} otherwise.
1420
1421
It is easy to confuse the equals operators with the assignment operator, \py{=}, which assigns a value to a variable. For example, the following statement creates a variable, \py{x}, if it doesn't already exist, and gives it the value \py{5}.
1422
1423
\index{equality}
1424
\index{assignment operator}
1425
\index{operator!assignment}
1426
1427
\begin{python}
1428
x = 5
1429
\end{python}
1430
1431
On the other hand, the following statement checks whether \py{x} is \py{5} and returns \py{True} or \py{False}. It does not create \py{x} or change its value.
1432
1433
\begin{python}
1434
x == 5
1435
\end{python}
1436
1437
You can use the equals operator in an \py{if} statement, like this:
1438
1439
\index{if statement}
1440
\index{statement!if}
1441
1442
\begin{python}
1443
if x == 5:
1444
print('yes, x is 5')
1445
\end{python}
1446
1447
If you make a mistake and use \py{=} in an \py{if} statement, like this:
1448
1449
\begin{python}
1450
if x = 5:
1451
print('yes, x is 5')
1452
\end{python}
1453
1454
That's a {\bf syntax error}, which means that the structure of the program is invalid. Python will print an error message and the program won't run.
1455
1456
\index{syntax error}
1457
\index{error!syntax}
1458
1459
The equals operator is one of the {\bf comparison operators}. The others are:
1460
1461
\index{comparison operator}
1462
\index{operator!comparison}
1463
1464
\begin{tabular}{l|c}
1465
{\bf Operation} & {\bf Symbol} \\
1466
\hline
1467
Less than & \py{<} \\
1468
Greater than & \py{>} \\
1469
Less than or equal & \py{<=} \\
1470
Greater than or equal & \py{>=} \\
1471
Equal & \py{==} \\
1472
Not equal & \py{!=} \\
1473
\end{tabular}
1474
1475
1476
\section{Metrics}
1477
\label{metrics}
1478
1479
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 random or {\bf stochastic}; models that do the same thing every time they run are {\bf deterministic}.
1480
1481
\index{stochastic}
1482
\index{deterministic}
1483
1484
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".
1485
1486
From the customer's point of view, we might like to know the probability of finding an available bike. From the system-owner's point of view, we might want to minimize the number of customers who don't get a bike when they want one, or maximize the number of bikes in use. Statistics like these that quantify how well the system works are called {\bf metrics}.
1487
1488
\index{metric}
1489
1490
As a simple example, let's measure the number of unhappy customers. Here's a version of \py{bike_to_olin} that keeps track of the number of customers who arrive at a station with no bikes:
1491
1492
\begin{python}
1493
def bike_to_olin(state):
1494
if state.wellesley == 0:
1495
state.wellesley_empty += 1
1496
return
1497
state.wellesley -= 1
1498
state.olin += 1
1499
\end{python}
1500
1501
If a customer arrives at the Wellesley station and finds no bike available, \py{bike_to_olin} updates \py{wellesley_empty} which counts the number of unhappy customers.
1502
1503
This function only works if we initialize \py{wellesley_empty} when we create the \py{State} object, like this:
1504
1505
\begin{python}
1506
bikeshare = State(olin=10, wellesley=2,
1507
olin_empty=0, wellesley_empty=0)
1508
\end{python}
1509
1510
Assuming we update \py{move_to_wellesley} the same way, we can run the simulation like this (see Section~\ref{documentation}):
1511
1512
\begin{python}
1513
run_simulation(bikeshare, 0.4, 0.2, 60)
1514
\end{python}
1515
1516
Then we can check the metrics:
1517
1518
\begin{python}
1519
print(bikeshare.olin_empty, bikeshare.wellesley_empty)
1520
\end{python}
1521
1522
Because the simulation is stochastic, the results are different each time it runs.
1523
1524
Before you go on, you might want to read the notebook for this chapter, \py{chap03.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
1525
1526
1527
\chapter{Sweeping parameters}
1528
\label{chap04}
1529
1530
In the previous chapter we defined metrics that quantify the performance of bike sharing this system. In this chapter we see how those metrics depend on the parameters of the system, like the arrival rate of customers at bike stations.
1531
1532
We also discuss a program development strategy, called incremental development, that might help you write programs faster and spend less time debugging.
1533
1534
1535
\section{Functions that return values}
1536
1537
We have seen several functions that return values; for example, when you run \py{sqrt}, it returns a number you can assign to a variable.
1538
1539
\index{return value}
1540
1541
\begin{python}
1542
t = sqrt(2 * h / a)
1543
\end{python}
1544
1545
When you run \py{State}, it returns a new \py{State} object:
1546
1547
\begin{python}
1548
bikeshare = State(olin=10, wellesley=2)
1549
\end{python}
1550
1551
Not all functions have return values. For example, when you run \py{step}, it updates a \py{State} object, but it doesn't return a value.
1552
1553
To write functions that return values, we can use a second form of the \py{return} statement, like this:
1554
1555
\index{return statement}
1556
\index{statement!return}
1557
1558
\begin{python}
1559
def add_five(x):
1560
return x + 5
1561
\end{python}
1562
1563
\py{add_five} takes a parameter, \py{x}, which could be any number. It computes \py{x + 5} and returns the result. So if we run it like this, the result is \py{8}:
1564
1565
\begin{python}
1566
add_five(3)
1567
\end{python}
1568
1569
As a more useful example, here's a version of \py{run_simulation} that creates a \py{State} object, runs a simulation, and then returns the \py{State} object as a result:
1570
1571
\begin{python}
1572
def run_simulation():
1573
p1 = 0.4
1574
p2 = 0.2
1575
num_steps = 60
1576
1577
state = State(olin=10, wellesley=2,
1578
olin_empty=0, wellesley_empty=0)
1579
1580
for i in range(num_steps):
1581
step(state, p1, p2)
1582
1583
return state
1584
\end{python}
1585
1586
If we call \py{run_simulation} like this:
1587
1588
\begin{python}
1589
state = run_simulation()
1590
\end{python}
1591
1592
It assigns the \py{State} object from \py{run_simulation} to \py{state}, which contains the metrics we are interested in:
1593
1594
\begin{python}
1595
print(state.olin_empty, state.wellesley_empty)
1596
\end{python}
1597
1598
1599
\section{Two kinds of parameters}
1600
1601
This version of \py{run_simulation} always starts with the same initial condition, 10 bikes at Olin and 2 bikes at Wellesley, and the same values of \py{p1}, \py{p2}, and \py{num_steps}. Taken together, these five values are the {\bf parameters of the model}, which are values that determine the behavior of the system.
1602
1603
\index{parameter!of a model}
1604
\index{parameter!of a function}
1605
1606
It is easy to get the parameters of a model confused with the parameters of a function. They are closely related ideas; in fact, it is common for the parameters of the model to appear as parameters in functions. For example, we can write a more general version of \py{run_simulation} that takes \py{p1} and \py{p2} as function parameters:
1607
1608
\begin{python}
1609
def run_simulation(p1, p2, num_steps):
1610
state = State(olin=10, wellesley=2,
1611
olin_empty=0, wellesley_empty=0)
1612
1613
for i in range(num_steps):
1614
step(state, p1, p2)
1615
1616
return state
1617
\end{python}
1618
1619
Now we can run it with different arrival rates, like this:
1620
1621
\begin{python}
1622
state = run_simulation(0.6, 0.3, 60)
1623
\end{python}
1624
1625
In this example, \py{0.6} gets assigned to \py{p1}, \py{0.3} gets assigned to \py{p2}, and \py{60} gets assigned to \py{num_steps}.
1626
1627
Now we can call \py{run_simulation} with different parameters and see how the metrics, like the number of unhappy customers, depend on the parameters. But before we do that, we need a new version of a for loop.
1628
1629
\index{metric}
1630
1631
1632
\section{Loops and arrays}
1633
\label{array}
1634
1635
In Section~\ref{forloop}, we saw a loop like this:
1636
1637
\begin{python}
1638
for i in range(4):
1639
bike_to_wellesley()
1640
\end{python}
1641
1642
\py{range(4)} creates a sequence of numbers from 0 to 3. Each time through the loop, the next number in the sequence gets assigned to the loop variable, \py{i}.
1643
1644
\index{loop}
1645
\index{loop variable}
1646
\index{variable!loop}
1647
1648
\py{range} only works with integers; to get a sequence of non-integer values, we can use \py{linspace}, which is defined in the ModSim library:
1649
1650
\begin{python}
1651
p1_array = linspace(0, 1, 5)
1652
\end{python}
1653
1654
The arguments indicate where the sequence should start and stop, and how many elements it should contain. In this example, the sequence contains \py{5} equally-spaced numbers, starting at \py{0} and ending at \py{1}.
1655
1656
\index{linspace}
1657
\index{NumPy}
1658
\index{array}
1659
1660
The result is a NumPy {\bf array}, which is a new kind of object we have not seen before. An array is a container for a sequence of numbers.
1661
1662
We can use an array in a \py{for} loop like this:
1663
1664
\begin{python}
1665
for p1 in p1_array:
1666
print(p1)
1667
\end{python}
1668
1669
When this loop runs, it
1670
1671
\begin{enumerate}
1672
1673
\item Gets the first value from the array and assigns it to \py{p1}.
1674
1675
\item Runs the body of the loop, which prints \py{p1}.
1676
1677
\item Gets the next value from the array and assigns it to \py{p1}.
1678
1679
\item Runs the body of the loop, which prints \py{p1}.
1680
1681
\end{enumerate}
1682
1683
And so on, until it gets to the end of the array. The result is:
1684
1685
\begin{result}
1686
0.0
1687
0.25
1688
0.5
1689
0.75
1690
1.0
1691
\end{result}
1692
1693
This will come in handy in the next section.
1694
1695
1696
\section{Sweeping parameters}
1697
1698
If we know the actual values of parameters like \py{p1} and \py{p2}, we can use them to make specific predictions, like how many bikes will be at Olin after one hour.
1699
1700
\index{prediction}
1701
\index{explanation}
1702
1703
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.
1704
\index{what if scenario}
1705
1706
As an example, suppose we have enough data to estimate that \py{p2} is about \py{0.2}, but we don't have any information about \py{p1}. We could run simulations with a range of values for \py{p1} and see how the results vary. This process is called {\bf sweeping} a parameter, in the sense that the value of the parameter ``sweeps" through a range of possible values.
1707
1708
\index{sweep}
1709
\index{parameter sweep}
1710
1711
Now that we know about loops and arrays, we can use them like this:
1712
1713
\begin{python}
1714
p1_array = linspace(0, 1, 11)
1715
p2 = 0.2
1716
num_steps = 60
1717
1718
for p1 in p1_array:
1719
state = run_simulation(p1, p2, num_steps)
1720
print(p1, state.olin_empty)
1721
\end{python}
1722
1723
Each time through the loop, we run a simulation with a different value of \py{p1} and the same value of \py{p2}, \py{0.2}. Then we print \py{p1} and the number of unhappy customers at Olin.
1724
1725
To save and plot the results, we can use a \py{SweepSeries} object, which is similar to a \py{TimeSeries}; the difference is that the labels in a \py{SweepSeries} are parameter values rather than time values.
1726
1727
We can create an empty \py{SweepSeries} like this:
1728
1729
\begin{code}
1730
sweep = SweepSeries()
1731
\end{code}
1732
1733
And add values like this:
1734
1735
\begin{python}
1736
for p1 in p1_array:
1737
state = run_simulation(p1, p2, num_steps)
1738
sweep[p1] = state.olin_empty
1739
\end{python}
1740
1741
The result is a \py{SweepSeries} that maps from each value of \py{p1} to the resulting number of unhappy customers. Then we can plot the results:
1742
1743
\begin{code}
1744
plot(sweep, label='Olin')
1745
\end{code}
1746
1747
1748
1749
1750
\section{Incremental development}
1751
1752
When you start writing programs that are more than a few lines, you
1753
might find yourself spending more and more time debugging. The more
1754
code you write before you start debugging, the harder it is to find
1755
the problem.
1756
1757
\index{debugging}
1758
\index{incremental development}
1759
1760
{\bf Incremental development} is a way of programming that tries
1761
to minimize the pain of debugging. The fundamental steps are:
1762
1763
\begin{enumerate}
1764
1765
\item Always start with a working program. If you have an
1766
example from a book, or a program you wrote that is similar to
1767
what you are working on, start with that. Otherwise, start with
1768
something you {\em know} is correct, like {\tt x=5}. Run the program
1769
and confirm that it does what you expect.
1770
1771
\item Make one small, testable change at a time. A ``testable''
1772
change is one that displays something or has some
1773
other effect you can check. Ideally, you should know what
1774
the correct answer is, or be able to check it by performing another
1775
computation.
1776
1777
\index{testable change}
1778
1779
\item Run the program and see if the change worked. If so, go back
1780
to Step 2. If not, you will have to do some debugging, but if the
1781
change you made was small, it shouldn't take long to find the problem.
1782
1783
\end{enumerate}
1784
1785
When this process works, your changes usually work the first time, or if they don't, the problem is obvious. In practice, there are two problems with incremental development:
1786
1787
\begin{itemize}
1788
1789
\item Sometimes you have to write extra code to generate visible output that you can check. This extra code is called {\bf 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.
1790
1791
\index{scaffolding}
1792
1793
\item When you are getting started, it might not be obvious how to
1794
choose the steps that get from {\tt x=5} to the program you are trying
1795
to write. You will see more examples of this process as we go along, and you will get better with experience.
1796
1797
\end{itemize}
1798
1799
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 development.
1800
1801
Before you go on, you might want to read the notebook for this chapter, \py{chap04.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
1802
1803
1804
%\part{Modeling population growth}
1805
1806
\chapter{World population}
1807
\label{chap05}
1808
1809
In 1968 Paul Erlich published {\it The Population Bomb}, in which he predicted that world population would grow quickly during the 1970s, that agricultural production could not keep up, and that mass starvation in the next two decades was inevitable (see \url{http://modsimpy.com/popbomb}). As someone who grew up during those decades, I am happy to report that those predictions were wrong.
1810
1811
\index{Erlich, Paul}
1812
\index{Population Bomb}
1813
1814
But world population growth is still a topic of concern, and it is an open question how many people the earth can sustain while maintaining and improving our quality of life.
1815
1816
\index{world population}
1817
\index{population}
1818
1819
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.
1820
1821
\index{prediction}
1822
1823
For background on world population growth, watch this video from the American Museum of Natural History \url{http://modsimpy.com/human}.
1824
1825
\index{American Museum of Natural History}
1826
1827
1828
\section{World Population Data}
1829
\label{worldpopdata}
1830
1831
The Wikipedia article on world population contains tables with estimates of world population from prehistory to the present, and projections for the future (\url{http://modsimpy.com/worldpop}).
1832
1833
\index{Wikipedia}
1834
\index{Pandas}
1835
1836
To read this data, we will use Pandas, which provides functions for working with data. The function we'll use is \py{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:
1837
1838
\index{\py{read_html}}
1839
\index{import statement}
1840
\index{statement!import}
1841
1842
\begin{python}
1843
from modsim import *
1844
\end{python}
1845
1846
which imports all functions from the ModSim library. To import \py{read_html}, the statement we need is:
1847
1848
\begin{python}
1849
from pandas import read_html
1850
\end{python}
1851
1852
Now we can use it like this:
1853
1854
\begin{python}
1855
filename = 'data/World_population_estimates.html'
1856
tables = read_html(filename,
1857
header=0,
1858
index_col=0,
1859
decimal='M')
1860
\end{python}
1861
1862
The arguments are:
1863
\index{argument}
1864
1865
\begin{itemize}
1866
1867
\item \py{filename}: The name of the file (including the directory it's in) as a string. This argument can also be a URL starting with \py{http}.
1868
1869
\item \py{header}: Indicates which row of each table should be considered the header, that is, the set of labels that identify the columns. In this case it is the first row (numbered 0).
1870
1871
\item \py{index_col}: Indicates which column of each table should be considered the {\bf index}, that is, the set of labels that identify the rows. In this case it is the first column, which contains the years.
1872
1873
\item \py{decimal}: Normally this argument is used to indicate which character should be considered a decimal point, because some conventions use a period and some use a comma. In this case I am abusing the feature by treating \py{M} as a decimal point, which allows some of the estimates, which are expressed in millions, to be read as numbers.
1874
1875
\end{itemize}
1876
1877
The result, which is assigned to \py{tables}, is a sequence that contains one \py{DataFrame} for each table. A \py{DataFrame} is an object, defined by Pandas, that represents tabular data.
1878
1879
\index{DataFrame}
1880
\index{sequence}
1881
1882
To select a \py{DataFrame} from \py{tables}, we can use the bracket operator like this:
1883
1884
\begin{python}
1885
table2 = tables[2]
1886
\end{python}
1887
1888
This line selects the third table (numbered 2), which contains population estimates from 1950 to 2016.
1889
1890
\index{bracket operator}
1891
\index{operator!bracket}
1892
1893
We can display the first few lines like this:
1894
1895
\begin{python}
1896
table2.head()
1897
\end{python}
1898
1899
The column labels are long strings, which makes them hard to work with. We can replace them with shorter strings like this:
1900
1901
\index{string}
1902
\index{columns}
1903
1904
\begin{python}
1905
table2.columns = ['census', 'prb', 'un', 'maddison',
1906
'hyde', 'tanton', 'biraben', 'mj',
1907
'thomlinson', 'durand', 'clark']
1908
\end{python}
1909
1910
Now we can select a column from the \py{DataFrame} using the dot operator, like selecting a state variable from a \py{State} object:
1911
1912
\index{dot operator}
1913
\index{operator!dot}
1914
1915
\begin{python}
1916
census = table2.census / 1e9
1917
un = table2.un / 1e9
1918
\end{python}
1919
1920
These lines select the estimates generated by the United Nations Department of Economic and Social Affairs (UN DESA) and the United States Census Bureau.
1921
1922
\index{United Nations}
1923
\index{United States Census Bureau}
1924
1925
Each result is a Pandas \py{Series}, which is like a \py{DataFrame} with just one column.
1926
1927
\index{Series}
1928
1929
The number \py{1e9} is a shorter, less error-prone way to write \py{1000000000} or one billion. When we divide a \py{Series} by a number, it divides all of the elements of the \py{Series}. From here on, we'll express population estimates in terms of billions.
1930
1931
1932
\section{Plotting}
1933
1934
Now we can plot the estimates like this:
1935
1936
\index{plot}
1937
1938
\begin{python}
1939
plot(census, ':', label='US Census')
1940
plot(un, '--', label='UN DESA')
1941
\end{python}
1942
1943
1944
The next two lines plot the \py{Series} objects. The {\bf format strings} \py{':'} and \py{'--'} indicate dotted and dashed lines. For more about format strings in Pyplot, see \url{http://modsimpy.com/plot}.
1945
1946
\index{format string}
1947
\index{Pyplot}
1948
1949
The \py{label} argument provides the string that appears in the legend.
1950
1951
\index{label}
1952
\index{legend}
1953
1954
\begin{figure}
1955
\centerline{\includegraphics[height=3in]{figs/chap05-fig01.pdf}}
1956
\caption{Estimates of world population, 1950--2016.}
1957
\label{chap05-fig01}
1958
\end{figure}
1959
1960
Figure~\ref{chap05-fig01} shows the result. The lines overlap almost completely; for most dates the difference between the two estimates is less than 1\%.
1961
1962
1963
\section{Constant growth model}
1964
1965
Suppose we want to predict world population growth over the next 50 or 100 years. We can do that by developing a model that describes how populations grow, fitting the model to the data we have so far, and then using the model to generate predictions.
1966
1967
\index{constant growth}
1968
1969
In the next few sections I demonstrate this process starting with simple models and gradually improving them.
1970
1971
\index{iterative modeling}
1972
1973
Although there is some curvature in the plotted estimates, it looks like world population growth has been close to linear since 1960 or so. So we'll start with a model that has constant growth.
1974
1975
To fit the model to the data, we'll compute the average annual growth from 1950 to 2016. Since the UN and Census data are so close, we'll use the Census data.
1976
1977
We can select a value from a \py{Series} using the bracket operator:
1978
\index{bracket operator}
1979
\index{operator!bracket}
1980
1981
\begin{python}
1982
census[1950]
1983
\end{python}
1984
1985
So we can get the total growth during the interval like this:
1986
1987
\begin{python}
1988
total_growth = census[2016] - census[1950]
1989
\end{python}
1990
1991
The numbers in brackets are called {\bf labels}, because they label the rows of the \py{Series} (not to be confused with the labels we saw in the previous section, which label lines in a graph).
1992
1993
\index{label}
1994
1995
In this example, the labels 2016 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 {\bf hard coding}; it is considered bad practice because if the data change in the future, we have to modify the program (see \url{http://modsimpy.com/hardcode}).
1996
1997
\index{hard coding}
1998
1999
It would be better to get the first and last labels from the \py{Series} like this:
2000
2001
\begin{python}
2002
t_0 = get_first_label(census)
2003
t_end = get_last_label(census)
2004
elapsed_time = t_end - t_0
2005
\end{python}
2006
2007
\py{get_first_label} and \py{get_last_label} are defined in \py{modsim.py}; as you might have guessed, they select the first and last labels from \py{census}.
2008
The difference between them is the elapsed time.
2009
2010
The ModSim library also defines \py{get_first_value} and \py{get_last_value}, which we can use to compute \py{total_growth}:
2011
2012
\begin{python}
2013
p_0 = get_first_value(census)
2014
p_end = get_last_value(census)
2015
total_growth = p_end - p_0
2016
\end{python}
2017
2018
Finally, we can compute average annual growth.
2019
2020
\begin{python}
2021
annual_growth = total_growth / elapsed_time
2022
\end{python}
2023
2024
The next step is to use this estimate to simulate population growth since 1950.
2025
2026
2027
\section{Simulation}
2028
2029
Our simulation will start with the observed population in 1950, \py{p_0}, and add \py{annual_growth} each year. To store the results, we'll use a \py{TimeSeries} object:
2030
2031
\index{TimeSeries}
2032
2033
\begin{python}
2034
results = TimeSeries()
2035
\end{python}
2036
2037
We can set the first value in the new \py{TimeSeries} by copying the first value from \py{census}:
2038
2039
\begin{python}
2040
results[t_0] = census[p_0]
2041
\end{python}
2042
2043
Then we set the rest of the values by simulating annual growth:
2044
2045
\begin{python}
2046
for t in linrange(t_0, t_end):
2047
results[t+1] = results[t] + annual_growth
2048
\end{python}
2049
2050
\py{linrange} is defined in the ModSim library. In this example it returns a NumPy array of integers from \py{t_0} to \py{t_end}, including the first but not the last.
2051
2052
\index{linrange}
2053
\index{NumPy}
2054
\index{array}
2055
2056
Each time through the loop, the loop variable \py{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 \py{annual_growth}. The last time through the loop, the value of \py{t} is 2015, so the last label in \py{results} is 2016, which is what we want.
2057
2058
\index{loop}
2059
\index{loop variable}
2060
2061
\begin{figure}
2062
\centerline{\includegraphics[height=3in]{figs/chap05-fig02.pdf}}
2063
\caption{Estimates of world population, 1950--2016, and a constant growth model.}
2064
\label{chap05-fig02}
2065
\end{figure}
2066
2067
Figure~\ref{chap05-fig02} shows the result. The model does not fit the data particularly well from 1950 to 1990, but after that, it's pretty good. Nevertheless, there are problems:
2068
2069
\begin{itemize}
2070
2071
\item 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 we expect them to depend on the current population.
2072
2073
\item According to this model, we would expect the population to keep growing at the same rate forever, and that does not seem reasonable.
2074
2075
\end{itemize}
2076
2077
We'll try out some different models in the next few sections, but first let's clean up the code.
2078
2079
Before you go on, you might want to read the notebook for this chapter, \py{chap05.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
2080
2081
2082
\chapter{Modeling growth}
2083
\label{chap06}
2084
In the previous chapter we simulated a model of world population with constant growth. In this chapter we see if we can make a better model with growth proportional to the population.
2085
2086
But first, we can improve the code from the previous chapter by encapsulating it in a function and using \py{System} objects.
2087
2088
\section{System objects}
2089
\label{nowwithsystem}
2090
2091
Like a \py{State} object, a \py{System} object contains variables and their values. The difference is:
2092
2093
\begin{itemize}
2094
2095
\item \py{State} objects contain state variables, which represent the state of the system, which get updated in the course of a simulation.
2096
2097
\item \py{System} objects contain {\bf system variables}, which represent parameters of the system, which usually don't get updated over the course of a simulation.
2098
2099
\end{itemize}
2100
2101
For example, in the bike share model, state variables include the number of bikes at each location, which get updated whenever a customer moves a bike. System variables include the number of locations, total number of bikes, and arrival rates at each location.
2102
2103
In the population model, the only state variable is the population. System variables include the annual growth rate, the initial time and population, and the end time.
2104
2105
Suppose we have the following variables, as computed in the previous chapter (assuming that \py{census} is a \py{Series} object):
2106
2107
\begin{python}
2108
t_0 = get_first_label(census)
2109
t_end = get_last_label(census)
2110
elapsed_time = t_end - t_0
2111
2112
p_0 = get_first_value(census)
2113
p_end = get_last_value(census)
2114
total_growth = p_end - p_0
2115
2116
annual_growth = total_growth / elapsed_time
2117
\end{python}
2118
2119
Some of these are parameters we need to simulate the system; others are temporary values we can discard. We can put the parameters we need into a \py{System} object like this:
2120
2121
\index{System object}
2122
2123
\begin{python}
2124
system = System(t_0=t_0,
2125
t_end=t_end,
2126
p_0=p_0,
2127
annual_growth=annual_growth)
2128
\end{python}
2129
2130
\py{t0} and \py{t_end} are the first and last years; \py{p_0} is the initial population, and \py{annual_growth} is the estimated annual growth.
2131
2132
Next we'll wrap the code from the previous chapter in a function:
2133
2134
\begin{python}
2135
def run_simulation1(system):
2136
results = TimeSeries()
2137
results[system.t_0] = system.p_0
2138
2139
for t in linrange(system.t_0, system.t_end):
2140
results[t+1] = results[t] + system.annual_growth
2141
2142
return results
2143
\end{python}
2144
2145
When \py{run_simulation1} runs, it stores the results in a \py{TimeSeries} and returns it.
2146
2147
\index{TimeSeries object}
2148
2149
The following function plots the results along with the estimates \py{census} and \py{un}:
2150
2151
\begin{python}
2152
def plot_results(census, un, timeseries, title):
2153
plot(census, ':', label='US Census')
2154
plot(un, '--', label='UN DESA')
2155
plot(timeseries, color='gray', label='model')
2156
2157
decorate(xlabel='Year',
2158
ylabel='World population (billion)',
2159
title=title)
2160
\end{python}
2161
2162
\index{plot}
2163
\index{decorate}
2164
2165
The \py{color} argument specifies the color of the line. For details on color specification in Pyplot, see \url{http://modsimpy.com/color}.
2166
2167
\index{Pyplot}
2168
\index{color}
2169
2170
Finally, we can run the simulation like this.
2171
2172
\begin{python}
2173
results = run_simulation1(system)
2174
plot_results(census, un, results, 'Constant growth model')
2175
\end{python}
2176
2177
The results are the same as Figure~\ref{chap05-fig02}.
2178
2179
It might not be obvious that using functions and \py{System} objects is a big improvement, and for a simple model that we run only once, maybe it's not. But as we work with more complex models, and when we run many simulations with different parameters, we'll see that the organization of the code makes a big difference.
2180
2181
Now let's see if we can improve the model.
2182
2183
2184
\section{Proportional growth model}
2185
2186
The biggest problem with the constant growth model is that it doesn't 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.
2187
2188
\index{proportional growth}
2189
2190
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:
2191
2192
\begin{python}
2193
def run_simulation2(system):
2194
results = TimeSeries()
2195
results[system.t_0] = system.p_0
2196
2197
for t in linrange(system.t_0, system.t_end):
2198
births = system.birth_rate * results[t]
2199
deaths = system.death_rate * results[t]
2200
results[t+1] = results[t] + births - deaths
2201
2202
return results
2203
\end{python}
2204
2205
Now we can choose the values of \py{birth_rate} and \py{death_rate} that best fit the data. Without trying too hard, I chose:
2206
2207
\begin{python}
2208
system.death_rate = 0.01
2209
system.birth_rate = 0.027
2210
\end{python}
2211
2212
Then I ran the simulation and plotted the results:
2213
2214
\begin{python}
2215
results = run_simulation2(system)
2216
plot_results(census, un, results, 'Proportional model')
2217
\end{python}
2218
2219
\begin{figure}
2220
\centerline{\includegraphics[height=3in]{figs/chap06-fig01.pdf}}
2221
\caption{Estimates of world population, 1950--2016, and a proportional model.}
2222
\label{chap06-fig01}
2223
\end{figure}
2224
2225
Figure~\ref{chap06-fig01} shows the results. The proportional model fits the data well from 1950 to 1965, but not so well after that. Overall, the {\bf 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.
2226
2227
In the next chapter we'll try one more time to find a model that makes sense and fits the data. But first, I want to make a few more improvements to the code.
2228
2229
2230
\section{Factoring out the update function}
2231
2232
\py{run_simulation1} and \py{run_simulation2} are nearly identical except for the body of the \py{for} loop, where we compute the population for the next year.
2233
2234
\index{update function}
2235
\index{function!update}
2236
2237
Rather than repeat identical code, we can separate the things that change from the things that don't. First, I'll pull out the update code from \py{run_simulation2} and make it a function:
2238
2239
\begin{python}
2240
def update_func1(pop, t, system):
2241
births = system.birth_rate * pop
2242
deaths = system.death_rate * pop
2243
return pop + births - deaths
2244
\end{python}
2245
2246
This function takes as arguments the current population, current year, and a \py{System} object; it returns the computed population for the next year.
2247
2248
This update function does not use \py{t}, so we could leave it out. But we will see other functions that need it, and it is convenient if they all take the same parameters, used or not.
2249
2250
Now we can write a function that runs any model:
2251
2252
\begin{python}
2253
def run_simulation(system, update_func):
2254
results = TimeSeries()
2255
results[system.t_0] = system.p_0
2256
2257
for t in linrange(system.t_0, system.t_end):
2258
results[t+1] = update_func(results[t], t, system)
2259
2260
return results
2261
\end{python}
2262
2263
This function demonstrates a feature we have not seen before: it takes a function as a parameter! When we call \py{run_simulation}, the second parameter is a function, like \py{update_func1}, that computes the population for the next year.
2264
2265
\index{function!as parameter}
2266
2267
Here's how we call it:
2268
2269
\begin{python}
2270
results = run_simulation(system, update_func1)
2271
\end{python}
2272
2273
Passing a function as an argument is the same as passing any other value. The argument, which is \py{update_func1} in this example, gets assigned to the parameter, which is called \py{update_func}. Inside \py{run_simulation}, we can run \py{update_func} just like any other function.
2274
2275
The loop in \py{run_simulation} calls \py{update_func1} once for each year between \py{t_0} and \py{t_end-1}. The result is the same as Figure~\ref{chap06-fig01}.
2276
2277
2278
\section{Combining birth and death}
2279
2280
While we are at it, we can also simplify the code by combining births and deaths to compute the net growth rate. Instead of two parameters, \py{birth_rate} and \py{death_rate}, we can write the update function in terms of a single parameter that represents the difference:
2281
2282
\begin{python}
2283
system.alpha = system.birth_rate - system.death_rate
2284
\end{python}
2285
2286
The name of this parameter, \py{alpha}, is the conventional name for a proportional growth rate.
2287
2288
Here's the modified version of \py{update_func1}:
2289
2290
\begin{python}
2291
def update_func2(pop, t, system):
2292
net_growth = system.alpha * pop
2293
return pop + net_growth
2294
\end{python}
2295
2296
And here's how we run it:
2297
2298
\begin{python}
2299
results = run_simulation(system, update_func2)
2300
\end{python}
2301
2302
Again, the result is the same as Figure~\ref{chap06-fig01}.
2303
2304
Before you go on, you might want to read the notebook for this chapter, \py{chap06.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
2305
2306
2307
2308
\chapter{Quadratic growth}
2309
\label{chap07}
2310
2311
In the previous chapter we developed a population model where net growth during each time step is proportional to the current population. This model seems more realistic than the constant growth model, but it does not fit the data as well.
2312
2313
There are a few things we could try to improve the model:
2314
2315
\begin{itemize}
2316
2317
\item Maybe the net growth rate varies over time.
2318
2319
\item Maybe net growth depends on the current population, but the relationship is quadratic, not linear.
2320
2321
\end{itemize}
2322
2323
In the notebook for this chapter, you will have a chance to try the first option. In this chapter, we explore the second.
2324
2325
2326
\section{Quadratic growth}
2327
\label{quadratic}
2328
2329
It makes sense that net growth should depend on the current population, but maybe it's not a linear relationship, like this:
2330
2331
\begin{python}
2332
net_growth = system.alpha * pop
2333
\end{python}
2334
2335
Maybe it's a quadratic relationship, like this:
2336
2337
\index{quadratic growth}
2338
2339
\begin{python}
2340
net_growth = system.alpha * pop + system.beta * pop**2
2341
\end{python}
2342
2343
We can test that conjecture with a new update function:
2344
2345
\begin{python}
2346
def update_func_quad(pop, t, system):
2347
net_growth = system.alpha * pop + system.beta * pop**2
2348
return pop + net_growth
2349
\end{python}
2350
2351
Now we need two parameters. I chose the following values by trial and error; we will see better ways to do it later.
2352
2353
\index{parameter}
2354
2355
\begin{python}
2356
system.alpha = 0.025
2357
system.beta = -0.0018
2358
\end{python}
2359
2360
And here's how we run it:
2361
2362
\begin{python}
2363
results = run_simulation(system, update_func_quad)
2364
\end{python}
2365
2366
\begin{figure}
2367
\centerline{\includegraphics[height=3in]{figs/chap07-fig01.pdf}}
2368
\caption{Estimates of world population, 1950--2016, and a quadratic model.}
2369
\label{chap07-fig01}
2370
\end{figure}
2371
2372
Figure~\ref{chap07-fig01} shows the result. The model fits the data well over the whole range, with just a bit of space between them in the 1960s.
2373
2374
Of course, we should expect the quadratic model to fit better than the constant and proportional models 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.
2375
2376
\index{quality of fit}
2377
\index{data}
2378
\index{fitting data}
2379
2380
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.
2381
2382
To understand it, let's look at net growth as a function of population. Here's how we compute it:
2383
2384
\begin{python}
2385
pop_array = linspace(0, 15, 100)
2386
net_growth_array = (system.alpha * pop_array +
2387
system.beta * pop_array**2)
2388
\end{python}
2389
2390
\py{pop_array} contains 100 equally spaced values from 0 to 15. \py{net_growth_array} contains the corresponding 100 values of net growth. We can plot the results like this:
2391
2392
\begin{python}
2393
plot(pop_array, net_growth_array)
2394
\end{python}
2395
2396
Previously we have used \py{plot} with \py{Series} objects. In this example, we use two NumPy arrays, corresponding to the x and y axes.
2397
2398
\begin{figure}
2399
\centerline{\includegraphics[height=3in]{figs/chap07-fig02.pdf}}
2400
\caption{Net growth as a function of population.}
2401
\label{chap07-fig02}
2402
\end{figure}
2403
2404
Figure~\ref{chap07-fig02} 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:
2405
\index{regime}
2406
2407
\begin{itemize}
2408
2409
\item 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.
2410
2411
\item Between 4 billion and 10 billion, the population grows quickly because there are a lot of people.
2412
2413
\item Above 10 billion, population grows more slowly; this behavior models the effect of resource limitations that lower birth rates or increase death rates.
2414
2415
\item Above 14 billion, resources are so limited that the death rate exceeds the birth rate and net growth becomes negative.
2416
2417
\end{itemize}
2418
2419
Just below 14 billion, there is a point 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 {\bf equilibrium}.
2420
2421
\index{equilibrium}
2422
2423
2424
\section{Equilibrium}
2425
\label{equilibrium}
2426
2427
To find the equilibrium point, we can find the roots, or zeros, of this equation:
2428
%
2429
\[ \Delta p = \alpha p + \beta p^2 \]
2430
%
2431
where $\Delta p$ is net population growth, $p$ is current population, and $\alpha$ and $\beta$ are the parameters of the model. We can rewrite the right hand side like this:
2432
%
2433
\[ \Delta p = p (\alpha + \beta p) \]
2434
%
2435
which is $0$ when $p=0$ or $p=-\alpha/\beta$. In this example, $\alpha = 0.025$ and $\beta = -0.0018$, so $-\alpha/\beta = 13.9$.
2436
2437
In the context of population modeling, the quadratic model is more conventionally written like this:
2438
%
2439
\[ \Delta p = r p (1 - p / K) \]
2440
%
2441
This is the same model; it's just a different way to {\bf parameterize} it. Given $\alpha$ and $\beta$, we can compute $r=\alpha$ and $K=-\alpha/\beta$.
2442
2443
\index{parameterize}
2444
2445
In this version, it is easier to interpret the parameters: $r$ is the maximum growth rate, observed when $p$ is small, and $K$ is the equilibrium point. $K$ is also called the {\bf carrying capacity}, since it indicates the maximum population the environment can sustain.
2446
2447
\index{carrying capacity}
2448
2449
In the next chapter we use the models we have developed to generate predictions.
2450
2451
\section{Dysfunctions}
2452
2453
When people learn about functions, there are a few things they often find confusing. In this section I present and explain some common problems.
2454
2455
As an example, suppose you want a function that takes as a parameter \py{System} object with variables \py{alpha} and \py{beta}, and computes the carrying capacity, \py{-alpha/beta}. Here's a good solution:
2456
2457
\begin{python}
2458
def carrying_capacity(system):
2459
K = -system.alpha / system.beta
2460
return K
2461
2462
sys1 = System(alpha=0.025, beta=-0.0018)
2463
pop = carrying_capacity(sys1)
2464
print(pop)
2465
\end{python}
2466
2467
Now let's see all the ways that can go wrong.
2468
2469
Dysfunction \#1: Not using parameters. In the following version, the function doesn't take any parameters; when \py{sys1} appears inside the function, it refers to the object we create outside the function.
2470
2471
\begin{python}
2472
def carrying_capacity():
2473
K = -sys1.alpha / sys1.beta
2474
return K
2475
2476
sys1 = System(alpha=0.025, beta=-0.0018)
2477
pop = carrying_capacity()
2478
print(pop)
2479
\end{python}
2480
2481
This version actually works, but it is not as versatile as it could be. If there are several \py{System} objects, this function can only work with one of them, and only if it is named \py{sys1}.
2482
2483
Dysfunction \#2: Clobbering the parameters. When people first learn about parameters, they often write functions like this:
2484
2485
\begin{python}
2486
# WRONG
2487
def carrying_capacity(system):
2488
system = System(alpha=0.025, beta=-0.0018)
2489
K = -system.alpha / system.beta
2490
return K
2491
2492
sys1 = System(alpha=0.03, beta=-0.002)
2493
pop = carrying_capacity(sys1)
2494
print(pop)
2495
\end{python}
2496
2497
In this example, we have a \py{System} object named \py{sys1} that gets passed as an argument to \py{carrying_capacity}. But when the function runs, it ignores the argument and immediately replaces it with a new \py{System} object. As a result, this function always returns the same value, no matter what argument is passed.
2498
2499
When you write a function, you generally don't 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.
2500
2501
2502
Dysfunction \#3: No return value. Here's a version that computes the value of \py{K} but doesn't return it.
2503
2504
\begin{python}
2505
# WRONG
2506
def carrying_capacity(system):
2507
K = -system.alpha / system.beta
2508
2509
sys1 = System(alpha=0.025, beta=-0.0018)
2510
pop = carrying_capacity(sys1)
2511
print(pop)
2512
\end{python}
2513
2514
A function that doesn't have a return statement always returns a special value called \py{None}, so in this example the value of \py{pop} is \py{None}. If you are debugging a program and find that the value of a variable is \py{None} when it shouldn't be, a function without a return statement is a likely cause.
2515
\index{None}
2516
2517
Dysfunction \#4: Ignoring the return value. Finally, here's a version where the function is correct, but the way it's used is not.
2518
2519
\begin{python}
2520
# WRONG
2521
def carrying_capacity(system):
2522
K = -system.alpha / system.beta
2523
return K
2524
2525
sys1 = System(alpha=0.025, beta=-0.0018)
2526
carrying_capacity(sys1)
2527
print(K)
2528
\end{python}
2529
2530
In this example, \py{carrying_capacity} runs and returns \py{K}, but the return value is dropped.
2531
2532
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 \py{pop} like this:
2533
2534
\begin{python}
2535
print(carrying_capacity(sys1))
2536
\end{python}
2537
2538
Or if you had more than one system, you could compute the total carrying capacity like this:
2539
2540
\begin{python}
2541
total = carrying_capacity(sys1) + carrying_capacity(sys2)
2542
\end{python}
2543
2544
Before you go on, you might want to read the notebook for this chapter, \py{chap07.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
2545
2546
2547
2548
2549
\chapter{Prediction}
2550
\label{chap08}
2551
2552
In the previous chapter we developed a quadratic model of world population growth from 1950 to 2016. It is a simple model, but it fits the data well and the mechanisms it's based on are plausible.
2553
2554
In this chapter we'll use the quadratic model to generate projections of future growth, and compare our results to projections from actual demographers. Also, we'll represent the models from the previous chapters as differential equations and solve them analytically.
2555
2556
\index{prediction}
2557
\index{projection}
2558
2559
2560
\section{Generating projections}
2561
2562
We'll start with the quadratic model from Section~\ref{quadratic}, which is based on this update function:
2563
\index{quadratic growth}
2564
2565
\begin{python}
2566
def update_func_quad(pop, t, system):
2567
net_growth = system.alpha * pop + system.beta * pop**2
2568
return pop + net_growth
2569
\end{python}
2570
2571
As we saw in the previous chapter, we can get the start date, end date, and initial population from \py{census}, which is a series that contains world population estimates generated by the U.S. Census:
2572
2573
\begin{python}
2574
t_0 = get_first_label(census)
2575
t_end = get_last_label(census)
2576
p_0 = census[t_0]
2577
\end{python}
2578
2579
Now we can create a \py{System} object:
2580
\index{System object}
2581
2582
\begin{python}
2583
system = System(t_0=t_0,
2584
t_end=t_end,
2585
p_0=p_0,
2586
alpha=0.025,
2587
beta=-0.0018)
2588
\end{python}
2589
2590
And run the model:
2591
2592
\begin{python}
2593
results = run_simulation(system, update_func_quad)
2594
\end{python}
2595
2596
We have already seen the results in Figure~\ref{chap07-fig01}. Now, to generate a projection, the only thing we have to change is \py{t_end}:
2597
2598
\begin{python}
2599
system.t_end = 2250
2600
results = run_simulation(system, update_func_quad)
2601
\end{python}
2602
2603
\begin{figure}
2604
\centerline{\includegraphics[height=3in]{figs/chap08-fig01.pdf}}
2605
\caption{Quadratic model of world population growth, with projection from 2016 to 2250.}
2606
\label{chap08-fig01}
2607
\end{figure}
2608
2609
Figure~\ref{chap08-fig01} shows the result, with a projection until 2250. According to this model, population growth will continue almost linearly for the next 50--100 years, then slow over the following 100 years, approaching 13.9 billion by 2250.
2610
2611
I am using the word ``projection" deliberately, rather than ``prediction", with the following distinction: ``prediction" implies something like ``this is what we should reasonably expect to happen, at least approximately"; ``projection" implies something like ``if this model is actually a good description of what is happening in this system, and if nothing in the future causes the parameters of the model to change, this is what would happen."
2612
2613
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.
2614
2615
The quadratic model we've 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.
2616
2617
\index{carrying capacity}
2618
2619
If that assumption is valid, we might be able to use actual population growth to estimate carrying capacity, especially if we observe the transition into the regime where the growth rate starts to fall.
2620
2621
But in the case of world population growth, those conditions don't apply. Over the last 50 years, the net growth rate has leveled off, but not yet started to fall, so we don't have enough data to make a credible estimate of carrying capacity. And resource limitations are probably {\em not} the primary reason growth has slowed. As evidence, consider:
2622
2623
\begin{itemize}
2624
2625
\item First, the death rate is not increasing; rather, it has declined from 1.9\% in 1950 to 0.8\% now (see \url{http://modsimpy.com/mortality}). So the decrease in net growth is due entirely to declining birth rates.
2626
2627
\index{mortality rate}
2628
2629
\item 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.
2630
2631
\index{birth rate}
2632
2633
\end{itemize}
2634
2635
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 assumptions of the model are not strictly true. For example, population growth might behave {\em as if} it is resource limited, even if the actual mechanism is something else.
2636
2637
In fact, demographers who study population growth often use models similar to ours. In the next section, we'll compare our projections to theirs.
2638
2639
2640
\section{Comparing projections}
2641
2642
Table 3 from \url{http://modsimpy.com/worldpop} contains projections from the U.S. Census and the United Nations DESA:
2643
2644
\begin{python}
2645
table3 = tables[3]
2646
\end{python}
2647
2648
For some years, one agency or the other has not published a projection, so some elements of \py{table3} contain the special value \py{NaN}, which stands for ``not a number". \py{NaN} is often used to indicate missing data.
2649
2650
\index{not a number}
2651
\index{NaN}
2652
\index{missing data}
2653
2654
\begin{figure}
2655
\centerline{\includegraphics[height=3in]{figs/chap08-fig02.pdf}}
2656
\caption{Projections of world population generated by the U.S. Census Bureau, the United Nations, and our quadratic model.}
2657
\label{chap08-fig02}
2658
\end{figure}
2659
2660
Pandas provides functions that deal with missing data, including \py{dropna}, which removes any elements in a series that contain \py{NaN}. Using \py{dropna}, we can plot the projections like this:
2661
2662
\index{Pandas}
2663
\index{dropna}
2664
2665
\begin{python}
2666
def plot_projections(table):
2667
census_proj = table.census / 1e9
2668
un_proj = table.un / 1e9
2669
2670
plot(census_proj.dropna(), 'b:', label='US Census')
2671
plot(un_proj.dropna(), 'g--', label='UN DESA')
2672
\end{python}
2673
2674
The format string \py{'b:'} indicates a blue dotted line; \py{g--} indicates a green dashed line.
2675
2676
\index{format string}
2677
\index{plot}
2678
2679
We can run our model over the same interval:
2680
2681
\begin{python}
2682
system.t_end = 2100
2683
results = run_simulation(system, update_func_quad)
2684
\end{python}
2685
2686
And compare our projections to theirs. Figure~\ref{chap08-fig02} 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 country, where conditions are different, and they take into account expected economic development.
2687
2688
\index{demography}
2689
2690
Nevertheless, their projections are qualitatively similar to ours, and theirs differ from each other almost as much as they differ from ours. So the results from this model, simple as it is, are not entirely crazy.
2691
2692
Before you go on, you might want to read the notebook for this chapter, \py{chap08.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
2693
2694
2695
\chapter{Analysis}
2696
\label{chap09}
2697
2698
In this chapter we express the models from previous chapters as difference equations and differential equations, solve the equations, and derive the functional forms of the solutions. We also discuss the complementary roles of mathematical analysis and simulation.
2699
2700
2701
\section{Recurrence relations}
2702
2703
The population models in the previous chapter and this one are simple enough that we didn't really need to run simulations. We could have solved them mathematically. For example, we wrote the constant growth model like this:
2704
2705
\begin{python}
2706
model[t+1] = model[t] + annual_growth
2707
\end{python}
2708
2709
In mathematical notation, we would write the same model like this:
2710
%
2711
\[ x_{n+1} = x_n + c \]
2712
%
2713
where $x_n$ is the population during year $n$, $x_0$ is a given initial population, and $c$ is constant annual growth. This way of representing the model is a {\bf recurrence relation}; see \url{http://modsimpy.com/recur}.
2714
2715
\index{recurrence relation}
2716
2717
Sometimes it is possible to solve a recurrence relation by writing an equation that computes $x_n$, for a given value of $n$, directly; that is, without computing the intervening values from $x_1$ through $x_{n-1}$.
2718
2719
In the case of constant growth we can see that $x_1 = x_0 + c$, and $x_2 = x_1 + c$. Combining these, we get $x_2 = x_0 + 2c$, then $x_3 = x_0 + 3c$, and it is not hard to conclude that in general
2720
%
2721
\[ x_n = x_0 + nc \]
2722
%
2723
So if we want to know $x_{100}$ and we don't care about the other values, we can compute it with one multiplication and one addition.
2724
2725
We can also write the proportional model as a recurrence relation:
2726
%
2727
\[ x_{n+1} = x_n + \alpha x_n \]
2728
%
2729
Or more conventionally as:
2730
%
2731
\[ x_{n+1} = x_n (1 + \alpha) \]
2732
%
2733
Now we can see that $x_1 = x_0 (1 + \alpha)$, and $x_2 = x_0 (1 + \alpha)^2$, and in general
2734
%
2735
\[ x_n = x_0 (1 + \alpha)^n \]
2736
%
2737
This result is a {\bf geometric progression}; see \url{http://modsimpy.com/geom}. When $\alpha$ is positive, the factor $1+\alpha$ is greater than 1, so the elements of the sequence grow without bound.
2738
2739
\index{geometric progression}
2740
\index{quadratic growth}
2741
2742
Finally, we can write the quadratic model like this:
2743
%
2744
\[ x_{n+1} = x_n + \alpha x_n + \beta x_n^2 \]
2745
%
2746
or with the more conventional parameterization like this:
2747
%
2748
\[ x_{n+1} = x_n + r x_n (1 - x_n / K) \]
2749
%
2750
There is no analytic solution to this equation, but we can approximate it with a differential equation and solve that, which is what we'll do in the next section.
2751
2752
2753
\section{Differential equations}
2754
\label{diffeq}
2755
2756
Starting again with the constant growth model
2757
%
2758
\[ x_{n+1} = x_n + c \]
2759
%
2760
If we define $\Delta x$ to be the change in $x$ from one time step to the next, we can write:
2761
%
2762
\[ \Delta x = x_{n+1} - x_n = c \]
2763
%
2764
If we define $\Delta 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:
2765
%
2766
\[ \frac{\Delta x}{\Delta t} = c \]
2767
%
2768
This model is {\bf 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 {\bf continuous} model might be more realistic.
2769
2770
\index{discrete}
2771
\index{continuous}
2772
\index{time step}
2773
2774
We can make this model continuous by writing the rate of change in the form of a derivative:
2775
%
2776
\[ \frac{dx}{dt} = c \]
2777
%
2778
This way of representing the model is a {\bf differential equation}; see \url{http://modsimpy.com/diffeq}.
2779
2780
\index{differential equation}
2781
2782
We can solve this differential equation if we multiply both sides by $dt$:
2783
%
2784
\[ dx = c dt \]
2785
%
2786
And then integrate both sides:
2787
%
2788
\[ x(t) = c t + x_0 \]
2789
%
2790
Similarly, we can write the proportional growth model like this:
2791
%
2792
\[ \frac{\Delta x}{\Delta t} = \alpha x \]
2793
%
2794
And as a differential equation like this:
2795
%
2796
\[ \frac{dx}{dt} = \alpha x \]
2797
%
2798
If we multiply both sides by $dt$ and divide by $x$, we get
2799
%
2800
\[ \frac{1}{x}~dx = \alpha~dt \]
2801
%
2802
Now we integrate both sides, yielding:
2803
%
2804
\[ \ln x = \alpha t + K \]
2805
%
2806
where $\ln$ is the natural logarithm and $K$ is the constant of integration. Exponentiating both sides\footnote{The exponential function can be written $\exp(x)$ or $e^x$. In this book I use the first form because it resembles the Python code. }, we have
2807
%
2808
\[ \exp(\ln(x)) = \exp(\alpha t + K) \]
2809
%
2810
which we can rewrite
2811
%
2812
\[ x = \exp(\alpha t) \exp(K) \]
2813
%
2814
Since $K$ is an arbitrary constant, $\exp(K)$ is also an arbitrary constant, so we can write
2815
%
2816
\[ x = C \exp(\alpha t) \]
2817
%
2818
where $C = \exp(K)$. There are many solutions to this differential equation, with different values of $C$. The particular solution we want is the one that has the value $x_0$ when $t=0$.
2819
2820
When $t=0$, $x(t) = C$, so $C = x_0$ and the solution we want is
2821
%
2822
\[ x(t) = x_0 \exp(\alpha t) \]
2823
%
2824
If you would like to see this derivation done more carefully, you might like this video: \url{http://modsimpy.com/khan1}.
2825
2826
\index{logarithm}
2827
\index{exponentiation}
2828
\index{integration}
2829
\index{constant of integration}
2830
2831
2832
\section{Analysis and simulation}
2833
2834
Once you have designed a model, there are generally two ways to proceed: simulation and analysis. Simulation often comes in the form of a computer program that models changes in a system over time, like births and deaths, or bikes moving from place to place. Analysis often comes in the form of algebra; that is, symbolic manipulation using mathematical notation.
2835
2836
\index{analysis}
2837
\index{algebra}
2838
\index{symbolic manipulation}
2839
2840
Analysis and simulation have different capabilities and limitations. Simulation is generally more versatile; it is easy to add and remove parts of a program and test many versions of a model, as we have done in the previous examples.
2841
2842
But there are several things we can do with analysis that are harder or impossible with simulations:
2843
2844
\begin{itemize}
2845
2846
\item With analysis we can sometimes compute, exactly and efficiently, a value that we could only approximate, less efficiently, with simulation. For example, in Figure~\ref{chap07-fig02}, we can see that net growth goes to zero near 14 billion, and we could estimate carrying capacity using a numerical search algorithm (more about that later). But with the analysis in Section~\ref{equilibrium}, we get the general result that $K=-\alpha/\beta$.
2847
2848
\item Analysis often provides ``computational shortcuts", that is, the ability to jump forward in time to compute the state of a system many time steps in the future without computing the intervening states.
2849
2850
\index{time step}
2851
2852
\item We can use analysis to state and prove generalizations about models; for example, we might prove that certain results will always or never occur. With simulations, we can show examples and sometimes find counterexamples, but it is hard to write proofs.
2853
2854
\index{proof}
2855
2856
\item 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.
2857
2858
\index{regime}
2859
2860
\end{itemize}
2861
2862
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 behind it" as if our world is the mere shadow of a world of ideal mathematical entities\footnote{I am not making this up; see \url{http://modsimpy.com/plato}.}.
2863
2864
\index{Plato}
2865
2866
This is, of course, nonsense. Mathematical notation is a language designed by humans for a purpose, specifically to facilitate symbolic manipulations like algebra. Similarly, programming languages are designed for a purpose, specifically to represent computational ideas and run programs.
2867
2868
\index{math notation}
2869
\index{programming languages}
2870
2871
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.
2872
2873
2874
\section{Analysis with WolframAlpha}
2875
2876
Until recently, most analysis was done by rubbing graphite on wood pulp\footnote{Or ``rubbing the white rock on the black rock", a line I got from Woodie Flowers, who got it from Stephen Jacobsen.}, a process that is laborious and error-prone. A useful alternative is symbolic computation. If you have used services like WolframAlpha, you have used symbolic computation.
2877
2878
\index{symbolic computation}
2879
\index{WolframAlpha}
2880
2881
For example, if you go to \url{https://www.wolframalpha.com/} and type
2882
2883
\begin{python}
2884
df(t) / dt = alpha f(t)
2885
\end{python}
2886
2887
WolframAlpha infers that \py{f(t)} is a function of \py{t} and \py{alpha} is a parameter; it classifies the query as a ``first-order linear ordinary differential equation", and reports the general solution:
2888
%
2889
\[ f(t) = c_1 \exp(\alpha t) \]
2890
%
2891
If you add a second equation to specify the initial condition:
2892
2893
\begin{python}
2894
df(t) / dt = alpha f(t), f(0) = p_0
2895
\end{python}
2896
2897
WolframAlpha reports the particular solution:
2898
2899
\[ f(t) = p_0 \exp(\alpha t) \]
2900
2901
WolframAlpha is based on Mathematica, a powerful programming language designed specifically for symbolic computation.
2902
2903
\index{Mathematica}
2904
2905
2906
\section{Analysis with SymPy}
2907
2908
Python has a library called SymPy that provides symbolic computation tools similar to Mathematica. They are not as easy to use as WolframAlpha, but they have some other advantages.
2909
2910
\index{SymPy}
2911
2912
Before we can use SymPy, we have to import it:
2913
2914
\index{import statement}
2915
\index{statement!import}
2916
2917
\begin{python}
2918
from sympy import *
2919
\end{python}
2920
2921
SymPy defines many functions, and some of them conflict with functions defined in \py{modsim} and the other libraries we're using. To avoid these conflicts, I suggest that you do symbolic computation with SymPy in a separate notebook.
2922
2923
SymPy defines a \py{Symbol} object that represents symbolic variable names, functions, and other mathematical entities.
2924
2925
\index{Symbol object}
2926
2927
The \py{symbols} function takes a string and returns \py{Symbol} objects. So if we run this assignment:
2928
2929
\begin{python}
2930
t = symbols('t')
2931
\end{python}
2932
2933
Python understands that \py{t} is a symbol, not a numerical value. If we now run
2934
2935
\begin{python}
2936
expr = t + 1
2937
\end{python}
2938
2939
Python doesn't try to perform numerical addition; rather, it creates a new \py{Symbol} that represents the sum of \py{t} and \py{1}. We can evaluate the sum using \py{subs}, which substitutes a value for a symbol. This example substitutes 2 for \py{t}:
2940
2941
\begin{python}
2942
expr.subs(t, 2)
2943
\end{python}
2944
2945
The result is 3.
2946
2947
Functions in SymPy are represented by a special kind of \py{Symbol}:
2948
2949
\begin{python}
2950
f = Function('f')
2951
\end{python}
2952
2953
Now if we write \py{f(t)}, we get an object that represents the evaluation of a function, $f$, at a value, $t$. But again SymPy doesn't actually try to evaluate it.
2954
2955
2956
\section{Differential equations in SymPy}
2957
2958
SymPy provides a function, \py{diff}, that can differentiate a function. We can apply it to \py{f(t)} like this:
2959
2960
\index{differential equation}
2961
\index{SymPy}
2962
2963
\begin{python}
2964
dfdt = diff(f(t), t)
2965
\end{python}
2966
2967
The result is a \py{Symbol} that represents the derivative of \py{f} with respect to \py{t}. But again, SymPy doesn't try to compute the derivative yet.
2968
2969
\index{Symbol object}
2970
2971
To represent a differential equation, we use \py{Eq}:
2972
2973
\begin{python}
2974
alpha = symbols('alpha')
2975
eq1 = Eq(dfdt, alpha*f(t))
2976
\end{python}
2977
2978
The result is an object that represents an equation, which is displayed like this:
2979
%
2980
\[ \frac{d}{d t} f{\left (t \right )} = \alpha f{\left (t \right )} \]
2981
%
2982
Now we can use \py{dsolve} to solve this differential equation:
2983
2984
\begin{python}
2985
solution_eq = dsolve(eq1)
2986
\end{python}
2987
2988
The result is the equation
2989
%
2990
\[ f{\left (t \right )} = C_{1} \exp(\alpha t) \]
2991
%
2992
This is the {\bf general solution}, which still contains an unspecified constant, $C_1$. To get the {\bf particular solution} where $f(0) = p_0$, we substitute \py{p_0} for \py{C1}. First, we have to create two more symbols:
2993
2994
\index{general solution}
2995
\index{particular solution}
2996
2997
\begin{python}
2998
C1, p_0 = symbols('C1 p_0')
2999
\end{python}
3000
3001
Now we can perform the substitution:
3002
3003
\begin{python}
3004
particular = solution_eq.subs(C1, p_0)
3005
\end{python}
3006
3007
The result is
3008
%
3009
\[ f{\left (t \right )} = p_{0} \exp(\alpha t) \]
3010
%
3011
This function is called the {\bf exponential growth curve}; see \url{http://modsimpy.com/expo}.
3012
3013
\index{exponential growth}
3014
3015
3016
\section{Solving the quadratic growth model}
3017
3018
In the notebook for this chapter, you will see how to use the same tools to solve the quadratic growth model with parameters $r$ and $K$. The general solution is
3019
%
3020
\[ f{\left (t \right )} = \frac{K \exp(C_{1} K + r t)}{\exp(C_{1} K + r t) - 1} \]
3021
%
3022
To get the particular solution where $f(0) = p_0$, we evaluate the general solution at $t=0$, which yields:
3023
%
3024
\[ f(0) = \frac{K \exp(C_{1} K)}{\exp(C_{1} K) - 1} \]
3025
%
3026
Then we set this expression equal to $p_0$ and solve for $C_1$. The result is:
3027
%
3028
\[ C_1 = \frac{1}{K} \ln{\left (- \frac{p_{0}}{K - p_{0}} \right )} \]
3029
%
3030
Finally, we substitute this value of $C_1$ into the general solution, which yields:
3031
%
3032
\[ f(t) = \frac{K p_{0} \exp(r t)}{K + p_{0} \exp(r t) - p_{0}} \]
3033
%
3034
This function is called the {\bf logistic growth curve}; see \url{http://modsimpy.com/logistic}. In the context of growth models, the logistic function is often written, equivalently,
3035
%
3036
\[ f(t) = \frac{K}{1 + A \exp(-rt)} \]
3037
%
3038
where $A = (K - p_0) / p_0$.
3039
3040
If you would like to see this differential equation solved by hand, you might like this video: \url{http://modsimpy.com/khan2}
3041
\index{quadratic growth}
3042
\index{logistic function}
3043
3044
3045
\section{Summary}
3046
3047
The following tables summarize the results so far:
3048
3049
\begin{tabular}{l|l}
3050
\hline
3051
Growth type & Discrete (difference equation) \\
3052
\hline
3053
Constant & linear: $x_n = p_0 + \alpha n$ \\
3054
3055
Proportional & geometric: $x_n = p_0(1+\alpha)^n$ \\
3056
3057
\end{tabular}
3058
3059
\begin{tabular}{l|l}
3060
\hline
3061
& Continuous (differential equation) \\
3062
\hline
3063
Constant & linear: $x(t) = p_0 + \alpha t$ \\
3064
3065
Proportional & exponential: $x(t) = p_0 \exp(\alpha t)$ \\
3066
3067
Quadratic & logistic: $x(t) = K / (1 + A\exp(-rt))$ \\
3068
\end{tabular}
3069
3070
What I've been calling the constant growth model is more commonly called ``linear growth" because the solution is a line. Similarly, what I've called proportional is commonly called ``exponential", and what I've called quadratic is commonly called ``logistic". I avoided these terms until now because they are based on results we had not derived yet.
3071
3072
\index{linear growth}
3073
\index{exponential growth}
3074
\index{logistic growth}
3075
3076
Before you go on, you might want to read the notebook for this chapter, \py{chap09sympy.ipynb}. For instructions on downloading and running the code, see Section~\ref{code}.
3077
3078
3079
\chapter{Case studies}
3080
\label{chap10}
3081
3082
This chapter reviews the computational patterns we have seen so far and presents exercises where you can apply them.
3083
3084
\section{Computational tools}
3085
3086
In Chapter~\ref{chap01} we used Pint to define units and perform calculations with units:
3087
3088
\begin{code}
3089
meter = UNITS.meter
3090
second = UNITS.second
3091
a = 9.8 * meter / second**2
3092
\end{code}
3093
3094
In Chapter~\ref{chap02} we defined a \py{State} object that contains variables that represent the state of a system, usually changing over time:
3095
3096
\begin{code}
3097
bikeshare = State(olin=10, wellesley=2)
3098
\end{code}
3099
3100
We used update operators like \py{+=} and \py{-=} to change state variables. We used \py{print} statements to display the values of variables.
3101
3102
We used the \py{flip} function to simulate random arrivals, and used \py{if} statements to check the results.
3103
3104
We learned to define new functions that take parameters:
3105
3106
\begin{code}
3107
def step(p1, p2):
3108
if flip(p1):
3109
bike_to_wellesley()
3110
3111
if flip(p2):
3112
bike_to_olin()
3113
\end{code}
3114
3115
We used a \py{for} loop with the \py{range} function to execute the body of the loop a specified number of times.
3116
3117
\begin{code}
3118
for i in range(4):
3119
step(p1, p2)
3120
\end{code}
3121
3122
We learned to create a \py{TimeSeries} object and use it to store the value of a state variable as it changes over time:
3123
3124
\begin{code}
3125
results = TimeSeries()
3126
3127
for i in range(10):
3128
step(0.3, 0.2)
3129
results[i] = bikeshare.olin
3130
\end{code}
3131
3132
We used \py{plot} to plot the results, \py{decorate} to label the axes, and \py{savefig} to save the figure.
3133
3134
\begin{code}
3135
plot(results, label='Olin')
3136
decorate(xlabel='Time step (min)',
3137
ylabel='Number of bikes')
3138
savefig('chap01-fig01.pdf)
3139
\end{code}
3140
3141
In Chapter~\ref{chap03} we used comparison operators to check for certain conditions and the \py{return} statement to end the execution of a function.
3142
3143
\begin{code}
3144
def bike_to_olin(state):
3145
if state.wellesley == 0:
3146
state.wellesley_empty += 1
3147
return
3148
state.wellesley -= 1
3149
state.olin += 1
3150
\end{code}
3151
3152
In Chapter~\ref{chap04} we wrote a version of \py{run_simulation} that uses a \py{return} statement to return a value:
3153
3154
\begin{code}
3155
def run_simulation(p1, p2, num_steps):
3156
state = State(olin=10, wellesley=2,
3157
olin_empty=0, wellesley_empty=0)
3158
3159
for i in range(num_steps):
3160
step(state, p1, p2)
3161
3162
return state
3163
\end{code}
3164
3165
This version of \py{run_simulation} returns the final value of \py{state}, which contains metrics we can use to measure the performance of the system.
3166
3167
We used \py{linspace} to create a NumPy array of equally spaced values, and a \py{for} loop to loop through the array. We used a \py{SweepSeries} to store results from a series of simulations, mapping from the value of a parameter to the value of a resulting metric.
3168
3169
\begin{code}
3170
p1_array = linspace(0, 1, 11)
3171
sweep = SweepSeries()
3172
3173
for p1 in p1_array:
3174
state = run_simulation(p1, p2, num_steps)
3175
sweep[p1] = state.olin_empty
3176
\end{code}
3177
3178
In Chapter~\ref{chap05} we used Pandas to read data from a web page and store the results in a \py{DataFrame}. We selected a column from the \py{DataFrame} to get a \py{Series}.
3179
3180
In Chapter~\ref{chap06} we created a \py{System} object to contain the parameters of the model, and defined another version of \py{run_simulation}:
3181
3182
\begin{code}
3183
def run_simulation(system, update_func):
3184
results = TimeSeries()
3185
results[system.t_0] = system.p_0
3186
3187
for t in linrange(system.t_0, system.t_end):
3188
results[t+1] = update_func(results[t], t, system)
3189
3190
return results
3191
\end{code}
3192
3193
This version takes a \py{System} object as a parameter, and an update function. Instead of returning the final state of the system, it returns a \py{TimeSeries} that contains the state as it changes over time.
3194
3195
The update function takes the current state of the system, the time, and the \py{System} object as parameters, and returns the new state. For example, here's the update function for the quadratic growth model.
3196
3197
\begin{code}
3198
def update_func_quad(pop, t, system):
3199
net_growth = system.alpha * pop + system.beta * pop**2
3200
return pop + net_growth
3201
\end{code}
3202
3203
In this example, the state of the system is a single number, \py{pop}. Later we'll see examples where state is represented by a \py{State} object with more than one variable.
3204
3205
Chapter~\ref{chap07} introduces the quadratic growth model and
3206
Chapter~\ref{chap08} uses the model to generate predictions, but neither chapter introduces new computational tools.
3207
3208
Chapter~\ref{chap09} introduces SymPy, which we can use to create \py{Symbol} objects:
3209
3210
\begin{code}
3211
t, alpha = symbols('t alpha')
3212
f = Function('f')
3213
\end{code}
3214
3215
Write differential equations:
3216
3217
\begin{code}
3218
dfdt = diff(f(t), t)
3219
eq1 = Eq(dfdt, alpha*f(t))
3220
\end{code}
3221
3222
And solve them:
3223
3224
\begin{code}
3225
solution_eq = dsolve(eq1)
3226
\end{code}
3227
3228
That's a brief summary of the computational tools we have seen so far.
3229
3230
3231
\section{Under the hood}
3232
\label{dataframe}
3233
3234
So far we've been using \py{DataFrame} and \py{Series} objects without really understanding how they work. In this section we'll review what we know so far and get into a little more detail.
3235
3236
Each \py{DataFrame} contains three objects: \py{index} is a sequence of labels for the rows, \py{columns} is a sequence of labels for the columns, and \py{values} is a NumPy array that contains the data.
3237
3238
In the \py{DataFrame} objects in this chapter, \py{index} contains years from 1950 to 2016, \py{columns} contains names of agencies and people that produce population estimates, and \py{values} is an array of estimates.
3239
3240
\begin{figure}
3241
\centerline{\includegraphics[height=2.5in]{figs/dataframe.pdf}}
3242
\caption{The elements that make up a \py{DataFrame} and a \py{Series}.}
3243
\label{fig-dataframe}
3244
\end{figure}
3245
3246
A \py{Series} is like a \py{DataFrame} with one column: it contains a string \py{name} that is like a column label, an index, and an array of values.
3247
3248
Figure~\ref{fig-dataframe} shows the elements of a \py{DataFrame} and \py{Series} graphically.
3249
3250
\index{type function}
3251
3252
To determine the types of these elements, we can use the Python function \py{type}:
3253
3254
\begin{code}
3255
type(table2)
3256
type(table2.index)
3257
type(table2.columns)
3258
type(table2.values)
3259
\end{code}
3260
3261
The type of \py{table2} is \py{DataFrame}. The type of \py{table2.index} is \py{Int64Index}, which is similar to a \py{Series}.
3262
3263
The type of \py{table2.columns} is \py{Index}, which might seem strange, because ``the" index is the sequence of row labels. But the sequence of column labels is also a kind of index.
3264
3265
The type of \py{table2.values} is \py{ndarray}, which is the primary array type provided by NumPy; the name indicates that the array is ``n-dimensional"; that is, it can have an arbitrary number of dimensions.
3266
3267
In \py{census} or \py{un}, the index is an \py{Int64Index} object and the values are stored in an \py{ndarray}.
3268
3269
In the ModSim library, the functions \py{get_first_label} and \py{get_last_label} provide a simple way to access the index of a \py{DataFrame} or \py{Series}:
3270
3271
\begin{code}
3272
def get_first_label(series):
3273
return series.index[0]
3274
3275
def get_last_label(series):
3276
return series.index[-1]
3277
\end{code}
3278
3279
In brackets, the number \py{0} selects the first label; the number \py{-1} selects the last label.
3280
3281
Several of the objects defined in \py{modsim} are modified versions of \py{Series} objects. \py{State} and \py{System} objects are \py{Series} where the labels are variable names. A \py{TimeSeries} is a \py{Series} where the labels are times, and a \py{SweepSeries} is a \py{Series} where the labels are parameter values.
3282
3283
Defining these objects wasn't necessary; we could do all the same things using \py{Series} objects. But giving them different names makes the code easier to read and understand, and helps avoid certain kinds of errors (like getting two \py{Series} objects mixed up).
3284
3285
If you write simulations in Python in the future, you can continue using the objects in \py{modsim}, if you find them useful, or you can use Pandas objects directly.
3286
3287
\section{One queue or two?}
3288
3289
This chapter presents two cases studies that let you practice what you have learned so far. The first case study is related to {\bf queueing theory}, which is the study of systems that involve waiting in lines, also known as ``queues".
3290
3291
Suppose you are designing the checkout area for a new store. There is enough room in the store for two checkout counters and a waiting area for customers. You can make two lines, one for each counter, or one line that feeds both counters.
3292
3293
In theory, you might expect a single line to be better, but it has some practical drawbacks: in order to maintain a single line, you might have to install barriers, and customers might be put off by what seems to be a longer line, even if it moves faster.
3294
3295
So you'd like to check whether the single line is really better and by how much. Simulation can help answer this question.
3296
3297
\begin{figure}
3298
\centerline{\includegraphics[width=4.5in]{figs/queue.pdf}}
3299
\caption{One queue, one server (left), one queue, two servers (middle), two queues, two servers (right).}
3300
\label{fig-queue}
3301
\end{figure}
3302
3303
Figure~\ref{fig-queue} shows the three scenarios we'll consider. As we did in the bike share model, we'll assume that a customer is equally likely to arrive during any time step. I'll denote this probability using the Greek letter lambda, $\lambda$, or the variable name \py{lam}. The value of $\lambda$ probably varies from day to day, so we'll have to consider a range of possibilities.
3304
3305
Based on data from other stores, you know that it takes 5 minutes for a customer to check out, on average. But checkout times are variable: most customers take less than 5 minutes, but some take substantially more. A simple way to model this variability is to assume that when a customer is checking out, they always have the same probability of finishing during the next time step, regardless of how long they have been checking out. I'll denote this probability using the Greek letter mu, $\mu$, or the variable name \py{mu}.
3306
3307
If we choose $\mu=1/5$ per minute, the average time for each checkout will be 5 minutes, which is consistent with the data. Most people takes less than 5 minutes, but a few take substantially longer, which is probably not a bad model of the distribution in real stores.
3308
3309
Now we're ready to get started. In the repository for this book, you'll find a notebook called \py{queue.ipynb} that contains some code to get you started and instructions.
3310
3311
As always, you should practice incremental development: write no more than one or two lines of code a time, and test as you go!
3312
3313
3314
3315
3316
\section{Predicting salmon populations}
3317
3318
Each year the U.S. Atlantic Salmon Assessment Committee reports estimates of salmon populations in oceans and rivers in the northeastern United States. The reports are useful for monitoring changes in these populations, but they generally do not include predictions.
3319
3320
The goal of this case study is to model year-to-year changes in population, evaluate how predictable these changes are, and estimate the probability that a particular population will increase or decrease in the next 10 years.
3321
3322
As an example, I use data from page 18 of the 2017 report, which provides population estimates for the Narraguagus and Sheepscot Rivers in Maine.
3323
3324
In the repository for this book, you'll find a notebook called \py{salmon.ipynb} that contains some code to get you started and instructions.
3325
3326
You should take my instructions as suggestions; if you want to try something different, please do!
3327
3328
3329
\section{Tree growth}
3330
3331
This case study is based on ``Height-Age Curves for Planted Stands of Douglas Fir, with Adjustments for Density", a working paper by Flewelling, Collier, Gonyea, Marshall, and Turnblom.
3332
3333
% TODO: Add paper to GitHub \url{http://modsimpy.com/trees}
3334
3335
It provides ``site index curves", which are curves that show the expected height of the tallest tree in a stand of Douglas firs as a function of age, for a stand where the trees are the same age.
3336
3337
Depending on the quality of the site, the trees might grow more quickly or slowing. So each curve is identified by a ``site index" that indicates the quality of the site.
3338
3339
\begin{figure}
3340
\centerline{\includegraphics[height=3in]{figs/trees-fig01.pdf}}
3341
\caption{Site index curves for tree growth.}
3342
\label{trees-fig01}
3343
\end{figure}
3344
3345
Figure~\ref{trees-fig01} shows site curves for three different site indices.
3346
The goal of this case study is to explain the shape of these curves, that is, why trees grow the way they do.
3347
3348
As a starting place, let's assume that the ability of the tree to gain mass is limited by the area it exposes to sunlight, and that the growth rate (in mass) is proportional to that area. In that case we can write:
3349
%
3350
$ m_{n+1} = m_n + \alpha A$
3351
%
3352
where $m_n$ is the mass of the at time step $n$, $A$ is the area exposed to sunlight, and $\alpha$ is an unknown growth parameter.
3353
3354
To get from $m$ to $A$, I'll make the additional assumption that mass is proportional to height raised to an unknown power:
3355
%
3356
$ m = \beta h^D $
3357
%
3358
where $h$ is height, $\beta$ is an unknown constant of proportionality, and $D$ is the dimension that relates height and mass. I start by assuming $D=3$, but then revisit that assumption.
3359
3360
Finally, we'll assume that area is proportional to height squared:
3361
3362
$ A = \gamma h^2$
3363
3364
I specify height in feet, and choose units for mass and area so that $\beta=1$ and $\gamma=1$. Putting all that together, we can write a difference equation for height:
3365
3366
$ h_{n+1}^D = h_n^D + \alpha h_n^2 $
3367
3368
With $D=3$, the solution to this equation is close to a straight line, which is not a bad model for the growth curves. But the model implies that trees can grow forever, and we know that's not true. As trees get taller, it gets harder for them to move water and nutrients against the force of gravity, and their growth slows.
3369
3370
We can model this effect by adding a factor to the model similar to what we saw in the logistic model of population growth. Instead of assuming:
3371
3372
$ m_{n+1} = m_n + \alpha A $
3373
3374
Let's assume
3375
3376
$ m_{n+1} = m_n + \alpha A (1 - h / K) $
3377
3378
where $K$ is similar to the carrying capacity of the logistic model. As $h$ approaches $K$, the factor $(1 - h/K)$ goes to 0, causing growth to level off.
3379
3380
In the repository for this book, you'll find a notebook called \py{trees.ipynb} that implements both models and uses them to fit the data. There are no exercises in this case study; it is mostly meant as an example of what you can do with the tools we have so far, and a preview of what we will be able to do with the tools in the next few chapters.
3381
3382
3383
3384
\chapter{Epidemiology}
3385
\label{chap11}
3386
3387
In this chapter, we develop a model of an epidemic as it spreads in a susceptible population, and use it to evaluate the effectiveness of possible interventions.
3388
3389
\index{epidemic}
3390
3391
My presentation of the SIR model in the next few chapters is based on an excellent article by David Smith and Lang Moore\footnote{Smith and Moore, ``The SIR Model for Spread of Disease," Journal of Online Mathematics and its Applications, December 2001, at \url{http://modsimpy.com/sir}.}.
3392
3393
\index{SIR model}
3394
3395
3396
\section{The Freshman Plague}
3397
3398
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".
3399
3400
\index{Olin College}
3401
\index{Freshman Plague}
3402
\index{Kermack-McKendrick}
3403
3404
In this chapter we introduce a well-known model of infectious disease, the Kermack-McKendrick model, and use it to explain the progression of the disease over the course of the semester, predict the effect of possible interventions (like immunization) and design the most effective intervention campaign.
3405
3406
\index{disease}
3407
\index{infection}
3408
\index{design}
3409
3410
So far we have done our own modeling; that is, we've chosen physical systems, 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.
3411
3412
3413
\section{The SIR model}
3414
\label{sirmodel}
3415
3416
The Kermack-McKendrick model is a simple version of an {\bf SIR model}, so-named because it considers three categories of people:
3417
3418
\begin{itemize}
3419
3420
\item {\bf S}: People who are ``susceptible", that is, capable of contracting the disease if they come into contact with someone who is infected.
3421
3422
\item {\bf I}: People who are ``infectious", that is, capable of passing along the disease if they come into contact with someone susceptible.
3423
3424
\item {\bf R}: People who are ``recovered". 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.
3425
3426
\end{itemize}
3427
3428
Let's 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.
3429
3430
Putting that a different way, if the time between recoveries is 4 days, the recovery rate is about 0.25 recoveries per day, which we'll denote with the Greek letter gamma, $\gamma$, or the variable name \py{gamma}.
3431
3432
If the total number of people in the population is $N$, and the fraction currently infectious is $i$, the total number of recoveries we expect per day is $\gamma i N$.
3433
3434
\index{recovery rate}
3435
3436
Now let's 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 a way that would cause them to become infected if the other person is infected. We'll denote this contact rate with the Greek letter beta, $\beta$.
3437
3438
\index{infection rate}
3439
3440
It's probably not reasonable to assume that we know $\beta$ ahead of time, but later we'll see how to estimate it based on data from previous outbreaks.
3441
3442
If $s$ is the fraction of the population that's susceptible, $s N$ is the number of susceptible people, $\beta s N$ is the number of contacts per day, and $\beta s i N$ is the number of those contacts where the other person is infectious.
3443
3444
\index{susceptible}
3445
3446
In summary:
3447
3448
\begin{itemize}
3449
3450
\item The number of recoveries we expect per day is $\gamma i N$; dividing by $N$ yields the fraction of the population that recovers in a day, which is $\gamma i$.
3451
3452
\item The number of new infections we expect per day is $\beta s i N$; dividing by $N$ yields the fraction of the population that gets infected in a day, which is $\beta s i$.
3453
3454
\end{itemize}
3455
3456
This model assumes that the population is closed; that is, no one arrives or departs, so the size of the population, $N$, is constant.
3457
3458
3459
\section{The SIR equations}
3460
\label{sireqn}
3461
3462
If we treat time as a continuous quantity, we can write differential equations that describe the rates of change for $s$, $i$, and $r$ (where $r$ is the fraction of the population that has recovered):
3463
%
3464
\begin{align*}
3465
\frac{ds}{dt} &= -\beta s i \\
3466
\frac{di}{dt} &= \beta s i - \gamma i\\
3467
\frac{dr}{dt} &= \gamma i
3468
\end{align*}
3469
%
3470
To avoid cluttering the equations, I leave it implied that $s$ is a function of time, $s(t)$, and likewise for $i$ and $r$.
3471
\index{differential equation}
3472
3473
SIR models are examples of {\bf compartment models}, so-called because they divide the world into discrete categories, or compartments, and describe transitions from one compartment to another. Compartments are also called {\bf stocks} and transitions between them are called {\bf flows}.
3474
3475
\index{compartment model}
3476
\index{stock}
3477
\index{flow}
3478
\index{stock and flow diagram}
3479
3480
In this example, there are three stocks---susceptible, infectious, and recovered---and two flows---new infections and recoveries. Compartment models are often represented visually using stock and flow diagrams (see \url{http://modsimpy.com/stock}).
3481
Figure~\ref{stock_flow1} shows the stock and flow diagram for an SIR model.
3482
3483
\begin{figure}
3484
\centerline{\includegraphics[width=4in]{figs/stock_flow1.pdf}}
3485
\caption{Stock and flow diagram for an SIR model.}
3486
\label{stock_flow1}
3487
\end{figure}
3488
3489
Stocks are represented by rectangles, flows by arrows. The widget in the middle of the arrows represents a valve that controls the rate of flow; the diagram shows the parameters that control the valves.
3490
3491
3492
\section{Implementation}
3493
3494
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 {\bf implementation}. In this section, we implement the SIR model in Python.
3495
3496
\index{implementation}
3497
3498
I'll represent the initial state of the system using a \py{State} object with state variables \py{S}, \py{I}, and \py{R}; they represent the fraction of the population in each compartment.
3499
3500
\index{System object}
3501
\index{State object}
3502
\index{state variable}
3503
3504
We can initialize the \py{State} object with the {\em number} of people in each compartment, assuming there is one infected student in a class of 90:
3505
3506
\begin{python}
3507
init = State(S=89, I=1, R=0)
3508
\end{python}
3509
3510
And then convert the numbers to fractions by dividing by the total:
3511
3512
\begin{python}
3513
init /= sum(init)
3514
\end{python}
3515
3516
For now, let's assume we know the time between contacts and time between recoveries:
3517
3518
\begin{python}
3519
tc = 3 # time between contacts in days
3520
tr = 4 # recovery time in days
3521
\end{python}
3522
3523
We can use them to compute the parameters of the model:
3524
3525
\begin{python}
3526
beta = 1 / tc # contact rate in per day
3527
gamma = 1 / tr # recovery rate in per day
3528
\end{python}
3529
3530
Now we need a \py{System} object to store the parameters and initial conditions. The following function takes the system parameters as function parameters and returns a new \py{System} object:
3531
3532
\index{\py{make_system}}
3533
3534
\begin{python}
3535
def make_system(beta, gamma):
3536
init = State(S=89, I=1, R=0)
3537
init /= sum(init)
3538
3539
t0 = 0
3540
t_end = 7 * 14
3541
3542
return System(init=init, t0=t0, t_end=t_end,
3543
beta=beta, gamma=gamma)
3544
\end{python}
3545
3546
The default value for \py{t_end} is 14 weeks, about the length of a semester.
3547
3548
3549
\section{The update function}
3550
3551
At any point in time, the state of the system is represented by a \py{State} object with three variables, \py{S}, \py{I} and \py{R}. So I'll define an update function that takes as parameters a \py{State} object, the current time, and a \py{System} object:
3552
3553
\index{update function}
3554
\index{function!update}
3555
\index{time step}
3556
3557
\begin{python}
3558
def update_func(state, t, system):
3559
s, i, r = state
3560
3561
infected = system.beta * i * s
3562
recovered = system.gamma * i
3563
3564
s -= infected
3565
i += infected - recovered
3566
r += recovered
3567
3568
return State(S=s, I=i, R=r)
3569
\end{python}
3570
3571
The first line uses a feature we have not seen before, {\bf multiple assignment}. The value on the right side is a \py{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 \py{State} object to the three variables, in order.
3572
3573
The local variables, \py{s}, \py{i} and \py{r}, are lowercase to distinguish them from the state variables, \py{S}, \py{I} and \py{R}.
3574
3575
\index{State object}
3576
\index{state variable}
3577
\index{local variable}
3578
3579
The update function computes \py{infected} and \py{recovered} as a fraction of the population, then updates \py{s}, \py{i} and \py{r}. The return value is a \py{State} that contains the updated values.
3580
3581
\index{return value}
3582
3583
When we call \py{update_func} like this:
3584
3585
\begin{python}
3586
state = update_func(init, 0, system)
3587
\end{python}
3588
3589
The result is a \py{State} object with these values:
3590
3591
\begin{tabular}{lr}
3592
& {\bf \sf value} \\
3593
\hline
3594
{\bf \sf S} & 0.985388 \\
3595
{\bf \sf I} & 0.011865 \\
3596
{\bf \sf R} & 0.002747 \\
3597
\end{tabular}
3598
3599
You might notice that this version of \py{update_func} does not use one of its parameters, \py{t}. I include it anyway because update functions sometimes depend on time, and it is convenient if they all take the same parameters, whether they need them or not.
3600
3601
%TODO: figure out when to talk about integers and floats (or never)
3602
3603
3604
\section{Running the simulation}
3605
3606
Now we can simulate the model over a sequence of time steps:
3607
3608
\index{time step}
3609
3610
\begin{python}
3611
def run_simulation(system, update_func):
3612
state = system.init
3613
3614
for t in linrange(system.t0, system.t_end):
3615
state = update_func(state, t, system)
3616
3617
return state
3618
\end{python}
3619
3620
The parameters of \py{run_simulation} are the \py{System} object and the update function. The \py{System} object contains the parameters, initial conditions, and values of \py{t0} and \py{t_end}.
3621
3622
\index{\py{run_simulation}}
3623
3624
The outline of this function should look familiar; it is similar to the function we used for the population model in Section~\ref{nowwithsystem}.
3625
3626
We can call \py{run_simulation} like this:
3627
3628
\begin{python}
3629
system = make_system(beta, gamma)
3630
final_state = run_simulation(system, update_func)
3631
\end{python}
3632
3633
The result is the final state of the system:
3634
3635
\begin{tabular}{lr}
3636
& {\bf \sf value} \\
3637
\hline
3638
{\bf \sf S} & 0.520819 \\
3639
{\bf \sf I} & 0.000676 \\
3640
{\bf \sf R} & 0.478505 \\
3641
\end{tabular}
3642
3643
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.
3644
3645
3646
\section{Collecting the results}
3647
3648
The previous version of \py{run_simulation} only returns the final state, but we might want to see how the state changes over time. We'll consider two ways to do that: first, using three \py{TimeSeries} objects, then using a new object called a \py{TimeFrame}.
3649
3650
\index{TimeFrame object}
3651
\index{TimeSeries object}
3652
3653
Here's the first version:
3654
3655
\begin{python}
3656
def run_simulation(system, update_func):
3657
S = TimeSeries()
3658
I = TimeSeries()
3659
R = TimeSeries()
3660
3661
state = system.init
3662
t0 = system.t0
3663
S[t0], I[t0], R[t0] = state
3664
3665
for t in linrange(system.t0, system.t_end):
3666
state = update_func(state, t, system)
3667
S[t+1], I[t+1], R[t+1] = state
3668
3669
return S, I, R
3670
\end{python}
3671
3672
First, we create \py{TimeSeries} objects to store the results. Notice that the variables \py{S}, \py{I}, and \py{R} are \py{TimeSeries} objects now.
3673
3674
Next we initialize \py{state}, \py{t0}, and the first elements of \py{S}, \py{I} and \py{R}.
3675
3676
Inside the loop, we use \py{update_func} to compute the state of the system at the next time step, then use multiple assignment to unpack the elements of \py{state}, assigning each to the corresponding \py{TimeSeries}.
3677
3678
\index{time step}
3679
3680
At the end of the function, we return the values \py{S}, \py{I}, and \py{R}. This is the first example we have seen where a function returns more than one value.
3681
3682
Now we can run the function like this:
3683
3684
\begin{python}
3685
system = make_system(beta, gamma)
3686
S, I, R = run_simulation(system, update_func)
3687
\end{python}
3688
3689
We'll use the following function to plot the results:
3690
3691
\begin{python}
3692
def plot_results(S, I, R):
3693
plot(S, '--', label='Susceptible')
3694
plot(I, '-', label='Infected')
3695
plot(R, ':', label='Resistant')
3696
decorate(xlabel='Time (days)',
3697
ylabel='Fraction of population')
3698
\end{python}
3699
3700
\index{plot}
3701
\index{decorate}
3702
3703
And run it like this:
3704
3705
\begin{python}
3706
plot_results(S, I, R)
3707
\end{python}
3708
3709
\begin{figure}
3710
\centerline{\includegraphics[height=3in]{figs/chap11-fig01.pdf}}
3711
\caption{Time series for \py{S}, \py{I}, and \py{R} over the course of 98 days.}
3712
\label{chap11-fig01}
3713
\end{figure}
3714
3715
Figure~\ref{chap11-fig01} 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 that's infected is never very high, but it adds up. In total, almost half the population gets sick.
3716
3717
3718
\section{Now with a TimeFrame}
3719
\label{timeframe}
3720
3721
If the number of state variables is small, storing them as separate \py{TimeSeries} objects might not be so bad. But a better alternative is to use a \py{TimeFrame}, which is another object defined in the ModSim library.
3722
3723
\index{TimeFrame object}
3724
\index{DataFrame object}
3725
3726
A \py{TimeFrame} is almost identical to a \py{DataFrame}, which we used in Section~\ref{worldpopdata}, with just a few changes I made to adapt it for our purposes.
3727
3728
Here's a more concise version of \py{run_simulation} using a \py{TimeFrame}:
3729
3730
\begin{python}
3731
def run_simulation(system, update_func):
3732
frame = TimeFrame(columns=system.init.index)
3733
frame.row[system.t0] = system.init
3734
3735
for t in linrange(system.t0, system.t_end):
3736
frame.row[t+1] = update_func(frame.row[t], system)
3737
3738
return frame
3739
\end{python}
3740
3741
The first line creates an empty \py{TimeFrame} with one column for each state variable. Then, before the loop starts, we store the initial conditions in the \py{TimeFrame} at \py{t0}. Based on the way we've been using \py{TimeSeries} objects, it is tempting to write:
3742
3743
\begin{python}
3744
frame[system.t0] = system.init
3745
\end{python}
3746
3747
But when you use the bracket operator with a \py{TimeFrame} or \py{DataFrame}, it selects a column, not a row. For example, to select a column, we could write:
3748
3749
\index{bracket operator}
3750
\index{operator~bracket}
3751
3752
\begin{python}
3753
frame['S']
3754
\end{python}
3755
3756
To select a row, we have to use \py{row}, like this:
3757
3758
\index{row}
3759
3760
\begin{python}
3761
frame.row[system.t0] = system.init
3762
\end{python}
3763
3764
Since the value on the right side is a \py{State}, the assignment matches up the index of the \py{State} with the columns of the \py{TimeFrame}; that is, it assigns the \py{S} value from \py{system.init} to the \py{S} column of \py{frame}, and likewise with \py{I} and \py{R}.
3765
3766
\index{assignment}
3767
3768
We can use the same feature to write the loop more concisely, assigning the \py{State} we get from \py{update_func} directly to the next row of \py{frame}.
3769
3770
\index{system variable}
3771
3772
Finally, we return \py{frame}. We can call this version of \py{run_simulation} like this:
3773
3774
\begin{python}
3775
results = run_simulation(system, update_func)
3776
\end{python}
3777
3778
And plot the results like this:
3779
3780
\begin{python}
3781
plot_results(results.S, results.I, results.R)
3782
\end{python}
3783
3784
As with a \py{DataFrame}, we can use the dot operator to select columns from a \py{TimeFrame}.
3785
3786
\index{dot operator}
3787
\index{operator!dot}
3788
3789
Before you go on, you might want to read the notebook for this chapter, \py{chap11.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
3790
3791
3792
\chapter{Optimization}
3793
\label{chap12}
3794
3795
In the previous chapter I presented the SIR model of infectious disease and used it to model the Freshman Plague at Olin. In this chapter we'll consider metrics intended to quantify the effects of the disease and interventions intended to reduce those effects.
3796
3797
\section{Metrics}
3798
\label{metrics2}
3799
3800
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 {\bf metrics}, as we saw in Section~\ref{metrics}.
3801
3802
\index{metric}
3803
3804
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 who get sick at any point.
3805
3806
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.
3807
3808
When a person gets infected, they move from \py{S} to \py{I}, so we can get the total number of infections by computing the difference in \py{S} at the beginning and the end:
3809
3810
\begin{python}
3811
def calc_total_infected(results, system):
3812
return results.S[system.t0] - results.S[system.t_end]
3813
\end{python}
3814
3815
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 \py{max} and \py{idxmax}.
3816
3817
\index{max}
3818
\index{idxmax}
3819
3820
If you have a \py{Series} called \py{S}, you can compute the largest value of the series like this:
3821
3822
\begin{python}
3823
largest_value = S.max()
3824
\end{python}
3825
3826
And the label of the largest value like this:
3827
3828
\begin{python}
3829
time_of_largest_value = S.idxmax()
3830
\end{python}
3831
3832
If the \py{Series} is a \py{TimeSeries}, the label you get from \py{idxmax} is a time or date. You can read more about these functions in the \py{Series} documentation at \url{http://modsimpy.com/series}.
3833
3834
\index{Series}
3835
3836
3837
\section{Immunization}
3838
3839
Models like this are useful for testing ``what if?" scenarios. As an example, we'll consider the effect of immunization.
3840
3841
\index{immunization}
3842
\index{vaccine}
3843
\index{Freshman Plague}
3844
3845
Suppose there is a vaccine that causes a student to become immune to the Freshman Plague without being infected. How might you modify the model to capture this effect?
3846
3847
One option is to treat immunization as a shortcut from susceptible to recovered without going through infectious. We can implement this feature like this:
3848
3849
\begin{python}
3850
def add_immunization(system, fraction):
3851
system.init.S -= fraction
3852
system.init.R += fraction
3853
\end{python}
3854
3855
\py{add_immunization} moves the given fraction of the population from \py{S} to \py{R}. If we assume that 10\% of students are vaccinated at the beginning of the semester, and the vaccine is 100\% effective, we can simulate the effect like this:
3856
3857
\begin{python}
3858
system2 = make_system(beta, gamma)
3859
add_immunization(system2, 0.1)
3860
results2 = run_simulation(system2, update_func)
3861
\end{python}
3862
3863
For comparison, we can run the same model without immunization and plot the results. Figure~\ref{chap12-fig01} shows \py{S} as a function of time, with and without immunization.
3864
3865
\begin{figure}
3866
\centerline{\includegraphics[height=3in]{figs/chap12-fig01.pdf}}
3867
\caption{Time series for \py{S}, with and without immunization.}
3868
\label{chap12-fig01}
3869
\end{figure}
3870
3871
Without immunization, almost 47\% of the population gets infected at some point. With 10\% immunization, only 31\% gets infected. That's pretty good.
3872
3873
\begin{figure}
3874
\centerline{\includegraphics[height=3in]{figs/chap12-fig02.pdf}}
3875
\caption{Fraction of the population infected as a function of immunization rate.}
3876
\label{chap12-fig02}
3877
\end{figure}
3878
3879
Now let's see what happens if we administer more vaccines. This following function sweeps a range of immunization rates:
3880
3881
\index{sweep}
3882
3883
\begin{python}
3884
def sweep_immunity(immunize_array):
3885
sweep = SweepSeries()
3886
3887
for fraction in immunize_array:
3888
sir = make_system(beta, gamma)
3889
add_immunization(sir, fraction)
3890
results = run_simulation(sir, update_func)
3891
sweep[fraction] = calc_total_infected(results, sir)
3892
3893
return sweep
3894
\end{python}
3895
3896
The parameter of \py{sweep_immunity} is an array of immunization rates. The result is a \py{SweepSeries} object that maps from each immunization rate to the resulting fraction of students ever infected.
3897
3898
\index{SweepSeries object}
3899
\index{parameter sweep}
3900
3901
Figure~\ref{chap12-fig02} shows a plot of the \py{SweepSeries}. Notice that the x-axis is the immunization rate, not time.
3902
3903
As the immunization rate increases, the number of infections drops steeply. If 40\% of the students are immunized, fewer than 4\% get sick. That's because immunization has two effects: it protects the people who get immunized (of course) but it also protects the rest of the population.
3904
3905
Reducing the number of ``susceptibles" and increasing the number of ``resistants" makes it harder for the disease to spread, because some fraction of contacts are wasted on people who cannot be infected. This phenomenon is called {\bf herd immunity}, and it is an important element of public health (see \url{http://modsimpy.com/herd}).
3906
3907
\index{herd immunity}
3908
3909
The steepness of the curve in Figure~\ref{chap12-fig02} is a blessing and a curse. It's a blessing because it means we don't have to immunize everyone, and vaccines can protect the ``herd" even if they are not 100\% effective.
3910
3911
But it's 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.
3912
3913
\index{measles}
3914
3915
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. We'll see an example in the next section.
3916
3917
3918
3919
3920
3921
\section{Hand washing}
3922
3923
Suppose you are the Dean of Student Life, and you have a budget of just \$1200 to combat the Freshman Plague. You have two options for spending this money:
3924
3925
\begin{enumerate}
3926
3927
\item You can pay for vaccinations, at a rate of \$100 per dose.
3928
3929
\item You can spend money on a campaign to remind students to wash hands frequently.
3930
3931
\end{enumerate}
3932
3933
We have already seen how we can model the effect of vaccination. Now let's think about the hand-washing campaign. We'll have to answer two questions:
3934
3935
\begin{enumerate}
3936
3937
\item How should we incorporate the effect of hand washing in the model?
3938
3939
\item How should we quantify the effect of the money we spend on a hand-washing campaign?
3940
3941
\end{enumerate}
3942
3943
For the sake of simplicity, let's assume that we have data from a similar campaign at another school showing that a well-funded campaign can change student behavior enough to reduce the infection rate by 20\%.
3944
3945
In terms of the model, hand washing has the effect of reducing \py{beta}. That's not the only way we could incorporate the effect, but it seems reasonable and it's easy to implement.
3946
3947
Now we have to model the relationship between the money we spend and the effectiveness of the campaign. Again, let's suppose we have data from another school that suggests:
3948
3949
\begin{itemize}
3950
3951
\item If we spend \$500 on posters, materials, and staff time, we can change student behavior in a way that decreases the effective value of \py{beta} by 10\%.
3952
3953
\item If we spend \$1000, the total decrease in \py{beta} is almost 20\%.
3954
3955
\item Above \$1000, additional spending has little additional benefit.
3956
3957
\end{itemize}
3958
3959
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 \py{factor}, which is the factor by which \py{beta} is reduced:
3960
3961
\index{logistic curve}
3962
3963
\begin{python}
3964
def compute_factor(spending):
3965
return logistic(spending, M=500, K=0.2, B=0.01)
3966
\end{python}
3967
3968
I use \py{compute_factor} to write \py{add_hand_washing}, which takes a \py{System} object and a budget, and modifies \py{system.beta} to model the effect of hand washing:
3969
3970
\begin{python}
3971
def add_hand_washing(system, spending):
3972
factor = compute_factor(spending)
3973
system.beta *= (1 - factor)
3974
\end{python}
3975
3976
Now we can sweep a range of values for \py{spending} and use the simulation to compute the effect:
3977
3978
\begin{python}
3979
def sweep_hand_washing(spending_array):
3980
sweep = SweepSeries()
3981
3982
for spending in spending_array:
3983
sir = make_system(beta, gamma)
3984
add_hand_washing(sir, spending)
3985
results, run_simulation(sir, update_func)
3986
sweep[spending] = calc_total_infected(results, sir)
3987
3988
return sweep
3989
\end{python}
3990
3991
Here's how we run it:
3992
3993
\begin{python}
3994
spending_array = linspace(0, 1200, 20)
3995
infected_sweep = sweep_hand_washing(spending_array)
3996
\end{python}
3997
3998
\begin{figure}
3999
\centerline{\includegraphics[height=3in]{figs/chap12-fig03.pdf}}
4000
\caption{Fraction of the population infected as a function of hand-washing campaign spending.}
4001
\label{chap12-fig03}
4002
\end{figure}
4003
4004
Figure~\ref{chap12-fig03} 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.
4005
4006
4007
\section{Optimization}
4008
4009
\begin{figure}
4010
\centerline{\includegraphics[height=3in]{figs/chap12-fig04.pdf}}
4011
\caption{Fraction of the population infected as a function of the number of doses.}
4012
\label{chap12-fig04}
4013
\end{figure}
4014
4015
Let's 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.
4016
4017
\index{optimization}
4018
4019
Here are the parameters:
4020
4021
\begin{python}
4022
num_students = 90
4023
budget = 1200
4024
price_per_dose = 100
4025
max_doses = int(budget / price_per_dose)
4026
\end{python}
4027
4028
The fraction \py{budget/price_per_dose} might not be an integer. \py{int} is a built-in function that converts numbers to integers, rounding down.
4029
4030
We'll sweep the range of possible doses:
4031
4032
\begin{python}
4033
dose_array = linrange(max_doses, endpoint=True)
4034
\end{python}
4035
4036
In this example we call \py{linrange} with only one argument; it returns a NumPy array with the integers from 0 to \py{max_doses}. With the argument \py{endpoint=True}, the result includes both endpoints.
4037
4038
\index{linrange}
4039
\index{NumPy}
4040
\index{array}
4041
4042
Then we run the simulation for each element of \py{dose_array}:
4043
4044
\begin{python}
4045
def sweep_doses(dose_array):
4046
sweep = SweepSeries()
4047
4048
for doses in dose_array:
4049
fraction = doses / num_students
4050
spending = budget - doses * price_per_dose
4051
4052
sir = make_system(beta, gamma)
4053
add_immunization(sir, fraction)
4054
add_hand_washing(sir, spending)
4055
4056
run_simulation(sir, update_func)
4057
sweep[doses] = calc_total_infected(sir)
4058
4059
return sweep
4060
\end{python}
4061
4062
For each number of doses, we compute the fraction of students we can immunize, \py{fraction} and the remaining budget we can spend on the campaign, \py{spending}. Then we run the simulation with those quantities and store the number of infections.
4063
4064
Figure~\ref{chap12-fig04} 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.
4065
4066
As we increase the number of doses, we have to cut campaign spending, which turns out to make things worse. But interestingly, when we get above 10 doses, the effect of herd immunity starts to kick in, and the number of sick students goes down again.
4067
4068
\index{herd immunity}
4069
4070
Before you go on, you might want to read the notebook for this chapter, \py{chap12.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
4071
4072
4073
4074
\chapter{Sweeping two parameters}
4075
\label{chap13}
4076
4077
In the previous chapters I presented an SIR model of infectious disease, specifically the Kermack-McKendrick model. We extended the model to include vaccination and the effect of a hand-washing campaign, and used the extended model to allocate a limited budget optimally, that is, to minimize the number of infections.
4078
4079
\index{Kermack-McKendrick model}
4080
\index{SIR model}
4081
4082
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.
4083
4084
\section{Sweeping beta}
4085
4086
Recall that $\beta$ is the contact rate, which captures both the frequency of interaction between people and the fraction of those interactions that result in a new infection. If $N$ is the size of the population and $s$ is the fraction that's susceptible, $s N$ is the number of susceptibles, $\beta s N$ is the number of contacts per day between susceptibles and other people, and $\beta s i N$ is the number of those contacts where the other person is infectious.
4087
\index{parameter sweep}
4088
4089
As $\beta$ increases, we expect the total number of infections to increase. To quantify that relationship, I'll create a range of values for $\beta$:
4090
4091
\begin{python}
4092
beta_array = linspace(0.1, 1.1, 11)
4093
\end{python}
4094
4095
Then run the simulation for each value and print the results.
4096
4097
\begin{python}
4098
for beta in beta_array:
4099
sir = make_system(beta, gamma)
4100
run_simulation(sir, update1)
4101
print(sir.beta, calc_total_infected(sir))
4102
\end{python}
4103
4104
We can wrap that code in a function and store the results in a \py{SweepSeries} object:
4105
\index{SweepSeries object}
4106
4107
\begin{python}
4108
def sweep_beta(beta_array, gamma):
4109
sweep = SweepSeries()
4110
for beta in beta_array:
4111
system = make_system(beta, gamma)
4112
run_simulation(system, update1)
4113
sweep[system.beta] = calc_total_infected(system)
4114
return sweep
4115
\end{python}
4116
4117
Now we can run \py{sweep_beta} like this:
4118
4119
\begin{python}
4120
infected_sweep = sweep_beta(beta_array, gamma)
4121
\end{python}
4122
4123
And plot the results:
4124
4125
\begin{python}
4126
label = 'gamma = ' + str(gamma)
4127
plot(infected_sweep, label=label)
4128
\end{python}
4129
4130
The first line uses string operations to assemble a label for the plotted line:
4131
\index{string}
4132
4133
\begin{itemize}
4134
4135
\item When the \py{+} operator is applied to strings, it joins them end-to-end, which is called {\bf concatenation}.
4136
4137
\index{concatenation}
4138
4139
\item The function \py{str} converts any type of object to a String representation. In this case, \py{gamma} is a number, so we have to convert it to a string before trying to concatenate it.
4140
4141
\index{str function}
4142
4143
\end{itemize}
4144
4145
% TODO: Make the gamma symbol appear in the line below and in the figures.
4146
4147
If the value of \py{gamma} is \py{0.25}, the value of \py{label} is the string \py{'gamma = 0.25'}.
4148
4149
\begin{figure}
4150
\centerline{\includegraphics[height=3in]{figs/chap13-fig01.pdf}}
4151
\caption{Fraction of students infected as a function of the parameter \py{beta}, with \py{gamma = 0.25}.}
4152
\label{chap13-fig01}
4153
\end{figure}
4154
4155
Figure~\ref{chap13-fig01} shows the results. Remember that this figure is a parameter sweep, not a time series, so the x-axis is the parameter \py{beta}, not time.
4156
4157
When \py{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 \py{beta} increases, it reaches a threshold near 0.3 where the fraction of infected students increases quickly. When \py{beta} exceeds 0.5, more than 80\% of the population gets sick.
4158
4159
4160
\section{Sweeping gamma}
4161
4162
Let's see what that looks like for a few different values of \py{gamma}. Again, we'll use \py{linspace} to make an array of values:
4163
4164
\index{linspace}
4165
4166
\begin{python}
4167
gamma_array = linspace(0.1, 0.7, 4)
4168
\end{python}
4169
4170
And run \py{sweep_beta} for each value of \py{gamma}:
4171
4172
\begin{python}
4173
for gamma in gamma_array:
4174
infected_sweep = sweep_beta(beta_array, gamma)
4175
label = 'gamma = ' + str(gamma)
4176
plot(infected_sweep, label=label)
4177
\end{python}
4178
4179
\begin{figure}
4180
\centerline{\includegraphics[height=3in]{figs/chap13-fig02.pdf}}
4181
\caption{Fraction of students infected as a function of the parameter \py{beta}, for several values of \py{gamma}.}
4182
\label{chap13-fig02}
4183
\end{figure}
4184
4185
Figure~\ref{chap13-fig02} shows the results. When \py{gamma} is low, the recovery rate is low, which means people are infectious longer. In that case, even a low contact rate (\py{beta}) results in an epidemic.
4186
4187
When \py{gamma} is high, \py{beta} has to be even higher to get things going.
4188
4189
4190
\section{SweepFrame}
4191
\label{sweepframe}
4192
4193
In the previous section, we swept a range of values for \py{gamma}, and for each value, we swept a range of values for \py{beta}. This process is a {\bf two-dimensional sweep}.
4194
4195
If we want to store the results, rather than plot them, we can use a \py{SweepFrame}, which is a kind of {\tt DataFrame} where the rows sweep one parameter, the columns sweep another parameter, and the values contain metrics from a simulation.
4196
4197
\index{SweepFrame object}
4198
\index{DataFrame object}
4199
4200
This function shows how it works:
4201
4202
\begin{python}
4203
def sweep_parameters(beta_array, gamma_array):
4204
frame = SweepFrame(columns=gamma_array)
4205
for gamma in gamma_array:
4206
frame[gamma] = sweep_beta(beta_array, gamma)
4207
return frame
4208
\end{python}
4209
4210
\py{sweep_parameters} takes as parameters an array of values for \py{beta} and an array of values for \py{gamma}.
4211
4212
It creates a \py{SweepFrame} to store the results, with one column for each value of \py{gamma} and one row for each value of \py{beta}.
4213
4214
Each time through the loop, we run \py{sweep_beta}. The result is a \py{SweepSeries} object with one element for each value of \py{beta}. The assignment inside the loop stores the \py{SweepSeries} as a new column in the \py{SweepFrame}, corresponding to the current value of \py{gamma}.
4215
4216
At the end, the \py{SweepFrame} stores the fraction of students infected for each pair of parameters, \py{beta} and \py{gamma}.
4217
4218
We can run \py{sweep_parameters} like this:
4219
4220
\begin{python}
4221
frame = sweep_parameters(beta_array, gamma_array)
4222
\end{python}
4223
4224
With the results in a \py{SweepFrame}, we can plot each column like this:
4225
4226
\begin{python}
4227
for gamma in gamma_array:
4228
label = 'gamma = ' + str(gamma)
4229
plot(frame[gamma], label=label)
4230
\end{python}
4231
4232
Alternatively, we can plot each row like this:
4233
4234
\begin{python}
4235
for beta in [1.1, 0.9, 0.7, 0.5, 0.3]:
4236
label = 'β = ' + str(beta)
4237
plot(frame.row[beta], label=label)
4238
\end{python}
4239
4240
\begin{figure}
4241
\centerline{\includegraphics[height=3in]{figs/chap13-fig03.pdf}}
4242
\caption{Fraction of students infected as a function of the parameter \py{gamma}, for several values of \py{beta}.}
4243
\label{chap13-fig03}
4244
\end{figure}
4245
4246
Figure~\ref{chap13-fig03} shows the results. This example demonstrates one use of a \py{SweepFrame}: we can run the analysis once, save the results, and then generate different visualizations.
4247
4248
Another way to visualize the results of a two-dimensional sweep is a {\bf contour plot}, which shows the parameters on the axes and contour lines, that is, lines of constant value. In this example, the value is the fraction of students infected.
4249
4250
\index{contour plot}
4251
4252
The ModSim library provides \py{contour}, which takes a \py{SweepFrame} as a parameter:
4253
4254
\begin{python}
4255
contour(frame)
4256
\end{python}
4257
4258
\begin{figure}
4259
\centerline{\includegraphics[height=3in]{figs/chap13-fig04.pdf}}
4260
\caption{Contour plot showing fraction of students infected as a function of the parameters \py{gamma} and \py{beta}.}
4261
\label{chap13-fig04}
4262
\end{figure}
4263
4264
Figure~\ref{chap13-fig04} shows the result. Infection rates are lowest in the lower right, where the contact rate is and the recovery rate is high. They increase as we move to the upper left, where the contact rate is high and the recovery rate is low.
4265
4266
This figure suggests that there might be a relationship between \py{beta} and \py{gamma} that determines the outcome of the model. In fact, there is. In the next chapter we'll explore it by running simulations, then derive it by analysis.
4267
4268
Before you go on, you might want to read the notebook for this chapter, \py{chap13.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
4269
4270
4271
\chapter{Analysis}
4272
\label{chap14}
4273
4274
In the previous chapters we used simulation to predict the effect of an infectious disease in a susceptible population and to design interventions that would minimize the effect.
4275
4276
In this chapter we use analysis to investigate the relationship between the parameters, \py{beta} and \py{gamma}, and the outcome of the simulation.
4277
4278
\section{Nondimensionalization}
4279
\label{nondim}
4280
4281
The figures in Section~\ref{sweepframe} suggest that there is a relationship between the parameters of the SIR model, \py{beta} and \py{gamma}, that determines the outcome of the simulation, the fraction of students infected.
4282
Let's think what that relationship might be.
4283
4284
\begin{itemize}
4285
4286
\item When \py{beta} exceeds \py{gamma}, that means there are more contacts (that is, potential infections) than recoveries during each day (or other unit of time). The difference between \py{beta} and \py{gamma} might be called the ``excess contact rate", in units of contacts per day.
4287
4288
\item As an alternative, we might consider the ratio \py{beta/gamma}, which is the number of contacts per recovery. Because the numerator and denominator are in the same units, this ratio is {\bf dimensionless}, which means it has no units.
4289
\index{dimensionless}
4290
4291
\end{itemize}
4292
4293
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: {\bf nondimensionalization} (see \url{http://modsimpy.com/nondim}).
4294
4295
\index{nondimensionalization}
4296
4297
So we'll try the second option first. In the notebook for this chapter, you can explore the first option as an exercise.
4298
4299
4300
\section{Exploring the results}
4301
4302
Suppose we have a \py{SweepFrame} with one row for each value of \py{beta} and one column for each value of \py{gamma}. Each element in the \py{SweepFrame} is the fraction of students infected in a simulation with a given pair of parameters.
4303
4304
We can print the values in the \py{SweepFrame} like this:
4305
4306
\begin{python}
4307
for gamma in frame.columns:
4308
column = frame[gamma]
4309
for beta in column.index:
4310
frac_infected = column[beta]
4311
print(beta, gamma, frac_infected)
4312
\end{python}
4313
4314
This is the first example we've seen with one \py{for} loop inside another:
4315
\index{loop}
4316
\index{for loop}
4317
4318
\begin{itemize}
4319
4320
\item Each time the outer loop runs, it selects a value of \py{gamma} from the columns of the \py{DataFrame} and extracts the corresponding column.
4321
4322
\item Each time the inner loop runs, it selects a value of \py{beta} from the column and selects the corresponding element, which is the fraction of students infected.
4323
4324
\end{itemize}
4325
4326
In the example from the previous chapter, \py{frame} has 4 columns, one for each value of \py{gamma}, and 11 rows, one for each value of \py{beta}. So these loops print 44 lines, one for each pair of parameters.
4327
4328
The following function encapulates the previous loop and plots the fraction infected as a function of the ratio \py{beta/gamma}:
4329
4330
\begin{python}
4331
def plot_sweep_frame(frame):
4332
for gamma in frame.columns:
4333
series = frame[gamma]
4334
for beta in series.index:
4335
frac_infected = series[beta]
4336
plot(beta/gamma, frac_infected, 'ro')
4337
\end{python}
4338
4339
\begin{figure}
4340
\centerline{\includegraphics[height=3in]{figs/chap14-fig01.pdf}}
4341
\caption{Total fraction infected as a function of contact number.}
4342
\label{chap14-fig01}
4343
\end{figure}
4344
4345
Figure~\ref{chap14-fig01} shows that the results fall on a single curve, at least approximately. That means that we can predict the fraction of students who will be infected based on a single parameter, the ratio \py{beta/gamma}. We don't need to know the values of \py{beta} and \py{gamma} separately.
4346
4347
4348
\section{Contact number}
4349
\label{contact}
4350
4351
From Section~\ref{sirmodel}, recall that the number of new infections in a given day is $\beta s i N$, and the number of recoveries is $\gamma i N$. If we divide these quantities, the result is $\beta s / \gamma$, which is the number of new infections per recovery (as a fraction of the population).
4352
4353
\index{contact number}
4354
\index{basic reproduction number}
4355
4356
When a new disease is introduced to a susceptible population, $s$ is approximately 1, so the number of people infected by each sick person is $\beta / \gamma$. This ratio is called the ``contact number" or ``basic reproduction number" (see \url{http://modsimpy.com/contact}). By convention it is usually denoted $R_0$, but in the context of an SIR model, this notation is confusing, so we'll use $c$ instead.
4357
4358
The results in the previous section suggest that there is a relationship between $c$ and the total number of infections. We can derive this relationship by analyzing the differential equations from Section~\ref{sireqn}:
4359
%
4360
\begin{align*}
4361
\frac{ds}{dt} &= -\beta s i \\
4362
\frac{di}{dt} &= \beta s i - \gamma i\\
4363
\frac{dr}{dt} &= \gamma i
4364
\end{align*}
4365
%
4366
In the same way we divided the contact rate by the infection rate to get the dimensionless quantity $c$, now we'll divide $di/dt$ by $ds/dt$ to get a ratio of rates:
4367
%
4368
\[ \frac{di}{ds} = -1 + \frac{1}{cs} \]
4369
%
4370
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. From that relationship, we can derive an equation that relates $c$ to the final value of $s$. In theory, this equation makes it possible to infer $c$ by observing the course of an epidemic.
4371
4372
Here's how the derivation goes. We multiply both sides of the previous equation by $ds$:
4373
%
4374
\[ di = \left( -1 + \frac{1}{cs} \right) ds \]
4375
%
4376
And then integrate both sides:
4377
%
4378
\[ i = -s + \frac{1}{c} \log s + q \]
4379
%
4380
where $q$ is a constant of integration. Rearranging terms yields:
4381
%
4382
\[ q = i + s - \frac{1}{c} \log s \]
4383
%
4384
Now let's see if we can figure out what $q$ is. At the beginning of an epidemic, if the fraction infected is small and nearly everyone is susceptible, we can use the approximations $i(0) = 0$ and $s(0) = 1$ to compute $q$:
4385
%
4386
\[ q = 0 + 1 + \frac{1}{c} \log 1 \]
4387
%
4388
Since $\log 1 = 0$, we get $q = 1$.
4389
\index{integration}
4390
\index{constant of integration}
4391
4392
\newcommand{\sinf}{s_{\infty}}
4393
4394
Now, at the end of the epidemic, let's assume that $i(\infty) = 0$, and $s(\infty)$ is an unknown quantity, $\sinf$. Now we have:
4395
%
4396
\[ q = 1 = 0 + \sinf - \frac{1}{c} \log \sinf \]
4397
%
4398
Solving for $c$, we get
4399
%
4400
\[ c = \frac{\log \sinf}{\sinf - 1} \]
4401
%
4402
By relating $c$ and $\sinf$, this equation makes it possible to estimate $c$ based on data, and possibly predict the behavior of future epidemics.
4403
4404
\section{Analysis and simulation}
4405
4406
Let's compare this analytic result to the results from simulation.
4407
I'll create an array of values for $\sinf$
4408
\index{linspace}
4409
4410
\begin{python}
4411
s_inf_array = linspace(0.0001, 0.9999, 31)
4412
\end{python}
4413
4414
And compute the corresponding values of $c$:
4415
4416
\begin{python}
4417
c_array = log(s_inf_array) / (s_inf_array - 1)
4418
\end{python}
4419
4420
To get the total infected, we compute the difference between $s(0)$ and $s(\infty)$, then store the results in a \py{Series}:
4421
\index{array}
4422
\index{series}
4423
4424
\begin{python}
4425
frac_infected = 1 - s_inf_array
4426
frac_infected_series = Series(frac_infected, index=c_array)
4427
\end{python}
4428
4429
Recall from Section~\ref{dataframe} that a \py{Series} object contains an index and a corresponding sequence of values. In this case, the index is \py{c_array} and the values are from \py{frac_infected}.
4430
4431
Now we can plot the results:
4432
4433
\begin{python}
4434
plot(frac_infected_series)
4435
\end{python}
4436
4437
\begin{figure}
4438
\centerline{\includegraphics[height=3in]{figs/chap14-fig02.pdf}}
4439
\caption{Total fraction infected as a function of contact number, showing results from simulation and analysis.}
4440
\label{chap14-fig02}
4441
\end{figure}
4442
4443
Figure~\ref{chap14-fig02} compares the analytic results from this section with the simulation results from Section~\ref{nondim}. When the contact number exceeds 1, analysis and simulation agree.
4444
When the contact number is less than 1, they do not: analysis indicates there should be no infections; in the simulations there are a small number of infections.
4445
\index{analysis}
4446
4447
The reason for the discrepancy is that the simulation divides time into a discrete 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?
4448
4449
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).
4450
\index{patient zero}
4451
4452
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.
4453
4454
\section{Estimating contact number}
4455
4456
Figure~\ref{chap14-fig02} 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.
4457
4458
Well, in theory we can. 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 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.
4459
4460
On the other hand, for larger contact numbers, nearly the entire population is infected, so the curve is nearly 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 close to 90\% of the population.
4461
4462
So the SIR model has limitations; nevertheless, it provides insight into the behavior of infectious disease, especially the phenomenon of herd immunity. As we saw in Chapter~\ref{chap12}, if we know the parameters of the model, we can use it to evaluate possible interventions. And as we saw in this chapter, we might be able to use data from earlier outbreaks to estimate the parameters.
4463
4464
Before you go on, you might want to read the notebook for this chapter, \py{chap14.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
4465
4466
4467
4468
%\part{Modeling thermal systems}
4469
4470
4471
4472
\chapter{Heat}
4473
\label{chap15}
4474
4475
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 the next few chapters, we'll do some physics, starting with {\bf thermal systems}, that is, systems where the temperature of objects changes as heat transfers from one to another.
4476
4477
\index{thermal system}
4478
4479
\section{The coffee cooling problem}
4480
4481
The coffee cooling problem was discussed by Jearl Walker in {\it Scientific American} in 1977\footnote{Walker, ``The Amateur Scientist", {\it Scientific American}, Volume 237, Issue 5, November 1977.}; since then it has become a standard example of modeling and simulation.
4482
4483
\index{coffee cooling problem}
4484
\index{Walker, Jearl}
4485
4486
Here is my version of the problem:
4487
4488
\begin{quote}
4489
Suppose I stop on the way to work to pick up a cup of coffee, which I take with milk. Assuming that I want the coffee to be as hot as possible when I arrive at work, should I add the milk at the coffee shop, wait until I get to work, or add the milk at some point in between?
4490
\end{quote}
4491
4492
To help answer this question, I made a trial run with the milk and coffee in separate containers and took some measurements\footnote{This is fiction. I usually drink tea and bike to work.}:
4493
4494
\begin{itemize}
4495
4496
\item When served, the temperature of the coffee is \SI{90}{\celsius}. The volume is \SI{300}{mL}.
4497
4498
\item The milk is at an initial temperature of \SI{5}{\celsius}, and I take about \SI{50}{mL}.
4499
4500
\item The ambient temperature in my car is \SI{22}{\celsius}.
4501
4502
\item 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 \SI{70}{\celsius}.
4503
4504
\item The milk container is not as well insulated. After 15 minutes, it warms up to \SI{20}{\celsius}, nearly the ambient temperature.
4505
4506
\end{itemize}
4507
4508
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.
4509
4510
4511
\section{Temperature and heat}
4512
4513
To understand how coffee cools (and milk warms), we need a model of temperature and heat. {\bf Temperature} is a property of an object or a system; in SI units it is measured in degrees Celsius (\si{\celsius}). Temperature quantifies how hot or cold the object is, which is related to the average velocity of the particles that make up the object.
4514
4515
\index{temperature}
4516
4517
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.
4518
The transferred energy is called {\bf heat}; in SI units it is measured in joules (\si{\joule}).
4519
4520
\index{heat}
4521
4522
Heat is related to temperature by the following equation (see \url{http://modsimpy.com/thermass}):
4523
%
4524
\[ Q = C~\Delta T \]
4525
%
4526
where $Q$ is the amount of heat transferred to an object, $\Delta T$ is resulting change in temperature, and $C$ is the {\bf thermal mass} of the object, which quantifies how much energy it takes to heat or cool it.
4527
In SI units, thermal mass is measured in joules per degree Celsius (\si{\joule\per\celsius}).
4528
4529
\index{thermal mass}
4530
4531
For objects made primarily from one material, thermal mass can be computed like this:
4532
%
4533
\[ C = m c_p \]
4534
%
4535
where $m$ is the mass of the object and $c_p$ is the {\bf specific heat capacity} of the material (see \url{http://modsimpy.com/specheat}).
4536
4537
\index{specific heat capacity}
4538
4539
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 \SI{4.2}{\joule\per\gram\per\celsius}. Assuming that the density of coffee is close to that of water, which is \SI{1}{\gram\per\milli\liter}, the mass of \SI{300}{\milli\liter} of coffee is \SI{300}{\gram}, and the thermal mass is \SI{1260}{\joule\per\celsius}.
4540
4541
\index{density}
4542
4543
So when a cup of coffee cools from \SI{90}{\celsius} to \SI{70}{\celsius}, the change in temperature, $\Delta T$ is \SI{20}{\celsius}, which means that \SI{25200}{\joule} of heat energy was transferred from the coffee to the surrounding environment (the cup holder and air in my car).
4544
4545
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 cannot\footnote{See \url{http://modsimpy.com/thermo}.}), you could use it to lift a cup of coffee from sea level to \SI{8571}{\meter}, just shy of the height of Mount Everest, \SI{8848}{\meter}.
4546
4547
\index{Mount Everest}
4548
4549
Assuming that the cup has less mass than the coffee, and is made from a material with lower specific heat, we can ignore the thermal mass of the cup.
4550
For a cup with substantial thermal mass, like a ceramic mug, we might consider a model that computes the temperature of coffee and cup separately.
4551
4552
4553
\section{Heat transfer}
4554
4555
In a situation like the coffee cooling problem, there are three ways heat transfers from one object to another (see \url{http://modsimpy.com/transfer}):
4556
4557
\index{heat transfer}
4558
\index{conduction}
4559
\index{convection}
4560
\index{radiation}
4561
4562
\begin{itemize}
4563
4564
\item Conduction: When objects at different temperatures come into contact, the faster-moving particles of the higher-temperature object transfer kinetic energy to the slower-moving particles of the lower-temperature object.
4565
4566
\item Convection: When particles in a gas or liquid flow from place to place, they carry heat energy with them. Fluid flows can be caused by external action, like stirring, or by internal differences in temperature. For example, you might have heard that hot air rises, which is a form of ``natural convection".
4567
4568
\index{fluid flow}
4569
4570
\item Radiation: As the particles in an object move due to thermal energy, they emit electromagnetic radiation. The energy carried by this radiation depends on the object's temperature and surface properties (see \url{http://modsimpy.com/thermrad}).
4571
4572
\end{itemize}
4573
4574
For objects like coffee in a car, the effect of radiation is much smaller than the effects of conduction and convection, so we will ignore it.
4575
4576
Convection can be a complex topic, since it often depends on details of fluid flow in three dimensions. But for this problem we will be able to get away with a simple model called ``Newton's law of cooling".
4577
4578
\index{Newton's law of cooling}
4579
4580
\section{Newton's law of cooling}
4581
4582
Newton's law of cooling asserts that the temperature rate of change for an object is proportional to the difference in temperature between the object and the surrounding environment:
4583
%
4584
\[ \frac{dT}{dt} = -r (T - T_{env}) \]
4585
%
4586
where $T$, the temperature of the object, is a function of time, $t$; $T_{env}$ is the temperature of the environment, and $r$ is a constant that characterizes how quickly heat is transferred between the system and the environment.
4587
4588
Newton's so-called ``law" is really a model: it is a good approximation in some conditions and less good in others.
4589
4590
For example, if the primary mechanism of heat transfer is conduction, Newton's law is ``true", which is to say that $r$ is constant over a wide range of temperatures. And sometimes we can estimate $r$ based on the material properties and shape of the object.
4591
4592
When convection contributes a non-negligible fraction of heat transfer, $r$ depends on temperature, but Newton's law is often accurate enough, at least over a narrow range of temperatures. In this case $r$ usually has to be estimated experimentally, since it depends on details of surface shape, air flow, evaporation, etc.
4593
4594
When radiation makes up a substantial part of heat transfer, Newton's law is not a good model at all. This is the case for objects in space or in a vacuum, and for objects at high temperatures (more than a few hundred degrees Celsius, say).
4595
4596
\index{radiation}
4597
4598
However, for a situation like the coffee cooling problem, we expect Newton's model to be quite good.
4599
4600
4601
\section{Implementation}
4602
\label{coffee_impl}
4603
4604
To get started, let's forget about the milk temporarily and focus on the coffee. I'll create a \py{State} object to represent the initial temperature:
4605
4606
\begin{python}
4607
init = State(T=90)
4608
\end{python}
4609
4610
And a \py{System} object to contain the parameters of the system:
4611
4612
\index{State object}
4613
\index{System object}
4614
4615
\begin{python}
4616
coffee = System(init=init,
4617
volume=300,
4618
r=0.01,
4619
T_env=22,
4620
t_0=0,
4621
t_end=30,
4622
dt=1)
4623
\end{python}
4624
4625
The values of \py{volume}, \py{T_env}, and \py{t_end} come from the statement of the problem. I chose the value of \py{r} arbitrarily for now; we will figure out how to estimate it soon.
4626
4627
\index{time step}
4628
4629
\py{dt} is the time step we use to simulate the cooling process.
4630
Strictly speaking, Newton's law is a differential equation, but over a short period of time we can approximate it with a difference equation:
4631
%
4632
\[ \Delta T = -r (T - T_{env}) dt \]
4633
%
4634
where $dt$ is a small time step and $\Delta T$ is the change in temperature during that time step.
4635
4636
Note: I use $\Delta T$ to denote a change in temperature over time, but in the context of heat transfer, you might also see $\Delta T$ used to denote the difference in temperature between an object and its environment, $T - T_{env}$. To minimize confusion, I avoid this second use.
4637
4638
Now we can write an update function:
4639
4640
\begin{python}
4641
def update_func(state, t, system):
4642
r, T_env, dt = system.r, system.T_env, system.dt
4643
4644
T = state.T
4645
T += -r * (T - T_env) * dt
4646
4647
return State(T=T)
4648
\end{python}
4649
4650
Like previous update functions, this one takes a \py{State} object, a time, and a \py{System} object.
4651
4652
Now if we run
4653
4654
\begin{python}
4655
update_func(init, 0, coffee)
4656
\end{python}
4657
4658
we see that the temperature after one minute is \SI{89.3}{\celsius}, so the temperature drops by about \SI{0.7}{\celsius\per\minute}, at least for this value of \py{r}.
4659
4660
Here's a version of \py{run_simulation} that simulates a series of time steps from \py{t_0} to \py{t_end}:
4661
4662
\index{time step}
4663
\index{\py{run_simulation}}
4664
4665
\begin{python}
4666
def run_simulation(system, update_func):
4667
init = system.init
4668
t_0, t_end, dt = system.t_0, system.t_end, system.dt
4669
4670
frame = TimeFrame(columns=init.index)
4671
frame.row[t_0] = init
4672
ts = linrange(t_0, t_end, dt)
4673
4674
for t in ts:
4675
frame.row[t+dt] = update_func(frame.row[t], t, system)
4676
4677
return frame
4678
\end{python}
4679
4680
This function is similar to previous versions of \py{run_simulation}.
4681
4682
One difference is that it uses \py{linrange} to make an array of values from \py{t_0} to \py{t_end} with time step \py{dt}. The result does not include \py{t_end}, so the last value in the array is \py{t_end-dt}.
4683
4684
\index{linrange}
4685
\index{NumPy}
4686
\index{array}
4687
4688
We can run it like this:
4689
4690
\begin{python}
4691
results = run_simulation(coffee, update_func)
4692
\end{python}
4693
4694
The result is a \py{TimeFrame} object with one row per time step and just one column, \py{T}. The temperature after 30 minutes is \SI{72.3}{\celsius}, which is a little higher than stated in the problem, \SI{70}{\celsius}. We can adjust \py{r} and find the right value by trial and error, but we'll see a better way in the next chapter.
4695
4696
\index{time step}
4697
\index{TimeFrame object}
4698
4699
First I want to wrap what we have so far in a function:
4700
4701
\begin{python}
4702
def make_system(T_init, r, volume, t_end):
4703
init = State(T=T_init)
4704
4705
return System(init=init,
4706
r=r,
4707
volume=volume,
4708
temp=T_init,
4709
t_0=0,
4710
t_end=t_end,
4711
dt=1,
4712
T_env=22)
4713
\end{python}
4714
4715
\py{make_system} takes the system parameters and packs them into a \py{System} object. Now we can simulate the system like this:
4716
4717
\index{\py{make_system}}
4718
4719
\begin{python}
4720
coffee = make_system(T_init=90, r=0.01,
4721
volume=300, t_end=30)
4722
results = run_simulation(coffee, update_func)
4723
\end{python}
4724
4725
Before you go on, you might want to read the notebook for this chapter, \py{chap15.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
4726
4727
4728
\chapter{Mixing}
4729
\label{chap16}
4730
4731
In the previous chapter we wrote a simulation of a cooling cup of coffee. Given the initial temperature of the coffee, the temperature of the atmosphere, and the rate parameter, \py{r}, we can predict how the temperature of the coffee will change over time.
4732
4733
In general, we don't know the value of \py{r}, but we can use measurements to estimate it. Given an initial temperature, a final temperature, and the time in between, we can find \py{r} by trial and error.
4734
4735
In this chapter, we'll see a better way to find \py{r}, using a {\bf bisection search}.
4736
4737
And then we'll get back to solving the coffee cooling problem.
4738
4739
\section{Finding roots}
4740
\label{root_bisect}
4741
4742
The ModSim library provides a method called \py{root_bisect} that finds the roots of non-linear equations. As a simple example, suppose you want to find the roots of the polynomial
4743
%
4744
\[ f(x) = (x - 1)(x - 2)(x - 3) \]
4745
%
4746
where {\bf 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.
4747
4748
\index{\py{root_bisect}}
4749
\index{root}
4750
4751
I'll use this example to demonstrate \py{root_bisect}. First, we have to write a function that evaluates $f$:
4752
4753
\begin{python}
4754
def func(x):
4755
return (x-1) * (x-2) * (x-3)
4756
\end{python}
4757
4758
Now we call \py{root_bisect} like this:
4759
4760
\begin{python}
4761
res = root_bisect(func, [1.5, 2.5])
4762
print(res.root)
4763
\end{python}
4764
4765
The first argument is the function whose roots we want.
4766
The second argument is an interval that contains a root.
4767
The result is an object that contains several variables, including \py{root}, which stores the root that was found.
4768
4769
So how can we use \py{root_bisect} to estimate \py{r}?
4770
4771
What we want is the value of \py{r} that yields a final temperature of \SI{70}{\celsius}. To work with \py{root_bisect}, we need a function that takes \py{r} as a parameter and returns the difference between the final temperature and the goal:
4772
4773
\begin{python}
4774
def error_func1(r):
4775
system = make_system(T_init=90, r=r, volume=300, t_end=30)
4776
results = run_simulation(system, update_func)
4777
T_final = get_last_value(results.T)
4778
return T_final - 70
4779
\end{python}
4780
4781
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 \py{r}, this error will be 0.
4782
4783
\index{error function}
4784
\index{function!error}
4785
4786
We can test \py{error_func1} like this, using our initial guess for \py{r}:
4787
4788
\begin{python}
4789
error_func1(r=0.01)
4790
\end{python}
4791
4792
The result is an error of \SI{2.3}{\celsius}, because the final temperature with this value of \py{r} is too high.
4793
4794
With \py{r=0.02}, the error is \SI{-10.9}{\celsius}, which means that the final temperature is too low.
4795
So we know that the correct value must be in between.
4796
4797
Now we can call \py{root_bisect} like this:
4798
4799
\begin{python}
4800
res = root_bisect(error_func1, [0.01, 0.02])
4801
r_coffee = res.root
4802
\end{python}
4803
4804
In this example, \py{r_coffee} turns out to be about \py{0.012}, in units of \si{\per\minute} (inverse minutes).
4805
4806
\begin{figure}
4807
\centerline{\includegraphics[height=3in]{figs/chap16-fig01.pdf}}
4808
\caption{Temperature of the coffee and milk over time.}
4809
\label{chap16-fig01}
4810
\end{figure}
4811
4812
As one of the exercises for this chapter, you will use the same process to estimate \py{r_milk}.
4813
4814
With the correct values of \py{r_coffee} and \py{r_milk}, the simulation results should look like Figure~\ref{chap16-fig01}, which shows the temperature of the coffee and milk over time.
4815
4816
4817
\section{Mixing liquids}
4818
4819
When we mix two liquids, the temperature of the mixture depends on the temperatures of the ingredients, but it might not be obvious how to compute it.
4820
4821
\index{mixing}
4822
4823
Assuming there are no chemical reactions that either produce or consume heat, the total thermal energy of the system is the same before and after mixing; in other words, thermal energy is {\bf conserved}.
4824
4825
\index{conservation of energy}
4826
4827
If the temperature of the first liquid is $T_1$, the temperature of the second liquid is $T_2$, and the final temperature of the mixture is $T$, the heat transfer into the first liquid is $C_1 (T - T_1)$ and the heat transfer into the second liquid is $C_2 (T - T_2)$, where $C_1$ and $C_2$ are the thermal masses of the liquids.
4828
4829
In order to conserve energy, these heat transfers must add up to 0:
4830
%
4831
\[ C_1 (T - T_1) + C_2 (T - T_2) = 0 \]
4832
%
4833
We can solve this equation for T:
4834
%
4835
\[ T = \frac{C_1 T_1 + C_2 T_2}{C_1 + C_2} \]
4836
%
4837
For the coffee cooling problem, we have the volume of each liquid; if we also know the density, $\rho$, and the specific heat capacity, $c_p$, we can compute thermal mass:
4838
%
4839
\[ C = \rho V c_p \]
4840
%
4841
If the liquids have the same density and heat capacity, they drop out of the equation, and we can write:
4842
%
4843
\[ T = \frac{V_1 T_1 + V_2 T_2}{V_1 + V_2} \]
4844
%
4845
where $V_1$ and $V_2$ are the volumes of the liquids.
4846
4847
As an approximation, I'll assume that milk and coffee have the same density and specific heat.
4848
As an exercise, you can look up these quantities and see how good this assumption is.
4849
4850
\index{volume}
4851
\index{density}
4852
\index{specific heat}
4853
4854
The following function takes two \py{System} objects that represent the coffee and milk, and creates a new \py{System} to represent the mixture:
4855
4856
\begin{python}
4857
def mix(system1, system2):
4858
assert system1.t_end == system2.t_end
4859
4860
V1, V2 = system1.volume, system2.volume
4861
T1, T2 = system1.temp, system2.temp
4862
4863
V_mix = V1 + V2
4864
T_mix = (V1 * T1 + V2 * T2) / V_mix
4865
4866
return make_system(T_init=T_mix,
4867
r=system1.r,
4868
volume=V_mix,
4869
t_end=30)
4870
\end{python}
4871
4872
The first line is an \py{assert} statement, which is a way of checking for errors. It compares \py{t_end} for the two systems to confirm that they have been cooling for the same time. If not, \py{assert} displays an error message and stops the program.
4873
4874
\index{assert statement}
4875
\index{statement!assert}
4876
4877
The next two lines extract volume and temperature from the two \py{System} objects. Then the following two lines compute the volume and temperature of the mixture. Finally, \py{mix} makes a new \py{System} object and returns it.
4878
4879
This function uses the value of \py{r} from \py{system1} as the value of \py{r} for the mixture. If \py{system1} 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 \py{r}. So this is an assumption we might want to revisit.
4880
4881
Notice that \py{mix} requires the \py{System} objects to have a variable named \py{temp}.
4882
To make sure this variable gets updated when we run a simulation, I use this function:
4883
4884
\begin{python}
4885
def run_and_set(system):
4886
results = run_simulation(system, update_func)
4887
system.temp = get_last_value(results.T)
4888
return results
4889
\end{python}
4890
4891
Now we have everything we need to solve the problem.
4892
4893
\section{Mix first or last?}
4894
4895
First I'll create objects to represent the coffee and cream:
4896
4897
\begin{python}
4898
coffee = make_system(T_init=90, r=r_coffee,
4899
volume=300, t_end=30)
4900
4901
milk = make_system(T_init=5, r=r_milk, volume=50, t_end=30)
4902
\end{python}
4903
4904
Then I'll mix them and simulate 30 minutes:
4905
4906
\begin{python}
4907
mix_first = mix(coffee, milk)
4908
mix_results = run_and_set(mix_first)
4909
\end{python}
4910
4911
The final temperature is \SI{61.4}{\celsius} which is still warm enough to be enjoyable. Would we do any better if we added the milk last?
4912
4913
I'll simulate the coffee and milk separately, and then mix them:
4914
4915
\begin{python}
4916
coffee_results = run_and_set(coffee)
4917
milk_results = run_and_set(milk)
4918
mix_last = mix(coffee, milk)
4919
\end{python}
4920
4921
After mixing, the temperature is \SI{63.1}{\celsius}. So it looks like adding the milk at the end is better by about \SI{1.7}{\celsius}. But is that the best we can do?
4922
4923
\section{Optimization}
4924
4925
Adding the milk after 30 minutes is better than adding immediately, but maybe there's something in between that's even better. To find out, I'll use the following function, which takes the time to add the milk, \py{t_add}, as a parameter:
4926
4927
\index{optimization}
4928
4929
\begin{python}
4930
def run_and_mix(t_add, t_total):
4931
coffee = make_system(T_init=90, r=r_coffee,
4932
volume=300, t_end=t_add)
4933
coffee_results = run_and_set(coffee)
4934
4935
milk = make_system(T_init=5, r=r_milk,
4936
volume=50, t_end=t_add)
4937
milk_results = run_and_set(milk)
4938
4939
mixture = mix(coffee, milk)
4940
mixture.t_end = t_total - t_add
4941
results = run_and_set(mixture)
4942
4943
return mixture.temp
4944
\end{python}
4945
4946
When \py{t_add=0}, we add the milk immediately; when \py{t_add=30}, we add it at the end. Now we can sweep the range of values in between:
4947
4948
\begin{python}
4949
sweep = SweepSeries()
4950
for t_add in linspace(0, 30, 11):
4951
sweep[t_add] = run_and_mix(t_add, 30)
4952
\end{python}
4953
4954
\begin{figure}
4955
\centerline{\includegraphics[height=3in]{figs/chap16-fig02.pdf}}
4956
\caption{Final temperature as a function of the time the milk is added.}
4957
\label{chap16-fig02}
4958
\end{figure}
4959
4960
Figure~\ref{chap16-fig02} 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 \py{TimeSeries}.
4961
4962
The final temperature is maximized when \py{t_add=30}, so adding the milk at the end is optimal.
4963
4964
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 won't let me take milk in a separate container, but I keep a bottle of milk in the refrigerator at my office. In that case is it better to add the milk at the coffee shop, or wait until I get to the office?
4965
4966
4967
\section{Analysis}
4968
4969
Simulating Newton's law of cooling is almost silly, because we can solve the differential equation analytically. If
4970
%
4971
\[ \frac{dT}{dt} = -r (T - T_{env}) \]
4972
%
4973
the general solution is
4974
%
4975
\[ T{\left (t \right )} = C_{1} \exp(-r t) + T_{env} \]
4976
%
4977
and the particular solution where $T(0) = T_{init}$ is
4978
%
4979
\[ T_{env} + \left(- T_{env} + T_{init}\right) \exp(-r t) \]
4980
%
4981
You can see how I got this solution using SymPy in \py{chap16sympy.ipynb} in the repository for this book. If you would like to see it done by hand, you can watch this video: \url{http://modsimpy.com/khan3}.
4982
4983
\index{analysis}
4984
\index{SymPy}
4985
4986
Now we can use the observed data to estimate the parameter $r$. If we observe $T(t_{end}) = T_{end}$, we can plug $t_{end}$ and $T_{end}$ into the particular solution and solve for $r$. The result is:
4987
%
4988
\[ r = \frac{1}{t_{end}} \log{\left (\frac{T_{init} - T_{env}}{T_{end} - T_{env}} \right )} \]
4989
%
4990
Plugging in $t_{end}=30$ and $T_{end}=70$ (and again with $T_{init}=90$ and $T_{env}=22$), the estimate for $r$ is 0.0116.
4991
4992
We can use the following function to compute the time series:
4993
4994
\index{unpack}
4995
4996
\begin{python}
4997
def run_analysis(system):
4998
T_env, r = system.T_env, system.r
4999
5000
T_init = system.init.T
5001
ts = linspace(0, system.t_end)
5002
5003
T_array = T_env + (T_init - T_env) * exp(-r * ts)
5004
5005
return TimeFrame(T_array, index=ts, columns=['T'])
5006
\end{python}
5007
5008
This function is similar to \py{run_simulation}; it takes a \py{System} as a parameter and returns a \py{TimeFrame} as a result.
5009
5010
Because \py{linrange} returns a NumPy array, \py{T_array} is also a NumPy array. To be consistent with \py{run_simulation}, we have to put it into a \py{TimeFrame}.
5011
5012
We can run it like this:
5013
\index{\py{run_analysis}}
5014
5015
\begin{python}
5016
r_coffee2 = 0.0116
5017
coffee2 = make_system(T_init=90, r=r_coffee2,
5018
volume=300, t_end=30)
5019
results = run_analysis(coffee2)
5020
\end{python}
5021
5022
The final temperature is \SI{70}{\celsius}, as it should be. In fact, the results are identical to what we got by simulation, with a small difference due to rounding.
5023
5024
Before you go on, you might want to read the notebook for this chapter, \py{chap16.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
5025
5026
5027
%%\part{Pharmacokinetics}
5028
5029
\chapter{Pharmacokinetics}
5030
\label{chap17}
5031
5032
{\bf Pharmacokinetics} is the study of how drugs and other substances move around the body, react, and are eliminated. In this chapter, we implement one of the most widely used pharmacokinetic models: the so-called {\bf minimal model} of glucose and insulin in the blood stream.
5033
5034
\index{pharmacokinetics}
5035
5036
%We will use this model to fit data collected from a patient, and use the %parameters of the fitted model to quantify the patient's ability to %produce insulin and process glucose.
5037
5038
\index{glucose}
5039
\index{insulin}
5040
5041
My presentation in this chapter follows Bergman (2005) ``Minimal Model" (abstract at \url{http://modsimpy.com/bergman},
5042
PDF at \url{http://modsimpy.com/minmod}).
5043
5044
5045
\section{The glucose-insulin system}
5046
5047
{\bf Glucose} is a form of sugar that circulates in the blood of animals; it is used as fuel for muscles, the brain, and other organs. The concentration of blood sugar is controlled by the hormone system, and especially by {\bf insulin}, which is produced by the pancreas and has the effect of reducing blood sugar.
5048
5049
\index{pancreas}
5050
5051
In people with normal pancreatic function, the hormone system maintains {\bf homeostasis}; that is, it keeps the concentration of blood sugar in a range that is neither too high or too low.
5052
5053
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 {\bf hyperglycemia}. Long term, severe hyperglycemia is the defining symptom of {\bf diabetes mellitus}, a serious disease that affects almost 10\% of the population in the U.S. (see \url{http://modsimpy.com/cdc}).
5054
5055
\index{hyperglycemia}
5056
\index{diabetes}
5057
5058
One of the most-used tests for hyperglycemia and diabetes is the frequently sampled intravenous glucose tolerance test (FSIGT), in which glucose is injected into the blood stream of a fasting subject (someone who has not eaten recently); then blood samples are collected at intervals of 2--10 minutes for 3 hours. The samples are analyzed to measure the concentrations of glucose and insulin.
5059
5060
\index{FSIGT}
5061
5062
By analyzing these measurements, we can estimate several parameters of the subject's response; the most important is a parameter denoted $S_I$, which quantifies the effect of insulin on the rate of reduction in blood sugar.
5063
5064
5065
\section{The glucose minimal model}
5066
5067
The ``minimal model" was proposed by Bergman, Ider, Bowden, and Cobelli\footnote{Bergman RN, Ider YZ, Bowden CR, Cobelli C., ``Quantitative estimation of insulin sensitivity", Am J Physiol. 1979 Jun;236(6):E667-77. Abstract at \url{http://modsimpy.com/insulin}.}.
5068
It consists of two parts: the glucose model and the insulin model. I will present an implementation of the glucose model; as a case study, you will have the chance to implement the insulin model.
5069
5070
\index{minimal model}
5071
5072
The original model was developed in the 1970s; since then, many variations and extensions have been proposed. Bergman's comments on the development of the model provide insight into their process:
5073
5074
\begin{quote}
5075
We applied the principle of Occam's Razor, i.e.~by asking
5076
what was the simplest model based upon known physiology
5077
that could account for the insulin-glucose relationship
5078
revealed in the data. Such a model must be simple
5079
enough to account totally for the measured glucose (given
5080
the insulin input), yet it must be possible, using mathematical
5081
techniques, to estimate all the characteristic parameters
5082
of the model from a single data set (thus avoiding
5083
unverifiable assumptions).
5084
\end{quote}
5085
5086
The most useful models are the ones that achieve this balance: including enough realism to capture the essential features of the system without so much complexity that they are impractical. In this example, the practical limit is the ability to estimate the parameters of the model using data, and to interpret the parameters meaningfully.
5087
5088
\index{Occam's Razor}
5089
5090
Bergman discusses the features he and his colleagues thought were essential:
5091
5092
\begin{quote}
5093
(1) Glucose, once elevated by injection, returns to basal level due to
5094
two effects: the effect of glucose itself to normalize its own
5095
concentration [...] as well as the catalytic effect of insulin to allow
5096
glucose to self-normalize (2) Also, we discovered
5097
that the effect of insulin on net glucose disappearance
5098
must be sluggish --- that is, that insulin acts slowly because
5099
insulin must first move from plasma to a remote compartment [...] to exert its action on glucose disposal.
5100
\end{quote}
5101
5102
To paraphrase the second point, the effect of insulin on glucose disposal, as seen in the data, happens more slowly than we would expect if it depended primarily on the concentration of insulin in the blood. Bergman's group hypothesized that insulin must move relatively slowly from the blood to a ``remote compartment" where it has its effect.
5103
5104
\index{compartment model}
5105
5106
At the time, the remote compartment was a modeling abstraction that might, or might not, represent 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.
5107
5108
\index{interstitial fluid}
5109
5110
The glucose model consists of two differential equations:
5111
%
5112
\[ \frac{dG}{dt} = -k_1 \left[ G(t) - G_b \right] - X(t) G(t) \]
5113
%
5114
\[ \frac{dX}{dt} = k_3 \left[I(t) - I_b \right] - k_2 X(t) \]
5115
%
5116
where
5117
5118
\begin{itemize}
5119
5120
\item $G$ is the concentration of blood glucose as a function of time and $dG/dt$ is its rate of change.
5121
5122
\item $X$ is the concentration of insulin in the tissue fluid as a function of time, and $dX/dt$ is its rate of change.
5123
5124
\item $I$ is the concentration of insulin in the blood as a function of time, which is taken as an input into the model, based on measurements.
5125
5126
\item $G_b$ is the basal concentration of blood glucose and $I_b$ is the basal concentration of blood insulin, that is, the concentrations at equilibrium. Both are constants estimated from measurements at the beginning or end of the test.
5127
5128
\item $k_1$, $k_2$, and $k_3$ are positive-valued parameters that control the rates of appearance and disappearance for glucose and insulin.
5129
5130
\end{itemize}
5131
5132
We can interpret the terms in the equations one by one:
5133
5134
\begin{itemize}
5135
5136
\item $-k_1 \left[ G(t) - G_b \right]$ is the rate of glucose disappearance due to the effect of glucose itself. When $G(t)$ is above basal level, $G_b$, this term is negative; when $G(t)$ is below basal level this term is positive. So in the absence of insulin, this term tends to restore blood glucose to basal level.
5137
5138
\item $-X(t) G(t)$ models the interaction of glucose and insulin in tissue fluid, so the rate increases as either $X$ or $G$ increases. This term does not require a rate parameter because the units of $X$ are unspecified; we can consider $X$ to be in whatever units make the parameter of this term 1.
5139
5140
\item $k_3 \left[ I(t) - I_b \right]$ 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.
5141
5142
\item $-k_2 X(t)$ is the rate of insulin disappearance in tissue fluid as it is consumed or broken down.
5143
5144
\end{itemize}
5145
5146
The initial state of the model is $X(0) = I_b$ and $G(0) = G_0$, where $G_0$ is a constant that represents the concentration of blood sugar immediately after the injection. In theory we could estimate $G_0$ based on measurements, but in practice it takes time for the injected glucose to spread through the blood volume. Since $G_0$ is not measurable, it is treated as a {\bf free parameter} of the model, which means that we are free to choose it to fit the data.
5147
5148
\index{free parameter}
5149
5150
5151
\section{Data}
5152
5153
To develop and test the model, I use data from Pacini and Bergman\footnote{``MINMOD: A computer program to calculate insulin sensitivity and pancreatic responsivity from the frequently sampled intravenous glucose tolerance test", {\em Computer Methods and Programs in Biomedicine} 23: 113-122, 1986.}. The dataset is in a file in the repository for this book, which we can read into a \py{DataFrame}:
5154
5155
\index{data}
5156
\index{DataFrame object}
5157
5158
\begin{python}
5159
data = pd.read_csv('data/glucose_insulin.csv',
5160
index_col='time')
5161
\end{python}
5162
5163
\py{data} has two columns: \py{glucose} is the concentration of blood glucose in \si{\milli\gram/\deci\liter}; \py{insulin} is concentration of insulin in the blood in \si{\micro U\per\milli\liter} (a medical ``unit", denoted \si{U}, is an amount defined by convention in context). The index is time in \si{\minute}.
5164
5165
\index{concentration}
5166
5167
\begin{figure}
5168
\centerline{\includegraphics[width=3.5in]{figs/chap17-fig01.pdf}}
5169
\caption{Glucose and insulin concentrations measured by FSIGT.}
5170
\label{chap17-fig01}
5171
\end{figure}
5172
5173
Figure~\ref{chap17-fig01} shows glucose and insulin concentrations over \SI{182}{\minute} for a subject with normal insulin production and sensitivity.
5174
5175
5176
\section{Interpolation}
5177
\label{interpolate}
5178
5179
Before we are ready to implement the model, there's 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 \py{DataFrame}, we only have measurements at discrete times. This is a job for interpolation!
5180
5181
\index{interpolation}
5182
5183
\begin{figure}
5184
\centerline{\includegraphics[height=3in]{figs/chap17-fig02.pdf}}
5185
\caption{Insulin concentrations measured by FSIGT and an interpolated function.}
5186
\label{chap17-fig02}
5187
\end{figure}
5188
5189
The ModSim library provides a function named \py{interpolate}, which is a wrapper for the SciPy function \py{interp1d}. It takes any kind of \py{Series} as a parameter, including \py{TimeSeries} and \py{SweepSeries}, and returns a function. That's right, I said it returns a {\em function}.
5190
5191
\index{function!as return value}
5192
\index{Series}
5193
\index{interpolate}
5194
\index{interp1d}
5195
\index{SciPy}
5196
5197
So we can call \py{interpolate} like this:
5198
5199
\begin{python}
5200
I = interpolate(data.insulin)
5201
\end{python}
5202
5203
Then we can call the new function, \py{I}, like this:
5204
5205
\begin{python}
5206
I(18)
5207
\end{python}
5208
5209
The result is 31.66, which is a linear interpolation between the actual measurements at \py{t=16} and \py{t=19}. We can also pass an array as an argument to \py{I}:
5210
5211
\begin{python}
5212
ts = linrange(t_0, t_end, endpoint=True)
5213
I(ts)
5214
\end{python}
5215
5216
The result is an array of interpolated values for equally-spaced values of \py{t}, shown in Figure~\ref{chap17-fig02}.
5217
5218
\index{linrange}
5219
\index{NumPy}
5220
5221
\py{interpolate} can take additional arguments, which it passes along to \py{interp1d}. You can read about these options at \url{http://modsimpy.com/interp}.
5222
5223
In the next chapter, we will use interpolated data to run the simulation of the glucose-insulin system.
5224
5225
Before you go on, you might want to read the notebook for this chapter, \py{chap17.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
5226
5227
5228
\chapter{The glucose model}
5229
\label{chap18}
5230
5231
In this chapter, we implement the glucose minimal model described in the previous chapter. We'll start with \py{run_simulation}, which solves differential equations using discrete time steps. This method works well enough for many applications, but it is not very accurate. In this chapter we explore a better option: using an {\bf ODE solver}.
5232
5233
5234
\section{Implementation}
5235
\label{glucose}
5236
5237
To get started, let's assume that the parameters of the model are known. We'll implement the model and use it to generate time series for \py{G} and \py{X}. Then we'll see how to find the parameters that generate the series that best fits the data.
5238
5239
Taking advantage of estimates from prior work, we'll start with these values:
5240
5241
\begin{python}
5242
params = Params(G0 = 290,
5243
k1 = 0.03,
5244
k2 = 0.02,
5245
k3 = 1e-05)
5246
\end{python}
5247
5248
A \py{Params} object is similar to a \py{System} or \py{State} object; it is useful for holding a collection of parameters.
5249
5250
\index{State object}
5251
\index{System object}
5252
\index{Params object}
5253
5254
% TODO: I introduce Params here because I use it with leastsq, but since we aren't
5255
% using leastsq here any more, we have the option of postponing Params
5256
5257
We can pass \py{params} and \py{data} to \py{make_system}:
5258
5259
\begin{python}
5260
def make_system(params, data):
5261
G0, k1, k2, k3 = params
5262
5263
Gb = data.glucose[0]
5264
Ib = data.insulin[0]
5265
I = interpolate(data.insulin)
5266
5267
t_0 = get_first_label(data)
5268
t_end = get_last_label(data)
5269
5270
init = State(G=G0, X=0)
5271
5272
return System(params,
5273
init=init, Gb=Gb, Ib=Ib, I=I,
5274
t_0=t_0, t_end=t_end, dt=2)
5275
\end{python}
5276
5277
\py{make_system} uses the measurements at \py{t=0} as the basal levels, \py{Gb} and \py{Ib}.
5278
It gets \py{t_0} and \py{t_end} from the data.
5279
And it uses the parameter \py{G0} as the initial value for \py{G}.
5280
Then it packs everything into a \py{System} object.
5281
5282
Here's the update function:
5283
5284
\index{update function}
5285
\index{function!update}
5286
5287
\begin{python}
5288
def update_func(state, t, system):
5289
G, X = state
5290
k1, k2, k3 = system.k1, system.k2, system.k3
5291
I, Ib, Gb = system.I, system.Ib, system.Gb
5292
dt = system.dt
5293
5294
dGdt = -k1 * (G - Gb) - X*G
5295
dXdt = k3 * (I(t) - Ib) - k2 * X
5296
5297
G += dGdt * dt
5298
X += dXdt * dt
5299
5300
return State(G=G, X=X)
5301
\end{python}
5302
5303
As usual, the update function takes a \py{State} object, a time, and a \py{System} object as parameters. The first line uses multiple assignment to extract the current values of \py{G} and \py{X}.
5304
5305
The following lines unpack the parameters we need from the \py{System} object.
5306
5307
Computing the derivatives \py{dGdt} and \py{dXdt} is straightforward; we just translate the equations from math notation to Python.
5308
5309
\index{derivative}
5310
5311
Then, to perform the update, we multiply each derivative by the discrete time step \py{dt}, which is \SI{2}{\minute} in this example.
5312
The return value is a \py{State} object with the new values of \py{G} and \py{X}.
5313
5314
\index{time step}
5315
5316
Before running the simulation, it is a good idea to run the update function with the initial conditions:
5317
5318
\begin{python}
5319
update_func(system.init, system.t_0, system)
5320
\end{python}
5321
5322
If it runs without errors and there is nothing obviously wrong with the results, we are ready to run the simulation. We'll use this version of \py{run_simulation}, which is very similar to previous versions:
5323
5324
\index{\py{run_simulation}}
5325
5326
\begin{python}
5327
def run_simulation(system, update_func):
5328
init = system.init
5329
t_0, t_end, dt = system.t_0, system.t_end, system.dt
5330
5331
frame = TimeFrame(columns=init.index)
5332
frame.row[t_0] = init
5333
ts = linrange(t_0, t_end, dt)
5334
5335
for t in ts:
5336
frame.row[t+dt] = update_func(frame.row[t], t, system)
5337
5338
return frame
5339
\end{python}
5340
5341
We can run it like this:
5342
5343
\begin{python}
5344
results = run_simulation(system, update_func)
5345
\end{python}
5346
5347
\begin{figure}
5348
\centerline{\includegraphics[width=3.5in]{figs/chap18-fig01.pdf}}
5349
\caption{Results from simulation of the glucose minimal model.}
5350
\label{chap18-fig01}
5351
\end{figure}
5352
5353
Figure~\ref{chap18-fig01} shows the results. The top plot shows simulated glucose levels from the model along with the measured data. The bottom plot shows simulated insulin levels in tissue fluid, which is in unspecified units, and not to be confused with measured insulin levels in the blood.
5354
5355
With the parameters I chose, the model fits the data well, except for the first few data points, where we don't expect the model to be accurate.
5356
5357
5358
\section{Solving differential equations}
5359
\label{slopefunc}
5360
5361
So far we have solved differential equations by rewriting them as difference equations. In the current example, the differential equations are:
5362
%
5363
\[ \frac{dG}{dt} = -k_1 \left[ G(t) - G_b \right] - X(t) G(t) \]
5364
%
5365
\[ \frac{dX}{dt} = k_3 \left[I(t) - I_b \right] - k_2 X(t) \]
5366
%
5367
If we multiply both sides by $dt$, we have:
5368
%
5369
\[ dG = \left[ -k_1 \left[ G(t) - G_b \right] - X(t) G(t) \right] dt \]
5370
%
5371
\[ dX = \left[ k_3 \left[I(t) - I_b \right] - k_2 X(t) \right] dt \]
5372
%
5373
When $dt$ is very small, or more precisely {\bf infinitesimal}, this equation is exact. But in our simulations, $dt$ is \SI{2}{\minute}, which is not very small. In effect, the simulations assume that the derivatives $dG/dt$ and $dX/dt$ are constant during each \SI{2}{\minute} time step.
5374
5375
\index{time step}
5376
5377
This method, evaluating derivatives at discrete time steps and assuming that they are constant in between, is called {\bf Euler's method} (see \url{http://modsimpy.com/euler}).
5378
5379
\index{Euler's method}
5380
5381
Euler's method is good enough for some simple problems, but it is not very accurate. Other methods are more accurate, but many of them are substantially more complicated.
5382
5383
One of the best simple methods is called {\bf Ralston's method}. The ModSim library provides a function called \py{run_ode_solver} that implements it.
5384
5385
The ``ODE" in \py{run_ode_solver} stands for ``ordinary differential equation". The equations we are solving are ``ordinary'' because all the derivatives are with respect to the same variable; in other words, there are no partial derivatives.
5386
5387
\index{ordinary differential equation}
5388
5389
To use \py{run_ode_solver}, we have to provide a ``slope function", like this:
5390
5391
\index{slope function}
5392
\index{function!slope}
5393
\index{unpack}
5394
5395
\begin{python}
5396
def slope_func(state, t, system):
5397
G, X = state
5398
k1, k2, k3 = system.k1, system.k2, system.k3
5399
I, Ib, Gb = system.I, system.Ib, system.Gb
5400
5401
dGdt = -k1 * (G - Gb) - X*G
5402
dXdt = k3 * (I(t) - Ib) - k2 * X
5403
5404
return dGdt, dXdt
5405
\end{python}
5406
5407
\py{slope_func} is similar to \py{update_func}; in fact, it takes the same parameters in the same order. But \py{slope_func} is simpler, because all we have to do is compute the derivatives, that is, the slopes. We don't have to do the updates; \py{run_ode_solver} does them for us.
5408
5409
\index{\py{run_ode_solver}}
5410
5411
Now we can call \py{run_ode_solver} like this:
5412
5413
\begin{python}
5414
results2, details = run_ode_solver(system, slope_func)
5415
\end{python}
5416
5417
\py{run_ode_solver} is similar to \py{run_simulation}: it takes a \py{System} object and a slope function as parameters. It returns two values: a \py{TimeFrame} with the solution and a \py{ModSimSeries} with additional information.
5418
5419
A \py{ModSimSeries} is like a \py{System} or \py{State} object; it contains a set of variables and their values. The \py{ModSimSeries} from \py{run_ode_solver}, which we assign to \py{details}, contains information about how the solver ran, including a success code and diagnostic message.
5420
5421
The \py{TimeFrame}, which we assign to \py{results}, has one row for each time step and one column for each state variable. In this example, the rows are time from 0 to 182 minutes; the columns are the state variables, \py{G} and \py{X}.
5422
5423
\index{TimeFrame object}
5424
5425
\begin{figure}
5426
\centerline{\includegraphics[height=3in]{figs/chap18-fig02.pdf}}
5427
\caption{Results from Euler's method (simulation) and Ralston's method (ODE solver).}
5428
\label{chap18-fig02}
5429
\end{figure}
5430
5431
Figure~\ref{chap18-fig02} shows the results from \py{run_simulation} and \py{run_ode_solver}. The difference between them is barely visible.
5432
5433
We can compute the percentage differences like this:
5434
5435
\begin{python}
5436
diff = results.G - results2.G
5437
percent_diff = diff / results2.G * 100
5438
\end{python}
5439
5440
The largest percentage difference is less than 2\%, which is small enough that it probably doesn't matter in practice. Later, we will see examples where Ralston's method is substantially more accurate.
5441
5442
Before you go on, you might want to read the notebook for this chapter, \py{chap18.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
5443
5444
5445
\chapter{Case studies}
5446
\label{chap19}
5447
5448
This chapter reviews the computational patterns we have seen so far and presents exercises where you can apply them.
5449
5450
\section{Computational tools}
5451
5452
In Chapter~\ref{chap11} we saw an update function that uses multiple assignment to unpack a \py{State} object and assign the state variables to local variables.
5453
5454
\begin{python}
5455
def update_func(state, t, system):
5456
s, i, r = state
5457
5458
infected = system.beta * i * s
5459
recovered = system.gamma * i
5460
5461
s -= infected
5462
i += infected - recovered
5463
r += recovered
5464
5465
return State(S=s, I=i, R=r)
5466
\end{python}
5467
5468
And in \py{run_simulation} we used multiple assignment again to assign state variables to a row in a \py{TimeFrame}:
5469
5470
\begin{python}
5471
def run_simulation(system, update_func):
5472
frame = TimeFrame(columns=system.init.index)
5473
frame.row[system.t_0] = system.init
5474
5475
for t in linrange(system.t_0, system.t_end):
5476
frame.row[t+1] = update_func(frame.row[t], system)
5477
5478
return frame
5479
\end{python}
5480
5481
In Chapter~\ref{chap12} we used the functions \py{max} and \py{idxmax} to compute metrics:
5482
5483
\begin{python}
5484
largest_value = S.max()
5485
time_of_largest_value = S.idxmax()
5486
\end{python}
5487
5488
And we saw the logistic function, a general function which is useful for modeling relationships between variables, like the effectiveness of an intervention as a function of expenditure.
5489
5490
In Chapter~\ref{chap13} we used a \py{SweepFrame} object to sweep two parameters:
5491
5492
\begin{python}
5493
def sweep_parameters(beta_array, gamma_array):
5494
frame = SweepFrame(columns=gamma_array)
5495
for gamma in gamma_array:
5496
frame[gamma] = sweep_beta(beta_array, gamma)
5497
return frame
5498
\end{python}
5499
5500
And we used \py{contour} to generate a contour plot of a two-dimensional sweep.
5501
5502
In Chapter~\ref{chap15} we used \py{linrange} to create an array of equally spaced values. \py{linrange} is similar to \py{linspace}: the difference is that \py{linrange} lets you specify the space between values, and it computes the number of values; \py{linspace} lets you specify the number of values, and it computes the space between them.
5503
5504
Here's a version of \py{run_simulation} that uses \py{linrange}:
5505
5506
\begin{python}
5507
def run_simulation(system, update_func):
5508
init = system.init
5509
t_0, t_end, dt = system.t_0, system.t_end, system.dt
5510
5511
frame = TimeFrame(columns=init.index)
5512
frame.row[t_0] = init
5513
ts = linrange(t_0, t_end, dt)
5514
5515
for t in ts:
5516
frame.row[t+dt] = update_func(frame.row[t], t, system)
5517
5518
return frame
5519
\end{python}
5520
5521
In Chapter~\ref{chap16} we used \py{root_bisect} to find the value of a parameter that yields a particular result. We defined an error function:
5522
5523
\index{\py{root_bisect}}
5524
5525
\begin{python}
5526
system = make_system(T_init=90, r=r, volume=300, t_end=30)
5527
results = run_simulation(system, update_func)
5528
T_final = get_last_value(results.T)
5529
return T_final - 70
5530
\end{python}
5531
5532
And passed it to \py{root_bisect} with an initial interval, like this:
5533
5534
\begin{python}
5535
res = root_bisect(error_func1, [0.01, 0.02])
5536
r_coffee = res.root
5537
\end{python}
5538
5539
In Chapter~\ref{chap17} we used \py{interpolate}, which returns a function:
5540
5541
\begin{python}
5542
I = interpolate(data.insulin)
5543
\end{python}
5544
5545
We can call \py{I} like any other function, passing as an argument either a single value or a NumPy array:
5546
5547
\begin{python}
5548
I(18)
5549
5550
ts = linrange(t_0, t_end)
5551
I(ts)
5552
\end{python}
5553
5554
In Chapter~\ref{chap18} we used a \py{Params} object, which is a collection of parameters.
5555
5556
\begin{python}
5557
params = Params(G0 = 290,
5558
k1 = 0.03,
5559
k2 = 0.02,
5560
k3 = 1e-05)
5561
\end{python}
5562
5563
Chapter~\ref{chap18} also introduces \py{run_ode_solver} which computes numerical solutions to differential equations.
5564
5565
\py{run_ode_solver} uses a slope function, which is similar to an update function:
5566
5567
\begin{python}
5568
G, X = state
5569
k1, k2, k3 = system.k1, system.k2, system.k3
5570
I, Ib, Gb = system.I, system.Ib, system.Gb
5571
5572
dGdt = -k1 * (G - Gb) - X*G
5573
dXdt = k3 * (I(t) - Ib) - k2 * X
5574
5575
return dGdt, dXdt
5576
\end{python}
5577
5578
And we can call it like this:
5579
5580
\begin{python}
5581
results, details = run_ode_solver(system, slope_func)
5582
\end{python}
5583
5584
The rest of this chapter presents case studies you can use to practice these tools.
5585
5586
5587
\section{The insulin minimal model}
5588
5589
Along with the glucose minimal model in Chapter~\ref{chap17}, Berman et al.~developed an insulin minimal model, in which the concentration of insulin, $I$, is governed by this differential equation:
5590
%
5591
\[ \frac{dI}{dt} = -k I(t) + \gamma \left[ G(t) - G_T \right] t \]
5592
%
5593
where
5594
5595
\begin{itemize}
5596
5597
\item $k$ is a parameter that controls the rate of insulin disappearance independent of blood glucose.
5598
5599
\item $G(t)$ is the measured concentration of blood glucose at time $t$.
5600
5601
\item $G_T$ is the glucose threshold; when blood glucose is above this level, it triggers an increase in blood insulin.
5602
5603
\item $\gamma$ is a parameter that controls the rate of increase (or decrease) in blood insulin when glucose is above (or below) $G_T$.
5604
5605
% TODO: explain why t is there
5606
5607
\end{itemize}
5608
5609
The initial condition is $I(0) = I_0$. As in the glucose minimal model, we treat the initial condition as a parameter which we'll choose to fit the data.
5610
5611
\index{insulin minimal model}
5612
\index{differential equation}
5613
5614
The parameters of this model can be used to estimate $\phi_1$ and $\phi_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:
5615
%
5616
\[ \phi_1 = \frac{I_{max} - I_b}{k (G_0 - G_b)}\]
5617
%
5618
\[ \phi_2 = \gamma \times 10^4 \]
5619
%
5620
where $I_{max}$ is the maximum measured insulin level, and $I_b$ and $G_b$ are the basal levels of insulin and glucose.
5621
5622
%TODO: Clarify whether G0 here is the parameter we estimated in the previous
5623
% model, or the maximum observed value of G.
5624
5625
In the repository for this book, you will find a notebook, \py{insulin.ipynb}, which contains starter code for this case study. Use it to implement the insulin model, find the parameters that best fit the data, and estimate these values.
5626
5627
5628
\section{Low-Pass Filter}
5629
5630
The following circuit diagram\footnote{From \url{http://modsimpy.com/divider}} shows a low-pass filter built with one resistor and one capacitor.
5631
5632
\centerline{\includegraphics[height=1.2in]{figs/RC_Divider.pdf}}
5633
5634
A ``filter" is a circuit takes a signal, $V_{in}$, as input and produces a signal, $V_{out}$, as output. In this context, a ``signal" is a voltage that changes over time.
5635
5636
A filter is ``low-pass" if it allows low-frequency signals to pass from $V_{in}$ to $V_{out}$ unchanged, but it reduces the amplitude of high-frequency signals.
5637
5638
By applying the laws of circuit analysis, we can derive a differential equation that describes the behavior of this system. By solving the differential equation, we can predict the effect of this circuit on any input signal.
5639
5640
Suppose we are given $V_{in}$ and $V_{out}$ at a particular instant in time. By Ohm's law, which is a simple model of the behavior of resistors, the instantaneous current through the resistor is:
5641
%
5642
\[ I_R = (V_{in} - V_{out}) / R \]
5643
%
5644
where $R$ is resistance in ohms (\si{\ohm}).
5645
5646
Assuming that no current flows through the output of the circuit, Kirchhoff's current law implies that the current through the capacitor is:
5647
%
5648
\[ I_C = I_R \]
5649
%
5650
According to a simple model of the behavior of capacitors, current through the capacitor causes a change in the voltage across the capacitor:
5651
%
5652
\[ I_C = C \frac{d V_{out}}{dt} \]
5653
%
5654
where $C$ is capacitance in farads (\si{\farad}). Combining these equations yields a differential equation for $V_{out}$:
5655
%
5656
\[ \frac{d V_{out}}{dt} = \frac{V_{in} - V_{out}}{R C} \]
5657
%
5658
In the repository for this book, you will find a notebook, \py{filter.ipynb}, which contains starter code for this case study. Follow the instructions to simulate the low-pass filter for input signals like this:
5659
%
5660
\[ V_{in}(t) = A \cos (2 \pi f t) \]
5661
%
5662
where $A$ is the amplitude of the input signal, say \SI{5}{\volt}, and $f$ is the frequency of the signal in \si{\hertz}.
5663
5664
In the repository for this book, you will find a notebook, \py{filter.ipynb}, which contains starter code for this case study. Read the notebook, run the code, and work on the exercises.
5665
5666
5667
5668
\section{Thermal behavior of a wall}
5669
5670
This case study is based on a paper by Gori, et~al\footnote{Gori, Marincioni, Biddulph, Elwell, ``Inferring the thermal resistance and effective thermal mass distribution of a wall from in situ measurements to characterise heat transfer at both the interior and exterior surfaces", {\it Energy and Buildings}, Volume 135, pages 398-409, \url{http://modsimpy.com/wall2}.
5671
5672
The authors put their paper under a Creative Commons license, and make their data available at \url{http://modsimpy.com/wall }. I thank them for their commitment to open, reproducible science, which made this case study possible.} that models the thermal behavior of a brick wall, with the goal of understanding the ``performance gap between the expected energy use of buildings and their measured energy use".
5673
5674
The following figure shows the scenario and their model of the wall:
5675
5676
\vspace{0.1in}
5677
\centerline{\includegraphics[height=1.4in]{figs/wall_model.pdf}}
5678
5679
On the interior and exterior surfaces of the wall, they measure temperature and heat flux over a period of three days. They model the wall using two thermal masses connected to the surfaces, and to each other, by thermal resistors.
5680
5681
The primary methodology of the paper is a Bayesian method for inferring the parameters of the system (two thermal masses and three thermal resistances).
5682
5683
The primary result is a comparison of two models: the one shown here with two thermal masses, and a simpler model with only one thermal mass. They find that the two-mass model is able to reproduce the measured fluxes substantially better.
5684
5685
For this case study we will implement their model and run it with the estimated parameters from the paper, and then use \py{fit_leastsq} to see if we can find parameters that yield lower errors.
5686
5687
In the repository for this book, you will find a notebook, \py{wall.ipynb} with the code and results for this case study.
5688
5689
5690
\chapter{Projectiles}
5691
\label{chap20}
5692
5693
So far the differential equations we've worked with have been {\bf 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 second derivatives.
5694
5695
\index{first order ODE}
5696
\index{second order ODE}
5697
5698
We'll revisit the falling penny example from Chapter~\ref{chap01}, and use \py{run_ode_solver} to find the position and velocity of the penny as it falls, with and without air resistance.
5699
5700
5701
\section{Newton's second law of motion}
5702
5703
First order ODEs can be written
5704
%
5705
\[ \frac{dy}{dx} = G(x, y) \]
5706
%
5707
where $G$ is some function of $x$ and $y$ (see \url{http://modsimpy.com/ode}). Second order ODEs can be written
5708
%
5709
\[ \frac{d^2y}{dx^2} = H(x, y, \frac{dy}{dt}) \]
5710
%
5711
where $H$ is a function of $x$, $y$, and $dy/dx$.
5712
5713
In this chapter, we will work with one of the most famous and useful second order ODE, Newton's second law of motion:
5714
%
5715
\[ F = m a \]
5716
%
5717
where $F$ is a force or the total of a set of forces, $m$ is the mass of a moving object, and $a$ is its acceleration.
5718
5719
\index{Newton's second law of motion}
5720
\index{differential equation}
5721
\index{acceleration}
5722
\index{velocity}
5723
\index{position}
5724
5725
Newton's 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
5726
%
5727
\[ a = \frac{d^2y}{dt^2} \]
5728
%
5729
Newton's law can be written
5730
%
5731
\[ \frac{d^2y}{dt^2} = F / m \]
5732
%
5733
And that's definitely a second order ODE. In general, $F$ can be a function of time, position, and velocity.
5734
5735
Of course, this ``law" is really a model in the sense that it is a simplification of the real world. Although it is often approximately true:
5736
5737
\begin{itemize}
5738
5739
\item It only applies if $m$ is constant. If mass depends on time, position, or velocity, we have to use a more general form of Newton's law (see \url{http://modsimpy.com/varmass}).
5740
5741
\index{variable mass}
5742
5743
\item It is not a good model for very small things, which are better described by another model, quantum mechanics.
5744
5745
\index{quantum mechanics}
5746
5747
\item And it is not a good model for things moving very fast, which are better described by yet another model, relativistic mechanics.
5748
5749
\index{relativity}
5750
5751
\end{itemize}
5752
5753
However, for medium-sized things with constant mass, moving at medium-sized speeds, Newton's model is extremely useful. If we can quantify the forces that act on such an object, we can predict how it will move.
5754
5755
5756
\section{Dropping pennies}
5757
5758
As a first example, let's get back to the penny falling from the Empire State Building, which we considered in Section~\ref{penny}. We will implement two models of this system: first without air resistance, then with.
5759
5760
\index{falling penny}
5761
\index{air resistance}
5762
5763
Given that the Empire State Building is \SI{381}{\meter} high, and assuming that the penny is dropped from a standstill, the initial conditions are:
5764
5765
\index{State object}
5766
5767
\begin{python}
5768
init = State(y=381 * m,
5769
v=0 * m/s)
5770
\end{python}
5771
5772
where \py{y} is height above the sidewalk and \py{v} is velocity. The units \py{m} and \py{s} are from the \py{UNITS} object provided by Pint:
5773
5774
\index{unit}
5775
\index{Pint}
5776
5777
\begin{python}
5778
m = UNITS.meter
5779
s = UNITS.second
5780
\end{python}
5781
5782
The only system parameter is the acceleration of gravity:
5783
5784
\begin{python}
5785
g = 9.8 * m/s**2
5786
\end{python}
5787
5788
In addition, we'll specify the duration of the simulation and the step size:
5789
5790
\begin{python}
5791
t_end = 10 * s
5792
dt = 0.1 * s
5793
\end{python}
5794
5795
With these parameters, the number of time steps is 100, which is good enough for many problems. Once we have a solution, we will increase the number of steps and see what effect it has on the results.
5796
5797
We need a \py{System} object to store the parameters:
5798
5799
\index{System object}
5800
5801
\begin{python}
5802
system = System(init=init, g=g, t_end=t_end, dt=dt)
5803
\end{python}
5804
5805
Now we need a slope function, and here's where things get tricky. As we have seen, \py{run_ode_solver} can solve systems of first order ODEs, but Newton's law is a second order ODE. However, if we recognize that
5806
5807
\index{slope function}
5808
\index{function!slope}
5809
5810
\begin{enumerate}
5811
5812
\item Velocity, $v$, is the derivative of position, $dy/dt$, and
5813
5814
\item Acceleration, $a$, is the derivative of velocity, $dv/dt$,
5815
5816
\end{enumerate}
5817
5818
we can rewrite Newton's law as a system of first order ODEs:
5819
%
5820
\[ \frac{dy}{dt} = v \]
5821
%
5822
\[ \frac{dv}{dt} = a \]
5823
%
5824
And we can translate those equations into a slope function:
5825
5826
\index{system of equations}
5827
\index{unpack}
5828
5829
\begin{python}
5830
def slope_func(state, t, system):
5831
y, v = state
5832
5833
dydt = v
5834
dvdt = -system.g
5835
5836
return dydt, dvdt
5837
\end{python}
5838
5839
The first parameter, \py{state}, contains the position and velocity of the penny. The last parameter, \py{system}, contains the system parameter \py{g}, which is the magnitude of acceleration due to gravity.
5840
5841
\index{State object}
5842
5843
The second parameter, \py{t}, is time. It is not used in this slope function because none of the factors of the model are time dependent (see Section~\ref{glucose}). I include it anyway because this function will be called by \py{run_ode_solver}, which always provides the same arguments, whether they are needed or not.
5844
5845
\index{time dependent}
5846
5847
The rest of the function is a straightforward translation of the differential equations, with the substitution $a = -g$, which indicates that acceleration due to gravity is in the direction of decreasing $y$. \py{slope_func} returns a sequence containing the two derivatives.
5848
5849
Before calling \py{run_ode_solver}, it is a good idea to test the slope function with the initial conditions:
5850
5851
\begin{python}
5852
dydt, dvdt = slope_func(system.init, 0, system)
5853
\end{python}
5854
5855
The result is \SI{0}{\meter\per\second} for velocity and \SI{9.8}{\meter\per\second\squared} for acceleration. Now we call \py{run_ode_solver} like this:
5856
5857
\begin{python}
5858
results, details = run_ode_solver(system, slope_func)
5859
\end{python}
5860
5861
\py{results} is a \py{TimeFrame} with two columns: \py{y} contains the height of the penny; \py{v} contains its velocity.
5862
5863
\index{TimeFrame object}
5864
\index{\py{run_ode_solver}}
5865
5866
We can plot the results like this:
5867
5868
\begin{python}
5869
def plot_position(results):
5870
plot(results.y)
5871
decorate(xlabel='Time (s)',
5872
ylabel='Position (m)')
5873
\end{python}
5874
5875
\begin{figure}
5876
\centerline{\includegraphics[height=3in]{figs/chap20-fig01.pdf}}
5877
\caption{Height of the penny versus time, with no air resistance.}
5878
\label{chap20-fig01}
5879
\end{figure}
5880
5881
Figure~\ref{chap20-fig01} shows the result. Since acceleration is constant, velocity increases linearly and position decreases quadratically; as a result, the height curve is a parabola.
5882
5883
\index{parabola}
5884
5885
The last value of \py{results.y} is \SI{-109}{\meter}, which means we ran the simulation too long. One way to solve this problem is to use the results to estimate the time when the penny hits the sidewalk.
5886
5887
The ModSim library provides \py{crossings}, which takes a \py{TimeSeries} and a value, and returns a sequence of times when the series passes through the value. We can find the time when the height of the penny is \py{0} like this:
5888
5889
\begin{python}
5890
t_crossings = crossings(results.y, 0)
5891
\end{python}
5892
5893
The result is an array with a single value, \SI{8.818}{s}. Now, we could run the simulation again with \py{t_end = 8.818}, but there's a better way.
5894
5895
\section{Events}
5896
\label{events}
5897
5898
As an option, \py{run_ode_solver} can take an {\bf event function}, which detects an ``event", like the penny hitting the sidewalk, and ends the simulation.
5899
5900
Event functions take the same parameters as slope functions, \py{state}, \py{t}, and \py{system}. They should return a value that passes through \py{0} when the event occurs. Here's an event function that detects the penny hitting the sidewalk:
5901
5902
\begin{python}
5903
def event_func(state, t, system):
5904
y, v = state
5905
return y
5906
\end{python}
5907
5908
The return value is the height of the penny, \py{y}, which passes through \py{0} when the penny hits the sidewalk.
5909
5910
We pass the event function to \py{run_ode_solver} like this:
5911
5912
\begin{python}
5913
results, details = run_ode_solver(system, slope_func,
5914
events=event_func)
5915
\end{python}
5916
5917
Then we can get the flight time and final velocity like this:
5918
5919
\begin{code}
5920
t_sidewalk = get_last_label(results) * s
5921
v_sidewalk = get_last_value(results.v)
5922
\end{code}
5923
5924
Before you go on, you might want to read the notebook for this chapter, \py{chap20.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
5925
5926
5927
5928
\chapter{Air resistance}
5929
\label{chap21}
5930
5931
In the previous chapter we simulated a penny falling in a vacuum, that is, without air resistance. But the computational framework we used is very general; it is easy to add additional forces, including drag.
5932
5933
In this chapter, I present a model of drag force and add it to the simulation.
5934
5935
5936
\section{Drag force}
5937
\label{drag}
5938
5939
As an object moves through a fluid, like air, the object applies force to the air and, in accordance with Newton's third law of motion, the air applies an equal and opposite force to the object (see \url{http://modsimpy.com/newton}).
5940
5941
\index{air resistance}
5942
\index{drag force}
5943
\index{force!drag}
5944
\index{drag equation}
5945
5946
The direction of this {\bf drag force} is opposite the direction of travel, and its magnitude is given by the drag equation (see \url{http://modsimpy.com/drageq}):
5947
%
5948
\[ F_d = \frac{1}{2}~\rho~v^2~C_d~A \]
5949
%
5950
where
5951
5952
\begin{itemize}
5953
5954
\item $F_d$ is force due to drag, in newtons (\si{\newton}).
5955
5956
\item $\rho$ is the density of the fluid in \si{\kg\per\meter\cubed}.
5957
\index{density}
5958
5959
\item $v$ is the magnitude of velocity in \si{\meter\per\second}.
5960
\index{velocity}
5961
5962
\item $A$ is the {\bf reference area} of the object, in \si{\meter\squared}. In this context, the reference area is the projected frontal area, that is, the visible area of the object as seen from a point on its line of travel (and far away).
5963
5964
\index{reference area}
5965
5966
\item $C_d$ is the {\bf drag coefficient}, a dimensionless quantity that depends on the shape of the object (including length but not frontal area), its surface properties, and how it interacts with the fluid.
5967
5968
\index{drag coefficient}
5969
5970
\end{itemize}
5971
5972
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 \url{http://modsimpy.com/dragco}).
5973
5974
For simple geometric objects we can sometimes guess the drag coefficient with reasonable accuracy; for more complex objects we usually have to take measurements and estimate $C_d$ from data.
5975
5976
Of course, the drag equation is itself a model, based on the assumption that $C_d$ 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 \SI{20}{\meter\per\second}), this model might be good enough, but we should remember to revisit this assumption.
5977
5978
For the falling penny, we can use measurements to estimate $C_d$. In particular, we can measure {\bf terminal velocity}, $v_{term}$, which is the speed where drag force equals force due to gravity:
5979
%
5980
\[ \frac{1}{2}~\rho~v_{term}^2~C_d~A = m g \]
5981
%
5982
where $m$ is the mass of the object and $g$ is acceleration due to gravity. Solving this equation for $C_d$ yields:
5983
%
5984
\[ C_d = \frac{2~m g}{\rho~v_{term}^2~A} \]
5985
%
5986
According to {\it Mythbusters}, the terminal velocity of a penny is between 35 and 65 mph (see \url{http://modsimpy.com/mythbust}). Using the low end of their range, 40 mph or about \SI{18}{\meter\per\second}, the estimated value of $C_d$ is 0.44, which is close to the drag coefficient of a smooth sphere.
5987
5988
\index{Mythbusters}
5989
\index{terminal velocity}
5990
5991
Now we are ready to add air resistance to the model.
5992
5993
5994
\section{Implementation}
5995
\label{penny_drag}
5996
5997
As the number of system parameters increases, and as we need to do more work to compute them, we will find it useful to define a \py{Params} object to contain the quantities we need to make a \py{System} object. \py{Params} objects are similar to \py{System} and \py{State} objects; in fact, all three have the same capabilities. I have given them different names to document the different roles they play.
5998
5999
% TODO: This is not actually the first use of Params
6000
6001
\index{Params object}
6002
6003
Here's the \py{Params} object for the falling penny:
6004
6005
\begin{python}
6006
params = Params(height = 381 * m,
6007
v_init = 0 * m / s,
6008
g = 9.8 * m/s**2,
6009
mass = 2.5e-3 * kg,
6010
diameter = 19e-3 * m,
6011
rho = 1.2 * kg/m**3,
6012
v_term = 18 * m / s)
6013
\end{python}
6014
6015
The mass and diameter are from \url{http://modsimpy.com/penny}. The density of air depends on temperature, barometric pressure (which depends on altitude), humidity, and composition (\url{http://modsimpy.com/density}). I chose a value that might be typical in Boston, Massachusetts at \SI{20}{\celsius}.
6016
6017
\index{System object}
6018
\index{\py{make_system}}
6019
6020
Here's a version of \py{make_system} that takes a \py{Params} object and returns a \py{System}:
6021
6022
\index{unpack}
6023
6024
\begin{python}
6025
def make_system(params):
6026
diameter, mass = params.diameter, params.mass
6027
g, rho = params.g, params.rho,
6028
v_init, v_term = params.v_init, params.v_term
6029
height = params.height
6030
6031
area = np.pi * (diameter/2)**2
6032
C_d = 2 * mass * g / (rho * area * v_term**2)
6033
init = State(y=height, v=v_init)
6034
t_end = 30 * s
6035
dt = t_end / 100
6036
6037
return System(params, area=area, C_d=C_d,
6038
init=init, t_end=t_end, dt=dt)
6039
\end{python}
6040
6041
The first argument of \py{System} is \py{params}, so the result contains all of the parameters in \py{params}, \py{area}, \py{C_d}, and the rest.
6042
6043
It might not be obvious why we need \py{Params} objects, but they will turn out to be useful soon.
6044
6045
We can make a \py{System} like this:
6046
6047
\begin{python}
6048
system = make_system(params)
6049
\end{python}
6050
6051
Now here's a version of the slope function that includes drag:
6052
6053
\index{slope function}
6054
\index{function!slope}
6055
\index{unpack}
6056
6057
\begin{python}
6058
def slope_func(state, t, system):
6059
y, v = state
6060
rho, C_d, g = system.rho, system.C_d, system.g
6061
area, mass = system.area, system.mass
6062
6063
f_drag = rho * v**2 * C_d * area / 2
6064
a_drag = f_drag / mass
6065
6066
dydt = v
6067
dvdt = -g + a_drag
6068
6069
return dydt, dvdt
6070
\end{python}
6071
6072
\py{f_drag} is force due to drag, based on the drag equation. \py{a_drag} is acceleration due to drag, based on Newton's second law.
6073
6074
\index{gravity}
6075
6076
To compute total acceleration, we add accelerations due to gravity and drag. \py{g} is negated because it is in the direction of decreasing \py{y}, and \py{a_drag} is positive because it is in the direction of increasing \py{y}. In the next chapter we will use \py{Vector} objects to keep track of the direction of forces and add them up in a less error-prone way.
6077
6078
To stop the simulation when the penny hits the sidewalk, we'll use the event function from Section~\ref{events}:
6079
6080
\begin{python}
6081
def event_func(state, t, system):
6082
y, v = state
6083
return y
6084
\end{python}
6085
6086
Now we can run the simulation like this:
6087
6088
\index{\py{run_ode_solver}}
6089
6090
\begin{python}
6091
results, details = run_ode_solver(system, slope_func,
6092
events=event_func)
6093
\end{python}
6094
6095
\begin{figure}
6096
\centerline{\includegraphics[height=3in]{figs/chap21-fig01.pdf}}
6097
\caption{Height of the penny versus time, with air resistance.}
6098
\label{chap21-fig01}
6099
\end{figure}
6100
6101
Figure~\ref{chap21-fig01} 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.
6102
6103
\index{terminal velocity}
6104
6105
Before you go on, you might want to read the notebook for this chapter, \py{chap21.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
6106
6107
6108
6109
\chapter{Projectiles in 2-D}
6110
\label{chap22}
6111
6112
In the previous chapter we modeled objects moving in one dimension, with and without drag. Now let's move on to two dimensions, and baseball!
6113
6114
In this chapter we model the flight of a baseball including the effect of air resistance. In the next chapter we use this model to solve an optimization problem.
6115
6116
6117
\section{Baseball}
6118
\label{baseball}
6119
6120
To model the flight of a baseball, we have to make some modeling decisions. To get started, we ignore any spin that might be on the ball, and the resulting Magnus force (see \url{http://modsimpy.com/magnus}). Under this assumption, the ball travels in a vertical plane, so we'll run simulations in two dimensions, rather than three.
6121
6122
\index{Magnus force}
6123
6124
Air resistance has a substantial effect on most projectiles in air, so we will include a drag force.
6125
6126
\index{air resistance}
6127
6128
To model air resistance, we'll need the mass, frontal area, and drag coefficient of a baseball. Mass and diameter are easy to find (see \url{http://modsimpy.com/baseball}). Drag coefficient is only a little harder; according to {\it The Physics of Baseball}\footnote{Adair, {\it The Physics of Baseball}, Third Edition, Perennial, 2002.}, the drag coefficient of a baseball is approximately 0.33 (with no units).
6129
6130
\index{drag coefficient}
6131
6132
However, this value {\em does} depend on velocity. At low velocities it might be as high as 0.5, and at high velocities as low as 0.28. Furthermore, the transition between these regimes typically happens exactly in the range of velocities we are interested in, between \SI{20}{\meter\per\second} and \SI{40}{\meter\per\second}.
6133
6134
Nevertheless, we'll start with a simple model where the drag coefficient does not depend on velocity; as an exercise at the end of the chapter, you will have a chance to implement a more detailed model and see what effect is has on the results.
6135
6136
But first we need a new computational tool, the \py{Vector} object.
6137
6138
6139
\section{Vectors}
6140
6141
Now that we are working in two dimensions, we will find it useful to work with {\bf vector quantities}, that is, quantities that represent both a magnitude and a direction. We will use vectors to represent positions, velocities, accelerations, and forces in two and three dimensions.
6142
6143
\index{Vector object}
6144
\index{array}
6145
\index{NumPy}
6146
6147
The ModSim library provides a \py{Vector} object that represents a vector quantity. A \py{Vector} object is a like a NumPy array; it contains elements that represent the {\bf components} of the vector. For example, in a \py{Vector} that represents a position in space, the components are the $x$ and $y$ coordinates (and a $z$ coordinate in 3-D). A \py{Vector} object can also have units, like the quantities we've seen in previous chapters.
6148
6149
\index{unit}
6150
6151
You can create a \py{Vector} by specifying its components. The following \py{Vector} represents a point \SI{3}{\meter} to the right (or east) and \SI{4}{\meter} up (or north) from an implicit origin:
6152
6153
\index{component}
6154
6155
\begin{python}
6156
A = Vector(3, 4) * m
6157
\end{python}
6158
6159
You can access the components of a \py{Vector} by name using the dot operator: for example, \py{A.x} or \py{A.y}. You can also access them by index using brackets: for example, \py{A[0]} or \py{A[1]}.
6160
6161
Similarly, you can get the magnitude and angle using the dot operator, \py{A.mag} and \py{A.angle}. {\bf Magnitude} is the length of the vector: if the \py{Vector} represents 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.
6162
6163
\index{angle}
6164
\index{magnitude}
6165
6166
The {\bf angle} of a \py{Vector} is its direction, expressed as the angle in radians from the positive $x$ axis. In the Cartesian plane, the angle \SI{0}{\radian} is due east, and the angle \SI{\pi}{\radian} is due west.
6167
6168
\index{radian}
6169
6170
\py{Vector} objects support most mathematical operations, including addition and subtraction:
6171
6172
\begin{python}
6173
B = Vector(1, 2) * m
6174
A + B
6175
A - B
6176
\end{python}
6177
6178
For the definition and graphical interpretation of these operations, see \url{http://modsimpy.com/vecops}.
6179
6180
\index{vector operation}
6181
6182
When you add and subtract \py{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 \py{Vector} objects.
6183
6184
\index{dimensions}
6185
6186
One note on working with angles: in mathematics, we almost always represent angle in radians, and most Python functions expect angles in radians. But people often think more naturally in degrees. It can be awkward, and error-prone, to use both units in the same program. Fortunately, Pint makes it possible to represent angles using quantities with units.
6187
6188
\index{degree}
6189
6190
As an example, I'll get the \py{degree} unit from \py{UNITS}, and create a quantity that represents 45 degrees:
6191
6192
\begin{python}
6193
degree = UNITS.degree
6194
angle = 45 * degree
6195
\end{python}
6196
6197
If we need to convert to radians we can use the \py{to} function
6198
\index{\py{to}}
6199
6200
\begin{python}
6201
radian = UNITS.radian
6202
rads = angle.to(radian)
6203
\end{python}
6204
6205
If you are given an angle and velocity, you can make a \py{Vector} using \py{pol2cart}, which converts from polar to Cartesian coordinates. To demonstrate, I'll extract the angle and magnitude of \py{A}:
6206
6207
\index{pol2cart}
6208
6209
\begin{python}
6210
mag = A.mag
6211
angle = A.angle
6212
\end{python}
6213
6214
And then make a new \py{Vector} with the same components:
6215
6216
\begin{python}
6217
x, y = pol2cart(angle, mag)
6218
Vector(x, y)
6219
\end{python}
6220
6221
Another way to represent the direction of \py{A} is a {\bf unit vector}, which is a vector with magnitude 1 that points in the same direction as \py{A}. You can compute a unit vector by dividing a vector by its magnitude:
6222
6223
\index{unit vector}
6224
\index{hat function}
6225
6226
\begin{python}
6227
A / A.mag
6228
\end{python}
6229
6230
We can do the same thing using the \py{hat} function, so named because unit vectors are conventionally decorated with a hat, like this: $\hat{A}$.
6231
6232
\begin{python}
6233
A.hat()
6234
\end{python}
6235
6236
Now let's get back to the game.
6237
6238
6239
\section{Simulating baseball flight}
6240
6241
Let's simulate the flight of a baseball that is batted from home plate at an angle of \SI{45}{\degree} and initial speed \SI{40}{\meter \per \second}.
6242
Using the center of home plate as the origin, the x-axis is parallel to the ground; the y-axis is vertical. The initial height is about \SI{1}{\meter}.
6243
6244
As in Section~\ref{penny_drag}, I'll create a \py{Params} object that contains the parameters of the system:
6245
6246
\index{Params object}
6247
6248
\begin{python}
6249
t_end = 10 * s
6250
dt = t_end / 100
6251
6252
params = Params(x = 0 * m,
6253
y = 1 * m,
6254
g = 9.8 * m/s**2,
6255
mass = 145e-3 * kg,
6256
diameter = 73e-3 * m,
6257
rho = 1.2 * kg/m**3,
6258
C_d = 0.33,
6259
angle = 45 * degree,
6260
velocity = 40 * m / s,
6261
t_end=t_end, dt=dt)
6262
\end{python}
6263
6264
The mass, diameter, and drag coefficient of the baseball are from the sources in Section~\ref{baseball}. The acceleration of gravity, \py{g}, is a well-known quantity, and the density of air, \py{rho}, is based on a temperature of \SI{20}{\celsius} at sea level (see \url{http://modsimpy.com/tempress}).
6265
I chose the value of \py{t_end} to run the simulation long enough for the ball to land on the ground.
6266
6267
\index{density}
6268
6269
The following function uses the \py{Params} object to make a \py{System} object. This two-step process makes the code more readable and makes it easier to work with functions like \py{root_bisect}.
6270
6271
\index{System object}
6272
\index{\py{make_system}}
6273
6274
\begin{python}
6275
def make_system(params):
6276
angle, velocity = params.angle, params.velocity
6277
6278
# convert angle to degrees
6279
theta = np.deg2rad(angle)
6280
6281
# compute x and y components of velocity
6282
vx, vy = pol2cart(theta, velocity)
6283
6284
# make the initial state
6285
R = Vector(params.x, params.y)
6286
V = Vector(vx, vy)
6287
init = State(R=R, V=V)
6288
6289
# compute area from diameter
6290
diameter = params.diameter
6291
area = np.pi * (diameter/2)**2
6292
6293
return System(params, init=init, area=area)
6294
\end{python}
6295
6296
\py{make_system} uses \py{np.deg2rad} to convert \py{angle} to radians and \py{pol2cart} to compute the $x$ and $y$ components of the initial velocity.
6297
6298
Then it makes \py{Vector} objects to represent the initial position, \py{R}, and velocity \py{V}. These vectors are stored as state variables in \py{init}.
6299
6300
\py{make_system} also computes \py{area}, then creates a \py{System} object with all of the variables from \py{params} plus \py{init} and \py{area}.
6301
6302
\index{deg2rad}
6303
\index{State object}
6304
6305
Next we need a function to compute drag force:
6306
6307
\begin{python}
6308
def drag_force(V, system):
6309
rho, C_d, area = system.rho, system.C_d, system.area
6310
6311
mag = -rho * V.mag**2 * C_d * area / 2
6312
direction = V.hat()
6313
f_drag = direction * mag
6314
return f_drag
6315
\end{python}
6316
6317
This function takes \py{V} as a \py{Vector} and returns \py{f_drag} as a \py{Vector}.
6318
It uses the drag equation to compute the magnitude of the drag force, and the \py{hat} function to compute the direction. \py{-V.hat()} computes a unit vector pointing in the opposite direction of \py{V}.
6319
6320
\index{unit vector}
6321
\index{slope function}
6322
\index{function!slope}
6323
6324
Now we're ready for a slope function:
6325
6326
\begin{python}
6327
def slope_func(state, t, system):
6328
R, V = state
6329
mass, g = system.mass, system.g
6330
6331
a_drag = drag_force(V, system) / mass
6332
a_grav = Vector(0, -g)
6333
6334
A = a_grav + a_drag
6335
6336
return V, A
6337
\end{python}
6338
6339
As usual, the parameters of the slope function are a \py{State} object, time, and a \py{System} object. In this example, we don't use \py{t}, but we can't leave it out because when \py{run_ode_solver} calls the slope function, it always provides the same arguments, whether they are needed or not.
6340
6341
The \py{State} object contains two state variables, \py{R} and \py{V}, that represent position and velocity as \py{Vector} objects.
6342
6343
\index{state variable}
6344
6345
The return values from the slope function are the derivatives of these quantities. The derivative of position is velocity, so the first return value is \py{V}, which we extracted from the \py{State} object. The derivative of velocity is acceleration, and that's what we have to compute.
6346
6347
\index{acceleration}
6348
\index{velocity}
6349
\index{position}
6350
6351
The total acceleration of the baseball is the sum of accelerations due to gravity and drag. These quantities have both magnitude and direction, so they are represented by \py{Vector} objects.
6352
6353
We already saw how \py{a_drag} is computed. \py{a_grav} is a \py{Vector} with magnitude \py{g} pointed in the negative \py{y} direction.
6354
6355
Using vectors to represent forces and accelerations makes the code concise, readable, and less error-prone. In particular, when we add \py{a_grav} and \py{a_drag}, the directions are likely to be correct, because they are encoded in the \py{Vector} objects. And the units are certain to be correct, because otherwise Pint would report an error.
6356
6357
\index{Pint}
6358
6359
As always, we can test the slope function by running it with the initial conditions:
6360
6361
\begin{python}
6362
slope_func(system.init, 0, system)
6363
\end{python}
6364
6365
We can use an event function to stop the simulation when the ball hits the ground.
6366
6367
\begin{python}
6368
def event_func(state, t, system):
6369
R, V = state
6370
return R.y
6371
\end{python}
6372
6373
The event function takes the same parameters as the slope function, and returns the y coordinate of \py{R}. When the y coordinate passes through 0, the simulation stops.
6374
6375
Now we're ready to run the simulation:
6376
6377
\begin{python}
6378
results, details = run_ode_solver(system, slope_func,
6379
events=event_func)
6380
\end{python}
6381
6382
\py{results} is a \py{TimeFrame} object with one column for each of the state variables, \py{R} and \py{V}.
6383
6384
\index{TimeFrame object}
6385
6386
We can get the flight time like this:
6387
6388
\begin{python}
6389
flight_time = get_last_label(results) * s
6390
\end{python}
6391
6392
And the final \py{x} coordinate like this:
6393
6394
\begin{python}
6395
R_final = get_last_value(results.R)
6396
x_dist = R_final.x
6397
\end{python}
6398
6399
6400
\section{Trajectories}
6401
6402
We can plot the $x$ and $y$ components of position like this:
6403
6404
\begin{python}
6405
x = results.R.extract('x')
6406
y = results.R.extract('y')
6407
6408
x.plot()
6409
y.plot()
6410
\end{python}
6411
6412
The \py{extract} function loops through the \py{Vector} objects in \py{R} and extracts one coordinate from each. The result is a \py{TimeSeries}.
6413
6414
\index{extract function}
6415
\index{TimeSeries}
6416
6417
\begin{figure}
6418
\centerline{\includegraphics[height=3in]{figs/chap22-fig01.pdf}}
6419
\caption{Simulated baseball flight, $x$ and $y$ components of position as a function of time.}
6420
\label{chap22-fig01}
6421
\end{figure}
6422
6423
Figure~\ref{chap22-fig01} shows the result. As expected, the $x$ component increases as the ball moves away from home plate. The $y$ position climbs initially and then descends, falling to \SI{0}{\meter} near \SI{5.0}{\second}.
6424
6425
\index{monotonic}
6426
6427
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:
6428
6429
\begin{python}
6430
x = results.R.extract('x')
6431
y = results.R.extract('y')
6432
plot(x, y, label='trajectory')
6433
\end{python}
6434
6435
\begin{figure}
6436
\centerline{\includegraphics[height=3in]{figs/chap22-fig02.pdf}}
6437
\caption{Simulated baseball flight, trajectory plot.}
6438
\label{chap22-fig02}
6439
\end{figure}
6440
6441
Figure~\ref{chap22-fig02} shows this way of visualizing the results, which is called a {\bf trajectory plot} (see \url{http://modsimpy.com/trajec}).
6442
6443
\index{trajectory plot}
6444
6445
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 don't get them mixed up! If you are looking at a time series plot and interpreting it as a trajectory, you will be very confused.
6446
6447
Before you go on, you might want to read the notebook for this chapter, \py{chap22.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
6448
6449
6450
\chapter{Optimization}
6451
\label{chap23}
6452
6453
In the previous chapter we developed a model of the flight of a baseball, including gravity and a simple version of drag, but neglecting spin, Magnus force, and the dependence of the coefficient of drag on velocity.
6454
6455
In this chapter we apply that model to an optimization problem.
6456
6457
\section{The Manny Ramirez problem}
6458
\label{manny}
6459
6460
Manny Ramirez is a former member of the Boston Red Sox (an American baseball team) who was notorious for his relaxed attitude and taste for practical jokes. Our objective in this chapter is to solve the following Manny-inspired problem:
6461
6462
{\it What is the minimum effort required to hit a home run in Fenway Park?}
6463
6464
Fenway Park is a baseball stadium in Boston, Massachusetts. One of its most famous features is the ``Green Monster", which is a wall in left field that is unusually close to home plate, only 310 feet away. To compensate for the short distance, the wall is unusually high, at 37 feet (see \url{http://modsimpy.com/wally}).
6465
6466
\index{Ramirez, Manny}
6467
\index{Fenway Park}
6468
\index{baseball}
6469
\index{Green Monster}
6470
\index{velocity}
6471
6472
We want to find the minimum velocity at which a ball can leave home plate and still go over the Green Monster. We'll proceed in the following steps:
6473
6474
\begin{enumerate}
6475
6476
\item For a given velocity, we'll find the optimal {\bf launch angle}, that is, the angle the ball should leave home plate to maximize its height when it reaches the wall.
6477
6478
\index{launch angle}
6479
6480
\item Then we'll find the minimal velocity that clears the wall, given that it has the optimal launch angle.
6481
6482
\end{enumerate}
6483
6484
We'll use the same model as in the previous chapter, with this \py{Params} object:
6485
6486
\begin{python}
6487
t_end = 20 * s
6488
dt = t_end / 100
6489
6490
params = Params(x = 0 * m,
6491
y = 1 * m,
6492
g = 9.8 * m/s**2,
6493
mass = 145e-3 * kg,
6494
diameter = 73e-3 * m,
6495
rho = 1.2 * kg/m**3,
6496
C_d = 0.3,
6497
angle = 45 * degree,
6498
velocity = 40 * m / s,
6499
t_end=t_end,
6500
dt=dt)
6501
\end{python}
6502
6503
\index{Params object}
6504
6505
This version of \py{make_system}:
6506
6507
\begin{python}
6508
def make_system(params):
6509
angle, velocity = params.angle, params.velocity
6510
6511
# convert angle to degrees
6512
theta = np.deg2rad(angle)
6513
6514
# compute x and y components of velocity
6515
vx, vy = pol2cart(theta, velocity)
6516
6517
# make the initial state
6518
R = Vector(params.x, params.y)
6519
V = Vector(vx, vy)
6520
init = State(R=R, V=V)
6521
6522
# compute area from diameter
6523
diameter = params.diameter
6524
area = np.pi * (diameter/2)**2
6525
6526
return System(params, init=init, area=area)
6527
\end{python}
6528
6529
This slope function:
6530
6531
\begin{python}
6532
def slope_func(state, t, system):
6533
R, V = state
6534
mass, g = system.mass, system.g
6535
6536
a_drag = drag_force(V, system) / mass
6537
a_grav = Vector(0, -g)
6538
6539
A = a_grav + a_drag
6540
6541
return V, A
6542
\end{python}
6543
6544
And this event function:
6545
6546
\begin{python}
6547
def event_func(state, t, system):
6548
R, V = state
6549
return R.y
6550
\end{python}
6551
6552
6553
\section{Finding the range}
6554
6555
Suppose we want to find the launch angle that maximizes {\bf range}, that is, the distance the ball travels in the air before landing. We'll use a function in the ModSim library, \py{maximize}, which takes a function and finds its maximum.
6556
6557
The function we pass to \py{maximize} should take launch angle and a \py{params} object, and return range:
6558
6559
\begin{python}
6560
def range_func(angle, params):
6561
params = Params(params, angle=angle)
6562
system = make_system(params)
6563
results, details = run_ode_solver(system, slope_func,
6564
events=event_func)
6565
x_dist = get_last_value(results.R).x
6566
print(angle, x_dist)
6567
return x_dist
6568
\end{python}
6569
6570
\py{range_func} makes a new \py{Params} object with the given value of \py{angle}. Then it makes a \py{System} object, calls \py{run_ode_solver}, and returns the final value of \py{x} from the results.
6571
6572
We can call \py{range_func} directly like this:
6573
6574
\begin{python}
6575
range_func(45, params)
6576
\end{python}
6577
6578
And we can sweep a sequence of angles like this:
6579
6580
\index{parameter sweep}
6581
\index{SweepSeries object}
6582
6583
\begin{python}
6584
angles = linspace(20, 80, 21)
6585
sweep = SweepSeries()
6586
6587
for angle in angles:
6588
x_dist = range_func(angle, params)
6589
print(angle, x_dist)
6590
sweep[angle] = x_dist
6591
\end{python}
6592
6593
\begin{figure}
6594
\centerline{\includegraphics[height=3in]{figs/chap23-fig01.pdf}}
6595
\caption{Distance from home plate as a function of launch angle, with fixed velocity.}
6596
\label{chap23-fig01}
6597
\end{figure}
6598
6599
Figure~\ref{chap23-fig01} shows the results. It looks like the optimal angle is between \SI{40}{\degree} and \SI{45}{\degree}.
6600
6601
%TODO: Draw a vertical line to show this
6602
6603
We can find the optimal angle more precisely and more efficiently using \py{maximize}, like this:
6604
6605
\begin{python}
6606
res = maximize(range_func, [0, 90], params)
6607
\end{python}
6608
6609
The first parameter is the function we want to maximize. The second is the range of values we want to search; in this case it's the range of angles from \SI{0}{\degree} to \SI{90}{\degree}. The third argument can be any object; it gets passed along as an argument when \py{maximize} calls \py{range_func}.
6610
6611
The return value from \py{maximize} is an \py{ModSimSeries} that contains the results, including \py{x}, which is the angle that yielded the highest range, and \py{fun}, which is the value of \py{range_func} when it's evaluated at \py{x}, that is, range when the baseball is launched at the optimal angle.
6612
6613
\index{ModSimSeries}
6614
6615
For these parameters, the optimal angle is about \SI{42}{\degree}, which yields a range of \SI{103}{\meter}.
6616
6617
\py{maximize} uses a golden section search, which you can read about at \url{http://modsimpy.com/minimize}).
6618
6619
% TODO: update this link to point to Golden section search
6620
6621
6622
\section{Finishing off the problem}
6623
6624
In the notebook for this chapter, \py{chap22.ipynb}, you'll have to chance to finish off the Manny Ramirez problem. There are a few things you'll have to do:
6625
6626
\begin{itemize}
6627
6628
\item In the previous section the ``optimal" launch angle is the one that maximizes range, but that's 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 you'll have to write a height function to compute it, and then use \py{maximize} to find the revised optimum.
6629
6630
\item Once you can find the optimal angle for any velocity, you have to find the minimum velocity that gets the ball over the wall. You'll 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.
6631
6632
\item Finally, you'll use \py{root_bisect} to find the velocity that makes the optimal height at the wall just barely 37 feet.
6633
6634
\index{\py{root_bisect}}
6635
6636
\end{itemize}
6637
6638
The notebook provides some additional hints, but at this point you should have everything you need. Good luck!
6639
6640
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 \url{http://modsimpy.com/runs}.
6641
6642
6643
\chapter{Rotation}
6644
\label{chap24}
6645
6646
In this chapter we model systems that involve rotating objects. In general, rotation is complicated: in three dimensions, objects can rotate around three axes; objects are often easier to spin around some axes than others; and they may be stable when spinning around some axes but not others.
6647
6648
\index{rotation}
6649
6650
If the configuration of an object changes over time, it might become easier or harder to spin, which explains the surprising dynamics of gymnasts, divers, ice skaters, etc.
6651
6652
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 \url{http://modsimpy.com/precess}.
6653
6654
\index{gyroscopic precession}
6655
6656
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).
6657
6658
\index{scalar}
6659
6660
This approach makes it possible to simulate and analyze many interesting systems, but you will also encounter systems that would be better approached with the more general toolkit.
6661
6662
The fundamental ideas in this chapter and the next are {\bf angular velocity}, {\bf angular acceleration}, {\bf torque}, and {\bf moment of inertia}. If you are not already familiar with these concepts, I will define them as we go along, and I will point to additional reading.
6663
6664
At the end of the next chapter, you will use these tools to simulate the behavior of a yo-yo (see \url{http://modsimpy.com/yoyo}). But we'll work our way up to it gradually, starting with toilet paper.
6665
6666
6667
6668
\section{The physics of toilet paper}
6669
\label{paper}
6670
6671
As a simple example of a system with rotation, we'll simulate the manufacture of a roll of toilet paper. Starting with a cardboard tube at the center, we will roll up \SI{47}{\meter} of paper, the typical length of a roll of toilet paper in the U.S. (see \url{http://modsimpy.com/paper}).
6672
6673
\index{toilet paper}
6674
6675
\begin{figure}
6676
\centerline{\includegraphics[height=2.5in]{figs/paper_roll.pdf}}
6677
\caption{Diagram of a roll of toilet paper, showing change in paper length as a result of a small rotation, $d\theta$.}
6678
\label{paper_roll}
6679
\end{figure}
6680
6681
Figure~\ref{paper_roll} 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, $R_{min}$. When the roll is complete, $r$ is $R_{max}$.
6682
6683
I'll use $\theta$ to represent the total rotation of the roll in radians. In the diagram, $d\theta$ represents a small increase in $\theta$, which corresponds to a distance along the circumference of the roll of $r~d\theta$.
6684
6685
\index{radian}
6686
6687
Finally, I'll use $y$ to represent the total length of paper that's been rolled. Initially, $\theta=0$ and $y=0$. For each small increase in $\theta$, there is a corresponding increase in $y$:
6688
%
6689
\[ dy = r~d\theta \]
6690
%
6691
If we divide both sides by a small increase in time, $dt$, we get a differential equation for $y$ as a function of time.
6692
%
6693
\[ \frac{dy}{dt} = r \frac{d\theta}{dt} \]
6694
%
6695
As we roll up the paper, $r$ increases, too. Assuming that $r$ increases by a fixed amount per revolution, we can write
6696
%
6697
\[ dr = k~d\theta \]
6698
%
6699
Where $k$ is an unknown constant we'll have to figure out. Again, we can divide both sides by $dt$ to get a differential equation in time:
6700
%
6701
\[ \frac{dr}{dt} = k \frac{d\theta}{dt} \]
6702
%
6703
Finally, let's assume that $\theta$ increases at a constant rate of \SI{10}{\radian\per\second} (about 95 revolutions per minute):
6704
%
6705
\[ \frac{d\theta}{dt} = 10 \]
6706
%
6707
This rate of change is called an {\bf angular velocity}. Now we have a system of three differential equations we can use to simulate the system.
6708
6709
\index{angular velocity}
6710
\index{differential equation}
6711
6712
6713
\section{Implementation}
6714
\label{papersim}
6715
6716
At this point we have a pretty standard process for writing simulations like this. First, we'll get the units we need from Pint:
6717
\index{Pint}
6718
6719
\begin{python}
6720
radian = UNITS.radian
6721
m = UNITS.meter
6722
s = UNITS.second
6723
\end{python}
6724
6725
And create a \py{Params} object with the parameters of the system:
6726
6727
\index{Params object}
6728
6729
\begin{python}
6730
params = Params(Rmin = 0.02 * m,
6731
Rmax = 0.055 * m,
6732
L = 47 * m,
6733
omega = 10 * radian / s,
6734
t_end = 130 * s,
6735
dt = 1*s)
6736
\end{python}
6737
6738
\py{Rmin} and \py{Rmax} are the initial and final values for the radius, \py{r}. \py{L} is the total length of the paper. \py{t_end} is the length of the simulation in time, and \py{dt} is the time step for the ODE solver.
6739
6740
We use the \py{Params} object to make a \py{System} object:
6741
6742
\index{System object}
6743
\index{\py{make_system}}
6744
6745
\begin{python}
6746
def make_system(params):
6747
init = State(theta = 0 * radian,
6748
y = 0 * m,
6749
r = params.Rmin)
6750
6751
k = estimate_k(params)
6752
6753
return System(params, init=init, k=k)
6754
\end{python}
6755
6756
The initial state contains three variables, \py{theta}, \py{y}, and \py{r}.
6757
6758
\py{estimate_k} computes the parameter, \py{k}, that relates \py{theta} and \py{r}. Here's how it works:
6759
6760
\begin{python}
6761
def estimate_k(params):
6762
Rmin, Rmax, L = params.Rmin, params.Rmax, params.L
6763
6764
Ravg = (Rmax + Rmin) / 2
6765
Cavg = 2 * pi * Ravg
6766
revs = L / Cavg
6767
rads = 2 * pi * revs
6768
k = (Rmax - Rmin) / rads
6769
return k
6770
\end{python}
6771
6772
\py{Ravg} is the average radius, half way between \py{Rmin} and \py{Rmax}, so \py{Cavg} is the circumference of the roll when \py{r} is \py{Ravg}.
6773
6774
\py{revs} is the total number of revolutions it would take to roll up length \py{L} if \py{r} were constant at \py{Ravg}. And \py{rads} is just \py{revs} converted to radians.
6775
6776
Finally, \py{k} is the change in \py{r} for each radian of revolution. For these parameters, \py{k} is about \py{2.8e-5} \si{\meter\per\radian}.
6777
6778
Now we can use the differential equations from Section~\ref{paper} to write a slope function:
6779
6780
\index{slope function}
6781
\index{Function!slope}
6782
6783
\begin{python}
6784
def slope_func(state, t, system):
6785
theta, y, r = state
6786
k, omega = system.k, system.omega
6787
6788
dydt = r * omega
6789
drdt = k * omega
6790
6791
return omega, dydt, drdt
6792
\end{python}
6793
6794
\begin{figure}[t]
6795
\centerline{\includegraphics[height=4.5in]{figs/chap24-fig01.pdf}}
6796
\caption{Results from paper rolling simulation, showing rotation, length, and radius over time.}
6797
\label{chap24-fig01}
6798
\end{figure}
6799
6800
As usual, the slope function takes a \py{State} object, a time, and a \py{System} object. The \py{State} object contains hypothetical values of \py{theta}, \py{y}, and \py{r} at time \py{t}. The job of the slope function is to compute the time derivatives of these values. The derivative of \py{theta} is angular velocity, which is often denoted \py{omega}.
6801
6802
\index{State object}
6803
6804
We'd like to stop the simulation when the length of paper on the roll is \py{L}. We can do that with an event function that passes through 0 when \py{y} equals \py{L}:
6805
6806
\begin{python}
6807
def event_func(state, t, system):
6808
theta, y, r = state
6809
return y - system.L
6810
\end{python}
6811
6812
Now we can run the simulation like this:
6813
6814
\begin{python}
6815
results, details = run_ode_solver(system, slope_func,
6816
events=event_func)
6817
\end{python}
6818
6819
6820
Figure~\ref{chap24-fig01} shows the results. \py{theta} grows linearly over time, as we should expect. As a result, \py{r} also grows linearly. But since the derivative of \py{y} depends on \py{r}, and \py{r} is increasing, \py{y} grows with increasing slope.
6821
6822
Because this system is so simple, it is almost silly to simulate it. As we'll see in the next section, it is easy enough to solve the differential equations analytically. But it is often useful to start with a simple simulation as a way of exploring and checking assumptions.
6823
6824
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:
6825
6826
\begin{itemize}
6827
6828
\item The total time for the simulation is about 2 minutes, which seems plausible for the time it would take to roll \SI{47}{\meter} of paper.
6829
6830
\item The final value of \py{theta} is about \SI{1250}{\radian}, which corresponds to about 200 revolutions, which also seems plausible.
6831
6832
\item The initial and final values for \py{r} are consistent with \py{Rmin} and \py{Rmax}, as we intended when we chose \py{k}.
6833
6834
\end{itemize}
6835
6836
But now that we have a working simulation, it is also useful to do some analysis.
6837
6838
6839
\section{Analysis}
6840
\label{paper_analysis}
6841
6842
The differential equations in Section~\ref{paper} are simple enough that we can just solve them. Since angular velocity is constant:
6843
%
6844
\[ \frac{d\theta}{dt} = \omega \]
6845
%
6846
We can find $\theta$ as a function of time by integrating both sides:
6847
%
6848
\[ \theta(t) = \omega t + C_1 \]
6849
%
6850
With the initial condition $\theta(0)=0$, we find $C_1=0$. Similarly,
6851
%
6852
\begin{equation}
6853
\frac{dr}{dt} = k \omega \label{eqn1}
6854
\end{equation}
6855
%
6856
So
6857
%
6858
\[ r(t) = k \omega t + C_2 \]
6859
%
6860
With the initial condition $r(0)=R_{min}$, we find $C_2=R_{min}$. Then we can plug the solution for $r$ into the equation for $y$:
6861
%
6862
\begin{align}
6863
\frac{dy}{dt} & = r \omega \label{eqn2} \\
6864
& = \left[ k \omega t + R_{min} \right] \omega \nonumber
6865
\end{align}
6866
%
6867
%
6868
Integrating both sides yields:
6869
%
6870
\[ y(t) = \left[ k \omega t^2 / 2 + R_{min} t \right] \omega + C_3\]
6871
%
6872
So $y$ is a parabola, as you might have guessed. With initial condition $y(0)=0$, we find $C_3=0$.
6873
6874
\index{analysis}
6875
\index{integration}
6876
6877
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~\ref{contact}, I'll divide Equations~\ref{eqn1} and \ref{eqn2}, yielding
6878
%
6879
\[ \frac{dr}{dy} = \frac{k}{r}\]
6880
%
6881
Separating variables yields
6882
%
6883
\[ r~dr = k~dy\]
6884
%
6885
Integrating both sides yields
6886
%
6887
\[ r^2 / 2 = k y + C \]
6888
%
6889
When $y=0$, $r=R_{min}$, so
6890
%
6891
\[ C = \frac{1}{2} R_{min}^2 \]
6892
%
6893
Solving for $y$, we have
6894
%
6895
\begin{equation}
6896
y = \frac{1}{2k} (r^2 - R_{min}^2) \label{eqn3}
6897
\end{equation}
6898
%
6899
When $y=L$, $r=R_{max}$; substituting in those values yields
6900
%
6901
\[ L = \frac{1}{2k} (R_{max}^2 - R_{min}^2) \]
6902
%
6903
Solving for $k$ yields
6904
%
6905
\begin{equation}
6906
k = \frac{1}{2L} (R_{max}^2 - R_{min}^2) \label{eqn4}
6907
\end{equation}
6908
%
6909
Plugging in the values of the parameters yields \py{2.8e-5} \si{\meter\per\radian}, the same as the ``estimate" we computed in Section~\ref{papersim}. In this case the estimate turns out to be exact.
6910
6911
Before you go on, you might want to read the notebook for this chapter, \py{chap24.ipynb}, and work on the exercises. For instructions on downloading and running the code, see Section~\ref{code}.
6912
6913
6914
\chapter{Torque}
6915
\label{chap25}
6916
6917
In the previous chapter we modeled a scenario with constant angular velocity. In this chapter we make it more complex; we'll model a teapot, on a turntable, revolving with constant angular acceleration and deceleration.
6918
6919
\section{Angular acceleration}
6920
6921
\index{angular acceleration}
6922
\index{torque}
6923
6924
Just as linear acceleration is the derivative of velocity, {\bf angular acceleration} is the derivative of angular velocity. And just as linear acceleration is caused by force, angular acceleration is caused by the rotational version of force, {\bf torque}. If you are not familiar with torque, you can read about it at \url{http://modsimpy.com/torque}.
6925
6926
In general, torque is a vector quantity, defined as the {\bf cross product} of $\vec{r}$ and $\vec{F}$, where $\vec{r}$ is the {\bf lever arm}, a vector from the point of rotation to the point where the force is applied, and $\vec{F}$ is the vector that represents the magnitude and direction of the force.
6927
6928
\index{vector}
6929
\index{lever arm}
6930
\index{cross product}
6931
6932
However, for the problems in this chapter, we only need the {\em magnitude} of torque; we don't care about the direction. In that case, we can compute
6933
%
6934
\[ \tau = r F \sin \theta \]
6935
%
6936
where $\tau$ is torque, $r$ is the length of the lever arm, $F$ is the magnitude of force, and $\theta$ is the angle between $\vec{r}$ and $\vec{F}$.
6937
6938
\index{magnitude}
6939
6940
Since torque is the product of a length and a force, it is expressed in newton meters (\si{\newton\meter}).
6941
6942
6943
\section{Moment of inertia}
6944
6945
In the same way that linear acceleration is related to force by Newton's second law of motion, $F=ma$, angular acceleration is related to torque by another form of Newton's law:
6946
%
6947
\[ \tau = I \alpha \]
6948
%
6949
Where $\alpha$ is angular acceleration and $I$ is {\bf moment of inertia}. Just as mass is what makes it hard to accelerate an object\footnote{That might sound like a dumb way to describe mass, but its actually one of the fundamental definitions.}, moment of inertia is what makes it hard to spin an object.
6950
6951
\index{mass}
6952
\index{moment of inertia}
6953
6954
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.
6955
6956
\index{tensor}
6957
6958
Fortunately, in a system where all rotation and torque happens around a single axis, we don't have to deal with the most general case. We can treat moment of inertia as a scalar quantity.
6959
6960
\index{scalar}
6961
6962
For a small object with mass $m$, rotating around a point at distance $r$, the moment of inertia is $I = m r^2$. 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.
6963
6964
However, for most simple shapes, people have already done the calculations; you can just look up the answers. For example, see \url{http://modsimpy.com/moment}.
6965
6966
6967
\section{Teapots and turntables}
6968
6969
Tables in Chinese restaurants often have a rotating tray or turntable
6970
that makes it easy for customers to share dishes. These turntables are
6971
supported by low-friction bearings that allow them to turn easily and
6972
glide. However, they can be heavy, especially when they are loaded with
6973
food, so they have a high moment of inertia.
6974
6975
\index{teapot}
6976
\index{turntable}
6977
6978
Suppose I am sitting at a table with a pot of tea on the turntable
6979
directly in front of me, and the person sitting directly opposite asks
6980
me to pass the tea. I push on the edge of the turntable with \SI{1}{\newton} of force until it has turned \SI{0.5}{\radian}, then let go. The turntable glides until it comes to a stop \SI{1.5}{\radian} from the starting position. How much force should I apply for a second push so the teapot glides to a
6981
stop directly opposite me?
6982
6983
\index{force}
6984
\index{Newton}
6985
\index{friction}
6986
6987
We'll answer this question in these steps:
6988
6989
\begin{enumerate}
6990
6991
\item
6992
I'll use the results from the first push to estimate the coefficient
6993
of friction for the turntable.
6994
6995
\item
6996
As an exercise, you'll use that coefficient of friction to estimate the force needed to rotate the turntable through the remaining angle.
6997
6998
\end{enumerate}
6999
7000
Our simulation will use the following parameters:
7001
7002
\begin{enumerate}
7003
7004
\item
7005
The radius of the turntable is \SI{0.5}{\meter}, and its weight is \SI{7}{\kg}.
7006
7007
\item
7008
The teapot weights \SI{0.3}{\kg}, and it sits \SI{0.4}{\meter} from the center of the turntable.
7009
7010
\end{enumerate}
7011
7012
\begin{figure}
7013
\centerline{\includegraphics[height=2.5in]{figs/teapot.pdf}}
7014
\caption{Diagram of a turntable with a teapot.}
7015
\label{teapot}
7016
\end{figure}
7017
7018
Figure~\ref{teapot} shows the scenario, where $F$ is the force I apply to the turntable at the perimeter, perpendicular to the moment arm, $r$, and $\tau$ is the resulting torque. The blue circle near the bottom is the teapot.
7019
7020
Here's a \py{Params} object with the parameters of the scenario:
7021
7022
\begin{python}
7023
params = Params(radius_disk=0.5*m,
7024
mass_disk=7*kg,
7025
radius_pot=0.4*m,
7026
mass_pot=0.3*kg,
7027
force=1*N,
7028
torque_friction=0.2*N*m,
7029
theta_end=0.5*radian,
7030
t_end=20*s)
7031
\end{python}
7032
7033
\index{Params object}
7034
7035
\py{make_system} creates the initial state, \py{init}, and
7036
computes the total moment of inertia for the turntable and the teapot.
7037
7038
\begin{python}
7039
def make_system(params):
7040
mass_disk, mass_pot = params.mass_disk, params.mass_pot
7041
radius_disk, radius_pot = params.radius_disk, params.radius_pot
7042
7043
init = State(theta=0*radian, omega=0*radian/s)
7044
7045
I_disk = mass_disk * radius_disk**2 / 2
7046
I_pot = mass_pot * radius_pot**2
7047
7048
return System(params, init=init, I=I_disk+I_pot)
7049
\end{python}
7050
7051
%\index{make_system}
7052
7053
In the initial state,
7054
\py{theta} represents the angle of the table in \si{\radian}; \py{omega} represents the angular velocity in \si{\radian\per\second}.
7055
7056
\py{I_disk} is the moment of inertia of the turntable, which is based on the moment of inertia for a horizontal disk revolving around a vertical axis through its center:
7057
%
7058
\[ I_{disk} = m r^2 / 2 \]
7059
%
7060
\py{I_pot} is the moment of inertia of the teapot, which I treat as a point mass with:
7061
%
7062
\[ I_{point} = m r^2 \]
7063
%
7064
In SI units, moment of inertia is expressed in \si{\kilogram\meter\squared}.
7065
7066
Now we can make a \py{System} object:
7067
7068
\begin{python}
7069
system1 = make_system(params)
7070
\end{python}
7071
7072
\index{System object}
7073
7074
Here's a slope that takes the current state, which contains angle and angular velocity, and returns the derivatives, angular velocity and angular acceleration:
7075
7076
\begin{python}
7077
def slope_func(state, t, system):
7078
theta, omega = state
7079
radius_disk, force = system.radius_disk, system.force
7080
torque_friction, I = system.torque_friction, system.I
7081
7082
torque = radius_disk * force - torque_friction
7083
alpha = torque / I
7084
7085
return omega, alpha
7086
\end{python}
7087
7088
\index{slope function}
7089
7090
In this scenario, the force I apply to the turntable is always perpendicular to the lever arm, so $\sin \theta = 1$ and the torque due to force is $\tau = r F$.
7091
7092
\py{torque_friction} represents the torque due to friction. Because the turntable is rotating in the direction of positive \py{theta}, friction acts in the direction of negative \py{theta}.
7093
7094
\index{friction}
7095
7096
Now we are ready to run the simulation, but first there's a problem we have to address.
7097
7098
When I stop pushing on the turntable, the angular acceleration changes
7099
abruptly. We could implement the slope function with an \py{if}
7100
statement that checks the value of \py{theta} and sets
7101
\py{force} accordingly. And for a coarse model like this one, that
7102
might be fine. But we will get more accurate results if we simulate the
7103
system in two phases:
7104
7105
\begin{enumerate}
7106
\item
7107
During the first phase, force is constant, and we run until
7108
\py{theta} is 0.5 radians.
7109
\item
7110
During the second phase, force is 0, and we run until \py{omega}
7111
is 0.
7112
\end{enumerate}
7113
7114
Then we can combine the results of the two phases into a single
7115
\py{TimeFrame}.
7116
7117
\index{two-phase simulation}
7118
7119
Here's the event function I'll use for Phase 1; it stops the simulation when \py{theta} reaches \py{theta_end}, which is when I stop pushing:
7120
7121
\begin{python}
7122
def event_func1(state, t, system):
7123
theta, omega = state
7124
return theta - system.theta_end
7125
\end{python}
7126
7127
Now we can run the first phase.
7128
7129
\begin{python}
7130
results1, details1 = run_ode_solver(system1, slope_func,
7131
events=event_func1)
7132
\end{python}
7133
7134
%\index{run_ode_solver}
7135
7136
\begin{figure}
7137
\centerline{\includegraphics[height=4.0in]{figs/chap25-fig01.pdf}}
7138
\caption{Angle and angular velocity of a turntable with applied force and friction.}
7139
\label{chap25-fig01}
7140
\end{figure}
7141
7142
Before we run the second phase, we have to extract the final time and
7143
state of the first phase.
7144
7145
\begin{python}
7146
t_0 = get_last_label(results1) * s
7147
init2 = results1.last_row()
7148
\end{python}
7149
7150
Now we can make a \py{System} object for Phase 2, with the initial state from Phase 1, and with \py{force=0}.
7151
7152
%\index{get_last_label}
7153
%\index{get_last_value}
7154
7155
\begin{python}
7156
system2 = System(system1, t_0=t_0, init=init2, force=0*N)
7157
\end{python}
7158
7159
For the second phase, we need an event function that stops when the turntable stops; that is, when angular velocity is 0.
7160
7161
\begin{python}
7162
def event_func2(state, t, system):
7163
theta, omega = state
7164
return omega
7165
\end{python}
7166
7167
Now we can run the second phase.
7168
7169
\begin{python}
7170
results2, details2 = run_ode_solver(system2, slope_func,
7171
events=event_func2)
7172
\end{python}
7173
7174
Pandas provides \py{combine_first}, which combines
7175
\py{results1} and \py{results2}.
7176
7177
\index{Pandas}
7178
7179
\begin{python}
7180
results = results1.combine_first(results2)
7181
\end{python}
7182
7183
Figure~\ref{chap25-fig01} shows the results. Angular velocity, \py{omega}, increases linearly while I am pushing, and decreases linearly after I let go. The angle, \py{theta}, is the integral of angular velocity, so it forms a parabola during each phase.
7184
7185
In the next section, we'll use this simulation to estimate the torque due to friction.
7186
7187
7188
\section{Estimating friction}
7189
7190
Let's take the code from the previous section and wrap it in a function.
7191
7192
\index{function}
7193
7194
\begin{python}
7195
def run_two_phases(force, torque_friction, params):
7196
# put the parameters into the Params object
7197
params = Params(params, force=force,
7198
torque_friction=torque_friction)
7199
7200
# run phase 1
7201
system1 = make_system(params)
7202
results1, details1 = run_ode_solver(system1, slope_func,
7203
events=event_func1)
7204
7205
# get the final state from phase 1
7206
t_0 = results1.last_label() * s
7207
init2 = results1.last_row()
7208
7209
# run phase 2
7210
system2 = System(system1, t_0=t_0, init=init2, force=0*N)
7211
results2, details2 = run_ode_solver(system2, slope_func,
7212
events=event_func2)
7213
7214
# combine and return the results
7215
results = results1.combine_first(results2)
7216
return TimeFrame(results)
7217
\end{python}
7218
7219
We can use \py{run_two_phases} to write an error function we can use, with \py{root_bisect}, to find the torque due to friction that yields the observed results from the first push, a total rotation of \SI{1.5}{\radian}.
7220
7221
\index{\py{root_bisect}}
7222
\index{error function}
7223
7224
\begin{python}
7225
def error_func1(torque_friction, params):
7226
force = 1 * N
7227
results = run_two_phases(force, torque_friction, params)
7228
theta_final = results.last_row().theta
7229
print(torque_friction, theta_final)
7230
return theta_final - 1.5 * radian
7231
\end{python}
7232
7233
Now we can use \py{root_bisect} to estimate torque due to friction.
7234
7235
\index{torque}
7236
\index{friction}
7237
\index{\py{root_bisect}}
7238
7239
\begin{python}
7240
res = root_bisect(error_func1, [0.5, 2], params)
7241
force = res.root
7242
\end{python}
7243
7244
The result is \SI{0.166}{\newton\meter}, a little less than the initial guess.
7245
7246
Now that we know the torque due to friction, we can compute the force needed to rotate the turntable through the remaining angle, that is, from \SI{1.5}{\radian} to \SI{3.14}{\radian}.
7247
7248
In the notebook for this chapter, \py{chap25.ipynb}, you will have a chance to finish off the exercise. For instructions on downloading and running the code, see Section~\ref{code}.
7249
7250
7251
\chapter{Case studies}
7252
\label{chap26}
7253
7254
\section{Computational tools}
7255
7256
In Chapter~\ref{chap20} we rewrote a second order differential equation as a system of first order equations, and solved them using a slope function like this:
7257
7258
\begin{python}
7259
def slope_func(state, t, system):
7260
y, v = state
7261
g = system.g
7262
7263
dydt = v
7264
dvdt = -g
7265
7266
return dydt, dvdt
7267
\end{python}
7268
7269
We used the \py{crossings} function to search for zero-crossings in the simulation results.
7270
7271
Then we used an event function like this:
7272
7273
\begin{python}
7274
def event_func(state, t, system):
7275
y, v = state
7276
return y
7277
\end{python}
7278
7279
To stop the simulation when an event occurs. Notice that the event function takes the same parameters as the slope function.
7280
7281
In Chapter~\ref{chap21} we developed a model of air resistance and used a \py{Params} object, which is a collection of parameters:
7282
7283
\begin{python}
7284
params = Params(height = 381 * m,
7285
v_init = 0 * m / s,
7286
g = 9.8 * m/s**2,
7287
mass = 2.5e-3 * kg,
7288
diameter = 19e-3 * m,
7289
rho = 1.2 * kg/m**3,
7290
v_term = 18 * m / s)
7291
\end{python}
7292
7293
And we saw a new way to create a \py{System} object, copying the variables from a \py{Params} object and adding or changing variables:
7294
7295
\begin{python}
7296
return System(params, area=area, C_d=C_d,
7297
init=init, t_end=t_end)
7298
\end{python}
7299
7300
We also used the \py{gradient} function to estimate acceleration, given velocity:
7301
7302
\begin{python}
7303
a = gradient(results.v)
7304
\end{python}
7305
7306
Chapter~\ref{chap22} introduces \py{Vector} objects, which can represent vector quantities, like position, velocity, force, and acceleration, in 2 or 3 dimensions.
7307
7308
\begin{python}
7309
A = Vector(3, 4) * m
7310
\end{python}
7311
7312
It also introduces trajectory plots, which show the path of an object in two dimensions:
7313
7314
\begin{python}
7315
x = results.R.extract('x')
7316
y = results.R.extract('y')
7317
7318
plot(x, y, label='trajectory')
7319
\end{python}
7320
7321
In Chapter~\ref{chap23} we define a range function that computes the distance a baseball flies as a function of launch angle:
7322
7323
\begin{python}
7324
def range_func(angle, params):
7325
params = Params(params, angle=angle)
7326
system = make_system(params)
7327
results, details = run_ode_solver(system, slope_func,
7328
events=event_func)
7329
x_dist = get_last_value(results.R).x
7330
return x_dist
7331
\end{python}
7332
7333
Then we use \py{maximize} to find the launch angle that maximizes range:
7334
7335
\begin{python}
7336
bounds = [0, 90] * degree
7337
res = maximize(range_func, bounds, params)
7338
\end{python}
7339
7340
With that, your toolkit is complete. Chapter~\ref{chap24} and Chapter~\ref{chap25} introduce the physics of rotation, but no new computational tools.
7341
7342
7343
\section{Bungee jumping}
7344
\label{bungee}
7345
7346
Suppose you want to set the world record for the highest ``bungee dunk", which is a stunt in which a bungee jumper dunks a cookie in a cup of tea at the lowest point of a jump. An example is shown in this video: \url{http://modsimpy.com/dunk}.
7347
7348
Since the record is \SI{70}{\meter}, let's design a jump for \SI{80}{\meter}. We'll start with the following modeling assumptions:
7349
7350
\begin{itemize}
7351
7352
\item Initially the bungee cord hangs from a crane with the attachment point \SI{80}{\meter} above a cup of tea.
7353
7354
\item Until the cord is fully extended, it applies no force to the jumper. It turns out this might not be a good assumption; we will revisit it.
7355
7356
\item After the cord is fully extended, it obeys Hooke's Law; that is, it applies a force to the jumper proportional to the extension of the cord beyond its resting length. See \url{http://modsimpy.com/hooke}.
7357
7358
\item The mass of the jumper is \SI{75}{\kilogram}.
7359
7360
\item The jumper is subject to drag force so that their terminal velocity is \SI{60}{\meter \per \second}.
7361
7362
\end{itemize}
7363
7364
Our objective is to choose the length of the cord, \py{L}, and its spring constant, \py{k}, so that the jumper falls all the way to the tea cup, but no farther!
7365
7366
In the repository for this book, you will find a notebook, \py{bungee.ipynb}, which contains starter code and exercises for this case study.
7367
7368
7369
\section{Bungee dunk revisited}
7370
7371
In the previous case study, we assume that the cord applies no force to the jumper until it is stretched.
7372
It is tempting to say that the cord has no effect because it falls along with the jumper, but that intuition is incorrect. As the cord falls, it transfers energy to the jumper.
7373
7374
\index{bungee jump}
7375
\index{bungee cord}
7376
7377
At \url{http://modsimpy.com/bungee} you'll find a paper\footnote{Heck, Uylings, and Kędzierska, ``Understanding the physics of bungee jumping", Physics Education, Volume 45, Number 1, 2010.} that explains this phenomenon and derives the acceleration of the jumper, $a$, as a function of position, $y$, and velocity, $v$:
7378
%
7379
\[ a = g + \frac{\mu v^2/2}{\mu(L+y) + 2L} \]
7380
%
7381
where $g$ is acceleration due to gravity, $L$ is the length of the cord, and $\mu$ is the ratio of the mass of the cord, $m$, and the mass of the jumper, $M$.
7382
7383
If you don't believe that their model is correct, this video might convince you: \url{http://modsimpy.com/drop}.
7384
7385
In the repository for this book, you will find a notebook, \py{bungee2.ipynb}, which contains starter code and exercises for this case study.
7386
How does the behavior of the system change as we vary the mass of the cord?
7387
When the mass of the cord equals the mass of the jumper, what is the net effect on the lowest point in the jump?
7388
7389
7390
7391
\section{Spider-Man}
7392
7393
In this case study we'll develop a model of Spider-Man swinging from a
7394
springy cable of webbing attached to the top of the Empire State
7395
Building. Initially, Spider-Man is at the top of a nearby building, as
7396
shown in Figure~\ref{spiderman}.
7397
7398
\index{Spider-man}
7399
\index{Empire State Building}
7400
7401
\begin{figure}
7402
\centerline{\includegraphics[height=2.8in]{figs/spiderman.pdf}}
7403
\caption{Diagram of the initial state for the Spider-Man case study.}
7404
\label{spiderman}
7405
\end{figure}
7406
7407
The origin, \texttt{O}, is at the base of the Empire State Building. The
7408
vector \py{H} represents the position where the webbing is attached
7409
to the building, relative to \py{O}. The vector \py{P} is the
7410
position of Spider-Man relative to \py{O}. And \py{L} is the
7411
vector from the attachment point to Spider-Man.
7412
7413
\index{vector}
7414
7415
By following the arrows from \py{O}, along \py{H}, and along
7416
\py{L}, we can see that
7417
7418
\begin{code}
7419
H + L = P
7420
\end{code}
7421
7422
So we can compute \py{L} like this:
7423
7424
\begin{code}
7425
L = P - H
7426
\end{code}
7427
7428
The goals of this case study are:
7429
7430
\begin{enumerate}
7431
7432
\item
7433
Implement a model of this scenario to predict Spider-Man's trajectory.
7434
\index{trajectory}
7435
7436
\item
7437
Choose the right time for Spider-Man to let go of the webbing in order
7438
to maximize the distance he travels before landing.
7439
\index{range}
7440
7441
\item
7442
Choose the best angle for Spider-Man to jump off the building, and let
7443
go of the webbing, to maximize range.
7444
\index{optimization}
7445
7446
\end{enumerate}
7447
7448
We'll use the following parameters:
7449
\index{parameter}
7450
7451
\begin{enumerate}
7452
7453
\item According to the Spider-Man Wiki\footnote{\url{http://modsimpy.com/spider}}, Spider-Man weighs \SI{76}{\kg}.
7454
7455
\item
7456
Let's assume his terminal velocity is \SI{60}{\meter\per\second}.
7457
\index{terminal velocity}
7458
7459
\item
7460
The length of the web is \SI{100}{\meter}.
7461
7462
\item
7463
The initial angle of the web is \SI{45}{\degree} to the left of straight
7464
down.
7465
7466
\item
7467
The spring constant of the web is \SI{40}{\newton\per\meter} when the cord is stretched, and 0 when it's compressed.
7468
7469
\end{enumerate}
7470
7471
In the repository for this book, you will find a notebook, \py{spiderman.ipynb}, which contains starter code. Read through the notebook and run the code. It uses \py{minimize}, which is a SciPy function that can search for an optimal set of parameters (as contrasted with \py{minimize_scalar}, which can only search along a single axis).
7472
7473
7474
\section{Kittens}
7475
7476
Let's simulate a kitten unrolling toilet paper. As reference material, see this video: \url{http://modsimpy.com/kitten}.
7477
7478
\index{kitten}
7479
7480
The interactions of the kitten and the paper roll are complex. To keep things simple, let's assume that the kitten pulls down on the free end of the roll with constant force. Also, we will neglect the friction between the roll and the axle.
7481
7482
\begin{figure}
7483
\centerline{\includegraphics[height=2.5in]{figs/kitten.pdf}}
7484
\caption{Diagram of a roll of toilet paper, showing a force, lever arm, and the resulting torque.}
7485
\label{kitten}
7486
\end{figure}
7487
7488
Figure~\ref{kitten} shows the paper roll with $r$, $F$, and $\tau$. As a vector quantity, the direction of $\tau$ is into the page, but we only care about its magnitude for now.
7489
7490
Here's the \py{Params} object with the parameters we'll need:
7491
7492
\index{Params object}
7493
7494
\begin{python}
7495
params = Params(Rmin = 0.02 * m,
7496
Rmax = 0.055 * m,
7497
Mcore = 15e-3 * kg,
7498
Mroll = 215e-3 * kg,
7499
L = 47 * m,
7500
tension = 2e-4 * N,
7501
t_end = 180 * s)
7502
\end{python}
7503
7504
As before, \py{Rmin} is the minimum radius and \py{Rmax} is the maximum. \py{L} is the length of the paper. \py{Mcore} is the mass of the cardboard tube at the center of the roll; \py{Mroll} is the mass of the paper. \py{tension} is the force applied by the kitten, in \si{\newton}. I chose a value that yields plausible results.
7505
7506
At \url{http://modsimpy.com/moment} you can find moments of inertia for simple geometric shapes. I'll model the cardboard tube at the center of the roll as a ``thin cylindrical shell", and the paper roll as a ``thick-walled cylindrical tube with open ends".
7507
7508
\index{cylinder}
7509
7510
The moment of inertia for a thin shell is just $m r^2$, where $m$ is the mass and $r$ is the radius of the shell.
7511
7512
For a thick-walled tube the moment of inertia is
7513
%
7514
\[ I = \frac{\pi \rho h}{2} (r_2^4 - r_1^4) \]
7515
%
7516
where $\rho$ is the density of the material, $h$ is the height of the tube, $r_2$ is the outer diameter, and $r_1$ is the inner diameter.
7517
7518
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, \py{r}. Here's the function that does it:
7519
7520
\index{unpack}
7521
7522
\begin{python}
7523
def moment_of_inertia(r, system):
7524
Mcore, Rmin = system.Mcore, system.Rmin
7525
rho_h = system.rho_h
7526
7527
Icore = Mcore * Rmin**2
7528
Iroll = pi * rho_h / 2 * (r**4 - Rmin**4)
7529
return Icore + Iroll
7530
\end{python}
7531
7532
\py{rho_h} is the product of density and height, $\rho h$, which is the mass per area. \py{rho_h} is computed in \py{make_system}:
7533
7534
\index{density}
7535
\index{\py{make_system}}
7536
7537
\begin{python}
7538
def make_system(params):
7539
L, Rmax, Rmin = params.L, params.Rmax, params.Rmin
7540
Mroll = params.Mroll
7541
7542
init = State(theta = 0 * radian,
7543
omega = 0 * radian/s,
7544
y = L)
7545
7546
area = pi * (Rmax**2 - Rmin**2)
7547
rho_h = Mroll / area
7548
k = (Rmax**2 - Rmin**2) / 2 / L / radian
7549
7550
return System(params, init=init, area=area,
7551
rho_h=rho_h, k=k)
7552
\end{python}
7553
7554
\py{make_system} also computes \py{k} using Equation~\ref{eqn4}.
7555
7556
In the repository for this book, you will find a notebook, \py{kitten.ipynb}, which contains starter code for this case study. Use it to implement this model and check whether the results seem plausible.
7557
7558
7559
\section{Simulating a yo-yo}
7560
7561
Suppose you are holding a yo-yo with a length of string wound around its axle, and you drop it while holding the end of the string stationary. As gravity accelerates the yo-yo downward, tension in the string exerts a force upward. Since this force acts on a point offset from the center of mass, it exerts a torque that causes the yo-yo to spin.
7562
7563
\index{yo-yo}
7564
\index{torque}
7565
\index{lever arm}
7566
7567
\begin{figure}
7568
\centerline{\includegraphics[height=2.5in]{figs/yoyo.pdf}}
7569
\caption{Diagram of a yo-yo showing forces due to gravity and tension in the string, the lever arm of tension, and the resulting torque.}
7570
\label{yoyo}
7571
\end{figure}
7572
7573
Figure~\ref{yoyo} 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.
7574
7575
\index{system of equations}
7576
7577
In this model, we can't figure out the linear and angular acceleration independently; we have to solve a system of equations:
7578
%
7579
\begin{align*}
7580
\sum F &= m a \\
7581
\sum \tau &= I \alpha
7582
\end{align*}
7583
%
7584
where the summations indicate that we are adding up forces and torques.
7585
7586
As in the previous examples, linear and angular velocity are related because of the way the string unrolls:
7587
%
7588
\[ \frac{dy}{dt} = -r \frac{d \theta}{dt} \]
7589
%
7590
In this example, the linear and angular accelerations have opposite sign. As the yo-yo rotates counter-clockwise, $\theta$ increases and $y$, which is the length of the rolled part of the string, decreases.
7591
7592
Taking the derivative of both sides yields a similar relationship between linear and angular acceleration:
7593
%
7594
\[ \frac{d^2 y}{dt^2} = -r \frac{d^2 \theta}{dt^2} \]
7595
%
7596
Which we can write more concisely:
7597
%
7598
\[ a = -r \alpha \]
7599
%
7600
This relationship is not a general law of nature; it is specific to scenarios like this where one object rolls along another without stretching or slipping.
7601
7602
\index{rolling}
7603
7604
Because of the way we've 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.
7605
7606
We can compute the acceleration of the yo-yo by adding up the linear forces:
7607
%
7608
\[ \sum F = T - mg = ma \]
7609
%
7610
Where $T$ is positive because the tension force points up, and $mg$ is negative because gravity points down.
7611
7612
Because gravity acts on the center of mass, it creates no torque, so the only torque is due to tension:
7613
%
7614
\[ \sum \tau = T r = I \alpha \]
7615
%
7616
Positive (upward) tension yields positive (counter-clockwise) angular acceleration.
7617
7618
\index{SymPy}
7619
7620
Now we have three equations in three unknowns, $T$, $a$, and $\alpha$, with $I$, $m$, $g$, and $r$ as known quantities. It is simple enough to solve these equations by hand, but we can also get SymPy to do it for us:
7621
7622
\begin{python}
7623
T, a, alpha, I, m, g, r = symbols('T a alpha I m g r')
7624
eq1 = Eq(a, -r * alpha)
7625
eq2 = Eq(T - m*g, m * a)
7626
eq3 = Eq(T * r, I * alpha)
7627
soln = solve([eq1, eq2, eq3], [T, a, alpha])
7628
\end{python}
7629
7630
The results are
7631
%
7632
\begin{align*}
7633
T &= m g I / I^* \\
7634
a &= -m g r^2 / I^* \\
7635
\alpha &= m g r / I^* \\
7636
\end{align*}
7637
%
7638
where $I^*$ is the augmented moment of inertia, $I + m r^2$.
7639
To simulate the system, we don't really need $T$; we can plug $a$ and $\alpha$ directly into the slope function.
7640
7641
In the repository for this book, you will find a notebook, \py{yoyo.ipynb}, which contains the derivation of these equations and starter code for this case study. Use it to implement and test this model.
7642
7643
7644
%\section{Rigid pendulum}
7645
7646
%\section{LRC circuit}
7647
7648
% Pendulum:
7649
7650
% Springy pendulum
7651
7652
% Stiff problem as k increases
7653
7654
% Add drag
7655
7656
% Rigid pendulum: solve those constraints
7657
7658
% Generalized coordinates
7659
7660
7661
\backmatter
7662
\printindex
7663
7664
%\afterpage{\blankpage}
7665
7666
7667
\end{document}
7668
7669
\end{itemize}
7670
7671
7672
\section{Under the hood}
7673
7674
Throughout this book, we'll use functions defined in the ModSim library. You don't have to know how they work, but you might be curious. So at the end of some chapters I'll provide additional information. If you are an experienced programmer, you might be interested by the design decisions I made. If you are a beginner, and you feel like you have your hands full already, feel free to skip these sections.
7675
7676
\index{modsim}
7677
7678
Most of the functions in \py{modsim} are based on other Python libraries; the libraries we have used so far include:
7679
7680
\begin{itemize}
7681
7682
\item {\bf Pint}, which provides units like meters and seconds, as we saw in Section~\ref{penny}.
7683
7684
\item {\bf NumPy}, which provides mathematical operations like \py{sqrt}, which we saw in Section~\ref{computation}.
7685
7686
\item {\bf Pandas}, which provides the \py{Series} object, which is the basis of the \py{State} object in Section~\ref{modeling}.
7687
7688
\item {\bf Pyplot}, which provides plotting functions, as we saw in Section~\ref{plotting}.
7689
7690
\end{itemize}
7691
7692
You could use these libraries directly, and when you have more experience, you probably will. But the functions in \py{modsim} are meant to be easier to use; they provide some additional capabilities, including error checking; and by hiding details you don't need to know about, they let you focus on more important things.
7693
7694
However, there are drawbacks. One is that it can be hard to understand the error messages. I'll have more to say about this in later chapters, but for now I have a suggestion. When you are getting started, you should practice making errors.
7695
7696
\index{debugging}
7697
7698
For each new function you learn, you should deliberately make as many mistakes as possible so you can see what happens. When you see what the errors messages are, you will understand what they mean. And that should help later, when you make errors accidentally.
7699
7700
7701
7702