diff --git a/dascore/viz/_lanes.py b/dascore/viz/_lanes.py index 6ff58dc7c..f6b12077f 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) @@ -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() @@ -238,14 +247,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 +345,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 +393,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") @@ -437,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_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..efb1a1e8e 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,27 @@ 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_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]) + 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",)) @@ -368,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):