Python Basics II

Contents

Python Basics II#

Welcome to Python Basics II! In this lecture, we’ll cover:

  1. Functions — Writing reusable code

  2. Data Structures — Lists, tuples, dictionaries, and sets

  3. File I/O — Reading and writing files

  4. Error Handling — Making robust code with try/except

By the end of this notebook, you’ll be able to write well-organized Python programs that can read data from files, process it with functions, and handle errors gracefully.

I Functions in Python#

1. What is a Function?#

In Python, a function is a reusable block of code that performs a specific task. It helps you:

  • Organize code into logical sections.

  • Avoid repetition by reusing the same logic multiple times.

  • Enhance readability by making your code self-contained and modular.

Python functions are defined with the def keyword. A typical function looks like this:

def function_name(parameters):
    """
    Docstring: A description of the function’s purpose, inputs, and outputs.
    """
    # function body
    # ...
    return result

A Simple Example#

def greet(name):
    """Return a greeting for the given name."""
    return f"Hello, {name}!"

print(greet("Rutgers"))  # Expect: Hello, Alice!
Hello, Rutgers!

2. Function Parameters and Arguments#

Functions can have different types of parameters and arguments to accommodate a variety of use cases.

2.1 Positional Arguments#

By default, arguments are matched to parameters by position:

def add(x, y):
    """Return the sum of x and y."""
    return x + y

result = add(3, 5)  # x=3, y=5
print(result)       # Expect: 8
8

2.2 Keyword Arguments#

You can also match arguments by explicitly naming them:

def connect(host, port):
    """Simulate connecting to a network host and port."""
    return f"Connecting to {host} on port {port}"

print(connect(port=8080, host="rutgers.edu"))
# Output: Connecting to localhost on port 8080
print(connect(host="rutgers.edu", port=8888))
Connecting to rutgers.edu on port 8080
Connecting to rutgers.edu on port 8888

2.3 Default Arguments#

If a parameter has a default value, it becomes optional for the caller:

def power(base, exponent=2):
    """Raise base to a given exponent; default exponent is 2."""
    return base ** exponent

print(power(4))      # exponent defaults to 2 -> 16
print(power(4, 3))   # -> 64
power(5)
16
64

Note: Be cautious when using mutable default arguments (e.g., lists or dictionaries) since they can lead to unexpected behavior if modified in-place.

def add_item(item, items=[]):
    items.append(item)
    return items

print (add_item(1))
print (add_item(2))
print (add_item(3))

list1 = add_item(,list)

print (add_item(1, [44]))
[1]
[1, 2]
[1, 2, 3]
[44, 1]
def add_item2(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

print (add_item2(1))
print (add_item2(2))
print (add_item2(3))
[1]
[2]
[3]

3. Return Values#

  1. Single Return Value: A function returns a single object—this object can be anything in Python (string, number, list, dictionary, etc.).

  2. Multiple Return Values: Python’s tuple-unpacking allows for multiple outputs.

def min_and_max(values):
    """Return both the minimum and maximum of a list of values."""
    return min(values), max(values)

mn, mx = min_and_max([3, 1, 5, 9, 2])
print(mn, mx)  # Expect: 1 9

mm = min_and_max([3, 1, 5, 9, 2])
print (mm)

def somefunc():
    return 4+5

print(somefunc())
1 9
(1, 9)
9
  1. No Explicit Return: If a function ends without a return statement, Python implicitly returns None.

4. Documentation with Docstrings#

A docstring is a specialized string literal that documents a function’s purpose, parameters, and return values. Typically enclosed with triple quotes ("""). Good docstrings:

  • Start with a one-sentence summary.

  • Optionally include more detailed explanations or examples in subsequent paragraphs.

  • Can use conventions like NumPy Docstrings or Google Python Style.

def linear_transform(x, a, b):
    """
    Compute a linear transformation of x: y = a*x + b.

    Parameters
    ----------
    x : float
        The input variable.
    a : float
        The slope of the linear function.
    b : float
        The intercept of the linear function.

    Returns
    -------
    float
        The value of y = a*x + b.
    """
    return a*x + b
help(linear_transform)
Help on function linear_transform in module __main__:

linear_transform(x, a, b)
    Compute a linear transformation of x: y = a*x + b.

    Parameters
    ----------
    x : float
        The input variable.
    a : float
        The slope of the linear function.
    b : float
        The intercept of the linear function.

    Returns
    -------
    float
        The value of y = a*x + b.

5. Scoping and Namespaces#

Variables defined inside a function are local to that function, meaning they are not accessible outside unless explicitly returned. This principle (known as scope) protects your variables from unintentional modifications.

def foo():
    x = 10  # local variable
    return x

def foo2():
    x = 20
    return x

print(foo())
# A local variable is not available outside the function.
# Running `print(x)` here would raise a NameError.

6. An Introduction to Recursion#

Though recursion can be powerful, it needs to be used judiciously:

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

Be mindful of recursion depth limits in Python.

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

print(factorial(5))
120

7. Best Practices#

  • Keep functions small and focused.

  • Name functions clearly and descriptively.

  • Use docstrings and type hints for clarity.

  • Validate inputs, watch for edge cases.

  • Consider unit tests (e.g., unittest, pytest).

Summary#

Functions are fundamental to writing modular, maintainable Python code. For a graduate student working on complex projects—perhaps in computational research, data science, or advanced engineering simulations—understanding how to design, document, and structure functions effectively is essential.

II Python Containers#

This section provides an overview of lists, tuples, and dictionaries, including slicing and key/value manipulation. We’ll explore usage and performance considerations, especially in large-scale or research scenarios.

1. Overview#

In Python, a container is any object that holds an arbitrary number of other objects. The most commonly used containers are:

  1. Lists: Ordered, mutable sequences.

  2. Tuples: Ordered, immutable sequences.

  3. Dictionaries: Key-value mappings, mutable in nature.

These containers are essential for organizing data in scientific computing and complex research applications.

2. Lists#

2.1 Basic Usage#

A Python list is defined using square brackets [ ]. It is mutable, meaning you can add, remove, or change elements in-place.

# Creating a list
data = [10, 20, 30, 40]

# Accessing elements by index
print(data[0])  # Output: 10
print(data[-1]) # Output: 40
print(data[3])
print(data[-2])

# Modifying elements
data[0] = 100
print('data', data)  # [100, 20, 30, 40]

data2 = data[:]  # data2 = data or data2=data[:]
print('data2', data2)

data2[0] = 1000
print('data', data)
print('data2', data2)

data2.append(10000)
print('data2', data2)
data2.reverse()
print('data2', data2)
10
40
40
30
data [100, 20, 30, 40]
data2 [100, 20, 30, 40]
data [100, 20, 30, 40]
data2 [1000, 20, 30, 40]
data2 [1000, 20, 30, 40, 10000]
data2 [10000, 40, 30, 20, 1000]

Common Methods#

  • append(x): Add an item to the end.

  • extend(iterable): Add multiple items (another list or iterator).

  • insert(i, x): Insert x at index i.

  • pop(i): Remove and return the item at index i (default last).

  • remove(x): Remove the first occurrence of x.

  • sort(): Sort the list in-place.

  • reverse(): Reverse the list in-place.

Inserting or removing elements from the beginning of a list can be costly (O(n)), as other elements shift.

2.2 List Slicing#

Slicing allows you to extract a subsequence of a list:

numbers = [0, 1, 2, 3, 4, 5, 6, 7]

# Basic slice: list[start:end:step]
sub_list = numbers[2:6]       # [2, 3, 4, 5]
step_slice = numbers[1:7:2]   # [1, 3, 5]
reverse_slice = numbers[::-1] # [7, 6, 5, 4, 3, 2, 1, 0]

print("sub_list:", sub_list)
print("step_slice:", step_slice)
print("reverse_slice:", reverse_slice)
sub_list: [2, 3, 4, 5]
step_slice: [1, 3, 5]
reverse_slice: [7, 6, 5, 4, 3, 2, 1, 0]

Note: For large-scale data, slicing can help avoid copying the entire list, especially if you’re using libraries like NumPy that implement slicing via views.

2.3 Advanced Considerations: List Comprehensions#

A list comprehension is a concise way to create lists:

squares = [x**2 for x in range(5)]
print(squares)

squares2 = []
for x in range(5):
    squares2.append(x**2)
print(squares2)
[0, 1, 4, 9, 16]
[0, 1, 4, 9, 16]

List comprehensions are often more Pythonic and can be faster than building lists with a standard for loop in many cases.

3. Tuples#

3.1 Basic Usage#

A tuple is an immutable ordered sequence, typically created using parentheses ( ). Similar to lists, but you cannot modify them in-place.

coords = (3.2, 4.1)
print(coords[0])  # 3.2

lcoords = [3.2, 4.1]
print(lcoords[0])

lcoords[0] = 100
print(lcoords[0])

# Tuples are immutable, so this assignment would raise a TypeError:
# coords[0] = 100

Trying to modify a tuple element will raise a TypeError:

3.2 Performance and Immutability#

Tuples can sometimes be more memory-efficient and faster to process than lists, especially for read-only data.

4. Dictionaries#

4.1 Basic Usage#

A dictionary in Python is a mutable mapping of key-value pairs, created using curly braces { } or the dict() constructor:

person = {
    "name": "Alice",
    "age": 29,
    "department": "Physics"
}

print(person["name"])  # Accessing values by key

person["age"] = 30      # Modifying values
person

print("shool" in person)
print(person.keys())
print(person.values())
print(person.items())
Alice
False
dict_keys(['name', 'age', 'department'])
dict_values(['Alice', 30, 'Physics'])
dict_items([('name', 'Alice'), ('age', 30), ('department', 'Physics')])

4.2 Key/Value Access and Methods#

  • Check existence: if "department" in person:

  • Add/Update: person["title"] = "Research Associate"

  • Remove entries: person.pop("title") or del person["age"]

Methods:

  • keys(), values(), items()

4.3 Dictionary Comprehensions#

Similar to list comprehensions, you can create a dictionary using dictionary comprehensions:

squares_dict = {x: x**2 for x in range(5)}
squares_dict

4.4 Use Cases#

  1. Metadata: Store experiment parameters.

  2. Lookup Tables: Fast mapping from IDs or keys to values.

  3. Sparse Data: Represent data that only has a few non-zero entries.

5. Nested Data Structures#

You can nest these containers for more complex data:

lab_data = {
    "experiment1": {
        "timestamp": "2025-01-01",
        "results": [1.2, 2.3, 3.4],
    },
    "experiment2": {
        "timestamp": "2025-01-02",
        "results": [2.1, 4.5, 9.0],
    }
}

print(lab_data["experiment1"]["results"][0])  # 1.2
1.2

6. Performance Considerations#

6.1 Lists vs. Tuples#

  • Lists: Good for data that you need to mutate frequently.

  • Tuples: Lighter and faster if the data is static.

6.2 Large-Scale Data#

  • Consider NumPy, pandas, or SciPy for specialized or large-scale data.

6.3 Dictionary Optimizations#

  • Hash tables can degrade if collisions become frequent.

  • For read-only dictionaries, consider types.MappingProxyType.

  • collections.OrderedDict can preserve insertion order if needed (though normal dicts do in 3.7+).

7. Best Practices#

  1. Start with Built-ins.

  2. Document nested containers.

  3. Validate that keys exist and list lengths match.

  4. Immutable Where Possible.

  5. Leverage Comprehensions.

  6. Time Complexity awareness.

Summary#

Python’s lists, tuples, and dictionaries form the backbone of most data structures in Python. Understanding their nuances, especially in research or large-scale code, is essential.

5. Sets#

A set is an unordered collection of unique elements. Sets are useful for:

  • Removing duplicates from a list

  • Fast membership testing (O(1) lookup)

  • Mathematical set operations (union, intersection, difference)

## Creating a set
unique_numbers = {1, 2, 3, 2, 1}  # {1, 2, 3} - duplicates removed!
empty_set = set()  # Note: {} creates an empty dict, not a set

## From a list
numbers = [1, 2, 2, 3, 3, 3]
unique = set(numbers)  # {1, 2, 3}
# Creating sets
particles = {'electron', 'proton', 'neutron', 'electron'}  # duplicate removed
print(f"Particles: {particles}")
print(f"Number of unique particles: {len(particles)}")

# Fast membership testing
print(f"Is 'proton' in the set? {'proton' in particles}")
print(f"Is 'muon' in the set? {'muon' in particles}")
Particles: {'proton', 'electron', 'neutron'}
Number of unique particles: 3
Is 'proton' in the set? True
Is 'muon' in the set? False

5.1 Set Operations#

Sets support mathematical operations:

  • Union (|): Elements in either set

  • Intersection (&): Elements in both sets

  • Difference (-): Elements in first but not second

  • Symmetric Difference (^): Elements in either but not both

# Set operations
fermions = {'electron', 'proton', 'neutron', 'quark'}
leptons = {'electron', 'muon', 'tau', 'neutrino'}

print(f"Fermions: {fermions}")
print(f"Leptons: {leptons}")
print(f"Union (all particles): {fermions | leptons}")
print(f"Intersection (both categories): {fermions & leptons}")
print(f"Fermions but not leptons: {fermions - leptons}")
Fermions: {'proton', 'electron', 'quark', 'neutron'}
Leptons: {'tau', 'electron', 'neutrino', 'muon'}
Union (all particles): {'tau', 'muon', 'electron', 'quark', 'neutron', 'proton', 'neutrino'}
Intersection (both categories): {'electron'}
Fermions but not leptons: {'proton', 'neutron', 'quark'}

5.2 Common Use Case: Removing Duplicates#

# Removing duplicates from experimental data
measurements = [1.2, 3.4, 1.2, 5.6, 3.4, 7.8, 1.2]

print(list(set(measurements)))

unique_measurements = list(set(measurements))
print(f"Original: {measurements}")
print(f"Unique values: {unique_measurements}")

# Note: set() doesn't preserve order. Use dict.fromkeys() to preserve order:
ordered_unique = list(dict.fromkeys(measurements))
print(f"Unique (order preserved): {ordered_unique}")
[1.2, 3.4, 5.6, 7.8]
Original: [1.2, 3.4, 1.2, 5.6, 3.4, 7.8, 1.2]
Unique values: [1.2, 3.4, 5.6, 7.8]
Unique (order preserved): [1.2, 3.4, 5.6, 7.8]

III. File Input/Output#

Working with files is essential for any scientific programming. You’ll need to:

  • Read experimental data from files

  • Save results for later analysis

  • Process large datasets that don’t fit in your code

Python makes file operations straightforward.

1. Opening and Closing Files#

The basic pattern for working with files:

## Open a file
file = open('filename.txt', 'mode')

## Do something with the file
content = file.read()

## Always close when done!
file.close()

File modes:

  • 'r' — Read (default). File must exist.

  • 'w' — Write. Creates new file or overwrites existing!

  • 'a' — Append. Adds to end of file.

  • 'r+' — Read and write.

2. The with Statement (Context Manager)#

Always use with when working with files! It automatically closes the file, even if an error occurs:

with open('filename.txt', 'r') as file:
    content = file.read()
    # File is automatically closed when we exit this block

This is the recommended way to handle files in Python.

3. Reading Files#

# First, let's create a sample file to work with
sample_data = """Temperature Data
Day 1: 23.5 C
Day 2: 24.1 C
Day 3: 22.8 C
Day 4: 25.0 C
Day 5: 23.9 C
"""

with open('temperature.txt', 'w') as f:
    f.write(sample_data)

print("Created temperature.txt")
Created temperature.txt

Reading Methods#

  • read() — Read entire file as one string

  • readline() — Read one line at a time

  • readlines() — Read all lines into a list

# Method 1: read() - entire file as string
with open('temperature.txt', 'r') as f:
    content = f.read()
print("=== Using read() ===")
print(content)

# Method 2: readlines() - list of lines
with open('temperature.txt', 'r') as f:
    lines = f.readlines()
print("=== Using readlines() ===")
print(lines)
print (len(lines))
=== Using read() ===
Temperature Data
Day 1: 23.5 C
Day 2: 24.1 C
Day 3: 22.8 C
Day 4: 25.0 C
Day 5: 23.9 C

=== Using readlines() ===
['Temperature Data\n', 'Day 1: 23.5 C\n', 'Day 2: 24.1 C\n', 'Day 3: 22.8 C\n', 'Day 4: 25.0 C\n', 'Day 5: 23.9 C\n']
6

Iterating Over Lines (Most Common Pattern)#

# Best practice: iterate directly over the file
with open('temperature.txt', 'r') as f:
    for line_number, line in enumerate(f, 1):
        print(f"Line {line_number}: {line.strip()}")
Line 1: Temperature Data
Line 2: Day 1: 23.5 C
Line 3: Day 2: 24.1 C
Line 4: Day 3: 22.8 C
Line 5: Day 4: 25.0 C
Line 6: Day 5: 23.9 C

4. Writing Files#

# Writing to a file
results = [
    ("Experiment 1", 3.14159),
    ("Experiment 2", 2.71828),
    ("Experiment 3", 1.41421),
]

with open('results.txt', 'w') as f:
    f.write("Experimental Results\n")  # write() for strings
    f.write("=" * 30 + "\n")
    for name, value in results:
        f.write(f"{name}: {value:.4f}\n")

print("Wrote results.txt")

# Verify by reading it back
with open('results.txt', 'r') as f:
    print(f.read())
Wrote results.txt
Experimental Results
==============================
Experiment 1: 3.1416
Experiment 2: 2.7183
Experiment 3: 1.4142

Appending to Files#

Use mode 'a' to add to the end of a file without erasing existing content:

# Append more results
with open('results.txt', 'a') as f:
    f.write("Experiment 4: 1.73205\n")
    f.write("Experiment 5: 2.23607\n")

# Check the updated file
with open('results.txt', 'r') as f:
    print(f.read())

5. Reading Numerical Data with NumPy#

For scientific data, numpy.loadtxt() and numpy.savetxt() are more convenient:

import numpy as np

# Create a numerical data file
data = """# Time (s), Position (m), Velocity (m/s)
0.0, 0.0, 10.0
1.0, 9.5, 9.0
2.0, 18.0, 8.0
3.0, 25.5, 7.0
4.0, 32.0, 6.0
"""

with open('motion_data.csv', 'w') as f:
    f.write(data)

# Read with numpy (skip header row, comma delimiter)
time, position, velocity = np.loadtxt('motion_data.csv',
                                       delimiter=',',
                                       skiprows=1,
                                       unpack=True)

print(f"Time: {time}")
print(f"Position: {position}")
print(f"Velocity: {velocity}")

Saving Numerical Data#

# Calculate acceleration and save results
acceleration = np.gradient(velocity, time)

# Stack columns and save
output_data = np.column_stack((time, position, velocity, acceleration))
np.savetxt('motion_analysis.csv',
           output_data,
           delimiter=',',
           header='Time,Position,Velocity,Acceleration',
           fmt='%.4f',
           comments='')

print("Saved motion_analysis.csv")

# Verify
with open('motion_analysis.csv', 'r') as f:
    print(f.read())

File I/O Summary#

Task

Method

Open file safely

with open(filename, mode) as f:

Read entire file

f.read()

Read lines

for line in f: or f.readlines()

Write string

f.write(string)

Read numerical data

np.loadtxt(filename, delimiter=',')

Save numerical data

np.savetxt(filename, data, delimiter=',')

IV. Error Handling with try/except#

Errors happen. Users enter invalid input. Files don’t exist. Networks fail. Good programs handle errors gracefully instead of crashing.

Python uses exceptions to handle errors. When something goes wrong, Python “raises” an exception. We can “catch” these exceptions and respond appropriately.

1. Common Python Exceptions#

Exception

Cause

ValueError

Wrong value type (e.g., int("hello"))

TypeError

Wrong type for operation (e.g., "5" + 3)

ZeroDivisionError

Division by zero

FileNotFoundError

File doesn’t exist

IndexError

List index out of range

KeyError

Dictionary key doesn’t exist

2. Basic try/except#

try:
    # Code that might cause an error
    risky_operation()
except SomeException:
    # Code to run if that error occurs
    handle_error()
x = 0
if x != 0:
    result = 10 / x
    print(result)
else:
    print("Cannot divide by zero.")
# Without error handling - this would crash!
# result = 10 / 0  # ZeroDivisionError

# With error handling
try:
    result = 10 / 0
    print(f"Result: {result}")
except ZeroDivisionError:
    print("Error: Cannot divide by zero!")

print("Program continues running...")
Error: Cannot divide by zero!
Program continues running...

3. Handling Multiple Exception Types#

def safe_divide(a, b):
    """Safely divide two numbers with error handling."""
    try:
        result = a / b
        return result
    except ZeroDivisionError:
        print("Error: Division by zero!")
        return None
    except TypeError:
        print("Error: Invalid types for division!")
        return None

# Test cases
print(f"10 / 2 = {safe_divide(10, 2)}")
print(f"10 / 0 = {safe_divide(10, 0)}")
print(f"'10' / 2 = {safe_divide('10', 2)}")
10 / 2 = 5.0
Error: Division by zero!
10 / 0 = None
Error: Invalid types for division!
'10' / 2 = None

4. The else and finally Clauses#

  • else: Runs if NO exception occurred

  • finally: ALWAYS runs, whether or not an exception occurred

try:
    risky_operation()
except SomeException:
    handle_error()
else:
    # Runs only if no exception
    success_actions()
finally:
    # Always runs
    cleanup()
def read_data_file(filename):
    """Read a data file with full error handling."""
    try:
        f = open(filename, 'r')
        data = f.read()
    except FileNotFoundError:
        print(f"Error: File '{filename}' not found!")
        return None
    else:
        print(f"Successfully read {len(data)} characters")
        return data
    finally:
        print("Cleanup: Closing file (if open)")
        try:
            f.close()
        except:
            pass  # File was never opened

# Test with existing and non-existing files
print("=== Reading existing file ===")
result = read_data_file('temperature.txt')

print("\n=== Reading non-existing file ===")
result = read_data_file('nonexistent.txt')
=== Reading existing file ===
Successfully read 87 characters
Cleanup: Closing file (if open)

=== Reading non-existing file ===
Error: File 'nonexistent.txt' not found!
Cleanup: Closing file (if open)

5. Practical Example: Input Validation#

A common use case is validating user input:

def get_positive_number(prompt):
    """Keep asking until user enters a valid positive number."""
    while True:
        try:
            value = float(input(prompt))
            if value <= 0:
                print("Please enter a positive number!")
                continue
            return value
        except ValueError:
            print("Invalid input! Please enter a number.")

# Uncomment to test interactively in a live notebook:
# mass = get_positive_number("Enter mass (kg): ")
# print(f"You entered: {mass} kg")

6. Physics Example: Robust Calculation Function#

import math

def calculate_orbital_period(semi_major_axis, central_mass):
    """
    Calculate orbital period using Kepler's third law.
    T = 2π * sqrt(a³ / (G * M))

    Parameters:
        semi_major_axis: in meters
        central_mass: in kg
    Returns:
        Period in seconds, or None if invalid input
    """
    G = 6.674e-11  # gravitational constant

    try:
        # Validate inputs
        if semi_major_axis <= 0:
            raise ValueError("Semi-major axis must be positive")
        if central_mass <= 0:
            raise ValueError("Central mass must be positive")

        # Calculate
        period = 2 * math.pi * math.sqrt(semi_major_axis**3 / (G * central_mass))
        return period

    except ValueError as e:
        print(f"Invalid input: {e}")
        return None
    except TypeError as e:
        print(f"Type error: {e}")
        return None

# Earth's orbit around the Sun
a_earth = 1.496e11  # meters
M_sun = 1.989e30    # kg

T = calculate_orbital_period(a_earth, M_sun)
if T:
    print(f"Earth's orbital period: {T:.2e} seconds")
    print(f"That's {T / (24*3600):.1f} days")

# Test error handling
print("\nTesting with invalid input:")
calculate_orbital_period(-1e11, M_sun)  # negative distance
calculate_orbital_period(a_earth, 0)     # zero mass
Earth's orbital period: 3.16e+07 seconds
That's 365.2 days

Testing with invalid input:
Invalid input: Semi-major axis must be positive
Invalid input: Central mass must be positive
import math

def calculate_orbital_period(semi_major_axis, central_mass):
    """
    Calculate orbital period using Kepler's third law.
    T = 2π * sqrt(a³ / (G * M))

    Parameters:
        semi_major_axis: in meters
        central_mass: in kg
    Returns:
        Period in seconds, or None if invalid input
    """
    if semi_major_axis <= 0 or central_mass <= 0:
        return None

    G = 6.674e-11  # gravitational constant
    period = 2 * math.pi * math.sqrt(semi_major_axis**3 / (G * central_mass))
    return period

a_earth = 1.496e11  # meters
M_sun = 1.989e30    # kg

T = calculate_orbital_period(a_earth, M_sun)
print(T)

7. Raising Your Own Exceptions#

Use raise to signal errors in your own code:

def set_temperature(kelvin):
    """Set temperature, ensuring it's physically valid."""
    if kelvin < 0:
        raise ValueError(f"Temperature {kelvin}K is below absolute zero!")
    print(f"Temperature set to {kelvin}K ({kelvin - 273.15:.1f}°C)")

# Valid temperature
set_temperature(300)

# Invalid temperature - this will raise an exception
try:
    set_temperature(-50)
except ValueError as e:
    print(f"Caught error: {e}")

Error Handling Summary#

Keyword

Purpose

try

Code that might raise an exception

except

Handle specific exception types

else

Run if no exception occurred

finally

Always run (cleanup)

raise

Raise your own exception

Best Practices:

  • Catch specific exceptions, not bare except:

  • Use finally for cleanup (closing files, connections)

  • Validate inputs early with meaningful error messages

  • Don’t silently ignore errors — at least log them

V. Summary & Next Steps#

In this lecture, we covered:

Functions#

  • Defining functions with def

  • Parameters: positional, keyword, default

  • Return values and docstrings

  • Scope and namespaces

Data Structures#

  • Lists: Ordered, mutable, indexed

  • Tuples: Ordered, immutable, faster

  • Dictionaries: Key-value pairs, fast lookup

  • Sets: Unique elements, set operations

File I/O#

  • Reading and writing text files

  • The with statement for safe file handling

  • NumPy’s loadtxt and savetxt for numerical data

Error Handling#

  • try/except for catching errors

  • else and finally clauses

  • Raising exceptions with raise

Next Lecture: Python Basics III#

  • NumPy arrays in depth

  • Advanced plotting with Matplotlib

  • Introduction to numerical methods

VI. Practice Exercises#

Exercise 1: Function Practice#

Write a function quadratic_formula(a, b, c) that:

  • Takes coefficients a, b, c of a quadratic equation ax² + bx + c = 0

  • Returns both roots (as a tuple)

  • Handles the case where the discriminant is negative (return None)

  • Includes a docstring

Exercise 2: Data Structure Practice#

Create a dictionary called element_data that stores:

  • Element symbols as keys

  • A dictionary with ‘name’, ‘atomic_number’, and ‘mass’ as values

  • Include at least 5 elements

  • Write a function that takes an element symbol and prints its properties

Exercise 3: File I/O Practice#

Write a program that:

  1. Creates a file with 10 random numbers (one per line)

  2. Reads the file back

  3. Calculates and prints the mean and standard deviation

  4. Saves the statistics to a new file

Exercise 4: Error Handling Practice#

Write a function safe_sqrt(x) that:

  • Returns the square root of x

  • Handles negative numbers gracefully (print warning, return None)

  • Handles non-numeric input (print error, return None)

  • Works correctly for valid positive numbers

# Your solutions here

# Exercise 1: quadratic_formula


# Exercise 2: element_data


# Exercise 3: File I/O


# Exercise 4: safe_sqrt

VII. Advanced Topics (Optional Reading)#

The following sections cover more advanced Python features. These are useful for writing sophisticated code but are not essential for the core computational physics content.

  • Variable Arguments (*args, **kwargs)

  • Iterators and Generators

  • Decorators

Feel free to explore these topics if you’re comfortable with the main material.

Variable Arguments: *args and **kwargs#

Sometimes you want a function to accept any number of arguments:

  • *args: Collects extra positional arguments as a tuple

  • **kwargs: Collects extra keyword arguments as a dictionary

def flexible_function(*args, **kwargs):
    """A function that accepts any arguments."""
    print(f"Positional args: {args}")
    print(f"Keyword args: {kwargs}")

flexible_function(1, 2, 3, name="physics", value=42)

Generators#

Generators are functions that produce a sequence of values lazily (one at a time), using yield instead of return. They’re memory-efficient for large sequences.

def fibonacci(n):
    """Generate first n Fibonacci numbers."""
    a, b = 0, 1
    count = 0
    while count < n:
        yield a  # Return value and pause
        a, b = b, a + b
        count += 1

# Use the generator
print("First 10 Fibonacci numbers:")
for num in fibonacci(10):
    print(num, end=" ")

Decorators#

Decorators wrap functions to add extra behavior without modifying the original function.

import time

def timing_decorator(func):
    """Measure how long a function takes to run."""
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {end - start:.4f} seconds")
        return result
    return wrapper

@timing_decorator
def slow_function():
    """A function that takes some time."""
    total = sum(i**2 for i in range(100000))
    return total

result = slow_function()