From cc64abcafeb011075d92f7a8be611cdfcfc95169 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 03:16:52 +0000 Subject: [PATCH] BlockLayout2D.to_dense: require floats when the layout has holes Holes are filled with NaN, which bool/str silently mangled and integers rejected with a conversion error. Sparse layouts now raise a clear ValueError for non-floating arrays; full layouts keep any dtype. Co-Authored-By: Claude Opus 5.5 --- src/CelerisLab/lbm/blocks.py | 10 +++++++--- tests/unit/test_block_layout.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/CelerisLab/lbm/blocks.py b/src/CelerisLab/lbm/blocks.py index a1aa588..f0fab2c 100644 --- a/src/CelerisLab/lbm/blocks.py +++ b/src/CelerisLab/lbm/blocks.py @@ -319,14 +319,18 @@ class BlockLayout2D: return blocked def to_dense(self, blocked): - """Unpack owner cores only; halo contents are ignored, holes are NaN.""" + """Unpack owner cores only; halo contents are ignored, holes are NaN. + + Full layouts accept any dtype; layouts with holes require floats. + """ blocked = self._check_blocked(blocked) flat = blocked.reshape(self.nblocks, NQ, -1) owned = self.owned.ravel() + if not owned.all() and not np.issubdtype(blocked.dtype, np.floating): + raise ValueError("a layout with holes unpacks floating arrays only (holes are NaN)") ob, oc = np.divmod(self.owner.ravel()[owned].astype(np.intp), flat.shape[2]) dense = np.empty((NQ, owned.size), dtype=blocked.dtype) - if not owned.all(): # full layouts keep any dtype; holes need a float NaN - dense[:, ~owned] = np.nan + dense[:, ~owned] = np.nan dense[:, owned] = flat[ob, :, oc].T return dense.reshape(NQ, *self.shape) diff --git a/tests/unit/test_block_layout.py b/tests/unit/test_block_layout.py index 9599c08..282931c 100644 --- a/tests/unit/test_block_layout.py +++ b/tests/unit/test_block_layout.py @@ -422,6 +422,18 @@ class SparseBlockLayoutTests(unittest.TestCase): self.assertEqual(layout.block_cell(0, 0, 0), address) self.assertEqual(address, layout.owner[0, 0]) + def test_to_dense_dtype_contract(self): + full = BlockLayout2D.build(6, 8, 3, 4) + blocked = np.arange(np.prod(full.blocked_shape), dtype=np.int64).reshape(full.blocked_shape) + dense = full.to_dense(blocked) # legacy: full layouts keep any dtype + self.assertEqual(dense.dtype, np.int64) + np.testing.assert_array_equal(dense, full.to_dense(blocked.astype(np.float64))) + sparse = BlockLayout2D.build(6, 8, 3, 4, origins=[(0, 0), (3, 4)]) + for dtype in (np.int64, np.uint16, np.bool_): + with self.subTest(dtype=dtype), self.assertRaises(ValueError): + sparse.to_dense(np.zeros(sparse.blocked_shape, dtype=dtype)) + self.assertEqual(sparse.to_dense(np.zeros(sparse.blocked_shape, np.float32)).dtype, np.float32) + def test_invalid_origins_rejected(self): for origins in ([(0, 4), (0, 0)], # not row-major [(3, 0), (0, 4)], # not row-major across rows