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
plot_io/formatters.py ADDED
@@ -0,0 +1,97 @@
1
+ """Tick label formatters, one per axis kind: numbers, times and categories.
2
+
3
+ A formatter gives the labels of tick values and the offset content written at the end of
4
+ the axis (the common ×10ᵏ factor and additive offset of numbers, ``+1×10⁹``; the date
5
+ part common to time ticks, ``Mar 2026``), or ``None`` when there is none. Axes and
6
+ colorbars label their ticks through the formatter of their axis
7
+ (:meth:`plot_io.axis.AxisState.formatter`).
8
+ """
9
+
10
+ import math
11
+ from typing import Any, NamedTuple
12
+
13
+ from .numbers import NumberStyle, additive_offset, apply_format, format_axis, offset_label
14
+ from .text import superscript
15
+ from .times import time_labels, time_ticks
16
+
17
+ __all__ = ["Formatted", "NumberFormatter", "TimeFormatter", "CategoryFormatter",
18
+ "number_offset"]
19
+
20
+
21
+ class Formatted(NamedTuple):
22
+ labels: list[Any]
23
+ offset: Any = None # content written at the end of the axis, or None
24
+
25
+
26
+ def number_offset(factor: int, shift: float, style: NumberStyle) -> Any:
27
+ """The offset content of numeric labels: ``×10ᵏ`` and the value added (``+1×10⁹``);
28
+ ``None`` for neither."""
29
+ if not factor and not shift:
30
+ return None
31
+ out: list[Any] = []
32
+ if factor:
33
+ out += ["×10", superscript(style.exponent(factor))]
34
+ if shift:
35
+ out += offset_label(shift, style)
36
+ return out
37
+
38
+
39
+ class NumberFormatter:
40
+ """Labels of numbers formatted together (shared decimals) in ``fmt``; ``"auto"``
41
+ writes a common part of four digits or more as an additive offset and large or small
42
+ values as mantissas of a common ×10ᵏ factor."""
43
+
44
+ def __init__(self, fmt: Any, style: NumberStyle, sci_limits: tuple[int, int]) -> None:
45
+ self.fmt, self.style, self.sci_limits = fmt, style, sci_limits
46
+
47
+ def __call__(self, values: list[float]) -> Formatted:
48
+ shift = additive_offset(values) if self.fmt == "auto" else 0.0
49
+ if shift: # the differences, rounded relative to the tick spacing
50
+ step = min((abs(b - a) for a, b in zip(values, values[1:]) if b != a),
51
+ default=abs(shift))
52
+ digits = 2 - math.floor(math.log10(step))
53
+ values = [round(v - shift, digits) + 0.0 for v in values]
54
+ labels, factor = format_axis(values, self.fmt, self.style, self.sci_limits)
55
+ return Formatted(labels, number_offset(factor, shift, self.style))
56
+
57
+ def offset(self, factor: int) -> Any:
58
+ """The offset content of labels written as mantissas of ×10^``factor``."""
59
+ return number_offset(factor, 0.0, self.style)
60
+
61
+
62
+ class TimeFormatter:
63
+ """Concise labels of times (seconds since the epoch) in the finest calendar unit the
64
+ ticks need, with the date part common to them as the offset; ``fmt``: strftime codes
65
+ or a function of a ``datetime`` (no offset)."""
66
+
67
+ def __init__(self, fmt: Any, tz: Any) -> None:
68
+ self.fmt, self.tz = fmt, tz
69
+
70
+ def __call__(self, values: list[float]) -> Formatted:
71
+ labels, offset = time_labels(values, self.tz, self.fmt)
72
+ return Formatted(labels, offset)
73
+
74
+ def ticks(self, lo: float, hi: float, most: int) -> tuple[list[tuple[float, Any]], Any]:
75
+ """Automatic ticks between ``lo`` and ``hi``: ``[(value, label)]`` and the offset."""
76
+ return time_ticks(lo, hi, most, self.tz, self.fmt)
77
+
78
+
79
+ class CategoryFormatter:
80
+ """Labels of categories (by index): their names (a list is a name of several lines),
81
+ or numeric categories in ``fmt``, or each name through ``fmt``."""
82
+
83
+ def __init__(self, categories: list[Any], fmt: Any, style: NumberStyle) -> None:
84
+ self.categories, self.fmt, self.style = categories, fmt, style
85
+
86
+ def __call__(self, index: list[int]) -> Formatted:
87
+ cats = [self.categories[i] for i in index]
88
+ fmt = self.fmt
89
+ if fmt != "auto":
90
+ if all(isinstance(c, (int, float)) and not isinstance(c, bool) for c in cats):
91
+ return Formatted(format_axis([float(c) for c in cats], fmt, self.style,
92
+ None)[0])
93
+ if callable(fmt):
94
+ return Formatted([fmt(c) for c in cats])
95
+ if fmt not in ("percent", "sci", "eng", "si", "int"):
96
+ return Formatted([apply_format(fmt, c) for c in cats])
97
+ return Formatted([str(c) if not isinstance(c, (list, str)) else c for c in cats])
@@ -0,0 +1,17 @@
1
+ """Genome tracks: signals, intervals, gene models, links and Hi-C triangles over a
2
+ region, and site matrices (a signal around many sites), read from gwseq_io's readers (or
3
+ any object with their methods) without depending on them; drawn on a genome x axis
4
+ (:mod:`~plot_io.genome.scale`), in a tracks figure (:func:`~plot_io.genome.layout.Tracks`)
5
+ or in any plot."""
6
+
7
+ from .signal import BigWig
8
+ from .combine import combine
9
+ from .intervals import Bed, Genes, Gtf
10
+ from .contacts import HiC
11
+ from .links import Links
12
+ from .layout import Track, Tracks
13
+ from .regions import Region, parse_region
14
+ from .sites import SignalMatrix, SiteHeatmap, SiteProfile
15
+
16
+ __all__ = ["Tracks", "Track", "BigWig", "combine", "Bed", "Genes", "Gtf", "HiC", "Links",
17
+ "SiteHeatmap", "SiteProfile", "SignalMatrix", "Region", "parse_region"]
@@ -0,0 +1,356 @@
1
+ """Readers for what is not one: paths, opened with gwseq_io, and plain data, read from
2
+ memory. Each meets its protocol (:mod:`plot_io.genome.readers`).
3
+
4
+ * paths: ``.bed`` (``.bed12``, …), ``.narrowPeak``, ``.broadPeak`` (gzipped too) through
5
+ ``gwseq_io.open_bed``; ``.tsv``, ``.csv``, ``.xlsx`` through ``gwseq_io.open_peaks``;
6
+ anything else through ``gwseq_io.open``, which tells the format by its bytes. The
7
+ formats gwseq_io has no reader for (bedGraph, BEDPE) are refused;
8
+ * :class:`Intervals`: BED columns (a gene table: BED12);
9
+ * :class:`Pairs`: BEDPE columns, the pairs with an anchor in the region;
10
+ * :class:`Signal`: ``chr``, ``start``, ``end``, ``value`` records, binned here;
11
+ * :class:`Matrix`: a square matrix over the region drawn, whatever it is.
12
+ """
13
+
14
+ import bisect
15
+ import importlib
16
+ import math
17
+ import os
18
+ import re
19
+ import weakref
20
+ from array import array
21
+ from collections.abc import Mapping, Sequence
22
+ from typing import Any
23
+
24
+ from . import sources
25
+
26
+ __all__ = ["check_path", "open_path", "close", "Deferred", "from_data", "Intervals", "Pairs",
27
+ "Signal", "Matrix", "REDUCTIONS"]
28
+
29
+ _BED = re.compile(r"\.(bed\d*|narrowpeak|broadpeak)(\.b?gz)?$", re.IGNORECASE)
30
+ _PEAKS = re.compile(r"\.(tsv|csv|xlsx)$", re.IGNORECASE)
31
+ #: Formats gwseq_io has no reader for, by suffix: their name and what to do instead.
32
+ _UNREAD = {
33
+ "bedgraph": ("bedGraph", "convert it to a bigWig (gwseq_io.convert_to_bigwig) or pass "
34
+ "its chr, start, end, value columns"),
35
+ "bdg": ("bedGraph", "convert it to a bigWig (gwseq_io.convert_to_bigwig) or pass its "
36
+ "chr, start, end, value columns"),
37
+ "bedpe": ("BEDPE", "pass its columns (chr1, start1, end1, chr2, start2, end2) as data"),
38
+ }
39
+
40
+
41
+ def _gwseq_io(path: str, what: str) -> Any:
42
+ try:
43
+ return importlib.import_module("gwseq_io")
44
+ except ImportError:
45
+ raise TypeError(f"{what}: reading the file {path!r} needs gwseq_io, which is not "
46
+ f"installed; or pass an open reader or plain data") from None
47
+
48
+
49
+ def check_path(path: Any, what: str) -> None:
50
+ """Refuse a path of a format gwseq_io has no reader for (by its suffix)."""
51
+ p = os.fsdecode(path)
52
+ name = p.lower()
53
+ for ext in (".gz", ".bgz"):
54
+ if name.endswith(ext):
55
+ name = name[:-len(ext)]
56
+ suffix = name.rsplit(".", 1)[-1] if "." in os.path.basename(name) else ""
57
+ if suffix in _UNREAD:
58
+ fmt, instead = _UNREAD[suffix]
59
+ raise TypeError(f"{what}: gwseq_io, which plot_io reads files with, has no {fmt} "
60
+ f"reader ({p!r}): {instead}")
61
+
62
+
63
+ def open_path(path: Any, what: str) -> Any:
64
+ """The gwseq_io reader of the file at ``path`` (see the module)."""
65
+ check_path(path, what)
66
+ p = os.fsdecode(path)
67
+ g = _gwseq_io(p, what)
68
+ if _BED.search(p):
69
+ return g.open_bed(p)
70
+ if _PEAKS.search(p):
71
+ return g.open_peaks(p)
72
+ try:
73
+ return g.open(p)
74
+ except g.InvalidFile as e:
75
+ raise TypeError(f"{what}: gwseq_io cannot read {p!r} ({e}); a BED or peaks file "
76
+ f"needs its suffix (.bed, .narrowPeak, .tsv, …)") from e
77
+
78
+
79
+ def close(reader: Any) -> None:
80
+ """Close a reader plot_io opened (errors ignored: it is being let go)."""
81
+ f = getattr(reader, "close", None)
82
+ if f is not None:
83
+ try:
84
+ f()
85
+ except Exception:
86
+ pass
87
+
88
+
89
+ class Deferred:
90
+ """A path's reader, opened when first used (at draw): once per figure, shared by the
91
+ series of the figure that read the same path, closed when the figure is collected (a
92
+ series without a figure: when this is). Every member is the reader's."""
93
+
94
+ def __init__(self, path: Any, kinds: tuple[str, ...], what: str, owner: Any) -> None:
95
+ self._path, self._kinds, self._what = os.fsdecode(path), kinds, what
96
+ self._owner = None if owner is None else weakref.ref(owner)
97
+ self._reader: Any = None
98
+
99
+ def _figure(self) -> Any:
100
+ owner = None if self._owner is None else self._owner()
101
+ return getattr(getattr(owner, "plot", None), "figure", None)
102
+
103
+ def opened(self) -> Any:
104
+ """The reader (opened now when it is not yet)."""
105
+ fig = self._figure()
106
+ cache = None if fig is None else fig.__dict__.setdefault("_genome_readers", {})
107
+ reader = self._reader if cache is None else cache.get(self._path)
108
+ if reader is None:
109
+ reader = open_path(self._path, self._what)
110
+ try:
111
+ sources.check_reader(reader, self._kinds, self._what)
112
+ except BaseException:
113
+ close(reader)
114
+ raise
115
+ if cache is None:
116
+ weakref.finalize(self, close, reader)
117
+ else:
118
+ cache[self._path] = reader
119
+ weakref.finalize(fig, close, reader)
120
+ else:
121
+ sources.check_reader(reader, self._kinds, self._what)
122
+ self._reader = reader
123
+ return reader
124
+
125
+ def __getattr__(self, name: str) -> Any:
126
+ if name.startswith("_"):
127
+ raise AttributeError(name)
128
+ return getattr(self.opened(), name)
129
+
130
+ def __repr__(self) -> str:
131
+ return f"<reader of {self._path!r}>"
132
+
133
+
134
+ def from_data(value: Any, want: str, what: str) -> Any:
135
+ """An in-memory reader of plain data for role ``want`` (see
136
+ :func:`plot_io.genome.readers.adapt`)."""
137
+ if want == "signal":
138
+ if callable(value): # f(chr, start, end, bins): values over the region
139
+ return value
140
+ return Signal(value, what)
141
+ if want in ("intervals", "annotation"):
142
+ return Intervals(value)
143
+ if want == "pairs":
144
+ return Pairs(value)
145
+ if want == "contacts":
146
+ return Matrix(value)
147
+ raise ValueError(f"unknown reader role {want!r}")
148
+
149
+
150
+ def _regions(regions: Any) -> list[tuple[str, int, int]]:
151
+ if isinstance(regions, tuple) and len(regions) == 3 and isinstance(regions[0], str):
152
+ regions = [regions]
153
+ return [(str(c), int(s), int(e)) for c, s, e in regions]
154
+
155
+
156
+ class Intervals:
157
+ """BED columns in memory (see :func:`~plot_io.genome.sources.entry_columns`): the
158
+ entries overlapping a region, on its chromosome when the data name one."""
159
+
160
+ def __init__(self, data: Any) -> None:
161
+ self.columns = sources.entry_columns(data)
162
+ chrs = self.columns.get("chr")
163
+ #: the chromosomes the data name (``None``: any)
164
+ self.chr_names = None if chrs is None else frozenset(chrs)
165
+
166
+ def read_entries(self, regions: Any, columns: Any = None, **kw: Any) -> dict[str, list]:
167
+ cols = self.columns
168
+ chrs = cols.get("chr")
169
+ starts, ends = cols["start"], cols["end"]
170
+ keep: list[int] = []
171
+ for c, lo, hi in _regions(regions):
172
+ keep += [i for i in range(len(starts)) if ends[i] > lo and starts[i] < hi
173
+ and (chrs is None or chrs[i] == c)]
174
+ return {k: [v[i] for i in keep] for k, v in cols.items()}
175
+
176
+
177
+ class Pairs:
178
+ """BEDPE columns in memory (see :func:`~plot_io.genome.sources.pair_columns`): the
179
+ pairs with either anchor overlapping a region, each once, in the given order."""
180
+
181
+ def __init__(self, data: Any) -> None:
182
+ self.columns = sources.pair_columns(data)
183
+
184
+ def read_entries(self, regions: Any, **kw: Any) -> dict[str, list]:
185
+ cols = self.columns
186
+ want = _regions(regions)
187
+ keep = []
188
+ for i in range(len(cols["start1"])):
189
+ for c, lo, hi in want:
190
+ if (cols["chr1"][i] == c and cols["end1"][i] > lo and cols["start1"][i] < hi) \
191
+ or (cols["chr2"][i] == c and cols["end2"][i] > lo
192
+ and cols["start2"][i] < hi):
193
+ keep.append(i)
194
+ break
195
+ return {k: [v[i] for i in keep] for k, v in cols.items()}
196
+
197
+
198
+ #: The reductions :class:`Signal` computes.
199
+ REDUCTIONS = ("mean", "min", "max", "sum", "count")
200
+ _SIGNAL_KEYS = {"chr": ("chr", "chrom", "chromosome", "seqname"),
201
+ "start": ("start", "chromstart", "chrom_start"),
202
+ "end": ("end", "chromend", "chrom_end"),
203
+ "value": ("value", "score", "signal", "dataValue")}
204
+
205
+
206
+ def _signal_columns(data: Any, what: str) -> dict[str, list]:
207
+ if hasattr(data, "to_dict") and hasattr(data, "columns"): # a DataFrame
208
+ data = {str(c): list(data[c]) for c in data.columns}
209
+ if isinstance(data, Mapping):
210
+ low = {str(k).lower(): v for k, v in data.items()}
211
+ cols = {}
212
+ for name, keys in _SIGNAL_KEYS.items():
213
+ got = next((low[k.lower()] for k in keys if k.lower() in low), None)
214
+ if got is None:
215
+ raise ValueError(f"{what}: a signal table needs chr, start, end and value "
216
+ f"columns; {name} is missing")
217
+ cols[name] = list(got.tolist() if hasattr(got, "tolist") else got)
218
+ return cols
219
+ if isinstance(data, (str, bytes)) or not isinstance(data, Sequence) and \
220
+ not hasattr(data, "__iter__"):
221
+ raise TypeError(f"{what} takes a signal reader, a path, a function f(chr, start, "
222
+ f"end, bins) or chr, start, end, value records; got "
223
+ f"{type(data).__name__}")
224
+ rows = list(data)
225
+ if rows and isinstance(rows[0], Mapping):
226
+ return _signal_columns({k: [r[k] for r in rows] for k in rows[0]}, what)
227
+ if any(isinstance(r, (str, bytes)) or not isinstance(r, Sequence) or len(r) != 4
228
+ for r in rows):
229
+ raise TypeError(f"{what} takes a signal reader, a path, a function f(chr, start, "
230
+ f"end, bins) or chr, start, end, value records (rows of 4); got "
231
+ f"{type(data).__name__}")
232
+ return {k: [r[i] for r in rows] for i, k in enumerate(("chr", "start", "end", "value"))}
233
+
234
+
235
+ class Signal:
236
+ """``chr``, ``start``, ``end``, ``value`` records in memory (columns, a DataFrame, or
237
+ rows), binned here as gwseq_io bins a bigWig: each bin the reduction (:data:`REDUCTIONS`)
238
+ of its bases, weighted by overlap, bases no record covers counting as ``def_value``
239
+ (left out when NaN)."""
240
+
241
+ def __init__(self, data: Any, what: str = "BigWig") -> None:
242
+ cols = _signal_columns(data, what)
243
+ n = len(cols["chr"])
244
+ if any(len(v) != n for v in cols.values()):
245
+ raise ValueError(f"{what}: the signal columns have different lengths")
246
+ by: dict[str, list[tuple[int, int, float]]] = {}
247
+ for i in range(n):
248
+ s = sources._position(cols["start"][i], i, "start")
249
+ e = sources._position(cols["end"][i], i, "end")
250
+ if e < s:
251
+ raise ValueError(f"record {i + 1} ends before it starts ({s}-{e})")
252
+ v = cols["value"][i]
253
+ by.setdefault(str(cols["chr"][i]), []).append(
254
+ (s, e, math.nan if v is None else float(v)))
255
+ self._chrs: dict[str, tuple[array, array, array, int]] = {}
256
+ for c, recs in by.items():
257
+ recs.sort()
258
+ self._chrs[c] = (array("q", [r[0] for r in recs]), array("q", [r[1] for r in recs]),
259
+ array("d", [r[2] for r in recs]),
260
+ max((r[1] - r[0] for r in recs), default=0))
261
+ #: the chromosomes the records name
262
+ self.chr_names = frozenset(self._chrs)
263
+
264
+ def read_values(self, regions: Any, *, bin_count: int | None = None,
265
+ bin_size: int | None = None, bin_mode: str = "mean",
266
+ def_value: float = 0.0, full_bin: bool = False, **kw: Any) -> list[array]:
267
+ if bin_mode not in REDUCTIONS:
268
+ raise ValueError(f"bin_mode {bin_mode!r}: plain signal data are reduced by "
269
+ f"{', '.join(REDUCTIONS)}")
270
+ if kw.get("snap_to_grid") not in (None, False, "auto"):
271
+ raise ValueError("plain signal data are binned from the region's start "
272
+ "(snap_to_grid=False)")
273
+ out = []
274
+ for c, lo, hi in _regions(regions):
275
+ if bin_count is not None:
276
+ n = max(1, int(bin_count))
277
+ edges = [lo + (hi - lo) * i / n for i in range(n + 1)]
278
+ edges[-1] = float(hi)
279
+ else:
280
+ size = int(bin_size or 1)
281
+ n = (hi - lo) // size if not full_bin else -(-(hi - lo) // size)
282
+ edges = [float(lo + i * size) for i in range(n + 1)]
283
+ out.append(self._bin(c, edges, bin_mode, float(def_value)))
284
+ return out
285
+
286
+ def _bin(self, chr: str, edges: list[float], mode: str, def_value: float) -> array:
287
+ n = len(edges) - 1
288
+ total = [0.0] * n # value × bases covered
289
+ covered = [0.0] * n # bases covered
290
+ lo_v = [math.inf] * n
291
+ hi_v = [-math.inf] * n
292
+ got = self._chrs.get(chr)
293
+ if got is not None and n > 0:
294
+ starts, ends, values, longest = got
295
+ a, b = edges[0], edges[-1]
296
+ i = bisect.bisect_left(starts, int(a) - longest)
297
+ j = bisect.bisect_left(starts, b)
298
+ for k in range(i, j):
299
+ s, e, v = starts[k], ends[k], values[k]
300
+ if e <= a or math.isnan(v):
301
+ continue
302
+ s0, e0 = max(float(s), a), min(float(e), b)
303
+ if e0 <= s0:
304
+ continue
305
+ first = max(0, bisect.bisect_right(edges, s0) - 1)
306
+ for m in range(first, n):
307
+ x0, x1 = max(s0, edges[m]), min(e0, edges[m + 1])
308
+ if x1 <= x0:
309
+ if edges[m] >= e0:
310
+ break
311
+ continue
312
+ total[m] += v * (x1 - x0)
313
+ covered[m] += x1 - x0
314
+ lo_v[m] = min(lo_v[m], v)
315
+ hi_v[m] = max(hi_v[m], v)
316
+ fill = not math.isnan(def_value)
317
+ out = array("d")
318
+ for m in range(n):
319
+ width = edges[m + 1] - edges[m]
320
+ gap = max(0.0, width - covered[m])
321
+ if mode == "count":
322
+ out.append(covered[m])
323
+ continue
324
+ if fill and gap > 1e-9:
325
+ total[m] += def_value * gap
326
+ lo_v[m], hi_v[m] = min(lo_v[m], def_value), max(hi_v[m], def_value)
327
+ weight = width
328
+ else:
329
+ weight = covered[m]
330
+ if weight <= 0:
331
+ out.append(math.nan)
332
+ elif mode == "mean":
333
+ out.append(total[m] / weight)
334
+ elif mode == "sum":
335
+ out.append(total[m])
336
+ else:
337
+ out.append(lo_v[m] if mode == "min" else hi_v[m])
338
+ return out
339
+
340
+
341
+ class Matrix:
342
+ """A square matrix of values over the region drawn (bins of ``span / n``): it has no
343
+ coordinates of its own, so it is the same whatever the region."""
344
+
345
+ bin_sizes: tuple[int, ...] = ()
346
+ normalizations = ("none",)
347
+
348
+ def __init__(self, data: Any) -> None:
349
+ h, w, k, flat = sources.matrix(data)
350
+ if h != w or k != 1 or not h:
351
+ raise ValueError(f"HiC needs a square matrix of values, got {h} × {w}")
352
+ self.n, self.values = h, flat
353
+
354
+ def read_values(self, region1: Any, region2: Any = None, **kw: Any) -> list[array]:
355
+ n, v = self.n, self.values
356
+ return [v[i * n:(i + 1) * n] for i in range(n)]
@@ -0,0 +1,123 @@
1
+ """Two signals as one: :func:`combine` gives a signal reader whose values are those of two
2
+ readers read with the same arguments, combined bin by bin (a difference, a ratio, a log2
3
+ ratio or a function)."""
4
+
5
+ import math
6
+ from collections.abc import Callable, Mapping
7
+ from typing import Any
8
+
9
+ from vec_io.arrays import doubles
10
+
11
+ from ..validators import real
12
+ from . import adapters, readers
13
+
14
+ __all__ = ["combine", "Combined", "OPS"]
15
+
16
+
17
+ def _ratio(a: float, b: float) -> float:
18
+ return a / b if b else math.nan
19
+
20
+
21
+ def _log2ratio(p: float) -> Callable[[float, float], float]:
22
+ def f(a: float, b: float) -> float:
23
+ a, b = a + p, b + p
24
+ return math.log2(a / b) if a > 0 and b > 0 else math.nan
25
+ return f
26
+
27
+
28
+ #: The operations by name: ``a - b``, ``a / b``, ``log2((a + p) / (b + p))``.
29
+ OPS = ("difference", "ratio", "log2ratio")
30
+
31
+
32
+ def _rows(v: Any) -> list[Any]:
33
+ """The rows of a read (a 2-D buffer or a sequence of rows), as doubles."""
34
+ shape = getattr(v, "shape", None)
35
+ if shape is not None and len(shape) == 2:
36
+ return [doubles(v[i]) for i in range(shape[0])]
37
+ return [doubles(r) for r in v]
38
+
39
+
40
+ class Combined:
41
+ """A signal reader of two (see :func:`combine`)."""
42
+
43
+ def __init__(self, a: Any, b: Any, op: Any, pseudo_count: Any) -> None:
44
+ self.a = self._signal(a)
45
+ self.b = self._signal(b)
46
+ p = real(pseudo_count)
47
+ if p is None or not math.isfinite(p) or p < 0:
48
+ raise ValueError(f"combine: pseudo_count must be a number ≥ 0, got "
49
+ f"{pseudo_count!r}")
50
+ if isinstance(op, str):
51
+ if op not in OPS:
52
+ raise ValueError(f"combine: op must be one of {', '.join(OPS)} or a "
53
+ f"function, got {op!r}")
54
+ f: Callable[[float, float], float] = (
55
+ (lambda x, y: x - y) if op == "difference" else
56
+ (lambda x, y: _ratio(x + p, y + p)) if op == "ratio" else _log2ratio(p))
57
+ elif callable(op):
58
+ f = op
59
+ else:
60
+ raise TypeError(f"combine: op must be one of {', '.join(OPS)} or a function, "
61
+ f"got {op!r}")
62
+ self.op, self.pseudo_count, self._f = op, p, f
63
+
64
+ def __getattr__(self, name: str) -> Any:
65
+ """``resolve_regions``: the first reader's, when both give it (read at draw)."""
66
+ if name == "resolve_regions" and "b" in self.__dict__ \
67
+ and hasattr(self.a, name) and hasattr(self.b, name):
68
+ return self.a.resolve_regions
69
+ raise AttributeError(name)
70
+
71
+ def _signal(self, v: Any) -> Any:
72
+ reader = readers.adapt(v, "signal", "combine", owner=self)
73
+ if not isinstance(reader, adapters.Deferred) and not hasattr(reader, "read_values"):
74
+ raise TypeError(f"combine needs signal readers or signal data, got {v!r}")
75
+ return reader
76
+
77
+ def __repr__(self) -> str:
78
+ op = self.op if isinstance(self.op, str) else getattr(self.op, "__name__", "op")
79
+ return f"combine({self.a!r}, {self.b!r}, {op!r})"
80
+
81
+ @property
82
+ def chr_sizes(self) -> Mapping[str, int]:
83
+ """The sizes either reader gives (the larger)."""
84
+ sizes: dict[str, int] = {}
85
+ for r in (self.a, self.b):
86
+ got = getattr(r, "chr_sizes", None)
87
+ if isinstance(got, Mapping):
88
+ for k, n in got.items():
89
+ sizes[str(k)] = max(sizes.get(str(k), 0), int(n))
90
+ if not sizes:
91
+ raise AttributeError("neither reader gives chr_sizes")
92
+ return sizes
93
+
94
+ def read_values(self, regions: Any, **kw: Any) -> list[Any]:
95
+ """Both readers read with these arguments, combined bin by bin: NaN where either
96
+ is NaN or the result is undefined (a ratio over 0, the log of a value ≤ 0)."""
97
+ ra, rb = _rows(self.a.read_values(regions, **kw)), _rows(self.b.read_values(regions, **kw))
98
+ if len(ra) != len(rb):
99
+ raise ValueError(f"combine: the readers gave {len(ra)} and {len(rb)} rows")
100
+ f = self._f
101
+ out = []
102
+ for x, y in zip(ra, rb):
103
+ if len(x) != len(y):
104
+ raise ValueError(f"combine: the readers gave rows of {len(x)} and {len(y)} "
105
+ f"bins (give both the same grid)")
106
+ row = [math.nan if math.isnan(a) or math.isnan(b) else float(f(a, b))
107
+ for a, b in zip(x, y)]
108
+ out.append(doubles(row))
109
+ return out
110
+
111
+
112
+ def combine(a: Any, b: Any, op: Any = "log2ratio", pseudo_count: Any = 0) -> Combined:
113
+ """A signal reader of ``a`` and ``b`` (readers, paths or signal data, as ``bigwig``
114
+ takes them) read with the same arguments and combined bin by bin: ``"difference"``
115
+ (``a - b``), ``"ratio"`` (``a / b``), ``"log2ratio"`` (``log2((a + p) / (b + p))``,
116
+ ``p`` the ``pseudo_count``; a ratio adds it too) or a function ``op(a, b)`` of two
117
+ numbers; NaN where either is NaN or the result is undefined. It is drawn wherever a
118
+ signal reader is: ``bigwig``, heat strips, the site matrix.
119
+
120
+ >>> fig.bigwig(plt.genome.combine("treat.bw", "input.bw", "log2ratio",
121
+ ... pseudo_count=1))
122
+ """
123
+ return Combined(a, b, op, pseudo_count)