Source code for Karana.KUtils.DataPlotter

# Copyright (c) 2024-2026 Karana Dynamics Pty Ltd. All rights reserved.
#
# NOTICE TO USER:
#
# This source code and/or documentation (the "Licensed Materials") is
# the confidential and proprietary information of Karana Dynamics Inc.
# Use of these Licensed Materials is governed by the terms and conditions
# of a separate software license agreement between Karana Dynamics and the
# Licensee ("License Agreement"). Unless expressly permitted under that
# agreement, any reproduction, modification, distribution, or disclosure
# of the Licensed Materials, in whole or in part, to any third party
# without the prior written consent of Karana Dynamics is strictly prohibited.
#
# THE LICENSED MATERIALS ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND.
# KARANA DYNAMICS DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
# BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, NON-INFRINGEMENT, AND
# FITNESS FOR A PARTICULAR PURPOSE.
#
# IN NO EVENT SHALL KARANA DYNAMICS BE LIABLE FOR ANY DAMAGES WHATSOEVER,
# INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, DATA, OR USE, EVEN IF
# ADVISED OF THE POSSIBILITY OF SUCH DAMAGES, WHETHER IN CONTRACT, TORT,
# OR OTHERWISE ARISING OUT OF OR IN CONNECTION WITH THE LICENSED MATERIALS.
#
# U.S. Government End Users: The Licensed Materials are a "commercial item"
# as defined at 48 C.F.R. 2.101, and are provided to the U.S. Government
# only as a commercial end item under the terms of this license.
#
# Any use of the Licensed Materials in individual or commercial software must
# include, in the user documentation and internal source code comments,
# this Notice, Disclaimer, and U.S. Government Use Provision.

"""Classes and functions used to stream data to a DashApp.

This module contains classes and functions used to stream data to a DashApp. These are useful for
plotting data while a simulation is running. The DashApp is a useful format, as it allows one to
view the results from a web browser, and that browser does not have to be on the same machine as
the machine generating the data. This is particularly useful when running a simulation on a
remote machine without an X session.
"""

import os
from Karana.Core import debug
from threading import Thread, Event, Lock
from werkzeug.serving import make_server, WSGIRequestHandler
from typing import TYPE_CHECKING, Any, cast, overload
from dash import Dash, html, Input, Output, dcc
from pathlib import Path
import plotly.graph_objects as go
from dash_extensions import WebSocket
import websockets
import asyncio
import socket
import struct
from json import dumps

if TYPE_CHECKING:
    from Karana.KUtils import SinglePlotData, PlotData
else:
    from ._KUtils_Py import SinglePlotData, PlotData


class _DashServer:
    """_DashSever is an internal class used to start/stop the server.

    Users should not create instances of this themselves. We do this in
    two classes to avoid reference cycles. Users need only delete the
    DashApp instance, and the server should shutdown automatically.
    """

    def __init__(self, title: str = "DataPlotter", host: str = "0.0.0.0", port: int = 8050):
        """Initialize the DashServer.

        Parameters
        ----------
        host: str
            The host to host the server on.
        port: int
            The port to use for the server.
        """
        self.host = host
        if self.host == "0.0.0.0":
            self.hostname = os.getenv("KARANA_WEBUI_ADDRESS", "localhost")
        else:
            self.hostname = self.host

        if port != 0:
            port_offset_str = os.getenv("KARANA_PORT_OFFSET", None)
            port_offset = int(port_offset_str) if port_offset_str else 0
            port += port_offset

        asset_folder = Path(__file__).parents[0].joinpath("assets")
        self._websocket_loop = asyncio.new_event_loop()
        asyncio.set_event_loop(self._websocket_loop)
        self.port: int
        self.app = Dash(__name__, title=title, assets_folder=str(asset_folder))

        self.update_clients = set()
        self.refresh_clients = set()
        self._startServer(port)

        # Store the figures on the DashServer as well. If we keep them on the main, then we can't cleanup.
        self.figs: list[go.Figure] = []
        self.gs: list[dcc.Graph] = []
        self.fig_labels: list[str] = []

        # Counter for the figures
        self.figure_counter: int = 0

        # Append-only histories avoid copying complete Plotly traces for every simulation sample.
        self.plot_histories: list[dict[str, Any]] = []
        self.history_lock = Lock()

    def _runServer(self, port: int, event: Event):
        """Run the server.

        This is the method run by the _server_thread.
        """

        class SilentHandler(WSGIRequestHandler):
            def log(self, type, message, *args):
                pass  # Do nothing — suppress all logs

        try:
            self._server = make_server(
                self.host, port, self.app.server, request_handler=SilentHandler
            )
            # If port 0 was passed in, then make_server will select an available port.
            # If non-zero, then this should be the same as port anyway. Use this so
            # any printed messages have the real port number.
        except Exception:
            event.set()
            raise
        self.port = self._server.port
        event.set()
        self._server.serve_forever()

    def _runWebsocket(self, event: Event):
        """Run the websocket.

        This is the method run by the _websocket_thread.
        """

        async def handler(websocket):
            # Only browsers use WebSockets. Simulation data arrives through framed loopback TCP.
            if websocket.request.path == "/refresh":
                self.refresh_clients.add(websocket)
                # A newly connected browser must request a full-history figure snapshot.
                await websocket.send(dumps({"msg": "refresh plots"}))
            else:
                self.update_clients.add(websocket)
            debug(lambda: "Client connected")
            try:
                async for _ in websocket:
                    # Browser connections are receive-only, but consuming frames keeps them alive.
                    pass  # pragma: no cover - Browser clients do not normally send messages.
            except (
                websockets.exceptions.ConnectionClosed
            ):  # pragma: no cover - Exclude browser disconnect timing from coverage.
                pass
            finally:
                debug(lambda: "Client disconnected")
                if websocket.request.path == "/refresh":
                    self.refresh_clients.discard(websocket)
                else:
                    self.update_clients.discard(websocket)

        self._websocket_loop = asyncio.new_event_loop()
        asyncio.set_event_loop(self._websocket_loop)
        self._websocket_stop = asyncio.Event()
        self.websocket_port = asyncio.Future()

        async def startServer():
            server = await websockets.serve(handler, "0.0.0.0", 0)
            self.websocket_port.set_result(server.sockets[0].getsockname()[1])
            event.set()
            debug(lambda: f"Starting WebSocket server on port {self.websocket_port.result()}...")
            await self._websocket_stop.wait()
            server.close()
            await server.wait_closed()
            debug(lambda: f"WebSocket server on port {self.websocket_port.result()} closed")

        self._websocket_loop.run_until_complete(startServer())
        self._websocket_loop.stop()

    def _startServer(self, port: int):
        """Start the server.

        This creates a thread and adds _run to it.
        """
        if not self.isRunning():
            # We use daemon threads so atexit functions can run without waiting for these threads to finish.
            # Use events so we know when the ports are set
            e1 = Event()
            e2 = Event()
            self._server_thread = Thread(target=self._runServer, args=(port, e1))
            self._server_thread.daemon = True
            self._server_thread.start()

            self._websocket_thread = Thread(target=self._runWebsocket, args=(e2,))
            self._websocket_thread.daemon = True
            self._websocket_thread.start()
            e1.wait()
            e2.wait()
            print(f"Dash app started at http://{self.hostname}:{self.port}")

    def _stopServer(self):
        """Stop the server and join the associated thread."""
        if self._websocket_loop and not self._websocket_loop.is_closed():
            self._websocket_loop.call_soon_threadsafe(lambda: self._websocket_stop.set())
        if self._server:
            self._server.shutdown()
            self._server = None
            print("Dash server shutting down...")
        if self._server_thread:
            self._server_thread.join()
        if self._websocket_thread:
            self._websocket_thread.join()

    async def _sendWebsocketMessage(self, message: str, path="/update"):
        """Async function to ping the websocket."""
        to_remove = set()

        if path == "/update":
            clients = self.update_clients
        elif path == "/refresh":
            clients = self.refresh_clients
        else:
            raise ValueError("path not recognized.")
        for ws in clients.copy():
            try:
                await ws.send(message)
            except (
                websockets.exceptions.ConnectionClosed
            ):  # pragma: no cover - Exclude from code coverage, as this won't run in testing
                debug(lambda: "Client disconnected before send")
                to_remove.add(ws)

        clients.difference_update(to_remove)

    def pingWebsocket(self):
        """Ping the websocket.

        Pinging the websocket will also update the plots.
        """
        message = {"msg": "update graph!"}
        asyncio.run_coroutine_threadsafe(
            self._sendWebsocketMessage(dumps(message)), self._websocket_loop
        )

    def sendWebsocketMessage(self, message: str, path="/update"):
        """Ping the websocket.

        Pinging the websocket will also update the plots.
        """
        asyncio.run_coroutine_threadsafe(
            self._sendWebsocketMessage(message, path=path), self._websocket_loop
        )

    def isRunning(self) -> bool:
        """Return a boolean that indicates whether the server is running or not."""
        return hasattr(self, "_server_thread") and self._server_thread.is_alive()

    def __del__(
        self,
    ):  # pragma: no cover - Normal reference cycles keep this from being run. We keep it around in case users ever trigger a strange scenario where this is removed (in which case it should shut down the server).
        """Stop the server if it is running."""
        self._stopServer()


class _DashAppBase:
    """Base class for the DashApp* classes."""

    def __init__(
        self,
        title: str = "DataPlotter",
        host: str = "0.0.0.0",
        port: int = 8050,
    ):
        """Initialize the DashApp.

        This starts a server on the provided host and port. This server should shutdown
        automatically once this instance is deleted.

        Parameters
        ----------
        title: str
            The title of the DashApp webpage.
        host: str
            The host to host the server on.
        port: int
            The port to use for the server.
        """
        self._server = _DashServer(host=host, port=port, title=title)
        app = self._server.app
        app.callback
        self.url = f"http://{self._server.hostname}:{self._server.websocket_port.result()}/update"

        app.layout = [
            html.Header(
                [
                    html.H1(children=title, style={"textAlign": "center", "flex-grow": "1"}),
                    html.Button(
                        r"☀️",
                        title="Toggle light/dark theme",
                        id="theme-toggle",
                        className="toggle-button",
                    ),
                ],
                style={
                    "display": "flex",
                    "justify-content": "space-between",
                    "align-items": "center",
                    "margin-bottom": "20px",
                    "padding-bottom": "10px",
                },
            ),
            dcc.Store(id="update-data", data=0),
            WebSocket(url=self.url, id="ws"),
        ]

        app.clientside_callback(
            r"""
            function(n_clicks) {
                const body = document.body;
                const button = document.getElementById('theme-toggle');
                const current = body.getAttribute('data-theme') || 'light';
                if (current === 'light') {
                    body.setAttribute('data-theme', 'dark');
                    button.textContent = '\u{1F319}';
                } else {
                    body.setAttribute('data-theme', 'light');
                    button.textContent = '\u2600\uFE0F';
                }
                return '';
            }
            """,
            Output("theme-toggle", "n_clicks"),
            Input("theme-toggle", "n_clicks"),
        )

    def printConnectionInfo(self):
        """Print a message about how to connect to the server."""
        http_url = f"http://{self._server.hostname}:{self._server.port}"
        lines = [
            f"[DashApp] Web server is running on port {self._server.port}",
            "You may be able to connect in your browser at:",
            f"\t\033[1m{http_url}\033[0m",  # ANSI bold
        ]
        print("\n".join(lines))

    def __del__(self):
        """Delete method.

        This stops the server.'
        """
        if hasattr(self, "_server"):
            self._server._stopServer()


[docs] class DashAppStatic(_DashAppBase): """Use this class to create a DashApp servers up static figures. These figures can be changed manually using the figs property. However, they are not designed to be updated regularly. For that, please use the DashApp class. """ def __init__( self, figs: list[go.Figure], title: str = "DataPlotter", host: str = "0.0.0.0", port: int = 8050, ): """Initialize the DashApp. This starts a server on the provided host and port. This server should shutdown automatically once this instance is deleted. Parameters ---------- title: str The title of the DashApp webpage. host: str The host to host the server on. port: int The port to use for the server. """ super().__init__(title=title, host=host, port=port) self.figs = figs self._createPlots(self._server, self._server.app) @property def figs(self) -> list[go.Figure]: """Figures shown by the DashApp.""" return self._server.figs @figs.setter def figs(self, figs: list[go.Figure]): self._server.figs = figs self._server.pingWebsocket() # Using a static method here so we don't create reference cycles that prevent d from being deleted. @staticmethod def _createPlots(server, app): gs = [ dcc.Graph(id=fig.layout.title.text or str(k), figure=fig) for k, fig in enumerate(server.figs) ] app.layout.append(html.Div(id="plots-div", children=gs)) @app.callback(Output("plots-div", "children"), Input("ws", "message")) def updatePlots(_): gs = [ dcc.Graph(id=fig.layout.title.text or str(k), figure=fig) for k, fig in enumerate(server.figs) ] return gs
def _snapshotFigures(server) -> list[go.Figure]: """Populate figures from append-only history for a full browser refresh. Parameters ---------- server : _DashServer Server containing the figure definitions and live trace histories. Returns ------- list[plotly.graph_objects.Figure] Figures populated with every sample received by the server. """ with server.history_lock: for fig, plot_history in zip(server.figs, server.plot_histories): for trace, y_history in zip(fig.data, plot_history["y"]): # Every trace in a plot shares one X history, avoiding duplicate time storage. trace.x = plot_history["x"] trace.y = y_history return server.figs def _replaceFigures(server, figures: list[go.Figure]): """Replace figures and rebuild append-only history from their trace data. Parameters ---------- server : _DashServer Server whose figures and histories are replaced. figures : list[plotly.graph_objects.Figure] New figures, including any existing trace samples that must be retained. """ with server.history_lock: server.figs = figures server.plot_histories = [] for figure in figures: # Plotly figures conventionally share X data; retain the first trace's X values once. first_trace = figure.data[0] if figure.data else None server.plot_histories.append( { "x": list(first_trace.x) if first_trace and first_trace.x is not None else [], "y": [list(trace.y) if trace.y is not None else [] for trace in figure.data], } ) def _decodeSamples(payload: bytes | memoryview) -> list[tuple[float, list[float]]]: """Decode one binary numeric sample frame. Parameters ---------- payload : bytes or memoryview Frame body after the one-byte binary message discriminator. Returns ------- list[tuple[float, list[float]]] Per-plot X values and flattened trace values. """ offset = 0 plot_count = struct.unpack_from("!I", payload, offset)[0] offset += 4 samples = [] for _ in range(plot_count): x_value = struct.unpack_from("!d", payload, offset)[0] offset += 8 value_count = struct.unpack_from("!I", payload, offset)[0] offset += 4 values = list(struct.unpack_from(f"!{value_count}d", payload, offset)) offset += value_count * 8 samples.append((x_value, values)) if offset != len(payload): raise ValueError("Binary plot frame contains trailing data") return samples def _receiveExactly(connection: socket.socket, buffer: bytearray, size: int) -> memoryview | None: """Receive an exact number of bytes into a reusable buffer. Parameters ---------- connection : socket.socket Connected producer socket. buffer : bytearray Reusable destination with capacity for at least ``size`` bytes. size : int Number of bytes required. Returns ------- memoryview or None View of the received bytes, or ``None`` when the peer closes before the frame is complete. """ view = memoryview(buffer)[:size] received = 0 while received < size: count = connection.recv_into(view[received:], size - received) if count == 0: return None received += count return view class _DashAppClient(_DashAppBase): """Use this class to create a DashApp that you can stream data to. This class will automatically create empty plots based on the `SinglePlotData`s provided. In addition, it will start a websocket and http server for clients to connect to view the plots. Calling `update` will update the data of the plots based on the data provided in the `SinglePlotData`s. """ def __init__( self, data: list[dict[str, Any]], title: str = "DataPlotter", host: str = "0.0.0.0", port: int = 8050, batch_size: int = 16, max_latency: float = 0.05, ): """Initialize the DashApp. This starts a server on the provided host and port. This server should shutdown automatically once this instance is deleted. Parameters ---------- data : list[SinglePlotData] Data to be plotted and update functions. title: str The title of the DashApp webpage. host: str The host to host the server on. port: int The port to use for the server. batch_size : int Number of simulation updates to combine before immediately sending a browser batch. max_latency : float Maximum seconds a partial browser batch may wait for another simulation update. """ super().__init__(title=title, host=host, port=port) # Pending browser updates are grouped by plot and flushed by size or latency. self._pending_plots: dict[str, dict[str, Any]] = {} self._pending_sample_count = 0 self._flush_handle: asyncio.TimerHandle | None = None self.batch_size = batch_size self.max_latency = max_latency # Add the plots for d in data: # Using underscore method here to avoid calling the refresh method, since it doesn't exist yet. self._addPlot(d) # Add extra HTML elements self._server.app.layout.append( html.Div( [ WebSocket( url=f"http://{self._server.hostname}:{self._server.websocket_port.result()}/refresh", id="ws-refresh", ), html.Div( id="ws-url-div", children=f"ws://{self._server.hostname}:{self._server.websocket_port.result()}/update", style={"display": "none"}, ), html.Div(id="plots-div"), ] ) ) # Use the static method to create callbacks. This avoids tying up self, which leads to reference cycles # that cause issues when tearing things down. self._createPlots(self._server, self._server.app) @staticmethod def _createPlots(server, app): # This callback refreshes the plots entirely @app.callback(Output("plots-div", "children"), Input("ws-refresh", "message")) def refreshPlots(msg): # Materialize complete Plotly traces only for a new/refreshing browser. server.figs = _snapshotFigures(server) server.gs = [ dcc.Graph(id=label, figure=fig) for label, fig in zip(server.fig_labels, server.figs) ] return server.gs # Refresh the plots with the initial data server.sendWebsocketMessage(dumps({"msg": "update graph!"}), path="/refresh") def update(self, inc_data: list[tuple[float, list[float]]]): """Update the plots. Data is appended to the complete server-side history and to a compact browser batch. The batch is sent when either ``batch_size`` updates accumulate or ``max_latency`` expires. Parameters ---------- inc_data : list[tuple[float, list[float]]] Per-plot X value and flattened trace values decoded from the binary TCP frame. """ with self._server.history_lock: for plot_index, (plot_id, data) in enumerate(zip(self._server.fig_labels, inc_data)): x_data, y_values = data history = self._server.plot_histories[plot_index] history["x"].append(x_data) for y_history, y_value in zip(history["y"], y_values): y_history.append(y_value) pending = self._pending_plots.get(plot_id) if pending is None: pending = {"id": plot_id, "x": [], "y": [[] for _ in y_values]} self._pending_plots[plot_id] = pending pending["x"].append(x_data) for trace_values, y_value in zip(pending["y"], y_values): trace_values.append(y_value) self._pending_sample_count += 1 if self._pending_sample_count >= self.batch_size: self._flushPendingData() elif self._flush_handle is None: # The deadline guarantees a partial batch is sent even if no more samples arrive. self._flush_handle = self._server._websocket_loop.call_later( self.max_latency, self._flushPendingData ) def setBatchSize(self, batch_size: int): """Change the number of updates that triggers an immediate batch flush. Parameters ---------- batch_size : int New positive batch size. A pending batch is flushed immediately when it already contains at least this many updates. """ self.batch_size = batch_size if self._pending_sample_count >= self.batch_size: self._flushPendingData() def setMaxLatency(self, max_latency: float): """Change how long a partial batch may wait before being sent. Parameters ---------- max_latency : float New non-negative latency in seconds. An outstanding deadline restarts from the time this method is called. """ self.max_latency = max_latency if self._flush_handle is not None: # Restart an outstanding deadline using the newly requested latency. self._flush_handle.cancel() self._flush_handle = self._server._websocket_loop.call_later( self.max_latency, self._flushPendingData ) def _flushPendingData(self): """Send pending samples as one compact, plot-oriented browser message. Notes ----- Pending buffers are swapped before transmission so samples received during the WebSocket send are accumulated in the next batch. """ if self._flush_handle is not None: self._flush_handle.cancel() self._flush_handle = None if not self._pending_plots: return # Swap buffers before sending so newly arriving samples join the next batch. payload = {"plots": list(self._pending_plots.values())} self._pending_plots = {} self._pending_sample_count = 0 self._server.sendWebsocketMessage(dumps(payload, separators=(",", ":"))) def addPlot(self, data: dict[str, Any]): """Add a new plot the window using the associated plot data. Parameters ---------- data : dict[str, Any] Example data associated with the plot. """ self._flushPendingData() self._addPlot(data) self._server.sendWebsocketMessage(dumps({"msg": "new plot added."}), path="/refresh") def removePlot(self, index: int): """Remove the plot at the associated index. The index corresponds to the figure in the slot of self._server.figs. Parameters ---------- index : int The index of the plot to remove. """ self._flushPendingData() with self._server.history_lock: self._server.figs.pop(index) self._server.fig_labels.pop(index) self._server.gs.pop(index) self._server.plot_histories.pop(index) self._server.sendWebsocketMessage(dumps({"msg": "removing plot"}), path="/refresh") def _addPlot(self, data: dict[str, Any]): """Add a new plot the window using the associated plot data. This does not call the plot refresh. Use the addPlot method for that. Parameters ---------- data : dict[str, Any] The data associated with updating the plot. """ # Create the figure based on the incoming data fig = go.Figure() for j, data_len in data["y_data"].items(): if data_len == 1: fig.add_trace(go.Scatter(x=[], y=[], mode="lines+markers", name=j)) else: for i in range(data_len): fig.add_trace(go.Scatter(x=[], y=[], mode="lines+markers", name=f"{j}[{i}]")) fig.update_layout( template="plotly_dark", xaxis_title=data["x_data_name"], title=data["title"] ) with self._server.history_lock: # Publish all structural state atomically with respect to full-history snapshots. label = f"karana-plot-{self._server.figure_counter}" # Add this to the server's list of figures self._server.figs.append(fig) # Store each trace in append-friendly lists; figures are snapshots, not live storage. self._server.plot_histories.append({"x": [], "y": [[] for _ in range(len(fig.data))]}) # Add the label to the server's list of figure labels self._server.fig_labels.append(label) # Add this to the server's Graph objects self._server.gs.append(dcc.Graph(id=label, figure=fig)) # Increment the figure counter self._server.figure_counter += 1 if TYPE_CHECKING: from multiprocessing import Event, Queue from multiprocessing.synchronize import Event as Evt def _startProcess( shutdown: "Evt", change_plot: "Evt", q: "Queue", data: dict[str, Any], title: str, host: str, port: int, batch_size: int, max_latency: float, ): """Start function for the DashApp multiprocessing process. Parameters ---------- shutdown : Evt An event to indicate shutdown. change_plot : Evt An event to indicate we are changing something fundamental about the plots. q : Queue A multiprocessing Queue to exchange data. data : list[SinglePlotData] The data to use to create the plots. title : str The title of the DashApp. host : str The host for the DashApp. port : int The port to server the DashApp on. batch_size : int Number of simulation samples that triggers an immediate browser batch. max_latency : float Maximum seconds a partial browser batch may wait before being sent. """ from json import loads # Bind loopback TCP before creating the parent-side PlotData connection. Unlike Unix-domain # sockets, this framed transport behaves the same on supported macOS and Linux systems. listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listener.bind(("127.0.0.1", 0)) listener.listen(1) data_port = listener.getsockname()[1] client = _DashAppClient(data, title, host, port, batch_size, max_latency) # Send data back to the parent process q.put(f"http://{client._server.hostname}:{client._server.port}") q.put("127.0.0.1") q.put(data_port) def processMessage(message): """Apply one decoded producer message on the WebSocket event-loop thread. Parameters ---------- message : list or dict Plot samples or a control message decoded from the framed TCP payload. """ if isinstance(message, list): client.update(message) elif isinstance(message, dict) and "REMOVE_PLOT" in message: client.removePlot(message["REMOVE_PLOT"]) change_plot.set() elif isinstance(message, dict) and "ADD_PLOT" in message: client.addPlot(message["ADD_PLOT"]) change_plot.set() elif isinstance(message, dict) and "GET_FIGS" in message: q.put(_snapshotFigures(client._server)) elif isinstance(message, dict) and "SET_FIGS" in message: client._flushPendingData() _replaceFigures(client._server, q.get()) client._server.sendWebsocketMessage(dumps({"msg": "updating plot"}), path="/refresh") change_plot.set() elif isinstance(message, dict) and "SET_BATCH_SIZE" in message: client.setBatchSize(message["SET_BATCH_SIZE"]) elif isinstance(message, dict) and "SET_MAX_LATENCY" in message: client.setMaxLatency(message["SET_MAX_LATENCY"]) def receiveData(): """Receive framed binary or JSON messages and schedule Dash event-loop handling. Notes ----- This function runs on a dedicated thread because socket reads are blocking. """ connection, _ = listener.accept() listener.close() with connection: connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) header_buffer = bytearray(4) payload_buffer = bytearray() while True: header = _receiveExactly(connection, header_buffer, 4) if header is None: break message_size = struct.unpack("!I", header)[0] # Grow only when a larger frame arrives; smaller frames reuse existing capacity. if message_size > len(payload_buffer): payload_buffer = bytearray(message_size) payload = _receiveExactly(connection, payload_buffer, message_size) if payload is None: break message_type = payload[0] if message_type == ord("D"): message = _decodeSamples(payload[1:]) elif message_type == ord("J"): # JSON controls are infrequent, so materializing their small payload is fine. message = loads(payload[1:].tobytes()) else: raise ValueError("Unknown plot transport message type") client._server._websocket_loop.call_soon_threadsafe(processMessage, message) # Release views before a future iteration replaces or grows their bytearrays. del header del payload # Keep blocking socket reads away from both the WSGI and browser WebSocket threads. receiver_thread = Thread(target=receiveData, daemon=True) receiver_thread.start() # Wait until we get the signal from the parent to shutdown shutdown.wait() # Closing PlotData in the parent closes the TCP peer and lets the receiver finish. receiver_thread.join(timeout=10.0) # Explicitly stop the server. del just removes things from the local scope, but # the process may exit after that since interpolation is techincally done (while # logic from __del__ is still running). This ensures the server is shutdown # every time before closing. client._server._stopServer()
[docs] class DashApp(PlotData): """Use this class to create a DashApp that you can stream data to. This class will automatically create empty plots based on the `SinglePlotData`s provided. In addition, it will start a websocket and http server for clients to connect to view the plots. Calling `update` will update the data of the plots based on the data provided in the `SinglePlotData`s. """ def __init__( self, data: list[SinglePlotData], title: str = "DataPlotter", host: str = "0.0.0.0", port: int = 8050, ): """Initialize the DashApp. This starts a server on the provided host and port. This server should shutdown automatically once this instance is deleted. Parameters ---------- data : list[SinglePlotData] Data to be plotted and update functions. title: str The title of the DashApp webpage. host: str The host to host the server on. port: int The port to use for the server. """ from multiprocessing import Event, Process, Queue # Set per-instance defaults without exposing additional constructor arguments. Control # messages remain disabled until the parent-side TCP transport has connected. self._transport_ready = False self.batch_size = 32 self.max_latency = 0.05 self.shutdown_event = shutdown = Event() self.change_plot = change_plot = Event() q = self._mp_queue = Queue() # Get data for the initial plots dat = [] for d in data: dark = { "title": d.title, "x_data_name": d.x_data_name, "x_data": 1, "y_data": {k: v for k, v in zip(d.y_data_names, d.y_data_sizes)}, } dat.append(dark) self.p = Process( target=_startProcess, args=( shutdown, change_plot, q, dat, title, host, port, self._batch_size, self._max_latency, ), ) self.p.daemon = True self.p.start() # Wait on the values from the queue # This is the server url self.url: str = q.get() # This is the internal TCP transport hostname/port. hostname = cast(str, q.get()) p = str(q.get()) self._transport_url = f"tcp://{hostname}:{p}" # Call the parent constructor super().__init__(data, hostname, p, "/data") self._transport_ready = True @property def batch_size(self) -> int: """Number of simulation updates that triggers an immediate browser batch. Returns ------- int Current positive batch size. """ return self._batch_size @batch_size.setter def batch_size(self, value: int): """Set the number of updates that triggers an immediate browser batch. Parameters ---------- value : int Positive number of updates per batch. Raises ------ ValueError If ``value`` is a boolean, is not an integer, or is less than one. """ if not isinstance(value, int) or isinstance(value, bool) or value < 1: raise ValueError("batch_size must be an integer greater than zero") self._batch_size = value if self._transport_ready: # Propagate changes made after construction to the child plotting process. self.sendMessage(dumps({"SET_BATCH_SIZE": value})) @property def max_latency(self) -> float: """Maximum time a partial browser batch may wait before being sent. Returns ------- float Current maximum latency in seconds. """ return self._max_latency @max_latency.setter def max_latency(self, value: float): """Set the maximum time a partial browser batch may wait. Parameters ---------- value : float Non-negative latency in seconds. Raises ------ ValueError If ``value`` is a boolean, is not numeric, or is negative. """ if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0: raise ValueError("max_latency must be a non-negative number") self._max_latency = float(value) if self._transport_ready: # Propagate changes made after construction to the child plotting process. self.sendMessage(dumps({"SET_MAX_LATENCY": self._max_latency})) @overload def removePlot(self, title: str, /): """Remove the plot with the associated title. This will only work if there is one and only one title associated with the plot. """ ... @overload def removePlot(self, index: int, /): """Remove the plot at the associated index. The index corresponds to the figure in the slot of self._server.figs. Parameters ---------- index : int The index of the plot to remove. """ ...
[docs] def removePlot(self, arg: int | str, /): # pyright: ignore - override with different signature on purpuse """Remove the plot based on the value of arg. See overloads for more details. """ index = None if isinstance(arg, str): for k, d in enumerate(self.plot_fns): if d.title == arg: if index is not None: raise ValueError(f"More than one plot has the title {arg}") else: index = k if index is None: raise ValueError(f"No match found for title {arg}") else: index = arg self.change_plot.clear() super().removePlot(index) self.change_plot.wait()
[docs] def addPlot(self, plot_data: SinglePlotData): """Add a plot. Parameters ---------- plot_fns : SinglePlotData The plotting functions associated with the new plot. """ self.change_plot.clear() super().addPlot(plot_data) self.change_plot.wait()
@property def figs(self) -> list[go.Figure]: """Get the figures associated with this DashApp.""" self.sendMessage(dumps({"GET_FIGS": 1})) return self._mp_queue.get() @figs.setter def figs(self, figs: list[go.Figure]): """Set the figures associated with this DashApp.""" self.change_plot.clear() self._mp_queue.put(figs) self.sendMessage(dumps({"SET_FIGS": 1})) self.change_plot.wait()
[docs] def __del__(self): """Shut everything down.""" self.shutdown() if self.p.is_alive(): # If p is still alive, try to shut it down cleanly. self.shutdown_event.set() if os.environ.get("DTEST_RUNNING", False): # If we are running tests in parallel, then don't set a timeout. # The strain of the tests means this may take a little while if the CPUs # are maxed. self.p.join() else: self.p.join(timeout=10.0)