diff --git a/src/phosphor/trace_grid.py b/src/phosphor/trace_grid.py index 654ef0c..2804ff3 100644 --- a/src/phosphor/trace_grid.py +++ b/src/phosphor/trace_grid.py @@ -338,6 +338,15 @@ def show_individual(self) -> bool: def show_mean(self) -> bool: return self._show_mean + @property + def show_error(self) -> bool: + """Whether the standard-deviation band is drawn. + + Reads back the resolved value, not what was asked for: requesting a band + without statistics resolves to off, because there is no spread to draw. + """ + return self._show_error + @property def autoscale(self) -> bool: return self._autoscale @@ -581,13 +590,20 @@ def _summary_positions(self) -> tuple[np.ndarray | None, np.ndarray | None]: return mean_pos, self._curve_positions(minmax_decimate(band, self._dec_plan)) def _curve_positions(self, curve: np.ndarray) -> np.ndarray: - """One line per row of *curve*, laid into the cells.""" + """One line per row of *curve*, laid into the cells. + + *curve* holds a whole number of per-channel blocks -- one for a mean, + two for the lower and upper edges of a band. Each block is mapped on its + own, because the cell mapping is per channel and would otherwise be + asked to broadcast a block of channels against twice as many rows. + """ n_lines, m = curve.shape[0], curve.shape[-1] - pos = np.empty((n_lines, m, 3), dtype=np.float32) - # The band is two curves per channel, so x tiles rather than broadcasts. reps = n_lines // self._n_ch + pos = np.empty((n_lines, m, 3), dtype=np.float32) pos[..., 0] = np.tile(self._x_line_dec, (reps, 1)) - pos[..., 1] = self._map_y(curve) + for i in range(reps): + block = slice(i * self._n_ch, (i + 1) * self._n_ch) + pos[block, :, 1] = self._map_y(curve[block]) pos[..., 2] = 0.0 return pos diff --git a/tests/test_trace_grid.py b/tests/test_trace_grid.py index 91a5324..bb14372 100644 --- a/tests/test_trace_grid.py +++ b/tests/test_trace_grid.py @@ -33,8 +33,11 @@ def make_widget(n_ch=2, n_samples=4, history=3, **config_kwargs) -> TraceGridWid w._x_line_dec = np.tile(np.arange(n_samples, dtype=np.float32), (n_ch, 1)) w._indiv_ml = w._mean_ml = w._error_ml = None w._graphics_version = -1 - # _map_y is affine per channel; identity keeps these tests about layout. - w._map_y = lambda a: np.asarray(a, dtype=np.float32) + # The real _map_y, not a stub. It is per channel, and stubbing it is what + # let a band of 2 x n_ch rows reach it and raise on every frame while the + # tests stayed green. + w._rects = np.column_stack([np.zeros(n_ch), np.arange(n_ch, dtype=float), np.ones(n_ch)]) + w._y_min, w._y_max = -1000.0, 1000.0 return w @@ -196,7 +199,9 @@ def test_the_mean_spans_more_waveforms_than_are_drawn(): w._buffer.push(wave(v, n_ch=1)) mean_pos, _ = w._summary_positions() - np.testing.assert_allclose(mean_pos[0, :, 1], 3.0) # mean of 1..5, not of 4..5 + # Compared through the cell mapping, since that is what the drawn y is. + expected = w._map_y(np.full((1, w._n_samples), 3.0, dtype=np.float32)) # 1..5, not 4..5 + np.testing.assert_allclose(mean_pos[0, :, 1], expected[0], rtol=1e-6) # ---- graphics actually get created ------------------------------------------ @@ -286,7 +291,8 @@ def test_the_newest_waveform_reaches_the_graphic(): w._refresh_lines() ys = w._indiv_ml.data[..., 1] - assert np.isclose(ys, 7.0).any(), "the value just pushed should be in the graphic" + expected = w._map_y(np.full((w._n_ch, w._n_samples), 7.0, dtype=np.float32))[0, 0] + assert np.isclose(ys, expected).any(), "the value just pushed should be in the graphic" def test_the_error_band_appears_once_there_is_a_spread(): @@ -316,3 +322,14 @@ def test_clearing_and_refilling_brings_the_graphics_back(): w._buffer.push(wave(2.0)) w._refresh_lines() assert w._indiv_ml is not None + + +def test_show_error_reads_back_what_was_resolved_not_what_was_asked(): + """A band without statistics is not a band. Callers persist this value, so + reading back the request rather than the resolution would restore a setting + that never took effect.""" + w = make_widget(show_error=True, track_statistics=False) + assert w.show_error is False + + w = make_widget(show_error=True) + assert w.show_error is True