Proposal: "intrange" for inclusive integer intervals

Getting a closed interval is awkward. Let's say you want an array of the integers from a to b, inclusive at both ends. e.g. for a=10 b=15, you want: [10, 11, 12, 13, 14, 15] Neither linspace nor arange makes this easy. arange(a, b) # wrong, it only goes up to 14 arange(a, b+1) # right, but awkward. you have to remember to add one linspace(a, b, b-a) # wrong, will produce fractions linspace(a, b, b-a, endpoint=False) # wrong, only goes up to 14 linspace(a, b, b-a+1) # right, awkward. also it gives you floats by default Both ways have the potential for an off-by-one error. I forget the right way to do it and have to look it up, pretty much every time. It has been proposed before to add an step parameter to linspace or an endpoint parameter to arange: https://github.com/numpy/numpy/issues/630 but it was rejected on the grounds that it's too hard / impossible to make it work properly with floats. But that's not a problem if the function is restricted to only give integers in the first place. There's a few ways this can be done; one is to make a function "crange" (closed range): crange(10, 15) # 10, 11, 12, 13, 14, 15 Like arange it would take a step parameter, defaulting to 1. Unlike arange, the start, stop, and step parameters must all be integers. This raises the question of what to do when the step size doesn't evenly divide the range. What should crange(10, 15, 3) return? There's no clear answer. There are three constraints we might be interested in: 1: for 0 <= i < len(array)-1: array[i] + step == array[i+1] 2: for x in array: start <= x <= stop 3: array[0] == start, array[-1] == stop But for crange(10, 15, 3) we can only choose two: [10, 13, 15] # violates 1, satisfies 2 and 3 [10, 13] # violates 2, satisfies 1 and 3 [10, 13, 16] # violates 3, satisfies 1 and 2 Depending on the situation you might plausibly want any one of these. Furthermore, there is the symmetric set of possibilities where we fix the stop bound, and step backwards from it, letting the start bound vary: [10, 12, 15] # violates 1, satisfies 2 and 3 [9, 12, 15] # violates 2, satisfies 1 and 3 [12, 15] # violates 3, satisfies 1 and 2 There are two axes of behaviour here: which bound we should "anchor", and what to do with the other bound. We can "anchor" either the start or the stop, and with the other bound we have three choices: "open", "closed", or "exact". Since "crange" now seems wrongly named, call it "intrange": def intrange(start: int, stop: int, step: int = 1, bound: str = "open", anchor: str = "start", dtype=None): ... Would behave like: intrange(10, 15, 3) [10, 13] # same as arange intrange(10, 15, 3, bound="closed") [10, 13, 16] intrange(10, 15, 3, bound="exact") [10, 13, 15] intrange(10, 15, 3, bound="open", anchor="stop") [12, 15] intrange(10, 15, 3, bound="closed", anchor="stop") [9, 12, 15] intrange(10, 15, 3, bound="exact", anchor="stop") [10, 12, 15] Anyway, the two most common kinds of integer range you'd want to make are easy .. mostly you just want step=1, inclusive at the start, and either exclusive or inclusive at the end. intrange(10, 15) [10, 11, 12, 13, 14] # same as arange intrange(10, 15, bound="closed") [10, 11, 12, 13, 14, 15] # easy to remember, intuitive, no off-by-one-error From there, you can make inclusive floating point ranges by scaling: intrange(10, 15, bound="closed") / 10 [1.0, 1.1, 1.2, 1.3, 1.4, 1.5] This way, we don't have to worry about floating point imprecision screwing up the bounds calculation. Sketch implementation: def intrange(start, stop, step=1, bound="open", anchor="start"): match (anchor, bound): case ("start", "open"): return np.arange(start, stop, step) case ("start", "closed"): return np.arange(start, stop + step, step) case ("start", "exact"): result = np.arange(start, stop + step, step) result[-1] = stop return result case ("stop", "open"): return np.flip(np.arange(stop, start, -step)) case ("stop", "closed"): return np.flip(np.arange(stop, start - step, -step)) case ("stop", "exact"): result = np.flip(np.arange(stop, start - step, -step)) result[0] = start return result case _: assert False

I'm personally not particularly interested in this direction. Because there are so many options, you basically have 6 separate functions that you have to document in one docstring, and each one is a 1- or 2-liner with `arange`. That isn't enough semantic compression to justify a new function, for me. It is also the case that there is still an ambiguity in what `closed` means in the case of a larger `step`; you excluded the option that I would have picked (`arange(start, stop+1, step)` behaves the way I would want in such cases; constant step, all values <= stop). `arange` is a flexible primitive, with semantics shared by the language itself. There are lots of ways to use that primitive to get a wide variety of results, which is what makes it a good primitive. Documenting those 1- or 2-liners in our docs would be preferable to having a new function, IMO. I would be mildly more open to one additional, focused function that *just* does a closed range (with `arange(start, stop+1, step)`) but still would prefer not to expand our API that way. On Thu, May 4, 2023 at 10:56 AM <cameron.pinnegar@gmail.com> wrote:
-- Robert Kern

I'm personally not particularly interested in this direction. Because there are so many options, you basically have 6 separate functions that you have to document in one docstring, and each one is a 1- or 2-liner with `arange`. That isn't enough semantic compression to justify a new function, for me. It is also the case that there is still an ambiguity in what `closed` means in the case of a larger `step`; you excluded the option that I would have picked (`arange(start, stop+1, step)` behaves the way I would want in such cases; constant step, all values <= stop). `arange` is a flexible primitive, with semantics shared by the language itself. There are lots of ways to use that primitive to get a wide variety of results, which is what makes it a good primitive. Documenting those 1- or 2-liners in our docs would be preferable to having a new function, IMO. I would be mildly more open to one additional, focused function that *just* does a closed range (with `arange(start, stop+1, step)`) but still would prefer not to expand our API that way. On Thu, May 4, 2023 at 10:56 AM <cameron.pinnegar@gmail.com> wrote:
-- Robert Kern
participants (2)
-
cameron.pinnegar@gmail.com
-
Robert Kern