Python For Subsurface
Python For Subsurface
Nestor Cardozo
University of Stavanger, Norway
This book was typeset using LATEX software.
Can one learn Python in just a few days and apply it to meaningful subsur-
face tasks? The answer is yes. With the rise of AI-powered code assistants and
Python’s extensive library ecosystem, learning syntax becomes less critical than
understanding fundamental programming concepts and logical problem-solving.
iii
iv
This course follows that approach. Examples from geosciences and reservoir
engineering illustrate both Python fundamentals (Chapters 2–5) and more ad-
vanced topics, such as data visualization and analysis (Chapters 6–7).
I hope this course inspires you to use Python in your daily work and en-
courage you to continue learning on your own.
Acknowledgements
These notes are adapted from a one-week course I taught at ConocoPhillips in
Stavanger in May 2025. I would like to thank Mathias Tomasgaard, Karl Simp-
son, and Dylan Loss for organizing the course. The original material was based
on internal ConocoPhillips data, which cannot be shared publicly. To make
the course more broadly accessible, I have revised the content to incorporate
publicly available datasets from three sources:
• Well log data from the FORCE 2020 Machine Learning competition.
• the F3 seismic dataset provided by TerraNubis.
Thanks to these organizations for making these data freely available to the
community.
Contents
1 Getting started 1
1.1 Installing Anaconda . . . . . . . . . . . . . . . . . . . . . . . . . 1
1.2 Where to write Python code . . . . . . . . . . . . . . . . . . . . . 2
1.2.1 Jupyter Notebooks . . . . . . . . . . . . . . . . . . . . . . 3
1.2.2 Python files . . . . . . . . . . . . . . . . . . . . . . . . . . 4
1.3 Managing conda environments . . . . . . . . . . . . . . . . . . . 5
1.4 Online resources . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.5 AI code assistants . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.6 Exercises . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
2 Data types 9
2.1 Numbers . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
2.1.1 Mathematical operations . . . . . . . . . . . . . . . . . . 11
2.2 Booleans . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
2.3 Sequences . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
2.3.1 Strings . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
2.3.2 Lists . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
2.3.3 Tuples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
2.4 Dictionaries . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
2.5 Exercises . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
v
vi CONTENTS
4 Control flow 37
4.1 Conditional execution . . . . . . . . . . . . . . . . . . . . . . . . 37
4.2 Iterative execution . . . . . . . . . . . . . . . . . . . . . . . . . . 40
4.2.1 while loop . . . . . . . . . . . . . . . . . . . . . . . . . . . 40
4.2.2 for loop . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
4.2.3 zip, enumerate and break . . . . . . . . . . . . . . . . . . 44
4.3 Vectorization . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45
4.4 List comprehensions . . . . . . . . . . . . . . . . . . . . . . . . . 46
4.5 Exercises . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
5 Organizing code 51
5.1 Functions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51
5.2 Classes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 56
5.3 Modular programming . . . . . . . . . . . . . . . . . . . . . . . . 61
5.4 Exercises . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63
6 Data visualization 65
6.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65
6.2 Well logs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71
6.3 Seismic data . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 78
6.4 Interactive plots . . . . . . . . . . . . . . . . . . . . . . . . . . . 86
6.5 Plotting DataFrames . . . . . . . . . . . . . . . . . . . . . . . . . 89
6.6 Exercises . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 93
7 Data analysis 95
7.1 Univariate analysis . . . . . . . . . . . . . . . . . . . . . . . . . . 95
7.2 Multivariate analysis . . . . . . . . . . . . . . . . . . . . . . . . . 100
7.3 Clustering data . . . . . . . . . . . . . . . . . . . . . . . . . . . . 105
7.4 Exercises . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
Getting started
There are mainly two strategies to setup a Python environment suitable for
scientific computing on your computer:
1. Install the Python core and add all the required scientific packages sepa-
rately.
2. Install a “ready-to-use” Python environment, specifically developed for
scientific purposes.
• conda - a package and environment manager for the command line inter-
face.
• Anaconda Navigator - a desktop application with options to launch ap-
plications and manage environments.
• over 250 scientific and machine learning packages. This is perhaps more
than we need.
Download Anaconda. You can skip the registration and follow the steps to
install it on your computer.1
1 Depending on your organization’s setup, you may need to use the Anaconda distribution
on your server.
1
2 CHAPTER 1. GETTING STARTED
Once Anaconda is installed, you can open the Anaconda Prompt from the
Search box (Windows) or a Terminal window (macOS or Linux; Figure 1.1a).
From the Search box you can open as well Anaconda Navigator (Windows), or
in the Applications folder double-click the Anaconda Navigator icon (macOS;
Figure 1.1b).
conda list
Figure 1.2: a. VS Code Welcome window. Red square is the Extensions icon.
b. VS Code extensions, including Python and Jupyter.
7. Select the right pointing triangle to the left of the cell to run it. You
should get a result like in Figure 1.3a.
8. Save the notebook.
5. Run the file by selecting the right pointing triangle in the upper right
corner of the window.
Notice that Python (.py) files are simpler than notebook (.ipynb) files, and
they can be edited on any text program.
2. Type:
3. Type:
4. Type:
to check the environment is installed. You should see the base and pfs
environments.
conda list
7. Now install the packages you need for the course. Type the following:
For formatting reasons, I broke the line (\), but you can have everything
in one line.
8. You will also need to install another package using pip. Type the follow-
ing:
9. Type again:
conda list
10. To deactivate the environment and come back to the base environment,
type:
conda deactivate
That’s it! You’ve now set up the environment for the course, and you can
select it in VS Code as we discussed in section 1.2. For further information
check this cheat sheet for managing conda environments.
Note
As shown above, a package can also be installed using the command pip install.
However, always try conda install first, because conda is better at checking
for internal consistency between the packages. If that does not work, then you
can try pip install. This will work most of the time.
1.4. ONLINE RESOURCES 7
Figure 1.4: a. ChatGPT query, and b. the result of running the code.
8 CHAPTER 1. GETTING STARTED
• Microsoft Copilot.
• ChatGPT.
• GitHub Copilot. If you are using this assistant, make sure to install in
VS Code the GitHub Copilot extension. After that and signing to your
GitHub account, you will be able to use GitHub Copilot directly in VS
Code.
I would encourage you to use AI assistants in your coding. The code gener-
ated is generally good, and if there are parts you don’t understand, it is possible
to get further help from the assistant. This book teaches Python using GitHub
Copilot and ChatGPT.
1.6 Exercises
1. Use Copilot or ChatGPT to learn how to plot the sine function in Python.
Ask the AI assistant to explain what the suggested code does. Then, copy
the code into both a Jupyter Notebook (.ipynb) and a standard Python
script file (.py) using VS Code. Run the code in both environments and
verify that the output is correct.
2. Next, plot both the sine and cosine functions in Python, with one graph
positioned above the other in the same figure. Repeat the same steps as
in the previous task: use the AI assistant, review the explanation, add
the code to both file formats, and confirm that the output displays as
expected.
Chapter 2
Data types
Here, 65 and "Cretaceous" are assigned to the variables age and top re-
spectively. The first line starting with # is a comment. Comments are ignored
by Python but are useful for documenting your code. Including comments is
always a good practice, as it helps others (and your future self) understand
what your code is doing.
Figure 2.1 shows the standard data types in Python. These are covered in
detail in the next sections, starting with Numbers.
2.1 Numbers
Python supports integers, floats, and complex numbers. Integers and floats
differ by the presence or absence of decimals. Complex numbers have a real
part and an imaginary part:
1 The code for this chapter is included in the notebook [Link]. As a best practice,
I recommend setting the kernel in VS Code to the course environment for all notebooks and
Python files throughout the course.
9
10 CHAPTER 2. DATA TYPES
my_integer = 32 # integer
my_float = 23.00 # float
my_complex = 5 + 3j # complex: real part = 5, imaginary part = 3
Python has several built-in functions. We can use the function type()
to find out the type of a variable, and the function print() to output this
information:
So our numbers are actually classes. The concept of class comes from the
object oriented philosophy of Python (chapter 5). For the moment, just realize
that the numbers above are class instances or objects that come with built-
in members. Some of these members are attributes of the object. Others are
methods, which perform actions and must be called using parentheses (). For
example, to output the real and imaginary parts of my_complex, we can do the
following:
print(my_complex.real, my_complex.imag)
5.0 3.0
2.1. NUMBERS 11
When you write code in VS Code, the editor suggests completions as you
type. You can press the Tab key to insert the most relevant suggestion. This
functionality is powered by IntelliSense, which includes code completion, con-
tent assist, and code hinting. For example, if you type my_complex.r, the
program will show a list of members of my_complex starting with r, with the
best match—real—at the top. You can see quick info for each member by se-
lecting the > icon, which expands the accompanying documentation to the side
2.2). IntelliSense is a powerful tool that helps you explore Python functions,
variables, and object members—such as attributes and methods—as you write
code. For more information, check out this site.
Alternatively, you can use Python’s dir() function to view all the attributes
and methods of an object.
a = 27
b = 4
print(a + b) # addition
print(a - b) # subtraction
print(a * b) # multiplication
print(a / b) # division
print(b ** 3) # exponentiation
print(a // b) # floor division
print(a % b) # modulo division
31
23
108
12 CHAPTER 2. DATA TYPES
6.75
64
6
3
Notice that division / returns a float value, even though the two numbers
are integers. Floor (or integer) division // returns the quotient of the division
rounded down to the nearest whole number, while modulo division % returns
the remainder of the division (for negative numbers this is more complicated,
see this note).
These operations also work with complex numbers. Let’s try the following:
3i 9 12
= − + i ≈ −0.360 + 0.480i (2.1)
4 − 3i 25 25
-0.36 0.48
However, for more advanced operations, we need to import the math library:
0.49999999999999994 0.49999999999999994
The first line of the code imports the math library, giving access to its func-
tions using the math.function_name() format. Options 1 and 2 both calculate
the sine of an angle. Note that the angle must first be converted from de-
grees to radians, since Python (and most programming languages) use radians
by default. Finally, the result isn’t exactly 0.5 due to floating-point precision
errors—these small inaccuracies are a normal consequence of how computers
2.2. BOOLEANS 13
The math library has a wide range of mathematical functions. You can find
the complete list here. Another way to quickly find out the functions in the
math library is to type math.; VS Code will show a list of the functions, and
you can find information about any of them by selecting the > icon.
2.2 Booleans
Booleans (or bools) can only have two values, True or False. Let’s look at
some examples:
a = 10 # variable a
b = 7 # variable b
c = (a > b) # bool c
d = (a == b) # bool d
e = (a < b) # bool e
f = (a != b) # bool f
The operators > (greater than), == (equal), < (lower than), and != (not
equal) are called comparison operators. You can find a list of these operators
here.
angle = 0.5
corrected_angle = (angle >= 1.0) * angle
print(corrected_angle)
0.0
Let’s wrap up this section with a mind-blowing experiment. Can you predict
what the output of the following code will be? Go ahead and run it to see the
result—then try to figure out why it behaves that way. Hint: Try printing the
values of a and b to help you understand what’s going on.
14 CHAPTER 2. DATA TYPES
a = 0.1 + 0.2
b = 0.3
print(a == b)
2.3 Sequences
A sequence is an ordered collection of items. Examples of sequences are strings,
lists, and tuples. Strings are sequences of characters, lists are ordered collections
of data (which can be of different type), and tuples are similar to lists, but they
cannot be modified after their creation. The elements of a sequence can be
accessed using indexes within brackets ([]):
• The first index of a sequence is always 0.
• Using negative integers (e.g., -1) as indices accesses elements starting
from the end of the sequence, where -1 refers to the last element, -2 to
the second last, and so on.
• Two integers separated by a colon (e.g. 3:7) define an index range, sam-
pling the sequence from the lower to the upper index, but not including
the upper index (i.e., indexes 3, 4, 5 and 6).
• Omitting the lower index in a slice (e.g., :5) means slicing from the start
of the sequence up to, but not including, the upper index.
• Omitting the upper index in a slice (e.g., 4:) means slicing from the lower
index through to the end of the sequence.
• Negative indices can also be used in slices (e.g., -2:), which selects ele-
ments starting from the second-to-last element through to the end of the
sequence.
• A slice can include a third value, called the step (e.g., 1:10:2), which
defines the stride between elements selected (in this example, every second
element).
• Negative steps in a slice (e.g., ::-1) reverse the sequence by stepping back-
ward through it.
Let’s look now at each of the sequence types, starting with strings.
2.3.1 Strings
Strings are sequences of characters and are defined within quotes—either sin-
gle quotes (’hello’), or double quotes ("hello"). Personally, I prefer using
double quotes since a string may contain an apostrophe (’). Strings can be
concatenated with the + operator and repeated with the * operator. Let’s take
a look:
2.3. SEQUENCES 15
Stressed desserts
Stressed dessertsStressed desserts
Stress
Stress
desserts
Srse esrs
stressed dessertS
Notice that the blank space is also a character. Formatted strings (f-strings)
can be used to include numerical values with a desired notation. Here is one
example:
Note that the string is prefixed with an f, and the value goes inside curly
braces {}, with a format specifier for the desired notation. f-strings were intro-
duced in Python 3.6 and they are the preferred way of formatting strings. You
can find more information about them here.
s = "Fake"
s[0] = "R" # error: string does not support item assignment
s = "Fake"
s = "R" + s[1:] # replace F with R
or:
s = "Fake"
s = [Link]("F", "R") # replace F with R
2.3.2 Lists
Lists are perhaps the most flexible type of sequence in Python, as they can
contain different data types (e.g., numbers and strings) and can grow or shrink
in size to accommodate their elements. Whenever you need to collect multiple
values in Python, lists are a great option. Lists are defined inside squared
brackets []. The symbol * can be used to create copies of a list, and the
symbol + can be used to concatenate lists. The function len() returns the
number of elements in a list (and any collection). For example:
# empty list
empty = []
print(empty)
[]
[0, 'zero', 0, 'zero', 1, 'one', 1, 'one']
8
tops = [252 ,"TR", 200, "J", 145, "K", 66, "Pa"] # geologic tops
print(tops)
And of course, you can access the elements of the list by their index:
2.3.3 Tuples
A tuple is also an ordered collection of elements which can be of different type.
However, a tuple is immutable. Once you create a tuple, it cannot be modified.
Tuples are defined inside parentheses ():
f_tops = (252 ,"TR", 201, "J", 145, "K", 66, "Pa") # geologic tops
print(f_tops)
Tuples are useful for storing data that should remain constant throughout
the execution of a program—for example, the months of the year.
2.4 Dictionaries
Dictionaries are collections of key-value pairs. They are defined inside curly
braces {}, with each key-value pair separated by a comma, and each key sep-
arated from its value by a colon. The order of items in a dictionary does not
matter. To retrieve a value, you use the corresponding key inside square brack-
ets.
Let’s update the code we wrote earlier to calculate the volume of the Earth
(p. 14), and make it more flexible by calculating the volume ratio of any two
planets in the solar system. To do this, we’ll use a dictionary, with the keys
being the planets’ names, and the values the planets’ radii:
18 CHAPTER 2. DATA TYPES
It is easy to add and remove key-value pairs from a dictionary. The dictio-
nary has also methods to list its keys, values, or items:
Dictionaries offer a flexible and efficient way to organize data that maps
unique keys to specific values, making them ideal for storing information like
petrophysical properties or unit conversion factors. JSON files are basically just
Python dictionaries, which shows how important dictionaries are.
2.5 Exercises
1. The average radius of the Earth is 6371 km. The oceans cover 70% of
the surface of the planet, and the average depth of the oceans is 3700
m. Estimate the surface area of the Earth, the surface area occupied by
oceans, and the volume of the oceans (Hint: The surface area of a sphere
is 4πr2 ).
2. Update the code to calculate the volume ratio between any two planets
in the solar system (page 18), so that the user can enter the names of the
two planets interactively. Hint: Use the Python input() function.
3. The table below shows the conversion of different pressure units to Pascals
(Pa):
Unit Pa
atm 101,325
bar 100,000
MPa 1’000,000
mm Hg 133.32
mm H2 O 9.81
psi 6894.76
(a) Make a dictionary to represent this table. The keys of the dictionary
are the units names, and the values are the units in Pascals.
(b) The maximum depth of the 14.4 km long Ryfylke tunnel is 292 m
below sea level. What is the pressure at this depth in Pa? Hint:
Pressure = water density * gravity * depth. Use water density =
1000 kg/m3 , and gravity = 9.8 m/s2 .
(c) What is the pressure at this depth in atm, mm Hg, and psi? Use
your dictionary to solve this problem.
20 CHAPTER 2. DATA TYPES
Chapter 3
In Chapter 2, we covered Python’s basic data types. For more complex and
efficient data analysis, however, we often rely on arrays and DataFrames. These
are powerful data structures offered by the NumPy and Pandas libraries, re-
spectively.
3.1 Arrays
Like lists, arrays are collections of items, but of the same type (e.g., all numbers
or all strings). To use arrays, we need to import the NumPy library1 :
my_array = [Link]([-10, 30, 60, 90, 120, 150, -100]) # new array
print(my_array)
21
22 CHAPTER 3. ENHANCED DATA STRUCTURES
Notice that arrays do not have methods to append, insert, or delete ele-
ments. Instead, we use NumPy’s standalone append(), insert(), or delete()
methods, each of which returns a new array. This means arrays are not ideal
for collecting objects incrementally—every time we add an item, a new array
is created. If you’re adding many items, this repeated copying can significantly
slow down your code.
Now, let’s use index ranges to print some elements of the array:
Index ranges are quite powerful. Suppose we want to calculate the differ-
ences between successive elements of the array. We can do this in one line of
code as follows:
[30 30 30 30 30 30 30 30 30 30 30]
my_array[1:] contains the second to the last element of the array, while
my_array[:-1] contains the first to the penultimate element of the array. Sub-
tracting these two arrays gives us the differences between the elements of the
array.2
We can use the array size attribute to get the number of elements in the
array:
and the dtype attribute to find out the type of elements in the array:
my_array.dtype
dtype('int64')
3.1.1 2D arrays
A 2D array is an array of 1D arrays. It can be constructed as follows:
# create a 3 x 4 array
my_2d_array = [Link]([[1, 2, 3, 4], [5, 6, 7, 8],
[9, 10, 11, 12]])
print(my_2d_array)
[[ 1 2 3 4]
[ 5 6 7 8]
[ 9 10 11 12]]
To access an element of the array, we use two indexes within brackets. The
first index refers to the row, and the second index to the column of the array.
This is illustrated in Figure 3.1 with a library cabinet for the box at row index
2 and column index 2:
11
Index ranges allow us to quickly access several elements of the array. This
is referred to as slicing the array. Figure 3.2a shows how to access the second
row of the cabinet. Let’s do the same with our array:
[5 6 7 8]
And Figure 3.2b shows how to select the second column of the cabinet. Let’s
do the same with our array:
24 CHAPTER 3. ENHANCED DATA STRUCTURES
[ 2 6 10]
[[1 2 3 4]
[5 6 7 8]]
[[ 3 4]
[ 7 8]
[11 12]]
We can use the array shape attribute to obtain the number of rows and
columns in the array. This returns a tuple whose first element is the number of
rows, and second element is the number of columns:
3.1. ARRAYS 25
Figure 3.2: Selecting a. the second row, and b. second column of cabinet.
3.1.2 3D arrays
3D arrays work the same way, they are arrays of 2D arrays:
[[[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]]
[[12 13 14 15]
[16 17 18 19]
[20 21 22 23]]]
[[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]]
[[12 13 14 15]
[16 17 18 19]
[20 21 22 23]]
[4 5 6 7]
[14 18 22]
Finally, the array ndim attribute tells us the dimensions of the array:
print(my_array.ndim)
print(my_2d_array.ndim)
print(my_3d_array.ndim)
1
2
3
Suppose we want to extract the months with precipitation higher than 100
mm. We can do the following:
[ True False True False False False False False False True True True]
Months with precipitation > 100 mm: ['Jan' 'Mar' 'Oct' 'Nov' 'Dec']
Precipitation in these months: [142 114 109 165 137]
• Element-wise operations
• Linear algebra operations
Element-wise operations
These are simple element-wise operations that involve an array3 , and array and
a scalar, or two arrays of the same dimension. For example:
# create a 3 x 3 array
array_a = [Link]([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
print(array_a, "\n") # print array_a
[[1 2 3]
[4 5 6]
[7 8 9]]
[[ 3 4 5]
[ 6 7 8]
[ 9 10 11]]
3 Mathematical functions on an array, and NumPy has plenty of them.
28 CHAPTER 3. ENHANCED DATA STRUCTURES
[[-1 0 1]
[ 2 3 4]
[ 5 6 7]]
[[ 2 4 6]
[ 8 10 12]
[14 16 18]]
[[0.5 1. 1.5]
[2. 2.5 3. ]
[3.5 4. 4.5]]
[[ 1 4 9]
[16 25 36]
[49 64 81]]
[[10 10 10]
[10 10 10]
[10 10 10]]
[[-8 -6 -4]
[-2 0 2]
[ 4 6 8]]
[[ 9 16 21]
[24 25 24]
[21 16 9]]
[[ 1 256 2187]
[4096 3125 1296]
[ 343 64 9]]
3.742
1.0
32
[-3 6 -3]
[[ 58 64]
[139 154]]
-14.0
[[ 1. 0. -0.]
[ 0. 1. 0.]
[ 0. 0. 1.]]
Here we use NumPy’s linear algebra functions, some of which are found in
the linalg module.
3.2 DataFrames
A DataFrame is a two-dimensional data structure in which information is or-
ganized in rows and columns, much like a table. To use DataFrames, we need
to import the Pandas library4 :
Let’s see one example. Suppose we want to convert the following table into
a DataFrame:
Here, we added columns to the DataFrame using lists. The column on the
far left—unlabeled by default—displays the index values, which help identify
individual rows. Alternatively, we can create the DataFrame using a dictionary:
# create Dictionary
my_dict = {"mineral":["halite", "quartz", "hematite",
"rutile", "olivine"],
"hardness":[2.5, 7, 6, 6, 7],
"density":[2.17, 2.65, 5.3, 4.24, 3.4]}
In this case the dictionary keys are the names of the columns, and the values
are the entries of the columns. What type of object is a DataFrame column?
Let’s see:
print(type(df["mineral"]),type(df["hardness"]),type(df["density"]))
# path to file
path = [Link]("..", "data", "field_production_monthly.csv")
In the cell above, we use Python’s os library to construct a path to the CSV
file5 . We then load the data using Pandas’ read_csv() method, which reads
5 [Link] automatically handles file path separators across different operating sys-
We can get more information about the DataFrame using its info() method:
<class '[Link]'>
RangeIndex: 26580 entries, 0 to 26579
Data columns (total 10 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 prfInformationCarrier 26580 non-null object
1 prfYear 26580 non-null int64
2 prfMonth 26580 non-null int64
3 prfPrdOilNetMillSm3 26580 non-null float64
4 prfPrdGasNetBillSm3 26580 non-null float64
5 prfPrdNGLNetMillSm3 26580 non-null float64
6 prfPrdCondensateNetMillSm3 26580 non-null float64
7 prfPrdOeNetMillSm3 26580 non-null float64
8 prfPrdProducedWaterInFieldMillSm3 26580 non-null float64
9 prfNpdidInformationCarrier 26580 non-null int64
dtypes: float64(6), int64(3), object(1)
memory usage: 2.0+ MB
The DataFrame has 10 columns and 26,580 entries. The column names are
rather long, so for convenience let’s extract them into a list. We can use the
DataFrame columns attribute to do that:
# column names
columns = [Link]()
print(columns)
Let’s filter the DataFrame to include only the fields operated by Cono-
coPhillips. First, we create a list of the relevant field names. Then, we filter the
DataFrame by selecting rows where the first column (columns[0], which contains
the field names) matches an entry in our list. This is done using the isin()
method of a pandas Series. This method returns a Boolean Series, which we
use to filter the DataFrame.
<class '[Link]'>
Index: 3233 entries, 101 to 24545
Data columns (total 10 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 prfInformationCarrier 3233 non-null object
1 prfYear 3233 non-null int64
2 prfMonth 3233 non-null int64
3 prfPrdOilNetMillSm3 3233 non-null float64
4 prfPrdGasNetBillSm3 3233 non-null float64
5 prfPrdNGLNetMillSm3 3233 non-null float64
6 prfPrdCondensateNetMillSm3 3233 non-null float64
7 prfPrdOeNetMillSm3 3233 non-null float64
8 prfPrdProducedWaterInFieldMillSm3 3233 non-null float64
9 prfNpdidInformationCarrier 3233 non-null int64
dtypes: float64(6), int64(3), object(1)
memory usage: 277.8+ KB
The DataFrame has now only 3,233 entries. Let’s look at the production
of Ekofisk. In the cell below, we make a smaller DataFrame for Ekofisk, and
use the DataFrame describe() method to get a descriptive statistics of the
DataFrame:
To end this example, let’s calculate the oil and water production of Ekofisk
in a given year. To do that, we filter the DataFrame to the year of interest,
and sum the entries of the oil production (columns[3]) and water production
(columns[8]) columns, using the Series sum() method:
I hope this example has given you a glimpse of the power of the Pandas
library. We’ll continue using Pandas throughout the course.
3.3 Exercises
1. The file xeek_train_subset.csv in the data folder, contains the logs of
12 wells from the Force 2020 Machine Learning lithology competition6 .
6 This is a subset of the original dataset which is available at Andy McDonald’s Petrophysics
Python Series
36 CHAPTER 3. ENHANCED DATA STRUCTURES
(a) Load the file using the Pandas read_csv() method. Use the DataFrame
head() and info() methods to learn more about the DataFrame.
(b) Extract the well 16/10-1 from the DataFrame. Hint: The well names
are in column WELL.
(c) The column FORCE_2020_LITHOFACIES_LITHOLOGY contains lithol-
ogy numbers. The significance of these numbers is as follows:
2. This exercise builds on Exercise 1 and continues working with the well
16/10-1.
(a) Extract the Gamma Ray log of the well (column GR) to a NumPy
array. Hint: You can use either the Series values attribute or the
to_numpy() method.
(b) Smooth the GR curve with a 5-point moving average. Hint: Use the
NumPy convolve() method. Pass to this method the GR array and
the moving average filter.
(c) Define a threshold (e.g., GR > 100 API) to identify shale-rich inter-
vals.
(d) Extract the depths (column DEPTH_MD) that are likely shale.
Chapter 4
Control flow
if␣expression:
␣␣␣␣statement_1
else:
␣␣␣␣statement_2
37
38 CHAPTER 4. CONTROL FLOW
# do the conversion
if t_type == "C":
tc_type = "F"
tc_val = 9 / 5 * t_val + 32
else:
tc_type = "C"
tc_val = 5 / 9 * (t_val - 32)
23.00 C = 73.40 F
In the cell above, and and or are logical operators. They are used to com-
bine conditional statements. Another logical operator is not, which negates a
Boolean value-returning False if the condition is True, and vice versa. You
can learn more about logical operators here.
<class '[Link]'>
RangeIndex: 26580 entries, 0 to 26579
Data columns (total 10 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 prfInformationCarrier 26580 non-null object
1 prfYear 26580 non-null int64
2 prfMonth 26580 non-null int64
3 prfPrdOilNetMillSm3 26580 non-null float64
4 prfPrdGasNetBillSm3 26580 non-null float64
5 prfPrdNGLNetMillSm3 26580 non-null float64
6 prfPrdCondensateNetMillSm3 26580 non-null float64
7 prfPrdOeNetMillSm3 26580 non-null float64
8 prfPrdProducedWaterInFieldMillSm3 26580 non-null float64
9 prfNpdidInformationCarrier 26580 non-null int64
dtypes: float64(6), int64(3), object(1)
memory usage: 2.0+ MB
initialize_variable
while␣expression:
4.2. ITERATIVE EXECUTION 41
␣␣␣␣statement
␣␣␣␣modify_variable
number = 0 # number
sum = 0 # sum
while number < 100_000:
# if number is multiple of 3 or 5
if number % 3 == 0 or number % 5 == 0:
sum += number # add number to sum
number += 1 # increment number
Depth[m] Pressure[kPa]
-------------------------
0.0 0.0
50.0 490.5
100.0 981.0
150.0 1471.5
200.0 1962.0
250.0 3286.3
300.0 4610.7
350.0 5935.1
400.0 7259.4
450.0 8583.8
500.0 9908.1
The while loop is especially useful in situations where the number of itera-
tions isn’t known in advance. For example:
attempts = 0
die1 = die2 = 0
attempts += 1
We don’t know how many attempts it will take to roll double sixes — it’s
entirely random. This example also demonstrates the use of Python’s built-in
random module.
The syntax comprises an item and a sequence. The sequence can be any
collection of data. During the execution of the loop, the first element of the
sequence is assigned to item and the statement(s) of the loop body are exe-
cuted, then the next element is assigned to item and the statement(s) are again
executed, and so on until all elements of the sequence are exhausted. Figure
4.2b illustrates this process.
Let’s try the examples above, but this time using a for loop:
sum = 0 # sum
# table heading
print(f"{"Depth[m]":<10} Pressure[kPa]")
print("-" * 25)
44 CHAPTER 4. CONTROL FLOW
for d in ds:
if d <= d_w: # if in water
p = rho_w * g * d
else: # else if in rock
p = p_w + rho_r * g * (d - d_w)
# print depth and pressure in kPa
print(f"{d:<10.1f} {p*1e-3:.1f}")
Depth[m] Pressure[kPa]
-------------------------
0.0 0.0
50.0 490.5
100.0 981.0
150.0 1471.5
200.0 1962.0
250.0 3286.3
300.0 4610.7
350.0 5935.1
400.0 7259.4
450.0 8583.8
500.0 9908.1
Quartz hardness is 7
Gypsum hardness is 2
Talc hardness is 1
4.3. VECTORIZATION 45
It is also possible to extract the index of the iteration along with the elements
by using the built-in enumerate() function, which makes it easy to track the
position in the sequence while iterating:
1. Quartz hardness is 7
2. Gypsum hardness is 2
3. Talc hardness is 1
Finally, you can use the break statement to stop a loop and exit it. Here’s
an example:
while True:
# ask user for a guess, int is used to convert input to int
guess = int(input("Guess the number: "))
if guess == number: # if guess is correct
print("You guessed it!")
break # exit the loop
elif guess < number: # if guess is too low
print("Too low!")
else: # if guess is too high
print("Too high!")
Too low!
Too low!
Too high!
You guessed it!
4.3 Vectorization
Vectorized operations can significantly improve the performance of your code.
Below is a vectorized version of the depth versus pressure code above. It gives
the same output:
# table heading
print(f"{"Depth[m]":<10} Pressure[kPa]")
print("-" * 25)
The boolean arrays water and rock indicate the depths at which these ma-
terials are present. Pressures (ps) are calculated in a single line using vectorized
operations. In this calculation, the water and rock arrays are automatically
converted by Python: True values become 1 and False values become 0. Fi-
nally, the for loop outputs the depths and corresponding pressures. The zip()
function allows simultaneous iteration over the ds (depths) and ps (pressures)
arrays.
Figure 4.3: Balance between performance and clarity. From Work Chronicles.
# table heading
print(f"{"Depth[m]":<10} Pressure[kPa]")
print("-" * 25)
This is pretty slick but perhaps not as clear as using a loop to calculate
pressures (Figure 4.3).
4.5 Exercises
1. The file xeek_train_subset.csv contains well log data including gamma
ray (GR) values and stratigraphic information.
(a) Extract the data for well 16/10-1 into a Pandas DataFrame.
(b) From the gamma ray log (column GR), compute a new column called
VSH (Volume of Shale) using different equations depending on the
age of the rocks. For rocks older than the Tertiary, use the following
equation:
where GR is the gamma ray value for the sample, GR min is the
minimum GR (0), and GR max is the maximum GR (200).
Hints:
• You should first compute IGR using the formula for all rows.
• Then apply the appropriate VSH equation depending on the
group name (in the GROUP column).
• Add the resulting VSH values as a new column in the DataFrame.
3. This exercise builds on Exercise 2 and continues working with the well
16/10-1.
(a) Create a dictionary that maps each geological group present in the
well to its top depth defined as the shallowest DEPTH_MD value where
that group appears.
Hints:
• Filter the data to include only rows for well 16/10-1.
• For each unique group in the GROUP column, find the minimum
value in the DEPTH_MD column where that group occurs.
• Construct a dictionary where the keys are the group names, and
the values are the corresponding top depths.
4.5. EXERCISES 49
(b) The above procedure will not work if the interval of interest is re-
peated in the well. For groups this does not seem to be a problem,
but for facies (column FORCE_2020_LITHOFACIES_LITHOLOGY) it is.
Create a dictionary that maps each facies in the well to its top depth.
Hints:
• Find the top depth of each facies. This is the first row where a
new facies appears.
• Build a dictionary mapping facies to their top depths. If a facies
appears more than once, append the row index to the key (e.g.
"65000-3").
50 CHAPTER 4. CONTROL FLOW
Chapter 5
Organizing code
Getting code to run is only the beginning. As your projects grow, keeping things
organized becomes just as important as making them work. Without structure,
your code can quickly turn into spaghetti code—a tangled mess that’s hard to
understand, reuse, or build on.
Good organization is key to writing code that lasts. Whether you’re working
solo or with others, how you structure your code affects how easily it can be
read, maintained, and improved.
In this chapter, we’ll explore how functions, classes, and modular program-
ming bring clarity and structure to your code, helping you build programs that
are both robust and scalable.
5.1 Functions
A function is a reusable block of code that performs a specific task. It allows
you to group related instructions under a single name, so you can run them
whenever you need—without repeating yourself. Functions help make your
code more organized, readable, and easier to maintain.
def is the header of the function, it generates the function object and assigns
a name to it. In the parentheses, the input parameters are included. When the
51
52 CHAPTER 5. ORGANIZING CODE
function does not have any input parameter, then the parentheses is left empty.
After the colon (:), the function statements are included. The return statement
returns a value. If val is not specified, the function returns None. Both val
and the return statement are optional.
Let’s turn the code that reads data from Factpages into a function1 :
def read_factpages(descriptor):
"""
Read data from NOD factpages
Input:
descriptor: String with NOD descriptor,
e.g. "field_production_monthly"
Output:
df: DataFrame with the data or
empty if data could not be read
"""
# construct the URL
u_1 = "[Link]
u_2 = "&rs:Command=Render&rc:Toolbar=false&rc:Parameters=f"
u_3 = "&IpAddress=not_used&CultureCode=en&rs:Format=CSV&Top100=false"
url = u_1 + descriptor + u_2 + u_3
return df
The text enclosed in triple quotes is called a docstring—it describes what the
function does. Including a clear and concise docstring is always a good habit,
as it helps others (and your future self) understand the purpose of the function.
1 The code for this chapter is included in the notebook [Link]
5.1. FUNCTIONS 53
<class '[Link]'>
RangeIndex: 26580 entries, 0 to 26579
Data columns (total 10 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 prfInformationCarrier 26580 non-null object
1 prfYear 26580 non-null int64
2 prfMonth 26580 non-null int64
3 prfPrdOilNetMillSm3 26580 non-null float64
4 prfPrdGasNetBillSm3 26580 non-null float64
5 prfPrdNGLNetMillSm3 26580 non-null float64
6 prfPrdCondensateNetMillSm3 26580 non-null float64
7 prfPrdOeNetMillSm3 26580 non-null float64
8 prfPrdProducedWaterInFieldMillSm3 26580 non-null float64
9 prfNpdidInformationCarrier 26580 non-null int64
dtypes: float64(6), int64(3), object(1)
memory usage: 2.0+ MB
<class '[Link]'>
RangeIndex: 2146 entries, 0 to 2145
Data columns (total 87 columns):
54 CHAPTER 5. ORGANIZING CODE
Let’s try another example. The area of a polygon of any shape (except one
that crosses itself) can be written as:
n
1X
A= (xi yi+1 − xi+1 yi ) if i + 1 > n, i + 1 = 1 (5.1)
2 i=1
where n is the number of points in the polygon, and (xi , yi ) are the x and
y coordinates of the points. For example, for a four points polygon the area is:
1
A= (x1 y2 − x2 y1 + x2 y3 − x3 y2 + x3 y4 − x4 y3 + x4 y1 − x1 y4 ) (5.2)
2
This equation returns a positive area if the points are ordered counter-
clockwise, or a negative area if the points are ordered clockwise. Let’s implement
a function that computes the area of a polygon using this formula:
def polyg_area(x,y):
"""
calculate and return the area of a polygon
from the x and y coordinates of its points
Note: points must be in sequential order
"""
npoints = [Link] # number of points
area = 0.0 # initialize area
# return area
return [Link](area)/2
def polyg_area(x,y):
"""
calculate and return the area of a polygon
from the x and y coordinates of its points
Note: points must be in sequential order
"""
return 0.5*[Link]([Link](x, [Link](y,1))
- [Link](y, [Link](x,1)))
The file net_oil.txt in the data directory, contains the x(east), y (north),
and z (value) of contours in an isochore map (vertical thickness) of net oil in a
trap. All values are in meters. Let’s compute the volume of the trap. We first
determine the area of the contours using our function:
for i in range(c_values.size):
# extract contour
contour = contours[contours[:,2] == c_values[i]]
# calculate area
c_areas[i] = polyg_area(contour[:,0],contour[:,1])
# plot contour
[Link](contour[:,0],contour[:,1],".",markersize=2)
2 To understand why this line works, check the NumPy roll and dot methods.
56 CHAPTER 5. ORGANIZING CODE
In the code above, we also plotted the contours using the Matplotlib library.
While this will be covered in more detail in Chapter 6, for now just notice how
straightforward it is to visualize data in Python.
To understand why this works, imagine slicing the volume into many hor-
izontal layers—these are the contours. We can estimate the volume between
each pair of adjacent contours and then sum them all to obtain the total volume.
5.2 Classes
Object-oriented design underpins many of Python’s libraries and tools. Python
is fundamentally an object-oriented programming (OOP) language. It organizes
code around objects—structures that combine data with the functions (called
5.2. CLASSES 57
methods) that operate on that data. The two main building blocks of OOP are
classes and objects.
Python makes working with classes simple. Here’s a basic class definition:
class ClassName:
def __init__(self, parameters):
# initialization code
[Link] = value
def method(self):
# method code
class Circle:
"""
A class that implements a circle
"""
# initialization requires center [x, y]
# and radius of circle
def __init__(self, center, radius):
[Link] = center
[Link] = radius
# methods
# circumference
def circumference(self):
return 2 * [Link] * [Link]
# area
def area(self):
return [Link] * [Link] ** 2
# shift center in x
def shift_in_x(self, x_value):
[Link][0] += x_value
58 CHAPTER 5. ORGANIZING CODE
# shift center in y
def shift_in_y(self, y_value):
[Link][1] += y_value
Now let’s use this class to fill a 20 × 20 unit square with circles of radius 1.
We’ll also calculate the areal porosity, which measures the fraction of the area
not occupied by the circles.
Note that only a single Circle instance (my_circle) is created in the sec-
ond line of the code. This circle is then shifted in the y (shift_in_y()) and x
(shift_in_x()) directions within two nested loops to fill the square. In each
iteration, the circle area is calculated using the area() method and added to
the total. The coordinates of points along the circumference are generated using
the coordinates() method and plotted with the Matplotlib plot() function.
Let’s look at another example. Building on our code to read data from
Factpages, we’ll now define a top-level class responsible for setting up the main
URL components and loading data from a descriptor.
class FP_reader:
"""
Class to read NOD factpages
"""
def __init__(self):
"""
initialize strings to construct
the URL as of May 2025
"""
self.u_1 = "[Link]
self.u_2 = "&rs:Command=Render&rc:Toolbar=false&rc:Parameters=f"
self.u_3 = "&IpAddress=not_used&CultureCode=en&rs:Format=CSV&Top100=false"
return [Link]
Next, we’ll create a class that inherits from the top-level class and is respon-
sible for reading data from specific fields.
class Field(FP_reader):
"""
Class to read field data from NOD factpages
"""
def __init__(self):
"""
60 CHAPTER 5. ORGANIZING CODE
return df
Note that the Field class currently has just one method, but it’s easy to
extend the class by adding more methods to read additional datasets from the
Field category in Factpages. Let’s read the monthly production from Cono-
coPhillips fields using the class:
<class '[Link]'>
Index: 3233 entries, 101 to 24545
Data columns (total 10 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 prfInformationCarrier 3233 non-null object
1 prfYear 3233 non-null int64
2 prfMonth 3233 non-null int64
3 prfPrdOilNetMillSm3 3233 non-null float64
4 prfPrdGasNetBillSm3 3233 non-null float64
5 prfPrdNGLNetMillSm3 3233 non-null float64
6 prfPrdCondensateNetMillSm3 3233 non-null float64
7 prfPrdOeNetMillSm3 3233 non-null float64
8 prfPrdProducedWaterInFieldMillSm3 3233 non-null float64
5.3. MODULAR PROGRAMMING 61
A module is a Python source file (.py) that contains code designed to per-
form a specific task. For example, the first Python file we created in this course,
my_first_code.py, is actually a module. A package is a directory that con-
tains modules or data, along with an initialization file (__init__.py), which
signals to Python that the directory should be treated as a package.
Figure 5.2: The factpages package with modules reader and field.
In the [Link] file, paste the code we created to define the FP_reader
class 3 :
import requests
import pandas as pd
class FP_reader:
"""
Class to read NOD factpages
"""
def __init__(self):...
3 The functions are collapsed for brevity. To view the full code, please refer to the corre-
sponding file.
62 CHAPTER 5. ORGANIZING CODE
In the [Link] file, paste the code we previously wrote to define the Field
class. Additional functions for working with the Field category of Factpages are
also included. Note that the field module imports the class FP_reader from
the reader module. This is necessary since Field inherits from FP_reader.
class Field(FP_reader):
"""
Class to read field data from NOD factpages
"""
def __init__(self):...
def monthly_total_production(self):...
def yearly_total_production(self):...
This will import all functions from the modules into the main package
namespace, making them accessible directly from the package.
Now we can use our package. Before running the cell below, make sure to
clear all outputs and restart the kernel. This ensures that we’re starting with
a clean slate.
# of ConocoPhillips fields
fields = ["EKOFISK", "ELDFISK", "TOMMELITEN GAMMA",
"TOR", "VEST EKOFISK", "ALBUSKJELL", "VALHALL",
"HOD", "TOMMELITEN A"]
field = [Link]() # create field object
df = field.monthly_production(fields) # read monthly production data
[Link]() # print info
<class '[Link]'>
Index: 3233 entries, 101 to 24545
Data columns (total 10 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 prfInformationCarrier 3233 non-null object
1 prfYear 3233 non-null int64
2 prfMonth 3233 non-null int64
3 prfPrdOilNetMillSm3 3233 non-null float64
4 prfPrdGasNetBillSm3 3233 non-null float64
5 prfPrdNGLNetMillSm3 3233 non-null float64
6 prfPrdCondensateNetMillSm3 3233 non-null float64
7 prfPrdOeNetMillSm3 3233 non-null float64
8 prfPrdProducedWaterInFieldMillSm3 3233 non-null float64
9 prfNpdidInformationCarrier 3233 non-null int64
dtypes: float64(6), int64(3), object(1)
memory usage: 277.8+ KB
<class '[Link]'>
Index: 427 entries, 0 to 3301
Data columns (total 5 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 prfInformationCarrier 427 non-null object
1 prfYear 427 non-null int64
2 prfInvestmentsMillNOK 427 non-null int64
3 prfNpdidInformationCarrier 427 non-null int64
4 dateSyncNPD 427 non-null object
dtypes: int64(3), object(2)
memory usage: 20.0+ KB
I hope this gives you a sense of the power behind structuring your code
into modules and packages. While we’ve only scratched the surface, I hope it
inspires you to explore more about modular programming.
5.4 Exercises
1. Expand the functionality of the factpages package by adding a module
to work with the Wellbore category. This module should have functions
64 CHAPTER 5. ORGANIZING CODE
for extracting:
(a) The current year exploration wellbores:
descriptor: wellbore_exploration_current_year
(b) The last year exploration wellbores:
descriptor: wellbore_exploration_last_year
(c) The last 10 years exploration wellbores:
descriptor: wellbore_exploration_last_10_years
(d) Short list of all exploration wellbores:
descriptor: wellbore_exploration_all_short
(e) Long list of all exploration wellbores:
descriptor: wellbore_exploration_all
(f) Development wellbores:
descriptor: wellbore_development_all
(g) Other wellbores:
descriptor: wellbore_other_all
(h) CO2 storage wellbores:
descriptor: wellbore_co2_storage
All these functions should allow filtering the output by fields (wlbField
column) and completion year (wlbCompletionYear column).
Chapter 6
Data visualization
6.1 Introduction
We begin this chapter with a brief overview of plotting in Python using the
Matplotlib library. This section draws on material from this excellent resource,
adapted here to introduce the core concepts. To create plots, you’ll need to
import the [Link] module, which provides a simple interface for
generating a wide range of visualizations. Once imported, this module allows
you to plot data with just a few lines of code1 :
This code generates the graph in Figure 6.1a. However, if you’d like to have
more control over your graph, you can plot the data like this:
1 The code for this section is included in the notebook chapter6_1.ipynb
65
66 CHAPTER 6. DATA VISUALIZATION
Figure 6.1: Gradual enhancement of the same plot through added elements,
including labels, legends, LaTeX text, and an inset for local detail.
This code produces the graph in Figure 6.1b. In the code above, the Mat-
plotlib subplots() function creates instances of the figure (fig) and axes (ax).
We can send methods to ax to make our graph.
What if we want to plot the functions in both linear and logarithmic graphs
side by side? It’s simple — just define the number of rows (1) and columns (2)
in the subplots() method. You can also set the figure size using the figsize
parameter. Since there are two subplots, there are two axes objects: axs[0]
for the left (linear) subplot, and axs[1] for the right (logarithmic) subplot:
This code produces the graph in Figure 6.2a. Let’s plot the functions on
both normal and logarithmic scales on the same graph. To do this, we need two
y-axes that share the same x-axis. The left y-axis will use a normal scale, while
the right one will use a logarithmic scale. The axes twinx() function creates a
second y-axis that shares the x-axis:
68 CHAPTER 6. DATA VISUALIZATION
Figure 6.2: a. Side by side, and b. Overlaid linear and logarithmic plots.
for i, ax in enumerate([Link]()):
# plot text in the middle of the subplot
[Link](0.4, 0.5, f"subplot {i+1}", color = "blue")
Figure 6.3: a. Four subplots in a 2x2 grid, and b. Different types of plots
provided by Matplotlib.
This code generates the graph in Figure 6.1d. Saving the plot as an image
file (e.g., png, pdf, svg, etc.) at a desired resolution (dots per inch, dpi) is easy:
Besides the plot method, Matplotlib offers other functions for generating
different types of plots. For a complete list of available plot types, you can
check the Matplotlib plot gallery. Here are a few examples:
A crucial part of making any graph is preparing the data and gathering all
necessary inputs. For the example shown in Figure 6.4, this process includes
the following steps:
1. Extract the well data. Note that the logs are plotted from 2000 m down-
wards.
By breaking the graph creation process into sequential steps, we are es-
sentially applying the divide and conquer approach. Additionally, by focusing
on what needs to be done rather than how to do it, we are practicing abstraction.
Parameters:
df ([Link]): The input DataFrame containing
data from multiple wells.
well (str): The name of the well to extract.
well_col (str): The name of the column that contains
well names. Defaults to "WELL".
depth_col (str): The name of the column that contains
depth values. Defaults to "DEPTH_MD".
min_depth (float, optional): If specified, only rows
with depth >= to this value will be included.
Defaults to None, which includes all depths.
Returns:
[Link]: A DataFrame containing only the rows
corresponding to the specified well. Returns
an empty DataFrame if the well is not found.
"""
# create an empty DataFrame
well_df = [Link]()
# if well is in the DataFrame column
if well in df[well_col].values:
# extract the well
2 The well data is read from a CSV file. If you need to read a well in the more standard
return well_df
The second function gets the group tops, and in fact the tops of any intervals
(step 2):
Parameters:
df ([Link]): The input DataFrame representing
a single well.
int_col (str): Name of the column containing interval
identifiers. Defaults to "GROUP".
depth_col (str): Name of the column containing measured
depths. Defaults to "DEPTH_MD".
Returns:
dict: A dictionary mapping each interval name
(from int_col) to its top depth (the first
occurrence in depth_col).
"""
# find the tops or rows where a new interval starts
return tops
The third function gets the group colors, and in fact the colors of any inter-
vals (step 3):
Parameters:
df1 ([Link]): DataFrame representing a well,
containing interval identifiers in `int_col`.
df2 ([Link]): DataFrame defining RGB color values
for each interval. Must include columns:
int_col, "RED", "GREEN", and "BLUE".
int_col (str): Name of the column used to identify
intervals in both DataFrames. Defaults to "GROUP".
Returns:
dict: A dictionary where keys are interval names and
values are [R, G, B] color lists.
"""
# find unique values in column
intervals = df1[int_col].unique()
return colors
And the fourth function plots the logs (steps 5 and 6):
Parameters:
df ([Link]): DataFrame containing well
log data.
6.2. WELL LOGS 75
Description:
The function plots the well logs in the following order:
- SP (Spontaneous Potential)
- GR (Gamma Ray)
- Resistivity: RMED and RDEP
- Density: RHOB and Neutron: NPHI
- Sonic: DTC and DTS
Returns:
None
"""
# if not six subplots, raise an error
if len(axs) != 6:
print("Error: ax must have 6 subplots")
return
Let’s begin by extracting the well and getting the groups tops and colors:3
print(group_tops)
Finally, we can plot the logs. The code below generates Figure 6.4.
By structuring our code into functions, modules, and packages, we’ve made
it highly versatile. This allows us to plot any well in the dataset or focus on
specific intervals with ease. For example, to plot the facies for well 16/10-
1—assuming we have a file that defines facies colors—we can do the following:
Figure 6.5: Multi-track visualization of well 16/10-1 logs using Python. The
last track shows facies.
The segy file containing the seismic data is relatively large (370 MB), so
it has been hosted on a remote server. To download it, we can use Python’s
urllib library. The process is straightforward: define the URL of the file and
the local filename, then pass these to the urlretrieve() function to download
and save the file in the desired directory4 .
To read the segy data, we’ll use the Segyio library, a Python package de-
signed for efficient handling of SEG-Y files. We can open the file using the Segyio
open() method. Once opened, we can access the inline, crossline, and two-way
travel time (TWT) axes using the ilines, xlines, and samples attributes of
the file object f, respectively. The range of each axis can be calculated by sub-
tracting the minimum value from the maximum, and the number of elements
in each can be obtained from the shape of the corresponding array.
# open segy file and print inline, xline and twt ranges
with [Link](filename,"r") as f:
s = [[Link], [Link], [Link]]
s_t = ["IL", "XL", "TWT"]
for i in range(len(s)):
rg = [Link]([[Link](s[i]), [Link](s[i])])
count = s[i].shape[0]
print(f"{s_t[i]} range: {rg}, count: {count}")
The seismic data spans inlines from 200 to 600, xlines from 500 to 1100, and
TWT from 200 to 1500 ms. We can also determine the sampling intervals—that
is, the step size between adjacent inlines, xlines, and time samples—to better
understand the resolution of the dataset.
80 CHAPTER 6. DATA VISUALIZATION
# inline step
il_st = ([Link]([Link])
- [Link]([Link])) / ([Link][0] - 1)
# xline step
xl_st = ([Link]([Link])
- [Link]([Link])) / ([Link][0] - 1)
# twt step
twt_st = ([Link]([Link])
- [Link]([Link])) / ([Link][0] - 1)
# print steps
print(f"IL/XL/TWT steps: {il_st}, {xl_st}, {twt_st}")
In this dataset, the inline and xline increments are 1, while the TWT incre-
ment is 4 ms. To read the seismic cube, we can use the Segyio [Link]()
method, passing the file name as input. After loading the data, we print the
shape of the resulting NumPy array to confirm its dimensions along the inline,
xline, and TWT axes.
# cube shape
shape = [Link]
print(f"{shape[0]} IL, {shape[1]} XL and {shape[2]} TWT samples")
In the code above, vmax is set to the 99th percentile of the absolute amplitude
values. This value will be used later to scale the color range when plotting the
data. Since we’re working with a subset of the original 3D cube, the final step
before visualization is to assign the correct inline, xline, and TWT values to the
axis ticks. This is done by creating arrays of tick positions and corresponding
labels for each axis.
We can now plot the data. To facilitate this process, I have made two
functions in the utilities module of our plot_utilities package. The first
function plots a trace:
Input:
trace: 1D numpy array with the trace.
y: y axis values.
vmax: max value for the x axis.
twt_pos: positions of the time values on the y axis.
twt_lab: labels for the time values on the y axis.
ax: axis of the subplot.
"""
# plot the trace
[Link](trace, y, color="black")
# add grid
[Link]()
ax.set_xlabel("Amplitude")
ax.set_ylabel("Time [ms]")
And the second function plots a slice, which can be either an inline, xline,
or time slice:
Input:
slice: 2D array with the seismic data.
vmax: max value for the color scale.
title: title of the plot.
sl_type: slice type (inline, xline or time).
ax_pos: positions of the axes.
ax_lab: labels for the axes.
fig: figure object.
ax: axis of the subplot.
cb: boolean for colorbar.
"""
slice_plot = [Link]()
# if inline or xline slice, transpose the slice
if sl_type == "inline" or sl_type == "xline":
slice_plot = slice_plot.T
# set title
ax.set_title(title)
# set x label
if sl_type == "xline":
ax.set_xlabel("Inline")
elif sl_type == "inline" or sl_type == "time":
ax.set_xlabel("Xline")
# set x ticks
ax.set_xticks(ax_pos[0])
ax.set_xticklabels(ax_lab[0])
# set y label
if sl_type == "time":
ax.set_ylabel("Inline")
elif sl_type == "inline" or sl_type == "xline":
ax.set_ylabel("Time [ms]")
6.3. SEISMIC DATA 83
# set y ticks
ax.set_yticks(ax_pos[1])
ax.set_yticklabels(ax_lab[1])
# plot trace
# create figure
fig, ax = [Link](figsize=(3, 7)) # 1 subplot
# plot trace
pu.plot_trace(trace, time_id, vmax, twt_pos, twt_lab, ax)
# plot slice
Figure 6.6: F3 seismic trace at inline 400 and xline 800, visualized using a.
Matplotlib and b. Plotly.
# create figure
fig, ax = [Link](figsize=(8, 6)) # 1 subplot
# plot slice
pu.plot_slice(slice, vmax, title, sl_type,
ax_pos, ax_lab, fig, ax, cb=False)
This code generates the plot in Figure 6.7. The code is quite flexible. You
can change the slice type (sl_type) and/or the slice value (value) to visualize
another slice. For example, try visualizing xline 700, and time slice 1000.
6.3. SEISMIC DATA 85
Figure 6.7: Inline 350 from the F3 dataset, visualized using Matplotlib.
# create figure
fig, ax = [Link](2,2,figsize=(14,12))
# plot slices
for i, sl_type in enumerate(sl_types):
# plot slice
pu.plot_slice(slices[i], vmax, titles[i], sl_type,
ax_pos[i], ax_lab[i], fig,
ax[i//2, i%2], cb=False)
# plot lines
ax[i//2, i%2].axvline(lines[i][0], color='k',
linestyle='--', linewidth=2)
ax[i//2, i%2].axhline(lines[i][1], color='k',
linestyle='--', linewidth=2)
# present figure
fig.tight_layout()
[Link]()
This code produces the plot shown in Figure 6.8. The first slider allows us
to select the inline, the second slider the xline, and the third slider the time
slice. The black dashed lines show the location of the slices.
Let’s begin by plotting a trace from our seismic cube. For this, we use the
Plotly graph_objects module (imported as go).5
5 Plotly also offers the express module, which is easier to use but provides fewer options
Figure 6.8: Controlling dynamically the displayed inline, xline and time slice of
a seismic cube via widgets.
This code generates Figure 6.6b. Notice that as you hover the cursor over
the plot, the amplitude and time values are displayed.
Now, let’s plot the trace alongside the time slice. To create two subplots —
one for the trace and another for the time slice — we use the make_subplots()
method from the Plotly subplots module:
# Create subplots
fig = make_subplots(rows=1, cols=2,
subplot_titles=(None, f"Time = {value} ms"))
# elements to draw
el_defs = [
# trace on 1st subplot
([Link](x=trace, y=time, mode="lines", name="Trace",
line_color="black"), 1, 1),
# slice on 2nd subplot
([Link](z=slice, colorscale="RdBu", name="Amplitude",
showscale=False, zmin=-vmax, zmax=vmax), 1, 2),
# trace location on 2nd subplot
([Link](x=[xl_id], y=[il_id], mode="markers", name="Trace",
marker=dict(size=10, color="black")), 1, 2)
]
# add to the figure
for el, row, col in el_defs:
fig.add_trace(el, row=row, col=col)
# update axes
fig.update_xaxes(title_text="Amplitude",
row=1, col=1, range=[-vmax, vmax])
fig.update_yaxes(title_text="Time [ms]",
row=1, col=1, autorange="reversed")
xl_text = [str(v) for v in xl_lab]
fig.update_xaxes(title_text="Xline", tickvals=xl_pos,
ticktext=xl_text, row=1, col=2)
il_text = [str(v) for v in il_lab]
fig.update_yaxes(title_text="Inline", tickvals = il_pos,
ticktext=il_text, row=1, col=2)
[Link]()
6.5. PLOTTING DATAFRAMES 89
Figure 6.9: F3 seismic data visualized using Plotly, with a. the trace and b. the
time-slice shown side-by-side. Notice that amplitude values can be extracted
by hovering the cursor over the slice.
We have only begun to explore what Plotly offers. With a bit of practice,
you’ll find it a powerful tool for creating clear and interactive visualizations. I
encourage you to experiment further and discover how it can support your own
projects.
Let’s look at an example using the Factpages data on monthly field pro-
duction from the NCS. We’ll start by reading the data with our factpages
package6 :
Before we can plot the data, we need to do some preparation. The code
below:
# Rename columns
[Link](columns={columns[1]:"year", columns[2]:"month",
columns[3]:"Oil MSm3", columns[4]:"Gas BSm3",
columns[5]:"NGL MSm3", columns[6]:"Condensate MSm3",
columns[7]:"Oil eq. MSm3", columns[8]:"Water MSm3"},
6.5. PLOTTING DATAFRAMES 91
inplace=True)
We now have the data in the form we need. Let’s plot the production from
the Ekofisk field — impressively, just a few lines of code will do the job and
generate Figure 6.10:
# y axes limits
max_value = df_field["Oil eq. MSm3"].max() * 1
for ax in axs:
ax.set_ylim(0, max_value)
While the basic plot() method gives us quick and useful plots, we can
create even better and interactive visualizations using hvplot, a powerful library
that extends DataFrame plotting with rich, interactive features. Let’s plot the
Ekofisk production data using hvplot:
df_field.[Link](x="Date", y=columns,
title=f"Production data for {field}",
width=800, height=400).opts(ylim=(0, max_value))
This code produces Figure 6.11a. Notice how the data values are displayed
when you hover the cursor over the curves. You can also toggle the curves on
and off by clicking the legend entries.
# area plot
is_stacked = False # stacked area or not
if is_stacked:
y_max = max_value * 2
alpha = 1.0
6.6. EXERCISES 93
Figure 6.11: Ekofisk monthly production data visualized using the a. hvplot
line() method, and the area() method with b. non-stacked and c. stacked data.
else:
y_max = max_value
alpha = 0.4
df_field.[Link](x="Date", y=columns,
stacked=is_stacked, alpha=alpha,
title=f"Production data for {field}",
width=800, height=400).opts(ylim=(0, y_max))
This code generates a non-stacked area plot (Figure 6.11b). Setting is_stacked
to True produces a stacked area plot instead (Figure 6.11c).
6.6 Exercises
1. Modify the plot_logs() function in the utilities module to:
tan δ ′ = V tan δ
t′ sin δ ′
=
t sin δ
where δ ′ and t′ are the exaggerated bedding dip and thickness, respec-
tively.
Data analysis
95
96 CHAPTER 7. DATA ANALYSIS
# paths to files
path_1 = [Link]("..", "data", "xeek_train_subset.csv")
path_2 = [Link]("..", "data", "lith_colors.csv")
{'Shale': [0.75, 0.75, 0.75], 'Sandstone': [1.0, 1.0, 0.0], 'SS/Shale': [1.0, 0.
88, 0.1], 'Limestone': [0.5, 1.0, 1.0], 'Tuff': [1.0, 0.55, 0.0], 'Marl': [0
.49, 0.99, 0.0], 'Anhydrite': [1.0, 0.5, 1.0], 'Dolomite': [0.5, 0.5, 1.0],
'Chalk': [0.5, 1.0, 1.0], 'Coal': [0.0, 0.0, 0.0], 'Halite': [0.49, 0.87, 0.
75]}
Let’s analyze one variable in the dataset, for example gamma ray (GR). To
begin, we can create a table where each column represents a lithology, and each
row shows a statistical summary of GR values within that lithology—including
the count, minimum, maximum, mean, standard deviation, and key percentiles.
The following function, available in the analysis module of our data_analysis
package, generates this summary:
Input:
df: DataFrame with the well logs
class_col: string with the name of the column with the
classes (e.g., "LITH" for lithology)
prop_col: string with the name of the column with the
property to analyze (e.g., "GR" for gamma ray)
Output:
table: DataFrame with the statistics of the property
for each class
"""
# get the classes
classes = df[class_col].unique()
# create a table
table = [Link](index=["count", "min",
"max", "mean", "std",
"25%", "50%", "75%"])
for clas in classes:
# get the porosity for the flow unit
prop = df[df[class_col] == clas][prop_col]
# add statistics to the table
table[clas] = [[Link](), [Link](), [Link](),
[Link](), [Link](),
[Link](0.25), [Link](),
[Link](0.75)]
return table
While this summary table is useful, a graphical display can make it easier
to understand the distribution of GR values. The following function from our
analysis module plots the property (GR) for each class (LITH) using either box
plots or violin plots. Note that this and several functions in this chapter rely
on the Seaborn library (imported as sns), which provides a high-level interface
for creating statistical graphics in Python:
98 CHAPTER 7. DATA ANALYSIS
Input:
df: DataFrame with the well logs
class_col: string with the name of the column with the
classes (e.g., "LITH" for lithology)
colors: dictionary with the class names as the keys
and the colors as [red, green, blue] values
prop_col: string with the name of the column with the
property to analyze (e.g., "GR" for gamma ray)
type: string with the type of plot to create.
Options are "boxplot" and "violin"
Output:
returns the figure
"""
# create a figure
fig, ax = [Link](figsize=(10, 6))
# create the plot
if type == "boxplot":
# create a boxplot
[Link](x=class_col, y=prop_col, data=df,
hue=class_col, palette=colors, ax=ax)
elif type == "violin":
# create a violin plot
[Link](x=class_col, y=prop_col, data=df,
hue=class_col, palette=colors, ax=ax)
else:
raise ValueError("type must be 'box' or 'violin'")
return fig
This code generates Figure 7.1a. The box plot is a graphical tool that shows
how values are spread out in a dataset. It highlights the median (the middle
value), the range of most values (the interquartile range), and any values that
are unusually high or low—these are called outliers.
7.1. UNIVARIATE ANALYSIS 99
Figure 7.1: a and b. Box plots for the GR and lithologies in the wells. c.
Violin plot for the same data.
As shown in Figure 7.1a, GR values exceeding 300 API are rare and likely
represent spurious measurements. Let’s remove these outliers from the dataset:
This code generates Figure 7.1b. We can also visualize the GR distribution
using a violin plot. The following code generates this plot:
This code generates Figure 7.1c. The violin plot is a graphical representation
that combines aspects of a box plot and a kernel density plot, showing the
distribution of data, its density at different values, and its summary statistics,
such as the median and quartiles.
100 CHAPTER 7. DATA ANALYSIS
The following function from our analysis module generates a cross plot of
two variables and fits a linear trend line using SciPy’s linregress() method,
which performs a least-squares regression.
Input:
df: DataFrame with the well logs
col_1 and col_2: columns to cross plot
class_col: string with the name of the column with the
classes (e.g., "LITH" for lithology)
cols: dictionary with the class names as the keys
and the colors as [red, green, blue] values
ax: axis to plot on
Output:
returns the slope, intercept and R2 of the line
fit to the data
"""
[Link]()
return m, b, r**2
Let’s use this function to cross plot neutron porosity (AI) versus density
(RHOB) for all the wells, with the data points colored by lithology:
7.2. MULTIVARIATE ANALYSIS 101
[Link]()
This code produces Figure 7.2. We have now a predictive model for esti-
mating neutron porosity from density and vice versa.
Figure 7.2: Cross plots of neutron porosity (NPHI) versus density (RHOB) for
all the wells. Points are colored by lithology (LITH), and dash red line is best
linear fit to the data.
This code generates Figure 7.3, which illustrates the variation of different
lithologies across the wells and highlights the correlation between NPHI and
RHOB within each well.
Figure 7.3: Cross plots of neutron porosity (NPHI) versus density (RHOB) for
each well (WELL). Data points are colored by lithology.
Likewise, we can do the same plot with the data separated by groups
(GROUP):
This code generates Figure 7.3. An interesting group is the Upper Permian
Zechstein, which exhibits significant lithological variability. Halite, being nearly
incompressible, shows no correlation between NPHI and RHOB, whereas the other
lithologies—except possibly shale—display a negative correlation between these
two variables.
7.2. MULTIVARIATE ANALYSIS 103
Figure 7.4: Cross plots of neutron porosity (NPHI) versus density (RHOB) for
each group (GROUP). Data points are colored by lithology.
Figure 7.5: Cross plot of four selected logs in the wells’ dataset. The data
points are colored by lithology (LITH).
variables are linearly related, and whether they are positively correlated (both
increase together) or negatively correlated (one increases as the other decreases).
The code below generates these matrices for the log data in Figure 7.5. The
result is shown in Figure 7.6.
cmap="coolwarm", vmin=-2,
vmax=2, ax=axs[0])
axs[0].set_title("Covariance Matrix")
fig.tight_layout()
[Link]()
Figure 7.6: Covariance (left) and correlation (right) matrices for the logs in
Figure 7.5.
One simple and widely used technique for this is K-Means clustering, a type
of unsupervised learning. Unlike supervised methods, K-Means doesn’t require
labeled data—instead, it identifies clusters based solely on the structure of the
data itself. The idea is to group data points so that those within the same
group (or cluster) are more similar to each other than to those in other groups.
106 CHAPTER 7. DATA ANALYSIS
Input:
data: DataFrame with the data to cluster
max_k: maximum number of clusters to test
Output:
returns the figure
"""
means_e = []
means_s = []
inertias = []
scores = []
for k in range(1,max_k+1):
means_e.append(k)
kmeans = KMeans(n_clusters=k, random_state=42)
[Link](data)
[Link](kmeans.inertia_)
if k > 1:
means_s.append(k)
score = silhouette_score(data, kmeans.labels_)
[Link](score)
fig.tight_layout()
[Link]()
return fig
This next function, also from our analysis module, performs the actual
clustering of the data using the K-Means algorithm.
7.3. CLUSTERING DATA 107
Input:
data: DataFrame (e.g., well logs) to cluster
k: number of clusters to create
Output:
returns the KMeans object and the labels
"""
# create the KMeans object
kmeans = KMeans(n_clusters=k, random_state=42)
# fit the model
[Link](data)
# well 16/10-1
df_well = df_1[df_1["WELL"] == "16/10-1"]
# key logs
df_4 = df_well[["DEPTH_MD", "GR", "RHOB", "NPHI", "DTC"]]
# drop NaNs
df_4 = df_4.dropna()
This code produces an elbow plot and a silhouette score plot (Figure 7.7).
Without going into details, these visualizations help estimate the optimal num-
ber of clusters: the elbow plot shows where adding more clusters no longer sig-
nificantly reduces the within-cluster variance (typically at the "elbow" point),
while the silhouette score plot indicates how well-separated the clusters are,
with higher scores suggesting better-defined groups. Together, they guide us in
selecting a number of clusters that balances simplicity with meaningful sepa-
ration. For the selected log data, five clusters appear to provide a good balance.
The code below performs the clustering and generates cross plots of the logs,
with data points color-coded according to their assigned cluster (Figure 7.8).
108 CHAPTER 7. DATA ANALYSIS
Figure 7.7: Elbow plot (left) and Silhouette scores (right) for KMeans classifi-
cation of four logs from well 16/10-1.
# number of clusters
n_clusters = 5
[Link]()
Figure 7.8: Cross plot of selected logs from well 16/10-1. The data points are
colored by the cluster number.
# plot logs
logs = ["GR", "RHOB", "NPHI", "DTC"]
for i, log in enumerate(logs):
# plot the log as a blue curve
axs[i].plot(df_4[log], df_4["DEPTH_MD"], '-', color="black", linewidth=0.5)
# set the title
axs[i].set_title(log)
# set grid
axs[i].grid()
axs[-1].set_title("Cluster")
fig.tight_layout()
[Link]()
The clusters in Figures 7.8 and 7.9 may correspond to different lithologies,
but not necessarily. They could also reflect uncertainties, noise, or artifacts
introduced during the classification process. It is important to corroborate
these results with additional information—such as core data, facies logs, or
geological context—before drawing definitive conclusions. As an example, you
can compare the clusters log with the facies log in Figure 6.5.
7.4. EXERCISES 111
7.4 Exercises
At this point, you’re mature enough in your understanding to begin designing
your own exercises. Consider exploring questions that interest you, experi-
menting with other datasets, or extending the methods introduced here to new
scenarios. You might revisit some examples—such as analysing the properties
of specific lithologies or groups across wells. The goal is to make the learning
your own: ask questions, try things out, and don’t be afraid to follow your
inquiries.
112 CHAPTER 7. DATA ANALYSIS
Chapter 8
That said, how you write code still matters—especially if it needs to be used
again, shared with a colleague, or maintained months down the line. While
syntax is foundational, good structure is what makes code robust and reusable.
Organizing your programs into functions, grouping related functionality into
modules and packages, writing clear documentation, and testing your code will
help others—and your future self—understand, trust, and build upon your work.
• Start simple, then iterate. Don’t wait for the perfect design be-
fore writing code. Build something that works, then refactor it to make
it clearer and more efficient. Tools like GitHub Copilot or ChatGPT
can help you get started faster, suggest solutions, or explain unfamiliar
code—but always review and test the suggestions critically.
• Write tests for your code. Even basic unit tests can help catch bugs
early and make sure future changes don’t break existing functionality.
Python’s unittest or pytest libraries are great places to start.
113
114 CHAPTER 8. ADVANCING YOUR SKILLS