REST & streaming reference

OPF API

Programmatic access to accounts, users, storage sites, live battery telemetry and dispatch control. Everything is JSON over HTTPS, scoped to the account behind your credentials.

Overview

Base URLhttps://api.opf.lt
Content typeapplication/json
TimestampsISO 8601, UTC
TimezoneEurope/Vilnius

All paths are prefixed with /api/v1 except /health. Request bodies are capped at 256 kB. Every response is JSON, including errors. Resources belonging to another account return 404 rather than 403, so ids cannot be probed.

Architecture

Five moving parts. The API never talks to a device directly: it writes to a command queue that the dispatcher agent drains, so every instruction is durable, attributable and acknowledged.

ComponentExposureRole
opf.ltPublicClient facing site: products, projects, contact.
api.opf.ltPublicThis API. Authentication, accounts, sites, telemetry, command queue.
dispatcher-brainsInternalValidated BESS decision service with physical battery model, MILP scheduling, forecasts, market/balancing optimization, PostgreSQL persistence, priority slots and guarded orchestration. It never publishes device commands.
dispatcher.opf.ltWireGuard onlyInternal dispatcher room: full telemetry and control surface.
dispatcher agentInternalClaims queued commands, publishes to MQTT, tracks acks, writes rollups.
dispatcher enginePublicLP optimiser at optimizer.opf.lt that produces the dispatch plan.
device ──MQTT──▶ api (ingest)          dispatcher room ──▶ POST /sites/:id/command
                     │                                        │
                     ▼                                        ▼
            telemetry_latest                            commands (queued)
                     │                                        │
                     │                                 dispatcher agent claims
                     ▼                                        ▼
            telemetry / telemetry_1m            MQTT storage/<site>/cmd  ──▶ device
                                                              ▲                │
                                                              └── cmd/ack ◀─────┘

Dispatcher room

dispatcher.opf.lt ↗ is the operator console built on this API: fleet KPIs, per-site telemetry, the live event stream, dispatch controls and the command queue. It is reachable only over the WireGuard tunnel — the public internet gets a 404 — and still requires a login.

URLhttps://dispatcher.opf.lt
NetworkWireGuard peers only
AuthSame accounts as this API
ExecutionVia dispatcher agent

Temporary demo access

user:     demo@opf.lt
password: OpfRoomDemo2026
role:     viewer (read only, cannot dispatch)

Temporary read-only credentials, published here on request. They cannot issue commands or change configuration. Remove them with DELETE /api/v1/users/:id when they are no longer needed.

Quickstart

  1. Create the first account and owner with POST /api/v1/auth/register.
  2. Keep the accessToken and send it as Authorization: Bearer ....
  3. Register a site whose siteId matches your MQTT topic segment.
  4. Publish telemetry to storage/<siteId>/rt; read it back from /telemetry/live.
  5. Issue dispatch with POST /api/v1/sites/<siteId>/command.
# 1. Bootstrap and capture the token
TOKEN=$(curl -s -X POST https://api.opf.lt/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@opf.lt","password":"OpfDemo2026Pass"}' \
  | grep -o '"accessToken":"[^"]*"' | cut -d'"' -f4)

# 2. Register a site
curl -s -X POST https://api.opf.lt/api/v1/sites \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"siteId":"sasiai","name":"OPF Sasiai","capacityKwh":150,"maxDischargeKw":30}'

# 3. Read the fleet
curl -s https://api.opf.lt/api/v1/telemetry/summary -H "Authorization: Bearer $TOKEN"

Authentication

Two credential types are accepted. Interactive clients use short lived JWT access tokens with a rotating refresh token. Machines use long lived API keys.

Bearer token

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Access tokens expire after 15m. When you receive 401, call /auth/refresh with your refresh token to obtain a new pair. Refresh tokens last 30 days, are single use, and are stored only as hashes, so a database leak cannot be replayed.

API key

X-API-Key: opf_9Kd2xQ7bTnVw5sLpAeR3yUfHgKmZcXdQ2NvBjWtE

Keys are shown once at creation and stored hashed. They carry their own role and can be revoked instantly. Use them for dashboards, exporters and cron jobs rather than sharing a user password.

Roles

RoleGrants
viewerRead sites, telemetry, prices and own profile.
operatorEverything viewer can do, plus issue dispatch commands to sites.
adminEverything operator can do, plus manage users, sites and API keys.
ownerFull control including deleting users and sites, and account settings.

Roles are hierarchical: each includes everything below it. Nobody can grant or modify a role above their own, which prevents privilege escalation by an admin.

Command lifecycle

A command is a queued record, not a fire and forget publish. It moves through these states, and both the console and GET /api/v1/commands read the same rows.

StatusMeaning
queuedAccepted by the API and waiting for the dispatcher agent to claim it.
sendingClaimed by an agent. The claim is atomic, so two agents can never send the same command.
sentPublished to the device topic at QoS 1. Waiting for the device acknowledgement.
ackedDevice confirmed on storage/<siteId>/cmd/ack. Terminal state.
failedPublish failed after retries, the device rejected it, or no ack arrived in time. Terminal state.
expiredSat in the queue longer than the stale window and was never executed. Terminal state.
Ack timeout60 s, then failed
Publish retries3 attempts
Queue stale window15 min, then expired
DeliveryMQTT QoS 1

Device contract

Your device receives this on storage/<siteId>/cmd:

{
  "requestId": "sasiai-1786655498795-7fda52",
  "action": "export",
  "powerKw": 25,
  "issuedAt": "2026-08-13T21:11:39.700Z",
  "source": "opf-dispatcher-agent"
}

And must reply on storage/<siteId>/cmd/ack with the same requestId:

{ "requestId": "sasiai-1786655498795-7fda52", "status": "ok" }

# or to reject it
{ "requestId": "sasiai-1786655498795-7fda52", "status": "rejected", "error": "inverter fault" }

Errors & limits

Every failure uses the same envelope:

{ "error": "powerKw exceeds site limit of 30 kW" }
StatusMeaning
400Validation failed: missing field, bad type, value out of range.
401No credentials, expired access token, or invalid API key.
403Authenticated but the role is too low, or signup disabled.
404Resource does not exist, or belongs to another account.
409Conflict: email already registered, siteId already taken.
429Rate limit exceeded. Retry after the window resets.
500Unexpected server error. Details are logged, not returned.
503Health check only: MongoDB or MQTT is unavailable.
Global limit300 requests per minute per IP
Login limit10 attempts per 15 minutes per IP
Body limit256 kB
HeadersRateLimit-* on every response

MQTT ingest

The service subscribes to the broker and stores whatever your hardware publishes. The wildcard segment of the topic is the siteId; messages from unregistered ids are dropped, so telemetry can never land in the wrong account.

TopicDirectionPurpose
storage/<siteId>/rtinboundReal time battery telemetry.
storage/<siteId>/statusinboundDevice status updates.
rut/<siteId>/telemetryinboundGateway telemetry.
rut/<siteId>/heartbeatinboundLiveness ping.
storage/<siteId>/cmdoutboundDispatch commands, QoS 1.

Payload sample

mosquitto_pub -h emqx -p 1883 -u opf_api -P "$MQTT_PASSWORD" \
  -t storage/sasiai/rt \
  -m '{"grid_kw":0.02,"pv_kw":0,"load_kw":0.11,"battery_kw":-0.2,"soc_pct":21,"temp_c":27}'

Field normalisation

Devices name things differently. These aliases are all accepted and mapped to one shape:

MetricAccepted keysMeaning
gridKwgrid_kw, gridKw, grid, p_gridGrid power. Negative means exporting.
pvKwpv_kw, pvKw, pv, solar_kwSolar generation.
loadKwload_kw, loadKw, load, p_loadSite consumption.
batteryKwbattery_kw, batteryKw, battery, p_battBattery power. Negative means discharging.
socPctsoc_pct, socPct, socState of charge, percent.
tempCtemp_c, tempC, temp, temperatureBattery or ambient temperature.
voltageVvoltage, voltage_v, vPack voltage.
currentAcurrent, current_a, iPack current.

Unrecognised keys are preserved verbatim under payload, so nothing is lost. The latest message per site is always available instantly; history is sampled at most once per minute and expires after 90 days. In parallel the dispatcher agent writes one minute rollups (avg, min and max per metric) into telemetry_1m, kept for 400 days for long range charts and reporting.

System

GET /health None

Liveness and dependency check. Returns 503 when MongoDB or MQTT is down, so it can drive a load balancer or uptime monitor.

Request

curl -s https://api.opf.lt/health

Response

{
  "status": "ok",
  "service": "opf-api",
  "ts": "2026-08-13T20:23:45.834Z",
  "mongo": true,
  "mqtt": {
    "enabled": true,
    "connected": true,
    "topics": ["storage/+/rt", "storage/+/status", "rut/+/telemetry"]
  }
}
GET /api/v1/operations/status Bearer or API key

Aggregates dispatcher-agent and dispatcher-brains health for the Operations view.

Request

curl -s https://api.opf.lt/api/v1/operations/status \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{
  "status": "ok",
  "agent": { "status": "ok", "mqtt": true, "postgres": true },
  "brains": { "status": "ready" }
}
GET /api/v1/meta None

Service metadata and a machine readable list of every route.

Request

curl -s https://api.opf.lt/api/v1/meta

Response

{
  "service": "opf-api",
  "version": "1.0.0",
  "timezone": "Europe/Vilnius",
  "endpoints": ["POST   /api/v1/auth/register", "POST   /api/v1/auth/login", "..."]
}

Decision Service

POST /v1/decisions Internal dispatcher-brains

Canonical normalized BESS decision contract. The dispatcher-brains service validates site state, horizon, battery constraints, forecasts, market data and objectives, then returns a deterministic MILP schedule with mutually exclusive charge/discharge modes, an independent safety check or an explicit idle fallback. The authenticated account scope is enforced and repeated account/site/idempotency-key requests replay the existing decision.

FieldTypeRequiredNotes
request_idstringrequiredCaller request identifier.
site_idstringrequiredBESS/site identifier.
requested_atISO datetimerequiredTimezone-aware request timestamp.
horizonobjectrequiredstart, end, slot_minutes and optional timezone.
stateobjectrequiredCurrent SoC, availability and observed telemetry.
constraintsobjectrequiredCapacity, power, SoC, reserve and efficiency limits.
objectiveobjectrequiredCost, peak, self-consumption, market or balancing objective.

Request

curl -s -X POST http://dispatcher_brains:3010/v1/decisions \
  -H "Content-Type: application/json" \
  -H "x-account-id: $ACCOUNT_ID" \
  -d @opf_bess/dispatcher-brains/contracts/decision-v1.request.example.json

Response

200 OK
{
  "status": "calculated",
  "decision_id": "dec_...",
  "solver_status": "feasible",
  "schedule": []
}

Error responses

401 Unauthorized
  Returned when x-account-id is missing.

  422 Unprocessable Entity
  Returned when timestamps, ranges, slot alignment, SoC limits, stale telemetry or required fields are invalid.
POST /v1/execution/authorize Internal dispatcher-brains

Evaluate an execution request against safety and rollout gates and persist an account-scoped audit event. This endpoint never publishes a device command.

Request

curl -s -X POST http://dispatcher_brains:3010/v1/execution/authorize \
  -H "Content-Type: application/json" \
  -H "x-account-id: $ACCOUNT_ID" \
  -d '{"decision_id":"dec_...","site_id":"site-001","status":"approved","mode":"approval_required","telemetry_observed_at":"2026-09-16T08:00:00Z"}'

Response

{ "allowed": false, "reason": "telemetry_stale", "command_id": null }
POST /v1/decisions/assemble Internal dispatcher-brains

Build a normalized decision request from PostgreSQL/Influx market, balancing, weather, PV and load data. Returns quality warnings or errors before optimization.

FieldTypeRequiredNotes
payloadobjectrequiredCaller-owned site state, constraints, objective and horizon.
x-account-idstringrequiredAuthenticated account scope.

Request

curl -s -X POST http://dispatcher_brains:3010/v1/decisions/assemble \
  -H "Content-Type: application/json" \
  -H "x-account-id: $ACCOUNT_ID" \
  -d @opf_bess/dispatcher-brains/contracts/decision-v1.request.example.json

Response

{ "accepted": true, "quality": { "completeness": 1 }, "request": {} }
GET /v1/kpis Internal dispatcher-brains

List account-scoped solver, cost, revenue and future forecast/execution KPI records.

Request

curl -s "http://dispatcher_brains:3010/v1/kpis?site_id=site-001" -H "x-account-id: $ACCOUNT_ID"

Response

{ "kpis": [{ "site_id": "site-001", "metric": "solver_duration", "value": 42.1, "unit": "ms" }], "aggregates": [{ "metric": "solver_duration", "average": 42.1 }] }
POST /v1/decisions/cycle Internal dispatcher-brains

Assemble persisted market, balancing, weather, load and PV inputs, apply quality gates, and run the normal safe decision path. It never publishes a device command.

Request

curl -s -X POST http://dispatcher_brains:3010/v1/decisions/cycle -H "Content-Type: application/json" -H "x-account-id: $ACCOUNT_ID" -d @opf_bess/dispatcher-brains/contracts/decision-v1.request.example.json

Response

{ "accepted": true, "quality": { "completeness": 1 }, "decision": { "status": "calculated" } }
POST /v1/forecasts/evaluate Internal dispatcher-brains

Evaluate MAE, RMSE, bias, MAPE and optional prediction-interval coverage against configured thresholds.

Request

curl -s -X POST http://dispatcher_brains:3010/v1/forecasts/evaluate -H "Content-Type: application/json" -H "x-account-id: $ACCOUNT_ID" -d '{"actual":[10,20],"predicted":[12,18],"lower":[8,15],"upper":[14,25],"thresholds":{"rmse":2}}'

Response

{ "metrics": { "mae": 2, "rmse": 2, "interval_coverage": 1 }, "quality": { "accepted": true } }
POST /v1/orchestration/resolve Internal dispatcher-brains

Resolve due 15-minute candidate slots by priority. This is currently dry-run orchestration: it persists the winner but never executes or publishes a device command.

FieldTypeRequiredNotes
site_idstringrequiredSite whose due slot candidates should be resolved.

Request

curl -s -X POST "http://dispatcher_brains:3010/v1/orchestration/resolve?site_id=site-001" -H "x-account-id: $ACCOUNT_ID"

Response

{ "resolved": ["dec_..._slot_0"], "executed": [], "device_publishing": false }
GET /api/v1/decisions Bearer or API key

List persisted dispatcher-brains decision runs for the authenticated account context.

Request

curl -s https://api.opf.lt/api/v1/decisions \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{ "decisions": [{ "decision_id": "dec_...", "status": "calculated", "site_id": "site-001" }] }
POST /api/v1/decisions/:decisionId/transition operator

Approve or advance a persisted decision through the lifecycle. Invalid transitions are rejected.

FieldTypeRequiredNotes
target_statusstringrequiredapproved, scheduled, executing, completed, rejected or cancelled, subject to lifecycle rules.

Request

curl -s -X POST https://api.opf.lt/api/v1/decisions/dec_.../transition \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"target_status":"approved"}'

Response

{ "decision_id": "dec_...", "status": "approved" }

Authentication

POST /api/v1/auth/register None

Creates an account and its first owner user. Allowed only when no users exist yet (first run bootstrap) or when ALLOW_SIGNUP is enabled. Password must be at least 12 characters and contain lower case, upper case and a digit.

FieldTypeRequiredNotes
emailstringrequiredLogin address. Stored lower case, must be unique.
passwordstringrequiredMinimum 12 chars, mixed case and a digit.
accountNamestringrequiredOrganisation name. A URL slug is derived from it.
namestringoptionalDisplay name. Defaults to the email.

Request

curl -s -X POST https://api.opf.lt/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "email": "admin@opf.lt",
    "password": "OpfDemo2026Pass",
    "name": "Admin",
    "accountName": "OPF"
  }'

Response

{
  "user": {
    "id": "6a7e2814d2f9fdca48904304",
    "accountId": "6a7e2813d2f9fdca48904303",
    "email": "admin@opf.lt",
    "name": "Admin",
    "role": "owner",
    "active": true,
    "createdAt": "2026-08-13T20:24:51.796Z",
    "lastLoginAt": null
  },
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refreshToken": "K9mZ3Xq...",
  "expiresAt": "2026-09-12T20:24:51.812Z"
}

Error responses

409 Conflict
{ "error": "email already registered" }

403 Forbidden
{ "error": "signup is disabled" }
POST /api/v1/auth/login None

Exchanges credentials for an access token (15 minutes) and a refresh token (30 days). Limited to 10 attempts per 15 minutes per IP. Unknown email and wrong password return the same error.

FieldTypeRequiredNotes
emailstringrequiredRegistered email address.
passwordstringrequiredAccount password.

Request

curl -s -X POST https://api.opf.lt/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "admin@opf.lt", "password": "OpfDemo2026Pass"}'

Response

{
  "user": {
    "id": "6a7e2814d2f9fdca48904304",
    "accountId": "6a7e2813d2f9fdca48904303",
    "email": "admin@opf.lt",
    "name": "Admin",
    "role": "owner",
    "active": true,
    "createdAt": "2026-08-13T20:24:51.796Z",
    "lastLoginAt": "2026-08-13T21:02:10.114Z"
  },
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refreshToken": "K9mZ3Xq...",
  "expiresAt": "2026-09-12T21:02:10.130Z"
}

Error responses

401 Unauthorized
{ "error": "invalid credentials" }

429 Too Many Requests
{ "error": "too many attempts, try again later" }
POST /api/v1/auth/refresh Refresh token

Rotates the refresh token and returns a fresh access token. Refresh tokens are single use: the old one is destroyed, so replaying a stolen token fails.

FieldTypeRequiredNotes
refreshTokenstringrequiredThe token returned by login or a previous refresh.

Request

curl -s -X POST https://api.opf.lt/api/v1/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken": "K9mZ3Xq..."}'

Response

{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refreshToken": "Pq7Rt2...",
  "expiresAt": "2026-09-12T21:14:03.552Z"
}

Error responses

401 Unauthorized
{ "error": "invalid or expired refresh token" }
POST /api/v1/auth/logout None

Destroys the supplied refresh token. Always returns success so it cannot be used to probe token validity.

Request

curl -s -X POST https://api.opf.lt/api/v1/auth/logout \
  -H "Content-Type: application/json" \
  -d '{"refreshToken": "K9mZ3Xq..."}'

Response

{ "ok": true }
GET /api/v1/auth/me Bearer or API key

Returns the caller identity and their account. With an API key it reports the key role instead of a user.

Request

curl -s https://api.opf.lt/api/v1/auth/me \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{
  "user": {
    "id": "6a7e2814d2f9fdca48904304",
    "accountId": "6a7e2813d2f9fdca48904303",
    "email": "admin@opf.lt",
    "name": "Admin",
    "role": "owner",
    "active": true,
    "createdAt": "2026-08-13T20:24:51.796Z",
    "lastLoginAt": "2026-08-13T21:02:10.114Z"
  },
  "account": {
    "id": "6a7e2813d2f9fdca48904303",
    "name": "OPF",
    "slug": "opf",
    "plan": "standard"
  }
}
POST /api/v1/auth/password Bearer

Changes your own password and signs out every other session. Not available to API keys.

FieldTypeRequiredNotes
currentPasswordstringrequiredExisting password.
newPasswordstringrequiredMust satisfy the password policy.

Request

curl -s -X POST https://api.opf.lt/api/v1/auth/password \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"currentPassword": "OpfDemo2026Pass", "newPassword": "NewOpfPass2026x"}'

Response

{ "ok": true, "message": "password updated, other sessions signed out" }

Accounts

GET /api/v1/accounts/current Bearer or API key

Your account profile plus usage counters.

Request

curl -s https://api.opf.lt/api/v1/accounts/current \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{
  "account": {
    "id": "6a7e2813d2f9fdca48904303",
    "name": "OPF",
    "slug": "opf",
    "plan": "standard",
    "active": true,
    "createdAt": "2026-08-13T20:24:51.780Z",
    "contact": { "email": "info@opf.systems", "phone": "", "address": "Kalviai, Lithuania" }
  },
  "usage": { "users": 3, "sites": 8 }
}
PATCH /api/v1/accounts/current owner

Updates the account display name and contact block.

FieldTypeRequiredNotes
namestringoptionalOrganisation display name.
contactobjectoptionalObject with email, phone and address strings.

Request

curl -s -X PATCH https://api.opf.lt/api/v1/accounts/current \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "OPF Energy",
    "contact": { "email": "info@opf.systems", "address": "Kalviai, Lithuania" }
  }'

Response

{ "account": { "id": "6a7e2813d2f9fdca48904303", "name": "OPF Energy", "slug": "opf" } }
GET /api/v1/accounts/current/audit admin

Audit trail of account changes: logins, user and site changes, commands, API key lifecycle. Retained 365 days.

FieldTypeRequiredNotes
limitqueryoptional1 to 500. Default 100.

Request

curl -s "https://api.opf.lt/api/v1/accounts/current/audit?limit=5" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{
  "entries": [
    { "ts": "2026-08-13T21:05:11.004Z", "action": "site.command", "actorId": "6a7e2814d2f9fdca48904304", "target": "sasiai" },
    { "ts": "2026-08-13T21:04:02.881Z", "action": "site.created", "actorId": "6a7e2814d2f9fdca48904304", "target": "sasiai" },
    { "ts": "2026-08-13T21:02:10.120Z", "action": "auth.login", "actorId": "6a7e2814d2f9fdca48904304", "target": null }
  ]
}

API keys

GET /api/v1/accounts/current/api-keys admin

Lists active keys. Only the prefix is stored in readable form; the secret cannot be recovered.

Request

curl -s https://api.opf.lt/api/v1/accounts/current/api-keys \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{
  "apiKeys": [
    {
      "id": "6a7e2999d2f9fdca48904311",
      "name": "grafana-readonly",
      "prefix": "opf_9Kd2xQ",
      "role": "viewer",
      "createdAt": "2026-08-13T21:10:44.201Z",
      "lastUsedAt": "2026-08-13T21:12:03.559Z"
    }
  ]
}
POST /api/v1/accounts/current/api-keys admin

Creates a machine credential for dashboards or scripts. The plaintext key is returned exactly once. Send it later in the X-API-Key header. You cannot grant a role above your own.

FieldTypeRequiredNotes
namestringrequiredLabel shown in the key list.
rolestringoptionalviewer, operator or admin. Default operator.

Request

curl -s -X POST https://api.opf.lt/api/v1/accounts/current/api-keys \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "grafana-readonly", "role": "viewer"}'

Response

{
  "apiKey": { "id": "6a7e2999d2f9fdca48904311", "name": "grafana-readonly", "prefix": "opf_9Kd2xQ", "role": "viewer" },
  "key": "opf_9Kd2xQ7bTnVw5sLpAeR3yUfHgKmZcXdQ2NvBjWtE"
}
DELETE /api/v1/accounts/current/api-keys/:id admin

Revokes a key immediately. Revoked keys are kept for audit purposes but never authenticate again.

Request

curl -s -X DELETE https://api.opf.lt/api/v1/accounts/current/api-keys/6a7e2999d2f9fdca48904311 \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{ "ok": true }

Users

GET /api/v1/users admin

Lists users in your account. Password hashes are never returned.

Request

curl -s https://api.opf.lt/api/v1/users \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{
  "users": [
    {
      "id": "6a7e2814d2f9fdca48904304",
      "accountId": "6a7e2813d2f9fdca48904303",
      "email": "admin@opf.lt",
      "name": "Admin",
      "role": "owner",
      "active": true,
      "createdAt": "2026-08-13T20:24:51.796Z",
      "lastLoginAt": "2026-08-13T21:02:10.114Z"
    }
  ]
}
POST /api/v1/users admin

Invites a user into your account with a starting password. You cannot create a user with a role above your own.

FieldTypeRequiredNotes
emailstringrequiredMust be unique across the platform.
passwordstringrequiredMust satisfy the password policy.
rolestringrequiredviewer, operator, admin or owner.
namestringoptionalDisplay name.

Request

curl -s -X POST https://api.opf.lt/api/v1/users \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "operator@opf.lt",
    "password": "FieldOps2026Key",
    "name": "Field Operator",
    "role": "operator"
  }'

Response

{
  "user": {
    "id": "6a7e2a55d2f9fdca48904308",
    "accountId": "6a7e2813d2f9fdca48904303",
    "email": "operator@opf.lt",
    "name": "Field Operator",
    "role": "operator",
    "active": true,
    "createdAt": "2026-08-13T21:20:31.442Z",
    "lastLoginAt": null
  }
}

Error responses

403 Forbidden
{ "error": "cannot grant a role above your own" }
GET /api/v1/users/:id admin

Fetches one user from your account. Users in other accounts return 404, never 403.

Request

curl -s https://api.opf.lt/api/v1/users/6a7e2a55d2f9fdca48904308 \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{ "user": { "id": "6a7e2a55d2f9fdca48904308", "email": "operator@opf.lt", "role": "operator", "active": true } }
PATCH /api/v1/users/:id admin

Updates name, role, active flag or password. Users above your own role cannot be modified.

FieldTypeRequiredNotes
namestringoptionalDisplay name.
rolestringoptionalCannot exceed your own role.
activebooleanoptionalfalse disables login immediately.
passwordstringoptionalResets the password.

Request

curl -s -X PATCH https://api.opf.lt/api/v1/users/6a7e2a55d2f9fdca48904308 \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"role": "admin", "active": true}'

Response

{ "user": { "id": "6a7e2a55d2f9fdca48904308", "email": "operator@opf.lt", "role": "admin", "active": true } }
DELETE /api/v1/users/:id owner

Removes a user and all of their sessions. You cannot delete yourself.

Request

curl -s -X DELETE https://api.opf.lt/api/v1/users/6a7e2a55d2f9fdca48904308 \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{ "ok": true }

Sites

GET /api/v1/sites viewer

Lists every site in your account with its latest telemetry attached. A site counts as online when telemetry arrived within the stale window (default 300 seconds).

Request

curl -s https://api.opf.lt/api/v1/sites \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{
  "sites": [
    {
      "siteId": "sasiai",
      "name": "OPF Sasiai",
      "location": "Kalviai",
      "capacityKwh": 150,
      "maxChargeKw": 30,
      "maxDischargeKw": 30,
      "gridExportKw": 30,
      "minSocPct": 5,
      "maxSocPct": 100,
      "mode": "auto",
      "minExportPrice": 115,
      "timezone": "Europe/Vilnius",
      "active": true,
      "createdAt": "2026-08-13T21:04:02.870Z",
      "online": true,
      "latest": {
        "ts": "2026-08-13T21:25:07.285Z",
        "metrics": { "gridKw": 0.02, "pvKw": 0, "loadKw": 0.11, "batteryKw": -0.2, "socPct": 21, "tempC": 27 }
      }
    }
  ]
}
POST /api/v1/sites admin

Registers a site. The siteId must match the wildcard segment your hardware publishes on, for example storage/sasiai/rt. Telemetry from unregistered site ids is discarded.

FieldTypeRequiredNotes
siteIdstringrequired2 to 49 chars: a-z, 0-9, dash, underscore. Globally unique.
namestringrequiredHuman readable name.
locationstringoptionalFree text location.
capacityKwhnumberoptionalUsable energy capacity. Default 0.
maxChargeKwnumberoptionalCharge power limit, enforced on commands.
maxDischargeKwnumberoptionalDischarge power limit, enforced on commands.
gridExportKwnumberoptionalConnection export cap.
minSocPctnumberoptionalReserve floor. Default 5.
maxSocPctnumberoptionalUpper limit. Default 100.
minExportPricenumberoptionalPrice floor in EUR/MWh for automatic export.
timezonestringoptionalIANA zone. Defaults to the service timezone.

Request

curl -s -X POST https://api.opf.lt/api/v1/sites \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "siteId": "sasiai",
    "name": "OPF Sasiai",
    "location": "Kalviai",
    "capacityKwh": 150,
    "maxChargeKw": 30,
    "maxDischargeKw": 30,
    "gridExportKw": 30,
    "minSocPct": 5,
    "minExportPrice": 115
  }'

Response

{
  "site": {
    "siteId": "sasiai",
    "name": "OPF Sasiai",
    "capacityKwh": 150,
    "maxChargeKw": 30,
    "maxDischargeKw": 30,
    "mode": "auto",
    "active": true,
    "online": false,
    "latest": null
  }
}

Error responses

409 Conflict
{ "error": "siteId already exists" }

400 Bad Request
{ "error": "siteId must be 2-49 chars of a-z, 0-9, dash or underscore" }
GET /api/v1/sites/:siteId viewer

One site with its latest telemetry.

Request

curl -s https://api.opf.lt/api/v1/sites/sasiai \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{
  "site": {
    "siteId": "sasiai",
    "name": "OPF Sasiai",
    "capacityKwh": 150,
    "mode": "auto",
    "online": true,
    "latest": {
      "ts": "2026-08-13T21:25:07.285Z",
      "metrics": { "gridKw": 0.02, "socPct": 21, "batteryKw": -0.2 }
    }
  }
}
PATCH /api/v1/sites/:siteId admin

Updates site configuration. Send only the fields you want changed.

Request

curl -s -X PATCH https://api.opf.lt/api/v1/sites/sasiai \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"minExportPrice": 120, "maxDischargeKw": 28}'

Response

{ "site": { "siteId": "sasiai", "minExportPrice": 120, "maxDischargeKw": 28, "mode": "auto" } }
DELETE /api/v1/sites/:siteId owner

Deletes the site and its latest telemetry record. Historical points expire on their own retention schedule.

Request

curl -s -X DELETE https://api.opf.lt/api/v1/sites/sasiai \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{ "ok": true }
POST /api/v1/sites/:siteId/command operator

Queues a dispatch command. The API does not touch the device: the dispatcher agent claims the command, publishes it to storage/{siteId}/cmd at QoS 1 and tracks the acknowledgement. Returns 202 with a requestId you can poll on /api/v1/commands/:requestId. powerKw is validated against the site limit both here and again by the agent at send time.

FieldTypeRequiredNotes
actionstringrequiredexport, charge, auto, idle or stop.
powerKwnumberoptionalTarget power. Must not exceed the matching site limit.

Request

curl -s -X POST https://api.opf.lt/api/v1/sites/sasiai/command \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"action": "export", "powerKw": 25}'

Response

202 Accepted
{
  "ok": true,
  "command": {
    "requestId": "sasiai-1786655498795-7fda52",
    "siteId": "sasiai",
    "action": "export",
    "powerKw": 25,
    "status": "queued",
    "createdAt": "2026-08-13T21:11:38.795Z"
  }
}

Error responses

400 Bad Request
{ "error": "powerKw exceeds site limit of 30 kW" }

400 Bad Request
{ "error": "action must be one of: export, charge, auto, idle, stop" }

Commands

GET /api/v1/commands viewer

Recent commands for your account, newest first. Use it to follow a dispatch from queued through to acknowledged.

FieldTypeRequiredNotes
siteIdqueryoptionalFilter to a single site.
statusqueryoptionalqueued, sending, sent, acked, failed or expired.
limitqueryoptional1 to 500. Default 50.

Request

curl -s "https://api.opf.lt/api/v1/commands?siteId=sasiai&limit=3" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{
  "commands": [
    {
      "requestId": "sasiai-1786655498795-7fda52",
      "siteId": "sasiai",
      "action": "export",
      "powerKw": 25,
      "status": "acked",
      "attempts": 1,
      "createdAt": "2026-08-13T21:11:38.795Z",
      "sentAt": "2026-08-13T21:11:39.738Z",
      "ackedAt": "2026-08-13T21:11:53.155Z",
      "error": null
    }
  ]
}
GET /api/v1/commands/:requestId viewer

One command with the raw acknowledgement payload the device returned.

Request

curl -s https://api.opf.lt/api/v1/commands/sasiai-1786655498795-7fda52 \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{
  "command": {
    "requestId": "sasiai-1786655498795-7fda52",
    "siteId": "sasiai",
    "action": "export",
    "powerKw": 25,
    "status": "acked",
    "attempts": 1,
    "createdAt": "2026-08-13T21:11:38.795Z",
    "sentAt": "2026-08-13T21:11:39.738Z",
    "ackedAt": "2026-08-13T21:11:53.155Z",
    "error": null
  },
  "ack": { "requestId": "sasiai-1786655498795-7fda52", "status": "ok" }
}

Error responses

404 Not Found
{ "error": "command not found" }

Telemetry

GET /api/v1/telemetry/live viewer

Latest reading for every site in your account. Ideal for a dashboard poll every few seconds.

Request

curl -s https://api.opf.lt/api/v1/telemetry/live \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{
  "ts": "2026-08-13T20:25:09.501Z",
  "sites": [
    {
      "siteId": "sasiai",
      "ts": "2026-08-13T20:25:07.285Z",
      "online": true,
      "metrics": { "gridKw": 0.02, "pvKw": 0, "loadKw": 0.11, "batteryKw": -0.2, "socPct": 21, "tempC": 27 }
    }
  ]
}
GET /api/v1/telemetry/live/:siteId viewer

Latest reading for one site, including the raw device payload exactly as published.

Request

curl -s https://api.opf.lt/api/v1/telemetry/live/sasiai \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{
  "siteId": "sasiai",
  "ts": "2026-08-13T20:25:07.285Z",
  "metrics": { "gridKw": 0.02, "pvKw": 0, "loadKw": 0.11, "batteryKw": -0.2, "socPct": 21, "tempC": 27 },
  "payload": { "grid_kw": 0.02, "pv_kw": 0, "load_kw": 0.11, "battery_kw": -0.2, "soc_pct": 21, "temp_c": 27 }
}

Error responses

404 Not Found
{ "error": "no telemetry received yet" }
GET /api/v1/telemetry/history/:siteId viewer

Time series for one site, ascending by timestamp. History is sampled at most once per minute per site and retained for 90 days by default.

FieldTypeRequiredNotes
fromqueryoptionalISO timestamp. Defaults to 24 hours ago.
toqueryoptionalISO timestamp. Defaults to now.
limitqueryoptional1 to 5000. Default 1000.

Request

curl -s "https://api.opf.lt/api/v1/telemetry/history/sasiai?from=2026-08-13T00:00:00Z&to=2026-08-13T23:59:59Z&limit=500" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{
  "siteId": "sasiai",
  "from": "2026-08-13T00:00:00.000Z",
  "to": "2026-08-13T23:59:59.000Z",
  "count": 2,
  "points": [
    { "ts": "2026-08-13T20:24:07.101Z", "metrics": { "gridKw": 0.01, "socPct": 21, "batteryKw": -0.18 } },
    { "ts": "2026-08-13T20:25:07.285Z", "metrics": { "gridKw": 0.02, "socPct": 21, "batteryKw": -0.2 } }
  ]
}

Error responses

400 Bad Request
{ "error": "from must be before to" }
GET /api/v1/telemetry/summary viewer

Fleet roll up: how many sites are online, aggregate capacity, stored energy derived from each site SOC, and combined power flows.

Request

curl -s https://api.opf.lt/api/v1/telemetry/summary \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{
  "ts": "2026-08-13T20:25:09.550Z",
  "sites": 8,
  "online": 7,
  "capacityKwh": 1211.5,
  "storedKwh": 402.31,
  "averageSocPct": 33.2,
  "batteryKw": -73.14,
  "pvKw": 0.01,
  "gridKw": -64.64
}
GET /api/v1/telemetry/stream viewer

Server sent events. Every ingested message for your account is pushed immediately, with a comment ping every 25 seconds to hold the connection open. Only messages from your own sites are delivered.

Request

curl -N -s https://api.opf.lt/api/v1/telemetry/stream \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

event: ready
data: {"ts":"2026-08-13T20:25:00.001Z"}

event: telemetry
data: {"siteId":"sasiai","ts":"2026-08-13T20:25:07.285Z","metrics":{"gridKw":0.02,"socPct":21}}

: ping
GET /api/v1/telemetry/prices viewer

Market prices from the shared price collection, used by the optimiser to choose export windows.

FieldTypeRequiredNotes
fromqueryoptionalISO timestamp. Defaults to 24 hours ago.
toqueryoptionalISO timestamp. Defaults to now.

Request

curl -s "https://api.opf.lt/api/v1/telemetry/prices?from=2026-08-14T00:00:00Z&to=2026-08-15T00:00:00Z" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{
  "collection": "price_market",
  "from": "2026-08-14T00:00:00.000Z",
  "to": "2026-08-15T00:00:00.000Z",
  "count": 2,
  "points": [
    { "start": "2026-08-14T08:00:00.000Z", "end": "2026-08-14T08:15:00.000Z", "price": 177.07 },
    { "start": "2026-08-14T09:00:00.000Z", "end": "2026-08-14T09:15:00.000Z", "price": 174.07 }
  ]
}
GET /api/v1/telemetry/starred viewer

Latest metrics for devices marked as starred in the device manager. This is the REST snapshot used by the dispatcher telemetry page.

Request

curl -s https://api.opf.lt/api/v1/telemetry/starred \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{
  "ts": "2026-09-14T10:00:00.000Z",
  "devices": [
    {
      "deviceId": "vires-alpha",
      "name": "Vires Alpha Park",
      "type": "storage",
      "groupId": "kaunas",
      "starred": true,
      "online": true,
      "latest": { "ts": "2026-09-14T10:00:00.000Z", "metrics": { "batteryKw": 42.1, "socPct": 77.4 } }
    }
  ]
}
GET /api/v1/telemetry/ws viewer

WebSocket feed for starred devices. Authenticate with ?token=ACCESS_TOKEN (or Authorization on the upgrade). Server sends ready, then a snapshot of starred devices, then telemetry frames as MQTT is ingested. Send {"type":"refresh"} to reload the snapshot after starring a device.

Request

wscat -c "wss://api.opf.lt/api/v1/telemetry/ws?token=$ACCESS_TOKEN"

Response

{ "type": "ready", "ts": "2026-09-14T10:00:00.000Z" }
{ "type": "snapshot", "ts": "...", "devices": [] }
{ "type": "telemetry", "deviceId": "vires-alpha", "ts": "...", "metrics": { "socPct": 77.4 } }

Inventory

GET /api/v1/groups viewer

List device groups for the current account.

Request

curl -s https://api.opf.lt/api/v1/groups \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{ "groups": [{ "groupId": "kaunas", "name": "Kaunas", "deviceCount": 3 }] }
POST /api/v1/groups operator

Create a device group. groupId is derived from name unless provided.

FieldTypeRequiredNotes
namestringrequiredDisplay name.
groupIdstringoptionalStable id. a-z, 0-9, dash, underscore.
descriptionstringoptionalShort note.

Request

curl -s -X POST https://api.opf.lt/api/v1/groups \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Kaunas"}'

Response

{ "group": { "groupId": "kaunas", "name": "Kaunas", "deviceCount": 0 } }
GET /api/v1/devices viewer

List managed devices. Filter with groupId, type (site|inverter|storage) or starred=true. Live telemetry is read from InfluxDB; starred is only a UI preference.

Request

curl -s "https://api.opf.lt/api/v1/devices?starred=true" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

{ "devices": [{ "deviceId": "vires-alpha", "type": "storage", "starred": true }] }
POST /api/v1/devices operator

Add a site, then an inverter under that site, then storage under the inverter. Site MQTT credentials are persisted in PostgreSQL and used by dispatcher-agent for per-site subscriptions.

FieldTypeRequiredNotes
namestringrequiredDisplay name.
typestringrequiredsite, unit or storage.
deviceIdstringoptionalMQTT key. Derived from name if omitted.
groupIdstringoptionalExisting group.
parentDeviceIdstringoptionalRequired site for inverter, required inverter for storage.
mqttHoststringsite onlySite MQTT broker host.
mqttPortnumbersite onlyMQTT broker port, normally 1883 or 8883.
mqttTlsbooleansite onlyUse TLS for the site broker.
mqttUsernamestringsite onlyCredential used by dispatcher-agent.
mqttPasswordstringsite onlyStored securely according to deployment policy.
latitudenumbersite onlySite latitude, -90 to 90.
longitudenumbersite onlySite longitude, -180 to 180.
pvModuleCountnumbersite onlyPV module count.
pvModulePowerWpnumbersite onlyPV module rated power.
pvTiltDegnumbersite onlyPV tilt, 0 to 90 degrees.
pvAzimuthDegnumbersite onlyPV azimuth, 0 to 360 degrees.
starredbooleanoptionalUI preference; it does not control Influx telemetry visibility.

Request

curl -s -X POST https://api.opf.lt/api/v1/devices \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Vires Alpha","type":"storage","groupId":"kaunas","starred":true}'

Response

{ "device": { "deviceId": "vires-alpha", "type": "storage", "starred": true } }

Dispatcher Brains API Manual

Overview

Dispatcher Brains is the standalone decision engine for the OPF BESS platform. It is responsible for assembling a normalized decision request, validating inputs, optimizing charge and discharge schedules, checking physical and operational safety, persisting decisions, and returning the decision payload to the rest of the platform.

This service is intentionally safety-first and does not directly publish commands to field devices. Execution is authorized through a guarded policy interface, but actual device-triggering remains disabled unless an external execution layer explicitly implements it under a separate contract.

The canonical runtime data model is built around PostgreSQL persistence and InfluxDB-backed telemetry reads. MongoDB is legacy-only and is not the active source of truth for the brains decision pipeline.

Purpose and Scope

The brains service exists to answer a single operational question:

"Given the current site state, forecast, market conditions, and safety constraints, what is the safe and explainable battery schedule for the next decision horizon?"

Its responsibilities include:

  • validating request structure and input quality
  • assembling site, forecast, market, weather and reserve inputs
  • computing a deterministic schedule with a constrained optimization step
  • re-validating the schedule against physical and operational safety rules
  • persisting decision metadata, KPI records, slots and overrides
  • exposing decision lifecycle states and authorization hooks
  • producing explainable results and safe fallback decisions when inputs are stale, invalid or infeasible

It does not replace the dispatcher-agent or the field execution layer. It produces decisions, not direct device control.

System Boundary

The brains service sits between:

  • upstream data adapters and persistence layers
  • site and market input providers
  • a planning and optimization engine
  • an execution authorization layer
  • downstream UI and orchestration consumers

The service boundary is intentionally narrow:

  • it reads normalized inputs
  • it computes decisions
  • it persists auditable results
  • it exposes policy and authorization endpoints
  • it never executes device commands by itself

Runtime Architecture

Core runtime components

  • app.main
  • FastAPI application bootstrap
  • startup/shutdown lifecycle logging
  • router registration
  • app.api
  • all HTTP endpoints and orchestration flows
  • health, readiness, decision assembly, execution authorization, forecasts, KPI listing and lifecycle transitions
  • app.config
  • runtime configuration and production validation
  • readiness summary for Postgres, InfluxDB and solver timeouts
  • app.domain.models
  • decision request and domain contracts
  • app.input_assembly
  • building a normalized request from market, weather, forecast and telemetry inputs
  • app.input_quality
  • input acceptance, stale-data detection and validation checks
  • app.optimization.optimizer
  • deterministic optimization routine for charge/discharge scheduling
  • app.optimization.balancing
  • reserve allocation logic
  • app.safety.validator
  • independent safety check after optimization
  • app.persistence.repository
  • PostgreSQL storage for decisions, overrides, KPIs, slot candidates and audits
  • app.persistence.adapters
  • adapters for PostgreSQL and InfluxDB-backed reads
  • app.execution
  • execution policy and authorization
  • app.orchestrator
  • slot candidate resolution and due-candidate selection
  • app.forecasting.baseline
  • baseline metrics and forecast quality evaluation

Canonical operational model

The service uses one consistent sign convention for battery power:

  • positive battery_power_kw = discharge
  • negative battery_power_kw = charge
  • zero battery_power_kw = idle

This remains consistent across optimization, safety validation and persisted decision output.

Decision Lifecycle

A decision usually follows this path:

  1. client sends a normalized decision request with x-account-id
  2. service validates required account and payload fields
  3. request is assembled from source data and quality checks
  4. optimization runs deterministically
  5. reserve allocation and independent safety validation are applied
  6. a valid decision is persisted to PostgreSQL
  7. slot candidates are generated and saved
  8. KPIs are recorded
  9. the response returns the decision object or a fallback explanation

Accepted and rejected transitions are stored with account scope and audit metadata.

Request and Response Model

Required HTTP headers

All relevant endpoints require the x-account-id header. The service checks it at the API boundary before performing action-specific logic.

Decision request contract

The decision request carries the normalized site context, time horizon, battery state, forecast values, prices, physical limits and safety assumptions needed for optimization.

The model includes fields such as:

  • account and site identity
  • request id and idempotency key
  • decision mode
  • time horizon start and end
  • SoC and reserve constraints
  • charge/discharge power limits
  • efficiency assumptions
  • market and price inputs
  • forecasts and site metadata

If the same request is submitted repeatedly with the same account, site and idempotency key, the service returns the previously stored decision instead of recalculating it.

Decision response structure

A successful decision response contains:

  • decision_id
  • request_id
  • site_id
  • status
  • mode
  • generated_at
  • algorithm_version
  • solver_status
  • objective_value
  • schedule
  • warnings
  • constraints
  • explanation
  • fallback

Fallback responses are used when optimization fails or a request is rejected for safety or quality reasons. These remain structured and auditable rather than silent failures.

API Endpoints

GET /health

Returns liveness information for the service.

Example response:

{
  "status": "ok",
  "service": "dispatcher-brains",
  "environment": "production"
}

GET /ready

Returns a readiness summary and dependency status.

Example response:

{
  "status": "ready",
  "service": "dispatcher-brains",
  "environment": "production",
  "dependencies": {
    "postgres": {"configured": true},
    "influxdb": {"configured": true},
    "secrets": {"production_safe": true},
    "solver": {"valid_timeout": true}
  }
}

GET /v1/execution/policy

Returns execution policy metadata. The service explicitly reports:

  • automatic_enabled
  • emergency_stop
  • supported modes
  • device_publishing: false

This is a key safety contract: the brains service does not by itself push device commands.

POST /v1/execution/authorize

Authorizes an execution request under the service policy. This endpoint logs an execution audit event and returns whether the command is allowed and the associated command id.

Input shape is an execution request containing:

  • decision_id
  • site_id
  • mode
  • status
  • command metadata

The endpoint never publishes a device command to hardware.

POST /v1/decisions

Creates one normalized decision.

Operation:

  • validates x-account-id
  • rejects duplicate idempotent requests for the same site and key
  • optimizes the schedule
  • runs safety checks
  • records KPI and decision data
  • returns the decision or a structured fallback

POST /v1/decisions/assemble

Assembles and validates the decision inputs without immediately performing the full optimization cycle.

It accepts a payload object containing the site, horizon, price, market, forecast and telemetry context. It returns:

  • accepted: boolean
  • quality summary
  • assembled normalized request

This endpoint is used for input quality inspection and pre-flight validation.

POST /v1/decisions/cycle

Runs the decision assembly path and immediately performs the standard safe decision pipeline.

This is the service’s main full-cycle orchestration endpoint for planning runs.

GET /v1/decisions

Lists recent decisions for an account and optional site filter.

GET /v1/kpis

Returns recent KPI rows and aggregates for the caller account and site.

Typical KPI categories include:

  • solver_duration
  • cost
  • revenue
  • decision lifecycle outcomes
  • aggregate metrics derived from persisted records

POST /v1/forecasts/evaluate

Evaluates forecast quality using actual and predicted series.

Example payload:

{
  "actual": [1.0, 2.0, 3.0],
  "predicted": [1.1, 1.9, 3.2],
  "thresholds": {
    "mape_max": 15.0,
    "mae_max": 0.5
  }
}

Returns forecast metrics and a quality result.

POST /v1/orchestration/resolve

Resolves due slot candidates using the stored candidate queue and the current time. This path selects only top-priority due slots, while still keeping device execution disabled.

The endpoint returns:

  • resolved candidates
  • executed: []
  • device_publishing: false

POST /v1/decisions/{decision_id}/transition

Transitions a decision between lifecycle states, such as queued, approved, rejected, cancelled or completed, depending on the business policy. This is persisted with account scope and audit information.

POST /v1/decisions/{decision_id}/override

Stores a manual override record and associated reason/actor metadata.

Data Stores

PostgreSQL

The project’s canonical persistence layer is PostgreSQL. It stores:

  • decisions
  • decision status transitions
  • overrides
  • slot candidates
  • execution audit records
  • KPI values
  • model and forecast metadata
  • idempotent request tracking

The repository boundary is implemented in app.persistence.repository.

InfluxDB

InfluxDB is the telemetry and time-series data source for live normalized readings and measurement history. It is used for assembled decision inputs rather than as the replacement for PostgreSQL persistence.

MongoDB

MongoDB is not the authoritative data source for the modern brains workflow. It is treated as a legacy or transitional store only. The canonical architecture is PostgreSQL + InfluxDB.

Safety and Execution Rules

The brains service is deliberately conservative.

Safety principles

  • no automatic device control by default
  • explicit execution policy required before any command is allowed
  • optimizer schedule must pass an independent validation step
  • stale telemetry is rejected before optimization
  • fallback decisions are returned when conditions are poor or safety checks fail
  • execution authorization is audited and linked to a decision

Execution policy

The runtime config sets:

  • automatic_mode = false
  • emergency_stop = false

This means the service does not directly start automatic charging or discharging without an explicit higher-level policy flow.

Validation stages

  1. input quality assessment
  2. request assembly
  3. deterministic optimization
  4. reserve allocation
  5. safety validation
  6. persisted decision creation
  7. candidate generation and KPI persistence

If safety validation fails, the engine returns a fallback rather than an unsafe plan.

Configuration

Runtime properties are defined in app.config.Settings, with environment values loaded from the local environment or .env file.

Key settings include:

  • service_name
  • port
  • environment
  • postgres host, db, user and password
  • influxdb URL, token, org and bucket
  • solver_timeout_seconds
  • automatic_mode
  • emergency_stop

The production validator rejects unsafe default secrets and invalid solver timeouts.

A readiness check evaluates:

  • Postgres configuration
  • InfluxDB configuration
  • production secret strength
  • solver timeout validity

Local Development

Run from the brains directory:

python3.12 -m venv .venv
. .venv/bin/activate
pip install -e '.[test]'
uvicorn app.main:app --reload --port 3010

Check health:

curl http://127.0.0.1:3010/health
curl http://127.0.0.1:3010/ready

Run tests:

pytest

Or use the project compose stack from the repository root:

docker compose build dispatcher_brains
docker compose up -d dispatcher_brains
docker compose exec -T dispatcher_brains wget -qO- http://127.0.0.1:3010/health

Operational Notes

Decision quality gates

The request assembly flow evaluates all relevant quality signals before optimization. If key telemetry is stale or malformed, the system can reject the request and return a structured outcome instead of feeding a dangerous schedule downstream.

A/B and pilot readiness

The brains service supports local repo-controlled validation, but pilot deployment, contract validation and external device execution remain outside this repository’s direct control. The service is production-aware but intentionally prevents operation beyond validated internal logic.

Litgrid and external integrations

The codebase already documents the need for a formal Litgrid IEC 60870-5-104 implementation. That work depends on the official point map, IDs, protocol mapping and production test endpoint, and is not considered a local repository-only task.

Key Risks and Guardrails

  • invalid or stale inputs are not silently accepted
  • optimization is not trusted without independent safety validation
  • device execution remains disabled unless a higher-level contract explicitly authorizes it
  • cross-account separation is enforced through account-scoped persistence and API checks
  • all critical decision paths are persisted for audit and settlement review

Summary

Dispatcher Brains is a repository-controlled decision engine for safe battery scheduling. It is designed to be deterministic, explainable, auditable and operationally conservative. In its current architecture, it provides strong local gatekeeping and optimization logic, while keeping actual device execution under a separate, policy-controlled layer.

This is the correct architecture for a production-ready BESS decision service: compute safe decisions locally, persist them clearly, and require explicit external authorization before movement of real-world assets occurs.

Complete Project Technical Reference

This section consolidates the implementation information from the service README, Phase 5 execution policy, ordered migrations, work-order status and the current source tree. It is intended to be the technical reference for operators, integrators and developers using the public manual.

Service Components

The Python package is organized around these ownership boundaries:

ModuleResponsibility
app.mainFastAPI application lifecycle, logging and router registration.
app.apiHTTP contracts, account scope checks, decision orchestration and API responses.
app.configEnvironment settings, production secret checks and readiness calculation.
app.domainVersioned request and domain models with strict validation.
app.input_assemblyNormalization of telemetry, weather, prices, market and reserve inputs.
app.input_qualityCompleteness, finite-value, timezone, stale-data and quality assessment.
app.optimizationDeterministic schedule optimization and reserve allocation.
app.safetyIndependent physical and operational schedule validation.
app.forecastingBaseline and linear forecast artifacts, metrics and threshold evaluation.
app.persistencePostgreSQL repository and PostgreSQL/InfluxDB input adapters.
app.lifecycleDecision status transition rules.
app.orchestrator15-minute candidate creation and dry-run priority resolution.
app.executionGuarded authorization policy; no MQTT publication.
app.settlementCommitment validation, reserve and settlement calculations.
app.slot_priorityDeterministic candidate priority ranking.

The service is packaged as opf-dispatcher-brains for Python 3.12 or newer. Runtime dependencies include FastAPI, Uvicorn, Pydantic Settings, SciPy, psycopg, InfluxDB Client and scikit-learn. Test dependencies include pytest and HTTPX.

End-to-End Data Flow

  1. dispatcher-agent, opf-api or an internal scheduler submits a request with an authenticated account scope.
  2. The input assembly path reads or receives site state, telemetry, forecasts, weather, prices, market commitments and reserve data.
  3. Quality gates check timestamps, timezone, completeness, finite numeric values, stale telemetry and source quality.
  4. The domain model validates the normalized request and all physical ranges.
  5. The optimizer calculates a deterministic schedule with mutually exclusive charge/discharge decisions.
  6. Reserve allocation adjusts the schedule without violating the battery model.
  7. The independent safety validator checks every slot again.
  8. A valid decision, snapshot, slot candidates, transition information and KPI values are persisted to PostgreSQL.
  9. The caller receives a calculated schedule, warnings, explanations and objective values.
  10. The dry-run orchestrator may select one due candidate per 15-minute slot, but selection is not execution.

Versioned Decision Contract

The canonical example is contracts/decision-v1.request.example.json. A request contains:

BlockRequired technical content
Identityrequest_id, idempotency_key, site_id, requested_at.
Moderecommendation, approval_required, semi_automatic or automatic.
HorizonISO timestamps, slot_minutes and timezone. The normal planning interval is 15 minutes.
StateObservation timestamp, SoC, battery power, temperature, grid/load/PV power, availability and telemetry quality.
ForecastsLoad, PV and wind series aligned to the horizon.
MarketBidding zone plus day-ahead, intraday, balancing-up and balancing-down series.
ConstraintsCapacity, charge/discharge limits, SoC bounds, reserve SoC, efficiencies, ramp, grid import/export limits and degradation cost.
ObjectiveCost, peak, self-consumption, market revenue, balancing and degradation weights.
MetadataSource system and schema_version, currently decision-v1.

All timestamps must be timezone-aware. Numeric values must be finite and physically meaningful. Slot boundaries must align with the requested horizon. Invalid contract data returns a validation error instead of entering optimization.

Physical Battery Model

The canonical power convention is:

  • battery_power_kw > 0: discharge; stored energy decreases.
  • battery_power_kw < 0: charge; stored energy increases.
  • battery_power_kw == 0: idle.

For a slot with duration duration_h:

discharge_delta_kWh = -(power_kW * duration_h / discharge_efficiency)
charge_delta_kWh = abs(power_kW) * duration_h * charge_efficiency

The model checks capacity, minimum and maximum SoC, reserve SoC, charge and discharge power, ramp rate, temperature and grid import/export limits. The physical model is independent from the optimizer so a solver result cannot bypass safety validation.

Optimization Behavior

The optimizer is deterministic and uses a constrained SciPy-based formulation. Charge and discharge are mutually exclusive in each slot through binary mode gates. The objective can combine:

  • energy cost minimization
  • peak reduction
  • PV self-consumption
  • day-ahead or intraday market revenue
  • balancing reserve value
  • degradation cost

Negative market prices are handled explicitly. Solver status, duration, objective components and binding constraints are returned. A solver timeout, infeasible model or failed safety validation produces an idle/fallback outcome with warnings rather than an unvalidated schedule.

Input Quality and Fallbacks

Quality checks are performed before optimization and include:

  • required horizon and site fields
  • timezone-aware timestamps
  • finite numeric values
  • slot alignment
  • telemetry freshness
  • explicit telemetry quality flags
  • missing forecast or market values
  • source completeness and range checks

An accepted quality result can still contain warnings. A rejected quality result returns accepted: false from input assembly and does not optimize. A schedule that fails the independent validator returns a structured fallback with the failed safety constraints. This behavior prevents stale or malformed data from becoming an executable recommendation.

Phase 5 Execution Policy

Phase 5 implements guarded execution foundations only. The current modes are:

ModeBehavior
recommendationProduces a schedule only. No execution.
approval_requiredRequires explicit operator approval before authorization.
semi_automaticReserved for future per-slot approval workflows.
automaticBlocked unless explicitly enabled by configuration and all hard gates pass.

Authorization is rejected when any of these conditions is true:

  • emergency stop is active
  • automatic mode is disabled
  • required approval is missing
  • decision status is not executable
  • telemetry is older than five minutes
  • command expiry has passed
  • device publishing has not been explicitly enabled

The configured default is DISPATCHER_BRAINS_AUTOMATIC_MODE=false. The brains service records and audits authorization decisions but never publishes MQTT commands. The dispatcher-agent owns any future command executor and feedback path.

15-Minute Candidate Resolution

Multiple independent schedules may exist for the same site and 15-minute slot. They are not overwritten or merged. The resolver chooses exactly one due candidate by descending priority:

PriorityClassMeaning
1000safetyPhysical, thermal, SoC, grid-limit or emergency protection.
950emergency_gridUrgent grid or operator protection.
900manualExplicit operator override.
800reserve_activationValidated balancing or reserve activation.
700automaticApproved optimizer strategy.
600marketMarket schedule without an active override.
100initialBaseline from weather, history and site parameters.

Equal priorities resolve by newest candidate, then candidate ID. POST /v1/orchestration/resolve persists the selected winner and returns executed: [] and device_publishing: false. A selected candidate is a database resolution, not a device command.

Persistence and Migrations

PostgreSQL is the canonical decision store. Ordered migrations are:

MigrationScope
001_decisions.sqlCore decisions and request snapshots.
002_phase5_audit.sqlPhase 5 execution and audit foundations.
003_account_scope.sqlAccount-scoped ownership and idempotency.
004_execution_audit_scope.sqlAccount-scoped execution audit records.
005_forecast_registry.sqlForecast model versions and artifacts.
006_decision_transition_audit.sqlLifecycle transition history.
007_forecast_runs_kpis.sqlForecast runs, training samples and KPI data.
008_bess_slot_candidates.sql15-minute decision candidates and resolution state.
009_manual_overrides.sqlPersisted manual overrides and actor/reason metadata.

The repository persists decision input snapshots, algorithm versions, schedules, transitions, overrides, candidate rows, execution authorization events, forecast metadata and solver/cost/revenue KPIs. PostgreSQL account scope is applied to reads, writes and transitions. Idempotency is keyed by account, site and idempotency key.

InfluxDB is the time-series source for telemetry reads and freshness-aware input assembly. It is not the decision audit store. MongoDB is a legacy or rollback source only and is not the canonical brains data path.

Forecasting

The current forecasting foundation includes:

  • seasonal and baseline forecasts
  • persisted linear model artifacts
  • active-version loading
  • forecast-run records
  • training-sample persistence
  • MAE, RMSE, bias and MAPE metrics
  • prediction-interval coverage
  • configurable threshold evaluation

Richer models, feature stores, scheduled retraining, drift detection, bias analysis, champion/challenger workflows, rollback automation and multi-site acceptance gates remain future work.

Markets, Balancing and Settlement

The current foundation supports:

  • day-ahead and transparency input paths
  • balancing A84/A60 input paths
  • reserve allocation
  • negative-price optimization behavior
  • commitment validation
  • revenue and settlement calculation helpers
  • raw provider payload retention foundations

Provider-specific activation feedback, intraday/Nord Pool commitment workflows, recovery scheduling, imbalance reports, provider compliance mapping and forecast-versus-realized reconciliation require official provider contracts and validated response mappings.

API and Platform Integration

The public opf-api owns authentication, account and site management, telemetry ingestion, operator-facing API access and the public documentation site. It forwards internal decision work to dispatcher-brains and exposes the public API reference at https://api.opf.lt/.

The dispatcher-agent is the integration boundary for provider synchronization, MQTT site configuration and any future command execution. The dispatcher-room is the operator interface for fleet telemetry, decisions, approvals and controls. The brains service remains independent and does not publish directly to MQTT.

Security and Tenant Isolation

Every brains endpoint that accesses account data requires x-account-id. The current boundary enforces presence and applies account scope to persistence operations. Production deployments must additionally provide authenticated service identity and site-to-account authorization rather than trusting an arbitrary caller value.

Production configuration rejects default PostgreSQL and InfluxDB secrets and rejects non-positive solver timeouts. Rate limiting, TLS termination, dependency timeouts and resource limits are deployment responsibilities that must be completed for a production rollout.

Testing and Validation

The test suite covers:

  • service scaffold and health/readiness behavior
  • domain and API contract validation
  • physical battery transitions
  • optimizer and safety scenarios
  • persistence and migrations
  • adapter and lifecycle behavior
  • forecasting, market and settlement foundations
  • Phase 5 controls and execution gates
  • slot priority resolution

Run the complete suite with:

pytest -q

The repository validation baseline has passed the brains regression suite. Production readiness still requires integration, load, adversarial, provider, device and pilot validation.

Current Completion Status

Complete locally

  • versioned decision contract and strict domain validation
  • deterministic physical model, optimizer and independent safety validator
  • explanations, warnings and idle fallbacks
  • PostgreSQL decision persistence and ordered migrations
  • account-scoped idempotent decision handling
  • lifecycle transitions, overrides and transition audit
  • input assembly and quality-gated decision cycle
  • forecast metrics, model artifact structure and training samples
  • reserve allocation, commitment validation and settlement foundations
  • KPI persistence and aggregation
  • dry-run 15-minute slot priority resolution
  • guarded execution authorization with automatic publishing disabled

Foundation exists but production work remains

  • full telemetry aggregation and configuration discovery
  • dispatcher-agent command and feedback loop
  • role-aware lifecycle policy and complete operator playback UI
  • scheduled forecast monitoring, richer ML and retraining
  • complete intraday, balancing activation and provider settlement compliance
  • edge buffering, replay protection, acknowledgements and reconnect behavior
  • command traceability, dashboards, alerts and model drift detection

Blocked by external validation

  • real device execution and BMS/EMS command contract
  • provider activation and settlement certification
  • Litgrid IEC 60870-5-104 point map, IOA profile and test endpoint
  • HA, backup/restore, certificates, RPO/RTO and production ownership
  • pilot sites, A/B acceptance, operator approval and rollback evidence

Litgrid IEC 60870-5-104

Litgrid integration is a separate edge handler and publisher work item. It must receive the official point map, IOA and type identifiers, common address, units, limits, acknowledgement rules, test endpoint and certification requirements before implementation can be validated. The intended behavior is to normalize Litgrid activation signals into 15-minute priority candidates and publish approved measurements, statuses and acknowledgements back to Litgrid with reconnect, timeout, duplicate protection, raw-payload audit and simulator tests.

Production Completion Criteria

A production rollout requires all of the following:

  • reproducible decisions from input snapshots and algorithm versions
  • deterministic physical and safety validation for every schedule
  • documented fallback or rejection for unsafe, stale or incomplete inputs
  • explicit execution authorization and automatic mode disabled by default
  • tenant isolation for every read, write, transition and execution path
  • raw provider payload, forecast version and realized result retention
  • command-to-slot-to-acknowledgement-to-telemetry correlation
  • TLS, service identity, rate limits, dependency timeouts and resource limits
  • PostgreSQL and InfluxDB backup, restore, HA and disaster-recovery evidence
  • staged recommendation-only, approval-required and limited-automatic pilot evidence
  • rollback criteria, named site approval and operator sign-off

MCP Natural-Language Explanation Service

Work order 26 now has a local implementation: a separate MCP-compatible JSON-RPC service for operator and AI-client explanations of dispatcher-brains behavior. It is a read-only explanation layer over persisted decision facts and versioned project documentation. The service is available inside Compose as dispatcher_brains_mcp on port 3011 and exposes POST /mcp.

Planned read-only tools include:

  • explain_decision: explain a persisted decision, its input snapshot, algorithm version, schedule, warnings and safety result.
  • explain_decision_slot: explain why one 15-minute slot charges, discharges or remains idle.
  • explain_fallback: explain input-quality rejection, optimizer failure or safety fallback.
  • explain_execution_policy: explain current mode, hard gates, authorization status and why device publishing is disabled.
  • explain_candidate_resolution: explain every candidate, priority, tie-break and selected winner for a slot.
  • explain_brains: answer documentation-grounded questions about the architecture and technical behavior.
  • brains_status: expose readiness, enabled modes, algorithm version and known limitations.

The service must enforce authenticated account and site scope, redact secrets and personal data, and identify the decision ID, source snapshot timestamp, algorithm version and source records used. Natural-language output is a presentation of authoritative facts; it cannot change the facts, safety status or execution status.

The MCP service must never provide tools for approval, override, lifecycle transition, candidate activation, command execution or MQTT publication. It must clearly distinguish a calculated decision, a selected dry-run candidate, an authorization result and an executed device command. Deterministic template explanations are required when an optional language-model provider is unavailable or times out.

The local implementation provides MCP initialize, tools/list and tools/call, account-scoped PostgreSQL decision/candidate readers, deterministic policy and documentation explanations, a health endpoint and focused protocol tests. Production service identity, complete redaction audit, provider timeouts and optional LLM integration remain hardening work.

Implementation, schema tests, tenant-isolation tests, redaction tests, provider-fallback tests and deployment documentation are tracked in 26-mcp-natural-language-explanations.prompt.md. Automatic execution remains disabled throughout this work.