From 0355743dc16b94b8226a30d17e405678c7dbd78e Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Fri, 21 Aug 2026 11:49:11 +0200 Subject: [PATCH 1/2] Draw lane membership as a row with no value Labels refuse true and false since #955: membership is a row with no value, and an interval outside the group has no row. The lanes plot and its tests, merged alongside in #952, still used booleans. --- dascore/viz/_lanes.py | 24 +++++++++++------------- tests/test_viz/test_inventory_viz.py | 5 +---- tests/test_viz/test_lanes.py | 17 ++++++++++++----- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/dascore/viz/_lanes.py b/dascore/viz/_lanes.py index 6ff58dc7c..2705e4c2b 100644 --- a/dascore/viz/_lanes.py +++ b/dascore/viz/_lanes.py @@ -60,9 +60,9 @@ def _default_label(value) -> str: """Text for a value which was not given a label of its own.""" if isinstance(value, str): return value - if isinstance(value, bool) or value is None: + if value is None: return "" - # A number states itself; a boolean group is named by its lane instead. + # A number states itself; a membership lane is named by its lane instead. return f"{value:g}" if isinstance(value, float) else str(value) @@ -238,14 +238,10 @@ def _resolve_colors(rows, kind, lane_index, string_map, color): # Each numeric lane is its own scale, so each earns its own bar; # one bar for two lanes would read from a scale only one of them has. return colors, ("colorbar", (rows["lane"].iloc[0], cmap, norm)) - # Boolean and unvalued lanes take one color, so the lane reads as one - # variable; a False interval is drawn faintly rather than dropped. + # An unvalued lane states membership: every row takes the one color, so + # the lane reads as one variable. base = plt.get_cmap(LANE_CMAP)(lane_index % 10) - colors = [ - base if normalize_value(x) is not False else (*base[:3], 0.25) - for x in rows["value"] - ] - return colors, ("legend", {rows["lane"].iloc[0]: base}) + return [base] * len(rows), ("legend", {rows["lane"].iloc[0]: base}) def _draw_open_edges(ax, rows, y_low, height, colors, span): @@ -340,11 +336,13 @@ def plot_lanes( one unnamed lane. value Column deciding each row's color. Strings are categorical, - numbers continuous, and booleans state membership of the lane. + numbers continuous, and a row with no value states membership + of the lane (true and false are not values; an interval outside + the lane has no row). label Column holding the text drawn in each box. Values supply it by - default: text as itself, a number as its digits, a boolean as - nothing, since the lane it sits in already names it. + default: text as itself, a number as its digits, a membership + row as nothing, since the lane it sits in already names it. lanes The lanes to draw, in order. Names with no rows are kept as empty lanes, so two figures of different subjects still line up. @@ -386,7 +384,7 @@ def plot_lanes( ... "group": ["zone", "zone", "noisy"], ... "start": [0.0, 10.0, 5.0], ... "end": [10.0, 20.0, 15.0], - ... "value": ["north", "south", True], + ... "value": ["north", "south", None], ... } ... ) >>> _ = plot_lanes(frame, lane="group", value="value") diff --git a/tests/test_viz/test_inventory_viz.py b/tests/test_viz/test_inventory_viz.py index 1f2e50572..cb2c6d122 100644 --- a/tests/test_viz/test_inventory_viz.py +++ b/tests/test_viz/test_inventory_viz.py @@ -99,10 +99,7 @@ def _main_path(epoch: int) -> inv.OpticalPath: start_distance=200.0, end_distance=400.0, group="zone", value="south" ), inv.OpticalPathLabel( - start_distance=150.0, end_distance=300.0, group="noisy", value=True - ), - inv.OpticalPathLabel( - start_distance=300.0, end_distance=400.0, group="noisy", value=False + start_distance=150.0, end_distance=300.0, group="noisy" ), inv.OpticalPathLabel( start_distance=100.0, end_distance=200.0, group="count", value=0 diff --git a/tests/test_viz/test_lanes.py b/tests/test_viz/test_lanes.py index 60278c82d..91bc1149b 100644 --- a/tests/test_viz/test_lanes.py +++ b/tests/test_viz/test_lanes.py @@ -55,7 +55,7 @@ def kinds_frame(): "lane": ["text", "text", "flag", "flag", "count", "count", "tick"], "start": [0.0, 5.0, 0.0, 5.0, 0.0, 5.0, 3.0], "end": [5.0, 10.0, 5.0, 10.0, 5.0, 10.0, 3.0], - "value": ["a", "b", True, False, 1, 2, None], + "value": ["a", "b", None, None, 1, 2, None], } ) @@ -253,7 +253,7 @@ def test_label_column(self): assert _texts(ax) == ["x"] def test_default_labels(self, kinds_frame): - """Numbers state themselves; booleans and None draw no text.""" + """Numbers state themselves; membership rows draw no text.""" ax = plot_lanes(kinds_frame, lane="lane", value="value") assert sorted(_texts(ax)) == ["1", "2", "a", "b"] @@ -277,14 +277,21 @@ def test_string_colors_frame_wide(self, string_frame): labels = [x.get_text() for x in ax.get_legend().get_texts()] assert labels == ["north", "south", "west"] - def test_boolean_lane(self, kinds_frame): - """False is the lane's color at low alpha; the legend names the lane.""" + def test_membership_lane(self, kinds_frame): + """Every row takes the lane's one color; the legend names the lane.""" ax = plot_lanes(kinds_frame, lane="lane", value="value", lanes=("flag",)) colors = _collections(ax)[0].get_facecolors() assert colors[0][3] == pytest.approx(1.0) - assert colors[1][3] == pytest.approx(0.25) + assert colors[1][3] == pytest.approx(1.0) + assert np.allclose(colors[0], colors[1]) assert [x.get_text() for x in ax.get_legend().get_texts()] == ["flag"] + def test_booleans_are_refused(self, kinds_frame): + """True and false are not values; membership is a row with none.""" + frame = kinds_frame.assign(value=["a", "b", True, False, 1, 2, None]) + with pytest.raises(ParameterError, match="not values"): + plot_lanes(frame, lane="lane", value="value") + def test_numeric_few_values(self, kinds_frame): """A few numbers are colored continuously but earn no colorbar.""" ax = plot_lanes(kinds_frame, lane="lane", value="value", lanes=("count",)) From 9331ed6b5c170b1f13e1ee055dbfbd9e43f71998 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Fri, 21 Aug 2026 12:10:37 +0200 Subject: [PATCH 2/2] Read a missing lane value as membership, and refuse a gap in a valued lane A frame of mixed lanes spells a missing value as NaN; the inventory path plot hands the lanes such a frame. --- dascore/viz/_lanes.py | 19 ++++++++++++++----- tests/test_viz/test_lanes.py | 10 ++++++++-- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/dascore/viz/_lanes.py b/dascore/viz/_lanes.py index 2705e4c2b..f6b12077f 100644 --- a/dascore/viz/_lanes.py +++ b/dascore/viz/_lanes.py @@ -102,11 +102,17 @@ def _read_frame(intervals, start, end, lane, value, label): out["start"] = _as_numeric(intervals[start]) out["end"] = _as_numeric(intervals[end]) out["lane"] = intervals[lane].astype(str) if lane else "" - out["value"] = intervals[value] if value else None + if value: + # a missing value (None, or the NaN a frame of mixed lanes spells + # it as) states membership; it is not a number + values = intervals[value].astype(object) + out["value"] = values.where(values.notna(), None) + else: + out["value"] = None if label: out["label"] = intervals[label].astype(str) elif value: - out["label"] = [_default_label(x) for x in intervals[value].tolist()] + out["label"] = [_default_label(x) for x in out["value"].tolist()] else: out["label"] = "" for flag in ("open_start", "open_end"): @@ -124,6 +130,9 @@ def _lane_kind(values) -> str: return "none" if len(kinds) > 1: return "mixed" + if any(x is None for x in values): + # a lane states membership (no values) or a value in every row + return "mixed" return kinds.pop() @@ -435,9 +444,9 @@ def plot_lanes( kind = _lane_kind(rows["value"]) if kind == "mixed": msg = ( - f"Lane {name!r} mixes value kinds, so it has no one color " - "scheme. A group states one variable; split the kinds into " - "separate lanes." + f"Lane {name!r} mixes value kinds, or values with rows of " + "none, so it has no one color scheme. A group states one " + "variable: a value in every row, or none at all." ) raise ParameterError(msg) sub_rows = _pack_rows(rows) if pack else np.zeros(len(rows), dtype=int) diff --git a/tests/test_viz/test_lanes.py b/tests/test_viz/test_lanes.py index 91bc1149b..efb1a1e8e 100644 --- a/tests/test_viz/test_lanes.py +++ b/tests/test_viz/test_lanes.py @@ -286,6 +286,12 @@ def test_membership_lane(self, kinds_frame): assert np.allclose(colors[0], colors[1]) assert [x.get_text() for x in ax.get_legend().get_texts()] == ["flag"] + def test_nan_states_membership(self, kinds_frame): + """A NaN value, as a mixed frame spells a missing one, is no value.""" + frame = kinds_frame.assign(value=["a", "b", np.nan, np.nan, 1, 2, None]) + ax = plot_lanes(frame, lane="lane", value="value") + assert sorted(_texts(ax)) == ["1", "2", "a", "b"] + def test_booleans_are_refused(self, kinds_frame): """True and false are not values; membership is a row with none.""" frame = kinds_frame.assign(value=["a", "b", True, False, 1, 2, None]) @@ -375,14 +381,14 @@ def test_color_name_on_a_numeric_lane(self, kinds_frame): assert np.allclose(colors[:, :3], [1, 0, 0]) def test_a_value_which_is_not_a_value(self): - """A lane value of NaN is refused, the way the model refuses it.""" + """A NaN among a lane's numbers is refused: a valued lane has no gaps.""" n = 10 values = [float(x) for x in range(n)] values[3] = float("nan") frame = pd.DataFrame( {"start": np.arange(n) * 1.0, "end": np.arange(n) + 1.0, "v": values} ) - with pytest.raises(ParameterError, match="must be finite"): + with pytest.raises(ParameterError, match="mixes value kinds"): plot_lanes(frame, value="v") def test_legend_off_suppresses_the_colorbar(self):