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.
Files changed (69) hide show
  1. polyphys/__init__.py +7 -0
  2. polyphys/__version__.py +22 -0
  3. polyphys/analyze/__init__.py +51 -0
  4. polyphys/analyze/clusters.py +1738 -0
  5. polyphys/analyze/correlations.py +995 -0
  6. polyphys/analyze/density.py +1138 -0
  7. polyphys/analyze/measurer.py +1338 -0
  8. polyphys/analyze/pipeline.py +1868 -0
  9. polyphys/analyze/stats.py +810 -0
  10. polyphys/analyze/sumrule.py +1159 -0
  11. polyphys/manage/__init__.py +11 -0
  12. polyphys/manage/organizer.py +2188 -0
  13. polyphys/manage/parser.py +1264 -0
  14. polyphys/manage/types.py +171 -0
  15. polyphys/manage/utils.py +777 -0
  16. polyphys/probe/__init__.py +25 -0
  17. polyphys/probe/binders.py +711 -0
  18. polyphys/probe/chain_size.py +583 -0
  19. polyphys/probe/core.py +437 -0
  20. polyphys/probe/histograms.py +863 -0
  21. polyphys/probe/pairs.py +555 -0
  22. polyphys/simulate/__init__.py +48 -0
  23. polyphys/simulate/build.py +1231 -0
  24. polyphys/simulate/core.py +237 -0
  25. polyphys/simulate/data.py +977 -0
  26. polyphys/simulate/dump.py +752 -0
  27. polyphys/simulate/log.py +788 -0
  28. polyphys/spec.py +1359 -0
  29. polyphys/tests/analyze/test_clusters.py +1058 -0
  30. polyphys/tests/analyze/test_correlations.py +708 -0
  31. polyphys/tests/analyze/test_density.py +813 -0
  32. polyphys/tests/analyze/test_measurer.py +785 -0
  33. polyphys/tests/analyze/test_pipeline.py +1881 -0
  34. polyphys/tests/analyze/test_stats.py +732 -0
  35. polyphys/tests/analyze/test_sumrule.py +917 -0
  36. polyphys/tests/conftest.py +753 -0
  37. polyphys/tests/data/lammps_build/make_starts.py +70 -0
  38. polyphys/tests/manage/test_organizer.py +1495 -0
  39. polyphys/tests/manage/test_parser.py +1222 -0
  40. polyphys/tests/manage/test_utils.py +498 -0
  41. polyphys/tests/probe/conftest.py +407 -0
  42. polyphys/tests/probe/test_binders.py +686 -0
  43. polyphys/tests/probe/test_chain_size.py +557 -0
  44. polyphys/tests/probe/test_core.py +391 -0
  45. polyphys/tests/probe/test_histograms.py +779 -0
  46. polyphys/tests/probe/test_pairs.py +475 -0
  47. polyphys/tests/simulate/test_build.py +1042 -0
  48. polyphys/tests/simulate/test_core.py +106 -0
  49. polyphys/tests/simulate/test_data.py +858 -0
  50. polyphys/tests/simulate/test_dump.py +487 -0
  51. polyphys/tests/simulate/test_log.py +667 -0
  52. polyphys/tests/strategies.py +120 -0
  53. polyphys/tests/synthetic.py +410 -0
  54. polyphys/tests/test_conftest.py +414 -0
  55. polyphys/tests/test_docs.py +82 -0
  56. polyphys/tests/test_packaging.py +97 -0
  57. polyphys/tests/test_spec.py +772 -0
  58. polyphys/tests/viz/conftest.py +32 -0
  59. polyphys/tests/viz/test_colors.py +113 -0
  60. polyphys/tests/viz/test_helpers.py +292 -0
  61. polyphys/viz/__init__.py +15 -0
  62. polyphys/viz/colors.py +204 -0
  63. polyphys/viz/helpers.py +549 -0
  64. polyphys-1.0.0.dist-info/METADATA +339 -0
  65. polyphys-1.0.0.dist-info/RECORD +69 -0
  66. polyphys-1.0.0.dist-info/WHEEL +5 -0
  67. polyphys-1.0.0.dist-info/licenses/AUTHORS.rst +13 -0
  68. polyphys-1.0.0.dist-info/licenses/LICENSE +22 -0
  69. 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
@@ -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)