Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions examples/performance_checker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Performance checker Example

This example demonstrates using nipanel with Streamlit to display a dynamic sine wave using the `streamlit-echarts` library.

## Features

- Generates sine wave data with varying frequency
- Displays the data in an chart
- Updates rapidly
- Shows timing information

### Required Software

- Python 3.9 or later

### Usage

Run `poetry run python examples/performance_checker/performance_checker.py`
36 changes: 36 additions & 0 deletions examples/performance_checker/performance_checker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Example of using nipanel to display a sine wave graph using st_echarts."""

import math
import time
from pathlib import Path

import numpy as np

import nipanel


panel_script_path = Path(__file__).with_name("performance_checker_panel.py")
panel = nipanel.create_panel(panel_script_path)

amplitude = 1.0
frequency = 1.0
num_points = 1000
try:
print(f"Panel URL: {panel.panel_url}")
print("Press Ctrl+C to exit")

# Generate and update the sine wave data as fast as possible
while True:
time_points = np.linspace(0, num_points, num_points)
sine_values = amplitude * np.sin(frequency * time_points)

panel.set_value("time_points", time_points.tolist())
panel.set_value("sine_values", sine_values.tolist())
panel.set_value("amplitude", amplitude)
panel.set_value("frequency", frequency)

# Slowly vary the frequency for a more dynamic visualization
frequency = 1.0 + 0.5 * math.sin(time.time() / 5.0)

except KeyboardInterrupt:
print("Exiting...")
121 changes: 121 additions & 0 deletions examples/performance_checker/performance_checker_panel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""A Streamlit visualization panel for the perf_check.py example script."""

import statistics
import time
from typing import Any, Tuple

import streamlit as st
from streamlit_echarts import st_echarts

import nipanel


def measure_get_value_time(
panel: "nipanel.StreamlitPanelValueAccessor", value_id: str, default_value: Any = None
) -> Tuple[Any, float]:
"""Measure the time it takes to get a value from the panel.

Args:
panel: The panel accessor object
value_id: The ID of the value to get
default_value: Default value if the value is not found

Returns:
A tuple of (value, time_ms) where time_ms is the time in milliseconds
"""
start_time = time.time()
value = panel.get_value(value_id, default_value)
end_time = time.time()
time_ms = (end_time - start_time) * 1000
return value, time_ms


st.set_page_config(page_title="Performance Checker Example", page_icon="📈", layout="wide")
st.title("Performance Checker Example")

# Initialize refresh history list if it doesn't exist
if "refresh_history" not in st.session_state:
st.session_state.refresh_history = []

# Store current timestamp and calculate time since last refresh
current_time = time.time()
if "last_refresh_time" not in st.session_state:
st.session_state.last_refresh_time = current_time
time_since_last_refresh = 0.0
else:
time_since_last_refresh = (current_time - st.session_state.last_refresh_time) * 1000
st.session_state.last_refresh_time = current_time

# Store the last 10 refresh times
st.session_state.refresh_history.append(time_since_last_refresh)
if len(st.session_state.refresh_history) > 10:
st.session_state.refresh_history.pop(0)

panel = nipanel.get_panel_accessor()

# Measure time to get each value
time_points, time_points_ms = measure_get_value_time(panel, "time_points", [0.0])
sine_values, sine_values_ms = measure_get_value_time(panel, "sine_values", [0.0])
amplitude, amplitude_ms = measure_get_value_time(panel, "amplitude", 1.0)
frequency, frequency_ms = measure_get_value_time(panel, "frequency", 1.0)
unset_value, unset_value_ms = measure_get_value_time(panel, "unset_value", "default")

if st.session_state.refresh_history:
history = st.session_state.refresh_history
else:
history = []

# Calculate statistics
min_time = min(history) if history else 0
max_time = max(history) if history else 0
avg_time = statistics.mean(history) if history else 0

# Prepare data for echarts
data = [{"value": [x, y]} for x, y in zip(time_points, sine_values)]

# Configure the chart options
options = {
"animation": False, # Disable animation for smoother updates
"title": {"text": "Sine Wave"},
"tooltip": {"trigger": "axis"},
"xAxis": {"type": "value", "name": "Time (s)", "nameLocation": "middle", "nameGap": 30},
"yAxis": {
"type": "value",
"name": "Amplitude",
"nameLocation": "middle",
"nameGap": 30,
},
"series": [
{
"data": data,
"type": "line",
"showSymbol": True,
"smooth": True,
"lineStyle": {"width": 2, "color": "#1f77b4"},
"areaStyle": {"color": "#1f77b4", "opacity": 0.3},
"name": "Sine Wave",
}
],
}

# Display the chart
st_echarts(options=options, height="400px", key="graph")

# Create columns for metrics
col1, col2, col3, col4 = st.columns(4)
with col1:
st.metric("Amplitude", f"{amplitude:.2f}")
st.metric("Frequency", f"{frequency:.2f} Hz")
with col2:
st.metric("Refresh Time", f"{time_since_last_refresh:.1f} ms")
st.metric("Min Refresh Time", f"{min_time:.1f} ms")
st.metric("Max Refresh Time", f"{max_time:.1f} ms")
st.metric("Avg Refresh Time", f"{avg_time:.1f} ms")

with col3:
st.metric("get time_points", f"{time_points_ms:.1f} ms")
st.metric("get sine_values", f"{sine_values_ms:.1f} ms")
st.metric("get amplitude", f"{amplitude_ms:.1f} ms")
st.metric("get frequency", f"{frequency_ms:.1f} ms")
with col4:
st.metric("get unset_value", f"{unset_value_ms:.1f} ms")
3 changes: 1 addition & 2 deletions protos/ni/pythonpanel/v1/python_panel_service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ service PythonPanelService {
// Get a value for a control on the panel
// Status Codes for errors:
// - INVALID_ARGUMENT: The specified identifier contains invalid characters. Only alphanumeric characters and underscores are allowed.
// - NOT_FOUND: The value with the specified identifier was not found
rpc GetValue(GetValueRequest) returns (GetValueResponse);

// Set a value for a control on the panel
Expand Down Expand Up @@ -99,7 +98,7 @@ message GetValueRequest {

message GetValueResponse {
// The value
google.protobuf.Any value = 1;
optional google.protobuf.Any value = 1;
}

message SetValueRequest {
Expand Down
16 changes: 8 additions & 8 deletions src/ni/pythonpanel/v1/python_panel_service_pb2.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions src/ni/pythonpanel/v1/python_panel_service_pb2.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,9 @@ class GetValueResponse(google.protobuf.message.Message):
*,
value: google.protobuf.any_pb2.Any | None = ...,
) -> None: ...
def HasField(self, field_name: typing.Literal["value", b"value"]) -> builtins.bool: ...
def ClearField(self, field_name: typing.Literal["value", b"value"]) -> None: ...
def HasField(self, field_name: typing.Literal["_value", b"_value", "value", b"value"]) -> builtins.bool: ...
def ClearField(self, field_name: typing.Literal["_value", b"_value", "value", b"value"]) -> None: ...
def WhichOneof(self, oneof_group: typing.Literal["_value", b"_value"]) -> typing.Literal["value"] | None: ...

global___GetValueResponse = GetValueResponse

Expand Down
1 change: 0 additions & 1 deletion src/ni/pythonpanel/v1/python_panel_service_pb2_grpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ def GetValue(self, request, context):
"""Get a value for a control on the panel
Status Codes for errors:
- INVALID_ARGUMENT: The specified identifier contains invalid characters. Only alphanumeric characters and underscores are allowed.
- NOT_FOUND: The value with the specified identifier was not found
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
Expand Down
3 changes: 0 additions & 3 deletions src/ni/pythonpanel/v1/python_panel_service_pb2_grpc.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ class PythonPanelServiceStub:
"""Get a value for a control on the panel
Status Codes for errors:
- INVALID_ARGUMENT: The specified identifier contains invalid characters. Only alphanumeric characters and underscores are allowed.
- NOT_FOUND: The value with the specified identifier was not found
"""

SetValue: grpc.UnaryUnaryMultiCallable[
Expand Down Expand Up @@ -104,7 +103,6 @@ class PythonPanelServiceAsyncStub:
"""Get a value for a control on the panel
Status Codes for errors:
- INVALID_ARGUMENT: The specified identifier contains invalid characters. Only alphanumeric characters and underscores are allowed.
- NOT_FOUND: The value with the specified identifier was not found
"""

SetValue: grpc.aio.UnaryUnaryMultiCallable[
Expand Down Expand Up @@ -161,7 +159,6 @@ class PythonPanelServiceServicer(metaclass=abc.ABCMeta):
"""Get a value for a control on the panel
Status Codes for errors:
- INVALID_ARGUMENT: The specified identifier contains invalid characters. Only alphanumeric characters and underscores are allowed.
- NOT_FOUND: The value with the specified identifier was not found
"""

@abc.abstractmethod
Expand Down
11 changes: 7 additions & 4 deletions src/nipanel/_panel_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,20 +116,23 @@ def set_value(self, panel_id: str, value_id: str, value: object, notify: bool) -
)
self._invoke_with_retry(self._get_stub().SetValue, set_value_request)

def get_value(self, panel_id: str, value_id: str) -> object:
def get_value(self, panel_id: str, value_id: str) -> tuple[bool, object]:
"""Get the value for the control with value_id.

Args:
panel_id: The ID of the panel.
value_id: The ID of the control.

Returns:
The value.
A tuple containing a boolean indicating whether the value was successfully retrieved and
the value itself (or None if not present).
"""
get_value_request = GetValueRequest(panel_id=panel_id, value_id=value_id)
response = self._invoke_with_retry(self._get_stub().GetValue, get_value_request)
the_value = from_any(response.value)
return the_value
if response.HasField("value"):
return True, from_any(response.value)
else:
return False, None

def _get_stub(self) -> PythonPanelServiceStub:
if self._stub is None:
Expand Down
18 changes: 8 additions & 10 deletions src/nipanel/_panel_value_accessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,15 @@ def get_value(self, value_id: str, default_value: _T | None = None) -> _T | obje
Returns:
The value, or the default value if not set
"""
try:
value = self._panel_client.get_value(self._panel_id, value_id)
if default_value is not None and not isinstance(value, type(default_value)):
raise TypeError("Value type does not match default value type.")
return value

except grpc.RpcError as e:
if e.code() == grpc.StatusCode.NOT_FOUND and default_value is not None:
found, value = self._panel_client.get_value(self._panel_id, value_id)
if not found:
if default_value is not None:
return default_value
else:
raise e
raise KeyError(f"Value with id '{value_id}' not found on panel '{self._panel_id}'.")

if default_value is not None and not isinstance(value, type(default_value)):
raise TypeError("Value type does not match default value type.")
return value

def set_value(self, value_id: str, value: object) -> None:
"""Set the value for a control on the panel.
Expand Down
10 changes: 5 additions & 5 deletions tests/unit/test_panel_client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import grpc
import pytest

from nipanel._panel_client import PanelClient

Expand Down Expand Up @@ -51,11 +50,12 @@ def test___start_panels___stop_panel_1_without_reset___enumerate_has_both_panels
}


def test___get_unset_value_raises_exception(fake_panel_channel: grpc.Channel) -> None:
def test___get_unset_value___returns_not_found(fake_panel_channel: grpc.Channel) -> None:
client = create_panel_client(fake_panel_channel)

with pytest.raises(Exception):
client.get_value("panel1", "unset_id")
response = client.get_value("panel1", "unset_id")

assert response == (False, None)


def test___set_value___enumerate_panels_shows_value(
Expand All @@ -73,7 +73,7 @@ def test___set_value___gets_value(fake_panel_channel: grpc.Channel) -> None:

client.set_value("panel1", "val1", "value1", notify=False)

assert client.get_value("panel1", "val1") == "value1"
assert client.get_value("panel1", "val1") == (True, "value1")


def create_panel_client(fake_panel_channel: grpc.Channel) -> PanelClient:
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_streamlit_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def test___panel___get_unset_value_with_no_default___raises_exception(
panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel)

value_id = "test_id"
with pytest.raises(grpc.RpcError):
with pytest.raises(KeyError):
panel.get_value(value_id)


Expand Down
2 changes: 1 addition & 1 deletion tests/utils/_fake_python_panel_servicer.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def EnumeratePanels( # noqa: N802
def GetValue(self, request: GetValueRequest, context: Any) -> GetValueResponse: # noqa: N802
"""Trivial implementation for testing."""
if request.value_id not in self._panel_value_ids.get(request.panel_id, {}):
context.abort(grpc.StatusCode.NOT_FOUND, "Value ID not found in panel")
return GetValueResponse()
value = self._panel_value_ids[request.panel_id][request.value_id]
return GetValueResponse(value=value)

Expand Down