Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Chapter 5: Functions & Modules - Building Reusable Scientific Code

Modeling the Universe | Python Fundamentals

San Diego State University

Learning Objectives

By the end of this chapter, you will be able to:

Prerequisites Check

Before starting this chapter, verify you can:


Chapter Overview

Functions are the fundamental building blocks of organized code. Without functions, you’d be copying and pasting the same code repeatedly, making bugs harder to fix and improvements impossible to maintain. But functions are more than just a way to avoid repetition—they’re how we create abstractions, manage complexity, and build reliable software. Whether you’re calculating statistical measures, simulating star cluster dynamics, or processing multi-wavelength observational data, every computational project starts with well-designed functions.

This chapter teaches you to think about functions as contracts between different parts of your code. When you write a function that calculates energy or performs numerical integration, you’re creating a promise: given valid input, the function will reliably return the correct output. This contract mindset helps you write functions that others (including future you) can trust and use effectively. You’ll learn how to choose between positional and keyword arguments, when to use default values, and how to design interfaces that are both flexible and clear.

We’ll explore Python’s scope rules, which determine where variables can be accessed, and learn how seemingly simple concepts like default arguments can create subtle bugs that have plagued even major scientific software packages. You’ll discover how Python’s flexible parameter system enables powerful interfaces, and how functional programming concepts prepare you for modern scientific computing frameworks. By the end, you’ll be organizing your code into modules that can be shared, tested, and maintained professionally—essential skills for collaborative scientific research.

5.1 Defining Functions: The Basics

A function encapsulates a piece of logic that transforms inputs into outputs. Think of a function as a machine: you feed it raw materials (inputs), it performs some process (the function body), and it produces a product (output). In scientific computing, a function might calculate statistical measures, integrate equations, or transform data — each function performs one clear task that can be tested and trusted.

Your First Function

Let’s start with something every scientist needs — calculating mean and standard deviation:

def calculate_mean(values):
    """
    Calculate the arithmetic mean of a list of values.
    
    This is our first function - notice the structure!
    """
    # Note: assert is for development/debugging
    # In production, use explicit validation:
    assert len(values) > 0, "Cannot calculate mean of empty list"
    # Better production version would be:
    # if len(values) == 0:
    #     raise ValueError("Cannot calculate mean of empty list")
    
    total = sum(values)
    count = len(values)
    mean = total / count
    return mean

# Using the function
measurements = [23.5, 24.1, 23.8, 24.3, 23.9]
avg = calculate_mean(measurements)
print(f"Mean temperature: {avg:.2f}°C")

# The assert would raise an error with empty list:
# empty_mean = calculate_mean([])  
# AssertionError: Cannot calculate mean of empty list

Let’s break down exactly how this works:

  1. def keyword: Tells Python we’re defining a function.

  2. Function name (calculate_mean): Follows snake_case convention, describes what it does.

  3. Parameters (values): Variables that receive values when function is called.

  4. Docstring: Brief description of what the function does (always include this!).

  5. Function body: Indented code that does the actual work.

  6. return statement: Sends a value back to whoever called the function.

When Python executes calculate_mean(measurements), it creates a temporary namespace where values = [23.5, 24.1, ...], runs the function body, and returns the result.

Functions Without Return Values

Not all functions return values. Some perform actions like printing results or saving data:

def report_statistics(data, name="Dataset"):
    """Report basic statistics without returning values."""
    mean = sum(data) / len(data)
    minimum = min(data)
    maximum = max(data)
    
    print(f"Statistics for {name}:")
    print(f"  Mean: {mean:.3f}")
    print(f"  Range: [{minimum:.3f}, {maximum:.3f}]")
    # No return statement - returns None implicitly

# Report some calculations
temperatures = [20.1, 21.5, 19.8, 22.3, 20.9]
report_statistics(temperatures, "Temperature (°C)")

Returning Multiple Values

Python functions can return multiple values using tuples — perfect for calculations that produce related results:

def analyze_data(values):
    """
    Calculate multiple statistics at once.
    
    Returns:
        mean, std_dev, min_val, max_val
    """
    n = len(values)
    mean = sum(values) / n
    
    # Calculate standard deviation
    variance = sum((x - mean)**2 for x in values) / n
    std_dev = variance ** 0.5
    
    return mean, std_dev, min(values), max(values)

# Analyze experimental data
data = [9.8, 10.1, 9.9, 10.2, 9.7, 10.0]
mean, std, min_val, max_val = analyze_data(data)

print(f"Mean: {mean:.3f}")
print(f"Std Dev: {std:.3f}")
print(f"Range: [{min_val}, {max_val}]")

Kinetic Energy in CGS Units

Now let’s calculate kinetic energy using CGS units (centimeters, grams, seconds):

def kinetic_energy_cgs(mass_g, velocity_cms):
    """
    Calculate kinetic energy in ergs.
    
    Parameters:
        mass_g: mass in grams
        velocity_cms: velocity in cm/s
    
    Returns:
        energy in ergs (g⋅cm²/s²)
    """
    energy_ergs = 0.5 * mass_g * velocity_cms**2
    return energy_ergs

# Example: electron moving at 1% speed of light
electron_mass = 9.109e-28  # grams
c_light = 2.998e10  # speed of light in cm/s
electron_velocity = 0.01 * c_light  # 1% of c
ke = kinetic_energy_cgs(electron_mass, electron_velocity)

print(f"Electron kinetic energy: {ke:.2e} ergs")
print(f"In eV: {ke/1.602e-12:.2f} eV")  # 1 eV = 1.602e-12 ergs

The Design Process: From Problem to Function

Before writing any function, design it first. This prevents the common mistake of coding yourself into a corner:

"""
DESIGN: Function to validate numerical calculation

PURPOSE: Ensure numerical results are physically reasonable
INPUT: value, expected_range
OUTPUT: boolean (True if valid)
PROCESS:
    - Check if value is finite
    - Check if value is within range
"""

def validate_result(value, min_val=None, max_val=None):
    """
    Validate a numerical result is reasonable.
    
    Simple validation - we'll expand this pattern later!
    """
    import math
    
    # Check if finite
    if not math.isfinite(value):
        return False
    
    # Check bounds if provided
    if min_val is not None and value < min_val:
        return False
    if max_val is not None and value > max_val:
        return False
    
    return True

# Test validation
energy = kinetic_energy_cgs(1.0, 100.0)
print(f"Energy = {energy} ergs")
print(f"Valid (positive)? {validate_result(energy, min_val=0)}")
print(f"Valid (in range)? {validate_result(energy, 0, 10000)}")

5.2 Function Arguments In-Depth

Python provides flexible ways to handle function parameters. Understanding the distinction between positional arguments, keyword arguments, and default values is crucial for designing clear, flexible interfaces. Let’s explore this flexibility through progressive examples.

Positional vs Keyword Arguments

def escape_velocity(mass_g, radius_cm):
    """
    Calculate escape velocity from a spherical body.
    
    Parameters:
        mass_g: mass of the body in grams
        radius_cm: radius of the body in cm
    
    Returns:
        escape velocity in cm/s
    """
    G = 6.674e-8  # gravitational constant in cm³/(g⋅s²)
    v_escape = (2 * G * mass_g / radius_cm) ** 0.5
    return v_escape

# Earth parameters
earth_mass = 5.972e27    # grams
earth_radius = 6.371e8   # cm

# Correct calculation
v_correct = escape_velocity(earth_mass, earth_radius)

# WRONG: Arguments reversed
v_wrong = escape_velocity(earth_radius, earth_mass)

print(f"Correct escape velocity: {v_correct:.2e} cm/s = {v_correct/1e5:.1f} km/s")
print(f"Wrong (args reversed): {v_wrong:.2e} cm/s")
print(f"The wrong value is {v_correct/v_wrong:.0f}x too small!")

# Keywords make intent clear
v_safe = escape_velocity(radius_cm=earth_radius, mass_g=earth_mass)
print(f"\nWith keywords: {v_safe:.2e} cm/s")

Default Arguments Make Functions Flexible

Default arguments let users omit parameters when standard values suffice:

def simulate_decay(initial_atoms, half_life_years=5730, time_years=0):
    """
    Simulate radioactive decay.
    
    Parameters:
        initial_atoms: starting number of atoms
        half_life_years: half-life in years (default: C-14 = 5730)
        time_years: elapsed time in years (default: 0)
    """
    import math
    remaining = initial_atoms * (0.5 ** (time_years / half_life_years))
    return remaining

# Different usage patterns
n0 = 1000000  # One million atoms

# Use all defaults (returns initial count)
print(f"At t=0: {simulate_decay(n0):.0f} atoms")

# Override just time (uses default half-life for C-14)
print(f"After 5730 years: {simulate_decay(n0, time_years=5730):.0f} atoms")

# Override all parameters (U-238 example)
print(f"U-238 after 1 billion years: {simulate_decay(n0, 4.5e9, 1e9):.0f} atoms")

The Mutable Default Trap

Here’s a critical bug that has appeared in major scientific software:

# THE TRAP - DON'T DO THIS!
def add_measurement_buggy(value, data=[]):
    """BUGGY VERSION - default list is shared!"""
    data.append(value)
    return data

# Watch the disaster unfold
day1 = add_measurement_buggy(23.5)
print(f"Day 1: {day1}")

day2 = add_measurement_buggy(24.1)  # Surprise!
print(f"Day 2: {day2}")  # Contains BOTH days!

print(f"Same list? {day1 is day2}")  # True - it's the same object!

Here’s the correct pattern:

def add_measurement_fixed(value, data=None):
    """CORRECT VERSION - new list each time if needed."""
    if data is None:
        data = []  # Create new list when not provided
    data.append(value)
    return data

# Now it works correctly
day1 = add_measurement_fixed(23.5)
day2 = add_measurement_fixed(24.1)
print(f"Day 1: {day1}")
print(f"Day 2: {day2}")  # Separate lists!

# Can still provide existing list
combined = []
add_measurement_fixed(23.5, combined)
add_measurement_fixed(24.1, combined)
print(f"Combined: {combined}")

Variable Arguments (*args and **kwargs)

def calculate_statistics(*values, method='all'):
    """
    Calculate statistics for any number of values.
    
    *args collects positional arguments into a tuple.
    """
    if not values:
        return None
    
    n = len(values)
    mean = sum(values) / n
    
    if method == 'mean':
        return mean
    
    # Calculate variance and std dev
    variance = sum((x - mean)**2 for x in values) / n
    std_dev = variance ** 0.5
    
    if method == 'all':
        return {
            'n': n,
            'mean': mean,
            'std': std_dev,
            'min': min(values),
            'max': max(values)
        }

# Works with any number of arguments!
print(calculate_statistics(1, 2, 3, 4, 5))
print(f"Mean only: {calculate_statistics(2.5, 3.7, 1.2, method='mean'):.2f}")
def run_experiment(name, **parameters):
    """
    Run experiment with flexible parameters.
    
    **kwargs collects keyword arguments into a dictionary.
    """
    print(f"=== Experiment: {name} ===")
    
    # Default parameters in CGS units
    defaults = {
        'temperature_k': 293.15,       # Kelvin
        'pressure_dynes_cm2': 1.01325e6,  # 1 atm in dynes/cm²
        'trials': 10
    }
    
    # Update with user parameters
    config = defaults.copy()
    config.update(parameters)
    
    print("Configuration:")
    for key, value in config.items():
        print(f"  {key}: {value}")
    
    return f"Completed {config['trials']} trials"

# Simple call
result = run_experiment("Test A")

# Complex call with many options
result = run_experiment(
    "Test B",
    temperature_k=350,
    pressure_dynes_cm2=2e6,
    trials=50,
    catalyst="Platinum"
)

Parameter Ordering Rules

Python enforces specific rules about parameter order in function definitions. These rules exist to make functions clearer and prevent ambiguous calls. Let’s build up from simple to complex.

Basic Rule: Required Before Optional

The fundamental rule is that parameters with defaults must come after parameters without defaults:

# WRONG - SyntaxError!
# def bad_function(x=10, y):  # Can't have required after optional
#     pass

# CORRECT - Required parameters first, then optional
def good_function(x, y=10):
    """Required 'x' must come before optional 'y'."""
    return x + y

# Usage
print(good_function(5))      # x=5, y=10 (default)
print(good_function(5, 20))  # x=5, y=20 (override)

The Complete Parameter Order

Python supports different parameter types that must appear in a specific order. Here’s the complete hierarchy:

Python Parameter Types (in required order)

Order

Type

Syntax Example

How to Call

1

Positional-only

def f(x, y, /):

Must use position: f(1, 2)

2

Standard

def f(a, b):

Position or keyword: f(1, 2) or f(a=1, b=2)

3

Default values

def f(x=10):

Optional: f() or f(5)

4

*args

def f(*args):

Collects extras: f(1, 2, 3) → args=(1,2,3)

5

Keyword-only (required)

def f(*, x):

Must use name: f(x=5) (required)

6

Keyword-only with default

def f(*, x=10):

Optional keyword: f() or f(x=5)

7

**kwargs

def f(**kwargs):

Collects extra keywords: f(a=1, b=2)

You rarely need all types in one function. Here are practical examples showing common combinations:

# Example 1: Mixing positional-only and keyword-only (Python 3.8+)
def divide(numerator, denominator, /, *, precision=2):
    """
    numerator, denominator: must be positional
    precision: must be keyword
    """
    result = numerator / denominator
    return round(result, precision)

print(divide(10, 3))                    # Uses default precision
print(divide(10, 3, precision=4))       # Specifies precision
# divide(numerator=10, denominator=3)   # ERROR! Must be positional

# Example 2: Flexible argument collection
def process(*data_points, normalize=True, **options):
    """
    data_points: any number of positional args
    normalize: keyword-only parameter
    options: any extra keyword arguments
    """
    print(f"Processing {len(data_points)} points")
    print(f"Normalize: {normalize}")
    print(f"Options: {options}")

process(1, 2, 3, 4, normalize=False, threshold=0.5, verbose=True)

Understanding Keyword-Only Parameters

Any parameter that comes AFTER *args (or a bare *) can ONLY be passed using its name. This feature makes function calls self-documenting:

# Without keyword-only: unclear what True and False mean
def process_data_unclear(data, normalize, remove_outliers):
    """What do the boolean parameters mean at the call site?"""
    pass

# Call is confusing:
# process_data_unclear(measurements, True, False)  # What's True? What's False?

# With keyword-only: self-documenting
def process_data_clear(data, *, normalize=True, remove_outliers=False):
    """The * forces callers to name the boolean parameters."""
    if normalize:
        # Normalize the data...
        pass
    if remove_outliers:
        # Remove outliers...
        pass

# Call is clear:
# process_data_clear(measurements, normalize=True, remove_outliers=False)

Common Patterns You’ll Use

# Pattern 1: Simple function with defaults
def calculate_interest(principal, rate=0.05, years=1):
    """Most common pattern - some required, some optional."""
    return principal * (1 + rate) ** years

# Pattern 2: Collecting variable arguments
def average(*numbers):
    """Accept any number of arguments."""
    if not numbers:
        return 0
    return sum(numbers) / len(numbers)

print(f"Average of 2, 4, 6, 8: {average(2, 4, 6, 8)}")

# Pattern 3: Force clarity with keyword-only
def create_particle(mass, velocity, *, charge=0, spin=0.5):
    """Mass and velocity are required, charge and spin must be named."""
    return {
        'mass': mass,
        'velocity': velocity, 
        'charge': charge,
        'spin': spin
    }

# Clear what each parameter means:
electron = create_particle(9.109e-28, 1e6, charge=-1.602e-19, spin=0.5)

# Pattern 4: Flexible keyword arguments
def plot(x, y, **options):
    """Accept x, y, and any number of plot options."""
    print(f"Plotting {len(x)} points")
    for key, value in options.items():
        print(f"  {key}: {value}")

plot([1, 2, 3], [2, 4, 6], color='red', linewidth=2, marker='o')

Understanding the Special Markers

Python uses two special markers to control how arguments can be passed:

# The / marker (Python 3.8+): everything before is positional-only
def divide(dividend, divisor, /):
    """Both arguments MUST be passed positionally."""
    return dividend / divisor

print(divide(10, 2))           # Works
# print(divide(dividend=10, divisor=2))  # ERROR! Can't use keywords

# The * marker: everything after is keyword-only  
def configure(name, *, debug=False, verbose=False):
    """name can be positional or keyword, others must be keyword."""
    print(f"Configuring {name}")
    if debug:
        print("  Debug mode enabled")
    if verbose:
        print("  Verbose output enabled")

configure("MyApp", debug=True)  # Must name debug
# configure("MyApp", True)  # ERROR! Can't pass debug positionally

5.3 Scope and Namespaces

Understanding scope—where variables can be accessed—is crucial for writing bug-free code. Python’s scope rules determine which variables are visible at any point in your program. Without understanding scope, you’ll encounter confusing bugs where variables don’t have the values you expect, or worse, where changing a variable in one place mysteriously affects code elsewhere.

The LEGB Rule

Python resolves variable names using the LEGB rule, searching in this order:

# Demonstrating LEGB with scientific context
speed_of_light = 2.998e10  # Global scope (cm/s in CGS)

def calculate_energy():
    # Enclosing scope
    rest_mass = 9.109e-28  # electron mass in grams
    
    def relativistic_factor(velocity_cms):
        # Local scope
        c = speed_of_light  # Accesses global (cm/s)
        beta = velocity_cms / c
        
        # Local calculation
        import math
        if beta >= 1:
            return float('inf')  # Cannot exceed speed of light
        gamma = 1 / math.sqrt(1 - beta**2)
        
        print(f"  Inside relativistic: β = {beta:.3f}, γ = {gamma:.3f}")
        return gamma
    
    # Use inner function
    v = 0.5 * speed_of_light  # Half light speed (cm/s)
    gamma = relativistic_factor(v)
    
    # Calculate relativistic energy (E = γmc²)
    energy_ergs = rest_mass * speed_of_light**2 * gamma
    print(f"Inside calculate: E = {energy_ergs:.2e} ergs")
    return energy_ergs

result = calculate_energy()
print(f"Global scope: c = {speed_of_light:.2e} cm/s")
print(f"Final energy: {result:.2e} ergs = {result/1.602e-12:.2f} eV")

The UnboundLocalError Trap

counter = 0  # Global

def increment_wrong():
    """This will crash - Python sees assignment and assumes local!"""
    # counter += 1  # UnboundLocalError!
    print("Uncomment the line above to see the error")

def increment_with_global():
    """Explicitly use global variable."""
    global counter
    counter += 1
    return counter

def increment_better(current_count):
    """Best approach - avoid global state entirely."""
    return current_count + 1

# Test the better approach
count = 0
count = increment_better(count)
count = increment_better(count)
print(f"Count: {count}")

# Global approach (generally avoid)
increment_with_global()
increment_with_global()
print(f"Global counter: {counter}")

Closures: Functions That Remember

def create_integrator(method='rectangle'):
    """
    Factory function that creates specialized integrators.
    
    The returned function 'remembers' the method.
    """
    def integrate(func, a, b, n=100):
        """Numerically integrate func from a to b."""
        dx = (b - a) / n
        total = 0
        
        if method == 'rectangle':
            # Left rectangle rule
            for i in range(n):
                x = a + i * dx
                total += func(x) * dx
                
        elif method == 'midpoint':
            # Midpoint rule (more accurate)
            for i in range(n):
                x = a + (i + 0.5) * dx
                total += func(x) * dx
        
        return total
    
    # Return the customized function
    return integrate

# Create specialized integrators
rect_integrate = create_integrator('rectangle')
mid_integrate = create_integrator('midpoint')

# Test with x² from 0 to 1 (exact answer = 1/3)
def f(x):
    return x**2

rect_result = rect_integrate(f, 0, 1, 100)
mid_result = mid_integrate(f, 0, 1, 100)
exact = 1/3

print(f"Rectangle rule: {rect_result:.6f}")
print(f"Midpoint rule:  {mid_result:.6f}")
print(f"Exact:          {exact:.6f}")
print(f"Rectangle error: {abs(rect_result-exact):.6f}")
print(f"Midpoint error:  {abs(mid_result-exact):.6f}")

5.4 Functional Programming Elements

**Side Effect**  
Any state change that occurs beyond returning a value from a function, such as modifying global variables or printing output.

Python supports functional programming—a style that treats computation as the evaluation of mathematical functions. These concepts are essential for modern scientific frameworks like JAX and lead to cleaner, more testable code.

Lambda Functions

Lambda functions are small, anonymous functions defined inline:

# Traditional function
def square(x):
    return x**2

# Equivalent lambda
square_lambda = lambda x: x**2

print(f"Traditional: {square(5)}")
print(f"Lambda: {square_lambda(5)}")

# Lambdas shine for sorting and filtering
data_points = [
    {'x': 1.5, 'y': 2.3, 'error': 0.1},
    {'x': 2.1, 'y': 4.7, 'error': 0.05},
    {'x': 0.8, 'y': 1.2, 'error': 0.2},
]

# Sort by x value
by_x = sorted(data_points, key=lambda p: p['x'])
print("\nSorted by x:")
for p in by_x:
    print(f"  x={p['x']}, y={p['y']}")

# Sort by relative error (error/y)
by_rel_error = sorted(data_points, key=lambda p: p['error']/p['y'])
print("\nSorted by relative error:")
for p in by_rel_error:
    rel_err = p['error']/p['y'] * 100
    print(f"  x={p['x']}, relative error={rel_err:.1f}%")

Map, Filter, and Reduce

These functional tools transform how you process data:

from functools import reduce

# Sample measurements in CGS units
measurements_cms = [981, 979, 983, 9900, 980, 982, 978]  # cm/s² (g values)

# FILTER: Remove outliers
mean_estimate = 980  # cm/s²
tolerance = 10  # cm/s²

good_data = list(filter(
    lambda x: abs(x - mean_estimate) < tolerance,
    measurements_cms
))

print(f"Original: {measurements_cms}")
print(f"Filtered: {good_data}")
print(f"Removed {len(measurements_cms) - len(good_data)} outliers")

# MAP: Apply calibration factor
calibration = 1.002  # 0.2% correction
calibrated = list(map(lambda x: x * calibration, good_data))
print(f"Calibrated: {[f'{x:.1f}' for x in calibrated]}")

# REDUCE: Calculate mean
mean = reduce(lambda a, b: a + b, calibrated) / len(calibrated)
print(f"Final mean: {mean:.1f} cm/s²")

# Note: These functional patterns are essential for JAX
# Pure functions enable automatic differentiation
# Map operations parallelize on GPUs
# Immutable data prevents side effects

Functions as First-Class Objects

In Python, functions are objects you can pass around:

def apply_operator(data, operator_func):
    """
    Apply any operation to data.
    
    operator_func determines the transformation.
    """
    return [operator_func(x) for x in data]

# Define different operations
def grams_to_kilograms(g):
    """Convert grams to kilograms."""
    return g / 1000.0

def dynes_to_newtons(dynes):
    """Convert dynes to Newtons (1 N = 10⁵ dynes)."""
    return dynes / 1e5

def normalize(x, mean=0, std=1):
    """Standardize value."""
    return (x - mean) / std

# Use different operations
masses_g = [100, 200, 300, 400]  # grams
masses_kg = apply_operator(masses_g, grams_to_kilograms)
print(f"Grams: {masses_g}")
print(f"Kilograms: {masses_kg}")

forces_dynes = [1e5, 2e5, 3e5, 4e5]  # dynes
forces_n = apply_operator(forces_dynes, dynes_to_newtons)
print(f"\nDynes: {forces_dynes}")
print(f"Newtons: {forces_n}")

# Create custom normalizer with closure
values = [2, 4, 6, 8, 10]
mean = sum(values) / len(values)
std = (sum((x - mean)**2 for x in values) / len(values)) ** 0.5

normalizer = lambda x: normalize(x, mean, std)
normalized = apply_operator(values, normalizer)
print(f"\nOriginal: {values}")
print(f"Normalized: {[f'{x:.2f}' for x in normalized]}")

5.5 Modules and Packages

As your analysis grows from scripts to projects, organization becomes critical. Modules let you organize related functions together and reuse them across projects.

Creating Your First Module

Save this as statistics_tools.py in your current directory:

# Method 1: Show the module contents (you would save this in a file)
module_code = '''
"""
statistics_tools.py - Basic statistical calculations.

This module provides fundamental statistical functions.
"""

import math

def mean(data):
    """Calculate arithmetic mean."""
    return sum(data) / len(data)

def variance(data):
    """Calculate population variance."""
    m = mean(data)
    return sum((x - m)**2 for x in data) / len(data)

def std_dev(data):
    """Calculate population standard deviation."""
    return math.sqrt(variance(data))

def std_error(data):
    """Calculate standard error of the mean."""
    return std_dev(data) / math.sqrt(len(data))

# Module-level code runs on import
print("Loading statistics_tools module...")

# Test code that only runs when script is executed directly
if __name__ == "__main__":
    test_data = [1, 2, 3, 4, 5]
    print(f"Test mean: {mean(test_data)}")
    print(f"Test std dev: {std_dev(test_data):.3f}")
'''

# Method 2: Programmatically create the file
with open('statistics_tools.py', 'w') as f:
    f.write(module_code)

print("Module file 'statistics_tools.py' has been created in your current directory")

Using Your Module

# Different import methods

# Method 1: Import entire module
import statistics_tools

data = [10, 12, 11, 13, 10, 11, 12]
m = statistics_tools.mean(data)
s = statistics_tools.std_dev(data)
print(f"Method 1 - Mean: {m:.2f}, Std: {s:.2f}")

# Method 2: Import specific functions
from statistics_tools import mean, std_error

se = std_error(data)
print(f"Method 2 - Standard error: {se:.3f}")

# Method 3: Import with alias
import statistics_tools as stats

var = stats.variance(data)
print(f"Method 3 - Variance: {var:.3f}")

Building a Physics Module

Let’s create a comprehensive module for physics calculations:

# Create physics_cgs.py module with all CGS units
physics_module = '''
"""
physics_cgs.py - Physics calculations in CGS units.

All calculations use CGS (centimeter-gram-second) units.
"""

# Physical constants in CGS
SPEED_OF_LIGHT = 2.998e10      # cm/s
PLANCK_CONSTANT = 6.626e-27    # erg⋅s  
BOLTZMANN = 1.381e-16          # erg/K
ELECTRON_MASS = 9.109e-28      # g
PROTON_MASS = 1.673e-24        # g
GRAVITATIONAL_CONSTANT = 6.674e-8  # cm³/(g⋅s²)

def kinetic_energy(mass_g, velocity_cms):
    """KE = ½mv² in ergs."""
    return 0.5 * mass_g * velocity_cms**2

def momentum(mass_g, velocity_cms):
    """p = mv in g⋅cm/s."""
    return mass_g * velocity_cms

def de_broglie_wavelength(mass_g, velocity_cms):
    """λ = h/p in cm."""
    p = momentum(mass_g, velocity_cms)
    return PLANCK_CONSTANT / p if p != 0 else float('inf')

def thermal_velocity(temp_k, mass_g):
    """RMS thermal velocity in cm/s."""
    import math
    return math.sqrt(3 * BOLTZMANN * temp_k / mass_g)

def photon_energy(wavelength_cm):
    """E = hc/λ in ergs."""
    return PLANCK_CONSTANT * SPEED_OF_LIGHT / wavelength_cm

def gravitational_force(m1_g, m2_g, distance_cm):
    """F = Gm₁m₂/r² in dynes."""
    if distance_cm == 0:
        return float('inf')
    return GRAVITATIONAL_CONSTANT * m1_g * m2_g / distance_cm**2

class Particle:
    """Simple particle with physics methods."""
    
    def __init__(self, mass_g, velocity_cms):
        self.mass = mass_g
        self.velocity = velocity_cms
    
    def kinetic_energy(self):
        return kinetic_energy(self.mass, self.velocity)
    
    def wavelength(self):
        return de_broglie_wavelength(self.mass, self.velocity)
'''

with open('physics_cgs.py', 'w') as f:
    f.write(physics_module)

# Now use it
import physics_cgs

# Electron at 1% light speed (all CGS units)
v_electron = 0.01 * physics_cgs.SPEED_OF_LIGHT  # cm/s
ke = physics_cgs.kinetic_energy(physics_cgs.ELECTRON_MASS, v_electron)
wavelength = physics_cgs.de_broglie_wavelength(physics_cgs.ELECTRON_MASS, v_electron)

print(f"Electron at 1% c:")
print(f"  Velocity = {v_electron:.2e} cm/s")
print(f"  KE = {ke:.2e} ergs")
print(f"  de Broglie λ = {wavelength:.2e} cm")

# Test gravitational force (Earth-Moon in CGS)
earth_mass_g = 5.972e27  # grams
moon_mass_g = 7.342e25   # grams  
earth_moon_distance_cm = 3.844e10  # cm

force = physics_cgs.gravitational_force(earth_mass_g, moon_mass_g, earth_moon_distance_cm)
print(f"\nEarth-Moon gravitational force: {force:.2e} dynes")

Import Best Practices

# GOOD: Clear, explicit imports
import numpy as np
import matplotlib.pyplot as plt
from math import sin, cos, pi

# BAD: Wildcard imports pollute namespace
# from numpy import *  # Adds 600+ names!
# from math import *   # Conflicts with numpy!

# Example of namespace collision
import math
import cmath  # Complex math module (introduced in Chapter 2)

# Clear which log we're using
real_log = math.log(10)      # Natural log of real number
complex_log = cmath.log(-1)   # Can handle complex numbers

print(f"math.log(10) = {real_log:.3f}")
print(f"cmath.log(-1) = {complex_log}")  # Returns complex number

# If we had used 'from math import *':
# log(10)  # Which log? math or cmath? Unclear!

5.6 Documentation and Testing

Good documentation and testing make your functions trustworthy and reusable. Professional scientific code requires both to ensure reproducibility and reliability.

Documentation Levels

# Level 1: Minimal (but essential)
def quadratic_formula(a, b, c):
    """Solve ax² + bx + c = 0."""
    discriminant = b**2 - 4*a*c
    if discriminant < 0:
        return None, None
    sqrt_disc = discriminant**0.5
    return (-b + sqrt_disc)/(2*a), (-b - sqrt_disc)/(2*a)

# Level 2: Professional documentation
def integrate_trapezoid(x_values, y_values):
    """
    Integrate using the trapezoidal rule.
    
    Parameters
    ----------
    x_values : list or array
        X coordinates of data points (must be sorted)
    y_values : list or array
        Y coordinates of data points
    
    Returns
    -------
    float
        Approximate integral of y with respect to x
    
    Examples
    --------
    >>> x = [0, 1, 2, 3]
    >>> y = [0, 1, 4, 9]  # y = x²
    >>> integrate_trapezoid(x, y)
    8.0  # Exact answer is 9
    
    Notes
    -----
    Uses the composite trapezoidal rule:
    ∫y dx ≈ Σ(y[i] + y[i+1])/2 * (x[i+1] - x[i])
    
    More accurate for smooth functions with
    many points.
    """
    if len(x_values) != len(y_values):
        raise ValueError("x and y must have same length")
    
    integral = 0
    for i in range(len(x_values) - 1):
        dx = x_values[i + 1] - x_values[i]
        avg_y = (y_values[i] + y_values[i + 1]) / 2
        integral += avg_y * dx
    
    return integral

# Test the function
x = [0, 0.5, 1.0, 1.5, 2.0]
y = [0, 0.25, 1.0, 2.25, 4.0]  # y = x²
result = integrate_trapezoid(x, y)
print(f"Integral of x² from 0 to 2: {result:.3f}")
print(f"Exact answer: {8/3:.3f}")
print(f"Error: {abs(result - 8/3):.3f}")

Testing Your Functions

def quadratic_formula(a, b, c):
    """
    Solve quadratic equation ax² + bx + c = 0.
    
    Returns:
        tuple: (root1, root2) or (None, None) if no real roots
    """
    import math
    
    # Calculate discriminant
    discriminant = b**2 - 4*a*c
    
    # Check if real roots exist
    if discriminant < 0:
        return None, None
    elif discriminant == 0:
        # One repeated root
        root = -b / (2*a)
        return root, root
    else:
        # Two distinct roots
        sqrt_disc = math.sqrt(discriminant)
        root1 = (-b + sqrt_disc) / (2*a)
        root2 = (-b - sqrt_disc) / (2*a)
        return root1, root2

def test_quadratic():
    """Test quadratic formula solver."""
    
    # Test 1: Simple case (x² - 5x + 6 = 0)
    # Roots should be 2 and 3
    r1, r2 = quadratic_formula(1, -5, 6)
    assert abs(r1 - 3) < 1e-10 or abs(r1 - 2) < 1e-10
    assert abs(r2 - 3) < 1e-10 or abs(r2 - 2) < 1e-10
    print("✓ Test 1: Simple roots")
    
    # Test 2: No real roots (x² + 1 = 0)
    r1, r2 = quadratic_formula(1, 0, 1)
    assert r1 is None and r2 is None
    print("✓ Test 2: No real roots")
    
    # Test 3: Single root (x² - 2x + 1 = 0)
    # Root should be 1 (twice)
    r1, r2 = quadratic_formula(1, -2, 1)
    assert abs(r1 - 1) < 1e-10
    assert abs(r2 - 1) < 1e-10
    print("✓ Test 3: Repeated root")
    
    print("All tests passed!")

# Run tests
test_quadratic()

Introduction to Type Hints (Optional Enhancement)

Python supports type hints to document expected types:

def kinetic_energy_typed(mass_g: float, velocity_cms: float) -> float:
    """
    Calculate kinetic energy with type hints.
    
    The ': float' annotations indicate expected types.
    The '-> float' indicates the return type.
    These are documentation only - Python doesn't enforce them!
    """
    return 0.5 * mass_g * velocity_cms**2

# Type hints help IDEs provide better autocomplete and catch potential errors
energy: float = kinetic_energy_typed(9.109e-28, 3e9)
print(f"Energy: {energy:.2e} ergs")

# Python still allows "wrong" types - hints aren't enforced!
# This works even though we pass integers instead of floats:
energy2 = kinetic_energy_typed(1, 100)  # Still works!
print(f"Integer inputs still work: {energy2} ergs")

# For more complex types, import from typing module
from typing import List, Tuple, Optional

def analyze_data_typed(values: List[float]) -> Tuple[float, float, Optional[float]]:
    """
    Analyze data with complex type hints.
    
    List[float] means a list of floats
    Tuple[float, float, Optional[float]] means it returns
    a tuple with two floats and maybe a third float (or None)
    """
    if not values:
        return 0.0, 0.0, None
    
    n = len(values)
    mean = sum(values) / n
    variance = sum((x - mean)**2 for x in values) / n
    std_dev = variance ** 0.5
    
    sorted_vals = sorted(values)
    median = sorted_vals[n // 2] if n % 2 == 1 else (sorted_vals[n//2-1] + sorted_vals[n//2]) / 2
    
    return mean, std_dev, median

# The IDE now knows exactly what types are returned
mean_val, std_val, median_val = analyze_data_typed([1.0, 2.0, 3.0, 4.0, 5.0])
print(f"Mean: {mean_val:.1f}, Std: {std_val:.2f}, Median: {median_val:.1f}")

5.7 Performance Considerations

Understanding function overhead helps you write efficient code. Let’s measure and optimize!

Function Call Overhead

import time

def simple_add(a, b):
    """Minimal work - overhead visible."""
    return a + b

def complex_calc(x):
    """Heavy computation - overhead negligible."""
    result = 0
    for i in range(100):
        result += (x + i) ** 0.5
    return result / 100

# Measure overhead
n_calls = 100000

# Simple function
start = time.perf_counter()
for _ in range(n_calls):
    simple_add(1.0, 2.0)
simple_time = time.perf_counter() - start

# Inline equivalent
start = time.perf_counter()
for _ in range(n_calls):
    result = 1.0 + 2.0
inline_time = time.perf_counter() - start

# Complex function
start = time.perf_counter()
for _ in range(n_calls // 100):  # Fewer calls (it's slower)
    complex_calc(1.0)
complex_time = time.perf_counter() - start

print("Function Call Analysis:")
print(f"Simple function: {simple_time*1000:.1f} ms")
print(f"Inline addition: {inline_time*1000:.1f} ms")
print(f"Overhead factor: {simple_time/inline_time:.1f}x")
print(f"\nComplex function: {complex_time*1000:.1f} ms")
print("\nLesson: Overhead only matters for trivial operations!")

Memoization for Expensive Calculations

from functools import lru_cache

# Fibonacci without memoization (exponentially slow!)
def fib_slow(n):
    if n < 2:
        return n
    return fib_slow(n-1) + fib_slow(n-2)

# Fibonacci with memoization (linear time!)
@lru_cache(maxsize=128)
def fib_fast(n):
    if n < 2:
        return n
    return fib_fast(n-1) + fib_fast(n-2)

# Add astrophysics-specific memoization example
@lru_cache(maxsize=128)
def planck_function(wavelength_nm, temp_k):
    """
    Cached Planck function for blackbody radiation.
    Expensive calculation cached for repeated calls.
    Common in spectral fitting where same temps recur.
    """
    import math
    h = 6.626e-27  # Planck constant in erg⋅s
    c = 2.998e10   # Speed of light in cm/s
    k = 1.381e-16  # Boltzmann constant in erg/K
    
    wavelength_cm = wavelength_nm * 1e-7
    
    # Planck function calculation
    exp_term = (h * c) / (wavelength_cm * k * temp_k)
    if exp_term > 700:  # Prevent overflow
        return 0
    
    numerator = 2 * h * c**2 / wavelength_cm**5
    denominator = math.exp(exp_term) - 1
    return numerator / denominator

# Compare performance
import time

n = 30

start = time.perf_counter()
result_slow = fib_slow(n)
time_slow = time.perf_counter() - start

start = time.perf_counter()
result_fast = fib_fast(n)
time_fast = time.perf_counter() - start

print(f"Fibonacci({n}) = {result_fast}")
print(f"Without memoization: {time_slow*1000:.1f} ms")
print(f"With memoization: {time_fast*1000:.3f} ms")
print(f"Speedup: {time_slow/time_fast:.0f}x")

# Check cache statistics
print(f"\nCache info: {fib_fast.cache_info()}")

# Test Planck function caching
print(f"\nPlanck function at 500nm, 5778K: {planck_function(500, 5778):.2e} erg/(s⋅cm²⋅cm⋅sr)")
print(f"Cache info: {planck_function.cache_info()}")

Main Takeaways

Functions transform programming from repetitive scripting into modular, maintainable software engineering. When you encapsulate logic in well-designed functions, you create building blocks that can be tested independently, shared with collaborators, and combined into complex analysis pipelines. The progression from simple functions to modules to packages mirrors how scientific software naturally grows—what starts as a quick calculation evolves into a shared tool used by entire research communities.

The distinction between positional, keyword, and default arguments gives you the flexibility to design interfaces that are both powerful and intuitive. Positional arguments work well for obvious parameters like power(base, exponent), while keyword arguments with defaults enable complex functions that remain simple for common cases. Understanding when to use each type—and the critical danger of mutable default arguments—prevents the subtle bugs that have plagued major scientific packages.

The scope rules and namespace concepts you’ve learned explain why variables sometimes behave unexpectedly in complex programs. Understanding the LEGB rule prevents frustrating bugs where variables have unexpected values or modifications in one place affect seemingly unrelated code. The mutable default argument trap demonstrates why understanding Python’s evaluation model is crucial for writing reliable code. These aren’t just academic concepts—they’ve caused real disasters in production systems.

Functional programming concepts like map, filter, and pure functions prepare you for modern scientific computing frameworks. JAX requires functional style for automatic differentiation, parallel processing works best with stateless functions, and testing becomes trivial when functions have no side effects. The ability to pass functions as arguments and return them from other functions enables powerful patterns like the specialized integrators we created with closures.

The performance measurements showed that function call overhead only matters for trivial operations in tight loops—exactly where you’ll want to use NumPy’s vectorized operations (Chapter 7) instead. For complex calculations, overhead is negligible compared to computation time. Memoization can provide dramatic speedups when expensive calculations repeat, as often happens in optimization and parameter searching.

Looking forward, the functions you’ve learned to write here form the foundation for object-oriented programming in Chapter 6, where functions become methods attached to objects. The module organization skills prepare you for building larger scientific packages, while the documentation practices ensure your code can be understood and maintained by others. Most importantly, thinking in terms of functional contracts and clear interfaces will make you a better computational scientist, capable of building the robust, efficient tools that modern research demands.

Definitions

argument - The actual value passed to a function when calling it (e.g., in f(5), 5 is an argument)

closure - A function that remembers variables from its enclosing scope even after that scope has finished executing

decorator - A function that modifies another function’s behavior without changing its code

default argument - A parameter value used when no argument is provided during function call

docstring - A string literal that appears as the first statement in a function, module, or class to document its purpose

function - A reusable block of code that performs a specific task, taking inputs and optionally returning outputs

global - A keyword that allows a function to modify a variable in the global scope

keyword argument - An argument passed to a function by explicitly naming the parameter

lambda - An anonymous function defined inline using the lambda keyword

LEGB - The order Python searches for variables: Local, Enclosing, Global, Built-in

memoization - Caching function results to avoid recomputing expensive operations

module - A Python file containing definitions and statements that can be imported and reused

namespace - A container that holds a set of identifiers and their associated objects

package - A directory containing multiple Python modules and an __init__.py file

parameter - A variable in a function definition that receives a value when the function is called

positional argument - An argument passed to a function based on its position in the parameter list

pure function - A function that always returns the same output for the same input with no side effects

return value - The result that a function sends back to the code that called it

scope - The region of a program where a variable is accessible

sentinel value - A special value (like None) used to signal a particular condition, often as default for mutable arguments

side effect - Any state change that occurs beyond returning a value from a function

*args - Syntax for collecting variable positional arguments into a tuple

*kwargs - Syntax for collecting variable keyword arguments into a dictionary

Key Takeaways

Quick Reference Tables

Function Definition Patterns

PatternSyntaxUse Case
Basic functiondef func(x, y):Simple operations
Positional onlydef func(a, b, /):Force positional
Default argumentsdef func(x, y=10):Optional parameters
Keyword onlydef func(*, x, y):Force keywords
Variable argsdef func(*args):Unknown number of inputs
Keyword argsdef func(**kwargs):Flexible options
Combineddef func(a, *args, x=1, **kwargs):Maximum flexibility
Lambdalambda x: x**2Simple inline functions

When to Use Different Argument Types

Argument TypeWhen to UseExample
PositionalObvious meaning, 1-3 paramspower(2, 3)
KeywordMany params, optionalplot(data, color='red')
DefaultCommon valuesround(3.14, digits=2)
*argsVariable inputsmaximum(1, 2, 3, 4)
**kwargsConfiguration optionssetup(debug=True, verbose=False)

Module Import Patterns

PatternExampleWhen to Use
Import moduleimport numpyUse many functions
Import with aliasimport numpy as npStandard abbreviations
Import specificfrom math import sin, cosFew specific functions
Import all (avoid!)from math import *Never in production
Relative importfrom . import moduleWithin packages

Common Function Bugs and Fixes

ProblemSymptomFix
Mutable defaultData persists between callsUse None sentinel
UnboundLocalErrorCan’t modify globalUse global or pass value
Missing returnFunction returns NoneAdd return statement
Namespace pollutionName conflictsAvoid wildcard imports
Slow recursionExponential timeAdd memoization
Type confusionUnexpected typesAdd type hints/validation

Next Chapter Preview

With functions and modules mastered, Chapter 6 introduces Object-Oriented Programming (OOP)—a paradigm that bundles data and behavior together. You’ll learn to create classes that model physical systems naturally: a Particle class with position and velocity attributes, methods to calculate energy and momentum, and special methods that make your objects work seamlessly with Python’s built-in functions.

The functional programming concepts from this chapter provide essential background for OOP. Methods are just functions attached to objects, and understanding scope prepares you for the self parameter that confuses many beginners. The module organization skills you’ve developed will expand to organizing classes and building object hierarchies. Most importantly, the design thinking you’ve practiced—creating clean interfaces and thinking about contracts—directly applies to designing effective classes that model the complex systems you’ll encounter in computational physics.

References

Historical Events and Technical Details

  1. Ariane 5 Flight 501 Failure (1996)

    • Lions, J. L. et al. (1996). “ARIANE 5 Flight 501 Failure: Report by the Inquiry Board.” European Space Agency. Paris: ESA.

    • Gleick, J. (1996). “A Bug and A Crash.” The New York Times Magazine, December 1, 1996.

    • Nuseibeh, B. (1997). “Ariane 5: Who Dunnit?” IEEE Software, 14(3), 15-16.

  2. LIGO Gravitational Wave Detection (2015)

    • Abbott, B. P. et al. (LIGO Scientific Collaboration) (2016). “Observation of Gravitational Waves from a Binary Black Hole Merger.” Physical Review Letters, 116(6), 061102.

    • Abbott, B. P. et al. (2016). “GW150914: The Advanced LIGO Detectors in the Era of First Discoveries.” Physical Review Letters, 116(13), 131103.

    • LIGO Scientific Collaboration. (2021). “LIGO Algorithm Library - LALSuite.” Available at: https://lscsoft.docs.ligo.org/lalsuite/

    • LIGO Open Science Center. (2024). “LIGO Open Data.” Available at: https://www.gw-openscience.org/

  3. Therac-25 Radiation Overdose Disasters (1985-1987)

    • Leveson, N.G. & Turner, C.S. (1993). “An Investigation of the Therac-25 Accidents.” IEEE Computer, 26(7), 18-41.

    • “Therac-25.” Wikipedia, The Free Encyclopedia. Wikimedia Foundation, Inc. Retrieved from: Therac-25

  4. Mars Climate Orbiter Loss (1999)

    • Stephenson, A. G. et al. (1999). “Mars Climate Orbiter Mishap Investigation Board Report.” NASA.

    • Oberg, J. (1999). “Why the Mars Probe Went Off Course.” IEEE Spectrum, 36(12), 34-39.

Python Documentation

  1. Python Language Reference

  2. Scientific Python Resources

    • Harris, C. R. et al. (2020). “Array programming with NumPy.” Nature, 585(7825), 357-362.

    • VanderPlas, J. (2016). Python Data Science Handbook. O’Reilly Media. ISBN: 978-1491912058.