diff --git a/dascore/viz/_lanes.py b/dascore/viz/_lanes.py index f6b12077f..666e980c0 100644 --- a/dascore/viz/_lanes.py +++ b/dascore/viz/_lanes.py @@ -56,11 +56,20 @@ def _as_numeric(values): return array.astype(float) +def _is_membership(value) -> bool: + """Whether a row states membership of its lane rather than a value. + + A frame carries that as no value at all, which pandas spells None, + NaN or NA depending on what else the column holds. + """ + return bool(pd.isna(value)) + + 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 value is None: + if _is_membership(value): return "" # A number states itself; a membership lane is named by its lane instead. return f"{value:g}" if isinstance(value, float) else str(value) @@ -124,15 +133,13 @@ def _read_frame(intervals, start, end, lane, value, label): def _lane_kind(values) -> str: """Return the one value kind a lane states, refusing a mixture.""" - kinds = {value_kind(normalize_value(x)) for x in values if x is not None} + kinds = {value_kind(normalize_value(x)) for x in values if not _is_membership(x)} kinds.discard(None) + kinds.discard("membership") if not kinds: 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() @@ -208,8 +215,11 @@ def _resolve_colors(rows, kind, lane_index, string_map, color): # default treatment rather than being matched against lane names. color = color.get(rows["lane"].iloc[0]) if isinstance(color, Mapping): - colors = [color.get(x, UNCOVERED_COLOR) for x in rows["value"]] - used = {x: color[x] for x in rows["value"] if x in color} + # A mapping is keyed by the value, and membership has one key + # however the column's dtype spelled it. + keys = [None if _is_membership(x) else x for x in rows["value"]] + colors = [color.get(x, UNCOVERED_COLOR) for x in keys] + used = {x: color[x] for x in keys if x in color} return colors, ("legend", used) if isinstance(color, str) and kind != "numeric": return [color] * len(rows), None @@ -221,7 +231,11 @@ def _resolve_colors(rows, kind, lane_index, string_map, color): ) if kind == "numeric": values = np.asarray( - [float(normalize_value(x)) for x in rows["value"]], dtype=float + [ + np.nan if _is_membership(x) else float(normalize_value(x)) + for x in rows["value"] + ], + dtype=float, ) if isinstance(color, str): try: @@ -235,7 +249,8 @@ def _resolve_colors(rows, kind, lane_index, string_map, color): if len(np.unique(values[np.isfinite(values)])) < 2: # One value is not a scale, so it gets a color and its number # rather than a colorbar reading from it to a value nothing has. - return [cmap(0.5)] * len(rows), None + # A row which states none is still not that value. + return [UNCOVERED_COLOR if np.isnan(x) else cmap(0.5) for x in values], None cmap, norm, ticks = numeric_scale(values, getattr(cmap, "name", NUMERIC_CMAP)) # A value nothing states maps to a transparent color unless the # colormap is told otherwise, and the box would simply vanish. @@ -350,8 +365,8 @@ def plot_lanes( 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 membership - row as nothing, since the lane it sits in already names it. + default: text as itself, a number as its digits, and a row which + states no value nothing, since its lane 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. diff --git a/dascore/viz/inventory.py b/dascore/viz/inventory.py index 99be03626..d2fbbbeba 100644 --- a/dascore/viz/inventory.py +++ b/dascore/viz/inventory.py @@ -806,7 +806,21 @@ def _segment_colors(one, color, mid, crs, handles, palette): handles.setdefault("n/a", PatchArtist(facecolor=UNPLACED, label="n/a")) return None, [UNPLACED] * len(mid) masks = interval_masks(mid, [x.interval for x in items]) - kinds = {value_kind(normalize_value(k)) for k in keys} + kinds = { + value_kind(normalize_value(k)) for k in keys if not _lanes._is_membership(k) + } + if not kinds: + # Every row states membership, so the group itself is the value + # and belonging to it is the only thing there is to color. + colors = [UNPLACED] * len(mid) + base = plt.get_cmap(_lanes.STRING_CMAP)(_lanes.WHEEL_ORDER[0]) + for mask in masks: + for position in np.flatnonzero(mask): + colors[position] = base + handles.setdefault(color, PatchArtist(facecolor=base, label=color)) + if any(c is UNPLACED for c in colors): + handles.setdefault("n/a", PatchArtist(facecolor=UNPLACED, label="n/a")) + return None, colors if kinds == {"numeric"}: values = np.full(len(mid), np.nan) for item, mask in zip(items, masks, strict=True): diff --git a/tests/test_viz/test_inventory_viz.py b/tests/test_viz/test_inventory_viz.py index cb2c6d122..7974f6e92 100644 --- a/tests/test_viz/test_inventory_viz.py +++ b/tests/test_viz/test_inventory_viz.py @@ -98,9 +98,13 @@ def _main_path(epoch: int) -> inv.OpticalPath: inv.OpticalPathLabel( start_distance=200.0, end_distance=400.0, group="zone", value="south" ), + # A label group states membership by stating no value. inv.OpticalPathLabel( start_distance=150.0, end_distance=300.0, group="noisy" ), + inv.OpticalPathLabel( + start_distance=300.0, end_distance=400.0, group="noisy" + ), inv.OpticalPathLabel( start_distance=100.0, end_distance=200.0, group="count", value=0 ), @@ -766,6 +770,13 @@ def test_color_label_group(self, site): assert "n/a" in labels assert ax.get_legend().get_title().get_text() == "zone" + def test_color_a_membership_group(self, site): + """A group everything belongs to is named by the group, not by None.""" + ax = map_path(site, "DAS.L1.00", time="2026-06-10", color="noisy") + labels = _legend_labels(ax) + assert "noisy" in labels + assert "None" not in labels + def test_color_numeric_group(self, site): """A numeric label group colors continuously.""" ax = map_path(site, "DAS.L1.00", time="2026-06-10", color="count") diff --git a/tests/test_viz/test_lanes.py b/tests/test_viz/test_lanes.py index efb1a1e8e..2d578b550 100644 --- a/tests/test_viz/test_lanes.py +++ b/tests/test_viz/test_lanes.py @@ -55,6 +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], + # A row belongs to its lane by stating no value at all. "value": ["a", "b", None, None, 1, 2, None], } ) @@ -253,7 +254,7 @@ def test_label_column(self): assert _texts(ax) == ["x"] def test_default_labels(self, kinds_frame): - """Numbers state themselves; membership rows draw no text.""" + """Numbers state themselves; a row stating no value draws no text.""" ax = plot_lanes(kinds_frame, lane="lane", value="value") assert sorted(_texts(ax)) == ["1", "2", "a", "b"] @@ -278,9 +279,10 @@ def test_string_colors_frame_wide(self, string_frame): assert labels == ["north", "south", "west"] def test_membership_lane(self, kinds_frame): - """Every row takes the lane's one color; the legend names the lane.""" + """Rows which state no value take the lane's color, and name it.""" ax = plot_lanes(kinds_frame, lane="lane", value="value", lanes=("flag",)) colors = _collections(ax)[0].get_facecolors() + assert len(colors) == 2 assert colors[0][3] == pytest.approx(1.0) assert colors[1][3] == pytest.approx(1.0) assert np.allclose(colors[0], colors[1]) @@ -331,6 +333,25 @@ def test_numeric_lanes_get_their_own_colorbar(self): assert bars[0].get_ylim() == pytest.approx((0.0, 7.0)) assert bars[1].get_ylim() == pytest.approx((100.0, 107.0)) + def test_one_value_is_not_no_value(self): + """A lone number colors its rows without swallowing the missing one.""" + frame = pd.DataFrame( + {"start": [0.0, 1.0, 2.0], "end": [1.0, 2.0, 3.0], "v": [5.0, None, 5.0]} + ) + ax = plot_lanes(frame, value="v") + colors = _collections(ax)[0].get_facecolors() + assert np.allclose(colors[0], colors[2]) + assert not np.allclose(colors[0], colors[1]) + assert np.allclose(colors[1][:3], plt.matplotlib.colors.to_rgb(UNCOVERED_COLOR)) + + def test_membership_has_one_key_in_a_mapping(self): + """A color keyed on None applies however the dtype spelled it.""" + frame = pd.DataFrame({"start": [0.0, 1.0], "end": [1.0, 2.0], "v": ["a", None]}) + ax = plot_lanes(frame, value="v", color={None: "red", "a": "blue"}) + colors = _collections(ax)[0].get_facecolors() + assert np.allclose(colors[0][:3], [0, 0, 1]) + assert np.allclose(colors[1][:3], [1, 0, 0]) + def test_numeric_one_value(self): """One number is not a scale, so every box shares one color.""" frame = pd.DataFrame({"start": [0.0, 1.0], "end": [1.0, 2.0], "v": [4, 4]}) @@ -380,16 +401,20 @@ def test_color_name_on_a_numeric_lane(self, kinds_frame): colors = _collections(ax)[0].get_facecolors() assert np.allclose(colors[:, :3], [1, 0, 0]) - def test_a_value_which_is_not_a_value(self): - """A NaN among a lane's numbers is refused: a valued lane has no gaps.""" + def test_a_number_nobody_stated(self): + """A missing number in a numeric lane is drawn, not made invisible.""" 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="mixes value kinds"): - plot_lanes(frame, value="v") + ax = plot_lanes(frame, value="v") + colors = _collections(ax)[0].get_facecolors() + # It states no value, so it takes the color which says so rather + # than the transparent one a colormap gives a NaN. + assert colors[3][3] == pytest.approx(1.0) + assert not np.allclose(colors[3], colors[0]) def test_legend_off_suppresses_the_colorbar(self): """legend='off' means no colorbar either."""