Earth & Environmental Computing · Week 04 · Course session · 43 slides
Download HTML
Earth & Environmental Computing | Computational Methods for Earth & Environmental Science | 16-Week Course

Functions and Debugging

Writing code you can reuse, and finding out why it does the wrong thing.

def / return · Arguments · Tracebacks · pdb · Tests
Week 04·Course session·Open Course Materials | Adapt for your classroom·Week 4 | Course session | Individual Lab 4 begins in the lab session
Learning Objectives

By the End of This Lecture, You Will Be Able To

  • Write a function with parameters, a return value, and a docstring
  • Choose between positional and keyword arguments, and set safe defaults
  • Read a traceback from the bottom up and name the failing line
  • Find a bug that raises nothing, using a test or a sanity anchor
Contents

Today's Lecture

01Writing a function
04Bugs that stay quiet
02Trusting a function
05Putting it to work
03Reading an error
Recap

Last Week in Sixty Seconds

  • An if/elif chain fires exactly one branch, and order decides which
  • A counter needs a reset path, or it reports a 900-day dry spell
  • A threshold is a scientific claim you have to defend
  • Today: give that decision a name, a docstring and a test
Motivation

Two Halves

Part One: Functions

  • Definitions and returns
  • Arguments, defaults, and the mutable-default trap
  • Scope, docstrings, type hints
  • Turning a long script into named pieces

Part Two: Debugging

  • Reading tracebacks from the bottom
  • The five errors you will meet
  • print, logging, pdb, the IDE debugger, tests
  • The bugs that raise nothing at all

Every example today runs on the Iowa River at Iowa City, USGS site 05454500.

Data

Today's Datasets

FileSourceCoverageUsed for
iowa_streamflow_daily.csvUSGS NWIS, site 05454500, Iowa River at Iowa CityDaily, 2015–2025, 4,018 rowsHydrologic indices, flow-duration curve
iowa_streamflow_daily_raw.rdbThe same download, before cleaningTab-separated, with provisional flagsThe debugging demonstrations
iowa_city_weather_daily.csvIowa Environmental Mesonet, station IOWDaily, 2019–2025, 2,549 rowsThe default-argument example
01

Writing a Function

A name for a piece of work, so you write it once and call it everywhere.

01 · Writing a function

Anatomy of a Function

Python

CFS_TO_M3S = 0.028316846592

def cfs_to_m3s(discharge_cfs):
    """Convert discharge from cubic feet per second to cubic metres per second."""
    return discharge_cfs * CFS_TO_M3S

q = cfs_to_m3s(1240.0)
print(f"1240 cfs = {q:.2f} m3/s")
  • def starts the definition, and the name follows
  • The names in parentheses are parameters, filled in at call time
  • The indented block is the body, four spaces on every line
  • return hands one value back and ends the call
01 · Writing a function

Return, Not Print

Python

def mean_flow_printed(df):
    print(df["discharge_cfs"].mean())    # prints, returns None

def mean_flow(df):
    return df["discharge_cfs"].mean()    # returns a usable value

print("printed version handed back:", mean_flow_printed(stream))
flow = mean_flow(stream)
print(f"returned version: {flow:.1f} cfs = {cfs_to_m3s(flow):.1f} m3/s")
  • The printed version hands back None, and the next line fails on it
  • The returned value keeps flowing through the program
  • If a later cell gets None, look for a function that prints instead of returns
01 · Writing a function

Positional and Keyword Arguments

Python

def sample_id(site, lat, lon):
    return f"{site}_{lat:.2f}_{lon:.2f}"

# positional: order is everything
print(sample_id("IOWARIVER", 41.66, -91.53))

# keyword: order stops mattering
print(sample_id(lon=-91.53, site="IOWARIVER", lat=41.66))

# swapped, and Python cannot tell
print(sample_id("IOWARIVER", -91.53, 41.66))
  • Positional arguments are matched left to right
  • Keyword arguments are matched by name and may appear in any order
  • Keywords must follow positionals in a call
  • Use keywords for anything a reader could confuse: coordinates, depths, thresholds

The third call puts the station in Kazakhstan. No error, no warning.

01 · Writing a function

Default Arguments

Python

def mean_flow_by(df, date_col="date", flow_col="discharge_cfs", freq="Y"):
    """Mean discharge grouped by period. Returns a Series indexed by period."""
    out = df.copy()
    key = out[date_col].dt.to_period(freq).astype(str)
    return out.groupby(key)[flow_col].mean()

print(mean_flow_by(stream).head(3))          # yearly, the default
print(mean_flow_by(stream, freq="M").head(3))  # monthly, one keyword changed
  • A default makes a parameter optional; callers override only what they need
  • Parameters with defaults come after those without
  • This is the signature style used throughout src/earthcourse/hydro.py
  • Defaults are evaluated once, when the def line runs, which is the next slide
01 · Writing a function

The Mutable Default Trap

Python

def add_site_buggy(name, sites=[]):        # one list, shared forever
    sites.append(name)
    return sites

def add_site(name, sites=None):            # a fresh list every call
    sites = [] if sites is None else sites
    sites.append(name)
    return sites

print("buggy:", add_site_buggy("Clear Creek"), add_site_buggy("Rapid Creek"))
print("fixed:", add_site("Clear Creek"), add_site("Rapid Creek"))
  • Never put a list, dict, or set in a default; use None and build it inside
  • The default object is created once, when Python reads the def line
01 · Writing a function

Variable Scope

Python

FREEZING_C = 0.0                       # global

def days_at_or_below_freezing(temps):
    count = 0                          # local to this call
    for t in temps:
        if t <= FREEZING_C:            # reads the global, cannot rebind it
            count += 1
    return count

n = days_at_or_below_freezing(weather["temp_min_c"])
print(f"{n} of {len(weather)} Iowa City days at or below freezing")
  • Names created inside a function are local and exist only during the call
  • A function can read a global name but cannot rebind one by assignment
  • Pass data in as arguments and hand results back with return
  • Globals as shared state are a debugging tax you pay later
01 · Writing a function

Collecting Arguments with *args and **kwargs

Python

def mean_of(*values):                  # values arrives as a tuple
    return sum(values) / len(values)

print(mean_of(2.1, 3.4, 2.8))
print(mean_of(*stream["discharge_cfs"].head(5)))    # unpack at the call

def plot_flow(df, **style):            # style arrives as a dict
    fig, ax = plt.subplots(figsize=(7, 2.2))
    ax.plot(df["date"], df["discharge_cfs"], **style)   # and unpacks again
    ax.set_ylabel("Discharge (cfs)")

plot_flow(stream.head(365), color="steelblue", linewidth=0.8)
  • *args collects extra positional arguments into a tuple
  • **kwargs collects extra keyword arguments into a dict
  • The same stars unpack at the call site, which is how you forward options to a library
01 · Writing a function

Docstrings

Python

def seven_day_low(df, flow_col="discharge_cfs"):
    """Lowest 7-day rolling mean discharge, in cubic feet per second.

    Parameters
    ----------
    df : daily record, one row per day
    flow_col : name of the discharge column (cfs)
    """
    return df[flow_col].rolling(7).mean().min()

print(f"Lowest 7-day mean: {seven_day_low(stream):.1f} cfs")
help(seven_day_low)
  • A string as the first statement of the function, in triple quotes, one summary line
  • State the units of every argument and of the return value
  • help() prints it back to you, and so does hovering in your editor
01 · Writing a function

Type Hints

Python

from pathlib import Path

def peak_above(df: "pd.DataFrame", threshold_cfs: float = 10000.0) -> int:
    """Count days whose discharge exceeds a threshold."""
    return int((df["discharge_cfs"] > threshold_cfs).sum())

print("Days above 10000 cfs:", peak_above(stream))
print("Annotations:", peak_above.__annotations__)
  • Annotations say what goes in and what comes out
  • Python does not check them at run time; your editor does, before you run anything
  • src/earthcourse/hydro.py uses them: cfs_to_m3s(discharge_cfs: float) -> float
  • Hints plus a docstring is the whole contract of a function in five lines
02

Trusting a Function

A function you have not checked is a guess with a name.

02 · Trusting a function

Write the Test Before You Trust

Python

def annual_mean_flow_simple(df):
    """Annual mean discharge (cfs) from daily data."""
    data = df.copy()                   # never mutate the caller's table
    data["year"] = data["date"].dt.year
    return data.groupby("year", as_index=False)["discharge_cfs"].mean()

tiny = pd.DataFrame({"date": pd.to_datetime(["2020-01-01", "2020-01-02", "2021-01-01"]),
                     "discharge_cfs": [10.0, 20.0, 40.0]})
got = annual_mean_flow_simple(tiny)
assert got.loc[got["year"] == 2020, "discharge_cfs"].iloc[0] == 15.0   # (10+20)/2
print(got)
  • Write the test first and state the expected answer out loud: (10 + 20) / 2 = 15
  • assert makes the check permanent, not a one-time glance
  • df.copy() keeps the caller's table untouched
02 · Trusting a function

Two Implementations, One Answer

Python

from earthcourse.hydro import annual_mean_flow

mine = annual_mean_flow_simple(stream)
package = annual_mean_flow(stream)
check = mine.merge(package, on="year")

assert np.allclose(check["discharge_cfs"], check["mean_discharge_cfs"])
print("My function matches the package function for all",
      len(check), "years.")
  • Agreement between two independent implementations is strong evidence
  • One implementation running without errors is weak evidence
  • This check returns in every later lab
Pause and think

?

Why test on three invented rows when eleven years of real Iowa River data are sitting right there?

You know the true answer for three rows; you do not know it for 4,018
Real data can only tell you the code ran, not that it is right
02 · Trusting a function

A Script Refactored Into Functions

scripts/messy_streamflow_analysis.py

  • One-letter names: p, x, a, b
  • A hard-coded relative path that breaks elsewhere
  • The same grouping idea written twice
  • A plot titled m3/s over a column in cfs

src/earthcourse/

  • read_processed_csv finds the project root for you
  • mean_flow_by(df, freq=...) does both groupings
  • annual_mean_flow is already written and tested
  • Each piece is callable, testable, and fixable in one place

You will refactor this script in Group Lab 4, Week 10. Today it is the before picture.

02 · Trusting a function

Where Our Functions Live

src/earthcourse/

  • io.py: find the project root, read data/processed
  • hydro.py: unit conversion, annual mean and peak flow, 7-day low flow, flow-duration curves
  • stats.py: regression summary, RMSE, MAE, split by date
  • plotting.py, spatial.py, modeling.py

In every notebook

  • from earthcourse.hydro import annual_mean_flow
  • from earthcourse.hydro import flow_duration_curve
  • Write it once here, import it everywhere
  • No copy-paste between labs
03

Reading an Error

Your code will break. The skill is shortening the time between breaking and knowing why.

03 · Reading an error

Anatomy of a Traceback

Python

Traceback (most recent call last):
  File "load_gauge.py", line 18, in <module>
    mean_flow = summarize("iowa_streamflow_daily_raw.rdb")
  File "load_gauge.py", line 12, in summarize
    values = to_floats(table["42884_00060_00003"])
  File "load_gauge.py", line 7, in to_floats
    return [float(c) for c in cells]
ValueError: could not convert string to float: '14n'
  • Read the last line first: it names the error and describes it
  • Everything above is the route Python took to get there
  • Then read upward for the deepest line you wrote; that is where to look
  • Here the fault is in the reading, not the converting: '14n' is a USGS type code
03 · Reading an error

Five Errors You Will Meet

ErrorWhat it meansWhere it bit us
NameErrorThe name does not exist yetcount used outside the function that made it
TypeErrorWrong kind of value, or wrong argument countmean_flow_printed returned None, then None + 1
ValueErrorRight type, impossible valuefloat('14n') on the USGS type row
IndexErrorPosition past the end of the sequenceAn off-by-one loop, or a short header row
KeyErrorThat key is not in the dictionarystream['discharge'] when the column is discharge_cfs
03 · Reading an error

print() Debugging the Raw USGS File

Python

raw = pd.read_csv(RAW_GAUGE, sep="\t", comment="#", dtype=str)

print("row 0      =", raw.iloc[0].to_list())
print("row 1      =", raw.iloc[1].to_list())
print("row 0 type =", type(raw[FLOW_COL].iloc[0]), repr(raw[FLOW_COL].iloc[0]))

try:
    float(raw[FLOW_COL].iloc[0])
except ValueError as error:
    print("Expected ValueError:", error)
  • Print the value and its type; most surprises are type surprises
  • Label every print, or you will not know which one spoke
  • Row 0 is the USGS type-definition row: 5s, 15s, 20d, 14n, 10s
  • It is metadata wearing a data row's clothes
Pause and think

?

You fix it with pd.to_numeric(errors='coerce'), the code runs clean, and the mean comes out at 2712.46 cfs. Is the bug gone?

No. The bad row became NaN and was skipped, quietly
The mean is right this time only because it was 1 row in 4,019
You did not fix it, you got away with it
04

Bugs That Stay Quiet

The dangerous bug runs cleanly and returns a number you believe.

04 · Bugs that stay quiet

The Bug With No Traceback

Python

coerced = pd.to_numeric(raw[FLOW_COL], errors="coerce")
print("rows            :", len(coerced))
print("NaN after coerce:", int(coerced.isna().sum()))
print(f"mean with coerce: {coerced.mean():.2f} cfs")
print(f"true mean       : {stream['discharge_cfs'].mean():.2f} cfs")

# A naive filter that also fails: '14n' starts with a digit
kept = raw[FLOW_COL].str.match(r"^-?\d", na=False)
print("rows kept by a starts-with-a-digit filter:", int(kept.sum()), "of", len(raw))
  • Identical to the true mean, so nothing looks wrong at all
  • errors='coerce' turns a loud failure into a silent one
  • The obvious filter does not help: '14n' starts with a digit
  • scripts/clean_data.py drops the row whose values end in s or d instead

A function that checks its own assumptions turns a silent wrong answer into a loud one.

04 · Bugs that stay quiet

The Unit Bug That Raises Nothing

Python

def cfs_to_m3s_buggy(discharge_cfs):
    """Convert cfs to m3/s. This version has a bug on purpose."""
    return discharge_cfs * 0.28316846592

print(f"Buggy   : 1000 cfs -> {cfs_to_m3s_buggy(1000.0):.1f} m3/s")
print(f"Correct : 1000 cfs -> {cfs_to_m3s(1000.0):.1f} m3/s")
print()
print("What the data itself says:")
print(stream[["discharge_cfs", "discharge_m3s"]].head(3).to_string(index=False))
  • Memorize one anchor per conversion: 1 cfs is about 0.028 m3/s
  • So 1000 cfs must be about 28 m3/s, not 283
  • The discharge_m3s column agrees with the anchor, not with the bug
  • No traceback will ever find this one; only a reader who knows the units will
04 · Bugs that stay quiet

From print() to logging

Python

import logging
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s %(message)s")

def read_gauge(path, flow_col):
    table = pd.read_csv(path, sep="\t", comment="#", dtype=str)
    logging.debug("rows read = %d", len(table))
    if any(str(v).endswith(("s", "d")) for v in table.iloc[0]):
        logging.warning("dropping USGS type row: %s", table.iloc[0].to_list())
        table = table.iloc[1:].reset_index(drop=True)
    logging.info("returning %d clean rows", len(table))
    return table.assign(discharge_cfs=table[flow_col].astype(float))
  • One switch, the level, turns all of it on or off; you never delete the lines
  • DEBUG to trace, INFO for progress, WARNING for suspicious data, ERROR for failure
  • The 79 estimated values are exactly the kind of thing a WARNING should surface
  • Add filename= to keep a record of a long processing run
04 · Bugs that stay quiet

breakpoint() and pdb

Python

def to_floats(cells):
    breakpoint()
    return [float(c) for c in cells]

to_floats(raw["42884_00060_00003"])
  • Execution stops on that line and hands you a prompt inside the function
  • Every local name is live and inspectable, with no import needed
  • n steps to the next line, s steps into a call, p prints an expression
  • l lists the code around you, c continues, q quits. Delete the line when done

Not run here: breakpoint() would pause this notebook. Demonstrate it in a terminal.

04 · Bugs that stay quiet

A pdb Session

Python

(Pdb) p len(cells)
4019
(Pdb) p cells.iloc[0]
'14n'
(Pdb) p cells.iloc[1]
'1050'
(Pdb) p float(cells.iloc[1])
1050.0
(Pdb) p cells.iloc[0].isdigit()
False
(Pdb) q
  • Five questions, asked in the room where the bug lives
  • Row 0 is not a number and never was; the file has a type row
  • Fix: drop the type row when reading, not after converting
04 · Bugs that stay quiet

The IDE Debugger

Breakpoints

Click the gutter beside a line. No code changes, nothing to delete later.

Variables panel

Every local in scope, with its type, updating as you step.

Step over, into, out

The same three moves as pdb, on buttons: F10, F11, Shift-F11.

Conditional breakpoints

Stop only when row == 8417, the one row of ten thousand that fails.

04 · Bugs that stay quiet

Tests as a Debugging Tool

Python

# tests/test_gauge.py, run with: pytest -q
def test_type_row_is_dropped():
    table = read_gauge(RAW_GAUGE, FLOW_COL)
    assert table["discharge_cfs"].iloc[0] == 1050.0

def test_row_count_matches_processed():
    assert len(read_gauge(RAW_GAUGE, FLOW_COL)) == len(stream)

test_type_row_is_dropped()
test_row_count_matches_processed()
print("2 passed")
  • Write the failing case as a test before you fix it; then the fix is verifiable
  • Small functions make this easy. This is the payoff for Part One
  • A test is a bug you only have to find once
05

Putting It to Work

Hydrologic indices, the flow-duration curve, and one bug end to end.

05 · Putting it to work

Hydrologic Indices as Functions

01

Given

Eleven years of daily discharge for the Iowa River at Iowa City.

02

Solve

annual_mean_flow, annual_peak_flow, seven_day_low_flow: three tested functions, merged by year.

03

Interpret

Sanity asserts: every mean positive, every peak above its 7-day low.

05 · Putting it to work

The Flow-Duration Curve

Python

from earthcourse.hydro import flow_duration_curve

fdc = flow_duration_curve(stream)
for pct in (10, 50, 90):
    q = np.interp(pct / 100, fdc["exceedance_probability"], fdc["discharge_cfs"])
    print(f"Q{pct:<3} exceeded {pct:>2}% of days: {q:8.1f} cfs")

fig, ax = plt.subplots(figsize=(7, 2.6))
ax.semilogy(fdc["exceedance_probability"] * 100, fdc["discharge_cfs"])
ax.set_xlabel("Percent of days flow is exceeded")
ax.set_ylabel("Discharge (cfs)")
ax.grid(True, alpha=0.3)
  • One curve summarizes eleven years of river behaviour
  • Q90 is the flow exceeded 90 percent of days: the water-supply floor
  • Q10/Q90 near 25 means a flashy river with little storage between storms
05 · Putting it to work

One Bug, Start to Finish

1

Read the error

ValueError in to_floats, called from summarize. Bottom line first.

2

Localize

The value is '14n'. The fault is upstream, in the reading, not the converting.

3

Inspect

A breakpoint shows row 0 holding 5s, 15s, 20d, 14n, 10s.

4

Fix narrowly

Drop the type row when reading. One condition, one function.

5

Lock it in

Keep the test that failed. Remove the breakpoint.

05 · Putting it to work

A Debugging Checklist

  • Read the whole traceback, bottom line first
  • Say out loud what you expected and what happened
  • Check the types, not just the values
  • Shrink the input until the failure is two lines long
  • Change one thing at a time, and re-run
  • Keep the failing case as a test once it passes
  • Before you believe a converted number, check it against an anchor
05 · This week's lab

Lab 4: Functions and Debugging

Lab session

  • Write annual_mean_flow_simple, then test it on three rows
  • Compare your version against the package with np.allclose
  • Hunt the planted factor-of-ten conversion bug

Studio session

  • Graded checkpoint: count_days_above(df, threshold_cfs)
  • Read Q90 off the flow-duration curve and say who cares
  • Explain the bug in writing, not just in code

Bring one calculation you have copy-pasted in the last two labs. It deserves to become a function.

Summary

Key Takeaways

  • A function returns a value; printing is for people, returning is for code
  • A docstring states what goes in, what comes out, and in which units
  • Read a traceback from the bottom: the last line names the problem
  • A clean run is not a correct run; the quiet bugs need a test or an anchor
Discussion · Next week: NumPy and array thinking

?

Think of a bug you have met, in code or in a spreadsheet, that ran without complaining and gave a wrong answer. How did you eventually notice?

Most people notice because a number broke their intuition
That intuition is what sanity anchors and tests make explicit