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.
| Component | Exposure | Role |
| opf.lt | Public | Client facing site: products, projects, contact. |
| api.opf.lt | Public | This API. Authentication, accounts, sites, telemetry, command queue. |
| dispatcher-brains | Internal | Validated 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.lt | WireGuard only | Internal dispatcher room: full telemetry and control surface. |
| dispatcher agent | Internal | Claims queued commands, publishes to MQTT, tracks acks, writes rollups. |
| dispatcher engine | Public | LP 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
- Create the first account and owner with
POST /api/v1/auth/register.
- Keep the
accessToken and send it as Authorization: Bearer ....
- Register a site whose
siteId matches your MQTT topic segment.
- Publish telemetry to
storage/<siteId>/rt; read it back from /telemetry/live.
- 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
| Role | Grants |
| viewer | Read sites, telemetry, prices and own profile. |
| operator | Everything viewer can do, plus issue dispatch commands to sites. |
| admin | Everything operator can do, plus manage users, sites and API keys. |
| owner | Full 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.
| Status | Meaning |
| queued | Accepted by the API and waiting for the dispatcher agent to claim it. |
| sending | Claimed by an agent. The claim is atomic, so two agents can never send the same command. |
| sent | Published to the device topic at QoS 1. Waiting for the device acknowledgement. |
| acked | Device confirmed on storage/<siteId>/cmd/ack. Terminal state. |
| failed | Publish failed after retries, the device rejected it, or no ack arrived in time. Terminal state. |
| expired | Sat 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" }
| Status | Meaning |
| 400 | Validation failed: missing field, bad type, value out of range. |
| 401 | No credentials, expired access token, or invalid API key. |
| 403 | Authenticated but the role is too low, or signup disabled. |
| 404 | Resource does not exist, or belongs to another account. |
| 409 | Conflict: email already registered, siteId already taken. |
| 429 | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error. Details are logged, not returned. |
| 503 | Health 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.
| Topic | Direction | Purpose |
| storage/<siteId>/rt | inbound | Real time battery telemetry. |
| storage/<siteId>/status | inbound | Device status updates. |
| rut/<siteId>/telemetry | inbound | Gateway telemetry. |
| rut/<siteId>/heartbeat | inbound | Liveness ping. |
| storage/<siteId>/cmd | outbound | Dispatch 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:
| Metric | Accepted keys | Meaning |
| gridKw | grid_kw, gridKw, grid, p_grid | Grid power. Negative means exporting. |
| pvKw | pv_kw, pvKw, pv, solar_kw | Solar generation. |
| loadKw | load_kw, loadKw, load, p_load | Site consumption. |
| batteryKw | battery_kw, batteryKw, battery, p_batt | Battery power. Negative means discharging. |
| socPct | soc_pct, socPct, soc | State of charge, percent. |
| tempC | temp_c, tempC, temp, temperature | Battery or ambient temperature. |
| voltageV | voltage, voltage_v, v | Pack voltage. |
| currentA | current, current_a, i | Pack 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.
| Field | Type | Required | Notes |
| request_id | string | required | Caller request identifier. |
| site_id | string | required | BESS/site identifier. |
| requested_at | ISO datetime | required | Timezone-aware request timestamp. |
| horizon | object | required | start, end, slot_minutes and optional timezone. |
| state | object | required | Current SoC, availability and observed telemetry. |
| constraints | object | required | Capacity, power, SoC, reserve and efficiency limits. |
| objective | object | required | Cost, 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.
| Field | Type | Required | Notes |
| payload | object | required | Caller-owned site state, constraints, objective and horizon. |
| x-account-id | string | required | Authenticated 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.
| Field | Type | Required | Notes |
| site_id | string | required | Site 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.
| Field | Type | Required | Notes |
| target_status | string | required | approved, 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.
| Field | Type | Required | Notes |
| email | string | required | Login address. Stored lower case, must be unique. |
| password | string | required | Minimum 12 chars, mixed case and a digit. |
| accountName | string | required | Organisation name. A URL slug is derived from it. |
| name | string | optional | Display 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.
| Field | Type | Required | Notes |
| email | string | required | Registered email address. |
| password | string | required | Account 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.
| Field | Type | Required | Notes |
| refreshToken | string | required | The 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.
| Field | Type | Required | Notes |
| currentPassword | string | required | Existing password. |
| newPassword | string | required | Must 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.
| Field | Type | Required | Notes |
| name | string | optional | Organisation display name. |
| contact | object | optional | Object 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.
| Field | Type | Required | Notes |
| limit | query | optional | 1 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.
| Field | Type | Required | Notes |
| name | string | required | Label shown in the key list. |
| role | string | optional | viewer, 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.
| Field | Type | Required | Notes |
| email | string | required | Must be unique across the platform. |
| password | string | required | Must satisfy the password policy. |
| role | string | required | viewer, operator, admin or owner. |
| name | string | optional | Display 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.
| Field | Type | Required | Notes |
| name | string | optional | Display name. |
| role | string | optional | Cannot exceed your own role. |
| active | boolean | optional | false disables login immediately. |
| password | string | optional | Resets 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.
| Field | Type | Required | Notes |
| siteId | string | required | 2 to 49 chars: a-z, 0-9, dash, underscore. Globally unique. |
| name | string | required | Human readable name. |
| location | string | optional | Free text location. |
| capacityKwh | number | optional | Usable energy capacity. Default 0. |
| maxChargeKw | number | optional | Charge power limit, enforced on commands. |
| maxDischargeKw | number | optional | Discharge power limit, enforced on commands. |
| gridExportKw | number | optional | Connection export cap. |
| minSocPct | number | optional | Reserve floor. Default 5. |
| maxSocPct | number | optional | Upper limit. Default 100. |
| minExportPrice | number | optional | Price floor in EUR/MWh for automatic export. |
| timezone | string | optional | IANA 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.
| Field | Type | Required | Notes |
| action | string | required | export, charge, auto, idle or stop. |
| powerKw | number | optional | Target 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.
| Field | Type | Required | Notes |
| siteId | query | optional | Filter to a single site. |
| status | query | optional | queued, sending, sent, acked, failed or expired. |
| limit | query | optional | 1 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.
| Field | Type | Required | Notes |
| from | query | optional | ISO timestamp. Defaults to 24 hours ago. |
| to | query | optional | ISO timestamp. Defaults to now. |
| limit | query | optional | 1 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.
| Field | Type | Required | Notes |
| from | query | optional | ISO timestamp. Defaults to 24 hours ago. |
| to | query | optional | ISO 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.
| Field | Type | Required | Notes |
| name | string | required | Display name. |
| groupId | string | optional | Stable id. a-z, 0-9, dash, underscore. |
| description | string | optional | Short 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.
| Field | Type | Required | Notes |
| name | string | required | Display name. |
| type | string | required | site, unit or storage. |
| deviceId | string | optional | MQTT key. Derived from name if omitted. |
| groupId | string | optional | Existing group. |
| parentDeviceId | string | optional | Required site for inverter, required inverter for storage. |
| mqttHost | string | site only | Site MQTT broker host. |
| mqttPort | number | site only | MQTT broker port, normally 1883 or 8883. |
| mqttTls | boolean | site only | Use TLS for the site broker. |
| mqttUsername | string | site only | Credential used by dispatcher-agent. |
| mqttPassword | string | site only | Stored securely according to deployment policy. |
| latitude | number | site only | Site latitude, -90 to 90. |
| longitude | number | site only | Site longitude, -180 to 180. |
| pvModuleCount | number | site only | PV module count. |
| pvModulePowerWp | number | site only | PV module rated power. |
| pvTiltDeg | number | site only | PV tilt, 0 to 90 degrees. |
| pvAzimuthDeg | number | site only | PV azimuth, 0 to 360 degrees. |
| starred | boolean | optional | UI 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:
- client sends a normalized decision request with x-account-id
- service validates required account and payload fields
- request is assembled from source data and quality checks
- optimization runs deterministically
- reserve allocation and independent safety validation are applied
- a valid decision is persisted to PostgreSQL
- slot candidates are generated and saved
- KPIs are recorded
- 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
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
- input quality assessment
- request assembly
- deterministic optimization
- reserve allocation
- safety validation
- persisted decision creation
- 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:
| Module | Responsibility |
|---|
app.main | FastAPI application lifecycle, logging and router registration. |
app.api | HTTP contracts, account scope checks, decision orchestration and API responses. |
app.config | Environment settings, production secret checks and readiness calculation. |
app.domain | Versioned request and domain models with strict validation. |
app.input_assembly | Normalization of telemetry, weather, prices, market and reserve inputs. |
app.input_quality | Completeness, finite-value, timezone, stale-data and quality assessment. |
app.optimization | Deterministic schedule optimization and reserve allocation. |
app.safety | Independent physical and operational schedule validation. |
app.forecasting | Baseline and linear forecast artifacts, metrics and threshold evaluation. |
app.persistence | PostgreSQL repository and PostgreSQL/InfluxDB input adapters. |
app.lifecycle | Decision status transition rules. |
app.orchestrator | 15-minute candidate creation and dry-run priority resolution. |
app.execution | Guarded authorization policy; no MQTT publication. |
app.settlement | Commitment validation, reserve and settlement calculations. |
app.slot_priority | Deterministic 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
dispatcher-agent, opf-api or an internal scheduler submits a request with an authenticated account scope.- The input assembly path reads or receives site state, telemetry, forecasts, weather, prices, market commitments and reserve data.
- Quality gates check timestamps, timezone, completeness, finite numeric values, stale telemetry and source quality.
- The domain model validates the normalized request and all physical ranges.
- The optimizer calculates a deterministic schedule with mutually exclusive charge/discharge decisions.
- Reserve allocation adjusts the schedule without violating the battery model.
- The independent safety validator checks every slot again.
- A valid decision, snapshot, slot candidates, transition information and KPI values are persisted to PostgreSQL.
- The caller receives a calculated schedule, warnings, explanations and objective values.
- 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:
| Block | Required technical content |
|---|
| Identity | request_id, idempotency_key, site_id, requested_at. |
| Mode | recommendation, approval_required, semi_automatic or automatic. |
| Horizon | ISO timestamps, slot_minutes and timezone. The normal planning interval is 15 minutes. |
| State | Observation timestamp, SoC, battery power, temperature, grid/load/PV power, availability and telemetry quality. |
| Forecasts | Load, PV and wind series aligned to the horizon. |
| Market | Bidding zone plus day-ahead, intraday, balancing-up and balancing-down series. |
| Constraints | Capacity, charge/discharge limits, SoC bounds, reserve SoC, efficiencies, ramp, grid import/export limits and degradation cost. |
| Objective | Cost, peak, self-consumption, market revenue, balancing and degradation weights. |
| Metadata | Source 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.
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:
| Mode | Behavior |
|---|
recommendation | Produces a schedule only. No execution. |
approval_required | Requires explicit operator approval before authorization. |
semi_automatic | Reserved for future per-slot approval workflows. |
automatic | Blocked 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:
| Priority | Class | Meaning |
|---|
| 1000 | safety | Physical, thermal, SoC, grid-limit or emergency protection. |
| 950 | emergency_grid | Urgent grid or operator protection. |
| 900 | manual | Explicit operator override. |
| 800 | reserve_activation | Validated balancing or reserve activation. |
| 700 | automatic | Approved optimizer strategy. |
| 600 | market | Market schedule without an active override. |
| 100 | initial | Baseline 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:
| Migration | Scope |
|---|
001_decisions.sql | Core decisions and request snapshots. |
002_phase5_audit.sql | Phase 5 execution and audit foundations. |
003_account_scope.sql | Account-scoped ownership and idempotency. |
004_execution_audit_scope.sql | Account-scoped execution audit records. |
005_forecast_registry.sql | Forecast model versions and artifacts. |
006_decision_transition_audit.sql | Lifecycle transition history. |
007_forecast_runs_kpis.sql | Forecast runs, training samples and KPI data. |
008_bess_slot_candidates.sql | 15-minute decision candidates and resolution state. |
009_manual_overrides.sql | Persisted 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.
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.