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

nens / ThreeDiToolbox / #2600

01 Oct 2025 08:42AM UTC coverage: 34.793% (-0.3%) from 35.131%
#2600

push

coveralls-python

web-flow
Fat improvements (#1143)

* qt6 stub

* qt6

* qt5 -> qt6

* convert

* fix

* FAT: Toggle items on/off with space bar

* basic color changing, still need to save in project

* lint

* Switching stacked or volume mode takes selection into account

* Fraction Analysis Tool: substances are listed alphabetically (#1133)

* CHANGES

* Qt6 fixes

* Plot highlighting (#1132)

* Stub plot highlighting

* stub plot highlighting

* Optimizations

* stub value marker

* Closest curve is now pixel-based

* Make coverals non-blocking for PRs GA

* optimiziation

* Proper highlighting of fills

* proper highlighting of bottom fill (uses fillLevel)

* lint

* refactor

* FAT: curve marker has same color as closest line

82 of 406 new or added lines in 42 files covered. (20.2%)

9 existing lines in 6 files now uncovered.

4878 of 14020 relevant lines covered (34.79%)

0.35 hits per line

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

13.55
/tool_fraction_analysis/fraction_plot.py
1
import re
1✔
2
from qgis.PyQt.QtGui import QColor
1✔
3
import pyqtgraph as pg
1✔
4
from qgis.PyQt.QtCore import pyqtSignal, QPointF
1✔
5
import numpy as np
1✔
6
from threedi_results_analysis.utils.geo_utils import distance_to_polyline, inbetween_polylines, below_polyline, closest_point_on_polyline
1✔
7
from threedi_results_analysis.utils.color import reduce_saturation
1✔
8

9
pg.setConfigOption("background", "w")
1✔
10
pg.setConfigOption("foreground", "k")
1✔
11
from qgis.PyQt.QtCore import Qt
1✔
12
from threedi_results_analysis.threedi_plugin_model import ThreeDiPluginModel
1✔
13
from threedi_results_analysis.tool_fraction_analysis.fraction_model import FractionModel
1✔
14

15

16
class FractionPlot(pg.PlotWidget):
1✔
17
    hover_plot = pyqtSignal(str)
1✔
18

19
    def __init__(self, parent, result_model: ThreeDiPluginModel, fraction_model: FractionModel):
1✔
20
        super().__init__(parent)
×
21
        self.showGrid(True, True, 0.5)
×
NEW
22
        self.proxy = pg.SignalProxy(self.scene().sigMouseMoved, rateLimit=10, slot=self.mouse_moved)
×
23
        self.fraction_model = fraction_model
×
24
        self.result_model = result_model
×
25
        # map from substance name to (list of) plot
26
        self.item_map = {}
×
27
        self.setLabel("bottom", "Time", "hrs")
×
28
        self.setLabel("left", "Concentration", "")
×
29
        self.getAxis("left").enableAutoSIPrefix(False)
×
NEW
30
        self.mouseLabel = pg.TextItem(text="", anchor=(1, 1), color=(0, 0, 0))
×
31

NEW
32
        self.mouseMarker = pg.ScatterPlotItem(
×
33
            [0], [0],
34
            symbol='o',
35
            size=12,
36
            brush='r',
37
            pen='k'
38
        )
NEW
39
        self.mouseMarker.setZValue(20)
×
NEW
40
        self.mouseMarker.setVisible(False)
×
NEW
41
        self.addItem(self.mouseMarker)
×
NEW
42
        self.addItem(self.mouseLabel)
×
43

44
    def clear_plot(self):
1✔
45
        self.clear()
×
46
        self.item_map.clear()
×
47
        self.setLabel("left", "Concentration", "")
×
NEW
48
        self.addItem(self.mouseMarker)
×
NEW
49
        self.addItem(self.mouseLabel)
×
NEW
50
        self.mouseLabel.setText("")
×
NEW
51
        self.mouseLabel.setZValue(200)
×
NEW
52
        self.mouseMarker.setVisible(False)
×
NEW
53
        self.mouseMarker.setZValue(200)
×
54

55
    def item_checked(self, model_item):
1✔
NEW
56
        if not self.item_map:  # No plots yet
×
NEW
57
            return
×
58
        substance = model_item.data()
×
59
        for plot in self.item_map[substance]:
×
NEW
60
            plot.setVisible(model_item.checkState() == Qt.CheckState.Checked)
×
61

62
    def mouse_moved(self, pos):
1✔
63
        # As we are using a ProxySignal, we get a list of events
NEW
64
        mouse_scene_x = pos[0].x()
×
NEW
65
        mouse_scene_y = pos[0].y()
×
NEW
66
        mouse_point = self.plotItem.vb.mapSceneToView(pos[0])
×
67

NEW
68
        min_dist = float("inf")
×
NEW
69
        closest_substance = None
×
NEW
70
        closest_data_point = None
×
NEW
71
        closest_color = None
×
72

NEW
73
        for substance, plots in self.item_map.items():
×
NEW
74
            if len(plots) == 2:  # STACKED MODE
×
75
                # There is also a fill, need to check whether point is in trapezoids
NEW
76
                plot1 = plots[1].curves[0]
×
NEW
77
                plot2 = plots[1].curves[1]
×
NEW
78
                scene_x1_data, scene_y1_data, x1_data, y1_data = self._getPlotDataInSceneCoordinates(plot1)
×
NEW
79
                _, y2_data = plot2.getData()
×
80

NEW
81
                if inbetween_polylines(mouse_point.x(), mouse_point.y(), x1_data, y1_data, y2_data):
×
NEW
82
                    closest_substance = substance
×
83
                    # find closest point in scene coordinates of base line
NEW
84
                    _, _, idx1 = closest_point_on_polyline(mouse_scene_x, mouse_scene_y, scene_x1_data, scene_y1_data)
×
NEW
85
                    closest_data_point = QPointF(x1_data[idx1], y1_data[idx1])
×
NEW
86
                    closest_color = plot1.opts['pen'].color()
×
NEW
87
                    break
×
NEW
88
            elif len(plots) == 1:  # SINGLE MODE
×
NEW
89
                item = plots[0]
×
NEW
90
                if item.opts['fillLevel'] == 0:
×
91
                    # this is the bottom plot, filled
NEW
92
                    scene_x_data, scene_y_data, x_data, y_data = self._getPlotDataInSceneCoordinates(item)
×
NEW
93
                    if below_polyline(mouse_point.x(), mouse_point.y(), x_data, y_data):
×
NEW
94
                        closest_substance = substance
×
NEW
95
                        _, _, idx = closest_point_on_polyline(mouse_scene_x, mouse_scene_y, scene_x_data, scene_y_data)
×
NEW
96
                        closest_data_point = QPointF(x_data[idx], y_data[idx])
×
NEW
97
                        closest_color = item.opts['pen'].color()
×
98
                        # find closest point to item in scene coordinates
NEW
99
                        break
×
100
                else:
NEW
101
                    scene_x_data, scene_y_data, x_data, y_data = self._getPlotDataInSceneCoordinates(item)
×
NEW
102
                    dist, data_point = distance_to_polyline(mouse_scene_x, mouse_scene_y, scene_x_data, scene_y_data)
×
NEW
103
                    if dist < 10:
×
NEW
104
                        if dist < min_dist:
×
NEW
105
                            min_dist = dist
×
NEW
106
                            closest_substance = substance
×
NEW
107
                            closest_color = item.opts['pen'].color()
×
NEW
108
                            closest_data_point = self.plotItem.vb.mapSceneToView(QPointF(data_point[0], data_point[1]))
×
109

NEW
110
        if closest_substance is not None:
×
NEW
111
            self.mouseLabel.setText("(%0.3f, %0.3f)" % (closest_data_point.x(), closest_data_point.y()))
×
NEW
112
            self.mouseLabel.setPos(closest_data_point.x(), closest_data_point.y())
×
NEW
113
            self.mouseMarker.setVisible(True)
×
NEW
114
            self.mouseMarker.setData([closest_data_point.x()], [closest_data_point.y()])
×
NEW
115
            self.mouseMarker.setBrush(closest_color)
×
116
        else:
NEW
117
            self.mouseLabel.setText("")
×
NEW
118
            self.mouseMarker.setVisible(False)
×
119

NEW
120
        self.hover_plot.emit(closest_substance)
×
121

122
    def item_color_changed(self, color_model_item):
1✔
NEW
123
        if not self.item_map:  # No plots yet
×
NEW
124
            return
×
125

NEW
126
        row = color_model_item.index().row()
×
NEW
127
        substance = self.fraction_model.item(row, 0).data()
×
128
        # Take current color
NEW
129
        style, color, width = self.fraction_model.item(row, 1).data()[0]
×
130

NEW
131
        fill_color = reduce_saturation(QColor(*color))
×
132

NEW
133
        pen = pg.mkPen(color=QColor(*color), width=width, style=style)
×
NEW
134
        self.item_map[substance][0].setPen(pen)
×
135

136
        # Check whether this is the bottom fill
NEW
137
        if self.item_map[substance][0].opts['fillLevel'] == 0:
×
NEW
138
            self.item_map[substance][0].setFillBrush(pg.mkBrush(fill_color))
×
139

NEW
140
        if len(self.item_map[substance]) == 2:
×
NEW
141
            self.item_map[substance][1].setBrush(pg.mkBrush(fill_color))
×
142

143
    def fraction_selected(self, feature_id, substance_unit: str, time_unit: str, stacked: bool, volume: bool):
1✔
144
        """
145
        Retrieve info from model and create plots
146
        """
147
        self.clear_plot()
×
148

149
        substance_unit_conversion = 1.0
×
150

151
        if volume:
×
152
            # for known units, we apply basic conversion
153
            pattern = r"^(.*)/\s*(m3|l)\s*$"
×
154
            matches = re.findall(pattern, substance_unit, flags=re.IGNORECASE)
×
155
            if len(matches) == 1 and len(matches[0]) == 2:
×
156
                if matches[0][1].lower() == "l":
×
157
                    substance_unit_conversion = 1000.0
×
158
                    processed_substance_unit = matches[0][0].strip()
×
159
                    volume_label = "Load"
×
160
                elif matches[0][1].lower() == "m3":
×
161
                    substance_unit_conversion = 1.0
×
162
                    processed_substance_unit = matches[0][0].strip()
×
163
                    volume_label = "Load"
×
164
            elif substance_unit.strip() == "%":
×
165
                substance_unit_conversion = 1.0
×
166
                processed_substance_unit = "m<sup>3</sup>"
×
167
                volume_label = "Volume"
×
168
            else:  # unknown, take original unit as-is and append x m3
169
                substance_unit_conversion = 1.0
×
170
                processed_substance_unit = f"{substance_unit} ยท m<sup>3</sup>"
×
171
                volume_label = "Load"
×
172

173
        plots = self.fraction_model.create_plots(feature_id, time_unit, stacked, volume, substance_unit_conversion)
×
174
        prev_plot = None
×
NEW
175
        for substance, plot, visible in plots:
×
176
            self.item_map[substance] = [plot]
×
177
            plot.setZValue(100)
×
NEW
178
            plot.setVisible(visible)
×
UNCOV
179
            self.addItem(plot)
×
180

181
            if stacked:
×
182
                # Add fill between consecutive plots
183
                plot_color = plot.opts['pen'].color()
×
184
                # Reduce saturation for fill
NEW
185
                fill_color = reduce_saturation(plot_color)
×
186
                if not prev_plot:
×
187
                    # this is the first, just fill downward to axis
188
                    plot.setFillLevel(0)
×
189
                    plot.setFillBrush(pg.mkBrush(fill_color))
×
190
                else:
191
                    fill = pg.FillBetweenItem(plot, prev_plot, pg.mkBrush(fill_color))
×
192
                    fill.setZValue(20)
×
NEW
193
                    fill.setVisible(visible)
×
194
                    self.addItem(fill)
×
195
                    self.item_map[substance].append(fill)
×
196

197
            prev_plot = plot
×
198

199
        self.setLabel("left", volume_label if volume else "Concentration", processed_substance_unit if volume else substance_unit)
×
200
        self.plotItem.vb.menu.viewAll.triggered.emit()
×
201

202
    def _getPlotDataInSceneCoordinates(self, plot):
1✔
NEW
203
        x_data, y_data = plot.getData()
×
NEW
204
        scene_points = [self.plotItem.vb.mapViewToScene(QPointF(x, y)) for x, y in zip(x_data, y_data)]
×
205
        # Convert to numpy arrays for distance calculations
NEW
206
        scene_x_data = np.array([pt.x() for pt in scene_points])
×
NEW
207
        scene_y_data = np.array([pt.y() for pt in scene_points])
×
NEW
208
        return scene_x_data, scene_y_data, x_data, y_data
×
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