Stage 3 of 6 · Theory then bench · about 40 minutes
Analog in
Light, temperature, pressure, position: the physical world does not come in highs and lows, it comes in amounts. This page is the theory and practice of turning an amount into a number, the single most consequential operation in instrumentation, and the one your Pi could not perform.
The world is continuous
A digital input, Week 4's Button, answers one question: is the voltage above or below a threshold? That is the right question for a pressed switch and the wrong one for almost every sensor: "is it bright?" throws away everything a greenhouse controller needs to know. An analog-to-digital converter (ADC) answers the real question, "how much?", by measuring the voltage at a pin and reporting it as a number. Everything else this week, dividers, calibration, averaging, exists to put a meaningful voltage on that pin and to interpret the number honestly. Reference article: analog-to-digital converter.
Two discretizations: sampling and quantization
A continuous signal has infinitely fine detail in two directions, time and amplitude, and a digital system can store neither. An ADC therefore discretizes twice:
- Sampling chops time: the signal is measured at discrete instants, and anything that happens between samples is invisible. The classical result (the Nyquist–Shannon theorem) says the sampling rate must exceed twice the signal's highest frequency to capture it faithfully; room light changes over seconds, so our few samples per second are luxurious, but the idea governs every data-acquisition system you will ever meet.
- Quantization chops amplitude: each measurement is rounded to the nearest of 2N levels, where N is the converter's resolution in bits. The rounding error, at most half a step, is the quantization error: not a malfunction but the price of finiteness.
The number that sizes the staircase is the step size:
step = V_ref / 2^N = 3.3 V / 4096 ≈ 0.8 mV per count (12-bit converter)
The ESP32's converter is 12-bit: 4096 distinguishable levels across its range. MicroPython's read_u16() reports on a 16-bit scale, 0 to 65535, for portability across chips; the hardware still resolves 4096 steps, rescaled, so neighbouring raw values move in jumps of about 16. More counts is finer, not automatically more accurate: noise, the reference and the circuit decide accuracy, which is why calibration closes this page.
The ESP32 ADC, in practice
>>> from machine import ADC, Pin
>>> adc = ADC(Pin(34))
>>> adc.atten(ADC.ATTN_11DB) # stretch the range to the full 0..3.3 V
>>> adc.read_u16()
31248
Three facts carry the practice:
- Use ADC1, pins 32 to 39. The chip has two converter blocks, and the second one is commandeered by the radio whenever Wi-Fi runs; a circuit on an ADC2 pin works today and dies mysteriously next week. GPIO34 is this course's light pin: ADC1, input-only, perfect for a sensor. Plan for Week 9 now.
- Always set the attenuation. The bare converter saturates around 1 V;
ATTN_11DBinserts a known attenuator so the useful range spans roughly the full 0 to 3.3 V. Forgotten attenuation is the classic "my readings max out early" bug. - Convert counts to volts for intuition, not for truth.
v ≈ read_u16() / 65535 × 3.3is the honest estimate, and the ESP32's converter is known to be nonlinear near the extremes of its range. For lab-grade volts there isread_uv(), which applies the chip's factory calibration; for this course and A2, relative, bench-calibrated readings are the right tool, and the conversion formula is for sanity checks. API details: machine.ADC.
The voltage divider, derived
The ADC measures voltage; an LDR changes resistance. The bridge between them is the oldest circuit in instrumentation, and it falls out of Week 4's series-loop reasoning in three lines. Put two resistances in series across the supply; the same current flows through both (one loop), so:
I = V_cc / (R1 + R2) # Ohm's law on the whole loop
V_out = I × R2 # Ohm's law on the bottom resistor
V_out = V_cc × R2 / (R1 + R2) # the voltage-divider equation
The midpoint voltage is the supply, split in proportion to the resistances. Make one of the two a sensor and the midpoint becomes a voltage that tracks the physical world, which is precisely what an ADC pin wants to see. The general form: voltage divider.
The light circuit, with numbers
The sensor itself: a light-dependent resistor (photoresistor), a photoconductive cell whose resistance falls as illumination rises, from hundreds of kilohms in the dark to a few kilohms or less in bright light. Its response is large, cheap and robust, and also nonlinear and part-to-part variable, which is the engineering reason the next section calibrates instead of computing lux from a datasheet. The 10 kΩ fixed resistor is chosen to sit mid-range of the LDR's swing, spreading the interesting light levels across the ADC's span; the worked numbers above show the spread.
Bench: live readings
Wire the divider with everything unpowered (rule 5 travels too: unplug the USB while wiring). LDR from 3V3 to a breadboard node, 10 kΩ from that node to GND, jumper from the node to GPIO34. Then the oldest experiment in sensing:
from machine import ADC, Pin
from time import sleep
adc = ADC(Pin(34))
adc.atten(ADC.ATTN_11DB)
while True:
raw = adc.read_u16()
volts = raw / 65535 * 3.3
print(f"raw={raw:5d} v={volts:.2f}")
sleep(0.2)
Cover the LDR with a finger, aim a phone flashlight at it, and watch the numbers chase your hand. Note your bench's extremes while you are there, finger-dark and flashlight-bright raw values; the next section needs them, and so does your lab evidence.
Calibration: counts to meaning
Raw counts are honest but parochial: your bench's "dark" and your partner's differ with the room, the part and the resistor. Calibration maps counts to meaning using measurements you took, the two-point version being all this course needs:
DARK = 1800 # your bench's covered-LDR reading
BRIGHT = 52000 # your bench's flashlight reading
def light_pct(raw):
pct = (raw - DARK) / (BRIGHT - DARK) * 100
return max(0, min(100, pct)) # clamp: readings can stray past the anchors
Now the node speaks a portable language, 0 to 100 percent of your bench's range, which is exactly the quantity A2's setpoint will be written in. The clamp matters: sunlight brighter than your flashlight, or a darker finger, must saturate gracefully rather than report 112% or −3%. Record the two anchors in your repo; calibration constants are configuration, and configuration is documented, not remembered.
Noise and averaging
Hold the light steady and the raw value still flickers by tens of counts: supply ripple, converter noise, the radio next week. The simplest cure is the oldest filter, the mean of several samples:
def read_avg(adc, n=16):
total = 0
for _ in range(n):
total += adc.read_u16()
return total // n
Averaging n samples shrinks uncorrelated noise by roughly √n (sixteen samples, a quarter of the jitter) at the price of time: sixteen reads take longer than one, so a heavily averaged sensor responds more smoothly and more slowly. That trade, smoothness against latency, is your first filter-design decision, and it returns with force in Week 10, where a control loop fed by jittery readings twitches and one fed by over-filtered readings lags. For now: average, note the n you chose, and know why it is a choice.