Reading Analog Sensors: Converting Voltage to Meaningful Data

Learn how to read analog sensors in robotics—convert raw ADC values to real-world units, apply calibration, filtering, and use data in control loops with Arduino code.

Reading Analog Sensors: Converting Voltage to Meaningful Data

Reading analog sensors in robotics means capturing a continuously varying electrical voltage produced by the sensor and converting it into a digital number using an Analog-to-Digital Converter (ADC), then applying mathematical transformations—scaling, offsetting, and calibration—to translate that raw number into a meaningful physical quantity such as temperature in degrees, distance in centimeters, or force in grams. This process of converting raw ADC counts into real-world units is the foundation of all sensor-driven robot behavior, from obstacle detection and line following to precise PID feedback control.

Introduction

The robot’s sensor returns a value of 647. What does that mean? Is the obstacle 10 centimeters away or 30? Is the temperature 20°C or 45°C? Is the battery voltage safe or dangerously low? The number 647 by itself is meaningless—it’s just a count from an analog-to-digital converter. Turning that raw count into something a robot can actually use—a distance, a temperature, a voltage—requires understanding the entire chain from physical phenomenon to digital number.

This is the challenge of analog sensor reading, and it’s more nuanced than it first appears. Most tutorials show you how to call analogRead() and get a number. Far fewer explain what that number actually represents, why it might drift or vary unexpectedly, how to convert it accurately into physical units, and how to clean up noise so the values your control algorithms receive are stable and trustworthy.

These details matter enormously in practice. A poorly calibrated distance sensor feeding incorrect values into a PID controller will produce incorrect corrections. A noisy temperature reading causing your motor controller to see phantom overtemperature events will trigger unnecessary shutdowns. An uncalibrated force sensor causing your gripper to apply three times the intended force will crush every object it touches. Getting analog sensing right is not an optional polish—it’s the bedrock upon which reliable robot behavior is built.

The Physical Reality: What Analog Sensors Actually Produce

The starting point for understanding analog sensing is understanding what sensors actually do physically. Almost all analog sensors work by converting a physical phenomenon—light intensity, temperature, distance, acceleration, force, humidity, chemical concentration—into an electrical voltage. This voltage is the sensor’s output, and it varies continuously as the physical quantity changes.

The Sensor Transfer Function

Every analog sensor has a transfer function—the mathematical relationship between the physical input quantity and the electrical output voltage. Transfer functions come in several forms:

Linear transfer functions are the simplest and most convenient. The output voltage changes proportionally with the physical input. A temperature sensor with a linear transfer function might output 10 mV per degree Celsius. At 25°C it outputs 250 mV. At 100°C it outputs 1000 mV. Linear sensors are easy to calibrate and easy to convert—you just apply a scaling factor and an offset.

Nonlinear transfer functions are more common in practice. The output voltage changes with the physical input, but not proportionally. Sharp IR distance sensors, for example, produce a voltage that follows an inverse relationship with distance—doubling the distance more than halves the voltage, with a complicated curve in between. Thermistors produce a voltage that follows an exponential curve with temperature. Converting these sensor outputs to physical units requires nonlinear math or lookup tables.

Ratiometric sensors produce an output that is a ratio of their supply voltage. A sensor that outputs “half of Vcc” at its midpoint will output exactly 2.5V when powered by 5V, or exactly 1.65V when powered by 3.3V. This matters because if your supply voltage is unstable, the sensor output is also unstable—even if nothing physical has changed. Ratiometric sensors should always be powered by a stable, regulated supply.

Understanding your specific sensor’s transfer function is the essential first step in building an accurate conversion from raw ADC counts to meaningful physical units.

The Analog-to-Digital Converter (ADC): From Voltage to Number

The microcontroller cannot directly process a continuously varying voltage—it needs discrete digital numbers. The Analog-to-Digital Converter (ADC) is the hardware component that performs this conversion.

How the ADC Works

The ADC measures the input voltage at a specific instant in time and converts it to the nearest integer value from a finite set of possible output values. This process involves two fundamental parameters:

Resolution is the number of distinct values the ADC can produce. The Arduino Uno’s ADC has 10-bit resolution, meaning it can produce 2¹⁰ = 1024 distinct values (0 through 1023). The Arduino Due and many 32-bit microcontrollers have 12-bit ADCs producing 4096 values (0 through 4095). Higher resolution means finer discrimination between similar voltage levels.

Reference voltage is the voltage that maps to the maximum ADC output. On the Arduino Uno, the default reference is the supply voltage (typically 5V). This means:

  • 0V → ADC output 0
  • 5V → ADC output 1023
  • 2.5V → ADC output 511 (approximately)

The relationship between input voltage and ADC count is:

Plaintext
ADC Count = (Input Voltage / Reference Voltage) × (2^Resolution - 1)

Solving for input voltage:

Plaintext
Input Voltage = (ADC Count / (2^Resolution - 1)) × Reference Voltage

For a 10-bit Arduino Uno with 5V reference:

Plaintext
Input Voltage = (ADC Count / 1023) × 5.0

Quantization Error

No matter how precise the input voltage, the ADC rounds it to the nearest representable value. This rounding error is called quantization error, and it’s inherent to all ADC systems. For a 10-bit ADC with 5V reference, the voltage step between adjacent counts is:

Plaintext
Voltage per count = 5.0V / 1023 = 4.89 mV

Any voltage change smaller than 4.89 mV will be invisible to the ADC—it will round to the same count as before. This fundamental limit means that very small physical changes cannot be detected regardless of how sensitive the sensor itself is. For applications needing high precision, using a higher-resolution ADC (12-bit, 16-bit, or even 24-bit for precision scales and analytical instruments) is essential.

The ADC Sampling Process in Arduino

The Arduino’s analogRead() function performs the entire ADC process and returns an integer from 0 to 1023:

C++
int rawValue = analogRead(A0);

What’s happening internally: the ADC charges a sample-and-hold capacitor to the input voltage, then performs a successive-approximation conversion to find the closest binary representation. This process takes about 100 microseconds on the Arduino Uno (or ~13 ADC clock cycles). If you need faster readings, you can configure the ADC prescaler—but faster conversions typically reduce accuracy.

C++
// Faster ADC on Arduino by changing prescaler
// Default prescaler is 128 (100 µs per conversion)
// Prescaler 16 gives ~12 µs per conversion but reduced accuracy

void setup() {
  // Set ADC prescaler to 16 for faster (less accurate) readings
  ADCSRA &= ~(bit(ADPS2) | bit(ADPS1) | bit(ADPS0));  // Clear bits
  ADCSRA |= bit(ADPS2);  // Set prescaler to 16
}

Converting Raw ADC Values to Physical Units

With the raw ADC count in hand, the next step is converting it to a meaningful physical unit. The exact conversion depends entirely on the sensor, but the process follows a consistent pattern.

Step 1: Convert Count to Voltage

Before applying any sensor-specific conversion, it’s often useful to calculate the actual measured voltage:

C++
float adcToVoltage(int adcCount) {
  const float REFERENCE_VOLTAGE = 5.0;   // Volts
  const int ADC_RESOLUTION = 1023;       // 10-bit: 2^10 - 1
  return (adcCount / (float)ADC_RESOLUTION) * REFERENCE_VOLTAGE;
}

For 3.3V systems (like the Arduino Due or most ARM-based boards), change REFERENCE_VOLTAGE to 3.3.

Step 2: Apply the Sensor Transfer Function

Linear sensor example — LM35 temperature sensor:

The LM35 outputs exactly 10 mV per degree Celsius, with 0V at 0°C. The conversion is straightforward:

C++
float lm35ToTemperature(int adcCount) {
  float voltage = adcToVoltage(adcCount);
  float temperatureCelsius = voltage * 100.0;  // 10 mV/°C = 100 °C/V
  return temperatureCelsius;
}

At 25°C, the LM35 outputs 250 mV → ADC count ≈ 51 (on 5V system) → voltage 0.249V → 24.9°C. The math checks out.

Potentiometer — angle measurement:

A potentiometer used as an angle sensor produces a linear voltage from 0V (one end of travel) to the supply voltage (other end of travel). If the pot has 270° of rotation:

C++
float potentiometerToAngle(int adcCount) {
  float voltage = adcToVoltage(adcCount);
  float angle = (voltage / 5.0) * 270.0;  // Scale to 270° range
  return angle;
}

Sharp IR distance sensor — nonlinear conversion:

The Sharp GP2Y0A21YK distance sensor produces a nonlinear output voltage that follows an inverse power law with distance. The datasheet provides a voltage-vs-distance curve. For rough accuracy, a simple inverse relationship works:

C++
float sharpIRToDistance(int adcCount) {
  if (adcCount == 0) return 999.0;  // Avoid division by zero
  
  float voltage = adcToVoltage(adcCount);
  
  // Empirical formula derived from Sharp GP2Y0A21YK datasheet curve
  // Valid for approximately 10cm to 80cm
  float distanceCm = 27.86 / (voltage - 0.42);
  
  // Clamp to sensor's valid range
  distanceCm = constrain(distanceCm, 10.0, 80.0);
  return distanceCm;
}

Note that this formula was derived empirically by fitting a curve to the datasheet data. For precise applications, you would measure your specific sensor against a ruler at multiple distances and fit the formula to your own data—different individual sensors can vary by several percent from the typical curve.

Thermistor — exponential conversion using Steinhart-Hart equation:

Thermistors are temperature-sensitive resistors that follow an exponential relationship. They’re cheap and sensitive but require more complex conversion math. The standard approach uses the Steinhart-Hart equation:

C++
float thermistorToTemperature(int adcCount) {
  const float SERIES_RESISTOR = 10000.0;   // 10k ohm series resistor
  const float NOMINAL_RESISTANCE = 10000.0; // Resistance at 25°C
  const float NOMINAL_TEMPERATURE = 25.0;   // Celsius
  const float B_COEFFICIENT = 3950.0;       // Beta value from datasheet
  const float KELVIN_OFFSET = 273.15;

  // Calculate thermistor resistance from voltage divider
  float voltage = adcToVoltage(adcCount);
  float resistance = SERIES_RESISTOR * voltage / (5.0 - voltage);

  // Steinhart-Hart approximation
  float steinhart = log(resistance / NOMINAL_RESISTANCE) / B_COEFFICIENT;
  steinhart += 1.0 / (NOMINAL_TEMPERATURE + KELVIN_OFFSET);
  float temperatureKelvin = 1.0 / steinhart;
  
  return temperatureKelvin - KELVIN_OFFSET;  // Convert to Celsius
}

The Steinhart-Hart equation is accurate to within 0.5°C over a wide temperature range—far better than a simple linear approximation of thermistor behavior.

Sensor Calibration: Correcting for Real-World Imperfections

Even with a perfect conversion formula from the datasheet, real sensors don’t perfectly match their theoretical behavior. Manufacturing variations, aging, temperature effects, and circuit differences all introduce errors. Calibration corrects for these real-world deviations.

Two-Point Calibration (Gain and Offset Correction)

The most common calibration technique uses two known reference points to determine a sensor’s actual gain (slope) and offset. This corrects for both multiplicative errors (the sensor reads 5% high everywhere) and additive errors (the sensor always reads 3 units too high).

C++
// Two-point calibration
// Measure raw ADC values at two known physical reference points
// then calculate the linear correction

struct Calibration {
  float rawLow;    // Raw ADC reading at low reference
  float rawHigh;   // Raw ADC reading at high reference
  float physLow;   // Known physical value at low reference
  float physHigh;  // Known physical value at high reference
};

float applyCalibration(float rawValue, Calibration cal) {
  // Linear interpolation between calibration points
  float slope = (cal.physHigh - cal.physLow) / (cal.rawHigh - cal.rawLow);
  float offset = cal.physLow - (slope * cal.rawLow);
  return slope * rawValue + offset;
}

// Example usage for a temperature sensor
// Calibration points: 0°C (ice water) and 100°C (boiling water)
// Raw readings taken at those temperatures
Calibration tempCal = {103, 872, 0.0, 100.0};

void loop() {
  int rawADC = analogRead(A0);
  float calibratedTemp = applyCalibration(rawADC, tempCal);
  Serial.print("Temperature: ");
  Serial.print(calibratedTemp);
  Serial.println(" °C");
}

How to perform a two-point calibration in practice:

  1. Expose the sensor to a precisely known low reference value. Record the raw ADC reading multiple times and average them.
  2. Expose the sensor to a precisely known high reference value. Record and average the raw ADC reading.
  3. Store both pairs of (raw, known) values as your calibration data.
  4. Apply the linear correction to all future readings.

For a distance sensor, you might measure at exactly 10 cm and exactly 50 cm from a flat wall with a steel ruler. For a temperature sensor, ice water (0°C at sea level) and boiling water (100°C at sea level, adjusting for altitude) are convenient reference points. For a voltage monitor, use a precision multimeter to measure the actual voltage while recording the ADC count.

Multi-Point Calibration for Nonlinear Sensors

For sensors with significant nonlinearity, two-point calibration doesn’t capture the curve shape. Multi-point calibration takes measurements at many reference points and stores them in a lookup table or fits a polynomial to the data:

C++
// Multi-point calibration using linear interpolation between table entries

const int CAL_POINTS = 8;
float calRaw[CAL_POINTS]  = {100, 200, 320, 450, 580, 700, 820, 940};
float calPhys[CAL_POINTS] = {80,  60,  45,  35,  27,  20,  15,  10};  // Distance in cm

float multiPointCalibration(float rawValue) {
  // Find which segment we're in
  for (int i = 0; i < CAL_POINTS - 1; i++) {
    if (rawValue >= calRaw[i] && rawValue <= calRaw[i + 1]) {
      // Linear interpolation within this segment
      float t = (rawValue - calRaw[i]) / (calRaw[i + 1] - calRaw[i]);
      return calPhys[i] + t * (calPhys[i + 1] - calPhys[i]);
    }
  }
  // Out of range: clamp to nearest end
  if (rawValue < calRaw[0]) return calPhys[0];
  return calPhys[CAL_POINTS - 1];
}

This approach is particularly useful for Sharp IR distance sensors, pH sensors, and any sensor with a known curve that doesn’t fit a simple formula neatly.

Dealing with Noise: Filtering Analog Sensor Readings

Real analog sensor readings are never perfectly stable. Even with nothing physically changing, the ADC output fluctuates by a few counts due to electrical noise—interference from power supplies, nearby motor switching, radio frequency pickup, and the fundamental thermal noise in the sensor and circuit. This noise causes problems in control systems: a PID derivative term amplifies rapid fluctuations into large corrective spikes, threshold-based decisions trigger spuriously, and logged data looks jagged and unprofessional.

Filtering reduces noise by averaging or smoothing the signal. Several filtering strategies are commonly used in robotics.

Simple Moving Average Filter

The simplest filter averages the last N readings. It’s effective at reducing random noise and very easy to implement:

C++
const int FILTER_SIZE = 10;
int filterBuffer[FILTER_SIZE];
int filterIndex = 0;
long filterSum = 0;
bool filterFull = false;

float movingAverage(int newReading) {
  // Subtract the old value being replaced
  filterSum -= filterBuffer[filterIndex];
  
  // Add the new value
  filterBuffer[filterIndex] = newReading;
  filterSum += newReading;
  
  // Advance the index
  filterIndex = (filterIndex + 1) % FILTER_SIZE;
  if (filterIndex == 0) filterFull = true;
  
  // Average over however many samples we have
  int count = filterFull ? FILTER_SIZE : filterIndex;
  return filterSum / (float)count;
}

void loop() {
  int raw = analogRead(A0);
  float filtered = movingAverage(raw);
  Serial.println(filtered);
}

Trade-off: Larger N reduces noise more but introduces more lag—the output responds more slowly to genuine rapid changes. For a static measurement like reading a potentiometer set position, large N is fine. For a rapidly-moving variable like motor speed, smaller N or a faster filter is needed.

Exponential Moving Average (EMA) Filter

The EMA filter weights recent measurements more heavily than older ones. It’s computationally cheaper than the moving average (no buffer needed) and has a different noise-vs-lag trade-off:

C++
float emaValue = 0;
bool emaInitialized = false;
const float ALPHA = 0.1;  // Smoothing factor: 0 = max smoothing, 1 = no smoothing

float exponentialMovingAverage(float newReading) {
  if (!emaInitialized) {
    emaValue = newReading;  // Initialize with first reading
    emaInitialized = true;
    return emaValue;
  }
  
  // EMA formula: output = alpha * new_reading + (1 - alpha) * previous_output
  emaValue = ALPHA * newReading + (1.0 - ALPHA) * emaValue;
  return emaValue;
}

The ALPHA parameter controls the trade-off: 0.1 gives heavy smoothing (slow response), 0.5 gives moderate smoothing, 0.9 follows the signal closely with minimal smoothing. Choose alpha based on the ratio of noise frequency to signal frequency—if noise changes much faster than the real signal, a small alpha works well.

Median Filter

For sensors that occasionally produce large outlier readings (spikes caused by momentary electrical interference or optical sensor glitches), a median filter is often more effective than averaging:

C++
const int MEDIAN_SIZE = 7;  // Must be odd
int medianBuffer[MEDIAN_SIZE];
int medianIndex = 0;

int compareInts(const void* a, const void* b) {
  return (*(int*)a - *(int*)b);
}

float medianFilter(int newReading) {
  medianBuffer[medianIndex] = newReading;
  medianIndex = (medianIndex + 1) % MEDIAN_SIZE;
  
  // Sort a copy of the buffer
  int sortBuffer[MEDIAN_SIZE];
  memcpy(sortBuffer, medianBuffer, sizeof(medianBuffer));
  qsort(sortBuffer, MEDIAN_SIZE, sizeof(int), compareInts);
  
  // Return the middle value
  return sortBuffer[MEDIAN_SIZE / 2];
}

The median filter completely ignores outliers—a single spike to 1023 in an otherwise normal 400-500 reading sequence won’t affect the filtered output at all. This makes it excellent for distance sensors and proximity sensors that occasionally give spurious zero or maximum readings.

Combining Filters

For best results, you can combine filters. A common approach for distance sensors is to apply a median filter first (to remove spikes) followed by an EMA filter (to smooth remaining noise):

C++
float readAndFilterDistanceSensor() {
  int raw = analogRead(DISTANCE_SENSOR_PIN);
  int deSpked = medianFilter(raw);
  float smoothed = exponentialMovingAverage(deSpked);
  float distanceCm = sharpIRToDistance((int)smoothed);
  return distanceCm;
}

The Complete Analog Sensor Reading Pipeline

Putting it all together, here is a complete, production-quality analog sensor reading pipeline for a Sharp IR distance sensor used in a robot:

C++
// Complete analog sensor pipeline: raw ADC → filtered → calibrated → physical units

// ---- Hardware Configuration ----
const int SENSOR_PIN = A0;
const float SUPPLY_VOLTAGE = 5.0;
const int ADC_MAX = 1023;

// ---- Calibration Data ----
// Measured at 10 cm and 60 cm using a steel ruler
const float CAL_RAW_LOW = 505.0;    // ADC count at 10 cm
const float CAL_RAW_HIGH = 138.0;   // ADC count at 60 cm
const float CAL_PHYS_LOW = 10.0;    // cm
const float CAL_PHYS_HIGH = 60.0;   // cm

// ---- Filter State ----
float emaState = 0;
bool emaInit = false;
const float EMA_ALPHA = 0.15;

// ---- Step 1: Read raw ADC ----
int readRaw() {
  return analogRead(SENSOR_PIN);
}

// ---- Step 2: Apply EMA filter ----
float applyEMA(float newValue) {
  if (!emaInit) { emaState = newValue; emaInit = true; }
  emaState = EMA_ALPHA * newValue + (1.0 - EMA_ALPHA) * emaState;
  return emaState;
}

// ---- Step 3: Convert raw count to voltage ----
float toVoltage(float adcCount) {
  return (adcCount / ADC_MAX) * SUPPLY_VOLTAGE;
}

// ---- Step 4: Apply sensor transfer function (nonlinear IR sensor) ----
float transferFunction(float voltage) {
  if (voltage < 0.3) return 100.0;  // Too close or no target
  return 27.86 / (voltage - 0.42);
}

// ---- Step 5: Apply two-point calibration correction ----
float calibrate(float rawDistance) {
  float slope = (CAL_PHYS_HIGH - CAL_PHYS_LOW) / (CAL_RAW_HIGH - CAL_RAW_LOW);
  float offset = CAL_PHYS_LOW - slope * CAL_RAW_LOW;
  
  // Note: calibration is applied to the ADC count domain, 
  // then convert. This is a simplified inline approach.
  return constrain(rawDistance, 5.0, 100.0);
}

// ---- Master reading function ----
float readDistanceCm() {
  int rawADC = readRaw();
  float filtered = applyEMA(rawADC);
  float voltage = toVoltage(filtered);
  float rawDistance = transferFunction(voltage);
  float finalDistance = calibrate(rawDistance);
  return finalDistance;
}

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

void loop() {
  float distance = readDistanceCm();
  Serial.print("Distance: ");
  Serial.print(distance, 1);
  Serial.println(" cm");
  delay(50);
}

This pipeline handles every step explicitly: raw reading, noise filtering, voltage conversion, transfer function application, and calibration correction. Each step is a separate function, making it easy to debug (you can print intermediate values at any stage), swap out (replace the transfer function with one for a different sensor), and reuse.

Oversampling: Increasing Effective Resolution

One often-overlooked technique for improving analog measurement quality is oversampling—taking multiple ADC readings and averaging them to achieve better effective resolution than the hardware ADC alone provides.

Oversampling works because electrical noise is approximately random. When you average N readings, the random noise averages down by a factor of √N, while the true signal averages up consistently. For every 4× oversampling, you gain approximately 1 bit of effective resolution:

C++
// Oversampling: read N times and average for better effective resolution
// 16x oversampling → ~12 bits effective resolution on a 10-bit ADC

float oversampledRead(int pin, int numSamples) {
  long sum = 0;
  for (int i = 0; i < numSamples; i++) {
    sum += analogRead(pin);
  }
  return sum / (float)numSamples;
}

// For maximum benefit, samples should be independent (noise uncorrelated)
// A small delay between samples can help if using fast ADC
float highResRead(int pin) {
  long sum = 0;
  const int SAMPLES = 16;  // 4x oversampling = ~1 extra effective bit
  for (int i = 0; i < SAMPLES; i++) {
    sum += analogRead(pin);
    delayMicroseconds(50);  // Allow noise to decorrelate
  }
  return sum / (float)SAMPLES;
}

With 16 samples, a 10-bit ADC achieves approximately 12-bit effective resolution (0 to 4095). With 64 samples, approximately 13-bit effective resolution. The trade-off is measurement time—64 samples at 100 µs each takes 6.4 ms, which is acceptable for slowly-varying signals like temperature but too slow for high-speed control loops.

Common Analog Sensors in Robotics: Reading Patterns

Different sensor categories follow different reading patterns. Here’s a practical reference for the most common analog sensors encountered in robotics.

Photoresistor (LDR) — Light Level Sensing

C++
// Photoresistor in voltage divider with 10k series resistor
// High light = low resistance = higher voltage = higher ADC count

float readLightLevel() {
  int raw = analogRead(A0);
  // Convert to approximate lux (empirical formula, varies by LDR type)
  float voltage = (raw / 1023.0) * 5.0;
  float resistance = (5.0 - voltage) / voltage * 10000.0;  // Series resistor 10k
  // Log relationship between LDR resistance and lux
  float lux = 500.0 / resistance * 1000.0;  // Rough approximation
  return lux;
}

// Simpler: just use the raw value comparatively (line-following sensors)
bool isOverBlackLine() {
  return analogRead(LINE_SENSOR_PIN) < 300;  // Threshold found by testing
}

FSR (Force Sensitive Resistor) — Contact Force

C++
// FSR in voltage divider with 10k series resistor
// No force = infinite resistance = 0V = ADC 0
// More force = lower resistance = higher voltage = higher ADC count

float readForceGrams(int adcCount) {
  if (adcCount < 10) return 0.0;  // No contact
  
  float voltage = (adcCount / 1023.0) * 5.0;
  float resistance = (5.0 - voltage) / voltage * 10000.0;  // 10k series
  
  // FSR 402 empirical conversion (varies significantly by sensor)
  float conductance = 1.0 / resistance;  // Microsiemens
  float force;
  if (conductance <= 0.0015) {
    force = (conductance / 0.0015) * 10.0;
  } else {
    force = 10.0 + ((conductance - 0.0015) / (0.015 - 0.0015)) * (1000.0 - 10.0);
  }
  return force;
}

Soil Moisture Sensor — Resistive Type

C++
// Soil moisture sensor: dry = high resistance = low voltage = low ADC
// Wet = low resistance = higher voltage = higher ADC

float readMoisturePercent(int adcCount) {
  // Map raw ADC to 0-100% moisture
  // Calibrate: measure in completely dry soil and fully saturated soil
  const int DRY_VALUE = 850;    // ADC reading in dry air
  const int WET_VALUE = 350;    // ADC reading in fully submerged water
  
  int clamped = constrain(adcCount, WET_VALUE, DRY_VALUE);
  float percent = map(clamped, DRY_VALUE, WET_VALUE, 0, 100);
  return percent;
}

Battery Voltage Monitor

A voltage divider is used to scale the battery voltage down to within the ADC’s input range:

C++
// Voltage divider for battery monitoring
// R1 = 10k (top), R2 = 4.7k (bottom), measures up to ~16V
// Vout = Vbat × R2 / (R1 + R2) = Vbat × 0.32

float readBatteryVoltage() {
  const float R1 = 10000.0;
  const float R2 = 4700.0;
  const float DIVIDER_RATIO = (R1 + R2) / R2;  // Inverse: to recover original
  
  int raw = analogRead(BATT_SENSE_PIN);
  float measuredVoltage = (raw / 1023.0) * 5.0;   // ADC pin voltage
  float batteryVoltage = measuredVoltage * DIVIDER_RATIO;
  
  return batteryVoltage;
}

bool isBatteryLow() {
  float voltage = readBatteryVoltage();
  return voltage < 10.5;  // For 3-cell LiPo: 3.5V/cell minimum
}

Practical Reference: Common Analog Sensors Comparison

SensorOutput TypeTypical ADC RangeConversion ComplexityCommon Use in Robotics
LM35 TemperatureLinear voltage0–512 counts (0–50°C)Simple (multiply by 0.488°C/count)Motor temp monitoring, environment sensing
PotentiometerLinear ratiometric0–1023 full sweepSimple (map to degrees/mm)Joint angle feedback, user input
Sharp IR DistanceNonlinear inverse100–700 counts (10–80cm)Moderate (empirical curve)Obstacle detection, proximity
Photoresistor (LDR)Nonlinear voltage dividerVaries by ambient lightModerate (resistance formula)Line following, ambient light
FSR Force SensorNonlinear resistive0–900 countsComplex (empirical formula)Gripper force, contact detection
Thermistor (NTC)Exponential resistiveVaries with temp rangeComplex (Steinhart-Hart)High-precision temperature
Soil MoistureResistive300–900 countsSimple (map function)Agricultural robots
Hall Effect (analog)Linear voltage0–1023 full rangeModerate (bipolar mapping)Magnetic field, linear position

Troubleshooting Analog Sensor Problems

Even with correct code, analog sensor systems can misbehave in frustrating ways. Understanding the common failure modes helps you diagnose and fix problems quickly.

Problem: Readings Are Wildly Unstable

Likely causes: Power supply noise, missing decoupling capacitor, floating ground, long unshielded wires.

Fix: Add a 100nF ceramic capacitor between the sensor’s VCC and GND pins, as close to the sensor as possible. Ensure all grounds are connected. Use shorter wires or twisted pairs. Power the sensor from a regulated linear regulator rather than directly from a switching regulator or motor power supply.

Problem: Readings Drift Over Time Even When Nothing Changes

Likely causes: Temperature effects on sensor or ADC reference, ratiometric sensor with unstable supply, ground potential shifting when motors run.

Fix: Use a stable reference voltage (Arduino has a 1.1V internal reference: analogReference(INTERNAL)). Separate sensor ground from motor ground using a star ground topology. Consider adding a dedicated LDO regulator for sensitive sensors.

C++
void setup() {
  analogReference(INTERNAL);  // Use Arduino's stable 1.1V internal reference
  // Note: sensors must output 0-1.1V range when using internal reference
  // Adjust voltage dividers accordingly
}

Problem: Readings Are Correct But Noisy

Likely causes: Insufficient filtering, ADC noise floor, nearby switching noise.

Fix: Increase filter size or decrease alpha in EMA filter. Apply oversampling. Move sensor away from motor drivers and switching power supplies. Add a 100nF bypass capacitor at the ADC pin to GND.

Problem: Sensor Reads Maximum or Minimum Constantly

Likely causes: Wrong voltage range (sensor outputs higher than ADC can handle), disconnected sensor (floating input reads randomly or rail), reversed polarity, wrong analog pin.

Fix: Verify sensor output voltage with a multimeter. Add a voltage divider if the sensor outputs more than the ADC reference voltage. Check all wiring connections. Add a 10k pulldown resistor to GND on the ADC pin to prevent floating input behavior.

Problem: Calibration Correct at Time of Calibration but Drifts Later

Likely causes: Temperature coefficient of sensor not accounted for, aging of sensor element, supply voltage changes.

Fix: Re-calibrate regularly for precision applications. Store calibration in EEPROM so it survives power cycles. Implement a temperature compensation term if operating across wide temperature ranges.

Integrating Sensor Readings Into Robot Control

The ultimate purpose of reading analog sensors is feeding meaningful data into robot decision-making and control algorithms. Here’s a practical integration example—a robot that uses a distance sensor with full pipeline (reading, filtering, conversion, calibration) feeding into a PID controller for obstacle-distance holding:

C++
// Robot that maintains a fixed distance from a wall
// Uses IR distance sensor with full pipeline + PID control

#include <Arduino.h>

// PID parameters (from previous article's PIDController class)
float Kp = 3.0, Ki = 0.2, Kd = 0.5;
float targetDistance = 20.0;  // Hold 20 cm from wall
float integralSum = 0, lastMeasurement = 0, lastTime = 0;

// EMA filter state
float emaState = 0;
bool emaInit = false;
const float EMA_ALPHA = 0.2;

float readFilteredDistance() {
  int raw = analogRead(A0);
  if (!emaInit) { emaState = raw; emaInit = true; }
  emaState = EMA_ALPHA * raw + (1.0 - EMA_ALPHA) * emaState;
  
  float voltage = (emaState / 1023.0) * 5.0;
  if (voltage < 0.3) return 100.0;
  return constrain(27.86 / (voltage - 0.42), 5.0, 100.0);
}

float computePID(float measurement) {
  float currentTime = millis() / 1000.0;
  float dt = currentTime - lastTime;
  if (dt <= 0) dt = 0.01;
  lastTime = currentTime;
  
  float error = targetDistance - measurement;
  integralSum = constrain(integralSum + error * dt, -50, 50);
  float dMeas = (measurement - lastMeasurement) / dt;
  lastMeasurement = measurement;
  
  return Kp * error + Ki * integralSum - Kd * dMeas;
}

void setMotors(float leftSpeed, float rightSpeed) {
  // Positive = forward, negative = backward
  analogWrite(LEFT_PWM, constrain(abs(leftSpeed), 0, 255));
  analogWrite(RIGHT_PWM, constrain(abs(rightSpeed), 0, 255));
  digitalWrite(LEFT_DIR, leftSpeed > 0 ? HIGH : LOW);
  digitalWrite(RIGHT_DIR, rightSpeed > 0 ? HIGH : LOW);
}

void loop() {
  float distance = readFilteredDistance();
  float output = computePID(distance);
  
  // Positive output = too far → drive forward
  // Negative output = too close → drive backward
  setMotors(output, output);
  
  Serial.print("Distance: "); Serial.print(distance);
  Serial.print(" cm | Error: "); Serial.print(targetDistance - distance);
  Serial.print(" cm | Output: "); Serial.println(output);
  
  delay(20);  // 50Hz control loop
}

This complete system demonstrates the full stack in practice: the sensor pipeline produces a clean, calibrated distance reading in centimeters, and the PID controller uses that reading as its process variable, computing corrective motor commands to hold the target distance. The quality of the sensor reading directly determines the quality of the control—which is why every step of the pipeline matters.

Summary

Reading analog sensors accurately involves a complete pipeline from physical phenomenon to meaningful number. The ADC converts voltage to a digital count. Converting that count to a physical unit requires the sensor’s transfer function—which may be linear, nonlinear, or exponential depending on the sensor technology. Calibration corrects for individual sensor variations and systematic errors. Filtering removes noise that would otherwise corrupt control algorithms, trigger false events, and produce jagged data.

Each stage of the pipeline matters. A well-read sensor produces clean, accurate, stable data that makes everything downstream—control loops, decision logic, logging, user interfaces—more reliable and accurate. A poorly-read sensor produces garbage data that no amount of clever algorithm design can fix.

The most important practical habits for analog sensor work are: always convert to physical units (never use raw ADC counts in application logic), always calibrate with known reference values, always apply at least some filtering, and always test by printing intermediate values at each pipeline stage when debugging unexpected behavior.

The next article shifts to digital signals—a complementary but fundamentally different approach to sensing and communication in robotics, where information is conveyed by discrete voltage levels rather than continuously varying values. Understanding both analog and digital sensing gives you the complete toolkit for interfacing with the enormous variety of sensors and actuators that make up modern robotic systems.

Share:
Subscribe
Notify of
0 Comments
Inline Feedbacks
View all comments

Discover More

Introduction to Descriptive Statistics: Summarizing Data for Beginners

Learn the basics of descriptive statistics with this beginner’s guide. Discover how to summarize and…

Understanding File Extensions and How Different OS Handle Them

Learn what file extensions are, how operating systems use them to identify file types, and…

Setting Up Your Python Development Environment: Python Installation and IDEs

Learn how to set up your Python development environment, from installing Python to configuring IDEs…

Understanding Variables and Data Types in C++

Learn about variables, data types, and memory management in C++ with this in-depth guide, including…

Data Science Page is Live

Discover the Power of Data: Introducing Data Science Category!

Choosing the Right Chart Types: Bar Charts, Line Graphs, and Pie Charts

Learn how to choose the right chart type. Explore bar charts, line graphs, and pie…

Click For More
0
Would love your thoughts, please comment.x
()
x