--- myst: html_meta: description: "How instro gets data to places" --- # Publishers {.lead} How instro gets data to places ## Overview Publishers are the mechanism by which `instro` handles data coming from instruments. In the example, we add two {py:obj}`Publishers ` to a `InstroDAQ`. ### Example ```python from instro.daq.drivers.labjack import LabJackTSeriesDriver from instro.daq import InstroDAQ from instro.lib.publishers import NominalCorePublisher, FilePublisher publishers = [ NominalCorePublisher(dataset_rid="ri.catalog.main.dataset.abc123"), FilePublisher(directory="/tmp", format="jsonl") ] daq = InstroDAQ( name="myDAQ", driver=LabJackTSeriesDriver(device_id="440020473"), publishers=publishers, ) # All data automatically streams to Nominal Core ``` Every [`Measurement` and `Command`](/library/library.md#measurements-and-commands) is now recorded by each {py:obj}`Publisher ` in `publishers`. An `Instrument` will `close()` all publishers when it closes. **Note:** Do not attach the same ordinary {py:obj}`Publisher ` instance to more than one instrument. Use [`SharedPublisher`](#sharedpublisher) when multiple instruments need to publish to one underlying destination. ## Built-in Publishers `instro` provides the following built-in publishers. ### {py:obj}`FilePublisher ` Writes measurements and commands to a local file in Avro, CSV, or JSON Lines format. **Constructor** ```py FilePublisher( directory: str | Path | None = None, # Created if missing; defaults to ./instro-measurements format: Literal["json", "jsonl", "csv", "avro"] = "avro", # Output file format custom_file_name: str | None = None, # Optional base name (no extension) *, file: IO | None = None, # Open stream to write to instead of a file ) ``` **Example:** ```python from instro.daq.drivers.labjack import LabJackTSeriesDriver from instro.daq import InstroDAQ from instro.lib.publishers import FilePublisher daq = InstroDAQ( name="myDAQ", driver=LabJackTSeriesDriver(device_id="440020473"), publishers=[FilePublisher(directory="./captures", format="avro")], ) ``` **Format tradeoffs:** - **`avro`** (default): Compact binary format. Uses the same schema as Nominal Core ingest, so captured files can be uploaded after the fact without transformation. Recommended for production captures. - **`csv`**: One row per `(timestamp, channel, value, tags)` tuple. Good for quick inspection in spreadsheet tools. - **`jsonl`**: Newline-delimited JSON, one record per publish. Recommended when you want human-readable output: each record is appended and flushed in constant time, and every flushed line is a complete record. **Streams:** pass an open stream as `file` instead of `directory`: binary for `avro`, text for `csv` and `jsonl` (`json` is not supported). The publisher leaves the stream open. See [Publishers: publish file stream](/examples/publishers/publish_file_stream.md). ### {py:obj}`NominalCorePublisher ` Sends measurement and command data to [Nominal Core](https://nominal.io/products/core) datasets. **Constructor Parameters:** ```python NominalCorePublisher( dataset_rid: str, # Required: RID of your Nominal Core dataset batch_size: int | None = None, # Optional: Batch size before writing max_wait: timedelta | None = None, # Optional: Max time before flush file_fallback: pathlib.Path | None = None, # Optional: Path of fallback file (.avro) used if network connectivity is lost. profile: str | None = None, # Optional: Nominal profile name api_key: str | None = None # Optional: API key for authentication ) ``` **Example:** ```python from instro.daq.drivers.labjack import LabJackTSeriesDriver from instro.daq import InstroDAQ from instro.lib.publishers import NominalCorePublisher DATASET_RID = "ri.catalog.main.dataset.abc123..." daq = InstroDAQ( name="myDAQ", driver=LabJackTSeriesDriver(device_id="440020473"), publishers=[NominalCorePublisher(dataset_rid=DATASET_RID)], ) ``` :::{note} A shorthand method to add publishing to Nominal Core is to directly pass in a `dataset_rid` kwarg. This requires a default profile to have been configured on the system. ```py daq = InstroDAQ( name="myDAQ", driver=LabJackTSeriesDriver(device_id="440020473"), dataset_rid="ri.catalog...", # Automatically creates NominalCorePublisher ) ``` ::: :::{note} **backup FilePublisher** If you only need a local file as a fallback for when network connectivity to Nominal Core is lost, use the `file_fallback` parameter on `NominalCorePublisher` instead of attaching a separate `FilePublisher`. ::: ### NominalConnectPublisher Streams real-time data to Nominal Connect for live visualization and monitoring during tests that use the Nominal Connect Desktop Application. Nominal Connect apps can also install `instro` for you; see [Using Nominal Connect](/installation.md#using-nominal-connect). **Constructor Parameters:** ```python from connect_python import Client NominalConnectPublisher( client: Client, # Nominal Connect client instance stream_id: str # Stream identifier for Connect ) ``` **Example, from a typical Connect app:** ```python from instro.daq.drivers.labjack import LabJackTSeriesDriver from instro.daq import InstroDAQ from instro.lib.publishers import NominalConnectPublisher import connect_python @connect_python.main def main(client: connect_python.Client): stream_id = "myDAQ-stream" daq = InstroDAQ( name="myDAQ", driver=LabJackTSeriesDriver(device_id="440020473"), publishers=[NominalConnectPublisher(client=client, stream_id=stream_id)], ) ``` ### {py:obj}`PlotlyPublisher ` Plots measurements and commands on a [Plotly](https://plotly.com/python/) figure, one trace per channel. **Constructor Parameters:** ```python PlotlyPublisher( figure: go.Figure | go.FigureWidget | None = None, # Optional: figure to plot on; created and displayed when omitted max_points: int = 10_000, # Points kept per channel; older points drop off the plot flush_interval: float = 0.1, # Seconds between writes to the figure ) ``` `PlotlyPublisher` requires the `plot` optional dependency: `pip install "instro[plot]"`. **Example:** ```python import plotly.graph_objects as go from instro.lib.publishers import PlotlyPublisher from instro.psu import InstroPSU from instro.psu.drivers import SimulatedPSU figure = go.Figure(layout_title_text="PSU voltage sweep") psu = InstroPSU( name="myPSU", driver=SimulatedPSU("TCPIP0::127.0.0.1::5025::SOCKET"), num_channels=2, publishers=[PlotlyPublisher(figure)], ) ``` See [Publishers: plot with Plotly](/examples/publishers/publish_plotly.md) for a full example. ## Attaching Publishers {py:obj}`Publishers ` can be attached after Instrument construction also. ```python ... daq = InstroDAQ(name="myDAQ", driver=LabJackTSeriesDriver(device_id="440020473")) daq.add_publisher(NominalCorePublisher(dataset_rid="ri.catalog...")) ``` ## Custom Publisher All {py:obj}`Publisher ` implementations follow a simple protocol with two required methods `publish` and `close`: ```python from typing import Protocol from instro.lib.types import Measurement, Command class Publisher(Protocol): def publish(self, data: Measurement | Command, **kwargs) -> None: """Called each time the instrument generates data""" ... def close(self) -> None: """Called when the owning instrument is closed""" ... ``` * `publish`: returns [`Measurements` and `Commands`](/library/library.md#measurements-and-commands). * `close`: release resources owned by the {py:obj}`Publisher `. The parent instrument calls `close()` during teardown. **Example** The example below prints to the console every time a `Measurement` is published corresponding to a predefined {abbr}`channel (A named signal for a series of measurements or computed values, example: voltage, pressure, system state.)` name. ```python from instro.lib.types import Measurement, Command class PrintChannelPublisher: def __init__(self, channel_name: str): self.channel_name = channel_name def publish(self, data: Measurement | Command, **kwargs) -> None: try: if isinstance(data, Measurement): if data.channel_data.get(self.channel_name, None): print(f"{self.channel_name}: {data.latest}") except Exception as e: print(f"Error publishing data: {e}") def close(self) -> None: pass ``` ## Publisher Wrappers Publisher wrappers modify the behavior of other publishers by adding buffering, asynchronous processing, or explicit shared ownership. They are composable. ### SharedPublisher `SharedPublisher` coordinates shared ownership of one underlying publisher across multiple instruments. The default model is still exclusive ownership: one publisher instance belongs to one instrument. Use `SharedPublisher` only when multiple instruments must publish to the same file, stream, client, or other sink. Each instrument receives its own `SharedPublisher` handle. Closing one instrument closes only that handle. The underlying publisher closes after the last shared handle closes. **When to use:** - Multiple instruments should write to the same local capture file - Multiple instruments should publish to the same custom sink - The underlying publisher owns a resource that should stay open until all instruments finish **Parameters:** ```python SharedPublisher( publisher: Publisher # The underlying publisher to share ) ``` **Example:** ```python from instro.lib.publishers import FilePublisher, SharedPublisher file_publisher = FilePublisher(directory="./captures", format="avro") shared_publisher = SharedPublisher(file_publisher) primary_psu.add_publisher(shared_publisher) secondary_psu.add_publisher(shared_publisher.clone()) primary_psu.close() # Leaves the underlying FilePublisher open. secondary_psu.close() # Closes the underlying FilePublisher. ``` See [Publishers: publish shared](/examples/publishers/publish_shared.md) for a full example. ### BasicBufferedPublisher `BasicBufferedPublisher` collects data in memory and publishes in batches, reducing the overhead of frequent publish calls. It is the concrete implementation of the abstract `BufferedPublisher` base, so instantiate `BasicBufferedPublisher`. **When to use:** - You're making many small publish calls and want to reduce overhead - You want to batch data before sending to a remote service - Network or I/O latency is impacting performance **Parameters:** ```python BasicBufferedPublisher( publisher: Publisher, # The publisher to wrap buffer_size: int = 1000 # Flush when buffer reaches this size ) ``` **Example:** ```python import time from instro.psu.drivers import SimulatedPSU from instro.psu import InstroPSU from instro.lib.publishers import BasicBufferedPublisher from instro.lib.types import Measurement, Command # Custom publisher that prints to console class PrintChannelPublisher: def __init__(self, channel_name: str): self.channel_name = channel_name def publish(self, data: Measurement | Command, **kwargs): if isinstance(data, Measurement): if data.channel_data.get(self.channel_name, None): print(f"{self.channel_name}: {data.latest}") def close(self): print("Publisher closed") # Wrap the custom publisher with buffering print_publisher = PrintChannelPublisher(channel_name="myPSU.ch1.current") buffered_publisher = BasicBufferedPublisher(print_publisher, buffer_size=5) psu = InstroPSU( name="myPSU", driver=SimulatedPSU("TCPIP0::127.0.0.1::5025::SOCKET"), num_channels=2, ) psu.add_publisher(buffered_publisher) psu.open() psu.output_enable(True, channel=1) # Console output only appears every 5 measurements (when buffer flushes) for v in range(12): psu.set_voltage(v * 0.1, channel=1) psu.get_current(channel=1) time.sleep(0.1) psu.close() # Remaining buffer is automatically flushed ``` :::{note} The buffer is automatically flushed when it reaches capacity or when `close()` is called, ensuring no data is lost. ::: ### QueuedPublisher Offloads publishing to a background thread, making publish calls non-blocking and preventing slow publishers from impacting instrument operations. **When to use:** - Publishing is slow (network latency, disk I/O) and blocking your test loop - You want instrument operations to proceed immediately without waiting for publish - You need guaranteed throughput for time-critical measurements **Parameters:** ```python QueuedPublisher( publisher: Publisher, # The publisher to wrap max_queue_size: int = 1000, # Maximum queue size wait_for_queue: bool = False # Wait for queue to empty on close ) ``` **Example:** ```python import time from instro.psu.drivers import SimulatedPSU from instro.psu import InstroPSU from instro.lib.publishers import QueuedPublisher from instro.lib.types import Measurement, Command # Custom publisher with artificial delay to simulate slow I/O class SlowPublisher: def publish(self, data: Measurement | Command, **kwargs): time.sleep(0.1) # Simulate slow network or disk I/O print(f"Published {len(data.channel_data)} channels") def close(self): print("Publisher closed") # Wrap the slow publisher with queuing slow_publisher = SlowPublisher() queued_publisher = QueuedPublisher( slow_publisher, max_queue_size=100, wait_for_queue=True # Ensure all data is sent before closing ) psu = InstroPSU( name="myPSU", driver=SimulatedPSU("TCPIP0::127.0.0.1::5025::SOCKET"), num_channels=2, ) psu.add_publisher(queued_publisher) psu.open() psu.output_enable(True, channel=1) # Publishing happens in background - no blocking! for v in range(10): start = time.time() psu.set_voltage(v, channel=1) psu.get_current(channel=1) print(f"Loop iteration: {time.time() - start:.4f}s") # Very fast! psu.close() # Waits for queue to empty before closing ``` :::{tip} Using `QueuedPublisher` can dramatically improve test performance when publishing is a bottleneck. The background thread handles all I/O while your test continues uninterrupted. :::