AI-assisted Python Coding

Day 2: Control Flow, I/O, and Debugging

Klaus Reygers

Studierendentage Physik, Uni Heidelberg, 23.-27.03.2026

Day 2: Data structures, functions, I/O, and debugging

  • Data structures
  • Functions and imports
  • Reading and writing simple data files
  • Debugging, testing, and units
  • Exception handling and paths

Data structures: list, tuple, dict, set

  • list (ordered, mutable) — measurements over time
  • tuple (ordered, immutable) — fixed coordinates (x, y, z)
  • dict (key → value) — parameters/constants
  • set (unique elements) — unique labels/IDs

Immutable means: you can’t change the elements inside the tuple (but you can assign a new tuple to a variable).

Lists: common methods

Lists are mutable and come with many useful methods:

  • Add/remove: .append, .extend, .insert, .pop, .remove
  • Order: .sort, .reverse
  • Concatenation: two lists can be joined with +
xs = [3, 1, 2]
xs.sort()
xs.reverse()
xs
[3, 2, 1]
a = [1, 2]
b = [3, 4]
a + b
[1, 2, 3, 4]

Lists and = (aliasing) vs .copy()

= does not copy the list. It creates a second name for the same object.

a = [1, 2, 3]
b = a
b.append(4)
a, b
([1, 2, 3, 4], [1, 2, 3, 4])

Make an independent copy:

c = a.copy()
c.append(5)
a, c
([1, 2, 3, 4], [1, 2, 3, 4, 5])

Slicing (lists and strings)

Slicing extracts parts of sequences: start:stop:step.

xs = [0, 1, 2, 3, 4, 5]
xs[1:4], xs[:3], xs[::2], xs[::-1]
([1, 2, 3], [0, 1, 2], [0, 2, 4], [5, 4, 3, 2, 1, 0])

The “include start, exclude end” rule is used across Python. For example, range(5) gives 0, 1, 2, 3, 4 (not including 5).

This allows one to split sequences cleanly:

xs[:4] + xs[4:] == xs
True

Works also for strings:

name = "info.txt"
name[:4], name[-3:]
('info', 'txt')

Tuples are immutable (example)

pos = (1.0, 2.0, 3.0)
pos[0]
1.0

Trying to do pos[0] = 0.0 would raise an error.

Dictionaries (example)

Use a dict to keep named parameters together.

params = {"g": 9.81, "y0": 10.0, "dt": 0.1}

t = 2.0
y = params["y0"] - 0.5 * params["g"] * t**2
y
-9.620000000000001

Iterating over a dictionary

params = {"g": 9.81, "y0": 10.0, "dt": 0.1}

keys = []
for key in params:
  keys.append(key)

items = []
for key, value in params.items():
  items.append((key, value))

values = list(params.values())

keys, items, values
(['g', 'y0', 'dt'],
 [('g', 9.81), ('y0', 10.0), ('dt', 0.1)],
 [9.81, 10.0, 0.1])

Variable scope (where a name is valid)

  • Names defined inside a function are local.
  • Names defined at the top level are global.

Rule of thumb: in Python, scope is mainly defined by functions, modules, and classes (not by if/for/while).

g = 9.81

def fall_distance(t):
  s = 0.5 * g * t**2  # uses global g
  return s

fall_distance(2.0)
19.62

If you define s inside the function, it does not exist outside.

Scope in if / for / while blocks (no block scope)

In Python, if/for/while blocks do not create a new scope (unlike C/C++/Java).

x = 1
if x > 0:
  y = 123
y
123

Common pitfall: the block might not run.

x = -1
if x > 0:
  y = 123

Pascal’s triangle

Write a program that prints the first 8 rows of Pascal’s triangle, nicely centered:

        1
       1 1
      1 2 1
     1 3 3 1
    1 4 6 4 1
   1 5 10 10 5 1
  1 6 15 20 15 6 1
 1 7 21 35 35 21 7 1

Hint: each row can be computed from the previous one.

Solution: Pascal’s triangle

n = 8
row = [1]
rows = [row]
for _ in range(n - 1):
  row = [1] + [row[i] + row[i+1] for i in range(len(row)-1)] + [1]
  rows.append(row)

width = len(" ".join(str(x) for x in rows[-1]))
for row in rows:
  print(" ".join(str(x) for x in row).center(width))
         1         
        1 1        
       1 2 1       
      1 3 3 1      
     1 4 6 4 1     
   1 5 10 10 5 1   
  1 6 15 20 15 6 1 
1 7 21 35 35 21 7 1

Functions (reuse + test your code)

Why functions?

  • avoid repeating the same code in multiple places
  • give a block of logic a name → easier to read
  • easy to test in isolation
def kinetic_energy(m, v):
  """Kinetic energy in joules: E = 0.5 * m * v^2"""
  return 0.5 * m * v**2

kinetic_energy(0.2, 3.0)   # m in kg, v in m/s → E in J
0.9

Rule: one function = one task. If a function does two things, split it.

Recursive function calls (example)

A recursive function calls itself on a smaller version of the same problem.

def factorial(n):
  if n == 0:
    return 1
  return n * factorial(n - 1)

factorial(5)
120

Key idea:

  • base case: stops recursion (n == 0)
  • recursive step: reduces the problem size (n - 1)

Recursion exercise: Euclidean GCD

Write a recursive function gcd(a, b) that computes the greatest common divisor of two positive integers.

Core idea of Euclid’s algorithm:

Any number that divides both a and b will also divide the remainder a mod b.

So we can repeatedly replace (a, b) by (b, a % b).

Requirements:

  1. Use recursion (no loop).
  2. Use a base case for b == 0.
  3. Evaluate gcd(84, 30).

Optional challenge: handle negative inputs by returning a non-negative GCD.

Solution: Euclidean GCD

def gcd(a, b):
  a = abs(a)
  b = abs(b)
  if b == 0:
    return a
  return gcd(b, a % b)

gcd(84, 30)
6

Type hints (optional)

You may see annotations like m: float or -> float.

  • They are hints for humans + IDEs (autocomplete, warnings).
  • Python does not enforce them at runtime.
def kinetic_energy(m: float, v: float) -> float:
  return 0.5 * m * v**2

kinetic_energy(2, 3)  # ints still work in most numeric formulas
9.0

Default arguments

You can provide a default value for a parameter. Then it becomes optional.

def fall_distance(t, g=9.81):
  return 0.5 * g * t**2

fall_distance(2.0), fall_distance(2.0, 1.62)  # Earth vs Moon
(19.62, 3.24)

Rule of thumb: defaults should be “safe” and make common cases easy.

Keyword arguments

When calling a function you can name arguments:

import math

def projectile_range(v0, angle_deg, g=9.81):
  angle = math.radians(angle_deg)
  return (v0**2 * math.sin(2 * angle)) / g

projectile_range(v0=20.0, angle_deg=45.0)
40.77471967380224

Keywords improve readability and allow reordering (but positional arguments must come first).

Imports (use libraries)

import math
math.sqrt(2)
1.4142135623730951

Aliases are common:

import random as rnd
rnd.seed(0)
rnd.random()
0.8444218515250481

For scientific code you often see (requires NumPy):

import numpy as np

Rule of thumb: put imports at the top of your file.

Regular expressions with re

Python provides the module re for working with regular expressions.

Task:

  1. Ask an LLM or coding assistant to explain what regular expressions are and how the Python module re is used.
  2. A Heidelberg Uni-ID follows the scheme ab123: two lowercase letters followed by three digits.
  3. Write a regular expression that matches exactly this format.
  4. Use re.fullmatch(...) to test your pattern on a few examples.

Python scripts: run code from the command line

Example script file free_fall.py:

import math

def fall_distance(t, g=9.81):
  return 0.5 * g * t**2

if __name__ == "__main__":
  t = 2.0
  s = fall_distance(t)
  print(f"t = {t:.1f} s -> s = {s:.2f} m")

if __name__ == "__main__": means:

  • run this block only when the file is executed as a script
  • do not run it when the file is imported (so you can reuse its functions)

Run it in a terminal (in the folder where the file is):

python3 free_fall.py

List comprehensions (compact list building)

Often you want “a new list from an old list”.

Pattern: [expression for item in iterable]

g = 9.81
times = [0, 0.5, 1.0, 1.5, 2.0]
positions = [0.5 * g * t**2 for t in times]
positions
[0.0, 1.22625, 4.905, 11.03625, 19.62]

You can also filter with an if:

data = [1.0, -999.0, 1.02, 0.98]
clean = [x for x in data if x > 0]
clean
[1.0, 1.02, 0.98]

Random numbers (quick example)

Random numbers are useful for noise/measurement uncertainty.

import random

random.seed(1)
measurements = [1.00 + random.uniform(-0.02, 0.02) for _ in range(5)]
measurements
[0.985374569764496,
 1.0138973494774892,
 1.0105509847590646,
 0.9902027610295768,
 0.9998174034836776]

Reading files (example)

Read measurement data from a CSV-like text file (with comment lines).

file_path = "data/data_rate_vs_thickness_pb.csv"

thickness_mm = []
count_rate = []

with open(file_path, "r", encoding="utf-8") as f:
  for line in f:
    line = line.strip()
    if not line or line.startswith("#"):
      continue

    t_str, r_str = line.split(",")
    thickness_mm.append(float(t_str))
    count_rate.append(float(r_str))

len(thickness_mm), thickness_mm[:3], count_rate[:3]
(9, [0.0, 3.0, 6.0], [52644.0, 43823.0, 35922.0])

Why use with for files?

Core pattern:

with open(file_path, "r", encoding="utf-8") as f:
  data = f.read()

Why with is useful:

  • It closes the file automatically, even if an error happens.
  • It avoids resource leaks (open file handles).
  • The code is cleaner than calling f.close() manually.

Substract background rate and print a table

Substract background rate (\(R_{net} = R_{meas} - 50\) counts/min).

background_rate = 50.0
net_count_rate = [r - background_rate for r in count_rate]

header = f"{'thickness_mm':>12} {'count_rate':>12} {'net_count_rate':>16}"
print(header)
print("-" * len(header))
for t, r, r_net in zip(thickness_mm, count_rate, net_count_rate):
  print(f"{t:12.0f} {r:12.0f} {r_net:16.0f}")
thickness_mm   count_rate   net_count_rate
------------------------------------------
           0        52644            52594
           3        43823            43773
           6        35922            35872
           9        29968            29918
          12        24771            24721
          15        20633            20583
          18        16839            16789
          21        13816            13766
          24        11576            11526

Writing a CSV file (example)

Write the processed data to a new CSV file with a header row.

import csv

output_path = "output/data_rate_vs_thickness_pb_with_net.csv"

with open(output_path, "w", newline="", encoding="utf-8") as f:
  writer = csv.writer(f)
  writer.writerow(["thickness_mm", "count_rate", "net_count_rate"])

  for t, r, r_net in zip(thickness_mm, count_rate, net_count_rate):
    writer.writerow([t, r, r_net])

print(f"Wrote {len(thickness_mm)} rows to {output_path}")
Wrote 9 rows to output/data_rate_vs_thickness_pb_with_net.csv

Debugging mindset

When code fails, work in small steps:

  • reproduce the error with a minimal example
  • change one thing at a time
  • check intermediate values
  • keep notes: what did you test, what changed?

This is usually faster than guessing.

Read error messages top to bottom

A Python traceback tells you:

  • where the error happened (file and line)
  • which call chain led there
  • what error type occurred (NameError, TypeError, …)

Rule of thumb:

  • first read the last traceback line (error type/message)
  • then jump to your own code line shown above

Lightweight checks: print and assert

def speed(distance_m, time_s):
  assert time_s > 0, "time_s must be positive"
  return distance_m / time_s

print("v =", speed(12.0, 3.0), "m/s")
v = 4.0 m/s

assert is meant to detect bugs in your code, not handle normal runtime situations.

What it does:

  • checks a condition (time_s > 0)
  • if true: program continues normally
  • if false: raises AssertionError with the given message

assert → “This should never happen unless there’s a bug”

if + raise → “This might happen, and we handle it”

Debugging exercise: read traceback first

The following script should compute background-corrected count rates from CSV-like lines.

def load_measurements(lines):
  rows = []
  for line in lines:
    line = line.strip()
    if not line or line.startswith("#"):
      continue
    t_str, r_str = line.split(",")
    rows.append({"thickness_mm": float(t_str), "count_rate": r_str.strip()})
  return rows

def net_count_rates(rows, background=50.0):
  return [row["count_rate"] - background for row in rows]

lines = [
  "# thickness_mm, count_rate",
  "0, 1200",
  "1, 980",
  "2, 810",
]

rows = load_measurements(lines)
net = net_count_rates(rows)
print(net)

Debugging exercise: traceback

Observed error message:

File "/Users/reygers/uni/Lehre/Vorlesungen/AI-assisted Python Coding SS2026/tmp/bug.py", line 22, in <module>
    net = net_count_rates(rows)
          ^^^^^^^^^^^^^^^^^^^^^
  File "/Users/reygers/uni/Lehre/Vorlesungen/AI-assisted Python Coding SS2026/tmp/bug.py", line 12, in net_count_rates
    return [row["count_rate"] - background for row in rows]
            ~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~
TypeError: unsupported operand type(s) for -: 'str' and 'float'

Tasks:

  1. Fix the bug by reading the traceback carefully (without AI first).
  2. Then paste both the code and the traceback into an LLM and ask for a minimal fix.

Keep track of how long you needed to find the bug without AI and how long the LLM took to find it.

First tiny tests with pytest

Example test file test_physics.py (assumes physics.py with kinetic_energy):

from physics import kinetic_energy

def test_kinetic_energy_zero_velocity():
  assert kinetic_energy(2.0, 0.0) == 0.0

def test_kinetic_energy_simple_value():
  assert kinetic_energy(2.0, 3.0) == 9.0

How to read this:

  • each function starting with test_ is one test case
  • assert checks expected result vs computed result
  • if assertion is true: test passes
  • if assertion is false: pytest reports a failure

Running pytest

Run in terminal (in the project folder):

pytest -q

pytest uses test discovery: it searches recursively for files matching test_*.py or *_test.py and runs all functions starting with test_. The -q flag means quiet (compact output).

Typical result:

  • 2 passed means both tests succeeded
  • 1 failed means one test result did not match expectation

Exception handling with try/except

Use exceptions for expected runtime problems, for example:

  • missing files
  • invalid user input
  • division by zero
def reciprocal(x):
  try:
    return 1.0 / x
  except ZeroDivisionError:
    return None

reciprocal(2.0), reciprocal(0.0)
(0.5, None)

Calling external commands from Python

Use the standard library module subprocess when a Python script should run an external program.

Basic pattern:

import subprocess

result = subprocess.run(
  ["python3", "--version"],
  capture_output=True,
  text=True,
  check=True,
)

result.stdout.strip() or result.stderr.strip()
'Python 3.12.12'

Useful options:

  • capture_output=True: capture stdout and stderr
  • text=True: return strings instead of bytes
  • check=True: raise an exception if the command fails