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/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 plot as plotmodule
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
- return plotmodule.Plot(data, colormap, **plotargs)
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=(scale, scale))
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 == 0:
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 = plotmodule.default_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
- x_width, y_width = smoothing.pick_kernel_bandwidth(points, bins=(size_x, size_y))
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
@@ -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 = min(min(line) for line in self.data) if self.min_data is None else self.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
- for frame, plot_line in zip_longest(axis_lines, plot_lines, fillvalue=""):
119
- yield frame.translate(self.font_mapping) + plot_line
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] = (0, 0),
168
- max_expansion: tuple[int, int] = (3, 3),
169
- keep_aspect_ratio: bool = False,
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). 0=> No maximum
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 expand_bins_arg, histogram2d, process_bin_args
9
- from .util import FloatLike, ValueRange, partial_first, partial_second
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
- bins = (bins[0] - 1, bins[1] - 1)
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
- bins = (1, 1)
136
+ float_bins = (1, 1)
156
137
 
157
- x_width = float(x_axis.value_range.max - x_axis.value_range.min) / bins[0] / 4
158
- y_width = float(y_axis.value_range.max - y_axis.value_range.min) / bins[1] / 4
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
- """Bin points into a 2-D histogram given bin edges
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
- expanded_bins = expand_bins_arg(bins)
284
+ _, num_bins, bin_centers = expand_bins_arg(bins)
305
285
 
306
- if isinstance(expanded_bins[0], Sequence):
307
- # we were given the bin centers, so just use them
308
- padding: tuple[FloatLike, FloatLike] = (0, 0)
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
- # we're computing the bin centers, so include some padding based on kernel width
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, BasicContext
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.9.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 :: 4 - Beta
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"> Terminal-based 2-D Histogram, Density Plots, and Heatmaps</h2>
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
- ## [Sub-modules / Usage Notes](https://billtompkins.github.io/densitty/docs/usage.html)
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,,