Multi-Series Push Subscription API

Multi-series DPS is a push-based service that delivers real-time updates for multiple Enact data series over SignalR.

A multi-series subscription lets you subscribe to several series and option sets with one join request. The backend returns a group name, and live updates are then pushed to a client handler registered for that group name.

A successful new multi-series subscription made with JoinMultiSeries counts as 10 API calls towards your monthly usage quota, regardless of how many series or option sets are included.

ReconnectToPush is used to restore an existing group after a disconnect and does not increase API usage.

Clients should not call JoinMultiSeries repeatedly for disconnect recovery. Use ReconnectToPush first, and only call JoinMultiSeries again if the backend returns ReconnectUnavailable.


Usage

You can use the service from any SignalR-compatible client.

At a high level, your client should:

  1. Connect to the Enact DPS SignalR hub using your Enact authentication.
  2. Call JoinMultiSeries with a list of series request objects.
  3. Read the returned multi-series group name.
  4. Register a client handler using that group name.
  5. Process pushed updates in that handler.
  6. If the connection drops, reconnect SignalR and call ReconnectToPush with the previous group name.
  7. If ReconnectToPush returns ReconnectUnavailable, call JoinMultiSeries again with the original request.
  8. Call LeaveGroup when you intentionally stop receiving updates for a group.

Join Request Shape

Call JoinMultiSeries with a list of series request objects:

[
  {
    "seriesId": "RealtimeFrequency",
    "countryId": "Gb"
  },
  {
    "seriesId": "OutturnFuel",
    "countryId": "Gb",
    "optionIds": [["CCGT"], ["Wind"]]
  }
]

Each request can include:

  • seriesId: required Enact series identifier.
  • countryId: optional country code. Defaults to "Gb" if omitted.
  • optionIds: optional list of option sets. Each option set is itself a list of strings.

Join Response

JoinMultiSeries returns the multi-series group name, also referred to as the push name.

Your client should register a SignalR client method/handler using that returned group name. Pushes for the subscribed series will be sent to that handler.

Keep the original join request and the returned group name in memory. You need them for reconnect handling.

Reconnect Flow

If the SignalR connection drops unexpectedly:

  1. Reconnect to the SignalR hub.
  2. Call ReconnectToPush with the previous group name.
  3. If it succeeds, continue listening on the same group name.
  4. If it fails for a transient reason, keep retrying ReconnectToPush.
  5. If the backend returns ReconnectUnavailable, the previous group can no longer be restored. Call JoinMultiSeries again with the original request and register a handler for the new group name.

This matters for API usage. ReconnectToPush does not increase usage, but JoinMultiSeries creates or refreshes a multi-series subscription and may count as 10 API calls.

Lease and Long-Running Clients

Multi-series push groups have a reconnect lease. The lease currently lasts 14 days.

For a long-running process, refresh the group before the lease expires by calling JoinMultiSeries again with the original request. We recommend refreshing after 13 days.

A planned lease refresh may count as a new multi-series subscription. Unexpected disconnect recovery should use ReconnectToPush first to avoid unnecessary API usage.

Stop Receiving Updates

When your client intentionally stops listening to a group, call:

LeaveGroup

with the group name.

This lets the backend clean up the connection’s group membership.


Subscription Shape

Each list inside optionIds is a standalone subscription, not a combination of filters.

For example, for a plant series:

{
  "seriesId": "Mel",
  "countryId": "Gb",
  "optionIds": [["Battery"], ["Z4"]]
}

means:

  • One subscription for all Battery-fuelled plants
  • One subscription for all plants in Zone 4

It does not mean Battery plants in Zone 4.

System Series

optionIds can be omitted if the series does not require options:

{
  "seriesId": "RealtimeFrequency",
  "countryId": "Gb"
}

For single-option series:

{
  "seriesId": "OutturnFuel",
  "countryId": "Gb",
  "optionIds": [["CCGT"], ["Wind"]]
}

For multi-option series:

{
  "seriesId": "ERA5WindByZone",
  "countryId": "Gb",
  "optionIds": [["AboveGround10M", "Z2", "Median"]]
}

You can use %ALL% in any option position:

{
  "seriesId": "OutturnFuel",
  "countryId": "Gb",
  "optionIds": [["%ALL%"]]
}
{
  "seriesId": "ERA5WindByZone",
  "countryId": "Gb",
  "optionIds": [["%ALL%", "Z1", "Median"]]
}
{
  "seriesId": "ERA5WindByZone",
  "countryId": "Gb",
  "optionIds": [["%ALL%", "%ALL%", "%ALL%"]]
}

Plant Series

Each plant-series option set must begin with one of:

  • A specific Enact plant ID
  • A fuel type, owner, or zone
  • %ALL% for all plants

Some plant series support additional options after the plant selector:

{
  "seriesId": "SomePlantSeries",
  "countryId": "Gb",
  "optionIds": [["Battery", "HalfHourly"]]
}

Recommended Python Client

We recommend using the LCPDelta Python package for Python clients. It handles SignalR connection management, reconnects, group restoration, callback buffering, and lease refreshes for you.

Use MultiSeriesDPSHelper for new Python multi-series push clients.

Python Example

import asyncio
import logging
import os
from datetime import datetime

from lcp_delta.enact import MultiSeriesDPSHelper


USERNAME = os.environ["LCPDELTA_ENACT_USERNAME"]
API_KEY = os.environ["LCPDELTA_ENACT_API_KEY"]


series_requests = [
    {"seriesId": "RealtimeFrequency", "countryId": "Gb"},
    {"seriesId": "Mel", "countryId": "Gb", "optionIds": [["%ALL%"]]},
    {"seriesId": "OutturnFuel", "countryId": "Gb", "optionIds": [["CCGT"], ["Wind"]]},
]


def on_chart_push(df, metadata):
    print(f"{datetime.now().isoformat(timespec='seconds')} {metadata.series_id} rows={len(df)}")
    print(f"Country: {metadata.country_id}")
    print(f"Options: {metadata.option_ids}")
    print(f"Group: {metadata.group_name}")
    print(df.tail(5))


async def refresh_snapshot_after_reconnect(helper):
    # Optional but recommended for stateful long-running clients.
    # If pushes may have been missed while disconnected, call the API here
    # to refresh your local latest snapshot before queued live pushes resume.
    pass


async def main():
    logging.basicConfig(level=logging.INFO)

    helper = MultiSeriesDPSHelper(
        USERNAME,
        API_KEY,
        callback=on_chart_push,
        reconnect_callback=refresh_snapshot_after_reconnect,
        reconnect_callback_timeout_seconds=120,
        concurrent_callbacks=True,
        max_workers=5,
        callback_queue_maxsize=5000,
        reconnect=True,
        auto_connect=False,
    )

    try:
        group_name = await helper.async_subscribe_to_chart_pushes(series_requests)
        print(f"Subscribed to group: {group_name}")

        # Keep the process alive.
        await asyncio.Event().wait()

    finally:
        await helper.async_close(wait_for_callbacks=True)


if __name__ == "__main__":
    asyncio.run(main())

Python Callback Shape

The push callback can be synchronous or asynchronous.

It can accept either one argument:

def on_chart_push(df):
    ...

or two arguments:

def on_chart_push(df, metadata):
    ...

The metadata object includes:

  • sequence
  • received_at_utc
  • group_name
  • push_id
  • series_id
  • country_id
  • option_ids
  • day
  • replace_series
  • refresh
  • plant, when plant metadata is available

The same metadata is also attached to the DataFrame:

metadata = df.attrs["enact_push_metadata"]

Python DataFrame Shape

Each pushed update is converted into a pandas DataFrame before your callback is called.

The DataFrame index is the point timestamp:

  • By default, the index contains UTC pandas timestamps.
  • If parse_datetimes=False, the index contains UTC ISO timestamp strings.

For a single-value series, the DataFrame has one value column. The column name is usually the push ID, for example:

Gb&RealtimeFrequency&none

For a multi-value series, the backend sends more than one value for the same timestamp. The Python helper creates one column per value, using the backend value order:

{push_id}_0
{push_id}_1
{push_id}_2
...

For example, if PredictedSystemPrice sends lower and upper bounds, the resulting columns are:

Gb&PredictedSystemPrice&P50_0  # lower bound
Gb&PredictedSystemPrice&P50_1  # upper bound

For a series such as EpexContractPricesChart, where the pushed values represent open, high, low, and close, the resulting columns are:

{push_id}_0  # open
{push_id}_1  # high
{push_id}_2  # low
{push_id}_3  # close

If the backend includes trailing value positions that are entirely null for a payload, the helper removes those right-hand all-null columns. Null columns between non-null columns are preserved, so column positions still match the backend value order.


Python Reconnect Behaviour

When the SignalR connection drops unexpectedly, MultiSeriesDPSHelper:

  1. Reconnects the SignalR connection.
  2. Restores each existing group with ReconnectToPush.
  3. Retries transient reconnect failures without increasing API usage.
  4. Falls back to JoinMultiSeries only when the backend says the old group cannot be restored.
  5. Runs reconnect_callback, if supplied.
  6. Releases queued live pushes for normal processing.

If reconnect_callback raises, the helper retries it until reconnect_callback_timeout_seconds is reached. After that, queued pushes are released so the client can continue processing live data.

Pushes received after reconnect are buffered while the reconnect callback is running. Pushes already queued before the disconnect are allowed to finish first.


Python Error Handling

Backend errors are raised as EnactApiError.

Useful fields are:

except EnactApiError as exc:
    print(exc.error_code)
    print(exc.message)
    print(exc.response)

Common error codes include:

  • RateLimited: too many join/reconnect requests in the configured rate-limit window.
  • DataCapped: the account has reached its usage limit.
  • ReconnectUnavailable: the old multi-series group cannot be restored, so the client must call JoinMultiSeries again.
  • DataFormatError: the join request was not valid.
  • InsufficientAccessPrivileges: the account does not have access to one or more requested series.

The Python helper uses ReconnectUnavailable specially. Transient ReconnectToPush failures are retried so API usage is not incremented unnecessarily. Only ReconnectUnavailable causes the helper to fall back to JoinMultiSeries.


Python Helper Settings

  • callback: default function called for each received push.
  • reconnect_callback: optional function called after reconnect and before queued live pushes resume.
  • parse_datetimes: defaults to True. DataFrame indexes are UTC pandas timestamps. Set to False for UTC ISO strings.
  • concurrent_callbacks: defaults to False. Set to True to process different push streams concurrently.
  • max_workers: number of callback worker shards when concurrent_callbacks=True.
  • callback_queue_maxsize: maximum number of queued or running callback items. 0 means unbounded. In production, use a finite value comfortably above max_workers and large enough for expected reconnect/push bursts.
  • reconnect: defaults to True. Leave enabled for long-running clients.
  • reconnect_initial_delay_seconds: first retry delay after reconnect failures.
  • reconnect_max_delay_seconds: maximum retry delay.
  • reconnect_callback_timeout_seconds: maximum time to keep retrying reconnect_callback.
  • lease_refresh_interval_days: defaults to 13. Refreshes the group lease before the 14-day reconnect lease expires. Set to None to disable proactive lease refresh.
  • auto_connect: when False, the helper connects on the first subscribe call. This is usually best for async applications.
  • suppress_unhandled_server_method_logs: suppresses noisy SignalR warnings for unrelated server methods while keeping connection warnings visible.
  • logger: optional Python logger for connection, reconnect, and callback errors.