Skip to content

Commit 0711406

Browse files
committed
feat: make _data the single source of truth for band storage
Scope cut from the original Cell-decoupling PR. Cell stays an Agent; only the backend array storage changes ship here. - Remove dual-write loops in set_band/get_band/apply_raster/get_raster - Remove per-cell delattr loop in remove_band - Add __getattr__/__setattr__ proxy so cell.<band> reads/writes _data - Wire cell._layer via _layer= kwarg in _initialize_cells - Add grid_pos property (open for maintainer review) - Add regression test for Agent-init and proxy and pos interaction
1 parent 16ad316 commit 0711406

2 files changed

Lines changed: 82 additions & 38 deletions

File tree

mesa_geo/raster_layers.py

Lines changed: 48 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,10 @@ class Cell(Agent):
229229
"""
230230
Cells are containers of raster attributes, and are building blocks of `RasterLayer`.
231231
232+
Band data is stored in the parent RasterLayer's ``_data`` arrays.
233+
Cell proxies attribute access into those arrays via ``__getattr__``
234+
and ``__setattr__``.
235+
232236
Deprecated:
233237
`Cell.indices` is deprecated. Use `Cell.rowcol` instead.
234238
"""
@@ -245,6 +249,7 @@ def __init__(
245249
*,
246250
rowcol=None,
247251
xy=None,
252+
_layer=None,
248253
):
249254
"""
250255
Initialize a cell.
@@ -256,13 +261,40 @@ def __init__(
256261
:param rowcol: Indices of the cell in (row, col) format.
257262
Origin is at upper left corner of the grid
258263
:param xy: Geographic/projected (x, y) coordinates of the cell center in the CRS.
264+
:param _layer: The parent RasterLayer for band proxy access (internal use).
259265
"""
266+
self._layer = _layer
260267

261268
super().__init__(model)
262269
self._pos = pos
263270
self._rowcol = indices if rowcol is None else rowcol
264271
self._xy = xy
265272

273+
def __getattr__(self, name: str):
274+
try:
275+
layer = object.__getattribute__(self, "_layer")
276+
except AttributeError:
277+
raise AttributeError(f"No attribute '{name}'") from None
278+
if layer is not None and name in layer._data:
279+
row, col = object.__getattribute__(self, "_rowcol")
280+
return layer._data[name][row, col]
281+
raise AttributeError(f"No band '{name}' on this layer")
282+
283+
def __setattr__(self, name: str, value):
284+
if name.startswith("_"):
285+
object.__setattr__(self, name, value)
286+
return
287+
try:
288+
layer = object.__getattribute__(self, "_layer")
289+
except AttributeError:
290+
object.__setattr__(self, name, value)
291+
return
292+
if layer is not None and name in layer._data:
293+
row, col = object.__getattribute__(self, "_rowcol")
294+
layer._data[name][row, col] = value
295+
return
296+
object.__setattr__(self, name, value)
297+
266298
@property
267299
def pos(self) -> Coordinate | None:
268300
"""
@@ -275,17 +307,13 @@ def pos(self, pos: Coordinate | None) -> None:
275307
"""
276308
Deprecated setter for `pos`.
277309
"""
278-
# mesa Agent set pos to None by default
279-
# avoid raising a warning when pos is set to None by the Agent constructor
280310
if pos is not None:
281311
warnings.warn(
282312
"Cell.pos setter is deprecated and will be read-only in a future release.",
283313
DeprecationWarning,
284314
stacklevel=2,
285315
)
286316

287-
# set the pos for backward compatibility
288-
# in the future, this will be removed because pos is read-only
289317
self._pos = pos
290318

291319
@property
@@ -334,6 +362,18 @@ def xy(self) -> FloatCoordinate | None:
334362
def step(self):
335363
pass
336364

365+
@property
366+
def grid_pos(self) -> Coordinate:
367+
"""
368+
Grid position in (grid_x, grid_y) format with origin at lower left.
369+
Preferred replacement for the deprecated ``pos`` property.
370+
"""
371+
row, col = self._rowcol
372+
grid_x = col
373+
grid_y = self._layer.height - row - 1
374+
return grid_x, grid_y
375+
376+
337377

338378
class RasterLayer(RasterBase):
339379
"""
@@ -407,25 +447,22 @@ def _initialize_cells(self) -> None:
407447
if supports_legacy_pos_indices:
408448

409449
def make_cell(grid_x: int, grid_y: int, row_idx: int, col_idx: int, xy):
410-
# Backward-compatible path for legacy signature:
411-
# __init__(self, model, pos=None, indices=None, ...)
412450
cell = self.cell_cls(
413451
self.model,
414452
pos=(grid_x, grid_y),
415453
indices=(row_idx, col_idx),
454+
_layer=self,
416455
)
417-
# Legacy constructor path does not accept xy; set it manually.
418456
cell._xy = xy
419457
return cell
420458
else:
421-
# New constructor path: __init__(self, model, pos=None, rowcol=None, xy=None, ...)
422-
# or: __init__(self, model, **kwargs)
423459
def make_cell(grid_x: int, grid_y: int, row_idx: int, col_idx: int, xy):
424460
return self.cell_cls(
425461
self.model,
426462
pos=(grid_x, grid_y),
427463
rowcol=(row_idx, col_idx),
428464
xy=xy,
465+
_layer=self,
429466
)
430467

431468
self.cells = []
@@ -539,13 +576,6 @@ def set_band(self, name: str, data: np.ndarray | float = 0.0) -> None:
539576
else:
540577
self._data[name] = np.full((self.height, self.width), data)
541578
self._attributes.add(name)
542-
for grid_x in range(self.width):
543-
for grid_y in range(self.height):
544-
setattr(
545-
self.cells[grid_x][grid_y],
546-
name,
547-
self._data[name][self.height - grid_y - 1, grid_x],
548-
)
549579

550580
def get_band(self, name: str) -> np.ndarray:
551581
"""
@@ -560,13 +590,7 @@ def get_band(self, name: str) -> np.ndarray:
560590
raise ValueError(
561591
f"Band '{name}' does not exist. Choose from {self._attributes}."
562592
)
563-
data = np.empty((self.height, self.width))
564-
for grid_x in range(self.width):
565-
for grid_y in range(self.height):
566-
data[self.height - grid_y - 1, grid_x] = getattr(
567-
self.cells[grid_x][grid_y], name
568-
)
569-
return data
593+
return self._data[name].copy()
570594

571595
def remove_band(self, name: str) -> None:
572596
"""
@@ -579,9 +603,6 @@ def remove_band(self, name: str) -> None:
579603
raise ValueError(f"Band '{name}' does not exist.")
580604
del self._data[name]
581605
self._attributes.discard(name)
582-
for column in self.cells:
583-
for cell in column:
584-
delattr(cell, name)
585606

586607
def apply_raster(
587608
self, data: np.ndarray, attr_name: str | Sequence[str] | None = None
@@ -642,13 +663,6 @@ def _default_attr_name() -> str:
642663
attr = _default_attr_name() if name is None else name
643664
self._attributes.add(attr)
644665
self._data[attr] = data[band_idx].copy()
645-
for grid_x in range(self.width):
646-
for grid_y in range(self.height):
647-
setattr(
648-
self.cells[grid_x][grid_y],
649-
attr,
650-
data[band_idx, self.height - grid_y - 1, grid_x],
651-
)
652666

653667
def get_raster(self, attr_name: str | Sequence[str] | None = None) -> np.ndarray:
654668
"""
@@ -684,11 +698,7 @@ def get_raster(self, attr_name: str | Sequence[str] | None = None) -> np.ndarray
684698
attr_names = [attr_name]
685699
data = np.empty((num_bands, self.height, self.width))
686700
for ind, name in enumerate(attr_names):
687-
for grid_x in range(self.width):
688-
for grid_y in range(self.height):
689-
data[ind, self.height - grid_y - 1, grid_x] = getattr(
690-
self.cells[grid_x][grid_y], name
691-
)
701+
data[ind] = self._data[name]
692702
return data
693703

694704
def get_random_xy(

tests/test_RasterLayer.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import mesa
77
import numpy as np
88
import rasterio as rio
9+
from mesa.agent import Agent
910

1011
import mesa_geo as mg
1112

@@ -558,3 +559,36 @@ def test_from_file_multiband_attr_name_none(self):
558559
for idx in range(data.shape[0])
559560
)
560561
)
562+
563+
# ------------------------------------------------------------------
564+
# Regression: Agent.__init__ and __setattr__ proxy and pos interaction
565+
# ------------------------------------------------------------------
566+
def test_agent_init_proxy_pos_interaction(self):
567+
"""Verify that Cell (as Agent subclass) constructs cleanly,
568+
band proxy reads/writes through _data, and pos property still
569+
behaves after Agent.__init__ sets self.pos = None."""
570+
layer = self.raster_layer
571+
572+
# Apply a known band
573+
band_data = np.arange(layer.height * layer.width, dtype=float).reshape(
574+
layer.height, layer.width
575+
)
576+
layer.apply_raster(band_data[np.newaxis, :, :], attr_name="elevation")
577+
578+
# Pick a cell and read band value via proxy
579+
cell = layer.cells[0][0] # grid_x=0, grid_y=0
580+
row, col = cell.rowcol
581+
expected_val = band_data[row, col]
582+
self.assertEqual(cell.elevation, expected_val)
583+
584+
# Write band value via proxy and confirm it lands in _data
585+
cell.elevation = 999.0
586+
self.assertEqual(layer._data["elevation"][row, col], 999.0)
587+
self.assertEqual(cell.elevation, 999.0)
588+
589+
# pos should still work (property, not routed through proxy)
590+
self.assertIsNotNone(cell.pos)
591+
self.assertEqual(cell.pos, (0, 0))
592+
593+
# Cell is an Agent
594+
self.assertIsInstance(cell, Agent)

0 commit comments

Comments
 (0)