densitty 0.8.2__py3-none-any.whl → 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.
- densitty/__init__.py +9 -0
- densitty/ansi.py +0 -1
- densitty/axis.py +222 -59
- densitty/binning.py +200 -115
- densitty/colorbar.py +84 -0
- densitty/detect.py +142 -24
- densitty/{plot.py → plotting.py} +77 -19
- densitty/smoothing.py +315 -0
- densitty/truecolor.py +2 -1
- densitty/util.py +149 -124
- densitty/util.pyi +38 -0
- {densitty-0.8.2.dist-info → densitty-1.0.0.dist-info}/METADATA +11 -6
- densitty-1.0.0.dist-info/RECORD +18 -0
- {densitty-0.8.2.dist-info → densitty-1.0.0.dist-info}/WHEEL +1 -1
- densitty-0.8.2.dist-info/RECORD +0 -15
- {densitty-0.8.2.dist-info → densitty-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {densitty-0.8.2.dist-info → densitty-1.0.0.dist-info}/top_level.txt +0 -0
densitty/__init__.py
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""In-terminal 2-D histogram/density/heatmap plotting"""
|
|
2
|
+
|
|
3
|
+
from .detect import densityplot2d, grid_heatmap, histplot2d, plot
|
|
4
|
+
from .detect import GRAYSCALE, FADE_IN, RAINBOW, REV_RAINBOW
|
|
5
|
+
from .axis import Axis
|
|
6
|
+
from .plotting import Plot
|
|
7
|
+
from .colorbar import make_colorbar
|
|
8
|
+
from .binning import histogram2d
|
|
9
|
+
from .smoothing import smooth2d
|
densitty/ansi.py
CHANGED
densitty/axis.py
CHANGED
|
@@ -4,15 +4,10 @@ import dataclasses
|
|
|
4
4
|
from decimal import Decimal
|
|
5
5
|
import itertools
|
|
6
6
|
import math
|
|
7
|
-
from typing import Optional
|
|
7
|
+
from typing import Optional, Sequence
|
|
8
8
|
|
|
9
|
-
from . import lineart
|
|
10
|
-
from .util import FloatLike, ValueRange
|
|
11
|
-
|
|
12
|
-
MIN_X_TICKS_PER_LABEL = 4
|
|
13
|
-
MIN_Y_TICKS_PER_LABEL = 2
|
|
14
|
-
DEFAULT_X_COLS_PER_TICK = 4
|
|
15
|
-
DEFAULT_Y_ROWS_PER_TICK = 2
|
|
9
|
+
from . import lineart, util
|
|
10
|
+
from .util import FloatLike, ValueRange
|
|
16
11
|
|
|
17
12
|
|
|
18
13
|
@dataclasses.dataclass
|
|
@@ -31,16 +26,63 @@ x_border = {False: BorderChars(" ", " ", " "), True: BorderChars("╶", "─", "
|
|
|
31
26
|
# Helper functions used by the Axis class below
|
|
32
27
|
|
|
33
28
|
|
|
34
|
-
def
|
|
29
|
+
def pick_step_size(lower_bound) -> Decimal:
|
|
30
|
+
"""Try to pick a step size that gives nice round values for step positions."""
|
|
31
|
+
if lower_bound <= 0:
|
|
32
|
+
raise ValueError("pick_step_size called with 0 or negative value")
|
|
33
|
+
|
|
34
|
+
_, frac, exp = util.sfrexp10(lower_bound) # 0.1 <= frac < 1.0
|
|
35
|
+
|
|
36
|
+
# round up to an appropriate "round" value that starts with 1/2/5
|
|
37
|
+
if frac <= Decimal("0.2"):
|
|
38
|
+
out = Decimal((0, (2,), exp - 1))
|
|
39
|
+
elif frac <= Decimal("0.5"):
|
|
40
|
+
out = Decimal((0, (5,), exp - 1))
|
|
41
|
+
else:
|
|
42
|
+
out = Decimal((0, (1,), exp))
|
|
43
|
+
|
|
44
|
+
if out >= 10:
|
|
45
|
+
# this will be printed as 1E1 or such. Give it more significant figures so
|
|
46
|
+
# it is printed as an integer:
|
|
47
|
+
return out.quantize(1)
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def add_x_label(line: list[str], label: str, ctr_pos: FloatLike):
|
|
35
52
|
"""Adds the label string to the output line, centered at specified position
|
|
36
53
|
The output line is a list of single-character strings, to make this kind of thing
|
|
37
54
|
straightforward"""
|
|
38
55
|
width = len(label)
|
|
39
|
-
start_col = max(ctr_pos - width
|
|
56
|
+
start_col = int(max(float(ctr_pos) + 0.49 - width / 2, 0))
|
|
40
57
|
end_col = start_col + width
|
|
41
58
|
line[start_col:end_col] = list(label)
|
|
42
59
|
|
|
43
60
|
|
|
61
|
+
def add_x_tick(line: list[str], start_pos: float, left_margin: int, num_cols: int):
|
|
62
|
+
"""Adds a (possibly fractional) tick to the output line, centered at specified position"""
|
|
63
|
+
base_idx = int(start_pos)
|
|
64
|
+
fractional_pos = start_pos - base_idx
|
|
65
|
+
margined_idx = base_idx + left_margin
|
|
66
|
+
if 0.25 <= fractional_pos <= 0.75:
|
|
67
|
+
# add a tick in the center of the appropriate bin
|
|
68
|
+
line[margined_idx] = lineart.merge_chars("│", line[margined_idx])
|
|
69
|
+
return
|
|
70
|
+
if fractional_pos < 0.25:
|
|
71
|
+
left_idx = max(margined_idx - 1, 0)
|
|
72
|
+
else:
|
|
73
|
+
left_idx = margined_idx
|
|
74
|
+
|
|
75
|
+
if base_idx == 0:
|
|
76
|
+
line[left_idx] = lineart.merge_chars("│", line[left_idx])
|
|
77
|
+
else:
|
|
78
|
+
line[left_idx] = "╱"
|
|
79
|
+
|
|
80
|
+
if base_idx >= num_cols - 1:
|
|
81
|
+
line[left_idx + 1] = lineart.merge_chars("│", line[left_idx + 1])
|
|
82
|
+
else:
|
|
83
|
+
line[left_idx + 1] = "╲"
|
|
84
|
+
|
|
85
|
+
|
|
44
86
|
def gen_tick_values(value_range, tick_step):
|
|
45
87
|
"""Produce tick values in the specified range. Basically numpy.arange"""
|
|
46
88
|
|
|
@@ -50,29 +92,142 @@ def gen_tick_values(value_range, tick_step):
|
|
|
50
92
|
tick += tick_step
|
|
51
93
|
|
|
52
94
|
|
|
53
|
-
def
|
|
54
|
-
|
|
95
|
+
def positions_to_labels(
|
|
96
|
+
printed_positions: Sequence[Decimal], ticked_positions: Sequence[FloatLike], fmt: str
|
|
97
|
+
) -> dict[FloatLike, str]:
|
|
98
|
+
"""Given positions, construct a label dict mapping position to string to be printed there.
|
|
99
|
+
The first positions get a printed value, the second set get a blank, for a bare 'tick'"""
|
|
100
|
+
|
|
101
|
+
tick_dict = {p: "" for p in ticked_positions}
|
|
102
|
+
printed_dict = {p: fmt.format(p) for p in util.sanitize_decimals(printed_positions)}
|
|
103
|
+
return tick_dict | printed_dict
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def calc_min_step(
|
|
107
|
+
value_range: ValueRange,
|
|
108
|
+
bin_width: FloatLike,
|
|
109
|
+
accomodate_values: bool,
|
|
110
|
+
tick_space: int,
|
|
111
|
+
fmt: str,
|
|
55
112
|
):
|
|
113
|
+
"""Calculate minimum step size for tick placement.
|
|
114
|
+
|
|
115
|
+
When accomodate_values is True, considers the width of printed labels.
|
|
116
|
+
Otherwise, only considers tick spacing requirements.
|
|
117
|
+
"""
|
|
118
|
+
if accomodate_values:
|
|
119
|
+
# find a representative printed label width to use for basic calculations
|
|
120
|
+
test_tick_step = pick_step_size((value_range.max - value_range.min) / 5)
|
|
121
|
+
test_values_printed = tuple(
|
|
122
|
+
fmt.format(value) for value in gen_tick_values(value_range, test_tick_step)
|
|
123
|
+
)
|
|
124
|
+
widths_in_bins = tuple(len(p) for p in test_values_printed)
|
|
125
|
+
# get the 3'd lowest width, or highest if there are less than 3 due to tick-step roundup:
|
|
126
|
+
example_width = sorted(widths_in_bins)[:3][-1]
|
|
127
|
+
min_printed_step = (example_width + 1) * bin_width
|
|
128
|
+
# If the printed labels are small (single-digit), the ticks themselves might be the
|
|
129
|
+
# limiting factor, especially if they are X-axis fractional ticks like "/\"
|
|
130
|
+
return max(min_printed_step, bin_width * (2 + tick_space * 2))
|
|
131
|
+
return bin_width * (2 + tick_space)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def gen_position_subsets(positions: tuple, tick_step: Decimal) -> list[Sequence[Decimal]]:
|
|
135
|
+
"""Generate candidate label position subsets based on tick step digit.
|
|
136
|
+
|
|
137
|
+
For tick steps starting with 1 or 2: generates every-5th subsets (5 variants).
|
|
138
|
+
For tick steps starting with 5: generates every-2nd subsets (2 variants).
|
|
139
|
+
"""
|
|
140
|
+
step_digit = tick_step.as_tuple().digits[0] # leading digit of tick step: 1, 2, or 5
|
|
141
|
+
# we want to pick different position subsets depending on whether we're advancing by
|
|
142
|
+
# 1eX, 2eX, or 5eX:
|
|
143
|
+
position_subsets = []
|
|
144
|
+
if step_digit in (1, 2):
|
|
145
|
+
# print on every fifth one, starting at 0..4
|
|
146
|
+
position_subsets += list(util.sanitize_decimals(positions[start::5]) for start in range(5))
|
|
147
|
+
elif step_digit == 5:
|
|
148
|
+
# print on every second one, starting at 0 or 1
|
|
149
|
+
position_subsets += list(util.sanitize_decimals(positions[start::2]) for start in range(2))
|
|
150
|
+
return position_subsets
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def label_ends_only(positions, tick_step, bin_width, accomodate_values, fmt):
|
|
154
|
+
"""See if printing just the labels for the first and last ticks will fit"""
|
|
155
|
+
|
|
156
|
+
if not accomodate_values:
|
|
157
|
+
# For Y axis / we don't care about printed widths
|
|
158
|
+
return positions_to_labels((positions[0], positions[-1]), positions, fmt)
|
|
159
|
+
|
|
160
|
+
end_positions = (positions[0], positions[-1])
|
|
161
|
+
ends_printed = tuple(positions_to_labels(end_positions, [], fmt).values())
|
|
162
|
+
|
|
163
|
+
half_len_first = len(ends_printed[0]) // 2
|
|
164
|
+
half_len_last = (len(ends_printed[1]) + 1) // 2
|
|
165
|
+
space_available = math.floor(tick_step / bin_width)
|
|
166
|
+
|
|
167
|
+
if half_len_first + half_len_last <= space_available:
|
|
168
|
+
# We can fit values on the first and last ticks
|
|
169
|
+
return positions_to_labels(end_positions, positions, fmt)
|
|
170
|
+
|
|
171
|
+
# Not enough space to print two values, so just print the first
|
|
172
|
+
return positions_to_labels(end_positions[:1], positions, fmt)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def find_fitting_subset(position_subsets, ticks_per_bin, accomodate_values, fmt):
|
|
176
|
+
"""Find roundest label subset that fits within space constraints"""
|
|
177
|
+
for position_subset in util.roundness_ordered(position_subsets):
|
|
178
|
+
if not accomodate_values: # it doesn't matter what the printed widths are, it will fit
|
|
179
|
+
return position_subset
|
|
180
|
+
# We're printing at most one value for every 2 ticks, so just make sure
|
|
181
|
+
# that the printed values will not run over the adjacent ticks' area
|
|
182
|
+
# Given the initial min_step logic, this will likely always be true
|
|
183
|
+
printed_widths = (
|
|
184
|
+
len(label) for label in positions_to_labels(position_subset, [], fmt).values()
|
|
185
|
+
)
|
|
186
|
+
allowed_width = ticks_per_bin * 2 - 2
|
|
187
|
+
if max(printed_widths) <= allowed_width:
|
|
188
|
+
return position_subset
|
|
189
|
+
return tuple()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def gen_full_labels(value_range: ValueRange, num_bins, accomodate_values, tick_space, fmt):
|
|
56
193
|
"""Generate positions for labels (plain ticks & ticks with value)"""
|
|
57
|
-
|
|
194
|
+
bin_width = (value_range.max - value_range.min) / num_bins
|
|
195
|
+
|
|
196
|
+
if bin_width <= 0:
|
|
197
|
+
# we don't have a sensible range for the axis values, so just have empty ticks
|
|
198
|
+
return {}
|
|
199
|
+
|
|
200
|
+
min_step = calc_min_step(value_range, bin_width, accomodate_values, tick_space, fmt)
|
|
201
|
+
cur_tick_step = pick_step_size(min_step)
|
|
202
|
+
|
|
203
|
+
while True:
|
|
204
|
+
positions = tuple(gen_tick_values(value_range, cur_tick_step))
|
|
205
|
+
|
|
206
|
+
if len(positions) == 0:
|
|
207
|
+
# No suitable ticks/labels.
|
|
208
|
+
if accomodate_values:
|
|
209
|
+
# Printed value labels won't fit, try without them
|
|
210
|
+
return gen_full_labels(value_range, num_bins, False, tick_space, "")
|
|
211
|
+
# Nothing fits
|
|
212
|
+
return {}
|
|
213
|
+
if len(positions) == 1:
|
|
214
|
+
return positions_to_labels(positions, [], fmt)
|
|
58
215
|
|
|
59
|
-
|
|
60
|
-
label_values = list(gen_tick_values(value_range, label_step))
|
|
61
|
-
if label_end_ticks or len(label_values) <= 2:
|
|
62
|
-
# ensure that first & last ticks have labels:
|
|
63
|
-
if label_values[0] != ticks[0]:
|
|
64
|
-
label_values = ticks[:1] + label_values
|
|
65
|
-
if label_values[-1] != ticks[-1]:
|
|
66
|
-
label_values += ticks[-1:]
|
|
216
|
+
position_subsets = gen_position_subsets(positions, cur_tick_step)
|
|
67
217
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
218
|
+
# Check to see if all generated label subsets only have a single entry
|
|
219
|
+
if max(len(subset) for subset in position_subsets) == 1:
|
|
220
|
+
# Try to just label the ends:
|
|
221
|
+
return label_ends_only(positions, cur_tick_step, bin_width, accomodate_values, fmt)
|
|
71
222
|
|
|
72
|
-
|
|
73
|
-
|
|
223
|
+
best_subset = find_fitting_subset(
|
|
224
|
+
position_subsets, cur_tick_step / bin_width, accomodate_values, fmt
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if best_subset:
|
|
228
|
+
return positions_to_labels(best_subset, positions, fmt)
|
|
74
229
|
|
|
75
|
-
|
|
230
|
+
cur_tick_step = pick_step_size(float(cur_tick_step) * 1.01)
|
|
76
231
|
|
|
77
232
|
|
|
78
233
|
def calc_edges(value_range, num_bins, values_are_edges):
|
|
@@ -88,7 +243,7 @@ def calc_edges(value_range, num_bins, values_are_edges):
|
|
|
88
243
|
values_are_edges: bool
|
|
89
244
|
Indicates that value_range specifies outside edges rather than bin centers
|
|
90
245
|
"""
|
|
91
|
-
if values_are_edges:
|
|
246
|
+
if values_are_edges or num_bins == 1:
|
|
92
247
|
bin_delta = (value_range.max - value_range.min) / num_bins
|
|
93
248
|
first_bin_min = value_range.min
|
|
94
249
|
else:
|
|
@@ -107,7 +262,9 @@ class Axis:
|
|
|
107
262
|
"""Options for axis generation."""
|
|
108
263
|
|
|
109
264
|
value_range: ValueRange # can also specify as a tuple of (min, max)
|
|
110
|
-
labels: Optional[dict[
|
|
265
|
+
labels: Optional[dict[FloatLike, str]] = (
|
|
266
|
+
None # map axis value to label (plus tick) at that value
|
|
267
|
+
)
|
|
111
268
|
label_fmt: str = "{}" # format for generated labels
|
|
112
269
|
border_line: bool = False # embed ticks in a horizontal X-axis or vertical Y-axis line
|
|
113
270
|
values_are_edges: bool = False # N+1 values, indicating boundaries between pixels, not centers
|
|
@@ -116,7 +273,7 @@ class Axis:
|
|
|
116
273
|
def __init__(
|
|
117
274
|
self,
|
|
118
275
|
value_range: ValueRange | tuple[FloatLike, FloatLike],
|
|
119
|
-
labels: Optional[dict[
|
|
276
|
+
labels: Optional[dict[FloatLike, str]] = None,
|
|
120
277
|
label_fmt: str = "{}",
|
|
121
278
|
border_line: bool = False,
|
|
122
279
|
values_are_edges: bool = False,
|
|
@@ -137,10 +294,11 @@ class Axis:
|
|
|
137
294
|
def _unjustified_y_axis(self, num_rows: int):
|
|
138
295
|
"""Returns the Y axis string for each line of the plot"""
|
|
139
296
|
if self.labels is None:
|
|
140
|
-
labels =
|
|
297
|
+
labels = gen_full_labels(
|
|
141
298
|
self.value_range,
|
|
142
|
-
num_rows
|
|
143
|
-
|
|
299
|
+
num_rows,
|
|
300
|
+
False,
|
|
301
|
+
0,
|
|
144
302
|
self.label_fmt,
|
|
145
303
|
)
|
|
146
304
|
else:
|
|
@@ -154,10 +312,12 @@ class Axis:
|
|
|
154
312
|
if label_values and row_min <= label_values[0] <= row_max:
|
|
155
313
|
label_str = labels[label_values[0]]
|
|
156
314
|
|
|
157
|
-
offset_frac = (label_values[0] - row_min) / (row_max - row_min)
|
|
158
|
-
|
|
315
|
+
offset_frac = (float(label_values[0]) - float(row_min)) / float(row_max - row_min)
|
|
316
|
+
# Try to avoid our cutoffs being exactly where roundoff errors can pop up and
|
|
317
|
+
# cause inconsistent behavior. So 0.249 rather than 0.25, 0.751 rather than 0.75:
|
|
318
|
+
if offset_frac < 0.249 and self.fractional_tick_pos:
|
|
159
319
|
tick_char = "▔"
|
|
160
|
-
elif offset_frac > 0.
|
|
320
|
+
elif offset_frac > 0.751 and self.fractional_tick_pos:
|
|
161
321
|
tick_char = "▁"
|
|
162
322
|
else:
|
|
163
323
|
tick_char = "─"
|
|
@@ -215,14 +375,9 @@ class Axis:
|
|
|
215
375
|
left_margin: int
|
|
216
376
|
chars to the left of leftmost data col. May have Labels/border-line.
|
|
217
377
|
"""
|
|
218
|
-
|
|
219
378
|
if self.labels is None:
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
num_cols // DEFAULT_X_COLS_PER_TICK,
|
|
223
|
-
MIN_X_TICKS_PER_LABEL,
|
|
224
|
-
self.label_fmt,
|
|
225
|
-
)
|
|
379
|
+
tick_space = 1 if self.fractional_tick_pos else 0
|
|
380
|
+
labels = gen_full_labels(self.value_range, num_cols, True, tick_space, self.label_fmt)
|
|
226
381
|
else:
|
|
227
382
|
labels = self.labels
|
|
228
383
|
|
|
@@ -242,24 +397,32 @@ class Axis:
|
|
|
242
397
|
for col_idx, (col_min, col_max) in enumerate(bins):
|
|
243
398
|
# use Decimal.next_plus to accomodate rounding error/truncation
|
|
244
399
|
if label_values and col_min <= label_values[0] <= col_max.next_plus():
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
offset_frac = (label_values[0] - col_min) / (col_max - col_min)
|
|
248
|
-
if self.fractional_tick_pos and offset_frac < 0.25:
|
|
249
|
-
if col_idx == 0:
|
|
250
|
-
tick_line[tick_idx - 1] = lineart.merge_chars("│", tick_line[tick_idx - 1])
|
|
251
|
-
else:
|
|
252
|
-
tick_line[tick_idx - 1] = "╱"
|
|
253
|
-
tick_line[tick_idx] = "╲"
|
|
254
|
-
elif self.fractional_tick_pos and offset_frac > 0.75:
|
|
255
|
-
tick_line[tick_idx] = "╱"
|
|
256
|
-
if col_idx < num_cols - 1:
|
|
257
|
-
tick_line[tick_idx + 1] = "╲"
|
|
258
|
-
else:
|
|
259
|
-
tick_line[tick_idx + 1] = lineart.merge_chars("│", tick_line[tick_idx + 1])
|
|
400
|
+
if self.fractional_tick_pos:
|
|
401
|
+
offset_frac = (label_values[0] - col_min) / (col_max - col_min)
|
|
260
402
|
else:
|
|
261
|
-
|
|
403
|
+
offset_frac = 0.5 # not doing fractional tick positioning == center the tick
|
|
404
|
+
|
|
405
|
+
add_x_tick(tick_line, col_idx + offset_frac, left_margin, num_cols)
|
|
406
|
+
add_x_label(
|
|
407
|
+
label_line, labels[label_values[0]], col_idx + left_margin + offset_frac
|
|
408
|
+
)
|
|
262
409
|
|
|
263
410
|
label_values = label_values[1:] # pop that first label since we added it
|
|
264
411
|
|
|
265
412
|
return "".join(tick_line), "".join(label_line)
|
|
413
|
+
|
|
414
|
+
def upscale(self, new_num_bins, multiplier):
|
|
415
|
+
"""Adjust axis for an upscaled plot"""
|
|
416
|
+
if self.values_are_edges:
|
|
417
|
+
# upscaling doesn't move the axis edges. First edge of first bin is the same.
|
|
418
|
+
return
|
|
419
|
+
old_num_bins = new_num_bins // multiplier
|
|
420
|
+
old_bin_width = (self.value_range.max - self.value_range.min) / (old_num_bins - 1)
|
|
421
|
+
new_bin_width = old_bin_width / multiplier
|
|
422
|
+
# we used to have a value for the center of the first bin, but now that bin is
|
|
423
|
+
# 'multiplier' bins, and the value corresponds to the center of that _group_ of bins
|
|
424
|
+
# Find the offset between that group center, and the center of the new edge bin:
|
|
425
|
+
offset = new_bin_width * (multiplier - 1) / 2
|
|
426
|
+
self.value_range = util.make_value_range(
|
|
427
|
+
(self.value_range.min - offset, self.value_range.max + offset)
|
|
428
|
+
)
|