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/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/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 new file mode 100644 index 0000000000..db5e033b3b --- /dev/null +++ b/releasenotes/notes/add-biadjacency-matrix-1412.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Added :meth:`~rustworkx.PyGraph.from_biadjacency_matrix`, + :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 bdd554d8cd..17f53280c5 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 @@ -346,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 0df0ee667d..732d3f94be 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 = ..., + format: str = ..., + parallel_edge: str = ..., +) -> Any: ... +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 = ..., + format: str = ..., + parallel_edge: str = ..., +) -> 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]]: ... @@ -1438,6 +1458,8 @@ class PyGraph(Generic[_S, _T]): matrix: npt.NDArray[np.float64], /, null_value: float = ... ) -> PyGraph[int, float]: ... @staticmethod + def from_biadjacency_matrix(matrix: Any, /) -> PyGraph[int, float]: ... + @staticmethod def from_complex_adjacency_matrix( matrix: npt.NDArray[np.complex64], /, null_value: complex = ... ) -> PyGraph[int, complex]: ... @@ -1622,6 +1644,8 @@ class PyDiGraph(Generic[_S, _T]): matrix: npt.NDArray[np.float64], /, null_value: float = ... ) -> PyDiGraph[int, float]: ... @staticmethod + def from_biadjacency_matrix(matrix: Any, /) -> 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/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 b6d05e1006..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,6 +787,68 @@ pub fn digraph_adjacency_matrix<'py>( Ok(matrix.into_pyarray(py)) } +/// Return the biadjacency matrix for a PyDiGraph class +/// +/// 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. +/// +/// :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 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 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, 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>( + py: Python<'py>, + graph: &digraph::PyDiGraph, + row_order: Vec, + column_order: Vec, + weight_fn: Option>, + default_weight: f64, + format: &str, + parallel_edge: &str, +) -> 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 /// /// In the case where there are multiple edges between nodes the value in the @@ -891,6 +954,66 @@ pub fn graph_adjacency_matrix<'py>( Ok(matrix.into_pyarray(py)) } +/// Return the biadjacency matrix for a PyGraph class +/// +/// 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. +/// +/// :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 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 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, 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>( + py: Python<'py>, + graph: &graph::PyGraph, + row_order: Vec, + column_order: Vec, + weight_fn: Option>, + default_weight: f64, + format: &str, + parallel_edge: &str, +) -> 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. /// /// :param PyGraph graph: The graph to be used. diff --git a/src/digraph.rs b/src/digraph.rs index f454dee2f9..e176205b56 100644 --- a/src/digraph.rs +++ b/src/digraph.rs @@ -2691,6 +2691,33 @@ impl PyDiGraph { _from_adjacency_matrix(py, matrix, null_value) } + /// 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 stored sparse matrix entry creates an edge from row node + /// ``i`` to column node ``m + j``. + /// + /// :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), text_signature = "(matrix, /)")] + pub fn from_biadjacency_matrix<'p>( + py: Python<'p>, + matrix: &Bound<'p, PyAny>, + ) -> PyResult { + crate::connectivity::biadjacency::digraph_from_biadjacency_matrix(py, matrix) + } + /// Create a new :class:`~rustworkx.PyDiGraph` object from an adjacency matrix /// with matrix elements of type ``complex`` /// diff --git a/src/graph.rs b/src/graph.rs index d9cd17611d..33f2312a59 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -1578,6 +1578,33 @@ impl PyGraph { _from_adjacency_matrix(py, matrix, null_value) } + /// 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 stored sparse matrix entry creates an edge between row + /// node ``i`` and column node ``m + j``. + /// + /// :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), text_signature = "(matrix, /)")] + pub fn from_biadjacency_matrix<'p>( + py: Python<'p>, + matrix: &Bound<'p, PyAny>, + ) -> PyResult { + crate::connectivity::biadjacency::graph_from_biadjacency_matrix(py, matrix) + } + /// Create a new :class:`~rustworkx.PyGraph` object from an adjacency matrix /// with matrix elements of type ``complex`` /// 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..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): @@ -377,3 +382,159 @@ def test_parallel_edge(self): rustworkx.digraph_adjacency_matrix( graph, weight_fn=lambda x: float(x), parallel_edge="error" ) + + +@unittest.skipIf(sp is None, "SciPy is not installed, skipping biadjacency matrix tests") +class TestDiGraphBiadjacencyMatrix(unittest.TestCase): + def test_from_biadjacency_matrix(self): + 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(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_integer_dtype(self): + matrix = sp.csr_array([[1, 0], [0, 2]], dtype=np.int64) + graph = rustworkx.PyDiGraph.from_biadjacency_matrix(matrix) + self.assertEqual( + [(0, 2, 1.0), (1, 3, 2.0)], + graph.weighted_edge_list(), + ) + + 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, 2, 0.0)], + graph.weighted_edge_list(), + ) + + 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(sp.csr_array((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]]) + 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() + 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.toarray()) + + 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.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.toarray()) + + 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.toarray()) + + 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, format="coo" + ) + 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() + 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.toarray()) + + 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.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 722aeec3c1..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): @@ -375,3 +380,149 @@ def test_parallel_edge(self): rustworkx.graph_adjacency_matrix( graph, weight_fn=lambda x: float(x), parallel_edge="error" ) + + +@unittest.skipIf(sp is None, "SciPy is not installed, skipping biadjacency matrix tests") +class TestGraphBiadjacencyMatrix(unittest.TestCase): + def test_from_biadjacency_matrix(self): + 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(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_integer_dtype(self): + matrix = sp.csr_array([[1, 0], [0, 2]], dtype=np.int64) + graph = rustworkx.PyGraph.from_biadjacency_matrix(matrix) + self.assertEqual( + [(0, 2, 1.0), (1, 3, 2.0)], + graph.weighted_edge_list(), + ) + + 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, 2, 0.0)], + graph.weighted_edge_list(), + ) + + 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(sp.csr_array((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.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]]) + 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() + 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.toarray()) + + 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.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.toarray()) + + 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.toarray()) + + 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, format="coo" + ) + 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() + 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.toarray()) + + 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.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"