plot-io 0.12.0__py3-none-win_amd64.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 (130) hide show
  1. plot_io/__init__.py +73 -0
  2. plot_io/_methods.py +1691 -0
  3. plot_io/_native/__init__.py +199 -0
  4. plot_io/_native/__main__.py +197 -0
  5. plot_io/_native/_api.py +457 -0
  6. plot_io/_native/bin/windows-x86_64.dll +0 -0
  7. plot_io/_native/kernels.py +335 -0
  8. plot_io/_native/manifest.json +49 -0
  9. plot_io/axis.py +1347 -0
  10. plot_io/boxes.py +1156 -0
  11. plot_io/canvas.py +389 -0
  12. plot_io/color/__init__.py +3 -0
  13. plot_io/color/data.py +1646 -0
  14. plot_io/color/maps.py +922 -0
  15. plot_io/color/values.py +295 -0
  16. plot_io/color/variants.py +1246 -0
  17. plot_io/colorbar.py +1426 -0
  18. plot_io/columns.py +614 -0
  19. plot_io/decorations.py +920 -0
  20. plot_io/defaults.py +809 -0
  21. plot_io/density.py +113 -0
  22. plot_io/ext.py +68 -0
  23. plot_io/facets.py +240 -0
  24. plot_io/figure.py +788 -0
  25. plot_io/formatters.py +97 -0
  26. plot_io/genome/__init__.py +17 -0
  27. plot_io/genome/adapters.py +356 -0
  28. plot_io/genome/combine.py +123 -0
  29. plot_io/genome/contacts.py +508 -0
  30. plot_io/genome/intervals.py +750 -0
  31. plot_io/genome/lanes.py +191 -0
  32. plot_io/genome/layout.py +184 -0
  33. plot_io/genome/links.py +364 -0
  34. plot_io/genome/pack.py +27 -0
  35. plot_io/genome/readers.py +145 -0
  36. plot_io/genome/regions.py +228 -0
  37. plot_io/genome/scale.py +349 -0
  38. plot_io/genome/series.py +344 -0
  39. plot_io/genome/signal.py +621 -0
  40. plot_io/genome/sites.py +782 -0
  41. plot_io/genome/sources.py +459 -0
  42. plot_io/genome/testing.py +161 -0
  43. plot_io/geometry.py +289 -0
  44. plot_io/groups.py +308 -0
  45. plot_io/guides.py +503 -0
  46. plot_io/helpers/__init__.py +17 -0
  47. plot_io/helpers/clustermap.py +202 -0
  48. plot_io/helpers/dendrogram.py +172 -0
  49. plot_io/helpers/grids.py +304 -0
  50. plot_io/helpers/qq.py +334 -0
  51. plot_io/helpers/raincloud.py +249 -0
  52. plot_io/helpers/tree.py +214 -0
  53. plot_io/helpers/volcano.py +505 -0
  54. plot_io/hints.py +125 -0
  55. plot_io/kernels.py +740 -0
  56. plot_io/labels.py +566 -0
  57. plot_io/layers/__init__.py +100 -0
  58. plot_io/layers/base.py +630 -0
  59. plot_io/layers/context.py +87 -0
  60. plot_io/layers/core.py +446 -0
  61. plot_io/layers/geoms.py +1362 -0
  62. plot_io/layers/grammar.py +586 -0
  63. plot_io/layers/paint.py +614 -0
  64. plot_io/layers/pipeline.py +170 -0
  65. plot_io/layers/positions.py +431 -0
  66. plot_io/layers/stats.py +1088 -0
  67. plot_io/layout.py +1381 -0
  68. plot_io/legend.py +605 -0
  69. plot_io/levels.py +509 -0
  70. plot_io/limits.py +124 -0
  71. plot_io/lines.py +42 -0
  72. plot_io/mapped.py +522 -0
  73. plot_io/markers.py +110 -0
  74. plot_io/markup.py +128 -0
  75. plot_io/numbers.py +354 -0
  76. plot_io/occupancy.py +295 -0
  77. plot_io/plot.py +597 -0
  78. plot_io/presets/__init__.py +51 -0
  79. plot_io/presets/_util.py +153 -0
  80. plot_io/presets/area.py +145 -0
  81. plot_io/presets/bar.py +261 -0
  82. plot_io/presets/box.py +400 -0
  83. plot_io/presets/catpoints.py +177 -0
  84. plot_io/presets/contour.py +353 -0
  85. plot_io/presets/curves.py +30 -0
  86. plot_io/presets/dots.py +355 -0
  87. plot_io/presets/ecdf.py +97 -0
  88. plot_io/presets/errorbar.py +106 -0
  89. plot_io/presets/errors.py +296 -0
  90. plot_io/presets/fill.py +74 -0
  91. plot_io/presets/heatmap.py +322 -0
  92. plot_io/presets/hexbin.py +198 -0
  93. plot_io/presets/hist.py +391 -0
  94. plot_io/presets/hist2d.py +87 -0
  95. plot_io/presets/kde.py +162 -0
  96. plot_io/presets/kde2d.py +147 -0
  97. plot_io/presets/line.py +143 -0
  98. plot_io/presets/pie.py +502 -0
  99. plot_io/presets/regression.py +115 -0
  100. plot_io/presets/scatter.py +194 -0
  101. plot_io/presets/stem.py +100 -0
  102. plot_io/presets/violin.py +323 -0
  103. plot_io/py.typed +0 -0
  104. plot_io/registry.py +82 -0
  105. plot_io/render.py +480 -0
  106. plot_io/rows.py +491 -0
  107. plot_io/scales.py +103 -0
  108. plot_io/seams.py +138 -0
  109. plot_io/settings.py +807 -0
  110. plot_io/shapes.py +66 -0
  111. plot_io/sharing.py +232 -0
  112. plot_io/sizemaps.py +97 -0
  113. plot_io/spec.py +240 -0
  114. plot_io/states.py +216 -0
  115. plot_io/styles/__init__.py +307 -0
  116. plot_io/styles/library.py +397 -0
  117. plot_io/tables.py +138 -0
  118. plot_io/text.py +412 -0
  119. plot_io/ticks.py +400 -0
  120. plot_io/times.py +287 -0
  121. plot_io/titles.py +225 -0
  122. plot_io/transform.py +114 -0
  123. plot_io/types.py +13604 -0
  124. plot_io/units.py +768 -0
  125. plot_io/validators.py +1029 -0
  126. plot_io/values.py +190 -0
  127. plot_io-0.12.0.dist-info/METADATA +215 -0
  128. plot_io-0.12.0.dist-info/RECORD +130 -0
  129. plot_io-0.12.0.dist-info/WHEEL +4 -0
  130. plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,782 @@
1
+ """Site matrices: a signal read in windows around sites (peaks, TSSs), one row per site.
2
+
3
+ :class:`SiteHeatmap` draws the rows as a heat map (``plot.site_heatmap``),
4
+ :class:`SiteProfile` their column mean (``plot.site_profile``), :func:`SignalMatrix` a
5
+ figure of both for each of several signals.
6
+
7
+ A window is ``flank`` around the site's ``anchor``, read as ``bins`` bins in one
8
+ ``read_values`` call per signal (no strand passed to the reader); a ``-`` site's row
9
+ (from a ``strand`` column) is reversed, so rows run 5′ to 3′. Bins wholly past a
10
+ chromosome end (the reader's ``chr_sizes``) are NaN. The read and the row order are kept
11
+ by the figure: a heat map and a profile of the same signal, sites and window share them.
12
+ """
13
+
14
+ import math
15
+ import operator
16
+ import os
17
+ from array import array
18
+ from collections.abc import Mapping
19
+ from typing import TYPE_CHECKING, Any, Self, Unpack
20
+
21
+ from .. import kernels
22
+ from ..color.values import color
23
+ from ..layers import add_layer
24
+ from ..layers.base import Series, common
25
+ from ..layers.core import Call, Layer, Table
26
+ from ..layers.grammar import entry
27
+ from ..layers.paint import Paint, role
28
+ from ..layers.geoms import GEOMS
29
+ from ..mapped import color_axis_of, trained
30
+ from ..presets._util import gradient_entry, memo
31
+ from ..settings import Setting, number, one_of, table
32
+ from ..validators import length
33
+ from ..spec import auto_colors, current
34
+ from . import readers, sources
35
+ from .regions import parse_bp
36
+
37
+ if TYPE_CHECKING:
38
+ from ..figure import Figure
39
+ from ..types import SiteHeatmapSettings, SiteProfileSettings
40
+
41
+ __all__ = ["SiteHeatmap", "SiteProfile", "SignalMatrix"]
42
+
43
+ #: The reductions a bin of a window takes.
44
+ _MODES = ("mean", "min", "max", "sum", "count")
45
+ #: The share of the rows a gap between groups takes.
46
+ _GAP = 0.02
47
+
48
+
49
+ def _flank(v: Any) -> tuple[int, int]:
50
+ """One length on both sides, or ``(upstream, downstream)``."""
51
+ if isinstance(v, (tuple, list)):
52
+ if len(v) != 2:
53
+ raise ValueError(f"expected a length or (upstream, downstream), got {v!r}")
54
+ up, down = (parse_bp(x, "flank") for x in v)
55
+ else:
56
+ up = down = parse_bp(v, "flank")
57
+ if up < 0 or down < 0 or up + down <= 0:
58
+ raise ValueError(f"the flanks must be non-negative and not both 0, got {v!r}")
59
+ return int(up), int(down)
60
+
61
+
62
+ def _sort(v: Any) -> Any:
63
+ if v is False:
64
+ return False
65
+ return one_of("mean", "max")(v)
66
+
67
+
68
+ def _row_bins(v: Any) -> Any:
69
+ if v is False or v == "auto":
70
+ return v
71
+ return number(positive=True, integer=True)(v)
72
+
73
+
74
+ def _stat(v: Any) -> Any:
75
+ return one_of("mean", "median")(v)
76
+
77
+
78
+ def _band(v: Any) -> Any:
79
+ if v is False:
80
+ return False
81
+ return one_of("sem", "sd")(v)
82
+
83
+
84
+ def _window_settings() -> dict[str, Setting]:
85
+ return dict(
86
+ flank=Setting(_flank, doc="The window around each site's anchor: a length on both "
87
+ "sides (`\"5kb\"`) or `(upstream, downstream)`."),
88
+ bins=Setting(number(positive=True, integer=True), doc="Bins per window."),
89
+ anchor=Setting(one_of("center", "start", "end", "summit"),
90
+ doc="The position the window is centred on: the site's `center`, "
91
+ "`start` or `end` (5′ and 3′ ends on a `-` site), or its "
92
+ "`summit` column (absolute, or a narrowPeak `peak` offset)."),
93
+ bin_mode=Setting(one_of(*_MODES),
94
+ doc="Reduction per bin: `mean`, `min`, `max`, `sum` or `count`."),
95
+ )
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Sites
100
+ # ---------------------------------------------------------------------------
101
+
102
+ def _columns(value: Any, what: str, owner: Any) -> dict[str, list[Any]]:
103
+ """The columns of a sites table: a mapping of columns, a DataFrame, rows (mappings,
104
+ or tuples in BED order), an interval reader or a path (every entry)."""
105
+ if hasattr(value, "to_dict") and hasattr(value, "columns"): # a DataFrame
106
+ return {str(c): list(value[c]) for c in value.columns}
107
+ if isinstance(value, Mapping):
108
+ return {str(k): list(v.tolist() if hasattr(v, "tolist") else v)
109
+ for k, v in value.items()}
110
+ if isinstance(value, (str, bytes)) or hasattr(value, "__fspath__") \
111
+ or sources.kind_of(value) is not None:
112
+ reader = readers.adapt(value, "intervals", what, owner=owner)
113
+ sizes = getattr(reader, "chr_sizes", None)
114
+ if not isinstance(sizes, Mapping) or not sizes:
115
+ raise ValueError(f"{what}: an interval reader as sites needs its chr_sizes "
116
+ f"(to read every entry); pass a table")
117
+ got = reader.read_entries([(str(c), 0, int(n)) for c, n in sizes.items()])
118
+ return _columns(dict(got.items()), what, owner)
119
+ rows = list(value)
120
+ if rows and isinstance(rows[0], Mapping):
121
+ return {str(k): [r[k] for r in rows] for k in rows[0]}
122
+ names = ("chr", "start", "end", "name", "score", "strand")
123
+ return {n: [r[i] if i < len(r) else None for r in rows]
124
+ for i, n in enumerate(names) if any(len(r) > i for r in rows)}
125
+
126
+
127
+ def _find(cols: Mapping[str, list[Any]], *names: str) -> list[Any] | None:
128
+ low = {k.lower(): v for k, v in cols.items()}
129
+ for n in names:
130
+ if n in low:
131
+ return low[n]
132
+ return None
133
+
134
+
135
+ class Sites:
136
+ """The sites of a site matrix: ``chr``, ``start``, ``end``, and when given
137
+ ``strand``, ``summit`` (absolute; or ``start`` plus a narrowPeak ``peak`` offset) and
138
+ one group label per site."""
139
+
140
+ def __init__(self, value: Any, group: Any, what: str, owner: Any = None) -> None:
141
+ cols = _columns(value, what, owner)
142
+ chrs = _find(cols, "chr", "chrom", "chromosome", "seqname")
143
+ starts = _find(cols, "start", "chromstart")
144
+ ends = _find(cols, "end", "chromend")
145
+ if chrs is None or starts is None or ends is None:
146
+ raise ValueError(f"{what}: sites need chr, start and end columns")
147
+ n = len(chrs)
148
+ self.chr = [str(c) for c in chrs]
149
+ self.start = [sources._position(s, i, "start") for i, s in enumerate(starts)]
150
+ self.end = [sources._position(e, i, "end") for i, e in enumerate(ends)]
151
+ if len(self.start) != n or len(self.end) != n:
152
+ raise ValueError(f"{what}: the site columns have different lengths")
153
+ strand = _find(cols, "strand")
154
+ #: ``True`` per ``-`` site (``None``: no strand column)
155
+ self.minus = None if strand is None else [s == "-" for s in strand]
156
+ summit = _find(cols, "summit")
157
+ peak = _find(cols, "peak")
158
+ if summit is not None:
159
+ self.summit: list[int] | None = [int(s) for s in summit]
160
+ elif peak is not None:
161
+ self.summit = [s + int(p) if p is not None and int(p) >= 0 else (s + e) // 2
162
+ for s, e, p in zip(self.start, self.end, peak)]
163
+ else:
164
+ self.summit = None
165
+ self.groups: list[Any] | None = None
166
+ if group is not None:
167
+ labels = cols.get(group) if isinstance(group, str) else group
168
+ if labels is None:
169
+ raise ValueError(f"{what}: no column {group!r} in the sites")
170
+ labels = list(labels.tolist() if hasattr(labels, "tolist") else labels)
171
+ if len(labels) != n:
172
+ raise ValueError(f"{what}: group has {len(labels)} labels for {n} sites")
173
+ self.groups = labels
174
+
175
+ def __len__(self) -> int:
176
+ return len(self.chr)
177
+
178
+ def anchors(self, anchor: str, what: str) -> list[int]:
179
+ """The anchor position of each site."""
180
+ s, e, minus = self.start, self.end, self.minus
181
+ if anchor == "center":
182
+ return [(a + b) // 2 for a, b in zip(s, e)]
183
+ if anchor == "summit":
184
+ if self.summit is None:
185
+ raise ValueError(f"{what}: anchor=\"summit\" needs a summit (or peak) "
186
+ f"column in the sites")
187
+ return list(self.summit)
188
+ first = anchor == "start"
189
+ if minus is None:
190
+ return list(s if first else e)
191
+ return [(b if m else a) if first else (a if m else b)
192
+ for a, b, m in zip(s, e, minus)]
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # The read and the row order (kept by the figure)
197
+ # ---------------------------------------------------------------------------
198
+
199
+ def _ident(v: Any) -> Any:
200
+ if isinstance(v, (str, bytes)) or hasattr(v, "__fspath__"):
201
+ return os.fsdecode(v)
202
+ return id(v)
203
+
204
+
205
+ class Matrix:
206
+ """The windows of one signal around the sites: ``n`` rows of ``w`` bins (row-major,
207
+ 5′ to 3′), the bins from ``-up`` to ``down`` bp."""
208
+
209
+ __slots__ = ("n", "w", "flat", "up", "down", "_rows")
210
+
211
+ def __init__(self, n: int, w: int, flat: array, up: int, down: int) -> None:
212
+ self.n, self.w, self.flat, self.up, self.down = n, w, flat, up, down
213
+ self._rows: Any = None
214
+
215
+ def rows(self) -> Any:
216
+ """The rows as an ``(n, w)`` buffer."""
217
+ if self._rows is None:
218
+ self._rows = memoryview(self.flat).cast("B").cast("d", (self.n, self.w))
219
+ return self._rows
220
+
221
+ def column(self, c: int, rows: Any) -> array:
222
+ """Bin ``c`` of ``rows`` (indices)."""
223
+ return kernels.take(self.flat[c::self.w], rows)
224
+
225
+ def edges(self) -> list[float]:
226
+ bw = (self.up + self.down) / self.w
227
+ return [-self.up + bw * j for j in range(self.w + 1)]
228
+
229
+
230
+ def _read(series: Any) -> Matrix:
231
+ """The windows of ``series``' signal around its sites (one read)."""
232
+ sites: Sites = series.sites
233
+ up, down = series.resolve("flank")
234
+ w = int(series.resolve("bins"))
235
+ anchors = sites.anchors(series.resolve("anchor"), series.kind)
236
+ minus = sites.minus or [False] * len(sites)
237
+ windows = [(c, a - down, a + up) if m else (c, a - up, a + down)
238
+ for c, a, m in zip(sites.chr, anchors, minus)]
239
+ reader = series.reader
240
+ mode = series.resolve("bin_mode")
241
+ n = len(windows)
242
+ if not n:
243
+ return Matrix(0, w, array("d"), up, down)
244
+ if callable(reader) and not hasattr(reader, "read_values"):
245
+ rows = [reader(c, s, e, w) for c, s, e in windows]
246
+ else:
247
+ rows = reader.read_values(windows, bin_count=w, bin_mode=mode, def_value=0.0)
248
+ g = kernels.grid(rows)
249
+ if g is not None:
250
+ h, gw, _, flat = g
251
+ else:
252
+ rows = [list(r) for r in rows]
253
+ h, gw = len(rows), len(rows[0]) if rows else 0
254
+ flat = array("d", [float(v) for r in rows for v in r])
255
+ if h != n or gw != w:
256
+ raise ValueError(f"{series.kind}: the reader gave {h} rows of {gw} bins for {n} "
257
+ f"windows of {w}")
258
+ sizes = getattr(reader, "chr_sizes", None)
259
+ sizes = sizes if isinstance(sizes, Mapping) else {}
260
+ bw = (up + down) / w
261
+ nan = math.nan
262
+ for i, (c, lo, hi) in enumerate(windows):
263
+ size = sizes.get(c)
264
+ if lo < 0 or (size is not None and hi > size):
265
+ for j in range(w):
266
+ a = lo + j * bw
267
+ if a + bw <= 0 or (size is not None and a >= size):
268
+ flat[i * w + j] = nan
269
+ if minus[i]:
270
+ flat[i * w:(i + 1) * w] = flat[i * w:(i + 1) * w][::-1]
271
+ return Matrix(n, w, flat, up, down)
272
+
273
+
274
+ def _row_stat(m: Matrix, how: str) -> array:
275
+ w, flat = m.w, m.flat
276
+ out = array("d")
277
+ for i in range(m.n):
278
+ row = flat[i * w:(i + 1) * w]
279
+ s = sum(row)
280
+ if s != s:
281
+ row = array("d", [v for v in row if v == v])
282
+ s = sum(row)
283
+ if not row:
284
+ out.append(math.nan)
285
+ elif how == "mean":
286
+ out.append(s / len(row))
287
+ else:
288
+ out.append(max(row))
289
+ return out
290
+
291
+
292
+ def _cache(series: Any) -> dict[Any, Any]:
293
+ fig = getattr(series.plot, "figure", None) if series.plot is not None else None
294
+ return (fig if fig is not None else series).__dict__.setdefault("_site_matrices", {})
295
+
296
+
297
+ def _key(series: Any) -> tuple[Any, ...]:
298
+ return (_ident(series.signal), _ident(series.sites_arg), series.resolve("flank"),
299
+ series.resolve("bins"), series.resolve("anchor"), series.resolve("bin_mode"))
300
+
301
+
302
+ def matrix(series: Any) -> Matrix:
303
+ """The read of ``series`` (shared by the series of its figure that read the same)."""
304
+ cache = _cache(series)
305
+ key = ("read", *_key(series))
306
+ got = cache.get(key)
307
+ if got is None:
308
+ try:
309
+ got = _read(series)
310
+ except Exception as e:
311
+ if series.kind in str(e):
312
+ raise
313
+ raise type(e)(f"{series.kind}: {e}") from e
314
+ cache[key] = got
315
+ cache[("held", *key)] = (series.signal, series.sites_arg)
316
+ return got
317
+
318
+
319
+ def ordered(series: Any, sort: Any) -> tuple[array, list[tuple[Any, int, int]]]:
320
+ """``(order, blocks)``: the rows in drawing order (the groups in the order they first
321
+ appear in the sites, the rows of each by ``sort``, descending, NaN last) and the
322
+ groups as ``(label, first, end)`` of ``order``."""
323
+ cache = _cache(series)
324
+ key = ("order", sort, _ident(series.group), *_key(series))
325
+ got = cache.get(key)
326
+ if got is not None:
327
+ return got
328
+ m = matrix(series)
329
+ if not sort:
330
+ order = array("q", range(m.n))
331
+ else:
332
+ order = kernels.argsort(_row_stat(m, sort), descending=True)
333
+ blocks: list[tuple[Any, int, int]] = []
334
+ groups = series.sites.groups
335
+ if groups is None:
336
+ blocks.append((None, 0, m.n))
337
+ else:
338
+ by: dict[Any, list[int]] = {g: [] for g in groups} # in the sites' order
339
+ for i in order:
340
+ by[groups[i]].append(i)
341
+ order = array("q")
342
+ for label, idx in by.items():
343
+ blocks.append((label, len(order), len(order) + len(idx)))
344
+ order.extend(idx)
345
+ got = cache[key] = (order, blocks)
346
+ return got
347
+
348
+
349
+ def averaged(m: Matrix, rows: array, k: int) -> array:
350
+ """``rows`` (indices, in order) averaged to ``k`` rows, each over ``len(rows) / k``
351
+ consecutive rows (a row on a boundary weighted by its share); NaN left out.
352
+ Row-major."""
353
+ n, w = len(rows), m.w
354
+ if not n:
355
+ return array("d")
356
+ if k >= n:
357
+ return kernels.take(m.rows(), rows)
358
+ out = array("d", bytes(8 * k * w))
359
+ step = n / k
360
+ spans = [] # whole rows [i0, i1), shares of rows i0 - 1 and i1
361
+ for j in range(k):
362
+ a, b = j * step, (float(n) if j == k - 1 else (j + 1) * step)
363
+ i0, i1 = math.ceil(a), min(n, math.floor(b))
364
+ spans.append((j * w, i0, i1, i0 - a, b - i1 if i1 < n else 0.0))
365
+ nan = math.nan
366
+ for c in range(w):
367
+ col = m.column(c, rows)
368
+ for at, i0, i1, fa, fb in spans:
369
+ part = col[i0:i1]
370
+ total, weight = sum(part), float(i1 - i0)
371
+ if total != total:
372
+ part = array("d", [v for v in part if v == v])
373
+ total, weight = sum(part), float(len(part))
374
+ if fa > 0:
375
+ v = col[i0 - 1]
376
+ if v == v:
377
+ total += fa * v
378
+ weight += fa
379
+ if fb > 0:
380
+ v = col[i1]
381
+ if v == v:
382
+ total += fb * v
383
+ weight += fb
384
+ out[at + c] = total / weight if weight > 1e-12 else nan
385
+ return out
386
+
387
+
388
+ def _x_ticks(series: Any, up: int, down: int) -> list[tuple[float, Any]]:
389
+ from .regions import auto_unit
390
+ from .scale import GenomeFormat
391
+ from ..numbers import NumberStyle
392
+ values = [float(-up), 0.0, float(down)] if up and down else \
393
+ [float(-up), 0.0] if up else [0.0, float(down)]
394
+ unit = auto_unit(-up, down)
395
+ labels = GenomeFormat(unit, "ticks").format_all(values, NumberStyle.of(
396
+ series.plot.x_axis))
397
+ return list(zip(values, labels))
398
+
399
+
400
+ class _SiteSeries(Series):
401
+ """Base of the site series: the signal, the sites and the group labels."""
402
+
403
+ SPLITS_GROUPS = False
404
+
405
+ def __init__(self, signal: Any, sites: Any, plot: Any = None, *, group: Any = None,
406
+ **settings: Any) -> None:
407
+ what = type(self).__name__
408
+ self.signal = signal
409
+ self.reader = readers.adapt(signal, "signal", what, owner=self)
410
+ self.sites_arg = sites
411
+ self.group = group
412
+ self.sites = Sites(sites, group, what, owner=self)
413
+ super().__init__(plot=plot, **settings)
414
+
415
+
416
+ class SiteHeatmap(_SiteSeries):
417
+ """A signal around sites, one row per site (a table with ``chr``, ``start``, ``end``
418
+ and optional ``strand``, ``summit`` columns, or an interval reader), x the distance
419
+ from the anchor in bp; ``group`` (a column of the sites, or a label per site) makes a
420
+ block of rows per group.
421
+
422
+ ``signal`` is anything a :class:`BigWig` takes (`readers <docs/api.md#readers>`_); ``sites`` is a table (a dict
423
+ of columns, a DataFrame, rows of dicts, or tuples in BED order) with ``chr``, ``start``,
424
+ ``end`` and optionally ``strand`` and ``summit`` (absolute; or a narrowPeak ``peak``
425
+ offset from ``start``), or an interval reader or path (every entry, over its
426
+ ``chr_sizes``).
427
+
428
+ The window is ``flank`` around the site's ``anchor`` (``center``, ``start``, ``end``: the
429
+ 5′ and 3′ ends on a ``-`` site; ``summit``), read as ``bins`` bins in one ``read_values``
430
+ call per signal (no strand passed to the reader). A ``-`` site's row is reversed, so
431
+ rows run 5′ to 3′. Bins wholly past a chromosome end (the reader's ``chr_sizes``; before
432
+ 0 always) are NaN. x is labelled in genome units (``-5kb 0 5kb``). A heat map and a
433
+ profile of the same signal, sites, ``group`` and window in one figure share the read and
434
+ the row order; :func:`~plot_io.genome.sites.SignalMatrix` makes the figure of both.
435
+
436
+ Rows are ordered by ``sort`` (each row's ``mean`` or ``max``, descending; ``False``: the
437
+ sites' order). With ``group``, the blocks are in the order the groups first appear, a
438
+ gap between blocks, the group names on the y axis (else no y axis).
439
+ ``row_bins="auto"`` averages consecutive rows (a row on a boundary weighted by its
440
+ share, NaN left out) so that at most two rows fall in a px; a number draws that many
441
+ rows; ``False`` every row. The color axis is trained on the rows drawn. Use::
442
+
443
+ p.site_heatmap("atac.bw", tss, flank=(2000, 5000), anchor="start", group="cluster")
444
+
445
+ Example::
446
+
447
+ import math
448
+ rng = random.Random(1)
449
+ sites = {"chr": ["chr1"] * 40, "start": [10_000 + 20_000 * i for i in range(40)],
450
+ "group": ["bound" if i % 3 else "unbound" for i in range(40)]}
451
+ sites["end"] = [s + 1 for s in sites["start"]]
452
+ signal = {"chr": [], "start": [], "end": [], "value": []}
453
+ for s, g in zip(sites["start"], sites["group"]):
454
+ h = rng.uniform(1, 3) if g == "bound" else rng.uniform(0, 0.5)
455
+ for x in range(s - 4000, s + 4000, 400):
456
+ signal["chr"].append("chr1")
457
+ signal["start"].append(x)
458
+ signal["end"].append(x + 400)
459
+ signal["value"].append(0.1 * rng.random() + h * math.exp(-((x - s) / 1000) ** 2))
460
+ p = plt.Plot(width=240, height=160)
461
+ p.site_heatmap(signal, sites, group="group", flank="4kb", bins=20, colorbar=True)
462
+ """
463
+
464
+ SETTINGS = table(
465
+ **_window_settings(),
466
+ sort=Setting(_sort, doc="Row order: `mean` or `max` of each row, descending (within "
467
+ "each group); `False`: the sites' order."),
468
+ row_bins=Setting(_row_bins,
469
+ doc="Rows drawn: `auto` averages consecutive rows so there are at "
470
+ "most two per px; a number of rows; `False`: every row."),
471
+ **common("color_map"),
472
+ nan_color=Setting(color, doc="Color of missing bins (past a chromosome end); "
473
+ "default: the map's `bad` (transparent)."),
474
+ **common("name", "opacity", "z", "rasterize"),
475
+ )
476
+
477
+ def __init__(self, signal: Any, sites: Any, plot: Any = None, *, group: Any = None,
478
+ **settings: Any) -> None:
479
+ super().__init__(signal, sites, plot=plot, group=group, **settings)
480
+
481
+ def set(self, **settings: "Unpack[SiteHeatmapSettings]") -> Self: # type: ignore[override]
482
+ super().set(**settings)
483
+ if getattr(self, "plot", None) is not None:
484
+ self._attach_bar()
485
+ return self
486
+
487
+ def _bar_source(self) -> Any:
488
+ return self
489
+
490
+ def blocks(self) -> tuple[array, list[tuple[Any, int, int, float]], float]:
491
+ """``(order, blocks, height)``: the blocks as ``(label, first, end, y)`` (``y`` the
492
+ top of the block in rows, gaps between groups included) and the rows' height."""
493
+ order, blocks = ordered(self, self.resolve("sort"))
494
+ n = len(order)
495
+ gap = _GAP * n if len(blocks) > 1 else 0.0
496
+ out = [(label, a, b, a + i * gap) for i, (label, a, b) in enumerate(blocks)]
497
+ return order, out, n + gap * (len(blocks) - 1)
498
+
499
+ def drawn_rows(self, height: float) -> list[int]:
500
+ """The rows drawn per block on axes ``height`` px high."""
501
+ _, blocks, total = self.blocks()
502
+ rb = self.resolve("row_bins")
503
+ n = sum(b - a for _, a, b, _ in blocks) or 1
504
+ out = []
505
+ for _, a, b, _ in blocks:
506
+ nb = b - a
507
+ if rb is False:
508
+ k = nb
509
+ elif rb == "auto":
510
+ k = math.ceil(2 * height * nb / total) if total else nb
511
+ else:
512
+ k = round(rb * nb / n)
513
+ out.append(max(1, min(nb, k)) if nb else 0)
514
+ return out
515
+
516
+ def _drawn(self, rows: list[int]) -> list[array]:
517
+ """The drawn values of each block, averaged to ``rows``."""
518
+ order, blocks, _ = self.blocks()
519
+ m = matrix(self)
520
+ cache = _cache(self)
521
+ out = []
522
+ for (_, a, b, _), k in zip(blocks, rows):
523
+ key = ("drawn", self.resolve("sort"), _ident(self.group), a, b, k, *_key(self))
524
+ got = cache.get(key)
525
+ if got is None:
526
+ got = cache[key] = averaged(m, order[a:b], k)
527
+ out.append(got)
528
+ return out
529
+
530
+ def layers(self) -> Call:
531
+ call = Call(self)
532
+ m = matrix(self)
533
+ order, blocks, total = self.blocks()
534
+ axis = color_axis_of(self)
535
+ cmap = axis.color_map()
536
+
537
+ def height(ctx: Any) -> float:
538
+ return abs(ctx.rect[3] - ctx.rect[1])
539
+
540
+ def placed() -> Any:
541
+ r = current()
542
+ return None if r is None or self.plot is None else r.placed.get(id(self.plot))
543
+
544
+ def values() -> Any: # trained once the axes are placed
545
+ ctx = placed()
546
+ if ctx is None:
547
+ return None
548
+ out = array("d")
549
+ for v in self._drawn(self.drawn_rows(height(ctx))):
550
+ out.extend(v)
551
+ return out
552
+
553
+ for k, (label, a, b, y0) in enumerate(blocks):
554
+ if b <= a or not m.w:
555
+ continue
556
+
557
+ call.layers.append(Layer(GEOMS["cells"], Table(),
558
+ paint=Paint(self, "sites.block", k=k, rows=b - a, y0=y0,
559
+ m=m, cmap=cmap),
560
+ stat="site_matrix", occupy="all", key=False))
561
+ name = self.resolve("name")
562
+ if name is not None:
563
+ call.entries.append(gradient_entry(self, name, axis.color_map))
564
+ call.color_sources = [(axis, values)]
565
+ call.resolution = lambda ctx: tuple(self.drawn_rows(height(ctx)))
566
+ call.extra = {"x": array("d", [-m.up, m.down]), "y": array("d", [0.0, total])}
567
+ call.ticks["x"] = _x_ticks(self, m.up, m.down)
568
+ if len(blocks) > 1:
569
+ call.ticks["y"] = [(y0 + (b - a) / 2, str(label)) for label, a, b, y0 in blocks]
570
+ else:
571
+ call.hide = {"y"}
572
+ call.invert = {"y"}
573
+ call.exact = {"x", "y"}
574
+ return call
575
+
576
+
577
+ class SiteProfile(_SiteSeries):
578
+ """The column mean (or median) of a signal around sites (see :class:`SiteHeatmap`):
579
+ one line per group, x the distance from the anchor in bp, an optional band.
580
+
581
+ Sites, windows and reads as :class:`SiteHeatmap`'s. Each group is a line in the
582
+ plot's colors (a legend entry each); ``band="sem"`` or ``"sd"`` draws a band about it.
583
+
584
+ Example::
585
+
586
+ import math
587
+ rng = random.Random(1)
588
+ sites = {"chr": ["chr1"] * 40, "start": [10_000 + 20_000 * i for i in range(40)],
589
+ "group": ["bound" if i % 3 else "unbound" for i in range(40)]}
590
+ sites["end"] = [s + 1 for s in sites["start"]]
591
+ signal = {"chr": [], "start": [], "end": [], "value": []}
592
+ for s, g in zip(sites["start"], sites["group"]):
593
+ h = rng.uniform(1, 3) if g == "bound" else rng.uniform(0, 0.5)
594
+ for x in range(s - 4000, s + 4000, 400):
595
+ signal["chr"].append("chr1")
596
+ signal["start"].append(x)
597
+ signal["end"].append(x + 400)
598
+ signal["value"].append(0.1 * rng.random() + h * math.exp(-((x - s) / 1000) ** 2))
599
+ p = plt.Plot(width=240, height=160)
600
+ p.site_profile(signal, sites, group="group", flank="4kb", bins=20, band="sem")
601
+ """
602
+
603
+ SETTINGS = table(
604
+ **_window_settings(),
605
+ stat=Setting(_stat, doc="The value per bin: the `mean` or `median` of the rows."),
606
+ band=Setting(_band, doc="A band about the line: `sem` (standard error of the mean), "
607
+ "`sd`; `None` or `False`: none."),
608
+ color=Setting(color, doc="Line color; default: the plot's next color (one per "
609
+ "group)."),
610
+ line_width=Setting(length(lo=0), inherited=True, doc="Line width."),
611
+ fill_opacity=Setting(number(lo=0, hi=1), doc="Opacity of the band."),
612
+ **common("name", "opacity", "z", "rasterize", "simplify"),
613
+ )
614
+
615
+ def set(self, **settings: "Unpack[SiteProfileSettings]") -> Self: # type: ignore[override]
616
+ super().set(**settings)
617
+ return self
618
+
619
+ def profiles(self) -> list[tuple[Any, array, array, array | None]]:
620
+ """``(group label, x, y, band half-width or None)`` per group, x the bin centres."""
621
+ m = matrix(self)
622
+ stat, band = self.resolve("stat"), self.resolve("band")
623
+ key = ("profile", stat, band, _ident(self.group), *_key(self))
624
+ cache = _cache(self)
625
+ got = cache.get(key)
626
+ if got is not None:
627
+ return got
628
+ order = array("q", range(m.n))
629
+ groups = self.sites.groups
630
+ if groups is not None:
631
+ by: dict[Any, list[int]] = {}
632
+ for i, g in enumerate(groups):
633
+ by.setdefault(g, []).append(i)
634
+ parts = [(label, array("q", idx)) for label, idx in by.items()]
635
+ else:
636
+ parts = [(None, order)]
637
+ edges = m.edges()
638
+ xs = array("d", [(a + b) / 2 for a, b in zip(edges, edges[1:])])
639
+ out = []
640
+ for label, rows in parts:
641
+ ys, hw = array("d"), array("d") if band else None
642
+ for c in range(m.w):
643
+ col = m.column(c, rows)
644
+ if sum(col) != sum(col):
645
+ col = array("d", [v for v in col if v == v])
646
+ k = len(col)
647
+ if not k:
648
+ ys.append(math.nan)
649
+ if hw is not None:
650
+ hw.append(math.nan)
651
+ continue
652
+ mean = sum(col) / k
653
+ ys.append(mean if stat == "mean" else _median(col))
654
+ if hw is not None:
655
+ var = max(0.0, sum(map(operator.mul, col, col)) / k - mean * mean) * k / (k - 1) \
656
+ if k > 1 else 0.0
657
+ hw.append(math.sqrt(var / (k if band == "sem" else 1)))
658
+ out.append((label, xs, ys, hw))
659
+ cache[key] = out
660
+ return out
661
+
662
+ def profile_colors(self, ctx: Any) -> list[Any]:
663
+ """The color of each profile."""
664
+ n = len(self.profiles())
665
+ c = self.resolve("color")
666
+ if c is not None:
667
+ return [ctx.color(c)] * n
668
+ auto = auto_colors(self) or [None]
669
+ return [ctx.color(auto[i % len(auto)]) for i in range(n)]
670
+
671
+ def layers(self) -> Call:
672
+ call = Call(self)
673
+ m = matrix(self)
674
+ got = self.profiles()
675
+
676
+ for i, (label, xs, ys, hw) in enumerate(got):
677
+ mine = []
678
+ if hw is not None:
679
+ lo = array("d", [y - h for y, h in zip(ys, hw)])
680
+ hi = array("d", [y + h for y, h in zip(ys, hw)])
681
+
682
+ mine.append(Layer(GEOMS["band"], Table(x=xs, top=hi, bottom=lo),
683
+ aes={"y1": "top", "y2": "bottom"},
684
+ paint=Paint(self, "band", color="profile_colors()",
685
+ index=i, opacity="fill_opacity"),
686
+ stat="site_profile", occupy=False))
687
+ mine.append(Layer(GEOMS["path"], Table(x=xs, y=ys),
688
+ paint=Paint(self, "stroke", color="profile_colors()", index=i,
689
+ opacity=True),
690
+ stat="site_profile"))
691
+ call.layers += mine
692
+ name = str(label) if label is not None else self.resolve("name")
693
+ if name is not None:
694
+ call.entries.append(entry(self, name, mine, fill="profile_colors()",
695
+ index=i))
696
+ call.color_count = 0 if self.is_set("color") else len(got)
697
+ call.ticks["x"] = _x_ticks(self, m.up, m.down)
698
+ call.extra = {"x": array("d", [-m.up, m.down])}
699
+ call.exact = {"x"}
700
+ return call
701
+
702
+
703
+ def _median(v: array) -> float:
704
+ s = sorted(v)
705
+ k = len(s)
706
+ return s[k // 2] if k % 2 else (s[k // 2 - 1] + s[k // 2]) / 2
707
+
708
+
709
+ #: The settings of :func:`SignalMatrix` that go to both plot types, to the heat maps,
710
+ #: and to the profiles.
711
+ _BOTH = ("flank", "bins", "anchor", "bin_mode", "group")
712
+
713
+
714
+ def SignalMatrix(signals: Any, sites: Any, **settings: Any) -> "Figure":
715
+ """A figure of site matrices: per signal (a mapping of names to signals, or one
716
+ signal), a column of a :class:`SiteProfile` above a :class:`SiteHeatmap` of the same
717
+ rows, x shared by column. ``share_color``: one color axis and one colorbar for all
718
+ heat maps (else one per column); ``profile_height``: the profiles' share of the
719
+ height (default ``0.25``). The settings of both plot types go to them; the others are
720
+ the figure's.
721
+
722
+ Use::
723
+
724
+ fig = plt.SignalMatrix({"ATAC": "atac.bw", "H3K27ac": "k27ac.bw"}, "peaks.narrowPeak",
725
+ anchor="summit", flank="5kb", width=180, height=600)
726
+ """
727
+ from ..figure import Figure
728
+ named = list(signals.items()) if isinstance(signals, Mapping) else [(None, signals)]
729
+ share = bool(settings.pop("share_color", False))
730
+ share_part = float(settings.pop("profile_height", 0.25))
731
+ both = {k: settings.pop(k) for k in _BOTH if k in settings}
732
+ heat = {k: settings.pop(k) for k in list(settings)
733
+ if k in SiteHeatmap.SETTINGS and k not in SiteProfile.SETTINGS}
734
+ prof = {k: settings.pop(k) for k in list(settings)
735
+ if k in SiteProfile.SETTINGS and k not in SiteHeatmap.SETTINGS}
736
+ for k in [k for k in settings if k in SiteHeatmap.SETTINGS]:
737
+ heat[k] = prof[k] = settings.pop(k)
738
+ n = len(named)
739
+ settings.setdefault("heights", [share_part, 1 - share_part])
740
+ settings.setdefault("share_x", "col")
741
+ fig = Figure(layout=[[f"p{i}" for i in range(n)], [f"h{i}" for i in range(n)]],
742
+ **settings)
743
+ if share:
744
+ fig.set(share_color=True)
745
+ for i, (name, signal) in enumerate(named):
746
+ top = fig.plot(f"p{i}", title=name) if name is not None else fig.plot(f"p{i}")
747
+ top.site_profile(signal, sites, **both, **prof)
748
+ bar = heat.get("colorbar", True) if not share or i == n - 1 else False
749
+ fig.plot(f"h{i}").site_heatmap(signal, sites, **both, **{**heat, "colorbar": bar})
750
+ return fig
751
+
752
+
753
+ add_layer("site_heatmap", SiteHeatmap)
754
+ add_layer("site_profile", SiteProfile)
755
+
756
+
757
+ @role("sites.block")
758
+ def _block_paint(s: Any, ctx: Any, glyph: bool, k: int = 0, rows: int = 0, y0: float = 0.0,
759
+ m: Any = None, cmap: Any = None) -> dict[str, Any]:
760
+ """The cells of block ``k`` (``rows`` sites from ``y0``), averaged to the rows the
761
+ axes' height draws."""
762
+ def make() -> dict[str, Any]:
763
+ drawn = s.drawn_rows(abs(ctx.rect[3] - ctx.rect[1]))
764
+ vals = s._drawn(drawn)[k]
765
+ st = trained(color_axis_of(s))
766
+ assert st.scale is not None
767
+ fn = st.scale.function
768
+ f = kernels.scaled(vals, 1.0, 0.0, fn, **st.scale.options) if fn else vals
769
+ fill, table, _ = cmap.cells(f, st.flo, st.fhi, "image")
770
+ return {"rows": drawn[k], "fill": fill, "table": table}
771
+ g = memo(ctx, (id(s), "block", k), make)
772
+ x0 = ctx.xmap(float(-m.up))
773
+ xb = (ctx.xmap(float(m.down)) - x0) / m.w
774
+ ya = ctx.ymap(y0)
775
+ yb = (ctx.ymap(y0 + rows) - ya) / g["rows"]
776
+ nan = ctx.color(s.resolve("nan_color"))
777
+ opacity = s.resolve("opacity")
778
+ return {"transform": (xb, 0.0, 0.0, yb, x0, ya),
779
+ "x_edges": range(m.w + 1), "y_edges": range(g["rows"] + 1),
780
+ "fill": g["fill"], "palette": g["table"],
781
+ "bad": nan if nan is not None else cmap.bad, "mode": "image",
782
+ "opacity": 1.0 if opacity is None else opacity}