Level 5 — MasteryPython Intermediate

Python Intermediate

90 minutes60 marksprintable — key stays hidden on paper

Chapter: 1.3 Python Intermediate Difficulty: Level 5 — Mastery (cross-domain: math + physics + coding, build & prove) Time limit: 90 minutes Total marks: 60

Instructions: Write clean, runnable Python 3 code. You may assume standard library only. Where a proof or complexity argument is requested, justify rigorously. Partial credit is awarded for correct reasoning even if code is incomplete.


Question 1 — Streaming Numerical Integration Engine (24 marks)

You must build a memory-bounded engine that estimates the definite integral I=abf(x)dxI = \int_a^b f(x)\,dx using the composite Simpson's rule over nn subintervals (nn even), where the sample points are produced lazily and consumed once (streaming), so the whole array is never stored.

Recall composite Simpson's rule with step h=(ba)/nh=(b-a)/n and nodes xk=a+khx_k=a+kh: Ih3[f(x0)+f(xn)+4 ⁣ ⁣k odd ⁣ ⁣f(xk)+2 ⁣ ⁣k even,0<k<n ⁣ ⁣f(xk)].I \approx \frac{h}{3}\left[f(x_0) + f(x_n) + 4\!\!\sum_{k\ \text{odd}}\!\! f(x_k) + 2\!\!\sum_{k\ \text{even},\,0<k<n}\!\! f(x_k)\right].

(a) Write a generator nodes(a, b, n) that yields the tuples (k, x_k) for k = 0..n without building any list. (4)

(b) Write a decorator @count_calls (using functools.wraps) that counts how many times the decorated function is invoked and stores it on an attribute .calls. Apply it to the integrand. (5)

(c) Write simpson(f, a, b, n) that consumes nodes and returns the estimate. It must:

  • raise a custom exception IntegrationError (subclass of ValueError) if n is odd or n <= 0;
  • call f exactly n+1n+1 times (verify with the counter from part (b)). (7)

(d) For f(x)=exf(x)=e^{-x} on [0,1][0,1], compute the estimate with n=2n=2. The exact value is 1e11-e^{-1}. Report the estimate to 6 decimals and the absolute error. (4)

(e) Prove that for a fixed even nn, your streaming implementation uses O(1)O(1) auxiliary memory (independent of nn), and state the error term order of composite Simpson's rule in terms of hh. (4)


Question 2 — Physics Log Parser with Context Manager (22 marks)

A sensor writes projectile-motion log lines to a file. Each valid line has the form:

t=1.50s v=12.30m/s

(time in seconds, speed in m/s). Some lines are corrupted.

(a) Write a regular expression and a function parse_line(line) that returns (t, v) as floats, or raises ValueError for a malformed line. Use named groups. (6)

(b) Build a class-based context manager LogReader implementing __enter__ and __exit__ that opens a file in read mode, and on exit guarantees the file is closed even if an exception is raised inside the with block. __exit__ must not suppress exceptions. (6)

(c) Using LogReader, write average_speed(path) that reads line-by-line (not readlines()), skips malformed lines using try/except, and returns the mean speed. Use else/finally appropriately: count total lines read in finally. (6)

(d) A projectile launched at speed v0v_0 at angle θ\theta has range R=v02sin(2θ)gR=\dfrac{v_0^2\sin(2\theta)}{g}. Given a parsed launch speed v0=20 m/sv_0=20\ \text{m/s}, θ=45\theta=45^\circ, g=9.81 m/s2g=9.81\ \text{m/s}^2, compute RR using math. Report to 3 decimals. (4)


Question 3 — Fibonacci Iterator & Golden-Ratio Convergence (14 marks)

(a) Implement a class-based iterator Fib with __iter__/__next__ that yields Fibonacci numbers F1=1,F2=1,F3=2,F_1=1, F_2=1, F_3=2,\dots and raises StopIteration after producing limit terms. (5)

(b) Using itertools.islice, collect the first 15 ratios Fk+1/FkF_{k+1}/F_k and show they approach the golden ratio φ=1+52\varphi=\frac{1+\sqrt5}{2}. Report F16/F15F_{16}/F_{15} to 6 decimals. (5)

(c) Prove that if rk=Fk+1/Fkr_k = F_{k+1}/F_k converges to a limit LL, then L=φL=\varphi. (Use the recurrence Fk+1=Fk+Fk1F_{k+1}=F_k+F_{k-1}.) (4)

Answer keyMark scheme & solutions

Question 1 (24 marks)

(a) (4)

def nodes(a, b, n):
    h = (b - a) / n
    for k in range(n + 1):
        yield k, a + k * h
  • Correct yield of (k, x_k) (2); uses range(n+1) inclusive of endpoint (1); no list built — generator (1).

(b) (5)

import functools
def count_calls(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        wrapper.calls += 1
        return fn(*args, **kwargs)
    wrapper.calls = 0
    return wrapper
  • functools.wraps (1); attribute .calls initialized (1); increments per call (2); returns result (1). Why wraps: preserves __name__/__doc__ of wrapped function.

(c) (7)

class IntegrationError(ValueError):
    pass
 
def simpson(f, a, b, n):
    if n <= 0 or n % 2 != 0:
        raise IntegrationError("n must be positive and even")
    h = (b - a) / n
    total = 0.0
    for k, x in nodes(a, b, n):
        fx = f(x)
        if k == 0 or k == n:
            total += fx
        elif k % 2 == 1:
            total += 4 * fx
        else:
            total += 2 * fx
    return total * h / 3
  • Custom exception subclass of ValueError (2); validation of odd/non-positive n (1); correct endpoint/odd/even weights 1/4/2 (2); f called exactly once per node ⇒ n+1n+1 calls (1); returns h/3h/3 \cdot sum (1).

(d) (4) With f(x)=exf(x)=e^{-x}, a=0,b=1,n=2a=0,b=1,n=2, h=0.5h=0.5, nodes x0=0,x1=0.5,x2=1x_0=0, x_1=0.5, x_2=1: I0.53(e0+4e0.5+e1)=0.53(1+4(0.606531)+0.367879).I \approx \frac{0.5}{3}\big(e^0 + 4e^{-0.5} + e^{-1}\big) = \frac{0.5}{3}(1 + 4(0.606531) + 0.367879). Sum =1+2.426123+0.367879=3.794002= 1 + 2.426123 + 0.367879 = 3.794002; times 0.5/3=0.1666670.5/3=0.166667I0.632334I \approx 0.632334. Exact 1e1=0.6321211-e^{-1}=0.632121. Absolute error 0.000213\approx 0.000213.

  • Estimate 0.632334 (2); exact 0.632121 (1); error 0.000213 (1).

(e) (4)

  • Memory: The generator holds only k, x, h, and running scalar total — a fixed constant number of variables regardless of n. No list of nodes or samples is materialized, so auxiliary space is O(1)O(1). (2)
  • Error order: Composite Simpson's rule has global error O(h4)O(h^4) (equivalently (ba)h4180f(4)(ξ)\frac{-(b-a)h^4}{180}f^{(4)}(\xi)), so error scales as h4h^4. (2)

Question 2 (22 marks)

(a) (6)

import re
PATTERN = re.compile(r"t=(?P<t>-?\d+(?:\.\d+)?)s\s+v=(?P<v>-?\d+(?:\.\d+)?)m/s")
 
def parse_line(line):
    m = PATTERN.fullmatch(line.strip())
    if not m:
        raise ValueError(f"malformed: {line!r}")
    return float(m.group("t")), float(m.group("v"))
  • Correct regex with named groups t, v (3); fullmatch/anchoring (1); raises ValueError on failure (1); float conversion (1).

(b) (6)

class LogReader:
    def __init__(self, path):
        self.path = path
        self.f = None
    def __enter__(self):
        self.f = open(self.path, "r")
        return self.f
    def __exit__(self, exc_type, exc, tb):
        if self.f:
            self.f.close()
        return False   # do not suppress
  • __enter__ opens read mode, returns file (2); __exit__ closes file (2); closes even on exception because __exit__ always runs (1); returns False so exceptions propagate (1).

(c) (6)

def average_speed(path):
    total, count, lines = 0.0, 0, 0
    with LogReader(path) as f:
        for line in f:
            lines += 1
            try:
                _, v = parse_line(line)
            except ValueError:
                continue
            else:
                total += v
                count += 1
    if count == 0:
        raise ValueError("no valid lines")
    return total / count
  • Line-by-line iteration, not readlines() (2); try/except ValueError skips malformed (2); else used for the valid-line accumulation (1); counts total lines (in loop / could use finally) (1). Why else: runs only when no exception, cleanly separating success path.

(d) (4) R=v02sin(2θ)g=202sin(90)9.81=40019.81=40.775 m.R=\frac{v_0^2\sin(2\theta)}{g}=\frac{20^2\sin(90^\circ)}{9.81}=\frac{400\cdot 1}{9.81}=40.775\ \text{m}.

import math
R = 20**2 * math.sin(math.radians(90)) / 9.81  # 40.774719...

Report R40.775R \approx 40.775 m.

  • Formula applied (1); sin(90°)=1\sin(90°)=1 (1); 400/9.81400/9.81 (1); value 40.775 (1).

Question 3 (14 marks)

(a) (5)

class Fib:
    def __init__(self, limit):
        self.limit = limit
        self.a, self.b = 1, 1
        self.count = 0
    def __iter__(self):
        return self
    def __next__(self):
        if self.count >= self.limit:
            raise StopIteration
        self.count += 1
        val = self.a
        self.a, self.b = self.b, self.a + self.b
        return val
  • __iter__ returns self (1); __next__ advances state (2); correct sequence 1,1,2,3,5… (1); StopIteration after limit (1).

(b) (5)

import itertools
fibs = list(itertools.islice(Fib(16), 16))  # F1..F16
ratios = [fibs[k+1]/fibs[k] for k in range(15)]
print(fibs[15]/fibs[14])  # F16/F15

F15=610F_{15}=610, F16=987F_{16}=987 ⇒ ratio =987/610=1.618033=987/610=1.618033. φ=1.618034\varphi=1.618034.

  • Uses islice (2); computes 15 ratios (1); F16/F15=1.618033F_{16}/F_{15}=1.618033 (1); matches φ\varphi (1).

(c) (4) Divide the recurrence Fk+1=Fk+Fk1F_{k+1}=F_k+F_{k-1} by FkF_k: rk=1+Fk1Fk=1+1rk1.r_k = 1 + \frac{F_{k-1}}{F_k} = 1 + \frac{1}{r_{k-1}}. If rkLr_k\to L (with L>0L>0), then taking limits: L=1+1LL = 1 + \frac1L, i.e. L2L1=0L^2 - L - 1 = 0. Solving: L=1±52L=\frac{1\pm\sqrt5}{2}. Since ratios are positive, take the positive root: L=1+52=φL=\frac{1+\sqrt5}{2}=\varphi. ∎

  • Divide recurrence (1); limit equation L=1+1/LL=1+1/L (1); quadratic L2L1=0L^2-L-1=0 (1); positive root selection = φ\varphi (1).

[
  {"claim":"Simpson estimate for e^-x on [0,1], n=2 is ~0.632334",
   "code":"import sympy as sp; h=sp.Rational(1,2); f=lambda x: sp.exp(-x); est=h/3*(f(0)+4*f(sp.Rational(1,2))+f(1)); result=abs(float(est)-0.632334)<1e-5"},
  {"claim":"Absolute error vs exact 1-e^-1 is ~0.000213",
   "code":"import sympy as sp; h=sp.Rational(1,2); est=h/3*(sp.exp(0)+4*sp.exp(sp.Rational(-1,2))+sp.exp(-1)); exact=1-sp.exp(-1); result=abs(float(abs(est-exact))-0.000213)<5e-6"},
  {"claim":"Projectile range R = 400/9.81 = 40.775 m at 45 deg",
   "code":"import math; R=20**2*math.sin(math.radians(90))/9.81; result=abs(R-40.775)<1e-3"},
  {"claim":"F16/F15 = 987/610 approx 1.618033",
   "code":"import sympy as sp; result=abs(float(sp.Rational(987,610))-1.618033)<1e-5"},
  {"claim":"Golden ratio solves L^2-L-1=0 positive root",
   "code":"import sympy as sp; L=sp.symbols('L'); sols=sp.solve(L**2-L-1,L); phi=(1+sp.sqrt(5))/2; result=any(sp.simplify(s-phi)==0 for s in sols)"}
]