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,200 @@
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 PySide6.QtWidgets import (QCheckBox, QDialog, QDoubleSpinBox, QFormLayout, QHBoxLayout, QLabel, QLineEdit,
17
+ QMessageBox, QPushButton, QSpinBox, QTabWidget, QVBoxLayout, QWidget)
18
+ import yaml
19
+
20
+
21
+ class ConfigEditor(QDialog):
22
+ def __init__(self, config: dict, parent=None):
23
+ super().__init__(parent)
24
+ self.config = config # store reference to config dict
25
+ self.setWindowTitle("Edit Config")
26
+ self.resize(600, 400)
27
+ main_layout = QVBoxLayout(self)
28
+ self.tab_widget = QTabWidget()
29
+ main_layout.addWidget(self.tab_widget)
30
+
31
+ # Initialize class variables
32
+ self.input_edits = {}
33
+ self.calc_edits = {}
34
+ self.output_widgets = {}
35
+ self.numeric_edit = None
36
+ self.custom_orders_edit = None
37
+ self.clockwise_checkbox = None
38
+ self.start_edit = None
39
+ self.bootstrap_iterations = None
40
+ self.bootstrap_seed = None
41
+
42
+ self.setup_input_tab()
43
+ self.setup_calculations_tab()
44
+ self.setup_output_tab()
45
+ self.setup_plots_tab()
46
+
47
+ # Button layout: Cancel, Apply, Save
48
+ btn_layout = QHBoxLayout()
49
+ cancel_btn = QPushButton("Cancel")
50
+ cancel_btn.clicked.connect(self.reject)
51
+ apply_btn = QPushButton("Apply")
52
+ apply_btn.clicked.connect(self.apply_changes)
53
+ save_btn = QPushButton("Save")
54
+ save_btn.clicked.connect(self.on_save)
55
+ btn_layout.addWidget(cancel_btn)
56
+ btn_layout.addWidget(apply_btn)
57
+ btn_layout.addWidget(save_btn)
58
+ main_layout.addLayout(btn_layout)
59
+
60
+ def setup_input_tab(self):
61
+ self.input_edits = {}
62
+ input_tab = QWidget()
63
+ input_layout = QFormLayout(input_tab)
64
+ for key, value in self.config.get("input data", {}).items():
65
+ hbox = QHBoxLayout()
66
+ le = QLineEdit(str(value))
67
+ self.input_edits[key] = le
68
+ hbox.addWidget(le)
69
+ if key in ['truth file', 'test scores']:
70
+ browse_btn = QPushButton("Browse")
71
+ browse_btn.clicked.connect(lambda _, le=le: self.browse_file(le))
72
+ hbox.addWidget(browse_btn)
73
+ input_layout.addRow(QLabel(key), hbox)
74
+ numeric = self.config.get("numeric_cols", {})
75
+ numeric_str = "\n".join(f"{k}: {v}" for k, v in numeric.items())
76
+ self.numeric_edit = QLineEdit(numeric_str)
77
+ input_layout.addRow(QLabel("numeric_cols"), self.numeric_edit)
78
+ self.tab_widget.addTab(input_tab, "Input")
79
+
80
+ def browse_file(self, line_edit: QLineEdit):
81
+ from PySide6.QtWidgets import QFileDialog
82
+ file_path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "All Files (*)")
83
+ if file_path:
84
+ line_edit.setText(file_path)
85
+
86
+ def setup_calculations_tab(self):
87
+ self.calc_edits = {}
88
+ calc_tab = QWidget()
89
+ calc_layout = QFormLayout(calc_tab)
90
+ # Use QDoubleSpinBox for binary threshold
91
+ if "binary threshold" in self.config:
92
+ threshold_value = self.config["binary threshold"]
93
+ threshold_spin = QDoubleSpinBox()
94
+ threshold_spin.setDecimals(2)
95
+ threshold_spin.setRange(0, 1000) # adjust range as needed
96
+ threshold_spin.setValue(float(threshold_value))
97
+ self.calc_edits["binary threshold"] = threshold_spin
98
+ calc_layout.addRow(QLabel("binary threshold"), threshold_spin)
99
+ # Use QSpinBox for min count per category
100
+ if "min count per category" in self.config:
101
+ min_count = self.config["min count per category"]
102
+ min_count_spin = QSpinBox()
103
+ min_count_spin.setMinimum(0)
104
+ min_count_spin.setMaximum(10000) # adjust range as needed
105
+ min_count_spin.setValue(int(min_count))
106
+ self.calc_edits["min count per category"] = min_count_spin
107
+ calc_layout.addRow(QLabel("min count per category"), min_count_spin)
108
+ # Bootstrap settings using QSpinBox
109
+ bootstrap = self.config.get("bootstrap", {})
110
+ self.bootstrap_iterations = QSpinBox()
111
+ self.bootstrap_iterations.setMinimum(0)
112
+ self.bootstrap_iterations.setMaximum(1000000)
113
+ self.bootstrap_iterations.setValue(int(bootstrap.get("iterations", 0)))
114
+ calc_layout.addRow(QLabel("Bootstrap Iterations"), self.bootstrap_iterations)
115
+ self.bootstrap_seed = QSpinBox()
116
+ self.bootstrap_seed.setMinimum(0)
117
+ self.bootstrap_seed.setMaximum(1000000)
118
+ self.bootstrap_seed.setValue(int(bootstrap.get("seed", 0)))
119
+ calc_layout.addRow(QLabel("Bootstrap Seed"), self.bootstrap_seed)
120
+ self.tab_widget.addTab(calc_tab, "Calculations")
121
+
122
+ def setup_output_tab(self):
123
+ self.output_widgets = {}
124
+ output_tab = QWidget()
125
+ output_layout = QFormLayout(output_tab)
126
+ output = self.config.get("output", {})
127
+ for subcat, settings in output.items():
128
+ row_widget = QWidget()
129
+ row_layout = QHBoxLayout(row_widget)
130
+ row_layout.setContentsMargins(0, 0, 0, 0)
131
+ cb = QCheckBox("Save")
132
+ cb.setChecked(settings.get("save", False))
133
+ le = QLineEdit(str(settings.get("file prefix", "")))
134
+ le.setEnabled(cb.isChecked())
135
+ cb.toggled.connect(lambda checked, le=le: le.setEnabled(checked))
136
+ row_layout.addWidget(cb)
137
+ row_layout.addWidget(QLabel("File Prefix:"))
138
+ row_layout.addWidget(le)
139
+ output_layout.addRow(QLabel(subcat.capitalize()), row_widget)
140
+ self.output_widgets[subcat] = (cb, le)
141
+ self.tab_widget.addTab(output_tab, "Pickle (.pkl) Output")
142
+
143
+ def setup_plots_tab(self):
144
+ plots_tab = QWidget()
145
+ plots_layout = QFormLayout(plots_tab)
146
+ plot = self.config.get("plot", {})
147
+ custom_orders = plot.get("custom_orders", {})
148
+ custom_orders_str = "\n".join(f"{k}: {v}" for k, v in custom_orders.items())
149
+ self.custom_orders_edit = QLineEdit(custom_orders_str)
150
+ plots_layout.addRow(QLabel("Custom Orders"), self.custom_orders_edit)
151
+ self.clockwise_checkbox = QCheckBox()
152
+ self.clockwise_checkbox.setChecked(plot.get("clockwise", False))
153
+ plots_layout.addRow(QLabel("Clockwise"), self.clockwise_checkbox)
154
+ self.start_edit = QLineEdit(str(plot.get("start", "")))
155
+ plots_layout.addRow(QLabel("Start"), self.start_edit)
156
+ self.tab_widget.addTab(plots_tab, "Plots")
157
+
158
+ def apply_changes(self):
159
+ # Input Tab
160
+ for key, widget in self.input_edits.items():
161
+ self.config.setdefault("input data", {})[key] = widget.text()
162
+ try:
163
+ new_numeric = yaml.load(self.numeric_edit.text(), Loader=yaml.SafeLoader)
164
+ if not isinstance(new_numeric, dict):
165
+ raise ValueError("numeric_cols must be a dictionary")
166
+ except Exception as e:
167
+ QMessageBox.warning(self, "Warning", f"Invalid format for numeric_cols: {e}")
168
+ new_numeric = self.config.get("numeric_cols", {})
169
+ self.config["numeric_cols"] = new_numeric
170
+
171
+ # Calculations Tab: use spinbox values
172
+ for key, widget in self.calc_edits.items():
173
+ self.config[key] = widget.value() # Use value() from spinboxes
174
+ self.config["bootstrap"] = {
175
+ "iterations": self.bootstrap_iterations.value(),
176
+ "seed": self.bootstrap_seed.value()
177
+ }
178
+ # Output Tab
179
+ for subcat, (cb, le) in self.output_widgets.items():
180
+ self.config.setdefault("output", {})[subcat] = {
181
+ "save": cb.isChecked(),
182
+ "file prefix": le.text()
183
+ }
184
+ # Plots Tab: Parse custom_orders into a dictionary
185
+ try:
186
+ new_custom_orders = yaml.load(self.custom_orders_edit.text(), Loader=yaml.SafeLoader)
187
+ if not isinstance(new_custom_orders, dict):
188
+ raise ValueError("custom_orders must be a dictionary")
189
+ except Exception as e:
190
+ QMessageBox.warning(self, "Warning", f"Invalid format for custom_orders: {e}")
191
+ new_custom_orders = self.config.get("plot", {}).get("custom_orders", {})
192
+ self.config.setdefault("plot", {})["custom_orders"] = new_custom_orders
193
+
194
+ self.config["plot"]["clockwise"] = self.clockwise_checkbox.isChecked()
195
+ self.config["plot"]["start"] = self.start_edit.text()
196
+ QMessageBox.information(self, "Applied", "Configuration updated.")
197
+
198
+ def on_save(self):
199
+ self.apply_changes()
200
+ self.accept()
@@ -0,0 +1,157 @@
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
+ import json
17
+ import yaml
18
+ import os
19
+
20
+ from PySide6.QtCore import QSettings
21
+ from PySide6.QtWidgets import (
22
+ QFileDialog,
23
+ QMessageBox,
24
+ QDialog,
25
+ )
26
+ from MIDRC_MELODY.gui.config_editor import ConfigEditor
27
+
28
+
29
+ def load_config_dict() -> dict:
30
+ """
31
+ Load config settings from QSettings or fallback to config.yaml in the repo root.
32
+ Post-process numeric columns so that any ".inf"/"inf" strings become float("inf").
33
+ """
34
+ settings = QSettings("MIDRC", "MIDRC-MELODY")
35
+ config_str = settings.value("config", "")
36
+ if config_str:
37
+ config = json.loads(config_str)
38
+ else:
39
+ # Default path: three levels up from this file
40
+ config_path = os.path.join(os.path.dirname(__file__), ".", ".", ".", "config.yaml")
41
+ with open(config_path, "r", encoding="utf-8") as f:
42
+ config = yaml.load(f, Loader=yaml.CLoader)
43
+ settings.setValue("config", json.dumps(config))
44
+
45
+ # Convert any ".inf"/"inf" bins into actual float("inf")
46
+ if "numeric_cols" in config:
47
+ for col, d in config["numeric_cols"].items():
48
+ if "bins" in d:
49
+ processed_bins = []
50
+ for b in d["bins"]:
51
+ if isinstance(b, (int, float)):
52
+ processed_bins.append(b)
53
+ else:
54
+ try:
55
+ if b.strip() in [".inf", "inf"]:
56
+ processed_bins.append(float("inf"))
57
+ else:
58
+ processed_bins.append(float(b))
59
+ except Exception:
60
+ processed_bins.append(b)
61
+ config["numeric_cols"][col]["bins"] = processed_bins
62
+
63
+ return config
64
+
65
+
66
+ @staticmethod
67
+ def save_config_dict(config: dict) -> None:
68
+ settings = QSettings("MIDRC", "MIDRC-MELODY")
69
+ settings.setValue("config", json.dumps(config))
70
+
71
+
72
+ def load_config_file(parent):
73
+ """
74
+ Attempt to load configuration from QSettings. If none exists, prompt the
75
+ user to pick a YAML/JSON file. Any errors are shown via QMessageBox.
76
+ """
77
+ try:
78
+ # This will raise if no config is stored in QSettings
79
+ _ = load_config_dict()
80
+ QMessageBox.information(parent, "Config Loaded", "Configuration loaded from QSettings.")
81
+ except Exception:
82
+ # Prompt user to select a file on disk
83
+ file_path, _ = QFileDialog.getOpenFileName(
84
+ parent,
85
+ "Select Config File",
86
+ os.path.expanduser("~"),
87
+ "Config Files (*.yaml *.json);;All Files (*)"
88
+ )
89
+ if file_path:
90
+ try:
91
+ # Read JSON or YAML and then save into QSettings via save_config_dict
92
+ if file_path.endswith(".json"):
93
+ with open(file_path, "r", encoding="utf-8") as f:
94
+ config = json.load(f)
95
+ else:
96
+ with open(file_path, "r", encoding="utf-8") as f:
97
+ config = yaml.load(f, Loader=yaml.CLoader)
98
+
99
+ save_config_dict(config) # persist into QSettings
100
+ QMessageBox.information(parent, "Config Loaded", "Configuration loaded from file.")
101
+ except Exception as e2:
102
+ QMessageBox.critical(parent, "Error", f"Failed to load selected config file:\n{e2}")
103
+ else:
104
+ QMessageBox.critical(parent, "Error", "No config file selected.")
105
+
106
+
107
+ def edit_config_file(parent):
108
+ """
109
+ Open the ConfigEditor on the current config (pulled via load_config_dict).
110
+ If loading fails, ask whether to pick an existing config file or start blank.
111
+ Upon acceptance, persist via save_config_dict.
112
+ """
113
+ try:
114
+ config = load_config_dict()
115
+ editor = ConfigEditor(config, parent=parent)
116
+ if editor.exec() == QDialog.Accepted:
117
+ save_config_dict(config)
118
+ except Exception as e:
119
+ # If load_config_dict() failed, ask user if they want to pick an existing file
120
+ resp = QMessageBox.question(
121
+ parent,
122
+ "Edit Config",
123
+ f"Failed to load config: {e}\n\nWould you like to select an existing config file?\n"
124
+ "Press Yes to select a file; or No to create a blank config.",
125
+ QMessageBox.Yes | QMessageBox.No,
126
+ )
127
+ if resp == QMessageBox.Yes:
128
+ # User chose to pick a file
129
+ file_path, _ = QFileDialog.getOpenFileName(
130
+ parent,
131
+ "Select Config File",
132
+ os.path.expanduser("~"),
133
+ "Config Files (*.yaml *.json);;All Files (*)"
134
+ )
135
+ if file_path:
136
+ try:
137
+ if file_path.endswith(".json"):
138
+ with open(file_path, "r", encoding="utf-8") as f:
139
+ config = json.load(f)
140
+ else:
141
+ with open(file_path, "r", encoding="utf-8") as f:
142
+ config = yaml.load(f, Loader=yaml.CLoader)
143
+ save_config_dict(config)
144
+ except Exception as e3:
145
+ QMessageBox.critical(parent, "Error", f"Failed to load selected config file: {e3}")
146
+ return
147
+ else:
148
+ QMessageBox.critical(parent, "Error", "No config file selected.")
149
+ return
150
+ else:
151
+ # User chose “No” → start with a blank config
152
+ config = {}
153
+
154
+ # Finally, open ConfigEditor on whichever config dict we have
155
+ editor = ConfigEditor(config, parent=parent)
156
+ if editor.exec() == QDialog.Accepted:
157
+ save_config_dict(config)
@@ -0,0 +1,154 @@
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
+ import sys
17
+ import time
18
+ from contextlib import ExitStack, redirect_stdout, redirect_stderr
19
+
20
+ from PySide6.QtWidgets import QMessageBox
21
+ from PySide6.QtGui import QTextCursor
22
+
23
+ from MIDRC_MELODY.common.data_loading import TestAndDemographicData, build_test_and_demographic_data
24
+ from MIDRC_MELODY.gui.data_loading import load_config_dict
25
+ from MIDRC_MELODY.gui.metrics_model import compute_qwk_metrics, compute_eod_aaod_metrics
26
+ from MIDRC_MELODY.gui.tqdm_handler import Worker, EmittingStream
27
+
28
+
29
+ class MainController:
30
+ def __init__(self, main_window):
31
+ self.main_window = main_window
32
+
33
+ def calculate_qwk(self):
34
+ """
35
+ Triggered by the “QWK Metrics” toolbar button. Loads config, shows the progress view,
36
+ and spins up a Worker that captures print() → GUI and calls compute_qwk_metrics.
37
+ """
38
+ try:
39
+ config = load_config_dict()
40
+ except Exception as e:
41
+ QMessageBox.critical(self.main_window, "Error", f"Failed to load config:\n{e}")
42
+ return
43
+
44
+ # Ensure the “Progress Output” tab is visible before the Worker starts
45
+ self.main_window.show_progress_view()
46
+
47
+ # Create and start a Worker that wraps compute_qwk_metrics
48
+ worker = self._make_worker(
49
+ banner="QWK",
50
+ compute_fn=compute_qwk_metrics,
51
+ result_handler=self.main_window.update_qwk_tables,
52
+ config=config,
53
+ )
54
+ self.main_window.threadpool.start(worker)
55
+
56
+ def calculate_eod_aaod(self):
57
+ """
58
+ Triggered by the “EOD/AAOD Metrics” toolbar button. Loads config, shows the progress view,
59
+ and spins up a Worker that captures print() → GUI and calls compute_eod_aaod_metrics.
60
+ """
61
+ try:
62
+ config = load_config_dict()
63
+ except Exception as e:
64
+ QMessageBox.critical(self.main_window, "Error", f"Failed to load config:\n{e}")
65
+ return
66
+
67
+ self.main_window.show_progress_view()
68
+
69
+ worker = self._make_worker(
70
+ banner="EOD/AAOD",
71
+ compute_fn=compute_eod_aaod_metrics,
72
+ result_handler=self.main_window.update_eod_aaod_tables,
73
+ config=config,
74
+ )
75
+ self.main_window.threadpool.start(worker)
76
+
77
+ def _make_worker(self, banner: str, compute_fn, result_handler, config: dict):
78
+ """
79
+ Internal helper to create a Worker that:
80
+ 1) Redirects stdout/stderr → EmittingStream → main_window.append_progress
81
+ 2) Prints “Computing {banner} metrics…”
82
+ 3) Calls the appropriate compute_fn (either compute_qwk_metrics or compute_eod_aaod_metrics)
83
+ 4) Restores stdout/stderr and returns the result
84
+
85
+ Parameters:
86
+ - banner: "QWK" or "EOD/AAOD" (used in the printed banner and error dialogs)
87
+ - compute_fn: either compute_qwk_metrics or compute_eod_aaod_metrics
88
+ - result_handler: a slot on main_window (update_qwk_tables or update_eod_aaod_tables)
89
+ - config: the loaded configuration dict
90
+
91
+ Returns:
92
+ - A Worker instance that, when started, will run _task(config) in a background thread.
93
+ """
94
+ def _task(cfg):
95
+ # 1) Save original stdout/stderr so we can restore later
96
+ original_stdout = sys.stdout
97
+ original_stderr = sys.stderr
98
+
99
+ # 2) Build an EmittingStream and connect its textWritten → append_progress
100
+ stream = EmittingStream()
101
+ stream.textWritten.connect(self.main_window.append_progress)
102
+
103
+ # 3) Redirect stdout/stderr → EmittingStream
104
+ with ExitStack() as es:
105
+ es.enter_context(redirect_stdout(stream))
106
+ es.enter_context(redirect_stderr(stream))
107
+
108
+ # 4) Print the initial banner
109
+ time_start = time.time()
110
+ if not self.main_window.progress_view.document().isEmpty():
111
+ # Move cursor to the end of the progress view
112
+ self.main_window.progress_view.moveCursor(QTextCursor.End)
113
+ print('\n', '-'*140, '\n')
114
+ print(f"Computing {banner} metrics... "
115
+ f"(Started at {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time_start))})")
116
+
117
+ # 5) Build test data and run the compute function
118
+ test_data: TestAndDemographicData = build_test_and_demographic_data(cfg)
119
+ plot_config = cfg.get("plot", None)
120
+ if compute_fn is compute_eod_aaod_metrics:
121
+ # EOD/AAOD needs a threshold from cfg (default to 0.5)
122
+ threshold = cfg.get("binary threshold", 0.5)
123
+ result = compute_fn(test_data, threshold, plot_config)
124
+ else:
125
+ # QWK just takes test_data
126
+ result = compute_fn(test_data, plot_config)
127
+
128
+ print(f"Finished {banner} metrics in {time.time() - time_start:.2f} seconds.")
129
+
130
+ # 6) Restore original stdout/stderr so further print() goes to console
131
+ sys.stdout = original_stdout
132
+ sys.stderr = original_stderr
133
+
134
+ # 7) Return the computed result, e.g.
135
+ # - For QWK: (all_rows, filtered_rows, kappas_rows, plot_args), reference_groups
136
+ # - For EOD/AAOD: (all_eod_rows, all_aaod_rows, filtered_rows, plot_args), reference_groups
137
+ return result, test_data.reference_groups
138
+
139
+ # Instantiate the Worker around our _task function + config dict
140
+ worker = Worker(_task, config)
141
+
142
+ # Connect the result signal to the appropriate table‐update slot
143
+ worker.signals.result.connect(result_handler)
144
+
145
+ # Connect any error to a QMessageBox
146
+ worker.signals.error.connect(
147
+ lambda e: QMessageBox.critical(
148
+ self.main_window,
149
+ "Error",
150
+ f"Error in {banner} Metrics:\n{e}"
151
+ )
152
+ )
153
+
154
+ return worker