• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

LCA-ActivityBrowser / activity-browser / 14497405347

16 Apr 2025 04:10PM UTC coverage: 53.056% (-0.08%) from 53.134%
14497405347

Pull #1433

github

web-flow
Merge ead51b511 into 45e974aed
Pull Request #1433: Add 'cumulative percent' contribution analysis mode

69 of 161 new or added lines in 2 files covered. (42.86%)

9 existing lines in 1 file now uncovered.

8403 of 15838 relevant lines covered (53.06%)

0.53 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

55.56
/activity_browser/ui/widgets/cutoff_menu.py
1
# -*- coding: utf-8 -*-
2
"""Classes related to the cutoff options menu in contributions tabs.
1✔
3

4
These classes contain all menu items required to modify the cutoffs of the MLCA results. The
5
CutoffMenu class is responsible for assembling the menu. Each different menu item is contained in
6
its separate class.
7
"""
8

9
from collections import namedtuple
1✔
10
from typing import Union
1✔
11

12
import numpy as np
1✔
13
from PySide2 import QtCore
1✔
14
from PySide2.QtCore import QLocale, Qt, Signal, Slot
1✔
15
from PySide2.QtGui import QDoubleValidator, QIntValidator
1✔
16
from PySide2.QtWidgets import (QButtonGroup, QHBoxLayout, QLabel, QLineEdit,
1✔
17
                               QPushButton, QRadioButton, QSlider, QVBoxLayout,
18
                               QWidget)
19

20
from ..style import vertical_line
1✔
21

22
# These tuples are used in referring to the two Types and three Labels used
23
Types = namedtuple("types", ("percent", "cum_percent", "number"))
1✔
24
Labels = namedtuple("labels", ("unit", "min", "max"))
1✔
25

26

27
class CutoffMenu(QWidget):
1✔
28
    """This class assembles the cutoff menu from the other classes in this module."""
29

30
    slider_change = Signal()
1✔
31

32
    def __init__(self, parent=None, cutoff_value=0.05, limit_type="percent"):
1✔
33
        super().__init__(parent)
1✔
34
        self.cutoff_value = cutoff_value
1✔
35
        self.limit_type = limit_type
1✔
36

37
        locale = QLocale(QLocale.English, QLocale.UnitedStates)
1✔
38
        locale.setNumberOptions(QLocale.RejectGroupSeparator)
1✔
39
        self.validators = Types(
1✔
40
            QDoubleValidator(0.001, 100.0, 1, self),
41
            QIntValidator(0, 100, self),
42
            QIntValidator(0, 50, self),
43
        )
44
        self.validators.percent.setLocale(locale)
1✔
45
        self.validators.number.setLocale(locale)
1✔
46
        self.buttons = Types(
1✔
47
            QRadioButton("Percent"),
48
            QRadioButton("Cumulative perc."),
49
            QRadioButton("Number"))
50
        self.buttons.percent.setChecked(True)
1✔
51
        self.buttons.percent.setToolTip(
1✔
52
            "This cut-off type shows contributions of at least some percentage "
53
            "(for example contributions of at least 5%)"
54
        )
55
        self.buttons.cum_percent.setToolTip(
1✔
56
            "This cut-off type shows contributions that together are some percentage "
57
            "(for example all highest contributors that together count up to 80%)"
58
        )
59
        self.buttons.number.setToolTip(
1✔
60
            "This cut-off type shows this many of the largest contributors "
61
            "(for example the top 5 contributors)"
62
        )
63
        self.button_group = QButtonGroup()
1✔
64
        self.button_group.addButton(self.buttons.percent, 0)
1✔
65
        self.button_group.addButton(self.buttons.cum_percent, 1)
1✔
66
        self.button_group.addButton(self.buttons.number, 2)
1✔
67
        self.button_id_limit_type = {
1✔
68
            0: "percent",
69
            1: "cum_percent",
70
            2: "number",
71
        }
72
        self.button_group.setExclusive(True)
1✔
73
        self.sliders = Types(
1✔
74
            LogarithmicSlider(self),
75
            QSlider(Qt.Horizontal, self),
76
            QSlider(Qt.Horizontal, self))
77
        self.sliders.percent.setToolTip(
1✔
78
            "This slider sets the cut-off percentage to show"
79
        )
80
        self.sliders.cum_percent.setToolTip(
1✔
81
            "This slider sets the cumulative cut-off percentage to show"
82
        )
83
        self.sliders.number.setToolTip(
1✔
84
            "This slider sets the amount of highest contributors to show"
85
        )
86
        self.units = Types("%", "cumulative %", "number")
1✔
87
        self.labels = Labels(QLabel(), QLabel(), QLabel())
1✔
88
        self.cutoff_slider_line = QLineEdit()
1✔
89
        self.cutoff_slider_line.setToolTip(
1✔
90
            "This entry sets the cut-off amount"
91
        )
92
        self.cutoff_slider_line.setLocale(locale)
1✔
93
        self.cutoff_slider_lft_btn = QPushButton("<")
1✔
94
        self.cutoff_slider_lft_btn.setToolTip(
1✔
95
            "This button moves the cut-off value one increment"
96
        )
97
        self.cutoff_slider_rght_btn = QPushButton(">")
1✔
98
        self.cutoff_slider_rght_btn.setToolTip(
1✔
99
            "This button moves the cut-off value one increment"
100
        )
101

102
        self.debounce_slider = QtCore.QTimer()
1✔
103
        self.debounce_slider.setInterval(300)
1✔
104
        self.debounce_slider.setSingleShot(True)
1✔
105

106
        self.debounce_text = QtCore.QTimer()
1✔
107
        self.debounce_text.setInterval(300)
1✔
108
        self.debounce_text.setSingleShot(True)
1✔
109

110
        self.make_layout()
1✔
111
        self.connect_signals()
1✔
112

113
    def connect_signals(self):
1✔
114
        """Connect the signals of the menu."""
115
        # Cut-off types
116
        self.button_group.buttonClicked.connect(self.cutoff_type_check)
1✔
117
        self.cutoff_slider_lft_btn.clicked.connect(self.cutoff_increment_left_check)
1✔
118
        self.cutoff_slider_rght_btn.clicked.connect(self.cutoff_increment_right_check)
1✔
119

120
        self.debounce_slider.timeout.connect(self.initiate_slider_change)
1✔
121
        self.debounce_text.timeout.connect(self.initiate_text_change)
1✔
122

123
        self.sliders.percent.valueChanged.connect(self.debounce_slider.start)
1✔
124
        self.sliders.cum_percent.valueChanged.connect(self.debounce_slider.start)
1✔
125
        self.sliders.number.valueChanged.connect(self.debounce_slider.start)
1✔
126
        self.cutoff_slider_line.textChanged.connect(self.debounce_text.start)
1✔
127

128
    def initiate_slider_change(self):
1✔
NEW
129
        if self.limit_type == "percent":
×
NEW
130
            self.cutoff_slider_percent_check("sl")
×
NEW
131
        elif self.limit_type == "cum_percent":
×
NEW
132
            self.cutoff_slider_cum_percent_check("sl")
×
NEW
133
        elif self.limit_type == "number":
×
NEW
134
            self.cutoff_slider_number_check("sl")
×
135

136
    def initiate_text_change(self):
1✔
NEW
137
        if self.limit_type == "percent":
×
NEW
138
            self.cutoff_slider_percent_check("le")
×
NEW
139
        elif self.limit_type == "cum_percent":
×
NEW
140
            self.cutoff_slider_cum_percent_check("le")
×
NEW
141
        elif self.limit_type == "number":
×
NEW
142
            self.cutoff_slider_number_check("le")
×
143

144
    @Slot(name="incrementLeftCheck")
1✔
145
    def cutoff_increment_left_check(self):
1✔
146
        """Move the slider 1 increment to left when left button is clicked."""
NEW
147
        if self.limit_type == "percent":
×
NEW
148
            num = int(self.sliders.percent.value())
×
NEW
149
            self.sliders.percent.setValue(num + 1)
×
NEW
150
        elif self.limit_type == "cum_percent":
×
NEW
151
            num = int(self.sliders.cum_percent.value())
×
NEW
152
            self.sliders.cum_percent.setValue(num - 1)
×
NEW
153
        elif self.limit_type == "number":
×
NEW
154
            num = int(self.sliders.number.value())
×
NEW
155
            self.sliders.number.setValue(num - 1)
×
156

157
    @Slot(name="incrementRightCheck")
1✔
158
    def cutoff_increment_right_check(self):
1✔
159
        """Move the slider 1 increment to right when right button is clicked."""
NEW
160
        if self.limit_type == "percent":
×
NEW
161
            num = int(self.sliders.percent.value())
×
NEW
162
            self.sliders.percent.setValue(num - 1)
×
NEW
163
        elif self.limit_type == "cum_percent":
×
NEW
164
            num = int(self.sliders.cum_percent.value())
×
NEW
165
            self.sliders.cum_percent.setValue(num + 1)
×
NEW
166
        elif self.limit_type == "number":
×
NEW
167
            num = int(self.sliders.number.value())
×
NEW
168
            self.sliders.number.setValue(num + 1)
×
169

170
    @Slot(name="isClicked")
1✔
171
    def cutoff_type_check(self) -> None:
1✔
172
        """Dependent on cutoff-type, set the right labels.
173

174
                Slot connected to the 'Cut-off types', the state of those buttons determines:
175
                - which sliders are visible
176
                - the unit shown
177
                - minimum and maximum
178
                - limit_type
179
                """
180
        # determine which mode is clicked
181
        clicked_type = self.button_id_limit_type[self.button_group.checkedId()]
1✔
182
        if self.limit_type == clicked_type:
1✔
NEW
183
            return  # immediately return if the clicked type was already toggled
×
184
        self.limit_type = clicked_type
1✔
185

186
        # temporarily block signals
187
        self.sliders.percent.blockSignals(True)
1✔
188
        self.sliders.cum_percent.blockSignals(True)
1✔
189
        self.sliders.number.blockSignals(True)
1✔
190
        self.cutoff_slider_line.blockSignals(True)
1✔
191

192
        if self.limit_type == "percent":
1✔
NEW
193
            self.sliders.percent.setVisible(True)
×
NEW
194
            self.sliders.cum_percent.setVisible(False)
×
NEW
195
            self.sliders.number.setVisible(False)
×
196

NEW
197
            self.labels.unit.setText(self.units.percent)
×
198
            self.labels.min.setText("100%")
×
199
            self.labels.max.setText("0.001%")
×
NEW
200
            self.cutoff_slider_line.setValidator(self.validators.percent)
×
201
        if self.limit_type == "cum_percent":
1✔
NEW
202
            self.sliders.percent.setVisible(False)
×
NEW
203
            self.sliders.cum_percent.setVisible(True)
×
NEW
204
            self.sliders.number.setVisible(False)
×
205

NEW
206
            self.labels.unit.setText(self.units.cum_percent)
×
NEW
207
            self.labels.min.setText("1%")
×
NEW
208
            self.labels.max.setText("100%")
×
NEW
209
            self.cutoff_slider_line.setValidator(self.validators.cum_percent)
×
210
        elif self.limit_type == "number":
1✔
211
            self.sliders.percent.setVisible(False)
1✔
212
            self.sliders.cum_percent.setVisible(False)
1✔
213
            self.sliders.number.setVisible(True)
1✔
214

215
            self.labels.unit.setText(self.units.number)
1✔
216
            self.labels.min.setText(str(self.sliders.number.minimum()))
1✔
217
            self.labels.max.setText(str(self.sliders.number.maximum()))
1✔
218
            self.cutoff_slider_line.setValidator(self.validators.number)
1✔
219

220
        # unblock signals
221
        self.sliders.percent.blockSignals(False)
1✔
222
        self.sliders.cum_percent.blockSignals(False)
1✔
223
        self.sliders.number.blockSignals(False)
1✔
224
        self.cutoff_slider_line.blockSignals(False)
1✔
225

226
    @Slot(str, name="sliderPercentCheck")
1✔
227
    def cutoff_slider_percent_check(self, editor: str):
1✔
228
        """If 'Percent' selected, change the plots and tables to reflect the slider/line-edit."""
NEW
229
        if not self.limit_type == "percent":
×
230
            return
×
NEW
231
        cutoff = 0.05
×
232

233
        # If called by slider
234
        if editor == "sl":
×
235
            self.cutoff_slider_line.blockSignals(True)
×
NEW
236
            cutoff = abs(self.sliders.percent.log_value)
×
237
            self.cutoff_slider_line.setText(str(cutoff))
×
238
            self.cutoff_slider_line.blockSignals(False)
×
239

240
        # if called by line edit
241
        elif editor == "le":
×
NEW
242
            self.sliders.percent.blockSignals(True)
×
243
            if self.cutoff_slider_line.text() == "-":
×
244
                cutoff = 0.001
×
245
                self.cutoff_slider_line.setText("0.001")
×
246
            elif self.cutoff_slider_line.text() == "":
×
247
                cutoff = 0.001
×
248
            else:
249
                cutoff = abs(float(self.cutoff_slider_line.text()))
×
250

251
            if cutoff > 100:
×
252
                cutoff = 100
×
253
                self.cutoff_slider_line.setText(str(cutoff))
×
NEW
254
            self.sliders.percent.log_value = float(cutoff)
×
NEW
255
            self.sliders.percent.blockSignals(False)
×
256

NEW
257
        self.cutoff_value = cutoff / 100
×
NEW
258
        self.slider_change.emit()
×
259

260
    @Slot(str, name="sliderCumPercentCheck")
1✔
261
    def cutoff_slider_cum_percent_check(self, editor: str):
1✔
262
        """If 'Percent' selected, change the plots and tables to reflect the slider/line-edit."""
NEW
263
        """If 'Number' selected, change the plots and tables to reflect the slider/line-edit."""
×
NEW
264
        if not self.limit_type == "cum_percent":
×
NEW
265
            return
×
NEW
266
        cutoff = 2
×
267

268
        # If called by slider
NEW
269
        if editor == "sl":
×
NEW
270
            self.cutoff_slider_line.blockSignals(True)
×
NEW
271
            cutoff = abs(int(self.sliders.cum_percent.value()))
×
NEW
272
            self.cutoff_slider_line.setText(str(cutoff))
×
NEW
273
            self.cutoff_slider_line.blockSignals(False)
×
NEW
274
            print("cutoff sl", cutoff)
×
275

276
        # if called by line edit
NEW
277
        elif editor == "le":
×
NEW
278
            self.sliders.cum_percent.blockSignals(True)
×
NEW
279
            if self.cutoff_slider_line.text() == "-":
×
NEW
280
                cutoff = self.sliders.cum_percent.minimum()
×
NEW
281
                self.cutoff_slider_line.setText(str(self.sliders.cum_percent.minimum()))
×
NEW
282
            elif self.cutoff_slider_line.text() == "":
×
NEW
283
                cutoff = self.sliders.cum_percent.minimum()
×
284
            else:
NEW
285
                cutoff = abs(int(self.cutoff_slider_line.text()))
×
286

NEW
287
            if cutoff > self.sliders.cum_percent.maximum():
×
NEW
288
                cutoff = self.sliders.cum_percent.maximum()
×
NEW
289
                self.cutoff_slider_line.setText(str(cutoff))
×
NEW
290
            self.sliders.cum_percent.setValue(int(cutoff))
×
NEW
291
            self.sliders.cum_percent.blockSignals(False)
×
292

293
        self.cutoff_value = cutoff / 100
×
UNCOV
294
        self.slider_change.emit()
×
295

296
    @Slot(str, name="sliderNumberCheck")
1✔
297
    def cutoff_slider_number_check(self, editor: str):
1✔
298
        """If 'Number' selected, change the plots and tables to reflect the slider/line-edit."""
NEW
299
        if not self.limit_type == "number":
×
300
            return
×
UNCOV
301
        cutoff = 2
×
302

303
        # If called by slider
304
        if editor == "sl":
×
305
            self.cutoff_slider_line.blockSignals(True)
×
NEW
306
            cutoff = abs(int(self.sliders.number.value()))
×
307
            self.cutoff_slider_line.setText(str(cutoff))
×
UNCOV
308
            self.cutoff_slider_line.blockSignals(False)
×
309

310
        # if called by line edit
311
        elif editor == "le":
×
NEW
312
            self.sliders.number.blockSignals(True)
×
313
            if self.cutoff_slider_line.text() == "-":
×
NEW
314
                cutoff = self.sliders.number.minimum()
×
NEW
315
                self.cutoff_slider_line.setText(str(self.sliders.number.minimum()))
×
316
            elif self.cutoff_slider_line.text() == "":
×
NEW
UNCOV
317
                cutoff = self.sliders.number.minimum()
×
318
            else:
UNCOV
319
                cutoff = abs(int(self.cutoff_slider_line.text()))
×
320

NEW
321
            if cutoff > self.sliders.number.maximum():
×
NEW
322
                cutoff = self.sliders.number.maximum()
×
323
                self.cutoff_slider_line.setText(str(cutoff))
×
NEW
324
            self.sliders.number.setValue(int(cutoff))
×
NEW
325
            self.sliders.number.blockSignals(False)
×
326

327
        self.cutoff_value = int(cutoff)
×
UNCOV
328
        self.slider_change.emit()
×
329

330
    def make_layout(self):
1✔
331
        """Assemble the layout of the cutoff menu.
332

333
        Construct the layout for the cutoff menu widget. The initial layout is set to 'Percent'.
334
        """
335
        layout = QHBoxLayout()
1✔
336

337
        # Cut-off types
338
        cutoff_type = QVBoxLayout()
1✔
339
        cutoff_type_label = QLabel("Cut-off type")
1✔
340

341
        # Cut-off slider
342
        cutoff_slider = QVBoxLayout()
1✔
343
        cutoff_slider_set = QVBoxLayout()
1✔
344
        cutoff_slider_label = QLabel("Cut-off level")
1✔
345
        self.sliders.percent.log_value = self.cutoff_value
1✔
346
        self.sliders.percent.setInvertedAppearance(True)
1✔
347
        self.sliders.cum_percent.setValue(self.cutoff_value)
1✔
348
        self.sliders.cum_percent.setMinimum(1)
1✔
349
        self.sliders.cum_percent.setMaximum(100)
1✔
350
        self.sliders.number.setValue(self.cutoff_value)
1✔
351
        self.sliders.number.setMinimum(1)
1✔
352
        self.sliders.number.setMaximum(50)
1✔
353
        cutoff_slider_minmax = QHBoxLayout()
1✔
354
        self.labels.min.setText("100%")
1✔
355
        self.labels.max.setText("0.001%")
1✔
356
        self.labels.unit.setText("%")
1✔
357
        cutoff_slider_ledit = QHBoxLayout()
1✔
358
        self.cutoff_slider_line.setValidator(self.validators.percent)
1✔
359
        self.cutoff_slider_lft_btn.setMaximumWidth(15)
1✔
360
        self.cutoff_slider_rght_btn.setMaximumWidth(15)
1✔
361

362
        # Assemble types
363
        cutoff_type.addWidget(cutoff_type_label)
1✔
364
        cutoff_type.addWidget(self.buttons.percent)
1✔
365
        cutoff_type.addWidget(self.buttons.cum_percent)
1✔
366
        cutoff_type.addWidget(self.buttons.number)
1✔
367

368
        # Assemble slider set
369
        self.sliders.number.setVisible(False)
1✔
370
        self.sliders.cum_percent.setVisible(False)
1✔
371
        cutoff_slider_set.addWidget(cutoff_slider_label)
1✔
372
        cutoff_slider_minmax.addWidget(self.labels.min)
1✔
373
        cutoff_slider_minmax.addWidget(self.sliders.percent)
1✔
374
        cutoff_slider_minmax.addWidget(self.sliders.cum_percent)
1✔
375
        cutoff_slider_minmax.addWidget(self.sliders.number)
1✔
376
        cutoff_slider_minmax.addWidget(self.labels.max)
1✔
377
        cutoff_slider_set.addLayout(cutoff_slider_minmax)
1✔
378

379
        cutoff_slider_ledit.addWidget(self.cutoff_slider_line)
1✔
380
        cutoff_slider_ledit.addWidget(self.cutoff_slider_lft_btn)
1✔
381
        cutoff_slider_ledit.addWidget(self.cutoff_slider_rght_btn)
1✔
382
        cutoff_slider_ledit.addWidget(self.labels.unit)
1✔
383
        cutoff_slider_ledit.addStretch(1)
1✔
384

385
        cutoff_slider.addLayout(cutoff_slider_set)
1✔
386
        cutoff_slider.addLayout(cutoff_slider_ledit)
1✔
387

388
        # Assemble cut-off menu
389
        layout.addLayout(cutoff_type)
1✔
390
        layout.addWidget(vertical_line())
1✔
391
        layout.addLayout(cutoff_slider)
1✔
392
        layout.addStretch()
1✔
393

394
        self.setLayout(layout)
1✔
395

396

397
class LogarithmicSlider(QSlider):
1✔
398
    """Makes a QSlider object that behaves logarithmically.
399

400
    Inherits from QSlider. This class uses the property `log_value` getter and setter to modify
401
    the QSlider through the `value` and `setValue` methods.
402
    """
403

404
    def __init__(self, parent=None):
1✔
405
        super().__init__(Qt.Horizontal, parent)
1✔
406
        self.setMinimum(1)
1✔
407
        self.setMaximum(100)
1✔
408

409
    @property
1✔
410
    def log_value(self) -> Union[int, float]:
1✔
411
        """Read (slider) and modify from 1-100 to 0.001-100 logarithmically with relevant rounding.
412

413
        This function converts the 1-100 values and modifies these to 0.001-100 on a logarithmic
414
        scale. Rounding is done based on magnitude.
415
        """
416

417
        # Logarithmic math refresher:
418
        # BOP = Base, Outcome Power;
419
        # log(B)(O) = P --> log(2)(64) = 6  ||  log(10)(1000) = 3
420
        #       B^P = O -->        2^6 = 64 ||           10^3 = 1000
421

422
        value = float(self.value())
×
423
        log_val = np.log10(value)
×
424
        power = log_val * 2.5 - 3
×
425
        ret_val = np.power(10, power)
×
UNCOV
426
        test_val = np.log10(ret_val)
×
427

428
        if test_val < -1:
×
429
            return ret_val.round(3)
×
430
        elif test_val < -0:
×
431
            return ret_val.round(2)
×
432
        elif test_val < 1:
×
UNCOV
433
            return ret_val.round(1)
×
434
        else:
UNCOV
435
            return ret_val.astype(int)
×
436

437
    @log_value.setter
1✔
438
    def log_value(self, value: float) -> None:
1✔
439
        """Modify value from 0.001-100 to 1-100 logarithmically and set slider to value."""
440
        value = int(float(value) * np.power(10, 3))
1✔
441
        log_val = np.log10(value).round(3)
1✔
442
        set_val = log_val * 20
1✔
443
        self.setValue(set_val)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc