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 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, pick_step_size
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 add_label(line: list[str], label: str, ctr_pos: int):
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 // 2, 0)
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 gen_labels(
54
- value_range: ValueRange, num_ticks, min_ticks_per_label, fmt, label_end_ticks=False
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
- """Generate positions for labels (plain ticks & ticks with value)"""
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
- ticks = list(gen_tick_values(value_range, tick_step))
60
- label_values = list(gen_tick_values(value_range, label_step))
61
- if label_end_ticks or len(label_values) <= 2:
62
- # ensure that first & last ticks have labels:
63
- if label_values[0] != ticks[0]:
64
- label_values = ticks[:1] + label_values
65
- if label_values[-1] != ticks[-1]:
66
- label_values += ticks[-1:]
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
- ticks_only = {value: "" for value in ticks}
73
- labeled_ticks = {value: fmt.format(value) for value in label_values}
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
- return ticks_only | labeled_ticks
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 = gen_labels(
289
+ labels = gen_full_labels(
141
290
  self.value_range,
142
- num_rows // DEFAULT_Y_ROWS_PER_TICK,
143
- MIN_Y_TICKS_PER_LABEL,
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
- labels = gen_labels(
221
- self.value_range,
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
- add_label(label_line, labels[label_values[0]], col_idx + left_margin)
246
- tick_idx = left_margin + col_idx
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
- tick_line[tick_idx] = lineart.merge_chars("│", tick_line[tick_idx])
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, decimal_value_range, most_round, round_up_ish
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 ValueRange(0, 1)
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
- range_top = max_value + math.ulp(max_value) # increase by smallest representable amount
54
- return ValueRange(min(values), range_top)
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 pick_edges(
58
- num_bins: int,
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
- num_bins: int
68
- Number of bins to partition into
72
+ num_outputs: int
73
+ Number of output values
69
74
  value_range: ValueRange
70
- Min/Max of the values to be binned
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 = decimal_value_range(value_range) # coerce into Decimal if not already
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) / num_bins
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(Decimal(value_range.min) / step_size) * step_size
80
- if first_edge + num_bins * step_size < value_range.max:
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 * Decimal(1.015625))
84
- first_edge = math.floor(Decimal(value_range.min) / step_size) * step_size
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 * num_bins
90
- num_trials = int((last_edge - value_range.max) // step_size + 1)
91
- offsets = (step_size * i for i in range(num_trials))
92
- edge_pairs = ((first_edge - offset, last_edge - offset) for offset in offsets)
93
- first_edge = most_round(edge_pairs)[0]
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
- num_edges = num_bins + 1
100
- return tuple(first_edge + step_size * i for i in range(num_edges))
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: FloatLike, end: FloatLike, step: FloatLike, align: bool):
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 = ValueRange(*ranges[0]), ValueRange(*ranges[1])
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
- if isinstance(bins, int):
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
- if isinstance(bins[0], int):
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)