Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 25 additions & 18 deletions dascore/viz/_lanes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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"):
Expand All @@ -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()


Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down Expand 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")
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 1 addition & 4 deletions tests/test_viz/test_inventory_viz.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 20 additions & 7 deletions tests/test_viz/test_lanes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
}
)

Expand Down Expand Up @@ -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"]

Expand All @@ -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",))
Expand Down Expand Up @@ -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):
Expand Down
Loading