# Calibrate Sensor Data: Counts to Real Units

> How to calibrate sensor data on the host: per-dataset transforms turn raw ADC counts into real engineering units, with data tables for shared constants.
>
> Data Processing · June 10, 2026 · by Alex Spataru · https://serial-studio.com/blog/sensor-data-transforms

A sensor almost never hands you the number you actually want. A 10-bit ADC gives you 0 to 1023, not volts. A thermistor gives you counts, not degrees. A load cell gives you millivolts, not kilograms. Somewhere between the raw reading and the dashboard, that value has to become a real, calibrated engineering quantity. The interesting question is *where* that conversion should happen.

## Where conversion belongs

You have three options, and two of them age badly. Convert in firmware, and every recalibration means editing and reflashing the device. Convert after the fact in a spreadsheet, and your live dashboard still shows raw counts while you are running the experiment. The middle ground is to convert on the host, live, in a place you can edit without touching the hardware. That is what Serial Studio's dataset transforms are for.

## Dataset transforms

Every dataset (each value you plot or display) can carry an optional `transform(value)` function, written in JavaScript or Lua. The frame parser produces raw values, each raw value is mapped to a dataset, and then, before it reaches the dashboard, the transform converts it. The value it returns replaces the raw one everywhere: widgets, plots, CSV export, MDF4 export, and the API.

Unit conversion is a one-liner:

```js
// Celsius to Fahrenheit
function transform(value) {
  return value * 9 / 5 + 32;
}
```

Calibrating a raw ADC count to volts has the same shape:

```js
// 10-bit ADC (0..1023) to volts on a 3.3 V reference
function transform(value) {
  return value / 1023 * 3.3;
}
```

Transforms are optional, so a dataset without one simply shows the parsed value unchanged. And they fail safe: if the function returns something invalid (`undefined`, `NaN`, or it throws), Serial Studio keeps the raw value rather than interrupting the stream.

## Filtering needs memory

Some conversions have to remember the previous sample. A noisy reading smooths nicely with an exponential moving average, which blends each new value with the last result. Variables declared at the top level of the transform persist between frames, so state is easy to keep:

```js
var alpha = 0.2;
var ema;

function transform(value) {
  ema = (ema === undefined) ? value : alpha * value + (1 - alpha) * ema;
  return ema;
}
```

Each dataset's top-level state is isolated, so two channels can both run an EMA without clobbering each other's variables.

## The magic-number problem, and data tables

Two things get awkward as a project grows. First, a calibration constant (a slope, an offset, a full-scale range) often applies to several channels, and copying the number into each transform means you have to edit it in several places later. Second, sometimes one transform computes a value that another needs, like a shared reference or a total across channels.

Serial Studio's answer is **data tables**: named collections of registers that any transform can read and write. A register has a name, a type, and a value, and it comes in two flavors:

- **Constant** registers are set when the project loads and are read-only at runtime. Calibration slopes, offsets, and thresholds belong here.
- **Computed** registers are ordinary memory. A transform writes one with `tableSet`, and it holds that value until something writes again. This is where running state lives: integrators, counters, filter state, latched flags.

Reading a shared constant keeps the magic number in exactly one place:

```js
function transform(value) {
  const slope  = tableGet("cal", "slope");
  const offset = tableGet("cal", "offset");
  return value * slope + offset;
}
```

Edit `cal.slope` once in the Project Editor and every channel that reads it updates together.

A computed register carries a value forward, or hands it to another dataset's transform:

```js
// running total, readable by any other transform
function transform(value) {
  const total = (tableGet("state", "total") || 0) + value;
  tableSet("state", "total", total);
  return total;
}
```

The full API is small: `tableGet` and `tableSet` for the registers, plus `datasetGetRaw` and `datasetGetFinal` for reading another dataset's value before or after its own transform has run.

## The mental model

It helps to keep three jobs separate. The frame parser decides *what the incoming values are*. A transform decides *what each value means in real units*. Data tables are the *shared memory* the transforms use to stay consistent and to hold state between frames. Keep those roles distinct and calibration stops being scattered across firmware, scripts, and spreadsheets.

If the idea of "one owner per value" is new, the companion post [How Serial Studio Turns Bytes Into Datasets](https://serial-studio.com/blog/frame-parsers-and-the-empty-parser) covers where parsing itself should live. The [dataset transforms](https://serial-studio.com/help/dataset-transforms) and [data tables](https://serial-studio.com/help/data-tables) references go deeper on both.
