MeterValues and Sampled Data

IoTEV ChargingOCPPEnergy
Share on LinkedIn Share on X Share on Reddit Share on HN Share on Bluesky

The energy bill for a fleet charging site does not match the CSMS reports. Investigation finds MeterValues sent every 5 minutes with only power readings—no cumulative energy register. Billing needs the odometer-style register value at transaction start and stop, not instantaneous power snapshots. OCPP MeterValues carry sampled measurands during transactions and at clock-aligned intervals. Configure them wrong and your revenue data is useless.

Measurands

Measurand Unit Purpose
Energy.Active.Import.Register Wh Billing (cumulative)
Power.Active.Import W Load management
Voltage V Installation diagnostics
Current.Import A Cable/connector monitoring
SoC Percent Vehicle state (if available)
Temperature Celsius Hardware health

Request only the measurands you need. Each adds message size and processing cost.

Sampled MeterValues during transactions

Sent via MeterValues message during active charging:

{
  "connectorId": 1,
  "transactionId": 42,
  "meterValue": [{
    "timestamp": "2025-11-02T14:30:00Z",
    "sampledValue": [
      {
        "value": "45230.5",
        "measurand": "Energy.Active.Import.Register",
        "unit": "Wh",
        "context": "Sample.Periodic"
      },
      {
        "value": "7200",
        "measurand": "Power.Active.Import",
        "unit": "W",
        "context": "Sample.Periodic"
      }
    ]
  }]
}

Configure sampling interval via Device Model variable:

Controller.MeterValueSampleInterval = 60  (seconds)

Or OCPP 1.6: ChangeConfiguration(MeterValueSampleInterval, 60).

Clock-aligned MeterValues

Sent at fixed intervals regardless of transaction state:

{
  "connectorId": 0,
  "meterValue": [{
    "timestamp": "2025-11-02T15:00:00Z",
    "sampledValue": [{
      "value": "128500.0",
      "measurand": "Energy.Active.Import.Register",
      "unit": "Wh",
      "context": "Sample.Clock"
    }]
  }]
}
Controller.ClockAlignedDataInterval = 900  (15 minutes)

Clock-aligned data supports grid monitoring and daily energy reconciliation without parsing transaction-level data.

Billing calculation

energy_delivered = meter_stop - meter_start
cost = energy_delivered × tariff_rate
def calculate_billing(start_tx: Transaction, stop_tx: Transaction, tariff) -> Bill:
    energy_wh = stop_tx.meter_stop - start_tx.meter_start
    energy_kwh = energy_wh / 1000

    duration = stop_tx.timestamp - start_tx.timestamp
    cost = tariff.calculate(energy_kwh, duration, stop_tx.timestamp)

    return Bill(
        transaction_id=start_tx.id,
        energy_kwh=energy_kwh,
        duration_minutes=duration.total_seconds() / 60,
        cost=cost,
    )

Validate: if meter_stop < meter_start, flag as meter rollover or data error.

SampledValue context field

Context Meaning
Sample.Periodic Regular interval during transaction
Sample.Clock Clock-aligned interval
Transaction.Begin Meter reading at transaction start
Transaction.End Meter reading at transaction stop
Interruption.Begin Power loss during session
Interruption.End Power restored during session

Store Transaction.Begin and Transaction.End readings separately from periodic samples. Billing uses these, not interpolated periodic values.

Storage schema

CREATE TABLE meter_values (
    id BIGSERIAL PRIMARY KEY,
    station_id VARCHAR(64) NOT NULL,
    connector_id INT NOT NULL,
    transaction_id INT,
    timestamp TIMESTAMPTZ NOT NULL,
    measurand VARCHAR(64) NOT NULL,
    value NUMERIC NOT NULL,
    unit VARCHAR(16) NOT NULL,
    context VARCHAR(32) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_mv_station_time ON meter_values (station_id, timestamp);
CREATE INDEX idx_mv_transaction ON meter_values (transaction_id);

At 60-second sampling with 4 measurands across 100 chargers: ~576,000 rows/day. Partition by month.

Data quality checks

def validate_meter_values(tx_id: int, values: list[MeterValue]) -> list[str]:
    errors = []
    energy_readings = [v for v in values
                       if v.measurand == "Energy.Active.Import.Register"]

    for i in range(1, len(energy_readings)):
        if energy_readings[i].value < energy_readings[i-1].value:
            errors.append(f"Register decreased at {energy_readings[i].timestamp}")

    power_readings = [v for v in values if v.measurand == "Power.Active.Import"]
    for p in power_readings:
        if p.value < 0:
            errors.append(f"Negative power at {p.timestamp}")
        if p.value > 50000:  # 50 kW — adjust for your hardware
            errors.append(f"Power exceeds hardware max at {p.timestamp}")

    return errors

Alert on validation failures. A decreasing register indicates meter replacement or communication corruption.

SampledValue data model

Each MeterValues message contains one or more SampledValue entries:

{
  "connectorId": 1,
  "transactionId": 42,
  "meterValue": [{
    "timestamp": "2024-12-27T10:05:00Z",
    "sampledValue": [
      {
        "value": "15420",
        "context": "Sample.Periodic",
        "measurand": "Energy.Active.Import.Register",
        "unit": "Wh",
        "location": "Outlet"
      },
      {
        "value": "7400",
        "context": "Sample.Periodic",
        "measurand": "Power.Active.Import",
        "unit": "W",
        "location": "Outlet"
      }
    ]
  }]
}

Store each SampledValue as separate time-series row — not the raw JSON blob. Enables querying by measurand and aggregation.

Billing-grade vs diagnostic sampling

Different measurands serve different purposes:

Measurand Purpose Sample interval Billing use
Energy.Active.Import.Register Total energy delivered 60s Primary billing
Power.Active.Import Instantaneous power 15s Load management
Current.Import Current draw 60s Diagnostic
Voltage Supply voltage 300s Diagnostic
SoC Vehicle battery level 60s Smart charging

Billing uses Register (cumulative Wh). Load management uses Power (instantaneous W). Don't bill on Power samples — integrate Power over time instead.

Time-series storage schema

CREATE TABLE meter_values (
    transaction_id  INT NOT NULL,
    connector_id    INT NOT NULL,
    timestamp       TIMESTAMPTZ NOT NULL,
    measurand       TEXT NOT NULL,
    value           NUMERIC NOT NULL,
    unit            TEXT NOT NULL,
    context         TEXT,
    PRIMARY KEY (transaction_id, timestamp, measurand)
);

-- Billing query: energy delivered in session
SELECT MAX(value) - MIN(value) AS energy_wh
FROM meter_values
WHERE transaction_id = 42
  AND measurand = 'Energy.Active.Import.Register';

Partition by month on timestamp. Index on (transaction_id, measurand, timestamp).

Failure modes

Production checklist

Signed metering and Eichrecht context

In regulated markets (Germany Eichrecht, UK AFC), billing-grade meters require signed energy readings — OCPP alone does not sign MeterValues. Integrate with hardware secure elements or external calibrated meters; export signed totals via vendor extension or separate OCPI CDR fields.

For non-regulated sites, still treat Energy.Active.Import.Register as the legal billing source and keep periodic Power samples for operations only. Display instantaneous kW to users; invoice on integrated kWh.

Handling meter rollover and replacement

Meters reset or roll over at 999,999.9 kWh. Detect jumps:

def adjust_for_rollover(prev, curr, max_register=999999.9):
    if curr < prev and (prev - curr) > max_register * 0.9:
        return curr + (max_register - prev)
    return curr - prev

When physical meter replacement mid-session is impossible, end transaction at replacement, start new transaction with meterStart at zero, link both in billing with a meter_swap reason code.

Sampling under poor connectivity

Reduce MeterValueSampleInterval offline only if storage allows — but never below what billing auditors require. A pragmatic split: 60s online, 300s offline for Power; always capture Transaction.Begin and Transaction.End Register readings locally even when CSMS is unreachable.

Aligning clock-aligned samples to grid intervals

Utilities often want readings on :00, :15, :30, :45. Configure ClockAlignedDataInterval and verify charger aligns to UTC or local utility timezone explicitly — mixed timezone sites produce unreconcilable grid reports.

Export formats for analytics teams

Beyond SQL storage, expose Parquet export with (station_id, connector_id, timestamp, measurand, value, context) — data science teams join with weather and tariff tables without scraping JSON logs.

Compression for historical archive

After 90 days, roll minute-level Register samples to 15-minute max/last for analytics retention — keep Transaction.Begin/End at full precision for billing disputes. Document compression policy for regulators asking about meter traceability.

Resources

Frequently asked questions

What is the difference between MeterValues and a meter reading?

A meter reading is the cumulative energy register value at a point in time (like an odometer). MeterValues in OCPP are sampled data points sent during a transaction, each containing one or more measurands (energy, power, voltage, current) with timestamps. Billing uses the difference between start and stop register readings.

How often should MeterValues be sampled?

Every 60 seconds during active transactions is standard for billing and load management. Every 300 seconds suffices for analytics-only use. During off-peak with no active sessions, clock-aligned samples every 15 minutes provide grid monitoring data without excessive message volume.

Which measurands are required for billing?

Energy.Active.Import.Register (cumulative kWh) is the billing measurand. Power.Active.Import provides real-time load data. Voltage and Current per phase help diagnose installation issues but are not billing requirements.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →