MIDRC-MELODY 0.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. MIDRC_MELODY/__init__.py +0 -0
  2. MIDRC_MELODY/__main__.py +4 -0
  3. MIDRC_MELODY/common/__init__.py +0 -0
  4. MIDRC_MELODY/common/data_loading.py +199 -0
  5. MIDRC_MELODY/common/data_preprocessing.py +134 -0
  6. MIDRC_MELODY/common/edit_config.py +156 -0
  7. MIDRC_MELODY/common/eod_aaod_metrics.py +292 -0
  8. MIDRC_MELODY/common/generate_eod_aaod_spiders.py +69 -0
  9. MIDRC_MELODY/common/generate_qwk_spiders.py +56 -0
  10. MIDRC_MELODY/common/matplotlib_spider.py +425 -0
  11. MIDRC_MELODY/common/plot_tools.py +132 -0
  12. MIDRC_MELODY/common/plotly_spider.py +217 -0
  13. MIDRC_MELODY/common/qwk_metrics.py +244 -0
  14. MIDRC_MELODY/common/table_tools.py +230 -0
  15. MIDRC_MELODY/gui/__init__.py +0 -0
  16. MIDRC_MELODY/gui/config_editor.py +200 -0
  17. MIDRC_MELODY/gui/data_loading.py +157 -0
  18. MIDRC_MELODY/gui/main_controller.py +154 -0
  19. MIDRC_MELODY/gui/main_window.py +545 -0
  20. MIDRC_MELODY/gui/matplotlib_spider_widget.py +204 -0
  21. MIDRC_MELODY/gui/metrics_model.py +62 -0
  22. MIDRC_MELODY/gui/plotly_spider_widget.py +56 -0
  23. MIDRC_MELODY/gui/qchart_spider_widget.py +272 -0
  24. MIDRC_MELODY/gui/shared/__init__.py +0 -0
  25. MIDRC_MELODY/gui/shared/react/__init__.py +0 -0
  26. MIDRC_MELODY/gui/shared/react/copyabletableview.py +100 -0
  27. MIDRC_MELODY/gui/shared/react/grabbablewidget.py +406 -0
  28. MIDRC_MELODY/gui/tqdm_handler.py +210 -0
  29. MIDRC_MELODY/melody.py +102 -0
  30. MIDRC_MELODY/melody_gui.py +111 -0
  31. MIDRC_MELODY/resources/MIDRC.ico +0 -0
  32. midrc_melody-0.3.3.dist-info/METADATA +151 -0
  33. midrc_melody-0.3.3.dist-info/RECORD +37 -0
  34. midrc_melody-0.3.3.dist-info/WHEEL +5 -0
  35. midrc_melody-0.3.3.dist-info/entry_points.txt +4 -0
  36. midrc_melody-0.3.3.dist-info/licenses/LICENSE +201 -0
  37. midrc_melody-0.3.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,204 @@
1
+ # Copyright (c) 2025 Medical Imaging and Data Resource Center (MIDRC).
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Qt-friendly Matplotlib spider-plot widget
15
+
16
+ This revision exposes the NavigationToolbar so callers can show/hide it at runtime.
17
+ Cropping issues are handled by constrained layout + tight_layout on resize.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from typing import List, Optional
22
+ import warnings
23
+
24
+ import matplotlib as mpl
25
+ from matplotlib.backends.backend_qtagg import (
26
+ FigureCanvasQTAgg as FigureCanvas,
27
+ NavigationToolbar2QT as NavigationToolbar,
28
+ )
29
+ from PySide6.QtWidgets import QTabWidget, QVBoxLayout, QWidget
30
+
31
+ from MIDRC_MELODY.common.matplotlib_spider import plot_spider_chart
32
+ from MIDRC_MELODY.common.plot_tools import SpiderPlotData
33
+ from MIDRC_MELODY.gui.shared.react.grabbablewidget import GrabbableWidgetMixin
34
+
35
+ __all__ = [
36
+ "MatplotlibSpiderWidget",
37
+ "display_spider_charts_in_tabs_matplotlib",
38
+ ]
39
+
40
+
41
+ class MatplotlibSpiderWidget(QWidget):
42
+ """Embed a Matplotlib *spider chart* (a.k.a. radar chart) in a Qt widget.
43
+
44
+ Parameters
45
+ ----------
46
+ spider_data
47
+ Data structure consumed by :func:`plot_spider_chart`.
48
+ show_toolbar
49
+ If *True*, display the Matplotlib *navigation toolbar*; if *False*, hide it.
50
+ You can also toggle visibility at runtime via `set_toolbar_visible()`.
51
+ pad
52
+ Extra padding (in figure‐fraction units) passed to ``tight_layout`` so
53
+ that very large tick labels have room.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ spider_data: SpiderPlotData,
59
+ parent: Optional[QWidget] = None,
60
+ *,
61
+ show_toolbar: bool = False,
62
+ pad: float = 0.4,
63
+ ) -> None:
64
+ super().__init__(parent)
65
+ self._grabbable_mixin = GrabbableWidgetMixin(self, "MIDRC-MELODY Spider Chart ")
66
+ _set_spider_chart_copyable_data(self, spider_data)
67
+
68
+ self._pad = pad
69
+
70
+ # ───────────────────────────────────────────────── build the Figure
71
+ with mpl.rc_context({"figure.constrained_layout.use": True}):
72
+ # Suppress only the “The figure layout has changed to tight” warning
73
+ with warnings.catch_warnings():
74
+ warnings.filterwarnings(
75
+ "ignore",
76
+ "The figure layout has changed to tight",
77
+ category=UserWarning,
78
+ )
79
+ fig = plot_spider_chart(spider_data)
80
+ fig.set_constrained_layout(True)
81
+ with warnings.catch_warnings():
82
+ warnings.filterwarnings(
83
+ "ignore",
84
+ "The figure layout has changed to tight",
85
+ category=UserWarning,
86
+ )
87
+ fig.tight_layout(pad=self._pad)
88
+
89
+ # ────────────────────────────────────────── wrap figure in a FigureCanvas
90
+ self._canvas = FigureCanvas(fig)
91
+
92
+ # ─────────────────────────────────── create the NavigationToolbar
93
+ self._toolbar = NavigationToolbar(self._canvas, self)
94
+ self._toolbar.setVisible(show_toolbar)
95
+
96
+ # ────────────────────────────────────── lay out canvas and (optional) toolbar
97
+ layout = QVBoxLayout(self)
98
+ layout.addWidget(self._toolbar)
99
+ layout.addWidget(self._canvas)
100
+
101
+ # ────────────────────────────────────── auto-adjust on resize events
102
+ self._canvas.mpl_connect("resize_event", self._on_resize)
103
+
104
+ # ---------------------------------------------------------------- API
105
+ def figure(self):
106
+ """Return the underlying :class:`matplotlib.figure.Figure`."""
107
+ return self._canvas.figure
108
+
109
+ def canvas(self) -> FigureCanvas:
110
+ return self._canvas
111
+
112
+ def set_toolbar_visible(self, visible: bool) -> None:
113
+ """Show or hide the Matplotlib navigation toolbar."""
114
+ self._toolbar.setVisible(visible)
115
+
116
+ # -------------------------------------------------------------- events
117
+ def resizeEvent(self, event): # Qt event → keep layout fresh
118
+ super().resizeEvent(event)
119
+ self._on_resize()
120
+
121
+ # --------------------------------------------------------------- intern
122
+ def _on_resize(self, *_):
123
+ """Re-run layout manager so annotations never get clipped."""
124
+ if not self._canvas or self._canvas.isHidden():
125
+ return
126
+ fig = self._canvas.figure
127
+ if getattr(fig, "canvas", None) is None:
128
+ return
129
+ with warnings.catch_warnings():
130
+ warnings.filterwarnings(
131
+ "ignore",
132
+ "(?s)(?=.*[Tt]ight)(?=.*[Ll]ayout).*", # Ignore tight layout warnings
133
+ category=UserWarning,
134
+ )
135
+ fig.tight_layout(pad=self._pad)
136
+ self._canvas.draw_idle()
137
+
138
+ @property
139
+ def copyable_data(self) -> str:
140
+ """
141
+ Get the copyable data for the chart view.
142
+
143
+ Returns:
144
+ str: The data to be copied to the clipboard when requested.
145
+ """
146
+ return self._grabbable_mixin.copyable_data
147
+
148
+ @copyable_data.setter
149
+ def copyable_data(self, data: str):
150
+ """
151
+ Set the copyable data for the chart view.
152
+
153
+ Args:
154
+ data (str): The data to be copied to the clipboard when requested.
155
+
156
+ Returns:
157
+ None
158
+ """
159
+ self._grabbable_mixin.copyable_data = data
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # Bulk helper: one tab per model (mirrors Plotly counterpart)
164
+ # ---------------------------------------------------------------------------
165
+
166
+ def display_spider_charts_in_tabs_matplotlib(
167
+ spider_data_list: List[SpiderPlotData], *, show_toolbar: bool = False, pad: float = 0.4
168
+ ) -> QTabWidget:
169
+ """Assemble a ``QTabWidget`` containing one spider chart per entry."""
170
+
171
+ tab_widget = QTabWidget()
172
+ tab_widget.setDocumentMode(True)
173
+ tab_widget.setTabPosition(QTabWidget.North)
174
+
175
+ for spider_data in spider_data_list:
176
+ container = QWidget()
177
+ layout = QVBoxLayout(container)
178
+
179
+ mpl_widget = MatplotlibSpiderWidget(
180
+ spider_data,
181
+ parent=container,
182
+ show_toolbar=show_toolbar,
183
+ pad=pad,
184
+ )
185
+ layout.addWidget(mpl_widget)
186
+
187
+ tab_widget.addTab(container, spider_data.model_name)
188
+ return tab_widget
189
+
190
+
191
+ def _set_spider_chart_copyable_data(widget: GrabbableWidgetMixin|QWidget, spider_data: SpiderPlotData) -> None:
192
+ """
193
+ Set the copyable data for the spider chart.
194
+
195
+ :arg widget: A GrabbalbeWidgetMixin or a QWidget that forwards the copyable_data property to a GrabbableWidgetMixin.
196
+ :arg spider_data: SpiderPlotData containing the data to be displayed.
197
+ """
198
+ if widget and spider_data:
199
+ headers = ['Model', 'Metric', 'Category', 'Group', 'Value']
200
+ formatted_text = "\t".join(headers) + "\n"
201
+ for group, value in zip(spider_data.groups, spider_data.values):
202
+ c, g = group.split(': ', 1) if ': ' in group else (group, group)
203
+ formatted_text += f"{spider_data.model_name}\t{spider_data.metric}\t{c}\t{g}\t{value}\n"
204
+ widget.copyable_data = formatted_text
@@ -0,0 +1,62 @@
1
+ # Copyright (c) 2025 Medical Imaging and Data Resource Center (MIDRC).
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ from MIDRC_MELODY.common.qwk_metrics import (
17
+ calculate_delta_kappa,
18
+ calculate_kappas_and_intervals,
19
+ )
20
+ from MIDRC_MELODY.common.eod_aaod_metrics import (
21
+ binarize_scores,
22
+ calculate_eod_aaod,
23
+ )
24
+
25
+ from PySide6.QtGui import QColor
26
+ from MIDRC_MELODY.common.table_tools import GLOBAL_COLORS, build_eod_aaod_tables_gui
27
+ from dataclasses import replace
28
+
29
+ def compute_qwk_metrics(test_data, plot_config=None):
30
+ # Compute QWK metrics and prepare table rows and plot args.
31
+ delta_kappas = calculate_delta_kappa(test_data)
32
+ all_rows = []
33
+ filtered_rows = []
34
+ maroon = QColor(*GLOBAL_COLORS['kappa_negative'])
35
+ green = QColor(*GLOBAL_COLORS['kappa_positive'])
36
+ for category, model_data in delta_kappas.items():
37
+ for model, groups in model_data.items():
38
+ for group, (delta, (lower_ci, upper_ci)) in groups.items():
39
+ qualifies = (lower_ci > 0 or upper_ci < 0)
40
+ color = green if qualifies and delta >= 0 else (maroon if qualifies and delta < 0 else None)
41
+ row = [model, category, group, f"{delta:.4f}", f"{lower_ci:.4f}", f"{upper_ci:.4f}"]
42
+ all_rows.append((row, color))
43
+ if qualifies:
44
+ filtered_rows.append((row, color))
45
+ kappas, intervals = calculate_kappas_and_intervals(test_data)
46
+ kappas_rows = []
47
+ for model in sorted(kappas.keys()):
48
+ row = [model, f"{kappas[model]:.4f}", f"{intervals[model][0]:.4f}", f"{intervals[model][1]:.4f}"]
49
+ kappas_rows.append((row, None))
50
+ p_c = plot_config if plot_config else {}
51
+ plot_args = (delta_kappas, test_data.test_cols, p_c)
52
+ return all_rows, filtered_rows, kappas_rows, plot_args
53
+
54
+ def compute_eod_aaod_metrics(test_data, threshold, plot_config: dict=None):
55
+ # Binzarize the scores and compute EOD/AAOD metrics, then build tables and plot args.
56
+ binarized = binarize_scores(test_data.matched_df, test_data.truth_col, test_data.test_cols, threshold=threshold)
57
+ new_data = replace(test_data, matched_df=binarized)
58
+ eod_aaod = calculate_eod_aaod(new_data)
59
+ all_eod_rows, all_aaod_rows, filtered_rows = build_eod_aaod_tables_gui(eod_aaod)
60
+ p_c = plot_config if plot_config else {}
61
+ plot_args = (eod_aaod, new_data.test_cols, p_c)
62
+ return all_eod_rows, all_aaod_rows, filtered_rows, plot_args
@@ -0,0 +1,56 @@
1
+ # Copyright (c) 2025 Medical Imaging and Data Resource Center (MIDRC).
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ from typing import List
17
+
18
+ from PySide6.QtWidgets import QWidget, QVBoxLayout, QTabWidget
19
+ from PySide6.QtWebEngineWidgets import QWebEngineView
20
+ from MIDRC_MELODY.common.plot_tools import SpiderPlotData
21
+ from MIDRC_MELODY.common.plotly_spider import spider_to_html
22
+
23
+
24
+ class PlotlySpiderWidget(QWidget):
25
+ def __init__(self, spider_data: SpiderPlotData, parent=None):
26
+ super().__init__(parent)
27
+
28
+ # 1) Generate the HTML <div> string from Plotly
29
+ html_div: str = spider_to_html(spider_data)
30
+
31
+ # 2) Create a QWebEngineView and load that HTML
32
+ self._view = QWebEngineView(self)
33
+ # Note: setHtml defaults to UTF-8. If you need local resources, pass baseUrl.
34
+ self._view.setHtml(html_div)
35
+
36
+ # 3) Put the QWebEngineView inside a vertical layout
37
+ layout = QVBoxLayout(self)
38
+ layout.addWidget(self._view)
39
+ self.setLayout(layout)
40
+
41
+
42
+ def display_spider_charts_in_tabs_plotly(spider_data_list: List[SpiderPlotData]) -> QTabWidget:
43
+ """
44
+ Replaces the old QtCharts polar approach with a Plotly-based QWebEngineView.
45
+ """
46
+ tab_widget = QTabWidget()
47
+ for spider_data in spider_data_list:
48
+ container = QWidget()
49
+ layout = QVBoxLayout(container)
50
+
51
+ # Use Plotly to draw the spider chart and embed as HTML
52
+ plotly_widget = PlotlySpiderWidget(spider_data)
53
+ layout.addWidget(plotly_widget)
54
+
55
+ tab_widget.addTab(container, spider_data.model_name)
56
+ return tab_widget
@@ -0,0 +1,272 @@
1
+ # Copyright (c) 2025 Medical Imaging and Data Resource Center (MIDRC).
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ from typing import List
17
+ from PySide6.QtWidgets import QWidget, QTabWidget, QVBoxLayout
18
+ from PySide6.QtCharts import QPolarChart, QLineSeries, QValueAxis, QCategoryAxis, QPieSeries
19
+ from PySide6.QtCore import Qt
20
+ from PySide6.QtGui import QPainter, QPen, QBrush, QColor
21
+ from MIDRC_MELODY.common.plot_tools import SpiderPlotData # reuse data class from common module
22
+ from MIDRC_MELODY.gui.shared.react.grabbablewidget import GrabbableChartView
23
+
24
+
25
+ def _fill_bounds(
26
+ chart: QPolarChart,
27
+ angles: List[float],
28
+ lower_bounds: List[float],
29
+ upper_bounds: List[float],
30
+ cat_axis: QCategoryAxis,
31
+ radial_axis: QValueAxis,
32
+ ) -> None:
33
+ """
34
+ Draw upper and lower bound lines on the spider chart (no filled area).
35
+ """
36
+ # Ensure lists align; if not, skip
37
+ if not angles or len(lower_bounds) != len(angles) or len(upper_bounds) != len(angles):
38
+ return
39
+
40
+ # Draw lower bound line
41
+ lower_series = QLineSeries()
42
+ for angle, lo in zip(angles, lower_bounds):
43
+ lower_series.append(angle, lo)
44
+ # Close the loop
45
+ lower_series.append(360, lower_bounds[0])
46
+ pen_lo = QPen(QColor('steelblue'))
47
+ pen_lo.setStyle(Qt.DashLine)
48
+ pen_lo.setWidth(2)
49
+ pen_lo.setColor(QColor(70, 130, 180, 128)) # semi-transparent steelblue
50
+ lower_series.setPen(pen_lo)
51
+ chart.addSeries(lower_series)
52
+ lower_series.attachAxis(cat_axis)
53
+ lower_series.attachAxis(radial_axis)
54
+
55
+ # Draw upper bound line
56
+ upper_series = QLineSeries()
57
+ for angle, hi in zip(angles, upper_bounds):
58
+ upper_series.append(angle, hi)
59
+ upper_series.append(360, upper_bounds[0])
60
+ pen_hi = QPen(QColor('steelblue'))
61
+ pen_hi.setStyle(Qt.DashLine)
62
+ pen_hi.setWidth(2)
63
+ pen_hi.setColor(QColor(70, 130, 180, 128))
64
+ upper_series.setPen(pen_hi)
65
+ chart.addSeries(upper_series)
66
+ upper_series.attachAxis(cat_axis)
67
+ upper_series.attachAxis(radial_axis)
68
+
69
+
70
+ def _apply_metric_overlay(
71
+ chart: QPolarChart,
72
+ angles: List[float],
73
+ values: List[float],
74
+ lower_bounds: List[float],
75
+ upper_bounds: List[float],
76
+ cat_axis: QCategoryAxis,
77
+ radial_axis: QValueAxis,
78
+ spider_data: SpiderPlotData,
79
+ ) -> None:
80
+ """
81
+ Apply metric-specific overlays (baselines, shaded regions, threshold markers) to the given QPolarChart.
82
+ """
83
+ metric = spider_data.metric.upper()
84
+ # Precompute full-circle angles for continuous lines/fills
85
+ full_angles_deg = [360 * i / 99 for i in range(100)]
86
+
87
+ # Determine small angle delta for threshold line markers
88
+ step_size = angles[1] - angles[0] if len(angles) > 1 else 360
89
+ delta = step_size * 0.2
90
+
91
+ # Configuration for each metric
92
+ overlay_config = {
93
+ 'QWK': {
94
+ 'baseline': {'type': 'line', 'y': 0, 'style': Qt.DashLine, 'color': 'seagreen', 'linewidth': 3, 'alpha': 0.8},
95
+ 'thresholds': [
96
+ (lower_bounds, lambda v: v > 0, 'maroon'),
97
+ (upper_bounds, lambda v: v < 0, 'red'),
98
+ ],
99
+ },
100
+ 'EOD': {
101
+ 'fill': {'lo': -0.1, 'hi': 0.1, 'color': 'lightgreen', 'alpha': 0.5},
102
+ 'thresholds': [
103
+ (values, lambda v: v > 0.1, 'maroon'),
104
+ (values, lambda v: v < -0.1, 'red'),
105
+ ],
106
+ },
107
+ 'AAOD': {
108
+ 'fill': {'lo': 0, 'hi': 0.1, 'color': 'lightgreen', 'alpha': 0.5},
109
+ 'baseline': {'type': 'ylim', 'lo': 0},
110
+ 'thresholds': [
111
+ (values, lambda v: v > 0.1, 'maroon'),
112
+ ],
113
+ },
114
+ }
115
+ cfg = overlay_config.get(metric)
116
+ if not cfg:
117
+ return
118
+
119
+ # --- Baseline rendering ---
120
+ if 'baseline' in cfg:
121
+ base = cfg['baseline']
122
+ if base['type'] == 'line':
123
+ baseline_series = QLineSeries()
124
+ for angle_deg in full_angles_deg:
125
+ baseline_series.append(angle_deg, base['y'])
126
+ pen = QPen(QColor(base['color']))
127
+ pen.setWidth(base['linewidth'])
128
+ pen.setStyle(base['style'])
129
+ color = QColor(base['color'])
130
+ color.setAlphaF(base['alpha'])
131
+ pen.setColor(color)
132
+ baseline_series.setPen(pen)
133
+ chart.addSeries(baseline_series)
134
+ baseline_series.attachAxis(cat_axis)
135
+ baseline_series.attachAxis(radial_axis)
136
+ elif base['type'] == 'ylim':
137
+ # Adjust radial axis minimum
138
+ y_max = spider_data.ylim_max[spider_data.metric]
139
+ radial_axis.setRange(base['lo'], y_max)
140
+
141
+ # --- Fill region if specified ---
142
+ if 'fill' in cfg:
143
+ f = cfg['fill']
144
+ pie_series = QPieSeries()
145
+ slice = pie_series.append("", 360)
146
+ color = QColor(f['color'])
147
+ color.setAlphaF(f['alpha'])
148
+ slice.setBrush(QBrush(color))
149
+ slice.setPen(QPen(Qt.NoPen))
150
+ chart.addSeries(pie_series)
151
+ # Note: QPieSeries does not support attaching axes like QAreaSeries.
152
+
153
+ # --- Threshold markers as short perpendicular lines ---
154
+ line_series_list = []
155
+ for data_list, cond, color_name in cfg.get('thresholds', []):
156
+ pen = QPen(QColor(color_name))
157
+ pen.setWidth(2)
158
+ for i, v in enumerate(data_list):
159
+ if cond(v):
160
+ angle_deg = angles[i]
161
+ radius = v
162
+ d = delta * (spider_data.ylim_max[spider_data.metric] - radius) / (spider_data.ylim_max[spider_data.metric] - spider_data.ylim_min[spider_data.metric])
163
+ # Create a small line segment around the threshold point
164
+ line_series = QLineSeries()
165
+ line_series.setPen(pen)
166
+ if (angle_deg - d) >= 0 and (angle_deg + d) < 360: # no wrap around
167
+ line_series.append(angle_deg - d, radius)
168
+ line_series.append(angle_deg + d, radius)
169
+ else:
170
+ line_series.append((angle_deg - d) % 360, radius)
171
+ line_series.append(360, radius)
172
+ # create a line_series_2 for to cover the wrap around
173
+ line_series_2 = QLineSeries()
174
+ line_series_2.setPen(pen)
175
+ line_series_2.append(0, radius)
176
+ line_series_2.append((angle_deg + d) % 360, radius)
177
+ line_series_list.append(line_series_2)
178
+ line_series_list.append(line_series)
179
+ for line_series in line_series_list:
180
+ chart.addSeries(line_series)
181
+ line_series.attachAxis(cat_axis)
182
+ line_series.attachAxis(radial_axis)
183
+
184
+
185
+ def create_spider_chart(spider_data: SpiderPlotData) -> QPolarChart:
186
+ """
187
+ Create a QPolarChart based on the SpiderPlotData, including metric-specific overlays.
188
+ """
189
+ chart = QPolarChart()
190
+ chart.setTitle(f"{spider_data.model_name} - {spider_data.metric}")
191
+ series = QLineSeries()
192
+
193
+ labels = spider_data.groups
194
+ values = spider_data.values
195
+ lower_bounds = spider_data.lower_bounds
196
+ upper_bounds = spider_data.upper_bounds
197
+
198
+ # Compute angles in degrees for QtCharts
199
+ step_size: float = 360 / len(labels)
200
+ angles: List[float] = [step_size * i for i in range(len(labels))]
201
+
202
+ # Add points for each group and close the loop
203
+ for angle, value in zip(angles, values):
204
+ series.append(angle, value)
205
+ # Close the loop by repeating the first point at 360°
206
+ if series.points():
207
+ series.append(angles[0] + 360, series.points()[0].y())
208
+
209
+ chart.addSeries(series)
210
+
211
+ # Create and configure the angular axis (categories for group labels)
212
+ cat_axis = QCategoryAxis()
213
+ cat_axis.setRange(0, 360)
214
+ cat_axis.setLabelsPosition(QCategoryAxis.AxisLabelsPositionOnValue)
215
+ for label, angle in zip(labels, angles):
216
+ cat_axis.append(label, angle)
217
+ chart.addAxis(cat_axis, QPolarChart.PolarOrientationAngular)
218
+ series.attachAxis(cat_axis)
219
+
220
+ # Create and configure the radial axis
221
+ radial_axis = QValueAxis()
222
+ radial_axis.setRange(spider_data.ylim_min[spider_data.metric],
223
+ spider_data.ylim_max[spider_data.metric])
224
+ radial_axis.setLabelFormat("%.2f")
225
+ chart.addAxis(radial_axis, QPolarChart.PolarOrientationRadial)
226
+ series.attachAxis(radial_axis)
227
+
228
+ # Fill the area between lower and upper bounds
229
+ _fill_bounds(
230
+ chart,
231
+ angles,
232
+ lower_bounds,
233
+ upper_bounds,
234
+ cat_axis,
235
+ radial_axis,
236
+ )
237
+
238
+ # Apply metric-specific overlay via helper function
239
+ _apply_metric_overlay(
240
+ chart,
241
+ angles,
242
+ values,
243
+ lower_bounds,
244
+ upper_bounds,
245
+ cat_axis,
246
+ radial_axis,
247
+ spider_data,
248
+ )
249
+
250
+ chart.legend().hide()
251
+ return chart
252
+
253
+
254
+ def display_spider_charts_in_tabs(spider_data_list: List[SpiderPlotData]) -> QTabWidget:
255
+ """
256
+ Given a list of SpiderPlotData objects, create a QTabWidget where each tab displays
257
+ the corresponding spider chart in a QChartView.
258
+ """
259
+ tab_widget = QTabWidget()
260
+ for spider_data in spider_data_list:
261
+ widget = QWidget()
262
+ layout = QVBoxLayout(widget)
263
+ chart = create_spider_chart(spider_data)
264
+ chart_view: GrabbableChartView = GrabbableChartView(
265
+ chart,
266
+ save_file_prefix=f"MIDRC-MELODY_{spider_data.metric}_{spider_data.model_name}_spider_chart",
267
+ )
268
+ set_spider_chart_copyable_data(chart_view, spider_data)
269
+ chart_view.setRenderHint(QPainter.Antialiasing) # ensure smooth rendering
270
+ layout.addWidget(chart_view)
271
+ tab_widget.addTab(widget, spider_data.model_name)
272
+ return tab_widget
File without changes
File without changes