ocpi-kit

Reading a CDR

Period boundaries, signed metering data, token identity and delivery latency — the four things a CDR consumer has to get right.

On this page

A CDR is the billing record: "Because a CDR is for billing purposes, it cannot be changed or replaced once sent to the eMSP." Four things about reading one are not obvious from the types.

Energy over time

A ChargingPeriod carries only its start_date_time"a period ends when the next one starts", and the last one ends at the CDR's end_date_time. Cdr::period_spans() closes the intervals for you:

for span in cdr.period_spans() {
    if let Some(kwh) = span.volume(CdrDimensionType::Energy) {
        println!("{}{}: {kwh} kWh", span.start, span.end);
    }
}

The spans partition the session exactly: the first starts at start_date_time, each ends where the next begins, and the last ends at end_date_time.

A period is a total, not a curve. It says 4.3 kWh flowed between two instants and nothing about how. Re-cutting these onto a finer grid — quarter hours for a settlement process, say — needs an assumption the CDR does not carry, and the specification declines to make it: it puts the obligation on the CPO to start a new period "every moment/event that has relevance for the total costs" instead. Apportioning by elapsed time is the usual choice and usually close, but it is your assumption to make and to record. Tariffs takes the same position and reports a PeriodSpansPriceChange note when a period outlasts the price that governs it.

Periods come back in the order the CDR gives them. validate() reports a CDR whose periods are out of order — check it first, because for an interval the ordering is the meaning.

Session carries the same periods and deliberately has no equivalent. A running session has no end_date_time, so its final period has no honest end, and the whole list is provisional — "any charging_periods from the existing object SHALL be replaced by the charging_periods from the newly received Session object". A CDR is the record that stops changing, which is what an interval needs.

Signed metering data

signed_data is not an opaque blob. It is a SignedData object — encoding_method, public_key, url and a list of SignedValue { nature, plain_data, signed_data }. The opaque part is the strings inside, and those are carried verbatim, whatever their length:

let end = cdr.signed_data.as_ref().and_then(SignedData::end_value);

A signed record is evidence; it is worth nothing if a byte moves. Real OCMF payloads routinely run past the string(5000) the specification gives, so the crate's governing rule matters here more than anywhere: the value arrives intact and validate() reports the length as TooLong rather than the decoder truncating or rejecting it. A decode and re-encode reproduces the original bytes exactly, and a test asserts it against a 6000-character blob.

value_for(nature) finds a reading by name and compares case-insensitively; Start and End have shortcuts. The nature is deliberately a string, not an enum — "others might be added later".

Token identity

CdrToken identifies the driver with country_code, party_id, uid, token_type and contract_id. Two things decide whether a whitelist matches.

uid is already case-folded. It is a CiString, so OCPI itself defines the comparison as case-insensitive — Eq and Hash follow, and an RFID UID keyed in a HashMap matches deterministically with no work on your part. See Parse, validate, construct.

contract_id needs normalising, and case-folding is not enough. The recommended format is an eMI3/IDACS eMAID, whose separators are optional and all-or-nothing: DE-8AA-CA2B3C4D5-N and DE8AACA2B3C4D5N are the same contract, and comparing the strings says they are not.

let key = ContractIdParts::normalise(cdr.cdr_token.contract_id.as_str());

None means the id does not follow the format, which an eMSP is free to do — treat that as "not comparable", not as "no match". The format has no marker, so any string of the right shape parses; the API docs are blunt about what that does and does not tell you.

auth_method is not part of the identity. It records how the session was authorised — AUTH_REQUEST, COMMAND, WHITELIST — not what the token is. The discriminator you want is token_type: RFID, APP_USER, AD_HOC_USER, EMAID.

Delivery latency

A CDR may arrive well after the session it records. last_updated is the moment to measure from, because a CDR has no later one — it cannot be changed once sent, so on a CDR alone last_updated is when it was created.

match cdr.delivery_latency_seconds() {
    Some(seconds) =>,
    None =>,   // placeholder timestamps; not a measurement
}

None guards the case that would otherwise poison an average: the specification lets a CPO send 1970-1-1T00:00:00Z for start_date_time and end_date_time when both parties agree, which would report half a century of latency. Negative values are returned as they are — they mean the CPO's clock disagrees with its own session, which is worth seeing.

OCPI sets no deadline for delivering a CDR, so there is nothing here to enforce against. If your process has one, delivery_latency_seconds() is the number to build the alert on, and the conformance runner will tell you whether a partner's date_from filter works well enough to poll for late arrivals incrementally.

Local time

Every time-of-day rule in OCPI — tariff restrictions, opening hours — is written in the Location's local time. DateTime::local_parts(offset_seconds) converts, returning LocalParts { date, time, iso_weekday } in the crate's own types:

let local = cdr.start_date_time.local_parts(3600);   // CET

The offset has to come from somewhere. A Location carries an IANA time_zone name, and tariffs::TimeZone resolves it against the zone database — which is what you need for anything spanning a daylight-saving change, where a fixed offset is wrong for half the year.

Improve this page on GitHub