From b9485518d3c07c5f14d649ac88c88416056655ae Mon Sep 17 00:00:00 2001 From: Stephen Roberts Date: Fri, 18 Sep 2026 11:05:00 +1000 Subject: [PATCH 1/2] Make the storage-type masks writable copies for pandas 3 (Copy-on-Write) Under pandas 3.0, where Copy-on-Write is the default, the boolean masks built from `(_storage_type.str.lower() == 'functional').values` are read-only views. The storage-area/volume kernels are compiled eagerly with a `boolean[:]` signature, which numba treats as writable-only, so SuperLink construction fails with "No matching definition ... readonly array(bool, 1d, C)". Copy the masks with np.array(), which is accepted under pandas 2 and 3 and keeps the kernels' eager signatures. Fixes mdbartos/pipedream#74 (cherry picked from commit e8fd8546725be9b9112957622937bf4c9e2618fb) --- pipedream_solver/nsuperlink.py | 7 +++++-- pipedream_solver/superlink.py | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/pipedream_solver/nsuperlink.py b/pipedream_solver/nsuperlink.py index 107355e..e8c9e88 100644 --- a/pipedream_solver/nsuperlink.py +++ b/pipedream_solver/nsuperlink.py @@ -428,8 +428,11 @@ def configure_storages(self): _storage_js = np.array([]) _storage_codes = np.array([]) # Separate storages into functional and tabular - _functional = (_storage_type.str.lower() == 'functional').values - _tabular = (_storage_type.str.lower() == 'tabular').values + # np.array(...) copies: under pandas >= 3 (Copy-on-Write) .values is a + # read-only view, which the eagerly-compiled numba kernels (boolean[:]) + # refuse to accept. A writable copy is accepted under pandas 2 and 3. + _functional = np.array((_storage_type.str.lower() == 'functional').values) + _tabular = np.array((_storage_type.str.lower() == 'tabular').values) # All entries must either be function or tabular assert (_tabular.sum() + _functional.sum()) == _storage_type.shape[0] # Configure tabular storages diff --git a/pipedream_solver/superlink.py b/pipedream_solver/superlink.py index 2abcd7a..f7fe26a 100644 --- a/pipedream_solver/superlink.py +++ b/pipedream_solver/superlink.py @@ -1702,8 +1702,11 @@ def configure_storages(self): _storage_factory = {} _storage_indices = None # Separate storages into functional and tabular - _functional = (_storage_type.str.lower() == 'functional').values - _tabular = (_storage_type.str.lower() == 'tabular').values + # np.array(...) copies: under pandas >= 3 (Copy-on-Write) .values is a + # read-only view, which the eagerly-compiled numba kernels (boolean[:]) + # refuse to accept. A writable copy is accepted under pandas 2 and 3. + _functional = np.array((_storage_type.str.lower() == 'functional').values) + _tabular = np.array((_storage_type.str.lower() == 'tabular').values) # All entries must either be function or tabular assert (_tabular.sum() + _functional.sum()) == _storage_type.shape[0] # Configure tabular storages From 585434fe9e903608f22726017635c72248b06814 Mon Sep 17 00:00:00 2001 From: Stephen Roberts Date: Fri, 18 Sep 2026 11:08:07 +1000 Subject: [PATCH 2/2] Copy the remaining DataFrame-derived arrays that reach numba kernels `_Sf_method_ik` was built as `Series.astype(np.int64).values`, so unlike the `.values.astype(...)` pattern used everywhere else it was never copied; under pandas 3 (Copy-on-Write) it is a read-only view and numba_b_ik, compiled with an eager `int64[:]` argument, rejects it during SuperLink.__init__. Copy it with np.array(). The same treatment for the geometry/transect/storage index arrays taken with `.loc[...].values` and for the tabular-storage index arrays, which reach kernels on code paths the basic examples do not exercise. Refs mdbartos/pipedream#74 (cherry picked from commit 8294e6da836f05e918829db009f60168531cbb0d) --- pipedream_solver/nsuperlink.py | 4 ++-- pipedream_solver/superlink.py | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pipedream_solver/nsuperlink.py b/pipedream_solver/nsuperlink.py index e8c9e88..adb4c78 100644 --- a/pipedream_solver/nsuperlink.py +++ b/pipedream_solver/nsuperlink.py @@ -467,8 +467,8 @@ def configure_storages(self): _storage_Vs = np.concatenate([_storage_Vs[i] for i in order]) _storage_inds = np.asarray(_storage_inds)[order] _storage_lens = np.asarray(_storage_lens)[order] - _storage_js = sj_to_storage_ind.index.values - _storage_codes = sj_to_storage_ind.values + _storage_js = np.array(sj_to_storage_ind.index.values) + _storage_codes = np.array(sj_to_storage_ind.values) # Export instance variables self._storage_indices = _storage_indices self._storage_factory = _storage_factory diff --git a/pipedream_solver/superlink.py b/pipedream_solver/superlink.py index f7fe26a..cfe0499 100644 --- a/pipedream_solver/superlink.py +++ b/pipedream_solver/superlink.py @@ -457,7 +457,7 @@ def __init__(self, superlinks, superjunctions, assert _Sf_method.isin(Sf_map).all() except: raise ValueError('Friction method must be one of cm, hw, dw.') - self._Sf_method_ik = _Sf_method.map(Sf_map).astype(np.int64).values + self._Sf_method_ik = np.array(_Sf_method.map(Sf_map).astype(np.int64).values) if 'roughness' in links: self._n_ik = links['roughness'].values.astype(np.float64) else: @@ -1050,14 +1050,14 @@ def _configure_internals_variable(self, internal_links=4, mobile_elements=True): x = np.zeros(NJ) z = np.zeros(NJ) dx = np.zeros(NL) - h = junctions['h_0'].values - Q = links['Q_0'].values + h = np.array(junctions['h_0'].values) + Q = np.array(links['Q_0'].values) xx = x.reshape(-1, njunctions) zz = z.reshape(-1, njunctions) dxdx = dx.reshape(-1, nlinks) hh = h.reshape(-1, njunctions) QQ = Q.reshape(-1, nlinks) - dx_j = superlinks['dx'].values + dx_j = np.array(superlinks['dx'].values) _z_inv_uk = self._z_inv_uk _z_inv_dk = self._z_inv_dk _dx_uk = self._dx_uk @@ -1630,7 +1630,7 @@ def configure_hydraulic_geometry(self): _geom_indices = pd.Series(_regular_shapes.index, index=_regular_shapes.str.lower().values) for geom in _unique_geom: - _ik_g = _geom_indices.loc[[geom]].values + _ik_g = np.array(_geom_indices.loc[[geom]].values) _geom_factory[geom] = _ik_g _uk_geom_factory[geom] = _ik_g[_link_start[_ik_g]] _dk_geom_factory[geom] = _ik_g[_link_end[_ik_g]] @@ -1656,7 +1656,7 @@ def configure_hydraulic_geometry(self): _unique_geom_o = set(_shape_o.str.lower().unique()) _geom_indices_o = pd.Series(_shape_o.index, index=_shape_o.str.lower().values) for geom in _unique_geom_o: - _o_g = _geom_indices_o.loc[[geom]].values + _o_g = np.array(_geom_indices_o.loc[[geom]].values) _geom_factory_o[geom] = _o_g _geom_codes_o = np.zeros(n_o, dtype=np.int64) for geom, indices in _geom_factory_o.items(): @@ -1765,7 +1765,7 @@ def link_hydraulic_geometry(self): # Compute hydraulic geometry for irregular geometries if _has_irregular: for transect_name, generator in _transect_factory.items(): - _ik_g = _transect_indices.loc[[transect_name]].values + _ik_g = np.array(_transect_indices.loc[[transect_name]].values) _Ik_g = _Ik[_ik_g] _Ip1k_g = _Ip1k[_ik_g] _h_Ik_g = _h_Ik[_Ik_g] @@ -1828,7 +1828,7 @@ def upstream_hydraulic_geometry(self, area='avg'): # Compute hydraulic geometry for irregular geometries if _uk_has_irregular: for transect_name, generator in _transect_factory.items(): - _ik_g = _uk_transect_indices.loc[[transect_name]].values + _ik_g = np.array(_uk_transect_indices.loc[[transect_name]].values) _ki_g = _ki[_ik_g] _Ik_g = _Ik[_ik_g] _h_Ik_g = _h_Ik[_Ik_g] @@ -1887,7 +1887,7 @@ def downstream_hydraulic_geometry(self, area='avg'): # Compute hydraulic geometry for irregular geometries if _dk_has_irregular: for transect_name, generator in _transect_factory.items(): - _ik_g = _dk_transect_indices.loc[[transect_name]].values + _ik_g = np.array(_dk_transect_indices.loc[[transect_name]].values) _ki_g = _ki[_ik_g] _Ip1k_g = _Ip1k[_ik_g] _h_Ip1k_g = _h_Ik[_Ip1k_g] @@ -1960,7 +1960,7 @@ def compute_storage_areas(self): _storage_c[_functional]) if _tabular.any(): for storage_name, generator in _storage_factory.items(): - _j_g = _storage_indices.loc[[storage_name]].values + _j_g = np.array(_storage_indices.loc[[storage_name]].values) _A_sj[_j_g] = generator.A_sj(_h_j[_j_g]) # Export instance variables self._A_sj = _A_sj @@ -1991,7 +1991,7 @@ def compute_storage_volumes(self): _storage_c[_functional]) if _tabular.any(): for storage_name, generator in _storage_factory.items(): - _j_g = _storage_indices.loc[[storage_name]].values + _j_g = np.array(_storage_indices.loc[[storage_name]].values) _V_sj[_j_g] = generator.V_sj(_h_j[_j_g]) # Export instance variables self._V_sj = _V_sj