densitty 0.8.2__py3-none-any.whl → 0.9.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/axis.py +192 -55
- densitty/binning.py +134 -63
- densitty/detect.py +71 -11
- densitty/plot.py +15 -7
- densitty/smoothing.py +317 -0
- densitty/truecolor.py +2 -1
- densitty/util.py +125 -123
- densitty/util.pyi +37 -0
- {densitty-0.8.2.dist-info → densitty-0.9.0.dist-info}/METADATA +6 -3
- densitty-0.9.0.dist-info/RECORD +17 -0
- densitty-0.8.2.dist-info/RECORD +0 -15
- {densitty-0.8.2.dist-info → densitty-0.9.0.dist-info}/WHEEL +0 -0
- {densitty-0.8.2.dist-info → densitty-0.9.0.dist-info}/licenses/LICENSE +0 -0
- {densitty-0.8.2.dist-info → densitty-0.9.0.dist-info}/top_level.txt +0 -0
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,136 @@ def gen_tick_values(value_range, tick_step):
|
|
|
50
92
|
tick += tick_step
|
|
51
93
|
|
|
52
94
|
|
|
53
|
-
def
|
|
54
|
-
|
|
95
|
+
def filter_labels(labels: dict, values_to_print: Sequence):
|
|
96
|
+
"""Remove printed value with empty string (just a tick) for any
|
|
97
|
+
labels that aren't in values_to_print"""
|
|
98
|
+
|
|
99
|
+
return {k: v if k in values_to_print else "" for k, v in labels.items()}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def calc_min_step(
|
|
103
|
+
value_range: ValueRange,
|
|
104
|
+
bin_width: FloatLike,
|
|
105
|
+
accomodate_values: bool,
|
|
106
|
+
tick_space: int,
|
|
107
|
+
fmt: str,
|
|
55
108
|
):
|
|
56
|
-
"""
|
|
57
|
-
tick_step, label_step = pick_step_size(value_range, num_ticks, min_ticks_per_label)
|
|
109
|
+
"""Calculate minimum step size for tick placement.
|
|
58
110
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
111
|
+
When accomodate_values is True, considers the width of printed labels.
|
|
112
|
+
Otherwise, only considers tick spacing requirements.
|
|
113
|
+
"""
|
|
114
|
+
if accomodate_values:
|
|
115
|
+
# find a representative printed label width to use for basic calculations
|
|
116
|
+
test_tick_step = pick_step_size((value_range.max - value_range.min) / 5)
|
|
117
|
+
test_values_printed = tuple(
|
|
118
|
+
fmt.format(value) for value in gen_tick_values(value_range, test_tick_step)
|
|
119
|
+
)
|
|
120
|
+
widths_in_bins = tuple(len(p) for p in test_values_printed)
|
|
121
|
+
# get the 3'd lowest width, or highest if there are less than 3 due to tick-step roundup:
|
|
122
|
+
example_width = sorted(widths_in_bins)[:3][-1]
|
|
123
|
+
min_printed_step = (example_width + 1) * bin_width
|
|
124
|
+
# If the printed labels are small (single-digit), the ticks themselves might be the
|
|
125
|
+
# limiting factor, especially if they are X-axis fractional ticks like "/\"
|
|
126
|
+
return max(min_printed_step, bin_width * (2 + tick_space * 2))
|
|
127
|
+
return bin_width * (2 + tick_space)
|
|
67
128
|
|
|
68
|
-
# sanity: if all but one ticks have labels, just label them all
|
|
69
|
-
if len(label_values) >= len(ticks) - 1:
|
|
70
|
-
label_values = ticks
|
|
71
129
|
|
|
72
|
-
|
|
73
|
-
|
|
130
|
+
def gen_label_subsets(positions: tuple, tick_step: Decimal) -> list[tuple]:
|
|
131
|
+
"""Generate candidate label subsets based on tick step digit.
|
|
74
132
|
|
|
75
|
-
|
|
133
|
+
For tick steps starting with 1 or 2: generates every-5th subsets (5 variants).
|
|
134
|
+
For tick steps starting with 5: generates every-2nd subsets (2 variants).
|
|
135
|
+
"""
|
|
136
|
+
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
|
|
139
|
+
# 1eX, 2eX, or 5eX:
|
|
140
|
+
label_subsets = []
|
|
141
|
+
if step_digit in (1, 2):
|
|
142
|
+
# print on every fifth label, starting at 0..4
|
|
143
|
+
label_subsets += list(positions[start::5] for start in range(5))
|
|
144
|
+
elif step_digit in (1, 5):
|
|
145
|
+
# print on every second label, starting at 0 or 1
|
|
146
|
+
label_subsets += list(positions[start::2] for start in range(2))
|
|
147
|
+
return label_subsets
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def label_ends_only(labels, positions, tick_step, bin_width, accomodate_values):
|
|
151
|
+
"""See if printing just the labels for the first and last ticks will fit"""
|
|
152
|
+
|
|
153
|
+
if not accomodate_values:
|
|
154
|
+
# For Y axis / we don't care about printed widths
|
|
155
|
+
return labels
|
|
156
|
+
|
|
157
|
+
half_len_a = len(labels[positions[0]]) // 2
|
|
158
|
+
half_len_b = (len(labels[positions[-1]]) + 1) // 2
|
|
159
|
+
space_available = math.floor(tick_step / bin_width)
|
|
160
|
+
|
|
161
|
+
if half_len_a + half_len_b <= space_available:
|
|
162
|
+
# We can fit values on the first and last ticks
|
|
163
|
+
return filter_labels(labels, (positions[0], positions[-1]))
|
|
164
|
+
|
|
165
|
+
# Not enough space to print two values, so just print the first
|
|
166
|
+
return filter_labels(labels, positions[0:1])
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def find_fitting_subset(label_subsets, labels, tick_step, bin_width, accomodate_values):
|
|
170
|
+
"""Find roundest label subset that fits within space constraints"""
|
|
171
|
+
for label_subset in util.roundness_ordered(label_subsets):
|
|
172
|
+
if not accomodate_values:
|
|
173
|
+
return filter_labels(labels, label_subset)
|
|
174
|
+
# We're printing at most one value for every 2 ticks, so just make sure
|
|
175
|
+
# that the printed values will not run over the adjacent ticks' area
|
|
176
|
+
# Given the initial min_step logic, this will likely always be true
|
|
177
|
+
allowed_width = tick_step / bin_width * 2 - 2
|
|
178
|
+
max_printed_width = max(len(labels[k]) for k in label_subset)
|
|
179
|
+
if max_printed_width <= allowed_width:
|
|
180
|
+
return filter_labels(labels, label_subset)
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def gen_full_labels(value_range: ValueRange, num_bins, accomodate_values, tick_space, fmt):
|
|
185
|
+
"""Generate positions for labels (plain ticks & ticks with value)"""
|
|
186
|
+
bin_width = (value_range.max - value_range.min) / num_bins
|
|
187
|
+
|
|
188
|
+
if bin_width <= 0:
|
|
189
|
+
# we don't have a sensible range for the axis values, so just have empty ticks
|
|
190
|
+
return {}
|
|
191
|
+
|
|
192
|
+
min_step = calc_min_step(value_range, bin_width, accomodate_values, tick_space, fmt)
|
|
193
|
+
cur_tick_step = pick_step_size(min_step)
|
|
194
|
+
|
|
195
|
+
while True:
|
|
196
|
+
labels = {
|
|
197
|
+
value: fmt.format(value) for value in gen_tick_values(value_range, cur_tick_step)
|
|
198
|
+
}
|
|
199
|
+
positions = tuple(labels.keys())
|
|
200
|
+
|
|
201
|
+
if len(labels) == 0:
|
|
202
|
+
# No suitable ticks/labels.
|
|
203
|
+
if accomodate_values:
|
|
204
|
+
# Printed value labels won't fit, try without them
|
|
205
|
+
return gen_full_labels(value_range, num_bins, False, tick_space, "")
|
|
206
|
+
# Nothing fits
|
|
207
|
+
return {}
|
|
208
|
+
if len(labels) == 1:
|
|
209
|
+
return labels
|
|
210
|
+
|
|
211
|
+
label_subsets = gen_label_subsets(positions, cur_tick_step)
|
|
212
|
+
|
|
213
|
+
# Check to see if all generated label subsets only have a single entry
|
|
214
|
+
if max(len(subset) for subset in label_subsets) == 1:
|
|
215
|
+
# Try to just label the ends:
|
|
216
|
+
return label_ends_only(labels, positions, cur_tick_step, bin_width, accomodate_values)
|
|
217
|
+
|
|
218
|
+
result = find_fitting_subset(
|
|
219
|
+
label_subsets, labels, cur_tick_step, bin_width, accomodate_values
|
|
220
|
+
)
|
|
221
|
+
if result is not None:
|
|
222
|
+
return result
|
|
223
|
+
|
|
224
|
+
cur_tick_step = pick_step_size(float(cur_tick_step) * 1.01)
|
|
76
225
|
|
|
77
226
|
|
|
78
227
|
def calc_edges(value_range, num_bins, values_are_edges):
|
|
@@ -88,7 +237,7 @@ def calc_edges(value_range, num_bins, values_are_edges):
|
|
|
88
237
|
values_are_edges: bool
|
|
89
238
|
Indicates that value_range specifies outside edges rather than bin centers
|
|
90
239
|
"""
|
|
91
|
-
if values_are_edges:
|
|
240
|
+
if values_are_edges or num_bins == 1:
|
|
92
241
|
bin_delta = (value_range.max - value_range.min) / num_bins
|
|
93
242
|
first_bin_min = value_range.min
|
|
94
243
|
else:
|
|
@@ -137,10 +286,11 @@ class Axis:
|
|
|
137
286
|
def _unjustified_y_axis(self, num_rows: int):
|
|
138
287
|
"""Returns the Y axis string for each line of the plot"""
|
|
139
288
|
if self.labels is None:
|
|
140
|
-
labels =
|
|
289
|
+
labels = gen_full_labels(
|
|
141
290
|
self.value_range,
|
|
142
|
-
num_rows
|
|
143
|
-
|
|
291
|
+
num_rows,
|
|
292
|
+
False,
|
|
293
|
+
0,
|
|
144
294
|
self.label_fmt,
|
|
145
295
|
)
|
|
146
296
|
else:
|
|
@@ -215,14 +365,9 @@ class Axis:
|
|
|
215
365
|
left_margin: int
|
|
216
366
|
chars to the left of leftmost data col. May have Labels/border-line.
|
|
217
367
|
"""
|
|
218
|
-
|
|
219
368
|
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
|
-
)
|
|
369
|
+
tick_space = 1 if self.fractional_tick_pos else 0
|
|
370
|
+
labels = gen_full_labels(self.value_range, num_cols, True, tick_space, self.label_fmt)
|
|
226
371
|
else:
|
|
227
372
|
labels = self.labels
|
|
228
373
|
|
|
@@ -242,23 +387,15 @@ class Axis:
|
|
|
242
387
|
for col_idx, (col_min, col_max) in enumerate(bins):
|
|
243
388
|
# use Decimal.next_plus to accomodate rounding error/truncation
|
|
244
389
|
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])
|
|
390
|
+
if self.fractional_tick_pos:
|
|
391
|
+
offset_frac = (label_values[0] - col_min) / (col_max - col_min)
|
|
260
392
|
else:
|
|
261
|
-
|
|
393
|
+
offset_frac = 0.5 # not doing fractional tick positioning == center the tick
|
|
394
|
+
|
|
395
|
+
add_x_tick(tick_line, col_idx + offset_frac, left_margin, num_cols)
|
|
396
|
+
add_x_label(
|
|
397
|
+
label_line, labels[label_values[0]], col_idx + left_margin + offset_frac
|
|
398
|
+
)
|
|
262
399
|
|
|
263
400
|
label_values = label_values[1:] # pop that first label since we added it
|
|
264
401
|
|
densitty/binning.py
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
import math
|
|
4
4
|
from bisect import bisect_right
|
|
5
5
|
from decimal import Decimal
|
|
6
|
+
from fractions import Fraction
|
|
6
7
|
from typing import Optional, Sequence
|
|
7
8
|
|
|
8
9
|
from .axis import Axis
|
|
9
10
|
from .util import FloatLike, ValueRange
|
|
10
|
-
from .util import clamp,
|
|
11
|
+
from .util import clamp, make_decimal, make_value_range, most_round, round_up_ish
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
def bin_edges(
|
|
@@ -45,17 +46,21 @@ def calc_value_range(values: Sequence[FloatLike]) -> ValueRange:
|
|
|
45
46
|
"""Calculate a value range from data values"""
|
|
46
47
|
if not values:
|
|
47
48
|
# Could raise an exception here, but for now just return _something_
|
|
48
|
-
return
|
|
49
|
+
return make_value_range((0, 1))
|
|
49
50
|
|
|
50
51
|
# bins are closed on left and open on right: i.e. left_edge <= values < right_edge
|
|
51
52
|
# so, the right-most bin edge needs to be larger than the largest data value:
|
|
52
53
|
max_value = max(values)
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
min_value = min(values)
|
|
55
|
+
data_value_range = make_value_range((min_value, max_value))
|
|
56
|
+
range_top = data_value_range.max + Decimal(
|
|
57
|
+
math.ulp(data_value_range.max)
|
|
58
|
+
) # increase by smallest representable amount
|
|
59
|
+
return ValueRange(data_value_range.min, range_top)
|
|
55
60
|
|
|
56
61
|
|
|
57
|
-
def
|
|
58
|
-
|
|
62
|
+
def segment_interval(
|
|
63
|
+
num_outputs: int,
|
|
59
64
|
value_range: ValueRange,
|
|
60
65
|
align=True,
|
|
61
66
|
) -> Sequence[FloatLike]:
|
|
@@ -64,43 +69,52 @@ def pick_edges(
|
|
|
64
69
|
Parameters
|
|
65
70
|
----------
|
|
66
71
|
values: Sequence of data values
|
|
67
|
-
|
|
68
|
-
Number of
|
|
72
|
+
num_outputs: int
|
|
73
|
+
Number of output values
|
|
69
74
|
value_range: ValueRange
|
|
70
|
-
Min/Max of the values
|
|
75
|
+
Min/Max of the output values
|
|
71
76
|
align: bool
|
|
72
77
|
Adjust the range somewhat to put bin size & edges on "round" values
|
|
73
78
|
"""
|
|
74
|
-
value_range =
|
|
79
|
+
value_range = make_value_range(value_range) # coerce into Decimal if not already
|
|
80
|
+
assert isinstance(value_range.min, Decimal) # make the type-checker happy
|
|
81
|
+
assert isinstance(value_range.max, Decimal)
|
|
82
|
+
num_steps = num_outputs - 1
|
|
75
83
|
|
|
76
|
-
min_step_size = (value_range.max - value_range.min) /
|
|
84
|
+
min_step_size = (value_range.max - value_range.min) / num_steps
|
|
77
85
|
if align:
|
|
78
86
|
step_size = round_up_ish(min_step_size)
|
|
79
|
-
first_edge = math.floor(
|
|
80
|
-
if first_edge +
|
|
87
|
+
first_edge = math.floor(Fraction(value_range.min) / step_size) * step_size
|
|
88
|
+
if first_edge + num_steps * step_size < value_range.max:
|
|
81
89
|
# Uh oh: even though we rounded up the bin size, shifting the first edge
|
|
82
90
|
# down to a multiple has shifted the last edge down too far. Bump up the step size:
|
|
83
|
-
step_size = round_up_ish(step_size *
|
|
84
|
-
first_edge = math.floor(
|
|
91
|
+
step_size = round_up_ish(step_size * Fraction(65, 64))
|
|
92
|
+
first_edge = math.floor(Fraction(value_range.min) / step_size) * step_size
|
|
85
93
|
# we now have a round step size, and a first edge that the highest possible multiple of it
|
|
86
94
|
# Test to see if any lower multiples of it will still include the whole ranges,
|
|
87
95
|
# and be "nicer" i.e. if data is all in 1.1..9.5 range with 10 bins, we now have bins
|
|
88
96
|
# covering 1-11, but could have 0-10
|
|
89
|
-
last_edge = first_edge + step_size *
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
last_edge = first_edge + step_size * num_steps
|
|
98
|
+
edge_pairs = []
|
|
99
|
+
max_step_slop = int((last_edge - Fraction(value_range.max)) // step_size)
|
|
100
|
+
for step_shift in range(-max_step_slop, 1):
|
|
101
|
+
for end_step_shift in range(-max_step_slop, step_shift + 1):
|
|
102
|
+
edge_pairs += [
|
|
103
|
+
(first_edge + step_shift * step_size, last_edge + end_step_shift * step_size)
|
|
104
|
+
]
|
|
105
|
+
first_edge, last_edge = most_round(edge_pairs)
|
|
95
106
|
else:
|
|
96
107
|
step_size = min_step_size
|
|
97
108
|
first_edge = value_range.min
|
|
109
|
+
last_edge = value_range.max
|
|
110
|
+
|
|
111
|
+
stepped_values = tuple(first_edge + step_size * i for i in range(num_outputs))
|
|
98
112
|
|
|
99
|
-
|
|
100
|
-
return tuple(
|
|
113
|
+
# The values may have overrun the end of the desired output range. Trim if so:
|
|
114
|
+
return tuple(v for v in stepped_values if v <= last_edge)
|
|
101
115
|
|
|
102
116
|
|
|
103
|
-
def edge_range(start:
|
|
117
|
+
def edge_range(start: Decimal, end: Decimal, step: Decimal, align: bool):
|
|
104
118
|
"""Similar to range/np.arange, but includes "end" in the output if appropriate"""
|
|
105
119
|
if align:
|
|
106
120
|
v = math.floor(start / step) * step
|
|
@@ -145,14 +159,14 @@ def bin_with_size(
|
|
|
145
159
|
x_range = calc_value_range(tuple(x for x, _ in points))
|
|
146
160
|
y_range = calc_value_range(tuple(y for _, y in points))
|
|
147
161
|
else:
|
|
148
|
-
x_range, y_range =
|
|
162
|
+
x_range, y_range = make_value_range(ranges[0]), make_value_range(ranges[1])
|
|
149
163
|
|
|
150
164
|
if not isinstance(bin_sizes, tuple):
|
|
151
165
|
# given just a single bin size: replicate it for both axes:
|
|
152
166
|
bin_sizes = (bin_sizes, bin_sizes)
|
|
153
167
|
|
|
154
|
-
x_edges = tuple(edge_range(x_range.min, x_range.max, bin_sizes[0], align))
|
|
155
|
-
y_edges = tuple(edge_range(y_range.min, y_range.max, bin_sizes[1], align))
|
|
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))
|
|
156
170
|
|
|
157
171
|
x_axis = Axis(x_range, values_are_edges=True, **axis_args)
|
|
158
172
|
y_axis = Axis(y_range, values_are_edges=True, **axis_args)
|
|
@@ -160,6 +174,97 @@ def bin_with_size(
|
|
|
160
174
|
return (bin_edges(points, x_edges, y_edges, drop_outside=drop_outside), x_axis, y_axis)
|
|
161
175
|
|
|
162
176
|
|
|
177
|
+
def expand_bins_arg(
|
|
178
|
+
bins: (
|
|
179
|
+
int
|
|
180
|
+
| tuple[int, int]
|
|
181
|
+
| Sequence[FloatLike]
|
|
182
|
+
| tuple[Sequence[FloatLike], Sequence[FloatLike]]
|
|
183
|
+
),
|
|
184
|
+
) -> tuple[int, int] | tuple[Sequence[FloatLike], Sequence[FloatLike]]:
|
|
185
|
+
"""Deal with 'bins' argument that is meant to apply to both axes"""
|
|
186
|
+
if isinstance(bins, int):
|
|
187
|
+
# we were given a single # of bins
|
|
188
|
+
return (bins, bins)
|
|
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.
|
|
231
|
+
"""
|
|
232
|
+
if isinstance(bins, int):
|
|
233
|
+
# we were given the number of bins for X or Y. Calculate the edges/centers:
|
|
234
|
+
if out_range is None:
|
|
235
|
+
out_range = find_range(points, padding)
|
|
236
|
+
return segment_interval(bins, out_range, align)
|
|
237
|
+
|
|
238
|
+
# we were given the bin edges/centers already
|
|
239
|
+
if out_range is not None:
|
|
240
|
+
raise ValueError("Both bin edges and bin ranges provided, pick one or the other")
|
|
241
|
+
return bins
|
|
242
|
+
|
|
243
|
+
|
|
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
|
+
|
|
255
|
+
if ranges is None:
|
|
256
|
+
ranges = (None, None)
|
|
257
|
+
|
|
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
|
+
|
|
265
|
+
return x_edges, y_edges
|
|
266
|
+
|
|
267
|
+
|
|
163
268
|
def histogram2d(
|
|
164
269
|
points: Sequence[tuple[FloatLike, FloatLike]],
|
|
165
270
|
bins: (
|
|
@@ -196,43 +301,9 @@ def histogram2d(
|
|
|
196
301
|
returns: Sequence[Sequence[int]], (x-)Axis, (y-)Axis
|
|
197
302
|
"""
|
|
198
303
|
|
|
199
|
-
|
|
200
|
-
# we were given a single # of bins
|
|
201
|
-
bins = (bins, bins)
|
|
202
|
-
|
|
203
|
-
if isinstance(bins, Sequence) and len(bins) > 2:
|
|
204
|
-
# we were given a single list of bin edges: replicate it
|
|
205
|
-
bins = (bins, bins)
|
|
304
|
+
expanded_bins = bins_to_edges(expand_bins_arg(bins))
|
|
206
305
|
|
|
207
|
-
|
|
208
|
-
# we were given the number of bins for X. Calculate the edges:
|
|
209
|
-
if ranges is None or ranges[0] is None:
|
|
210
|
-
x_range = calc_value_range(tuple(x for x, _ in points))
|
|
211
|
-
else:
|
|
212
|
-
x_range = ValueRange(*ranges[0])
|
|
213
|
-
|
|
214
|
-
x_edges = pick_edges(bins[0], x_range, align)
|
|
215
|
-
else:
|
|
216
|
-
# we were given the bin edges already
|
|
217
|
-
if ranges is not None and ranges[0] is not None:
|
|
218
|
-
raise ValueError("Both bin edges and bin ranges provided, pick one or the other")
|
|
219
|
-
assert isinstance(bins[0], Sequence)
|
|
220
|
-
x_edges = bins[0]
|
|
221
|
-
|
|
222
|
-
if isinstance(bins[1], int):
|
|
223
|
-
# we were given the number of bins. Calculate the edges:
|
|
224
|
-
if ranges is None or ranges[1] is None:
|
|
225
|
-
y_range = calc_value_range(tuple(y for _, y in points))
|
|
226
|
-
else:
|
|
227
|
-
y_range = ValueRange(*ranges[1])
|
|
228
|
-
|
|
229
|
-
y_edges = pick_edges(bins[1], y_range, align)
|
|
230
|
-
else:
|
|
231
|
-
# we were given the bin edges already
|
|
232
|
-
if ranges is not None and ranges[1] is not None:
|
|
233
|
-
raise ValueError("Both bin edges and bin ranges provided, pick one or the other")
|
|
234
|
-
assert isinstance(bins[1], Sequence)
|
|
235
|
-
y_edges = bins[1]
|
|
306
|
+
x_edges, y_edges = process_bin_args(points, expanded_bins, ranges, align, (0, 0))
|
|
236
307
|
|
|
237
308
|
x_axis = Axis((x_edges[0], x_edges[-1]), values_are_edges=True, **axis_args)
|
|
238
309
|
y_axis = Axis((y_edges[0], y_edges[-1]), values_are_edges=True, **axis_args)
|