🎀 Day 17 – Decorators

1. Learning Objectives

By the end of Day 17, you will be able to:

  • Understand what decorators are and why they are useful in AEC programming.
  • Write simple decorators to add behaviour (timing, logging, validation) to existing functions.
  • Use the @decorator syntax for clean, readable code.
  • Apply decorators to practical AEC tasks: timing analysis runs, caching heavy structural calculations, validating inputs, and logging audit trails.
  • Create decorators with arguments for flexible reuse.

2. Concept Explanation

2.1 What is a Decorator?

A decorator is a function that takes another function as input, wraps it with additional behaviour, and returns the wrapped function. In Python, you apply a decorator with the @ symbol above a function definition.

Think of it like this:
In an AEC office, you might have a standard "stamp" that you apply to all calculations – a note saying "Checked by Engineer" and the date. The calculation itself doesn't change, but it gains that extra annotation. A decorator does exactly that: it adds behaviour before, after, or around the original function.

2.2 The Basic Pattern

def my_decorator(func):
    def wrapper(*args, **kwargs):
        # Code to run BEFORE the original function
        print(f"Calling function: {func.__name__}")
        result = func(*args, **kwargs)   # Call the original function
        # Code to run AFTER the original function
        print(f"Finished: {func.__name__}")
        return result
    return wrapper

@my_decorator
def say_hello(name):
    print(f"Hello, {name}!")

say_hello("Engineer")
# Output:
# Calling function: say_hello
# Hello, Engineer!
# Finished: say_hello

2.3 When to Use Decorators in AEC

Use CaseDescription
TimingMeasure how long a heavy calculation takes
LoggingLog all function calls for audit trails
ValidationCheck inputs are valid (e.g., span > 0)
Caching (memoization)Store results of expensive computations
Retry logicRetry a network call to a BIM server on failure
AuthorizationCheck user permissions before running a function

2.4 Decorator with Arguments

Sometimes you need to pass arguments to the decorator itself (e.g., a log level, a threshold value).

def repeat(num_times):
    """Decorator that repeats a function call num_times times."""
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(num_times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(num_times=3)
def print_beam(beam_id):
    print(f"Processing beam {beam_id}")

print_beam("B1")
# Output:
# Processing beam B1
# Processing beam B1
# Processing beam B1

3. Code Examples

Example 1: Timing decorator for structural analysis

import time

def timer(func):
    """Decorator that prints the execution time of a function."""
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        elapsed = end - start
        print(f"[TIMER] {func.__name__} took {elapsed:.4f} seconds")
        return result
    return wrapper

@timer
def analyse_beam(span, load):
    """Simulate a heavy structural analysis calculation."""
    # Simulate computation
    total = 0
    for i in range(1000000):
        total += (load * span**2) / (8 * (i + 1))
    return total

# Use it
result = analyse_beam(6.0, 25.0)
print(f"Result: {result:.2f}")

Example 2: Input validation decorator

def validate_positive(func):
    """Decorator that ensures all numeric arguments are positive."""
    def wrapper(*args, **kwargs):
        for arg in args:
            if isinstance(arg, (int, float)) and arg <= 0:
                raise ValueError(f"Argument {arg} must be positive")
        for key, val in kwargs.items():
            if isinstance(val, (int, float)) and val <= 0:
                raise ValueError(f"Keyword argument {key}={val} must be positive")
        return func(*args, **kwargs)
    return wrapper

@validate_positive
def moment_udl(load, span):
    """Max bending moment for simply supported beam with UDL."""
    return load * span**2 / 8

# Test
print(moment_udl(25, 6.0))   # Works
# print(moment_udl(-25, 6.0)) # Raises ValueError
# print(moment_udl(25, 0))    # Raises ValueError

Example 3: Logging decorator for audit trail

def log_call(func):
    """Decorator that logs each function call with timestamp."""
    def wrapper(*args, **kwargs):
        from datetime import datetime
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        arg_str = ", ".join([str(a) for a in args] + [f"{k}={v}" for k, v in kwargs.items()])
        print(f"[LOG {timestamp}] {func.__name__}({arg_str})")
        result = func(*args, **kwargs)
        print(f"[LOG] → Result: {result:.4f}")
        return result
    return wrapper

@log_call
def deflection_check(load, span, E, I):
    return 5 * load * 1000 * (span * 1000)**4 / (384 * E * I)

deflection_check(25.0, 6.0, 200000, 120e6)
# Output:
# [LOG 2026-05-06 13:20:00] deflection_check(25.0, 6.0, 200000, 120000000.0)
# [LOG] → Result: 7.0312

Example 4: Caching (memoization) decorator

def memoize(func):
    """Decorator that caches results of expensive function calls."""
    cache = {}
    def wrapper(*args):
        if args in cache:
            print(f"[CACHE] Returning cached result for {args}")
            return cache[args]
        result = func(*args)
        cache[args] = result
        print(f"[CACHE] Stored result for {args}")
        return result
    return wrapper

@memoize
def compute_section_modulus(b, h):
    """Expensive computation of section modulus (simulated delay)."""
    import time
    time.sleep(1)   # Simulate heavy calculation
    S = b * h**2 / 6
    return S

# First call – computes and caches
S1 = compute_section_modulus(200, 400)
print(f"S1 = {S1:.0f} mm³")

# Second call with same args – uses cache (instant)
S2 = compute_section_modulus(200, 400)
print(f"S2 = {S2:.0f} mm³")

# Different args – computes again
S3 = compute_section_modulus(250, 500)
print(f"S3 = {S3:.0f} mm³")

Example 5: Decorator with arguments – repeat analysis

def repeat_analysis(num_runs):
    """Decorator that runs an analysis multiple times and averages."""
    def decorator(func):
        def wrapper(*args, **kwargs):
            results = []
            for i in range(num_runs):
                result = func(*args, **kwargs)
                results.append(result)
                print(f"  Run {i+1}: {result:.4f}")
            avg = sum(results) / len(results)
            print(f"Average over {num_runs} runs: {avg:.4f}")
            return avg
        return wrapper
    return decorator

@repeat_analysis(num_runs=5)
def sample_deflection(load, span):
    """Simple deflection with slight random variation (simulating Monte Carlo)."""
    import random
    # Add ±5% random variation to simulate uncertainty
    variation = 1 + random.uniform(-0.05, 0.05)
    return (load * span**2 / 8) * variation

avg_M = sample_deflection(25.0, 6.0)
print(f"Average moment: {avg_M:.2f} kNm")

4. Hands‑on Exercises (3–5 Problems)

Problem 1 – Simple timing decorator
Write a decorator print_time that prints "Start" before a function runs and "End" after it finishes. Apply it to a function compute_area(length, width) that returns length * width. Test it.

Problem 2 – Validation decorator for beam depth
Write a decorator valid_depth that checks if the first argument (depth) is between 200 and 600 mm. If not, print a warning and return None instead of running the function. Apply it to a function classify_beam(depth) that returns a string classification.

Problem 3 – Retry decorator for network calls
Write a decorator retry(max_attempts=3) that retries a function if it raises an exception. Add a small delay between attempts. Apply it to a function fetch_material_data(material) that randomly fails (simulate with random.random()). Print how many attempts were needed.

Problem 4 – Logging all AEC calculations
Create a decorator log_to_file(filename) that appends a log entry to a text file each time the decorated function is called. The log entry should include: timestamp, function name, arguments, and result. Apply it to a moment_udl function and run it a few times. Check the file contents.

Solutions (attempt first):

# P1
def print_time(func):
    def wrapper(*args, **kwargs):
        print("Start")
        result = func(*args, **kwargs)
        print("End")
        return result
    return wrapper

@print_time
def compute_area(l, w):
    return l * w

print(compute_area(8, 5))

# P2
def valid_depth(func):
    def wrapper(depth, *args, **kwargs):
        if depth < 200 or depth > 600:
            print(f"WARNING: Depth {depth}mm out of range [200, 600]")
            return None
        return func(depth, *args, **kwargs)
    return wrapper

@valid_depth
def classify_beam(depth):
    if depth < 400:
        return "Medium beam"
    else:
        return "Heavy beam"

print(classify_beam(350))
print(classify_beam(150))

# P3
import time, random
def retry(max_attempts=3):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"Attempt {attempt} failed: {e}")
                    if attempt < max_attempts:
                        time.sleep(0.5)
            raise Exception(f"All {max_attempts} attempts failed")
        return wrapper
    return decorator

@retry(max_attempts=3)
def fetch_material_data(material):
    if random.random() < 0.6:  # 60% chance of failure
        raise ConnectionError(f"Could not fetch {material}")
    return f"{material} data loaded"

print(fetch_material_data("Steel"))

# P4
def log_to_file(filename="audit.log"):
    def decorator(func):
        def wrapper(*args, **kwargs):
            from datetime import datetime
            result = func(*args, **kwargs)
            timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            with open(filename, "a") as f:
                f.write(f"[{timestamp}] {func.__name__}(args={args}, kwargs={kwargs}) → {result}\n")
            return result
        return wrapper
    return decorator

@log_to_file("beam_calc_log.txt")
def moment_udl(load, span):
    return load * span**2 / 8

moment_udl(25, 6.0)
moment_udl(18, 4.5)
# Check beam_calc_log.txt

5. Applied Challenge Task

Task: Engineering Calculation Manager with Decorators

Build a set of decorators and apply them to a small library of structural engineering functions.

Core functions to decorate:

def moment_udl(load, span):
    return load * span**2 / 8

def shear_udl(load, span):
    return load * span / 2

def deflection_udl(load, span, E, I):
    return 5 * load * 1000 * (span * 1000)**4 / (384 * E * I)

def required_section_modulus(M, fy):
    return M * 1e6 / (0.6 * fy)

Requirements:

  1. Timer decorator – Wrap all four functions with a timer that prints execution time to 4 decimal places.
  2. Input validation decorator – Ensure all numeric inputs are positive. If not, raise a ValueError with a clear message.
  3. Logging decorator – Log each call to "calc_log.csv" in CSV format: timestamp, function_name, arg1, arg2, ..., result.
  4. Memoization decorator – Apply only to deflection_udl since it's the most expensive. Cache results keyed by (load, span, E, I).
  5. Demonstrate all decorators working together – Call each function at least twice with the same and different arguments to show caching, timing, logging, and validation.

Bonus:

  • Create a @requires_units(units_dict) decorator that takes a dictionary specifying expected units for each parameter (e.g., {"load": "kN/m", "span": "m"}) and prints a unit check message.
  • Use functools.wraps to preserve function metadata (name, docstring) in all decorators.

Why this matters:
In a real engineering office, calculation traceability, validation, and performance monitoring are essential. Decorators provide a clean, non‑invasive way to add these cross‑cutting concerns to existing code without modifying the core logic.


6. Brief Review Summary

  • A decorator wraps a function to add behaviour before, after, or around it.
  • @decorator syntax is syntactic sugar for func = decorator(func).
  • Common AEC uses: timing, logging, validation, caching, retry.
  • Decorators can accept arguments for flexibility.
  • Use functools.wraps to preserve original function metadata.
  • Multiple decorators can stack: @log @timer def calc(): ...

Key takeaway:
Decorators allow you to add professional‑grade features to your engineering functions – logging, validation, performance monitoring – without cluttering the core calculation logic. They keep your code clean and maintainable.


7. Preview of Next Topic (Day 18)

Tomorrow we’ll cover Context Managers.
You’ll learn:

  • Using with statements for resource management (files, database connections, temporary settings).
  • Creating your own context managers with __enter__ and __exit__.
  • Using contextlib for simpler context manager creation.
  • Practical AEC examples: safely opening model files with automatic rollback, temporary unit conversion context, measuring execution time with a context manager.

Context managers make your code safer and more readable when dealing with resources.

Comments

EARN AT THE COMFORT OF YOUR HOME

Sponsored content

Popular posts from this blog

Day 5 – Functions, Scope, and Docstrings

Day 10 – Exception Handling and Debugging

Day 1 – Python Foundations for AEC Professionals