Level 01 · Signals, Systems & Control

Signals as Data

Signals as lists of samples, simulated systems, and the first feedback loop.

Level 1 · Signals, Systems & Control: Signals as Data

A signal is anything that changes over time and can be measured: the voltage from a microphone, the depth of water at a pier, the temperature of a room. A computer never sees the smooth curve. It sees a list of numbers, one measurement every so often, plus one extra number that says how often. The first half of this level works with such lists: their constant part, their strength, ratios in decibels, smoothing, how fast they wiggle, shifting them in time, and what a converter with a few bits keeps.

The second half turns to systems, things that take one signal in (the flow from a tap, the power of a heater) and produce another (a water level, a temperature). You will simulate a system one small time step at a time, find where it settles, and then close the loop: measure the output, compare it with what you wanted, and correct. That idea, feedback, is what makes a thermostat or a cruise control work, and it explains why a fixed setting fails as soon as somebody opens a window.

The paths overlap here. The mean of a signal is its offset, and the RMS of a centred signal is the standard deviation of the Statistics path. Every simulation is a loop that updates a running value, the one-pass pattern of the Algorithms path. And in the Machine Learning path a gradient-descent step w = w - rate * slope has exactly the shape of the Euler step below, with the same danger when the step is too large. Here the question is always physical: what does this number say about the thing being measured?

A signal is a list of samples

To sample a signal is to measure it at regular moments. The number of measurements per second is the sample rate f_s (fs in code), in hertz (Hz). Sample k (counting from 0) is taken at time t_k = k / f_s seconds, and each sample stands for 1 / f_s seconds of signal.

A child on a swing moves 0.3 m either side of the lowest point, one full swing every 2 seconds (a frequency of 0.5 Hz). Record it ten times a second:

import math

fs = 10                  # samples per second
n = 25
swing = []
for k in range(n):
    t = k / fs
    swing.append(round(0.3 * math.cos(2 * math.pi * 0.5 * t), 3))
print(swing[:6])
print("sample 7 is taken at", 7 / fs, "s")
print("last sample at", (n - 1) / fs, "s; the recording lasts", n / fs, "s")
plot([k / fs for k in range(n)], swing, kind="stem", xlabel="time (s)", ylabel="position (m)")
[0.3, 0.285, 0.243, 0.176, 0.093, 0.0]
sample 7 is taken at 0.7 s
last sample at 2.4 s; the recording lasts 2.5 s

Press Run to see the chart: each stem is one sample. Sample k is x_k = A\cos(2\pi f t_k), with amplitude A = 0.3 m and frequency f = 0.5 Hz. The angle 2\pi f t_k is 2 * pi * freq * t because freq * t is the number of cycles gone by and one cycle is 2 * pi radians. Note the last line: the last sample is at (n - 1) / fs, but the recording lasts n / fs.

The list itself knows nothing about time. The same 25 numbers could be a swing at 10 Hz or a guitar string at 10,000 Hz; only fs tells them apart. And sampling has a limit: a wave needs more than two samples per cycle. Above fs / 2, the Nyquist frequency, a wave disguises itself as a slower one (aliasing). At 10 samples per second a 9 Hz wave gives the same numbers as an upside-down 1 Hz wave:

import math
fs = 10
fast = [round(math.sin(2 * math.pi * 9 * k / fs), 3) + 0.0 for k in range(6)]
slow = [round(-math.sin(2 * math.pi * 1 * k / fs), 3) + 0.0 for k in range(6)]
print(fast)
print(slow)
[0.0, -0.588, -0.951, -0.951, -0.588, 0.0]
[0.0, -0.588, -0.951, -0.951, -0.588, 0.0]

(+ 0.0 turns a -0.0 into 0.0.) This is why film wheels seem to spin backwards, and why audio is sampled at 44,100 Hz, just over twice the highest pitch we hear.

Common mistakes: using k instead of k / fs as the time. Forgetting the 2 * pi. Giving (n - 1) / fs as the duration.

Practice: Sampling a Tuning Fork

Offset and centring

Many sensors measure from a reference that is not zero. A depth gauge on a pier reads about 2.4 m, and waves move the reading up and down around that. The constant part of a signal is its offset, in electronics the DC offset (DC for "direct current", the part that never changes). Over a recording of N samples x_0, \dots, x_{N-1} it is their mean,

\bar{x} = \frac{1}{N}\sum_{k=0}^{N-1} x_k,

and subtracting it centres the signal: what is left, x_k - \bar{x}, is the wave alone, wobbling around zero.

depth = [2.46, 2.31, 2.38, 2.52, 2.35, 2.42, 2.49, 2.27]    # metres, one per second

offset = sum(depth) / len(depth)
waves = [round(d - offset, 3) for d in depth]
print("still-water depth:", round(offset, 3), "m")
print(waves)
print("centred samples add up to zero:", abs(sum(waves)) < 1e-9)
plot(depth, label="depth")
plot(waves, label="waves only")
still-water depth: 2.4 m
[0.06, -0.09, -0.02, 0.12, -0.05, 0.02, 0.09, -0.13]
centred samples add up to zero: True

Press Run: the two lines have the same shape, one lifted by 2.4 m. The centred samples sum to zero, the balance-point property of the mean: the waves push the water up exactly as much as they pull it down. Centring matters because the measures in the next section should describe the wobble, not the level it wobbles around.

Common mistakes: subtracting in place (depth[i] -= offset) when the caller still needs the raw readings. Removing the mean of a recording whose level drifts (a rising tide): then there is no constant part, and a moving average follows the drift better.

Practice: The Microphone's Resting Voltage

Energy, power and RMS

How strong is a signal? Positive and negative samples would cancel in a plain sum, so we square first, which is also what physics does: the heat a voltage v produces in a resistor grows with v * v.

  • Energy: the sum of the squares. It grows with the length of the recording, like the total heat delivered.
  • Average power: the energy divided by the number of samples, the energy per sample. It does not depend on how long you listened.
  • RMS (root mean square): the square root of the power. It is back in the signal's units and is the size of a typical sample.
  • Peak-to-peak: max - min, the full swing from one extreme to the other.

For N samples x_0, \dots, x_{N-1}:

E = \sum_{k=0}^{N-1} x_k^2, \qquad P = \frac{E}{N}, \qquad x_{\text{RMS}} = \sqrt{P} = \sqrt{\frac{1}{N}\sum_{k=0}^{N-1} x_k^2}

In the code the sum E is sum(v * v for v in volts): the term x_k^2 is written in front of for, and the for runs over every sample.

European mains electricity is a 50 Hz sine wave with a peak of about 325 V. Three cycles, one sample per millisecond:

import math
volts = [325 * math.sin(2 * math.pi * 50 * k / 1000) for k in range(60)]   # 60 ms

energy = sum(v * v for v in volts)
power = energy / len(volts)
print("peak-to-peak:", round(max(volts) - min(volts), 1), "V")
print("RMS:", round(math.sqrt(power), 1), "V; peak / sqrt(2) =", round(325 / math.sqrt(2), 1), "V")

six = volts + volts                          # six cycles instead of three
print("energy:", round(energy), "->", round(sum(v * v for v in six)))
print("power: ", round(power), "->", round(sum(v * v for v in six) / len(six)))
peak-to-peak: 650.0 V
RMS: 229.8 V; peak / sqrt(2) = 229.8 V
energy: 3168750 -> 6337500
power:  52812 -> 52812

There is the 230 V printed on plugs: it is the RMS, and it means "this wave heats a kettle as much as a steady 230 V would". The peak-to-peak value says something else: the insulation must survive a 650 V swing. Doubling the recording doubles the energy and leaves the power alone.

An offset counts towards the power (a steady 2 V has an RMS of 2 V although nothing wobbles), so centre first if you want the strength of the wobble alone.

Common mistakes: summing samples instead of squares (a sine wave gives about 0). Dividing by N - 1, which belongs to the sample variance, not to power. Forgetting the square root.

Practice: How Hard Is the Washing Machine Shaking?

Decibels

Ears, eyes and radios respond to ratios: going from 1 W to 2 W of sound is about as noticeable as going from 50 W to 100 W. Decibels (dB) put ratios on a logarithmic scale, which turns multiplying into adding:

  • a power ratio P_2 / P_1 is 10\log_{10}(P_2 / P_1) dB,
  • an amplitude ratio A_2 / A_1 (voltage, pressure: any quantity whose square is a power) is 20\log_{10}(A_2 / A_1) dB.

The 20 is there because power grows with the square of amplitude, and the logarithm of a square is twice the logarithm: 10\log_{10}(A_2^2 / A_1^2) = 20\log_{10}(A_2 / A_1). Doubling a voltage quadruples the power, and both are +6 dB. A TV cable that loses half its power every 100 m loses 3 dB per 100 m, and losses in a row simply add:

import math
power_db = lambda ratio: 10 * math.log10(ratio)
amp_db = lambda ratio: 20 * math.log10(ratio)
print("power x2:", round(power_db(2), 2), " power x0.5:", round(power_db(0.5), 2), " voltage x2:", round(amp_db(2), 2))

cable = 4 * power_db(0.5)                   # 400 m of cable
print("400 m:", round(cable, 2), "dB, power left", 10 ** (cable / 10), "=", 0.5 ** 4)
print("with a 15 dB amplifier:", round(cable + 15, 2), "dB")
power x2: 3.01  power x0.5: -3.01  voltage x2: 6.02
400 m: -12.04 dB, power left 0.0625 = 0.0625
with a 15 dB amplifier: 2.96 dB

0 dB means no change, positive means bigger, negative smaller; every 10 dB is another factor of 10 in power, so the range from a whisper to a jet engine (a power ratio near 10^{13}) fits into 0 to 130 dB. To go back, undo the logarithm: a power ratio is 10 ** (dB / 10), an amplitude ratio 10 ** (dB / 20).

Common mistakes: 10 * log10 for a voltage ratio, or 20 * log10 for a power ratio. Multiplying decibels or adding ratios. Using math.log (natural logarithm) instead of math.log10. A ratio of zero, which is minus infinity decibels.

Practice: Decibels Along a Speaker Chain

Smoothing with a moving average

Measurements are noisy. A cyclist's GPS reports a speed every second, each off by a few km/h. A moving average replaces each sample by the mean of a short window of samples. Random errors partly cancel inside the window; the slow, real changes survive. A live display can only average the last w readings,

y_k = \frac{1}{w}\sum_{i=0}^{w-1} x_{k-i},

where y_k is the smoothed value at sample k. At the start, where fewer than w readings exist, it averages what there is:

speed = [18, 22, 17, 25, 19, 21, 30, 20, 18, 23]     # km/h, one per second
w = 4
smooth = []
for k in range(len(speed)):
    window = speed[max(0, k - w + 1): k + 1]           # the last w readings, fewer at the start
    smooth.append(sum(window) / len(window))
print([round(s, 1) for s in smooth])
plot(speed, label="GPS")
plot(smooth, label="last 4 s")
[18.0, 20.0, 19.0, 20.5, 20.8, 20.5, 23.8, 22.5, 22.2, 22.8]

Press Run: the raw line jumps between 17 and 30, the smoothed one stays between 18 and 24, and the glitch to 30 km/h has become a small bump. The price is lag, the same price every physical sensor pays. A sudden jump from 0 to 10 comes out of this filter as 2.5, 5, 7.5, 10: spread over w samples, about (w - 1) / 2 samples late on average. A wider window removes more noise and reacts more slowly. When the whole recording is available afterwards, a centred window (samples before and after) avoids the lag; then decide, and say, what happens where the window runs off either end.

Common mistakes: dividing by w when the window at an edge holds fewer samples. Off-by-one slices (speed[k - w : k] leaves out the current sample). A negative start index, which Python counts from the end: hence max(0, ...).

Practice: A Calm Fuel Gauge

Zero crossings and frequency

A centred oscillating signal crosses zero twice per cycle. Counting crossings over a known time measures its frequency (cycles per second); 1 / freq is the period, the length of one cycle. One clean way counts only upward crossings, where a negative sample is followed by one that is not; each starts a new cycle:

import math
fs = 50
x = [math.sin(2 * math.pi * 3 * k / fs + 0.4) for k in range(2 * fs)]   # 2 s of a 3 Hz wave

ups = 0
for k in range(1, len(x)):
    if x[k - 1] < 0 <= x[k]:
        ups += 1
seconds = len(x) / fs
print(ups, "upward crossings in", seconds, "s: about", ups / seconds, "Hz")
plot(x, kind="stem")
6 upward crossings in 2.0 s: about 3.0 Hz

Two choices hide in that if. A sample that is exactly 0 must count as one side; here 0 <= x[k] puts it with the positives. And the count is a whole number, so over 2 seconds the estimate moves in steps of 0.5 Hz: listening longer, or timing from the first crossing to the last, gives a finer answer. Noise is the real enemy: a little jitter near zero adds extra crossings, so smooth first, or demand a clear climb before the next crossing counts.

Common mistakes: forgetting that a cycle has two crossings. Counting on a signal with an offset (2.4 m of water never crosses zero). Starting at k = 0, so x[k - 1] is the last sample. Dividing by samples instead of seconds.

Practice: A Tuner That Counts Zero Crossings

From formula to Python

The measures so far share one shape, and they turn into Python the same way: a sum is a loop, usually a generator expression inside sum. The index under \sum becomes the for, its range becomes what the loop runs over, and the term being added becomes the expression in front of for. range(N) stops before N, just as \sum_{k=0}^{N-1} stops at N - 1.

  • The mean \bar{x} = \frac{1}{N}\sum_{k=0}^{N-1} x_k is sum(x) / len(x).
  • The energy E = \sum_{k=0}^{N-1} x_k^2 is sum(v * v for v in x).
  • The RMS \sqrt{\frac{1}{N}\sum_{k=0}^{N-1} x_k^2} is math.sqrt(sum(v * v for v in x) / len(x)).
  • The moving average y_k = \frac{1}{w}\sum_{i=0}^{w-1} x_{k-i}, for every k from w - 1 on, is a sum inside a list comprehension, one entry per k: [sum(x[k - i] for i in range(w)) / w for k in range(w - 1, len(x))].
  • A count is a sum of ones. Write [\text{condition}] for 1 when the condition holds and 0 when it does not; the number of upward crossings \sum_{k=1}^{N-1} [x_{k-1} < 0 \le x_k] is sum(x[k - 1] < 0 <= x[k] for k in range(1, len(x))), because True counts as 1.
import math
x = [3, -1, -2, 4, 1, -3, 2, 0]
N, w = len(x), 3
mean = sum(x) / N
energy = sum(v * v for v in x)
rms = math.sqrt(energy / N)
smooth = [sum(x[k - i] for i in range(w)) / w for k in range(w - 1, N)]
ups = sum(x[k - 1] < 0 <= x[k] for k in range(1, N))
print("mean", mean, " energy", energy, " RMS", round(rms, 3))
print("moving average", [round(s, 2) for s in smooth])
print(ups, "upward crossings")
mean 0.5  energy 44  RMS 2.345
moving average [0.0, 0.33, 1.0, 0.67, 0.0, -0.33]
2 upward crossings

Reading the other way works too: when a specification gives a formula, name each symbol in code first (N is len(x), x_k is x[k]), then write the sum as a generator. Later levels use this for convolution, the Fourier transform and the cost of a controller.

Shifting and reversing

A delay of d samples moves a signal later: y[k] = x[k - d], a lag of d / fs seconds. In a list of fixed length the start fills with silence and the end falls off. An echo is a delayed, quieter copy added to the original:

clap = [0, 8, 4, 2, 1, 0, 0, 0, 0, 0]
d = 4
delayed = [0] * d + clap[:len(clap) - d]
echo = [a + 0.5 * b for a, b in zip(clap, delayed)]
print(delayed)
print(echo)
print(clap[::-1])
[0, 0, 0, 0, 0, 8, 4, 2, 1, 0]
[0.0, 8.0, 4.0, 2.0, 1.0, 4.0, 2.0, 1.0, 0.5, 0.0]
[0, 0, 0, 0, 0, 1, 2, 4, 8, 0]

Sound travels about 343 m/s, so a wall 17 m away returns an echo after 0.1 s: at 8000 samples per second, a delay of 800 samples. Reversing, x[::-1], plays the signal backwards; the clap now swells and stops dead, like a reversed cymbal. Order matters when you combine them: delaying then reversing is not the same as reversing then delaying.

Common mistakes: clap[:-d], which is empty for d = 0. A negative d (an advance, moving the sound earlier) needs its own slice. list.reverse(), which changes the caller's list and returns None.

Practice: Delay and Reverse on a Sample Pad

Quantisation: what a converter keeps

An analogue-to-digital converter (ADC) turns a voltage into a whole number. With bits bits it has 2 ** bits codes and splits its input range into that many equal steps. A 3.3 V converter with 8 bits has 256 codes and a step size q of 3.3 / 256, about 12.9 mV; with 12 bits the step is 0.8 mV. Every voltage within one step gets the same code, so detail smaller than q is lost for good: that is quantisation.

A kitchen thermometer that shows half degrees is a quantiser with q = 0.5. Rounding to the nearest step, the error is never more than half a step:

q = 0.5
true_temps = [20.12, 20.31, 20.26, 20.74, 21.49]
shown = [round(t / q) * q for t in true_temps]
errors = [round(abs(s - t), 2) for s, t in zip(shown, true_temps)]
print(shown)
print(errors, "largest:", max(errors), "<= q / 2:", max(errors) <= q / 2)
[20.0, 20.5, 20.5, 20.5, 21.5]
[0.12, 0.19, 0.24, 0.24, 0.01] largest: 0.24 <= q / 2: True

In symbols, a value v is shown as q \cdot \operatorname{round}(v / q), and the error |v - q \cdot \operatorname{round}(v / q)| is at most q / 2. On a wave the steps are easy to see:

import math
q = 0.25
true = [math.sin(2 * math.pi * k / 40) for k in range(40)]
shown = [round(v / q) * q for v in true]
plot(true, label="true")
plot(shown, kind="step", label="q = 0.25", xlabel="sample")

Press Run: the smooth wave becomes a staircase with steps 0.25 high. In the thermometer, 20.31 and 20.74 °C both show 20.5: a 0.43 °C difference has vanished. Each extra bit halves the step, about 6 dB (20 * log10(2)) more range between the loudest and quietest signal the converter can tell apart, which is why 16-bit audio spans about 96 dB. A converter also has a limited input range: a voltage beyond it gets the lowest or highest code (it is clipped), a clamp such as min(max(code, 0), 2 ** bits - 1), and its error can be far more than half a step. Converters differ in whether they round down or to the nearest step, and whether a code stands for the bottom or the middle of its step; the data sheet says which.

Common mistakes: dividing the range by bits or by 2 ** bits - 1 instead of what the specification says. Forgetting to clamp. Using int() to round down: it rounds towards zero and gets negative numbers wrong; math.floor rounds down. Python's round sends exact halves to the even neighbour (round(2.5) is 2).

Practice: What an 8-Bit Converter Hears

Resampling

When two devices disagree about the sample rate, a signal must be resampled: new samples made at new times from the old ones. Lowering the rate by a whole factor keeps every m-th sample, x[::m], but it lowers the Nyquist frequency too, so smooth first or fast wiggles alias. Raising the rate means inventing values in between: a hold repeats the last reading until the next one (as a display does), and linear interpolation draws a straight line between neighbours. For time t, the position in the old list is p = t * fs, a whole part i and a fraction f:

import math
temps = [14.0, 15.2, 16.8, 17.1]       # °C, logged every 10 minutes from 9:00
fs = 1 / 10                            # samples per minute

p = 25 * fs                            # 9:25
i = math.floor(p)
f = p - i
print("between samples", i, "and", i + 1, "with fraction", f)
print("about", round(temps[i] + f * (temps[i + 1] - temps[i]), 2), "°C")
between samples 2 and 3 with fraction 0.5
about 16.95 °C

At 9:25 the log is half-way between 16.8 (9:20) and 17.1 (9:30), so the line gives 16.95. Interpolation cannot recover detail that was never recorded: a spike at 9:24 is gone.

Common mistakes: reading temps[i + 1] at the very last sample, where f is 0 and the answer is just temps[i]. Mixing seconds and minutes. Forgetting that N samples span only N - 1 gaps.

Practice: Changing a Wind Logger's Sample Rate

A system in time steps

A first-order system moves towards a target value at a rate proportional to how far away it is. A cup of tea in a 20 °C room cools faster the hotter it is:

\text{rate of change of } T = \frac{T_{\text{room}} - T}{\tau}

The time constant \tau (tau) says how sluggish it is: a thick mug with a lid has a large \tau. To simulate it, take steps of length \Delta t (dt) and change T by \Delta t times the current rate. With T_k the temperature after k steps,

T_{k+1} = T_k + \Delta t \, \frac{T_{\text{room}} - T_k}{\tau}.

This is the Euler method. In code, T = T + dt * (T_room - T) / tau computes T_{k+1} from T_k and overwrites it. Smaller steps follow the true curve, T_{\text{room}} + (T_0 - T_{\text{room}})\,e^{-t/\tau}, more closely:

import math
T_room, tau = 20, 15            # °C, minutes
for dt in (5, 1, 0.1):
    T, temps = 90.0, [90.0]
    for step in range(round(15 / dt)):        # 15 minutes
        T = T + dt * (T_room - T) / tau
        temps.append(T)
    print("dt =", dt, "min:", round(T, 2), "°C after 15 minutes")
    plot([k * dt for k in range(len(temps))], temps, label=f"dt = {dt}", xlabel="minutes", ylabel="tea (°C)")
print("exact:", round(T_room + (90 - T_room) * math.exp(-15 / tau), 2), "°C")
dt = 5 min: 40.74 °C after 15 minutes
dt = 1 min: 44.87 °C after 15 minutes
dt = 0.1 min: 45.67 °C after 15 minutes
exact: 45.75 °C

Press Run. With 5-minute steps the simulation keeps the fast early cooling rate for too long and ends 5 °C too low; with 0.1-minute steps it is within a tenth of a degree. Keep dt well below tau. The time constant has a physical meaning: after one tau the tea has closed about 63% of the gap to the room (70 °C became about 26 °C), after three about 95%, after five over 99%. The same curve describes a capacitor charging, a room warming and a sensor catching up.

Common mistakes: using the new value inside the rate (the whole step uses the value at its start). A dt above 2 * tau, where the simulation oscillates and blows up. A minus sign in the wrong place, so the tea heats up away from the room. A tau in minutes with a dt in seconds.

Practice: A Room on a Heater Timetable

Steady state

Give a first-order system a constant input and it settles where its rate of change is zero: the steady state, or final value. You find it without simulating, by setting the rate to zero and solving.

A water tank has a tap adding u centimetres of depth per minute and an outlet pipe draining h / tau per minute (more when the tank is deeper and pushes harder; roughly true for a long, narrow pipe). Each Euler step is h_{k+1} = h_k + \Delta t\,(u - h_k / \tau). The rate u - h / \tau is zero when h = u\tau, the depth where outflow balances inflow. Turned around, the tap setting for a desired depth is u = h / \tau.

tau, dt, u = 10, 0.1, 3.0        # minutes, minutes, cm per minute
h = 0.0
for step in range(600):          # 60 minutes
    h = h + dt * (u - h / tau)
print("predicted", u * tau, "cm; simulated after 60 minutes", round(h, 2), "cm")
predicted 30.0 cm; simulated after 60 minutes 29.93 cm

Sixty minutes is six time constants, so the tank is within a fraction of a per cent of the prediction; it approaches the steady state but never quite reaches it. If the setting you solve for is beyond what the tap can give, the honest answer is "as much as it can", and the tank settles lower.

Practice: The Dial Setting and the Open Window

Open loop and closed loop

The setting u = h / tau is an open-loop controller: worked out once from a model, with nobody looking at the water again. It is right only while the model is. Now a garden hose fed from the tank is opened, drawing another 1.5 cm per minute: a disturbance, an input the controller did not plan for.

tau, dt, target = 10, 0.1, 50
u = target / tau                  # open-loop setting: 5 cm per minute

h, levels = 0.0, [0.0]
for step in range(1200):          # 120 minutes
    hose = 1.5 if step * dt >= 60 else 0.0      # the hose is opened after an hour
    h = h + dt * (u - h / tau - hose)
    levels.append(h)
print("depth at 60 min:", round(levels[600], 1), "cm; at 120 min:", round(levels[1200], 1), "cm")
plot([k * dt for k in range(len(levels))], levels, xlabel="minutes", ylabel="depth (cm)", title="Open loop")
depth at 60 min: 49.9 cm; at 120 min: 35.0 cm

Press Run. The tank fills to its 50 cm plan, then sinks to a new steady state of tau * (u - 1.5) = 35 cm, and the tap never notices. Open loop cannot fight a disturbance it does not know about, and the world is full of them: an open window, a hill, a heavier load.

A closed-loop (feedback) controller measures the output and uses it. The vocabulary is the same everywhere: the setpoint (what you want), the measurement (what the sensor says), the error (setpoint minus measurement), the controller (the rule that turns the error into a command), the actuator (tap, heater, engine) and the plant (tank, room, car). The loop repeats: measure, compare, command. Two such rules follow.

Practice: Cruise Control on a Hill

On/off control with hysteresis

The simplest rule switches the actuator fully on or off. A water tower is filled by a pump adding 6 cm of depth per minute, while the town draws 2 cm per minute; the target is 300 cm. "Pump on below 300, off above" works, but switches nearly every minute and wears the pump out. Hysteresis adds a dead band: switch on only below 300 - band, off only above 300 + band, and in between keep doing whatever you were doing.

def tower(band, minutes=600):
    h, on, switches = 300.0, False, 0
    lo = hi = h
    for minute in range(minutes):
        if not on and h < 300 - band:
            on, switches = True, switches + 1
        elif on and h > 300 + band:
            on, switches = False, switches + 1
        h = h + 6 * on - 2          # pump in (True counts as 1) minus what the town uses
        lo, hi = min(lo, h), max(hi, h)
    return switches, lo, hi

for band in (0, 5, 20):
    switches, lo, hi = tower(band)
    print("band", band, "cm:", switches / 10, "switches per hour, depth", lo, "to", hi)
band 0 cm: 40.0 switches per hour, depth 298.0 to 302.0
band 5 cm: 13.3 switches per hour, depth 294.0 to 306.0
band 20 cm: 3.6 switches per hour, depth 278.0 to 322.0

A wider band means fewer switches and a bigger swing. On/off control never settles; it cycles between the edges of the band, slightly past them because the level keeps moving for the minute after each decision. With a noisy sensor, hysteresis is essential: readings that jitter around the setpoint would flip a band-0 controller with every jitter.

Common mistakes: leaving out the "otherwise keep the current state" part, which is the whole point. Mixing < and <= without checking the specification. Forgetting the starting state.

Practice: A Thermostat That Does Not Chatter

Proportional control

A proportional (P) controller pushes in proportion to the error: with setpoint r and measurement y_k, the error is e_k = r - y_k and the command is

u_k = K_p \, e_k,

hard when far away, gently when close. The gain K_p (Kp) says how hard per unit of error. A real actuator has limits, so the command is saturated, clipped to its range with max(lo, min(hi, command)). Here is the tank again, the tap now limited to 8 cm per minute, with the hose opened after two hours:

tau, dt, target, u_max = 10, 0.1, 50, 8
for Kp in (0.5, 1, 5):
    h = 0.0
    for step in range(2400):                       # 240 minutes
        hose = 1.5 if step * dt >= 120 else 0.0
        u = max(0.0, min(u_max, Kp * (target - h)))  # push in proportion, within 0..8
        h = h + dt * (u - h / tau - hose)
        if step == 1199:
            before = h
    print("Kp =", Kp, ": depth", round(before, 2), "before the hose,", round(h, 2), "after")
Kp = 0.5 : depth 41.67 before the hose, 39.17 after
Kp = 1 : depth 45.45 before the hose, 44.09 after
Kp = 5 : depth 49.02 before the hose, 48.73 after

The open loop lost 15 cm to the hose; with Kp = 5 feedback loses less than a third of a centimetre. But no gain reaches 50 cm, even before the hose. That gap is the steady-state error of P control, and it has a physical cause: the outlet always drains, so the tap must always run, and a P controller only opens the tap when there is an error. Some error must remain, just enough that Kp * error supplies the outflow. Setting the rate K_p(r - h) - h / \tau to zero gives the final depth h_\infty = \frac{K_p r}{K_p + 1/\tau}, with r the target; a bigger gain shrinks the error but never removes it. (A later level adds integral action, which removes it.)

Saturation shapes the start: with an error of 50 cm, Kp = 5 asks for 250 cm per minute and gets 8, so at first every large gain fills at the same maximum rate. Collect the depths in a list and plot them for each gain to see it.

Common mistakes: writing measurement - setpoint, so the controller pushes the wrong way. Saturating the error instead of the command. Expecting P control to reach the setpoint. Raising Kp without limit: with a delay in the loop, a large gain oscillates.

Practice: A Heater That Pushes Harder When It Is Colder

Delay

Every real loop has delay: a sensor in a thick housing, a long pipe, a slow network. To simulate a sensor d steps late, keep the history of the output and read the entry from d steps ago (the first entry while the history is shorter):

tau, dt, target, u_max, Kp = 10, 0.1, 50, 8, 1
for delay in (0, 1, 2):                         # minutes
    d = round(delay / dt)                       # steps
    h, levels = 0.0, [0.0]
    for step in range(600):                     # 60 minutes
        seen = levels[-1 - d] if len(levels) > d else levels[0]
        u = max(0.0, min(u_max, Kp * (target - seen)))
        h = h + dt * (u - h / tau)
        levels.append(h)
    print("delay", delay, "min: highest", round(max(levels), 2), "cm; last ten minutes",
          round(min(levels[500:]), 2), "to", round(max(levels[500:]), 2), "cm")
    plot([k * dt for k in range(len(levels))], levels, label=f"delay {delay} min", xlabel="minutes", ylabel="depth (cm)")
delay 0 min: highest 45.45 cm; last ten minutes 45.45 to 45.45 cm
delay 1 min: highest 47.3 cm; last ten minutes 45.45 to 45.45 cm
delay 2 min: highest 50.3 cm; last ten minutes 39.42 to 50.3 cm

Press Run to see all three. Without delay the depth rises smoothly to 45.45 cm. With one minute of delay the controller keeps the tap open after the tank has reached its final depth, because the sensor still reports less, so the depth shoots past and comes back. With two minutes each correction arrives so late that it overshoots the other way, and the tank swings for ever. The same controller that is calm with fresh information becomes unstable with stale information, and the higher the gain, the less delay it tolerates. An on/off controller with a late sensor swings well beyond its band.

Common mistakes: levels[-d] instead of levels[-1 - d] (with d = 0, levels[-0] is the first entry). Measuring after the update instead of before, which shifts the delay by a step. Judging stability from a run too short to show whether the swings die away.

Practice: The Thermostat With a Slow Sensor

Reading a step response

The standard test of a loop is a step: change the setpoint suddenly and record the output. Two numbers summarise the result. The percent overshoot is how far the output went past its final value, as a share of the size of the step,

\text{overshoot} = 100 \cdot \frac{y_{\text{peak}} - y_{\text{final}}}{y_{\text{final}} - y_{\text{start}}} \ \%,

or 0 if it never went past. The final value is where the output settles, which for a P controller is not the setpoint. The tank with a one-minute delay started at 0, settled at 50 / 1.1 = 45.45 cm and peaked at 47.3 cm: an overshoot of 100 * 1.85 / 45.45, about 4.1%.

The settling time is the first moment after which the output stays within a band around the final value, usually 2% of the step size, for the rest of the record: |y_j - y_{\text{final}}| \le 0.02\,|y_{\text{final}} - y_{\text{start}}| for every later sample j. Take a lift sent up 30 m, its position logged every second:

time (s):     0   1   2    3     4     5     6     7     8
position (m): 0   9   21   29.6  31.2  30.5  29.5  30.2  30.1

The band is 2% of 30 m, 0.6 m, so inside means 29.4 to 30.6 m. At 3 s the lift is already inside (29.6), but at 4 s it is outside again (31.2); from 5 s on every sample is inside. The settling time is 5 s, not 3 s: entering the band and leaving it again does not count. A record whose last sample is outside has not settled at all.

Overshoot and settling time pull against each other: a gentle controller does not overshoot but takes long to get close; a lively one gets there fast, shoots past and may take long to calm down. Tuning means choosing the balance the job needs. A lift must not overshoot a floor by much; a tank can wobble a little.

Common mistakes: measuring overshoot against the setpoint instead of the final value. Dividing by the final value instead of the step size, which is wrong whenever the start is not zero. Taking the first sample inside the band as the settling time. Forgetting that a step can go down.

Practice: How Far the Drone Shot Past, When Has the Oven Settled?, Tuning the Village Hall Heating

Practice

These problems are required on this path at this level; the path adds one problem from each of the level's topics, picked for you and kept.

Checklist

You can now:

  • Treat a signal as a list of samples with a sample rate: give the time of sample k, the duration of a recording, and the fastest frequency it can hold.
  • Find a signal's DC offset as its mean and centre the signal without changing the original.
  • Compute energy, average power, RMS and peak-to-peak, and say what each means physically.
  • Convert amplitude and power ratios to decibels and back, and add the decibels of stages in a chain.
  • Smooth a noisy signal with a moving average, handle the edges, and explain the lag it causes.
  • Estimate a frequency by counting zero crossings, with a clear rule for samples that are exactly zero.
  • Delay, advance and reverse a fixed-length signal, and turn a delay in samples into seconds.
  • Quantise values with a step size, clamp out-of-range ones, and bound the quantisation error.
  • Resample by keeping every m-th sample, holding, or interpolating along straight lines.
  • Simulate a first-order system with Euler steps, choose a sensible dt, and find its steady state by setting the rate to zero.
  • Explain why an open-loop setting fails under a disturbance and how feedback corrects it.
  • Write on/off control with hysteresis and proportional control with saturation, and explain the steady-state error of P control.
  • Show how a delay in the loop causes overshoot and oscillation, and read percent overshoot and settling time from a step response.