Back to blog
Tutorial10 min read

OCPP Smart Charging: SetChargingProfile in Practice

Charging profiles are the most misunderstood part of OCPP 1.6. Stacking levels, purposes, schedule periods and the composite schedule — what each one actually does, with payloads.

I
Infinite Service Team

The Smart Charging profile is where OCPP 1.6 stops being a simple request/response protocol. SetChargingProfile has nested structures, three different purposes, a stacking model, and a set of rules for combining profiles that is easy to get subtly wrong.

It is also the part of the spec that most implementations get away with ignoring until a grid operator asks them to curtail load, at which point it becomes urgent.

The shape of a charging profile

A charging profile answers one question: *how much current or power is this charger allowed to deliver, and when?* Everything in the structure serves that.

[2, "msg-01", "SetChargingProfile", {
  "connectorId": 1,
  "csChargingProfiles": {
    "chargingProfileId": 100,
    "stackLevel": 0,
    "chargingProfilePurpose": "TxDefaultProfile",
    "chargingProfileKind": "Absolute",
    "chargingSchedule": {
      "chargingRateUnit": "A",
      "chargingSchedulePeriod": [
        { "startPeriod": 0,     "limit": 32.0 },
        { "startPeriod": 21600, "limit": 16.0 },
        { "startPeriod": 64800, "limit": 32.0 }
      ]
    }
  }
}]

That profile says: 32 A from the start, drop to 16 A six hours in, back to 32 A eighteen hours in. startPeriod is seconds relative to the start of the schedule, not a wall-clock time and not a duration.

The three purposes are not interchangeable

chargingProfilePurpose decides what the profile applies to, and this is where most confusion starts.

ChargePointMaxProfile is the physical ceiling for the entire charge point. It is set on connectorId: 0 because it describes the station, not a socket. Nothing may exceed it — it is the limit of the hardware and the supply behind it.

TxDefaultProfile is the default for transactions. Set it on a specific connector, or on connectorId: 0 to give every connector the same default. It applies to transactions that have no profile of their own, including future ones.

TxProfile applies to one specific, currently running transaction. It requires a transactionId, and it is discarded when that transaction ends. This is what you use to curtail a session in progress.

The practical consequence: sending a TxProfile for a transaction that has already stopped is not an error you will notice — the charger accepts it and throws it away.

Stacking is not addition

stackLevel is an integer, and higher wins. This is the rule people misread most often.

Profiles at different stack levels do not sum, average, or blend. Within a given purpose, the valid profile with the highest stack level replaces the ones below it entirely. A profile at stackLevel 2 does not add a constraint on top of stackLevel 1; it supersedes it.

The layering across purposes is separate and does behave as a ceiling: the effective limit is the lowest of the applicable ChargePointMaxProfile, the winning TxDefaultProfile, and the winning TxProfile. So purposes cap each other, while stack levels within a purpose override each other.

A common deployment pattern:

  • stackLevel 0 — TxDefaultProfile, the normal operating limit
  • stackLevel 1 — TxDefaultProfile, a tariff-driven overnight schedule
  • stackLevel 2 — TxProfile, an operator curtailment for one session

Remove the level 2 profile and the level 1 schedule resumes. Nothing needs to restate what it replaced.

Absolute, Recurring, Relative

chargingProfileKind decides when startPeriod: 0 means.

Absolute anchors the schedule to startSchedule, an explicit timestamp. Use it for a one-off window: curtail between 18:00 and 20:00 tonight.

Recurring repeats daily or weekly, controlled by recurrencyKind. startSchedule sets the phase — the time of day the cycle begins. Use it for tariffs.

Relative anchors startPeriod: 0 to the moment the transaction starts, and ignores startSchedule entirely. Use it for behaviour that depends on session age rather than time of day: full current for the first hour, reduced afterwards.

A frequent bug is sending Absolute with no startSchedule. The spec requires it for Absolute and Recurring, and chargers vary in whether they reject the profile or quietly treat it as Relative.

Amps or watts, and why it matters

chargingRateUnit is A or W, and a charger is not required to support both. GetConfiguration on ChargingScheduleAllowedChargingRateUnit tells you which are available.

For AC charging the conversion depends on the phase count, which the profile does not carry. 32 A on single-phase 230 V is roughly 7.4 kW; the same 32 A on three-phase is roughly 22 kW. If your backend reasons in watts and the charger works in amps, the phase assumption lives somewhere in your code and it needs to be explicit.

numberPhases in a schedule period defaults to 3 when omitted. On a single-phase installation that default is wrong, and a limit calculated from it will be three times too high.

Verifying what the charger actually did

SetChargingProfile returns Accepted, Rejected, or NotSupported. Accepted means the charger stored the profile — not that the profile is currently in effect, and not that it produced the limit you intended.

To see the result of combining everything, use GetCompositeSchedule:

[2, "msg-02", "GetCompositeSchedule", {
  "connectorId": 1,
  "duration": 86400,
  "chargingRateUnit": "A"
}]

The charger returns the flattened schedule it will actually follow, with every profile already resolved. This is the only reliable way to check your stacking logic, and it is the first thing to reach for when a curtailment does not appear to take effect.

If the composite schedule disagrees with what you expected, the usual causes are a stack level that is lower than something already installed, a TxProfile whose transactionId does not match the running transaction, or a ChargePointMaxProfile capping you from above.

Clearing profiles

ClearChargingProfile deletes by filter, not by id alone. Every field is optional, and omitting them all clears everything:

[2, "msg-03", "ClearChargingProfile", {
  "connectorId": 1,
  "chargingProfilePurpose": "TxProfile"
}]

That clears TxProfiles on connector 1 and leaves defaults intact. Sending {} clears every profile on the charge point, which is occasionally what you want and more often an outage.

Testing this

Smart charging is difficult to test against real hardware because the interesting cases are time-dependent — a recurring profile that misbehaves at a day boundary, a curtailment that arrives mid-session, a composite schedule that only goes wrong once three profiles are stacked.

This is exactly the kind of logic worth exercising in simulation. SimPilot can hold a session open while you install, stack and clear profiles against it, and TestPilot checks a charger's own handling of the Smart Charging profile against the spec. The full message set is documented in our OCPP 1.6 reference.

Related articles