Source code for modelly.space.grid.layers

"""Property layers: numpy arrays attached to a grid and exposed on its cells.

A property layer is one ``np.ndarray`` shaped like the grid. The grid exposes it
as an attribute (``grid.sugar``) and every cell of that grid gets an accessor
(``cell.sugar``) that reads and writes the array at the cell's coordinate, so
vectorised updates on the array and per-cell reads stay in sync for free.
"""

from __future__ import annotations

from itertools import chain

import numpy as np

EMPTY_LAYER = "empty"


[docs] class HasPropertyLayers: """Mixin giving a grid named property layers backed by numpy arrays. Expects the host to define ``dimensions`` (the array shape), ``cell_klass`` (a class private to the grid, since accessors are installed on it), ``property_layers`` and ``_read_only_layers``. """ dimensions: tuple[int, ...] cell_klass: type property_layers: dict[str, np.ndarray] _read_only_layers: set[str]
[docs] def create_property_layer( self, name: str, default_value=0.0, dtype=float, read_only: bool = False, ) -> np.ndarray: """Create a property layer array and attach it to cells. Warning: Do not reassign `grid.name` directly — this will detach it from `property_layers` and break cell-level access. Use in-place operations. grid.sugar[:] = 0 # correct grid.sugar = array # wrong — breaks cell access """ array = np.full(self.dimensions, default_value, dtype=dtype) self._attach_property_layer(name, array, read_only=read_only) return array
[docs] def add_property_layer( self, name: str, array: np.ndarray, read_only: bool = False ) -> None: """Attach an existing array as a property layer. Shape must match `self.dimensions`. Warning: Do not reassign `grid.name` directly — this will detach it from `property_layers` and break cell-level access. Use in-place operations. grid.sugar[:] = 0 # correct grid.sugar = array # wrong — breaks cell access """ if tuple(array.shape) != tuple(self.dimensions): raise ValueError( f"Array shape {array.shape} does not match grid dimensions {self.dimensions}." ) self._attach_property_layer(name, array, read_only=read_only)
[docs] def remove_property_layer(self, name: str) -> None: """Remove a property_layer and its grid and cell attributes. Args: name: property_layer name. """ if name not in self.property_layers: raise KeyError(f"No property_layer named '{name}'.") del self.property_layers[name] delattr(self, name) delattr(self.cell_klass, name) self.cell_klass.property_layers.discard(name) self._read_only_layers.discard(name)
def _attach_property_layer( self, name: str, array: np.ndarray, read_only: bool = False ) -> None: self._check_layer_name(name) self.property_layers[name] = array setattr(self, name, array) self._install_layer_accessor(name, array, read_only) self.cell_klass.property_layers.add(name) if read_only: self._read_only_layers.add(name) def _check_layer_name(self, name: str) -> None: """Reject a layer name that already exists or would shadow an attribute.""" if name in self.property_layers: raise ValueError(f"property_layer '{name}' already exists.") if name in self.__dict__ or any(name in c.__dict__ for c in type(self).__mro__): raise ValueError( f"property_layer '{name}' conflicts with an existing Grid attribute." ) # Attaching a layer installs an accessor on cell_klass via setattr, so any # name already defined on that class would be silently shadowed. Walking # __mro__ __dict__ covers @property accessors, methods, plain class vars, # and __slots__ members alike (a slot is a member_descriptor living in the # class __dict__). "empty" is exempt: Grid.__init__ itself installs it as # a property layer backing Cell.empty. cell_attrs = set( chain.from_iterable(c.__dict__ for c in self.cell_klass.__mro__) ) if name != EMPTY_LAYER and name in cell_attrs: raise ValueError( f"property_layer '{name}' conflicts with an existing " f"{self.cell_klass.__name__} attribute." ) def _install_layer_accessor( self, name: str, array: np.ndarray, read_only: bool ) -> None: """Install ``cell.<name>`` on the grid's cell class, reading/writing ``array``.""" def getter(self_cell): return array[self_cell.coordinate] def setter(self_cell, value): array[self_cell.coordinate] = value accessor = ( property(getter, doc=f"property_layer '{name}'") if read_only else property(getter, setter, doc=f"property_layer '{name}'") ) setattr(self.cell_klass, name, accessor) def _restore_layer_accessors(self) -> None: """Re-install every layer accessor after unpickling. Read-only layers (tracked in ``_read_only_layers``) are restored without a setter so the read-only constraint survives the round trip. """ # Older pickles may predate _read_only_layers; default to no read-only layers. self._read_only_layers = getattr(self, "_read_only_layers", set()) for name, array in self.property_layers.items(): self._install_layer_accessor(name, array, name in self._read_only_layers)