Analog-to-Digital Conversion: How Robots Read Sensor Values

Analog-to-digital conversion (ADC) is the process by which a microcontroller translates a continuously variable voltage from a sensor into a discrete numeric value that code can process — on an Arduino Uno, the 10-bit ADC divides the 0–5V input range into 1,024 steps (0 to 1023), so a voltage of 2.5V produces a reading of approximately 511, and each step represents a voltage change of about 4.9mV. The quality of an ADC reading depends on four factors: resolution (how many steps), reference voltage accuracy (the standard the measurement is made against), sampling rate (how frequently the measurement is taken), and noise (unwanted voltage fluctuations that shift readings up or down by several steps even when the measured quantity hasn’t changed).

Introduction

Every analog sensor your robot uses — a potentiometer measuring a joint angle, an IR detector estimating distance, a thermistor monitoring battery temperature, a current sensor tracking motor load — ultimately produces a voltage. That voltage is meaningless to the microcontroller’s digital CPU until the ADC converts it to a number. The ADC is the bridge between the continuously varying physical world and the discrete numeric world of robot code.

Most robot builders use analogRead() on Arduino and move on without thinking much about what’s happening underneath. That works well for simple applications. But as robots become more sophisticated — requiring accurate distance measurements, precise joint angles, reliable battery state-of-charge estimation — understanding ADC behavior becomes essential. Noise corrupts readings. Reference voltage inaccuracy makes calibrations drift. Sampling rate limits how quickly a sensor can be polled. Resolution determines the finest measurement the robot can detect.

This article goes inside the ADC: what it does, how it works, what the numbers mean, and the full toolkit of techniques — hardware and software — for getting the most accurate, reliable sensor readings possible from the ADC built into every Arduino and microcontroller.

What the ADC Actually Does

An analog-to-digital converter answers one question: what fraction of the reference voltage is the input voltage? It answers this by comparing the input to the reference and expressing the result as a binary number with a fixed number of bits.

The Measurement Model

ADC output = round( V_in / V_ref × (2^N - 1) )

Where:
  V_in  = input voltage (volts)
  V_ref = reference voltage (the full-scale reference, e.g. 5.0V)
  N     = ADC resolution in bits (10 for ATmega328P)
  2^N   = 1024 for a 10-bit ADC
  2^N-1 = 1023 (maximum output value)

Examples (10-bit ADC, 5V reference):
  V_in = 0.0V   → ADC = 0
  V_in = 2.5V   → ADC = round(2.5/5.0 × 1023) = round(511.5) = 512
  V_in = 5.0V   → ADC = 1023
  V_in = 1.0V   → ADC = round(1.0/5.0 × 1023) = round(204.6) = 205
  V_in = 4.887V → ADC = round(4.887/5.0 × 1023) = round(1000.0) = 1000

The inverse — converting a reading back to voltage:

V_in = ADC_reading × V_ref / (2^N - 1)
     = ADC_reading × 5.0 / 1023       (for Arduino Uno default settings)

Examples:
  ADC = 512  → V_in = 512 × 5.0/1023 = 2.502V
  ADC = 205  → V_in = 205 × 5.0/1023 = 1.002V
  ADC = 1023 → V_in = 1023 × 5.0/1023 = 5.0V (saturated)

This relationship seems straightforward, but three sources of error complicate it in practice: reference voltage inaccuracy, quantization error, and noise. Understanding each shapes how you use the ADC.

Resolution: How Fine Can the Measurement Be?

Resolution is the number of bits the ADC uses to represent the conversion result — and it determines the smallest voltage change the ADC can distinguish.

Bit Depth and Voltage Resolution

ADC resolution comparison:

Bits   Steps     Voltage per step (5V ref)   Typical use
────────────────────────────────────────────────────────────────────
8      256        19.6mV                       Basic sensing, coarse control
10     1024        4.9mV                        Arduino Uno/Nano/Mega (default)
12     4096        1.2mV                        ESP32, STM32, higher precision
14     16384       0.3mV                        Precision instruments
16     65536       0.076mV = 76µV               High-precision sensors
24     16,777,216  0.30µV                       Scales, audio interfaces (external ADC)

Arduino Uno (10-bit, 5V reference):
  Minimum detectable voltage change: 5.0V / 1023 = 4.89mV per LSB (Least Significant Bit)
  This means: two voltages that differ by less than 4.89mV are indistinguishable —
  both produce the same ADC reading.

  For a potentiometer measuring a joint angle from 0° to 270°:
  270° / 1023 steps = 0.264° per step
  Angular resolution: better than 0.3° — adequate for most robot arm applications

  For a battery voltage divider (12.6V max mapped to 5V):
  12.6V / 1023 steps = 12.3mV per step at the battery level
  Battery measurement resolution: ~12mV — adequate for state-of-charge estimation

When Resolution Is Insufficient

If the sensor’s output spans only a fraction of the ADC’s input range, the effective resolution is reduced:

Example: a pressure sensor outputs 0.5V at minimum pressure, 2.5V at maximum.
The sensor spans only 2.0V of the 5.0V reference range.

Effective steps used: (2.0V / 5.0V) × 1023 = 409 steps
Effective resolution: log2(409) ≈ 8.7 bits — equivalent to an 8-bit ADC!

By switching to the internal 1.1V reference (if sensor is within 0–1.1V):
or adding an op-amp to amplify the 0.5V–2.5V range to 0V–5V:
Full 10-bit resolution is restored.

Amplification gain needed: 5V / 2.0V = 2.5×
Op-amp circuit (non-inverting amplifier, gain 2.5×):
  V_out = V_in × (1 + R2/R1) → R2/R1 = 1.5 → e.g. R1=10kΩ, R2=15kΩ
  BUT: must ensure V_in × 2.5 never exceeds 5V (input clamp needed)

The technique of amplifying a sensor’s output to span the full ADC input range is called “signal conditioning” and is worth the added component cost for any application where measurement precision matters.

The ADC Hardware Inside the Microcontroller

The ATmega328P (Arduino Uno) uses a successive approximation register (SAR) ADC — the most common architecture in microcontrollers because it balances speed, accuracy, and silicon area efficiently.

Successive Approximation: How the Conversion Works

SAR ADC conversion process (10-bit, 5V reference):

Goal: determine the digital representation of V_in = 3.14V

Bit 9 (MSB): Is V_in > 5.0/2 = 2.5V?   YES → bit 9 = 1, estimate = 2.5V
Bit 8:       Is V_in > 2.5+1.25 = 3.75V? NO  → bit 8 = 0, estimate stays 2.5V
Bit 7:       Is V_in > 2.5+0.625 = 3.125V? NO → bit 7 = 0, estimate stays 2.5V
Bit 6:       Is V_in > 2.5+0.3125 = 2.8125V? YES → bit 6=1, estimate=2.8125V
Bit 5:       Is V_in > 2.8125+0.1563 = 2.9688V? YES → bit 5=1, estimate=2.9688V
Bit 4:       Is V_in > 2.9688+0.0781 = 3.047V? YES → bit 4=1, estimate=3.047V
Bit 3:       Is V_in > 3.047+0.0391 = 3.086V? YES → bit 3=1, estimate=3.086V
Bit 2:       Is V_in > 3.086+0.0195 = 3.105V? YES → bit 2=1, estimate=3.105V
Bit 1:       Is V_in > 3.105+0.0098 = 3.115V? YES → bit 1=1, estimate=3.115V
Bit 0 (LSB): Is V_in > 3.115+0.0049 = 3.120V? YES → bit 0=1, estimate=3.120V

Result: 0b1001111111 = decimal 639
Check: 639 × 5.0/1023 = 3.123V (compared to actual 3.14V — 17mV error due to
       the limited resolution of 4.9mV/step)

This 10-step binary search is why the SAR ADC requires exactly N clock cycles
to convert an N-bit result — 10 clock cycles for a 10-bit result.

Sampling and Hold

Before the comparison process begins, the ADC samples the input voltage by charging an internal capacitor (the sample-and-hold capacitor, approximately 14pF on the ATmega328P) to match the input voltage. This capacitor then holds this voltage stable while the 10-step comparison proceeds. The input signal may change during the comparison — but the held capacitor voltage doesn’t, ensuring the conversion is a snapshot of the instantaneous voltage at the sample moment.

The sample-and-hold capacitor takes time to charge fully. If the source impedance driving the ADC pin is high (the sensor has a weak output), the capacitor may not fully charge before the conversion begins, leading to reading errors. The maximum recommended source impedance for the ATmega328P ADC is 10kΩ:

Source impedance and ADC accuracy:

Source impedance < 10kΩ:
  Sample capacitor fully charges → accurate readings
  Voltage dividers, potentiometers ≤ 10kΩ: fine
  
Source impedance 10kΩ – 100kΩ:
  Partial charging → systematic low-reading error
  High-value thermistors (100kΩ): expect ~1% error
  
Source impedance > 100kΩ:
  Significant error; readings unreliable

Fix for high-impedance sources:
  Add a buffer op-amp (unity-gain voltage follower) between sensor and ADC:
  - Op-amp output impedance: ~1Ω
  - No longer loads the sensor
  - ADC sees low impedance → accurate readings
  
  Or: add a 100nF capacitor from ADC pin to GND
  - Capacitor stores charge; source charges capacitor over time
  - ADC samples from capacitor (low impedance)
  - Slows response to fast signal changes (acts as low-pass filter)

Noise: The Invisible Enemy of ADC Accuracy

In a perfectly quiet electrical environment, an ADC connected to a stable voltage would always return the same reading. In a real robot with switching power supplies, PWM motor drives, wireless communication, and high-current wires, the ADC input sees noise — rapid, random voltage fluctuations superimposed on the signal of interest. This noise shifts the ADC reading by 1–10+ LSBs even when the actual measured quantity hasn’t changed.

Sources of ADC Noise in Robots

Noise source         Magnitude    Mechanism
──────────────────────────────────────────────────────────────────────
PWM motor drive      5–50mV      Switching currents in motor wires induce
                                  voltage in adjacent sensor wires
WiFi/BLE radio       2–20mV      RF transmissions couple onto input traces
Switching regulator  5–30mV      Switching frequency ripple on power rails
Motor brushes        10–100mV    Brush arcing generates broadband RF noise
Microcontroller ops  1–5mV       Digital switching inside the chip itself
Ground resistance    1–10mV      Current through ground wiring creates
                                  voltage drops (see article 63)

Each noise source manifests as fluctuation in ADC readings. A reading that should be stable at 512 instead shows values like 507, 515, 510, 518, 509, 512, 514 — a ±9 LSB spread representing ±44mV of apparent signal variation.

Software Noise Reduction: Averaging

The simplest noise reduction technique: average multiple readings. If the noise is random (not correlated with the signal), averaging N readings reduces noise by the square root of N:

// Simple averaging — reduces noise by sqrt(N)
// N=4:  noise reduced to 50%   (noise / sqrt(4) = noise / 2)
// N=16: noise reduced to 25%   (noise / sqrt(16) = noise / 4)
// N=64: noise reduced to 12.5% (noise / sqrt(64) = noise / 8)

int averagedRead(int pin, int numSamples) {
  long sum = 0;
  for (int i = 0; i < numSamples; i++) {
    sum += analogRead(pin);
    delayMicroseconds(200);  // Small gap lets ADC settle between reads
  }
  return sum / numSamples;
}

// Usage — 16-sample average:
int reading = averagedRead(A0, 16);  // ~1.7ms total (16 × 104µs)
float voltage = reading * (5.0 / 1023.0);

Averaging trades time for accuracy. 16 samples take 16× longer than one sample. For slowly-changing sensor readings (temperature, battery voltage, joint angle at low speed), this is entirely acceptable. For fast-moving signals (high-speed encoder position, rapidly-varying distance sensor), averaging introduces lag that may be unacceptable.

Oversampling and Decimation: Free Extra Bits

A mathematically elegant technique from signal processing can extract more resolution from an existing ADC by exploiting the noise that’s already present:

// Oversampling + decimation: gain 1 extra bit per 4× oversampling
// 4× oversample: 10-bit → 11-bit effective resolution
// 16× oversample: 10-bit → 12-bit effective resolution  
// 64× oversample: 10-bit → 13-bit effective resolution
// 256× oversample: 10-bit → 14-bit effective resolution

// How it works mathematically:
// 1. Sum N samples (N = 4^k for k extra bits)
// 2. Right-shift result by k bits (divide by 2^k)
// Result has k more bits of resolution than raw ADC

// 12-bit result from 10-bit ADC using 16× oversampling:
uint32_t oversampledRead(int pin) {
  uint32_t sum = 0;
  for (int i = 0; i < 16; i++) {       // 16 samples for 2 extra bits
    sum += analogRead(pin);
    delayMicroseconds(100);
  }
  return sum >> 2;  // Divide by 4 (shift right 2 for 2 extra bits)
  // Result range: 0–4092 (12-bit equivalent, 0–4095 theoretical)
}

// Usage:
uint32_t highRes = oversampledRead(A0);  // 0–4092
float voltage = highRes * (5.0 / 4092.0);  // Convert 12-bit to volts

Important: Oversampling only works if genuine noise is present — the noise acts as dithering that randomizes the quantization error between samples. If the input is perfectly stable (no noise), oversampling gives you extra counts that are all identical rather than a higher-resolution measurement. In practice, robot ADC inputs always have enough noise for oversampling to work effectively.

Low-Pass Filtering: Exponential Moving Average

For sensor signals that vary slowly (temperature, battery voltage, slow position changes), a software low-pass filter smooths out high-frequency noise while tracking real signal changes:

// Exponential moving average (EMA) filter
// New output = alpha × new_reading + (1-alpha) × previous_output
// alpha: 0.0 = infinite smoothing (never changes), 1.0 = no smoothing (raw reading)
// alpha = 0.1: ~90% of previous value, ~10% of new reading → heavy smoothing
// alpha = 0.5: balanced smoothing
// alpha = 0.9: light smoothing — tracks fast changes, modest noise reduction

class EMAFilter {
private:
  float alpha;
  float filtered;
  bool initialized;

public:
  EMAFilter(float a) : alpha(a), filtered(0), initialized(false) {}

  float update(float newReading) {
    if (!initialized) {
      filtered = newReading;   // Seed with first reading (avoid startup lag)
      initialized = true;
    } else {
      filtered = alpha * newReading + (1.0f - alpha) * filtered;
    }
    return filtered;
  }
};

// Usage:
EMAFilter batteryFilter(0.05f);  // Heavy smoothing: 5% new, 95% previous
EMAFilter distFilter(0.3f);      // Moderate smoothing for distance sensor

void loop() {
  float battRaw = analogRead(A0) * (5.0 / 1023.0);
  float battFiltered = batteryFilter.update(battRaw);

  float distRaw = analogRead(A1) * (5.0 / 1023.0);
  float distFiltered = distFilter.update(distRaw);

  Serial.print(battFiltered, 3);
  Serial.print(",");
  Serial.println(distFiltered, 3);
}

The EMA filter has a time constant determined by alpha and the sampling rate. At 10Hz sampling rate with alpha=0.1, the filter’s time constant is approximately 1/(alpha × sample_rate) = 1/(0.1 × 10) = 1 second — the filter output takes about 1 second to settle to a new stable value after a step change in input.

Hardware Noise Reduction

Software filtering treats the symptom; hardware reduction attacks the cause. Combined, they are far more effective than either alone.

Decoupling capacitors on the AVCC pin: The ADC’s power supply (AVCC) must be well-filtered. A 10µF electrolytic capacitor and 100nF ceramic capacitor from AVCC to GND, placed as close to the AVCC pin as possible, filter switching noise from the power rail before it reaches the ADC reference:

Arduino Uno: AVCC is already connected to VCC on the board.
For best ADC noise performance on custom boards:
  AVCC ──[10µF + 100nF to GND]──── filtered supply point

Separation from high-current circuits: Route analog sensor wires away from motor wires, PWM lines, and the main power harness. Electromagnetic coupling (mutual inductance between parallel wires) is proportional to the area enclosed between the two wires — use twisted pairs for sensor signals in noisy environments, or route sensor wires on a completely separate harness.

AGND / DGND separation: On mixed-signal boards, the analog ground (connected to ADC reference) should be separated from the digital ground (switched currents from the microcontroller’s digital logic). They connect at a single point — the power supply’s GND — rather than sharing a common ground plane that conducts digital switching noise into the analog domain.

ADC Noise Reduction Mode (AVR-specific): The ATmega328P has a special power reduction mode that halts the CPU clock and all digital activity during an ADC conversion, preventing internal switching noise from coupling into the ADC result:

#include <avr/sleep.h>

// ADC noise reduction mode — takes a conversion with CPU halted
// Reduces internal digital switching noise during conversion
int noiseFreeRead(int pin) {
  analogRead(pin);  // Discard first reading after any channel switch
  
  set_sleep_mode(SLEEP_MODE_ADC);  // CPU halts, ADC runs, wakes on completion
  sleep_mode();                    // Enter ADC noise reduction sleep
  
  return ADC;  // ADC register contains the result
}

This technique can reduce ADC noise by 2–4 LSBs for high-precision measurements.

Calibration: Turning Raw Readings into Meaningful Values

Raw ADC readings (0–1023) are only useful after calibration — the process of establishing the mathematical relationship between ADC count and the physical quantity being measured.

Two-Point Linear Calibration

The most common calibration method: measure the ADC reading at two known physical values and fit a straight line between them:

// Two-point linear calibration for a sensor

struct Calibration {
  float rawLow;   // ADC reading at physical low reference point
  float rawHigh;  // ADC reading at physical high reference point
  float physLow;  // Physical value at low reference (e.g., 0°C)
  float physHigh; // Physical value at high reference (e.g., 100°C)
};

float applyCalibration(int rawADC, const Calibration& cal) {
  // Linear interpolation: y = y0 + (y1-y0) × (x-x0)/(x1-x0)
  return cal.physLow + (cal.physHigh - cal.physLow) 
         * (rawADC - cal.rawLow) / (cal.rawHigh - cal.rawLow);
}

// Example: IR distance sensor calibration
// Measured at known distances with ruler:
//   10cm → ADC reads 680
//   80cm → ADC reads 120
Calibration irCal = {680, 120, 10.0, 80.0};

float readDistance() {
  int raw = analogRead(A0);
  return applyCalibration(raw, irCal);
}

Performing a two-point calibration:

  1. Place the sensor or set the physical quantity at the low reference value (known precisely)
  2. Record the average ADC reading at this value (average 20–50 samples to reduce noise)
  3. Do the same at the high reference value
  4. Store these four values as the calibration constants

After calibration, the sensor reading is accurate at the two calibration points and interpolated linearly between them. For sensors with non-linear response (thermistors, Sharp IR distance sensors), two-point calibration works well only near the calibration points. Multi-point calibration or lookup tables improve accuracy across the full range.

Storing Calibration in EEPROM

Calibration values should persist across power cycles — re-calibrating every time the robot turns on is impractical. Store calibration values in EEPROM:

#include <EEPROM.h>

const int CAL_EEPROM_ADDR = 0;
const uint32_t CAL_MAGIC = 0xCAL1B00;  // Sentinel value

void saveCalibration(const Calibration& cal) {
  EEPROM.put(CAL_EEPROM_ADDR, CAL_MAGIC);
  EEPROM.put(CAL_EEPROM_ADDR + 4, cal);
  Serial.println(F("Calibration saved."));
}

bool loadCalibration(Calibration& cal) {
  uint32_t magic;
  EEPROM.get(CAL_EEPROM_ADDR, magic);
  if (magic != CAL_MAGIC) return false;  // No stored calibration
  EEPROM.get(CAL_EEPROM_ADDR + 4, cal);
  return true;
}

// In setup():
Calibration irCal;
if (!loadCalibration(irCal)) {
  // No stored calibration — use safe defaults or prompt for calibration
  Serial.println(F("No calibration found! Using defaults."));
  irCal = {680, 120, 10.0, 80.0};
}

Automatic Zero Calibration

For sensors with a known zero (IMU gyroscopes, load cells, current sensors at no load), automatic zero calibration at startup removes the sensor’s offset error:

// Gyroscope zero calibration — average 200 readings while robot is stationary
float calibrateGyroZero(int pin, int numSamples = 200) {
  Serial.println(F("Hold robot still for gyro calibration..."));
  delay(1000);  // Brief pause for user to stop moving robot
  
  long sum = 0;
  for (int i = 0; i < numSamples; i++) {
    sum += analogRead(pin);
    delay(5);  // 5ms between samples → 200 samples over 1 second
  }
  
  float zero = (float)sum / numSamples;
  Serial.print(F("Gyro zero offset: "));
  Serial.println(zero);
  return zero;
}

// In setup():
float gyroZero = calibrateGyroZero(A2);

// In loop():
int rawGyro = analogRead(A2);
float angularRate = (rawGyro - gyroZero) * GYRO_SCALE;  // Degrees per second

This zero calibration eliminates the constant offset that most analog sensors have due to manufacturing variation, component aging, and temperature effects — often the largest source of systematic error after quantization.

External ADCs: When the Built-In Isn’t Enough

The Arduino Uno’s built-in 10-bit ADC is adequate for many applications, but some robotics tasks genuinely require more resolution, more channels, or better accuracy:

External ADC options for robotics:

ADS1115 (16-bit, I2C):
  Resolution: 16-bit → 65,536 steps
  Voltage step (4.096V range): 4.096V / 32767 = 0.125mV per LSB
  Channels: 4 single-ended or 2 differential
  Sample rate: up to 860 samples/second
  Programmable gain amplifier (PGA): ×1/3 to ×16 → input ranges ±0.256V to ±6.144V
  Cost: ~$1–3 (breakout board)
  Best for: precision battery monitoring, high-accuracy angle measurement,
            load cell interface, current sensing

MCP3208 (12-bit, SPI):
  Resolution: 12-bit → 4,096 steps
  Channels: 8 single-ended or 4 differential
  Sample rate: up to 100,000 samples/second at 5V
  Cost: ~$3–5
  Best for: multi-channel analog sensing where Arduino A0–A5 aren't enough

ADS7828 (12-bit, I2C):
  8 channels, I2C
  Allows multiple chips (up to 4) on one bus → up to 32 channels
  Cost: ~$2–5

For Raspberry Pi (no built-in ADC):
  MCP3008 (SPI, 10-bit, 8 channels): most popular, ~$2–3
  ADS1115 (I2C, 16-bit): high precision option

Using the ADS1115 with Arduino

#include <Wire.h>
#include <Adafruit_ADS1X15.h>

Adafruit_ADS1115 ads;

void setup() {
  Serial.begin(9600);
  Wire.begin();

  ads.begin();
  ads.setGain(GAIN_ONE);  // ±4.096V range, 0.125mV per bit
}

void loop() {
  // Read channel 0 (differential between A0 and A1 if DIFF mode, else single-ended)
  int16_t raw = ads.readADC_SingleEnded(0);  // Returns -32768 to 32767
  float voltage = ads.computeVolts(raw);      // Converts to volts using gain setting

  Serial.print(F("ADS1115 Ch0: "));
  Serial.print(raw);
  Serial.print(F(" = "));
  Serial.print(voltage, 4);  // 4 decimal places: 0.1234V
  Serial.println(F("V"));

  delay(100);
}

The ADS1115’s 16-bit resolution gives 0.125mV per step in the ±4.096V range — about 40× finer than the Arduino’s built-in 10-bit ADC at 4.9mV per step. For applications like measuring small differential voltages from a current-sensing shunt resistor or reading the output of a strain gauge bridge, this resolution difference is decisive.

Practical ADC Quick Reference

Arduino Uno ADC summary:
  Resolution:    10-bit (0–1023)
  Voltage range: 0 to VCC (0–5V default)
  LSB voltage:   4.89mV (= 5V / 1023)
  Channels:      6 (A0–A5)
  Sample time:   ~104µs (default prescaler 128)
  Max safe input: 0V to 5V (exceeding VCC+0.3V damages ADC)
  Max source impedance: 10kΩ (higher = accuracy loss)
  Reference options: DEFAULT (5V), INTERNAL (1.1V), EXTERNAL (AREF pin)

Key code patterns:
  int raw = analogRead(A0);                    // 0–1023
  float volts = raw * (5.0 / 1023.0);          // Convert to voltage
  analogReference(INTERNAL);                   // Switch to 1.1V reference
  analogRead(A0);                              // Discard first after reference change

Noise reduction ladder (add techniques until noise is acceptable):
  Level 1: Average 4–8 readings           → reduces noise ~50%
  Level 2: Add 100nF cap on ADC pin       → hardware LP filter
  Level 3: Average 16–64 readings         → reduces noise 75–87.5%
  Level 4: EMA software filter (alpha 0.1) → tracks slow signals smoothly
  Level 5: Oversampling (16× or 64×)      → gains 2–3 extra bits resolution
  Level 6: ADC noise reduction mode       → eliminates internal MCU noise
  Level 7: External ADS1115 or MCP3208    → higher resolution hardware

Analog-to-digital conversion is the process that makes sensor readings possible — translating the continuously varying voltages from the physical world into the discrete numeric values that microcontroller code can process and act on. The quality of those readings depends on four interacting factors: resolution (how finely the voltage range is divided), reference voltage accuracy (the precision of the full-scale standard), noise (random fluctuations that add uncertainty to each reading), and sampling rate (how frequently measurements can be taken).

The Arduino Uno’s 10-bit, 5V-reference ADC provides 4.89mV resolution per step — adequate for joint angle measurement, battery monitoring, IR distance sensing, and most other robotics applications. When noise is a problem, a layered approach starting with software averaging and moving through hardware decoupling capacitors, EMA filtering, oversampling, and finally external higher-resolution ADC chips provides progressively better results. When the built-in ADC’s resolution isn’t sufficient, the ADS1115 (16-bit, I2C) extends the capability while remaining on the simple, familiar I2C bus.

Calibration — establishing the mathematical relationship between ADC count and physical quantity through two-point measurement, stored in EEPROM for persistence — transforms raw numbers into meaningful sensor readings. Combined with appropriate noise reduction, calibrated ADC readings give a robot accurate, reliable knowledge of its physical environment: the foundation of everything from closed-loop motor control to environmental sensing and beyond.

Real Sensor Worked Examples

Theory becomes immediately useful when applied to specific sensors. These complete worked examples show the full chain from wiring to calibrated reading for the most common analog sensors in robotics.

Worked Example 1: Sharp GP2Y0A21 IR Distance Sensor

The Sharp GP2Y0A21 is one of the most popular analog distance sensors in beginner robotics — it measures distance from ~10cm to 80cm and outputs a voltage that decreases (non-linearly) as distance increases.

Sensor characteristics:
  Supply: 5V (80mA peak — too much for 5V Arduino pin; use 5V power rail directly)
  Output: 0.4V at 80cm → 3.1V at 10cm (non-linear, approximately 1/distance)
  Response time: ~40ms (25Hz maximum useful sample rate)

Wiring:
  Red wire → 5V supply rail (NOT Arduino pin)
  Black wire → GND
  Yellow wire → Arduino A0

The non-linear characteristic means simple two-point linear calibration
gives poor results across the full range. Better approach: use the known
1/distance relationship with a scale factor.
// Sharp GP2Y0A21 distance reading with curve compensation
const int IR_PIN = A0;

// Empirically determined constant for this sensor (varies ±10% between units)
// Determined by: measure voltage at 10cm and 80cm, then fit: d = k / V
// k ≈ 27 for distance in cm when V in volts (typical value)
const float IR_CONSTANT = 27.0;

float readIRDistance() {
  // Average 5 readings to reduce noise (sensor is noisy)
  long sum = 0;
  for (int i = 0; i < 5; i++) {
    sum += analogRead(IR_PIN);
    delay(8);  // At least 40ms total (5 × 8ms) — matches sensor response time
  }
  float avgRaw = sum / 5.0;
  float voltage = avgRaw * (5.0 / 1023.0);

  // Clamp to valid output range (below 0.4V is beyond 80cm range)
  if (voltage < 0.4) return 80.0;  // Beyond range — report 80cm max

  // Apply inverse relationship: distance ≈ k / voltage
  float distanceCm = IR_CONSTANT / voltage;

  // Clamp to valid range: 10–80cm
  return constrain(distanceCm, 10.0, 80.0);
}

void setup() {
  Serial.begin(9600);
}

void loop() {
  float dist = readIRDistance();
  Serial.print(F("Distance: "));
  Serial.print(dist, 1);
  Serial.println(F(" cm"));
  delay(50);  // 20Hz reporting rate
}

Calibrating the IR_CONSTANT for your specific sensor: Place an object at exactly 20cm. Read the raw voltage. Set IR_CONSTANT = 20 × voltage. Verify at other distances. The constant typically ranges from 24–30 across different sensor units.

Worked Example 2: NTC Thermistor Temperature Measurement

Thermistors are resistors whose resistance changes dramatically with temperature — NTC (Negative Temperature Coefficient) types decrease in resistance as temperature increases. They’re inexpensive, robust, and widely used for motor winding temperature monitoring in robots.

Thermistor circuit: voltage divider with fixed resistor

VCC (5V) ──[R_fixed: 10kΩ]──┬──── Arduino A1
                             │
                          [Thermistor, R_T]
                             │
                            GND

Voltage at A1 = 5V × R_T / (R_fixed + R_T)

As temperature rises: R_T decreases → voltage at A1 decreases
// NTC thermistor temperature reading using Steinhart-Hart equation
// Provides accurate temperature across the full range (vs. simple linear approx)

const int THERM_PIN = A1;
const float R_FIXED = 10000.0;   // 10kΩ fixed resistor
const float R_NOMINAL = 10000.0; // Thermistor resistance at T_NOMINAL
const float T_NOMINAL = 25.0;    // Temperature for R_NOMINAL (°C)
const float BCOEFFICIENT = 3950; // Beta coefficient from datasheet

float readTemperature() {
  // Read ADC and compute thermistor resistance
  int raw = analogRead(THERM_PIN);
  if (raw == 0) return -999.0;  // Prevent division by zero

  float voltage = raw * (5.0 / 1023.0);
  float r_thermistor = R_FIXED * voltage / (5.0 - voltage);

  // Steinhart-Hart simplified B-coefficient equation:
  // 1/T = 1/T0 + (1/B) × ln(R/R0)
  // T in Kelvin
  float t_kelvin = 1.0 / (
    1.0 / (T_NOMINAL + 273.15) +
    (1.0 / BCOEFFICIENT) * log(r_thermistor / R_NOMINAL)
  );

  return t_kelvin - 273.15;  // Convert Kelvin to Celsius
}

void setup() {
  Serial.begin(9600);
}

void loop() {
  float tempC = readTemperature();
  Serial.print(F("Temperature: "));
  Serial.print(tempC, 1);
  Serial.println(F(" °C"));
  delay(1000);
}

Practical notes: The B-coefficient (3950 in this example) comes from the thermistor datasheet. Common values range from 3000–4500. For motor winding protection, set a maximum safe temperature threshold (typically 80–120°C depending on winding class) and cut motor power if exceeded.

Worked Example 3: Battery Voltage Monitor

Accurate battery voltage monitoring lets a robot report state of charge, prevent deep discharge damage, and trigger return-to-charger behavior. The voltage divider scales the battery voltage into the ADC’s 0–5V range:

For a 3S LiPo battery (9.0–12.6V range):

Battery+ ──[R1: 47kΩ]──┬──── Arduino A2
                        │
                     [R2: 22kΩ]
                        │
                       GND

Divider ratio: R2 / (R1 + R2) = 22 / (47 + 22) = 22/69 = 0.319

V_adc at 12.6V = 12.6 × 0.319 = 4.02V → ADC = 822 ✓ (within 0–5V)
V_adc at 9.0V  =  9.0 × 0.319 = 2.87V → ADC = 587 ✓
// Battery voltage monitor with state-of-charge estimation

const int BATT_PIN = A2;
const float R1 = 47000.0;
const float R2 = 22000.0;
const float DIVIDER_RATIO = R2 / (R1 + R2);
const float ADC_REF = 5.0;

// 3S LiPo voltage → approximate state of charge (simplified table)
// Actual SoC is non-linear and load-dependent; this is a rough guide
const float CELL_VOLTAGES[] = {4.20, 4.10, 4.00, 3.90, 3.80, 3.70, 3.60};
const float SOC_PERCENTS[]  = {100,   90,   80,   60,   40,   20,    5  };
const int   TABLE_LEN = 7;

float readBatteryVoltage() {
  // Average 10 readings for stable measurement
  long sum = 0;
  for (int i = 0; i < 10; i++) {
    sum += analogRead(BATT_PIN);
    delay(2);
  }
  float adcAvg = sum / 10.0;
  float v_adc = adcAvg * (ADC_REF / 1023.0);
  return v_adc / DIVIDER_RATIO;  // Recover battery voltage from divider
}

float estimateSoC(float battVoltage) {
  float perCellV = battVoltage / 3.0;  // 3S = 3 cells

  if (perCellV >= CELL_VOLTAGES[0]) return SOC_PERCENTS[0];
  if (perCellV <= CELL_VOLTAGES[TABLE_LEN-1]) return SOC_PERCENTS[TABLE_LEN-1];

  // Linear interpolation between table entries
  for (int i = 0; i < TABLE_LEN - 1; i++) {
    if (perCellV <= CELL_VOLTAGES[i] && perCellV >= CELL_VOLTAGES[i+1]) {
      return SOC_PERCENTS[i] + (SOC_PERCENTS[i+1] - SOC_PERCENTS[i]) *
             (perCellV - CELL_VOLTAGES[i]) / (CELL_VOLTAGES[i+1] - CELL_VOLTAGES[i]);
    }
  }
  return 0;
}

void loop() {
  float batt = readBatteryVoltage();
  float soc  = estimateSoC(batt);

  Serial.print(F("Battery: "));
  Serial.print(batt, 2);
  Serial.print(F("V | SoC: "));
  Serial.print(soc, 0);
  Serial.println(F("%"));

  if (batt < 9.9) {  // Below 3.3V/cell — critical for 3S LiPo
    Serial.println(F("WARNING: Battery critically low! Return to charge."));
  }

  delay(5000);  // Check every 5 seconds
}

These three worked examples cover the most common pattern in analog robotics sensing: read the ADC, apply the sensor’s mathematical model (linear, inverse, Steinhart-Hart), filter for noise if needed, and compare against thresholds or calibration tables to produce meaningful outputs.

Hot this week

Input/Output Pins: Your Robot’s Connection to the World

Learn how microcontroller I/O pins work in robotics—digital vs analog pins, INPUT/OUTPUT modes, PWM, current limits, protection, and practical wiring for sensors and actuators.

Memory in Robotics: RAM, Flash, and EEPROM Explained

Understand RAM, Flash, and EEPROM memory in robotics—learn what each type stores, how much is available, how to avoid running out, and practical optimization techniques.

Understanding Clock Speed and Processing Power in Robot Brains

Learn how clock speed and processing power affect robot performance—understand MHz, instruction cycles, benchmarking, and matching processor specs to your robot's real needs.

C++ Performance Profiling and Optimization Techniques

Master C++ performance optimization. Learn how to profile code, eliminate bottlenecks, leverage CPU caches, use Google Benchmark, and apply modern C++ techniques for maximum speed.

Implementing Design Patterns in Modern C++: A Complete Guide

Discover how modern C++ (C++11/14/17/20) revolutionizes classic GoF design patterns. Learn to write safer, cleaner, and more efficient code using smart pointers, lambdas, concepts, and std::variant.

Topics

Input/Output Pins: Your Robot’s Connection to the World

Learn how microcontroller I/O pins work in robotics—digital vs analog pins, INPUT/OUTPUT modes, PWM, current limits, protection, and practical wiring for sensors and actuators.

Memory in Robotics: RAM, Flash, and EEPROM Explained

Understand RAM, Flash, and EEPROM memory in robotics—learn what each type stores, how much is available, how to avoid running out, and practical optimization techniques.

Understanding Clock Speed and Processing Power in Robot Brains

Learn how clock speed and processing power affect robot performance—understand MHz, instruction cycles, benchmarking, and matching processor specs to your robot's real needs.

C++ Performance Profiling and Optimization Techniques

Master C++ performance optimization. Learn how to profile code, eliminate bottlenecks, leverage CPU caches, use Google Benchmark, and apply modern C++ techniques for maximum speed.

Implementing Design Patterns in Modern C++: A Complete Guide

Discover how modern C++ (C++11/14/17/20) revolutionizes classic GoF design patterns. Learn to write safer, cleaner, and more efficient code using smart pointers, lambdas, concepts, and std::variant.

SIMD Programming in C++: A Comprehensive Guide to Vectorization

SIMD (Single Instruction, Multiple Data) programming in C++ is...

Writing Cache-Friendly C++ Code

Learn to write cache-friendly C++ code — understand CPU caches, cache lines, spatial and temporal locality, data-oriented design, struct layout, false sharing, and how to measure cache performance.

Understanding Undefined Behavior in C++

Master C++ undefined behavior — learn what it is, the most dangerous forms (signed overflow, null dereference, data races, UB in templates), how compilers exploit it, and how to detect and eliminate it.

Related Articles

Popular Categories