polyphys 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- polyphys/__init__.py +7 -0
- polyphys/__version__.py +22 -0
- polyphys/analyze/__init__.py +51 -0
- polyphys/analyze/clusters.py +1738 -0
- polyphys/analyze/correlations.py +995 -0
- polyphys/analyze/density.py +1138 -0
- polyphys/analyze/measurer.py +1338 -0
- polyphys/analyze/pipeline.py +1868 -0
- polyphys/analyze/stats.py +810 -0
- polyphys/analyze/sumrule.py +1159 -0
- polyphys/manage/__init__.py +11 -0
- polyphys/manage/organizer.py +2188 -0
- polyphys/manage/parser.py +1264 -0
- polyphys/manage/types.py +171 -0
- polyphys/manage/utils.py +777 -0
- polyphys/probe/__init__.py +25 -0
- polyphys/probe/binders.py +711 -0
- polyphys/probe/chain_size.py +583 -0
- polyphys/probe/core.py +437 -0
- polyphys/probe/histograms.py +863 -0
- polyphys/probe/pairs.py +555 -0
- polyphys/simulate/__init__.py +48 -0
- polyphys/simulate/build.py +1231 -0
- polyphys/simulate/core.py +237 -0
- polyphys/simulate/data.py +977 -0
- polyphys/simulate/dump.py +752 -0
- polyphys/simulate/log.py +788 -0
- polyphys/spec.py +1359 -0
- polyphys/tests/analyze/test_clusters.py +1058 -0
- polyphys/tests/analyze/test_correlations.py +708 -0
- polyphys/tests/analyze/test_density.py +813 -0
- polyphys/tests/analyze/test_measurer.py +785 -0
- polyphys/tests/analyze/test_pipeline.py +1881 -0
- polyphys/tests/analyze/test_stats.py +732 -0
- polyphys/tests/analyze/test_sumrule.py +917 -0
- polyphys/tests/conftest.py +753 -0
- polyphys/tests/data/lammps_build/make_starts.py +70 -0
- polyphys/tests/manage/test_organizer.py +1495 -0
- polyphys/tests/manage/test_parser.py +1222 -0
- polyphys/tests/manage/test_utils.py +498 -0
- polyphys/tests/probe/conftest.py +407 -0
- polyphys/tests/probe/test_binders.py +686 -0
- polyphys/tests/probe/test_chain_size.py +557 -0
- polyphys/tests/probe/test_core.py +391 -0
- polyphys/tests/probe/test_histograms.py +779 -0
- polyphys/tests/probe/test_pairs.py +475 -0
- polyphys/tests/simulate/test_build.py +1042 -0
- polyphys/tests/simulate/test_core.py +106 -0
- polyphys/tests/simulate/test_data.py +858 -0
- polyphys/tests/simulate/test_dump.py +487 -0
- polyphys/tests/simulate/test_log.py +667 -0
- polyphys/tests/strategies.py +120 -0
- polyphys/tests/synthetic.py +410 -0
- polyphys/tests/test_conftest.py +414 -0
- polyphys/tests/test_docs.py +82 -0
- polyphys/tests/test_packaging.py +97 -0
- polyphys/tests/test_spec.py +772 -0
- polyphys/tests/viz/conftest.py +32 -0
- polyphys/tests/viz/test_colors.py +113 -0
- polyphys/tests/viz/test_helpers.py +292 -0
- polyphys/viz/__init__.py +15 -0
- polyphys/viz/colors.py +204 -0
- polyphys/viz/helpers.py +549 -0
- polyphys-1.0.0.dist-info/METADATA +339 -0
- polyphys-1.0.0.dist-info/RECORD +69 -0
- polyphys-1.0.0.dist-info/WHEEL +5 -0
- polyphys-1.0.0.dist-info/licenses/AUTHORS.rst +13 -0
- polyphys-1.0.0.dist-info/licenses/LICENSE +22 -0
- polyphys-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fixtures for the tests of :mod:`polyphys.viz`.
|
|
3
|
+
|
|
4
|
+
The viz layer needs matplotlib, which the ``viz`` extra installs. The test
|
|
5
|
+
modules of the matplotlib-backed modules call
|
|
6
|
+
``pytest.importorskip("matplotlib")`` first, and the fixtures here do the
|
|
7
|
+
same, so ``test_colors.py``, whose module imports no plotting library,
|
|
8
|
+
runs without the extra. Every figure is drawn with the Agg backend, which
|
|
9
|
+
needs no display, and closed by the fixture that made it. All data is tier
|
|
10
|
+
A: a few hand-written points.
|
|
11
|
+
"""
|
|
12
|
+
from typing import Any, Iterator
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def figure() -> Iterator[Any]:
|
|
19
|
+
"""An empty Agg figure, closed after the test."""
|
|
20
|
+
matplotlib = pytest.importorskip("matplotlib")
|
|
21
|
+
matplotlib.use("Agg")
|
|
22
|
+
import matplotlib.pyplot as plt
|
|
23
|
+
|
|
24
|
+
fig = plt.figure()
|
|
25
|
+
yield fig
|
|
26
|
+
plt.close(fig)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def ax(figure: Any) -> Any:
|
|
31
|
+
"""One empty axes on the test's figure."""
|
|
32
|
+
return figure.add_subplot()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Tests for :mod:`polyphys.viz.colors`.
|
|
2
|
+
|
|
3
|
+
The expected colors of the three Tol palettes are read from Figures 1, 3,
|
|
4
|
+
and 4 of Paul Tol, *Colour Schemes*, SRON/EPS/TN/09-002, issue 3.2
|
|
5
|
+
(18 August 2021), in the fixed-sequence order its page 6 gives; those of
|
|
6
|
+
``DUTCH_FIELD`` from the wpDataTables article the module docstring cites,
|
|
7
|
+
read on 2026-11-06. The form test states a property of every palette. The
|
|
8
|
+
expected endpoints of a truncated map are the parent map's own values at
|
|
9
|
+
the cut points, a reproducible computation on the parent; the count and
|
|
10
|
+
name follow from the arguments.
|
|
11
|
+
"""
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from polyphys.viz.colors import (
|
|
17
|
+
DUTCH_FIELD,
|
|
18
|
+
STUDY_COLORS,
|
|
19
|
+
TOL_BRIGHT,
|
|
20
|
+
TOL_MUTED,
|
|
21
|
+
TOL_VIBRANT,
|
|
22
|
+
truncate_colormap,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def viridis():
|
|
28
|
+
"""matplotlib's registered viridis map, the parent of every cut."""
|
|
29
|
+
matplotlib = pytest.importorskip("matplotlib")
|
|
30
|
+
return matplotlib.colormaps["viridis"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.parametrize("palette, expected", [
|
|
34
|
+
pytest.param(
|
|
35
|
+
TOL_BRIGHT,
|
|
36
|
+
("#4477AA", "#EE6677", "#228833", "#CCBB44", "#66CCEE", "#AA3377",
|
|
37
|
+
"#BBBBBB"),
|
|
38
|
+
id="tol-bright-fig1"),
|
|
39
|
+
pytest.param(
|
|
40
|
+
TOL_VIBRANT,
|
|
41
|
+
("#EE7733", "#0077BB", "#33BBEE", "#EE3377", "#CC3311", "#009988",
|
|
42
|
+
"#BBBBBB"),
|
|
43
|
+
id="tol-vibrant-fig3"),
|
|
44
|
+
pytest.param(
|
|
45
|
+
TOL_MUTED,
|
|
46
|
+
("#CC6677", "#332288", "#DDCC77", "#117733", "#88CCEE", "#882255",
|
|
47
|
+
"#44AA99", "#999933", "#AA4499", "#DDDDDD"),
|
|
48
|
+
id="tol-muted-fig4"),
|
|
49
|
+
pytest.param(
|
|
50
|
+
DUTCH_FIELD,
|
|
51
|
+
("#e60049", "#0bb4ff", "#50e991", "#e6d800", "#9b19f5", "#ffa300",
|
|
52
|
+
"#dc0ab4", "#b3d4ff", "#00bfa0"),
|
|
53
|
+
id="dutch-field-wpdatatables"),
|
|
54
|
+
])
|
|
55
|
+
def test_palette_matches_its_source(palette, expected):
|
|
56
|
+
"""Each cited palette holds its source's colors in the source's order."""
|
|
57
|
+
assert palette == expected
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.parametrize("palette", [
|
|
61
|
+
pytest.param(TOL_BRIGHT, id="tol-bright"),
|
|
62
|
+
pytest.param(TOL_VIBRANT, id="tol-vibrant"),
|
|
63
|
+
pytest.param(TOL_MUTED, id="tol-muted"),
|
|
64
|
+
pytest.param(DUTCH_FIELD, id="dutch-field"),
|
|
65
|
+
pytest.param(STUDY_COLORS, id="study-colors"),
|
|
66
|
+
])
|
|
67
|
+
def test_palette_entries_are_distinct_hex_colors(palette):
|
|
68
|
+
"""Every palette entry is a #RRGGBB string, and no color repeats."""
|
|
69
|
+
hex_color = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
|
70
|
+
|
|
71
|
+
assert all(hex_color.match(color) for color in palette)
|
|
72
|
+
assert len(set(color.lower() for color in palette)) == len(palette)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_truncate_colormap_endpoints_are_the_parent_values(viridis):
|
|
76
|
+
"""The cut map starts at cmap(lower) and ends at cmap(upper)."""
|
|
77
|
+
np = pytest.importorskip("numpy")
|
|
78
|
+
|
|
79
|
+
cut = truncate_colormap(viridis, 0.2, 0.8, n=5)
|
|
80
|
+
|
|
81
|
+
# Exact: the endpoint colors are copied from the parent's lookup table.
|
|
82
|
+
np.testing.assert_array_equal(cut(0.0), viridis(0.2))
|
|
83
|
+
np.testing.assert_array_equal(cut(1.0), viridis(0.8))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_truncate_colormap_has_n_colors_and_a_named_range(viridis):
|
|
87
|
+
"""The cut map samples n colors and is named trunc(<name>,lower,upper)."""
|
|
88
|
+
cut = truncate_colormap(viridis, 0.2, 0.8, n=5)
|
|
89
|
+
|
|
90
|
+
assert cut.N == 5
|
|
91
|
+
assert cut.name == "trunc(viridis,0.2,0.8)"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_truncate_colormap_accepts_a_registered_name(viridis):
|
|
95
|
+
"""A color map name is looked up in matplotlib's registry."""
|
|
96
|
+
np = pytest.importorskip("numpy")
|
|
97
|
+
|
|
98
|
+
cut = truncate_colormap("viridis", 0.2, 0.8, n=5)
|
|
99
|
+
|
|
100
|
+
np.testing.assert_array_equal(cut(0.0), viridis(0.2))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.parametrize("lower, upper, n", [
|
|
104
|
+
pytest.param(0.8, 0.2, 5, id="lower-above-upper"),
|
|
105
|
+
pytest.param(-0.1, 1.0, 5, id="lower-below-zero"),
|
|
106
|
+
pytest.param(0.0, 1.1, 5, id="upper-above-one"),
|
|
107
|
+
pytest.param(0.0, 1.0, 1, id="one-color"),
|
|
108
|
+
])
|
|
109
|
+
def test_truncate_colormap_rejects_a_bad_range_or_count(viridis, lower,
|
|
110
|
+
upper, n):
|
|
111
|
+
"""A range outside 0 <= lower < upper <= 1, or n below 2, is refused."""
|
|
112
|
+
with pytest.raises(ValueError, match="lower|n must"):
|
|
113
|
+
truncate_colormap(viridis, lower, upper, n=n)
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Tests for :mod:`polyphys.viz.helpers`.
|
|
2
|
+
|
|
3
|
+
Every expected tick value is hand arithmetic on the module's rule: ticks
|
|
4
|
+
every `step` from `lower` to `upper`, limits widened by ``step / 10``
|
|
5
|
+
rounded down to its first non-zero digit (``0.025 -> 0.02``,
|
|
6
|
+
``0.05 -> 0.05``), labels rounded to `decimals`. Every expected legend value
|
|
7
|
+
is the input the handle or legend was built from: the labels, colors,
|
|
8
|
+
styles, and titles given, read back from the artists.
|
|
9
|
+
"""
|
|
10
|
+
import importlib
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
pytest.importorskip("matplotlib")
|
|
17
|
+
|
|
18
|
+
import matplotlib # noqa: E402
|
|
19
|
+
import numpy as np # noqa: E402
|
|
20
|
+
from matplotlib.colors import to_rgba # noqa: E402
|
|
21
|
+
from matplotlib.lines import Line2D # noqa: E402
|
|
22
|
+
from matplotlib.patches import Patch # noqa: E402
|
|
23
|
+
|
|
24
|
+
from polyphys.viz.helpers import ( # noqa: E402
|
|
25
|
+
band_under_lines,
|
|
26
|
+
color_handles,
|
|
27
|
+
line_handles,
|
|
28
|
+
move_legend_to_axes,
|
|
29
|
+
set_xticks,
|
|
30
|
+
set_yticks,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture
|
|
35
|
+
def two_panels(figure):
|
|
36
|
+
"""Two side-by-side axes; the left one holds two labeled lines and the
|
|
37
|
+
figure a legend of them titled 'curves'."""
|
|
38
|
+
left, right = figure.subplots(ncols=2)
|
|
39
|
+
left.plot([0, 1], [0, 1], label="rise")
|
|
40
|
+
left.plot([0, 1], [1, 0], label="fall")
|
|
41
|
+
legend = figure.legend(title="curves")
|
|
42
|
+
return left, right, legend
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_set_xticks_places_ticks_every_step_to_upper(ax):
|
|
46
|
+
"""Ticks run from lower to upper in steps of step, upper included."""
|
|
47
|
+
ticks = set_xticks(ax, 0.0, 1.0, 0.25)
|
|
48
|
+
|
|
49
|
+
# Exact: the positions are lower + k * step for a step with a short
|
|
50
|
+
# binary expansion.
|
|
51
|
+
np.testing.assert_array_equal(ticks, [0.0, 0.25, 0.5, 0.75, 1.0])
|
|
52
|
+
np.testing.assert_array_equal(ax.get_xticks(), ticks)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_set_xticks_widens_the_limits_by_the_tolerance(ax):
|
|
56
|
+
"""The limits sit one rounded-down tenth of a step beyond the ticks."""
|
|
57
|
+
set_xticks(ax, 0.0, 1.0, 0.25)
|
|
58
|
+
|
|
59
|
+
# 0.25 / 10 = 0.025 rounds down to 0.02; float64 round-off tolerance.
|
|
60
|
+
assert ax.get_xlim() == pytest.approx((-0.02, 1.02), abs=1e-12)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_set_xticks_writes_a_near_zero_tick_as_exactly_zero(ax):
|
|
64
|
+
"""A tick that float arithmetic puts near zero is exactly 0.0."""
|
|
65
|
+
# -0.3 + 3 * 0.1 is 5.55e-17 in float64, not 0.0.
|
|
66
|
+
ticks = set_xticks(ax, -0.3, 0.3, 0.1)
|
|
67
|
+
|
|
68
|
+
assert ticks[3] == 0.0
|
|
69
|
+
assert ax.get_xticklabels()[3].get_text() == "0.0"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_set_xticks_hides_labels_on_request(ax):
|
|
73
|
+
"""labels=False leaves every tick label empty."""
|
|
74
|
+
set_xticks(ax, 0.0, 1.0, 0.25, labels=False)
|
|
75
|
+
|
|
76
|
+
assert [label.get_text() for label in ax.get_xticklabels()] == [""] * 5
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_set_xticks_rounds_labels_to_decimals(ax):
|
|
80
|
+
"""Labels are the ticks rounded to `decimals` places."""
|
|
81
|
+
set_xticks(ax, 0.0, 0.9, 0.3, decimals=1)
|
|
82
|
+
|
|
83
|
+
assert ([label.get_text() for label in ax.get_xticklabels()]
|
|
84
|
+
== ["0.0", "0.3", "0.6", "0.9"])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_set_xticks_forwards_text_kwargs_to_the_labels(ax):
|
|
88
|
+
"""Keyword arguments such as fontsize reach every tick label."""
|
|
89
|
+
set_xticks(ax, 0.0, 1.0, 0.5, fontsize=7)
|
|
90
|
+
|
|
91
|
+
assert all(label.get_fontsize() == 7 for label in ax.get_xticklabels())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.parametrize("lower, upper, step", [
|
|
95
|
+
pytest.param(0.0, 1.0, 0.0, id="zero-step"),
|
|
96
|
+
pytest.param(0.0, 1.0, -0.1, id="negative-step"),
|
|
97
|
+
pytest.param(0.0, 1.0, 1.0, id="step-equals-span"),
|
|
98
|
+
pytest.param(1.0, 0.0, 0.1, id="lower-above-upper"),
|
|
99
|
+
])
|
|
100
|
+
def test_set_xticks_rejects_a_bad_step_or_order(ax, lower, upper, step):
|
|
101
|
+
"""A step that is not positive and below the span, or lower >= upper,
|
|
102
|
+
is refused."""
|
|
103
|
+
with pytest.raises(ValueError, match="step must|lower must"):
|
|
104
|
+
set_xticks(ax, lower, upper, step)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_set_yticks_applies_the_same_rule_on_the_y_axis(ax):
|
|
108
|
+
"""set_yticks places the ticks and widens the limits of the y axis."""
|
|
109
|
+
ticks = set_yticks(ax, 0.0, 1.0, 0.5)
|
|
110
|
+
|
|
111
|
+
np.testing.assert_array_equal(ticks, [0.0, 0.5, 1.0])
|
|
112
|
+
np.testing.assert_array_equal(ax.get_yticks(), ticks)
|
|
113
|
+
# 0.5 / 10 = 0.05 is already one significant digit.
|
|
114
|
+
assert ax.get_ylim() == pytest.approx((-0.05, 1.05), abs=1e-12)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_color_handles_make_one_patch_per_label_in_order():
|
|
118
|
+
"""Each label gets a Patch of its color, in the order given."""
|
|
119
|
+
handles = color_handles(["a", "b"], ["#ff0000", "#0000ff"])
|
|
120
|
+
|
|
121
|
+
assert all(isinstance(handle, Patch) for handle in handles)
|
|
122
|
+
assert [handle.get_label() for handle in handles] == ["a", "b"]
|
|
123
|
+
assert [handle.get_facecolor() for handle in handles] == [
|
|
124
|
+
to_rgba("#ff0000"), to_rgba("#0000ff")]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.mark.parametrize("labels, color", [
|
|
128
|
+
pytest.param(["a", "b", "c"], (0.1, 0.2, 0.3), id="rgb-tuple"),
|
|
129
|
+
pytest.param(["a", "b"], (0.1, 0.2, 0.3, 0.5), id="rgba-tuple"),
|
|
130
|
+
pytest.param(["a", "b"], "red", id="name"),
|
|
131
|
+
])
|
|
132
|
+
def test_color_handles_repeat_one_color_for_every_label(labels, color):
|
|
133
|
+
"""One color, a name or an RGB(A) tuple, is given to every label."""
|
|
134
|
+
handles = color_handles(labels, color)
|
|
135
|
+
|
|
136
|
+
assert [handle.get_facecolor() for handle in handles] == \
|
|
137
|
+
[to_rgba(color)] * len(labels)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_color_handles_reject_a_list_of_the_wrong_length():
|
|
141
|
+
"""A list of colors must have one entry per label."""
|
|
142
|
+
with pytest.raises(ValueError, match="colors has 3 entries"):
|
|
143
|
+
color_handles(["a", "b"], ["red", "green", "blue"])
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_line_handles_repeat_a_numpy_integer_width():
|
|
147
|
+
"""A NumPy integer width is one value, given to every label."""
|
|
148
|
+
handles = line_handles(["a", "b"], linewidths=np.int64(2))
|
|
149
|
+
|
|
150
|
+
assert [h.get_linewidth() for h in handles] == [2, 2]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_line_handles_repeat_a_dash_tuple_for_every_label():
|
|
154
|
+
"""An (offset, on-off sequence) dash tuple is one line style."""
|
|
155
|
+
handles = line_handles(["a", "b"], linestyles=(0, (5, 2)))
|
|
156
|
+
|
|
157
|
+
assert [h.get_linestyle() for h in handles] == ["--", "--"]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_line_handles_carry_the_style_marker_and_color_given():
|
|
161
|
+
"""Each label gets a Line2D with its own style and marker and the shared
|
|
162
|
+
color and width."""
|
|
163
|
+
handles = line_handles(["a", "b"], linestyles=["-", "--"],
|
|
164
|
+
markers=["o", "^"], linewidths=2, color="red")
|
|
165
|
+
|
|
166
|
+
assert all(isinstance(handle, Line2D) for handle in handles)
|
|
167
|
+
assert [(h.get_label(), h.get_linestyle(), h.get_marker())
|
|
168
|
+
for h in handles] == [("a", "-", "o"), ("b", "--", "^")]
|
|
169
|
+
assert [h.get_linewidth() for h in handles] == [2, 2]
|
|
170
|
+
assert [h.get_color() for h in handles] == ["red", "red"]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_line_handles_reject_a_sequence_of_the_wrong_length():
|
|
174
|
+
"""A per-label sequence must have one entry per label."""
|
|
175
|
+
with pytest.raises(ValueError, match="markers has 3 entries"):
|
|
176
|
+
line_handles(["a", "b"], markers=["o", "^", "s"])
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_move_legend_to_axes_moves_entries_and_title_into_the_axes(
|
|
180
|
+
two_panels):
|
|
181
|
+
"""The figure legend disappears and each target axes gets a legend with
|
|
182
|
+
the same labels and title."""
|
|
183
|
+
left, right, legend = two_panels
|
|
184
|
+
|
|
185
|
+
new = move_legend_to_axes(legend, [left, right],
|
|
186
|
+
["upper left", "lower right"])
|
|
187
|
+
|
|
188
|
+
assert left.figure.legends == []
|
|
189
|
+
assert [leg.axes for leg in new] == [left, right]
|
|
190
|
+
for panel in (left, right):
|
|
191
|
+
texts = [t.get_text() for t in panel.get_legend().get_texts()]
|
|
192
|
+
assert texts == ["rise", "fall"]
|
|
193
|
+
assert panel.get_legend().get_title().get_text() == "curves"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_move_legend_to_axes_takes_a_new_title(two_panels):
|
|
197
|
+
"""A title argument replaces the moved legend's title."""
|
|
198
|
+
left, right, legend = two_panels
|
|
199
|
+
|
|
200
|
+
move_legend_to_axes(legend, [right], ["upper left"], title="new")
|
|
201
|
+
|
|
202
|
+
assert right.get_legend().get_title().get_text() == "new"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_move_legend_to_axes_rejects_mismatched_locations(two_panels):
|
|
206
|
+
"""One location per target axes is required."""
|
|
207
|
+
left, right, legend = two_panels
|
|
208
|
+
|
|
209
|
+
with pytest.raises(ValueError, match="locs has 1 entries for 2 axes"):
|
|
210
|
+
move_legend_to_axes(legend, [left, right], ["upper left"])
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_band_under_lines_shades_each_band_in_its_line_color(ax):
|
|
214
|
+
"""One band per line is filled between the given edges, over the line's
|
|
215
|
+
x data, in the line's color."""
|
|
216
|
+
ax.plot([0, 1, 2], [0.5, 0.5, 0.5], color="#ff0000")
|
|
217
|
+
ax.plot([0, 1, 2], [1.5, 1.5, 1.5], color="#0000ff")
|
|
218
|
+
|
|
219
|
+
bands = band_under_lines(
|
|
220
|
+
ax, [([0, 0, 0], [1, 1, 1]), ([1, 1, 1], [2, 2, 2])], alpha=0.5)
|
|
221
|
+
|
|
222
|
+
assert len(bands) == 2
|
|
223
|
+
assert [tuple(b.get_facecolor()[0]) for b in bands] == [
|
|
224
|
+
to_rgba("#ff0000", 0.5), to_rgba("#0000ff", 0.5)]
|
|
225
|
+
for band, (lower, upper) in zip(bands, [(0, 1), (1, 2)]):
|
|
226
|
+
vertices = band.get_paths()[0].vertices
|
|
227
|
+
# Exact: the polygon vertices are the integer edges given.
|
|
228
|
+
assert vertices[:, 1].min() == lower
|
|
229
|
+
assert vertices[:, 1].max() == upper
|
|
230
|
+
np.testing.assert_array_equal(np.unique(vertices[:, 0]), [0, 1, 2])
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@pytest.mark.parametrize("bands, message", [
|
|
234
|
+
pytest.param([([0], [1]), ([0], [1])], "2 bands were given for 1 lines",
|
|
235
|
+
id="more-bands-than-lines"),
|
|
236
|
+
pytest.param([([0, 0], [1, 1])], "a band has edges of shape",
|
|
237
|
+
id="edge-length-differs-from-line"),
|
|
238
|
+
])
|
|
239
|
+
def test_band_under_lines_rejects_a_band_that_does_not_fit(ax, bands,
|
|
240
|
+
message):
|
|
241
|
+
"""More bands than lines, or an edge of the wrong length, is refused."""
|
|
242
|
+
ax.plot([0, 1, 2], [0, 0, 0])
|
|
243
|
+
|
|
244
|
+
with pytest.raises(ValueError, match=message):
|
|
245
|
+
band_under_lines(ax, bands)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# On the research code's space table (tier C)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _plot_state_points(ax, table, colors): # pragma: no cover - tier C
|
|
252
|
+
"""One line per state point of a space table, ``gyrTMon-mean`` against
|
|
253
|
+
``time``, in the given colors; returns the labels, the rounded crowder
|
|
254
|
+
volume fractions."""
|
|
255
|
+
labels = []
|
|
256
|
+
for (phi, rows), color in zip(table.groupby("phi_bulk_c_round"), colors):
|
|
257
|
+
ax.plot(rows["time"], rows["gyrTMon-mean"], color=color)
|
|
258
|
+
labels.append(f"{phi:g}")
|
|
259
|
+
return labels
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@pytest.mark.private_data
|
|
263
|
+
def test_helpers_draw_the_research_space_table( # pragma: no cover
|
|
264
|
+
ax, private_data_dir):
|
|
265
|
+
"""A figure of the research code's TransFociCub gyration space table has
|
|
266
|
+
one line per state point, one legend handle per line in its color from
|
|
267
|
+
``color_handles``, and x ticks from ``set_xticks`` whose first and last
|
|
268
|
+
frame the time axis. A property check: there is no research figure to
|
|
269
|
+
compare with."""
|
|
270
|
+
from polyphys.analyze.pipeline import space_tseries_from_spec
|
|
271
|
+
|
|
272
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
|
273
|
+
spec = importlib.import_module("projects.trans_foci_cub.spec").SPEC
|
|
274
|
+
parent = private_data_dir / "TransFociCub" / "analysis"
|
|
275
|
+
folders = sorted(parent.glob("*-bug-ensAvg"))
|
|
276
|
+
if len(folders) != 1:
|
|
277
|
+
pytest.skip(f"no single *-bug-ensAvg folder under {parent}")
|
|
278
|
+
table = space_tseries_from_spec(folders[0], spec, "gyrTMon")
|
|
279
|
+
n_states = table["ensemble_long"].nunique()
|
|
280
|
+
colors = [tuple(color) for color in matplotlib.colormaps["viridis"](
|
|
281
|
+
np.linspace(0.0, 1.0, n_states))]
|
|
282
|
+
t_max = float(table["time"].max())
|
|
283
|
+
|
|
284
|
+
labels = _plot_state_points(ax, table, colors)
|
|
285
|
+
handles = color_handles(labels, colors)
|
|
286
|
+
ticks = set_xticks(ax, 0.0, t_max, t_max / 5)
|
|
287
|
+
|
|
288
|
+
assert len(ax.lines) == n_states == len(handles)
|
|
289
|
+
assert [to_rgba(handle.get_facecolor()) for handle in handles] == [
|
|
290
|
+
to_rgba(line.get_color()) for line in ax.lines]
|
|
291
|
+
assert ticks[0] <= table["time"].min() and ticks[-1] >= t_max
|
|
292
|
+
assert ax.get_xlim()[0] <= 0.0 and ax.get_xlim()[1] >= t_max
|
polyphys/viz/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The viz layer: color lists and small matplotlib helpers for the studies'
|
|
3
|
+
figures, which the ``viz`` extra makes usable
|
|
4
|
+
(``python -m pip install 'polyphys[viz]'``).
|
|
5
|
+
|
|
6
|
+
:mod:`polyphys.viz.colors` holds the color lists, which need no plotting
|
|
7
|
+
library, and :func:`~polyphys.viz.colors.truncate_colormap`, which imports
|
|
8
|
+
matplotlib when called. :mod:`polyphys.viz.helpers` needs matplotlib at
|
|
9
|
+
import and raises :exc:`ImportError` with that hint when it is missing. This
|
|
10
|
+
package imports neither module on its own; import the one you need.
|
|
11
|
+
|
|
12
|
+
.. versionadded:: 0.8.0
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__all__ = ["colors", "helpers"]
|
polyphys/viz/colors.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""
|
|
2
|
+
==========================================================
|
|
3
|
+
:mod:`polyphys.viz.colors`
|
|
4
|
+
==========================================================
|
|
5
|
+
|
|
6
|
+
The :mod:`polyphys.viz.colors` module holds the qualitative color lists the
|
|
7
|
+
studies' figures use, as tuples of ``#RRGGBB`` strings, and
|
|
8
|
+
:func:`truncate_colormap`, which cuts a matplotlib color map down to a
|
|
9
|
+
sub-range of its domain so that, for instance, the pale end of a sequential
|
|
10
|
+
map does not vanish against a white background.
|
|
11
|
+
|
|
12
|
+
The module imports no plotting library at import time, so the color lists
|
|
13
|
+
can be read without the ``viz`` extra and passed to any plotting call that
|
|
14
|
+
accepts matplotlib color strings; :func:`truncate_colormap` imports
|
|
15
|
+
matplotlib when called and raises :exc:`ImportError` with the install hint
|
|
16
|
+
when it is missing.
|
|
17
|
+
|
|
18
|
+
Sources
|
|
19
|
+
=======
|
|
20
|
+
|
|
21
|
+
``TOL_BRIGHT``, ``TOL_VIBRANT``, ``TOL_MUTED``
|
|
22
|
+
Paul Tol, *Colour Schemes*, SRON Technical Note SRON/EPS/TN/09-002,
|
|
23
|
+
issue 3.2, 18 August 2021, Figures 1, 3, and 4
|
|
24
|
+
(https://sronpersonalpages.nl/~pault/data/colourschemes.pdf). The
|
|
25
|
+
schemes are designed to be distinct for color-blind readers, distinct
|
|
26
|
+
from black and white, and distinct on screen and paper. Each tuple is
|
|
27
|
+
in the order the note gives for picking colors in a fixed sequence
|
|
28
|
+
(page 6), and ends with the scheme's gray, which the note reserves for
|
|
29
|
+
bad or missing data.
|
|
30
|
+
``DUTCH_FIELD``
|
|
31
|
+
A nine-color palette named "Dutch Field" in a HEAVY.AI (formerly
|
|
32
|
+
OmniSci) blog post, "12 color palettes for telling better stories with
|
|
33
|
+
your data", which could not be opened when this module was written. The
|
|
34
|
+
values here are the ones listed, under the same name, by
|
|
35
|
+
M. Jovanovic, "12 Great Data Visualization Color Palettes to Use",
|
|
36
|
+
wpDataTables, 17 December 2023
|
|
37
|
+
(https://wpdatatables.com/data-visualization-color-palette/), read on
|
|
38
|
+
2026-11-06. Neither page states terms of reuse.
|
|
39
|
+
``STUDY_COLORS``
|
|
40
|
+
The maintainer's own twelve-color list, assembled from the lists above
|
|
41
|
+
and CSS named colors for the published figures; its fifth entry is
|
|
42
|
+
matplotlib's ``gray``, ``#808080``.
|
|
43
|
+
|
|
44
|
+
Examples
|
|
45
|
+
========
|
|
46
|
+
|
|
47
|
+
>>> from polyphys.viz.colors import TOL_BRIGHT, truncate_colormap
|
|
48
|
+
>>> TOL_BRIGHT[0]
|
|
49
|
+
'#4477AA'
|
|
50
|
+
>>> len(TOL_BRIGHT)
|
|
51
|
+
7
|
|
52
|
+
>>> truncate_colormap("viridis", 0.2, 0.8, n=5).name
|
|
53
|
+
'trunc(viridis,0.2,0.8)'
|
|
54
|
+
"""
|
|
55
|
+
from typing import TYPE_CHECKING, Union
|
|
56
|
+
|
|
57
|
+
import numpy as np
|
|
58
|
+
|
|
59
|
+
if TYPE_CHECKING: # pragma: no cover - imported for the annotations only
|
|
60
|
+
from matplotlib.colors import Colormap, LinearSegmentedColormap
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"DUTCH_FIELD",
|
|
64
|
+
"STUDY_COLORS",
|
|
65
|
+
"TOL_BRIGHT",
|
|
66
|
+
"TOL_MUTED",
|
|
67
|
+
"TOL_VIBRANT",
|
|
68
|
+
"truncate_colormap",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
#: Tol's *bright* scheme, his default for lines and their labels: blue, red,
|
|
72
|
+
#: green, yellow, cyan, purple, grey.
|
|
73
|
+
TOL_BRIGHT: tuple[str, ...] = (
|
|
74
|
+
"#4477AA",
|
|
75
|
+
"#EE6677",
|
|
76
|
+
"#228833",
|
|
77
|
+
"#CCBB44",
|
|
78
|
+
"#66CCEE",
|
|
79
|
+
"#AA3377",
|
|
80
|
+
"#BBBBBB",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
#: Tol's *vibrant* scheme, designed for TensorBoard: orange, blue, cyan,
|
|
84
|
+
#: magenta, red, teal, grey.
|
|
85
|
+
TOL_VIBRANT: tuple[str, ...] = (
|
|
86
|
+
"#EE7733",
|
|
87
|
+
"#0077BB",
|
|
88
|
+
"#33BBEE",
|
|
89
|
+
"#EE3377",
|
|
90
|
+
"#CC3311",
|
|
91
|
+
"#009988",
|
|
92
|
+
"#BBBBBB",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
#: Tol's *muted* scheme, with more colors but no clear red or medium blue:
|
|
96
|
+
#: rose, indigo, sand, green, cyan, wine, teal, olive, purple, pale grey.
|
|
97
|
+
TOL_MUTED: tuple[str, ...] = (
|
|
98
|
+
"#CC6677",
|
|
99
|
+
"#332288",
|
|
100
|
+
"#DDCC77",
|
|
101
|
+
"#117733",
|
|
102
|
+
"#88CCEE",
|
|
103
|
+
"#882255",
|
|
104
|
+
"#44AA99",
|
|
105
|
+
"#999933",
|
|
106
|
+
"#AA4499",
|
|
107
|
+
"#DDDDDD",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
#: The nine-color "Dutch Field" palette; see the module docstring for its
|
|
111
|
+
#: provenance.
|
|
112
|
+
DUTCH_FIELD: tuple[str, ...] = (
|
|
113
|
+
"#e60049",
|
|
114
|
+
"#0bb4ff",
|
|
115
|
+
"#50e991",
|
|
116
|
+
"#e6d800",
|
|
117
|
+
"#9b19f5",
|
|
118
|
+
"#ffa300",
|
|
119
|
+
"#dc0ab4",
|
|
120
|
+
"#b3d4ff",
|
|
121
|
+
"#00bfa0",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
#: The maintainer's twelve-color list for the published figures.
|
|
125
|
+
STUDY_COLORS: tuple[str, ...] = (
|
|
126
|
+
"#00008b",
|
|
127
|
+
"#e60049",
|
|
128
|
+
"#9b19f5",
|
|
129
|
+
"#228b22",
|
|
130
|
+
"#808080",
|
|
131
|
+
"#ffe119",
|
|
132
|
+
"#ff00ff",
|
|
133
|
+
"#00ffff",
|
|
134
|
+
"#ffa300",
|
|
135
|
+
"#00ff00",
|
|
136
|
+
"#8b4513",
|
|
137
|
+
"#000000",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def truncate_colormap(
|
|
142
|
+
cmap: "Union[str, Colormap]",
|
|
143
|
+
lower: float = 0.0,
|
|
144
|
+
upper: float = 1.0,
|
|
145
|
+
n: int = 256,
|
|
146
|
+
) -> "LinearSegmentedColormap":
|
|
147
|
+
"""
|
|
148
|
+
Cut `cmap` down to the sub-range ``[lower, upper]`` of its domain.
|
|
149
|
+
|
|
150
|
+
The result is a linear segmented color map through the `n` colors
|
|
151
|
+
that `cmap` takes at equally spaced points of ``[lower, upper]``, so its
|
|
152
|
+
value at 0 is ``cmap(lower)`` and its value at 1 is ``cmap(upper)``.
|
|
153
|
+
Time and space are :math:`O(n)`.
|
|
154
|
+
|
|
155
|
+
Parameters
|
|
156
|
+
----------
|
|
157
|
+
cmap : str or matplotlib.colors.Colormap
|
|
158
|
+
The color map to cut, or the name of a registered one, such as
|
|
159
|
+
``'viridis'``.
|
|
160
|
+
lower, upper : float, default 0.0 and 1.0
|
|
161
|
+
The sub-range of ``[0, 1]`` to keep; ``0 <= lower < upper <= 1``.
|
|
162
|
+
n : int, default 256
|
|
163
|
+
The number of colors sampled from `cmap`, at least 2.
|
|
164
|
+
|
|
165
|
+
Returns
|
|
166
|
+
-------
|
|
167
|
+
matplotlib.colors.LinearSegmentedColormap
|
|
168
|
+
The truncated map, named ``'trunc(<cmap.name>,<lower>,<upper>)'``.
|
|
169
|
+
|
|
170
|
+
Raises
|
|
171
|
+
------
|
|
172
|
+
ImportError
|
|
173
|
+
If matplotlib is not installed; the ``viz`` extra installs it.
|
|
174
|
+
ValueError
|
|
175
|
+
If the range is not ``0 <= lower < upper <= 1`` or `n` is below 2.
|
|
176
|
+
|
|
177
|
+
Examples
|
|
178
|
+
--------
|
|
179
|
+
>>> import matplotlib
|
|
180
|
+
>>> dark = truncate_colormap("viridis", 0.5, 1.0, n=4)
|
|
181
|
+
>>> dark.name
|
|
182
|
+
'trunc(viridis,0.5,1.0)'
|
|
183
|
+
>>> dark(0.0) == matplotlib.colormaps["viridis"](0.5)
|
|
184
|
+
True
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
import matplotlib
|
|
188
|
+
from matplotlib.colors import LinearSegmentedColormap
|
|
189
|
+
except ImportError as error: # pragma: no cover - depends on the install
|
|
190
|
+
raise ImportError(
|
|
191
|
+
"polyphys.viz.colors.truncate_colormap needs matplotlib; "
|
|
192
|
+
"install it with python -m pip install 'polyphys[viz]'."
|
|
193
|
+
) from error
|
|
194
|
+
if isinstance(cmap, str):
|
|
195
|
+
cmap = matplotlib.colormaps[cmap]
|
|
196
|
+
if not 0.0 <= lower < upper <= 1.0:
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"the range must satisfy 0 <= lower < upper <= 1, got "
|
|
199
|
+
f"lower={lower} and upper={upper}.")
|
|
200
|
+
if n < 2:
|
|
201
|
+
raise ValueError(f"n must be at least 2, got {n}.")
|
|
202
|
+
colors = cmap(np.linspace(lower, upper, n))
|
|
203
|
+
return LinearSegmentedColormap.from_list(
|
|
204
|
+
f"trunc({cmap.name},{lower},{upper})", colors, N=n)
|