From 8be90f7426f481e723fbd64219cd900b2291d64a Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Mon, 30 Jun 2025 15:50:13 -0500 Subject: [PATCH 01/26] add numeric and enum inputs to the simple graph example --- examples/simple_graph/amplitude_enum.py | 11 ++++++++ examples/simple_graph/simple_graph.py | 16 ++++++----- examples/simple_graph/simple_graph_panel.py | 30 ++++++++++++++------- 3 files changed, 41 insertions(+), 16 deletions(-) create mode 100644 examples/simple_graph/amplitude_enum.py diff --git a/examples/simple_graph/amplitude_enum.py b/examples/simple_graph/amplitude_enum.py new file mode 100644 index 0000000..b1f12ca --- /dev/null +++ b/examples/simple_graph/amplitude_enum.py @@ -0,0 +1,11 @@ +"""Enumeration for amplitude values used in the simple graph example.""" + +import enum + + +class AmplitudeEnum(enum.IntEnum): + """Enumeration for amplitude values.""" + + SMALL = 1 + MEDIUM = 5 + BIG = 10 diff --git a/examples/simple_graph/simple_graph.py b/examples/simple_graph/simple_graph.py index 4b63530..b62cb5b 100644 --- a/examples/simple_graph/simple_graph.py +++ b/examples/simple_graph/simple_graph.py @@ -5,6 +5,7 @@ from pathlib import Path import numpy as np +from amplitude_enum import AmplitudeEnum import nipanel @@ -12,8 +13,6 @@ panel_script_path = Path(__file__).with_name("simple_graph_panel.py") panel = nipanel.create_panel(panel_script_path) -amplitude = 1.0 -frequency = 1.0 num_points = 100 try: @@ -22,16 +21,19 @@ # Generate and update the sine wave data periodically while True: + amplitude_enum = AmplitudeEnum(panel.get_value("amplitude_enum", AmplitudeEnum.SMALL.value)) + base_frequency = panel.get_value("base_frequency", 1.0) + + # Slowly vary the total frequency for a more dynamic visualization + frequency = base_frequency + 0.5 * math.sin(time.time() / 5.0) + time_points = np.linspace(0, num_points, num_points) - sine_values = amplitude * np.sin(frequency * time_points) + sine_values = amplitude_enum.value * np.sin(frequency * time_points) + panel.set_value("frequency", frequency) 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) time.sleep(0.1) except KeyboardInterrupt: diff --git a/examples/simple_graph/simple_graph_panel.py b/examples/simple_graph/simple_graph_panel.py index 6550627..061c782 100644 --- a/examples/simple_graph/simple_graph_panel.py +++ b/examples/simple_graph/simple_graph_panel.py @@ -1,6 +1,7 @@ """A Streamlit visualization panel for the simple_graph.py example script.""" import streamlit as st +from amplitude_enum import AmplitudeEnum from streamlit_echarts import st_echarts import nipanel @@ -8,23 +9,34 @@ st.set_page_config(page_title="Simple Graph Example", page_icon="📈", layout="wide") st.title("Simple Graph Example") +col1, col2, col3, col4, col5, col6 = st.columns(6) panel = nipanel.get_panel_accessor() -time_points = panel.get_value("time_points", [0.0]) -sine_values = panel.get_value("sine_values", [0.0]) -amplitude = panel.get_value("amplitude", 1.0) -frequency = panel.get_value("frequency", 1.0) -col1, col2, col3, col4, col5 = st.columns(5) with col1: - st.metric("Amplitude", f"{amplitude:.2f}") + amplitude_tuple = st.selectbox( + "Amplitude", + options=[(e.name, e.value) for e in AmplitudeEnum], + format_func=lambda x: x[0], + index=0, + ) + amplitude_enum = AmplitudeEnum[amplitude_tuple[0]] + panel.set_value("amplitude_enum", amplitude_enum) with col2: - st.metric("Frequency", f"{frequency:.2f} Hz") + base_frequency = st.number_input("Base Frequency", value=1.0, step=0.1) + panel.set_value("base_frequency", base_frequency) + with col3: - st.metric("Min Value", f"{min(sine_values):.3f}") + frequency = panel.get_value("frequency", 0.0) + st.metric("Frequency", f"{frequency:.2f} Hz") + +time_points = panel.get_value("time_points", [0.0]) +sine_values = panel.get_value("sine_values", [0.0]) with col4: - st.metric("Max Value", f"{max(sine_values):.3f}") + st.metric("Min Value", f"{min(sine_values):.3f}") with col5: + st.metric("Max Value", f"{max(sine_values):.3f}") +with col6: st.metric("Data Points", len(sine_values)) # Prepare data for echarts From 585948c1f741a992536ad3be29f91f7fbcb31574 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Mon, 30 Jun 2025 16:48:28 -0500 Subject: [PATCH 02/26] get_value returns the enum type when a default is provided --- examples/simple_graph/simple_graph.py | 2 +- src/nipanel/_panel_value_accessor.py | 11 ++++- tests/unit/test_streamlit_panel.py | 58 +++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/examples/simple_graph/simple_graph.py b/examples/simple_graph/simple_graph.py index b62cb5b..5764f47 100644 --- a/examples/simple_graph/simple_graph.py +++ b/examples/simple_graph/simple_graph.py @@ -21,7 +21,7 @@ # Generate and update the sine wave data periodically while True: - amplitude_enum = AmplitudeEnum(panel.get_value("amplitude_enum", AmplitudeEnum.SMALL.value)) + amplitude_enum = panel.get_value("amplitude_enum", AmplitudeEnum.SMALL) base_frequency = panel.get_value("base_frequency", 1.0) # Slowly vary the total frequency for a more dynamic visualization diff --git a/src/nipanel/_panel_value_accessor.py b/src/nipanel/_panel_value_accessor.py index c174843..8b65f47 100644 --- a/src/nipanel/_panel_value_accessor.py +++ b/src/nipanel/_panel_value_accessor.py @@ -1,5 +1,6 @@ from __future__ import annotations +import enum from abc import ABC from typing import TypeVar, overload @@ -62,8 +63,16 @@ def get_value(self, value_id: str, default_value: _T | None = None) -> _T | obje """ 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.") + if isinstance(default_value, enum.Enum): + enum_type = type(default_value) + return enum_type(value) + + raise TypeError( + f"Value type {type(value).__name__} does not match default value type {type(default_value).__name__}." + ) + return value except grpc.RpcError as e: diff --git a/tests/unit/test_streamlit_panel.py b/tests/unit/test_streamlit_panel.py index a271c79..a4aaaa1 100644 --- a/tests/unit/test_streamlit_panel.py +++ b/tests/unit/test_streamlit_panel.py @@ -242,6 +242,17 @@ def test___set_int_type___get_value_with_bool_default___raises_exception( panel.get_value(value_id, False) +def test___set_string_enum_type___get_value_with_int_enum_default___raises_exception( + fake_panel_channel: grpc.Channel, +) -> None: + panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel) + value_id = "test_id" + panel.set_value(value_id, test_types.MyStrEnum.VALUE3) + + with pytest.raises(ValueError): + panel.get_value(value_id, test_types.MyIntEnum.VALUE10) + + @pytest.mark.parametrize( "value_payload", [ @@ -341,6 +352,53 @@ def test___sequence_of_builtin_type___set_value___gets_same_value( assert list(received_value) == list(value_payload) # type: ignore [call-overload] +def test___set_int_enum_value___get_value___returns_int_enum( + fake_panel_channel: grpc.Channel, +) -> None: + panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel) + value_id = "test_id" + enum_value = test_types.MyIntEnum.VALUE20 + panel.set_value(value_id, enum_value) + + retrieved_value = panel.get_value(value_id, test_types.MyIntEnum.VALUE10) + + assert_type(retrieved_value, test_types.MyIntEnum) + assert retrieved_value is test_types.MyIntEnum.VALUE20 + assert retrieved_value.value == enum_value.value + assert retrieved_value.name == enum_value.name + + +def test___set_string_enum_value___get_value___returns_string_enum( + fake_panel_channel: grpc.Channel, +) -> None: + panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel) + value_id = "test_id" + enum_value = test_types.MyStrEnum.VALUE3 + panel.set_value(value_id, enum_value) + + retrieved_value = panel.get_value(value_id, test_types.MyStrEnum.VALUE1) + + assert_type(retrieved_value, test_types.MyStrEnum) + assert retrieved_value is test_types.MyStrEnum.VALUE3 + assert retrieved_value.value == enum_value.value + assert retrieved_value.name == enum_value.name + + +def test___set_int_flags_value___get_value___returns_int_flags( + fake_panel_channel: grpc.Channel, +) -> None: + panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel) + value_id = "test_id" + flags_value = test_types.MyIntFlags.VALUE1 | test_types.MyIntFlags.VALUE4 + panel.set_value(value_id, flags_value) + + retrieved_value = panel.get_value(value_id, test_types.MyIntFlags.VALUE2) + + assert_type(retrieved_value, test_types.MyIntFlags) + assert retrieved_value == (test_types.MyIntFlags.VALUE1 | test_types.MyIntFlags.VALUE4) + assert retrieved_value.value == flags_value.value + + def test___panel___panel_is_running_and_in_memory( fake_panel_channel: grpc.Channel, ) -> None: From 163735b9784de37212e90f0dbae5ed0246684706 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Tue, 1 Jul 2025 12:46:53 -0500 Subject: [PATCH 03/26] _sync_session_state() --- examples/simple_graph/simple_graph_panel.py | 3 +-- src/nipanel/_convert.py | 9 +++++++++ src/nipanel/_streamlit_panel_initializer.py | 10 ++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/examples/simple_graph/simple_graph_panel.py b/examples/simple_graph/simple_graph_panel.py index 061c782..1ece3c7 100644 --- a/examples/simple_graph/simple_graph_panel.py +++ b/examples/simple_graph/simple_graph_panel.py @@ -23,8 +23,7 @@ amplitude_enum = AmplitudeEnum[amplitude_tuple[0]] panel.set_value("amplitude_enum", amplitude_enum) with col2: - base_frequency = st.number_input("Base Frequency", value=1.0, step=0.1) - panel.set_value("base_frequency", base_frequency) + base_frequency = st.number_input("Base Frequency", value=1.0, step=0.1, key="base_frequency") with col3: frequency = panel.get_value("frequency", 0.0) diff --git a/src/nipanel/_convert.py b/src/nipanel/_convert.py index 5bbc6d0..61a4858 100644 --- a/src/nipanel/_convert.py +++ b/src/nipanel/_convert.py @@ -129,3 +129,12 @@ def from_any(protobuf_any: any_pb2.Any) -> object: converter = _CONVERTER_FOR_GRPC_TYPE[underlying_typename] return converter.to_python(protobuf_any) + + +def is_supported_type(value: object) -> bool: + """Check if a given Python value can be converted to protobuf Any.""" + try: + _get_best_matching_type(value) + return True + except TypeError: + return False diff --git a/src/nipanel/_streamlit_panel_initializer.py b/src/nipanel/_streamlit_panel_initializer.py index 7dee3fd..a13d20c 100644 --- a/src/nipanel/_streamlit_panel_initializer.py +++ b/src/nipanel/_streamlit_panel_initializer.py @@ -3,6 +3,7 @@ import streamlit as st +from nipanel._convert import is_supported_type from nipanel._streamlit_panel import StreamlitPanel from nipanel._streamlit_panel_value_accessor import StreamlitPanelValueAccessor from nipanel.streamlit_refresh import initialize_refresh_component @@ -61,6 +62,7 @@ def get_panel_accessor() -> StreamlitPanelValueAccessor: st.session_state[PANEL_ACCESSOR_KEY] = _initialize_panel_from_base_path() panel = cast(StreamlitPanelValueAccessor, st.session_state[PANEL_ACCESSOR_KEY]) + _sync_session_state(panel) refresh_component = initialize_refresh_component(panel.panel_id) refresh_component() return panel @@ -75,3 +77,11 @@ def _initialize_panel_from_base_path() -> StreamlitPanelValueAccessor: if not panel_id: raise ValueError(f"Panel ID is empty in baseUrlPath: '{base_url_path}'") return StreamlitPanelValueAccessor(panel_id) + + +def _sync_session_state(panel): + """Automatically read keyed control values from the session state.""" + for key in st.session_state.keys(): + value = st.session_state[key] + if is_supported_type(value): + panel.set_value(str(key), value) From acc1e9b74ab61edee2f1691cd95dad17e3b58e45 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Tue, 1 Jul 2025 14:07:42 -0500 Subject: [PATCH 04/26] GetValueResponse.found --- examples/simple_graph/simple_graph.py | 3 ++ .../pythonpanel/v1/python_panel_service.proto | 3 ++ .../v1/python_panel_service_pb2.py | 16 +++++----- .../v1/python_panel_service_pb2.pyi | 6 +++- src/nipanel/_panel_client.py | 6 ++-- src/nipanel/_panel_value_accessor.py | 29 +++++++++---------- src/nipanel/_streamlit_panel_initializer.py | 2 +- tests/unit/test_panel_client.py | 10 +++---- tests/unit/test_streamlit_panel.py | 2 +- tests/utils/_fake_python_panel_servicer.py | 4 +-- 10 files changed, 45 insertions(+), 36 deletions(-) diff --git a/examples/simple_graph/simple_graph.py b/examples/simple_graph/simple_graph.py index 5764f47..e6fc75b 100644 --- a/examples/simple_graph/simple_graph.py +++ b/examples/simple_graph/simple_graph.py @@ -21,6 +21,9 @@ # Generate and update the sine wave data periodically while True: + # not-found values need to be performant + not_found = panel.get_value("no_such_value", "Hello, World!") + amplitude_enum = panel.get_value("amplitude_enum", AmplitudeEnum.SMALL) base_frequency = panel.get_value("base_frequency", 1.0) diff --git a/protos/ni/pythonpanel/v1/python_panel_service.proto b/protos/ni/pythonpanel/v1/python_panel_service.proto index 5b8dd2a..eeb520c 100644 --- a/protos/ni/pythonpanel/v1/python_panel_service.proto +++ b/protos/ni/pythonpanel/v1/python_panel_service.proto @@ -100,6 +100,9 @@ message GetValueRequest { message GetValueResponse { // The value google.protobuf.Any value = 1; + + // Was the value found? + bool found = 2; } message SetValueRequest { diff --git a/src/ni/pythonpanel/v1/python_panel_service_pb2.py b/src/ni/pythonpanel/v1/python_panel_service_pb2.py index 3435f6b..7b61f1d 100644 --- a/src/ni/pythonpanel/v1/python_panel_service_pb2.py +++ b/src/ni/pythonpanel/v1/python_panel_service_pb2.py @@ -14,7 +14,7 @@ from google.protobuf import any_pb2 as google_dot_protobuf_dot_any__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n,ni/pythonpanel/v1/python_panel_service.proto\x12\x11ni.pythonpanel.v1\x1a\x19google/protobuf/any.proto\"U\n\x11StartPanelRequest\x12\x10\n\x08panel_id\x18\x01 \x01(\t\x12\x19\n\x11panel_script_path\x18\x02 \x01(\t\x12\x13\n\x0bpython_path\x18\x03 \x01(\t\"\'\n\x12StartPanelResponse\x12\x11\n\tpanel_url\x18\x01 \x01(\t\"3\n\x10StopPanelRequest\x12\x10\n\x08panel_id\x18\x01 \x01(\t\x12\r\n\x05reset\x18\x02 \x01(\x08\"\x13\n\x11StopPanelResponse\"\x18\n\x16\x45numeratePanelsRequest\"J\n\x10PanelInformation\x12\x10\n\x08panel_id\x18\x01 \x01(\t\x12\x11\n\tpanel_url\x18\x02 \x01(\t\x12\x11\n\tvalue_ids\x18\x03 \x03(\t\"N\n\x17\x45numeratePanelsResponse\x12\x33\n\x06panels\x18\x01 \x03(\x0b\x32#.ni.pythonpanel.v1.PanelInformation\"5\n\x0fGetValueRequest\x12\x10\n\x08panel_id\x18\x01 \x01(\t\x12\x10\n\x08value_id\x18\x02 \x01(\t\"7\n\x10GetValueResponse\x12#\n\x05value\x18\x01 \x01(\x0b\x32\x14.google.protobuf.Any\"j\n\x0fSetValueRequest\x12\x10\n\x08panel_id\x18\x01 \x01(\t\x12\x10\n\x08value_id\x18\x02 \x01(\t\x12#\n\x05value\x18\x03 \x01(\x0b\x32\x14.google.protobuf.Any\x12\x0e\n\x06notify\x18\x04 \x01(\x08\"\x12\n\x10SetValueResponse2\xdb\x03\n\x12PythonPanelService\x12Y\n\nStartPanel\x12$.ni.pythonpanel.v1.StartPanelRequest\x1a%.ni.pythonpanel.v1.StartPanelResponse\x12V\n\tStopPanel\x12#.ni.pythonpanel.v1.StopPanelRequest\x1a$.ni.pythonpanel.v1.StopPanelResponse\x12h\n\x0f\x45numeratePanels\x12).ni.pythonpanel.v1.EnumeratePanelsRequest\x1a*.ni.pythonpanel.v1.EnumeratePanelsResponse\x12S\n\x08GetValue\x12\".ni.pythonpanel.v1.GetValueRequest\x1a#.ni.pythonpanel.v1.GetValueResponse\x12S\n\x08SetValue\x12\".ni.pythonpanel.v1.SetValueRequest\x1a#.ni.pythonpanel.v1.SetValueResponseB\x9a\x01\n\x15\x63om.ni.pythonpanel.v1B\x17PythonPanelServiceProtoP\x01Z\rpythonpanelv1\xf8\x01\x01\xa2\x02\x04NIPP\xaa\x02\"NationalInstruments.PythonPanel.V1\xca\x02\x11NI\\PythonPanel\\V1\xea\x02\x13NI::PythonPanel::V1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n,ni/pythonpanel/v1/python_panel_service.proto\x12\x11ni.pythonpanel.v1\x1a\x19google/protobuf/any.proto\"U\n\x11StartPanelRequest\x12\x10\n\x08panel_id\x18\x01 \x01(\t\x12\x19\n\x11panel_script_path\x18\x02 \x01(\t\x12\x13\n\x0bpython_path\x18\x03 \x01(\t\"\'\n\x12StartPanelResponse\x12\x11\n\tpanel_url\x18\x01 \x01(\t\"3\n\x10StopPanelRequest\x12\x10\n\x08panel_id\x18\x01 \x01(\t\x12\r\n\x05reset\x18\x02 \x01(\x08\"\x13\n\x11StopPanelResponse\"\x18\n\x16\x45numeratePanelsRequest\"J\n\x10PanelInformation\x12\x10\n\x08panel_id\x18\x01 \x01(\t\x12\x11\n\tpanel_url\x18\x02 \x01(\t\x12\x11\n\tvalue_ids\x18\x03 \x03(\t\"N\n\x17\x45numeratePanelsResponse\x12\x33\n\x06panels\x18\x01 \x03(\x0b\x32#.ni.pythonpanel.v1.PanelInformation\"5\n\x0fGetValueRequest\x12\x10\n\x08panel_id\x18\x01 \x01(\t\x12\x10\n\x08value_id\x18\x02 \x01(\t\"F\n\x10GetValueResponse\x12#\n\x05value\x18\x01 \x01(\x0b\x32\x14.google.protobuf.Any\x12\r\n\x05\x66ound\x18\x02 \x01(\x08\"j\n\x0fSetValueRequest\x12\x10\n\x08panel_id\x18\x01 \x01(\t\x12\x10\n\x08value_id\x18\x02 \x01(\t\x12#\n\x05value\x18\x03 \x01(\x0b\x32\x14.google.protobuf.Any\x12\x0e\n\x06notify\x18\x04 \x01(\x08\"\x12\n\x10SetValueResponse2\xdb\x03\n\x12PythonPanelService\x12Y\n\nStartPanel\x12$.ni.pythonpanel.v1.StartPanelRequest\x1a%.ni.pythonpanel.v1.StartPanelResponse\x12V\n\tStopPanel\x12#.ni.pythonpanel.v1.StopPanelRequest\x1a$.ni.pythonpanel.v1.StopPanelResponse\x12h\n\x0f\x45numeratePanels\x12).ni.pythonpanel.v1.EnumeratePanelsRequest\x1a*.ni.pythonpanel.v1.EnumeratePanelsResponse\x12S\n\x08GetValue\x12\".ni.pythonpanel.v1.GetValueRequest\x1a#.ni.pythonpanel.v1.GetValueResponse\x12S\n\x08SetValue\x12\".ni.pythonpanel.v1.SetValueRequest\x1a#.ni.pythonpanel.v1.SetValueResponseB\x9a\x01\n\x15\x63om.ni.pythonpanel.v1B\x17PythonPanelServiceProtoP\x01Z\rpythonpanelv1\xf8\x01\x01\xa2\x02\x04NIPP\xaa\x02\"NationalInstruments.PythonPanel.V1\xca\x02\x11NI\\PythonPanel\\V1\xea\x02\x13NI::PythonPanel::V1b\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'ni.pythonpanel.v1.python_panel_service_pb2', globals()) @@ -39,11 +39,11 @@ _GETVALUEREQUEST._serialized_start=478 _GETVALUEREQUEST._serialized_end=531 _GETVALUERESPONSE._serialized_start=533 - _GETVALUERESPONSE._serialized_end=588 - _SETVALUEREQUEST._serialized_start=590 - _SETVALUEREQUEST._serialized_end=696 - _SETVALUERESPONSE._serialized_start=698 - _SETVALUERESPONSE._serialized_end=716 - _PYTHONPANELSERVICE._serialized_start=719 - _PYTHONPANELSERVICE._serialized_end=1194 + _GETVALUERESPONSE._serialized_end=603 + _SETVALUEREQUEST._serialized_start=605 + _SETVALUEREQUEST._serialized_end=711 + _SETVALUERESPONSE._serialized_start=713 + _SETVALUERESPONSE._serialized_end=731 + _PYTHONPANELSERVICE._serialized_start=734 + _PYTHONPANELSERVICE._serialized_end=1209 # @@protoc_insertion_point(module_scope) diff --git a/src/ni/pythonpanel/v1/python_panel_service_pb2.pyi b/src/ni/pythonpanel/v1/python_panel_service_pb2.pyi index 71483cf..42a2c58 100644 --- a/src/ni/pythonpanel/v1/python_panel_service_pb2.pyi +++ b/src/ni/pythonpanel/v1/python_panel_service_pb2.pyi @@ -162,6 +162,9 @@ class GetValueResponse(google.protobuf.message.Message): DESCRIPTOR: google.protobuf.descriptor.Descriptor VALUE_FIELD_NUMBER: builtins.int + FOUND_FIELD_NUMBER: builtins.int + found: builtins.bool + """Was the value found?""" @property def value(self) -> google.protobuf.any_pb2.Any: """The value""" @@ -170,9 +173,10 @@ class GetValueResponse(google.protobuf.message.Message): self, *, value: google.protobuf.any_pb2.Any | None = ..., + found: builtins.bool = ..., ) -> 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 ClearField(self, field_name: typing.Literal["found", b"found", "value", b"value"]) -> None: ... global___GetValueResponse = GetValueResponse diff --git a/src/nipanel/_panel_client.py b/src/nipanel/_panel_client.py index d06e2c0..c57eb71 100644 --- a/src/nipanel/_panel_client.py +++ b/src/nipanel/_panel_client.py @@ -116,7 +116,7 @@ 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: @@ -128,8 +128,10 @@ def get_value(self, panel_id: str, value_id: str) -> object: """ get_value_request = GetValueRequest(panel_id=panel_id, value_id=value_id) response = self._invoke_with_retry(self._get_stub().GetValue, get_value_request) + if not response.found: + return False, None the_value = from_any(response.value) - return the_value + return True, the_value def _get_stub(self) -> PythonPanelServiceStub: if self._stub is None: diff --git a/src/nipanel/_panel_value_accessor.py b/src/nipanel/_panel_value_accessor.py index 8b65f47..5b7988f 100644 --- a/src/nipanel/_panel_value_accessor.py +++ b/src/nipanel/_panel_value_accessor.py @@ -61,25 +61,22 @@ 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)): - if isinstance(default_value, enum.Enum): - enum_type = type(default_value) - return enum_type(value) + found, value = self._panel_client.get_value(self._panel_id, value_id) + if not found: + if default_value is not None: + return default_value + raise KeyError(f"Value with id '{value_id}' not found on panel '{self._panel_id}'.") - raise TypeError( - f"Value type {type(value).__name__} does not match default value type {type(default_value).__name__}." - ) + if default_value is not None and not isinstance(value, type(default_value)): + if isinstance(default_value, enum.Enum): + enum_type = type(default_value) + return enum_type(value) - return value + raise TypeError( + f"Value type {type(value).__name__} does not match default value type {type(default_value).__name__}." + ) - except grpc.RpcError as e: - if e.code() == grpc.StatusCode.NOT_FOUND and default_value is not None: - return default_value - else: - raise e + return value def set_value(self, value_id: str, value: object) -> None: """Set the value for a control on the panel. diff --git a/src/nipanel/_streamlit_panel_initializer.py b/src/nipanel/_streamlit_panel_initializer.py index a13d20c..7d3800f 100644 --- a/src/nipanel/_streamlit_panel_initializer.py +++ b/src/nipanel/_streamlit_panel_initializer.py @@ -79,7 +79,7 @@ def _initialize_panel_from_base_path() -> StreamlitPanelValueAccessor: return StreamlitPanelValueAccessor(panel_id) -def _sync_session_state(panel): +def _sync_session_state(panel: StreamlitPanelValueAccessor) -> None: """Automatically read keyed control values from the session state.""" for key in st.session_state.keys(): value = st.session_state[key] diff --git a/tests/unit/test_panel_client.py b/tests/unit/test_panel_client.py index 2a9723d..151d912 100644 --- a/tests/unit/test_panel_client.py +++ b/tests/unit/test_panel_client.py @@ -1,5 +1,4 @@ import grpc -import pytest from nipanel._panel_client import PanelClient @@ -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( @@ -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: diff --git a/tests/unit/test_streamlit_panel.py b/tests/unit/test_streamlit_panel.py index a4aaaa1..aca8ff1 100644 --- a/tests/unit/test_streamlit_panel.py +++ b/tests/unit/test_streamlit_panel.py @@ -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) diff --git a/tests/utils/_fake_python_panel_servicer.py b/tests/utils/_fake_python_panel_servicer.py index 5d450a0..77941e8 100644 --- a/tests/utils/_fake_python_panel_servicer.py +++ b/tests/utils/_fake_python_panel_servicer.py @@ -62,9 +62,9 @@ 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(found=False) value = self._panel_value_ids[request.panel_id][request.value_id] - return GetValueResponse(value=value) + return GetValueResponse(found=True, value=value) def SetValue(self, request: SetValueRequest, context: Any) -> SetValueResponse: # noqa: N802 """Trivial implementation for testing.""" From 5c1e492282810c9505de8e66f35381c5a13500f2 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Tue, 1 Jul 2025 15:17:34 -0500 Subject: [PATCH 05/26] nipanel.enum_selectbox --- examples/all_types/all_types_panel.py | 19 +++++++- examples/all_types/define_types.py | 14 +++--- examples/simple_graph/simple_graph.py | 7 +-- examples/simple_graph/simple_graph_panel.py | 19 +++----- src/nipanel/__init__.py | 2 + src/nipanel/_streamlit_components.py | 49 +++++++++++++++++++++ 6 files changed, 83 insertions(+), 27 deletions(-) create mode 100644 src/nipanel/_streamlit_components.py diff --git a/examples/all_types/all_types_panel.py b/examples/all_types/all_types_panel.py index be5b0a0..e098183 100644 --- a/examples/all_types/all_types_panel.py +++ b/examples/all_types/all_types_panel.py @@ -1,5 +1,7 @@ """A Streamlit visualization panel for the all_types.py example script.""" +from enum import Enum + import streamlit as st from define_types import all_types_with_values @@ -11,10 +13,25 @@ panel = nipanel.get_panel_accessor() for name in all_types_with_values.keys(): - col1, col2 = st.columns([0.4, 0.6]) + st.markdown("---") + + default_value = all_types_with_values[name] + col1, col2, col3 = st.columns([0.2, 0.2, 0.6]) with col1: st.write(name) with col2: + if isinstance(all_types_with_values[name], Enum): + nipanel.enum_selectbox(panel, label=name, value=default_value, key=name) + elif isinstance(all_types_with_values[name], bool): + st.checkbox(label=name, value=default_value, key=name) + elif isinstance(all_types_with_values[name], int): + st.number_input(label=name, value=default_value, key=name) + elif isinstance(all_types_with_values[name], float): + st.number_input(label=name, value=default_value, key=name, format="%.2f") + elif isinstance(all_types_with_values[name], str): + st.text_input(label=name, value=default_value, key=name) + + with col3: st.write(panel.get_value(name)) diff --git a/examples/all_types/define_types.py b/examples/all_types/define_types.py index 5d5906e..b8b4c88 100644 --- a/examples/all_types/define_types.py +++ b/examples/all_types/define_types.py @@ -38,16 +38,19 @@ class MyStrEnum(str, enum.Enum): "float": 13.12, "int": 42, "str": "sample string", + # supported enum and flag types + "intflags": MyIntFlags.VALUE1 | MyIntFlags.VALUE4, + "intenum": MyIntEnum.VALUE20, + "strenum": MyStrEnum.VALUE3, + # NI types + "nitypes_Scalar": Scalar(42, "m"), + "nitypes_AnalogWaveform": AnalogWaveform.from_array_1d(np.array([1.0, 2.0, 3.0])), # supported collection types "bool_collection": [True, False, True], "bytes_collection": [b"one", b"two", b"three"], "float_collection": [1.1, 2.2, 3.3], "int_collection": [1, 2, 3], "str_collection": ["one", "two", "three"], - # supported enum and flag types - "intflags": MyIntFlags.VALUE1 | MyIntFlags.VALUE4, - "intenum": MyIntEnum.VALUE20, - "strenum": MyStrEnum.VALUE3, "intflags_collection": [MyIntFlags.VALUE1, MyIntFlags.VALUE2, MyIntFlags.VALUE4], "intenum_collection": [MyIntEnum.VALUE10, MyIntEnum.VALUE20, MyIntEnum.VALUE30], "strenum_collection": [MyStrEnum.VALUE1, MyStrEnum.VALUE2, MyStrEnum.VALUE3], @@ -56,9 +59,6 @@ class MyStrEnum(str, enum.Enum): "tuple": (4, 5, 6), "set": {7, 8, 9}, "frozenset": frozenset([10, 11, 12]), - # NI types - "nitypes_Scalar": Scalar(42, "m"), - "nitypes_AnalogWaveform": AnalogWaveform.from_array_1d(np.array([1.0, 2.0, 3.0])), # supported 2D collections "list_list_float": [[1.0, 2.0], [3.0, 4.0]], "tuple_tuple_float": ((1.0, 2.0), (3.0, 4.0)), diff --git a/examples/simple_graph/simple_graph.py b/examples/simple_graph/simple_graph.py index e6fc75b..acb9ab4 100644 --- a/examples/simple_graph/simple_graph.py +++ b/examples/simple_graph/simple_graph.py @@ -21,17 +21,14 @@ # Generate and update the sine wave data periodically while True: - # not-found values need to be performant - not_found = panel.get_value("no_such_value", "Hello, World!") - - amplitude_enum = panel.get_value("amplitude_enum", AmplitudeEnum.SMALL) + amplitude = panel.get_value("amplitude", AmplitudeEnum.SMALL) base_frequency = panel.get_value("base_frequency", 1.0) # Slowly vary the total frequency for a more dynamic visualization frequency = base_frequency + 0.5 * math.sin(time.time() / 5.0) time_points = np.linspace(0, num_points, num_points) - sine_values = amplitude_enum.value * np.sin(frequency * time_points) + sine_values = amplitude.value * np.sin(frequency * time_points) panel.set_value("frequency", frequency) panel.set_value("time_points", time_points.tolist()) diff --git a/examples/simple_graph/simple_graph_panel.py b/examples/simple_graph/simple_graph_panel.py index 1ece3c7..9f2dea9 100644 --- a/examples/simple_graph/simple_graph_panel.py +++ b/examples/simple_graph/simple_graph_panel.py @@ -12,25 +12,16 @@ col1, col2, col3, col4, col5, col6 = st.columns(6) panel = nipanel.get_panel_accessor() +frequency = panel.get_value("frequency", 0.0) +time_points = panel.get_value("time_points", [0.0]) +sine_values = panel.get_value("sine_values", [0.0]) with col1: - amplitude_tuple = st.selectbox( - "Amplitude", - options=[(e.name, e.value) for e in AmplitudeEnum], - format_func=lambda x: x[0], - index=0, - ) - amplitude_enum = AmplitudeEnum[amplitude_tuple[0]] - panel.set_value("amplitude_enum", amplitude_enum) + nipanel.enum_selectbox(panel, label="Amplitude", value=AmplitudeEnum.MEDIUM, key="amplitude") with col2: - base_frequency = st.number_input("Base Frequency", value=1.0, step=0.1, key="base_frequency") - + st.number_input("Base Frequency", value=1.0, step=0.5, key="base_frequency") with col3: - frequency = panel.get_value("frequency", 0.0) st.metric("Frequency", f"{frequency:.2f} Hz") - -time_points = panel.get_value("time_points", [0.0]) -sine_values = panel.get_value("sine_values", [0.0]) with col4: st.metric("Min Value", f"{min(sine_values):.3f}") with col5: diff --git a/src/nipanel/__init__.py b/src/nipanel/__init__.py index 05d5a73..61d11db 100644 --- a/src/nipanel/__init__.py +++ b/src/nipanel/__init__.py @@ -3,12 +3,14 @@ from importlib.metadata import version from nipanel._panel import Panel +from nipanel._streamlit_components import enum_selectbox from nipanel._streamlit_panel import StreamlitPanel from nipanel._streamlit_panel_initializer import create_panel, get_panel_accessor from nipanel._streamlit_panel_value_accessor import StreamlitPanelValueAccessor __all__ = [ "create_panel", + "enum_selectbox", "get_panel_accessor", "Panel", "StreamlitPanel", diff --git a/src/nipanel/_streamlit_components.py b/src/nipanel/_streamlit_components.py new file mode 100644 index 0000000..637cbc5 --- /dev/null +++ b/src/nipanel/_streamlit_components.py @@ -0,0 +1,49 @@ +"""Streamlit UI components for NI Panel.""" + +from enum import Enum +from typing import TypeVar + +import streamlit as st + +from nipanel._panel_value_accessor import PanelValueAccessor + +T = TypeVar("T", bound=Enum) + + +def enum_selectbox(panel: PanelValueAccessor, label: str, value: T, key: str) -> T: + """Create a selectbox for an Enum. + + The selectbox will display the names of all the enum values, and when a value is selected, + that value will be stored in the panel under the specified key. + + Args: + panel: The panel + label: Label to display for the selectbox + value: The default enum value to select (also determines the specific enum type) + key: Key to use for storing the enum value in the panel + + Returns: + The selected enum value of the same specific enum subclass as the input value + """ + enum_class = type(value) + if not issubclass(enum_class, Enum): + raise TypeError(f"Expected an Enum type, got {type(value)}") + + options = [(e.name, e.value) for e in enum_class] + + default_index = 0 + if value is not None: + for i, (name, _) in enumerate(options): + if name == value.name: + default_index = i + break + + box_tuple = st.selectbox( + label, + options=options, + format_func=lambda x: x[0], + index=default_index, + ) + enum_value = enum_class[box_tuple[0]] + panel.set_value(key, enum_value) + return enum_value From 5c64b0f2b7efc21de443ebcee2b0cf866c5df5c3 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Tue, 1 Jul 2025 15:28:59 -0500 Subject: [PATCH 06/26] fix mypy --- examples/all_types/all_types_panel.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/examples/all_types/all_types_panel.py b/examples/all_types/all_types_panel.py index e098183..caa522d 100644 --- a/examples/all_types/all_types_panel.py +++ b/examples/all_types/all_types_panel.py @@ -1,6 +1,7 @@ """A Streamlit visualization panel for the all_types.py example script.""" from enum import Enum +from typing import cast import streamlit as st from define_types import all_types_with_values @@ -23,15 +24,15 @@ with col2: if isinstance(all_types_with_values[name], Enum): - nipanel.enum_selectbox(panel, label=name, value=default_value, key=name) + nipanel.enum_selectbox(panel, label=name, value=cast(Enum, default_value), key=name) elif isinstance(all_types_with_values[name], bool): - st.checkbox(label=name, value=default_value, key=name) + st.checkbox(label=name, value=cast(bool, default_value), key=name) elif isinstance(all_types_with_values[name], int): - st.number_input(label=name, value=default_value, key=name) + st.number_input(label=name, value=cast(int, default_value), key=name) elif isinstance(all_types_with_values[name], float): - st.number_input(label=name, value=default_value, key=name, format="%.2f") + st.number_input(label=name, value=cast(float, default_value), key=name, format="%.2f") elif isinstance(all_types_with_values[name], str): - st.text_input(label=name, value=default_value, key=name) + st.text_input(label=name, value=cast(str, default_value), key=name) with col3: st.write(panel.get_value(name)) From 3c84a99b9fe8abaa30ffb68e459c338f06d563ae Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Wed, 2 Jul 2025 13:01:59 -0500 Subject: [PATCH 07/26] start improving daqmx example --- examples/all_types/all_types_panel.py | 10 +-- .../nidaqmx_continuous_analog_input.py | 64 +++++++++++++------ .../nidaqmx_continuous_analog_input_panel.py | 40 ++++++++++-- src/nipanel/_streamlit_components.py | 16 +++-- 4 files changed, 93 insertions(+), 37 deletions(-) diff --git a/examples/all_types/all_types_panel.py b/examples/all_types/all_types_panel.py index caa522d..7a7ba3a 100644 --- a/examples/all_types/all_types_panel.py +++ b/examples/all_types/all_types_panel.py @@ -23,15 +23,15 @@ st.write(name) with col2: - if isinstance(all_types_with_values[name], Enum): + if isinstance(default_value, Enum): nipanel.enum_selectbox(panel, label=name, value=cast(Enum, default_value), key=name) - elif isinstance(all_types_with_values[name], bool): + elif isinstance(default_value, bool): st.checkbox(label=name, value=cast(bool, default_value), key=name) - elif isinstance(all_types_with_values[name], int): + elif isinstance(default_value, int): st.number_input(label=name, value=cast(int, default_value), key=name) - elif isinstance(all_types_with_values[name], float): + elif isinstance(default_value, float): st.number_input(label=name, value=cast(float, default_value), key=name, format="%.2f") - elif isinstance(all_types_with_values[name], str): + elif isinstance(default_value, str): st.text_input(label=name, value=cast(str, default_value), key=name) with col3: diff --git a/examples/nidaqmx/nidaqmx_continuous_analog_input.py b/examples/nidaqmx/nidaqmx_continuous_analog_input.py index a00b768..bb6c470 100644 --- a/examples/nidaqmx/nidaqmx_continuous_analog_input.py +++ b/examples/nidaqmx/nidaqmx_continuous_analog_input.py @@ -1,34 +1,56 @@ """Data acquisition script that continuously acquires analog input data.""" +import time from pathlib import Path import nidaqmx -from nidaqmx.constants import AcquisitionType +from nidaqmx.constants import AcquisitionType, TerminalConfiguration import nipanel panel_script_path = Path(__file__).with_name("nidaqmx_continuous_analog_input_panel.py") panel = nipanel.create_panel(panel_script_path) -# How to use nidaqmx: https://nidaqmx-python.readthedocs.io/en/stable/ -with nidaqmx.Task() as task: - task.ai_channels.add_ai_voltage_chan("Dev1/ai0") - task.ai_channels.add_ai_thrmcpl_chan("Dev1/ai1") - task.timing.cfg_samp_clk_timing( - rate=1000.0, sample_mode=AcquisitionType.CONTINUOUS, samps_per_chan=3000 - ) - panel.set_value("sample_rate", task._timing.samp_clk_rate) - task.start() +try: print(f"Panel URL: {panel.panel_url}") - try: - print(f"Press Ctrl + C to stop") - while True: - data = task.read( - number_of_samples_per_channel=1000 # pyright: ignore[reportArgumentType] + print(f"Press Ctrl + C to quit") + while True: + print(f"Waiting for the 'Run' button to be pressed...") + while not panel.get_value("run_button", False): + time.sleep(0.1) + + panel.set_value("is_running", True) + + # How to use nidaqmx: https://nidaqmx-python.readthedocs.io/en/stable/ + with nidaqmx.Task() as task: + task.ai_channels.add_ai_voltage_chan( + physical_channel="Dev1/ai0", + min_val=panel.get_value("voltage_min_value", -5.0), + max_val=panel.get_value("voltage_max_value", 5.0), + terminal_config=panel.get_value( + "terminal_configuration", TerminalConfiguration.DEFAULT + ), ) - panel.set_value("voltage_data", data[0]) - panel.set_value("thermocouple_data", data[1]) - except KeyboardInterrupt: - pass - finally: - task.stop() + task.ai_channels.add_ai_thrmcpl_chan("Dev1/ai1") + task.timing.cfg_samp_clk_timing( + rate=1000.0, sample_mode=AcquisitionType.CONTINUOUS, samps_per_chan=3000 + ) + panel.set_value("sample_rate", task._timing.samp_clk_rate) + try: + print(f"Starting data acquisition...") + task.start() + while not panel.get_value("stop_button", False): + data = task.read( + number_of_samples_per_channel=1000 # pyright: ignore[reportArgumentType] + ) + panel.set_value("voltage_data", data[0]) + panel.set_value("thermocouple_data", data[1]) + except KeyboardInterrupt: + raise + finally: + print(f"Stopping data acquisition...") + task.stop() + panel.set_value("is_running", False) + +except KeyboardInterrupt: + pass diff --git a/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py b/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py index e9163b0..079f728 100644 --- a/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py +++ b/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py @@ -1,6 +1,7 @@ """Streamlit visualization script to display data acquired by nidaqmx_continuous_analog_input.py.""" import streamlit as st +from nidaqmx.constants import TerminalConfiguration from streamlit_echarts import st_echarts import nipanel @@ -8,6 +9,15 @@ st.set_page_config(page_title="NI-DAQmx Example", page_icon="📈", layout="wide") st.title("Analog Input - Voltage and Thermocouple in a Single Task") + +panel = nipanel.get_panel_accessor() + +is_running = panel.get_value("is_running", False) +if is_running: + st.button("Stop", key="stop_button") +else: + st.button("Run", key="run_button") + voltage_tab, thermocouple_tab = st.tabs(["Voltage", "Thermocouple"]) st.markdown( @@ -16,12 +26,14 @@ div[data-baseweb="select"] { width: 190px !important; /* Adjust the width as needed */ } + div.stNumberInput { + width: 190px !important; + } """, unsafe_allow_html=True, ) -panel = nipanel.get_panel_accessor() thermocouple_data = panel.get_value("thermocouple_data", [0.0]) voltage_data = panel.get_value("voltage_data", [0.0]) @@ -34,7 +46,7 @@ "legend": {"data": ["Voltage (V)", "Temperature (C)"]}, "xAxis": { "type": "category", - "data": [x / sample_rate for x in range(len(voltage_data))], + "data": [x / sample_rate if sample_rate > 0.001 else x for x in range(len(voltage_data))], "name": "Time", "nameLocation": "center", "nameGap": 40, @@ -75,11 +87,29 @@ st.selectbox(options=["Dev1/ai0"], label="Physical Channels", disabled=True) st.selectbox(options=["Off"], label="Logging Modes", disabled=False) with center_volt_tab: - st.selectbox(options=["-5"], label="Min Value") - st.selectbox(options=["5"], label="Max Value") + st.number_input( + "Min Value", + value=-5.0, + step=0.1, + disabled=panel.get_value("is_running", False), + key="voltage_min_value", + ) + st.number_input( + "Max Value", + value=5.0, + step=0.1, + disabled=panel.get_value("is_running", False), + key="voltage_max_value", + ) st.selectbox(options=["1000"], label="Samples per Loops", disabled=False) with right_volt_tab: - st.selectbox(options=["default"], label="Terminal Configurations") + nipanel.enum_selectbox( + panel, + label="Terminal Configuration", + value=TerminalConfiguration.DEFAULT, + disabled=panel.get_value("is_running", False), + key="terminal_configuration", + ) st.selectbox(options=["OnboardClock"], label="Sample Clock Sources", disabled=False) diff --git a/src/nipanel/_streamlit_components.py b/src/nipanel/_streamlit_components.py index 637cbc5..6c34020 100644 --- a/src/nipanel/_streamlit_components.py +++ b/src/nipanel/_streamlit_components.py @@ -1,7 +1,7 @@ """Streamlit UI components for NI Panel.""" from enum import Enum -from typing import TypeVar +from typing import Any, Callable, TypeVar import streamlit as st @@ -10,7 +10,14 @@ T = TypeVar("T", bound=Enum) -def enum_selectbox(panel: PanelValueAccessor, label: str, value: T, key: str) -> T: +def enum_selectbox( + panel: PanelValueAccessor, + label: str, + value: T, + key: str, + disabled: bool = False, + format_func: Callable[[Any], str] = lambda x: x[0], +) -> T: """Create a selectbox for an Enum. The selectbox will display the names of all the enum values, and when a value is selected, @@ -39,10 +46,7 @@ def enum_selectbox(panel: PanelValueAccessor, label: str, value: T, key: str) -> break box_tuple = st.selectbox( - label, - options=options, - format_func=lambda x: x[0], - index=default_index, + label, options=options, format_func=format_func, index=default_index, disabled=disabled ) enum_value = enum_class[box_tuple[0]] panel.set_value(key, enum_value) From 05386761a5841f25cefbddab2ac5a4e7171a74f8 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Wed, 2 Jul 2025 15:52:55 -0500 Subject: [PATCH 08/26] Enhance enum support in Streamlit panel and tests - Added MyIntableFlags and MyIntableEnum classes to define new enum types. - Updated all_types_with_values to include new enum types. - Modified PanelValueAccessor to allow list values without type matching. - Improved test coverage for enum types in StreamlitPanel. --- examples/all_types/all_types_panel.py | 16 ++-- examples/all_types/define_types.py | 38 ++++++++- src/nipanel/_panel_value_accessor.py | 11 ++- tests/types.py | 30 +++++-- tests/unit/test_streamlit_panel.py | 108 ++++++++++++++++++++++++-- 5 files changed, 181 insertions(+), 22 deletions(-) diff --git a/examples/all_types/all_types_panel.py b/examples/all_types/all_types_panel.py index 7a7ba3a..ec6c866 100644 --- a/examples/all_types/all_types_panel.py +++ b/examples/all_types/all_types_panel.py @@ -1,6 +1,6 @@ """A Streamlit visualization panel for the all_types.py example script.""" -from enum import Enum +from enum import Enum, Flag from typing import cast import streamlit as st @@ -23,11 +23,11 @@ st.write(name) with col2: - if isinstance(default_value, Enum): - nipanel.enum_selectbox(panel, label=name, value=cast(Enum, default_value), key=name) - elif isinstance(default_value, bool): + if isinstance(default_value, bool): st.checkbox(label=name, value=cast(bool, default_value), key=name) - elif isinstance(default_value, int): + elif isinstance(default_value, Enum) and not isinstance(default_value, Flag): + nipanel.enum_selectbox(panel, label=name, value=cast(Enum, default_value), key=name) + elif isinstance(default_value, int) and not isinstance(default_value, Flag): st.number_input(label=name, value=cast(int, default_value), key=name) elif isinstance(default_value, float): st.number_input(label=name, value=cast(float, default_value), key=name, format="%.2f") @@ -35,4 +35,8 @@ st.text_input(label=name, value=cast(str, default_value), key=name) with col3: - st.write(panel.get_value(name)) + value = panel.get_value(name) + value_with_default = panel.get_value(name, default_value=default_value) + st.write(value_with_default) + if str(value) != str(value_with_default): + st.write("(", value, ")") diff --git a/examples/all_types/define_types.py b/examples/all_types/define_types.py index b8b4c88..aff591e 100644 --- a/examples/all_types/define_types.py +++ b/examples/all_types/define_types.py @@ -15,6 +15,14 @@ class MyIntFlags(enum.IntFlag): VALUE4 = 4 +class MyIntableFlags(enum.Flag): + """Example of an Flag enum with int values.""" + + VALUE1 = 1 + VALUE2 = 2 + VALUE4 = 4 + + class MyIntEnum(enum.IntEnum): """Example of an IntEnum enum.""" @@ -23,6 +31,14 @@ class MyIntEnum(enum.IntEnum): VALUE30 = 30 +class MyIntableEnum(enum.Enum): + """Example of an enum with int values.""" + + VALUE100 = 100 + VALUE200 = 200 + VALUE300 = 300 + + class MyStrEnum(str, enum.Enum): """Example of a mixin string enum.""" @@ -31,6 +47,22 @@ class MyStrEnum(str, enum.Enum): VALUE3 = "value3" +class MyStringableEnum(enum.Enum): + """Example of an enum with string values.""" + + VALUE1 = "value1" + VALUE2 = "value2" + VALUE3 = "value3" + + +class MyMixedEnum(enum.Enum): + """Example of an enum with mixed values.""" + + VALUE1 = "value1" + VALUE2 = 2 + VALUE3 = 3.0 + + all_types_with_values = { # supported scalar types "bool": True, @@ -42,6 +74,10 @@ class MyStrEnum(str, enum.Enum): "intflags": MyIntFlags.VALUE1 | MyIntFlags.VALUE4, "intenum": MyIntEnum.VALUE20, "strenum": MyStrEnum.VALUE3, + "intableenum": MyIntableEnum.VALUE200, + "intableflags": MyIntableFlags.VALUE1 | MyIntableFlags.VALUE2, + "stringableenum": MyStringableEnum.VALUE2, + "mixedenum": MyMixedEnum.VALUE2, # NI types "nitypes_Scalar": Scalar(42, "m"), "nitypes_AnalogWaveform": AnalogWaveform.from_array_1d(np.array([1.0, 2.0, 3.0])), @@ -62,6 +98,6 @@ class MyStrEnum(str, enum.Enum): # supported 2D collections "list_list_float": [[1.0, 2.0], [3.0, 4.0]], "tuple_tuple_float": ((1.0, 2.0), (3.0, 4.0)), - "set_list_float": set([(1.0, 2.0), (3.0, 4.0)]), + "set_tuple_float": set([(1.0, 2.0), (3.0, 4.0)]), "frozenset_frozenset_float": frozenset([frozenset([1.0, 2.0]), frozenset([3.0, 4.0])]), } diff --git a/src/nipanel/_panel_value_accessor.py b/src/nipanel/_panel_value_accessor.py index 5b7988f..b4de9de 100644 --- a/src/nipanel/_panel_value_accessor.py +++ b/src/nipanel/_panel_value_accessor.py @@ -72,9 +72,11 @@ def get_value(self, value_id: str, default_value: _T | None = None) -> _T | obje enum_type = type(default_value) return enum_type(value) - raise TypeError( - f"Value type {type(value).__name__} does not match default value type {type(default_value).__name__}." - ) + # lists are allowed to not match, since sets and tuples are converted to lists + if not isinstance(value, list): + raise TypeError( + f"Value type {type(value).__name__} does not match default value type {type(default_value).__name__}." + ) return value @@ -85,6 +87,9 @@ def set_value(self, value_id: str, value: object) -> None: value_id: The id of the value value: The value """ + if isinstance(value, enum.Enum): + value = value.value + self._panel_client.set_value( self._panel_id, value_id, value, notify=self._notify_on_set_value ) diff --git a/tests/types.py b/tests/types.py index 0d17f9b..fa5cb0e 100644 --- a/tests/types.py +++ b/tests/types.py @@ -21,6 +21,14 @@ class MyIntFlags(enum.IntFlag): VALUE4 = 4 +class MyIntableFlags(enum.Flag): + """Example of a simple flag with int values.""" + + VALUE8 = 8 + VALUE16 = 16 + VALUE32 = 32 + + class MyIntEnum(enum.IntEnum): """Example of an IntEnum enum.""" @@ -53,17 +61,25 @@ class MixinStrEnum(str, enum.Enum): VALUE33 = "value33" -class MyEnum(enum.Enum): - """Example of a simple enum.""" +class MyIntableEnum(enum.Enum): + """Example of a simple enum with int values.""" VALUE100 = 100 VALUE200 = 200 VALUE300 = 300 -class MyFlags(enum.Flag): - """Example of a simple flag.""" +class MyStringableEnum(StrEnum): + """Example of a simple enum with str values.""" - VALUE8 = 8 - VALUE16 = 16 - VALUE32 = 32 + VALUE1 = "value10" + VALUE2 = "value20" + VALUE3 = "value30" + + +class MyMixedEnum(enum.Enum): + """Example of an enum with mixed values.""" + + VALUE1 = "value1" + VALUE2 = 2 + VALUE3 = 3.0 diff --git a/tests/unit/test_streamlit_panel.py b/tests/unit/test_streamlit_panel.py index aca8ff1..3302f1a 100644 --- a/tests/unit/test_streamlit_panel.py +++ b/tests/unit/test_streamlit_panel.py @@ -1,5 +1,7 @@ +import enum import grpc import pytest +from datetime import datetime from typing_extensions import assert_type import tests.types as test_types @@ -261,16 +263,38 @@ def test___set_string_enum_type___get_value_with_int_enum_default___raises_excep 3.14, True, b"robotext", + ], +) +def test___builtin_scalar_type___set_value___gets_same_value( + fake_panel_channel: grpc.Channel, + value_payload: object, +) -> None: + """Test that set_value() and get_value() work for builtin scalar types.""" + panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel) + + value_id = "test_id" + panel.set_value(value_id, value_payload) + + assert panel.get_value(value_id) == value_payload + + +@pytest.mark.parametrize( + "value_payload", + [ test_types.MyIntFlags.VALUE1 | test_types.MyIntFlags.VALUE4, + test_types.MyIntableFlags.VALUE16 | test_types.MyIntableFlags.VALUE32, test_types.MyIntEnum.VALUE20, + test_types.MyIntableEnum.VALUE200, test_types.MyStrEnum.VALUE3, + test_types.MyStringableEnum.VALUE2, test_types.MixinIntEnum.VALUE33, test_types.MixinStrEnum.VALUE11, + test_types.MyMixedEnum.VALUE2, ], ) -def test___builtin_scalar_type___set_value___gets_same_value( +def test___enum_type___set_value___gets_same_value( fake_panel_channel: grpc.Channel, - value_payload: object, + value_payload: enum.Enum, ) -> None: """Test that set_value() and get_value() work for builtin scalar types.""" panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel) @@ -278,14 +302,24 @@ def test___builtin_scalar_type___set_value___gets_same_value( value_id = "test_id" panel.set_value(value_id, value_payload) - assert panel.get_value(value_id) == value_payload + # without providing a default value, get_value will return the raw value, not the enum + assert panel.get_value(value_id) == value_payload.value @pytest.mark.parametrize( "value_payload", [ - test_types.MyEnum.VALUE300, - test_types.MyFlags.VALUE8 | test_types.MyFlags.VALUE16, + datetime.now(), + lambda x: x + 1, + [1, "string"], + ["string", []], + (42, "hello", 3.14, b"bytes"), + set([1, "mixed", True]), + (i for i in range(5)), + { + "key1": [1, 2, 3], + "key2": {"nested": True, "values": [4.5, 6.7]}, + }, ], ) def test___unsupported_type___set_value___raises( @@ -368,6 +402,22 @@ def test___set_int_enum_value___get_value___returns_int_enum( assert retrieved_value.name == enum_value.name +def test___set_intable_enum_value___get_value___returns_enum( + fake_panel_channel: grpc.Channel, +) -> None: + panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel) + value_id = "test_id" + enum_value = test_types.MyIntableEnum.VALUE200 + panel.set_value(value_id, enum_value) + + retrieved_value = panel.get_value(value_id, test_types.MyIntableEnum.VALUE100) + + assert_type(retrieved_value, test_types.MyIntableEnum) + assert retrieved_value is test_types.MyIntableEnum.VALUE200 + assert retrieved_value.value == enum_value.value + assert retrieved_value.name == enum_value.name + + def test___set_string_enum_value___get_value___returns_string_enum( fake_panel_channel: grpc.Channel, ) -> None: @@ -384,6 +434,38 @@ def test___set_string_enum_value___get_value___returns_string_enum( assert retrieved_value.name == enum_value.name +def test___set_stringable_enum_value___get_value___returns_enum( + fake_panel_channel: grpc.Channel, +) -> None: + panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel) + value_id = "test_id" + enum_value = test_types.MyStringableEnum.VALUE3 + panel.set_value(value_id, enum_value) + + retrieved_value = panel.get_value(value_id, test_types.MyStringableEnum.VALUE1) + + assert_type(retrieved_value, test_types.MyStringableEnum) + assert retrieved_value is test_types.MyStringableEnum.VALUE3 + assert retrieved_value.value == enum_value.value + assert retrieved_value.name == enum_value.name + + +def test___set_mixed_enum_value___get_value___returns_enum( + fake_panel_channel: grpc.Channel, +) -> None: + panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel) + value_id = "test_id" + enum_value = test_types.MyMixedEnum.VALUE2 + panel.set_value(value_id, enum_value) + + retrieved_value = panel.get_value(value_id, test_types.MyMixedEnum.VALUE1) + + assert_type(retrieved_value, test_types.MyMixedEnum) + assert retrieved_value is test_types.MyMixedEnum.VALUE2 + assert retrieved_value.value == enum_value.value + assert retrieved_value.name == enum_value.name + + def test___set_int_flags_value___get_value___returns_int_flags( fake_panel_channel: grpc.Channel, ) -> None: @@ -399,6 +481,22 @@ def test___set_int_flags_value___get_value___returns_int_flags( assert retrieved_value.value == flags_value.value +def test___set_intable_flags_value___get_value___returns_flags( + fake_panel_channel: grpc.Channel, +) -> None: + panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel) + value_id = "test_id" + flags_value = test_types.MyIntableFlags.VALUE16 | test_types.MyIntableFlags.VALUE32 + panel.set_value(value_id, flags_value) + + retrieved_value = panel.get_value(value_id, test_types.MyIntableFlags.VALUE8) + + assert_type(retrieved_value, test_types.MyIntableFlags) + assert retrieved_value is test_types.MyIntableFlags.VALUE16 | test_types.MyIntableFlags.VALUE32 + assert retrieved_value.value == flags_value.value + assert retrieved_value.name == flags_value.name + + def test___panel___panel_is_running_and_in_memory( fake_panel_channel: grpc.Channel, ) -> None: From 1fc1ce2499f52a648fd391105f8c9d4d24372c13 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Wed, 2 Jul 2025 16:20:30 -0500 Subject: [PATCH 09/26] Refactor NI-DAQmx example to enhance channel settings and timing configuration in Streamlit panel --- .../nidaqmx_continuous_analog_input.py | 6 +- .../nidaqmx_continuous_analog_input_panel.py | 223 +++++++++++------- 2 files changed, 139 insertions(+), 90 deletions(-) diff --git a/examples/nidaqmx/nidaqmx_continuous_analog_input.py b/examples/nidaqmx/nidaqmx_continuous_analog_input.py index bb6c470..076d021 100644 --- a/examples/nidaqmx/nidaqmx_continuous_analog_input.py +++ b/examples/nidaqmx/nidaqmx_continuous_analog_input.py @@ -31,7 +31,11 @@ "terminal_configuration", TerminalConfiguration.DEFAULT ), ) - task.ai_channels.add_ai_thrmcpl_chan("Dev1/ai1") + task.ai_channels.add_ai_thrmcpl_chan( + "Dev1/ai1", + min_val=panel.get_value("thermocouple_min_value", 0.0), + max_val=panel.get_value("thermocouple_max_value", 100.0), + ) task.timing.cfg_samp_clk_timing( rate=1000.0, sample_mode=AcquisitionType.CONTINUOUS, samps_per_chan=3000 ) diff --git a/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py b/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py index 079f728..f5a199b 100644 --- a/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py +++ b/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py @@ -18,8 +18,6 @@ else: st.button("Run", key="run_button") -voltage_tab, thermocouple_tab = st.tabs(["Voltage", "Thermocouple"]) - st.markdown( """ """, unsafe_allow_html=True, @@ -39,95 +40,139 @@ sample_rate = panel.get_value("sample_rate", 0.0) -st.header("Voltage & Thermocouple") -voltage_therm_graph = { - "animation": False, - "tooltip": {"trigger": "axis"}, - "legend": {"data": ["Voltage (V)", "Temperature (C)"]}, - "xAxis": { - "type": "category", - "data": [x / sample_rate if sample_rate > 0.001 else x for x in range(len(voltage_data))], - "name": "Time", - "nameLocation": "center", - "nameGap": 40, - }, - "yAxis": { - "type": "value", - "name": "Measurement", - "nameRotate": 90, - "nameLocation": "center", - "nameGap": 40, - }, - "series": [ - { - "name": "voltage_amplitude", - "type": "line", - "data": voltage_data, - "emphasis": {"focus": "series"}, - "smooth": True, - "seriesLayoutBy": "row", - }, - { - "name": "thermocouple_amplitude", - "type": "line", - "data": thermocouple_data, - "color": "red", - "emphasis": {"focus": "series"}, - "smooth": True, - "seriesLayoutBy": "row", - }, - ], -} -st_echarts(options=voltage_therm_graph, height="400px", key="voltage_therm_graph") - -voltage_tab.header("Voltage") -with voltage_tab: - left_volt_tab, center_volt_tab, right_volt_tab = st.columns(3) - with left_volt_tab: - st.selectbox(options=["Dev1/ai0"], label="Physical Channels", disabled=True) - st.selectbox(options=["Off"], label="Logging Modes", disabled=False) - with center_volt_tab: - st.number_input( - "Min Value", - value=-5.0, - step=0.1, - disabled=panel.get_value("is_running", False), - key="voltage_min_value", - ) - st.number_input( - "Max Value", - value=5.0, - step=0.1, - disabled=panel.get_value("is_running", False), - key="voltage_max_value", - ) - st.selectbox(options=["1000"], label="Samples per Loops", disabled=False) - with right_volt_tab: - nipanel.enum_selectbox( - panel, - label="Terminal Configuration", - value=TerminalConfiguration.DEFAULT, - disabled=panel.get_value("is_running", False), - key="terminal_configuration", - ) - st.selectbox(options=["OnboardClock"], label="Sample Clock Sources", disabled=False) +# Create two-column layout for the entire interface +left_col, right_col = st.columns([1, 1]) +# Left column - Channel tabs and Timing Settings +with left_col: + # Channel Settings tabs + st.header("Channel Settings") + voltage_tab, thermocouple_tab = st.tabs(["Voltage", "Thermocouple"]) -thermocouple_tab.header("Thermocouple") -with thermocouple_tab: - left, middle, right = st.columns(3) - with left: - st.selectbox(options=["Dev1/ai1"], label="Physical Channel", disabled=True) - st.selectbox(options=["0"], label="Min", disabled=False) - st.selectbox(options=["100"], label="Max", disabled=False) - st.selectbox(options=["Off"], label="Logging Mode", disabled=False) + voltage_tab.header("Voltage") + with voltage_tab: + channel_left, channel_right = st.columns(2) + with channel_left: + st.selectbox(options=["Dev1/ai0"], label="Physical Channels", disabled=True) + st.number_input( + "Min Value", + value=-5.0, + step=0.1, + disabled=panel.get_value("is_running", False), + key="voltage_min_value", + ) + st.number_input( + "Max Value", + value=5.0, + step=0.1, + disabled=panel.get_value("is_running", False), + key="voltage_max_value", + ) + with channel_right: + nipanel.enum_selectbox( + panel, + label="Terminal Configuration", + value=TerminalConfiguration.DEFAULT, + disabled=panel.get_value("is_running", False), + key="terminal_configuration", + ) - with middle: - st.selectbox(options=["Deg C"], label="Units", disabled=False) - st.selectbox(options=["J"], label="Thermocouple Type", disabled=False) - st.selectbox(options=["Constant Value"], label="CJC Source", disabled=False) - st.selectbox(options=["1000"], label="Samples per Loop", disabled=False) - with right: - st.selectbox(options=["25"], label="CJC Value", disabled=False) + thermocouple_tab.header("Thermocouple") + with thermocouple_tab: + channel_left, channel_middle, channel_right = st.columns(3) + with channel_left: + st.selectbox(options=["Dev1/ai1"], label="Physical Channel", disabled=True) + st.number_input( + "Min Value", + value=0.0, + step=1.0, + disabled=panel.get_value("is_running", False), + key="thermocouple_min_value", + ) + st.number_input( + "Max Value", + value=100.0, + step=1.0, + disabled=panel.get_value("is_running", False), + key="thermocouple_max_value", + ) + with channel_middle: + st.selectbox(options=["Deg C"], label="Units", disabled=False) + st.selectbox(options=["J"], label="Thermocouple Type", disabled=False) + with channel_right: + st.selectbox(options=["Constant Value"], label="CJC Source", disabled=False) + st.selectbox(options=["25"], label="CJC Value", disabled=False) + + # Timing Settings section in left column + st.header("Timing Settings") + timing_left, timing_right = st.columns(2) + with timing_left: st.selectbox(options=["OnboardClock"], label="Sample Clock Source", disabled=False) + st.selectbox(options=["1000"], label="Samples per Loop", disabled=False) + with timing_right: st.selectbox(options=[" "], label="Actual Sample Rate", disabled=True) + st.text_input( + label="Sample Rate", + disabled=True, + value=str(sample_rate) if sample_rate else "", + key="sample_rate_display", + ) + +# Right column - Graph and Logging Settings +with right_col: + # Graph section + st.header("Voltage & Thermocouple") + voltage_therm_graph = { + "animation": False, + "tooltip": {"trigger": "axis"}, + "legend": {"data": ["Voltage (V)", "Temperature (C)"]}, + "xAxis": { + "type": "category", + "data": [ + x / sample_rate if sample_rate > 0.001 else x for x in range(len(voltage_data)) + ], + "name": "Time", + "nameLocation": "center", + "nameGap": 40, + }, + "yAxis": { + "type": "value", + "name": "Measurement", + "nameRotate": 90, + "nameLocation": "center", + "nameGap": 40, + }, + "series": [ + { + "name": "voltage_amplitude", + "type": "line", + "data": voltage_data, + "emphasis": {"focus": "series"}, + "smooth": True, + "seriesLayoutBy": "row", + }, + { + "name": "thermocouple_amplitude", + "type": "line", + "data": thermocouple_data, + "color": "red", + "emphasis": {"focus": "series"}, + "smooth": True, + "seriesLayoutBy": "row", + }, + ], + } + st_echarts(options=voltage_therm_graph, height="400px", key="voltage_therm_graph") + + # Logging Settings section in right column + st.header("Logging Settings") + logging_left, logging_right = st.columns(2) + with logging_left: + st.selectbox(options=["Off"], label="Logging Mode", disabled=False) + with logging_right: + st.text_input( + label="TDMS File Path", + disabled=panel.get_value("is_running", False), + value="", + key="tdms_file_path", + ) From bd844266be50cc7ab7f0b245e7f5fb5aa6d52759 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Wed, 2 Jul 2025 16:59:15 -0500 Subject: [PATCH 10/26] finish updating daqmx example, so all the controls (except I/O) are fully functional --- .../nidaqmx_continuous_analog_input.py | 25 +++++- .../nidaqmx_continuous_analog_input_panel.py | 89 +++++++++++++++---- 2 files changed, 95 insertions(+), 19 deletions(-) diff --git a/examples/nidaqmx/nidaqmx_continuous_analog_input.py b/examples/nidaqmx/nidaqmx_continuous_analog_input.py index 076d021..ba090f9 100644 --- a/examples/nidaqmx/nidaqmx_continuous_analog_input.py +++ b/examples/nidaqmx/nidaqmx_continuous_analog_input.py @@ -4,7 +4,15 @@ from pathlib import Path import nidaqmx -from nidaqmx.constants import AcquisitionType, TerminalConfiguration +from nidaqmx.constants import ( + AcquisitionType, + TerminalConfiguration, + CJCSource, + TemperatureUnits, + ThermocoupleType, + LoggingMode, + LoggingOperation, +) import nipanel @@ -35,9 +43,22 @@ "Dev1/ai1", min_val=panel.get_value("thermocouple_min_value", 0.0), max_val=panel.get_value("thermocouple_max_value", 100.0), + units=panel.get_value("thermocouple_units", TemperatureUnits.DEG_C), + thermocouple_type=panel.get_value("thermocouple_type", ThermocoupleType.K), + cjc_source=panel.get_value( + "thermocouple_cjc_source", CJCSource.CONSTANT_USER_VALUE + ), + cjc_val=panel.get_value("thermocouple_cjc_val", 25.0), ) task.timing.cfg_samp_clk_timing( - rate=1000.0, sample_mode=AcquisitionType.CONTINUOUS, samps_per_chan=3000 + rate=panel.get_value("sample_rate_input", 1000.0), + sample_mode=AcquisitionType.CONTINUOUS, + samps_per_chan=panel.get_value("samples_per_channel", 3000), + ) + task.in_stream.configure_logging( + file_path=panel.get_value("tdms_file_path", "data.tdms"), + logging_mode=panel.get_value("logging_mode", LoggingMode.OFF), + operation=LoggingOperation.OPEN_OR_CREATE, ) panel.set_value("sample_rate", task._timing.samp_clk_rate) try: diff --git a/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py b/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py index f5a199b..5f67a91 100644 --- a/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py +++ b/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py @@ -1,7 +1,13 @@ """Streamlit visualization script to display data acquired by nidaqmx_continuous_analog_input.py.""" import streamlit as st -from nidaqmx.constants import TerminalConfiguration +from nidaqmx.constants import ( + TerminalConfiguration, + CJCSource, + TemperatureUnits, + ThermocoupleType, + LoggingMode, +) from streamlit_echarts import st_echarts import nipanel @@ -97,25 +103,66 @@ key="thermocouple_max_value", ) with channel_middle: - st.selectbox(options=["Deg C"], label="Units", disabled=False) - st.selectbox(options=["J"], label="Thermocouple Type", disabled=False) + nipanel.enum_selectbox( + panel, + label="Units", + value=TemperatureUnits.DEG_C, + disabled=panel.get_value("is_running", False), + key="thermocouple_units", + ) + nipanel.enum_selectbox( + panel, + label="Thermocouple Type", + value=ThermocoupleType.K, + disabled=panel.get_value("is_running", False), + key="thermocouple_type", + ) with channel_right: - st.selectbox(options=["Constant Value"], label="CJC Source", disabled=False) - st.selectbox(options=["25"], label="CJC Value", disabled=False) + nipanel.enum_selectbox( + panel, + label="CJC Source", + value=CJCSource.CONSTANT_USER_VALUE, + disabled=panel.get_value("is_running", False), + key="thermocouple_cjc_source", + ) + st.number_input( + "CJC Value", + value=25.0, + step=1.0, + disabled=panel.get_value("is_running", False), + key="thermocouple_cjc_val", + ) # Timing Settings section in left column st.header("Timing Settings") timing_left, timing_right = st.columns(2) with timing_left: - st.selectbox(options=["OnboardClock"], label="Sample Clock Source", disabled=False) - st.selectbox(options=["1000"], label="Samples per Loop", disabled=False) + st.selectbox( + options=["OnboardClock"], + label="Sample Clock Source", + disabled=True, + ) + st.number_input( + "Sample Rate", + value=1000.0, + step=100.0, + min_value=1.0, + disabled=panel.get_value("is_running", False), + key="sample_rate_input", + ) with timing_right: - st.selectbox(options=[" "], label="Actual Sample Rate", disabled=True) + st.number_input( + "Samples per Loop", + value=3000, + step=100, + min_value=10, + disabled=panel.get_value("is_running", False), + key="samples_per_channel", + ) st.text_input( - label="Sample Rate", - disabled=True, + label="Actual Sample Rate", value=str(sample_rate) if sample_rate else "", - key="sample_rate_display", + key="actual_sample_rate_display", ) # Right column - Graph and Logging Settings @@ -168,11 +215,19 @@ st.header("Logging Settings") logging_left, logging_right = st.columns(2) with logging_left: - st.selectbox(options=["Off"], label="Logging Mode", disabled=False) - with logging_right: - st.text_input( - label="TDMS File Path", + nipanel.enum_selectbox( + panel, + label="Logging Mode", + value=LoggingMode.OFF, disabled=panel.get_value("is_running", False), - value="", - key="tdms_file_path", + key="logging_mode", ) + with logging_right: + col1, col2 = st.columns([3, 1]) + with col1: + tdms_file_path = st.text_input( + label="TDMS File Path", + disabled=panel.get_value("is_running", False), + value="data.tdms", + key="tdms_file_path", + ) From d4dd4c90ee5d40c30b6d455504f15318d2eed1ac Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Wed, 2 Jul 2025 17:04:15 -0500 Subject: [PATCH 11/26] clean up lint and mypy --- examples/all_types/all_types_panel.py | 11 +++++------ tests/unit/test_streamlit_panel.py | 3 ++- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/all_types/all_types_panel.py b/examples/all_types/all_types_panel.py index ec6c866..a7e1897 100644 --- a/examples/all_types/all_types_panel.py +++ b/examples/all_types/all_types_panel.py @@ -1,7 +1,6 @@ """A Streamlit visualization panel for the all_types.py example script.""" from enum import Enum, Flag -from typing import cast import streamlit as st from define_types import all_types_with_values @@ -24,15 +23,15 @@ with col2: if isinstance(default_value, bool): - st.checkbox(label=name, value=cast(bool, default_value), key=name) + st.checkbox(label=name, value=default_value, key=name) elif isinstance(default_value, Enum) and not isinstance(default_value, Flag): - nipanel.enum_selectbox(panel, label=name, value=cast(Enum, default_value), key=name) + nipanel.enum_selectbox(panel, label=name, value=default_value, key=name) elif isinstance(default_value, int) and not isinstance(default_value, Flag): - st.number_input(label=name, value=cast(int, default_value), key=name) + st.number_input(label=name, value=default_value, key=name) elif isinstance(default_value, float): - st.number_input(label=name, value=cast(float, default_value), key=name, format="%.2f") + st.number_input(label=name, value=default_value, key=name, format="%.2f") elif isinstance(default_value, str): - st.text_input(label=name, value=cast(str, default_value), key=name) + st.text_input(label=name, value=default_value, key=name) with col3: value = panel.get_value(name) diff --git a/tests/unit/test_streamlit_panel.py b/tests/unit/test_streamlit_panel.py index 3302f1a..095cc0a 100644 --- a/tests/unit/test_streamlit_panel.py +++ b/tests/unit/test_streamlit_panel.py @@ -1,7 +1,8 @@ import enum +from datetime import datetime + import grpc import pytest -from datetime import datetime from typing_extensions import assert_type import tests.types as test_types From 0cbbee47a4724c7947f3edcd1674d0576ed4fd88 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Mon, 7 Jul 2025 09:28:32 -0500 Subject: [PATCH 12/26] update comment in proto file --- protos/ni/pythonpanel/v1/python_panel_service.proto | 1 - 1 file changed, 1 deletion(-) diff --git a/protos/ni/pythonpanel/v1/python_panel_service.proto b/protos/ni/pythonpanel/v1/python_panel_service.proto index eeb520c..b59ae79 100644 --- a/protos/ni/pythonpanel/v1/python_panel_service.proto +++ b/protos/ni/pythonpanel/v1/python_panel_service.proto @@ -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 From 2a0c4e4ae93e6d940214d5d6adb50632af3503c0 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Mon, 7 Jul 2025 09:37:22 -0500 Subject: [PATCH 13/26] revert simple_graph changes --- examples/simple_graph/amplitude_enum.py | 11 ----------- examples/simple_graph/simple_graph.py | 16 +++++++--------- examples/simple_graph/simple_graph_panel.py | 16 +++++++--------- 3 files changed, 14 insertions(+), 29 deletions(-) delete mode 100644 examples/simple_graph/amplitude_enum.py diff --git a/examples/simple_graph/amplitude_enum.py b/examples/simple_graph/amplitude_enum.py deleted file mode 100644 index b1f12ca..0000000 --- a/examples/simple_graph/amplitude_enum.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Enumeration for amplitude values used in the simple graph example.""" - -import enum - - -class AmplitudeEnum(enum.IntEnum): - """Enumeration for amplitude values.""" - - SMALL = 1 - MEDIUM = 5 - BIG = 10 diff --git a/examples/simple_graph/simple_graph.py b/examples/simple_graph/simple_graph.py index acb9ab4..4b63530 100644 --- a/examples/simple_graph/simple_graph.py +++ b/examples/simple_graph/simple_graph.py @@ -5,7 +5,6 @@ from pathlib import Path import numpy as np -from amplitude_enum import AmplitudeEnum import nipanel @@ -13,6 +12,8 @@ panel_script_path = Path(__file__).with_name("simple_graph_panel.py") panel = nipanel.create_panel(panel_script_path) +amplitude = 1.0 +frequency = 1.0 num_points = 100 try: @@ -21,19 +22,16 @@ # Generate and update the sine wave data periodically while True: - amplitude = panel.get_value("amplitude", AmplitudeEnum.SMALL) - base_frequency = panel.get_value("base_frequency", 1.0) - - # Slowly vary the total frequency for a more dynamic visualization - frequency = base_frequency + 0.5 * math.sin(time.time() / 5.0) - time_points = np.linspace(0, num_points, num_points) - sine_values = amplitude.value * np.sin(frequency * time_points) + sine_values = amplitude * np.sin(frequency * time_points) - panel.set_value("frequency", frequency) 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) time.sleep(0.1) except KeyboardInterrupt: diff --git a/examples/simple_graph/simple_graph_panel.py b/examples/simple_graph/simple_graph_panel.py index 9f2dea9..6550627 100644 --- a/examples/simple_graph/simple_graph_panel.py +++ b/examples/simple_graph/simple_graph_panel.py @@ -1,7 +1,6 @@ """A Streamlit visualization panel for the simple_graph.py example script.""" import streamlit as st -from amplitude_enum import AmplitudeEnum from streamlit_echarts import st_echarts import nipanel @@ -9,24 +8,23 @@ st.set_page_config(page_title="Simple Graph Example", page_icon="📈", layout="wide") st.title("Simple Graph Example") -col1, col2, col3, col4, col5, col6 = st.columns(6) panel = nipanel.get_panel_accessor() -frequency = panel.get_value("frequency", 0.0) time_points = panel.get_value("time_points", [0.0]) sine_values = panel.get_value("sine_values", [0.0]) +amplitude = panel.get_value("amplitude", 1.0) +frequency = panel.get_value("frequency", 1.0) +col1, col2, col3, col4, col5 = st.columns(5) with col1: - nipanel.enum_selectbox(panel, label="Amplitude", value=AmplitudeEnum.MEDIUM, key="amplitude") + st.metric("Amplitude", f"{amplitude:.2f}") with col2: - st.number_input("Base Frequency", value=1.0, step=0.5, key="base_frequency") -with col3: st.metric("Frequency", f"{frequency:.2f} Hz") -with col4: +with col3: st.metric("Min Value", f"{min(sine_values):.3f}") -with col5: +with col4: st.metric("Max Value", f"{max(sine_values):.3f}") -with col6: +with col5: st.metric("Data Points", len(sine_values)) # Prepare data for echarts From 285401f6811294dd21e24d4c98983798359ac744 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Mon, 7 Jul 2025 09:44:57 -0500 Subject: [PATCH 14/26] put enum_selectbox in a controls folder --- src/nipanel/__init__.py | 2 +- .../{_streamlit_components.py => controls/_enum_selectbox.py} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/nipanel/{_streamlit_components.py => controls/_enum_selectbox.py} (96%) diff --git a/src/nipanel/__init__.py b/src/nipanel/__init__.py index 61d11db..28b6271 100644 --- a/src/nipanel/__init__.py +++ b/src/nipanel/__init__.py @@ -3,7 +3,7 @@ from importlib.metadata import version from nipanel._panel import Panel -from nipanel._streamlit_components import enum_selectbox +from nipanel.controls._enum_selectbox import enum_selectbox from nipanel._streamlit_panel import StreamlitPanel from nipanel._streamlit_panel_initializer import create_panel, get_panel_accessor from nipanel._streamlit_panel_value_accessor import StreamlitPanelValueAccessor diff --git a/src/nipanel/_streamlit_components.py b/src/nipanel/controls/_enum_selectbox.py similarity index 96% rename from src/nipanel/_streamlit_components.py rename to src/nipanel/controls/_enum_selectbox.py index 6c34020..16ab64d 100644 --- a/src/nipanel/_streamlit_components.py +++ b/src/nipanel/controls/_enum_selectbox.py @@ -1,4 +1,4 @@ -"""Streamlit UI components for NI Panel.""" +"""A selectbox that allows selecting an Enum value.""" from enum import Enum from typing import Any, Callable, TypeVar From 2731464230aff10bdd4fc299b0a908a2c3c0a032 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Mon, 7 Jul 2025 10:28:28 -0500 Subject: [PATCH 15/26] cosmetic improvements for the nidaqmx example panel --- .../nidaqmx_continuous_analog_input_panel.py | 355 +++++++++--------- 1 file changed, 179 insertions(+), 176 deletions(-) diff --git a/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py b/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py index 5f67a91..da1646b 100644 --- a/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py +++ b/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py @@ -13,37 +13,36 @@ import nipanel -st.set_page_config(page_title="NI-DAQmx Example", page_icon="📈", layout="wide") -st.title("Analog Input - Voltage and Thermocouple in a Single Task") - -panel = nipanel.get_panel_accessor() - -is_running = panel.get_value("is_running", False) -if is_running: - st.button("Stop", key="stop_button") -else: - st.button("Run", key="run_button") - st.markdown( """ """, unsafe_allow_html=True, ) +st.set_page_config(page_title="NI-DAQmx Example", page_icon="📈", layout="wide") +st.title("Analog Input - Voltage and Thermocouple in a Single Task") + +panel = nipanel.get_panel_accessor() +is_running = panel.get_value("is_running", False) + +if is_running: + st.button(r"âšī¸ $\large{\textbf{Stop}}$", key="stop_button") +else: + st.button(r"â–ļī¸ $\large{\textbf{Run}}$", key="run_button") + thermocouple_data = panel.get_value("thermocouple_data", [0.0]) voltage_data = panel.get_value("voltage_data", [0.0]) - sample_rate = panel.get_value("sample_rate", 0.0) # Create two-column layout for the entire interface @@ -52,182 +51,186 @@ # Left column - Channel tabs and Timing Settings with left_col: # Channel Settings tabs - st.header("Channel Settings") - voltage_tab, thermocouple_tab = st.tabs(["Voltage", "Thermocouple"]) + with st.container(border=True): + st.header("Channel Settings") + voltage_tab, thermocouple_tab = st.tabs(["Voltage", "Thermocouple"]) + + voltage_tab.header("Voltage") + with voltage_tab: + channel_left, channel_right = st.columns(2) + with channel_left: + st.selectbox(options=["Dev1/ai0"], label="Physical Channels", disabled=True) + st.number_input( + "Min Value", + value=-5.0, + step=0.1, + disabled=panel.get_value("is_running", False), + key="voltage_min_value", + ) + st.number_input( + "Max Value", + value=5.0, + step=0.1, + disabled=panel.get_value("is_running", False), + key="voltage_max_value", + ) + with channel_right: + nipanel.enum_selectbox( + panel, + label="Terminal Configuration", + value=TerminalConfiguration.DEFAULT, + disabled=panel.get_value("is_running", False), + key="terminal_configuration", + ) + + thermocouple_tab.header("Thermocouple") + with thermocouple_tab: + channel_left, channel_middle, channel_right = st.columns(3) + with channel_left: + st.selectbox(options=["Dev1/ai1"], label="Physical Channel", disabled=True) + st.number_input( + "Min Value", + value=0.0, + step=1.0, + disabled=panel.get_value("is_running", False), + key="thermocouple_min_value", + ) + st.number_input( + "Max Value", + value=100.0, + step=1.0, + disabled=panel.get_value("is_running", False), + key="thermocouple_max_value", + ) + with channel_middle: + nipanel.enum_selectbox( + panel, + label="Units", + value=TemperatureUnits.DEG_C, + disabled=panel.get_value("is_running", False), + key="thermocouple_units", + ) + nipanel.enum_selectbox( + panel, + label="Thermocouple Type", + value=ThermocoupleType.K, + disabled=panel.get_value("is_running", False), + key="thermocouple_type", + ) + with channel_right: + nipanel.enum_selectbox( + panel, + label="CJC Source", + value=CJCSource.CONSTANT_USER_VALUE, + disabled=panel.get_value("is_running", False), + key="thermocouple_cjc_source", + ) + st.number_input( + "CJC Value", + value=25.0, + step=1.0, + disabled=panel.get_value("is_running", False), + key="thermocouple_cjc_val", + ) - voltage_tab.header("Voltage") - with voltage_tab: - channel_left, channel_right = st.columns(2) - with channel_left: - st.selectbox(options=["Dev1/ai0"], label="Physical Channels", disabled=True) - st.number_input( - "Min Value", - value=-5.0, - step=0.1, - disabled=panel.get_value("is_running", False), - key="voltage_min_value", - ) - st.number_input( - "Max Value", - value=5.0, - step=0.1, - disabled=panel.get_value("is_running", False), - key="voltage_max_value", - ) - with channel_right: - nipanel.enum_selectbox( - panel, - label="Terminal Configuration", - value=TerminalConfiguration.DEFAULT, - disabled=panel.get_value("is_running", False), - key="terminal_configuration", + # Timing Settings section in left column + with st.container(border=True): + st.header("Timing Settings") + timing_left, timing_right = st.columns(2) + with timing_left: + st.selectbox( + options=["OnboardClock"], + label="Sample Clock Source", + disabled=True, ) - - thermocouple_tab.header("Thermocouple") - with thermocouple_tab: - channel_left, channel_middle, channel_right = st.columns(3) - with channel_left: - st.selectbox(options=["Dev1/ai1"], label="Physical Channel", disabled=True) st.number_input( - "Min Value", - value=0.0, - step=1.0, + "Sample Rate", + value=1000.0, + step=100.0, + min_value=1.0, disabled=panel.get_value("is_running", False), - key="thermocouple_min_value", + key="sample_rate_input", ) + with timing_right: st.number_input( - "Max Value", - value=100.0, - step=1.0, + "Samples per Loop", + value=3000, + step=100, + min_value=10, disabled=panel.get_value("is_running", False), - key="thermocouple_max_value", + key="samples_per_channel", ) - with channel_middle: - nipanel.enum_selectbox( - panel, - label="Units", - value=TemperatureUnits.DEG_C, - disabled=panel.get_value("is_running", False), - key="thermocouple_units", + st.text_input( + label="Actual Sample Rate", + value=str(sample_rate) if sample_rate else "", + key="actual_sample_rate_display", ) - nipanel.enum_selectbox( - panel, - label="Thermocouple Type", - value=ThermocoupleType.K, - disabled=panel.get_value("is_running", False), - key="thermocouple_type", - ) - with channel_right: - nipanel.enum_selectbox( - panel, - label="CJC Source", - value=CJCSource.CONSTANT_USER_VALUE, - disabled=panel.get_value("is_running", False), - key="thermocouple_cjc_source", - ) - st.number_input( - "CJC Value", - value=25.0, - step=1.0, - disabled=panel.get_value("is_running", False), - key="thermocouple_cjc_val", - ) - - # Timing Settings section in left column - st.header("Timing Settings") - timing_left, timing_right = st.columns(2) - with timing_left: - st.selectbox( - options=["OnboardClock"], - label="Sample Clock Source", - disabled=True, - ) - st.number_input( - "Sample Rate", - value=1000.0, - step=100.0, - min_value=1.0, - disabled=panel.get_value("is_running", False), - key="sample_rate_input", - ) - with timing_right: - st.number_input( - "Samples per Loop", - value=3000, - step=100, - min_value=10, - disabled=panel.get_value("is_running", False), - key="samples_per_channel", - ) - st.text_input( - label="Actual Sample Rate", - value=str(sample_rate) if sample_rate else "", - key="actual_sample_rate_display", - ) # Right column - Graph and Logging Settings with right_col: - # Graph section - st.header("Voltage & Thermocouple") - voltage_therm_graph = { - "animation": False, - "tooltip": {"trigger": "axis"}, - "legend": {"data": ["Voltage (V)", "Temperature (C)"]}, - "xAxis": { - "type": "category", - "data": [ - x / sample_rate if sample_rate > 0.001 else x for x in range(len(voltage_data)) - ], - "name": "Time", - "nameLocation": "center", - "nameGap": 40, - }, - "yAxis": { - "type": "value", - "name": "Measurement", - "nameRotate": 90, - "nameLocation": "center", - "nameGap": 40, - }, - "series": [ - { - "name": "voltage_amplitude", - "type": "line", - "data": voltage_data, - "emphasis": {"focus": "series"}, - "smooth": True, - "seriesLayoutBy": "row", + with st.container(border=True): + # Graph section + st.header("Voltage & Thermocouple") + voltage_therm_graph = { + "animation": False, + "tooltip": {"trigger": "axis"}, + "legend": {"data": ["Voltage (V)", "Temperature (C)"]}, + "xAxis": { + "type": "category", + "data": [ + x / sample_rate if sample_rate > 0.001 else x for x in range(len(voltage_data)) + ], + "name": "Time", + "nameLocation": "center", + "nameGap": 40, }, - { - "name": "thermocouple_amplitude", - "type": "line", - "data": thermocouple_data, - "color": "red", - "emphasis": {"focus": "series"}, - "smooth": True, - "seriesLayoutBy": "row", + "yAxis": { + "type": "value", + "name": "Measurement", + "nameRotate": 90, + "nameLocation": "center", + "nameGap": 40, }, - ], - } - st_echarts(options=voltage_therm_graph, height="400px", key="voltage_therm_graph") + "series": [ + { + "name": "voltage_amplitude", + "type": "line", + "data": voltage_data, + "emphasis": {"focus": "series"}, + "smooth": True, + "seriesLayoutBy": "row", + }, + { + "name": "thermocouple_amplitude", + "type": "line", + "data": thermocouple_data, + "color": "red", + "emphasis": {"focus": "series"}, + "smooth": True, + "seriesLayoutBy": "row", + }, + ], + } + st_echarts(options=voltage_therm_graph, height="446px", key="voltage_therm_graph") # Logging Settings section in right column - st.header("Logging Settings") - logging_left, logging_right = st.columns(2) - with logging_left: - nipanel.enum_selectbox( - panel, - label="Logging Mode", - value=LoggingMode.OFF, - disabled=panel.get_value("is_running", False), - key="logging_mode", - ) - with logging_right: - col1, col2 = st.columns([3, 1]) - with col1: - tdms_file_path = st.text_input( - label="TDMS File Path", + with st.container(border=True): + st.header("Logging Settings") + logging_left, logging_right = st.columns(2) + with logging_left: + nipanel.enum_selectbox( + panel, + label="Logging Mode", + value=LoggingMode.OFF, disabled=panel.get_value("is_running", False), - value="data.tdms", - key="tdms_file_path", + key="logging_mode", ) + with logging_right: + col1, col2 = st.columns([3, 1]) + with col1: + tdms_file_path = st.text_input( + label="TDMS File Path", + disabled=panel.get_value("is_running", False), + value="data.tdms", + key="tdms_file_path", + ) From 80f7d6024402b1ef64f0a57926d31ebeda7bd115 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Mon, 7 Jul 2025 10:38:28 -0500 Subject: [PATCH 16/26] flag checkboxes control --- examples/all_types/all_types_panel.py | 2 + src/nipanel/__init__.py | 4 +- src/nipanel/controls/_flag_checkboxes.py | 70 ++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 src/nipanel/controls/_flag_checkboxes.py diff --git a/examples/all_types/all_types_panel.py b/examples/all_types/all_types_panel.py index a7e1897..19ee511 100644 --- a/examples/all_types/all_types_panel.py +++ b/examples/all_types/all_types_panel.py @@ -24,6 +24,8 @@ with col2: if isinstance(default_value, bool): st.checkbox(label=name, value=default_value, key=name) + elif isinstance(default_value, Flag): + nipanel.flag_checkboxes(panel, label=name, value=default_value, key=name) elif isinstance(default_value, Enum) and not isinstance(default_value, Flag): nipanel.enum_selectbox(panel, label=name, value=default_value, key=name) elif isinstance(default_value, int) and not isinstance(default_value, Flag): diff --git a/src/nipanel/__init__.py b/src/nipanel/__init__.py index 28b6271..637c8b6 100644 --- a/src/nipanel/__init__.py +++ b/src/nipanel/__init__.py @@ -3,14 +3,16 @@ from importlib.metadata import version from nipanel._panel import Panel -from nipanel.controls._enum_selectbox import enum_selectbox from nipanel._streamlit_panel import StreamlitPanel from nipanel._streamlit_panel_initializer import create_panel, get_panel_accessor from nipanel._streamlit_panel_value_accessor import StreamlitPanelValueAccessor +from nipanel.controls._enum_selectbox import enum_selectbox +from nipanel.controls._flag_checkboxes import flag_checkboxes __all__ = [ "create_panel", "enum_selectbox", + "flag_checkboxes", "get_panel_accessor", "Panel", "StreamlitPanel", diff --git a/src/nipanel/controls/_flag_checkboxes.py b/src/nipanel/controls/_flag_checkboxes.py new file mode 100644 index 0000000..e21f1ea --- /dev/null +++ b/src/nipanel/controls/_flag_checkboxes.py @@ -0,0 +1,70 @@ +"""A set of checkboxes for selecting Flag enum values.""" + +from enum import Flag +from typing import TypeVar + +import streamlit as st + +from nipanel._panel_value_accessor import PanelValueAccessor + +T = TypeVar("T", bound=Flag) + + +def flag_checkboxes( + panel: PanelValueAccessor, + label: str, + value: T, + key: str, + disabled: bool = False, +) -> T: + """Create a set of checkboxes for a Flag enum. + + This will display a checkbox for each individual flag value in the enum. When checkboxes + are selected or deselected, the combined Flag value will be stored in the panel under + the specified key. + + Args: + panel: The panel + label: Label to display above the checkboxes + value: The default Flag enum value (also determines the specific Flag enum type) + key: Key to use for storing the Flag value in the panel + disabled: Whether the checkboxes should be disabled + + Returns: + The selected Flag enum value with all selected flags combined + """ + flag_type = type(value) + if not issubclass(flag_type, Flag): + raise TypeError(f"Expected a Flag enum type, got {type(value)}") + + st.write(label + ":") + + # Get all individual flag values (skip composite values and zero value) + flag_values = [ + flag for flag in flag_type if flag.value & (flag.value - 1) == 0 and flag.value != 0 + ] + + # Create a container for flag checkboxes + flag_container = st.container() + selected_flags = flag_type(0) # Start with no flags + + # If default value is set, use it as the initial state + if value: + selected_flags = value + + # Create a checkbox for each flag + for flag in flag_values: + is_selected = bool(selected_flags & flag) + if flag_container.checkbox( + label=str(flag.name), + value=is_selected, + key=f"{key}_{flag.name}", + disabled=disabled, + ): + selected_flags |= flag + else: + selected_flags &= ~flag + + # Store the selected flags in the panel + panel.set_value(key, selected_flags) + return selected_flags From 3de247ec78664a7b00bd8d67fd2b6c3dda17a8b0 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Mon, 7 Jul 2025 10:49:43 -0500 Subject: [PATCH 17/26] refactor: reorganize controls imports and update usage in examples --- examples/all_types/all_types_panel.py | 5 +++-- .../nidaqmx/nidaqmx_continuous_analog_input_panel.py | 11 ++++++----- src/nipanel/__init__.py | 4 ---- src/nipanel/controls/__init__.py | 6 ++++++ 4 files changed, 15 insertions(+), 11 deletions(-) create mode 100644 src/nipanel/controls/__init__.py diff --git a/examples/all_types/all_types_panel.py b/examples/all_types/all_types_panel.py index 19ee511..4b16318 100644 --- a/examples/all_types/all_types_panel.py +++ b/examples/all_types/all_types_panel.py @@ -6,6 +6,7 @@ from define_types import all_types_with_values import nipanel +import nipanel.controls as ni st.set_page_config(page_title="All Types Example", page_icon="📊", layout="wide") @@ -25,9 +26,9 @@ if isinstance(default_value, bool): st.checkbox(label=name, value=default_value, key=name) elif isinstance(default_value, Flag): - nipanel.flag_checkboxes(panel, label=name, value=default_value, key=name) + ni.flag_checkboxes(panel, label=name, value=default_value, key=name) elif isinstance(default_value, Enum) and not isinstance(default_value, Flag): - nipanel.enum_selectbox(panel, label=name, value=default_value, key=name) + ni.enum_selectbox(panel, label=name, value=default_value, key=name) elif isinstance(default_value, int) and not isinstance(default_value, Flag): st.number_input(label=name, value=default_value, key=name) elif isinstance(default_value, float): diff --git a/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py b/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py index da1646b..c6403ba 100644 --- a/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py +++ b/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py @@ -11,6 +11,7 @@ from streamlit_echarts import st_echarts import nipanel +import nipanel.controls as ni st.markdown( @@ -75,7 +76,7 @@ key="voltage_max_value", ) with channel_right: - nipanel.enum_selectbox( + ni.enum_selectbox( panel, label="Terminal Configuration", value=TerminalConfiguration.DEFAULT, @@ -103,14 +104,14 @@ key="thermocouple_max_value", ) with channel_middle: - nipanel.enum_selectbox( + ni.enum_selectbox( panel, label="Units", value=TemperatureUnits.DEG_C, disabled=panel.get_value("is_running", False), key="thermocouple_units", ) - nipanel.enum_selectbox( + ni.enum_selectbox( panel, label="Thermocouple Type", value=ThermocoupleType.K, @@ -118,7 +119,7 @@ key="thermocouple_type", ) with channel_right: - nipanel.enum_selectbox( + ni.enum_selectbox( panel, label="CJC Source", value=CJCSource.CONSTANT_USER_VALUE, @@ -218,7 +219,7 @@ st.header("Logging Settings") logging_left, logging_right = st.columns(2) with logging_left: - nipanel.enum_selectbox( + ni.enum_selectbox( panel, label="Logging Mode", value=LoggingMode.OFF, diff --git a/src/nipanel/__init__.py b/src/nipanel/__init__.py index 637c8b6..05d5a73 100644 --- a/src/nipanel/__init__.py +++ b/src/nipanel/__init__.py @@ -6,13 +6,9 @@ from nipanel._streamlit_panel import StreamlitPanel from nipanel._streamlit_panel_initializer import create_panel, get_panel_accessor from nipanel._streamlit_panel_value_accessor import StreamlitPanelValueAccessor -from nipanel.controls._enum_selectbox import enum_selectbox -from nipanel.controls._flag_checkboxes import flag_checkboxes __all__ = [ "create_panel", - "enum_selectbox", - "flag_checkboxes", "get_panel_accessor", "Panel", "StreamlitPanel", diff --git a/src/nipanel/controls/__init__.py b/src/nipanel/controls/__init__.py new file mode 100644 index 0000000..6882af0 --- /dev/null +++ b/src/nipanel/controls/__init__.py @@ -0,0 +1,6 @@ +"""Controls for nipanel.""" + +from nipanel.controls._enum_selectbox import enum_selectbox +from nipanel.controls._flag_checkboxes import flag_checkboxes + +__all__ = ["enum_selectbox", "flag_checkboxes"] From e77345e6e6202a53eab4dddd9cd8b4be05320385 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Mon, 7 Jul 2025 11:23:54 -0500 Subject: [PATCH 18/26] cleanup --- examples/all_types/define_types.py | 10 +++++----- src/nipanel/controls/_flag_checkboxes.py | 7 +++++-- tests/unit/test_streamlit_panel.py | 8 ++++---- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/examples/all_types/define_types.py b/examples/all_types/define_types.py index aff591e..33c8c47 100644 --- a/examples/all_types/define_types.py +++ b/examples/all_types/define_types.py @@ -18,9 +18,9 @@ class MyIntFlags(enum.IntFlag): class MyIntableFlags(enum.Flag): """Example of an Flag enum with int values.""" - VALUE1 = 1 - VALUE2 = 2 - VALUE4 = 4 + VALUE8 = 8 + VALUE16 = 16 + VALUE32 = 32 class MyIntEnum(enum.IntEnum): @@ -75,7 +75,7 @@ class MyMixedEnum(enum.Enum): "intenum": MyIntEnum.VALUE20, "strenum": MyStrEnum.VALUE3, "intableenum": MyIntableEnum.VALUE200, - "intableflags": MyIntableFlags.VALUE1 | MyIntableFlags.VALUE2, + "intableflags": MyIntableFlags.VALUE8 | MyIntableFlags.VALUE32, "stringableenum": MyStringableEnum.VALUE2, "mixedenum": MyMixedEnum.VALUE2, # NI types @@ -98,6 +98,6 @@ class MyMixedEnum(enum.Enum): # supported 2D collections "list_list_float": [[1.0, 2.0], [3.0, 4.0]], "tuple_tuple_float": ((1.0, 2.0), (3.0, 4.0)), - "set_tuple_float": set([(1.0, 2.0), (3.0, 4.0)]), + "set_list_float": set([(1.0, 2.0), (3.0, 4.0)]), "frozenset_frozenset_float": frozenset([frozenset([1.0, 2.0]), frozenset([3.0, 4.0])]), } diff --git a/src/nipanel/controls/_flag_checkboxes.py b/src/nipanel/controls/_flag_checkboxes.py index e21f1ea..8dd4579 100644 --- a/src/nipanel/controls/_flag_checkboxes.py +++ b/src/nipanel/controls/_flag_checkboxes.py @@ -1,7 +1,7 @@ """A set of checkboxes for selecting Flag enum values.""" from enum import Flag -from typing import TypeVar +from typing import TypeVar, Callable import streamlit as st @@ -16,6 +16,7 @@ def flag_checkboxes( value: T, key: str, disabled: bool = False, + label_formatter: Callable[[Flag], str] = lambda x: str(x.name), ) -> T: """Create a set of checkboxes for a Flag enum. @@ -29,6 +30,8 @@ def flag_checkboxes( value: The default Flag enum value (also determines the specific Flag enum type) key: Key to use for storing the Flag value in the panel disabled: Whether the checkboxes should be disabled + label_formatter: Function that formats the flag to a string for display. Default + uses flag.name. Returns: The selected Flag enum value with all selected flags combined @@ -56,7 +59,7 @@ def flag_checkboxes( for flag in flag_values: is_selected = bool(selected_flags & flag) if flag_container.checkbox( - label=str(flag.name), + label=label_formatter(flag), value=is_selected, key=f"{key}_{flag.name}", disabled=disabled, diff --git a/tests/unit/test_streamlit_panel.py b/tests/unit/test_streamlit_panel.py index 095cc0a..90fb3ec 100644 --- a/tests/unit/test_streamlit_panel.py +++ b/tests/unit/test_streamlit_panel.py @@ -403,7 +403,7 @@ def test___set_int_enum_value___get_value___returns_int_enum( assert retrieved_value.name == enum_value.name -def test___set_intable_enum_value___get_value___returns_enum( +def test___set_intable_enum_value___get_value___returns_intable_enum( fake_panel_channel: grpc.Channel, ) -> None: panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel) @@ -435,7 +435,7 @@ def test___set_string_enum_value___get_value___returns_string_enum( assert retrieved_value.name == enum_value.name -def test___set_stringable_enum_value___get_value___returns_enum( +def test___set_stringable_enum_value___get_value___returns_stringable_enum( fake_panel_channel: grpc.Channel, ) -> None: panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel) @@ -451,7 +451,7 @@ def test___set_stringable_enum_value___get_value___returns_enum( assert retrieved_value.name == enum_value.name -def test___set_mixed_enum_value___get_value___returns_enum( +def test___set_mixed_enum_value___get_value___returns_mixed_enum( fake_panel_channel: grpc.Channel, ) -> None: panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel) @@ -482,7 +482,7 @@ def test___set_int_flags_value___get_value___returns_int_flags( assert retrieved_value.value == flags_value.value -def test___set_intable_flags_value___get_value___returns_flags( +def test___set_intable_flags_value___get_value___returns_intable_flags( fake_panel_channel: grpc.Channel, ) -> None: panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel) From 6d7df1e2e93c8168a6bfa0fb137443a1dc037935 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Mon, 7 Jul 2025 14:14:08 -0500 Subject: [PATCH 19/26] feat: implement set_value_if_changed method to optimize value updates and add tests --- src/nipanel/_streamlit_panel_initializer.py | 2 +- .../_streamlit_panel_value_accessor.py | 19 +++ src/nipanel/controls/_enum_selectbox.py | 6 +- src/nipanel/controls/_flag_checkboxes.py | 6 +- tests/unit/test_streamlit_panel.py | 2 +- .../test_streamlit_panel_value_accessor.py | 111 ++++++++++++++++++ tests/utils/_fake_python_panel_servicer.py | 7 ++ 7 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 tests/unit/test_streamlit_panel_value_accessor.py diff --git a/src/nipanel/_streamlit_panel_initializer.py b/src/nipanel/_streamlit_panel_initializer.py index 7d3800f..a05822d 100644 --- a/src/nipanel/_streamlit_panel_initializer.py +++ b/src/nipanel/_streamlit_panel_initializer.py @@ -84,4 +84,4 @@ def _sync_session_state(panel: StreamlitPanelValueAccessor) -> None: for key in st.session_state.keys(): value = st.session_state[key] if is_supported_type(value): - panel.set_value(str(key), value) + panel.set_value_if_changed(str(key), value) diff --git a/src/nipanel/_streamlit_panel_value_accessor.py b/src/nipanel/_streamlit_panel_value_accessor.py index dc9e1d2..7f7de19 100644 --- a/src/nipanel/_streamlit_panel_value_accessor.py +++ b/src/nipanel/_streamlit_panel_value_accessor.py @@ -1,5 +1,6 @@ from __future__ import annotations +import collections from typing import final import grpc @@ -17,6 +18,8 @@ class StreamlitPanelValueAccessor(PanelValueAccessor): This class should only be used within a Streamlit script. """ + __slots__ = ["_last_values"] + def __init__( self, panel_id: str, @@ -43,3 +46,19 @@ def __init__( grpc_channel_pool=grpc_channel_pool, grpc_channel=grpc_channel, ) + self._last_values: collections.defaultdict[str, object] = collections.defaultdict( + lambda: object() + ) + + def set_value_if_changed(self, value_id: str, value: object) -> None: + """Set the value for a control on the panel only if it has changed since the last call. + + This method helps reduce unnecessary updates when the value hasn't changed. + + Args: + value_id: The id of the value + value: The value to set + """ + if value != self._last_values[value_id]: + self.set_value(value_id, value) + self._last_values[value_id] = value diff --git a/src/nipanel/controls/_enum_selectbox.py b/src/nipanel/controls/_enum_selectbox.py index 16ab64d..84e8c7a 100644 --- a/src/nipanel/controls/_enum_selectbox.py +++ b/src/nipanel/controls/_enum_selectbox.py @@ -5,13 +5,13 @@ import streamlit as st -from nipanel._panel_value_accessor import PanelValueAccessor +from nipanel._streamlit_panel_value_accessor import StreamlitPanelValueAccessor T = TypeVar("T", bound=Enum) def enum_selectbox( - panel: PanelValueAccessor, + panel: StreamlitPanelValueAccessor, label: str, value: T, key: str, @@ -49,5 +49,5 @@ def enum_selectbox( label, options=options, format_func=format_func, index=default_index, disabled=disabled ) enum_value = enum_class[box_tuple[0]] - panel.set_value(key, enum_value) + panel.set_value_if_changed(key, enum_value) return enum_value diff --git a/src/nipanel/controls/_flag_checkboxes.py b/src/nipanel/controls/_flag_checkboxes.py index 8dd4579..11fcf1c 100644 --- a/src/nipanel/controls/_flag_checkboxes.py +++ b/src/nipanel/controls/_flag_checkboxes.py @@ -5,13 +5,13 @@ import streamlit as st -from nipanel._panel_value_accessor import PanelValueAccessor +from nipanel._streamlit_panel_value_accessor import StreamlitPanelValueAccessor T = TypeVar("T", bound=Flag) def flag_checkboxes( - panel: PanelValueAccessor, + panel: StreamlitPanelValueAccessor, label: str, value: T, key: str, @@ -69,5 +69,5 @@ def flag_checkboxes( selected_flags &= ~flag # Store the selected flags in the panel - panel.set_value(key, selected_flags) + panel.set_value_if_changed(key, selected_flags) return selected_flags diff --git a/tests/unit/test_streamlit_panel.py b/tests/unit/test_streamlit_panel.py index 90fb3ec..9b67bf6 100644 --- a/tests/unit/test_streamlit_panel.py +++ b/tests/unit/test_streamlit_panel.py @@ -297,7 +297,7 @@ def test___enum_type___set_value___gets_same_value( fake_panel_channel: grpc.Channel, value_payload: enum.Enum, ) -> None: - """Test that set_value() and get_value() work for builtin scalar types.""" + """Test that set_value() and get_value() work for enum types.""" panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel) value_id = "test_id" diff --git a/tests/unit/test_streamlit_panel_value_accessor.py b/tests/unit/test_streamlit_panel_value_accessor.py new file mode 100644 index 0000000..7add639 --- /dev/null +++ b/tests/unit/test_streamlit_panel_value_accessor.py @@ -0,0 +1,111 @@ +import grpc + +from nipanel import StreamlitPanelValueAccessor +from tests.types import MyIntEnum +from tests.utils._fake_python_panel_service import FakePythonPanelService + + +def test___no_previous_value___set_value_if_changed___sets_value( + fake_panel_channel: grpc.Channel, +) -> None: + accessor = StreamlitPanelValueAccessor("panel_id", grpc_channel=fake_panel_channel) + + accessor.set_value_if_changed("test_id", "test_value") + + assert accessor.get_value("test_id") == "test_value" + + +def test___set_value_if_changed___set_same_value___does_not_set_value_again( + fake_panel_channel: grpc.Channel, + fake_python_panel_service: FakePythonPanelService, +) -> None: + accessor = StreamlitPanelValueAccessor("panel_id", grpc_channel=fake_panel_channel) + accessor.set_value_if_changed("test_id", "test_value") + initial_set_count = fake_python_panel_service.servicer.set_count + + accessor.set_value_if_changed("test_id", "test_value") + + assert fake_python_panel_service.servicer.set_count == initial_set_count + assert accessor.get_value("test_id") == "test_value" + + +def test___set_value_if_changed___set_different_value___sets_new_value( + fake_panel_channel: grpc.Channel, +) -> None: + accessor = StreamlitPanelValueAccessor("panel_id", grpc_channel=fake_panel_channel) + accessor.set_value_if_changed("test_id", "test_value") + + accessor.set_value_if_changed("test_id", "new_value") + + assert accessor.get_value("test_id") == "new_value" + + +def test___set_value_if_changed___different_value_ids___tracks_separately( + fake_panel_channel: grpc.Channel, +) -> None: + accessor = StreamlitPanelValueAccessor("panel_id", grpc_channel=fake_panel_channel) + + accessor.set_value_if_changed("id1", "value1") + accessor.set_value_if_changed("id2", "value2") + + accessor.set_value_if_changed("id1", "value1") + accessor.set_value_if_changed("id2", "new_value2") + + assert accessor.get_value("id1") == "value1" + assert accessor.get_value("id2") == "new_value2" + + +def test___set_value_if_changed_with_list_value___set_same_value___does_not_set_value_again( + fake_panel_channel: grpc.Channel, + fake_python_panel_service: FakePythonPanelService, +) -> None: + accessor = StreamlitPanelValueAccessor("panel_id", grpc_channel=fake_panel_channel) + accessor.set_value_if_changed("test_id", [1, 2, 3]) + initial_set_count = fake_python_panel_service.servicer.set_count + + accessor.set_value_if_changed("test_id", [1, 2, 3]) + + assert fake_python_panel_service.servicer.set_count == initial_set_count + assert accessor.get_value("test_id") == [1, 2, 3] + + +def test___set_value_if_changed_with_list_value___set_different_value___sets_new_value( + fake_panel_channel: grpc.Channel, + fake_python_panel_service: FakePythonPanelService, +) -> None: + accessor = StreamlitPanelValueAccessor("panel_id", grpc_channel=fake_panel_channel) + accessor.set_value_if_changed("test_id", [1, 2, 3]) + initial_set_count = fake_python_panel_service.servicer.set_count + + accessor.set_value_if_changed("test_id", [1, 2, 4]) + + assert fake_python_panel_service.servicer.set_count > initial_set_count + assert accessor.get_value("test_id") == [1, 2, 4] + + +def test___set_value_if_changed_with_enum_value___set_same_value___does_not_set_value_again( + fake_panel_channel: grpc.Channel, + fake_python_panel_service: FakePythonPanelService, +) -> None: + accessor = StreamlitPanelValueAccessor("panel_id", grpc_channel=fake_panel_channel) + accessor.set_value_if_changed("test_id", MyIntEnum.VALUE20) + initial_set_count = fake_python_panel_service.servicer.set_count + + accessor.set_value_if_changed("test_id", MyIntEnum.VALUE20) + + assert fake_python_panel_service.servicer.set_count == initial_set_count + assert accessor.get_value("test_id") == 20 # Enums are stored as their values + + +def test___set_value_if_changed_with_enum_value___set_different_value___sets_new_value( + fake_panel_channel: grpc.Channel, + fake_python_panel_service: FakePythonPanelService, +) -> None: + accessor = StreamlitPanelValueAccessor("panel_id", grpc_channel=fake_panel_channel) + accessor.set_value_if_changed("test_id", MyIntEnum.VALUE20) + initial_set_count = fake_python_panel_service.servicer.set_count + + accessor.set_value_if_changed("test_id", MyIntEnum.VALUE30) + + assert fake_python_panel_service.servicer.set_count > initial_set_count + assert accessor.get_value("test_id") == 30 # New enum value should be set diff --git a/tests/utils/_fake_python_panel_servicer.py b/tests/utils/_fake_python_panel_servicer.py index 77941e8..f0e2183 100644 --- a/tests/utils/_fake_python_panel_servicer.py +++ b/tests/utils/_fake_python_panel_servicer.py @@ -26,6 +26,7 @@ def __init__(self) -> None: self._panel_is_running: dict[str, bool] = {} self._panel_value_ids: dict[str, dict[str, Any]] = {} self._fail_next_start_panel = False + self._set_count: int = 0 self._notification_count: int = 0 self._python_path: str = "" @@ -70,6 +71,7 @@ def SetValue(self, request: SetValueRequest, context: Any) -> SetValueResponse: """Trivial implementation for testing.""" self._init_panel(request.panel_id) self._panel_value_ids[request.panel_id][request.value_id] = request.value + self._set_count += 1 if request.notify: self._notification_count += 1 return SetValueResponse() @@ -78,6 +80,11 @@ def fail_next_start_panel(self) -> None: """Set whether the StartPanel method should fail the next time it is called.""" self._fail_next_start_panel = True + @property + def set_count(self) -> int: + """Get the total number of times SetValue was called.""" + return self._set_count + @property def notification_count(self) -> int: """Get the number of notifications sent from SetValue.""" From cb4a74371a5741d2357129b467097ca5a387fb16 Mon Sep 17 00:00:00 2001 From: Mike Prosser Date: Mon, 7 Jul 2025 14:17:26 -0500 Subject: [PATCH 20/26] cleanup --- examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py b/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py index c6403ba..563a472 100644 --- a/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py +++ b/examples/nidaqmx/nidaqmx_continuous_analog_input_panel.py @@ -14,6 +14,9 @@ import nipanel.controls as ni +st.set_page_config(page_title="NI-DAQmx Example", page_icon="📈", layout="wide") +st.title("Analog Input - Voltage and Thermocouple in a Single Task") + st.markdown( """