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/__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
|
@@ -92,11 +92,15 @@ def gen_tick_values(value_range, tick_step):
|
|
|
92
92
|
tick += tick_step
|
|
93
93
|
|
|
94
94
|
|
|
95
|
-
def
|
|
96
|
-
|
|
97
|
-
|
|
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'"""
|
|
98
100
|
|
|
99
|
-
|
|
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
|
|
100
104
|
|
|
101
105
|
|
|
102
106
|
def calc_min_step(
|
|
@@ -127,58 +131,62 @@ def calc_min_step(
|
|
|
127
131
|
return bin_width * (2 + tick_space)
|
|
128
132
|
|
|
129
133
|
|
|
130
|
-
def
|
|
131
|
-
"""Generate candidate label subsets based on tick step digit.
|
|
134
|
+
def gen_position_subsets(positions: tuple, tick_step: Decimal) -> list[Sequence[Decimal]]:
|
|
135
|
+
"""Generate candidate label position subsets based on tick step digit.
|
|
132
136
|
|
|
133
137
|
For tick steps starting with 1 or 2: generates every-5th subsets (5 variants).
|
|
134
138
|
For tick steps starting with 5: generates every-2nd subsets (2 variants).
|
|
135
139
|
"""
|
|
136
140
|
step_digit = tick_step.as_tuple().digits[0] # leading digit of tick step: 1, 2, or 5
|
|
137
|
-
|
|
138
|
-
# we want to pick different label subsets depending on whether we're advancing by
|
|
141
|
+
# we want to pick different position subsets depending on whether we're advancing by
|
|
139
142
|
# 1eX, 2eX, or 5eX:
|
|
140
|
-
|
|
143
|
+
position_subsets = []
|
|
141
144
|
if step_digit in (1, 2):
|
|
142
|
-
# print on every fifth
|
|
143
|
-
|
|
144
|
-
elif step_digit
|
|
145
|
-
# print on every second
|
|
146
|
-
|
|
147
|
-
return
|
|
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
|
|
148
151
|
|
|
149
152
|
|
|
150
|
-
def label_ends_only(
|
|
153
|
+
def label_ends_only(positions, tick_step, bin_width, accomodate_values, fmt):
|
|
151
154
|
"""See if printing just the labels for the first and last ticks will fit"""
|
|
152
155
|
|
|
153
156
|
if not accomodate_values:
|
|
154
157
|
# For Y axis / we don't care about printed widths
|
|
155
|
-
return
|
|
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())
|
|
156
162
|
|
|
157
|
-
|
|
158
|
-
|
|
163
|
+
half_len_first = len(ends_printed[0]) // 2
|
|
164
|
+
half_len_last = (len(ends_printed[1]) + 1) // 2
|
|
159
165
|
space_available = math.floor(tick_step / bin_width)
|
|
160
166
|
|
|
161
|
-
if
|
|
167
|
+
if half_len_first + half_len_last <= space_available:
|
|
162
168
|
# We can fit values on the first and last ticks
|
|
163
|
-
return
|
|
169
|
+
return positions_to_labels(end_positions, positions, fmt)
|
|
164
170
|
|
|
165
171
|
# Not enough space to print two values, so just print the first
|
|
166
|
-
return
|
|
172
|
+
return positions_to_labels(end_positions[:1], positions, fmt)
|
|
167
173
|
|
|
168
174
|
|
|
169
|
-
def find_fitting_subset(
|
|
175
|
+
def find_fitting_subset(position_subsets, ticks_per_bin, accomodate_values, fmt):
|
|
170
176
|
"""Find roundest label subset that fits within space constraints"""
|
|
171
|
-
for
|
|
172
|
-
if not accomodate_values:
|
|
173
|
-
return
|
|
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
|
|
174
180
|
# We're printing at most one value for every 2 ticks, so just make sure
|
|
175
181
|
# that the printed values will not run over the adjacent ticks' area
|
|
176
182
|
# Given the initial min_step logic, this will likely always be true
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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()
|
|
182
190
|
|
|
183
191
|
|
|
184
192
|
def gen_full_labels(value_range: ValueRange, num_bins, accomodate_values, tick_space, fmt):
|
|
@@ -193,33 +201,31 @@ def gen_full_labels(value_range: ValueRange, num_bins, accomodate_values, tick_s
|
|
|
193
201
|
cur_tick_step = pick_step_size(min_step)
|
|
194
202
|
|
|
195
203
|
while True:
|
|
196
|
-
|
|
197
|
-
value: fmt.format(value) for value in gen_tick_values(value_range, cur_tick_step)
|
|
198
|
-
}
|
|
199
|
-
positions = tuple(labels.keys())
|
|
204
|
+
positions = tuple(gen_tick_values(value_range, cur_tick_step))
|
|
200
205
|
|
|
201
|
-
if len(
|
|
206
|
+
if len(positions) == 0:
|
|
202
207
|
# No suitable ticks/labels.
|
|
203
208
|
if accomodate_values:
|
|
204
209
|
# Printed value labels won't fit, try without them
|
|
205
210
|
return gen_full_labels(value_range, num_bins, False, tick_space, "")
|
|
206
211
|
# Nothing fits
|
|
207
212
|
return {}
|
|
208
|
-
if len(
|
|
209
|
-
return
|
|
213
|
+
if len(positions) == 1:
|
|
214
|
+
return positions_to_labels(positions, [], fmt)
|
|
210
215
|
|
|
211
|
-
|
|
216
|
+
position_subsets = gen_position_subsets(positions, cur_tick_step)
|
|
212
217
|
|
|
213
218
|
# Check to see if all generated label subsets only have a single entry
|
|
214
|
-
if max(len(subset) for subset in
|
|
219
|
+
if max(len(subset) for subset in position_subsets) == 1:
|
|
215
220
|
# Try to just label the ends:
|
|
216
|
-
return label_ends_only(
|
|
221
|
+
return label_ends_only(positions, cur_tick_step, bin_width, accomodate_values, fmt)
|
|
217
222
|
|
|
218
|
-
|
|
219
|
-
|
|
223
|
+
best_subset = find_fitting_subset(
|
|
224
|
+
position_subsets, cur_tick_step / bin_width, accomodate_values, fmt
|
|
220
225
|
)
|
|
221
|
-
|
|
222
|
-
|
|
226
|
+
|
|
227
|
+
if best_subset:
|
|
228
|
+
return positions_to_labels(best_subset, positions, fmt)
|
|
223
229
|
|
|
224
230
|
cur_tick_step = pick_step_size(float(cur_tick_step) * 1.01)
|
|
225
231
|
|
|
@@ -256,7 +262,9 @@ class Axis:
|
|
|
256
262
|
"""Options for axis generation."""
|
|
257
263
|
|
|
258
264
|
value_range: ValueRange # can also specify as a tuple of (min, max)
|
|
259
|
-
labels: Optional[dict[
|
|
265
|
+
labels: Optional[dict[FloatLike, str]] = (
|
|
266
|
+
None # map axis value to label (plus tick) at that value
|
|
267
|
+
)
|
|
260
268
|
label_fmt: str = "{}" # format for generated labels
|
|
261
269
|
border_line: bool = False # embed ticks in a horizontal X-axis or vertical Y-axis line
|
|
262
270
|
values_are_edges: bool = False # N+1 values, indicating boundaries between pixels, not centers
|
|
@@ -265,7 +273,7 @@ class Axis:
|
|
|
265
273
|
def __init__(
|
|
266
274
|
self,
|
|
267
275
|
value_range: ValueRange | tuple[FloatLike, FloatLike],
|
|
268
|
-
labels: Optional[dict[
|
|
276
|
+
labels: Optional[dict[FloatLike, str]] = None,
|
|
269
277
|
label_fmt: str = "{}",
|
|
270
278
|
border_line: bool = False,
|
|
271
279
|
values_are_edges: bool = False,
|
|
@@ -304,10 +312,12 @@ class Axis:
|
|
|
304
312
|
if label_values and row_min <= label_values[0] <= row_max:
|
|
305
313
|
label_str = labels[label_values[0]]
|
|
306
314
|
|
|
307
|
-
offset_frac = (label_values[0] - row_min) / (row_max - row_min)
|
|
308
|
-
|
|
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:
|
|
309
319
|
tick_char = "▔"
|
|
310
|
-
elif offset_frac > 0.
|
|
320
|
+
elif offset_frac > 0.751 and self.fractional_tick_pos:
|
|
311
321
|
tick_char = "▁"
|
|
312
322
|
else:
|
|
313
323
|
tick_char = "─"
|
|
@@ -400,3 +410,19 @@ class Axis:
|
|
|
400
410
|
label_values = label_values[1:] # pop that first label since we added it
|
|
401
411
|
|
|
402
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
|
+
)
|
densitty/binning.py
CHANGED
|
@@ -10,8 +10,30 @@ from .axis import Axis
|
|
|
10
10
|
from .util import FloatLike, ValueRange
|
|
11
11
|
from .util import clamp, make_decimal, make_value_range, most_round, round_up_ish
|
|
12
12
|
|
|
13
|
+
# Following MatPlotLib, the 'bins' argument for functions can be:
|
|
14
|
+
# int: number of bins for both X and Y
|
|
15
|
+
# Sequence[FloatLike]: bin edges for both X and Y
|
|
16
|
+
# tuple(int, int): number of bins for X, number of bins for Y
|
|
17
|
+
# tuple(Sequence[FloatLike], Sequence[FloatLike]): bin edges for X, bin edges for Y
|
|
13
18
|
|
|
14
|
-
|
|
19
|
+
CountArg = int
|
|
20
|
+
EdgesArg = Sequence[FloatLike]
|
|
21
|
+
# a type for the "for both X and Y" variants:
|
|
22
|
+
SingleBinsArg = CountArg | EdgesArg
|
|
23
|
+
|
|
24
|
+
# a type for the tuple (X,Y) variants:
|
|
25
|
+
DoubleCountArg = tuple[CountArg, CountArg]
|
|
26
|
+
DoubleEdgesArg = tuple[EdgesArg, EdgesArg]
|
|
27
|
+
ExpandedBinsArg = DoubleCountArg | DoubleEdgesArg
|
|
28
|
+
|
|
29
|
+
FullBinsArg = Optional[SingleBinsArg | ExpandedBinsArg]
|
|
30
|
+
|
|
31
|
+
RangesArg = tuple[Optional[ValueRange], Optional[ValueRange]]
|
|
32
|
+
|
|
33
|
+
DEFAULT_NUM_BINS = (10, 10)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def bin_by_edges(
|
|
15
37
|
points: Sequence[tuple[FloatLike, FloatLike]],
|
|
16
38
|
x_edges: Sequence[FloatLike],
|
|
17
39
|
y_edges: Sequence[FloatLike],
|
|
@@ -50,13 +72,34 @@ def calc_value_range(values: Sequence[FloatLike]) -> ValueRange:
|
|
|
50
72
|
|
|
51
73
|
# bins are closed on left and open on right: i.e. left_edge <= values < right_edge
|
|
52
74
|
# so, the right-most bin edge needs to be larger than the largest data value:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
range_top =
|
|
57
|
-
math.ulp(
|
|
58
|
-
) # increase by smallest representable amount
|
|
59
|
-
return ValueRange(
|
|
75
|
+
min_value = make_decimal(min(values))
|
|
76
|
+
max_value = make_decimal(max(values))
|
|
77
|
+
|
|
78
|
+
range_top = max_value + Decimal(
|
|
79
|
+
math.ulp(max_value)
|
|
80
|
+
) # increase by smallest float-representable amount
|
|
81
|
+
return ValueRange(min_value, range_top)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def align_value_range(vr: ValueRange, alignment_arg: FloatLike) -> ValueRange:
|
|
85
|
+
"""Shift the provided ValueRange up or down to the specified alignment.
|
|
86
|
+
up/down choice based on which will shift it less"""
|
|
87
|
+
alignment = make_decimal(alignment_arg)
|
|
88
|
+
width = vr.max - vr.min
|
|
89
|
+
aligned_min = math.floor(vr.min / alignment) * alignment
|
|
90
|
+
aligned_max = math.ceil(vr.max / alignment) * alignment
|
|
91
|
+
shift_for_min = vr.min - aligned_min # how far down did 'min' get shifted?
|
|
92
|
+
shift_for_max = aligned_max - vr.max # how far up did 'max' get shifted?
|
|
93
|
+
if shift_for_min < shift_for_max:
|
|
94
|
+
return ValueRange(aligned_min, aligned_min + width)
|
|
95
|
+
return ValueRange(aligned_max - width, aligned_max)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def force_value_range_width(vr: ValueRange, width: FloatLike) -> ValueRange:
|
|
99
|
+
"""Return a ValueRange with specified width, centered on an existing ValueRange"""
|
|
100
|
+
half_width = make_decimal(width) / 2
|
|
101
|
+
midpoint = (vr.max + vr.min) / 2
|
|
102
|
+
return ValueRange(midpoint - half_width, midpoint + half_width)
|
|
60
103
|
|
|
61
104
|
|
|
62
105
|
def segment_interval(
|
|
@@ -114,13 +157,18 @@ def segment_interval(
|
|
|
114
157
|
return tuple(v for v in stepped_values if v <= last_edge)
|
|
115
158
|
|
|
116
159
|
|
|
117
|
-
def edge_range(
|
|
118
|
-
"""
|
|
160
|
+
def edge_range(rng: ValueRange, step_arg: FloatLike, align: bool):
|
|
161
|
+
"""Generator providing values containing range, by step.
|
|
162
|
+
The first value will be rng.min, or rng.min rounded down to nearest 'step'
|
|
163
|
+
The last value will be equal to or larger than rng.max"""
|
|
164
|
+
|
|
165
|
+
step = make_decimal(step_arg) # turn into decimal if it isn't already
|
|
119
166
|
if align:
|
|
120
|
-
v = math.floor(
|
|
167
|
+
v = math.floor(rng.min / step) * step
|
|
121
168
|
else:
|
|
122
|
-
v =
|
|
123
|
-
|
|
169
|
+
v = rng.min
|
|
170
|
+
|
|
171
|
+
while v < (rng.max + step).next_minus():
|
|
124
172
|
if align:
|
|
125
173
|
yield round(v / step) * step
|
|
126
174
|
else:
|
|
@@ -128,171 +176,103 @@ def edge_range(start: Decimal, end: Decimal, step: Decimal, align: bool):
|
|
|
128
176
|
v += step
|
|
129
177
|
|
|
130
178
|
|
|
131
|
-
def
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
ranges: Optional[tuple[ValueRange, ValueRange]] = None,
|
|
135
|
-
align=True,
|
|
136
|
-
drop_outside=True,
|
|
137
|
-
**axis_args,
|
|
138
|
-
) -> tuple[Sequence[Sequence[int]], Axis, Axis]:
|
|
139
|
-
"""Bin points into a 2-D histogram, given bin sizes
|
|
140
|
-
|
|
141
|
-
Parameters
|
|
142
|
-
----------
|
|
143
|
-
points: Sequence of (X,Y) tuples: the points to bin
|
|
144
|
-
bin_sizes: float or tuple(float, float)
|
|
145
|
-
Size(s) of (X,Y) bins to partition into
|
|
146
|
-
ranges: Optional (ValueRange, ValueRange)
|
|
147
|
-
((x_min, x_max), (y_min, y_max)) for the bins. Default: take from data.
|
|
148
|
-
align: bool (default: True)
|
|
149
|
-
Force bin edges to be at a multiple of the bin size
|
|
150
|
-
drop_outside: bool (default: True)
|
|
151
|
-
True: Drop any data points outside the ranges
|
|
152
|
-
False: Put any outside points in closest bin (i.e. edge bins include outliers)
|
|
153
|
-
axis_args: Extra arguments to pass through to Axis constructor
|
|
154
|
-
|
|
155
|
-
returns: Sequence[Sequence[int]], (x-)Axis, (y-)Axis
|
|
156
|
-
"""
|
|
157
|
-
|
|
158
|
-
if ranges is None:
|
|
159
|
-
x_range = calc_value_range(tuple(x for x, _ in points))
|
|
160
|
-
y_range = calc_value_range(tuple(y for _, y in points))
|
|
161
|
-
else:
|
|
162
|
-
x_range, y_range = make_value_range(ranges[0]), make_value_range(ranges[1])
|
|
163
|
-
|
|
164
|
-
if not isinstance(bin_sizes, tuple):
|
|
165
|
-
# given just a single bin size: replicate it for both axes:
|
|
166
|
-
bin_sizes = (bin_sizes, bin_sizes)
|
|
167
|
-
|
|
168
|
-
x_edges = tuple(edge_range(x_range.min, x_range.max, make_decimal(bin_sizes[0]), align))
|
|
169
|
-
y_edges = tuple(edge_range(y_range.min, y_range.max, make_decimal(bin_sizes[1]), align))
|
|
170
|
-
|
|
171
|
-
x_axis = Axis(x_range, values_are_edges=True, **axis_args)
|
|
172
|
-
y_axis = Axis(y_range, values_are_edges=True, **axis_args)
|
|
173
|
-
|
|
174
|
-
return (bin_edges(points, x_edges, y_edges, drop_outside=drop_outside), x_axis, y_axis)
|
|
179
|
+
def make_edges(rng: ValueRange, step_arg: FloatLike, align: bool):
|
|
180
|
+
"""Return the edges as from 'edge_range', as a tuple for convenience"""
|
|
181
|
+
return tuple(edge_range(rng, step_arg, align))
|
|
175
182
|
|
|
176
183
|
|
|
177
184
|
def expand_bins_arg(
|
|
178
|
-
bins:
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if len(bins) > 2:
|
|
191
|
-
# we were given a single list of bin edges: replicate it
|
|
192
|
-
return (bins, bins)
|
|
193
|
-
|
|
194
|
-
# Flagged by type-checker: 'bins' could conceivably be a Sequence of len 1 or 2
|
|
195
|
-
if not isinstance(bins, tuple):
|
|
196
|
-
raise ValueError("Invalid 'bins' argument")
|
|
197
|
-
|
|
198
|
-
# We got a tuple of int/int or of Sequence/Sequence: return it
|
|
199
|
-
return bins
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def bins_to_edges(
|
|
203
|
-
bins: tuple[int, int] | tuple[Sequence[FloatLike], Sequence[FloatLike]],
|
|
204
|
-
) -> tuple[int, int] | tuple[Sequence[FloatLike], Sequence[FloatLike]]:
|
|
205
|
-
"""Number of edges = number of bins + 1. 'bins' argument may be # of bins,
|
|
206
|
-
or a collection of edges. Only add 1 in the former case.
|
|
207
|
-
"""
|
|
208
|
-
if isinstance(bins[0], int):
|
|
209
|
-
return (bins[0] + 1, bins[1] + 1)
|
|
210
|
-
return bins
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
def find_range(points: Sequence[FloatLike], padding: FloatLike) -> ValueRange:
|
|
214
|
-
"""Calculate a range if none is provided, then produce segment values"""
|
|
215
|
-
|
|
216
|
-
range_unpadded = calc_value_range(points)
|
|
217
|
-
range_padding = make_decimal(padding)
|
|
218
|
-
return ValueRange(range_unpadded.min - range_padding, range_unpadded.max + range_padding)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def segment_one_dim_if_needed(
|
|
222
|
-
points: Sequence[FloatLike],
|
|
223
|
-
bins: int | Sequence[FloatLike],
|
|
224
|
-
out_range: Optional[ValueRange],
|
|
225
|
-
align: bool,
|
|
226
|
-
padding: FloatLike,
|
|
227
|
-
) -> Sequence[FloatLike]:
|
|
228
|
-
"""Helper function for processing 'bins' argument:
|
|
229
|
-
If 'bins' argument is a number of bins, find equally spaced values in the range.
|
|
230
|
-
If not given a range, compute it first.
|
|
185
|
+
bins: FullBinsArg,
|
|
186
|
+
) -> tuple[bool, DoubleCountArg, Optional[DoubleEdgesArg]]:
|
|
187
|
+
"""Deal with 'bins' that may be
|
|
188
|
+
- None
|
|
189
|
+
- an integer indicating number of bins
|
|
190
|
+
- a list of edges/centers for the bins
|
|
191
|
+
- a 2-tuple of either of those
|
|
192
|
+
Returns a 3-tuple:
|
|
193
|
+
- specified/not-default (bool),
|
|
194
|
+
- 2-tuple of number of bins,
|
|
195
|
+
- optional 2-tuple of lists of edges/centers
|
|
231
196
|
"""
|
|
197
|
+
if bins is None:
|
|
198
|
+
return (False, DEFAULT_NUM_BINS, None)
|
|
232
199
|
if isinstance(bins, int):
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
200
|
+
num_bins = (bins, bins)
|
|
201
|
+
bin_positions = None
|
|
202
|
+
elif len(bins) > 2:
|
|
203
|
+
# we were given a single list of bin edges
|
|
204
|
+
num = len(bins) - 1
|
|
205
|
+
num_bins = (num, num)
|
|
206
|
+
bin_positions = (bins, bins)
|
|
207
|
+
else:
|
|
208
|
+
if not isinstance(bins, tuple):
|
|
209
|
+
raise ValueError("Invalid 'bins' argument")
|
|
210
|
+
# we either have a tuple of int/int or Sequence/Sequence
|
|
211
|
+
if isinstance(bins[0], int):
|
|
212
|
+
num_bins = bins
|
|
213
|
+
bin_positions = None
|
|
214
|
+
else:
|
|
215
|
+
num_bins = (len(bins[0]) - 1, len(bins[1]) - 1)
|
|
216
|
+
bin_positions = bins
|
|
217
|
+
return True, num_bins, bin_positions
|
|
243
218
|
|
|
244
|
-
def process_bin_args(
|
|
245
|
-
points: Sequence[tuple[FloatLike, FloatLike]],
|
|
246
|
-
bins: tuple[int, int] | tuple[Sequence[FloatLike], Sequence[FloatLike]],
|
|
247
|
-
ranges: Optional[tuple[Optional[ValueRange], Optional[ValueRange]]],
|
|
248
|
-
align: bool,
|
|
249
|
-
padding: tuple[FloatLike, FloatLike],
|
|
250
|
-
) -> tuple[Sequence[FloatLike], Sequence[FloatLike]]:
|
|
251
|
-
"""Utility function to process the various types that a 'bins' argument might be
|
|
252
|
-
bins, ranges, align: as for histogram2d
|
|
253
|
-
"""
|
|
254
219
|
|
|
255
|
-
|
|
256
|
-
|
|
220
|
+
def expand_bin_size_arg(
|
|
221
|
+
bin_size: Optional[FloatLike | tuple[FloatLike, FloatLike]],
|
|
222
|
+
) -> Optional[tuple[FloatLike, FloatLike]]:
|
|
223
|
+
"""If bin_size arg is not a 2-tuple, replicate it into one"""
|
|
224
|
+
if bin_size is None:
|
|
225
|
+
return None
|
|
226
|
+
if isinstance(bin_size, tuple):
|
|
227
|
+
return bin_size
|
|
228
|
+
return (bin_size, bin_size)
|
|
257
229
|
|
|
258
|
-
x_edges = segment_one_dim_if_needed(
|
|
259
|
-
tuple(x for x, _ in points), bins[0], ranges[0], align, padding[0]
|
|
260
|
-
)
|
|
261
|
-
y_edges = segment_one_dim_if_needed(
|
|
262
|
-
tuple(y for _, y in points), bins[1], ranges[1], align, padding[1]
|
|
263
|
-
)
|
|
264
230
|
|
|
265
|
-
|
|
231
|
+
def range_from_arg_or_data(range_arg, points):
|
|
232
|
+
"""Return range arg if given, or calculate a range from the data"""
|
|
233
|
+
if range_arg:
|
|
234
|
+
return make_value_range(range_arg)
|
|
235
|
+
return calc_value_range(tuple(points))
|
|
266
236
|
|
|
267
237
|
|
|
268
|
-
def histogram2d(
|
|
238
|
+
def histogram2d( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
|
|
269
239
|
points: Sequence[tuple[FloatLike, FloatLike]],
|
|
270
|
-
bins:
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
| Sequence[FloatLike]
|
|
274
|
-
| tuple[Sequence[FloatLike], Sequence[FloatLike]]
|
|
275
|
-
) = 10,
|
|
276
|
-
ranges: Optional[tuple[Optional[ValueRange], Optional[ValueRange]]] = None,
|
|
240
|
+
bins: FullBinsArg = None,
|
|
241
|
+
ranges: Optional[RangesArg] = None,
|
|
242
|
+
bin_size: Optional[FloatLike | tuple[FloatLike, FloatLike]] = None,
|
|
277
243
|
align=True,
|
|
278
244
|
drop_outside=True,
|
|
279
245
|
**axis_args,
|
|
280
246
|
) -> tuple[Sequence[Sequence[int]], Axis, Axis]:
|
|
281
|
-
"""Bin points into a 2-D histogram, given number of bins, or bin
|
|
247
|
+
"""Bin points into a 2-D histogram, given number of bins, bin edges, or bin sizes
|
|
248
|
+
|
|
249
|
+
Parameters can be combined in the following ways:
|
|
250
|
+
- bin_size with optional ranges
|
|
251
|
+
- bins (as edges) with no ranges
|
|
252
|
+
- bins (as count) with optional ranges
|
|
253
|
+
- bins (as count) + bin_size with no ranges: Fixed number and size of bins, centered on data
|
|
282
254
|
|
|
283
255
|
Parameters
|
|
284
256
|
----------
|
|
285
257
|
points: Sequence of (X,Y) tuples: the points to bin
|
|
286
|
-
bins: int or (int, int) or [float,...] or ([float,...], [float,...])
|
|
287
|
-
int: number of bins for both X & Y
|
|
258
|
+
bins: int or (int, int) or [float,...] or ([float,...], [float,...]) or None
|
|
259
|
+
int: number of bins for both X & Y
|
|
288
260
|
(int,int): number of bins in X, number of bins in Y
|
|
289
261
|
list[float]: bin edges for both X & Y
|
|
290
262
|
(list[float], list[float]): bin edges for X, bin edges for Y
|
|
263
|
+
None: defaults to DEFAULT_NUM_BINS if bin_size is not provided
|
|
291
264
|
ranges: Optional (ValueRange, ValueRange)
|
|
292
265
|
((x_min, x_max), (y_min, y_max)) for the bins if # of bins is provided
|
|
293
|
-
|
|
266
|
+
Cannot be specified with bins (as count) + bin_size, or bins (as edges)
|
|
267
|
+
Default if allowed: take from data
|
|
268
|
+
bin_size: Optional float or (float, float)
|
|
269
|
+
Size(s) of (X,Y) bins to partition into.
|
|
270
|
+
Cannot be combined with bins (as edges) since edge spacing already determines size.
|
|
271
|
+
float: bin size for both X & Y
|
|
272
|
+
(float, float): bin size for X, bin size for Y
|
|
294
273
|
align: bool (default: True)
|
|
295
|
-
pick bin edges at 'round' values if # of bins is provided
|
|
274
|
+
pick bin edges at 'round' values if # of bins is provided, or force bin edges
|
|
275
|
+
to be at multiples of bin_size if bin_size is provided
|
|
296
276
|
drop_outside: bool (default: True)
|
|
297
277
|
True: Drop any data points outside the ranges
|
|
298
278
|
False: Put any outside points in closest bin (i.e. edge bins include outliers)
|
|
@@ -301,11 +281,45 @@ def histogram2d(
|
|
|
301
281
|
returns: Sequence[Sequence[int]], (x-)Axis, (y-)Axis
|
|
302
282
|
"""
|
|
303
283
|
|
|
304
|
-
|
|
284
|
+
bins_specified, num_bins, bin_edges = expand_bins_arg(bins)
|
|
285
|
+
|
|
286
|
+
bin_sizes = expand_bin_size_arg(bin_size)
|
|
287
|
+
if ranges is None:
|
|
288
|
+
ranges = (None, None)
|
|
289
|
+
|
|
290
|
+
if bin_edges and any(ranges):
|
|
291
|
+
raise ValueError("Cannot specify both bin edges and plot range")
|
|
292
|
+
if bins_specified and bin_sizes and any(ranges):
|
|
293
|
+
# The number of bins and bin size imply a size of plot range, so this
|
|
294
|
+
# is overconstrained.
|
|
295
|
+
raise ValueError("Cannot specify number of bins and bin size and plot range")
|
|
296
|
+
|
|
297
|
+
x_range = range_from_arg_or_data(ranges[0], (x for x, _ in points))
|
|
298
|
+
y_range = range_from_arg_or_data(ranges[1], (y for _, y in points))
|
|
305
299
|
|
|
306
|
-
|
|
300
|
+
if bins_specified and bin_sizes:
|
|
301
|
+
# range width must be num_bins * bin_sizes, so take the data's range
|
|
302
|
+
# and force the width, aligning as needed
|
|
303
|
+
x_range = force_value_range_width(x_range, num_bins[0] * bin_sizes[0])
|
|
304
|
+
if align:
|
|
305
|
+
x_range = align_value_range(x_range, bin_sizes[0])
|
|
306
|
+
|
|
307
|
+
y_range = force_value_range_width(y_range, num_bins[1] * bin_sizes[1])
|
|
308
|
+
if align:
|
|
309
|
+
y_range = align_value_range(y_range, bin_sizes[1])
|
|
310
|
+
|
|
311
|
+
# Handle different parameter combinations
|
|
312
|
+
if bin_edges:
|
|
313
|
+
x_edges, y_edges = bin_edges
|
|
314
|
+
else:
|
|
315
|
+
if bin_sizes:
|
|
316
|
+
x_edges = make_edges(x_range, bin_sizes[0], align)
|
|
317
|
+
y_edges = make_edges(y_range, bin_sizes[1], align)
|
|
318
|
+
else:
|
|
319
|
+
# Only number of bins provided, if that
|
|
320
|
+
x_edges = segment_interval(num_bins[0] + 1, x_range, align)
|
|
321
|
+
y_edges = segment_interval(num_bins[1] + 1, y_range, align)
|
|
307
322
|
|
|
308
323
|
x_axis = Axis((x_edges[0], x_edges[-1]), values_are_edges=True, **axis_args)
|
|
309
324
|
y_axis = Axis((y_edges[0], y_edges[-1]), values_are_edges=True, **axis_args)
|
|
310
|
-
|
|
311
|
-
return (bin_edges(points, x_edges, y_edges, drop_outside), x_axis, y_axis)
|
|
325
|
+
return (bin_by_edges(points, x_edges, y_edges, drop_outside), x_axis, y_axis)
|