Python For Engineers
Python For Engineers
Lorenz Kies
1 Getting Started 9
1.1 My first Program: “Hello World” . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
1.2 A Calculator on Steroids . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
1.3 Variables & Expressions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
1.4 Types & Values . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
1.5 Functions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
1.6 Conditionals with if and else . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
1.7 Operators . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
1.7.1 Arithmetical Operators . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
1.7.2 Relational Operators . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
1.7.3 Logical Operators . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
1.7.4 Operator Precedence . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
1.8 Examples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
1.8.1 Beam Deflection . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
1.8.2 Maximum . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
1.8.3 Price Calculation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
1.9 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
2 Moving On 22
2.1 Loops . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
2.1.1 for Loops . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
2.1.2 The range Function . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
2.1.3 Iterating Over a Collection . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
2.1.4 while Loops . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
2.1.5 Executing Code Step by Step with a Debugger . . . . . . . . . . . . . . . . 26
2.1.6 break and continue . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
2.2 More About Functions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
2.2.1 Pure Functions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
2.2.2 Docstrings . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
2.2.3 Scopes and Variable Visibility . . . . . . . . . . . . . . . . . . . . . . . . . 30
2.3 Examples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31
2.3.1 Factorial . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
2.3.2 Herons Algorithm . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
2.4 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
i
3.1.3 List Examples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40
3.2 Tuples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
3.3 Dictionaries . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
3.4 Sets . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
3.5 More Examples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
3.5.1 Why Sets over Lists? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
3.6 Dictionaries for Caching . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 48
3.7 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49
6 Object-Oriented Programming 80
6.1 Defining a Class . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 80
6.2 Special Methods . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 81
6.3 Complex Numbers in Python . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 82
6.4 Encapsulation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
6.5 Inheritance . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 84
6.6 Dataclasses & Namedtuples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 87
6.7 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 88
6.8 Appendix - Decorators . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 88
ii
7.7.2 Automatic Formatting & Linting . . . . . . . . . . . . . . . . . . . . . . . . . 100
7.7.3 Use More Functions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 100
7.7.4 Use Early Returns . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 100
7.7.5 Use Type Hints . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 101
7.7.6 Prioritize Readability . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 101
7.7.7 Don’t Be Afraid to Start Over . . . . . . . . . . . . . . . . . . . . . . . . . . 101
7.8 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 101
8 Pandas 103
8.1 Series for Ordered Data . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 103
8.2 DataFrames as Collections of Series . . . . . . . . . . . . . . . . . . . . . . . . 104
8.3 Convenience Functions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 107
8.4 Indexing & Filtering . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 107
8.5 Saving & Loading DataFrames . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 108
8.6 Grouping & Aggregating . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 109
8.7 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 113
iii
12.2 Making Scripts Importable . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 155
12.3 Minimal Project Setup . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 156
12.4 Managing Dependencies . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 157
12.5 Testing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 157
12.6 Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 160
iv
Python for Engineers
Introduction
This course is meant as an introduction to Python and programming in general for engineers. We
live in an age where practically unimaginable computing resources are almost universally avail-
able. Being able to make use of these resources has become a requirement to work productively
in many science and engineering related fields. In addition to that, programming might also help
you in other avenues of your life. It is therefore important, that you as engineers have a basic
understanding of programming and some commonly available tools.
Python as a language and ecosystem comes with a lot of libraries, tools and other resources for
almost everything you might want to do. In addition to that it is a fairly beginner-friendly language,
which make this is a great candidate for this course.
In this course you will learn:
• The fundamentals of programming in Python (variables and expressions, control structures,
functions, common data structures)
• The basics Python’s rich scientific tool set (NumPy, SciPy, Matplotlib, Pandas, Python Con-
trol)
• How to find and read documentation
• How to use the tools required for developing and running Python code
• And how to use those skills to solve everyday engineering problems
Additionally, there will be a lot of pointers for further reading and learning to lead you to more
advanced topics.
Course Structure
CONTENTS 1
Python for Engineers
Schedule (Preliminary)
All of the course material is in English, this is not to inconvenience you, but it should ideally be
to help you. Pretty much all programming languages as well as related tools and most online
resources are in English. This makes programming as a whole mostly an exclusively English
thing. To help you get used to programming in English i.e. English variable names, interfaces and
CONTENTS 2
Python for Engineers
documentation the course material is in English. To not pose too much of an additional challenge
to people who are not as comfortable in English, the lectures will be held in German.
Large language model based AI tools have seen a rapid increase in capabilities and popularity in
recent years. This is very much a double-edged sword in the context of learning to program. On
the one hand they allow you to do more things faster with less knowledge, but on the other hand
they allow you to do more things faster with less knowledge. In a few more words:
AI tools are now capable of solving many small common problems decently, so they can be an at-
tractive option if you are not interested in learning and just need quick results. They are especially
good at solving most of the classical introduction to programming exercises. This might easily
you lead to believe, that there is no use in learning “how to generate a list of Fibonacci numbers”
since you could always just have an AI do it, just like you don’t do arithmetic with pen and paper
since you have a calculator. But solving these problems is not about having generating a solution
to these problems, but about the skills you learn along the way. Skills that you will need to solve
more complex problems even with the help of AI tools.
Here is an analogy to illustrate this idea: In your very first swimming lessons, you will likely be in
a pool where you can easily stand up (i.e. use AI tools). So you might ask why you would need to
learn to swim when you can just stand up. But you are not learning to swim so you can keep you
head above water in the beginners pool (i.e. solve practice problems) you are learning to swim so
you won’t drown when you can’t stand (i.e. solve real world problems).
Not only are these tools very good at solving practice problems, they can be even worse: depend-
ing on how tightly they are integrated into your workflow, they might not only deprive you of the
valuable learning experience, but fool you into believing you learned something that you didn’t. So
while AI tools, if used right, can be a helpful tool in learning to program, they can quickly become
a crutch that hinders learning. That is why, for learning to program, I recommend that you
do not use any AI tools.
To be able to follow the live lectures and solve the exercise sheets you will need to set up your de-
velopment environment. It is recommended that you install Python using uv and use Visual Studio
Code as your code editor. The installation of those tools is described in the following sections.
For the installation of programming environments, but also for programming itself, it is very useful
to have at least some understanding of shells / command line interfaces and package managers.
Shells are text-based interfaces to the operating system, which allow you to interact with the sys-
tem by typing commands into a terminal and getting text-based output back.
While there are also other options, the shells you will typically be using are:
• PowerShell on Windows
CONTENTS 3
Python for Engineers
There are many ways to install Python on your system. As mentioned above, we will be using uv
which includes an alternative implementation of PIP in addition to some more advanced features
for project management.
Windows
You may have to agree to the terms of service the first time you use WinGet.
CONTENTS 4
Python for Engineers
First, test if your installation of uv was successful by entering the following command in your
terminal:
uv
Unlike many other methods of installing Python, uv does not install Python globally on your system.
Instead, uv is used to create isolated Python installations called virtual environments for each
project. For these purposes, we will consider this course to be the project. Start by creating
a directory where you want to store the material for this course. Then open your shell in said
directory. You can usually do this by right-clicking in your file explorer while holding the shift key
and selecting “Open PowerShell window here” or “Open in Terminal” or something similar.
Start by verifying that you are in the correct directory with:
pwd
This should display the path to the directory you just created.
If the location is correct, initialize the project with uv:
This will add them to your project specification in stored in a file called [Link] and
install them in a virtual environment for this project.
In the first few lectures, we will only be using Jupyter notebooks, so you can skip this section for
now.
There are two primary ways to execute Python code in this environment.
The first option is to execute Python through uv:
Alternatively, you can activate the virtual environment created by uv and then execute Python
directly:
Using Windows PowerShell:
CONTENTS 5
Python for Engineers
.\venv\Scripts\Activate.ps1
You may first need to allow the execution of scripts by opening a new PowerShell window as
administrator and running:
Set-ExecutionPolicy RemoteSigned
source venv/bin/activate
To edit your code, you will need a code editor. For this we will use Visual Studio Code. You can
also use the graphical installer from the Visual Studio Code website if you are having trouble with
the instructions.
Windows
Linux
MacOS
Extensions
So set up VS-Code for Python development you will need to install some extensions. You can
open the extensions tab by pressing Ctrl+Shift+X or by clicking the respective icon in the
sidebar.
CONTENTS 6
Python for Engineers
To use all of VS Code’s features, it needs to know what files you are working on and what Python
environment you are using. To do this, you need to open the folder you created for this course as
a workspace in VS Code. You can do this by clicking File -> Open Folder... and selecting
the folder you created for this course.
Next you need to make sure, that VS Code is using the correct Python environment. When you
open a Python file, you should see a notification in the bottom right corner asking you to select
a Python environment. Click on it and select the environment that is located in the venv folder
in your project directory. If you don’t see the notification, you can also select the environment by
clicking on the Python version displayed in the bottom left corner. Alternatively, you can also open
the dialog for selecting the Python environment with the command palette (Ctrl+Shift+P) and
searching for “Python: Select Interpreter”.
Similarly, when you open a Jupyter notebook, you should see a notification asking you to select a
kernel. Click on it and select the kernel that is located in the venv folder in your project directory.
CONTENTS 7
Python for Engineers
If you don’t see the notification, you can also select the kernel by clicking on the kernel name
displayed in the top right corner of the notebook editor.
Shortcuts
Code editors like VS-Code come with a lot of shortcuts to make editing code faster and less te-
dious. Here are some of the most useful ones, you can find a more comprehensive list here.
CONTENTS 8
CHAPTER
ONE
GETTING STARTED
A common first program that to write when learning a new language is the “Hello World” program.
It simply prints out the text "Hello World" and in Python it is only one line. And print, in this
case, does not mean to literally print something out (not anymore), but to display the text on the
standard output, meaning the screen. We can execute a cell by pressing Ctrl + Enter or by
clicking the play button in top left of the cell.
print("Hello World")
Hello World
In some sense, you could think of Python as a kind of like a calculator on steroids. It can do
most things that you are used to from normal calculators. Like basic arithmetic and other com-
mon functions you might find on a scientific calculator. This is especially true when using Jupyter
notebooks, since we don’t need to use the print() function to display the result of an expression
because the result of the last expression is always displayed automatically.
(2 + 2) * 3
12
9
Python for Engineers
11
3
7
11
2 * 2 * 3.14159
12.56636
Without exponentiation, we have to write the radius two times, which is a bit annoying. To make
this easier, we can store the radius in a variable. This is done by using the assignment operator
=. While we are at it, we can also assign the value of 𝜋 in a variable, so we can reuse it later.
When we write multiple lines, the lines will be executed in order, and the result of the last line will
be displayed.
# assigns the value of the expression on the right to the variable on the␣
↪left
radius = 3 # ie `radius` is assigned the value 3, not the same as `=` in␣
↪math
pi = 3.14159
radius * radius * pi # use variables in place of values
28.27431
Now we can just change the value of the radius variable radius to 3 and the rest automatically
adapts to it.
Attempting to refer to an undefined variable is an error, variable names and all other identifiers are
case sensitive.
------------------------------------------------------------------------
↪---
NameError Traceback (most recent call␣
↪last)
Cell In[9], line 1
----> 1 x = undefined_variable # attempting to use an undefined␣
↪variable produces an error
x = Radius # we defined the radius with a lower case `r`, not an upper␣
↪case `R`
------------------------------------------------------------------------
↪---
NameError Traceback (most recent call␣
↪last)
Cell In[10], line 1
----> 1 x = Radius # we defined the radius with a lower case `r`, not␣
↪an upper case `R`
Variables can also be changed during runtime, they are called “variables” after all.
x = 2
print(x)
x = 3
print(x)
2
3
With 𝑟 and 𝜋 stored in variables, we can also calculate the circumference of the circle.
2 * radius * pi
18.849539999999998
We can also store the circumference in a variable. In general, everywhere you can write a value,
you can also write an expression. This is what makes programming so powerful.
18.849539999999998
Checkup Question
Consider the following code snippet:
radius = 5
Radius = 10
result = radius * 2
print(result)
Unlike a normal calculator, we are not limited to numeric values. There are also other types of
values like text. In the programming context we generally call these strings because they are
strings of characters. Formally, we could say that a type is a set of values, i.e. the type integer is
the set of all whole numbers or string is the set of all sequences of characters. Informally the type
is the kind of value we are talking about just we might categorize other things.
Let’s use some strings to make the output a bit nicer. For different types there are different oper-
ations we can perform on them. We can do arithmetic with numbers, but we can’t do arithmetic
with strings. We can, however, concatenate them using the + (plus) operator. It is important to
note that even though we are using the same symbol, addition and concatenation are different
operations.
HelloWorld
------------------------------------------------------------------------
↪---
TypeError Traceback (most recent call␣
↪last)
Just like in natural language, even if the syntax or grammar is correct, the sentence might still
not make sense. In Python something + something_else could be a syntactically correct
expression but if something is a number and something_else is a string it doesn’t make
sense and therefore we get a TypeError. To see what type a value has, we can use Python’s
built-in type() function.
<class 'float'>
<class 'str'>
In some cases, we can convert values to another type, but this is not always without information
loss. For example, we know that we can convert a number to a string because that’s how we
represent numbers on paper. We perform this conversion by calling the type, in this case str, as
a function with the value we want to convert as its argument.
number_as_string = str(circumference)
print(number_as_string) # does the same as just printing the number␣
↪because printing needs to convert to string anyway
print(type(number_as_string)) # now it is a string
18.849539999999998
<class 'str'>
42
------------------------------------------------------------------------
↪---
ValueError Traceback (most recent call␣
↪last)
circumference = 18.849539999999998
print(type(2))
print(type(2.5))
print(type("Hello World"))
print(type(True))
<class 'int'>
<class 'float'>
<class 'str'>
<class 'bool'>
Checkup Question
What is the result of the expression "Radius = " + str(5.0)?
• A: An error: TypeError
• B: The string "Radius = 5.0"
• C: The string "Radius = str(5.0)"
• D: The number 5.0
The correct answer is B: The string "Radius = 5.0". Using the str() function on the number
5.0 converts it to a string which can be concatenated with the first string.
1.5 Functions
Just like we assigned values to variables to reuse them in expressions, we can define functions
to reuse entire expressions (and pretty much everything else). As a first intuition, we can think of
functions just like we know them from math. A function takes some input, substitutes it into an
expression and finally returns the expressions result.
We can define a function using the def keyword followed by its signature and definition. A func-
tions’ signature describes the amount and type of arguments a function takes, and what type of
value it returns.
For example the function 𝑓(𝑥) = 𝑥2 that maps a real number to another real numbers i.e. 𝑓 ∶ ℝ →
ℝ would look like this in Python:
1.5. Functions 14
Python for Engineers
# the definition consists of the def keyword, the function name and␣
↪parameter names
4.0
17.64
While in mathematics it is very common to give functions single letter names, it is considered bad
practice in programming. A functions name should reflect what the function does. So in this case it
should not be named f but rather square. That way users of the function or someone reading the
code immediately can understand what the function does without having to look at the definition.
Functions in programming languages, also sometimes called procedures, can actually do much
more than mapping inputs to outputs. We will learn more about this in the next lecture.
Going back to our circle area example, we might define the function circle_area that computes
a circles’ area given its radius.
print(circle_area(1.0))
print(circle_area(2.0))
# no error because python does not check types and `True` is interpreted␣
↪as the numeric value 1
print(circle_area(True))
3.14159
12.56636
3.14159
The type annotations are actually completely optional and not enforced, they are only there to help
humans understand whats going on. As far as Python is concerned we defined the function as
def circle_area(r): and the type annotations are just comments.
However, we do get errors when we pass to many or to few parameters to a function:
------------------------------------------------------------------------
↪---
1.5. Functions 15
Python for Engineers
------------------------------------------------------------------------
↪---
TypeError Traceback (most recent call␣
↪last)
Checkup Question
In the function definition def circle_area(radius: float) -> float:, what does ->
float indicate?
• A: The function requires exactly one input named float.
• B: It’s a comment ignored by Python.
• C: The function converts the input radius to a float.
• D: The function is expected to return a value of type float.
The correct answer is D: The function is expected to return a value of type float, since it is a
type hint, it must not necessarily return a float. In some sense B is also correct since it is an
annotation that Python does not really care about. However, unlike an actual comment the hinted
return type must be a valid expression.
The thing that makes programming much more powerful than a simple sequence of math equa-
tions, is the power to repeat or skip parts of the code based on values calculated in the program
itself.
On such “control structure” is the if - else construct which enables us to do something, some-
thing else or even nothing at all.
In this example, we want to print something different depending on the value of my_number.
The statements that are part of the if and else sections are grouped by indentation.
my_number = 2 # one equals sign is assignment, a statement (!)
else:
print("your number is not greater than 3") # the other
your number is 2
your number is not greater than 3
In the context of functions, we can also see this as a way to define a function piecewise. For
example, we would define the absolute value of a number as follows:
𝑥 if 𝑥 ≥ 0
|𝑥| = abs(𝑥) = {
−𝑥 else
print(absolute_value(-1.0))
print(absolute_value(2.0)) # this is the absolute value function
print(abs(-3.0)) # python already has a built-in absolute value function
1.0
2.0
3.0
Checkup Question
What is true about expressions of the form [left side] = [right side]?
• A: Always evaluates to True or False.
• B: The left and right side must be equal.
• C: The left and right side will be equal.
• D: The left side must be a variable.
The correct answer is D: It is an assignment which is technically not an expression but a statement.
After it has been executed the variable on the left will have the value that the right side evaluated
to. If the right side depends on the variable the two sides will most likely not be equal afterward.
1.7 Operators
Python, like most programming languages, implements all the common arithmetical operators that
you already know, but there are also some more “computing” related operators that we will get to
in a bit.
For positive numbers the modulo operator is equivalent to the remainder of a division.
Relational operators compare two numerical values and return what is called a boolean value. A
boolean value is always either True or False.
Note the difference between comparison == and assignment =, one checks for equality of the left
and right side, while the other assigns the right side to the left side.
Lastly, there are the logical operators and, or and not. These are used to combine boolean
values. The effect of these can be seen in the so-called “truth tables”.
1.7. Operators 18
Python for Engineers
x y x and y x or y not x
False False False False True
False True False True True
True False False True False
True True True True False
Just like we have rules like for the precedence of arithmetic operations like multiplication and
addition in mathematics, Python extends these rules to the new types of operators. Here is an
incomplete list from strongest to weakest precedence:
• () Parentheses
• ** Exponentiation
• *, /, //, % Multiplication and division related operators
• +, - Addition and subtraction
• <, <=, >, >=, ==, != Comparison operators
• not, and, or Logical operators
Checkup Question
What is the result of the expression 5 + 2 * 3 == 11?
• A: True
• B: False
• C: 21
• D: An error, because it is not a valid expression.
The correct answer is A: True. Just like in regular mathematics, multiplication is done before
addition. After that the comparison evaluates to True.
1.7. Operators 19
Python for Engineers
1.8 Examples
Now, to get a better understanding of how what you have learned can be used, let’s program
some simple examples.
A beam is rigidly supported at one end and a force 𝐹 is applied at the other free end like shown
in the figure below.
In you mechanics script you might find the following formula for this:
𝐹 2
𝑤(𝑥) = 𝑥 (3𝐿 − 𝑥)
6𝐸𝐼
Write a function to determine the deflection 𝑤 at position 𝑥 as a function of the applied force 𝐹
and length of the beam 𝐿. Assume that the stiffness 𝐸𝐼 = 1 is unity.
print(beam_deflection(1, 3, 1))
1.0
1.8.2 Maximum
Write a function that takes two inputs and always returns the larger one.
print(maximum(1, 2))
print(maximum(3, 4))
print(max(5, 6)) # again, python already has a built-in function for this
2
4
6
1.8. Examples 20
Python for Engineers
A hardware store is selling bolts, they cost 20 cents a piece with bulk discounts when buying larger
quantities:
• 10% discount when buying 100 pieces or more
• 20% discount when buying 1000 pieces or more
• 30% discount when buying 10000 pieces or more
Write a function that calculates the price for a given quantity of bolts, taking into account the
discounts as they apply.
print(calculate_price(50))
print(calculate_price(200))
print(calculate_price(1000))
print(calculate_price(10000))
10.0
36.0
160.0
1400.0
1.9 Summary
1.9. Summary 21
CHAPTER
TWO
MOVING ON
2.1 Loops
In the last lecture we saw how if-else constructs are in some sense similar to piecewise func-
tions in mathematics. Like with the definition of the absolute value:
𝑥 if 𝑥 ≥ 0
abs(𝑥) = {
−𝑥 else
A similar comparison can be made with for loops and sums or products. Let’s say we want to
calculate the sum of the first 𝑛 square numbers. Mathematically would write this as:
𝑛
𝑓(𝑛) = ∑ 𝑖2
𝑖=1
For every 𝑖 from 1 to 𝑛 inclusive we want to add its square to our total sum. In Python this translates
to:
22
Python for Engineers
print(sum_of_squares(1))
print(sum_of_squares(2))
print(sum_of_squares(3))
1
5
14
We use the range function, to generate the series of values that we want to sum, or more generally,
iterate over. We get more information about a function by using Python’s built in help function:
help(range)
class range(object)
| range(stop) -> range object
| range(start, stop[, step]) -> range object
|
| Return an object that produces a sequence of integers from start␣
↪(inclusive)
| to stop (exclusive) by step. range(i, j) produces i, i+1, i+2, ...,
↪ j-1.
| start defaults to 0, and stop is omitted! range(4) produces 0, 1,␣
↪2, 3.
| These are exactly the valid indices for a list of 4 elements.
| When step is given, it specifies the increment (or decrement).
...
We have already seen that we can supply it with a pair of inclusive starting value and exclusive
stopping value:
1
2
(continues on next page)
2.1. Loops 23
Python for Engineers
If we only pass one argument to it, it will use that as the stopping value, implicitly set the starting
value to 0:
0
1
2
3
4
While it may be somewhat unintuitive, the fact that we start at zero but stop before 5 means that
we still end up with 5 numbers and 5 iterations.
Finally, instead of always incrementing by 1 from one number to the next we can also specify a
step size by passing a third value:
# count up in increments of 2
for i in range(0, 10, 2):
print(i)
0
2
4
6
8
If we only care about the fact that something is done a certain number of times, we can explicitly
ignore the iteration variable by assigning it to _:
hello world
hello world
hello world
2.1. Loops 24
Python for Engineers
You might also remember that there is a special sigma notation for summing over a collection or a
set of values. Say we are solving a mechanics problem and we have a set of forces acting on an
object in the 𝑥 direction 𝐹𝑥 = {2 N, −3 N, 5 N}. We want to calculate the resulting force 𝐹res as:
𝐹res = ∑ 𝑓
𝑓∈𝐹𝑥
F_x = [2, -3, 5] # not actually a set, more on collections in the next␣
↪lecture
F_res = 0
for f in F_x:
F_res += f
print(F_res)
print(sum(F_x)) # we can also directly sum over collections
4
4
When we are writing for i in range(...) we are technically also looping over an iterable,
in this case the range iterable which gives us evenly spaced integers.
Checkup Question
Consider the following piece of code:
res = 0
for i in range(1, 5, 2):
res += i
print(res)
Another kind of loop which is much more primitive and has no close equivalence in mathematics
is the while loop. As the name suggests, it loops “while” a condition is fulfilled, i.e. evaluates to
True. We can use this to emulate a for loop:
i = 0
while i < 5:
print(i)
(continues on next page)
2.1. Loops 25
Python for Engineers
0
1
2
3
4
This also nicely illustrates why you should prefer the for loop in this case: we can save ourselves
two lines of code and much more importantly two opportunities to make a mistake. Especially
forgetting to increment the counter and causing an infinite loop can be a very confusing bug.
Most of the time we want to loop over some collection or perform a known number of iterations,
in which case we use a for loop. There are much fewer cases where we use while loops, it is
generally in situations when it is not clear how many iterations something will take.
We might implement naive division by successive subtraction using while loops, if we didn’t know
about division we would have no way to know how often we need to subtract the divisor from the
dividend:
print(naive_divide(10, 3), 10 // 3)
print(naive_divide(7, 2), 7 // 2)
# print(naive_divide(7, -2), 7 // -2) # whoops, infinite loop
3 3
3 3
As the flow of execution becomes more complex, it can quickly get hard to reason through the
code step by step in you head. In these cases, a debugger can be very helpful to do exactly that
while also showing us the current value of variables.
In VSCode, we can execute a cell in debug mode by pressing Ctrl + Shift + Alt + Enter
or by clicking the dropdown menu next to the run button and selecting “Debug Cell”. Executing
a cell in debug mode does not do anything by itself, to get control of the execution we must first
define a breakpoint where the interpreter will stop and wait for our instructions. Breakpoints can
be added by hovering over the blank space before a line and clicking on the red dot that appears.
The red dot signifies that a breakpoint has been set. Once execution has stopped we can use the
little menu that appears to either continue execution until the next breakpoint F5 or go to the next
line by pressing F10.
2.1. Loops 26
Python for Engineers
We will make use of the debugger in the next section to get a better understanding of what the
loop related keywords break and continue do.
There are two additional keywords associated with loops: break and continue. break is used
to exit the loop prematurely, while continue is used to skip the rest of the current iteration and
continue with the next one. Let’s see how this works in practice:
1
2
3
4
5
6
7
breaking: 7 is divisible by 7
print(i)
1
3
5
Checkup Question
Consider the following piece of code:
number = 5
while number > 0:
number = number - 1 # the same as `number -= 1`
if number % 3 == 0:
continue
print(number)
if number == 2:
break
2.1. Loops 27
Python for Engineers
The correct answers are C: 2 and E: 4. The loop counts down from the number 5. Before the first
printout the number is decremented to 4 skipping 5. The iteration for 3 is skipped with continue
because 3 is divisible by 3. After reaching and printing 2, the loop is exited with break.
We only got to know the very basics of functions in the last lecture, there is a lot more to know
about them. This is also not nearly everything, we will learn even more later on in the course.
In the last lecture you already saw how functions in Python can behave just like functions in math
in that they are a deterministic mapping from a set of inputs to a set of outputs. But unlike functions
in math, this is not always true for functions in Python and other programming languages. Instead
of considering functions as mappings, we can think of them as a set of instructions or statements
which can potentially interact with the outside world and may ore may not return a value.
For example a functions return value could rely on a variable outside the function:
secret_variable = 42
# this functions return value depends on other things than the parameters,
↪ it depends on the value of `secret_variable`
def mystery_function() -> str:
return "the secret is " + str(secret_variable)
# just like with the definition when calling the function we also have to␣
↪use the brackets even without a parameter
print(mystery_function())
secret_variable = 5
print(mystery_function()) # same arguments, but different result
the secret is 42
the secret is 5
In this case, the functions return value can be different even if we call it with the same input.
The information can also flow in the other direction, meaning that instead of the outside world
effecting the functions result. The function can effect the outside world, for example by printing
something to the standard output.
# this function has no side effects, calling it multiple times makes not␣
↪difference
x = sum_of_squares(3)
x = sum_of_squares(3)
hello there
hello there
Because of these deviations from the mathematical sense, functions in programming languages
are also sometimes called procedures to differentiate them from mathematical functions. If a pro-
cedure (or colloquially a function) behaves like a mathematical function, we call it a pure function.
You should prefer writing pure functions whenever possible because they are easier to reason
about since they are deterministic and (except for execution speed) it does not matter whether
you reuse the functions return value or call it another time since it cannot affect the outside world
and must always return the same value for the same inputs.
2.2.2 Docstrings
Another thing that helps other people understand your code, especially when reading it, are doc-
strings. They are written in triple quotes at the start of the function. They should briefly explain
what the purpose of a function is, what the input parameters are and what the function returns.
We will often omit these in the lecture in the name of brevity, but you should always use them when
writing anything serious.
Args:
radius (float): The radius of the circle.
Returns:
float: The area of the circle.
"""
pi = 3.14159
return radius * radius * pi
Most code editors and IDEs will display this docstring when hovering over the function name. You
can also display them in the interpreter by using the help function:
Args:
radius (float): The radius of the circle.
Every function has its own local “scope” or “namespace”. This means that variables defined inside
a function only persist during the function call and are not visible from the outside. The same is not
true in the other direction, however. A function can access variables defined outside the function
i.e. where it was defined. If a function defines a variable that is already defined in an outer scope,
the new variable will shadow the outer variable. This means that every reference to that variables
name inside the function will refer to inner variable and the outer variable will stay untouched.
Note: Unlike in many other programming languages, such as C or Java, if statements and loops
do not create a new scope. So unlike the braces that indentations replaces in Python, a new
level of indentation does not necessarily create a new scope.
Let’s take a look at some examples to get a better idea of how this works:
# try going through this step by step with a debugger and look at the␣
↪variables
outer_variable = 42
same_name = "hello"
# the function ends automatically after the last line that is part of␣
↪the function
some_function()
print("outside: same_name:", same_name) # this refers to the outer␣
↪variable, it is unchanged
outer_variable: 42
inside: same_name: world
outside: same_name: hello
------------------------------------------------------------------------
↪---
NameError Traceback (most recent call␣
↪last)
Cell In[17], line 14
12 some_function()
13 print("outside: same_name:", same_name) # this refers to the␣
↪outer variable, it is unchanged
---> 14 print(local_variable) # this will raise an error because␣
(continues on next page)
Checkup Question
Consider the following piece of code:
x = 5
def f():
x = 10
return x
x = 15
print(f(), x)
2.3 Examples
FizzBuzz is a simple programming exercise that is based on a children’s game. The rules are as
follows:
• Count up through the whole numbers
• If a number is divisible by 3 say Fizz instead of the number
• If a number is divisible by 5 say Buzz instead of the number
• If it is divisible by both 3 and 5 say FizzBuzz
1
2
(continues on next page)
2.3. Examples 31
Python for Engineers
2.3.1 Factorial
print(factorial(0))
print(factorial(1))
print(factorial(3))
print(factorial(5))
1
1
6
120
2.3. Examples 32
Python for Engineers
⎧ 0 𝑛=0
{
𝐹𝑛 = ⎨ 1 𝑛=1
{ 𝐹
⎩ 𝑛−1 + 𝐹𝑛−2 𝑛>1
print(fib(0))
print(fib(1))
print(fib(2))
print(fib(3))
print(fib(4))
print(fib(5))
0
1
1
2
3
5
print(fib_rec(0))
print(fib_rec(1))
print(fib_rec(2))
print(fib_rec(3))
(continues on next page)
2.3. Examples 33
Python for Engineers
0
1
1
2
3
5
Heron’s algorithm is a very simple algorithm to get increasingly precise approximations for the
square root of a number.
A guess for the square root 𝑥𝑛 of the number 𝑎 is improved with the following computation:
𝑥𝑛 + 𝑎/𝑥𝑛
𝑥𝑛+1 =
2
Write a function to implement Heron’s algorithm, stop after the approximation is correct to at least
6 decimal places.
print(2**0.5)
print(heron_sqrt(2))
1.4142135623730951
1.4142135623746899
2.4 Summary
2.4. Summary 34
CHAPTER
THREE
But we are getting ahead of ourselves, let’s just start with lists, which is probably the most common
data structure.
3.1 Lists
If we want to store multiple elements in one place, we can use a list. Similar to lists in real life, a
list is an ordered collection of things. Here ordered means that each element has a clearly defined
position and two lists are only equal if they contain the same elements in the same order. This is
as opposed to sets which will introduce later. A list is created with square brackets.
35
Python for Engineers
[1, 2, 3]
[42, 'hello', 'world', [1, 2, 3], [4, 5, 6]]
<class 'list'>
3
5
We can access the individual elements in a list using the [] operator and the index of the element
we want to access. Note that the indices start at 0, so if we want to get the second element of a
list, we have to use the index 1.
print(list_1[1])
This also works for strings, because strings are also kind of like lists of characters.
The square bracket operator generally works for all list-like things in Python. We will see more of
these later.
print("Hello World"[2])
It is also possible to index the elements from the end of the list by using negative indices.
3
2
Attempting to get an item at an index that does not exist will raise an IndexError.
------------------------------------------------------------------------
↪---
IndexError Traceback (most recent call␣
↪last)
3.1. Lists 36
Python for Engineers
list_3 = [4, 5, 6]
list_4 = list_1 + list_3
print(list_4)
list_5 = list_1 * 5 # and multiplication as repeated addition also works
print(list_5)
[1, 2, 3, 4, 5, 6]
[1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3]
hello Alice
hello Bob
hello Charlie
But there is a much cleaner way to do this, we can directly “iterate” through every item using a for
loop:
hello Alice
hello Bob
hello Charlie
There are a few functions associated with lists that you should know about.
my_list = [2, 4, 6, 8]
my_list.append(1)
my_list.insert(1, 12) # new index of the element, and the element itself
print(my_list)
[2, 12, 4, 6, 8, 1]
my_list = [2, 4, 6, 8]
3.1. Lists 37
Python for Engineers
8
[2, 4, 6]
6
[2, 4]
Be careful when inserting or removing elements at arbitrary positions in the list, this is a com-
paratively expensive operation as opposed to appending to or popping from the back. After an
insertion or deletion all elements after that position will need to be moved one position up or down
resulting in len(list)/2 move operations on average. Appending or popping from the back is
(for the most part) independent of the size of the list and therefore very fast.
chaos_list = [3, 4, 1, 2, 5]
[1, 2, 3, 4, 5]
[1, 2, 3, 4, 5]
[5, 4, 3, 2, 1]
[3, 2, 1]
In addition to single-element access, Python also offers the slicing operator [:] to easily get
sections of a list. The operator takes in two arguments: the start and end index of the slice. The
start index is inclusive, and the end index is exclusive. Both arguments are optional and default
to the start and end of the list, respectively.
other_list = ["I", "like", "lists", "in", "pthon", "!"]
print(other_list)
print(other_list[2:5])
# if the start or end is not specified, its starts or end with the list
print(other_list[:3])
print(other_list[4:])
# this can also be used with negative indices, this leaves out the last␣
↪element of the list
print(other_list[:-1])
3.1. Lists 38
Python for Engineers
Unlike regular indexing, slicing does not raise an IndexError if the indices are out of bounds.
Instead, the resulting slice only contains the elements that are in bounds.
There is also a variation on the slicing operator with two colons [::]. It lets us specify a step size
(just like with range()).
['like', 'in']
['!', 'python', 'in', 'lists', 'like', 'I']
Often we need to work with multidimensional lists, like with matrices or tables. Python does not
have multidimensional arrays, but we can just put lists in lists to make them ourselves.
list_2d = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
[10, 11, 12],
]
print(list_2d)
# indexing is done from the outer most list to the inner most list
print(list_2d[2]) # access third sublist
print(list_2d[2][1]) # access second element of third sublist
# this way the "coordinates" in the 2d list are addressed in reverse␣
↪order list_2d[y][x]
Later we will see how NumPy makes working with such multidimensional data structures much
easier, especially mathematical operations.
Checkup Question
Consider the following code:
3.1. Lists 39
Python for Engineers
my_list = [1, 2, 3]
my_list.append(5)
print(my_list[3] + my_list[-3])
That was quite a lot of things, let’s see some of these in action to get an idea how they are used
in practice. We will write three functions: beam_deflection_list() which is supposed to
calculate the beam deflection at multiple points of a beam, is_sorted() which checks if a list
is sorted and fib_list() which generates the first N Fibonacci numbers.
# from last time ...
def beam_deflection(position: float, force: float, length: float) ->␣
↪float:
return force / 6 * position**2 * (3 * length - position)
3.1. Lists 40
Python for Engineers
True
False
True
series = [0, 1]
for _ in range(n - 1): # use an underscore to make it clear that the␣
↪loop variable is not used
[Link](series[-1] + series[-2])
return series
print(fib_list(0))
print(fib_list(3))
print(fib_list(10))
print(fib_list(20))
[0]
[0, 1, 1, 2]
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597,
↪ 2584, 4181, 6765]
3.2 Tuples
Tuples are in many ways very similar to lists, but despite their functional similarities, they are con-
ceptually very different. We create a tuple simply by separating the elements with a comma. Often
you will also see them enclosed in parentheses to prevent other syntax from taking precedence.
<class 'tuple'>
<class 'tuple'>
Like lists, we can also read the elements with the [] operator. But unlike lists we can not write
them with the [] operator or append elements to them. This is because tuples are immutable,
they can not be changed after they are created.
3.2. Tuples 41
Python for Engineers
print(my_tuple[0]) # hello
my_tuple[0] = "goodbye" # this will raise an error, tuples are immutable
my_tuple.append("!") # so will this
hello
------------------------------------------------------------------------
↪---
TypeError Traceback (most recent call␣
↪last)
So why make this slightly awkward restricted version of a list, why not just use lists? The difference
lies in how you are supposed to use them: You should use tuples for possibly inhomogeneous
collections (multiple types) of fixed size, where it does not make sense to change one without
changing the other. And use lists for collections of arbitrary size made up of similar items that
may change.
A function taking a person tuple can reasonably expect, that it is always made up of the str
name and int birth year, but a function taking a list of rocks should expect any number of rocks
as strings. This is also reflected in the type annotation syntax.
list[str]
Tuples also implement an ordering so you can use <, >, <= and >= to compare and sort them.
The order is determined from the order of their elements, comparing the next pair of elements if
3.2. Tuples 42
Python for Engineers
print((1, 3) > (1, 2)) # first pair of numbers tie, but 3 > 2 in the␣
↪second pair
True
False
[(1, 2), (1, 3), (2, 1)]
Checkup Question
Consider the following code:
my_tuple = (1, 2, 3)
my_tuple.pop(0)
print(my_tuple)
3.3 Dictionaries
Dictionaries are a critical data structure that powers a lot of the underlying language. Similarly to
real world dictionaries, they map a discrete set of inputs which we call keys to associated outputs
which we call values. Most importantly, they do this very efficiently, so a dictionary is not just about
the association between the keys and values but also the fact that looking up a value by its key is
much faster than a naive implementation using a list of pairs.
"hello": "hallo",
"world": "welt",
}
print(type(en_to_de)) # dict
print(en_to_de["hello"], en_to_de["world"])
<class 'dict'>
hallo welt
We can check if a key is in a dictionary using the in operator, assigning to a key will create it if it
does not exist.
3.3. Dictionaries 43
Python for Engineers
print(char_counts)
print(Counter("hello world!")) # the correct way to do it
{'h': 1, 'e': 1, 'l': 3, 'o': 2, ' ': 1, 'w': 1, 'r': 1, 'd': 1, '!': 1}
Counter({'l': 3, 'o': 2, 'h': 1, 'e': 1, ' ': 1, 'w': 1, 'r': 1, 'd': 1,
↪ '!': 1})
Iterating through a dictionary will yield its keys, we can iterate through the keys and values simul-
taneously using the items() method which will yield (key, value) tuples.
# DONT DO THIS
for char in char_counts:
print(f"Character '{char}' occurs {char_counts[char]} times in the␣
↪sentence")
print()
# DO THIS INSTEAD if you need both the key and the value
for char, count in char_counts.items():
print(f"Character '{char}' occurs {count} times in the sentence")
3.3. Dictionaries 44
Python for Engineers
Checkup Question
Consider the following code:
3.3. Dictionaries 45
Python for Engineers
3.4 Sets
Sets are just like dictionaries (mostly) but without the values, they only have keys. They are pretty
much as mathematicians would define them, an unordered collection of unique elements.
<class 'set'>
<class 'dict'>
{'B', 'C', 'A'} 3
False
True
{'B', 'C', 'D', 'A'}
{'B', 'C', 'D', 'A'}
{'C', 'D', 'A'}
Unordered means that elements do not have a clearly defined position and two sets are considered
the same if they have the same elements as opposed to lists where this is not the case.
True
False
set_a = {1, 2, 3}
set_b = {3, 4, 5}
{1, 2, 3, 4, 5}
{3}
{1, 2}
{1, 2, 4, 5}
Checkup Question
Consider the following code:
3.4. Sets 46
Python for Engineers
my_set = {1, 2, 3}
my_set.add(2)
my_set.remove(2)
print(my_set)
Just like dictionaries, operations like test for membership and adding elements are much faster
than a naive implementation using lists.
%%time
# takes multiple seconds
even_count = 0
for num in random_numbers:
if num in even_numbers_list:
even_count += 1
print(even_count)
479
CPU times: user 6.02 s, sys: 14 ms, total: 6.04 s
Wall time: 5.64 s
%%time
# takes less than a tenth of a second
even_count = 0
for num in random_numbers:
if num in even_numbers_set:
even_count += 1
print(even_count)
479
CPU times: user 661 μs, sys: 0 ns, total: 661 μs
Wall time: 602 μs
Last lecture we saw that the recursive implementation for Fibonacci numbers was very slow and
limited to small inputs because it needed to recalculate the lower Fibonacci an exponentially in-
creasing number of times.
9227465
To get around this recomputing problem we can cache the result of previously computed Fibonacci
numbers in a dictionary. This way we can simply look up the previous two Fibonacci numbers
instead of recomputing them, thereby breaking the exponential growth of the number of recursive
calls.
fib_cache = {}
354224848179261915075
This pattern, where we use cache the result of subproblems to speed up the calculation of larger
problems, has a name: it is called dynamic programming. If you ever actually need caching you
should not implement it yourself, instead you should use Python’s [Link] decorator
instead.
print(cached_fib(100))
354224848179261915075
3.7 Summary
In this lecture we learned about Python’s four basic data structures: list, tuple, dict and set
and things we can do with them. As a general rule of thumb, here is when to use which:
• Use a list for similar items (type) of arbitrary size whose order matters, or checking for mem-
bership is not important.
• Use a tuple bundling related of different data types together, the size should usually be the
same from one instance to the next.
• Use dictionaries for efficient mapping from inputs to outputs of arbitrary size. If you have
a collection of dicts that all define the same keys you should probably use a namedtu-
ple or a dataclass instead, more on that in the lecture on classes and object-oriented
programming.
• Use a set to keep track of the existence of unique elements or to perform set operations.
3.7. Summary 49
CHAPTER
FOUR
To make use of external libraries, we first need to install them. We can do this by invoking Pythons
package manager pip in your OS’s terminal (bash for Linux or PowerShell for Windows):
You can also do this directly from the notebook by using a magic command:
...
This has the advantage that you do not need to make sure to install the package in the correct
environment since it will be installed in the same environment as the notebook is running in.
But beware where you install packages! In most cases you should not install packages for a
project into you main environment. This can quickly create version conflicts and might even break
some tools. Instead, you should create virtual environments for each project and install only the
packages you need for that project into that environment. We will talk about virtual environments
and how to manage them in a later lecture.
50
Python for Engineers
4.2 NumPy
As the name suggests, NumPy is a library for numerical computing in Python. We import it, using
an import statement. We can also import it under a short alias to avoid overly verbose code. Most
common libraries have an agreed upon alias that everyone uses. For NumPy, this is np. So we
import it like this:
The . (dot) operator in Python and other programming languages is kind of similar to the “/” or
“" in your file systems. It is used to access members functions or variables of an object, similar
to how you would access a file in a folder. You can imagine NumPy as a folder and the array
function as a file in that folder. We can access the array function by typing [Link] (or
[Link] if you import NumPy as np). And similarly to how folders can also be nested in other
folders, objects can also be nested in other objects. NumPy, for example, contains a submodule
linalg which contains the function inv which can be accessed by [Link].
It is also possible to only import specific parts of a package using the from keyword. We will go
into more details about modules and importing later.
If you have experience working in a command line environment you might know that you can use
something like dir or ls to list the contents of a folder. In Python, we can use the dir function
to do the same.
my_list = [1, 2, 3]
# dir(my_list) # here we can find the append method we talked about last␣
↪lecture
['__subclasshook__',
'append',
'clear',
'copy',
'count',
'extend',
'index',
'insert',
'pop',
'remove',
'reverse',
'sort']
4.2. NumPy 51
Python for Engineers
536
['False_',
'ScalarType',
'True_',
'_CopyMode',
'_NoValue',
'__NUMPY_SETUP__',
'__all__',
'__array_api_version__',
'__array_namespace_info__',
'__builtins__']
The bread and butter of NumPy is the NumPy array, it is different from Python’s list in that it
supports arithmetical operations and behaves more like a mathematical vector or matrix. We can
create an ndarray from a list by using the array function. While these arrays offer a lot of the
features that lists do, the two should not and can not be used interchangeably. Some notable
differences are that NumPy arrays are homogeneous, meaning that all elements must be of the
same type, their contents are stored in a contiguous block of memory (important for performance),
and they are fixed size, meaning we cannot append or remove elements from them.
[1 2 3]
<class '[Link]'>
int64
As the name ndarray suggests, we can also create higher dimensional arrays. We do this by
passing in a nested list.
list_2d = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]]
np_2d = [Link](list_2d)
print(np_2d)
print(np_2d.shape)
print(type(np_2d.shape), np_2d.shape[1]) # shape is tuple, access␣
↪elements with []
print("")
[[ 1 2 3]
[ 4 5 6]
[ 7 8 9]
[10 11 12]]
(4, 3)
<class 'tuple'> 3
4.2. NumPy 52
Python for Engineers
Unlike nested lists, the “sub-arrays” of a NumPy array must all have the same length.
[Link]([[1, 2], [3]]) # error: sub lists have different lengths
------------------------------------------------------------------------
↪---
ValueError Traceback (most recent call␣
↪last)
Cell In[8], line 1
----> 1 [Link]([[1, 2], [3]]) # error: sub lists have different␣
↪lengths
These homogeneity restrictions (while necessary for the performance benefits) also benefits us in
other ways like allowing more flexible slicing.
print(np_2d) # cleaner formatting
print(np_2d[2, 1]) # easier access [y, x] instead of [y][x]
print(np_2d[1:3, 1:3]) # access a rectangular subsection
print(np_2d[1]) # access second row
print(np_2d[:, 1]) # access second column
[[ 1 2 3]
[ 4 5 6]
[ 7 8 9]
[10 11 12]]
8
[[5 6]
[8 9]]
[4 5 6]
[ 2 5 8 11]
(2, 2, 2)
A lot of the time when working with numerical data, we want to apply the same operation to all
elements of an array or perform element-wise operations between two arrays. The homogeneous
nature of ndarrays not only makes this very performant, it also makes it very easy to read and
write. Most arithmetic operations that we can perform on single numbers (or pairs of numbers)
we can also perform on arrays without explicitly looping over them.
# scalar operations on an array
array2 = array * 2 + 2
print(array2)
(continues on next page)
4.2. NumPy 53
Python for Engineers
[4 6 8]
[ 4 12 24]
When doing element wise operations, the shapes must be compatible. This usually means that
they must either have the same length or one of them must be a scalar in which case scalar will be
“broadcasted” to the shape of the other array. Understanding these broadcasting rules for higher
dimensional arrays can be a bit confusing at first, but can make certain operations very convenient
to express.
------------------------------------------------------------------------
↪---
ValueError Traceback (most recent call␣
↪last)
Cell In[12], line 1
----> 1 [Link]([1, 2, 3]) + [Link]([4, 5]) # incompatible shapes
Theses implicit vectorized operations mean that all function that only use arithmetic operations
naturally work on arrays as well.
print(add_and_square(2, 5))
print(add_and_square([Link]([1, 2, 3]), [Link]([4, 5, 6]))) # also␣
↪works with arrays
49
[25 49 81]
NumPy also comes with all the usual mathematical constants and functions, like the square root,
power, and trigonometric functions. If they are used on a NumPy array (or any other iterable), the
function is applied to each element.
squares = vec**2
print(squares)
print([Link](squares))
print([Link]([0, [Link] / 2]))
4.2. NumPy 54
Python for Engineers
[ 1 4 9 16]
[1. 2. 3. 4.]
[0. 1.]
There are a few functions that create special arrays that are commonly needed for doing vec-
torized computations. The most common ones are linspace, arange, zeros, ones, and
[Link].
# arange is similar but instead of specifying how many values are in the␣
↪array, the distance between them is specified
print([Link](1, 10, 1)) # almost equal to [Link](range(1,10,1))
print([Link](1, 10, 0.5))
[1 2 3 4 5 6 7 8 9]
[1. 1.5 2. 2.5 3. 3.5 4. 4.5 5. 5.5 6. 6.5 7. 7.5 8. 8.5 9. 9.
↪5]
# zeros and ones create arrays of the specified size made up of 0s and 1s␣
↪respectively
print([Link](5))
print([Link](10))
# the shape can also be a tuple to create multi-dimensional arrays
print([Link]((2, 3)))
[0. 0. 0. 0. 0.]
[1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]
[[1. 1. 1.]
[1. 1. 1.]]
[0.38528082 0.58261164 0.24707753 0.41324455 0.89771061 0.35981933
0.78797988 0.48444789]
[2.45060753 4.63820712 0.14009443 0.69574187]
4.2. NumPy 55
Python for Engineers
Checkup Question
Consider the following code:
a = [Link]([1, 2])
b = [Link]([3, 4])
print(a + b)
NumPy also implements a lot of linear algebra operations. There is no special type for matrices or
vectors in NumPy, it is the operation that defines whether an ndarray is interpreted as a matrix
/ vector or something list-like.
To multiply two matrices or vectors together, we could use the [Link](A, B) function. But
just like we can use ** instead of pow() we can also use the @ operator instead of [Link]()
which makes the code a lot more readable. When doing a matrix vector operation, NumPy will
automatically interpret the 1D array as a 2D array with one row or column, depending on the order
of the operands. This also means that multiplying two vectors, using the matrix multiplication
operator, will do the same as the dot product.
A=
[[ 1 2]
[-1 4]]
B=
[5 6]
A*B=
[17 19]
B*A=
[-1 34]
4.2. NumPy 56
Python for Engineers
F=
[[1 1]
[1 0]]
F*F=
[[2 1]
[1 1]]
F^3=
[[3 2]
[2 1]]
F^5=
[[8 5]
[5 3]]
And of course we can also easily invert matrices and solve linear systems of equations.
# solve a linear system of equations in the form Ax = B
print([Link](A) @ B) # using inverse
print([Link](A, B)) # using solve
[1.33333333 1.83333333]
[1.33333333 1.83333333]
You should generally prefer [Link] over multiplying the inverse, as it is more nu-
merically stable and faster.
NumPy has functions for most other linear algebra operations you know about, like the determi-
nant, inverse, transpose, eigenvalues / eigenvectors or the singular value decomposition.
print([Link](A)) # determinant of a
print([Link](A)) # inverse of a
print([Link](A))
print(A.T) # if you matrix is already a numpy array should use .T to get␣
↪a transposed view
6.0
[[ 0.66666667 -0.33333333]
[ 0.16666667 0.16666667]]
[[ 1 -1]
[ 2 4]]
[[ 1 -1]
[ 2 4]]
4.2. NumPy 57
Python for Engineers
[2. 3.]
[3. 5.]
EigResult(eigenvalues=array([2., 3.]), eigenvectors=array([[-0.89442719,
↪ -0.70710678],
[-0.4472136 , -0.70710678]]))
[-0.89442719 -0.4472136 ] [-0.89442719 -0.4472136 ]
Checkup Question
Consider the following piece of code:
4.2. NumPy 58
Python for Engineers
4.2.6 Examples
Let’s look at a few examples to see how we might use the features we just learned about in prac-
tice.
Write a function that calculates the first 𝑛 square numbers and returns them as a NumPy array
without using a loop.
print(square_numbers(10))
[ 1 4 9 16 25 36 49 64 81 100]
In the first lecture, we wrote a function to calculate the beam deflection at a given point for a given
load and beam length:
Exploit NumPy’s vectorized operations to compute the deflection at 10 equally spaced points
along a beam of length = 5 with a load of force = 10.
length = 5
positions = [Link](0, length, 10)
deflections = beam_deflection(positions, 10, length)
deflections
Write a function that calculates the 𝑛-th Fibonacci number repeated multiplication of the Fibonacci
matrix F:
1 1
F=[ ]
1 0
4.2. NumPy 59
Python for Engineers
return res[0, 1]
# or using matrix power
# return [Link].matrix_power(F, n)[0, 1]
print(fib_mat(0))
print(fib_mat(1))
print(fib_mat(2))
print(fib_mat(3))
print(fib_mat(25))
0
1
1
2
75025
Statics
We can derive two equations by considering the force equilibrium conditions at the center node:
!
Σ𝐹𝑥 = sin(−45°) ⋅ 𝑆𝐿 + sin(60°) ⋅ 𝑆𝑅 = 0
!
Σ𝐹𝑦 = cos(−45°) ⋅ 𝑆𝐿 + cos(60°) ⋅ 𝑆𝑅 − 𝐹 = 0
Assuming that 𝐹 = 𝑚𝑔 = 10 reformulate the equation in matrix form and determine 𝑆𝐿 and 𝑆𝑅
using NumPy.
A = [Link]([
[[Link]([Link](-45)), [Link]([Link](60))],
[[Link]([Link](-45)), [Link]([Link](60))],
])
b = [Link]([0, 10])
S_L, S_R = [Link](A, b)
print(f"{S_L = :.3f}, {S_R = :.3f}")
4.2. NumPy 60
Python for Engineers
Finally, let’s try to use some more in-depth mathematics knowledge to solve the problem with
fewer matrix multiplication operations than our simple solution. Some computations, like expo-
nentiation, can be performed more efficiently in a basis of eigenvectors.
M𝑛 = V ⋅ D𝑛 ⋅ V−1
Where D is a diagonal matrix with the eigenvalues of M and V a matrix whose columns are made
up of the respective eigenvectors of M.
Remember that the powers of a diagonal matrix can be computed by simply computing the powers
of the diagonal elements.
V = eigenvectors
V_inv = [Link](V)
D = [Link](eigenvalues)
# this is using the same math as the exercise from the previous lecture
def fib_eig(n: int) -> int:
# round to account for floating point inaccuracies
return round((V @ [Link](eigenvalues**n) @ V_inv)[0, 1])
print(fib_eig(0))
print(fib_eig(1))
print(fib_eig(2))
print(fib_eig(3))
print(fib_eig(25))
[ 1.61803399 -0.61803399]
[[ 0.85065081 -0.52573111]
[ 0.52573111 0.85065081]]
0
1
1
2
75025
4.2. NumPy 61
Python for Engineers
4.3 Matplotlib
Just like with NumPy (and many other libraries), the primary use of the library is in the name. In
the case of Matplotlib it is creating all kinds of plots.
[Link]("[Link]")
Plotting in Matplotlib is very similar to how you would plot with pen and paper. Simply give the plot
function a series of x and y values, and it will draw and connect them with lines.
There are a lot of ways to customize plots, here are a few examples. Check out the cheat sheet
for an overview of the most important kind plots available and how to customize them.
fig, ax = [Link]()
4.3. Matplotlib 62
Python for Engineers
ax.set_xlabel("x [s]")
ax.set_ylabel("y [m]")
ax.set_title("Example of Matplotlib")
[Link]("[Link]") # save the figure to a {png, svg, pdf, ...}␣
↪file
# [Link](["a","b","c","d"]) # override the labels set in the plot␣
↪commands
Matplotlib also supports a lot of other types of plots, like bar charts, box plots, vector plots, and
many more. If you can think of it, it’s probably already implemented. If you need to use them, you
will find lots of examples and help in the documentation online.
4.3. Matplotlib 63
Python for Engineers
4.3.2 Subplots
Sometimes we want to plot multiple things in one figure instead of having them each in their own
figure. For this, we can use [Link](), the function returns the “handles” for the figure
and the different plots in a list. Instead of calling the plot functions with plt, we call them on the
handles of the different plots called axes, you can also work this way if you only have a single
figure.
When working with axes instead of plt, some commands change slightly:
[Link]() -> ax.set_title()
[Link]() -> ax.set_xlabel()
[Link]() -> ax.set_xlim()
... -> ...
If you are unsure, just Google it, look at the cheat sheet or search the Matplotlib documentation.
axes[0, 0].set_xlabel("xlabel")
axes[1, 0].set_title("axes title")
[Link]("figure title");
4.3. Matplotlib 64
Python for Engineers
4.3.3 Examples
It is time for more examples. Plot the following functions in the range [−3, 3] and properly annotate
the plot:
1
𝑓1 (𝑥) = 2𝑥 − 𝑥3
4
𝑥
𝑓2 (𝑥) = sin(𝑒 )
1
𝑓3 (𝑥) =
𝑥
Make sure the plot looks correct and the region shown is sensible.
xs = [Link](-3, 3, 200)
f2s = [Link]([Link](xs))
[Link](xs, f2s, label="$f_2(x)$")
f3s = 1 / xs
f3s[len(xs) // 2] = [Link]
# a nan in the y data will lead to a break in the lines
[Link](xs, f3s, label="$f_3(x)$")
# you can also produce the nan by using an odd number of points which␣
↪will contain a 0
# the division by zero will then also lead to a nan in the correct place
[Link]()
[Link](-5, 5) # we can also override the automatic range
[Link]("$x$")
[Link]("$f_n(x)$");
And for something slightly less “made-up”, plot the deflection of the beam from the previous sec-
tion along its length and annotate the plot. The force is applied in the negative y-direction, so the
4.3. Matplotlib 65
Python for Engineers
Checkup Question
Consider the following code:
xs = [Link](-2, 2, 8)
[Link](xs, xs**2)
[Link](xs, 1/xs)
4.3. Matplotlib 66
Python for Engineers
4.4 Summary
4.4. Summary 67
CHAPTER
FIVE
As a diligent student, you have always taken notes in every class, and because you really like the
Markdown format, you have made all your notes in Markdown (the same markup language that is
used for these Jupyter notebooks). Unfortunately you are not that good at keeping things in order,
and you always have trouble finding the files where your took notes. So to get things in order, we
are going to write a program that will search for all markdown files, look for all headings in these
files, and then make a table of contents file that lists all the headings and links to them. To make it
a bit more interesting, we only want to list top and second level headings in the table of contents.
Here is an example to better illustrate what we want to achieve:
This is what the file system looks like:
notes
├── [Link]
│ > # Important Stuff
│ > ## How to Sign up for Exams
├── math
│ └── [Link]
│ > # My Math Notes
│ > not part of a heading
│ > #also not part of a heading
│ >
│ > ## Linear Algebra
│ > ### Matrices
│ > ## Differential Equations
└── mechanics
└── mechanics-1
├── [Link]
│ > # Mechanics 1 Exercises
└── [Link]
> # Mechanics 1
> ## Statics
And this is what the table of contents file should look like:
68
Python for Engineers
# Table of Contents
- notes/
- [[Link]](<notes/[Link]>)
- [Important Stuff](<notes/[Link]#important-stuff>)
- [How to Sign up for Exams](<notes/[Link]#how-to-sign-up-
↪for-exams>)
- math/
- [[Link]](<notes/math/[Link]>)
- [My Math Notes](<notes/math/[Link]#my-math-notes>)
- [Linear Algebra](<notes/math/[Link]#linear-algebra>)
- [Differential Equations](<notes/math/[Link]#differential-
↪equations>)
- mechanics/
- mechanics 1/
- [[Link]](<notes/mechanics/mechanics 1/[Link]>)
- [Exercises](<notes/mechanics/mechanics 1/[Link]#exercises>
↪)
- [mechanics [Link]](<notes/mechanics/mechanics 1/mechanics [Link]>)
- [Mechanics 1](<notes/mechanics/mechanics 1/mechanics [Link]
↪#mechanics-1>)
- [Statics](<notes/mechanics/mechanics 1/mechanics [Link]#statics>
↪)
The file system on most modern computers is what we could call a hierarchical file system. And as
the name suggests this means there is some hierarchy to everything. More precisely, this means
that the files don’t just lay around in some big metaphorical pile of files, but they are structure in
directories and subdirectories. To uniquely refer to a file, we specify all the steps we need to take
to get to it, we call these steps the “path” to the file. Take this path for example:
C:\Users\Alice\Documents\[Link]
It tells us that we need to start at the C drive, then go to the Users directory, the Alice directory,
the Documents directory where we will find a file called [Link]. The individual steps of the
path are separated by a backslash \ so if we were given this path as a string and wanted to get
the individual components we could simply split it at the backslashes:
But now when we go to a different operating system like Linux, the equivalent path would look
something like this:
/home/alice/documents/[Link]
There is a different path separator and the path starts at the root directory / instead of a drive
letter like on Windows. So previous example code would not work on Linux. Because of this and
many other reasons you should not directly work with paths as string and manually manipulate
them. To write robust platform independent code, we can use the pathlib module that is part of
Python’s standard library. It provides a class called Path that implements many useful methods
related to working with paths.
We can use these tools to create a path to another file in the same directory:
# create the new path by going to the parent and then adding a string␣
↪onto the path using the / (division) operator
# # this will combine the path with the string using the correct␣
↪separator for the platform
todo_path = Path(todo_path) # ignore this... it is necessary because it␣
↪was a pure path
other_file = todo_path.parent / "todo_backup.txt"
print(other_file)
# a path does not need to point to an existing file
print(other_file.exists()) # check if the file exists
C:/Users/Alice/Documents/todo_backup.txt
False
Paths do not need to start at the root of the file system. They can also be relative to something like
the current working directory. The current working directory is usually where program is started
from or in this case it is the directory where VS Code is started from. We can get a path to the
current working directory by using the static [Link]() method.
PosixPath('/absolute/path/to/working/directory')
The notes directory is in the current working directory, so we can also use a relative path to refer
to it.
relative_path = Path("notes")
print(relative_path.resolve()) # resolve the relative path to an␣
↪absolute path # BUILD COMMENT
# this is equivalent to
[Link]() / relative_path # we can also concatenate a path to another␣
↪path
PosixPath('/absolute/path/to/working/directory/notes')
If a path refers to a directory we can list all children of that directory with the iterdir method.
notes_dir = Path("notes")
print(notes_dir.is_dir()) # check if the path is a directory
print([Link]())
print(list(notes_dir.iterdir()))
True
/absolute/path/to/working/directory
[PosixPath('notes/math'), PosixPath('notes/mechanics'), PosixPath(
↪'notes/[Link]')]
To create the table of contents we will need to search for markdown files in the specified directory
and its subdirectories and sub subdirectories and so on. This is called recursively traversing the
file system which is also a good hint at how we can achieve this. Let’s write a function that takes a
path to a directory and lists its children, recursively calling itself if one of the children is a directory.
recursive_traverse(notes_dir)
notes/mechanics/mechanics 1/[Link]
notes/mechanics/mechanics 1/mechanics [Link]
(continues on next page)
Since this kind of thing is such a common pattern, there are already solutions for this in the stan-
dard library. However, it is still a useful concept to know for when these functions don’t serve your
specific needs.
notes/[Link]
notes/math/[Link]
notes/mechanics/mechanics 1/[Link]
notes/mechanics/mechanics 1/mechanics [Link]
The Path class and the pathlib module have many more useful methods, if you need something
it probably exists, just look at the documentation.
Checkup Question
Consider the following code snippet:
To find the headings in the markdown files, we need to be able to look at their contents, meaning
we need to read them. We can do this by using the open function. It takes a path and gives us a
file object that we can read from. The object remembers our position in the file for the next read
operation.
# My Math Notes
not part of a heading
#also not part of a heading
## Linear Algebra
### Matrices
## Differential Equations
# My Math Notes
By default, files are opened in read mode, if we want to write to a file we need to open it for writing
by specifying the mode as 'w' for write, by default this will create the file if it does not exist. There
are also other modes like 'a' for appending to a file or 'r+' for reading and writing.
table_of_contests_file = Path("[Link]")
print("file exists", table_of_contests_file.exists())
The open function can treat a file as one of two different kinds: as a text file which it assumes by
default or as a binary file. Every file is technically a binary file, but files that only contain printable
• B:
with open("[Link]") as file:
lines = [Link]()
• C:
file = open("[Link]")
lines = []
for line in file:
[Link](line)
• D:
lines = open("[Link]", "r").read().splitlines()
The correct answer is B. The with statement ensures that the file is properly closed after its suite
finishes, even if an exception is raised. There is also no need to use a for loop to read the lines
as the method readlines already takes care of this.
Finally, to be able to parse the contents of the files and generate the table of contents file, we still
need to learn a few things about working with strings. For example, we will need to find out if a
given line is level one or two heading.
For a line to be considered a heading it needs to start with a variable number of # characters
depending on the level of the heading, then at least one space and finally the actual heading text.
with open(markdown_file) as f:
lines = [Link]()
raw_headings: list[str] = []
for line in lines:
# strip removes leading and trailing whitespace, '\n' is whitespace
print(f'"{[Link]()}"', end="") # end='' prevents print from␣
↪adding a newline
if line[:2] == "# ": # are the first two characters '# '?
# easy to mess up, will always be false if the
# slice length and the string length are not the same
print(" -> top level heading")
raw_headings.append(line)
elif [Link]("## "): # use startswith instead of slicing,␣
(continues on next page)
Now that we identified what is and is not a heading, we still need to isolate the heading part. For
this we need to split the string on the first whitespace character. We also need to get in into the
correct form for a Markdown link, which means replacing the spaces with dashes and converting
the whole thing to lowercase. So given the raw heading string ## My Math Notes we want
somehow turn it into my-math-notes and determine that it is a level two heading.
Using these basic string operations to check if a string fulfills specific criteria and then even more
operations to isolate certain parts can quickly become very complex and lead to hard to maintain
and most likely buggy code. If you encounter a situation like this, you should consider using
regular expressions. Regular expressions or regex are a special purpose language for exactly
this purpose and all major programming languages have support for them. You are not expected
to learn regex from this lecture, but you should know that it is a thing. To illustrate the basic
principles of regular expressions we will redo our example using regular expressions.
Regular expression can get very hard to read, so tools like regex101 and RegExr can be very
helpful in understanding, writing and testing them.
import re
with open(Path("notes/math/[Link]")) as f:
whole_file = [Link]()
# iterate through all matches assigning the two capture groups to␣
↪`prefix` and `content`
for prefix, content in [Link](pattern, whole_file):
print(prefix, content)
# My Math Notes
## Linear Algebra
## Differential Equations
Play around with this regular expression and step through an interactive explanation on regex101.
Another useful tool for working with strings that you should know about is string interpolation and
f-strings.
Whether we want to print out values for debug purposes or write them to a file in a structured way,
we often need to transform our numeric or other data into string form. While we could do all of this
by converting the individual to strings and then concatenating everything together with even more
strings, there is a much more elegant solution: string interpolation. There are multiple ways to
do this in Python, but we will be using f-strings. Like the name suggests, f-strings start with an f
before the quotes. Within the f-string, we can use curly braces to embed variables or expressions
into the string.
x = 2
f"i am a format string showing the number {x}"
# f-strings also allow format specifiers that determine how the value is␣
↪converted to a string
print(f"floats: {[Link]}, with less decimals {[Link]:.4f}")
print(f"and in scientific notation: {[Link]:.2e}")
print(f"{42:04d}") # pad left with zeros
print(f"0x{42:x}, 0x{42:X} 0b{42:b}") # hexadecimals and binary
print(f"{x = }, {x**2 = }") # print an expression and its value (the␣
↪spaces matter)
print(f"current date and time: {[Link]():%Y-%m-%d %H:%M:%S}") #␣
↪format datetime objects
# and much more
Checkup Question
Consider the following code:
Now that we figured out all the subproblems that we needed to solve our task, let’s put it all together
and write the program that generates the table of contents file. Starting with a docstring that
describes what the function should do.
with all the headings and second level headings found in the files␣
↪and create hyperlinks to them.
Args:
root_dir (Path): The root directory to search for markdown files
output_file (Path): The file to write the table of contents to
Returns:
Markdown: The table of contents as a :class:`Markdown` object
"""
generate_table_of_contents(Path("notes"), Path("[Link]"))
Pretty(filename="[Link]")
# Table of Contents
- notes/
- [[Link]](<notes/[Link]>)
- [Important Stuff](<notes/[Link]#important-stuff>)
- [How to Sign up for Exams](<notes/[Link]#how-to-sign-
↪up-for-exams>)
- mechanics/
- mechanics 1/
- [[Link]](<notes/mechanics/mechanics 1/[Link]>)
- [Exercises](<notes/mechanics/mechanics 1/[Link]
↪#exercises>)
- [mechanics [Link]](<notes/mechanics/mechanics 1/mechanics [Link]>)
- [Mechanics 1](<notes/mechanics/mechanics 1/mechanics [Link]
↪#mechanics-1>)
- [Statics](<notes/mechanics/mechanics 1/mechanics [Link]
↪#statics>)
- math/
- [[Link]](<notes/math/[Link]>)
- [My Math Notes](<notes/math/[Link]#my-math-notes>)
- [Linear Algebra](<notes/math/[Link]#linear-algebra>)
- [Differential Equations](<notes/math/[Link]#differential-
↪equations>)
To make sure that the Markdown is correct, we can also display the rendered Markdown:
# ... which will lead to problems when generating a pdf with latex
# Markdown(filename="[Link]") # rendered markdown
5.7 Summary
5.7. Summary 79
CHAPTER
SIX
OBJECT-ORIENTED PROGRAMMING
We will do this by creating a class that should model complex numbers. Classes are defined using
the class keyword followed by the name of the class. Adhering to the PEP 8 style guide, we write
class names in PascalCase, e.g. capitalize every word and remove spaces.
class Complex:
# like functions, you should also put docstrings in classes to␣
↪document them
80
Python for Engineers
In Python, objects are a bit like dictionaries, where the field names map to their values, only that
the syntax is a bit different.
In fact, they actually are dictionaries with some syntactic sugar on top. We can access the under-
lying dictionary using the special __dict__ attribute of the object.
z.__dict__
But where is the magnitude function that we defined? The magnitude function is not part of the
object itself, it belongs to the class so that’s where we find it.
Complex.__dict__
mappingproxy({'__module__': '__main__',
'__annotations__': {'real_part': float, 'imaginary_part':␣
↪float},
'__doc__': 'A class to represent complex numbers.',
'magnitude': <function __main__.[Link](self) ->
↪ float>,
'__dict__': <attribute '__dict__' of 'Complex' objects>,
'__weakref__': <attribute '__weakref__' of 'Complex'␣
↪objects>})
Creating objects like this is a bit cumbersome and limiting. To make this a bit more ergonomic we
can define the special __init__ method. This method is called with the parameters we pass to
the “class call” and is used to initialize the object. This is often also called a constructor, because
it constructs the object.
class Complex:
real_part: float
imaginary_part: float
# another special method to make the class print out more usefull
# the method should be defined, such that `eval(repr(obj)) == obj`
# eval takes a string and executes it as python code returning the␣
↪result
def __repr__(self) -> str: # short for representation
return f"Complex({self.real_part}, {self.imaginary_part})"
z = Complex(1.0, 2.0)
print(z)
# print([Link](Complex(3.0, 4.0)))
print(z + Complex(3.0, 4.0))
print(f"{repr(z)==repr(eval(repr(z))) = }") # compare the string since␣
↪we didn't define equality
Complex(1.0, 2.0)
Complex(4.0, 6.0)
repr(z)==repr(eval(repr(z))) = True
These methods are also called dunder methods because they are surrounded by double un-
derscores and there are many more of them. As we already saw with __dict__ there are also
dunder attributes. Unless you either know what you are doing or want to implement one of
these special methods you should not start you own method or attributes names with a double
underscore.
This was only made to serve as an example, you should not reimplement complex numbers your-
self. Complex numbers are actually built into Python and you can create them by using the imag-
inary unit j or their constructor complex.
w = 2 + 3j
print(w)
print([Link], [Link])
print(type(w))
print(w + 2)
(2+3j)
2.0 3.0
<class 'complex'>
(4+3j)
6.4 Encapsulation
class SphereCoordinate:
# the leading underscore tell people that this is a private attribute
# and should not be accessed from outside the class
_lat_deg: float
_lon_deg: float
self._lat_deg = lat
@property
def lon_deg(self) -> float:
return self._lon_deg
# they also allow us to make attribute like things that are computed␣
↪from other
(continues on next page)
6.4. Encapsulation 83
Python for Engineers
# ...
Coordinate(52.38, 9.72)
50
0.8726646259971648
Encapsulation does not only help in ensuring consistent state, it also isolates the interface from the
implementation. For example, if for some reason we want to change the internal representation
of the coordinates from degrees to radians, this implementation would allow us to do this since all
access goes through the getters and setter where we can make the necessary conversions and
consistency checks.
One thing to keep in mind is that there is nothing to be gained by implementing a getter and setter
which read or write to the underlying field without any additional logic. It adds nothing and behaves
just like a public field. Properties are most useful for read only or computed fields. Your first line
of defense against inconsistency should not be a setter which validates the input, it should be to
disallow any change at all since it is often unnecessary and fewer changing things generally lead
to less confusion.
6.5 Inheritance
Like other languages which offer OOP features, Python, of course, also supports inheritance. We
will only briefly go over this, as you are not going to use it as much as people who (religiously)
teach OOP would have you believe.
Inheritance is a way to model relationships between types and lets them reuse common function-
ality. Inheritance models an is a relationship, like an engineering student is a student or a student
is a person. In this case, the engineering student would inherit from the student and the student
from the person. By deriving / inheriting from another class, the subclass inherits all of the fields
and methods of the parent class and can add additional fields and methods or override existing
ones. In Python, this would look like this:
6.5. Inheritance 84
Python for Engineers
class Person:
def __init__(self, name: str, age: int):
[Link] = name
[Link] = age
def say_hello(self):
print(f"Hello, my name is {[Link]} and I am {[Link]} years␣
↪old.")
class Student(Person):
def __init__(self, name: str, age: int, grades: dict):
Person.__init__(self, name, age) # delegate to the parent classes
[Link] = grades
6.5. Inheritance 85
Python for Engineers
def __repr__(self) -> str: # override the __repr__ function with one␣
↪that includes grades
return f"Student({[Link], [Link], [Link]})"
bob = people[1]
print(bob)
print("is Student?", isinstance(bob, Student))
print("is Person?", isinstance(bob, Person))
This is a typical “academic” example of inheritance and should be taken as such. In practice you
would likely not implement such a thing like this. Especially in multi-paradigm languages where
there are many other tools that can solve the sample problems. You rarely actually need to use
inheritance and most of the time you are better off just putting the parent class into the would-be
child class, which is called composition. See here for the reasoning behind this.
Real world example of inheritance are often much more abstract and don’t have real world analo-
gies like the one above. One example that you already learned about is [Link] and
its related classes like PosixPath, WindowsPath and PurePath. This has little to do with the
common real world comparison of literal inheritance. Here we can also see the Liskov substitution
principle in action: In lecture 4 we defined all our function to operate on Path objects even though
they actually received PosixPath or WindowsPath objects.
6.5. Inheritance 86
Python for Engineers
In a lot of cases, you just want to have a simple data container without any complicated initialization
functions or other validation. In this case you should consider using a dataclass where the
__init__ and __repr__ functions are automatically generated for you. This make the code
much simpler and is one less opportunity to make a mistake.
But to the minimalist Python programmer, even this might be too much, and they would really just
want a fancier tuple with named fields. This is where namedtuple comes in, which is a function
that generates simple tuple like classes.
# named tuples are actual tuple so they inherit all of tuple's properties
print(guido[0]) # like indexing
first, last, year = guido # and destructuring
print(first, last, year)
# and being immutable
# guido.first_name = "Bob" # this will raise an error
6.7 Summary
Unlike their usage might suggest, decorators aren’t magic. To get a better understanding of how
they work, we will implement one ourselves.
In essence, a decorator is just a function that takes another function and wraps or modifies it in
some way, or in the case of a class it takes the whole class and modifies it.
Let’s implement a decorator that prints out debug information when calling the decorated function:
To apply a function as a decorator we use the @ syntax before the function’s definition. The dec-
orated function is replaced by whatever the decorator function returns:
@unary_debug_print
def square(x: float) -> float:
return x**2
6.7. Summary 88
Python for Engineers
The @ decorator syntax is just syntactic sugar to make decorators more convenient to use. We
can achieve the same results by “decorating” the function manually:
SEVEN
We can assign the elements of a tuple, list, or any other iterable to individual variables using
unpacking assignments.
# simple unpacking
point = (1, 2) # bracket are optional
x, y = point
print(x, y)
# since we can create the duple on the right side, we can also use this␣
↪to do multiple assignments at one
# this can make for nice looking code
a, b = 5, 6
print(a, b)
# we can also use this to swap variables
a, b = b, a
print(a, b)
1 2
p1 3 4
5 6
6 5
This can also be used to intuitively return multiple values from a function:
90
Python for Engineers
s, p = sum_and_product(2, 3)
print(s, p)
5 6
There are many situations where we want to iterate over both the indices and the values of a
list. Or we want to iterate over two or more iterables at the same time. The naive solution to this
problem is to just iterate over the indices and then use square brackets to access the elements.
This is not very elegant and is prone to errors. Instead, enumerate() and zip() should be
used to write more concise code.
# DON'T DO THIS
for i in range(len(names)):
print(f"{names[i]} is {ages[i]} years old")
print("")
# do this instead
for name, age in zip(names, ages, strict=True):
# use strict=True so that the zip will throw an error if the lists␣
↪are not the same length
print(f"{name} is {age} years old")
In other situations, you might also need the index of the items while iterating over them. Instead
of iterating over just the index and then using the [] operator to access the element, you should
use enumerate().
# DON'T DO THIS
for i in range(len(names)):
print(f"{names[i]} has index {i}")
print("")
# do this instead
for i, name in enumerate(names):
print(f"{name} has index {i}")
List comprehensions are a very powerful and elegant language feature in Python. They can be
used to transform and filter lists. The source can also be a generator expression or any other
iterable. With all these features combined, complex series can be generated with little code.
List comprehensions can also be nested within each other to generate or transform multidimen-
sional lists. Just like regular for loops, they can also be combined with structured bindings to
iterate through multiple lists at once.
The equivalent of list comprehensions also exists for dictionaries and sets, as dictionary and set
comprehensions.
{0, 1, 2}
{0: '0', 1: '1', 2: '2'}
Tuple comprehensions do not exist, but something similar can be achieved by passing a generator
expression to the tuple() constructor.
In Python, assigning a variable to another name does not create a copy of the object, but only
a shared reference to it. This can be very confusing when working with mutable objects, so it is
important to know what’s really going on.
We can check if two variables refer to the same object by comparing their id()s or using the is
operator which does the same thing under the hood.
my_list = [1, 2, 3]
the_same_list = my_list
the_same_list[1] = 5
print(f"{my_list = }, {the_same_list = }") # both lists are changed
print(f"{id(my_list) == id(the_same_list) = }") # because they are the␣
↪same object
my_list = [1, 2, 3]
a_different_list = my_list.copy()
print(f"{my_list == a_different_list = }") # the values are the same␣
↪(equal)
print(f"{my_list is a_different_list = }") # but the objects are␣
↪different (not identical)
# so changing one doesn't change the other
a_different_list[1] = 5
print(f"{my_list = }, {a_different_list = }")
These reference semantics also apply to function arguments. When we are passing a variable to
a function, we are only giving it a shared reference to the same data and the function works on
the original object.
my_list = [1, 2, 3]
changed_list = do_something(my_list)
print(changed_list) # the function returns the changed list
print(my_list) # the original list is also changed
# this is because changed_list and my_list are both a reference to the␣
↪same object
print(my_list is changed_list)
[1, 42, 3]
[1, 42, 3]
True
Often times, this is fine, especially when performing read-only operations on the list or when it
doesn’t matter what happens to the list. As a general rule, it is not a good idea to modify input
arguments, unless it is obvious from the function name. If you need something like the input to
modify, just make a copy of it.
my_list = [1, 2, 3]
a_new_list = do_something_else(my_list)
print(my_list)
print(a_new_list)
[1, 2, 3]
[1, 42, 3]
This can be especially treacherous when working with mutable default arguments that are sup-
posed to be mutated in the function. The default argument is only evaluated once when the func-
tion is defined, and not every time the function is called as one might expect.
[1, 2, 3, 1]
[1]
[1, 1]
To implement the desired behavior correctly, we can use None as the default value and check for
it in the function body.
print(append_one([1, 2, 3]))
print(append_one_fixed()) # new list is created in the function body
print(append_one_fixed())
[1, 2, 3, 1]
[1]
[1]
Another common pitfall is creating a nested list with the multiplication operator.
one_dim = [0] * 3
one_dim[1] = 1
print(one_dim) # works as expected
two_dim = [[0] * 3] * 3
two_dim[1][1] = 1
print(two_dim) # unintuitive behavior
print(two_dim[0] is two_dim[1]) # all the inner lists are the same␣
↪object (identical)
[0, 1, 0]
[[0, 1, 0], [0, 1, 0], [0, 1, 0]]
True
[[0, 0, 0], [0, 1, 0], [0, 0, 0]]
7.5 Lambdas
Sometimes we need to pass functions to other functions. These are often very simple, and using
the def syntax makes for unpleasant code. The solution to this is to use lambda expressions.
numbers = [1, 4, 2, 3]
print("sum", accumulate(numbers, lambda x, y: x + y)) # lambda [params]␣
↪: [expression]
print("prod", accumulate(numbers, lambda x, y: x * y))
print("max", accumulate(numbers, max))
magic_number = 3
# lambdas can access the variables of the scope they are created in
print("bad idea", accumulate(numbers, lambda x, y: magic_number))
sum 10
prod 24
max 4
bad idea 3
Passing functions to other functions is part of the functional programming paradigm. This can
make for very elegant code, but in some situations, it is a lot harder to understand than the imper-
ative style programming.
7.5. Lambdas 96
Python for Engineers
There are a lot of ways to specify and pass function parameters in Python. We can set default
values, pass parameters both by position or name, and we can use * and ** to pass multiple
parameters at once.
f2()
f2(4) # pass the first of the defaults
f2(b=9) # only specify a value for b
f3()
f3(1, 2, 3)
print("")
# the names "args" and "kwargs" are just conventions, the stars are what␣
↪matters
def f4(a, *args, **kwargs): # absorb all other keyword parameter into␣
↪dict kwargs
f4(1)
f4(1, 2, 3, d=4, e=5)
f3: args=()
f3: args=(1, 2, 3)
f5(a=2, b=3)
f5(c=4)
We can also force the user of our function to pass parameters by position or value by including the
special / and * placeholder arguments in the function’s signature. All parameters before the /
must be passed by position, and all parameters after the * must be passed by name. Everything
in between can be passed by both.
f6(1, 2, kw_only=3) # ok
f6(1, pos_or_kw=2, kw_only=3) # also ok
------------------------------------------------------------------------
↪---
TypeError Traceback (most recent call␣
↪last)
Cell In[22], line 1
----> 1 f6(1, 2, 3) # error because `kw_only` must be passed as a␣
↪keyword argument
------------------------------------------------------------------------
↪---
TypeError Traceback (most recent call␣
↪last)
Cell In[23], line 1
----> 1 f6(pos_only=1, pos_or_kw=2, kw_only=3) # error because `pos_or_
↪kw` must be passed as a positional argument
This is useful for forcing optional parameters to be passed only as keyword arguments, which
allows adding new parameters changing their order without potentially breaking the code that
relied on the order of the parameters.
Writing good code is not just about writing code that works, it is also about writing code that is
easy to read and understand. There is no one set of rules that you can follow to write better code,
but there are a few rules that, when applied, will generally lead to nicer code.
You will not get to apply all of these conventions, as the exercises are not complex enough for
some of them to be relevant. However, you should still be aware of them and try to apply them
when you can.
Phil Karlton once famously said: “There are two hard things in computer science: cache inval-
idation and naming things.” Whether true or not, naming things is certainly very important, and
sometimes it is really hard to find a good name for something. Nevertheless, there are a few rules
that you should follow when naming things in Python.
The first isn’t actually the names, but how you write them. They should follow a consistent style.
When this style is not already specified by the project you are working on, you should follow the
PEP 8 style guide. The essence of that is:
• Use snake_case for variable and function names
• Use PascalCase for class names
• Use UPPER_SNAKE_CASE for constants
• Don’t use single character variable names, except for counters in loops (we sometimes
break this rule when doing math)
• Don’t use cryptic abbreviations without vowels. It’s fine if your variable names consist of
multiple words, but don’t make them too long either. If you have a very big expression, you
can still use local shorthands to reduce visual clutter.
Try to name your variables and functions so that it is obvious what the variable contains / how it
is calculated, or what the function does without having to look up the definition. While comments
are a useful feature to give the reader information about things that are not immediately obvious
from threading the code, it is event better to have the code be self-explanatory. Instead of writing:
length_left_list = 11
length_right_list = 42
Luckily you do not have to carry all of this burden yourself. There are tools out there which can
help you keep you code tidy and warn you of common malpractices. Autoformatters like Black
for Python mostly add and remove whitespace to make everything look consistent. Linters like
Ruff on the other hand will warn you of common mistakes or anti-patterns and sometimes offer
automatic fixes.
After installing Black you will need to set these settings in VS-Code’s user or workspace
[Link] to configure it:
{
"[Link]": true,
"[Link]": true,
"[python]": {
"[Link]": "[Link]-formatter"
},
"[Link]": ["-l", "99"]
}
The setting [Link] tells VS-Code to format the code every time you save
the file. For this to work in notebooks, you also need to explicitly enable it with notebook.
formatOnSave. The last setting [Link] increases the allowed maximum
line length to 99 characters to make better use of modern monitors.
It is also possible to set these settings in the settings UI by just searching for the names of the
setting.
Functions are probably the most powerful abstraction there is, use them! Once you find yourself
writing the same code twice, you should think about writing a function for it. If you write it three
times, you should have already written the function. Often, it will also make sense to write a
function even if you only use it once. You can do this to make it clear that this is a meaningful sub-
operation of your function. This also helps to prevent your functions from turning into a 100-line
messes.
You will often encounter situations where you need to check that multiple conditions are met before
doing the actual work. A bad way to do this is to keep nesting if statements.
As you can see, this will quickly lead to a lot of indentation and make the code hard to read.
Instead, you should use early returns. This means that you check for error cases first and return
early if you find one. This will make the code much easier to read.
Use type hints! They make it easier to understand what a function does and how it is used. They
will also help your IDE give you better suggestions for auto-completion and find errors in your code.
Whenever you are unsure about how to write something, you should (almost) always go for the
more readable option. This might not always be the most efficient one or the one with the fewest
lines of code, but it will likely save you and everyone else time. Complexity is a fairly good measure
of how hard it is to understand something, so it is important to always strive to write the least
complex code possible.
It is unlikely that you will get “it” right the first time. Burning it all down and starting from scratch
is often way less work than you think, since most of the work was probably trying to figure out the
problem, not writing the code. You can carry over that experience to the new implementation and
write it much faster and better without the holes that you dug yourself into on your first attempt.
7.8 Summary
EIGHT
PANDAS
import numpy as np
import pandas as pd
import [Link] as plt
[Link]("[Link]")
Up to this points we have already spent some time getting to know the NumPy ndarray and it
has served us well for most numerical needs. But just because we were able to manage, does
not mean we should not look for better tools.
Often times we have some NumPy array that contains our index points like times ts and some
data vals that represent the values at these time points. These variables belong together but
there really isn’t anything actually keeping them together and forcing them to be consistent. If they
only exist as short-lived temporary values this is mostly unproblematic, but if we use them more
and often pass them between functions they really should be contained in some data structure to
ensure consistency and make the code simpler and less error-prone.
This is where Panda’s Series comes in, it can hold the data and the time index in one object and
also implements a lot of useful methods for working with the data.
# bundle the free variable time and the dependent variable temperature␣
↪into a pandas Series
103
Python for Engineers
0.0 290.15
600.0 291.15
1200.0 293.15
1800.0 294.15
2400.0 294.15
3000.0 292.15
3600.0 293.15
dtype: float64
If we have another series of values that is defined for the same index, we can use a DataFrame,
it is essentially a dictionary of Series objects that share the same index.
temperatures = [Link](
{"inside_temp": temp_deg, "outside_temp": temp_deg[::-1] - 10},␣
↪index=[Link](ts, name="time")
)
temperatures # dataframes have nice html representations in notebooks
inside_temp outside_temp
time
0.0 17 10
600.0 18 9
1200.0 20 11
1800.0 21 11
(continues on next page)
DataFrames implement a convenient plot that plots all columns against the DataFrame’s in-
dex. By default, it uses the matplotlib backend, so the plots can be modified further using
other Matplotlib functions. This is very useful to get a quite visual overview of the contained data.
We can easily add new columns by assigning to a new key just like we would with a dictionary. If
we compute the new column from existing columns, they will inherit the same index, so the new
data integrates seamlessly into the existing DataFrame.
time
0.0 17
600.0 18
1200.0 20
1800.0 21
2400.0 21
3000.0 19
3600.0 20
Name: inside_temp, dtype: int64
There are (too) many ways to index into a DataFrame, here are some of the most common ones:
outside_temp inside_temp
time
0.0 10 17
600.0 9 18
1200.0 11 20
1800.0 11 21
2400.0 10 21
3000.0 8 19
3600.0 7 20
time
0.0 17
600.0 18
1200.0 20
1800.0 21
Name: inside_temp, dtype: int64
# iloc will indexes by position like we know from most other array like␣
↪data structures
[Link][0:2, 1] # the first two rows, second column
time
0.0 10
600.0 9
Name: outside_temp, dtype: int64
Pandas also implements a bunch of common statistical operations as member functions to Se-
riess and DataFrames.
# on Series
print(f"{temperatures["inside_temp"].mean() = }")
print(f"{temperatures["inside_temp"].sum() = }")
# ... and many more methods
temperatures["inside_temp"].mean() = np.float64(19.428571428571427)
temperatures["inside_temp"].sum() = np.int64(136)
# and on DataFrames
[Link]()
inside_temp 1.511858
outside_temp 1.511858
difference 1.914854
dtype: float64
With Pandas, we get a lot of functionality that makes it easier to filter for the data you are interested
in. For example, let’s say we want to isolate all points in time where the inside temperature was
between 18 and 20 degrees:
time
0.0 False
600.0 True
1200.0 True
1800.0 True
2400.0 True
3000.0 True
3600.0 True
Name: inside_temp, dtype: bool
This is called boolean indexing and is also available for the underlying NumPy arrays.
One downside of filtering this way is that we have to repeat the name of the DataFrame for every
filter and indexing operation. This can make the code a bit ugly and harder to change. We can
get around this by using the query method.
True
To do useful work on data, you will most likely need to load your data from somewhere and then
save processed results somewhere else. Pandas makes this very easy with its read_csv and
to_csv functions.
temperatures.to_csv("[Link]")
# temperature_df.to_csv("[Link]", sep="\t") # the separator can␣
↪also be changed
Pretty(filename="[Link]")
time,inside_temp,outside_temp,difference
0.0,17,10,7
600.0,18,9,9
1200.0,20,11,9
(continues on next page)
When reloading the file, make sure to specify the correct index column, otherwise a new integer
index will be created, and the original index will just become another column.
While csv has its advantages like, ease of implementation, human readability and extreme preva-
lence and portability. It is not very space efficient or fast and has no standardized way of storing
metadata or preserving types. Luckily, Pandas also supports more advanced binary formats like
parquet, feather and hdf5 which are just as easy to use.
temperatures.to_parquet("[Link]")
reloaded_parquet = pd.read_parquet("[Link]") # no need to␣
↪specify the index column
Finally, let’s take a look at how we can group and aggregate data in a DataFrame with a real
world example. The data we are working with is derived from this larger dataset.
Our contains the following columns for the months of April and May in 2015:
• datetime_str: The date and time of the observation as a string.
• temperature_C: The temperature in degrees Celsius.
• summary: A short description of the weather conditions.
We want to create four plots from this:
• The temperature over the entire time period.
• The daily minimum and maximum temperature and the difference between them over the
entire time period.
• A scatter plot of temperature over all hours of the day for May.
• A bar chart of the mean temperatures for the five most common weather conditions.
weather = pd.read_csv("[Link]")
# process the datetime string into a datetime object and create helper␣
↪columns
weather["datetime"] = pd.to_datetime(weather["datetime_str"])
weather["date"] = weather["datetime"].[Link] # access individual␣
↪components of the datetime for all rows
weather["hour"] = weather["datetime"].[Link]
weather = weather.set_index("datetime").sort_index()
[Link](y=["temperature_degC"]);
To compute the daily minimum and maximum temperature, we will need to group the hourly data
by day and then compute the minimum and maximum temperature for each day. Since this is a
very common operation, Pandas has some very convenient methods for this. We start by grouping
the DataFrame by the auxiliary date column using the groupby method. It returns a GroupBy
object which is basically collection of tuples containing the groups name and a DataFrame con-
taining the data for that group.
for group, df in [Link]("date"):
print(f"{group = }")
display([Link](3)) # head(3) to only show the first 3 rows
break # only print the first group
group = [Link](2015, 4, 1)
datetime_str temperature_
↪degC \
datetime
2015-04-01 02:00:00+02:00 2015-04-01 02:00:00.000 +0200 9.
↪872222
While we could work with this iterable ourselves now, we usually want to apply some common
aggregation function like mean, min, max, etc. to the groups. We do this by simply calling the
aggregations functions as member functions on the GroupBy object. It is also possible to perform
multiple aggregations at once by passing a list of aggregations to the agg method which is also a
member of the GroupBy object.
# only use the date and temperature columns because we only want
# to aggregate the temperature we need the date to group by
weather[["date", "temperature_degC"]].groupby("date").mean()
temperature_degC
date
2015-04-01 8.578535
2015-04-02 7.522454
2015-04-03 5.137037
2015-04-04 6.156481
2015-04-05 7.329630
... ...
2015-05-28 12.442130
2015-05-29 14.666898
2015-05-30 18.041667
2015-05-31 18.919213
2015-06-01 14.038889
8.7 Summary
NINE
ADVANCED PLOTTING
import numpy as np
import [Link] as plt
from vscdark import set_style
set_style("vsclight")
<[Link] object>
Up until now, we have only used line plots and scatter plots which are also really just line plots
without the lines. However, if we take a look at this Matplotlib cheatsheet we see that there are
many more types of plots that you might want to use. We are going to take a look at three more
of these, which are all associated with visualizing distributions in some way.
• [Link] to make histograms of univariate distributions of data.
• [Link] to compare multiple univariate distributions by characteristic values like the
median and quartiles.
• [Link] to compare multiple univariate distributions by approximate probability
density functions (kernel density estimates).
To compare different processes of producing 3 mm steel balls for ball bearings, we have measured
the diameter of 500 balls from each process.
[Link](42)
process_1 = [Link]([Link]((3, 3.01), (0.01, 0.02), (250,
↪ 2)))
process_2 = [Link](3.02, 0.005, 500)
process_3 = [Link]([[Link](3.0, 0.03, 490), [2.83, 2.85,
↪ 2.87, 2.89, 2.91, 2.93, 2.95, 2.97, 2.99]])
114
Python for Engineers
We can see that the distribution is slightly skewed to the left but otherwise mostly centered around
the desired diameter of 3 mm.
But how does it compare to the other two processes? To compare multiple distributions we can
plot them together in a single diagram using box plots or violin plots. Box plots and violin plots are
different ways of visualizing the same data: box plot show characteristic values like the median,
25% and 75% percentiles, and outliers, while violin plots also show a kernel density estimate of
the distribution.
Matplotlib only offers simple and sometimes fairly clunky to use versions of these plots. For more
advanced (and beautiful) statistical visualizations you should take a look at Seaborn. It is a sta-
tistical plotting library build on top of Matplotlib that abstracts away a lot of the more finicky details
of making these plots, offering a more high-level interface with good defaults.
Interactive plots can be very useful to give the reader an intuition of how changing a variable
can effect an otherwise static visualization. A user-changeable parameter can also serve as an
additional visualization dimension as an alternative or addition to 3D plotting. You can do a lot of
things with widgets, this article gives a good overview of most of the features.
The simplest way to use ipywidgets is to use interact. The basic idea is that we wrap the
thing we want to interact with in a function whose parameters we want to change interactively.
We then pass this function to interact, and it will create the necessary sliders, text boxes, and
other required input elements and automatically call the function with the current input elements’
values.
def square(x: int = 2) -> int: # interact uses the function default to␣
↪initialize the slider
return x * x
While the simple tuple syntax allows us to quickly get something up and running, we can get more
precise controls by passing the input widgets explicitly. And we are of course not limited to just
numbers or text as output, the function is allowed to produce or return any kind of output supported
in Jupyter notebooks including plots or HTML.
interact(
plot_trig,
f=FloatSlider(value=1, min=0, max=4, step=0.1, layout=Layout(width=
↪"600px")),
a=FloatSlider(value=1, min=-2, max=2, step=0.1, layout=Layout(width=
↪"600px")),
interactive(children=(FloatSlider(value=1.0, description='f',␣
↪layout=Layout(width='600px'), max=4.0), FloatSli…
Using interact like this allows us to quickly create interactive visualizations. You can get a lot more
involved with different kinds of inputs and more complex user interfaces, but this is beyond the
scope of this lecture and for you to look up once you need it.
Another kind of interactivity you might want is the interactive backend of Matplotlib. Instead of
getting an image, you can also configure Matplotlib to return an interactive window which allows
you to zoom, pan and select data points. This is especially useful for large detailed datasets or
3D plots where you can rotate the plot to get a better understanding of the data.
fig, ax = [Link]()
Matplotlib’s interactive backend also allows widgets to be added, but it is not as easy to use
as the ipywidgets, which should probably be your first choice for interactivity when working in
notebooks. You can find examples of this in the matplotlib documentation.
The interactive backend can be very useful, especially when exploring data, sadly it is not without
its caveats. Here are some downsides you might have to deal with:
• The interactive figures are kept open until they are explicitly closed. This can lead to perfor-
mance problems, especially when running cells multiple times.
• Since the interactive figures refer to live objects, they change when the object changes.
This can lead to unexpected behavior when using the same figure object in multiple cells
because later cells can change and even break the figures of earlier cells.
• Since the figures require the interpreter to be running, the outputs will be lost when reopening
the notebook and will need to be re-rendered. This way you can not simply view a notebook
without needing to re-execute the code.
• They are not very robust and may lead to errors on some platforms or with some configura-
tions.
So it might not be worth activating them when you don’t really need them.
If you want robust interactive plots that do not suffer from all of those problems, consider taking a
look at Plotly which outputs pure JavaScript and HTML that runs independently of the IPython
kernel.
9.4 3D Plotting
One application where the interactive backend really provides a lot of value is 3D plotting because
it allows rotating the plot to get a better view and 3D effect.
fig = [Link]()
ax = fig.add_subplot(projection="3d")
x = [Link](0, 4 * [Link])
s = 1j - 0.1
y = [Link]([Link](s * x))
z = [Link]([Link](s * x))
[Link](x, y, z)
ax.set_xlabel("x")
ax.set_ylabel("Real")
ax.set_zlabel("Imaginary")
ax.view_init(elev=20, azim=-50) # manually set the view for static output
# [Link]()
Instead of two outputs as a function of a single variable resulting in a curve in 3D space, we can
also plot a single function of two variables as a surface. To generate the grid of coordinates to
evaluate the function on, we can use [Link] which takes two (or more) 1D arrays and
returns two (or more) 2D arrays where each pair (or set) of values from the arrays corresponds to
a point on the grid.
(array([[1, 2, 3, 4],
[1, 2, 3, 4],
[1, 2, 3, 4]]),
array([[5, 5, 5, 5],
[6, 6, 6, 6],
[7, 7, 7, 7]]))
fig = [Link]()
ax = fig.add_subplot(projection="3d")
ls = [Link](-2, 2, 25)
x, y = [Link](ls, ls) # use meshgrid to evaluate a function on a␣
↪grid
z = [Link](-(x**2 + y**2))
print(f"{[Link] = }, {[Link] = }, {[Link] = }, {[Link] = }")
[Link] = (25,), [Link] = (25, 25), [Link] = (25, 25), [Link] = (25,
↪ 25)
Keep in mind…
While 3D plots can look quite nice and have the potential to visualize things that 2D plots can’t,
they can quickly become confusing and are often hard to read. Always ask yourself whether you
really need a 3D plot or whether another kind of plot is better at getting your message across.
In most situations, the extra dimension can also be represented using color, size, or an extra
plot. Your number-one priority should always be readability. If your medium allows for it, use an
interactive plot or an animation so that the 3D effect is easier to grasp without perspective illusions.
Depending on your type of data, you can also draw lines to a reference plane or line.
# the same plot can also easily be created in 2D with color as the third␣
↪dimension
fig, ax = [Link]()
# cont = [Link](x, y, z, shading="gouraud") # full continuous␣
↪color
# cont = [Link](x, y, z, levels=20) # colored contour lines
cont = [Link](x, y, z, levels=20) # discrete levels make the␣
↪contours easier to see
[Link](cont)
[Link](False)
# [Link]()
9.5 Animations
Another dimension we can make use of is time itself by animating our plots. For this, matplotlib
provides the FuncAnimation class.
fig, ax = [Link]()
x = [Link](0, 2 * [Link])
line = [Link](x, [Link](x))[0] # save the line handle from the plot call
N = 100
(continues on next page)
def animate(frame_num):
line.set_ydata([Link](x - frame_num / N * 2 * [Link]))
return (line,) # return all the objects that we changed
Displaying these animations live obviously requires the interactive backend. But like other uses
of the interactive backend, this can be a bit unreliable. One work around for this is to save the
file to a video or GIF and display that instead. This also has the advantages that the animations
persists when reopening the notebook and can be shared more easily. However, it can be a bit
slow to render.
<[Link] object>
FuncAnimation is also not without its downsides and technicalities. Depending on your work-
flow, it might also make sense to generate and save the individual frames one by one and then
use something like FFmpeg to combine them into a video.
9.6 Summary
TEN
𝑑2 𝑥(𝑡)
+ 𝜔2 𝑥(𝑡) = 0
𝑑𝑡2
Here 𝑥(𝑡) is only a function of time and the derivative is also only with respect to time 𝑡. In these
2
cases, we typically use the Newton notation for the time derivatives, e.g., 𝑥̈ = 𝑑𝑑𝑡2𝑥 for second
derivative.
Meanwhile, PDEs involve functions and derivatives with respect to multiple variables, such as the
heat equation:
𝜕𝑢(𝑡, 𝑥) 𝜕 2 𝑢(𝑡, 𝑥)
=𝛼
𝜕𝑡 𝜕𝑥2
Here 𝑢(𝑡, 𝑥) is a function of both time 𝑡 and space 𝑥 and the derivatives are partial derivatives.
In this chapter, we will only cover ODEs, but the methods we will learn can be extended to PDEs
as well. This can be done by approximating the continuous space with a discrete grid which yields
a system of ODEs.
import numpy as np
import [Link] as plt
[Link]("[Link]")
124
Python for Engineers
𝑦(𝑡)
̇ = −𝑦(𝑡) ⋅ 𝑡
!
𝑦(𝑡)
̇ = −𝑦(𝑡) ⋅ 𝑡 = 𝑓(𝑡, 𝑦)
⇒ 𝑓(𝑡, 𝑦) = −𝑦 ⋅ 𝑡
Another very useful optional argument is t_eval, which can be used to specify at which points
in time the solution should be determined. Without this, the solver will choose the points in time
itself, attempting to keep the error below a certain threshold.
delta_t = 0.1
ts = [Link](0, 3, delta_t)
interval = (min(ts), max(ts))
y0 = [1]
There are many more options to solve_ivp. These are including but not limited to the solver to
use, tolerance settings, what exactly to output and solver specific additional information about the
problem to speed up the process. If your results are incorrect or too slow, you should probably
take a look at these settings. However, the default settings should be sufficient for most cases.
Now let’s look at something slightly more complicated, a harmonic oscillator. A simple driven
harmonic oscillator can be described by the following equation:
𝑚𝑦(𝑡)
̈ + 𝑑𝑦(𝑡)
̇ + 𝑘𝑦(𝑡) = 𝑢(𝑡)
𝑑 𝑘 1
⇒ 𝑦(𝑡)
̈ =− 𝑦(𝑡)
̇ − 𝑦(𝑡) + 𝑢(𝑡)
𝑚 𝑚 𝑚
where 𝑚, 𝑑, and 𝑘 are the mass, damping coefficient, and spring constant respectively. 𝑦(𝑡) is the
displacement of the mass at time 𝑡, and 𝑢(𝑡) is an external force applied to the mass.
If we want to plug this into solve_ivp, we quickly notice a problem. The function requires us
to provide a function that returns the first derivative of the variable to integrate, but there is a
second derivative on the left side. While solve_ivp can actually only solve equations with first
order time derivatives, it can also solve entire systems of first order differential equations. This is
not a big problem however, since any 𝑛-th order ODE can be reformulated into a system of 𝑛 first
order ODEs by introducing new variables for the lower order derivatives. In this case we introduce
the velocity 𝑣(𝑡) = 𝑦(𝑡)
̇ as a new variable so that 𝑦(𝑡) ̈ = 𝑣(𝑡)
̇ is only a first order derivative of the
state.
𝑦(𝑡)
̇ 𝑦(𝑡)
̇ 𝑦(𝑡)
̇ =[
x(𝑡) ]=[1 𝑑 𝑘 ] = f (𝑡, x = [ ])
𝑣(𝑡)
̇ 𝑚 𝑢(𝑡) − 𝑚 ̇
𝑦(𝑡) − 𝑚 𝑦(𝑡)
𝑣(𝑡)
For now let’s consider the autonomous case 𝑢(𝑡) = 0 and see how the system evolves from the
initial conditions 𝑦(0) = 1 and 𝑦(0)
̇ = 0.
k = m = 1
d = 0.3
Now let’s consider the case where we have a harmonic excitation 𝑢(𝑡) = sin(𝜔𝑡). We can imple-
ment this excitation as a time dependent term in the dydt function.
from typing import Callable
dt = 0.1
interval = (0, 50)
[Link]();
A lot of the systems we encounter are (approximately) linear and time-invariant systems. This
means we can make use of all the tools that you learned in your control theory and signals and
systems class. To make use of these tools in Python, we will use the Python Control Systems
Library often called control for short. Many of these features are also implement in scipy.
signal which also contains a lot of useful functions for signal processing, but we will focus on
control here.
The driven harmonic oscillator from the previous section is a linear time-invariant system because
the system function f(𝑡, x) is linear in x(𝑡) and 𝑢(𝑡) and otherwise independent of 𝑡. When this is
the case, it can be written as a matrix vector expression:
we also introduce the concept of an output 𝑦(𝑡) which may not necessarily include all states of the
system:
0 1 0
̇ =[
x(𝑡) 𝑘 𝑑 ] x(𝑡) + [ 1 ] 𝑢(𝑡)
−𝑚 −𝑚 𝑚
𝑦(𝑡) = [1 0] x(𝑡) + 0 ⋅ 𝑢(𝑡)
import control as ct
harmonic_oscillator_sys = [Link](A, B, C, D)
harmonic_oscillator_sys
0 1 0
⎡ −1 −0.3 1 ⎤
⎢ ⎥
⎣ 1 0 0 ⎦
The poles of the system, which are equal to the eigenvalues of the matrix A, show us that the
system is stable with our choice of parameters.
print(f"{harmonic_oscillator_sys.poles() = }")
print(f"{[Link](A) = }")
print("stable?", [Link]([Link](harmonic_oscillator_sys.poles()) < 0))
We can excite the system with an arbitrary sequence of inputs. Let’s use the same harmonic
excitation to make sure we get the same results.
To get the resulting amplitude and phase lag for a whole frequency range, we can use the fre-
quency_response function. The result is basically a numerical representation of the system’s
transfer function.
Two other signatures of a system are its impulse and step response. They can be computed using
the impulse_response and step_response functions respectively. They return TimeRe-
sponseData objects which contains the time points and states/outputs of the system at those
time points. They also implement convenient plot methods to visualize the results.
step_response = harmonic_oscillator_sys.step_response()
print(f"{type(step_response) = }")
[Link](step_response.time, step_response.outputs, label="step response")
# sys.step_response().plot(label="step response")
harmonic_oscillator_sys.impulse_response().plot(label="impulse response",␣
↪color="C1");
Another common representation for LTI systems are transfer functions. The control systems
library implements these using the TransferFunction class. We can create a transfer function
either by specifying the numerator and denominator polynomials or by specifying the zeros, poles
and a gain k using zpk.
harmonic_oscillator_sys.to_tf()
Control systems are usually made up of multiple LTI systems. Systems can be combined by
putting them in series which corresponds to multiplication of the transfer function or in parallel
which corresponds to addition of the transfer function.
print("series")
display(plant * sensor)
print("parallel")
display(plant + sensor)
series
parallel
We can also add a feedback path using the feedback function or realize any other arbitrary
topology using the interconnect function.
The integral controller makes sure that the output matches the input eventually. In other words, it
ensures a DC-gain of 1 and thereby a steady state error of 0.
Some of these features are not limited to linear systems, they also work on potentially nonlinear
systems which are represented by the generic InputOutputSystem class. Non-linear systems,
discrete time systems and the much more is beyond the scope of this showcase, however.
There is a lot more you can do with the control library, but this is for you to look up as you need
it.
While step responses and frequency responses are a very useful tool for understanding a system’s
behavior, they are limited to LTI systems. Another insightful way to visualize system behavior that
also works for non-linear systems is the phase portrait.
As we saw earlier when solving non-linear differential equations with solve_ivp, the au-
tonomous behavior of a system can be described by the function x(𝑡) ̇ = f(𝑡, x(𝑡)). If the system is
time-invariant, it basically tells us the direction (and magnitude) that the system will change with
̇ for a given state x(𝑡). If the system has two states, this mapping can be visualized as a vector
x(𝑡)
field in the plane.
There are two kinds of matplotlib plots that are suited for visualizing this kind of data:
• quiver allows us to show both the magnitude and direction of the flow, but it is hard to
follow the actual trajectories in phase space.
• streamplot visualizes the trajectories or “streamlines” in phase space, we can still use
color or line width to show the magnitude of the flow.
ls = [Link](-1, 1, 20)
xs, vs = [Link](ls, ls)
# if you write your function right, they work with any dimension of numpy␣
↪arrays
# so we can easily evaluate the function on the entire grid
x_dots, v_dots = dydt_oscillator(0, [xs, vs])
magnitude = [Link]([x_dots, v_dots], axis=0)
We can see how a positive position will lead to a negative change in velocity, and a positive velocity
will lead to a positive change in position so that the trajectories in phase space are spirals around
the origin. The streamlines from streamplot also enable us to see that the trajectories spiral
inwards towards the origin, which is the stable equilibrium point of the system.
While it is good to be aware of these two kinds of plots, you should know that this functionality
and much more are already implemented in control.phase_plane_plot. In addition to the
streamlines or vectorfield, it can also compute and plot some additional features that are especially
interesting for non-linear systems.
A classic example of a simple non-linear differential equation is a pendulum:
̈ = − 𝑔 sin(𝜃(𝑡))
𝜃(𝑡)
𝑙
or in state space form:
̇
𝜃(𝑡) 𝜔(𝑡)
̇ =[
x(𝑡) ]=[ 𝑔 ]
𝜔(𝑡)
̇ − 𝑙 sin(𝜃(𝑡))
where 𝜃(𝑡) is the angle of the pendulum and 𝜔(𝑡) is the angular velocity.
g = l = 1
[Link](figsize=(8, 6))
ct.phase_plane_plot(dydt_pendulum, pointdata=[-4, 4, -3, 3], plot_
↪streamplot={"vary_linewidth": True})
[Link](r"angle $\theta$")
[Link](r"angular velocity $\omega$")
# ct.phase_plane_plot(harmonic_oscillator_sys) # also works with␣
↪StateSpace and other InputOutputSystem objects
[Link]().set_aspect("equal")
[Link](False)
Not only do we get the streamlines, phase_plane_plot also finds the equilibrium points and
plots separatrices around equilibrium points with real valued eigenvalues. As we can see, the
pendulum has stable (if it is dampened) equilibrium points at (𝜃 = 0 mod 2𝜋, 𝜔 = 0) and saddle
points at (𝜃 = 𝜋 mod 2𝜋, 𝜔 = 0) which separate trajectories that swing back and forth from those
that have enough energy swing around.
10.6 Summary
ELEVEN
import numpy as np
import [Link] as plt
[Link]("[Link]")
SciPy and most other numerical packages in other languages usually don’t directly provide func-
tion to solve equations, they only provide functions to find the roots (or zeros) of a function, i.e.
finding the values of 𝑥 for which 𝑓(𝑥) = 0. We can reformulate any equation solving problem
lhs(𝑥) = rhs(𝑥) into a root finding problem by subtracting the right-hand side from the left-hand
side:
Finding the roots or zeros of this function is equivalent to finding the solution to the original equa-
tion.
One of the simplest equations with no closed form solution that appears in real world engineering
problems is the following:
𝑒𝑥 = −𝑥
Equations of this form appear when solving for the current in a simple circuit with a diode attached
to a real voltage source.
𝑈
𝐼0 ⋅ (𝑒 𝑈𝑇 − 1) = (𝑈0 − 𝑈 )/𝑅𝑖
137
Python for Engineers
There is no way to express the solution to this equation in terms of elementary functions. However,
we can still find an arbitrarily precise* numerical solution for a given set of parameters by making
use of numerical root finding methods.
Reformulated to a root finding problem we get:
!
𝑓(𝑥) = 𝑒𝑥 + 𝑥 = 0
Let’s see how we can solve this in Python using functions from [Link]. The submod-
ule contains a few functions that we could use, but the go-to candidate is usually root_scalar,
which provides a unified interface to scalar root finding algorithms.
f = lambda x: [Link](x) + x
res_init = optimize.root_scalar(f, x0=0) # we need to provide an initial␣
↪guess
xs = [Link](-2, 2, 100)
[Link](xs, [Link](xs), label="lhs(x) = exp(x)")
[Link](xs, -xs, label="rhs(x) = -x")
[Link](res_init.root, [Link](res_init.root), color="red", label=
↪"Intersection", zorder=10)
[Link]();
res_init:
converged: True
flag: converged
function_calls: 10
iterations: 5
root: -0.567143290409784
method: newton
res_bracket:
converged: True
flag: converged
function_calls: 7
iterations: 6
root: -0.5671432904097838
method: brentq
11.1.2 Limitations
A critical caveat of these numerical methods is that most of them generally only find a root, not
all roots. The latter is a much harder problem and usually requires more knowledge about the
function you are trying to solve.
There are tricks that we can use to find multiple roots, but they usually require additional informa-
tion and have their own caveats and limitations.
xs = [Link](-2, 2, 100)
f_2 = lambda x: x**2 - 1
Dividing the interval of interest into smaller search regions does give us multiple roots, but there
are still lots of caveats. We can only find one root per segment, so the search grid needs to be fine
enough, another critical parameter that we need to know beforehand. Using the sign to determine
whether a root exists in an interval can fail the function changes sign an even number of times in
the interval. Lastly, the process can quickly become very computationally demanding, especially
in higher dimensions.
We can also solve systems of equations in a similar way. Just like we can reformulate a single
equation as a root finding problem 𝑓(𝑥) = 0, we can reformulate a system of equations as a
vectorial root finding problem f(x) = 0.
For example, let’s consider the following system of equations:
𝑥1 + 𝑥2 = 𝑥21 + 1
𝑥1 ⋅ 𝑥2 = 6
We can reformulate this in terms of the vector x = [𝑥1 , 𝑥2 ]𝑇 and the vectorial function f(x) =
[𝑓1 (x), 𝑓2 (x)]𝑇 :
𝑓1 (x) 𝑥 + 𝑥2 − 𝑥21 − 1
f(x) = [ ]=[ 1 ]
𝑓2 (x) 𝑥1 ⋅ 𝑥2 − 6
11.2 Optimization
Optimization is very much related to root finding, since, as we know from calculus, a minimum or
maximum is a root of the derivative of the respective function (assuming the function is differen-
tiable). However unlike zeroes, not all stationary points are equal, some might be global minima
others might be local maxima and yet others might no minimum or maximum at all.
Let’s start with a simple example that we can also solve by just looking at the function.
We want to find the minimum of 𝑓3 (𝑥) = (𝑥 − 1)2
For this, we will use minimize and just like with the local root finding methods, we also need to
provide an initial guess.
[Link](f_3, x0=0)
The immediate problem that we encounter it that there is no maximize function. Instead, we
have to find the maximum by searching for a minimum of the negated function 𝑓4,neg = −𝑓4 (𝑥).
The optimization terminated successfully, but did we actually find the maximum? Let’s plot the
function in our region of interest and see what it looks like.
xs = [Link](-2, 5)
[Link](xs, f_4(xs))
[Link](res_loc.x, f_4(res_loc.x), color="red", zorder=10);
It seems like we only found a local maximum, which is a fundamental downside to local optimiza-
tion. Just like numerical root finding only finds a single root, numerical optimization only finds a
single optimum. But unlike roots, an optimum might only be local. If we had chosen a different ini-
tial guess we might have found the true optimum but choosing the right initial guess either requires
additional knowledge about the function.
Global optimization attempts to solve this problem by systematically searching the region of inter-
est instead of just iteratively improving an initial guess.
message:
Optimization terminated successfully.
success:
True
fun:
-1.7105680653395763
x:
[ 3.014e+00]
nit:
4
nfev:
81
population:
[[ 3.015e+00]
[ 3.022e+00]
...
[ 2.934e+00]
[ 3.020e+00]]
population_energies: [-1.711e+00 -1.710e+00 ... -1.697e+00 -1.710e+00]
jac: [ 2.220e-08]
It seems to have found the global optimum at 𝑥 ≈ 3, but it is important to know that this is not
guaranteed. Unlike the name might lead you to believe, global optimizers don’t necessarily find
the global optimum, they just search “globally”.
As always, there is much more to know here, just like the root finding functions, the optimization
functions also have a lot of options like:
• method: Choose a different optimization algorithm.
• bounds: Limit the region of valid solutions.
• jac, hess: Provide a way to compute the Jacobian or Hessian of the function to speed up
optimization. These can be automatically computed using libraries like jax.
• constraints: Account for constraints in the optimization problem.
• Various optimizer specific settings that impact performance and accuracy…
A very common kind of optimization problem that you likely already have encountered is curve
fitting. We usually have some input output pairs (𝑥𝑛 , 𝑦𝑛 ), and we want to find some parameters
𝑝1 , 𝑝2 , … such that a function 𝑓(𝑥𝑛 , 𝑝1 , 𝑝2 , …) ≈ 𝑦𝑛 fits our observations as well as possible. But
since “as best as possible” is not really something we can give to minimize we need to define
a loss function (also called cost or objective function) that quantitatively describes how good (or
technically speaking bad) our fit is.
A very common loss function for these kinds of problems is the mean-squared error (MSE) where
we just sum up the squared errors for all observations:
𝑁
𝐿(𝑝1 , 𝑝2 , …) = ∑(𝑓(𝑥𝑛 , 𝑝1 , 𝑝2 , …) − 𝑦𝑛 )2
𝑛=1
Technically this is not the mean squared error but the sum of squared errors, but this does not
change the result of the optimization.
Say we measured the impulse response of a harmonic oscillator. We know it should be a decaying
sinusoidal function like this:
Let’s fit the function to our data to determine the dampened resonance frequency 𝑓 and the damp-
ing factor 𝛾. We will also need to fit the amplitude 𝑎0 .
def fit_func(t: float, a0: float, freq: float, gamma: float, phi: float) -
↪> float:
return a0 * [Link](2 * [Link] * freq * t + phi) * [Link](-gamma * t)
While this was a good exercise in formulating an objective functions, it is an unnecessarily com-
plicated approach. Since this is such a common task, [Link] already provides a
function curve_fit that formulates and solved the optimization problem for you.
On domain where we can make use of these numerical methods is optimal control. As the name
suggests, optimal control concerns itself with optimally controlling systems. This can be either
offline by finding optimal rules to control a system like optimal gains for a PID controller, or online by
finding optimal control inputs to make a system follow a desired trajectory as closely as possible.
For example, we may want to optimize the gains of a feedback controller so that it quickly reaches
its setpoint with minimal ringing. Let’s use the harmonic oscillator that we introduced last lecture.
0 1 0
̇ =[
x(𝑡) 𝑘 𝑑 ] x(𝑡) + [ 1 ] 𝑢(𝑡)
−𝑚 −𝑚 𝑚
While it is already stable we can still improve its performance by enhancing the dampening and
disturbance rejection. The controller should be a linear controller that takes into account the cur-
import control as ct
k = m = 1
d = 0.1
oscillator = [Link](
[Link]([[0, 1], [-k / m, -d / m]]),
[Link]([[0], [1 / m]]),
[Link](2),
[Link]((2, 1)),
)
To optimize the response, we need to define a loss function. Choosing a loss function is in some
sense similar to programming, we need to translate our intuitive idea of good or bad behavior in
to an abstract mathematical form that the optimizer can work with. It is often the most difficult part
i solving these kinds of problems.
We want to minimize the both the deviation in position 𝑦[𝑛] as well as velocity 𝑣[𝑛] while also
penalizing large control inputs 𝑢[𝑛] to avoid an overly aggressive controller. Whenever we have
multiple competing goals like these, we need to prioritize between them by giving them different
weights. This way the optimizer can take the relative importance of each goal into account to
arrive at an overall good solution.
In the absence of any better ideas for how to penalize a deviation, it is typically a good idea to
square it. This often yields the desired results while giving the optimizer a well-behaved function
to work with. With this we choose our loss as:
𝐿(𝑘1 , 𝑘2 ) = ∑ (𝐶𝑦 𝑦[𝑛]2 + 𝐶𝑣 𝑣[𝑛]2 + 𝐶𝑢 𝑢[𝑛]2 )
𝑛
Where 𝐶𝑦 , 𝐶𝑣 , 𝐶𝑢 are the weights for the position, velocity and control input respectively.
C_y = 1
C_v = 1
C_u = 0.1
closed_loop_opt = [Link](res.x)
ct.impulse_response([oscillator, closed_loop, closed_loop_opt], T=20).
↪plot(
label=["open loop", "closed loop", "optimized closed loop"]
);
This kind of optimization is actually a very common problem in control called a linear quadratic
regulator (LQR). The name derives from the fact that the system is linear, and the cost is quadratic.
There are special methods available like control’s lqr that solve these kinds of problems very
efficiently.
[2.31736438 3.5933049 ]
[2.31662479 3.72664992]
The results are not quite equal which is mostly due to numerical errors and the fact that we only
considered a finite time horizon as opposed to lqr which assumes an infinite time horizon.
We can also go a step further. Instead of optimizing the controller ahead of time, we can also
optimize the individual control inputs online. For this let’s assume we have a desired output tra-
jectory 𝑦∗ (𝑡) that we want the system to follow. We want to find the controls 𝑢(𝑡) that minimize the
deviation from this trajectory:
𝑇
∫ (𝑦(𝑡) − 𝑦∗ (𝑡))2 𝑑𝑡
0
To make it a bit more interesting we will also want to limit the control range such that
ts = [Link](0, 5, 50)
desired_response = [Link](ts - 2, 0, 1)
u_max = 1.5
budget = 3
We can see that the resulting controls compensate for and exploit the resonance by anticipating
the initial acceleration and compensating for the ringing after reaching the setpoint. Overall, the
optimization result is very close to the desired trajectory while conforming to all our restrictions.
This way of optimizing the control inputs online is a very simple form of model predictive con-
trol (MPC). If you are doing something with MPC you should probably use a library like do-MPC
instead of implementing this by hand.
11.5 Summary
TWELVE
PYTHON PROJECTS
!python3 my_script.py
are standins for something that you would execute in your shell (bash, PowerShell, …) like this:
An important part of clean project organization is splitting the code into multiple source files called
modules. To make these modules work together, we need to be able to access functions and
other definitions from one module in another.
In the simplest case, we have two files / modules in the same directory, let’s call them [Link]
and [Link]. We want to use definitions from [Link] in [Link].
%%writefile my_module.py
class MyClass:
pass
def my_function():
(continues on next page)
150
Python for Engineers
MY_CONSTANT = 42
%%writefile main_a.py
import my_module
obj = my_module.MyClass()
my_module.my_function()
print(my_module.MY_CONSTANT)
We can also import it under an alias, just like we do with numpy and other common libraries:
%%writefile main_b.py
mod.my_function()
!python3 main_b.py
Importing something with an alias can be confusing to the reader since it is no longer obvious
where the definitions come from, so only do it if it significantly improves readability or the alias is
a common one like with np for numpy.
It is also possible to only import parts of a module using a from ... import ... statement:
%%writefile main_c.py
my_function()
!python3 main_c.py
When we make multiple import calls to the same module, it is only executed once, making the
second import much faster, if the initial import requires expensive operations.
We can also import with an alias when using from imports. This can be useful for avoiding names-
pace conflicts when two modules define the same name. However, in these cases it is often better
to just use the fully qualified name like [Link]() to distinguish them.
%%writefile main_d.py
func()
Finally, it is also possible to import everything from a module using the wildcard * character. This
should mostly be avoided however, since it is not immediately clear which definitions come from
which module if there are multiple wildcard imports.
%%writefile main_e.py
Things get somewhat more complicated when the files are located in different directories. Con-
sider the following directory structure:
src/
├── main/
│ ├── dir_1/
│ │ └── module_1.py
│ │ > def func_1():
│ │ > print("hello from func_1")
│ └── [Link]
│ > ...
├── module_2.py
│ > def func_2():
│ > print("hello from func_2")
└── dir_3/
└── module_3.py
> def func_3():
> print("hello from func_3")
!python3 src/main/main_a.py
We need to use the from syntax here since dir_1 is not a Python file and does not contain an
__init__.py file, more on that here. In general subdirectories are addressed as submodules
using a ., in most cases, this . behaves very similar to the path separator / in the file system.
Importing func_2 into [Link]
This one is not as simple, we need to address a parent of the current file. This is something that
Python does not normally allow. If we attempt it, we get an error message:
%%writefile src/main/main_b.py
module_2.func_2()
!python3 src/main/main_b.py
The interpreter seems to know what we want to do, but it does not allow us to do it. This is because
it always considers the file executed as the root of the project, and it does not allow accessing files
higher up file system hierarchy, as they are not considered part of the package.
The clean solution to this is to execute the file with a different root that contains the file we want
to import. This can be done by telling python to consider the directory as a module using the -m
flag. Since the file we want to execute is now considered a submodule of the package we replace
the path separator / with a dot . and leave out the .py extension:
!python3 -m [Link].main_b
In some cases you may not want to do this, and you simply want to run the file as a script. One
“hack” to work around this, is to add the (parent) directory we want to import from to the list of
locations that Python searches for modules. Normally this contains the current file’s directory and
other directories that contain installed libraries.
import sys
[Link]
['/home/vscode/.pyenv/versions/3.12.9/lib/[Link]',
'/home/vscode/.pyenv/versions/3.12.9/lib/python3.12',
'/home/vscode/.pyenv/versions/3.12.9/lib/python3.12/lib-dynload',
'',
'/builds/python-for-engineers/course/.venv/lib/python3.12/site-packages
↪']
After appending the parent src directory to the [Link] list, we import the module as if we
were importing from that parent path:
%%writefile src/main/main_b_hack.py
import sys
[Link]("src")
import module_2
module_2.func_2()
!python3 src/main/main_b_hack.py
Note that the path we have to append depends on the working directory of the shell we are exe-
cuting the script from. This is not necessarily a bad thing since it is not usually a good idea to just
blindly add the parent directory of the current file to the search path. It is possible however, by
using some path manipulation tricks:
import sys
from pathlib import Path
[Link](str(Path(__file__).absolute().parent))
# file is the current files path, it is not defined in an interactive␣
↪session like Jupyter notebooks
%%writefile src/main/main_c.py
module_3.func_3()
!python3 -m [Link].main_c
%%writefile src/main/main_c_hack.py
import sys
[Link]("src")
module_3.func_3()
!python3 src/main/main_c_hack.py
This should cover most cases that you will encounter in practice. For more details on importing
you can take a look at the official Python tutorial.
As discussed in the previous section, importing a file will execute all top level statements in it. So
if we want to import a function from a script, it will be executed potentially leading to unwanted
side effects or errors. To prevent this, we can include a check that determines whether the file is
being executed as a script and only execute the top level code if it is.
%%writefile my_script.py
def useful_function():
return 42
def main():
print("this is only printed when executed as a script", useful_
↪function())
if __name__ == "__main__":
# only execute this when this is the top level module, ie. it is run␣
↪as a script
main()
!python my_script.py
%%writefile other_script.py
import my_script
!python3 other_script.py
The main use case for this is writing scripts or applications that can also be imported into other files
to allow for easier interoperability without having to resort to the (limited) command line interface
of the script.
So, given this freedom of organization, how should one structure a Python project? As mentioned
in the intro there is not universally agreed upon structure, but this minimal setup should be a
reasonable choice most simpler projects that are not intended to be distributed.
project_directory/
├── docs/
│ └── ... # project documentation
├── tests/
│ └── ... # testing code and resources
├── src/
│ └── package_name/
│ └── ... # source files and directories for submodules
├── [Link] # list of dependencies or [Link] etc. for␣
↪more advanced setup
└── [Link] # project description and setup instructions
There are many ways to manage dependencies for Python projects. The simplest one is to simply
list all required packages in a [Link] file places in the project root. The file contains
one package and optional version constraint per line:
These files are usually created semi-automatically. You first install the packages in your virtual
environment using pip install and then export the list of installed packages and their depen-
dencies to a file using the pip freeze command:
The environment can then be recreated somewhere else by reinstalling the same version of all
the packages listed in the requirements file:
There are also more advanced and modern ways to manage dependencies like Poetry or Pixi.
These tools also take care of other project configuration like build systems, packaging, and pub-
lishing.
12.5 Testing
Testing is an important part of any robust project. These automated tests allow us to verify that
the code works as expected and that changes do not break existing functionality. A very conve-
nient framework for this is pytest. It provides a simple way to run tests and display helpful error
messages when tests fail.
Say we have the following module in our source directory:
%%writefile src/calendar_utils.py
To test it, we create a corresponding file in the tests directory with the same name as the module,
but with a test_ prefix:
%%writefile tests/test_calendar_utils.py
import sys
[Link]("src") # for sibling directory import
# can (and should) alternatively be defined in `pythonpath` in [Link]␣
↪or [Link]
import calendar_utils
def test_is_leap_year():
# use assertions with expected results
assert calendar_utils.is_leap_year(2020) == True
assert calendar_utils.is_leap_year(2021) == False
assert calendar_utils.is_leap_year(2100) == False
assert calendar_utils.is_leap_year(2000) == True
To run the tests, we simply invoke pytest in the project root. It will then automatically discover
all tests in the tests directory and run them. It is usually a good idea to add the -v flag to make
the output more verbose providing a detailed report of the test results.
!pytest -v
collected 1 item ␣
↪
tests/test_calendar_utils.py::test_is_leap_year
FAILED [100%]
=================================== FAILURES␣
↪===================================
______________________________ test_is_leap_year _______________________
↪________
def test_is_leap_year():
# use assertions with expected results
assert calendar_utils.is_leap_year(2020) == True
assert calendar_utils.is_leap_year(2021) == False
> assert calendar_utils.is_leap_year(2100) == False
E assert True == False
E + where True = <function is_leap_year at 0x78969af6f6a0>(2100)
E + where <function is_leap_year at 0x78969af6f6a0> =␣
↪calendar_utils.is_leap_year
tests/test_calendar_utils.py:12: AssertionError
=========================== short test summary info␣
↪============================
FAILED tests/test_calendar_utils.py::test_is_leap_year - assert True ==␣
↪False
+ where True = <function is_leap_year at 0x78969af6f6a0>(2100)
+ where <function is_leap_year at 0x78969af6f6a0> = calendar_utils.
↪is_leap_year
The test fails because the is_leap_year() function is not implemented properly. The test
report shows us exactly which assertion failed and how it differs from the expected value. Years
that are divisible by 100 are not leap years unless they are also divisible by 400. After adding
these additional rules, all tests should pass successfully.
%%writefile src/calendar_utils.py
!pytest -v
tests/test_calendar_utils.py::test_is_leap_year PASSED ␣
↪ [100%]
Writing good tests is often not easy and sometimes only possible in hindsight. Ideally the tests
should cover every possible path through the control flow, but this is rarely feasible. A (somewhat)
more realistic goal is to make sure that the tests cause every line of code to be executed at least
once. This is called code coverage and can be measured using tools like [Link].
Testing should not be overdone, however. There is no point in writing 10 tests that all test the
same code path just with slightly different inputs. Write a test for every edge case or special case
(if feasible), and a few tests for the common cases. If you fixed or at least identified a bug, write a
test that fails because of that bug to avoid reintroducing it in the future. Focus on writing tests for
features, that are hard to get right on the first try and end-to-end tests that verify single use cases
from start to finish.
12.6 Summary