densitty 0.9.0__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 +76 -50
- densitty/binning.py +170 -156
- densitty/colorbar.py +84 -0
- densitty/detect.py +81 -23
- densitty/{plot.py → plotting.py} +62 -12
- densitty/smoothing.py +57 -59
- densitty/util.py +25 -2
- densitty/util.pyi +1 -0
- {densitty-0.9.0.dist-info → densitty-1.0.0.dist-info}/METADATA +6 -4
- densitty-1.0.0.dist-info/RECORD +18 -0
- {densitty-0.9.0.dist-info → densitty-1.0.0.dist-info}/WHEEL +1 -1
- densitty-0.9.0.dist-info/RECORD +0 -17
- {densitty-0.9.0.dist-info → densitty-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {densitty-0.9.0.dist-info → densitty-1.0.0.dist-info}/top_level.txt +0 -0
densitty/colorbar.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Colorbar generation for density plots."""
|
|
2
|
+
|
|
3
|
+
from .axis import Axis
|
|
4
|
+
from .plotting import Plot
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def make_colorbar(
|
|
8
|
+
source_plot: Plot,
|
|
9
|
+
label_fmt: str = "{}",
|
|
10
|
+
vertical: bool = False,
|
|
11
|
+
) -> Plot:
|
|
12
|
+
"""Create a colorbar Plot object from an existing Plot.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
source_plot : Plot
|
|
17
|
+
The Plot object to create a colorbar for.
|
|
18
|
+
label_fmt : str
|
|
19
|
+
Format string for min/max labels (e.g., "{:.2f}").
|
|
20
|
+
vertical : bool
|
|
21
|
+
Vertical/Columnnar bar rather than horizontal/row.
|
|
22
|
+
|
|
23
|
+
Returns
|
|
24
|
+
-------
|
|
25
|
+
Plot
|
|
26
|
+
A new Plot object representing the colorbar.
|
|
27
|
+
"""
|
|
28
|
+
min_value, max_value = source_plot.data_limits()
|
|
29
|
+
|
|
30
|
+
color_map = source_plot.color_map
|
|
31
|
+
|
|
32
|
+
labels = {
|
|
33
|
+
min_value: label_fmt.format(min_value),
|
|
34
|
+
max_value: label_fmt.format(max_value),
|
|
35
|
+
}
|
|
36
|
+
axis = Axis(
|
|
37
|
+
value_range=(min_value, max_value),
|
|
38
|
+
labels=labels,
|
|
39
|
+
values_are_edges=False,
|
|
40
|
+
border_line=False,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if vertical:
|
|
44
|
+
size = len(source_plot.data) # num rows => height
|
|
45
|
+
gradient_data = [[i / (size - 1)] for i in range(size)] if size > 1 else [[0.5]]
|
|
46
|
+
else:
|
|
47
|
+
size = len(source_plot.data[0]) # num cols => width
|
|
48
|
+
gradient_data = (
|
|
49
|
+
[
|
|
50
|
+
[i / (size - 1) for i in range(size)],
|
|
51
|
+
]
|
|
52
|
+
if size > 1
|
|
53
|
+
else [[0.5]]
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if vertical:
|
|
57
|
+
return Plot(
|
|
58
|
+
data=gradient_data,
|
|
59
|
+
color_map=color_map,
|
|
60
|
+
render_halfheight=source_plot.render_halfheight,
|
|
61
|
+
font_mapping=source_plot.font_mapping,
|
|
62
|
+
y_axis=axis,
|
|
63
|
+
min_data=0,
|
|
64
|
+
max_data=1,
|
|
65
|
+
flip_y=True,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return Plot(
|
|
69
|
+
data=gradient_data,
|
|
70
|
+
color_map=color_map,
|
|
71
|
+
render_halfheight=source_plot.render_halfheight,
|
|
72
|
+
font_mapping=source_plot.font_mapping,
|
|
73
|
+
x_axis=axis,
|
|
74
|
+
min_data=0,
|
|
75
|
+
max_data=1,
|
|
76
|
+
flip_y=False,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def add_colorbar(source_plot: Plot, label_fmt: str = "{}", padding: str = " ") -> Plot:
|
|
81
|
+
"""Add a vertical colorbar to an existing Plot."""
|
|
82
|
+
cb = make_colorbar(source_plot, label_fmt, vertical=True)
|
|
83
|
+
source_plot.glue_on(cb, padding)
|
|
84
|
+
return source_plot
|
densitty/detect.py
CHANGED
|
@@ -9,9 +9,8 @@ from types import MappingProxyType
|
|
|
9
9
|
from typing import Any, Callable, Optional, Sequence
|
|
10
10
|
import time
|
|
11
11
|
|
|
12
|
-
from . import ansi, ascii_art, binning, lineart, smoothing, truecolor
|
|
13
|
-
from . import
|
|
14
|
-
from .util import FloatLike, ValueRange
|
|
12
|
+
from . import ansi, ascii_art, axis, binning, colorbar, lineart, plotting, smoothing, truecolor
|
|
13
|
+
from .util import FloatLike, ValueRange, make_value_range
|
|
15
14
|
|
|
16
15
|
if sys.platform == "win32":
|
|
17
16
|
# pylint: disable=import-error
|
|
@@ -427,6 +426,24 @@ FADE_IN = MappingProxyType(
|
|
|
427
426
|
}
|
|
428
427
|
)
|
|
429
428
|
|
|
429
|
+
RAINBOW = MappingProxyType(
|
|
430
|
+
{
|
|
431
|
+
ColorSupport.NONE: ascii_art.EXTENDED,
|
|
432
|
+
ColorSupport.ANSI_4BIT: ansi.RAINBOW_16,
|
|
433
|
+
ColorSupport.ANSI_8BIT: ansi.RAINBOW,
|
|
434
|
+
ColorSupport.ANSI_24BIT: truecolor.RAINBOW,
|
|
435
|
+
}
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
REV_RAINBOW = MappingProxyType(
|
|
439
|
+
{
|
|
440
|
+
ColorSupport.NONE: ascii_art.EXTENDED,
|
|
441
|
+
ColorSupport.ANSI_4BIT: ansi.REV_RAINBOW_16,
|
|
442
|
+
ColorSupport.ANSI_8BIT: ansi.REV_RAINBOW,
|
|
443
|
+
ColorSupport.ANSI_24BIT: truecolor.REV_RAINBOW,
|
|
444
|
+
}
|
|
445
|
+
)
|
|
446
|
+
|
|
430
447
|
|
|
431
448
|
def pick_colormap(maps: dict[ColorSupport, Callable]) -> Callable:
|
|
432
449
|
"""Detect color support and pick the best color map"""
|
|
@@ -434,21 +451,33 @@ def pick_colormap(maps: dict[ColorSupport, Callable]) -> Callable:
|
|
|
434
451
|
return maps[support]
|
|
435
452
|
|
|
436
453
|
|
|
437
|
-
def plot(data, colors=FADE_IN, **plotargs):
|
|
438
|
-
"""Wrapper for plot.Plot that picks colormap from dict
|
|
454
|
+
def plot(data, colors=FADE_IN, colorscale=False, **plotargs):
|
|
455
|
+
"""Wrapper for plot.Plot that picks colormap from dict
|
|
456
|
+
|
|
457
|
+
Parameters
|
|
458
|
+
----------
|
|
459
|
+
data : Sequence[Sequence[float]]
|
|
460
|
+
The data to be plotted.
|
|
461
|
+
colors : Dict mapping color support to color map
|
|
462
|
+
The color scale (e.g. GRAYSCALE, FADE_IN)
|
|
463
|
+
colorscale: bool
|
|
464
|
+
Display a vertical color scale on the right
|
|
465
|
+
(for horizontal, just directly create a colorbar
|
|
466
|
+
and output it after outputting the plot)
|
|
467
|
+
plotargs: any Plot() keyword arguments
|
|
468
|
+
"""
|
|
439
469
|
colormap = pick_colormap(colors)
|
|
440
|
-
|
|
470
|
+
the_plot = plotting.Plot(data, colormap, **plotargs)
|
|
471
|
+
if colorscale:
|
|
472
|
+
colorbar.add_colorbar(the_plot)
|
|
473
|
+
return the_plot
|
|
441
474
|
|
|
442
475
|
|
|
443
476
|
def histplot2d(
|
|
444
477
|
points: Sequence[tuple[FloatLike, FloatLike]],
|
|
445
|
-
bins:
|
|
446
|
-
int
|
|
447
|
-
| tuple[int, int]
|
|
448
|
-
| Sequence[FloatLike]
|
|
449
|
-
| tuple[Sequence[FloatLike], Sequence[FloatLike]]
|
|
450
|
-
) = 10,
|
|
478
|
+
bins: binning.FullBinsArg = None,
|
|
451
479
|
ranges: Optional[tuple[Optional[ValueRange], Optional[ValueRange]]] = None,
|
|
480
|
+
bin_size: Optional[FloatLike | tuple[FloatLike, FloatLike]] = None,
|
|
452
481
|
align=True,
|
|
453
482
|
drop_outside=True,
|
|
454
483
|
colors=FADE_IN,
|
|
@@ -461,8 +490,9 @@ def histplot2d(
|
|
|
461
490
|
"""Wrapper for binning.histogram2d / plot.Plot to simplify 2-D histogram plotting"""
|
|
462
491
|
binned_data, x_axis, y_axis = binning.histogram2d(
|
|
463
492
|
points,
|
|
464
|
-
bins,
|
|
493
|
+
bins=bins,
|
|
465
494
|
ranges=ranges,
|
|
495
|
+
bin_size=bin_size,
|
|
466
496
|
align=align,
|
|
467
497
|
drop_outside=drop_outside,
|
|
468
498
|
border_line=border_line,
|
|
@@ -472,7 +502,7 @@ def histplot2d(
|
|
|
472
502
|
if scale is True:
|
|
473
503
|
p.upscale()
|
|
474
504
|
elif scale:
|
|
475
|
-
p.upscale(max_expansion=
|
|
505
|
+
p.upscale(max_expansion=scale)
|
|
476
506
|
|
|
477
507
|
return p
|
|
478
508
|
|
|
@@ -480,12 +510,7 @@ def histplot2d(
|
|
|
480
510
|
def densityplot2d(
|
|
481
511
|
points: Sequence[tuple[FloatLike, FloatLike]],
|
|
482
512
|
kernel: Optional[smoothing.SmoothingFunc] = None,
|
|
483
|
-
bins:
|
|
484
|
-
int
|
|
485
|
-
| tuple[int, int]
|
|
486
|
-
| Sequence[FloatLike]
|
|
487
|
-
| tuple[Sequence[FloatLike], Sequence[FloatLike]]
|
|
488
|
-
) = 0,
|
|
513
|
+
bins: binning.FullBinsArg = None,
|
|
489
514
|
ranges: Optional[tuple[Optional[ValueRange], Optional[ValueRange]]] = None,
|
|
490
515
|
align=True,
|
|
491
516
|
colors=FADE_IN,
|
|
@@ -496,11 +521,11 @@ def densityplot2d(
|
|
|
496
521
|
):
|
|
497
522
|
"""Wrapper for smoothing.smooth2d / plot.Plot to simplify 2-D density plots"""
|
|
498
523
|
|
|
499
|
-
if bins
|
|
524
|
+
if not bins:
|
|
500
525
|
try:
|
|
501
526
|
terminal_size: Optional[os.terminal_size] = os.get_terminal_size()
|
|
502
527
|
except OSError:
|
|
503
|
-
terminal_size =
|
|
528
|
+
terminal_size = plotting.default_terminal_size
|
|
504
529
|
if terminal_size is None:
|
|
505
530
|
raise OSError("No terminal size from os.get_terminal_size()")
|
|
506
531
|
size_x = terminal_size.columns - 10
|
|
@@ -508,7 +533,16 @@ def densityplot2d(
|
|
|
508
533
|
bins = (size_x, size_y)
|
|
509
534
|
|
|
510
535
|
if kernel is None:
|
|
511
|
-
|
|
536
|
+
_, num_bins, bin_centers = binning.expand_bins_arg(bins)
|
|
537
|
+
if bin_centers:
|
|
538
|
+
# we were given a list of bin centers, so generate bounds from that:
|
|
539
|
+
x_bin_range = make_value_range((bin_centers[0][0], bin_centers[0][-1]))
|
|
540
|
+
y_bin_range = make_value_range((bin_centers[1][0], bin_centers[1][-1]))
|
|
541
|
+
x_width, y_width = smoothing.pick_kernel_bandwidth(
|
|
542
|
+
points, bins=num_bins, ranges=(x_bin_range, y_bin_range)
|
|
543
|
+
)
|
|
544
|
+
else:
|
|
545
|
+
x_width, y_width = smoothing.pick_kernel_bandwidth(points, bins=num_bins)
|
|
512
546
|
kernel = smoothing.gaussian_with_sigmas(x_width, y_width)
|
|
513
547
|
|
|
514
548
|
smoothed, x_axis, y_axis = smoothing.smooth2d(
|
|
@@ -523,3 +557,27 @@ def densityplot2d(
|
|
|
523
557
|
p = plot(smoothed, colors, x_axis=x_axis, y_axis=y_axis, **plotargs)
|
|
524
558
|
|
|
525
559
|
return p
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def grid_heatmap(data, x_labels, y_labels, colors=REV_RAINBOW, max_cell_size=0, **plotargs):
|
|
563
|
+
"""Create a grid-style heatmap, with explicit X and Y labels for each bin"""
|
|
564
|
+
if max_cell_size == 0:
|
|
565
|
+
max_x_label = max(len(x) for x in x_labels)
|
|
566
|
+
max_cell_size = max_x_label + 2
|
|
567
|
+
|
|
568
|
+
num_rows = len(data)
|
|
569
|
+
num_cols = len(data[0])
|
|
570
|
+
if y_labels and len(y_labels) != num_rows:
|
|
571
|
+
raise ValueError("Number of Y labels does not match number of rows")
|
|
572
|
+
if x_labels and len(x_labels) != num_cols:
|
|
573
|
+
raise ValueError("Number of X labels does not match number of columns")
|
|
574
|
+
|
|
575
|
+
# value range along axis is arbitrary, but is needed to locate the labels along the axis
|
|
576
|
+
# so just use integers 0..N-1
|
|
577
|
+
x_axis = axis.Axis((0, num_cols - 1), dict(enumerate(x_labels)))
|
|
578
|
+
y_axis = axis.Axis((0, num_rows - 1), dict(enumerate(y_labels)))
|
|
579
|
+
|
|
580
|
+
plt = plot(data, colors=colors, x_axis=x_axis, y_axis=y_axis, flip_y=False, **plotargs)
|
|
581
|
+
plt.upscale(max_expansion=max_cell_size)
|
|
582
|
+
|
|
583
|
+
return plt
|
densitty/{plot.py → plotting.py}
RENAMED
|
@@ -37,6 +37,14 @@ class Plot:
|
|
|
37
37
|
x_axis: Optional[Axis] = None
|
|
38
38
|
y_axis: Optional[Axis] = None
|
|
39
39
|
flip_y: bool = True # put the first row of data at the bottom of the output
|
|
40
|
+
to_right: Optional["Plot" | Sequence[str]] = None
|
|
41
|
+
right_padding: Optional[str] = None
|
|
42
|
+
|
|
43
|
+
def data_limits(self):
|
|
44
|
+
"""Return (min,max) of the plot data."""
|
|
45
|
+
min_data = min(min(line) for line in self.data) if self.min_data is None else self.min_data
|
|
46
|
+
max_data = max(max(line) for line in self.data) if self.max_data is None else self.max_data
|
|
47
|
+
return (min_data, max_data)
|
|
40
48
|
|
|
41
49
|
def as_ascii(self):
|
|
42
50
|
"""Output using direct characters (ASCII-art)."""
|
|
@@ -73,8 +81,7 @@ class Plot:
|
|
|
73
81
|
Also flips data if requested
|
|
74
82
|
"""
|
|
75
83
|
|
|
76
|
-
min_data =
|
|
77
|
-
max_data = max(max(line) for line in self.data) if self.max_data is None else self.max_data
|
|
84
|
+
min_data, max_data = self.data_limits()
|
|
78
85
|
data_scale = max_data - min_data
|
|
79
86
|
if data_scale == 0:
|
|
80
87
|
# all data has the same value (or we were told it does)
|
|
@@ -91,6 +98,17 @@ class Plot:
|
|
|
91
98
|
"""Are there two pixels per output character?"""
|
|
92
99
|
return self.is_color() and self.render_halfheight
|
|
93
100
|
|
|
101
|
+
def left_margin(self):
|
|
102
|
+
"""Returns the column of the first plotted data"""
|
|
103
|
+
if self.y_axis:
|
|
104
|
+
if self.is_halfheight():
|
|
105
|
+
num_rows = (len(self.data) + 1) // 2
|
|
106
|
+
else:
|
|
107
|
+
num_rows = len(self.data)
|
|
108
|
+
y_axis_lines = self.y_axis.render_as_y(num_rows, False, False, False)
|
|
109
|
+
return lineart.display_len(y_axis_lines[0])
|
|
110
|
+
return 0
|
|
111
|
+
|
|
94
112
|
def as_strings(self):
|
|
95
113
|
"""Scale data to 0..1 range and feed it through the appropriate output function"""
|
|
96
114
|
if self.is_halfheight():
|
|
@@ -115,13 +133,24 @@ class Plot:
|
|
|
115
133
|
axis_lines[-1] = lineart.merge_lines(x_ticks, axis_lines[-1])
|
|
116
134
|
axis_lines += [x_labels]
|
|
117
135
|
|
|
118
|
-
|
|
119
|
-
|
|
136
|
+
if isinstance(self.to_right, Plot):
|
|
137
|
+
lines_to_right = self.to_right.as_strings()
|
|
138
|
+
elif self.to_right is not None:
|
|
139
|
+
lines_to_right = self.to_right
|
|
140
|
+
else:
|
|
141
|
+
lines_to_right = []
|
|
142
|
+
|
|
143
|
+
padding = self.right_padding if self.right_padding is not None else ""
|
|
144
|
+
|
|
145
|
+
for frame, plot_line, glued_on in zip_longest(
|
|
146
|
+
axis_lines, plot_lines, lines_to_right, fillvalue=""
|
|
147
|
+
):
|
|
148
|
+
yield frame.translate(self.font_mapping) + plot_line + padding + glued_on
|
|
120
149
|
|
|
121
|
-
def show(self, printer=print):
|
|
150
|
+
def show(self, prefix="", printer=print):
|
|
122
151
|
"""Simple helper function to output/print a plot"""
|
|
123
152
|
for line in self.as_strings():
|
|
124
|
-
printer(line)
|
|
153
|
+
printer(prefix + line)
|
|
125
154
|
|
|
126
155
|
def _compute_scaling_multipliers(
|
|
127
156
|
self,
|
|
@@ -164,24 +193,29 @@ class Plot:
|
|
|
164
193
|
|
|
165
194
|
def upscale(
|
|
166
195
|
self,
|
|
167
|
-
max_size: tuple[int, int] =
|
|
168
|
-
max_expansion: tuple[int, int] =
|
|
169
|
-
keep_aspect_ratio: bool =
|
|
196
|
+
max_size: int | tuple[int, int] = 0,
|
|
197
|
+
max_expansion: int | tuple[int, int] = 3,
|
|
198
|
+
keep_aspect_ratio: bool = True,
|
|
170
199
|
):
|
|
171
200
|
"""Scale up 'data' by repeating lines and values within lines.
|
|
172
201
|
|
|
173
202
|
Parameters
|
|
174
203
|
----------
|
|
175
|
-
max_size : tuple (int, int)
|
|
204
|
+
max_size : int or tuple (int, int)
|
|
176
205
|
If positive: Maximum number of columns, maximum number of rows
|
|
177
206
|
If zero: Use terminal size
|
|
178
207
|
If negative: Use as offset from terminal size
|
|
179
208
|
Default: Based on terminal size (0).
|
|
180
|
-
max_expansion : tuple (int, int)
|
|
181
|
-
maximum expansion factor in each direction. Default (3,3).
|
|
209
|
+
max_expansion : int or tuple (int, int)
|
|
210
|
+
maximum expansion factor in each direction. Default (3,3). None=> No maximum
|
|
182
211
|
keep_aspect_ratio : bool
|
|
183
212
|
Require that X and Y scaling are equal.
|
|
184
213
|
"""
|
|
214
|
+
if isinstance(max_size, int):
|
|
215
|
+
max_size = (max_size, max_size)
|
|
216
|
+
|
|
217
|
+
if isinstance(max_expansion, int) or max_expansion is None:
|
|
218
|
+
max_expansion = (max_expansion, max_expansion)
|
|
185
219
|
|
|
186
220
|
float_mult = self._compute_scaling_multipliers(max_size, keep_aspect_ratio)
|
|
187
221
|
col_mult = max(int(float_mult[0]), 1)
|
|
@@ -206,4 +240,20 @@ class Plot:
|
|
|
206
240
|
|
|
207
241
|
# repeat each of those by the row multiplier
|
|
208
242
|
self.data = repeat_each(x_expanded, row_mult)
|
|
243
|
+
|
|
244
|
+
# If we have axes, adjust for the new pixel/bin size
|
|
245
|
+
if self.x_axis:
|
|
246
|
+
self.x_axis.upscale(len(self.data[0]), col_mult)
|
|
247
|
+
|
|
248
|
+
if self.y_axis:
|
|
249
|
+
self.y_axis.upscale(len(self.data), row_mult)
|
|
250
|
+
|
|
251
|
+
# if we have a glued-on plot to the right (likely a colorbar), scale it up in Y to match
|
|
252
|
+
if isinstance(self.to_right, Plot):
|
|
253
|
+
self.to_right.upscale(max_size=0, max_expansion=(1, col_mult), keep_aspect_ratio=False)
|
|
209
254
|
return self
|
|
255
|
+
|
|
256
|
+
def glue_on(self, to_right, padding=" "):
|
|
257
|
+
"""Add lines or another plot to the right of this one."""
|
|
258
|
+
self.right_padding = padding
|
|
259
|
+
self.to_right = to_right
|
densitty/smoothing.py
CHANGED
|
@@ -5,8 +5,14 @@ import math
|
|
|
5
5
|
from typing import Callable, Optional, Sequence
|
|
6
6
|
|
|
7
7
|
from .axis import Axis
|
|
8
|
-
from .binning import
|
|
9
|
-
|
|
8
|
+
from .binning import (
|
|
9
|
+
FullBinsArg,
|
|
10
|
+
calc_value_range,
|
|
11
|
+
expand_bins_arg,
|
|
12
|
+
histogram2d,
|
|
13
|
+
segment_interval,
|
|
14
|
+
)
|
|
15
|
+
from .util import FloatLike, ValueRange, make_decimal, partial_first, partial_second
|
|
10
16
|
|
|
11
17
|
BareSmoothingFunc = Callable[[FloatLike, FloatLike], FloatLike]
|
|
12
18
|
|
|
@@ -57,43 +63,6 @@ def gaussian_with_sigmas(sigma_x, sigma_y) -> SmoothingFunc:
|
|
|
57
63
|
return gaussian_with_inv_cov(inv_cov)
|
|
58
64
|
|
|
59
65
|
|
|
60
|
-
def covariance(points: Sequence[tuple[FloatLike, FloatLike]]):
|
|
61
|
-
"""Calculate the covariance matrix of a list of points"""
|
|
62
|
-
num = len(points)
|
|
63
|
-
xs = tuple(x for x, _ in points)
|
|
64
|
-
ys = tuple(y for _, y in points)
|
|
65
|
-
mean_x = sum(xs) / num
|
|
66
|
-
mean_y = sum(ys) / num
|
|
67
|
-
cov_xx = sum((x - mean_x) ** 2 for x in xs) / num
|
|
68
|
-
cov_yy = sum((y - mean_y) ** 2 for y in ys) / num
|
|
69
|
-
cov_xy = sum((x - mean_x) * (y - mean_y) for x, y in points) / num
|
|
70
|
-
return ((cov_xx, cov_xy), (cov_xy, cov_yy))
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def kde(points: Sequence[tuple[FloatLike, FloatLike]]):
|
|
74
|
-
"""Kernel for Kernel Density Estimation
|
|
75
|
-
Note that the resulting smoothing function is quite broad, since the
|
|
76
|
-
covariance estimate converges very slowly.
|
|
77
|
-
|
|
78
|
-
This may make sense to use if the distribution of points is itself a
|
|
79
|
-
Gaussian, but makes much less sense if it has any internal structure,
|
|
80
|
-
as that will all get smoothed out.
|
|
81
|
-
"""
|
|
82
|
-
# From Scott's rule / Silverman's factor: Bandwidth is n**(-1/6)
|
|
83
|
-
# That is to scale the std deviation (characteristic width)
|
|
84
|
-
# We're using the square of that: (co)variance, so scale by n**(-1/3)
|
|
85
|
-
# And invert to get something we can pass to the gaussian func
|
|
86
|
-
cov = covariance(points)
|
|
87
|
-
scale = len(points) ** (1 / 3)
|
|
88
|
-
scaled_det = scale * (cov[0][0] * cov[1][1] - cov[1][0] * cov[0][1])
|
|
89
|
-
inv_scaled_cov = (
|
|
90
|
-
(cov[1][1] / scaled_det, -cov[0][1] / scaled_det),
|
|
91
|
-
(-cov[1][0] / scaled_det, cov[0][0] / scaled_det),
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
return gaussian_with_inv_cov(inv_scaled_cov)
|
|
95
|
-
|
|
96
|
-
|
|
97
66
|
def triangle(width_x, width_y) -> SmoothingFunc:
|
|
98
67
|
"""Produce a kernel function for a 2-D triangle with specified width/height
|
|
99
68
|
This is much cheaper computationally than the Gaussian, and gives decent results.
|
|
@@ -142,6 +111,17 @@ def pick_kernel_bandwidth(
|
|
|
142
111
|
if bins[0] <= 0 or bins[1] <= 0:
|
|
143
112
|
raise ValueError("Number of bins must be nonzero")
|
|
144
113
|
|
|
114
|
+
# we'll reduce the number of bins gradually until we get the right smoothness
|
|
115
|
+
# track the number of bins in each direction as a float, so we can maintain the
|
|
116
|
+
# aspect ratio without roundoff error accumulating:
|
|
117
|
+
float_bins: tuple[float, float] = bins
|
|
118
|
+
|
|
119
|
+
# bin_step: how much we reduce the # of bins by each iteration.
|
|
120
|
+
# 1.0 in the larger direction, a fraction in the smaller direction:
|
|
121
|
+
if bins[0] > bins[1]:
|
|
122
|
+
bin_step = (1.0, (bins[1] / bins[0]))
|
|
123
|
+
else:
|
|
124
|
+
bin_step = ((bins[0] / bins[1]), 1.0)
|
|
145
125
|
while bins[0] > 0 and bins[1] > 0:
|
|
146
126
|
binned, x_axis, y_axis = histogram2d(points, bins, ranges, align=False)
|
|
147
127
|
nonzero_bins = [b for row in binned for b in row if b > 0]
|
|
@@ -149,13 +129,14 @@ def pick_kernel_bandwidth(
|
|
|
149
129
|
test_val = sorted(nonzero_bins)[test_pos]
|
|
150
130
|
if test_val >= smoothness:
|
|
151
131
|
break
|
|
152
|
-
|
|
132
|
+
float_bins = (float_bins[0] - bin_step[0], float_bins[1] - bin_step[1])
|
|
133
|
+
bins = (round(float_bins[0]), round(float_bins[1]))
|
|
153
134
|
else:
|
|
154
135
|
# We never managed to get 'smoothness' per bin, so just give up and smooth a lot
|
|
155
|
-
|
|
136
|
+
float_bins = (1, 1)
|
|
156
137
|
|
|
157
|
-
x_width = float(x_axis.value_range.max - x_axis.value_range.min) /
|
|
158
|
-
y_width = float(y_axis.value_range.max - y_axis.value_range.min) /
|
|
138
|
+
x_width = float(x_axis.value_range.max - x_axis.value_range.min) / float_bins[0] / 4
|
|
139
|
+
y_width = float(y_axis.value_range.max - y_axis.value_range.min) / float_bins[1] / 4
|
|
159
140
|
|
|
160
141
|
return (x_width, y_width)
|
|
161
142
|
|
|
@@ -226,7 +207,7 @@ def smooth_to_bins(
|
|
|
226
207
|
x_centers: Sequence[FloatLike],
|
|
227
208
|
y_centers: Sequence[FloatLike],
|
|
228
209
|
) -> Sequence[Sequence[float]]:
|
|
229
|
-
"""
|
|
210
|
+
"""Generate smoothed/density values over a grid, given data points and a kernel
|
|
230
211
|
|
|
231
212
|
Parameters
|
|
232
213
|
----------
|
|
@@ -263,15 +244,16 @@ def smooth_to_bins(
|
|
|
263
244
|
return out
|
|
264
245
|
|
|
265
246
|
|
|
247
|
+
def pad_range(range_unpadded: ValueRange, padding: FloatLike):
|
|
248
|
+
"""Add padding to both sides of a ValueRange"""
|
|
249
|
+
range_padding = make_decimal(padding)
|
|
250
|
+
return ValueRange(range_unpadded.min - range_padding, range_unpadded.max + range_padding)
|
|
251
|
+
|
|
252
|
+
|
|
266
253
|
def smooth2d(
|
|
267
254
|
points: Sequence[tuple[FloatLike, FloatLike]],
|
|
268
255
|
kernel: SmoothingFunc,
|
|
269
|
-
bins:
|
|
270
|
-
int
|
|
271
|
-
| tuple[int, int]
|
|
272
|
-
| Sequence[FloatLike]
|
|
273
|
-
| tuple[Sequence[FloatLike], Sequence[FloatLike]]
|
|
274
|
-
) = 10,
|
|
256
|
+
bins: FullBinsArg = None,
|
|
275
257
|
ranges: Optional[tuple[Optional[ValueRange], Optional[ValueRange]]] = None,
|
|
276
258
|
align=True,
|
|
277
259
|
**axis_args,
|
|
@@ -288,29 +270,45 @@ def smooth2d(
|
|
|
288
270
|
(int,int): number of columns (X), rows (Y)
|
|
289
271
|
list[float]: Column/Row centers
|
|
290
272
|
(list[float], list[float]): column centers for X, column centers for Y
|
|
273
|
+
Default: binning.DEFAULT_NUM_BINS
|
|
291
274
|
ranges: Optional (ValueRange, ValueRange)
|
|
292
275
|
((x_min, x_max), (y_min, y_max)) for the row/column centers if 'bins' is int
|
|
293
276
|
Default: take from data min/max, with buffer based on kernel width
|
|
294
277
|
align: bool (default: True)
|
|
295
278
|
pick bin edges at 'round' values if # of bins is provided
|
|
296
|
-
drop_outside: bool (default: True)
|
|
297
|
-
True: Drop any data points outside the ranges
|
|
298
|
-
False: Put any outside points in closest bin (i.e. edge bins include outliers)
|
|
299
279
|
axis_args: Extra arguments to pass through to Axis constructor
|
|
300
280
|
|
|
301
281
|
returns: Sequence[Sequence[int]], (x-)Axis, (y-)Axis
|
|
302
282
|
"""
|
|
303
283
|
|
|
304
|
-
|
|
284
|
+
_, num_bins, bin_centers = expand_bins_arg(bins)
|
|
305
285
|
|
|
306
|
-
if
|
|
307
|
-
#
|
|
308
|
-
|
|
286
|
+
if bin_centers and ranges:
|
|
287
|
+
# First and last bin centers imply a range, which may be inconsistent
|
|
288
|
+
# with the passed-in ranges. Only supply one or the other.
|
|
289
|
+
raise ValueError("Both 'ranges' and bin centers provided")
|
|
290
|
+
|
|
291
|
+
if bin_centers:
|
|
292
|
+
x_centers, y_centers = bin_centers
|
|
309
293
|
else:
|
|
310
|
-
#
|
|
294
|
+
# No centers, just number of bins, and maybe user-specified ranges
|
|
295
|
+
if ranges:
|
|
296
|
+
x_range, y_range = ranges
|
|
297
|
+
else:
|
|
298
|
+
x_range, y_range = None, None
|
|
299
|
+
# if we use a range based on the min/max of the data, we also
|
|
300
|
+
# include some padding based on the half-width of the kernel
|
|
311
301
|
padding = func_width_half_height(kernel)
|
|
302
|
+
if not x_range:
|
|
303
|
+
x_range = calc_value_range(tuple(x for x, _ in points))
|
|
304
|
+
x_range = pad_range(x_range, padding[0])
|
|
305
|
+
if not y_range:
|
|
306
|
+
y_range = calc_value_range(tuple(y for _, y in points))
|
|
307
|
+
y_range = pad_range(y_range, padding[1])
|
|
308
|
+
|
|
309
|
+
x_centers = segment_interval(num_bins[0], x_range, align)
|
|
310
|
+
y_centers = segment_interval(num_bins[1], y_range, align)
|
|
312
311
|
|
|
313
|
-
x_centers, y_centers = process_bin_args(points, expanded_bins, ranges, align, padding)
|
|
314
312
|
x_axis = Axis((x_centers[0], x_centers[-1]), values_are_edges=False, **axis_args)
|
|
315
313
|
y_axis = Axis((y_centers[0], y_centers[-1]), values_are_edges=False, **axis_args)
|
|
316
314
|
|
densitty/util.py
CHANGED
|
@@ -6,11 +6,10 @@ import math
|
|
|
6
6
|
import typing
|
|
7
7
|
|
|
8
8
|
from bisect import bisect_left
|
|
9
|
-
from decimal import Decimal,
|
|
9
|
+
from decimal import BasicContext, Decimal, DecimalTuple
|
|
10
10
|
from fractions import Fraction
|
|
11
11
|
from typing import Any, Callable, NamedTuple, Sequence
|
|
12
12
|
|
|
13
|
-
|
|
14
13
|
# FloatLike and Vec are defined in the stubs file util.pyi for type checking
|
|
15
14
|
# At runtime, define as Any so older Python versions don't choke:
|
|
16
15
|
if not typing.TYPE_CHECKING:
|
|
@@ -86,6 +85,30 @@ def make_decimal(x: FloatLike) -> Decimal:
|
|
|
86
85
|
return BasicContext.create_decimal_from_float(float(x))
|
|
87
86
|
|
|
88
87
|
|
|
88
|
+
def sanitize_decimals(values: Sequence[Decimal]) -> Sequence[Decimal]:
|
|
89
|
+
"""Strip trailing "0"s if all values in the list have the trailing "0"s
|
|
90
|
+
So [1.000, 2.000] becomes [1, 2]"""
|
|
91
|
+
if not values:
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
as_tuples = [v.as_tuple() for v in values]
|
|
95
|
+
cur_exponent = as_tuples[0].exponent
|
|
96
|
+
if not all(t.exponent == cur_exponent for t in as_tuples):
|
|
97
|
+
# inconsistent exponent: just return them as is
|
|
98
|
+
return values
|
|
99
|
+
while cur_exponent < 0 and all(t.digits[-1] == 0 for t in as_tuples):
|
|
100
|
+
# all values have a trailing 0. Remove, and add a leading 0 to prevent (0,) from vanishing:
|
|
101
|
+
as_tuples = [
|
|
102
|
+
DecimalTuple(t.sign, (0,) + t.digits[:-1], cur_exponent + 1) for t in as_tuples
|
|
103
|
+
]
|
|
104
|
+
cur_exponent += 1
|
|
105
|
+
|
|
106
|
+
as_decimals = (Decimal(t) for t in as_tuples)
|
|
107
|
+
|
|
108
|
+
# zero values may be something like "0E8" or "0E-2". Make them just be "0":
|
|
109
|
+
return [d if d != 0 else Decimal(0) for d in as_decimals]
|
|
110
|
+
|
|
111
|
+
|
|
89
112
|
def make_value_range(v: ValueRange | Sequence[FloatLike]) -> ValueRange:
|
|
90
113
|
"""Produce a ValueRange from from something that may be a sequence of FloatLikes"""
|
|
91
114
|
return ValueRange(make_decimal(v[0]), make_decimal(v[1]))
|
densitty/util.pyi
CHANGED
|
@@ -24,6 +24,7 @@ def clamp_rgb(rgb): ...
|
|
|
24
24
|
def interp(piecewise: Sequence[Vec], x: float) -> Vec: ...
|
|
25
25
|
def nearest(stepwise: Sequence, x: float): ...
|
|
26
26
|
def make_decimal(x: FloatLike) -> Decimal: ...
|
|
27
|
+
def sanitize_decimals(values: Sequence[Decimal]) -> Sequence[Decimal]: ...
|
|
27
28
|
def make_value_range(v: ValueRange | Sequence[FloatLike]) -> ValueRange: ...
|
|
28
29
|
def partial_first(f: Callable[[FloatLike, FloatLike], FloatLike]) -> Callable: ...
|
|
29
30
|
def partial_second(f: Callable[[FloatLike, FloatLike], FloatLike]) -> Callable: ...
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: densitty
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: densitty - create textual 2-D density plots, heatmaps, and 2-D histograms in Python
|
|
5
5
|
Author: Bill Tompkins
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/BillTompkins/densitty
|
|
8
8
|
Keywords: densitty,ascii,ascii-art,plotting,terminal,Python
|
|
9
|
-
Classifier: Development Status ::
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
10
|
Classifier: Environment :: Console
|
|
11
11
|
Classifier: Operating System :: OS Independent
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
@@ -24,7 +24,7 @@ License-File: LICENSE
|
|
|
24
24
|
Dynamic: license-file
|
|
25
25
|
|
|
26
26
|
<h1 align="center">densitty</h1>
|
|
27
|
-
<h2 align="center">
|
|
27
|
+
<h2 align="center">Terminal-based 2-D Histogram, Density Plots, and Heatmaps in Python</h2>
|
|
28
28
|
|
|
29
29
|
Generate 2-D histograms (density plots, heat maps, eye diagrams) similar to [matplotlib's hist2d](https://matplotlib.org/stable/gallery/statistics/hist.html "hist2d"), but with output in the terminal, and no external dependencies.
|
|
30
30
|
|
|
@@ -32,8 +32,10 @@ Generate 2-D histograms (density plots, heat maps, eye diagrams) similar to [mat
|
|
|
32
32
|
|
|
33
33
|
## [Examples/Gallery](https://billtompkins.github.io/densitty/docs/examples.html)
|
|
34
34
|
|
|
35
|
-
## [
|
|
35
|
+
## [Usage Notes](https://billtompkins.github.io/densitty/docs/usage.html)
|
|
36
36
|
|
|
37
37
|
## [Color, Size, and Glyph Support](https://billtompkins.github.io/densitty/docs/terminal_support.html)
|
|
38
38
|
|
|
39
39
|
## [API](https://billtompkins.github.io/densitty/docs/api.html)
|
|
40
|
+
|
|
41
|
+
## [GitHub](https://github.com/BillTompkins/densitty/tree/main)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
densitty/__init__.py,sha256=BeG85uFYNLaO6TaG-Q1zWgllq9SJfgH1fyKTixSz3Lw,336
|
|
2
|
+
densitty/ansi.py,sha256=fm6yHRszx0sF-inGurtxC_-TL0dvePLcSQ8r-Okm20w,3660
|
|
3
|
+
densitty/ascii_art.py,sha256=-MUppQkEeCAqSxdLCfuDRD1NKtzSrJAt2WdJEL56_fI,589
|
|
4
|
+
densitty/axis.py,sha256=1yv3ohhzF7hICGu8dB3zluD5i0U7-X9E8A2TxzJMgSU,17372
|
|
5
|
+
densitty/binning.py,sha256=6r0NCMKVs6lGl_7Uec4WXuI0yYDHYqkuyjKsOx2burk,13343
|
|
6
|
+
densitty/colorbar.py,sha256=63XIm-KQm1Dzh_vp5rgU7fmsIdcgfmfEKjQJdll8FEU,2247
|
|
7
|
+
densitty/detect.py,sha256=tARq3Uhv_r8V4Xtjvdnbgq16esKdzXzOJCKZb1eu3MI,22241
|
|
8
|
+
densitty/lineart.py,sha256=Qkw_F1QEK9yd0ttNjg62bomIavMPza3pk8w7DA85zWs,4124
|
|
9
|
+
densitty/plotting.py,sha256=u8GJQIHECpR-ayDDirabVAZS9Mrw9rxIYgnC2wK83e8,10427
|
|
10
|
+
densitty/smoothing.py,sha256=r-fjRQmEO9OZxBN7Y5f1FFzUGPh0OYixHM6AUt1Rui4,12284
|
|
11
|
+
densitty/truecolor.py,sha256=6UDLJRUKP8rProemJyfSIgtZ5IdB1v8R_PV41fW6CjA,6016
|
|
12
|
+
densitty/util.py,sha256=nCVQAUNNK6VrG32GmqJ7ZU2OeWcWDpD_vMmGCe2llqU,8291
|
|
13
|
+
densitty/util.pyi,sha256=3nELig5yUy6b_cxb-3tjEIzmWHFNa7DX5sf3RGd0p-k,1436
|
|
14
|
+
densitty-1.0.0.dist-info/licenses/LICENSE,sha256=LexlQlxS7F07WxcVOOmZAZ_3vReYv0cM8Zg6pTx7_fI,1073
|
|
15
|
+
densitty-1.0.0.dist-info/METADATA,sha256=Ugrt_V4ljCRkjn5ipr87lxpvxS5i91IDqrqbW_xSMIU,1922
|
|
16
|
+
densitty-1.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
17
|
+
densitty-1.0.0.dist-info/top_level.txt,sha256=Q50fHzFeZkhO_61VVAIJZyKU44Upacx_blojlLpYqNo,9
|
|
18
|
+
densitty-1.0.0.dist-info/RECORD,,
|