From c24158403d3d571c494cb5aef12a40fa33df7f0e Mon Sep 17 00:00:00 2001 From: spital <11034264+spital@users.noreply.github.com> Date: Wed, 27 May 2026 09:07:55 +0200 Subject: [PATCH 1/2] Add biadjacency matrix helpers Add constructors and matrix export functions for dense NumPy biadjacency matrices on PyGraph and PyDiGraph. The constructors create row nodes before column nodes, and the export functions use explicit row and column node-index orders. Also add type stubs, API documentation, a release note, and tests for construction, null values, validation, directed edge direction, and parallel-edge reductions. Fixes #1412 --- docs/source/api/pydigraph_api_functions.rst | 1 + docs/source/api/pygraph_api_functions.rst | 1 + .../notes/add-biadjacency-matrix-1412.yaml | 8 + rustworkx/__init__.pyi | 2 + rustworkx/rustworkx.pyi | 28 ++ src/connectivity/mod.rs | 245 ++++++++++++++++++ src/digraph.rs | 73 ++++++ src/graph.rs | 71 +++++ src/lib.rs | 2 + tests/digraph/test_adjacency_matrix.py | 150 +++++++++++ tests/graph/test_adjacency_matrix.py | 148 +++++++++++ 11 files changed, 729 insertions(+) create mode 100644 releasenotes/notes/add-biadjacency-matrix-1412.yaml diff --git a/docs/source/api/pydigraph_api_functions.rst b/docs/source/api/pydigraph_api_functions.rst index 2753c4262a..3775a76d19 100644 --- a/docs/source/api/pydigraph_api_functions.rst +++ b/docs/source/api/pydigraph_api_functions.rst @@ -19,6 +19,7 @@ the functions from the explicitly typed based on the data type. rustworkx.digraph_floyd_warshall_numpy rustworkx.digraph_floyd_warshall_successor_and_distance rustworkx.digraph_adjacency_matrix + rustworkx.digraph_biadjacency_matrix rustworkx.digraph_all_simple_paths rustworkx.digraph_all_pairs_all_simple_paths rustworkx.digraph_astar_shortest_path diff --git a/docs/source/api/pygraph_api_functions.rst b/docs/source/api/pygraph_api_functions.rst index c1e81d8cc9..362daf9a5c 100644 --- a/docs/source/api/pygraph_api_functions.rst +++ b/docs/source/api/pygraph_api_functions.rst @@ -19,6 +19,7 @@ typed API based on the data type. rustworkx.graph_floyd_warshall_numpy rustworkx.graph_floyd_warshall_successor_and_distance rustworkx.graph_adjacency_matrix + rustworkx.graph_biadjacency_matrix rustworkx.graph_all_simple_paths rustworkx.graph_all_pairs_all_simple_paths rustworkx.graph_astar_shortest_path diff --git a/releasenotes/notes/add-biadjacency-matrix-1412.yaml b/releasenotes/notes/add-biadjacency-matrix-1412.yaml new file mode 100644 index 0000000000..8b1f5c73c4 --- /dev/null +++ b/releasenotes/notes/add-biadjacency-matrix-1412.yaml @@ -0,0 +1,8 @@ +--- +features: + - | + Added :meth:`~rustworkx.PyGraph.from_biadjacency_matrix`, + :meth:`~rustworkx.PyDiGraph.from_biadjacency_matrix`, + :func:`~rustworkx.graph_biadjacency_matrix`, and + :func:`~rustworkx.digraph_biadjacency_matrix` for dense NumPy + biadjacency matrix conversion. diff --git a/rustworkx/__init__.pyi b/rustworkx/__init__.pyi index bdd554d8cd..0f9ccf4738 100644 --- a/rustworkx/__init__.pyi +++ b/rustworkx/__init__.pyi @@ -100,6 +100,8 @@ from .rustworkx import graph_condensation as graph_condensation from .rustworkx import weakly_connected_components as weakly_connected_components from .rustworkx import digraph_adjacency_matrix as digraph_adjacency_matrix from .rustworkx import graph_adjacency_matrix as graph_adjacency_matrix +from .rustworkx import digraph_biadjacency_matrix as digraph_biadjacency_matrix +from .rustworkx import graph_biadjacency_matrix as graph_biadjacency_matrix from .rustworkx import cycle_basis as cycle_basis from .rustworkx import articulation_points as articulation_points from .rustworkx import bridges as bridges diff --git a/rustworkx/rustworkx.pyi b/rustworkx/rustworkx.pyi index 0df0ee667d..8ecb93a49b 100644 --- a/rustworkx/rustworkx.pyi +++ b/rustworkx/rustworkx.pyi @@ -298,6 +298,26 @@ def graph_adjacency_matrix( parallel_edge: str = ..., node_list: Sequence[int] | None = ..., ) -> npt.NDArray[np.float64]: ... +def digraph_biadjacency_matrix( + graph: PyDiGraph[_S, _T], + row_order: Sequence[int], + column_order: Sequence[int], + /, + weight_fn: Callable[[_T], float] | None = ..., + default_weight: float = ..., + null_value: float = ..., + parallel_edge: str = ..., +) -> npt.NDArray[np.float64]: ... +def graph_biadjacency_matrix( + graph: PyGraph[_S, _T], + row_order: Sequence[int], + column_order: Sequence[int], + /, + weight_fn: Callable[[_T], float] | None = ..., + default_weight: float = ..., + null_value: float = ..., + parallel_edge: str = ..., +) -> npt.NDArray[np.float64]: ... def cycle_basis(graph: PyGraph, /, root: int | None = ...) -> list[list[int]]: ... def articulation_points(graph: PyGraph, /) -> set[int]: ... def bridges(graph: PyGraph, /) -> set[tuple[int]]: ... @@ -1438,6 +1458,10 @@ class PyGraph(Generic[_S, _T]): matrix: npt.NDArray[np.float64], /, null_value: float = ... ) -> PyGraph[int, float]: ... @staticmethod + def from_biadjacency_matrix( + matrix: npt.NDArray[np.float64], /, null_value: float = ... + ) -> PyGraph[int, float]: ... + @staticmethod def from_complex_adjacency_matrix( matrix: npt.NDArray[np.complex64], /, null_value: complex = ... ) -> PyGraph[int, complex]: ... @@ -1622,6 +1646,10 @@ class PyDiGraph(Generic[_S, _T]): matrix: npt.NDArray[np.float64], /, null_value: float = ... ) -> PyDiGraph[int, float]: ... @staticmethod + def from_biadjacency_matrix( + matrix: npt.NDArray[np.float64], /, null_value: float = ... + ) -> PyDiGraph[int, float]: ... + @staticmethod def from_complex_adjacency_matrix( matrix: npt.NDArray[np.complex64], /, null_value: complex = ... ) -> PyDiGraph[int, complex]: ... diff --git a/src/connectivity/mod.rs b/src/connectivity/mod.rs index b6d05e1006..e55bf1b6c3 100644 --- a/src/connectivity/mod.rs +++ b/src/connectivity/mod.rs @@ -786,6 +786,178 @@ pub fn digraph_adjacency_matrix<'py>( Ok(matrix.into_pyarray(py)) } +type ParallelEdgeFn = fn(f64, f64, usize) -> f64; + +fn parallel_edge_fn(parallel_edge: &str) -> PyResult { + match parallel_edge { + "sum" => Ok(|current, edge_weight, _| current + edge_weight), + "min" => Ok(|current, edge_weight, _| current.min(edge_weight)), + "max" => Ok(|current, edge_weight, _| current.max(edge_weight)), + "avg" => Ok(|current, edge_weight, count| { + (current * count as f64 + edge_weight) / ((count + 1) as f64) + }), + _ => Err(PyValueError::new_err( + "Parallel edges can currently only be dealt with using \"sum\", \"min\", \"max\", or \"avg\".", + )), + } +} + +fn apply_biadjacency_edge( + matrix: &mut Array2, + parallel_edge_count: &mut HashMap<[usize; 2], usize>, + row: usize, + column: usize, + edge_weight: f64, + parallel_edge_fn: ParallelEdgeFn, +) { + let key = [row, column]; + let count = parallel_edge_count.entry(key).or_insert(0); + if *count == 0 { + matrix[[row, column]] = edge_weight; + } else { + matrix[[row, column]] = parallel_edge_fn(matrix[[row, column]], edge_weight, *count); + } + *count += 1; +} + +fn validate_biadjacency_node_order( + graph: &StablePyGraph, + row_order: &[usize], + column_order: &[usize], +) -> PyResult<()> { + let mut row_nodes = HashSet::new(); + for node in row_order { + if !row_nodes.insert(*node) { + return Err(PyValueError::new_err(format!( + "row_order contains duplicate node index {node}" + ))); + } + } + + let mut column_nodes = HashSet::new(); + for node in column_order { + if !column_nodes.insert(*node) { + return Err(PyValueError::new_err(format!( + "column_order contains duplicate node index {node}" + ))); + } + if row_nodes.contains(node) { + return Err(PyValueError::new_err(format!( + "row_order and column_order must be disjoint; node index {node} appears in both" + ))); + } + } + + for node in row_order.iter().chain(column_order.iter()) { + if !graph.contains_node(NodeIndex::new(*node)) { + return Err(PyValueError::new_err(format!( + "Node index {node} is not present in the graph" + ))); + } + } + Ok(()) +} + +fn biadjacency_node_map(node_order: &[usize]) -> HashMap { + node_order + .iter() + .enumerate() + .map(|(index, node)| (*node, index)) + .collect() +} + +fn graph_biadjacency_index( + source: usize, + target: usize, + row_map: &HashMap, + column_map: &HashMap, +) -> Option<(usize, usize)> { + row_map + .get(&source) + .and_then(|row| column_map.get(&target).map(|column| (*row, *column))) + .or_else(|| { + row_map + .get(&target) + .and_then(|row| column_map.get(&source).map(|column| (*row, *column))) + }) +} + +/// Return the biadjacency matrix for a PyDiGraph class +/// +/// This function returns a dense numpy array with rows and columns ordered +/// according to the explicit node index lists passed in. Only directed edges +/// from ``row_order`` nodes to ``column_order`` nodes are included. The row +/// and column orders must contain unique node indices and must be disjoint. +/// +/// In the case where there are multiple edges between nodes the value in the +/// output matrix will be assigned based on a given parameter. Currently, the minimum, maximum, average, and default sum are supported. +/// +/// :param PyDiGraph graph: The DiGraph used to generate the biadjacency matrix +/// from +/// :param list row_order: The node indices to use for the rows of the output +/// matrix. +/// :param list column_order: The node indices to use for the columns of the +/// output matrix. +/// :param callable weight_fn: A callable object (function, lambda, etc) which +/// will be passed the edge object and expected to return a ``float``. +/// :param float default_weight: If ``weight_fn`` is not used this can be +/// optionally used to specify a default weight to use for all edges. +/// :param float null_value: An optional float that will treated as a null +/// value. This is the default value in the output matrix and it is used +/// to indicate the absence of an edge between 2 nodes. By default this is +/// ``0.0``. +/// :param String parallel_edge: Optional argument that determines how the function handles parallel edges. +/// ``"min"`` causes the value in the output matrix to be the minimum of the edges' weights, and similar behavior can be expected for ``"max"`` and ``"avg"``. +/// The function defaults to ``"sum"`` behavior, where the value in the output matrix is the sum of all parallel edge weights. +/// +/// :return: The biadjacency matrix for the input directed graph as a numpy array +/// :rtype: numpy.ndarray +#[pyfunction] +#[pyo3( + signature=(graph, row_order, column_order, weight_fn=None, default_weight=1.0, null_value=0.0, parallel_edge="sum"), + text_signature = "(graph, row_order, column_order, /, weight_fn=None, default_weight=1.0, null_value=0.0, parallel_edge=\"sum\")" +)] +#[allow(clippy::too_many_arguments)] +pub fn digraph_biadjacency_matrix<'py>( + py: Python<'py>, + graph: &digraph::PyDiGraph, + row_order: Vec, + column_order: Vec, + weight_fn: Option>, + default_weight: f64, + null_value: f64, + parallel_edge: &str, +) -> PyResult>> { + validate_biadjacency_node_order(&graph.graph, &row_order, &column_order)?; + let parallel_edge_fn = parallel_edge_fn(parallel_edge)?; + let row_map = biadjacency_node_map(&row_order); + let column_map = biadjacency_node_map(&column_order); + let mut matrix = Array2::::from_elem((row_order.len(), column_order.len()), null_value); + let mut parallel_edge_count = HashMap::new(); + + let biadjacency_edges = graph.graph.edge_references().filter_map(|edge| { + let source = edge.source().index(); + let target = edge.target().index(); + row_map + .get(&source) + .and_then(|row| column_map.get(&target).map(|column| (*row, *column))) + .map(|(row, column)| (row, column, edge.weight())) + }); + + for (row, column, weight) in biadjacency_edges { + let edge_weight = weight_callable(py, &weight_fn, weight, default_weight)?; + apply_biadjacency_edge( + &mut matrix, + &mut parallel_edge_count, + row, + column, + edge_weight, + parallel_edge_fn, + ); + } + Ok(matrix.into_pyarray(py)) +} + /// Return the adjacency matrix for a PyGraph class /// /// In the case where there are multiple edges between nodes the value in the @@ -891,6 +1063,79 @@ pub fn graph_adjacency_matrix<'py>( Ok(matrix.into_pyarray(py)) } +/// Return the biadjacency matrix for a PyGraph class +/// +/// This function returns a dense numpy array with rows and columns ordered +/// according to the explicit node index lists passed in. Edges between +/// ``row_order`` nodes and ``column_order`` nodes are included. The row and +/// column orders must contain unique node indices and must be disjoint. +/// +/// In the case where there are multiple edges between nodes the value in the +/// output matrix will be assigned based on a given parameter. Currently, the minimum, maximum, average, and default sum are supported. +/// +/// :param PyGraph graph: The graph used to generate the biadjacency matrix from +/// :param list row_order: The node indices to use for the rows of the output +/// matrix. +/// :param list column_order: The node indices to use for the columns of the +/// output matrix. +/// :param callable weight_fn: A callable object (function, lambda, etc) which +/// will be passed the edge object and expected to return a ``float``. +/// :param float default_weight: If ``weight_fn`` is not used this can be +/// optionally used to specify a default weight to use for all edges. +/// :param float null_value: An optional float that will treated as a null +/// value. This is the default value in the output matrix and it is used +/// to indicate the absence of an edge between 2 nodes. By default this is +/// ``0.0``. +/// :param String parallel_edge: Optional argument that determines how the function handles parallel edges. +/// ``"min"`` causes the value in the output matrix to be the minimum of the edges' weights, and similar behavior can be expected for ``"max"`` and ``"avg"``. +/// The function defaults to ``"sum"`` behavior, where the value in the output matrix is the sum of all parallel edge weights. +/// +/// :return: The biadjacency matrix for the input graph as a numpy array +/// :rtype: numpy.ndarray +#[pyfunction] +#[pyo3( + signature=(graph, row_order, column_order, weight_fn=None, default_weight=1.0, null_value=0.0, parallel_edge="sum"), + text_signature = "(graph, row_order, column_order, /, weight_fn=None, default_weight=1.0, null_value=0.0, parallel_edge=\"sum\")" +)] +#[allow(clippy::too_many_arguments)] +pub fn graph_biadjacency_matrix<'py>( + py: Python<'py>, + graph: &graph::PyGraph, + row_order: Vec, + column_order: Vec, + weight_fn: Option>, + default_weight: f64, + null_value: f64, + parallel_edge: &str, +) -> PyResult>> { + validate_biadjacency_node_order(&graph.graph, &row_order, &column_order)?; + let parallel_edge_fn = parallel_edge_fn(parallel_edge)?; + let row_map = biadjacency_node_map(&row_order); + let column_map = biadjacency_node_map(&column_order); + let mut matrix = Array2::::from_elem((row_order.len(), column_order.len()), null_value); + let mut parallel_edge_count = HashMap::new(); + + let biadjacency_edges = graph.graph.edge_references().filter_map(|edge| { + let source = edge.source().index(); + let target = edge.target().index(); + graph_biadjacency_index(source, target, &row_map, &column_map) + .map(|(row, column)| (row, column, edge.weight())) + }); + + for (row, column, weight) in biadjacency_edges { + let edge_weight = weight_callable(py, &weight_fn, weight, default_weight)?; + apply_biadjacency_edge( + &mut matrix, + &mut parallel_edge_count, + row, + column, + edge_weight, + parallel_edge_fn, + ); + } + Ok(matrix.into_pyarray(py)) +} + /// Compute the complement of an undirected graph. /// /// :param PyGraph graph: The graph to be used. diff --git a/src/digraph.rs b/src/digraph.rs index f454dee2f9..d19c5ffc93 100644 --- a/src/digraph.rs +++ b/src/digraph.rs @@ -2691,6 +2691,35 @@ impl PyDiGraph { _from_adjacency_matrix(py, matrix, null_value) } + /// Create a new :class:`~rustworkx.PyDiGraph` object from a biadjacency + /// matrix with matrix elements of type ``float``. + /// + /// This method can be used to construct a new bipartite + /// :class:`~rustworkx.PyDiGraph` object from an input biadjacency matrix. + /// For an input matrix with shape ``(m, n)``, the first ``m`` nodes are + /// generated from the rows and the next ``n`` nodes are generated from the + /// columns. Each non-null matrix entry creates an edge from row node ``i`` + /// to column node ``m + j``. + /// + /// :param ndarray matrix: The input numpy array biadjacency matrix to + /// create a new :class:`~rustworkx.PyDiGraph` object from. It must be + /// a 2 dimensional array and be a ``float``/``np.float64`` data type. + /// :param float null_value: An optional float that will treated as a null + /// value. If any element in the input matrix is this value it will be + /// treated as not an edge. By default this is ``0.0``. + /// + /// :returns: A new graph object generated from the biadjacency matrix + /// :rtype: PyDiGraph + #[staticmethod] + #[pyo3(signature=(matrix, null_value=0.0), text_signature = "(matrix, /, null_value=0.0)")] + pub fn from_biadjacency_matrix<'p>( + py: Python<'p>, + matrix: PyReadonlyArray2<'p, f64>, + null_value: f64, + ) -> PyResult { + _from_biadjacency_matrix(py, matrix, null_value) + } + /// Create a new :class:`~rustworkx.PyDiGraph` object from an adjacency matrix /// with matrix elements of type ``complex`` /// @@ -3635,6 +3664,50 @@ where }) } +fn is_not_matrix_null(elem: &T, null_value: T) -> bool +where + T: Copy + std::cmp::PartialEq + IsNan, +{ + if null_value.is_nan() { + !elem.is_nan() + } else { + *elem != null_value + } +} + +fn _from_biadjacency_matrix<'p, T>( + py: Python<'p>, + matrix: PyReadonlyArray2<'p, T>, + null_value: T, +) -> PyResult +where + T: Copy + std::cmp::PartialEq + numpy::Element + pyo3::IntoPyObject<'p> + IsNan, +{ + let array = matrix.as_array(); + let shape = array.shape(); + let mut out_graph = StablePyGraph::::new(); + let _node_indices: Vec = (0..(shape[0] + shape[1])) + .map(|node| Ok(out_graph.add_node(node.into_py_any(py)?))) + .collect::>>()?; + for ((source, target), elem) in array.indexed_iter() { + if is_not_matrix_null(elem, null_value) { + out_graph.add_edge( + NodeIndex::new(source), + NodeIndex::new(shape[0] + target), + elem.into_py_any(py)?, + ); + } + } + Ok(PyDiGraph { + graph: out_graph, + cycle_state: algo::DfsSpace::default(), + check_cycle: false, + node_removed: false, + multigraph: true, + attrs: py.None(), + }) +} + /// Simple wrapper newtype that lets us use `Py` pointers as hash keys with the equality defined by /// the pointer address. This is equivalent to using Python's `is` operator for comparisons. /// Using a newtype rather than casting the pointer to `usize` inline lets us retrieve a copy of diff --git a/src/graph.rs b/src/graph.rs index d9cd17611d..87449e40b2 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -1578,6 +1578,35 @@ impl PyGraph { _from_adjacency_matrix(py, matrix, null_value) } + /// Create a new :class:`~rustworkx.PyGraph` object from a biadjacency + /// matrix with matrix elements of type ``float``. + /// + /// This method can be used to construct a new bipartite + /// :class:`~rustworkx.PyGraph` object from an input biadjacency matrix. + /// For an input matrix with shape ``(m, n)``, the first ``m`` nodes are + /// generated from the rows and the next ``n`` nodes are generated from the + /// columns. Each non-null matrix entry creates an edge between row node + /// ``i`` and column node ``m + j``. + /// + /// :param ndarray matrix: The input numpy array biadjacency matrix to + /// create a new :class:`~rustworkx.PyGraph` object from. It must be a + /// 2 dimensional array and be a ``float``/``np.float64`` data type. + /// :param float null_value: An optional float that will treated as a null + /// value. If any element in the input matrix is this value it will be + /// treated as not an edge. By default this is ``0.0``. + /// + /// :returns: A new graph object generated from the biadjacency matrix + /// :rtype: PyGraph + #[staticmethod] + #[pyo3(signature=(matrix, null_value=0.0), text_signature = "(matrix, /, null_value=0.0)")] + pub fn from_biadjacency_matrix<'p>( + py: Python<'p>, + matrix: PyReadonlyArray2<'p, f64>, + null_value: f64, + ) -> PyResult { + _from_biadjacency_matrix(py, matrix, null_value) + } + /// Create a new :class:`~rustworkx.PyGraph` object from an adjacency matrix /// with matrix elements of type ``complex`` /// @@ -2309,3 +2338,45 @@ where attrs: py.None(), }) } + +fn is_not_matrix_null(elem: &T, null_value: T) -> bool +where + T: Copy + std::cmp::PartialEq + IsNan, +{ + if null_value.is_nan() { + !elem.is_nan() + } else { + *elem != null_value + } +} + +fn _from_biadjacency_matrix<'p, T>( + py: Python<'p>, + matrix: PyReadonlyArray2<'p, T>, + null_value: T, +) -> PyResult +where + T: Copy + std::cmp::PartialEq + numpy::Element + pyo3::IntoPyObject<'p> + IsNan, +{ + let array = matrix.as_array(); + let shape = array.shape(); + let mut out_graph = StablePyGraph::::default(); + let _node_indices: Vec = (0..(shape[0] + shape[1])) + .map(|node| Ok(out_graph.add_node(node.into_py_any(py)?))) + .collect::>>()?; + for ((source, target), elem) in array.indexed_iter() { + if is_not_matrix_null(elem, null_value) { + out_graph.add_edge( + NodeIndex::new(source), + NodeIndex::new(shape[0] + target), + elem.into_py_any(py)?, + ); + } + } + Ok(PyGraph { + graph: out_graph, + node_removed: false, + multigraph: true, + attrs: py.None(), + }) +} diff --git a/src/lib.rs b/src/lib.rs index 32f7b942b7..244550baca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -539,6 +539,8 @@ fn rustworkx(py: Python<'_>, m: &Bound) -> PyResult<()> { m.add_wrapped(wrap_pyfunction!(digraph_distance_matrix))?; m.add_wrapped(wrap_pyfunction!(digraph_adjacency_matrix))?; m.add_wrapped(wrap_pyfunction!(graph_adjacency_matrix))?; + m.add_wrapped(wrap_pyfunction!(digraph_biadjacency_matrix))?; + m.add_wrapped(wrap_pyfunction!(graph_biadjacency_matrix))?; m.add_wrapped(wrap_pyfunction!(graph_all_pairs_all_simple_paths))?; m.add_wrapped(wrap_pyfunction!(digraph_all_pairs_all_simple_paths))?; m.add_wrapped(wrap_pyfunction!(graph_longest_simple_path))?; diff --git a/tests/digraph/test_adjacency_matrix.py b/tests/digraph/test_adjacency_matrix.py index 751dd47f16..4bf94611b0 100644 --- a/tests/digraph/test_adjacency_matrix.py +++ b/tests/digraph/test_adjacency_matrix.py @@ -377,3 +377,153 @@ def test_parallel_edge(self): rustworkx.digraph_adjacency_matrix( graph, weight_fn=lambda x: float(x), parallel_edge="error" ) + + +class TestDiGraphBiadjacencyMatrix(unittest.TestCase): + def test_from_biadjacency_matrix(self): + input_array = np.array( + [[1.0, 0.0, 2.0], [0.0, 3.0, 0.0]], + dtype=np.float64, + ) + graph = rustworkx.PyDiGraph.from_biadjacency_matrix(input_array) + self.assertEqual(5, graph.num_nodes()) + self.assertEqual( + [(0, 2, 1.0), (0, 4, 2.0), (1, 3, 3.0)], + graph.weighted_edge_list(), + ) + + def test_from_biadjacency_matrix_non_zero_null(self): + input_array = np.array( + [[np.inf, -1.0], [2.0, np.inf], [np.inf, 4.0]], + dtype=np.float64, + ) + graph = rustworkx.PyDiGraph.from_biadjacency_matrix(input_array, null_value=np.inf) + self.assertEqual(5, graph.num_nodes()) + self.assertEqual( + [(0, 4, -1.0), (1, 3, 2.0), (2, 4, 4.0)], + graph.weighted_edge_list(), + ) + + def test_from_biadjacency_matrix_nan_null(self): + input_array = np.array( + [[np.nan, 1.0], [2.0, np.nan]], + dtype=np.float64, + ) + graph = rustworkx.PyDiGraph.from_biadjacency_matrix(input_array, null_value=np.nan) + self.assertEqual( + [(0, 3, 1.0), (1, 2, 2.0)], + graph.weighted_edge_list(), + ) + + def test_from_biadjacency_matrix_different_dtype(self): + input_array = np.array([[1, 0], [0, 1]], dtype=np.int64) + with self.assertRaises(TypeError): + rustworkx.PyDiGraph.from_biadjacency_matrix(input_array) + + def test_from_biadjacency_matrix_empty_dimension(self): + graph = rustworkx.PyDiGraph.from_biadjacency_matrix(np.zeros((3, 0), dtype=np.float64)) + self.assertEqual(3, graph.num_nodes()) + self.assertEqual([], graph.weighted_edge_list()) + + def test_biadjacency_matrix(self): + graph = rustworkx.PyDiGraph() + graph.add_nodes_from(range(5)) + graph.add_edges_from([(0, 2, 1.0), (0, 4, 2.0), (1, 3, 3.0)]) + + matrix = rustworkx.digraph_biadjacency_matrix( + graph, [0, 1], [2, 3, 4], weight_fn=lambda x: x + ) + expected = np.array([[1.0, 0.0, 2.0], [0.0, 3.0, 0.0]]) + np.testing.assert_array_equal(expected, matrix) + + def test_biadjacency_matrix_ignores_incoming_edges(self): + graph = rustworkx.PyDiGraph() + graph.add_nodes_from(range(3)) + graph.add_edge(2, 0, 7.0) + + matrix = rustworkx.digraph_biadjacency_matrix(graph, [0], [2], weight_fn=float) + np.testing.assert_array_equal([[0.0]], matrix) + + def test_biadjacency_matrix_parallel_edges(self): + graph = rustworkx.PyDiGraph() + graph.add_nodes_from(range(2)) + graph.add_edges_from([(0, 1, 1.0), (0, 1, 3.0)]) + + max_matrix = rustworkx.digraph_biadjacency_matrix( + graph, [0], [1], weight_fn=float, parallel_edge="max" + ) + np.testing.assert_array_equal([[3.0]], max_matrix) + + min_matrix = rustworkx.digraph_biadjacency_matrix( + graph, [0], [1], weight_fn=float, parallel_edge="min" + ) + np.testing.assert_array_equal([[1.0]], min_matrix) + + def test_biadjacency_matrix_default_weight(self): + graph = rustworkx.PyDiGraph() + graph.add_nodes_from(range(2)) + graph.add_edge(0, 1, "edge") + + matrix = rustworkx.digraph_biadjacency_matrix(graph, [0], [1], default_weight=5.0) + np.testing.assert_array_equal([[5.0]], matrix) + + def test_biadjacency_matrix_null_value(self): + graph = rustworkx.PyDiGraph() + graph.add_nodes_from(range(3)) + graph.add_edge(0, 1, 2.0) + + matrix = rustworkx.digraph_biadjacency_matrix( + graph, [0], [1, 2], weight_fn=float, null_value=-1.0 + ) + np.testing.assert_array_equal([[2.0, -1.0]], matrix) + + def test_biadjacency_matrix_invalid_parallel_edge(self): + graph = rustworkx.PyDiGraph() + graph.add_nodes_from(range(2)) + graph.add_edge(0, 1, 1.0) + + with self.assertRaises(ValueError): + rustworkx.digraph_biadjacency_matrix( + graph, [0], [1], weight_fn=float, parallel_edge="error" + ) + + def test_biadjacency_matrix_missing_node(self): + graph = rustworkx.PyDiGraph() + graph.add_node(0) + with self.assertRaises(ValueError): + rustworkx.digraph_biadjacency_matrix(graph, [0], [1]) + + def test_biadjacency_matrix_duplicate_node_order(self): + graph = rustworkx.PyDiGraph() + graph.add_nodes_from(range(3)) + + with self.assertRaisesRegex(ValueError, "row_order contains duplicate node index 0"): + rustworkx.digraph_biadjacency_matrix(graph, [0, 0], [1]) + with self.assertRaisesRegex(ValueError, "column_order contains duplicate node index 1"): + rustworkx.digraph_biadjacency_matrix(graph, [0], [1, 1]) + + def test_biadjacency_matrix_overlapping_node_order(self): + graph = rustworkx.PyDiGraph() + graph.add_nodes_from(range(2)) + + with self.assertRaisesRegex(ValueError, "must be disjoint"): + rustworkx.digraph_biadjacency_matrix(graph, [0], [0, 1]) + + def test_biadjacency_matrix_non_contiguous_node_indices(self): + graph = rustworkx.PyDiGraph() + graph.add_nodes_from(range(4)) + graph.add_edge(0, 3, 7.0) + graph.remove_node(1) + + matrix = rustworkx.digraph_biadjacency_matrix(graph, [0], [3], weight_fn=float) + np.testing.assert_array_equal([[7.0]], matrix) + + with self.assertRaises(ValueError): + rustworkx.digraph_biadjacency_matrix(graph, [0], [1]) + + def test_biadjacency_matrix_empty_order(self): + graph = rustworkx.PyDiGraph() + graph.add_nodes_from(range(2)) + + matrix = rustworkx.digraph_biadjacency_matrix(graph, [0], []) + self.assertEqual((1, 0), matrix.shape) diff --git a/tests/graph/test_adjacency_matrix.py b/tests/graph/test_adjacency_matrix.py index 722aeec3c1..24dab50771 100644 --- a/tests/graph/test_adjacency_matrix.py +++ b/tests/graph/test_adjacency_matrix.py @@ -375,3 +375,151 @@ def test_parallel_edge(self): rustworkx.graph_adjacency_matrix( graph, weight_fn=lambda x: float(x), parallel_edge="error" ) + + +class TestGraphBiadjacencyMatrix(unittest.TestCase): + def test_from_biadjacency_matrix(self): + input_array = np.array( + [[1.0, 0.0, 2.0], [0.0, 3.0, 0.0]], + dtype=np.float64, + ) + graph = rustworkx.PyGraph.from_biadjacency_matrix(input_array) + self.assertEqual(5, graph.num_nodes()) + self.assertEqual( + [(0, 2, 1.0), (0, 4, 2.0), (1, 3, 3.0)], + graph.weighted_edge_list(), + ) + + def test_from_biadjacency_matrix_non_zero_null(self): + input_array = np.array( + [[np.inf, -1.0], [2.0, np.inf], [np.inf, 4.0]], + dtype=np.float64, + ) + graph = rustworkx.PyGraph.from_biadjacency_matrix(input_array, null_value=np.inf) + self.assertEqual(5, graph.num_nodes()) + self.assertEqual( + [(0, 4, -1.0), (1, 3, 2.0), (2, 4, 4.0)], + graph.weighted_edge_list(), + ) + + def test_from_biadjacency_matrix_nan_null(self): + input_array = np.array( + [[np.nan, 1.0], [2.0, np.nan]], + dtype=np.float64, + ) + graph = rustworkx.PyGraph.from_biadjacency_matrix(input_array, null_value=np.nan) + self.assertEqual( + [(0, 3, 1.0), (1, 2, 2.0)], + graph.weighted_edge_list(), + ) + + def test_from_biadjacency_matrix_different_dtype(self): + input_array = np.array([[1, 0], [0, 1]], dtype=np.int64) + with self.assertRaises(TypeError): + rustworkx.PyGraph.from_biadjacency_matrix(input_array) + + def test_from_biadjacency_matrix_empty_dimension(self): + graph = rustworkx.PyGraph.from_biadjacency_matrix(np.zeros((0, 3), dtype=np.float64)) + self.assertEqual(3, graph.num_nodes()) + self.assertEqual([], graph.weighted_edge_list()) + + def test_biadjacency_matrix(self): + graph = rustworkx.PyGraph() + graph.add_nodes_from(range(5)) + graph.add_edges_from([(0, 2, 1.0), (0, 4, 2.0), (1, 3, 3.0)]) + + matrix = rustworkx.graph_biadjacency_matrix(graph, [0, 1], [2, 3, 4], weight_fn=lambda x: x) + expected = np.array([[1.0, 0.0, 2.0], [0.0, 3.0, 0.0]]) + np.testing.assert_array_equal(expected, matrix) + + def test_biadjacency_matrix_reverse_edge_order(self): + graph = rustworkx.PyGraph() + graph.add_nodes_from(range(3)) + graph.add_edge(2, 0, 7.0) + + matrix = rustworkx.graph_biadjacency_matrix(graph, [0], [2], weight_fn=float) + np.testing.assert_array_equal([[7.0]], matrix) + + def test_biadjacency_matrix_parallel_edges(self): + graph = rustworkx.PyGraph() + graph.add_nodes_from(range(2)) + graph.add_edges_from([(0, 1, 1.0), (0, 1, 3.0)]) + + sum_matrix = rustworkx.graph_biadjacency_matrix( + graph, [0], [1], weight_fn=float, parallel_edge="sum" + ) + np.testing.assert_array_equal([[4.0]], sum_matrix) + + avg_matrix = rustworkx.graph_biadjacency_matrix( + graph, [0], [1], weight_fn=float, parallel_edge="avg" + ) + np.testing.assert_array_equal([[2.0]], avg_matrix) + + def test_biadjacency_matrix_default_weight(self): + graph = rustworkx.PyGraph() + graph.add_nodes_from(range(2)) + graph.add_edge(0, 1, "edge") + + matrix = rustworkx.graph_biadjacency_matrix(graph, [0], [1], default_weight=5.0) + np.testing.assert_array_equal([[5.0]], matrix) + + def test_biadjacency_matrix_null_value(self): + graph = rustworkx.PyGraph() + graph.add_nodes_from(range(3)) + graph.add_edge(0, 1, 2.0) + + matrix = rustworkx.graph_biadjacency_matrix( + graph, [0], [1, 2], weight_fn=float, null_value=-1.0 + ) + np.testing.assert_array_equal([[2.0, -1.0]], matrix) + + def test_biadjacency_matrix_invalid_parallel_edge(self): + graph = rustworkx.PyGraph() + graph.add_nodes_from(range(2)) + graph.add_edge(0, 1, 1.0) + + with self.assertRaises(ValueError): + rustworkx.graph_biadjacency_matrix( + graph, [0], [1], weight_fn=float, parallel_edge="error" + ) + + def test_biadjacency_matrix_missing_node(self): + graph = rustworkx.PyGraph() + graph.add_node(0) + with self.assertRaises(ValueError): + rustworkx.graph_biadjacency_matrix(graph, [0], [1]) + + def test_biadjacency_matrix_duplicate_node_order(self): + graph = rustworkx.PyGraph() + graph.add_nodes_from(range(3)) + + with self.assertRaisesRegex(ValueError, "row_order contains duplicate node index 0"): + rustworkx.graph_biadjacency_matrix(graph, [0, 0], [1]) + with self.assertRaisesRegex(ValueError, "column_order contains duplicate node index 1"): + rustworkx.graph_biadjacency_matrix(graph, [0], [1, 1]) + + def test_biadjacency_matrix_overlapping_node_order(self): + graph = rustworkx.PyGraph() + graph.add_nodes_from(range(2)) + + with self.assertRaisesRegex(ValueError, "must be disjoint"): + rustworkx.graph_biadjacency_matrix(graph, [0], [0, 1]) + + def test_biadjacency_matrix_non_contiguous_node_indices(self): + graph = rustworkx.PyGraph() + graph.add_nodes_from(range(4)) + graph.add_edge(0, 3, 7.0) + graph.remove_node(1) + + matrix = rustworkx.graph_biadjacency_matrix(graph, [0], [3], weight_fn=float) + np.testing.assert_array_equal([[7.0]], matrix) + + with self.assertRaises(ValueError): + rustworkx.graph_biadjacency_matrix(graph, [0], [1]) + + def test_biadjacency_matrix_empty_order(self): + graph = rustworkx.PyGraph() + graph.add_nodes_from(range(2)) + + matrix = rustworkx.graph_biadjacency_matrix(graph, [], [1]) + self.assertEqual((0, 1), matrix.shape) From e1240a1651bf5aad6238d6358a2577858328f8e7 Mon Sep 17 00:00:00 2001 From: spital <11034264+spital@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:16:50 +0000 Subject: [PATCH 2/2] Use SciPy sparse arrays for biadjacency matrix API Address review feedback on #1589: * Return scipy sparse arrays * Add the universal rustworkx.biadjacency_matrix function * Move the shared conversion code from graph.rs/digraph.rs/connectivity into src/connectivity/biadjacency.rs using generics * Use a match on HashMap::entry() when accumulating parallel edges to save a hashmap access * Make biadjacency tests skip when SciPy is not installed and add scipy to the test extra only for CPython >= 3.11 on non-Windows platforms. * Drop the unrelated lr_planar.rs clippy change (split out to #1595). --- docs/source/api/algorithm_functions/other.rst | 1 + pyproject.toml | 1 + .../notes/add-biadjacency-matrix-1412.yaml | 7 +- rustworkx/__init__.py | 36 ++ rustworkx/__init__.pyi | 9 + rustworkx/rustworkx.pyi | 16 +- src/connectivity/biadjacency.rs | 332 ++++++++++++++++++ src/connectivity/mod.rs | 222 +++--------- src/digraph.rs | 70 +--- src/graph.rs | 68 +--- tests/digraph/test_adjacency_matrix.py | 69 ++-- tests/graph/test_adjacency_matrix.py | 63 ++-- uv.lock | 63 +++- 13 files changed, 597 insertions(+), 360 deletions(-) create mode 100644 src/connectivity/biadjacency.rs diff --git a/docs/source/api/algorithm_functions/other.rst b/docs/source/api/algorithm_functions/other.rst index 91228a3b38..2b9cc444b0 100644 --- a/docs/source/api/algorithm_functions/other.rst +++ b/docs/source/api/algorithm_functions/other.rst @@ -7,6 +7,7 @@ Other Algorithm Functions :toctree: ../../apiref rustworkx.adjacency_matrix + rustworkx.biadjacency_matrix rustworkx.transitivity rustworkx.core_number rustworkx.graph_line_graph diff --git a/pyproject.toml b/pyproject.toml index 19cdfe9dbb..d3cd0c59f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ test = [ "maturin>=1.9.0,<2.0", "testtools>=2.5.0", "networkx>=3.2", + "scipy>=1.11; python_version >= '3.11' and platform_system != 'Windows'", "stestr>=4.1", ] lint = [ diff --git a/releasenotes/notes/add-biadjacency-matrix-1412.yaml b/releasenotes/notes/add-biadjacency-matrix-1412.yaml index 8b1f5c73c4..db5e033b3b 100644 --- a/releasenotes/notes/add-biadjacency-matrix-1412.yaml +++ b/releasenotes/notes/add-biadjacency-matrix-1412.yaml @@ -2,7 +2,6 @@ features: - | Added :meth:`~rustworkx.PyGraph.from_biadjacency_matrix`, - :meth:`~rustworkx.PyDiGraph.from_biadjacency_matrix`, - :func:`~rustworkx.graph_biadjacency_matrix`, and - :func:`~rustworkx.digraph_biadjacency_matrix` for dense NumPy - biadjacency matrix conversion. + :meth:`~rustworkx.PyDiGraph.from_biadjacency_matrix`, and + :func:`~rustworkx.biadjacency_matrix` for SciPy sparse biadjacency matrix + conversion. diff --git a/rustworkx/__init__.py b/rustworkx/__init__.py index 24660f76c5..cfeb68f249 100644 --- a/rustworkx/__init__.py +++ b/rustworkx/__init__.py @@ -260,6 +260,42 @@ def adjacency_matrix(graph, weight_fn=None, default_weight=1.0, null_value=0.0, raise TypeError(f"Invalid Input Type {type(graph)} for graph") +@_rustworkx_dispatch +def biadjacency_matrix( + graph, + row_order, + column_order, + weight_fn=None, + default_weight=1.0, + format="csr", + parallel_edge="sum", +): + """Return the biadjacency matrix for a graph object as a SciPy sparse array. + + :param graph: The graph used to generate the biadjacency matrix from. Can + be either a :class:`~rustworkx.PyGraph` or :class:`~rustworkx.PyDiGraph`. + :param list row_order: The node indices to use for the rows of the output + matrix. + :param list column_order: The node indices to use for the columns of the + output matrix. + :param callable weight_fn: A callable object which will be passed the edge + object and expected to return a ``float``. + :param float default_weight: If ``weight_fn`` is not used this can be + optionally used to specify a default weight to use for all edges. + :param str format: The SciPy sparse array format to return. Defaults to + ``"csr"``. + :param str parallel_edge: Optional argument that determines how the function + handles parallel edges. ``"min"``, ``"max"``, ``"avg"``, and ``"sum"`` + are supported. The default is ``"sum"``. + + :returns: The biadjacency matrix. + :rtype: scipy.sparse.sparray + + :raises ImportError: If SciPy is not installed. + """ + raise TypeError(f"Invalid Input Type {type(graph)} for graph") + + @_rustworkx_dispatch def all_simple_paths(graph, from_, to, min_depth=None, cutoff=None): """Return all simple paths between 2 nodes in a PyGraph object diff --git a/rustworkx/__init__.pyi b/rustworkx/__init__.pyi index 0f9ccf4738..17f53280c5 100644 --- a/rustworkx/__init__.pyi +++ b/rustworkx/__init__.pyi @@ -348,6 +348,15 @@ def adjacency_matrix( null_value: float = ..., node_list: Sequence[int] | None = ..., ) -> npt.NDArray[np.float64]: ... +def biadjacency_matrix( + graph: PyGraph[_S, _T] | PyDiGraph[_S, _T], + row_order: Sequence[int], + column_order: Sequence[int], + weight_fn: Callable[[_T], float] | None = ..., + default_weight: float = ..., + format: str = ..., + parallel_edge: str = ..., +) -> Any: ... def all_simple_paths( graph: PyGraph | PyDiGraph, from_: int, diff --git a/rustworkx/rustworkx.pyi b/rustworkx/rustworkx.pyi index 8ecb93a49b..732d3f94be 100644 --- a/rustworkx/rustworkx.pyi +++ b/rustworkx/rustworkx.pyi @@ -305,9 +305,9 @@ def digraph_biadjacency_matrix( /, weight_fn: Callable[[_T], float] | None = ..., default_weight: float = ..., - null_value: float = ..., + format: str = ..., parallel_edge: str = ..., -) -> npt.NDArray[np.float64]: ... +) -> Any: ... def graph_biadjacency_matrix( graph: PyGraph[_S, _T], row_order: Sequence[int], @@ -315,9 +315,9 @@ def graph_biadjacency_matrix( /, weight_fn: Callable[[_T], float] | None = ..., default_weight: float = ..., - null_value: float = ..., + format: str = ..., parallel_edge: str = ..., -) -> npt.NDArray[np.float64]: ... +) -> Any: ... def cycle_basis(graph: PyGraph, /, root: int | None = ...) -> list[list[int]]: ... def articulation_points(graph: PyGraph, /) -> set[int]: ... def bridges(graph: PyGraph, /) -> set[tuple[int]]: ... @@ -1458,9 +1458,7 @@ class PyGraph(Generic[_S, _T]): matrix: npt.NDArray[np.float64], /, null_value: float = ... ) -> PyGraph[int, float]: ... @staticmethod - def from_biadjacency_matrix( - matrix: npt.NDArray[np.float64], /, null_value: float = ... - ) -> PyGraph[int, float]: ... + def from_biadjacency_matrix(matrix: Any, /) -> PyGraph[int, float]: ... @staticmethod def from_complex_adjacency_matrix( matrix: npt.NDArray[np.complex64], /, null_value: complex = ... @@ -1646,9 +1644,7 @@ class PyDiGraph(Generic[_S, _T]): matrix: npt.NDArray[np.float64], /, null_value: float = ... ) -> PyDiGraph[int, float]: ... @staticmethod - def from_biadjacency_matrix( - matrix: npt.NDArray[np.float64], /, null_value: float = ... - ) -> PyDiGraph[int, float]: ... + def from_biadjacency_matrix(matrix: Any, /) -> PyDiGraph[int, float]: ... @staticmethod def from_complex_adjacency_matrix( matrix: npt.NDArray[np.complex64], /, null_value: complex = ... diff --git a/src/connectivity/biadjacency.rs b/src/connectivity/biadjacency.rs new file mode 100644 index 0000000000..5bd67ce793 --- /dev/null +++ b/src/connectivity/biadjacency.rs @@ -0,0 +1,332 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +use crate::{StablePyGraph, digraph, graph, weight_callable}; +use hashbrown::hash_map::Entry; +use hashbrown::{HashMap, HashSet}; +use numpy::IntoPyArray; +use petgraph::graph::NodeIndex; +use petgraph::visit::{EdgeRef, IntoEdgeReferences}; +use petgraph::{Directed, EdgeType, Undirected, algo}; +use pyo3::IntoPyObjectExt; +use pyo3::exceptions::{PyImportError, PyTypeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyModule}; + +const SCIPY_REQUIRED_ERROR: &str = + "scipy is required for biadjacency matrix conversion. Install scipy to use this function."; + +type ParallelEdgeFn = fn(f64, f64, usize) -> f64; +type SparseMatrixData = (usize, usize, Vec, Vec, Vec); + +pub(super) struct BiadjacencyMatrixOptions<'a> { + pub(super) row_order: Vec, + pub(super) column_order: Vec, + pub(super) weight_fn: Option>, + pub(super) default_weight: f64, + pub(super) format: &'a str, + pub(super) parallel_edge: &'a str, +} + +fn scipy_sparse_module<'py>(py: Python<'py>) -> PyResult> { + py.import("scipy.sparse") + .map_err(|_| PyImportError::new_err(SCIPY_REQUIRED_ERROR)) +} + +fn parallel_edge_fn(parallel_edge: &str) -> PyResult { + match parallel_edge { + "sum" => Ok(|current, edge_weight, _| current + edge_weight), + "min" => Ok(|current, edge_weight, _| current.min(edge_weight)), + "max" => Ok(|current, edge_weight, _| current.max(edge_weight)), + "avg" => Ok(|current, edge_weight, count| { + (current * count as f64 + edge_weight) / ((count + 1) as f64) + }), + _ => Err(PyValueError::new_err( + "Parallel edges can currently only be dealt with using \"sum\", \"min\", \"max\", or \"avg\".", + )), + } +} + +fn validate_biadjacency_node_order( + graph: &StablePyGraph, + row_order: &[usize], + column_order: &[usize], +) -> PyResult<()> { + let mut row_nodes = HashSet::new(); + for node in row_order { + if !row_nodes.insert(*node) { + return Err(PyValueError::new_err(format!( + "row_order contains duplicate node index {node}" + ))); + } + } + + let mut column_nodes = HashSet::new(); + for node in column_order { + if !column_nodes.insert(*node) { + return Err(PyValueError::new_err(format!( + "column_order contains duplicate node index {node}" + ))); + } + if row_nodes.contains(node) { + return Err(PyValueError::new_err(format!( + "row_order and column_order must be disjoint; node index {node} appears in both" + ))); + } + } + + for node in row_order.iter().chain(column_order.iter()) { + if !graph.contains_node(NodeIndex::new(*node)) { + return Err(PyValueError::new_err(format!( + "Node index {node} is not present in the graph" + ))); + } + } + Ok(()) +} + +fn biadjacency_node_map(node_order: &[usize]) -> HashMap { + node_order + .iter() + .enumerate() + .map(|(index, node)| (*node, index)) + .collect() +} + +fn digraph_biadjacency_index( + source: usize, + target: usize, + row_map: &HashMap, + column_map: &HashMap, +) -> Option<(usize, usize)> { + row_map + .get(&source) + .and_then(|row| column_map.get(&target).map(|column| (*row, *column))) +} + +fn graph_biadjacency_index( + source: usize, + target: usize, + row_map: &HashMap, + column_map: &HashMap, +) -> Option<(usize, usize)> { + digraph_biadjacency_index(source, target, row_map, column_map).or_else(|| { + row_map + .get(&target) + .and_then(|row| column_map.get(&source).map(|column| (*row, *column))) + }) +} + +fn add_biadjacency_edge( + weights: &mut HashMap<[usize; 2], (f64, usize)>, + row: usize, + column: usize, + edge_weight: f64, + parallel_edge_fn: ParallelEdgeFn, +) { + match weights.entry([row, column]) { + Entry::Vacant(entry) => { + entry.insert((edge_weight, 1)); + } + Entry::Occupied(mut entry) => { + let (current, count) = entry.get_mut(); + *current = parallel_edge_fn(*current, edge_weight, *count); + *count += 1; + } + } +} + +fn build_biadjacency_triplets<'py, Ty, F>( + py: Python<'py>, + graph: &StablePyGraph, + options: &'py BiadjacencyMatrixOptions<'_>, + edge_index: F, +) -> PyResult<(Vec, Vec, Vec)> +where + Ty: EdgeType, + F: Fn(usize, usize, &HashMap, &HashMap) -> Option<(usize, usize)>, +{ + let parallel_edge_fn = parallel_edge_fn(options.parallel_edge)?; + let row_map = biadjacency_node_map(&options.row_order); + let column_map = biadjacency_node_map(&options.column_order); + let mut weights = HashMap::new(); + + for edge in graph.edge_references() { + let source = edge.source().index(); + let target = edge.target().index(); + if let Some((row, column)) = edge_index(source, target, &row_map, &column_map) { + let edge_weight = weight_callable( + py, + &options.weight_fn, + edge.weight(), + options.default_weight, + )?; + add_biadjacency_edge(&mut weights, row, column, edge_weight, parallel_edge_fn); + } + } + + let mut entries: Vec<(usize, usize, f64)> = weights + .into_iter() + .map(|([row, column], (weight, _))| (row, column, weight)) + .collect(); + entries.sort_unstable_by_key(|(row, column, _)| (*row, *column)); + + let mut rows = Vec::with_capacity(entries.len()); + let mut columns = Vec::with_capacity(entries.len()); + let mut data = Vec::with_capacity(entries.len()); + for (row, column, weight) in entries { + rows.push(row as i64); + columns.push(column as i64); + data.push(weight); + } + Ok((rows, columns, data)) +} + +fn triplets_to_scipy_sparse<'py>( + py: Python<'py>, + rows: Vec, + columns: Vec, + data: Vec, + shape: (usize, usize), + format: &str, +) -> PyResult> { + let sparse = scipy_sparse_module(py)?; + let kwargs = PyDict::new(py); + kwargs.set_item("shape", shape)?; + // Hand the triplets to SciPy as NumPy arrays so the buffers are copied + // wholesale instead of boxing every entry into a Python object. + let coo = sparse.call_method( + "coo_array", + (( + data.into_pyarray(py), + (rows.into_pyarray(py), columns.into_pyarray(py)), + ),), + Some(&kwargs), + )?; + coo.call_method1("asformat", (format,)) +} + +pub(super) fn digraph_to_biadjacency_matrix<'py>( + py: Python<'py>, + graph: &StablePyGraph, + options: BiadjacencyMatrixOptions<'_>, +) -> PyResult> { + validate_biadjacency_node_order(graph, &options.row_order, &options.column_order)?; + let (rows, columns, data) = + build_biadjacency_triplets(py, graph, &options, digraph_biadjacency_index)?; + triplets_to_scipy_sparse( + py, + rows, + columns, + data, + (options.row_order.len(), options.column_order.len()), + options.format, + ) +} + +pub(super) fn graph_to_biadjacency_matrix<'py>( + py: Python<'py>, + graph: &StablePyGraph, + options: BiadjacencyMatrixOptions<'_>, +) -> PyResult> { + validate_biadjacency_node_order(graph, &options.row_order, &options.column_order)?; + let (rows, columns, data) = + build_biadjacency_triplets(py, graph, &options, graph_biadjacency_index)?; + triplets_to_scipy_sparse( + py, + rows, + columns, + data, + (options.row_order.len(), options.column_order.len()), + options.format, + ) +} + +fn scipy_sparse_to_coo_data<'py>( + py: Python<'py>, + matrix: &Bound<'py, PyAny>, +) -> PyResult { + let sparse = scipy_sparse_module(py)?; + let is_sparse: bool = sparse.call_method1("issparse", (matrix,))?.extract()?; + if !is_sparse { + return Err(PyTypeError::new_err( + "matrix must be a scipy sparse matrix or sparse array", + )); + } + + let coo = matrix.call_method0("tocoo")?; + let shape: (usize, usize) = coo.getattr("shape")?.extract()?; + let rows: Vec = coo.getattr("row")?.call_method0("tolist")?.extract()?; + let columns: Vec = coo.getattr("col")?.call_method0("tolist")?.extract()?; + let data: Vec = coo.getattr("data")?.call_method0("tolist")?.extract()?; + + if rows.len() != columns.len() || rows.len() != data.len() { + return Err(PyValueError::new_err( + "scipy sparse matrix row, column, and data arrays must have the same length", + )); + } + + Ok((shape.0, shape.1, rows, columns, data)) +} + +fn stable_graph_from_biadjacency_matrix( + py: Python<'_>, + matrix: &Bound<'_, PyAny>, +) -> PyResult> { + let (row_count, column_count, rows, columns, data) = scipy_sparse_to_coo_data(py, matrix)?; + let mut out_graph = StablePyGraph::::with_capacity(row_count + column_count, data.len()); + + for node in 0..(row_count + column_count) { + out_graph.add_node(node.into_py_any(py)?); + } + + for ((row, column), weight) in rows.into_iter().zip(columns).zip(data) { + if row >= row_count || column >= column_count { + return Err(PyValueError::new_err( + "scipy sparse matrix contains an entry outside its shape", + )); + } + out_graph.add_edge( + NodeIndex::new(row), + NodeIndex::new(row_count + column), + weight.into_py_any(py)?, + ); + } + + Ok(out_graph) +} + +pub(crate) fn graph_from_biadjacency_matrix( + py: Python<'_>, + matrix: &Bound<'_, PyAny>, +) -> PyResult { + Ok(graph::PyGraph { + graph: stable_graph_from_biadjacency_matrix::(py, matrix)?, + node_removed: false, + multigraph: true, + attrs: py.None(), + }) +} + +pub(crate) fn digraph_from_biadjacency_matrix( + py: Python<'_>, + matrix: &Bound<'_, PyAny>, +) -> PyResult { + Ok(digraph::PyDiGraph { + graph: stable_graph_from_biadjacency_matrix::(py, matrix)?, + cycle_state: algo::DfsSpace::default(), + check_cycle: false, + node_removed: false, + multigraph: true, + attrs: py.None(), + }) +} diff --git a/src/connectivity/mod.rs b/src/connectivity/mod.rs index e55bf1b6c3..83489e62a2 100644 --- a/src/connectivity/mod.rs +++ b/src/connectivity/mod.rs @@ -13,6 +13,7 @@ #![allow(clippy::float_cmp)] mod all_pairs_all_simple_paths; +pub(crate) mod biadjacency; mod johnson_simple_cycles; mod subgraphs; @@ -786,108 +787,13 @@ pub fn digraph_adjacency_matrix<'py>( Ok(matrix.into_pyarray(py)) } -type ParallelEdgeFn = fn(f64, f64, usize) -> f64; - -fn parallel_edge_fn(parallel_edge: &str) -> PyResult { - match parallel_edge { - "sum" => Ok(|current, edge_weight, _| current + edge_weight), - "min" => Ok(|current, edge_weight, _| current.min(edge_weight)), - "max" => Ok(|current, edge_weight, _| current.max(edge_weight)), - "avg" => Ok(|current, edge_weight, count| { - (current * count as f64 + edge_weight) / ((count + 1) as f64) - }), - _ => Err(PyValueError::new_err( - "Parallel edges can currently only be dealt with using \"sum\", \"min\", \"max\", or \"avg\".", - )), - } -} - -fn apply_biadjacency_edge( - matrix: &mut Array2, - parallel_edge_count: &mut HashMap<[usize; 2], usize>, - row: usize, - column: usize, - edge_weight: f64, - parallel_edge_fn: ParallelEdgeFn, -) { - let key = [row, column]; - let count = parallel_edge_count.entry(key).or_insert(0); - if *count == 0 { - matrix[[row, column]] = edge_weight; - } else { - matrix[[row, column]] = parallel_edge_fn(matrix[[row, column]], edge_weight, *count); - } - *count += 1; -} - -fn validate_biadjacency_node_order( - graph: &StablePyGraph, - row_order: &[usize], - column_order: &[usize], -) -> PyResult<()> { - let mut row_nodes = HashSet::new(); - for node in row_order { - if !row_nodes.insert(*node) { - return Err(PyValueError::new_err(format!( - "row_order contains duplicate node index {node}" - ))); - } - } - - let mut column_nodes = HashSet::new(); - for node in column_order { - if !column_nodes.insert(*node) { - return Err(PyValueError::new_err(format!( - "column_order contains duplicate node index {node}" - ))); - } - if row_nodes.contains(node) { - return Err(PyValueError::new_err(format!( - "row_order and column_order must be disjoint; node index {node} appears in both" - ))); - } - } - - for node in row_order.iter().chain(column_order.iter()) { - if !graph.contains_node(NodeIndex::new(*node)) { - return Err(PyValueError::new_err(format!( - "Node index {node} is not present in the graph" - ))); - } - } - Ok(()) -} - -fn biadjacency_node_map(node_order: &[usize]) -> HashMap { - node_order - .iter() - .enumerate() - .map(|(index, node)| (*node, index)) - .collect() -} - -fn graph_biadjacency_index( - source: usize, - target: usize, - row_map: &HashMap, - column_map: &HashMap, -) -> Option<(usize, usize)> { - row_map - .get(&source) - .and_then(|row| column_map.get(&target).map(|column| (*row, *column))) - .or_else(|| { - row_map - .get(&target) - .and_then(|row| column_map.get(&source).map(|column| (*row, *column))) - }) -} - /// Return the biadjacency matrix for a PyDiGraph class /// -/// This function returns a dense numpy array with rows and columns ordered +/// This function returns a SciPy sparse array with rows and columns ordered /// according to the explicit node index lists passed in. Only directed edges /// from ``row_order`` nodes to ``column_order`` nodes are included. The row /// and column orders must contain unique node indices and must be disjoint. +/// SciPy is required at runtime to use this function. /// /// In the case where there are multiple edges between nodes the value in the /// output matrix will be assigned based on a given parameter. Currently, the minimum, maximum, average, and default sum are supported. @@ -902,20 +808,21 @@ fn graph_biadjacency_index( /// will be passed the edge object and expected to return a ``float``. /// :param float default_weight: If ``weight_fn`` is not used this can be /// optionally used to specify a default weight to use for all edges. -/// :param float null_value: An optional float that will treated as a null -/// value. This is the default value in the output matrix and it is used -/// to indicate the absence of an edge between 2 nodes. By default this is -/// ``0.0``. +/// :param str format: The SciPy sparse array format to return. Defaults to +/// ``"csr"``. /// :param String parallel_edge: Optional argument that determines how the function handles parallel edges. /// ``"min"`` causes the value in the output matrix to be the minimum of the edges' weights, and similar behavior can be expected for ``"max"`` and ``"avg"``. /// The function defaults to ``"sum"`` behavior, where the value in the output matrix is the sum of all parallel edge weights. /// -/// :return: The biadjacency matrix for the input directed graph as a numpy array -/// :rtype: numpy.ndarray +/// :return: The biadjacency matrix for the input directed graph as a SciPy +/// sparse array. +/// :rtype: scipy.sparse.sparray +/// +/// :raises ImportError: If SciPy is not installed. #[pyfunction] #[pyo3( - signature=(graph, row_order, column_order, weight_fn=None, default_weight=1.0, null_value=0.0, parallel_edge="sum"), - text_signature = "(graph, row_order, column_order, /, weight_fn=None, default_weight=1.0, null_value=0.0, parallel_edge=\"sum\")" + signature=(graph, row_order, column_order, weight_fn=None, default_weight=1.0, format="csr", parallel_edge="sum"), + text_signature = "(graph, row_order, column_order, /, weight_fn=None, default_weight=1.0, format=\"csr\", parallel_edge=\"sum\")" )] #[allow(clippy::too_many_arguments)] pub fn digraph_biadjacency_matrix<'py>( @@ -925,37 +832,21 @@ pub fn digraph_biadjacency_matrix<'py>( column_order: Vec, weight_fn: Option>, default_weight: f64, - null_value: f64, + format: &str, parallel_edge: &str, -) -> PyResult>> { - validate_biadjacency_node_order(&graph.graph, &row_order, &column_order)?; - let parallel_edge_fn = parallel_edge_fn(parallel_edge)?; - let row_map = biadjacency_node_map(&row_order); - let column_map = biadjacency_node_map(&column_order); - let mut matrix = Array2::::from_elem((row_order.len(), column_order.len()), null_value); - let mut parallel_edge_count = HashMap::new(); - - let biadjacency_edges = graph.graph.edge_references().filter_map(|edge| { - let source = edge.source().index(); - let target = edge.target().index(); - row_map - .get(&source) - .and_then(|row| column_map.get(&target).map(|column| (*row, *column))) - .map(|(row, column)| (row, column, edge.weight())) - }); - - for (row, column, weight) in biadjacency_edges { - let edge_weight = weight_callable(py, &weight_fn, weight, default_weight)?; - apply_biadjacency_edge( - &mut matrix, - &mut parallel_edge_count, - row, - column, - edge_weight, - parallel_edge_fn, - ); - } - Ok(matrix.into_pyarray(py)) +) -> PyResult> { + biadjacency::digraph_to_biadjacency_matrix( + py, + &graph.graph, + biadjacency::BiadjacencyMatrixOptions { + row_order, + column_order, + weight_fn, + default_weight, + format, + parallel_edge, + }, + ) } /// Return the adjacency matrix for a PyGraph class @@ -1065,10 +956,11 @@ pub fn graph_adjacency_matrix<'py>( /// Return the biadjacency matrix for a PyGraph class /// -/// This function returns a dense numpy array with rows and columns ordered +/// This function returns a SciPy sparse array with rows and columns ordered /// according to the explicit node index lists passed in. Edges between /// ``row_order`` nodes and ``column_order`` nodes are included. The row and /// column orders must contain unique node indices and must be disjoint. +/// SciPy is required at runtime to use this function. /// /// In the case where there are multiple edges between nodes the value in the /// output matrix will be assigned based on a given parameter. Currently, the minimum, maximum, average, and default sum are supported. @@ -1082,20 +974,20 @@ pub fn graph_adjacency_matrix<'py>( /// will be passed the edge object and expected to return a ``float``. /// :param float default_weight: If ``weight_fn`` is not used this can be /// optionally used to specify a default weight to use for all edges. -/// :param float null_value: An optional float that will treated as a null -/// value. This is the default value in the output matrix and it is used -/// to indicate the absence of an edge between 2 nodes. By default this is -/// ``0.0``. +/// :param str format: The SciPy sparse array format to return. Defaults to +/// ``"csr"``. /// :param String parallel_edge: Optional argument that determines how the function handles parallel edges. /// ``"min"`` causes the value in the output matrix to be the minimum of the edges' weights, and similar behavior can be expected for ``"max"`` and ``"avg"``. /// The function defaults to ``"sum"`` behavior, where the value in the output matrix is the sum of all parallel edge weights. /// -/// :return: The biadjacency matrix for the input graph as a numpy array -/// :rtype: numpy.ndarray +/// :return: The biadjacency matrix for the input graph as a SciPy sparse array. +/// :rtype: scipy.sparse.sparray +/// +/// :raises ImportError: If SciPy is not installed. #[pyfunction] #[pyo3( - signature=(graph, row_order, column_order, weight_fn=None, default_weight=1.0, null_value=0.0, parallel_edge="sum"), - text_signature = "(graph, row_order, column_order, /, weight_fn=None, default_weight=1.0, null_value=0.0, parallel_edge=\"sum\")" + signature=(graph, row_order, column_order, weight_fn=None, default_weight=1.0, format="csr", parallel_edge="sum"), + text_signature = "(graph, row_order, column_order, /, weight_fn=None, default_weight=1.0, format=\"csr\", parallel_edge=\"sum\")" )] #[allow(clippy::too_many_arguments)] pub fn graph_biadjacency_matrix<'py>( @@ -1105,35 +997,21 @@ pub fn graph_biadjacency_matrix<'py>( column_order: Vec, weight_fn: Option>, default_weight: f64, - null_value: f64, + format: &str, parallel_edge: &str, -) -> PyResult>> { - validate_biadjacency_node_order(&graph.graph, &row_order, &column_order)?; - let parallel_edge_fn = parallel_edge_fn(parallel_edge)?; - let row_map = biadjacency_node_map(&row_order); - let column_map = biadjacency_node_map(&column_order); - let mut matrix = Array2::::from_elem((row_order.len(), column_order.len()), null_value); - let mut parallel_edge_count = HashMap::new(); - - let biadjacency_edges = graph.graph.edge_references().filter_map(|edge| { - let source = edge.source().index(); - let target = edge.target().index(); - graph_biadjacency_index(source, target, &row_map, &column_map) - .map(|(row, column)| (row, column, edge.weight())) - }); - - for (row, column, weight) in biadjacency_edges { - let edge_weight = weight_callable(py, &weight_fn, weight, default_weight)?; - apply_biadjacency_edge( - &mut matrix, - &mut parallel_edge_count, - row, - column, - edge_weight, - parallel_edge_fn, - ); - } - Ok(matrix.into_pyarray(py)) +) -> PyResult> { + biadjacency::graph_to_biadjacency_matrix( + py, + &graph.graph, + biadjacency::BiadjacencyMatrixOptions { + row_order, + column_order, + weight_fn, + default_weight, + format, + parallel_edge, + }, + ) } /// Compute the complement of an undirected graph. diff --git a/src/digraph.rs b/src/digraph.rs index d19c5ffc93..e176205b56 100644 --- a/src/digraph.rs +++ b/src/digraph.rs @@ -2691,33 +2691,31 @@ impl PyDiGraph { _from_adjacency_matrix(py, matrix, null_value) } - /// Create a new :class:`~rustworkx.PyDiGraph` object from a biadjacency - /// matrix with matrix elements of type ``float``. + /// Create a new :class:`~rustworkx.PyDiGraph` object from a SciPy sparse + /// biadjacency matrix with matrix elements that can be converted to + /// ``float``. /// /// This method can be used to construct a new bipartite /// :class:`~rustworkx.PyDiGraph` object from an input biadjacency matrix. /// For an input matrix with shape ``(m, n)``, the first ``m`` nodes are /// generated from the rows and the next ``n`` nodes are generated from the - /// columns. Each non-null matrix entry creates an edge from row node ``i`` - /// to column node ``m + j``. + /// columns. Each stored sparse matrix entry creates an edge from row node + /// ``i`` to column node ``m + j``. /// - /// :param ndarray matrix: The input numpy array biadjacency matrix to - /// create a new :class:`~rustworkx.PyDiGraph` object from. It must be - /// a 2 dimensional array and be a ``float``/``np.float64`` data type. - /// :param float null_value: An optional float that will treated as a null - /// value. If any element in the input matrix is this value it will be - /// treated as not an edge. By default this is ``0.0``. + /// :param matrix: The input SciPy sparse biadjacency matrix or sparse + /// array to create a new :class:`~rustworkx.PyDiGraph` object from. /// /// :returns: A new graph object generated from the biadjacency matrix /// :rtype: PyDiGraph + /// + /// :raises ImportError: If SciPy is not installed. #[staticmethod] - #[pyo3(signature=(matrix, null_value=0.0), text_signature = "(matrix, /, null_value=0.0)")] + #[pyo3(signature=(matrix), text_signature = "(matrix, /)")] pub fn from_biadjacency_matrix<'p>( py: Python<'p>, - matrix: PyReadonlyArray2<'p, f64>, - null_value: f64, + matrix: &Bound<'p, PyAny>, ) -> PyResult { - _from_biadjacency_matrix(py, matrix, null_value) + crate::connectivity::biadjacency::digraph_from_biadjacency_matrix(py, matrix) } /// Create a new :class:`~rustworkx.PyDiGraph` object from an adjacency matrix @@ -3664,50 +3662,6 @@ where }) } -fn is_not_matrix_null(elem: &T, null_value: T) -> bool -where - T: Copy + std::cmp::PartialEq + IsNan, -{ - if null_value.is_nan() { - !elem.is_nan() - } else { - *elem != null_value - } -} - -fn _from_biadjacency_matrix<'p, T>( - py: Python<'p>, - matrix: PyReadonlyArray2<'p, T>, - null_value: T, -) -> PyResult -where - T: Copy + std::cmp::PartialEq + numpy::Element + pyo3::IntoPyObject<'p> + IsNan, -{ - let array = matrix.as_array(); - let shape = array.shape(); - let mut out_graph = StablePyGraph::::new(); - let _node_indices: Vec = (0..(shape[0] + shape[1])) - .map(|node| Ok(out_graph.add_node(node.into_py_any(py)?))) - .collect::>>()?; - for ((source, target), elem) in array.indexed_iter() { - if is_not_matrix_null(elem, null_value) { - out_graph.add_edge( - NodeIndex::new(source), - NodeIndex::new(shape[0] + target), - elem.into_py_any(py)?, - ); - } - } - Ok(PyDiGraph { - graph: out_graph, - cycle_state: algo::DfsSpace::default(), - check_cycle: false, - node_removed: false, - multigraph: true, - attrs: py.None(), - }) -} - /// Simple wrapper newtype that lets us use `Py` pointers as hash keys with the equality defined by /// the pointer address. This is equivalent to using Python's `is` operator for comparisons. /// Using a newtype rather than casting the pointer to `usize` inline lets us retrieve a copy of diff --git a/src/graph.rs b/src/graph.rs index 87449e40b2..33f2312a59 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -1578,33 +1578,31 @@ impl PyGraph { _from_adjacency_matrix(py, matrix, null_value) } - /// Create a new :class:`~rustworkx.PyGraph` object from a biadjacency - /// matrix with matrix elements of type ``float``. + /// Create a new :class:`~rustworkx.PyGraph` object from a SciPy sparse + /// biadjacency matrix with matrix elements that can be converted to + /// ``float``. /// /// This method can be used to construct a new bipartite /// :class:`~rustworkx.PyGraph` object from an input biadjacency matrix. /// For an input matrix with shape ``(m, n)``, the first ``m`` nodes are /// generated from the rows and the next ``n`` nodes are generated from the - /// columns. Each non-null matrix entry creates an edge between row node - /// ``i`` and column node ``m + j``. + /// columns. Each stored sparse matrix entry creates an edge between row + /// node ``i`` and column node ``m + j``. /// - /// :param ndarray matrix: The input numpy array biadjacency matrix to - /// create a new :class:`~rustworkx.PyGraph` object from. It must be a - /// 2 dimensional array and be a ``float``/``np.float64`` data type. - /// :param float null_value: An optional float that will treated as a null - /// value. If any element in the input matrix is this value it will be - /// treated as not an edge. By default this is ``0.0``. + /// :param matrix: The input SciPy sparse biadjacency matrix or sparse + /// array to create a new :class:`~rustworkx.PyGraph` object from. /// /// :returns: A new graph object generated from the biadjacency matrix /// :rtype: PyGraph + /// + /// :raises ImportError: If SciPy is not installed. #[staticmethod] - #[pyo3(signature=(matrix, null_value=0.0), text_signature = "(matrix, /, null_value=0.0)")] + #[pyo3(signature=(matrix), text_signature = "(matrix, /)")] pub fn from_biadjacency_matrix<'p>( py: Python<'p>, - matrix: PyReadonlyArray2<'p, f64>, - null_value: f64, + matrix: &Bound<'p, PyAny>, ) -> PyResult { - _from_biadjacency_matrix(py, matrix, null_value) + crate::connectivity::biadjacency::graph_from_biadjacency_matrix(py, matrix) } /// Create a new :class:`~rustworkx.PyGraph` object from an adjacency matrix @@ -2338,45 +2336,3 @@ where attrs: py.None(), }) } - -fn is_not_matrix_null(elem: &T, null_value: T) -> bool -where - T: Copy + std::cmp::PartialEq + IsNan, -{ - if null_value.is_nan() { - !elem.is_nan() - } else { - *elem != null_value - } -} - -fn _from_biadjacency_matrix<'p, T>( - py: Python<'p>, - matrix: PyReadonlyArray2<'p, T>, - null_value: T, -) -> PyResult -where - T: Copy + std::cmp::PartialEq + numpy::Element + pyo3::IntoPyObject<'p> + IsNan, -{ - let array = matrix.as_array(); - let shape = array.shape(); - let mut out_graph = StablePyGraph::::default(); - let _node_indices: Vec = (0..(shape[0] + shape[1])) - .map(|node| Ok(out_graph.add_node(node.into_py_any(py)?))) - .collect::>>()?; - for ((source, target), elem) in array.indexed_iter() { - if is_not_matrix_null(elem, null_value) { - out_graph.add_edge( - NodeIndex::new(source), - NodeIndex::new(shape[0] + target), - elem.into_py_any(py)?, - ); - } - } - Ok(PyGraph { - graph: out_graph, - node_removed: false, - multigraph: true, - attrs: py.None(), - }) -} diff --git a/tests/digraph/test_adjacency_matrix.py b/tests/digraph/test_adjacency_matrix.py index 4bf94611b0..c5ee0cfb1c 100644 --- a/tests/digraph/test_adjacency_matrix.py +++ b/tests/digraph/test_adjacency_matrix.py @@ -15,6 +15,11 @@ import rustworkx import numpy as np +try: + import scipy.sparse as sp +except ModuleNotFoundError: + sp = None + class TestDAGAdjacencyMatrix(unittest.TestCase): def test_single_neighbor(self): @@ -379,49 +384,43 @@ def test_parallel_edge(self): ) +@unittest.skipIf(sp is None, "SciPy is not installed, skipping biadjacency matrix tests") class TestDiGraphBiadjacencyMatrix(unittest.TestCase): def test_from_biadjacency_matrix(self): - input_array = np.array( + matrix = sp.csr_array( [[1.0, 0.0, 2.0], [0.0, 3.0, 0.0]], dtype=np.float64, ) - graph = rustworkx.PyDiGraph.from_biadjacency_matrix(input_array) + graph = rustworkx.PyDiGraph.from_biadjacency_matrix(matrix) self.assertEqual(5, graph.num_nodes()) self.assertEqual( [(0, 2, 1.0), (0, 4, 2.0), (1, 3, 3.0)], graph.weighted_edge_list(), ) - def test_from_biadjacency_matrix_non_zero_null(self): - input_array = np.array( - [[np.inf, -1.0], [2.0, np.inf], [np.inf, 4.0]], - dtype=np.float64, - ) - graph = rustworkx.PyDiGraph.from_biadjacency_matrix(input_array, null_value=np.inf) - self.assertEqual(5, graph.num_nodes()) + def test_from_biadjacency_matrix_integer_dtype(self): + matrix = sp.csr_array([[1, 0], [0, 2]], dtype=np.int64) + graph = rustworkx.PyDiGraph.from_biadjacency_matrix(matrix) self.assertEqual( - [(0, 4, -1.0), (1, 3, 2.0), (2, 4, 4.0)], + [(0, 2, 1.0), (1, 3, 2.0)], graph.weighted_edge_list(), ) - def test_from_biadjacency_matrix_nan_null(self): - input_array = np.array( - [[np.nan, 1.0], [2.0, np.nan]], - dtype=np.float64, - ) - graph = rustworkx.PyDiGraph.from_biadjacency_matrix(input_array, null_value=np.nan) + def test_from_biadjacency_matrix_stored_zero(self): + matrix = sp.coo_array(([0.0], ([0], [1])), shape=(1, 2)) + graph = rustworkx.PyDiGraph.from_biadjacency_matrix(matrix) self.assertEqual( - [(0, 3, 1.0), (1, 2, 2.0)], + [(0, 2, 0.0)], graph.weighted_edge_list(), ) - def test_from_biadjacency_matrix_different_dtype(self): - input_array = np.array([[1, 0], [0, 1]], dtype=np.int64) + def test_from_biadjacency_matrix_rejects_dense_input(self): + input_array = np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float64) with self.assertRaises(TypeError): rustworkx.PyDiGraph.from_biadjacency_matrix(input_array) def test_from_biadjacency_matrix_empty_dimension(self): - graph = rustworkx.PyDiGraph.from_biadjacency_matrix(np.zeros((3, 0), dtype=np.float64)) + graph = rustworkx.PyDiGraph.from_biadjacency_matrix(sp.csr_array((3, 0), dtype=np.float64)) self.assertEqual(3, graph.num_nodes()) self.assertEqual([], graph.weighted_edge_list()) @@ -434,7 +433,17 @@ def test_biadjacency_matrix(self): graph, [0, 1], [2, 3, 4], weight_fn=lambda x: x ) expected = np.array([[1.0, 0.0, 2.0], [0.0, 3.0, 0.0]]) - np.testing.assert_array_equal(expected, matrix) + self.assertTrue(sp.issparse(matrix)) + self.assertEqual("csr", matrix.format) + np.testing.assert_array_equal(expected, matrix.toarray()) + + def test_universal_biadjacency_matrix(self): + graph = rustworkx.PyDiGraph() + graph.add_nodes_from(range(2)) + graph.add_edge(0, 1, 5.0) + + matrix = rustworkx.biadjacency_matrix(graph, [0], [1], weight_fn=float) + np.testing.assert_array_equal([[5.0]], matrix.toarray()) def test_biadjacency_matrix_ignores_incoming_edges(self): graph = rustworkx.PyDiGraph() @@ -442,7 +451,7 @@ def test_biadjacency_matrix_ignores_incoming_edges(self): graph.add_edge(2, 0, 7.0) matrix = rustworkx.digraph_biadjacency_matrix(graph, [0], [2], weight_fn=float) - np.testing.assert_array_equal([[0.0]], matrix) + np.testing.assert_array_equal([[0.0]], matrix.toarray()) def test_biadjacency_matrix_parallel_edges(self): graph = rustworkx.PyDiGraph() @@ -452,12 +461,12 @@ def test_biadjacency_matrix_parallel_edges(self): max_matrix = rustworkx.digraph_biadjacency_matrix( graph, [0], [1], weight_fn=float, parallel_edge="max" ) - np.testing.assert_array_equal([[3.0]], max_matrix) + np.testing.assert_array_equal([[3.0]], max_matrix.toarray()) min_matrix = rustworkx.digraph_biadjacency_matrix( graph, [0], [1], weight_fn=float, parallel_edge="min" ) - np.testing.assert_array_equal([[1.0]], min_matrix) + np.testing.assert_array_equal([[1.0]], min_matrix.toarray()) def test_biadjacency_matrix_default_weight(self): graph = rustworkx.PyDiGraph() @@ -465,17 +474,18 @@ def test_biadjacency_matrix_default_weight(self): graph.add_edge(0, 1, "edge") matrix = rustworkx.digraph_biadjacency_matrix(graph, [0], [1], default_weight=5.0) - np.testing.assert_array_equal([[5.0]], matrix) + np.testing.assert_array_equal([[5.0]], matrix.toarray()) - def test_biadjacency_matrix_null_value(self): + def test_biadjacency_matrix_sparse_format(self): graph = rustworkx.PyDiGraph() graph.add_nodes_from(range(3)) graph.add_edge(0, 1, 2.0) matrix = rustworkx.digraph_biadjacency_matrix( - graph, [0], [1, 2], weight_fn=float, null_value=-1.0 + graph, [0], [1, 2], weight_fn=float, format="coo" ) - np.testing.assert_array_equal([[2.0, -1.0]], matrix) + self.assertEqual("coo", matrix.format) + np.testing.assert_array_equal([[2.0, 0.0]], matrix.toarray()) def test_biadjacency_matrix_invalid_parallel_edge(self): graph = rustworkx.PyDiGraph() @@ -516,7 +526,7 @@ def test_biadjacency_matrix_non_contiguous_node_indices(self): graph.remove_node(1) matrix = rustworkx.digraph_biadjacency_matrix(graph, [0], [3], weight_fn=float) - np.testing.assert_array_equal([[7.0]], matrix) + np.testing.assert_array_equal([[7.0]], matrix.toarray()) with self.assertRaises(ValueError): rustworkx.digraph_biadjacency_matrix(graph, [0], [1]) @@ -526,4 +536,5 @@ def test_biadjacency_matrix_empty_order(self): graph.add_nodes_from(range(2)) matrix = rustworkx.digraph_biadjacency_matrix(graph, [0], []) + self.assertTrue(sp.issparse(matrix)) self.assertEqual((1, 0), matrix.shape) diff --git a/tests/graph/test_adjacency_matrix.py b/tests/graph/test_adjacency_matrix.py index 24dab50771..875a3ecc08 100644 --- a/tests/graph/test_adjacency_matrix.py +++ b/tests/graph/test_adjacency_matrix.py @@ -15,6 +15,11 @@ import rustworkx import numpy as np +try: + import scipy.sparse as sp +except ModuleNotFoundError: + sp = None + class TestGraphAdjacencyMatrix(unittest.TestCase): def test_single_neighbor(self): @@ -377,49 +382,43 @@ def test_parallel_edge(self): ) +@unittest.skipIf(sp is None, "SciPy is not installed, skipping biadjacency matrix tests") class TestGraphBiadjacencyMatrix(unittest.TestCase): def test_from_biadjacency_matrix(self): - input_array = np.array( + matrix = sp.csr_array( [[1.0, 0.0, 2.0], [0.0, 3.0, 0.0]], dtype=np.float64, ) - graph = rustworkx.PyGraph.from_biadjacency_matrix(input_array) + graph = rustworkx.PyGraph.from_biadjacency_matrix(matrix) self.assertEqual(5, graph.num_nodes()) self.assertEqual( [(0, 2, 1.0), (0, 4, 2.0), (1, 3, 3.0)], graph.weighted_edge_list(), ) - def test_from_biadjacency_matrix_non_zero_null(self): - input_array = np.array( - [[np.inf, -1.0], [2.0, np.inf], [np.inf, 4.0]], - dtype=np.float64, - ) - graph = rustworkx.PyGraph.from_biadjacency_matrix(input_array, null_value=np.inf) - self.assertEqual(5, graph.num_nodes()) + def test_from_biadjacency_matrix_integer_dtype(self): + matrix = sp.csr_array([[1, 0], [0, 2]], dtype=np.int64) + graph = rustworkx.PyGraph.from_biadjacency_matrix(matrix) self.assertEqual( - [(0, 4, -1.0), (1, 3, 2.0), (2, 4, 4.0)], + [(0, 2, 1.0), (1, 3, 2.0)], graph.weighted_edge_list(), ) - def test_from_biadjacency_matrix_nan_null(self): - input_array = np.array( - [[np.nan, 1.0], [2.0, np.nan]], - dtype=np.float64, - ) - graph = rustworkx.PyGraph.from_biadjacency_matrix(input_array, null_value=np.nan) + def test_from_biadjacency_matrix_stored_zero(self): + matrix = sp.coo_array(([0.0], ([0], [1])), shape=(1, 2)) + graph = rustworkx.PyGraph.from_biadjacency_matrix(matrix) self.assertEqual( - [(0, 3, 1.0), (1, 2, 2.0)], + [(0, 2, 0.0)], graph.weighted_edge_list(), ) - def test_from_biadjacency_matrix_different_dtype(self): - input_array = np.array([[1, 0], [0, 1]], dtype=np.int64) + def test_from_biadjacency_matrix_rejects_dense_input(self): + input_array = np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float64) with self.assertRaises(TypeError): rustworkx.PyGraph.from_biadjacency_matrix(input_array) def test_from_biadjacency_matrix_empty_dimension(self): - graph = rustworkx.PyGraph.from_biadjacency_matrix(np.zeros((0, 3), dtype=np.float64)) + graph = rustworkx.PyGraph.from_biadjacency_matrix(sp.csr_array((0, 3), dtype=np.float64)) self.assertEqual(3, graph.num_nodes()) self.assertEqual([], graph.weighted_edge_list()) @@ -428,9 +427,11 @@ def test_biadjacency_matrix(self): graph.add_nodes_from(range(5)) graph.add_edges_from([(0, 2, 1.0), (0, 4, 2.0), (1, 3, 3.0)]) - matrix = rustworkx.graph_biadjacency_matrix(graph, [0, 1], [2, 3, 4], weight_fn=lambda x: x) + matrix = rustworkx.biadjacency_matrix(graph, [0, 1], [2, 3, 4], weight_fn=lambda x: x) expected = np.array([[1.0, 0.0, 2.0], [0.0, 3.0, 0.0]]) - np.testing.assert_array_equal(expected, matrix) + self.assertTrue(sp.issparse(matrix)) + self.assertEqual("csr", matrix.format) + np.testing.assert_array_equal(expected, matrix.toarray()) def test_biadjacency_matrix_reverse_edge_order(self): graph = rustworkx.PyGraph() @@ -438,7 +439,7 @@ def test_biadjacency_matrix_reverse_edge_order(self): graph.add_edge(2, 0, 7.0) matrix = rustworkx.graph_biadjacency_matrix(graph, [0], [2], weight_fn=float) - np.testing.assert_array_equal([[7.0]], matrix) + np.testing.assert_array_equal([[7.0]], matrix.toarray()) def test_biadjacency_matrix_parallel_edges(self): graph = rustworkx.PyGraph() @@ -448,12 +449,12 @@ def test_biadjacency_matrix_parallel_edges(self): sum_matrix = rustworkx.graph_biadjacency_matrix( graph, [0], [1], weight_fn=float, parallel_edge="sum" ) - np.testing.assert_array_equal([[4.0]], sum_matrix) + np.testing.assert_array_equal([[4.0]], sum_matrix.toarray()) avg_matrix = rustworkx.graph_biadjacency_matrix( graph, [0], [1], weight_fn=float, parallel_edge="avg" ) - np.testing.assert_array_equal([[2.0]], avg_matrix) + np.testing.assert_array_equal([[2.0]], avg_matrix.toarray()) def test_biadjacency_matrix_default_weight(self): graph = rustworkx.PyGraph() @@ -461,17 +462,18 @@ def test_biadjacency_matrix_default_weight(self): graph.add_edge(0, 1, "edge") matrix = rustworkx.graph_biadjacency_matrix(graph, [0], [1], default_weight=5.0) - np.testing.assert_array_equal([[5.0]], matrix) + np.testing.assert_array_equal([[5.0]], matrix.toarray()) - def test_biadjacency_matrix_null_value(self): + def test_biadjacency_matrix_sparse_format(self): graph = rustworkx.PyGraph() graph.add_nodes_from(range(3)) graph.add_edge(0, 1, 2.0) matrix = rustworkx.graph_biadjacency_matrix( - graph, [0], [1, 2], weight_fn=float, null_value=-1.0 + graph, [0], [1, 2], weight_fn=float, format="coo" ) - np.testing.assert_array_equal([[2.0, -1.0]], matrix) + self.assertEqual("coo", matrix.format) + np.testing.assert_array_equal([[2.0, 0.0]], matrix.toarray()) def test_biadjacency_matrix_invalid_parallel_edge(self): graph = rustworkx.PyGraph() @@ -512,7 +514,7 @@ def test_biadjacency_matrix_non_contiguous_node_indices(self): graph.remove_node(1) matrix = rustworkx.graph_biadjacency_matrix(graph, [0], [3], weight_fn=float) - np.testing.assert_array_equal([[7.0]], matrix) + np.testing.assert_array_equal([[7.0]], matrix.toarray()) with self.assertRaises(ValueError): rustworkx.graph_biadjacency_matrix(graph, [0], [1]) @@ -522,4 +524,5 @@ def test_biadjacency_matrix_empty_order(self): graph.add_nodes_from(range(2)) matrix = rustworkx.graph_biadjacency_matrix(graph, [], [1]) + self.assertTrue(sp.issparse(matrix)) self.assertEqual((0, 1), matrix.shape) diff --git a/uv.lock b/uv.lock index efa3abc9c0..100db9bd83 100644 --- a/uv.lock +++ b/uv.lock @@ -2680,7 +2680,7 @@ wheels = [ [[package]] name = "rustworkx" -version = "0.17.1" +version = "0.18.0" source = { editable = "." } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -2732,6 +2732,7 @@ test = [ { name = "maturin" }, { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", marker = "python_full_version >= '3.11' and sys_platform != 'win32'" }, { name = "stestr" }, { name = "testtools" }, ] @@ -2778,6 +2779,7 @@ stubs = [ test = [ { name = "maturin", specifier = ">=1.9.0,<2.0" }, { name = "networkx", specifier = ">=3.2" }, + { name = "scipy", marker = "python_full_version >= '3.11' and sys_platform != 'win32'", specifier = ">=1.11" }, { name = "stestr", specifier = ">=4.1" }, { name = "testtools", specifier = ">=2.5.0" }, ] @@ -2786,6 +2788,65 @@ testinfra = [ { name = "uv", specifier = "==0.11.15" }, ] +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, +] + [[package]] name = "setuptools" version = "82.0.1"