From 016e4843eedf6d27ba2411ae77d68585b67b3b64 Mon Sep 17 00:00:00 2001 From: Zach Domke Date: Thu, 12 Mar 2026 14:51:19 -0700 Subject: [PATCH 1/6] ENH: Import formulas from .trc file --- trace/widgets/control_panel.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/trace/widgets/control_panel.py b/trace/widgets/control_panel.py index 45e9742..767e766 100644 --- a/trace/widgets/control_panel.py +++ b/trace/widgets/control_panel.py @@ -401,9 +401,13 @@ def set_curves(self, curves: list[dict] = None) -> None: if axis_item is None: axis_item = self.add_empty_axis(axis_name) - pv_name = curve_dict.get("channel", "") - del curve_dict["channel"] # Remove channel key to avoid conflicts with y_channel - axis_item.add_curve(pv_name, curve_dict) + if "channel" in curve_dict: + pv_name = curve_dict.get("channel", "") + del curve_dict["channel"] # Remove channel key to avoid conflicts with y_channel + axis_item.add_curve(pv_name, curve_dict) + elif "formula" in curve_dict: + formula = curve_dict.get("formula", "f://") + axis_item.add_formula_curve(formula) self.plot.redrawPlot() self.axis_list.itemAt(self.axis_list.count() - 2).widget() From f3e3409221dea05b41222fcece5e51da15ec7256 Mon Sep 17 00:00:00 2001 From: Zach Domke Date: Thu, 12 Mar 2026 14:51:53 -0700 Subject: [PATCH 2/6] ENH: Update FormulaExample.trc file to match curve indexing scheme --- trace/examples/FormulaExample.trc | 49 ++++++++++++------------------- 1 file changed, 18 insertions(+), 31 deletions(-) diff --git a/trace/examples/FormulaExample.trc b/trace/examples/FormulaExample.trc index b982790..11d868f 100644 --- a/trace/examples/FormulaExample.trc +++ b/trace/examples/FormulaExample.trc @@ -1,52 +1,37 @@ { "archiver_url": "http://lcls-archapp.slac.stanford.edu", "plot": { - "refreshInterval": 0.0, + "refreshInterval": 1.0, "title": "", "xGrid": false, "yGrid": false, "opacity": 128, "backgroundColor": "#ffffff", "legend": false, - "crosshair": false, "mouseMode": 1 }, "time_axis": { "name": "Main Time Axis", - "start": "2024-09-04 11:03:44", - "end": "2024-09-04 12:03:44", + "start": "-1m", + "end": "now", "location": "bottom" }, "y-axes": [ { "name": "Axis 1", "orientation": "left", - "minRange": -1.3843656974520628e-09, - "maxRange": 4.752694382245206e-08, + "label": "Axis 1", + "minRange": 1.1067500225324897e-08, + "maxRange": 2.6286015399675106e-08, "autoRange": true, "logMode": false }, { - "name": "Axis 2", + "name": "torr", "orientation": "left", - "minRange": 9.047133561624635e-10, - "maxRange": 1.3621653831337537e-08, - "autoRange": true, - "logMode": false - }, - { - "name": "Axis 3", - "orientation": "left", - "minRange": 1.3015564414604301e-08, - "maxRange": 4.65547480853957e-08, - "autoRange": true, - "logMode": false - }, - { - "name": "Axis 4", - "orientation": "left", - "minRange": -1.04, - "maxRange": 1.04, + "label": "torr", + "minRange": 1.0577346003248955e-09, + "maxRange": 1.6276249774675104e-08, "autoRange": true, "logMode": false } @@ -55,25 +40,27 @@ { "useArchiveData": true, "liveData": true, + "showExtensionLine": false, "channel": "KLYS:LI22:31:KVAC", "name": "KLYS:LI22:31:KVAC", "color": "#008cf9", "lineStyle": 1, "lineWidth": 1, "symbolSize": 10, - "yAxisName": "Axis 1", + "yAxisName": "torr", "thresholdColor": "white" }, { "useArchiveData": true, "liveData": true, + "showExtensionLine": false, "channel": "KLYS:LI22:41:KVAC", "name": "KLYS:LI22:41:KVAC", "color": "#006e00", "lineStyle": 1, "lineWidth": 1, "symbolSize": 10, - "yAxisName": "Axis 1", + "yAxisName": "torr", "thresholdColor": "white" } ], @@ -82,12 +69,12 @@ "useArchiveData": true, "liveData": true, "plot_style": "Line", - "formula": "f://{A}+{B}", + "formula": "f://{x1}+{x2}", "curveDict": { - "A": "KLYS:LI22:31:KVAC", - "B": "KLYS:LI22:41:KVAC" + "x1": "KLYS:LI22:31:KVAC", + "x2": "KLYS:LI22:41:KVAC" }, - "name": "f://{A}+{B}", + "name": "f://{x1}+{x2}", "color": "#b80058", "lineStyle": 1, "lineWidth": 1, From df27ee650ba1b0905e3574235bb2df778d3fad9c Mon Sep 17 00:00:00 2001 From: Zach Domke Date: Thu, 12 Mar 2026 16:19:51 -0700 Subject: [PATCH 3/6] TST: Test FileHandler.open_file() --- trace/tests/test_file_io/test_file_handler.py | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 trace/tests/test_file_io/test_file_handler.py diff --git a/trace/tests/test_file_io/test_file_handler.py b/trace/tests/test_file_io/test_file_handler.py new file mode 100644 index 0000000..9ae1c42 --- /dev/null +++ b/trace/tests/test_file_io/test_file_handler.py @@ -0,0 +1,174 @@ +from os import environ +from unittest.mock import Mock, patch + +import pytest + +from file_io import TraceFileHandler + +DUMMY_ARCHIVER_URL = "http://dummy.archiver.url/retrieval" + + +@pytest.fixture +def file_handler(qapp): + """Fixture for an instance of TraceFileHandler with a mocked plot. + + Yields + ------ + An instance of TraceFileHandler. + """ + mock_plot = Mock() + with patch.dict(environ, {"PYDM_ARCHIVER_URL": DUMMY_ARCHIVER_URL}): + yield TraceFileHandler(mock_plot) + + +def test_open_file_curves_signal_includes_formulas(file_handler, get_test_file, qtbot): + """Test that opening a .trc file with formulas emits curves_signal with both + regular curves and formula curves combined into a single list. + + Parameters + ---------- + file_handler : fixture + Instance of TraceFileHandler for testing + get_test_file : fixture + A fixture used to get test files from the test_data directory + qtbot : fixture + pytest-qt fixture for Qt testing + + Expectations + ------------ + curves_signal is emitted once with a list whose length equals the number + of regular curves plus the number of formulas in the file. + """ + test_file = get_test_file("test_file_formula.trc") + + with patch.dict(environ, {"PYDM_ARCHIVER_URL": DUMMY_ARCHIVER_URL}): + with qtbot.wait_signal(file_handler.curves_signal) as blocker: + file_handler.open_file(test_file) + + # test_file.trc has 2 regular curves + 1 formula + received_curves = blocker.args[0] + assert len(received_curves) == 3 + + +def test_open_file_formula_entries_have_formula_key(file_handler, get_test_file, qtbot): + """Test that formula entries in the emitted curves_signal have the 'formula' key. + + Parameters + ---------- + file_handler : fixture + Instance of TraceFileHandler for testing + get_test_file : fixture + A fixture used to get test files from the test_data directory + qtbot : fixture + pytest-qt fixture for Qt testing + + Expectations + ------------ + Exactly one entry in curves_signal has a 'formula' key, and its value + matches the formula string from the test file. + """ + test_file = get_test_file("test_file_formula.trc") + + with patch.dict(environ, {"PYDM_ARCHIVER_URL": DUMMY_ARCHIVER_URL}): + with qtbot.wait_signal(file_handler.curves_signal) as blocker: + file_handler.open_file(test_file) + + received_curves = blocker.args[0] + formula_entries = [c for c in received_curves if "formula" in c] + + assert len(formula_entries) == 1 + assert formula_entries[0]["formula"] == "f://{x0}+{x1}" + + +def test_open_file_regular_curve_entries_have_channel_key(file_handler, get_test_file, qtbot): + """Test that regular curve entries in the emitted curves_signal have the 'channel' key. + + Parameters + ---------- + file_handler : fixture + Instance of TraceFileHandler for testing + get_test_file : fixture + A fixture used to get test files from the test_data directory + qtbot : fixture + pytest-qt fixture for Qt testing + + Expectations + ------------ + Exactly two entries in curves_signal have a 'channel' key, corresponding + to the regular PV curves in the test file. + """ + test_file = get_test_file("test_file_formula.trc") + + with patch.dict(environ, {"PYDM_ARCHIVER_URL": DUMMY_ARCHIVER_URL}): + with qtbot.wait_signal(file_handler.curves_signal) as blocker: + file_handler.open_file(test_file) + + received_curves = blocker.args[0] + channel_entries = [c for c in received_curves if "channel" in c] + + assert len(channel_entries) == 2 + + +def test_open_file_formulas_appended_after_curves(file_handler, get_test_file, qtbot): + """Test that formula entries appear after regular curve entries in curves_signal, + reflecting the file_data["curves"] + file_data["formula"] concatenation order. + + Parameters + ---------- + file_handler : fixture + Instance of TraceFileHandler for testing + get_test_file : fixture + A fixture used to get test files from the test_data directory + qtbot : fixture + pytest-qt fixture for Qt testing + + Expectations + ------------ + The first entries in curves_signal have 'channel' keys (regular curves), + and the last entries have 'formula' keys (formula curves). + """ + test_file = get_test_file("test_file_formula.trc") + + with patch.dict(environ, {"PYDM_ARCHIVER_URL": DUMMY_ARCHIVER_URL}): + with qtbot.wait_signal(file_handler.curves_signal) as blocker: + file_handler.open_file(test_file) + + received_curves = blocker.args[0] + + assert "channel" in received_curves[0] + assert "channel" in received_curves[1] + assert "formula" in received_curves[2] + + +def test_open_file_formula_properties_match_trc_file(file_handler, get_test_file, qtbot): + """Test that the formula entry in curves_signal contains all properties + from the .trc file, including name, color, yAxisName, and curveDict. + + Parameters + ---------- + file_handler : fixture + Instance of TraceFileHandler for testing + get_test_file : fixture + A fixture used to get test files from the test_data directory + qtbot : fixture + pytest-qt fixture for Qt testing + + Expectations + ------------ + The formula entry in curves_signal has the correct formula string, name, + color, yAxisName, lineWidth, and curveDict matching the test file. + """ + test_file = get_test_file("test_file_formula.trc") + + with patch.dict(environ, {"PYDM_ARCHIVER_URL": DUMMY_ARCHIVER_URL}): + with qtbot.wait_signal(file_handler.curves_signal) as blocker: + file_handler.open_file(test_file) + + received_curves = blocker.args[0] + formula_entry = next(c for c in received_curves if "formula" in c) + + assert formula_entry["name"] == "formula0" + assert formula_entry["formula"] == "f://{x0}+{x1}" + assert formula_entry["yAxisName"] == "Main Range Axis" + assert formula_entry["color"] == "#00ff00" + assert formula_entry["curveDict"] == {"x0": "KLYS:LI22:31:KVAC", "x1": "KLYS:LI22:41:KVAC"} From fde84558dec50d551ae4c264f4e8da25604365b5 Mon Sep 17 00:00:00 2001 From: zdomke Date: Thu, 12 Mar 2026 16:55:49 -0700 Subject: [PATCH 4/6] TST: Test ControlPanel.set_curves() --- .../tests/test_widgets/test_control_panel.py | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 trace/tests/test_widgets/test_control_panel.py diff --git a/trace/tests/test_widgets/test_control_panel.py b/trace/tests/test_widgets/test_control_panel.py new file mode 100644 index 0000000..2c30405 --- /dev/null +++ b/trace/tests/test_widgets/test_control_panel.py @@ -0,0 +1,181 @@ +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def control_panel(qtrace): + """Fixture for the ControlPanel embedded in a TraceDisplay instance. + + Yields + ------ + The ControlPanel instance from TraceDisplay. + """ + yield qtrace.control_panel + + +@pytest.fixture +def control_panel_with_axis(control_panel): + """Fixture for a ControlPanel with one real axis named 'Y-Axis 0'. + + Yields + ------ + Tuple of (ControlPanel, AxisItem) for 'Y-Axis 0'. + """ + axis_item = control_panel.add_empty_axis("Y-Axis 0") + yield control_panel, axis_item + + +def test_set_curves_formula_calls_add_formula_curve(control_panel_with_axis): + """Test that set_curves calls add_formula_curve when a formula entry is provided. + + Parameters + ---------- + control_panel_with_axis : fixture + A ControlPanel with an existing axis named 'Y-Axis 0' + + Expectations + ------------ + When a curve dict with a 'formula' key is passed to set_curves, + add_formula_curve should be called on the correct AxisItem with + the formula string. + """ + cp, axis_item = control_panel_with_axis + formula = "f://{x1}+1" + + with patch.object(axis_item, "add_formula_curve", return_value=MagicMock()) as mock_add: + cp.set_curves([{"formula": formula, "yAxisName": "Y-Axis 0"}]) + + mock_add.assert_called_once_with(formula) + + +def test_set_curves_formula_uses_default_axis_when_yaxisname_missing(control_panel_with_axis): + """Test that set_curves routes to 'Y-Axis 0' when yAxisName is absent from the curve dict. + + Parameters + ---------- + control_panel_with_axis : fixture + A ControlPanel with an existing axis named 'Y-Axis 0' + + Expectations + ------------ + When a formula curve dict has no 'yAxisName' key, set_curves defaults + to 'Y-Axis 0' and calls add_formula_curve on that axis. + """ + cp, axis_item = control_panel_with_axis + formula = "f://{x1}+1" + + with patch.object(axis_item, "add_formula_curve", return_value=MagicMock()) as mock_add: + cp.set_curves([{"formula": formula}]) + + mock_add.assert_called_once_with(formula) + + +def test_set_curves_formula_targets_named_axis(control_panel_with_axis, qtrace): + """Test that set_curves adds the formula curve to the axis matching yAxisName. + + Parameters + ---------- + control_panel_with_axis : fixture + A ControlPanel with an existing axis named 'Y-Axis 0' + qtrace : fixture + The TraceDisplay instance + + Expectations + ------------ + The formula is routed to the axis specified by yAxisName, not a + different axis. + """ + cp, axis_item = control_panel_with_axis + other_axis = cp.add_empty_axis("Other Axis") + + formula = "f://{x1}*2" + + with patch.object(axis_item, "add_formula_curve", return_value=MagicMock()) as mock_target, \ + patch.object(other_axis, "add_formula_curve", return_value=MagicMock()) as mock_other: # fmt: skip + cp.set_curves([{"formula": formula, "yAxisName": "Y-Axis 0"}]) + + mock_target.assert_called_once_with(formula) + mock_other.assert_not_called() + + +def test_set_curves_formula_creates_axis_when_missing(control_panel_with_axis): + """Test that set_curves creates a new axis when yAxisName does not exist. + + Parameters + ---------- + control_panel_with_axis : fixture + A ControlPanel with an existing axis (used so axis_list is non-empty + and the trailing axis_list.itemAt() call in set_curves doesn't crash) + + Expectations + ------------ + When a formula curve dict references an axis that doesn't exist, + add_empty_axis is called and add_formula_curve is then called on + the newly created axis. + """ + cp, axis_item = control_panel_with_axis + formula = "f://{x1}+{x2}" + + with patch.object(cp, "get_axis_item", return_value=None), \ + patch.object(cp, "add_empty_axis", return_value=axis_item) as mock_add_axis, \ + patch.object(axis_item, "add_formula_curve", return_value=MagicMock()) as mock_add_formula: # fmt: skip + cp.set_curves([{"formula": formula, "yAxisName": "Missing Axis"}]) + + mock_add_axis.assert_called_once_with("Missing Axis") + mock_add_formula.assert_called_once_with(formula) + + +def test_set_curves_channel_removes_channel_key_before_add_curve(control_panel_with_axis): + """Test that the 'channel' key is removed from curve_dict before calling add_curve. + + Parameters + ---------- + control_panel_with_axis : fixture + A ControlPanel with an existing axis named 'Y-Axis 0' + + Expectations + ------------ + add_curve is called with the PV name and the curve_dict, where the + 'channel' key has been deleted to avoid conflicts with y_channel. + """ + cp, axis_item = control_panel_with_axis + curve_dict = {"channel": "SOME:PV", "color": "#ff0000", "yAxisName": "Y-Axis 0"} + + with patch.object(axis_item, "add_curve", return_value=MagicMock()) as mock_add_curve: + cp.set_curves([curve_dict]) + + args = mock_add_curve.call_args + pv_name_arg, dict_arg = args[0] + + assert pv_name_arg == "SOME:PV" + assert "channel" not in dict_arg + + +def test_set_curves_mixed_channel_and_formula(control_panel_with_axis): + """Test that set_curves correctly handles a mix of channel and formula entries. + + Parameters + ---------- + control_panel_with_axis : fixture + A ControlPanel with an existing axis named 'Y-Axis 0' + + Expectations + ------------ + Each entry is routed correctly: channel entries call add_curve and + formula entries call add_formula_curve, each exactly once. + """ + cp, axis_item = control_panel_with_axis + formula = "f://{x1}+{x2}" + + with patch.object(axis_item, "add_curve", return_value=MagicMock()) as mock_add_curve, \ + patch.object(axis_item, "add_formula_curve", return_value=MagicMock()) as mock_add_formula: # fmt: skip + cp.set_curves( + [ + {"channel": "SOME:PV", "yAxisName": "Y-Axis 0"}, + {"formula": formula, "yAxisName": "Y-Axis 0"}, + ] + ) + + mock_add_curve.assert_called_once() + mock_add_formula.assert_called_once_with(formula) From 50c7c278ab7deae5230e1dd2b35914b8a442428e Mon Sep 17 00:00:00 2001 From: zdomke Date: Thu, 12 Mar 2026 17:02:09 -0700 Subject: [PATCH 5/6] TST: Fix qtrace fixture --- trace/tests/conftest.py | 10 ++++++++-- trace/tests/test_file_io/test_file_handler.py | 13 +++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/trace/tests/conftest.py b/trace/tests/conftest.py index e44ba1e..0ab2c0c 100644 --- a/trace/tests/conftest.py +++ b/trace/tests/conftest.py @@ -3,6 +3,7 @@ from unittest import mock import pytest +from qtpy.QtWidgets import QMenu from pydm.application import PyDMApplication @@ -73,10 +74,15 @@ def qtrace(qtbot, qapp): ------ An instance of TraceDisplay. """ - trace = TraceDisplay() + # TraceDisplay.__init__ returns early when app.main_window is None (use_main_window=False). + # Patch it with a MagicMock so build_ui() and configure_app() run normally. + # construct_trace_menu is also patched because QMenu rejects a MagicMock parent. + with mock.patch.object(qapp, "main_window", mock.MagicMock()): + with mock.patch.object(TraceDisplay, "construct_trace_menu", return_value=QMenu()): + trace = TraceDisplay() # updateXAxis would be called on application render; necessary for testing X-Axis - trace.main_plot.updateXAxis(True) + trace.plot.updateXAxis(True) yield trace trace.close() diff --git a/trace/tests/test_file_io/test_file_handler.py b/trace/tests/test_file_io/test_file_handler.py index 9ae1c42..1b26673 100644 --- a/trace/tests/test_file_io/test_file_handler.py +++ b/trace/tests/test_file_io/test_file_handler.py @@ -18,7 +18,8 @@ def file_handler(qapp): """ mock_plot = Mock() with patch.dict(environ, {"PYDM_ARCHIVER_URL": DUMMY_ARCHIVER_URL}): - yield TraceFileHandler(mock_plot) + with patch("file_io.file_handler.QMessageBox.warning", return_value=Mock()): + yield TraceFileHandler(mock_plot) def test_open_file_curves_signal_includes_formulas(file_handler, get_test_file, qtbot): @@ -39,7 +40,7 @@ def test_open_file_curves_signal_includes_formulas(file_handler, get_test_file, curves_signal is emitted once with a list whose length equals the number of regular curves plus the number of formulas in the file. """ - test_file = get_test_file("test_file_formula.trc") + test_file = get_test_file("test_file.trc") with patch.dict(environ, {"PYDM_ARCHIVER_URL": DUMMY_ARCHIVER_URL}): with qtbot.wait_signal(file_handler.curves_signal) as blocker: @@ -67,7 +68,7 @@ def test_open_file_formula_entries_have_formula_key(file_handler, get_test_file, Exactly one entry in curves_signal has a 'formula' key, and its value matches the formula string from the test file. """ - test_file = get_test_file("test_file_formula.trc") + test_file = get_test_file("test_file.trc") with patch.dict(environ, {"PYDM_ARCHIVER_URL": DUMMY_ARCHIVER_URL}): with qtbot.wait_signal(file_handler.curves_signal) as blocker: @@ -97,7 +98,7 @@ def test_open_file_regular_curve_entries_have_channel_key(file_handler, get_test Exactly two entries in curves_signal have a 'channel' key, corresponding to the regular PV curves in the test file. """ - test_file = get_test_file("test_file_formula.trc") + test_file = get_test_file("test_file.trc") with patch.dict(environ, {"PYDM_ARCHIVER_URL": DUMMY_ARCHIVER_URL}): with qtbot.wait_signal(file_handler.curves_signal) as blocker: @@ -127,7 +128,7 @@ def test_open_file_formulas_appended_after_curves(file_handler, get_test_file, q The first entries in curves_signal have 'channel' keys (regular curves), and the last entries have 'formula' keys (formula curves). """ - test_file = get_test_file("test_file_formula.trc") + test_file = get_test_file("test_file.trc") with patch.dict(environ, {"PYDM_ARCHIVER_URL": DUMMY_ARCHIVER_URL}): with qtbot.wait_signal(file_handler.curves_signal) as blocker: @@ -158,7 +159,7 @@ def test_open_file_formula_properties_match_trc_file(file_handler, get_test_file The formula entry in curves_signal has the correct formula string, name, color, yAxisName, lineWidth, and curveDict matching the test file. """ - test_file = get_test_file("test_file_formula.trc") + test_file = get_test_file("test_file.trc") with patch.dict(environ, {"PYDM_ARCHIVER_URL": DUMMY_ARCHIVER_URL}): with qtbot.wait_signal(file_handler.curves_signal) as blocker: From 788b06787b1a6d05a98526044ffe1b32af43170d Mon Sep 17 00:00:00 2001 From: zdomke Date: Fri, 13 Mar 2026 09:24:34 -0700 Subject: [PATCH 6/6] MNT: Add scipy to requirements.txt --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index a77faee..4fde2dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ pydm pandas +scipy pytest pytest-qt qtawesome