npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ocpp-smart-charge-engine

v0.3.0

Published

Library-agnostic OCPP smart charging constraint solver. Works with ocpp-ws-io, raw WebSocket, or any OCPP implementation.

Readme


What is this?

ocpp-smart-charge-engine solves one problem: how to distribute your site's grid power fairly and safely among EV chargers using OCPP's SetChargingProfile command.

It is completely library-agnostic. It does not care whether you use ocpp-ws-io, raw WebSockets, or any other OCPP implementation. You supply a dispatcher callback, and the engine calls it with the computed charging profile — what you do inside the callback is entirely up to you.

Note on Charger Compatibility SetChargingProfile is part of the OCPP 1.6 Smart Charging optional feature profile and is mandatory in OCPP 2.0.1. If a charger rejects the command (e.g., older hardware without Smart Charging support), your dispatcher should catch the error. The engine handles this gracefully — it emits a 'dispatchError' event for the failing session and continues dispatching to all other sessions.


Documentation

| Guide | Description | |---|---| | Grid & Load Management | Multi-panel sites, mixed fleets, hierarchical grid, OCPP profile types | | ocpp-ws-io + Express Example | Full CSMS integration — correct API usage, REST admin endpoints, auto-dispatch | | Charging Strategies | Equal Share, Priority, Time-of-Use, runtime swap, custom strategy | | Database-Driven Config | Store panel/charger config in DB — engine registry, hot-reload, charger reassignment |


Install

npm install ocpp-smart-charge-engine

Quick Start

import { SmartChargingEngine, Strategies } from "ocpp-smart-charge-engine";
import { buildOcpp16Profile } from "ocpp-smart-charge-engine/builders";

const engine = new SmartChargingEngine({
  siteId: "SITE-HQ-001",
  maxGridPowerKw: 100, // 100kW grid connection
  safetyMarginPct: 5, // Use max 95kW, leave 5% buffer
  algorithm: Strategies.EQUAL_SHARE,

  // The ONLY integration point — use whatever OCPP library you have.
  // `sessionProfile` contains raw numbers (kW, W, A).
  // Use the builder helpers to convert to the correct OCPP version shape.
  dispatcher: async ({
    clientId,
    connectorId,
    transactionId,
    sessionProfile,
  }) => {
    await server.safeSendToClient(clientId, "ocpp1.6", "SetChargingProfile", {
      connectorId,
      csChargingProfiles: buildOcpp16Profile(sessionProfile),
    });
  },

  // Optional: send ClearChargingProfile when sessions end
  clearDispatcher: async ({ clientId, connectorId }) => {
    await server.safeSendToClient(clientId, "ocpp1.6", "ClearChargingProfile", {
      connectorId,
      chargingProfilePurpose: "TxProfile",
      stackLevel: 0,
    });
  },
  autoClearOnRemove: true, // auto-clear when removeSession() is called
});

// When a car connects (from your OCPP StartTransaction handler)
engine.addSession({
  transactionId: payload.transactionId,
  clientId: client.identity,
  connectorId: payload.connectorId,
  maxHardwarePowerKw: 22, // Charger max hardware rating
  minChargeRateKw: 1.4, // Minimum — prevents EV faulting on low power
});

// Recalculate and dispatch profiles to all active chargers
await engine.dispatch();

// Auto-dispatch every 60s (useful for TIME_OF_USE tariffs)
engine.startAutoDispatch(60_000);

// When a car leaves — also sends ClearChargingProfile (autoClearOnRemove)
engine.removeSession(payload.transactionId);
await engine.dispatch(); // Redistribute power to remaining sessions

Library-Agnostic Dispatcher Examples

The dispatcher receives a sessionProfile with raw calculated numbers. Use the builder helpers from ocpp-smart-charge-engine/builders to convert to the correct OCPP version-specific shape. Schemas differ between versions — that's exactly why the engine doesn't build the profile itself.

With ocpp-ws-io — OCPP 1.6

import { buildOcpp16Profile } from "ocpp-smart-charge-engine/builders";

dispatcher: async ({
  clientId,
  connectorId,
  transactionId,
  sessionProfile,
}) => {
  await server.safeSendToClient(
    clientId,
    "ocpp1.6",
    "SetChargingProfile",
    {
      connectorId,
      csChargingProfiles: buildOcpp16Profile(sessionProfile),
    },
    { idempotencyKey: `profile-${transactionId}` },
  );
};

With ocpp-ws-io — OCPP 2.0.1 / 2.1

import { buildOcpp201Profile } from "ocpp-smart-charge-engine/builders";

dispatcher: async ({
  clientId,
  connectorId,
  transactionId,
  sessionProfile,
}) => {
  await server.safeSendToClient(
    clientId,
    "ocpp2.0.1",
    "SetChargingProfile",
    {
      evseId: connectorId, // NOTE: connectorId → evseId in 2.0.1
      chargingProfile: buildOcpp201Profile(sessionProfile),
    },
    { idempotencyKey: `profile-${transactionId}` },
  );
};

Mixed fleet (some 1.6, some 2.0.1)

import {
  buildOcpp16Profile,
  buildOcpp201Profile,
} from "ocpp-smart-charge-engine/builders";

const protocolMap = new Map<string, "ocpp1.6" | "ocpp2.0.1">(); // populated on connect

dispatcher: async ({
  clientId,
  connectorId,
  transactionId,
  sessionProfile,
}) => {
  const protocol = protocolMap.get(clientId) ?? "ocpp1.6";

  if (protocol === "ocpp1.6") {
    await server.safeSendToClient(clientId, "ocpp1.6", "SetChargingProfile", {
      connectorId,
      csChargingProfiles: buildOcpp16Profile(sessionProfile),
    });
  } else {
    await server.safeSendToClient(clientId, "ocpp2.0.1", "SetChargingProfile", {
      evseId: connectorId,
      chargingProfile: buildOcpp201Profile(sessionProfile),
    });
  }
};

With raw WebSocket

import { buildOcpp16Profile } from "ocpp-smart-charge-engine/builders";

dispatcher: async ({ clientId, connectorId, sessionProfile }) => {
  const ws = wsMap.get(clientId);
  ws?.send(
    JSON.stringify([
      2,
      crypto.randomUUID(),
      "SetChargingProfile",
      {
        connectorId,
        csChargingProfiles: buildOcpp16Profile(sessionProfile),
      },
    ]),
  );
};

ClearChargingProfile

When a car leaves or you want to remove throttling, you need to send ClearChargingProfile. The engine handles this via clearDispatcher.

Auto-clear on session removal

const engine = new SmartChargingEngine({
  // ...
  clearDispatcher: async ({ clientId, connectorId }) => {
    // OCPP 1.6
    await server.safeSendToClient(clientId, "ocpp1.6", "ClearChargingProfile", {
      connectorId,
      chargingProfilePurpose: "TxProfile",
      stackLevel: 0,
    });
    // OCPP 2.0.1
    // await server.safeSendToClient(clientId, "ocpp2.0.1", "ClearChargingProfile", {
    //   chargingProfileCriteria: { evseId: connectorId, chargingProfilePurpose: "TxProfile" },
    // });
  },
  autoClearOnRemove: true, // fires clearDispatcher automatically on removeSession()
});

Manual clear

await engine.clearDispatch(); // clear ALL active sessions
await engine.clearDispatch(42); // clear only transactionId 42

Auto-Dispatch

Instead of manually calling engine.dispatch() after every event, use auto-dispatch for periodic recalculation. Ideal for TIME_OF_USE strategies that change power limits throughout the day.

// Recalculate and push profiles every 60 seconds
engine.startAutoDispatch(60_000);

// Fires only when sessions are active (no-op when no cars connected)
engine.on("dispatched", (profiles) => {
  console.log(`Auto-dispatched ${profiles.length} profiles`);
});

// Stop when shutting down
engine.stopAutoDispatch();

// Check if running
console.log(engine.config.autoDispatchActive); // true | false

Minimum Charge Rate (minChargeRateKw)

Some EVs and heat pumps fault if power drops below a minimum threshold. Set minChargeRateKw per session to guarantee a floor.

engine.addSession({
  transactionId: 1,
  clientId: "CP-001",
  minChargeRateKw: 1.4, // 6A × 230V = 1.38kW — IEC 61851 minimum
});
// This session receives at least 1.4kW — headroom is taken from other sessions
// to honor it. The value is also written into chargingSchedule.minChargingRate.

If the floors of all sessions together exceed the grid, the minimums can't all be met. Grid safety wins: allocations are scaled to fit and a gridOverCommitted event fires with feasible: false and a starvedSessions list (see Grid safety).


Strategies

Headroom redistribution (water-filling). All three built-in strategies fully utilize the grid: when a session is capped (by hardware/EV limit or low SoC), the power it cannot use is redistributed to the sessions that can still accept more. The total never exceeds the grid limit, and no session exceeds its caps.

EQUAL_SHARE (default)

Divides available grid power equally among all active sessions, capped by maxHardwarePowerKw and maxEvAcceptancePowerKw. Surplus from a capped session flows to the rest.

// 3 cars, 100kW grid, 5% margin = 95kW effective → ~31.67 kW each.
// If one car caps at 10kW, its unused share is split among the other two.

PRIORITY

Allocates power proportionally to each session's priority value (higher number = more power). Surplus from a capped session is redistributed to the others by priority weight. (Priorities that sum to 0 fall back to an equal split.)

engine.addSession({ transactionId: 1, clientId: "CP-001", priority: 8 }); // → 80kW
engine.addSession({ transactionId: 2, clientId: "CP-002", priority: 2 }); // → 20kW
// Total: 100kW. If CP-001 caps at 40kW, CP-002 receives the surplus → 60kW.

TIME_OF_USE

Reduces grid usage during configured peak pricing windows. Works best with startAutoDispatch(). Pass timeOfUseTimezone (IANA) to evaluate windows in the site's local time rather than the server's.

const engine = new SmartChargingEngine({
  algorithm: Strategies.TIME_OF_USE,
  timeOfUseWindows: [
    { peakStartHour: 18, peakEndHour: 22, peakPowerMultiplier: 0.5 }, // 50% during 6–10pm
  ],
  timeOfUseTimezone: "Europe/Berlin", // optional — defaults to server local time
  // ...
});
engine.startAutoDispatch(60_000); // recalculate every minute
// At 7pm Berlin: effectiveGrid = 100 * 0.5 = 50kW
// At 2pm Berlin: effectiveGrid = 100kW

peakPowerMultiplier must be between 0 and 1; peak hours must be integers 0–23. Invalid windows throw SmartChargingConfigError at construction.

Custom strategy

Pass your own StrategyFn as the algorithm (or via setAlgorithm() at runtime). It receives the active sessions, the effective grid limit, and a context with the configured voltage. Use the buildSessionProfile helper to produce results.

import { SmartChargingEngine } from "ocpp-smart-charge-engine";
import { buildSessionProfile } from "ocpp-smart-charge-engine/strategies";
import type { StrategyFn } from "ocpp-smart-charge-engine";

// Give the longest-waiting session priority (FIFO).
const fifo: StrategyFn = (sessions, grid, ctx) => {
  const oldest = [...sessions].sort((a, b) => a.addedAt - b.addedAt)[0];
  return sessions.map((s) =>
    buildSessionProfile(s, s === oldest ? grid : 0, ctx?.voltageV),
  );
};

const engine = new SmartChargingEngine({ siteId: "S", maxGridPowerKw: 100, algorithm: fifo, dispatcher });
// engine.config.algorithm === "CUSTOM"

The grid-budget guard still runs after any strategy (built-in or custom), so a custom strategy can never push the total above the grid limit.


Grid safety & over-subscription

The engine guarantees a hard invariant: the sum of allocated power never exceeds the effective grid limit, and no session ever exceeds its hardware/EV caps.

If the per-session minChargeRateKw floors add up to more than the grid can supply, the engine scales allocations down to fit and emits gridOverCommitted:

engine.on("gridOverCommitted", (info) => {
  console.warn(`Site over-subscribed: wanted ${info.requestedKw}kW, grid is ${info.effectiveGridLimitKw}kW`);
  if (!info.feasible) {
    // Even the minimums don't fit — these sessions were scaled below their floor:
    for (const s of info.starvedSessions) {
      console.warn(`  ${s.clientId} got ${s.allocatedKw}kW (wanted min ${s.minChargeRateKw}kW)`);
    }
  }
});

Updating sessions in place

When an EV reports a new acceptance limit (or you want to re-prioritize mid-charge), use updateSession() instead of removeSession() + addSession(). It preserves addedAt, keeps transactionId/clientId immutable, validates the change, and — unlike a remove — does not trigger autoClearOnRemove.

engine.updateSession(txId, { maxEvAcceptancePowerKw: 7.4, priority: 5 });
await engine.dispatch(); // re-balances with the new value

Persistence (snapshot & restore)

Persist active sessions (e.g. to Redis/DB) and restore them after a process restart so allocations survive a redeploy.

// Save — getSnapshot() is JSON-serializable and includes addedAt.
await redis.set("sce:SITE-001", JSON.stringify(engine.getSnapshot()));

// Restore — validates every entry up front (atomic); throws before applying if any is invalid.
const saved = JSON.parse(await redis.get("sce:SITE-001"));
engine.loadSnapshot(saved);            // replaces all sessions (default)
engine.loadSnapshot(saved, { clear: false }); // merge instead of replace
await engine.dispatch();

Builders — ocpp-smart-charge-engine/builders

Version-specific helpers to convert raw SessionProfile numbers into the correct OCPP SetChargingProfile payload.

| Helper | OCPP Version | Field name in payload | chargingSchedule shape | | ----------------------- | ------------ | --------------------- | ------------------------ | | buildOcpp16Profile() | 1.6 | csChargingProfiles | single object | | buildOcpp201Profile() | 2.0.1 | chargingProfile | array | | buildOcpp21Profile() | 2.1 | chargingProfile | array + V2G fields |

Why the difference? OCPP 2.0.1 made chargingSchedule an array, renamed chargingProfileIdid, and changed transactionId from integer to string. OCPP 2.1 adds dischargeLimit for V2G (Vehicle-to-Grid).

Builder options (all three accept these)

buildOcpp16Profile(sessionProfile, {
  stackLevel: 0,
  purpose: "TxProfile", // "TxProfile" | "TxDefaultProfile" | "ChargePointMaxProfile"
  rateUnit: "W", // "W" | "A"
  numberPhases: 3,

  // Multi-period schedule — overrides the calculated single-period
  periods: [
    { startPeriod: 0, limit: 22000, numberPhases: 3 }, // 22kW for first 2h
    { startPeriod: 7200, limit: 7000, numberPhases: 3 }, // 7kW after 2h
  ],
});

OCPP 2.1 — V2G Discharge

import { buildOcpp21Profile } from "ocpp-smart-charge-engine/builders";

dispatcher: async ({ clientId, connectorId, sessionProfile }) => {
  await server.safeSendToClient(clientId, "ocpp2.1", "SetChargingProfile", {
    evseId: connectorId,
    chargingProfile: buildOcpp21Profile(sessionProfile, {
      dischargeLimitW: 7400, // Allow 7.4kW V2G discharge (ISO 15118-20)
    }),
  });
};

API Reference

new SmartChargingEngine(config)

| Option | Type | Default | Description | | -------------------- | --------------------------- | ------------- | ------------------------------------------------ | | siteId | string | required | Human-readable site identifier | | maxGridPowerKw | number | required | Maximum site grid power in kW | | dispatcher | ChargingProfileDispatcher | required | Your OCPP send function | | clearDispatcher | ClearProfileDispatcher | — | Optional: sends ClearChargingProfile | | autoClearOnRemove | boolean | false | Auto-clear profile on removeSession() | | algorithm | Strategy \| StrategyFn | EQUAL_SHARE | Built-in strategy name or a custom function | | safetyMarginPct | number | 5 | Power held in reserve (%) | | phases | 1 \| 2 \| 3 | 3 | AC phase count for the site (2 = split-phase) | | voltageV | number | 230 | Grid voltage — used for the amps calculation | | timeOfUseWindows | TimeOfUseWindow[] | [] | Peak windows (TIME_OF_USE only) | | timeOfUseTimezone | string | server tz | IANA timezone for TOU windows (e.g. Asia/Kolkata) | | debug | boolean | false | Enable verbose console logging |

All numeric options are validated at construction; invalid values throw SmartChargingConfigError.

addSession(session) options

| Option | Type | Default | Description | | ------------------------ | ---------------- | ------- | --------------------------------------------------------- | | transactionId | number\|string | req. | OCPP transaction ID | | clientId | string | req. | Charging station identity | | connectorId | number | 1 | Connector / EVSE ID | | maxHardwarePowerKw | number | | Charger hardware limit (upper cap) | | maxEvAcceptancePowerKw | number | | EV acceptance limit (upper cap) | | minChargeRateKw | number | 0 | Minimum power floor — prevents EV faults | | priority | number | 1 | Session priority (PRIORITY strategy only) | | phases | 1 \| 2 \| 3 | site | Phase count for this connector (2 = split-phase) | | metadata | object | — | Arbitrary data (RFID, tariff ID, etc.) — stored, not used |

Numeric fields are validated: caps must be a positive number or Infinity; minChargeRateKw/priority must be non-negative; invalid values throw SmartChargingConfigError.

Methods

| Method | Description | | ------------------------------- | ---------------------------------------------------------------------------------------- | | addSession(session) | Register a session. Throws DuplicateSessionError if already exists | | updateSession(txId, patch) | Update a session in place (no clear/re-add side effects). Throws SessionNotFoundError | | removeSession(txId) | Remove a session. Throws SessionNotFoundError if not found | | safeRemoveSession(txId) | Remove without throwing — returns undefined if not found | | optimize() | Calculate profiles without dispatching. Returns SessionProfile[] | | dispatch() | Calculate profiles and call dispatcher for each. Returns Promise<SessionProfile[]> | | clearDispatch(txId?) | Send ClearChargingProfile to one or all sessions. No-op if no clearDispatcher | | startAutoDispatch(ms) | Start periodic dispatch every ms milliseconds (min 1000ms) | | stopAutoDispatch() | Stop the auto-dispatch interval | | setGridLimit(kw) | Update grid limit at runtime | | setAlgorithm(strategy \| fn) | Hot-swap a built-in strategy or a custom StrategyFn at runtime | | setSafetyMargin(pct) | Update safety margin at runtime | | getSessions() | Read-only array of active sessions (copies) | | getSession(txId) | Single session snapshot (copy), or undefined | | getSnapshot() | JSON-serializable snapshot of all sessions for persistence | | loadSnapshot(sessions, opts?) | Restore sessions from a snapshot (validated, atomic). { clear } replaces or merges | | isEmpty() | Returns true when no sessions are registered |

Events

| Event | Payload | Fired when | | --------------------- | ---------------------------------- | ------------------------------------------------ | | sessionAdded | ActiveSession | A session is registered | | sessionUpdated | ActiveSession | A session is updated via updateSession() | | sessionRemoved | ActiveSession | A session is removed | | snapshotLoaded | ReadonlyArray<ActiveSession> | After loadSnapshot() restores sessions | | optimized | SessionProfile[] | After optimize() completes | | dispatched | SessionProfile[] | After all dispatcher calls settle | | dispatchError | DispatchErrorEvent | A dispatcher call throws; engine continues | | gridOverCommitted | GridOverCommitInfo | Min-rate floors exceed the grid; allocations scaled (incl. starvedSessions) | | cleared | ClearDispatchPayload | After a clearDispatcher call succeeds | | clearError | ClearDispatchPayload & { error } | A clearDispatcher call throws | | autoDispatchStarted | number (intervalMs) | After startAutoDispatch() is called | | autoDispatchStopped | — | After stopAutoDispatch() is called | | error | Error | A strategy function throws |


License

MIT © 2026 Rohit Tiwari