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 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
@@ -8,7 +8,6 @@ from typing import Optional, Sequence
8
8
 
9
9
  from .util import nearest
10
10
 
11
-
12
11
  RESET = "\033[0m"
13
12
 
14
13
 
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 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"""
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
- return {k: v if k in values_to_print else "" for k, v in labels.items()}
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 gen_label_subsets(positions: tuple, tick_step: Decimal) -> list[tuple]:
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
- label_subsets = []
143
+ position_subsets = []
141
144
  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
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(labels, positions, tick_step, bin_width, accomodate_values):
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 labels
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
- half_len_a = len(labels[positions[0]]) // 2
158
- half_len_b = (len(labels[positions[-1]]) + 1) // 2
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 half_len_a + half_len_b <= space_available:
167
+ if half_len_first + half_len_last <= space_available:
162
168
  # We can fit values on the first and last ticks
163
- return filter_labels(labels, (positions[0], positions[-1]))
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 filter_labels(labels, positions[0:1])
172
+ return positions_to_labels(end_positions[:1], positions, fmt)
167
173
 
168
174
 
169
- def find_fitting_subset(label_subsets, labels, tick_step, bin_width, accomodate_values):
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 label_subset in util.roundness_ordered(label_subsets):
172
- if not accomodate_values:
173
- return filter_labels(labels, label_subset)
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
- 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
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
- labels = {
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(labels) == 0:
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(labels) == 1:
209
- return labels
213
+ if len(positions) == 1:
214
+ return positions_to_labels(positions, [], fmt)
210
215
 
211
- label_subsets = gen_label_subsets(positions, cur_tick_step)
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 label_subsets) == 1:
219
+ if max(len(subset) for subset in position_subsets) == 1:
215
220
  # Try to just label the ends:
216
- return label_ends_only(labels, positions, cur_tick_step, bin_width, accomodate_values)
221
+ return label_ends_only(positions, cur_tick_step, bin_width, accomodate_values, fmt)
217
222
 
218
- result = find_fitting_subset(
219
- label_subsets, labels, cur_tick_step, bin_width, accomodate_values
223
+ best_subset = find_fitting_subset(
224
+ position_subsets, cur_tick_step / bin_width, accomodate_values, fmt
220
225
  )
221
- if result is not None:
222
- return result
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[float, str]] = None # map axis value to label (plus tick) at that value
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[float, str]] = None,
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
- if offset_frac < 0.25 and self.fractional_tick_pos:
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.75 and self.fractional_tick_pos:
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
- def bin_edges(
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
- max_value = max(values)
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)
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(start: Decimal, end: Decimal, step: Decimal, align: bool):
118
- """Similar to range/np.arange, but includes "end" in the output if appropriate"""
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(start / step) * step
167
+ v = math.floor(rng.min / step) * step
121
168
  else:
122
- v = start
123
- while v < end + step:
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 bin_with_size(
132
- points: Sequence[tuple[FloatLike, FloatLike]],
133
- bin_sizes: FloatLike | tuple[FloatLike, FloatLike],
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
- 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.
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
- # 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
-
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
- if ranges is None:
256
- ranges = (None, None)
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
- return x_edges, y_edges
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
- int
272
- | tuple[int, int]
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 edges
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 (default: 10)
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
- Default: take from data.
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
- expanded_bins = bins_to_edges(expand_bins_arg(bins))
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
- x_edges, y_edges = process_bin_args(points, expanded_bins, ranges, align, (0, 0))
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)