From 8831addf28f41445fe4a94d9655ed16c63ea0137 Mon Sep 17 00:00:00 2001 From: peterhinch Date: Wed, 7 Jun 2023 18:06:10 +0100 Subject: [PATCH] Add parse2d section. --- README.md | 12 ++++ parse2d/README.md | 135 ++++++++++++++++++++++++++++++++++++++++ parse2d/demo_parse2d.py | 49 +++++++++++++++ parse2d/parse2d.py | 72 +++++++++++++++++++++ 4 files changed, 268 insertions(+) create mode 100644 parse2d/README.md create mode 100644 parse2d/demo_parse2d.py create mode 100644 parse2d/parse2d.py diff --git a/README.md b/README.md index ee68311..2fa97fb 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ Please also see the [official examples](https://github.com/micropython/micropyth 4.14 [NTP time](./README.md#414-ntp-time) More portable than official driver with other benefits. 4.15 [Date](./README.md#415-date) Small and simple classes for handling dates. 4.16 [Greatest common divisor](./README.md#416-greatest-common-divisor) Neat algorithm. + 4.17 [2D array indexing](./README.md#417-2d-array-indexing) Use `[1:3, 20]` syntax to address a 2D array. 5. [Module Index](./README.md#5-module-index) Supported code. Device drivers, GUI's, utilities. 5.1 [uasyncio](./README.md#51-uasyncio) Tutorial and drivers for asynchronous coding. 5.2 [Memory Device Drivers](./README.md#52-memory-device-drivers) Drivers for nonvolatile memory devices. @@ -369,6 +370,17 @@ def gcd(a, b) : a, b = b, a % b return a ``` +## 4.17 2D array indexing + +This enables a class to be written that maps a 2D address onto an underlying 1D +addressable object such as an array, list or random access file. An instance +can then be accessed with syntax such as +```python +s = sum(obj[5, 0:20]) # Sum row 5, cols 0..19 (or x = 5, y = 0..19) +obj[10, 10] = 42 +obj[0:5, 3] = iter(range(100, 105)) +``` +See [the docs](./parse2d/README.md). ##### [Index](./README.md#0-index) diff --git a/parse2d/README.md b/parse2d/README.md new file mode 100644 index 0000000..7184237 --- /dev/null +++ b/parse2d/README.md @@ -0,0 +1,135 @@ +# Access a list or array as a 2D array + +The `parse2d` module provides a generator function `do_args`. This enables a +generator to be instantiated which maps one or two dimensional address args +onto index values. These can be used to address any object which can be +represented as a one dimensional array including arrays, bytearrays, lists or +random access files. The aim is to simplify writing classes that implement 2D +arrays of objects - specifically writing `__getitem__` and `__setitem__` +methods whose addressing modes conform with Python conventions. + +Addressing modes include slice objects; sets of elements can be accessed or +populated without explicit iteration. Thus, if `demo` is an instance of a user +defined class, the following access modes might be permitted: +```python +demo = MyClass(10, 10) # Create an object that can be viewed as 10 rows * 10 cols +demo[8, 8] = 42 # Populate a single cell +demo[0:, 0] = iter((66, 77, 88)) # Populate a column +demo[2:5, 2:5] = iter(range(50, 60)) # Populate a rectangular region. +print(sum(demo[0, 0:])) # Sum a row +for row, _ in enumerate(demo[0:, 0]): + print(*demo[row, 0:]) # Size- and shape-agnostic print +``` +The object can also be accessed as a 1D list. An application can mix and match +1D and 2D addressing as required: +```python +demo[-1] = 999 # Set last element +demo[-10: -1] = 7 # Set previous 9 elements +``` +The focus of this module is convenience, minimising iteration and avoiding +error-prone address calculations. It is not a high performance solution. The +module resulted from guidance provided in +[this discussion](https://github.com/orgs/micropython/discussions/11611). + +# The do_args generator function + +This takes the following args: + * `args` A 1- or 2-tuple. In the case of a 1-tuple (1D access) the element is + an int or slice object. In the 2-tuple (2D) case each element can be an int or + a slice. + * `nrows` No. of rows in the array. + * `ncols` No. of columns. + +This facilitates the design of `__getitem__` and `__setitem__`, e.g. +```python + def __getitem__(self, *args): + indices = do_args(args, self.nrows, self.ncols) + for i in indices: + yield self.cells[i] +``` +The generator is agnostic of the meaning of the first and second args: the +mathematical `[x, y]` or the graphics `[row, col]` conventions may be applied. +Index values are `row * ncols + col` or `x * ncols + y` as shown by the +following which must be run on CPython: +```python +>>> g = do_args(((1, slice(0, 9)),), 10, 10) +>>> for index in g: print(f"{index} ", end = "") +10 11 12 13 14 15 16 17 18 >>> +``` +Three argument slices are supported: +```python +>>> g = do_args(((1, slice(8, 0, -2)),), 10, 10) +>>> for index in g: print(f"{index} ", end = "") +... +18 16 14 12 >>> +``` +# Addressing + +The module aims to conform with Python rules. Thus, if `demo` is an instance of +a class representing a 10x10 array, +```python +print(demo[0, 10]) +``` +will produce an `IndexError: Index out of range`. By contrast +```python +print(demo[0, 0:100]) +``` +will print row 0 (columns 0-9)without error. This is analogous to Python's +behaviour with list addressing: +```python +>>> l = [0] * 10 +>>> l[10] +Traceback (most recent call last): + File "", line 1, in +IndexError: list index out of range +>>> l[0:100] +[0, 0, 0, 0, 0, 0, 0, 0, 0, 0] +>>> +``` +# Class design + +## __getitem__() + +RAM usage can be minimised by having this return an iterator. This enables +usage such as `sum(arr[0:, 1])` and minimises RAM allocation. + +## __setitem__() + +The semantics of the right hand side of assignment expressions is defined in +the user class. The following uses a `list` as the addressable object. +```python +from parse2d import do_args + +class MyIntegerArray: + def __init__(self, nrows, ncols): + self.nrows = nrows + self.ncols = ncols + self.cells = [0] * nrows * ncols + + def __getitem__(self, *args): + indices = do_args(args, self.nrows, self.ncols) + for i in indices: + yield self.cells[i] + + def __setitem__(self, *args): + value = args[1] + indices = do_args(args[: -1], self.nrows, self.ncols) + for i in indices: + self.cells[i] = value +``` +The `__setitem__` method is minimal. In a practical class `value` might be a +`list`, `tuple` or an object supporting the iterator protocol. + +# The int2D demo + +RHS semantics differ from Python `list` practice in that you can populate an +entire row with +```python +demo[0:, 0] = iter((66, 77, 88)) +``` +If the iterator runs out of data, the last item is repeated. Equally you could +have +```python +demo[0:, 0] = 4 +``` +The demo also illustrates the case where `__getitem__` returns an iterator. diff --git a/parse2d/demo_parse2d.py b/parse2d/demo_parse2d.py new file mode 100644 index 0000000..bf6aa09 --- /dev/null +++ b/parse2d/demo_parse2d.py @@ -0,0 +1,49 @@ +# demo_parse2d.py Parse args for item access dunder methods for a 2D array. + +# Released under the MIT License (MIT). See LICENSE. +# Copyright (c) 2023 Peter Hinch + +from parse2d import do_args + +class int2D: + def __init__(self, nrows, ncols): + self.nrows = nrows + self.ncols = ncols + self.cells = [0] * nrows * ncols + + def __getitem__(self, *args): + indices = do_args(args, self.nrows, self.ncols) + for i in indices: + yield self.cells[i] + + def __setitem__(self, *args): + x = args[1] # Value + indices = do_args(args[: -1], self.nrows, self.ncols) + for i in indices: + if isinstance(x, int): + self.cells[i] = x + else: + try: + z = next(x) # Will throw if not an iterator or generator + except StopIteration: + pass # Repeat last value + self.cells[i] = z + +demo = int2D(10, 10) +demo[0, 1:5] = iter(range(10, 20)) +print(next(demo[0, 0:])) +demo[0:, 0] = iter((66,77,88)) +print(next(demo[0:, 0])) +demo[8, 8] = 42 +#demo[8, 10] = 9999 # Index out of range +demo[2:5, 2:5] = iter(range(50, 60)) +for row in range(10): + print(*demo[row, 0:]) + + +# 1D addressing +# g = do_args((30,), 10, 10) # 30 +# g = do_args((slice(30, 34),), 10, 10) # 30 31 32 33 +# 2D addressing +# g = do_args(((1, slice(0, 9)),), 10, 10) # 10 11 12 ... 18 +# g = do_args(((1, 2),), 10, 10) # 10 diff --git a/parse2d/parse2d.py b/parse2d/parse2d.py new file mode 100644 index 0000000..f407181 --- /dev/null +++ b/parse2d/parse2d.py @@ -0,0 +1,72 @@ +# parse2d.py Parse args for item access dunder methods for a 2D array. + +# Released under the MIT License (MIT). See LICENSE. +# Copyright (c) 2023 Peter Hinch + + +# Called from __getitem__ or __setitem__ args is a 1-tuple. The single item may be an int or a +# slice for 1D access. Or it may be a 2-tuple for 2D access. Items in the 2-tuple may be ints +# or slices in any combination. +# As a generator it returns offsets into the underlying 1D array or list. +def do_args(args, nrows, ncols): + # Given a slice and a maximum address return start and stop addresses (or None on error) + # Step value must be 1, hence does not support start > stop (used with step < 0) + def do_slice(sli, nbytes): + step = sli.step if sli.step is not None else 1 + start = sli.start if sli.start is not None else 0 + stop = sli.stop if sli.stop is not None else nbytes + start = min(start if start >= 0 else max(nbytes + start, 0), nbytes) + stop = min(stop if stop >= 0 else max(nbytes + stop, 0), nbytes) + ok = (start < stop and step > 0) or (start > stop and step < 0) + return (start, stop, step) if ok else None # Caller should check + + def ivalid(n, nmax): # Validate an integer arg, handle -ve args + n = n if n >= 0 else nmax + n + if n < 0 or n > nmax - 1: + raise IndexError("Index out of range") + return n + + def fail(n): + raise IndexError("Invalid index", n) + + ncells = nrows * ncols + n = args[0] + if isinstance(n, int): # Index into 1D array + yield ivalid(n, ncells) + elif isinstance(n, slice): # Slice of 1D array + cells = do_slice(n, ncells) + if cells is not None: + for cell in range(*cells): + yield cell + elif isinstance(n, tuple): # or isinstance(n, list) old versions of grid + if len(n) != 2: + fail(n) + row = n[0] # May be slice + if isinstance(row, int): + row = ivalid(row, nrows) + col = n[1] + if isinstance(col, int): + col = ivalid(col, ncols) + if isinstance(row, int) and isinstance(col, int): + yield row * ncols + col + elif isinstance(row, slice) and isinstance(col, int): + rows = do_slice(row, nrows) + if rows is not None: + for row in range(*rows): + yield row * ncols + col + elif isinstance(row, int) and isinstance(col, slice): + cols = do_slice(col, ncols) + if cols is not None: + for col in range(*cols): + yield row * ncols + col + elif isinstance(row, slice) and isinstance(col, slice): + rows = do_slice(row, nrows) + cols = do_slice(col, ncols) + if cols is not None and rows is not None: + for row in range(*rows): + for col in range(*cols): + yield row * ncols + col + else: + fail(n) + else: + fail(n)