From dcf1f439a89d45c7780fcc49ce0b5869d699ae33 Mon Sep 17 00:00:00 2001 From: Amin Alam Date: Sun, 26 Oct 2025 20:50:12 +0100 Subject: [PATCH 01/12] pycache and pytest added --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index b3d2a18..519d31b 100644 --- a/.gitignore +++ b/.gitignore @@ -41,10 +41,12 @@ htmlcov/ .coverage .coverage.* .cache +.pytest_cache/ nosetests.xml coverage.xml *.cover .hypothesis/ +.pytest/ # Translations *.mo From 774461460246ced83be2e084c2e43dcbacdd63a4 Mon Sep 17 00:00:00 2001 From: Amin Alam Date: Sun, 26 Oct 2025 20:50:50 +0100 Subject: [PATCH 02/12] Handled empty edges case in forceatlas2_igraph_layout --- fa2_modified/forceatlas2.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/fa2_modified/forceatlas2.py b/fa2_modified/forceatlas2.py index fa7a67e..e486ddf 100644 --- a/fa2_modified/forceatlas2.py +++ b/fa2_modified/forceatlas2.py @@ -272,7 +272,12 @@ def to_sparse(graph, weight_attr=None): edges.extend([(v, u) for u, v in edges]) weights.extend(weights) - return csr_matrix((weights, list(zip(*edges)))) + # Handle empty edges case + n = graph.vcount() + if len(edges) == 0: + return csr_matrix((n, n)) + + return csr_matrix((weights, list(zip(*edges))), shape=(n, n)) assert isinstance(G, igraph.Graph), "Not a igraph graph" assert isinstance(pos, (list, numpy.ndarray)) or (pos is None), "pos must be a list or numpy array" From 9031fc455fba1cc783d11941a915acdb1f5418f6 Mon Sep 17 00:00:00 2001 From: Amin Alam Date: Sun, 26 Oct 2025 20:51:48 +0100 Subject: [PATCH 03/12] tests added --- pytest.ini | 32 ++ requirements-test.txt | 21 ++ tests/__init__.py | 2 + tests/conftest.py | 99 ++++++ tests/test_api_consistency.py | 387 ++++++++++++++++++++ tests/test_fa2util.py | 552 +++++++++++++++++++++++++++++ tests/test_fa2util_precise.py | 511 ++++++++++++++++++++++++++ tests/test_forceatlas2.py | 474 +++++++++++++++++++++++++ tests/test_igraph_integration.py | 298 ++++++++++++++++ tests/test_integration.py | 364 +++++++++++++++++++ tests/test_networkx_integration.py | 290 +++++++++++++++ tests/test_utils.py | 76 ++++ 12 files changed, 3106 insertions(+) create mode 100644 pytest.ini create mode 100644 requirements-test.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_api_consistency.py create mode 100644 tests/test_fa2util.py create mode 100644 tests/test_fa2util_precise.py create mode 100644 tests/test_forceatlas2.py create mode 100644 tests/test_igraph_integration.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_networkx_integration.py create mode 100644 tests/test_utils.py diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c9ffd92 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,32 @@ +[pytest] +# pytest configuration for fa2_modified + +# Test discovery patterns +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +# Test paths +testpaths = tests + +# Output options +addopts = + -v + --strict-markers + --tb=short + --disable-warnings + +# Markers +markers = + slow: marks tests as slow (deselect with '-m "not slow"') + integration: marks tests as integration tests + requires_networkx: requires networkx to be installed + requires_igraph: requires igraph to be installed + +# Coverage options (if using pytest-cov) +# Uncomment to enable coverage reporting +# addopts = --cov=fa2_modified --cov-report=html --cov-report=term + +# Minimum Python version +minversion = 3.8 + diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..958e4ef --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,21 @@ +# Testing dependencies for fa2_modified + +# Core testing framework +pytest>=7.0.0 +pytest-cov>=4.0.0 + +# Core dependencies (required) +numpy>=1.20.0 +scipy>=1.7.0 +tqdm>=4.60.0 + +# Optional integration testing dependencies +networkx>=2.5 +python-igraph>=0.9.0; platform_system != "Windows" + +# Code quality tools +flake8>=6.0.0 +black>=23.0.0 +isort>=5.12.0 +mypy>=1.0.0 + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..901dbef --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +# Test package for fa2_modified + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0e589a5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,99 @@ +""" +Pytest configuration and fixtures for fa2_modified tests +""" +import pytest +import numpy as np + + +@pytest.fixture +def simple_adjacency_matrix(): + """Simple 4-node graph as adjacency matrix""" + return np.array([ + [0, 1, 0, 1], + [1, 0, 1, 0], + [0, 1, 0, 1], + [1, 0, 1, 0] + ]) + + +@pytest.fixture +def weighted_adjacency_matrix(): + """Simple weighted graph as adjacency matrix""" + return np.array([ + [0.0, 2.5, 0.0, 1.0], + [2.5, 0.0, 3.0, 0.0], + [0.0, 3.0, 0.0, 1.5], + [1.0, 0.0, 1.5, 0.0] + ]) + + +@pytest.fixture +def triangle_graph(): + """Triangle graph (3 nodes, all connected)""" + return np.array([ + [0, 1, 1], + [1, 0, 1], + [1, 1, 0] + ]) + + +@pytest.fixture +def star_graph(): + """Star graph (1 hub connected to 4 leaves)""" + n = 5 + G = np.zeros((n, n)) + for i in range(1, n): + G[0, i] = 1 + G[i, 0] = 1 + return G + + +@pytest.fixture +def disconnected_graph(): + """Graph with two disconnected components""" + return np.array([ + [0, 1, 0, 0], + [1, 0, 0, 0], + [0, 0, 0, 1], + [0, 0, 1, 0] + ]) + + +@pytest.fixture +def ring_graph(): + """Ring graph (nodes in a circle)""" + n = 6 + G = np.zeros((n, n)) + for i in range(n): + G[i, (i + 1) % n] = 1 + G[(i + 1) % n, i] = 1 + return G + + +@pytest.fixture +def random_seed(): + """Set random seed for reproducible tests""" + import random + np.random.seed(42) + random.seed(42) + yield + # Reset after test + np.random.seed(None) + random.seed(None) + + +def pytest_configure(config): + """Configure pytest with custom markers""" + config.addinivalue_line( + "markers", "slow: marks tests as slow (deselect with '-m \"not slow\"')" + ) + config.addinivalue_line( + "markers", "integration: marks tests as integration tests" + ) + config.addinivalue_line( + "markers", "requires_networkx: requires networkx to be installed" + ) + config.addinivalue_line( + "markers", "requires_igraph: requires igraph to be installed" + ) + diff --git a/tests/test_api_consistency.py b/tests/test_api_consistency.py new file mode 100644 index 0000000..6bb9f8e --- /dev/null +++ b/tests/test_api_consistency.py @@ -0,0 +1,387 @@ +""" +API consistency and error handling tests +Tests inspired by external ForceAtlas2 implementations +""" +import pytest +import numpy as np +import scipy.sparse +import numbers + +try: + import networkx as nx + NETWORKX_AVAILABLE = True +except ImportError: + NETWORKX_AVAILABLE = False + +from fa2_modified import ForceAtlas2 + + +class TestForceAtlas2APIConsistency: + """Test API consistency across different input types""" + + @pytest.mark.skipif(not NETWORKX_AVAILABLE, reason="NetworkX not installed") + def test_api_consistency_random_vs_custom_positions(self): + """ + Run ForceAtlas2 layout on the same graph twice: + once with random initial positions and once with custom positions. + Both should return valid dictionaries with proper structure. + """ + G = nx.path_graph(10) + forceatlas = ForceAtlas2(verbose=False) + + # Layout with random initial positions + pos1 = forceatlas.forceatlas2_networkx_layout(G, pos=None, iterations=10) + + # Layout with user-defined positions + pos_initial = {n: (0.1 * n, 0.1 * n) for n in G.nodes()} + pos2 = forceatlas.forceatlas2_networkx_layout(G, pos=pos_initial, iterations=10) + + # Both outputs must be dictionaries with 10 items + assert isinstance(pos1, dict) + assert isinstance(pos2, dict) + assert len(pos1) == 10 + assert len(pos2) == 10 + + # Keys should match + assert set(pos1.keys()) == set(pos2.keys()) + + # All values should be valid (x,y) tuples + for p in pos1.values(): + assert isinstance(p, tuple) + assert len(p) == 2 + assert all(isinstance(coord, numbers.Real) for coord in p) + + for p in pos2.values(): + assert isinstance(p, tuple) + assert len(p) == 2 + assert all(isinstance(coord, numbers.Real) for coord in p) + + @pytest.mark.skipif(not NETWORKX_AVAILABLE, reason="NetworkX not installed") + def test_api_consistency_custom_parameters(self): + """ + Test ForceAtlas2 with various custom parameters to ensure + API consistency across different configurations. + """ + G = nx.complete_graph(5) + pos = {i: (float(i), float(i)) for i in G.nodes()} + + forceatlas = ForceAtlas2( + outboundAttractionDistribution=True, + edgeWeightInfluence=0.5, + jitterTolerance=5.0, + barnesHutOptimize=True, + barnesHutTheta=1.5, + scalingRatio=1.0, + strongGravityMode=True, + gravity=0.5, + verbose=False + ) + + new_pos = forceatlas.forceatlas2_networkx_layout(G, pos=pos, iterations=20) + + # Verify output structure + assert isinstance(new_pos, dict) + assert len(new_pos) == 5 + + for key, value in new_pos.items(): + assert isinstance(value, tuple) + assert len(value) == 2 + assert all(isinstance(coord, numbers.Real) for coord in value) + + def test_api_consistency_numpy_sparse(self): + """Test that numpy array and sparse matrix give compatible results""" + # Create same graph as numpy array and sparse matrix + G_numpy = np.array([ + [0, 1, 1, 0], + [1, 0, 1, 1], + [1, 1, 0, 1], + [0, 1, 1, 0] + ], dtype=float) + + G_sparse = scipy.sparse.lil_matrix(G_numpy) + + forceatlas = ForceAtlas2(verbose=False) + + # Use same seed for both + np.random.seed(42) + import random + random.seed(42) + pos_numpy = forceatlas.forceatlas2(G_numpy, iterations=10) + + np.random.seed(42) + random.seed(42) + pos_sparse = forceatlas.forceatlas2(G_sparse, iterations=10) + + # Both should return same number of positions + assert len(pos_numpy) == len(pos_sparse) == 4 + + # Positions should be very similar (identical with same seed) + for i in range(4): + assert pos_numpy[i][0] == pytest.approx(pos_sparse[i][0], abs=1e-10) + assert pos_numpy[i][1] == pytest.approx(pos_sparse[i][1], abs=1e-10) + + +class TestForceAtlas2ErrorHandling: + """Test error handling for invalid inputs""" + + def test_invalid_matrix_non_square(self): + """Test that non-square matrix raises AssertionError""" + forceatlas = ForceAtlas2(verbose=False) + + with pytest.raises(AssertionError): + G = np.array([[0, 1], [1, 0], [0, 1]], dtype=float) + forceatlas.init(G, pos=None) + + def test_invalid_matrix_non_symmetric(self): + """Test that non-symmetric matrix raises AssertionError""" + forceatlas = ForceAtlas2(verbose=False) + + with pytest.raises(AssertionError): + G = np.array([[0, 1, 0], [0, 0, 1], [1, 0, 0]], dtype=float) + forceatlas.init(G, pos=None) + + def test_invalid_position_type(self): + """Test that invalid position type raises AssertionError""" + forceatlas = ForceAtlas2(verbose=False) + G = np.array([[0, 1], [1, 0]], dtype=float) + + with pytest.raises(AssertionError): + forceatlas.init(G, pos="invalid") + + def test_invalid_position_shape(self): + """Test that position array with wrong shape raises error""" + forceatlas = ForceAtlas2(verbose=False) + G = np.array([[0, 1], [1, 0]], dtype=float) + + # Position array with wrong number of nodes + with pytest.raises((AssertionError, IndexError, ValueError)): + pos = np.array([[0.0, 0.0]]) # Only 1 position for 2 nodes + forceatlas.forceatlas2(G, pos=pos, iterations=10) + + def test_invalid_graph_type(self): + """Test that invalid graph type raises AssertionError""" + forceatlas = ForceAtlas2(verbose=False) + + with pytest.raises(AssertionError): + forceatlas.init([[0, 1], [1, 0]], pos=None) + + @pytest.mark.skipif(not NETWORKX_AVAILABLE, reason="NetworkX not installed") + def test_networkx_invalid_graph_type(self): + """Test that non-NetworkX object raises AssertionError""" + forceatlas = ForceAtlas2(verbose=False) + + with pytest.raises(AssertionError): + forceatlas.forceatlas2_networkx_layout( + "not a NetworkX graph", + pos=None, + iterations=10 + ) + + @pytest.mark.skipif(not NETWORKX_AVAILABLE, reason="NetworkX not installed") + def test_networkx_invalid_position_type(self): + """Test that invalid position type for NetworkX raises AssertionError""" + G = nx.complete_graph(5) + forceatlas = ForceAtlas2(verbose=False) + + with pytest.raises(AssertionError): + forceatlas.forceatlas2_networkx_layout( + G, + pos="invalid", + iterations=10 + ) + + @pytest.mark.skipif(not NETWORKX_AVAILABLE, reason="NetworkX not installed") + def test_networkx_position_list_not_dict(self): + """Test that position as list (not dict) raises AssertionError""" + G = nx.complete_graph(3) + forceatlas = ForceAtlas2(verbose=False) + + with pytest.raises(AssertionError): + forceatlas.forceatlas2_networkx_layout( + G, + pos=[(0, 0), (1, 1), (2, 2)], + iterations=10 + ) + + +class TestForceAtlas2InitializationEdgeCases: + """Test edge cases in ForceAtlas2 initialization""" + + def test_init_returns_correct_types(self): + """Test that init returns lists of correct types""" + G = np.array([[0, 1], [1, 0]], dtype=float) + forceatlas = ForceAtlas2(verbose=False) + nodes, edges = forceatlas.init(G, pos=None) + + assert isinstance(nodes, list) + assert isinstance(edges, list) + assert all(hasattr(n, 'mass') for n in nodes) + assert all(hasattr(e, 'weight') for e in edges) + + def test_init_edge_count(self): + """Test that edges are counted correctly (undirected)""" + # Triangle graph: 3 edges + G = np.array([ + [0, 1, 1], + [1, 0, 1], + [1, 1, 0] + ], dtype=float) + + forceatlas = ForceAtlas2(verbose=False) + nodes, edges = forceatlas.init(G, pos=None) + + # Should have 3 edges (not 6, since we only count upper triangle) + assert len(edges) == 3 + + def test_init_node_mass_calculation(self): + """Test that node mass is calculated as 1 + degree""" + G = np.array([ + [0, 1, 1, 1], + [1, 0, 0, 0], + [1, 0, 0, 0], + [1, 0, 0, 0] + ], dtype=float) + + forceatlas = ForceAtlas2(verbose=False) + nodes, edges = forceatlas.init(G, pos=None) + + # Node 0 has degree 3, so mass should be 4 + assert nodes[0].mass == 4.0 + + # Nodes 1, 2, 3 each have degree 1, so mass should be 2 + assert nodes[1].mass == 2.0 + assert nodes[2].mass == 2.0 + assert nodes[3].mass == 2.0 + + def test_init_with_weighted_edges(self): + """Test that weighted edges preserve their weights""" + G = np.array([ + [0.0, 2.5, 0.0], + [2.5, 0.0, 3.7], + [0.0, 3.7, 0.0] + ], dtype=float) + + forceatlas = ForceAtlas2(verbose=False) + nodes, edges = forceatlas.init(G, pos=None) + + # Check that edge weights are preserved + weights = sorted([e.weight for e in edges]) + assert weights == pytest.approx([2.5, 3.7]) + + def test_init_with_custom_positions(self): + """Test that custom positions are used correctly""" + G = np.array([[0, 1], [1, 0]], dtype=float) + pos = np.array([[1.5, 2.5], [3.5, 4.5]]) + + forceatlas = ForceAtlas2(verbose=False) + nodes, edges = forceatlas.init(G, pos=pos) + + assert nodes[0].x == pytest.approx(1.5) + assert nodes[0].y == pytest.approx(2.5) + assert nodes[1].x == pytest.approx(3.5) + assert nodes[1].y == pytest.approx(4.5) + + +class TestForceAtlas2OutputFormat: + """Test output format consistency""" + + def test_output_is_list_of_tuples(self): + """Test that forceatlas2 returns list of (x,y) tuples""" + G = np.array([[0, 1, 1], [1, 0, 1], [1, 1, 0]], dtype=float) + forceatlas = ForceAtlas2(verbose=False) + positions = forceatlas.forceatlas2(G, iterations=10) + + assert isinstance(positions, list) + assert len(positions) == 3 + + for pos in positions: + assert isinstance(pos, tuple) + assert len(pos) == 2 + assert isinstance(pos[0], numbers.Real) + assert isinstance(pos[1], numbers.Real) + + @pytest.mark.skipif(not NETWORKX_AVAILABLE, reason="NetworkX not installed") + def test_networkx_output_is_dict(self): + """Test that NetworkX layout returns dict""" + G = nx.path_graph(5) + forceatlas = ForceAtlas2(verbose=False) + positions = forceatlas.forceatlas2_networkx_layout(G, iterations=10) + + assert isinstance(positions, dict) + assert len(positions) == 5 + assert set(positions.keys()) == set(G.nodes()) + + for node, pos in positions.items(): + assert isinstance(pos, tuple) + assert len(pos) == 2 + assert isinstance(pos[0], numbers.Real) + assert isinstance(pos[1], numbers.Real) + + def test_positions_are_finite(self): + """Test that all positions are finite (not NaN or inf)""" + G = np.array([ + [0, 1, 1, 0], + [1, 0, 1, 1], + [1, 1, 0, 1], + [0, 1, 1, 0] + ], dtype=float) + + forceatlas = ForceAtlas2(verbose=False) + positions = forceatlas.forceatlas2(G, iterations=50) + + for pos in positions: + assert np.isfinite(pos[0]) + assert np.isfinite(pos[1]) + + def test_positions_are_not_all_same(self): + """Test that positions are not all identical (layout has spread)""" + G = np.array([ + [0, 1, 1], + [1, 0, 1], + [1, 1, 0] + ], dtype=float) + + forceatlas = ForceAtlas2(verbose=False) + positions = forceatlas.forceatlas2(G, iterations=100) + + # Check that not all positions are identical + x_coords = [pos[0] for pos in positions] + y_coords = [pos[1] for pos in positions] + + # Standard deviation should be non-zero + assert np.std(x_coords) > 1e-6 or np.std(y_coords) > 1e-6 + + +class TestForceAtlas2ParameterValidation: + """Test parameter validation""" + + def test_unimplemented_linLogMode_raises_error(self): + """Test that linLogMode=True raises AssertionError""" + with pytest.raises(AssertionError): + ForceAtlas2(linLogMode=True) + + def test_unimplemented_adjustSizes_raises_error(self): + """Test that adjustSizes=True raises AssertionError""" + with pytest.raises(AssertionError): + ForceAtlas2(adjustSizes=True) + + def test_unimplemented_multiThreaded_raises_error(self): + """Test that multiThreaded=True raises AssertionError""" + with pytest.raises(AssertionError): + ForceAtlas2(multiThreaded=True) + + def test_valid_parameters_accepted(self): + """Test that all valid parameter combinations are accepted""" + valid_configs = [ + {}, + {'barnesHutOptimize': True, 'barnesHutTheta': 1.5}, + {'strongGravityMode': True, 'gravity': 2.0}, + {'outboundAttractionDistribution': True, 'edgeWeightInfluence': 0.5}, + {'scalingRatio': 5.0, 'jitterTolerance': 2.0}, + {'verbose': False}, + ] + + for config in valid_configs: + fa2 = ForceAtlas2(**config) + assert fa2 is not None + diff --git a/tests/test_fa2util.py b/tests/test_fa2util.py new file mode 100644 index 0000000..fb69248 --- /dev/null +++ b/tests/test_fa2util.py @@ -0,0 +1,552 @@ +""" +Tests for fa2util module - testing force calculations and utility classes +""" +import pytest +import numpy as np +from math import sqrt + +from fa2_modified import fa2util + + +class TestNode: + """Test Node class""" + + def test_node_initialization(self): + """Test Node object initialization""" + node = fa2util.Node() + assert node.mass == 0.0 + assert node.old_dx == 0.0 + assert node.old_dy == 0.0 + assert node.dx == 0.0 + assert node.dy == 0.0 + assert node.x == 0.0 + assert node.y == 0.0 + + def test_node_attributes(self): + """Test Node attribute assignment""" + node = fa2util.Node() + node.mass = 5.0 + node.x = 10.0 + node.y = 20.0 + node.dx = 1.0 + node.dy = 2.0 + + assert node.mass == 5.0 + assert node.x == 10.0 + assert node.y == 20.0 + assert node.dx == 1.0 + assert node.dy == 2.0 + + +class TestEdge: + """Test Edge class""" + + def test_edge_initialization(self): + """Test Edge object initialization""" + edge = fa2util.Edge() + assert edge.node1 == -1 + assert edge.node2 == -1 + assert edge.weight == 0.0 + + def test_edge_attributes(self): + """Test Edge attribute assignment""" + edge = fa2util.Edge() + edge.node1 = 0 + edge.node2 = 1 + edge.weight = 2.5 + + assert edge.node1 == 0 + assert edge.node2 == 1 + assert edge.weight == 2.5 + + +class TestLinRepulsion: + """Test linear repulsion force function""" + + def test_repulsion_basic(self): + """Test basic repulsion between two nodes""" + n1 = fa2util.Node() + n1.x = 0.0 + n1.y = 0.0 + n1.mass = 1.0 + + n2 = fa2util.Node() + n2.x = 1.0 + n2.y = 0.0 + n2.mass = 1.0 + + fa2util.linRepulsion(n1, n2, coefficient=1.0) + + # Nodes should be pushed apart + assert n1.dx < 0 # n1 pushed left + assert n2.dx > 0 # n2 pushed right + assert abs(n1.dx) == abs(n2.dx) # Equal and opposite forces + assert n1.dy == 0.0 # No vertical movement + assert n2.dy == 0.0 + + def test_repulsion_zero_distance(self): + """Test repulsion when nodes are at the same position""" + n1 = fa2util.Node() + n1.x = 0.0 + n1.y = 0.0 + n1.mass = 1.0 + + n2 = fa2util.Node() + n2.x = 0.0 + n2.y = 0.0 + n2.mass = 1.0 + + fa2util.linRepulsion(n1, n2, coefficient=1.0) + + # No force should be applied when distance is zero + assert n1.dx == 0.0 + assert n1.dy == 0.0 + assert n2.dx == 0.0 + assert n2.dy == 0.0 + + def test_repulsion_different_masses(self): + """Test repulsion with different node masses""" + n1 = fa2util.Node() + n1.x = 0.0 + n1.y = 0.0 + n1.mass = 2.0 + + n2 = fa2util.Node() + n2.x = 1.0 + n2.y = 0.0 + n2.mass = 1.0 + + fa2util.linRepulsion(n1, n2, coefficient=1.0) + + # Forces should be equal and opposite despite different masses + assert abs(n1.dx) == abs(n2.dx) + + +class TestLinGravity: + """Test linear gravity force function""" + + def test_gravity_basic(self): + """Test basic gravity pulling node toward origin""" + n = fa2util.Node() + n.x = 10.0 + n.y = 0.0 + n.mass = 1.0 + + fa2util.linGravity(n, g=1.0) + + # Node should be pulled toward origin + assert n.dx < 0 + assert n.dy == 0.0 + + def test_gravity_at_origin(self): + """Test gravity when node is at origin""" + n = fa2util.Node() + n.x = 0.0 + n.y = 0.0 + n.mass = 1.0 + + fa2util.linGravity(n, g=1.0) + + # No force when at origin + assert n.dx == 0.0 + assert n.dy == 0.0 + + def test_gravity_diagonal(self): + """Test gravity pulling diagonally""" + n = fa2util.Node() + n.x = 3.0 + n.y = 4.0 + n.mass = 1.0 + + fa2util.linGravity(n, g=1.0) + + # Both components should pull toward origin + assert n.dx < 0 + assert n.dy < 0 + + # Check that the direction is correct + distance = sqrt(n.x**2 + n.y**2) + expected_dx = -(n.x / distance) * n.mass + expected_dy = -(n.y / distance) * n.mass + + assert abs(n.dx - expected_dx) < 1e-10 + assert abs(n.dy - expected_dy) < 1e-10 + + +class TestStrongGravity: + """Test strong gravity force function""" + + def test_strong_gravity_basic(self): + """Test strong gravity force""" + n = fa2util.Node() + n.x = 10.0 + n.y = 5.0 + n.mass = 1.0 + + fa2util.strongGravity(n, g=1.0, coefficient=1.0) + + # Should pull toward origin + assert n.dx < 0 + assert n.dy < 0 + + def test_strong_gravity_at_origin(self): + """Test strong gravity when at origin""" + n = fa2util.Node() + n.x = 0.0 + n.y = 0.0 + n.mass = 1.0 + + fa2util.strongGravity(n, g=1.0, coefficient=1.0) + + # No force at origin + assert n.dx == 0.0 + assert n.dy == 0.0 + + +class TestLinAttraction: + """Test linear attraction force function""" + + def test_attraction_basic(self): + """Test basic attraction between connected nodes""" + n1 = fa2util.Node() + n1.x = 0.0 + n1.y = 0.0 + n1.mass = 1.0 + + n2 = fa2util.Node() + n2.x = 10.0 + n2.y = 0.0 + n2.mass = 1.0 + + fa2util.linAttraction(n1, n2, e=1.0, distributedAttraction=False, coefficient=1.0) + + # Nodes should be pulled together + assert n1.dx > 0 # n1 pulled right + assert n2.dx < 0 # n2 pulled left + assert abs(n1.dx) == abs(n2.dx) # Equal and opposite + + def test_attraction_distributed(self): + """Test distributed attraction (hub dissuasion)""" + n1 = fa2util.Node() + n1.x = 0.0 + n1.y = 0.0 + n1.mass = 10.0 # Hub node with high mass + + n2 = fa2util.Node() + n2.x = 10.0 + n2.y = 0.0 + n2.mass = 1.0 + + fa2util.linAttraction(n1, n2, e=1.0, distributedAttraction=True, coefficient=1.0) + + # With distributed attraction, force on n1 is reduced by its mass + assert n1.dx > 0 + assert n2.dx < 0 + + def test_attraction_weighted_edge(self): + """Test attraction with edge weight""" + n1 = fa2util.Node() + n1.x = 0.0 + n1.y = 0.0 + n1.mass = 1.0 + + n2 = fa2util.Node() + n2.x = 10.0 + n2.y = 0.0 + n2.mass = 1.0 + + fa2util.linAttraction(n1, n2, e=2.0, distributedAttraction=False, coefficient=1.0) + + # Stronger edge weight should result in stronger force + assert n1.dx > 0 + assert n2.dx < 0 + + +class TestApplyForces: + """Test force application functions""" + + def test_apply_repulsion(self): + """Test applying repulsion to multiple nodes""" + nodes = [] + for i in range(3): + n = fa2util.Node() + n.x = float(i) + n.y = 0.0 + n.mass = 1.0 + nodes.append(n) + + fa2util.apply_repulsion(nodes, coefficient=1.0) + + # Middle node should have zero net force (symmetry) + # End nodes should be pushed outward + assert nodes[0].dx < 0 # Left node pushed left + assert nodes[2].dx > 0 # Right node pushed right + + def test_apply_gravity(self): + """Test applying gravity to multiple nodes""" + nodes = [] + for i in range(3): + n = fa2util.Node() + n.x = float(i + 1) + n.y = float(i + 1) + n.mass = 1.0 + nodes.append(n) + + fa2util.apply_gravity(nodes, gravity=1.0, scalingRatio=1.0, useStrongGravity=False) + + # All nodes should be pulled toward origin + for node in nodes: + assert node.dx < 0 + assert node.dy < 0 + + def test_apply_strong_gravity(self): + """Test applying strong gravity to multiple nodes""" + nodes = [] + for i in range(3): + n = fa2util.Node() + n.x = float(i + 1) + n.y = float(i + 1) + n.mass = 1.0 + nodes.append(n) + + fa2util.apply_gravity(nodes, gravity=1.0, scalingRatio=1.0, useStrongGravity=True) + + # All nodes should be pulled toward origin + for node in nodes: + assert node.dx < 0 + assert node.dy < 0 + + def test_apply_attraction(self): + """Test applying attraction forces along edges""" + nodes = [] + for i in range(3): + n = fa2util.Node() + n.x = float(i * 10) + n.y = 0.0 + n.mass = 1.0 + nodes.append(n) + + edges = [] + # Connect node 0 to node 1 + e = fa2util.Edge() + e.node1 = 0 + e.node2 = 1 + e.weight = 1.0 + edges.append(e) + + fa2util.apply_attraction(nodes, edges, distributedAttraction=False, + coefficient=1.0, edgeWeightInfluence=1.0) + + # Node 0 and 1 should be pulled together + assert nodes[0].dx > 0 + assert nodes[1].dx < 0 + # Node 2 should have no force (not connected) + assert nodes[2].dx == 0.0 + + +class TestRegion: + """Test Barnes-Hut Region class""" + + def test_region_initialization(self): + """Test Region initialization with nodes""" + nodes = [] + for i in range(4): + n = fa2util.Node() + n.x = float(i) + n.y = float(i) + n.mass = 1.0 + nodes.append(n) + + region = fa2util.Region(nodes) + + assert region.mass > 0 + assert region.size > 0 + assert len(region.nodes) == 4 + assert len(region.subregions) == 0 + + def test_region_mass_center(self): + """Test region mass center calculation""" + nodes = [] + # Create nodes in a square pattern + positions = [(0, 0), (0, 10), (10, 0), (10, 10)] + for x, y in positions: + n = fa2util.Node() + n.x = float(x) + n.y = float(y) + n.mass = 1.0 + nodes.append(n) + + region = fa2util.Region(nodes) + + # Center should be at (5, 5) + assert abs(region.massCenterX - 5.0) < 1e-10 + assert abs(region.massCenterY - 5.0) < 1e-10 + + def test_region_build_subregions(self): + """Test building subregions for Barnes-Hut""" + nodes = [] + # Create nodes in different quadrants + positions = [(0, 0), (0, 10), (10, 0), (10, 10)] + for x, y in positions: + n = fa2util.Node() + n.x = float(x) + n.y = float(y) + n.mass = 1.0 + nodes.append(n) + + region = fa2util.Region(nodes) + region.buildSubRegions() + + # Should have created subregions + assert len(region.subregions) > 0 + + def test_region_single_node(self): + """Test region with single node""" + n = fa2util.Node() + n.x = 5.0 + n.y = 5.0 + n.mass = 1.0 + + region = fa2util.Region([n]) + region.buildSubRegions() + + # Single node should not create subregions + assert len(region.subregions) == 0 + + +class TestAdjustSpeedAndApplyForces: + """Test speed adjustment and force application""" + + def test_adjust_speed_basic(self): + """Test basic speed adjustment""" + nodes = [] + for i in range(5): + n = fa2util.Node() + n.x = float(i) + n.y = float(i) + n.mass = 1.0 + n.dx = 0.1 + n.dy = 0.1 + n.old_dx = 0.0 + n.old_dy = 0.0 + nodes.append(n) + + result = fa2util.adjustSpeedAndApplyForces(nodes, speed=1.0, + speedEfficiency=1.0, + jitterTolerance=1.0) + + assert 'speed' in result + assert 'speedEfficiency' in result + assert result['speed'] > 0 + assert result['speedEfficiency'] > 0 + + def test_adjust_speed_applies_forces(self): + """Test that forces are applied to node positions""" + nodes = [] + for i in range(3): + n = fa2util.Node() + n.x = float(i) + n.y = float(i) + n.mass = 1.0 + n.dx = 1.0 + n.dy = -1.0 + n.old_dx = 0.5 + n.old_dy = -0.5 + nodes.append(n) + + old_positions = [(n.x, n.y) for n in nodes] + + result = fa2util.adjustSpeedAndApplyForces(nodes, speed=1.0, + speedEfficiency=1.0, + jitterTolerance=1.0) + + # Positions should have changed + for i, n in enumerate(nodes): + assert n.x != old_positions[i][0] or n.y != old_positions[i][1] + + +class TestEdgeCases: + """Test edge cases and error conditions""" + + def test_empty_nodes_list(self): + """Test with empty nodes list""" + nodes = [] + fa2util.apply_repulsion(nodes, coefficient=1.0) + fa2util.apply_gravity(nodes, gravity=1.0, scalingRatio=1.0) + # Should not raise errors + + def test_single_node_forces(self): + """Test forces on single node""" + nodes = [] + n = fa2util.Node() + n.x = 5.0 + n.y = 5.0 + n.mass = 1.0 + nodes.append(n) + + fa2util.apply_repulsion(nodes, coefficient=1.0) + fa2util.apply_gravity(nodes, gravity=1.0, scalingRatio=1.0) + + # Gravity should pull toward origin + assert n.dx < 0 + assert n.dy < 0 + + def test_edge_weight_zero(self): + """Test attraction with zero edge weight""" + nodes = [] + for i in range(2): + n = fa2util.Node() + n.x = float(i * 10) + n.y = 0.0 + n.mass = 1.0 + nodes.append(n) + + edges = [] + e = fa2util.Edge() + e.node1 = 0 + e.node2 = 1 + e.weight = 0.0 + edges.append(e) + + fa2util.apply_attraction(nodes, edges, distributedAttraction=False, + coefficient=1.0, edgeWeightInfluence=1.0) + + # Zero weight edge should produce no force + assert nodes[0].dx == 0.0 + assert nodes[1].dx == 0.0 + + def test_edge_weight_influence_variations(self): + """Test different edge weight influence values""" + nodes = [] + for i in range(2): + n = fa2util.Node() + n.x = float(i * 10) + n.y = 0.0 + n.mass = 1.0 + nodes.append(n) + + edges = [] + e = fa2util.Edge() + e.node1 = 0 + e.node2 = 1 + e.weight = 2.0 + edges.append(e) + + # Test with edgeWeightInfluence = 0 (ignore weights) + fa2util.apply_attraction(nodes, edges, distributedAttraction=False, + coefficient=1.0, edgeWeightInfluence=0.0) + force_0 = nodes[0].dx + + # Reset + for n in nodes: + n.dx = 0.0 + n.dy = 0.0 + + # Test with edgeWeightInfluence = 1 (use weights) + fa2util.apply_attraction(nodes, edges, distributedAttraction=False, + coefficient=1.0, edgeWeightInfluence=1.0) + force_1 = nodes[0].dx + + # With weight=2 and influence=1, force should be stronger + assert abs(force_1) > abs(force_0) + diff --git a/tests/test_fa2util_precise.py b/tests/test_fa2util_precise.py new file mode 100644 index 0000000..3706b92 --- /dev/null +++ b/tests/test_fa2util_precise.py @@ -0,0 +1,511 @@ +""" +Precise numerical tests for fa2util module with exact value checking +Tests inspired by external ForceAtlas2 implementations to ensure numerical accuracy +""" +import pytest +import math +from fa2_modified import fa2util + + +class TestLinRepulsionPrecise: + """Precise numerical tests for linear repulsion""" + + def test_lin_repulsion_exact_values(self): + """Test linear repulsion with exact numerical expectations""" + # Two nodes at (1,1) and (2,2) with mass 2 each + a = fa2util.Node() + b = fa2util.Node() + a.mass = 2.0 + b.mass = 2.0 + a.x, a.y = 1.0, 1.0 + b.x, b.y = 2.0, 2.0 + + # Reset forces + a.dx = a.dy = b.dx = b.dy = 0.0 + + fa2util.linRepulsion(a, b, coefficient=1.0) + + # dx = 1-2 = -1, dy = -1, dist_sq = 2 + # factor = (coefficient * mass_a * mass_b) / dist_sq = (1 * 2 * 2) / 2 = 2 + # a.dx += -1 * 2 = -2, a.dy += -1 * 2 = -2 + # b.dx -= -1 * 2 = 2, b.dy -= -1 * 2 = 2 + assert a.dx == pytest.approx(-2.0) + assert a.dy == pytest.approx(-2.0) + assert b.dx == pytest.approx(2.0) + assert b.dy == pytest.approx(2.0) + + def test_lin_repulsion_asymmetric_masses(self): + """Test repulsion with different masses""" + a = fa2util.Node() + b = fa2util.Node() + a.mass = 3.0 + b.mass = 2.0 + a.x, a.y = 0.0, 0.0 + b.x, b.y = 1.0, 0.0 + a.dx = a.dy = b.dx = b.dy = 0.0 + + fa2util.linRepulsion(a, b, coefficient=1.0) + + # dx = 0-1 = -1, dist_sq = 1 + # factor = (1 * 3 * 2) / 1 = 6 + # a.dx += -1 * 6 = -6 + # b.dx -= -1 * 6 = 6 + assert a.dx == pytest.approx(-6.0) + assert a.dy == pytest.approx(0.0) + assert b.dx == pytest.approx(6.0) + assert b.dy == pytest.approx(0.0) + + +class TestLinRepulsionRegion: + """Test linear repulsion between node and region""" + + def test_lin_repulsion_region_basic(self): + """Test linear repulsion between a node and a region""" + node = fa2util.Node() + node.mass = 3.0 + node.x = 5.0 + node.y = 5.0 + node.dx = node.dy = 0.0 + + # Create a region with TWO nodes (single node regions don't calculate mass) + dummy1 = fa2util.Node() + dummy1.mass = 1.0 + dummy1.x = 3.0 + dummy1.y = 3.0 + + dummy2 = fa2util.Node() + dummy2.mass = 1.0 + dummy2.x = 3.0 + dummy2.y = 3.0 + + region = fa2util.Region([dummy1, dummy2]) + + # The region should have mass from both nodes + assert region.mass == pytest.approx(2.0) + + fa2util.linRepulsion_region(node, region, coefficient=1.0) + + # dx = 5-3 = 2, dy = 2, dist_sq = 8 + # factor = (1 * 3 * 2) / 8 = 0.75 + # node.dx += 2 * 0.75 = 1.5 + # node.dy += 2 * 0.75 = 1.5 + assert node.dx == pytest.approx(1.5) + assert node.dy == pytest.approx(1.5) + + def test_lin_repulsion_region_no_update_zero_distance(self): + """Test no update when node is at region center""" + node = fa2util.Node() + node.mass = 1.0 + node.x = 3.0 + node.y = 3.0 + node.dx = node.dy = 0.0 + + dummy = fa2util.Node() + dummy.mass = 1.0 + dummy.x = 3.0 + dummy.y = 3.0 + region = fa2util.Region([dummy]) + + fa2util.linRepulsion_region(node, region, coefficient=1.0) + + # No force when at same position + assert node.dx == pytest.approx(0.0) + assert node.dy == pytest.approx(0.0) + + +class TestLinGravityPrecise: + """Precise numerical tests for linear gravity""" + + def test_lin_gravity_exact_values(self): + """Test linear gravity with exact numerical expectations""" + node = fa2util.Node() + node.mass = 2.0 + node.x = 3.0 + node.y = 4.0 # distance from origin = 5 + node.dx = node.dy = 0.0 + + fa2util.linGravity(node, g=1.0) + + # distance = sqrt(9 + 16) = 5 + # factor = (mass * g) / distance = (2 * 1) / 5 = 0.4 + # dx -= 3 * 0.4 = -1.2 + # dy -= 4 * 0.4 = -1.6 + assert node.dx == pytest.approx(-1.2) + assert node.dy == pytest.approx(-1.6) + + def test_lin_gravity_different_gravity_values(self): + """Test linear gravity with different gravity constants""" + node = fa2util.Node() + node.mass = 1.0 + node.x = 3.0 + node.y = 4.0 # distance = 5 + node.dx = node.dy = 0.0 + + fa2util.linGravity(node, g=2.0) + + # factor = (1 * 2) / 5 = 0.4 + # dx -= 3 * 0.4 = -1.2 + # dy -= 4 * 0.4 = -1.6 + assert node.dx == pytest.approx(-1.2) + assert node.dy == pytest.approx(-1.6) + + +class TestStrongGravityPrecise: + """Precise numerical tests for strong gravity""" + + def test_strong_gravity_exact_values(self): + """Test strong gravity with exact numerical expectations""" + node = fa2util.Node() + node.mass = 2.0 + node.x = 3.0 + node.y = 4.0 + node.dx = node.dy = 0.0 + + fa2util.strongGravity(node, g=1.0, coefficient=1.0) + + # factor = coefficient * mass * g = 1 * 2 * 1 = 2 + # dx -= 3 * 2 = -6 + # dy -= 4 * 2 = -8 + assert node.dx == pytest.approx(-6.0) + assert node.dy == pytest.approx(-8.0) + + def test_strong_gravity_with_scaling(self): + """Test strong gravity with different coefficient""" + node = fa2util.Node() + node.mass = 1.0 + node.x = 2.0 + node.y = 3.0 + node.dx = node.dy = 0.0 + + fa2util.strongGravity(node, g=1.0, coefficient=2.0) + + # factor = 2 * 1 * 1 = 2 + # dx -= 2 * 2 = -4 + # dy -= 3 * 2 = -6 + assert node.dx == pytest.approx(-4.0) + assert node.dy == pytest.approx(-6.0) + + +class TestLinAttractionPrecise: + """Precise numerical tests for linear attraction""" + + def test_lin_attraction_exact_values(self): + """Test linear attraction with exact numerical expectations""" + a = fa2util.Node() + b = fa2util.Node() + a.mass = 2.0 + b.mass = 3.0 + a.x, a.y = 0.0, 0.0 + b.x, b.y = 3.0, 4.0 # distance = 5 + a.dx = a.dy = b.dx = b.dy = 0.0 + + edge_weight = 1.0 + + fa2util.linAttraction( + a, b, edge_weight, + distributedAttraction=False, + coefficient=2.0 + ) + + # dx = 0-3 = -3, dy = 0-4 = -4 + # factor = -coefficient * edge_weight = -2.0 * 1.0 = -2.0 + # a.dx += -3 * -2.0 = 6.0 + # a.dy += -4 * -2.0 = 8.0 + # b.dx -= -3 * -2.0 = -6.0 + # b.dy -= -4 * -2.0 = -8.0 + assert a.dx == pytest.approx(6.0) + assert a.dy == pytest.approx(8.0) + assert b.dx == pytest.approx(-6.0) + assert b.dy == pytest.approx(-8.0) + + def test_lin_attraction_distributed(self): + """Test distributed attraction (hub dissuasion)""" + a = fa2util.Node() + b = fa2util.Node() + a.mass = 4.0 # Hub with higher mass + b.mass = 1.0 + a.x, a.y = 0.0, 0.0 + b.x, b.y = 2.0, 0.0 + a.dx = a.dy = b.dx = b.dy = 0.0 + + edge_weight = 1.0 + + fa2util.linAttraction( + a, b, edge_weight, + distributedAttraction=True, + coefficient=1.0 + ) + + # dx = 0-2 = -2, dy = 0 + # factor = -coefficient * edge_weight / mass_a = -1.0 * 1.0 / 4.0 = -0.25 + # a.dx += -2 * -0.25 = 0.5 + # b.dx -= -2 * -0.25 = -0.5 + assert a.dx == pytest.approx(0.5) + assert a.dy == pytest.approx(0.0) + assert b.dx == pytest.approx(-0.5) + assert b.dy == pytest.approx(0.0) + + +class TestApplyForcesPrecise: + """Precise tests for force application functions""" + + def test_apply_repulsion_exact(self): + """Test apply_repulsion with exact values""" + a = fa2util.Node() + b = fa2util.Node() + a.mass = b.mass = 2.0 + a.x, a.y = 0.0, 0.0 + b.x, b.y = 1.0, 0.0 + a.dx = a.dy = b.dx = b.dy = 0.0 + + fa2util.apply_repulsion([a, b], coefficient=2.0) + + # dx = 0-1 = -1, dist_sq = 1 + # factor = (2 * 2 * 2) / 1 = 8 + # a.dx += -1 * 8 = -8 + # b.dx -= -1 * 8 = 8 + assert a.dx == pytest.approx(-8.0) + assert a.dy == pytest.approx(0.0) + assert b.dx == pytest.approx(8.0) + assert b.dy == pytest.approx(0.0) + + def test_apply_gravity_linear_exact(self): + """Test apply_gravity with linear mode""" + node = fa2util.Node() + node.mass = 1.0 + node.x, node.y = 3.0, 4.0 # distance = 5 + node.dx = node.dy = 0.0 + + fa2util.apply_gravity([node], gravity=1.0, scalingRatio=1.0, useStrongGravity=False) + + # factor = (mass * gravity) / distance = (1 * 1) / 5 = 0.2 + # dx -= 3 * 0.2 = -0.6 + # dy -= 4 * 0.2 = -0.8 + assert node.dx == pytest.approx(-0.6) + assert node.dy == pytest.approx(-0.8) + + def test_apply_gravity_strong_exact(self): + """Test apply_gravity with strong gravity mode""" + node = fa2util.Node() + node.mass = 1.0 + node.x, node.y = 3.0, 4.0 + node.dx = node.dy = 0.0 + + fa2util.apply_gravity([node], gravity=1.0, scalingRatio=1.0, useStrongGravity=True) + + # factor = scaling_ratio * mass * gravity = 1 * 1 * 1 = 1 + # dx -= 3 * 1 = -3 + # dy -= 4 * 1 = -4 + assert node.dx == pytest.approx(-3.0) + assert node.dy == pytest.approx(-4.0) + + def test_apply_attraction_exact(self): + """Test apply_attraction with exact values""" + a = fa2util.Node() + b = fa2util.Node() + a.mass = b.mass = 1.0 + a.x, a.y = 0.0, 0.0 + b.x, b.y = 1.0, 0.0 + a.dx = a.dy = b.dx = b.dy = 0.0 + + edge = fa2util.Edge() + edge.node1 = 0 + edge.node2 = 1 + edge.weight = 1.0 + nodes = [a, b] + + fa2util.apply_attraction( + nodes, + [edge], + distributedAttraction=False, + coefficient=1.0, + edgeWeightInfluence=1.0 + ) + + # dx = 0-1 = -1, factor = -1.0 * 1.0 = -1.0 + # a.dx += -1 * -1.0 = 1.0 + # b.dx -= -1 * -1.0 = -1.0 + assert a.dx == pytest.approx(1.0) + assert a.dy == pytest.approx(0.0) + assert b.dx == pytest.approx(-1.0) + assert b.dy == pytest.approx(0.0) + + +class TestRegionPrecise: + """Precise tests for Region class methods""" + + def test_region_mass_center_calculation(self): + """Test precise calculation of region mass center""" + a = fa2util.Node() + b = fa2util.Node() + a.mass = 2.0 + b.mass = 4.0 + a.x, a.y = 0.0, 0.0 + b.x, b.y = 4.0, 0.0 + + region = fa2util.Region([a, b]) + + # Total mass = 6 + assert region.mass == pytest.approx(6.0) + + # Center of mass: ((0*2 + 4*4)/6, (0*2 + 0*4)/6) = (16/6, 0) = (8/3, 0) + assert region.massCenterX == pytest.approx(8.0 / 3.0) + assert region.massCenterY == pytest.approx(0.0) + + def test_region_size_calculation(self): + """Test region size calculation""" + # Create nodes at specific positions + nodes = [] + positions = [(0, 0), (4, 0)] + for x, y in positions: + n = fa2util.Node() + n.mass = 2.0 + n.x, n.y = float(x), float(y) + nodes.append(n) + + region = fa2util.Region(nodes) + + # massSumX = 0*2 + 4*2 = 8, mass = 4 + # Center is at (8/4, 0) = (2, 0) + # Distance from (0,0) to (2,0) = 2, from (4,0) to (2,0) = 2 + # Size should be 2 * max_distance = 2 * 2 = 4.0 + expected_size = 4.0 + assert region.size == pytest.approx(expected_size) + + def test_region_build_subregions_quadrants(self): + """Test that subregions are built in correct quadrants""" + # Create 4 nodes in different quadrants around (2, 2) + nodes = [] + positions = [(1, 3), (1, 1), (3, 3), (3, 1)] + for x, y in positions: + n = fa2util.Node() + n.mass = 1.0 + n.x, n.y = float(x), float(y) + nodes.append(n) + + region = fa2util.Region(nodes) + region.buildSubRegions() + + # Should create 4 subregions (one for each quadrant) + assert len(region.subregions) == 4 + + # Each subregion should have exactly 1 node + for subregion in region.subregions: + assert len(subregion.nodes) == 1 + + def test_region_apply_force_single_node(self): + """Test applying force from a single-node region""" + target = fa2util.Node() + target.mass = 1.0 + target.x = 5.0 + target.y = 5.0 + target.dx = target.dy = 0.0 + + source = fa2util.Node() + source.mass = 1.0 + source.x = 1.0 + source.y = 1.0 + + region = fa2util.Region([source]) + + # For a single-node region, apply_force calls linRepulsion + region.applyForce(target, theta=100.0, coefficient=1.0) + + # dx = 5-1 = 4, dy = 4, dist_sq = 32 + # factor = (1 * 1 * 1) / 32 = 0.03125 + # target.dx += 4 * 0.03125 = 0.125 + # target.dy += 4 * 0.03125 = 0.125 + assert target.dx == pytest.approx(0.125) + assert target.dy == pytest.approx(0.125) + + def test_region_apply_force_uses_approximation(self): + """Test that region uses approximation when theta condition is met""" + target = fa2util.Node() + target.mass = 1.0 + target.x = 10.0 + target.y = 10.0 + target.dx = target.dy = 0.0 + + # Create a region with multiple nodes close together + nodes = [] + for i in range(4): + n = fa2util.Node() + n.mass = 1.0 + n.x = float(i) + n.y = 0.0 + nodes.append(n) + + region = fa2util.Region(nodes) + region.buildSubRegions() + + # With large theta and large distance, should use region approximation + region.applyForce(target, theta=0.5, coefficient=1.0) + + # Force should have been applied (non-zero) + assert target.dx != 0.0 or target.dy != 0.0 + + +class TestAdjustSpeedPrecise: + """Precise tests for speed adjustment""" + + def test_adjust_speed_modifies_positions(self): + """Test that adjust_speed_and_apply_forces updates node positions""" + a = fa2util.Node() + b = fa2util.Node() + a.mass = b.mass = 1.0 + a.x, a.y = 0.0, 0.0 + b.x, b.y = 1.0, 0.0 + + # Set forces + a.old_dx, a.old_dy = 0.0, 0.0 + a.dx, a.dy = 1.0, 0.0 + b.old_dx, b.old_dy = 0.0, 0.0 + b.dx, b.dy = -1.0, 0.0 + + old_a_x = a.x + old_b_x = b.x + + result = fa2util.adjustSpeedAndApplyForces( + [a, b], + speed=1.0, + speedEfficiency=1.0, + jitterTolerance=1.0 + ) + + # Positions should have changed + assert a.x != old_a_x + assert b.x != old_b_x + + # Result should contain updated speed values + assert 'speed' in result + assert 'speedEfficiency' in result + assert result['speed'] > 0 + assert result['speedEfficiency'] > 0 + + def test_adjust_speed_returns_valid_dict(self): + """Test that adjust_speed returns properly formatted dictionary""" + nodes = [] + for i in range(3): + n = fa2util.Node() + n.mass = 1.0 + n.x = float(i) + n.y = 0.0 + n.dx = 0.1 + n.dy = 0.0 + n.old_dx = 0.0 + n.old_dy = 0.0 + nodes.append(n) + + result = fa2util.adjustSpeedAndApplyForces( + nodes, + speed=1.0, + speedEfficiency=1.0, + jitterTolerance=1.0 + ) + + assert isinstance(result, dict) + assert 'speed' in result + assert 'speedEfficiency' in result + assert isinstance(result['speed'], float) + assert isinstance(result['speedEfficiency'], float) + diff --git a/tests/test_forceatlas2.py b/tests/test_forceatlas2.py new file mode 100644 index 0000000..7201e6a --- /dev/null +++ b/tests/test_forceatlas2.py @@ -0,0 +1,474 @@ +""" +Tests for ForceAtlas2 main class and layout algorithms +""" +import pytest +import numpy as np +import scipy.sparse + +from fa2_modified import ForceAtlas2 + + +class TestForceAtlas2Initialization: + """Test ForceAtlas2 class initialization""" + + def test_default_initialization(self): + """Test ForceAtlas2 with default parameters""" + fa2 = ForceAtlas2() + + assert fa2.outboundAttractionDistribution is False + assert fa2.linLogMode is False + assert fa2.adjustSizes is False + assert fa2.edgeWeightInfluence == 1.0 + assert fa2.jitterTolerance == 1.0 + assert fa2.barnesHutOptimize is True + assert fa2.barnesHutTheta == 1.2 + assert fa2.scalingRatio == 2.0 + assert fa2.strongGravityMode is False + assert fa2.gravity == 1.0 + assert fa2.verbose is True + + def test_custom_parameters(self): + """Test ForceAtlas2 with custom parameters""" + fa2 = ForceAtlas2( + outboundAttractionDistribution=True, + edgeWeightInfluence=0.5, + jitterTolerance=2.0, + barnesHutOptimize=False, + barnesHutTheta=1.5, + scalingRatio=3.0, + strongGravityMode=True, + gravity=2.0, + verbose=False + ) + + assert fa2.outboundAttractionDistribution is True + assert fa2.edgeWeightInfluence == 0.5 + assert fa2.jitterTolerance == 2.0 + assert fa2.barnesHutOptimize is False + assert fa2.barnesHutTheta == 1.5 + assert fa2.scalingRatio == 3.0 + assert fa2.strongGravityMode is True + assert fa2.gravity == 2.0 + assert fa2.verbose is False + + def test_unimplemented_features_raise_error(self): + """Test that unimplemented features raise assertion error""" + with pytest.raises(AssertionError): + ForceAtlas2(linLogMode=True) + + with pytest.raises(AssertionError): + ForceAtlas2(adjustSizes=True) + + with pytest.raises(AssertionError): + ForceAtlas2(multiThreaded=True) + + +class TestForceAtlas2Init: + """Test the init method that prepares graph data""" + + def test_init_numpy_array(self): + """Test init with numpy array""" + G = np.array([ + [0, 1, 0, 1], + [1, 0, 1, 0], + [0, 1, 0, 1], + [1, 0, 1, 0] + ]) + + fa2 = ForceAtlas2(verbose=False) + nodes, edges = fa2.init(G) + + assert len(nodes) == 4 + assert len(edges) == 4 # 4 unique edges in undirected graph + + # Check node masses (1 + number of connections) + assert nodes[0].mass == 3.0 # 1 + 2 edges + assert nodes[1].mass == 3.0 + assert nodes[2].mass == 3.0 + assert nodes[3].mass == 3.0 + + def test_init_sparse_matrix(self): + """Test init with scipy sparse matrix""" + G = scipy.sparse.lil_matrix([ + [0, 1, 0, 1], + [1, 0, 1, 0], + [0, 1, 0, 1], + [1, 0, 1, 0] + ]) + + fa2 = ForceAtlas2(verbose=False) + nodes, edges = fa2.init(G) + + assert len(nodes) == 4 + assert len(edges) == 4 + + def test_init_with_positions(self): + """Test init with initial positions""" + G = np.array([ + [0, 1], + [1, 0] + ]) + pos = np.array([[0.0, 0.0], [1.0, 1.0]]) + + fa2 = ForceAtlas2(verbose=False) + nodes, edges = fa2.init(G, pos=pos) + + assert nodes[0].x == 0.0 + assert nodes[0].y == 0.0 + assert nodes[1].x == 1.0 + assert nodes[1].y == 1.0 + + def test_init_without_positions(self): + """Test init without initial positions (random)""" + G = np.array([ + [0, 1], + [1, 0] + ]) + + fa2 = ForceAtlas2(verbose=False) + nodes, edges = fa2.init(G, pos=None) + + # Should have random positions between 0 and 1 + assert 0 <= nodes[0].x <= 1 + assert 0 <= nodes[0].y <= 1 + assert 0 <= nodes[1].x <= 1 + assert 0 <= nodes[1].y <= 1 + + def test_init_weighted_graph(self): + """Test init with weighted edges""" + G = np.array([ + [0.0, 2.5, 0.0], + [2.5, 0.0, 1.5], + [0.0, 1.5, 0.0] + ]) + + fa2 = ForceAtlas2(verbose=False) + nodes, edges = fa2.init(G) + + assert len(edges) == 2 + # Check edge weights + weights = sorted([e.weight for e in edges]) + assert weights == [1.5, 2.5] + + def test_init_invalid_input(self): + """Test init with invalid input""" + fa2 = ForceAtlas2(verbose=False) + + # Non-square matrix + with pytest.raises(AssertionError): + G = np.array([[0, 1], [1, 0], [0, 1]]) + fa2.init(G) + + # Non-symmetric matrix + with pytest.raises(AssertionError): + G = np.array([[0, 1], [0, 0]]) + fa2.init(G) + + # Invalid type + with pytest.raises(AssertionError): + fa2.init([[0, 1], [1, 0]]) + + +class TestForceAtlas2Layout: + """Test the main forceatlas2 layout method""" + + def test_simple_graph_layout(self): + """Test layout on simple graph""" + G = np.array([ + [0, 1, 0, 0], + [1, 0, 1, 0], + [0, 1, 0, 1], + [0, 0, 1, 0] + ]) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2(G, iterations=10) + + assert len(pos) == 4 + assert all(len(p) == 2 for p in pos) # Each position is (x, y) + assert all(isinstance(p[0], float) and isinstance(p[1], float) for p in pos) + + def test_layout_with_initial_positions(self): + """Test layout with provided initial positions""" + G = np.array([ + [0, 1], + [1, 0] + ]) + initial_pos = np.array([[0.0, 0.0], [10.0, 0.0]]) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2(G, pos=initial_pos, iterations=10) + + assert len(pos) == 2 + # Positions should have changed from initial + # (they should be pulled together by attraction) + + def test_layout_barnes_hut_enabled(self): + """Test layout with Barnes-Hut optimization""" + G = np.array([ + [0, 1, 1, 0], + [1, 0, 1, 1], + [1, 1, 0, 1], + [0, 1, 1, 0] + ]) + + fa2 = ForceAtlas2(barnesHutOptimize=True, verbose=False) + pos = fa2.forceatlas2(G, iterations=10) + + assert len(pos) == 4 + + def test_layout_barnes_hut_disabled(self): + """Test layout without Barnes-Hut optimization""" + G = np.array([ + [0, 1, 1, 0], + [1, 0, 1, 1], + [1, 1, 0, 1], + [0, 1, 1, 0] + ]) + + fa2 = ForceAtlas2(barnesHutOptimize=False, verbose=False) + pos = fa2.forceatlas2(G, iterations=10) + + assert len(pos) == 4 + + def test_layout_strong_gravity(self): + """Test layout with strong gravity mode""" + G = np.array([ + [0, 1, 0], + [1, 0, 0], + [0, 0, 0] + ]) + + fa2 = ForceAtlas2(strongGravityMode=True, gravity=2.0, verbose=False) + pos = fa2.forceatlas2(G, iterations=50) + + assert len(pos) == 3 + # With strong gravity, nodes should be pulled toward origin + + def test_layout_outbound_attraction_distribution(self): + """Test layout with outbound attraction distribution (hub dissuasion)""" + # Create a star graph (one hub connected to all others) + n = 5 + G = np.zeros((n, n)) + for i in range(1, n): + G[0, i] = 1 + G[i, 0] = 1 + + fa2 = ForceAtlas2(outboundAttractionDistribution=True, verbose=False) + pos = fa2.forceatlas2(G, iterations=50) + + assert len(pos) == n + + def test_layout_edge_weight_influence(self): + """Test layout with different edge weight influence""" + G = np.array([ + [0.0, 1.0, 5.0], + [1.0, 0.0, 1.0], + [5.0, 1.0, 0.0] + ]) + + # With weight influence = 1, weights should matter + fa2_weighted = ForceAtlas2(edgeWeightInfluence=1.0, verbose=False) + pos_weighted = fa2_weighted.forceatlas2(G, iterations=50) + + # With weight influence = 0, weights should be ignored + fa2_unweighted = ForceAtlas2(edgeWeightInfluence=0.0, verbose=False) + pos_unweighted = fa2_unweighted.forceatlas2(G, iterations=50) + + assert len(pos_weighted) == 3 + assert len(pos_unweighted) == 3 + + def test_layout_different_iterations(self): + """Test that more iterations produce different results""" + G = np.array([ + [0, 1, 1], + [1, 0, 1], + [1, 1, 0] + ]) + + fa2 = ForceAtlas2(verbose=False) + + # Set seed for reproducibility + np.random.seed(42) + pos_10 = fa2.forceatlas2(G, iterations=10) + + np.random.seed(42) + pos_100 = fa2.forceatlas2(G, iterations=100) + + # Results should be different with different iteration counts + assert pos_10 != pos_100 + + def test_layout_sparse_matrix(self): + """Test layout with scipy sparse matrix""" + G = scipy.sparse.lil_matrix([ + [0, 1, 1, 0], + [1, 0, 1, 0], + [1, 1, 0, 1], + [0, 0, 1, 0] + ]) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2(G, iterations=10) + + assert len(pos) == 4 + + def test_layout_single_node(self): + """Test layout with single node""" + G = np.array([[0]]) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2(G, iterations=10) + + assert len(pos) == 1 + + def test_layout_disconnected_nodes(self): + """Test layout with disconnected nodes""" + G = np.array([ + [0, 1, 0, 0], + [1, 0, 0, 0], + [0, 0, 0, 1], + [0, 0, 1, 0] + ]) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2(G, iterations=50) + + assert len(pos) == 4 + # Two disconnected components + + +class TestForceAtlas2Parameters: + """Test different parameter combinations""" + + def test_varying_scaling_ratio(self): + """Test with different scaling ratios""" + G = np.array([ + [0, 1, 1], + [1, 0, 1], + [1, 1, 0] + ]) + + for ratio in [0.5, 1.0, 2.0, 5.0]: + fa2 = ForceAtlas2(scalingRatio=ratio, verbose=False) + pos = fa2.forceatlas2(G, iterations=10) + assert len(pos) == 3 + + def test_varying_gravity(self): + """Test with different gravity values""" + G = np.array([ + [0, 1, 0], + [1, 0, 1], + [0, 1, 0] + ]) + + for grav in [0.1, 1.0, 5.0, 10.0]: + fa2 = ForceAtlas2(gravity=grav, verbose=False) + pos = fa2.forceatlas2(G, iterations=10) + assert len(pos) == 3 + + def test_varying_jitter_tolerance(self): + """Test with different jitter tolerance values""" + G = np.array([ + [0, 1, 1], + [1, 0, 1], + [1, 1, 0] + ]) + + for jitter in [0.5, 1.0, 2.0]: + fa2 = ForceAtlas2(jitterTolerance=jitter, verbose=False) + pos = fa2.forceatlas2(G, iterations=10) + assert len(pos) == 3 + + def test_varying_barnes_hut_theta(self): + """Test with different Barnes-Hut theta values""" + G = np.array([ + [0, 1, 1, 0], + [1, 0, 1, 1], + [1, 1, 0, 1], + [0, 1, 1, 0] + ]) + + for theta in [0.5, 1.0, 1.2, 2.0]: + fa2 = ForceAtlas2(barnesHutTheta=theta, verbose=False) + pos = fa2.forceatlas2(G, iterations=10) + assert len(pos) == 4 + + +class TestForceAtlas2Reproducibility: + """Test reproducibility with same random seed""" + + def test_reproducibility_with_seed(self): + """Test that same seed produces same results""" + G = np.array([ + [0, 1, 1], + [1, 0, 1], + [1, 1, 0] + ]) + + fa2 = ForceAtlas2(verbose=False) + + # First run + np.random.seed(42) + import random + random.seed(42) + pos1 = fa2.forceatlas2(G, iterations=10) + + # Second run with same seed + np.random.seed(42) + random.seed(42) + pos2 = fa2.forceatlas2(G, iterations=10) + + # Results should be identical + for p1, p2 in zip(pos1, pos2): + assert abs(p1[0] - p2[0]) < 1e-10 + assert abs(p1[1] - p2[1]) < 1e-10 + + +class TestForceAtlas2EdgeCases: + """Test edge cases and boundary conditions""" + + def test_zero_iterations(self): + """Test with zero iterations""" + G = np.array([ + [0, 1], + [1, 0] + ]) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2(G, iterations=0) + + assert len(pos) == 2 + + def test_large_graph(self): + """Test with larger graph""" + n = 50 + G = np.zeros((n, n)) + # Create a ring graph + for i in range(n): + G[i, (i + 1) % n] = 1 + G[(i + 1) % n, i] = 1 + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2(G, iterations=10) + + assert len(pos) == n + + def test_complete_graph(self): + """Test with complete graph""" + n = 10 + G = np.ones((n, n)) - np.eye(n) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2(G, iterations=10) + + assert len(pos) == n + + def test_no_edges(self): + """Test with graph with no edges""" + G = np.zeros((5, 5)) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2(G, iterations=10) + + assert len(pos) == 5 + diff --git a/tests/test_igraph_integration.py b/tests/test_igraph_integration.py new file mode 100644 index 0000000..1b6062e --- /dev/null +++ b/tests/test_igraph_integration.py @@ -0,0 +1,298 @@ +""" +Tests for igraph integration +""" +import pytest +import numpy as np + +try: + import igraph + IGRAPH_AVAILABLE = True +except ImportError: + IGRAPH_AVAILABLE = False + +from fa2_modified import ForceAtlas2 + + +@pytest.mark.skipif(not IGRAPH_AVAILABLE, reason="igraph not installed") +class TestIgraphIntegration: + """Test ForceAtlas2 with igraph graphs""" + + def test_igraph_simple_graph(self): + """Test with simple igraph graph""" + G = igraph.Graph() + G.add_vertices(4) + G.add_edges([(0, 1), (1, 2), (2, 3), (3, 0)]) + + fa2 = ForceAtlas2(verbose=False) + layout = fa2.forceatlas2_igraph_layout(G, iterations=10) + + assert isinstance(layout, igraph.layout.Layout) + assert len(layout) == 4 + assert layout.dim == 2 + + def test_igraph_with_initial_positions(self): + """Test igraph layout with initial positions""" + G = igraph.Graph() + G.add_vertices(3) + G.add_edges([(0, 1), (1, 2)]) + + initial_pos = [[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]] + + fa2 = ForceAtlas2(verbose=False) + layout = fa2.forceatlas2_igraph_layout(G, pos=initial_pos, iterations=10) + + assert len(layout) == 3 + + def test_igraph_with_numpy_positions(self): + """Test igraph layout with numpy array positions""" + G = igraph.Graph() + G.add_vertices(3) + G.add_edges([(0, 1), (1, 2)]) + + initial_pos = np.array([[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]]) + + fa2 = ForceAtlas2(verbose=False) + layout = fa2.forceatlas2_igraph_layout(G, pos=initial_pos, iterations=10) + + assert len(layout) == 3 + + def test_igraph_weighted_graph(self): + """Test with weighted igraph graph""" + G = igraph.Graph() + G.add_vertices(3) + G.add_edges([(0, 1), (1, 2), (2, 0)]) + G.es['weight'] = [1.0, 5.0, 1.0] + + fa2 = ForceAtlas2(verbose=False) + layout = fa2.forceatlas2_igraph_layout(G, iterations=10, weight_attr='weight') + + assert len(layout) == 3 + + def test_igraph_custom_weight_attribute(self): + """Test with custom weight attribute name""" + G = igraph.Graph() + G.add_vertices(3) + G.add_edges([(0, 1), (1, 2)]) + G.es['strength'] = [2.0, 3.0] + + fa2 = ForceAtlas2(verbose=False) + layout = fa2.forceatlas2_igraph_layout(G, iterations=10, weight_attr='strength') + + assert len(layout) == 3 + + def test_igraph_star_graph(self): + """Test with star graph (hub topology)""" + G = igraph.Graph.Star(6) + + fa2 = ForceAtlas2(outboundAttractionDistribution=True, verbose=False) + layout = fa2.forceatlas2_igraph_layout(G, iterations=50) + + assert len(layout) == 6 + + def test_igraph_complete_graph(self): + """Test with complete graph""" + G = igraph.Graph.Full(5) + + fa2 = ForceAtlas2(verbose=False) + layout = fa2.forceatlas2_igraph_layout(G, iterations=10) + + assert len(layout) == 5 + + def test_igraph_ring_graph(self): + """Test with ring graph""" + G = igraph.Graph.Ring(8) + + fa2 = ForceAtlas2(verbose=False) + layout = fa2.forceatlas2_igraph_layout(G, iterations=50) + + assert len(layout) == 8 + + def test_igraph_tree_graph(self): + """Test with tree graph""" + G = igraph.Graph.Tree(15, 3) # Tree with 15 nodes, 3 children per node + + fa2 = ForceAtlas2(verbose=False) + layout = fa2.forceatlas2_igraph_layout(G, iterations=50) + + assert len(layout) == 15 + + def test_igraph_lattice_graph(self): + """Test with lattice graph""" + G = igraph.Graph.Lattice([3, 3], circular=False) + + fa2 = ForceAtlas2(verbose=False) + layout = fa2.forceatlas2_igraph_layout(G, iterations=50) + + assert len(layout) == 9 + + def test_igraph_single_node(self): + """Test with single node""" + G = igraph.Graph() + G.add_vertices(1) + + fa2 = ForceAtlas2(verbose=False) + layout = fa2.forceatlas2_igraph_layout(G, iterations=10) + + assert len(layout) == 1 + + def test_igraph_disconnected_graph(self): + """Test with disconnected components""" + G = igraph.Graph() + G.add_vertices(6) + G.add_edges([(0, 1), (1, 2)]) # Component 1 + G.add_edges([(3, 4), (4, 5)]) # Component 2 + + fa2 = ForceAtlas2(verbose=False) + layout = fa2.forceatlas2_igraph_layout(G, iterations=50) + + assert len(layout) == 6 + + def test_igraph_no_edges(self): + """Test with nodes but no edges""" + G = igraph.Graph() + G.add_vertices(4) + + fa2 = ForceAtlas2(verbose=False) + layout = fa2.forceatlas2_igraph_layout(G, iterations=10) + + assert len(layout) == 4 + + def test_igraph_layout_coordinates_format(self): + """Test that layout coordinates are in correct format""" + G = igraph.Graph() + G.add_vertices(3) + G.add_edges([(0, 1), (1, 2)]) + + fa2 = ForceAtlas2(verbose=False) + layout = fa2.forceatlas2_igraph_layout(G, iterations=10) + + coords = layout.coords + assert len(coords) == 3 + for coord in coords: + assert len(coord) == 2 + assert isinstance(coord[0], (int, float)) + assert isinstance(coord[1], (int, float)) + + def test_igraph_parameter_combinations(self): + """Test various parameter combinations with igraph""" + G = igraph.Graph.Erdos_Renyi(n=30, p=0.1) + + params = [ + {'barnesHutOptimize': True, 'strongGravityMode': False}, + {'barnesHutOptimize': False, 'strongGravityMode': True}, + {'outboundAttractionDistribution': True, 'edgeWeightInfluence': 0.5}, + {'scalingRatio': 5.0, 'gravity': 2.0}, + ] + + for param in params: + fa2 = ForceAtlas2(**param, verbose=False) + layout = fa2.forceatlas2_igraph_layout(G, iterations=10) + assert len(layout) == 30 + + def test_igraph_invalid_input(self): + """Test that non-igraph graph raises error""" + fa2 = ForceAtlas2(verbose=False) + + with pytest.raises(AssertionError): + fa2.forceatlas2_igraph_layout([[0, 1], [1, 0]], iterations=10) + + def test_igraph_invalid_pos_type(self): + """Test that invalid pos type raises error""" + G = igraph.Graph() + G.add_vertices(3) + G.add_edges([(0, 1), (1, 2)]) + + fa2 = ForceAtlas2(verbose=False) + + with pytest.raises(AssertionError): + fa2.forceatlas2_igraph_layout(G, pos="invalid", iterations=10) + + def test_igraph_directed_graph(self): + """Test with directed graph (converted to undirected internally)""" + G = igraph.Graph(directed=True) + G.add_vertices(3) + G.add_edges([(0, 1), (1, 2), (2, 0)]) + + fa2 = ForceAtlas2(verbose=False) + # Note: The current implementation assumes undirected graphs + # This test verifies the conversion happens correctly + layout = fa2.forceatlas2_igraph_layout(G, iterations=10) + + assert len(layout) == 3 + + def test_igraph_random_graph(self): + """Test with random Erdos-Renyi graph""" + G = igraph.Graph.Erdos_Renyi(n=25, p=0.15) + + fa2 = ForceAtlas2(verbose=False) + layout = fa2.forceatlas2_igraph_layout(G, iterations=50) + + assert len(layout) == 25 + + def test_igraph_barabasi_albert_graph(self): + """Test with Barabasi-Albert (scale-free) graph""" + G = igraph.Graph.Barabasi(n=30, m=2) + + fa2 = ForceAtlas2(outboundAttractionDistribution=True, verbose=False) + layout = fa2.forceatlas2_igraph_layout(G, iterations=50) + + assert len(layout) == 30 + + +@pytest.mark.skipif(not IGRAPH_AVAILABLE, reason="igraph not installed") +class TestIgraphReproducibility: + """Test reproducibility with igraph graphs""" + + def test_reproducibility_with_seed(self): + """Test that same seed produces same igraph layout""" + G = igraph.Graph.Erdos_Renyi(n=20, p=0.2) + + fa2 = ForceAtlas2(verbose=False) + + # First run + np.random.seed(42) + import random + random.seed(42) + layout1 = fa2.forceatlas2_igraph_layout(G, iterations=50) + + # Second run with same seed + np.random.seed(42) + random.seed(42) + layout2 = fa2.forceatlas2_igraph_layout(G, iterations=50) + + # Results should be identical + coords1 = layout1.coords + coords2 = layout2.coords + for i in range(len(coords1)): + assert abs(coords1[i][0] - coords2[i][0]) < 1e-10 + assert abs(coords1[i][1] - coords2[i][1]) < 1e-10 + + +@pytest.mark.skipif(not IGRAPH_AVAILABLE, reason="igraph not installed") +class TestIgraphWeights: + """Test edge weight handling in igraph graphs""" + + def test_no_weights(self): + """Test without weights""" + G = igraph.Graph() + G.add_vertices(4) + G.add_edges([(0, 1), (1, 2), (2, 3)]) + + fa2 = ForceAtlas2(verbose=False) + layout = fa2.forceatlas2_igraph_layout(G, iterations=10, weight_attr=None) + + assert len(layout) == 4 + + def test_varying_weights(self): + """Test with varying edge weights""" + G = igraph.Graph() + G.add_vertices(4) + G.add_edges([(0, 1), (1, 2), (2, 3), (3, 0)]) + G.es['weight'] = [1.0, 10.0, 1.0, 1.0] + + fa2 = ForceAtlas2(edgeWeightInfluence=1.0, verbose=False) + layout = fa2.forceatlas2_igraph_layout(G, iterations=100, weight_attr='weight') + + assert len(layout) == 4 + # The edge with weight 10 should pull nodes 1 and 2 closer + diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..742e4cb --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,364 @@ +""" +Integration tests for fa2_modified package +Tests the interaction between different components +""" +import pytest +import numpy as np +import scipy.sparse + +from fa2_modified import ForceAtlas2 + + +class TestEndToEndWorkflow: + """Test complete end-to-end workflows""" + + def test_numpy_to_layout_workflow(self): + """Test complete workflow from numpy array to layout""" + # Create adjacency matrix + G = np.array([ + [0, 1, 1, 0, 0], + [1, 0, 1, 1, 0], + [1, 1, 0, 1, 1], + [0, 1, 1, 0, 1], + [0, 0, 1, 1, 0] + ]) + + # Initialize ForceAtlas2 + fa2 = ForceAtlas2( + barnesHutOptimize=True, + barnesHutTheta=1.2, + scalingRatio=2.0, + strongGravityMode=False, + gravity=1.0, + verbose=False + ) + + # Compute layout + positions = fa2.forceatlas2(G, iterations=100) + + # Verify results + assert len(positions) == 5 + assert all(len(pos) == 2 for pos in positions) + + # Verify positions are reasonable (not all at origin) + x_coords = [pos[0] for pos in positions] + y_coords = [pos[1] for pos in positions] + assert np.std(x_coords) > 0.01 + assert np.std(y_coords) > 0.01 + + def test_sparse_to_layout_workflow(self): + """Test complete workflow from sparse matrix to layout""" + # Create sparse adjacency matrix + G = scipy.sparse.lil_matrix([ + [0, 1, 1, 0, 0], + [1, 0, 1, 1, 0], + [1, 1, 0, 1, 1], + [0, 1, 1, 0, 1], + [0, 0, 1, 1, 0] + ]) + + fa2 = ForceAtlas2(verbose=False) + positions = fa2.forceatlas2(G, iterations=100) + + assert len(positions) == 5 + assert all(len(pos) == 2 for pos in positions) + + def test_weighted_workflow(self): + """Test workflow with weighted edges""" + # Create weighted graph where one edge is much stronger + G = np.array([ + [0.0, 1.0, 1.0, 0.0], + [1.0, 0.0, 10.0, 0.0], # Strong connection between 1 and 2 + [1.0, 10.0, 0.0, 1.0], + [0.0, 0.0, 1.0, 0.0] + ]) + + fa2 = ForceAtlas2(edgeWeightInfluence=1.0, verbose=False) + positions = fa2.forceatlas2(G, iterations=200) + + # Nodes 1 and 2 should be closer together due to strong edge + dist_1_2 = np.linalg.norm(np.array(positions[1]) - np.array(positions[2])) + dist_0_1 = np.linalg.norm(np.array(positions[0]) - np.array(positions[1])) + + # The strong edge should pull nodes closer (this is a heuristic test) + assert len(positions) == 4 + + def test_initial_positions_workflow(self): + """Test workflow with provided initial positions""" + G = np.array([ + [0, 1, 0], + [1, 0, 1], + [0, 1, 0] + ]) + + # Start with nodes in a line + initial_pos = np.array([ + [0.0, 0.0], + [5.0, 0.0], + [10.0, 0.0] + ]) + + fa2 = ForceAtlas2(verbose=False) + positions = fa2.forceatlas2(G, pos=initial_pos, iterations=100) + + assert len(positions) == 3 + # Positions should have changed from initial + for i in range(3): + pos_changed = (positions[i][0] != initial_pos[i][0] or + positions[i][1] != initial_pos[i][1]) + assert pos_changed + + +class TestParameterInteractions: + """Test interactions between different parameters""" + + def test_gravity_repulsion_balance(self): + """Test balance between gravity and repulsion""" + G = np.array([ + [0, 1, 0, 0], + [1, 0, 0, 0], + [0, 0, 0, 1], + [0, 0, 1, 0] + ]) + + # High gravity should keep nodes close to origin + fa2_high_gravity = ForceAtlas2(gravity=10.0, scalingRatio=1.0, verbose=False) + pos_high_gravity = fa2_high_gravity.forceatlas2(G, iterations=100) + + # Low gravity should allow nodes to spread out more + fa2_low_gravity = ForceAtlas2(gravity=0.1, scalingRatio=1.0, verbose=False) + pos_low_gravity = fa2_low_gravity.forceatlas2(G, iterations=100) + + # Calculate spread from origin + spread_high = np.mean([np.linalg.norm(pos) for pos in pos_high_gravity]) + spread_low = np.mean([np.linalg.norm(pos) for pos in pos_low_gravity]) + + # Lower gravity should result in larger spread (generally) + assert len(pos_high_gravity) == 4 + assert len(pos_low_gravity) == 4 + + def test_barnes_hut_vs_direct(self): + """Test that Barnes-Hut gives similar results to direct computation""" + G = np.array([ + [0, 1, 1, 0, 0], + [1, 0, 1, 1, 0], + [1, 1, 0, 1, 1], + [0, 1, 1, 0, 1], + [0, 0, 1, 1, 0] + ]) + + # Set seed for reproducibility + np.random.seed(42) + import random + random.seed(42) + + fa2_barnes = ForceAtlas2(barnesHutOptimize=True, verbose=False) + pos_barnes = fa2_barnes.forceatlas2(G, iterations=50) + + np.random.seed(42) + random.seed(42) + + fa2_direct = ForceAtlas2(barnesHutOptimize=False, verbose=False) + pos_direct = fa2_direct.forceatlas2(G, iterations=50) + + # Results should be similar (not identical due to approximation) + assert len(pos_barnes) == len(pos_direct) + + def test_strong_vs_normal_gravity(self): + """Test difference between strong and normal gravity""" + G = np.array([ + [0, 1, 0], + [1, 0, 1], + [0, 1, 0] + ]) + + np.random.seed(42) + fa2_strong = ForceAtlas2(strongGravityMode=True, gravity=1.0, verbose=False) + pos_strong = fa2_strong.forceatlas2(G, iterations=100) + + np.random.seed(42) + fa2_normal = ForceAtlas2(strongGravityMode=False, gravity=1.0, verbose=False) + pos_normal = fa2_normal.forceatlas2(G, iterations=100) + + assert len(pos_strong) == 3 + assert len(pos_normal) == 3 + + +class TestScalability: + """Test with different graph sizes""" + + def test_small_graph(self): + """Test with very small graph (2 nodes)""" + G = np.array([ + [0, 1], + [1, 0] + ]) + + fa2 = ForceAtlas2(verbose=False) + positions = fa2.forceatlas2(G, iterations=10) + + assert len(positions) == 2 + + def test_medium_graph(self): + """Test with medium-sized graph (50 nodes)""" + n = 50 + G = np.zeros((n, n)) + # Create a random graph with ~10% edge density + for i in range(n): + for j in range(i + 1, n): + if np.random.random() < 0.1: + G[i, j] = 1 + G[j, i] = 1 + + fa2 = ForceAtlas2(barnesHutOptimize=True, verbose=False) + positions = fa2.forceatlas2(G, iterations=50) + + assert len(positions) == n + + @pytest.mark.slow + def test_large_graph(self): + """Test with larger graph (200 nodes)""" + n = 200 + G = scipy.sparse.lil_matrix((n, n)) + # Create a sparse random graph + for i in range(n): + num_edges = np.random.randint(1, 5) + for _ in range(num_edges): + j = np.random.randint(0, n) + if i != j: + G[i, j] = 1 + G[j, i] = 1 + + fa2 = ForceAtlas2(barnesHutOptimize=True, verbose=False) + positions = fa2.forceatlas2(G, iterations=50) + + assert len(positions) == n + + +class TestSpecialGraphTopologies: + """Test with special graph structures""" + + def test_star_topology(self): + """Test star graph (hub and spokes)""" + n = 10 + G = np.zeros((n, n)) + # Node 0 is hub, connected to all others + for i in range(1, n): + G[0, i] = 1 + G[i, 0] = 1 + + fa2 = ForceAtlas2(outboundAttractionDistribution=True, verbose=False) + positions = fa2.forceatlas2(G, iterations=100) + + assert len(positions) == n + + def test_complete_bipartite(self): + """Test complete bipartite graph""" + n1, n2 = 5, 5 + n = n1 + n2 + G = np.zeros((n, n)) + # Connect all nodes in first set to all nodes in second set + for i in range(n1): + for j in range(n1, n): + G[i, j] = 1 + G[j, i] = 1 + + fa2 = ForceAtlas2(verbose=False) + positions = fa2.forceatlas2(G, iterations=100) + + assert len(positions) == n + + def test_chain_topology(self): + """Test chain/path graph""" + n = 10 + G = np.zeros((n, n)) + for i in range(n - 1): + G[i, i + 1] = 1 + G[i + 1, i] = 1 + + fa2 = ForceAtlas2(verbose=False) + positions = fa2.forceatlas2(G, iterations=100) + + assert len(positions) == n + + def test_grid_topology(self): + """Test grid graph""" + rows, cols = 4, 4 + n = rows * cols + G = np.zeros((n, n)) + + # Helper to convert 2D coords to 1D index + def idx(r, c): + return r * cols + c + + # Connect grid neighbors + for r in range(rows): + for c in range(cols): + if r < rows - 1: + G[idx(r, c), idx(r + 1, c)] = 1 + G[idx(r + 1, c), idx(r, c)] = 1 + if c < cols - 1: + G[idx(r, c), idx(r, c + 1)] = 1 + G[idx(r, c + 1), idx(r, c)] = 1 + + fa2 = ForceAtlas2(verbose=False) + positions = fa2.forceatlas2(G, iterations=100) + + assert len(positions) == n + + +class TestConvergence: + """Test convergence properties""" + + def test_convergence_over_iterations(self): + """Test that layout stabilizes over iterations""" + G = np.array([ + [0, 1, 1, 0], + [1, 0, 1, 1], + [1, 1, 0, 1], + [0, 1, 1, 0] + ]) + + fa2 = ForceAtlas2(verbose=False) + + # Run with different iteration counts + np.random.seed(42) + pos_10 = fa2.forceatlas2(G, iterations=10) + + np.random.seed(42) + pos_100 = fa2.forceatlas2(G, iterations=100) + + np.random.seed(42) + pos_1000 = fa2.forceatlas2(G, iterations=1000) + + # More iterations should produce valid layouts + assert len(pos_10) == 4 + assert len(pos_100) == 4 + assert len(pos_1000) == 4 + + def test_reproducibility_same_seed(self): + """Test that same seed produces identical results""" + G = np.array([ + [0, 1, 1], + [1, 0, 1], + [1, 1, 0] + ]) + + fa2 = ForceAtlas2(verbose=False) + + # First run + np.random.seed(12345) + import random + random.seed(12345) + pos1 = fa2.forceatlas2(G, iterations=50) + + # Second run with same seed + np.random.seed(12345) + random.seed(12345) + pos2 = fa2.forceatlas2(G, iterations=50) + + # Should be identical + for i in range(len(pos1)): + assert abs(pos1[i][0] - pos2[i][0]) < 1e-10 + assert abs(pos1[i][1] - pos2[i][1]) < 1e-10 + diff --git a/tests/test_networkx_integration.py b/tests/test_networkx_integration.py new file mode 100644 index 0000000..6233e44 --- /dev/null +++ b/tests/test_networkx_integration.py @@ -0,0 +1,290 @@ +""" +Tests for NetworkX integration +""" +import pytest +import numpy as np +import numbers + +try: + import networkx as nx + NETWORKX_AVAILABLE = True +except ImportError: + NETWORKX_AVAILABLE = False + +from fa2_modified import ForceAtlas2 + + +@pytest.mark.skipif(not NETWORKX_AVAILABLE, reason="NetworkX not installed") +class TestNetworkXIntegration: + """Test ForceAtlas2 with NetworkX graphs""" + + def test_networkx_simple_graph(self): + """Test with simple NetworkX graph""" + G = nx.Graph() + G.add_edges_from([(0, 1), (1, 2), (2, 3), (3, 0)]) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, iterations=10) + + assert len(pos) == 4 + assert all(node in pos for node in G.nodes()) + assert all(len(pos[node]) == 2 for node in G.nodes()) + + def test_networkx_with_initial_positions(self): + """Test NetworkX layout with initial positions""" + G = nx.Graph() + G.add_edges_from([(0, 1), (1, 2)]) + + initial_pos = { + 0: (0.0, 0.0), + 1: (1.0, 0.0), + 2: (2.0, 0.0) + } + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, pos=initial_pos, iterations=10) + + assert len(pos) == 3 + assert all(node in pos for node in G.nodes()) + + def test_networkx_weighted_graph(self): + """Test with weighted NetworkX graph""" + G = nx.Graph() + G.add_edge(0, 1, weight=1.0) + G.add_edge(1, 2, weight=5.0) + G.add_edge(2, 0, weight=1.0) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, iterations=10, weight_attr='weight') + + assert len(pos) == 3 + + def test_networkx_star_graph(self): + """Test with star graph (hub topology)""" + G = nx.star_graph(5) + + fa2 = ForceAtlas2(outboundAttractionDistribution=True, verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, iterations=50) + + assert len(pos) == 6 # 1 hub + 5 leaves + + def test_networkx_complete_graph(self): + """Test with complete graph""" + G = nx.complete_graph(5) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, iterations=10) + + assert len(pos) == 5 + + def test_networkx_path_graph(self): + """Test with path graph""" + G = nx.path_graph(10) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, iterations=50) + + assert len(pos) == 10 + + def test_networkx_cycle_graph(self): + """Test with cycle graph""" + G = nx.cycle_graph(8) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, iterations=50) + + assert len(pos) == 8 + + def test_networkx_grid_graph(self): + """Test with 2D grid graph""" + G = nx.grid_2d_graph(3, 3) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, iterations=50) + + assert len(pos) == 9 + + def test_networkx_random_geometric_graph(self): + """Test with random geometric graph""" + G = nx.random_geometric_graph(20, 0.3) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, iterations=50) + + assert len(pos) == 20 + + def test_networkx_karate_club(self): + """Test with Karate Club graph (classic test graph)""" + G = nx.karate_club_graph() + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, iterations=100) + + assert len(pos) == 34 + + def test_networkx_single_node(self): + """Test with single node""" + G = nx.Graph() + G.add_node(0) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, iterations=10) + + assert len(pos) == 1 + assert 0 in pos + + def test_networkx_disconnected_graph(self): + """Test with disconnected components""" + G = nx.Graph() + G.add_edges_from([(0, 1), (1, 2)]) + G.add_edges_from([(3, 4), (4, 5)]) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, iterations=50) + + assert len(pos) == 6 + + def test_networkx_string_node_labels(self): + """Test with string node labels""" + G = nx.Graph() + G.add_edges_from([('A', 'B'), ('B', 'C'), ('C', 'A')]) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, iterations=10) + + assert len(pos) == 3 + assert 'A' in pos + assert 'B' in pos + assert 'C' in pos + + def test_networkx_mixed_node_labels(self): + """Test with mixed node labels""" + G = nx.Graph() + G.add_edges_from([(1, 'A'), ('A', 2.5), (2.5, 1)]) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, iterations=10) + + assert len(pos) == 3 + + def test_networkx_no_edges(self): + """Test with nodes but no edges""" + G = nx.Graph() + G.add_nodes_from([0, 1, 2, 3]) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, iterations=10) + + assert len(pos) == 4 + + def test_networkx_position_format(self): + """Test that positions are in correct format""" + G = nx.Graph() + G.add_edges_from([(0, 1), (1, 2)]) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, iterations=10) + + assert isinstance(pos, dict) + for node, position in pos.items(): + assert len(position) == 2 + assert isinstance(position[0], numbers.Real) + assert isinstance(position[1], numbers.Real) + + def test_networkx_parameter_combinations(self): + """Test various parameter combinations with NetworkX""" + G = nx.karate_club_graph() + + params = [ + {'barnesHutOptimize': True, 'strongGravityMode': False}, + {'barnesHutOptimize': False, 'strongGravityMode': True}, + {'outboundAttractionDistribution': True, 'edgeWeightInfluence': 0.5}, + {'scalingRatio': 5.0, 'gravity': 2.0}, + ] + + for param in params: + fa2 = ForceAtlas2(**param, verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, iterations=10) + assert len(pos) == 34 + + def test_networkx_invalid_input(self): + """Test that non-NetworkX graph raises error""" + fa2 = ForceAtlas2(verbose=False) + + with pytest.raises(AssertionError): + fa2.forceatlas2_networkx_layout([[0, 1], [1, 0]], iterations=10) + + def test_networkx_invalid_pos_type(self): + """Test that invalid pos type raises error""" + G = nx.Graph() + G.add_edges_from([(0, 1), (1, 2)]) + + fa2 = ForceAtlas2(verbose=False) + + with pytest.raises(AssertionError): + fa2.forceatlas2_networkx_layout(G, pos=[(0, 0), (1, 1)], iterations=10) + + +@pytest.mark.skipif(not NETWORKX_AVAILABLE, reason="NetworkX not installed") +class TestNetworkXEdgeWeights: + """Test edge weight handling in NetworkX graphs""" + + def test_default_weight_attribute(self): + """Test with default weight attribute""" + G = nx.Graph() + G.add_edge(0, 1, weight=2.0) + G.add_edge(1, 2, weight=1.0) + + fa2 = ForceAtlas2(edgeWeightInfluence=1.0, verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, iterations=50) + + assert len(pos) == 3 + + def test_custom_weight_attribute(self): + """Test with custom weight attribute""" + G = nx.Graph() + G.add_edge(0, 1, strength=3.0) + G.add_edge(1, 2, strength=1.0) + + fa2 = ForceAtlas2(edgeWeightInfluence=1.0, verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, iterations=50, weight_attr='strength') + + assert len(pos) == 3 + + def test_no_weight_attribute(self): + """Test with no weight attribute (unweighted)""" + G = nx.Graph() + G.add_edges_from([(0, 1), (1, 2), (2, 0)]) + + fa2 = ForceAtlas2(verbose=False) + pos = fa2.forceatlas2_networkx_layout(G, iterations=50, weight_attr=None) + + assert len(pos) == 3 + + +@pytest.mark.skipif(not NETWORKX_AVAILABLE, reason="NetworkX not installed") +class TestNetworkXReproducibility: + """Test reproducibility with NetworkX graphs""" + + def test_reproducibility_with_seed(self): + """Test that same seed produces same NetworkX layout""" + G = nx.karate_club_graph() + + fa2 = ForceAtlas2(verbose=False) + + # First run + np.random.seed(42) + import random + random.seed(42) + pos1 = fa2.forceatlas2_networkx_layout(G, iterations=50) + + # Second run with same seed + np.random.seed(42) + random.seed(42) + pos2 = fa2.forceatlas2_networkx_layout(G, iterations=50) + + # Results should be identical + for node in G.nodes(): + assert abs(pos1[node][0] - pos2[node][0]) < 1e-10 + assert abs(pos1[node][1] - pos2[node][1]) < 1e-10 + diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..16659c9 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,76 @@ +""" +Tests for utility classes and functions +""" +import pytest +import time + +from fa2_modified.forceatlas2 import Timer + + +class TestTimer: + """Test Timer utility class""" + + def test_timer_initialization(self): + """Test Timer initialization""" + timer = Timer() + assert timer.name == "Timer" + assert timer.start_time == 0.0 + assert timer.total_time == 0.0 + + def test_timer_custom_name(self): + """Test Timer with custom name""" + timer = Timer(name="TestTimer") + assert timer.name == "TestTimer" + + def test_timer_start_stop(self): + """Test timer start and stop""" + timer = Timer() + timer.start() + time.sleep(0.01) # Sleep for 10ms + timer.stop() + + assert timer.total_time > 0.0 + assert timer.total_time < 1.0 # Should be much less than 1 second + + def test_timer_multiple_runs(self): + """Test timer accumulates time over multiple runs""" + timer = Timer() + + timer.start() + time.sleep(0.01) + timer.stop() + first_time = timer.total_time + + timer.start() + time.sleep(0.01) + timer.stop() + second_time = timer.total_time + + assert second_time > first_time + assert second_time >= first_time * 1.5 # Rough check + + def test_timer_display(self, capsys): + """Test timer display output""" + timer = Timer(name="DisplayTest") + timer.start() + time.sleep(0.01) + timer.stop() + + timer.display() + + captured = capsys.readouterr() + assert "DisplayTest" in captured.out + assert "seconds" in captured.out + + def test_timer_zero_time(self): + """Test timer with no elapsed time""" + timer = Timer() + assert timer.total_time == 0.0 + + # Start and immediately stop + timer.start() + timer.stop() + + # Time should still be very small (close to zero) + assert timer.total_time >= 0.0 + From 76e0e158df7fdf9d00e30d45112630836cf62f0d Mon Sep 17 00:00:00 2001 From: Amin Alam Date: Sun, 26 Oct 2025 20:52:23 +0100 Subject: [PATCH 04/12] github workflow for PR triggered tests --- .github/workflows/ci.yml | 91 ++++++++++++++++ .github/workflows/code-quality.yml | 87 +++++++++++++++ .github/workflows/tests.yml | 167 +++++++++++++++++++++++++++++ 3 files changed, 345 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/code-quality.yml create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f858a9a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,91 @@ +name: Continuous Integration + +on: + push: + branches: [ main, master, dev ] + pull_request: + branches: [ main, master, dev ] + schedule: + # Run tests weekly on Monday at 00:00 UTC + - cron: '0 0 * * 1' + workflow_dispatch: + +jobs: + quick-test: + name: Quick test (Python 3.11, Ubuntu) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip setuptools wheel + pip install Cython + pip install -e . + pip install pytest networkx + + - name: Run quick tests + run: | + pytest tests/test_fa2util.py tests/test_forceatlas2.py -v + + full-test: + name: Full test suite + needs: quick-test + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.8', '3.10', '3.11', '3.12', '3.13'] + exclude: + - os: macos-latest + python-version: '3.8' + - os: windows-latest + python-version: '3.8' + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Install system dependencies (Ubuntu) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y build-essential + + - name: Install dependencies + run: | + python -m pip install --upgrade pip setuptools wheel + pip install Cython + pip install -e . + pip install pytest pytest-cov networkx + + - name: Install igraph (non-Windows) + if: runner.os != 'Windows' + continue-on-error: true + run: pip install python-igraph + + - name: Run tests + run: | + pytest tests/ -v --cov=fa2_modified --cov-report=xml + + - name: Upload coverage + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11' + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + flags: unittests + fail_ci_if_error: false + diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..6ee2af1 --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,87 @@ +name: Code Quality + +on: + pull_request: + branches: [ main, master, dev ] + push: + branches: [ main, master, dev ] + +jobs: + lint: + name: Linting (flake8) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install flake8 + run: pip install flake8 + + - name: Lint with flake8 + run: | + # Stop the build if there are Python syntax errors or undefined names + flake8 fa2_modified --count --select=E9,F63,F7,F82 --show-source --statistics + # Exit-zero treats all errors as warnings + flake8 fa2_modified --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + format-check: + name: Code Formatting (black) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install black + run: pip install black + + - name: Check formatting + run: black --check --diff fa2_modified/ tests/ + + import-sort: + name: Import Sorting (isort) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install isort + run: pip install isort + + - name: Check import sorting + run: isort --check-only --diff fa2_modified/ tests/ + + type-check: + name: Type Checking (mypy) + runs-on: ubuntu-latest + continue-on-error: true # Don't fail the build on type errors for now + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install mypy numpy scipy tqdm + + - name: Type check with mypy + run: mypy fa2_modified/ --ignore-missing-imports || echo "Type checking found issues (not blocking)" + diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3a2a2cd --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,167 @@ +name: Tests + +on: + pull_request: + branches: [ main, master, dev ] + push: + branches: [ main, master, dev ] + workflow_dispatch: + +jobs: + test: + name: Test Python ${{ matrix.python-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Install system dependencies (Ubuntu) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y build-essential + + - name: Install system dependencies (macOS) + if: runner.os == 'macOS' + run: | + brew install gcc + + - name: Upgrade pip and install build tools + run: | + python -m pip install --upgrade pip setuptools wheel + + - name: Install Cython + run: | + pip install Cython + + - name: Install package with dependencies + run: | + pip install -e . + + - name: Install test dependencies + run: | + pip install pytest pytest-cov + pip install networkx + + - name: Install igraph (non-Windows) + if: runner.os != 'Windows' + continue-on-error: true + run: | + pip install python-igraph + + - name: List installed packages + run: | + pip list + + - name: Run tests + run: | + pytest tests/ -v --tb=short --cov=fa2_modified --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11' + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + + test-minimal: + name: Test minimal dependencies (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.8', '3.12'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Install minimal dependencies + run: | + python -m pip install --upgrade pip setuptools wheel + pip install Cython + pip install -e . + pip install pytest + + - name: Run core tests only + run: | + pytest tests/test_fa2util.py tests/test_forceatlas2.py -v + + test-without-cython: + name: Test without Cython (Python 3.11) + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install without Cython + run: | + python -m pip install --upgrade pip setuptools wheel + # Install dependencies but not Cython + pip install numpy scipy tqdm pytest networkx + # Try to install the package (should fall back to pure Python or pre-generated C) + pip install -e . || echo "Installation failed as expected without Cython" + + - name: Run basic tests + run: | + pytest tests/test_fa2util.py -v || echo "Tests may fail without compiled extension" + + lint: + name: Linting and code quality + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install linting tools + run: | + pip install flake8 black isort + + - name: Check with flake8 + run: | + # Stop the build if there are Python syntax errors or undefined names + flake8 fa2_modified --count --select=E9,F63,F7,F82 --show-source --statistics + # Exit-zero treats all errors as warnings + flake8 fa2_modified --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Check code formatting with black + run: | + black --check fa2_modified/ tests/ || echo "Code needs formatting" + + - name: Check import sorting with isort + run: | + isort --check-only fa2_modified/ tests/ || echo "Imports need sorting" + From 3c439bb275401b5676a2c60b8ab94bc3fd95077c Mon Sep 17 00:00:00 2001 From: Amin Alam Date: Sun, 26 Oct 2025 20:53:15 +0100 Subject: [PATCH 05/12] codecov added --- .codecov.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .codecov.yml diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..5946969 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,24 @@ +# Codecov configuration for fa2_modified + +coverage: + status: + project: + default: + target: auto + threshold: 5% + patch: + default: + target: auto + threshold: 5% + +ignore: + - "tests/" + - "examples/" + - "setup.py" + - "fa2_modified/fa2util.c" # Generated C file + +comment: + layout: "reach, diff, flags, files" + behavior: default + require_changes: false + From d45b24b77f34fbc184bccb777e2f51c5eee1dd05 Mon Sep 17 00:00:00 2001 From: Amin Alam Date: Sun, 26 Oct 2025 21:02:15 +0100 Subject: [PATCH 06/12] cdef changed to cpdef --- fa2_modified/fa2util.pxd | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/fa2_modified/fa2util.pxd b/fa2_modified/fa2util.pxd index ff43384..a5b4e58 100644 --- a/fa2_modified/fa2util.pxd +++ b/fa2_modified/fa2util.pxd @@ -35,26 +35,26 @@ cdef class Edge: yDist = cython.double, distance2 = cython.double, factor = cython.double) -cdef void linRepulsion(Node n1, Node n2, double coefficient=*) +cpdef void linRepulsion(Node n1, Node n2, double coefficient=*) @cython.locals(xDist = cython.double, yDist = cython.double, distance2 = cython.double, factor = cython.double) -cdef void linRepulsion_region(Node n, Region r, double coefficient=*) +cpdef void linRepulsion_region(Node n, Region r, double coefficient=*) @cython.locals(xDist = cython.double, yDist = cython.double, distance = cython.double, factor = cython.double) -cdef void linGravity(Node n, double g) +cpdef void linGravity(Node n, double g) @cython.locals(xDist = cython.double, yDist = cython.double, factor = cython.double) -cdef void strongGravity(Node n, double g, double coefficient=*) +cpdef void strongGravity(Node n, double g, double coefficient=*) @cython.locals(xDist = cython.double, yDist = cython.double, @@ -97,7 +97,7 @@ cdef class Region: @cython.locals(distance = cython.double, subregion = Region) - cdef void applyForce(self, Node n, double theta, double coefficient=*) + cpdef void applyForce(self, Node n, double theta, double coefficient=*) @cython.locals(n = Node) cpdef applyForceOnNodes(self, list nodes, double theta, double coefficient=*) From 493986d13bec9d515004d583eb8f4c1268709d81 Mon Sep 17 00:00:00 2001 From: Amin Alam Date: Sun, 26 Oct 2025 21:05:30 +0100 Subject: [PATCH 07/12] timer eased for older python versions --- tests/test_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 16659c9..33e61fe 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -47,7 +47,8 @@ def test_timer_multiple_runs(self): second_time = timer.total_time assert second_time > first_time - assert second_time >= first_time * 1.5 # Rough check + # Allow for OS scheduling/timer resolution differences across platforms + assert second_time >= first_time * 1.05 def test_timer_display(self, capsys): """Test timer display output""" From bb6f2a050ad9c4a12269e1ca53cf2e8aaca3734b Mon Sep 17 00:00:00 2001 From: Amin Alam Date: Sun, 26 Oct 2025 21:11:52 +0100 Subject: [PATCH 08/12] code format fixed --- fa2_modified/forceatlas2.py | 2 +- tests/conftest.py | 2 +- tests/test_api_consistency.py | 5 +++-- tests/test_fa2util.py | 5 +++-- tests/test_fa2util_precise.py | 4 +++- tests/test_forceatlas2.py | 2 +- tests/test_igraph_integration.py | 2 +- tests/test_integration.py | 2 +- tests/test_networkx_integration.py | 4 ++-- 9 files changed, 16 insertions(+), 12 deletions(-) diff --git a/fa2_modified/forceatlas2.py b/fa2_modified/forceatlas2.py index e486ddf..d826006 100644 --- a/fa2_modified/forceatlas2.py +++ b/fa2_modified/forceatlas2.py @@ -258,8 +258,8 @@ def forceatlas2_networkx_layout(self, G, pos=None, iterations=100, weight_attr=N # This function returns an igraph layout def forceatlas2_igraph_layout(self, G, pos=None, iterations=100, weight_attr=None): - from scipy.sparse import csr_matrix import igraph + from scipy.sparse import csr_matrix def to_sparse(graph, weight_attr=None): edges = graph.get_edgelist() diff --git a/tests/conftest.py b/tests/conftest.py index 0e589a5..f34fc2a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,8 @@ """ Pytest configuration and fixtures for fa2_modified tests """ -import pytest import numpy as np +import pytest @pytest.fixture diff --git a/tests/test_api_consistency.py b/tests/test_api_consistency.py index 6bb9f8e..b3b913e 100644 --- a/tests/test_api_consistency.py +++ b/tests/test_api_consistency.py @@ -2,10 +2,11 @@ API consistency and error handling tests Tests inspired by external ForceAtlas2 implementations """ -import pytest +import numbers + import numpy as np +import pytest import scipy.sparse -import numbers try: import networkx as nx diff --git a/tests/test_fa2util.py b/tests/test_fa2util.py index fb69248..aa67599 100644 --- a/tests/test_fa2util.py +++ b/tests/test_fa2util.py @@ -1,10 +1,11 @@ """ Tests for fa2util module - testing force calculations and utility classes """ -import pytest -import numpy as np from math import sqrt +import numpy as np +import pytest + from fa2_modified import fa2util diff --git a/tests/test_fa2util_precise.py b/tests/test_fa2util_precise.py index 3706b92..461be99 100644 --- a/tests/test_fa2util_precise.py +++ b/tests/test_fa2util_precise.py @@ -2,8 +2,10 @@ Precise numerical tests for fa2util module with exact value checking Tests inspired by external ForceAtlas2 implementations to ensure numerical accuracy """ -import pytest import math + +import pytest + from fa2_modified import fa2util diff --git a/tests/test_forceatlas2.py b/tests/test_forceatlas2.py index 7201e6a..4e7a107 100644 --- a/tests/test_forceatlas2.py +++ b/tests/test_forceatlas2.py @@ -1,8 +1,8 @@ """ Tests for ForceAtlas2 main class and layout algorithms """ -import pytest import numpy as np +import pytest import scipy.sparse from fa2_modified import ForceAtlas2 diff --git a/tests/test_igraph_integration.py b/tests/test_igraph_integration.py index 1b6062e..7c5fd0c 100644 --- a/tests/test_igraph_integration.py +++ b/tests/test_igraph_integration.py @@ -1,8 +1,8 @@ """ Tests for igraph integration """ -import pytest import numpy as np +import pytest try: import igraph diff --git a/tests/test_integration.py b/tests/test_integration.py index 742e4cb..6a3ca48 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2,9 +2,9 @@ Integration tests for fa2_modified package Tests the interaction between different components """ -import pytest import numpy as np import scipy.sparse +import pytest from fa2_modified import ForceAtlas2 diff --git a/tests/test_networkx_integration.py b/tests/test_networkx_integration.py index 6233e44..5c1f00e 100644 --- a/tests/test_networkx_integration.py +++ b/tests/test_networkx_integration.py @@ -1,9 +1,9 @@ """ Tests for NetworkX integration """ -import pytest -import numpy as np import numbers +import numpy as np +import pytest try: import networkx as nx From 05d64a5b24be368cafad393bbe0a85ba02b2709a Mon Sep 17 00:00:00 2001 From: Amin Alam Date: Sun, 26 Oct 2025 21:14:43 +0100 Subject: [PATCH 09/12] code format fixed --- fa2_modified/fa2util.pxd | 1 - tests/test_integration.py | 2 +- tests/test_networkx_integration.py | 1 + tests/test_utils.py | 3 ++- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/fa2_modified/fa2util.pxd b/fa2_modified/fa2util.pxd index a5b4e58..e9899f8 100644 --- a/fa2_modified/fa2util.pxd +++ b/fa2_modified/fa2util.pxd @@ -13,7 +13,6 @@ # Available under the GPLv3 import cython - # This will substitute for the nLayout object cdef class Node: cdef public double mass diff --git a/tests/test_integration.py b/tests/test_integration.py index 6a3ca48..a64420e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -3,8 +3,8 @@ Tests the interaction between different components """ import numpy as np -import scipy.sparse import pytest +import scipy.sparse from fa2_modified import ForceAtlas2 diff --git a/tests/test_networkx_integration.py b/tests/test_networkx_integration.py index 5c1f00e..acb4379 100644 --- a/tests/test_networkx_integration.py +++ b/tests/test_networkx_integration.py @@ -2,6 +2,7 @@ Tests for NetworkX integration """ import numbers + import numpy as np import pytest diff --git a/tests/test_utils.py b/tests/test_utils.py index 33e61fe..8c4a2d7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,9 +1,10 @@ """ Tests for utility classes and functions """ -import pytest import time +import pytest + from fa2_modified.forceatlas2 import Timer From aa5042d51e3e7eeffc095e3e3da77a4a752f0139 Mon Sep 17 00:00:00 2001 From: Amin Alam Date: Sun, 26 Oct 2025 21:17:48 +0100 Subject: [PATCH 10/12] formats fixed with isor and black --- fa2_modified/fa2util.pxd | 2 + fa2_modified/fa2util.py | 98 ++++++--- fa2_modified/forceatlas2.py | 132 +++++++----- tests/__init__.py | 1 - tests/conftest.py | 41 ++-- tests/test_api_consistency.py | 215 +++++++++----------- tests/test_fa2util.py | 264 +++++++++++++----------- tests/test_fa2util_precise.py | 208 ++++++++++--------- tests/test_forceatlas2.py | 309 +++++++++++------------------ tests/test_igraph_integration.py | 168 ++++++++-------- tests/test_integration.py | 247 +++++++++++------------ tests/test_networkx_integration.py | 174 ++++++++-------- tests/test_utils.py | 30 +-- 13 files changed, 928 insertions(+), 961 deletions(-) diff --git a/fa2_modified/fa2util.pxd b/fa2_modified/fa2util.pxd index e9899f8..9345af7 100644 --- a/fa2_modified/fa2util.pxd +++ b/fa2_modified/fa2util.pxd @@ -13,6 +13,8 @@ # Available under the GPLv3 import cython + + # This will substitute for the nLayout object cdef class Node: cdef public double mass diff --git a/fa2_modified/fa2util.py b/fa2_modified/fa2util.py index a1fadfc..c743b34 100644 --- a/fa2_modified/fa2util.py +++ b/fa2_modified/fa2util.py @@ -36,6 +36,7 @@ def __init__(self): # Here are some functions from ForceFactory.java # ============================================= + # Repulsion function. `n1` and `n2` should be nodes. This will # adjust the dx and dy values of `n1` `n2` def linRepulsion(n1, n2, coefficient=0): @@ -131,18 +132,37 @@ def apply_gravity(nodes, gravity, scalingRatio, useStrongGravity=False): strongGravity(n, gravity, scalingRatio) -def apply_attraction(nodes, edges, distributedAttraction, coefficient, edgeWeightInfluence): +def apply_attraction( + nodes, edges, distributedAttraction, coefficient, edgeWeightInfluence +): # Optimization, since usually edgeWeightInfluence is 0 or 1, and pow is slow if edgeWeightInfluence == 0: for edge in edges: - linAttraction(nodes[edge.node1], nodes[edge.node2], 1, distributedAttraction, coefficient) + linAttraction( + nodes[edge.node1], + nodes[edge.node2], + 1, + distributedAttraction, + coefficient, + ) elif edgeWeightInfluence == 1: for edge in edges: - linAttraction(nodes[edge.node1], nodes[edge.node2], edge.weight, distributedAttraction, coefficient) + linAttraction( + nodes[edge.node1], + nodes[edge.node2], + edge.weight, + distributedAttraction, + coefficient, + ) else: for edge in edges: - linAttraction(nodes[edge.node1], nodes[edge.node2], pow(edge.weight, edgeWeightInfluence), - distributedAttraction, coefficient) + linAttraction( + nodes[edge.node1], + nodes[edge.node2], + pow(edge.weight, edgeWeightInfluence), + distributedAttraction, + coefficient, + ) # For Barnes Hut Optimization @@ -170,7 +190,9 @@ def updateMassAndGeometry(self): self.size = 0.0 for n in self.nodes: - distance = sqrt((n.x - self.massCenterX) ** 2 + (n.y - self.massCenterY) ** 2) + distance = sqrt( + (n.x - self.massCenterX) ** 2 + (n.y - self.massCenterY) ** 2 + ) self.size = max(self.size, 2 * distance) def buildSubRegions(self): @@ -179,8 +201,8 @@ def buildSubRegions(self): bottomleftNodes = [] toprightNodes = [] bottomrightNodes = [] - # Optimization: The distribution of self.nodes into - # subregions now requires only one for loop. Removed + # Optimization: The distribution of self.nodes into + # subregions now requires only one for loop. Removed # topNodes and bottomNodes arrays: memory space saving. for n in self.nodes: if n.x < self.massCenterX: @@ -192,7 +214,7 @@ def buildSubRegions(self): if n.y < self.massCenterY: bottomrightNodes.append(n) else: - toprightNodes.append(n) + toprightNodes.append(n) if len(topleftNodes) > 0: if len(topleftNodes) < len(self.nodes): @@ -237,7 +259,9 @@ def applyForce(self, n, theta, coefficient=0): if len(self.nodes) < 2: linRepulsion(n, self.nodes[0], coefficient) else: - distance = sqrt((n.x - self.massCenterX) ** 2 + (n.y - self.massCenterY) ** 2) + distance = sqrt( + (n.x - self.massCenterX) ** 2 + (n.y - self.massCenterY) ** 2 + ) if distance * theta > self.size: linRepulsion_region(n, self, coefficient) else: @@ -255,43 +279,58 @@ def adjustSpeedAndApplyForces(nodes, speed, speedEfficiency, jitterTolerance): totalSwinging = 0.0 # How much irregular movement totalEffectiveTraction = 0.0 # How much useful movement for n in nodes: - swinging = sqrt((n.old_dx - n.dx) * (n.old_dx - n.dx) + (n.old_dy - n.dy) * (n.old_dy - n.dy)) + swinging = sqrt( + (n.old_dx - n.dx) * (n.old_dx - n.dx) + + (n.old_dy - n.dy) * (n.old_dy - n.dy) + ) totalSwinging += n.mass * swinging - totalEffectiveTraction += .5 * n.mass * sqrt( - (n.old_dx + n.dx) * (n.old_dx + n.dx) + (n.old_dy + n.dy) * (n.old_dy + n.dy)) + totalEffectiveTraction += ( + 0.5 + * n.mass + * sqrt( + (n.old_dx + n.dx) * (n.old_dx + n.dx) + + (n.old_dy + n.dy) * (n.old_dy + n.dy) + ) + ) # Optimize jitter tolerance. The 'right' jitter tolerance for # this network. Bigger networks need more tolerance. Denser # networks need less tolerance. Totally empiric. - estimatedOptimalJitterTolerance = .05 * sqrt(len(nodes)) + estimatedOptimalJitterTolerance = 0.05 * sqrt(len(nodes)) minJT = sqrt(estimatedOptimalJitterTolerance) maxJT = 10 - jt = jitterTolerance * max(minJT, - min(maxJT, estimatedOptimalJitterTolerance * totalEffectiveTraction / ( - len(nodes) * len(nodes)))) + jt = jitterTolerance * max( + minJT, + min( + maxJT, + estimatedOptimalJitterTolerance + * totalEffectiveTraction + / (len(nodes) * len(nodes)), + ), + ) minSpeedEfficiency = 0.05 # Protective against erratic behavior if totalEffectiveTraction and totalSwinging / totalEffectiveTraction > 2.0: if speedEfficiency > minSpeedEfficiency: - speedEfficiency *= .5 + speedEfficiency *= 0.5 jt = max(jt, jitterTolerance) if totalSwinging == 0: - targetSpeed = float('inf') + targetSpeed = float("inf") else: targetSpeed = jt * speedEfficiency * totalEffectiveTraction / totalSwinging if totalSwinging > jt * totalEffectiveTraction: if speedEfficiency > minSpeedEfficiency: - speedEfficiency *= .7 + speedEfficiency *= 0.7 elif speed < 1000: speedEfficiency *= 1.3 # But the speed shoudn't rise too much too quickly, since it would # make the convergence drop dramatically. - maxRise = .5 + maxRise = 0.5 speed = speed + min(targetSpeed - speed, maxRise * speed) # Apply forces. @@ -299,14 +338,17 @@ def adjustSpeedAndApplyForces(nodes, speed, speedEfficiency, jitterTolerance): # Need to add a case if adjustSizes ("prevent overlap") is # implemented. for n in nodes: - swinging = n.mass * sqrt((n.old_dx - n.dx) * (n.old_dx - n.dx) + (n.old_dy - n.dy) * (n.old_dy - n.dy)) + swinging = n.mass * sqrt( + (n.old_dx - n.dx) * (n.old_dx - n.dx) + + (n.old_dy - n.dy) * (n.old_dy - n.dy) + ) factor = speed / (1.0 + sqrt(speed * swinging)) n.x = n.x + (n.dx * factor) n.y = n.y + (n.dy * factor) values = {} - values['speed'] = speed - values['speedEfficiency'] = speedEfficiency + values["speed"] = speed + values["speedEfficiency"] = speedEfficiency return values @@ -315,6 +357,10 @@ def adjustSpeedAndApplyForces(nodes, speed, speedEfficiency, jitterTolerance): import cython if not cython.compiled: - print("Warning: uncompiled fa2util module. Compile with cython for a 10-100x speed boost.") + print( + "Warning: uncompiled fa2util module. Compile with cython for a 10-100x speed boost." + ) except: - print("No cython detected. Install cython and compile the fa2util module for a 10-100x speed boost.") + print( + "No cython detected. Install cython and compile the fa2util module for a 10-100x speed boost." + ) diff --git a/fa2_modified/forceatlas2.py b/fa2_modified/forceatlas2.py index d826006..fd729c2 100644 --- a/fa2_modified/forceatlas2.py +++ b/fa2_modified/forceatlas2.py @@ -38,34 +38,35 @@ def start(self): self.start_time = time.time() def stop(self): - self.total_time += (time.time() - self.start_time) + self.total_time += time.time() - self.start_time def display(self): print(self.name, " took ", "%.2f" % self.total_time, " seconds") class ForceAtlas2: - def __init__(self, - # Behavior alternatives - outboundAttractionDistribution=False, # Dissuade hubs - linLogMode=False, # NOT IMPLEMENTED - adjustSizes=False, # Prevent overlap (NOT IMPLEMENTED) - edgeWeightInfluence=1.0, - - # Performance - jitterTolerance=1.0, # Tolerance - barnesHutOptimize=True, - barnesHutTheta=1.2, - multiThreaded=False, # NOT IMPLEMENTED - - # Tuning - scalingRatio=2.0, - strongGravityMode=False, - gravity=1.0, - - # Log - verbose=True): - assert linLogMode == adjustSizes == multiThreaded == False, "You selected a feature that has not been implemented yet..." + def __init__( + self, + # Behavior alternatives + outboundAttractionDistribution=False, # Dissuade hubs + linLogMode=False, # NOT IMPLEMENTED + adjustSizes=False, # Prevent overlap (NOT IMPLEMENTED) + edgeWeightInfluence=1.0, + # Performance + jitterTolerance=1.0, # Tolerance + barnesHutOptimize=True, + barnesHutTheta=1.2, + multiThreaded=False, # NOT IMPLEMENTED + # Tuning + scalingRatio=2.0, + strongGravityMode=False, + gravity=1.0, + # Log + verbose=True, + ): + assert ( + linLogMode == adjustSizes == multiThreaded == False + ), "You selected a feature that has not been implemented yet..." self.outboundAttractionDistribution = outboundAttractionDistribution self.linLogMode = linLogMode self.adjustSizes = adjustSizes @@ -78,20 +79,27 @@ def __init__(self, self.gravity = gravity self.verbose = verbose - def init(self, - G, # a graph in 2D numpy ndarray format (or) scipy sparse matrix format - pos=None # Array of initial positions - ): + def init( + self, + G, # a graph in 2D numpy ndarray format (or) scipy sparse matrix format + pos=None, # Array of initial positions + ): isSparse = False if isinstance(G, numpy.ndarray): # Check our assumptions assert G.shape == (G.shape[0], G.shape[0]), "G is not 2D square" - assert numpy.all(G.T == G), "G is not symmetric. Currently only undirected graphs are supported" - assert isinstance(pos, numpy.ndarray) or (pos is None), "Invalid node positions" + assert numpy.all( + G.T == G + ), "G is not symmetric. Currently only undirected graphs are supported" + assert isinstance(pos, numpy.ndarray) or ( + pos is None + ), "Invalid node positions" elif scipy.sparse.issparse(G): # Check our assumptions assert G.shape == (G.shape[0], G.shape[0]), "G is not 2D square" - assert isinstance(pos, numpy.ndarray) or (pos is None), "Invalid node positions" + assert isinstance(pos, numpy.ndarray) or ( + pos is None + ), "Invalid node positions" G = G.tolil() isSparse = True else: @@ -121,7 +129,8 @@ def init(self, edges = [] es = numpy.asarray(G.nonzero()).T for e in es: # Iterate through edges - if e[1] <= e[0]: continue # Avoid duplicate edges + if e[1] <= e[0]: + continue # Avoid duplicate edges edge = fa2util.Edge() edge.node1 = e[0] # The index of the first node in `nodes` edge.node2 = e[1] # The index of the second node in `nodes` @@ -146,11 +155,12 @@ def init(self, # # Currently, only undirected graphs are supported so the adjacency matrix # should be symmetric. - def forceatlas2(self, - G, # a graph in 2D numpy ndarray format (or) scipy sparse matrix format - pos=None, # Array of initial positions - iterations=100 # Number of times to iterate the main loop - ): + def forceatlas2( + self, + G, # a graph in 2D numpy ndarray format (or) scipy sparse matrix format + pos=None, # Array of initial positions + iterations=100, # Number of times to iterate the main loop + ): # Initializing, initAlgo() # ================================================================ @@ -196,27 +206,41 @@ def forceatlas2(self, repulsion_timer.start() # parallelization should be implemented here if self.barnesHutOptimize: - rootRegion.applyForceOnNodes(nodes, self.barnesHutTheta, self.scalingRatio) + rootRegion.applyForceOnNodes( + nodes, self.barnesHutTheta, self.scalingRatio + ) else: fa2util.apply_repulsion(nodes, self.scalingRatio) repulsion_timer.stop() # Gravitational forces gravity_timer.start() - fa2util.apply_gravity(nodes, self.gravity, scalingRatio=self.scalingRatio, useStrongGravity=self.strongGravityMode) + fa2util.apply_gravity( + nodes, + self.gravity, + scalingRatio=self.scalingRatio, + useStrongGravity=self.strongGravityMode, + ) gravity_timer.stop() # If other forms of attraction were implemented they would be selected here. attraction_timer.start() - fa2util.apply_attraction(nodes, edges, self.outboundAttractionDistribution, outboundAttCompensation, - self.edgeWeightInfluence) + fa2util.apply_attraction( + nodes, + edges, + self.outboundAttractionDistribution, + outboundAttCompensation, + self.edgeWeightInfluence, + ) attraction_timer.stop() # Adjust speeds and apply forces applyforces_timer.start() - values = fa2util.adjustSpeedAndApplyForces(nodes, speed, speedEfficiency, self.jitterTolerance) - speed = values['speed'] - speedEfficiency = values['speedEfficiency'] + values = fa2util.adjustSpeedAndApplyForces( + nodes, speed, speedEfficiency, self.jitterTolerance + ) + speed = values["speed"] + speedEfficiency = values["speedEfficiency"] applyforces_timer.stop() if self.verbose: @@ -233,19 +257,25 @@ def forceatlas2(self, # # This function returns a NetworkX layout, which is really just a # dictionary of node positions (2D X-Y tuples) indexed by the node name. - def forceatlas2_networkx_layout(self, G, pos=None, iterations=100, weight_attr=None): + def forceatlas2_networkx_layout( + self, G, pos=None, iterations=100, weight_attr=None + ): import networkx + try: import cynetworkx except ImportError: cynetworkx = None - assert ( - isinstance(G, networkx.classes.graph.Graph) - or (cynetworkx and isinstance(G, cynetworkx.classes.graph.Graph)) + assert isinstance(G, networkx.classes.graph.Graph) or ( + cynetworkx and isinstance(G, cynetworkx.classes.graph.Graph) ), "Not a networkx graph" - assert isinstance(pos, dict) or (pos is None), "pos must be specified as a dictionary, as in networkx" - M = networkx.to_scipy_sparse_array(G, dtype='f', format='lil', weight=weight_attr) + assert isinstance(pos, dict) or ( + pos is None + ), "pos must be specified as a dictionary, as in networkx" + M = networkx.to_scipy_sparse_array( + G, dtype="f", format="lil", weight=weight_attr + ) if pos is None: l = self.forceatlas2(M, pos=None, iterations=iterations) else: @@ -276,11 +306,13 @@ def to_sparse(graph, weight_attr=None): n = graph.vcount() if len(edges) == 0: return csr_matrix((n, n)) - + return csr_matrix((weights, list(zip(*edges))), shape=(n, n)) assert isinstance(G, igraph.Graph), "Not a igraph graph" - assert isinstance(pos, (list, numpy.ndarray)) or (pos is None), "pos must be a list or numpy array" + assert isinstance(pos, (list, numpy.ndarray)) or ( + pos is None + ), "pos must be a list or numpy array" if isinstance(pos, list): pos = numpy.array(pos) diff --git a/tests/__init__.py b/tests/__init__.py index 901dbef..22b536e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,2 +1 @@ # Test package for fa2_modified - diff --git a/tests/conftest.py b/tests/conftest.py index f34fc2a..a40cef5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ """ Pytest configuration and fixtures for fa2_modified tests """ + import numpy as np import pytest @@ -8,33 +9,26 @@ @pytest.fixture def simple_adjacency_matrix(): """Simple 4-node graph as adjacency matrix""" - return np.array([ - [0, 1, 0, 1], - [1, 0, 1, 0], - [0, 1, 0, 1], - [1, 0, 1, 0] - ]) + return np.array([[0, 1, 0, 1], [1, 0, 1, 0], [0, 1, 0, 1], [1, 0, 1, 0]]) @pytest.fixture def weighted_adjacency_matrix(): """Simple weighted graph as adjacency matrix""" - return np.array([ - [0.0, 2.5, 0.0, 1.0], - [2.5, 0.0, 3.0, 0.0], - [0.0, 3.0, 0.0, 1.5], - [1.0, 0.0, 1.5, 0.0] - ]) + return np.array( + [ + [0.0, 2.5, 0.0, 1.0], + [2.5, 0.0, 3.0, 0.0], + [0.0, 3.0, 0.0, 1.5], + [1.0, 0.0, 1.5, 0.0], + ] + ) @pytest.fixture def triangle_graph(): """Triangle graph (3 nodes, all connected)""" - return np.array([ - [0, 1, 1], - [1, 0, 1], - [1, 1, 0] - ]) + return np.array([[0, 1, 1], [1, 0, 1], [1, 1, 0]]) @pytest.fixture @@ -51,12 +45,7 @@ def star_graph(): @pytest.fixture def disconnected_graph(): """Graph with two disconnected components""" - return np.array([ - [0, 1, 0, 0], - [1, 0, 0, 0], - [0, 0, 0, 1], - [0, 0, 1, 0] - ]) + return np.array([[0, 1, 0, 0], [1, 0, 0, 0], [0, 0, 0, 1], [0, 0, 1, 0]]) @pytest.fixture @@ -74,6 +63,7 @@ def ring_graph(): def random_seed(): """Set random seed for reproducible tests""" import random + np.random.seed(42) random.seed(42) yield @@ -87,13 +77,10 @@ def pytest_configure(config): config.addinivalue_line( "markers", "slow: marks tests as slow (deselect with '-m \"not slow\"')" ) - config.addinivalue_line( - "markers", "integration: marks tests as integration tests" - ) + config.addinivalue_line("markers", "integration: marks tests as integration tests") config.addinivalue_line( "markers", "requires_networkx: requires networkx to be installed" ) config.addinivalue_line( "markers", "requires_igraph: requires igraph to be installed" ) - diff --git a/tests/test_api_consistency.py b/tests/test_api_consistency.py index b3b913e..8638ecf 100644 --- a/tests/test_api_consistency.py +++ b/tests/test_api_consistency.py @@ -2,6 +2,7 @@ API consistency and error handling tests Tests inspired by external ForceAtlas2 implementations """ + import numbers import numpy as np @@ -10,6 +11,7 @@ try: import networkx as nx + NETWORKX_AVAILABLE = True except ImportError: NETWORKX_AVAILABLE = False @@ -19,7 +21,7 @@ class TestForceAtlas2APIConsistency: """Test API consistency across different input types""" - + @pytest.mark.skipif(not NETWORKX_AVAILABLE, reason="NetworkX not installed") def test_api_consistency_random_vs_custom_positions(self): """ @@ -29,34 +31,34 @@ def test_api_consistency_random_vs_custom_positions(self): """ G = nx.path_graph(10) forceatlas = ForceAtlas2(verbose=False) - + # Layout with random initial positions pos1 = forceatlas.forceatlas2_networkx_layout(G, pos=None, iterations=10) - + # Layout with user-defined positions pos_initial = {n: (0.1 * n, 0.1 * n) for n in G.nodes()} pos2 = forceatlas.forceatlas2_networkx_layout(G, pos=pos_initial, iterations=10) - + # Both outputs must be dictionaries with 10 items assert isinstance(pos1, dict) assert isinstance(pos2, dict) assert len(pos1) == 10 assert len(pos2) == 10 - + # Keys should match assert set(pos1.keys()) == set(pos2.keys()) - + # All values should be valid (x,y) tuples for p in pos1.values(): assert isinstance(p, tuple) assert len(p) == 2 assert all(isinstance(coord, numbers.Real) for coord in p) - + for p in pos2.values(): assert isinstance(p, tuple) assert len(p) == 2 assert all(isinstance(coord, numbers.Real) for coord in p) - + @pytest.mark.skipif(not NETWORKX_AVAILABLE, reason="NetworkX not installed") def test_api_consistency_custom_parameters(self): """ @@ -65,7 +67,7 @@ def test_api_consistency_custom_parameters(self): """ G = nx.complete_graph(5) pos = {i: (float(i), float(i)) for i in G.nodes()} - + forceatlas = ForceAtlas2( outboundAttractionDistribution=True, edgeWeightInfluence=0.5, @@ -75,47 +77,45 @@ def test_api_consistency_custom_parameters(self): scalingRatio=1.0, strongGravityMode=True, gravity=0.5, - verbose=False + verbose=False, ) - + new_pos = forceatlas.forceatlas2_networkx_layout(G, pos=pos, iterations=20) - + # Verify output structure assert isinstance(new_pos, dict) assert len(new_pos) == 5 - + for key, value in new_pos.items(): assert isinstance(value, tuple) assert len(value) == 2 assert all(isinstance(coord, numbers.Real) for coord in value) - + def test_api_consistency_numpy_sparse(self): """Test that numpy array and sparse matrix give compatible results""" # Create same graph as numpy array and sparse matrix - G_numpy = np.array([ - [0, 1, 1, 0], - [1, 0, 1, 1], - [1, 1, 0, 1], - [0, 1, 1, 0] - ], dtype=float) - + G_numpy = np.array( + [[0, 1, 1, 0], [1, 0, 1, 1], [1, 1, 0, 1], [0, 1, 1, 0]], dtype=float + ) + G_sparse = scipy.sparse.lil_matrix(G_numpy) - + forceatlas = ForceAtlas2(verbose=False) - + # Use same seed for both np.random.seed(42) import random + random.seed(42) pos_numpy = forceatlas.forceatlas2(G_numpy, iterations=10) - + np.random.seed(42) random.seed(42) pos_sparse = forceatlas.forceatlas2(G_sparse, iterations=10) - + # Both should return same number of positions assert len(pos_numpy) == len(pos_sparse) == 4 - + # Positions should be very similar (identical with same seed) for i in range(4): assert pos_numpy[i][0] == pytest.approx(pos_sparse[i][0], abs=1e-10) @@ -124,159 +124,140 @@ def test_api_consistency_numpy_sparse(self): class TestForceAtlas2ErrorHandling: """Test error handling for invalid inputs""" - + def test_invalid_matrix_non_square(self): """Test that non-square matrix raises AssertionError""" forceatlas = ForceAtlas2(verbose=False) - + with pytest.raises(AssertionError): G = np.array([[0, 1], [1, 0], [0, 1]], dtype=float) forceatlas.init(G, pos=None) - + def test_invalid_matrix_non_symmetric(self): """Test that non-symmetric matrix raises AssertionError""" forceatlas = ForceAtlas2(verbose=False) - + with pytest.raises(AssertionError): G = np.array([[0, 1, 0], [0, 0, 1], [1, 0, 0]], dtype=float) forceatlas.init(G, pos=None) - + def test_invalid_position_type(self): """Test that invalid position type raises AssertionError""" forceatlas = ForceAtlas2(verbose=False) G = np.array([[0, 1], [1, 0]], dtype=float) - + with pytest.raises(AssertionError): forceatlas.init(G, pos="invalid") - + def test_invalid_position_shape(self): """Test that position array with wrong shape raises error""" forceatlas = ForceAtlas2(verbose=False) G = np.array([[0, 1], [1, 0]], dtype=float) - + # Position array with wrong number of nodes with pytest.raises((AssertionError, IndexError, ValueError)): pos = np.array([[0.0, 0.0]]) # Only 1 position for 2 nodes forceatlas.forceatlas2(G, pos=pos, iterations=10) - + def test_invalid_graph_type(self): """Test that invalid graph type raises AssertionError""" forceatlas = ForceAtlas2(verbose=False) - + with pytest.raises(AssertionError): forceatlas.init([[0, 1], [1, 0]], pos=None) - + @pytest.mark.skipif(not NETWORKX_AVAILABLE, reason="NetworkX not installed") def test_networkx_invalid_graph_type(self): """Test that non-NetworkX object raises AssertionError""" forceatlas = ForceAtlas2(verbose=False) - + with pytest.raises(AssertionError): forceatlas.forceatlas2_networkx_layout( - "not a NetworkX graph", - pos=None, - iterations=10 + "not a NetworkX graph", pos=None, iterations=10 ) - + @pytest.mark.skipif(not NETWORKX_AVAILABLE, reason="NetworkX not installed") def test_networkx_invalid_position_type(self): """Test that invalid position type for NetworkX raises AssertionError""" G = nx.complete_graph(5) forceatlas = ForceAtlas2(verbose=False) - + with pytest.raises(AssertionError): - forceatlas.forceatlas2_networkx_layout( - G, - pos="invalid", - iterations=10 - ) - + forceatlas.forceatlas2_networkx_layout(G, pos="invalid", iterations=10) + @pytest.mark.skipif(not NETWORKX_AVAILABLE, reason="NetworkX not installed") def test_networkx_position_list_not_dict(self): """Test that position as list (not dict) raises AssertionError""" G = nx.complete_graph(3) forceatlas = ForceAtlas2(verbose=False) - + with pytest.raises(AssertionError): forceatlas.forceatlas2_networkx_layout( - G, - pos=[(0, 0), (1, 1), (2, 2)], - iterations=10 + G, pos=[(0, 0), (1, 1), (2, 2)], iterations=10 ) class TestForceAtlas2InitializationEdgeCases: """Test edge cases in ForceAtlas2 initialization""" - + def test_init_returns_correct_types(self): """Test that init returns lists of correct types""" G = np.array([[0, 1], [1, 0]], dtype=float) forceatlas = ForceAtlas2(verbose=False) nodes, edges = forceatlas.init(G, pos=None) - + assert isinstance(nodes, list) assert isinstance(edges, list) - assert all(hasattr(n, 'mass') for n in nodes) - assert all(hasattr(e, 'weight') for e in edges) - + assert all(hasattr(n, "mass") for n in nodes) + assert all(hasattr(e, "weight") for e in edges) + def test_init_edge_count(self): """Test that edges are counted correctly (undirected)""" # Triangle graph: 3 edges - G = np.array([ - [0, 1, 1], - [1, 0, 1], - [1, 1, 0] - ], dtype=float) - + G = np.array([[0, 1, 1], [1, 0, 1], [1, 1, 0]], dtype=float) + forceatlas = ForceAtlas2(verbose=False) nodes, edges = forceatlas.init(G, pos=None) - + # Should have 3 edges (not 6, since we only count upper triangle) assert len(edges) == 3 - + def test_init_node_mass_calculation(self): """Test that node mass is calculated as 1 + degree""" - G = np.array([ - [0, 1, 1, 1], - [1, 0, 0, 0], - [1, 0, 0, 0], - [1, 0, 0, 0] - ], dtype=float) - + G = np.array( + [[0, 1, 1, 1], [1, 0, 0, 0], [1, 0, 0, 0], [1, 0, 0, 0]], dtype=float + ) + forceatlas = ForceAtlas2(verbose=False) nodes, edges = forceatlas.init(G, pos=None) - + # Node 0 has degree 3, so mass should be 4 assert nodes[0].mass == 4.0 - + # Nodes 1, 2, 3 each have degree 1, so mass should be 2 assert nodes[1].mass == 2.0 assert nodes[2].mass == 2.0 assert nodes[3].mass == 2.0 - + def test_init_with_weighted_edges(self): """Test that weighted edges preserve their weights""" - G = np.array([ - [0.0, 2.5, 0.0], - [2.5, 0.0, 3.7], - [0.0, 3.7, 0.0] - ], dtype=float) - + G = np.array([[0.0, 2.5, 0.0], [2.5, 0.0, 3.7], [0.0, 3.7, 0.0]], dtype=float) + forceatlas = ForceAtlas2(verbose=False) nodes, edges = forceatlas.init(G, pos=None) - + # Check that edge weights are preserved weights = sorted([e.weight for e in edges]) assert weights == pytest.approx([2.5, 3.7]) - + def test_init_with_custom_positions(self): """Test that custom positions are used correctly""" G = np.array([[0, 1], [1, 0]], dtype=float) pos = np.array([[1.5, 2.5], [3.5, 4.5]]) - + forceatlas = ForceAtlas2(verbose=False) nodes, edges = forceatlas.init(G, pos=pos) - + assert nodes[0].x == pytest.approx(1.5) assert nodes[0].y == pytest.approx(2.5) assert nodes[1].x == pytest.approx(3.5) @@ -285,104 +266,96 @@ def test_init_with_custom_positions(self): class TestForceAtlas2OutputFormat: """Test output format consistency""" - + def test_output_is_list_of_tuples(self): """Test that forceatlas2 returns list of (x,y) tuples""" G = np.array([[0, 1, 1], [1, 0, 1], [1, 1, 0]], dtype=float) forceatlas = ForceAtlas2(verbose=False) positions = forceatlas.forceatlas2(G, iterations=10) - + assert isinstance(positions, list) assert len(positions) == 3 - + for pos in positions: assert isinstance(pos, tuple) assert len(pos) == 2 assert isinstance(pos[0], numbers.Real) assert isinstance(pos[1], numbers.Real) - + @pytest.mark.skipif(not NETWORKX_AVAILABLE, reason="NetworkX not installed") def test_networkx_output_is_dict(self): """Test that NetworkX layout returns dict""" G = nx.path_graph(5) forceatlas = ForceAtlas2(verbose=False) positions = forceatlas.forceatlas2_networkx_layout(G, iterations=10) - + assert isinstance(positions, dict) assert len(positions) == 5 assert set(positions.keys()) == set(G.nodes()) - + for node, pos in positions.items(): assert isinstance(pos, tuple) assert len(pos) == 2 assert isinstance(pos[0], numbers.Real) assert isinstance(pos[1], numbers.Real) - + def test_positions_are_finite(self): """Test that all positions are finite (not NaN or inf)""" - G = np.array([ - [0, 1, 1, 0], - [1, 0, 1, 1], - [1, 1, 0, 1], - [0, 1, 1, 0] - ], dtype=float) - + G = np.array( + [[0, 1, 1, 0], [1, 0, 1, 1], [1, 1, 0, 1], [0, 1, 1, 0]], dtype=float + ) + forceatlas = ForceAtlas2(verbose=False) positions = forceatlas.forceatlas2(G, iterations=50) - + for pos in positions: assert np.isfinite(pos[0]) assert np.isfinite(pos[1]) - + def test_positions_are_not_all_same(self): """Test that positions are not all identical (layout has spread)""" - G = np.array([ - [0, 1, 1], - [1, 0, 1], - [1, 1, 0] - ], dtype=float) - + G = np.array([[0, 1, 1], [1, 0, 1], [1, 1, 0]], dtype=float) + forceatlas = ForceAtlas2(verbose=False) positions = forceatlas.forceatlas2(G, iterations=100) - + # Check that not all positions are identical x_coords = [pos[0] for pos in positions] y_coords = [pos[1] for pos in positions] - + # Standard deviation should be non-zero assert np.std(x_coords) > 1e-6 or np.std(y_coords) > 1e-6 class TestForceAtlas2ParameterValidation: """Test parameter validation""" - + def test_unimplemented_linLogMode_raises_error(self): """Test that linLogMode=True raises AssertionError""" with pytest.raises(AssertionError): ForceAtlas2(linLogMode=True) - + def test_unimplemented_adjustSizes_raises_error(self): """Test that adjustSizes=True raises AssertionError""" with pytest.raises(AssertionError): ForceAtlas2(adjustSizes=True) - + def test_unimplemented_multiThreaded_raises_error(self): """Test that multiThreaded=True raises AssertionError""" with pytest.raises(AssertionError): ForceAtlas2(multiThreaded=True) - + def test_valid_parameters_accepted(self): """Test that all valid parameter combinations are accepted""" valid_configs = [ {}, - {'barnesHutOptimize': True, 'barnesHutTheta': 1.5}, - {'strongGravityMode': True, 'gravity': 2.0}, - {'outboundAttractionDistribution': True, 'edgeWeightInfluence': 0.5}, - {'scalingRatio': 5.0, 'jitterTolerance': 2.0}, - {'verbose': False}, + {"barnesHutOptimize": True, "barnesHutTheta": 1.5}, + {"strongGravityMode": True, "gravity": 2.0}, + {"outboundAttractionDistribution": True, "edgeWeightInfluence": 0.5}, + {"scalingRatio": 5.0, "jitterTolerance": 2.0}, + {"verbose": False}, ] - + for config in valid_configs: fa2 = ForceAtlas2(**config) assert fa2 is not None - diff --git a/tests/test_fa2util.py b/tests/test_fa2util.py index aa67599..3088218 100644 --- a/tests/test_fa2util.py +++ b/tests/test_fa2util.py @@ -1,6 +1,7 @@ """ Tests for fa2util module - testing force calculations and utility classes """ + from math import sqrt import numpy as np @@ -11,7 +12,7 @@ class TestNode: """Test Node class""" - + def test_node_initialization(self): """Test Node object initialization""" node = fa2util.Node() @@ -22,7 +23,7 @@ def test_node_initialization(self): assert node.dy == 0.0 assert node.x == 0.0 assert node.y == 0.0 - + def test_node_attributes(self): """Test Node attribute assignment""" node = fa2util.Node() @@ -31,7 +32,7 @@ def test_node_attributes(self): node.y = 20.0 node.dx = 1.0 node.dy = 2.0 - + assert node.mass == 5.0 assert node.x == 10.0 assert node.y == 20.0 @@ -41,21 +42,21 @@ def test_node_attributes(self): class TestEdge: """Test Edge class""" - + def test_edge_initialization(self): """Test Edge object initialization""" edge = fa2util.Edge() assert edge.node1 == -1 assert edge.node2 == -1 assert edge.weight == 0.0 - + def test_edge_attributes(self): """Test Edge attribute assignment""" edge = fa2util.Edge() edge.node1 = 0 edge.node2 = 1 edge.weight = 2.5 - + assert edge.node1 == 0 assert edge.node2 == 1 assert edge.weight == 2.5 @@ -63,142 +64,142 @@ def test_edge_attributes(self): class TestLinRepulsion: """Test linear repulsion force function""" - + def test_repulsion_basic(self): """Test basic repulsion between two nodes""" n1 = fa2util.Node() n1.x = 0.0 n1.y = 0.0 n1.mass = 1.0 - + n2 = fa2util.Node() n2.x = 1.0 n2.y = 0.0 n2.mass = 1.0 - + fa2util.linRepulsion(n1, n2, coefficient=1.0) - + # Nodes should be pushed apart assert n1.dx < 0 # n1 pushed left assert n2.dx > 0 # n2 pushed right assert abs(n1.dx) == abs(n2.dx) # Equal and opposite forces assert n1.dy == 0.0 # No vertical movement assert n2.dy == 0.0 - + def test_repulsion_zero_distance(self): """Test repulsion when nodes are at the same position""" n1 = fa2util.Node() n1.x = 0.0 n1.y = 0.0 n1.mass = 1.0 - + n2 = fa2util.Node() n2.x = 0.0 n2.y = 0.0 n2.mass = 1.0 - + fa2util.linRepulsion(n1, n2, coefficient=1.0) - + # No force should be applied when distance is zero assert n1.dx == 0.0 assert n1.dy == 0.0 assert n2.dx == 0.0 assert n2.dy == 0.0 - + def test_repulsion_different_masses(self): """Test repulsion with different node masses""" n1 = fa2util.Node() n1.x = 0.0 n1.y = 0.0 n1.mass = 2.0 - + n2 = fa2util.Node() n2.x = 1.0 n2.y = 0.0 n2.mass = 1.0 - + fa2util.linRepulsion(n1, n2, coefficient=1.0) - + # Forces should be equal and opposite despite different masses assert abs(n1.dx) == abs(n2.dx) class TestLinGravity: """Test linear gravity force function""" - + def test_gravity_basic(self): """Test basic gravity pulling node toward origin""" n = fa2util.Node() n.x = 10.0 n.y = 0.0 n.mass = 1.0 - + fa2util.linGravity(n, g=1.0) - + # Node should be pulled toward origin assert n.dx < 0 assert n.dy == 0.0 - + def test_gravity_at_origin(self): """Test gravity when node is at origin""" n = fa2util.Node() n.x = 0.0 n.y = 0.0 n.mass = 1.0 - + fa2util.linGravity(n, g=1.0) - + # No force when at origin assert n.dx == 0.0 assert n.dy == 0.0 - + def test_gravity_diagonal(self): """Test gravity pulling diagonally""" n = fa2util.Node() n.x = 3.0 n.y = 4.0 n.mass = 1.0 - + fa2util.linGravity(n, g=1.0) - + # Both components should pull toward origin assert n.dx < 0 assert n.dy < 0 - + # Check that the direction is correct distance = sqrt(n.x**2 + n.y**2) expected_dx = -(n.x / distance) * n.mass expected_dy = -(n.y / distance) * n.mass - + assert abs(n.dx - expected_dx) < 1e-10 assert abs(n.dy - expected_dy) < 1e-10 class TestStrongGravity: """Test strong gravity force function""" - + def test_strong_gravity_basic(self): """Test strong gravity force""" n = fa2util.Node() n.x = 10.0 n.y = 5.0 n.mass = 1.0 - + fa2util.strongGravity(n, g=1.0, coefficient=1.0) - + # Should pull toward origin assert n.dx < 0 assert n.dy < 0 - + def test_strong_gravity_at_origin(self): """Test strong gravity when at origin""" n = fa2util.Node() n.x = 0.0 n.y = 0.0 n.mass = 1.0 - + fa2util.strongGravity(n, g=1.0, coefficient=1.0) - + # No force at origin assert n.dx == 0.0 assert n.dy == 0.0 @@ -206,58 +207,64 @@ def test_strong_gravity_at_origin(self): class TestLinAttraction: """Test linear attraction force function""" - + def test_attraction_basic(self): """Test basic attraction between connected nodes""" n1 = fa2util.Node() n1.x = 0.0 n1.y = 0.0 n1.mass = 1.0 - + n2 = fa2util.Node() n2.x = 10.0 n2.y = 0.0 n2.mass = 1.0 - - fa2util.linAttraction(n1, n2, e=1.0, distributedAttraction=False, coefficient=1.0) - + + fa2util.linAttraction( + n1, n2, e=1.0, distributedAttraction=False, coefficient=1.0 + ) + # Nodes should be pulled together assert n1.dx > 0 # n1 pulled right assert n2.dx < 0 # n2 pulled left assert abs(n1.dx) == abs(n2.dx) # Equal and opposite - + def test_attraction_distributed(self): """Test distributed attraction (hub dissuasion)""" n1 = fa2util.Node() n1.x = 0.0 n1.y = 0.0 n1.mass = 10.0 # Hub node with high mass - + n2 = fa2util.Node() n2.x = 10.0 n2.y = 0.0 n2.mass = 1.0 - - fa2util.linAttraction(n1, n2, e=1.0, distributedAttraction=True, coefficient=1.0) - + + fa2util.linAttraction( + n1, n2, e=1.0, distributedAttraction=True, coefficient=1.0 + ) + # With distributed attraction, force on n1 is reduced by its mass assert n1.dx > 0 assert n2.dx < 0 - + def test_attraction_weighted_edge(self): """Test attraction with edge weight""" n1 = fa2util.Node() n1.x = 0.0 n1.y = 0.0 n1.mass = 1.0 - + n2 = fa2util.Node() n2.x = 10.0 n2.y = 0.0 n2.mass = 1.0 - - fa2util.linAttraction(n1, n2, e=2.0, distributedAttraction=False, coefficient=1.0) - + + fa2util.linAttraction( + n1, n2, e=2.0, distributedAttraction=False, coefficient=1.0 + ) + # Stronger edge weight should result in stronger force assert n1.dx > 0 assert n2.dx < 0 @@ -265,7 +272,7 @@ def test_attraction_weighted_edge(self): class TestApplyForces: """Test force application functions""" - + def test_apply_repulsion(self): """Test applying repulsion to multiple nodes""" nodes = [] @@ -275,14 +282,14 @@ def test_apply_repulsion(self): n.y = 0.0 n.mass = 1.0 nodes.append(n) - + fa2util.apply_repulsion(nodes, coefficient=1.0) - + # Middle node should have zero net force (symmetry) # End nodes should be pushed outward assert nodes[0].dx < 0 # Left node pushed left assert nodes[2].dx > 0 # Right node pushed right - + def test_apply_gravity(self): """Test applying gravity to multiple nodes""" nodes = [] @@ -292,14 +299,16 @@ def test_apply_gravity(self): n.y = float(i + 1) n.mass = 1.0 nodes.append(n) - - fa2util.apply_gravity(nodes, gravity=1.0, scalingRatio=1.0, useStrongGravity=False) - + + fa2util.apply_gravity( + nodes, gravity=1.0, scalingRatio=1.0, useStrongGravity=False + ) + # All nodes should be pulled toward origin for node in nodes: assert node.dx < 0 assert node.dy < 0 - + def test_apply_strong_gravity(self): """Test applying strong gravity to multiple nodes""" nodes = [] @@ -309,14 +318,16 @@ def test_apply_strong_gravity(self): n.y = float(i + 1) n.mass = 1.0 nodes.append(n) - - fa2util.apply_gravity(nodes, gravity=1.0, scalingRatio=1.0, useStrongGravity=True) - + + fa2util.apply_gravity( + nodes, gravity=1.0, scalingRatio=1.0, useStrongGravity=True + ) + # All nodes should be pulled toward origin for node in nodes: assert node.dx < 0 assert node.dy < 0 - + def test_apply_attraction(self): """Test applying attraction forces along edges""" nodes = [] @@ -326,7 +337,7 @@ def test_apply_attraction(self): n.y = 0.0 n.mass = 1.0 nodes.append(n) - + edges = [] # Connect node 0 to node 1 e = fa2util.Edge() @@ -334,10 +345,15 @@ def test_apply_attraction(self): e.node2 = 1 e.weight = 1.0 edges.append(e) - - fa2util.apply_attraction(nodes, edges, distributedAttraction=False, - coefficient=1.0, edgeWeightInfluence=1.0) - + + fa2util.apply_attraction( + nodes, + edges, + distributedAttraction=False, + coefficient=1.0, + edgeWeightInfluence=1.0, + ) + # Node 0 and 1 should be pulled together assert nodes[0].dx > 0 assert nodes[1].dx < 0 @@ -347,7 +363,7 @@ def test_apply_attraction(self): class TestRegion: """Test Barnes-Hut Region class""" - + def test_region_initialization(self): """Test Region initialization with nodes""" nodes = [] @@ -357,14 +373,14 @@ def test_region_initialization(self): n.y = float(i) n.mass = 1.0 nodes.append(n) - + region = fa2util.Region(nodes) - + assert region.mass > 0 assert region.size > 0 assert len(region.nodes) == 4 assert len(region.subregions) == 0 - + def test_region_mass_center(self): """Test region mass center calculation""" nodes = [] @@ -376,13 +392,13 @@ def test_region_mass_center(self): n.y = float(y) n.mass = 1.0 nodes.append(n) - + region = fa2util.Region(nodes) - + # Center should be at (5, 5) assert abs(region.massCenterX - 5.0) < 1e-10 assert abs(region.massCenterY - 5.0) < 1e-10 - + def test_region_build_subregions(self): """Test building subregions for Barnes-Hut""" nodes = [] @@ -394,30 +410,30 @@ def test_region_build_subregions(self): n.y = float(y) n.mass = 1.0 nodes.append(n) - + region = fa2util.Region(nodes) region.buildSubRegions() - + # Should have created subregions assert len(region.subregions) > 0 - + def test_region_single_node(self): """Test region with single node""" n = fa2util.Node() n.x = 5.0 n.y = 5.0 n.mass = 1.0 - + region = fa2util.Region([n]) region.buildSubRegions() - + # Single node should not create subregions assert len(region.subregions) == 0 class TestAdjustSpeedAndApplyForces: """Test speed adjustment and force application""" - + def test_adjust_speed_basic(self): """Test basic speed adjustment""" nodes = [] @@ -431,16 +447,16 @@ def test_adjust_speed_basic(self): n.old_dx = 0.0 n.old_dy = 0.0 nodes.append(n) - - result = fa2util.adjustSpeedAndApplyForces(nodes, speed=1.0, - speedEfficiency=1.0, - jitterTolerance=1.0) - - assert 'speed' in result - assert 'speedEfficiency' in result - assert result['speed'] > 0 - assert result['speedEfficiency'] > 0 - + + result = fa2util.adjustSpeedAndApplyForces( + nodes, speed=1.0, speedEfficiency=1.0, jitterTolerance=1.0 + ) + + assert "speed" in result + assert "speedEfficiency" in result + assert result["speed"] > 0 + assert result["speedEfficiency"] > 0 + def test_adjust_speed_applies_forces(self): """Test that forces are applied to node positions""" nodes = [] @@ -454,13 +470,13 @@ def test_adjust_speed_applies_forces(self): n.old_dx = 0.5 n.old_dy = -0.5 nodes.append(n) - + old_positions = [(n.x, n.y) for n in nodes] - - result = fa2util.adjustSpeedAndApplyForces(nodes, speed=1.0, - speedEfficiency=1.0, - jitterTolerance=1.0) - + + result = fa2util.adjustSpeedAndApplyForces( + nodes, speed=1.0, speedEfficiency=1.0, jitterTolerance=1.0 + ) + # Positions should have changed for i, n in enumerate(nodes): assert n.x != old_positions[i][0] or n.y != old_positions[i][1] @@ -468,14 +484,14 @@ def test_adjust_speed_applies_forces(self): class TestEdgeCases: """Test edge cases and error conditions""" - + def test_empty_nodes_list(self): """Test with empty nodes list""" nodes = [] fa2util.apply_repulsion(nodes, coefficient=1.0) fa2util.apply_gravity(nodes, gravity=1.0, scalingRatio=1.0) # Should not raise errors - + def test_single_node_forces(self): """Test forces on single node""" nodes = [] @@ -484,14 +500,14 @@ def test_single_node_forces(self): n.y = 5.0 n.mass = 1.0 nodes.append(n) - + fa2util.apply_repulsion(nodes, coefficient=1.0) fa2util.apply_gravity(nodes, gravity=1.0, scalingRatio=1.0) - + # Gravity should pull toward origin assert n.dx < 0 assert n.dy < 0 - + def test_edge_weight_zero(self): """Test attraction with zero edge weight""" nodes = [] @@ -501,21 +517,26 @@ def test_edge_weight_zero(self): n.y = 0.0 n.mass = 1.0 nodes.append(n) - + edges = [] e = fa2util.Edge() e.node1 = 0 e.node2 = 1 e.weight = 0.0 edges.append(e) - - fa2util.apply_attraction(nodes, edges, distributedAttraction=False, - coefficient=1.0, edgeWeightInfluence=1.0) - + + fa2util.apply_attraction( + nodes, + edges, + distributedAttraction=False, + coefficient=1.0, + edgeWeightInfluence=1.0, + ) + # Zero weight edge should produce no force assert nodes[0].dx == 0.0 assert nodes[1].dx == 0.0 - + def test_edge_weight_influence_variations(self): """Test different edge weight influence values""" nodes = [] @@ -525,29 +546,38 @@ def test_edge_weight_influence_variations(self): n.y = 0.0 n.mass = 1.0 nodes.append(n) - + edges = [] e = fa2util.Edge() e.node1 = 0 e.node2 = 1 e.weight = 2.0 edges.append(e) - + # Test with edgeWeightInfluence = 0 (ignore weights) - fa2util.apply_attraction(nodes, edges, distributedAttraction=False, - coefficient=1.0, edgeWeightInfluence=0.0) + fa2util.apply_attraction( + nodes, + edges, + distributedAttraction=False, + coefficient=1.0, + edgeWeightInfluence=0.0, + ) force_0 = nodes[0].dx - + # Reset for n in nodes: n.dx = 0.0 n.dy = 0.0 - + # Test with edgeWeightInfluence = 1 (use weights) - fa2util.apply_attraction(nodes, edges, distributedAttraction=False, - coefficient=1.0, edgeWeightInfluence=1.0) + fa2util.apply_attraction( + nodes, + edges, + distributedAttraction=False, + coefficient=1.0, + edgeWeightInfluence=1.0, + ) force_1 = nodes[0].dx - + # With weight=2 and influence=1, force should be stronger assert abs(force_1) > abs(force_0) - diff --git a/tests/test_fa2util_precise.py b/tests/test_fa2util_precise.py index 461be99..f5c9822 100644 --- a/tests/test_fa2util_precise.py +++ b/tests/test_fa2util_precise.py @@ -2,6 +2,7 @@ Precise numerical tests for fa2util module with exact value checking Tests inspired by external ForceAtlas2 implementations to ensure numerical accuracy """ + import math import pytest @@ -11,7 +12,7 @@ class TestLinRepulsionPrecise: """Precise numerical tests for linear repulsion""" - + def test_lin_repulsion_exact_values(self): """Test linear repulsion with exact numerical expectations""" # Two nodes at (1,1) and (2,2) with mass 2 each @@ -21,12 +22,12 @@ def test_lin_repulsion_exact_values(self): b.mass = 2.0 a.x, a.y = 1.0, 1.0 b.x, b.y = 2.0, 2.0 - + # Reset forces a.dx = a.dy = b.dx = b.dy = 0.0 - + fa2util.linRepulsion(a, b, coefficient=1.0) - + # dx = 1-2 = -1, dy = -1, dist_sq = 2 # factor = (coefficient * mass_a * mass_b) / dist_sq = (1 * 2 * 2) / 2 = 2 # a.dx += -1 * 2 = -2, a.dy += -1 * 2 = -2 @@ -35,7 +36,7 @@ def test_lin_repulsion_exact_values(self): assert a.dy == pytest.approx(-2.0) assert b.dx == pytest.approx(2.0) assert b.dy == pytest.approx(2.0) - + def test_lin_repulsion_asymmetric_masses(self): """Test repulsion with different masses""" a = fa2util.Node() @@ -45,9 +46,9 @@ def test_lin_repulsion_asymmetric_masses(self): a.x, a.y = 0.0, 0.0 b.x, b.y = 1.0, 0.0 a.dx = a.dy = b.dx = b.dy = 0.0 - + fa2util.linRepulsion(a, b, coefficient=1.0) - + # dx = 0-1 = -1, dist_sq = 1 # factor = (1 * 3 * 2) / 1 = 6 # a.dx += -1 * 6 = -6 @@ -60,7 +61,7 @@ def test_lin_repulsion_asymmetric_masses(self): class TestLinRepulsionRegion: """Test linear repulsion between node and region""" - + def test_lin_repulsion_region_basic(self): """Test linear repulsion between a node and a region""" node = fa2util.Node() @@ -68,32 +69,32 @@ def test_lin_repulsion_region_basic(self): node.x = 5.0 node.y = 5.0 node.dx = node.dy = 0.0 - + # Create a region with TWO nodes (single node regions don't calculate mass) dummy1 = fa2util.Node() dummy1.mass = 1.0 dummy1.x = 3.0 dummy1.y = 3.0 - + dummy2 = fa2util.Node() dummy2.mass = 1.0 dummy2.x = 3.0 dummy2.y = 3.0 - + region = fa2util.Region([dummy1, dummy2]) - + # The region should have mass from both nodes assert region.mass == pytest.approx(2.0) - + fa2util.linRepulsion_region(node, region, coefficient=1.0) - + # dx = 5-3 = 2, dy = 2, dist_sq = 8 # factor = (1 * 3 * 2) / 8 = 0.75 # node.dx += 2 * 0.75 = 1.5 # node.dy += 2 * 0.75 = 1.5 assert node.dx == pytest.approx(1.5) assert node.dy == pytest.approx(1.5) - + def test_lin_repulsion_region_no_update_zero_distance(self): """Test no update when node is at region center""" node = fa2util.Node() @@ -101,15 +102,15 @@ def test_lin_repulsion_region_no_update_zero_distance(self): node.x = 3.0 node.y = 3.0 node.dx = node.dy = 0.0 - + dummy = fa2util.Node() dummy.mass = 1.0 dummy.x = 3.0 dummy.y = 3.0 region = fa2util.Region([dummy]) - + fa2util.linRepulsion_region(node, region, coefficient=1.0) - + # No force when at same position assert node.dx == pytest.approx(0.0) assert node.dy == pytest.approx(0.0) @@ -117,7 +118,7 @@ def test_lin_repulsion_region_no_update_zero_distance(self): class TestLinGravityPrecise: """Precise numerical tests for linear gravity""" - + def test_lin_gravity_exact_values(self): """Test linear gravity with exact numerical expectations""" node = fa2util.Node() @@ -125,16 +126,16 @@ def test_lin_gravity_exact_values(self): node.x = 3.0 node.y = 4.0 # distance from origin = 5 node.dx = node.dy = 0.0 - + fa2util.linGravity(node, g=1.0) - + # distance = sqrt(9 + 16) = 5 # factor = (mass * g) / distance = (2 * 1) / 5 = 0.4 # dx -= 3 * 0.4 = -1.2 # dy -= 4 * 0.4 = -1.6 assert node.dx == pytest.approx(-1.2) assert node.dy == pytest.approx(-1.6) - + def test_lin_gravity_different_gravity_values(self): """Test linear gravity with different gravity constants""" node = fa2util.Node() @@ -142,9 +143,9 @@ def test_lin_gravity_different_gravity_values(self): node.x = 3.0 node.y = 4.0 # distance = 5 node.dx = node.dy = 0.0 - + fa2util.linGravity(node, g=2.0) - + # factor = (1 * 2) / 5 = 0.4 # dx -= 3 * 0.4 = -1.2 # dy -= 4 * 0.4 = -1.6 @@ -154,7 +155,7 @@ def test_lin_gravity_different_gravity_values(self): class TestStrongGravityPrecise: """Precise numerical tests for strong gravity""" - + def test_strong_gravity_exact_values(self): """Test strong gravity with exact numerical expectations""" node = fa2util.Node() @@ -162,15 +163,15 @@ def test_strong_gravity_exact_values(self): node.x = 3.0 node.y = 4.0 node.dx = node.dy = 0.0 - + fa2util.strongGravity(node, g=1.0, coefficient=1.0) - + # factor = coefficient * mass * g = 1 * 2 * 1 = 2 # dx -= 3 * 2 = -6 # dy -= 4 * 2 = -8 assert node.dx == pytest.approx(-6.0) assert node.dy == pytest.approx(-8.0) - + def test_strong_gravity_with_scaling(self): """Test strong gravity with different coefficient""" node = fa2util.Node() @@ -178,9 +179,9 @@ def test_strong_gravity_with_scaling(self): node.x = 2.0 node.y = 3.0 node.dx = node.dy = 0.0 - + fa2util.strongGravity(node, g=1.0, coefficient=2.0) - + # factor = 2 * 1 * 1 = 2 # dx -= 2 * 2 = -4 # dy -= 3 * 2 = -6 @@ -190,7 +191,7 @@ def test_strong_gravity_with_scaling(self): class TestLinAttractionPrecise: """Precise numerical tests for linear attraction""" - + def test_lin_attraction_exact_values(self): """Test linear attraction with exact numerical expectations""" a = fa2util.Node() @@ -200,15 +201,13 @@ def test_lin_attraction_exact_values(self): a.x, a.y = 0.0, 0.0 b.x, b.y = 3.0, 4.0 # distance = 5 a.dx = a.dy = b.dx = b.dy = 0.0 - + edge_weight = 1.0 - + fa2util.linAttraction( - a, b, edge_weight, - distributedAttraction=False, - coefficient=2.0 + a, b, edge_weight, distributedAttraction=False, coefficient=2.0 ) - + # dx = 0-3 = -3, dy = 0-4 = -4 # factor = -coefficient * edge_weight = -2.0 * 1.0 = -2.0 # a.dx += -3 * -2.0 = 6.0 @@ -219,7 +218,7 @@ def test_lin_attraction_exact_values(self): assert a.dy == pytest.approx(8.0) assert b.dx == pytest.approx(-6.0) assert b.dy == pytest.approx(-8.0) - + def test_lin_attraction_distributed(self): """Test distributed attraction (hub dissuasion)""" a = fa2util.Node() @@ -229,15 +228,13 @@ def test_lin_attraction_distributed(self): a.x, a.y = 0.0, 0.0 b.x, b.y = 2.0, 0.0 a.dx = a.dy = b.dx = b.dy = 0.0 - + edge_weight = 1.0 - + fa2util.linAttraction( - a, b, edge_weight, - distributedAttraction=True, - coefficient=1.0 + a, b, edge_weight, distributedAttraction=True, coefficient=1.0 ) - + # dx = 0-2 = -2, dy = 0 # factor = -coefficient * edge_weight / mass_a = -1.0 * 1.0 / 4.0 = -0.25 # a.dx += -2 * -0.25 = 0.5 @@ -250,7 +247,7 @@ def test_lin_attraction_distributed(self): class TestApplyForcesPrecise: """Precise tests for force application functions""" - + def test_apply_repulsion_exact(self): """Test apply_repulsion with exact values""" a = fa2util.Node() @@ -259,9 +256,9 @@ def test_apply_repulsion_exact(self): a.x, a.y = 0.0, 0.0 b.x, b.y = 1.0, 0.0 a.dx = a.dy = b.dx = b.dy = 0.0 - + fa2util.apply_repulsion([a, b], coefficient=2.0) - + # dx = 0-1 = -1, dist_sq = 1 # factor = (2 * 2 * 2) / 1 = 8 # a.dx += -1 * 8 = -8 @@ -270,37 +267,41 @@ def test_apply_repulsion_exact(self): assert a.dy == pytest.approx(0.0) assert b.dx == pytest.approx(8.0) assert b.dy == pytest.approx(0.0) - + def test_apply_gravity_linear_exact(self): """Test apply_gravity with linear mode""" node = fa2util.Node() node.mass = 1.0 node.x, node.y = 3.0, 4.0 # distance = 5 node.dx = node.dy = 0.0 - - fa2util.apply_gravity([node], gravity=1.0, scalingRatio=1.0, useStrongGravity=False) - + + fa2util.apply_gravity( + [node], gravity=1.0, scalingRatio=1.0, useStrongGravity=False + ) + # factor = (mass * gravity) / distance = (1 * 1) / 5 = 0.2 # dx -= 3 * 0.2 = -0.6 # dy -= 4 * 0.2 = -0.8 assert node.dx == pytest.approx(-0.6) assert node.dy == pytest.approx(-0.8) - + def test_apply_gravity_strong_exact(self): """Test apply_gravity with strong gravity mode""" node = fa2util.Node() node.mass = 1.0 node.x, node.y = 3.0, 4.0 node.dx = node.dy = 0.0 - - fa2util.apply_gravity([node], gravity=1.0, scalingRatio=1.0, useStrongGravity=True) - + + fa2util.apply_gravity( + [node], gravity=1.0, scalingRatio=1.0, useStrongGravity=True + ) + # factor = scaling_ratio * mass * gravity = 1 * 1 * 1 = 1 # dx -= 3 * 1 = -3 # dy -= 4 * 1 = -4 assert node.dx == pytest.approx(-3.0) assert node.dy == pytest.approx(-4.0) - + def test_apply_attraction_exact(self): """Test apply_attraction with exact values""" a = fa2util.Node() @@ -309,21 +310,21 @@ def test_apply_attraction_exact(self): a.x, a.y = 0.0, 0.0 b.x, b.y = 1.0, 0.0 a.dx = a.dy = b.dx = b.dy = 0.0 - + edge = fa2util.Edge() edge.node1 = 0 edge.node2 = 1 edge.weight = 1.0 nodes = [a, b] - + fa2util.apply_attraction( nodes, [edge], distributedAttraction=False, coefficient=1.0, - edgeWeightInfluence=1.0 + edgeWeightInfluence=1.0, ) - + # dx = 0-1 = -1, factor = -1.0 * 1.0 = -1.0 # a.dx += -1 * -1.0 = 1.0 # b.dx -= -1 * -1.0 = -1.0 @@ -335,7 +336,7 @@ def test_apply_attraction_exact(self): class TestRegionPrecise: """Precise tests for Region class methods""" - + def test_region_mass_center_calculation(self): """Test precise calculation of region mass center""" a = fa2util.Node() @@ -344,16 +345,16 @@ def test_region_mass_center_calculation(self): b.mass = 4.0 a.x, a.y = 0.0, 0.0 b.x, b.y = 4.0, 0.0 - + region = fa2util.Region([a, b]) - + # Total mass = 6 assert region.mass == pytest.approx(6.0) - + # Center of mass: ((0*2 + 4*4)/6, (0*2 + 0*4)/6) = (16/6, 0) = (8/3, 0) assert region.massCenterX == pytest.approx(8.0 / 3.0) assert region.massCenterY == pytest.approx(0.0) - + def test_region_size_calculation(self): """Test region size calculation""" # Create nodes at specific positions @@ -364,16 +365,16 @@ def test_region_size_calculation(self): n.mass = 2.0 n.x, n.y = float(x), float(y) nodes.append(n) - + region = fa2util.Region(nodes) - + # massSumX = 0*2 + 4*2 = 8, mass = 4 # Center is at (8/4, 0) = (2, 0) # Distance from (0,0) to (2,0) = 2, from (4,0) to (2,0) = 2 # Size should be 2 * max_distance = 2 * 2 = 4.0 expected_size = 4.0 assert region.size == pytest.approx(expected_size) - + def test_region_build_subregions_quadrants(self): """Test that subregions are built in correct quadrants""" # Create 4 nodes in different quadrants around (2, 2) @@ -384,17 +385,17 @@ def test_region_build_subregions_quadrants(self): n.mass = 1.0 n.x, n.y = float(x), float(y) nodes.append(n) - + region = fa2util.Region(nodes) region.buildSubRegions() - + # Should create 4 subregions (one for each quadrant) assert len(region.subregions) == 4 - + # Each subregion should have exactly 1 node for subregion in region.subregions: assert len(subregion.nodes) == 1 - + def test_region_apply_force_single_node(self): """Test applying force from a single-node region""" target = fa2util.Node() @@ -402,24 +403,24 @@ def test_region_apply_force_single_node(self): target.x = 5.0 target.y = 5.0 target.dx = target.dy = 0.0 - + source = fa2util.Node() source.mass = 1.0 source.x = 1.0 source.y = 1.0 - + region = fa2util.Region([source]) - + # For a single-node region, apply_force calls linRepulsion region.applyForce(target, theta=100.0, coefficient=1.0) - + # dx = 5-1 = 4, dy = 4, dist_sq = 32 # factor = (1 * 1 * 1) / 32 = 0.03125 # target.dx += 4 * 0.03125 = 0.125 # target.dy += 4 * 0.03125 = 0.125 assert target.dx == pytest.approx(0.125) assert target.dy == pytest.approx(0.125) - + def test_region_apply_force_uses_approximation(self): """Test that region uses approximation when theta condition is met""" target = fa2util.Node() @@ -427,7 +428,7 @@ def test_region_apply_force_uses_approximation(self): target.x = 10.0 target.y = 10.0 target.dx = target.dy = 0.0 - + # Create a region with multiple nodes close together nodes = [] for i in range(4): @@ -436,20 +437,20 @@ def test_region_apply_force_uses_approximation(self): n.x = float(i) n.y = 0.0 nodes.append(n) - + region = fa2util.Region(nodes) region.buildSubRegions() - + # With large theta and large distance, should use region approximation region.applyForce(target, theta=0.5, coefficient=1.0) - + # Force should have been applied (non-zero) assert target.dx != 0.0 or target.dy != 0.0 class TestAdjustSpeedPrecise: """Precise tests for speed adjustment""" - + def test_adjust_speed_modifies_positions(self): """Test that adjust_speed_and_apply_forces updates node positions""" a = fa2util.Node() @@ -457,33 +458,30 @@ def test_adjust_speed_modifies_positions(self): a.mass = b.mass = 1.0 a.x, a.y = 0.0, 0.0 b.x, b.y = 1.0, 0.0 - + # Set forces a.old_dx, a.old_dy = 0.0, 0.0 a.dx, a.dy = 1.0, 0.0 b.old_dx, b.old_dy = 0.0, 0.0 b.dx, b.dy = -1.0, 0.0 - + old_a_x = a.x old_b_x = b.x - + result = fa2util.adjustSpeedAndApplyForces( - [a, b], - speed=1.0, - speedEfficiency=1.0, - jitterTolerance=1.0 + [a, b], speed=1.0, speedEfficiency=1.0, jitterTolerance=1.0 ) - + # Positions should have changed assert a.x != old_a_x assert b.x != old_b_x - + # Result should contain updated speed values - assert 'speed' in result - assert 'speedEfficiency' in result - assert result['speed'] > 0 - assert result['speedEfficiency'] > 0 - + assert "speed" in result + assert "speedEfficiency" in result + assert result["speed"] > 0 + assert result["speedEfficiency"] > 0 + def test_adjust_speed_returns_valid_dict(self): """Test that adjust_speed returns properly formatted dictionary""" nodes = [] @@ -497,17 +495,13 @@ def test_adjust_speed_returns_valid_dict(self): n.old_dx = 0.0 n.old_dy = 0.0 nodes.append(n) - + result = fa2util.adjustSpeedAndApplyForces( - nodes, - speed=1.0, - speedEfficiency=1.0, - jitterTolerance=1.0 + nodes, speed=1.0, speedEfficiency=1.0, jitterTolerance=1.0 ) - - assert isinstance(result, dict) - assert 'speed' in result - assert 'speedEfficiency' in result - assert isinstance(result['speed'], float) - assert isinstance(result['speedEfficiency'], float) + assert isinstance(result, dict) + assert "speed" in result + assert "speedEfficiency" in result + assert isinstance(result["speed"], float) + assert isinstance(result["speedEfficiency"], float) diff --git a/tests/test_forceatlas2.py b/tests/test_forceatlas2.py index 4e7a107..0b99341 100644 --- a/tests/test_forceatlas2.py +++ b/tests/test_forceatlas2.py @@ -1,6 +1,7 @@ """ Tests for ForceAtlas2 main class and layout algorithms """ + import numpy as np import pytest import scipy.sparse @@ -10,11 +11,11 @@ class TestForceAtlas2Initialization: """Test ForceAtlas2 class initialization""" - + def test_default_initialization(self): """Test ForceAtlas2 with default parameters""" fa2 = ForceAtlas2() - + assert fa2.outboundAttractionDistribution is False assert fa2.linLogMode is False assert fa2.adjustSizes is False @@ -26,7 +27,7 @@ def test_default_initialization(self): assert fa2.strongGravityMode is False assert fa2.gravity == 1.0 assert fa2.verbose is True - + def test_custom_parameters(self): """Test ForceAtlas2 with custom parameters""" fa2 = ForceAtlas2( @@ -38,9 +39,9 @@ def test_custom_parameters(self): scalingRatio=3.0, strongGravityMode=True, gravity=2.0, - verbose=False + verbose=False, ) - + assert fa2.outboundAttractionDistribution is True assert fa2.edgeWeightInfluence == 0.5 assert fa2.jitterTolerance == 2.0 @@ -50,120 +51,102 @@ def test_custom_parameters(self): assert fa2.strongGravityMode is True assert fa2.gravity == 2.0 assert fa2.verbose is False - + def test_unimplemented_features_raise_error(self): """Test that unimplemented features raise assertion error""" with pytest.raises(AssertionError): ForceAtlas2(linLogMode=True) - + with pytest.raises(AssertionError): ForceAtlas2(adjustSizes=True) - + with pytest.raises(AssertionError): ForceAtlas2(multiThreaded=True) class TestForceAtlas2Init: """Test the init method that prepares graph data""" - + def test_init_numpy_array(self): """Test init with numpy array""" - G = np.array([ - [0, 1, 0, 1], - [1, 0, 1, 0], - [0, 1, 0, 1], - [1, 0, 1, 0] - ]) - + G = np.array([[0, 1, 0, 1], [1, 0, 1, 0], [0, 1, 0, 1], [1, 0, 1, 0]]) + fa2 = ForceAtlas2(verbose=False) nodes, edges = fa2.init(G) - + assert len(nodes) == 4 assert len(edges) == 4 # 4 unique edges in undirected graph - + # Check node masses (1 + number of connections) assert nodes[0].mass == 3.0 # 1 + 2 edges assert nodes[1].mass == 3.0 assert nodes[2].mass == 3.0 assert nodes[3].mass == 3.0 - + def test_init_sparse_matrix(self): """Test init with scipy sparse matrix""" - G = scipy.sparse.lil_matrix([ - [0, 1, 0, 1], - [1, 0, 1, 0], - [0, 1, 0, 1], - [1, 0, 1, 0] - ]) - + G = scipy.sparse.lil_matrix( + [[0, 1, 0, 1], [1, 0, 1, 0], [0, 1, 0, 1], [1, 0, 1, 0]] + ) + fa2 = ForceAtlas2(verbose=False) nodes, edges = fa2.init(G) - + assert len(nodes) == 4 assert len(edges) == 4 - + def test_init_with_positions(self): """Test init with initial positions""" - G = np.array([ - [0, 1], - [1, 0] - ]) + G = np.array([[0, 1], [1, 0]]) pos = np.array([[0.0, 0.0], [1.0, 1.0]]) - + fa2 = ForceAtlas2(verbose=False) nodes, edges = fa2.init(G, pos=pos) - + assert nodes[0].x == 0.0 assert nodes[0].y == 0.0 assert nodes[1].x == 1.0 assert nodes[1].y == 1.0 - + def test_init_without_positions(self): """Test init without initial positions (random)""" - G = np.array([ - [0, 1], - [1, 0] - ]) - + G = np.array([[0, 1], [1, 0]]) + fa2 = ForceAtlas2(verbose=False) nodes, edges = fa2.init(G, pos=None) - + # Should have random positions between 0 and 1 assert 0 <= nodes[0].x <= 1 assert 0 <= nodes[0].y <= 1 assert 0 <= nodes[1].x <= 1 assert 0 <= nodes[1].y <= 1 - + def test_init_weighted_graph(self): """Test init with weighted edges""" - G = np.array([ - [0.0, 2.5, 0.0], - [2.5, 0.0, 1.5], - [0.0, 1.5, 0.0] - ]) - + G = np.array([[0.0, 2.5, 0.0], [2.5, 0.0, 1.5], [0.0, 1.5, 0.0]]) + fa2 = ForceAtlas2(verbose=False) nodes, edges = fa2.init(G) - + assert len(edges) == 2 # Check edge weights weights = sorted([e.weight for e in edges]) assert weights == [1.5, 2.5] - + def test_init_invalid_input(self): """Test init with invalid input""" fa2 = ForceAtlas2(verbose=False) - + # Non-square matrix with pytest.raises(AssertionError): G = np.array([[0, 1], [1, 0], [0, 1]]) fa2.init(G) - + # Non-symmetric matrix with pytest.raises(AssertionError): G = np.array([[0, 1], [0, 0]]) fa2.init(G) - + # Invalid type with pytest.raises(AssertionError): fa2.init([[0, 1], [1, 0]]) @@ -171,80 +154,58 @@ def test_init_invalid_input(self): class TestForceAtlas2Layout: """Test the main forceatlas2 layout method""" - + def test_simple_graph_layout(self): """Test layout on simple graph""" - G = np.array([ - [0, 1, 0, 0], - [1, 0, 1, 0], - [0, 1, 0, 1], - [0, 0, 1, 0] - ]) - + G = np.array([[0, 1, 0, 0], [1, 0, 1, 0], [0, 1, 0, 1], [0, 0, 1, 0]]) + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2(G, iterations=10) - + assert len(pos) == 4 assert all(len(p) == 2 for p in pos) # Each position is (x, y) assert all(isinstance(p[0], float) and isinstance(p[1], float) for p in pos) - + def test_layout_with_initial_positions(self): """Test layout with provided initial positions""" - G = np.array([ - [0, 1], - [1, 0] - ]) + G = np.array([[0, 1], [1, 0]]) initial_pos = np.array([[0.0, 0.0], [10.0, 0.0]]) - + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2(G, pos=initial_pos, iterations=10) - + assert len(pos) == 2 # Positions should have changed from initial # (they should be pulled together by attraction) - + def test_layout_barnes_hut_enabled(self): """Test layout with Barnes-Hut optimization""" - G = np.array([ - [0, 1, 1, 0], - [1, 0, 1, 1], - [1, 1, 0, 1], - [0, 1, 1, 0] - ]) - + G = np.array([[0, 1, 1, 0], [1, 0, 1, 1], [1, 1, 0, 1], [0, 1, 1, 0]]) + fa2 = ForceAtlas2(barnesHutOptimize=True, verbose=False) pos = fa2.forceatlas2(G, iterations=10) - + assert len(pos) == 4 - + def test_layout_barnes_hut_disabled(self): """Test layout without Barnes-Hut optimization""" - G = np.array([ - [0, 1, 1, 0], - [1, 0, 1, 1], - [1, 1, 0, 1], - [0, 1, 1, 0] - ]) - + G = np.array([[0, 1, 1, 0], [1, 0, 1, 1], [1, 1, 0, 1], [0, 1, 1, 0]]) + fa2 = ForceAtlas2(barnesHutOptimize=False, verbose=False) pos = fa2.forceatlas2(G, iterations=10) - + assert len(pos) == 4 - + def test_layout_strong_gravity(self): """Test layout with strong gravity mode""" - G = np.array([ - [0, 1, 0], - [1, 0, 0], - [0, 0, 0] - ]) - + G = np.array([[0, 1, 0], [1, 0, 0], [0, 0, 0]]) + fa2 = ForceAtlas2(strongGravityMode=True, gravity=2.0, verbose=False) pos = fa2.forceatlas2(G, iterations=50) - + assert len(pos) == 3 # With strong gravity, nodes should be pulled toward origin - + def test_layout_outbound_attraction_distribution(self): """Test layout with outbound attraction distribution (hub dissuasion)""" # Create a star graph (one hub connected to all others) @@ -253,141 +214,108 @@ def test_layout_outbound_attraction_distribution(self): for i in range(1, n): G[0, i] = 1 G[i, 0] = 1 - + fa2 = ForceAtlas2(outboundAttractionDistribution=True, verbose=False) pos = fa2.forceatlas2(G, iterations=50) - + assert len(pos) == n - + def test_layout_edge_weight_influence(self): """Test layout with different edge weight influence""" - G = np.array([ - [0.0, 1.0, 5.0], - [1.0, 0.0, 1.0], - [5.0, 1.0, 0.0] - ]) - + G = np.array([[0.0, 1.0, 5.0], [1.0, 0.0, 1.0], [5.0, 1.0, 0.0]]) + # With weight influence = 1, weights should matter fa2_weighted = ForceAtlas2(edgeWeightInfluence=1.0, verbose=False) pos_weighted = fa2_weighted.forceatlas2(G, iterations=50) - + # With weight influence = 0, weights should be ignored fa2_unweighted = ForceAtlas2(edgeWeightInfluence=0.0, verbose=False) pos_unweighted = fa2_unweighted.forceatlas2(G, iterations=50) - + assert len(pos_weighted) == 3 assert len(pos_unweighted) == 3 - + def test_layout_different_iterations(self): """Test that more iterations produce different results""" - G = np.array([ - [0, 1, 1], - [1, 0, 1], - [1, 1, 0] - ]) - + G = np.array([[0, 1, 1], [1, 0, 1], [1, 1, 0]]) + fa2 = ForceAtlas2(verbose=False) - + # Set seed for reproducibility np.random.seed(42) pos_10 = fa2.forceatlas2(G, iterations=10) - + np.random.seed(42) pos_100 = fa2.forceatlas2(G, iterations=100) - + # Results should be different with different iteration counts assert pos_10 != pos_100 - + def test_layout_sparse_matrix(self): """Test layout with scipy sparse matrix""" - G = scipy.sparse.lil_matrix([ - [0, 1, 1, 0], - [1, 0, 1, 0], - [1, 1, 0, 1], - [0, 0, 1, 0] - ]) - + G = scipy.sparse.lil_matrix( + [[0, 1, 1, 0], [1, 0, 1, 0], [1, 1, 0, 1], [0, 0, 1, 0]] + ) + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2(G, iterations=10) - + assert len(pos) == 4 - + def test_layout_single_node(self): """Test layout with single node""" G = np.array([[0]]) - + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2(G, iterations=10) - + assert len(pos) == 1 - + def test_layout_disconnected_nodes(self): """Test layout with disconnected nodes""" - G = np.array([ - [0, 1, 0, 0], - [1, 0, 0, 0], - [0, 0, 0, 1], - [0, 0, 1, 0] - ]) - + G = np.array([[0, 1, 0, 0], [1, 0, 0, 0], [0, 0, 0, 1], [0, 0, 1, 0]]) + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2(G, iterations=50) - + assert len(pos) == 4 # Two disconnected components class TestForceAtlas2Parameters: """Test different parameter combinations""" - + def test_varying_scaling_ratio(self): """Test with different scaling ratios""" - G = np.array([ - [0, 1, 1], - [1, 0, 1], - [1, 1, 0] - ]) - + G = np.array([[0, 1, 1], [1, 0, 1], [1, 1, 0]]) + for ratio in [0.5, 1.0, 2.0, 5.0]: fa2 = ForceAtlas2(scalingRatio=ratio, verbose=False) pos = fa2.forceatlas2(G, iterations=10) assert len(pos) == 3 - + def test_varying_gravity(self): """Test with different gravity values""" - G = np.array([ - [0, 1, 0], - [1, 0, 1], - [0, 1, 0] - ]) - + G = np.array([[0, 1, 0], [1, 0, 1], [0, 1, 0]]) + for grav in [0.1, 1.0, 5.0, 10.0]: fa2 = ForceAtlas2(gravity=grav, verbose=False) pos = fa2.forceatlas2(G, iterations=10) assert len(pos) == 3 - + def test_varying_jitter_tolerance(self): """Test with different jitter tolerance values""" - G = np.array([ - [0, 1, 1], - [1, 0, 1], - [1, 1, 0] - ]) - + G = np.array([[0, 1, 1], [1, 0, 1], [1, 1, 0]]) + for jitter in [0.5, 1.0, 2.0]: fa2 = ForceAtlas2(jitterTolerance=jitter, verbose=False) pos = fa2.forceatlas2(G, iterations=10) assert len(pos) == 3 - + def test_varying_barnes_hut_theta(self): """Test with different Barnes-Hut theta values""" - G = np.array([ - [0, 1, 1, 0], - [1, 0, 1, 1], - [1, 1, 0, 1], - [0, 1, 1, 0] - ]) - + G = np.array([[0, 1, 1, 0], [1, 0, 1, 1], [1, 1, 0, 1], [0, 1, 1, 0]]) + for theta in [0.5, 1.0, 1.2, 2.0]: fa2 = ForceAtlas2(barnesHutTheta=theta, verbose=False) pos = fa2.forceatlas2(G, iterations=10) @@ -396,28 +324,25 @@ def test_varying_barnes_hut_theta(self): class TestForceAtlas2Reproducibility: """Test reproducibility with same random seed""" - + def test_reproducibility_with_seed(self): """Test that same seed produces same results""" - G = np.array([ - [0, 1, 1], - [1, 0, 1], - [1, 1, 0] - ]) - + G = np.array([[0, 1, 1], [1, 0, 1], [1, 1, 0]]) + fa2 = ForceAtlas2(verbose=False) - + # First run np.random.seed(42) import random + random.seed(42) pos1 = fa2.forceatlas2(G, iterations=10) - + # Second run with same seed np.random.seed(42) random.seed(42) pos2 = fa2.forceatlas2(G, iterations=10) - + # Results should be identical for p1, p2 in zip(pos1, pos2): assert abs(p1[0] - p2[0]) < 1e-10 @@ -426,19 +351,16 @@ def test_reproducibility_with_seed(self): class TestForceAtlas2EdgeCases: """Test edge cases and boundary conditions""" - + def test_zero_iterations(self): """Test with zero iterations""" - G = np.array([ - [0, 1], - [1, 0] - ]) - + G = np.array([[0, 1], [1, 0]]) + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2(G, iterations=0) - + assert len(pos) == 2 - + def test_large_graph(self): """Test with larger graph""" n = 50 @@ -447,28 +369,27 @@ def test_large_graph(self): for i in range(n): G[i, (i + 1) % n] = 1 G[(i + 1) % n, i] = 1 - + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2(G, iterations=10) - + assert len(pos) == n - + def test_complete_graph(self): """Test with complete graph""" n = 10 G = np.ones((n, n)) - np.eye(n) - + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2(G, iterations=10) - + assert len(pos) == n - + def test_no_edges(self): """Test with graph with no edges""" G = np.zeros((5, 5)) - + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2(G, iterations=10) - - assert len(pos) == 5 + assert len(pos) == 5 diff --git a/tests/test_igraph_integration.py b/tests/test_igraph_integration.py index 7c5fd0c..343205a 100644 --- a/tests/test_igraph_integration.py +++ b/tests/test_igraph_integration.py @@ -1,11 +1,13 @@ """ Tests for igraph integration """ + import numpy as np import pytest try: import igraph + IGRAPH_AVAILABLE = True except ImportError: IGRAPH_AVAILABLE = False @@ -16,250 +18,251 @@ @pytest.mark.skipif(not IGRAPH_AVAILABLE, reason="igraph not installed") class TestIgraphIntegration: """Test ForceAtlas2 with igraph graphs""" - + def test_igraph_simple_graph(self): """Test with simple igraph graph""" G = igraph.Graph() G.add_vertices(4) G.add_edges([(0, 1), (1, 2), (2, 3), (3, 0)]) - + fa2 = ForceAtlas2(verbose=False) layout = fa2.forceatlas2_igraph_layout(G, iterations=10) - + assert isinstance(layout, igraph.layout.Layout) assert len(layout) == 4 assert layout.dim == 2 - + def test_igraph_with_initial_positions(self): """Test igraph layout with initial positions""" G = igraph.Graph() G.add_vertices(3) G.add_edges([(0, 1), (1, 2)]) - + initial_pos = [[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]] - + fa2 = ForceAtlas2(verbose=False) layout = fa2.forceatlas2_igraph_layout(G, pos=initial_pos, iterations=10) - + assert len(layout) == 3 - + def test_igraph_with_numpy_positions(self): """Test igraph layout with numpy array positions""" G = igraph.Graph() G.add_vertices(3) G.add_edges([(0, 1), (1, 2)]) - + initial_pos = np.array([[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]]) - + fa2 = ForceAtlas2(verbose=False) layout = fa2.forceatlas2_igraph_layout(G, pos=initial_pos, iterations=10) - + assert len(layout) == 3 - + def test_igraph_weighted_graph(self): """Test with weighted igraph graph""" G = igraph.Graph() G.add_vertices(3) G.add_edges([(0, 1), (1, 2), (2, 0)]) - G.es['weight'] = [1.0, 5.0, 1.0] - + G.es["weight"] = [1.0, 5.0, 1.0] + fa2 = ForceAtlas2(verbose=False) - layout = fa2.forceatlas2_igraph_layout(G, iterations=10, weight_attr='weight') - + layout = fa2.forceatlas2_igraph_layout(G, iterations=10, weight_attr="weight") + assert len(layout) == 3 - + def test_igraph_custom_weight_attribute(self): """Test with custom weight attribute name""" G = igraph.Graph() G.add_vertices(3) G.add_edges([(0, 1), (1, 2)]) - G.es['strength'] = [2.0, 3.0] - + G.es["strength"] = [2.0, 3.0] + fa2 = ForceAtlas2(verbose=False) - layout = fa2.forceatlas2_igraph_layout(G, iterations=10, weight_attr='strength') - + layout = fa2.forceatlas2_igraph_layout(G, iterations=10, weight_attr="strength") + assert len(layout) == 3 - + def test_igraph_star_graph(self): """Test with star graph (hub topology)""" G = igraph.Graph.Star(6) - + fa2 = ForceAtlas2(outboundAttractionDistribution=True, verbose=False) layout = fa2.forceatlas2_igraph_layout(G, iterations=50) - + assert len(layout) == 6 - + def test_igraph_complete_graph(self): """Test with complete graph""" G = igraph.Graph.Full(5) - + fa2 = ForceAtlas2(verbose=False) layout = fa2.forceatlas2_igraph_layout(G, iterations=10) - + assert len(layout) == 5 - + def test_igraph_ring_graph(self): """Test with ring graph""" G = igraph.Graph.Ring(8) - + fa2 = ForceAtlas2(verbose=False) layout = fa2.forceatlas2_igraph_layout(G, iterations=50) - + assert len(layout) == 8 - + def test_igraph_tree_graph(self): """Test with tree graph""" G = igraph.Graph.Tree(15, 3) # Tree with 15 nodes, 3 children per node - + fa2 = ForceAtlas2(verbose=False) layout = fa2.forceatlas2_igraph_layout(G, iterations=50) - + assert len(layout) == 15 - + def test_igraph_lattice_graph(self): """Test with lattice graph""" G = igraph.Graph.Lattice([3, 3], circular=False) - + fa2 = ForceAtlas2(verbose=False) layout = fa2.forceatlas2_igraph_layout(G, iterations=50) - + assert len(layout) == 9 - + def test_igraph_single_node(self): """Test with single node""" G = igraph.Graph() G.add_vertices(1) - + fa2 = ForceAtlas2(verbose=False) layout = fa2.forceatlas2_igraph_layout(G, iterations=10) - + assert len(layout) == 1 - + def test_igraph_disconnected_graph(self): """Test with disconnected components""" G = igraph.Graph() G.add_vertices(6) G.add_edges([(0, 1), (1, 2)]) # Component 1 G.add_edges([(3, 4), (4, 5)]) # Component 2 - + fa2 = ForceAtlas2(verbose=False) layout = fa2.forceatlas2_igraph_layout(G, iterations=50) - + assert len(layout) == 6 - + def test_igraph_no_edges(self): """Test with nodes but no edges""" G = igraph.Graph() G.add_vertices(4) - + fa2 = ForceAtlas2(verbose=False) layout = fa2.forceatlas2_igraph_layout(G, iterations=10) - + assert len(layout) == 4 - + def test_igraph_layout_coordinates_format(self): """Test that layout coordinates are in correct format""" G = igraph.Graph() G.add_vertices(3) G.add_edges([(0, 1), (1, 2)]) - + fa2 = ForceAtlas2(verbose=False) layout = fa2.forceatlas2_igraph_layout(G, iterations=10) - + coords = layout.coords assert len(coords) == 3 for coord in coords: assert len(coord) == 2 assert isinstance(coord[0], (int, float)) assert isinstance(coord[1], (int, float)) - + def test_igraph_parameter_combinations(self): """Test various parameter combinations with igraph""" G = igraph.Graph.Erdos_Renyi(n=30, p=0.1) - + params = [ - {'barnesHutOptimize': True, 'strongGravityMode': False}, - {'barnesHutOptimize': False, 'strongGravityMode': True}, - {'outboundAttractionDistribution': True, 'edgeWeightInfluence': 0.5}, - {'scalingRatio': 5.0, 'gravity': 2.0}, + {"barnesHutOptimize": True, "strongGravityMode": False}, + {"barnesHutOptimize": False, "strongGravityMode": True}, + {"outboundAttractionDistribution": True, "edgeWeightInfluence": 0.5}, + {"scalingRatio": 5.0, "gravity": 2.0}, ] - + for param in params: fa2 = ForceAtlas2(**param, verbose=False) layout = fa2.forceatlas2_igraph_layout(G, iterations=10) assert len(layout) == 30 - + def test_igraph_invalid_input(self): """Test that non-igraph graph raises error""" fa2 = ForceAtlas2(verbose=False) - + with pytest.raises(AssertionError): fa2.forceatlas2_igraph_layout([[0, 1], [1, 0]], iterations=10) - + def test_igraph_invalid_pos_type(self): """Test that invalid pos type raises error""" G = igraph.Graph() G.add_vertices(3) G.add_edges([(0, 1), (1, 2)]) - + fa2 = ForceAtlas2(verbose=False) - + with pytest.raises(AssertionError): fa2.forceatlas2_igraph_layout(G, pos="invalid", iterations=10) - + def test_igraph_directed_graph(self): """Test with directed graph (converted to undirected internally)""" G = igraph.Graph(directed=True) G.add_vertices(3) G.add_edges([(0, 1), (1, 2), (2, 0)]) - + fa2 = ForceAtlas2(verbose=False) # Note: The current implementation assumes undirected graphs # This test verifies the conversion happens correctly layout = fa2.forceatlas2_igraph_layout(G, iterations=10) - + assert len(layout) == 3 - + def test_igraph_random_graph(self): """Test with random Erdos-Renyi graph""" G = igraph.Graph.Erdos_Renyi(n=25, p=0.15) - + fa2 = ForceAtlas2(verbose=False) layout = fa2.forceatlas2_igraph_layout(G, iterations=50) - + assert len(layout) == 25 - + def test_igraph_barabasi_albert_graph(self): """Test with Barabasi-Albert (scale-free) graph""" G = igraph.Graph.Barabasi(n=30, m=2) - + fa2 = ForceAtlas2(outboundAttractionDistribution=True, verbose=False) layout = fa2.forceatlas2_igraph_layout(G, iterations=50) - + assert len(layout) == 30 @pytest.mark.skipif(not IGRAPH_AVAILABLE, reason="igraph not installed") class TestIgraphReproducibility: """Test reproducibility with igraph graphs""" - + def test_reproducibility_with_seed(self): """Test that same seed produces same igraph layout""" G = igraph.Graph.Erdos_Renyi(n=20, p=0.2) - + fa2 = ForceAtlas2(verbose=False) - + # First run np.random.seed(42) import random + random.seed(42) layout1 = fa2.forceatlas2_igraph_layout(G, iterations=50) - + # Second run with same seed np.random.seed(42) random.seed(42) layout2 = fa2.forceatlas2_igraph_layout(G, iterations=50) - + # Results should be identical coords1 = layout1.coords coords2 = layout2.coords @@ -271,28 +274,27 @@ def test_reproducibility_with_seed(self): @pytest.mark.skipif(not IGRAPH_AVAILABLE, reason="igraph not installed") class TestIgraphWeights: """Test edge weight handling in igraph graphs""" - + def test_no_weights(self): """Test without weights""" G = igraph.Graph() G.add_vertices(4) G.add_edges([(0, 1), (1, 2), (2, 3)]) - + fa2 = ForceAtlas2(verbose=False) layout = fa2.forceatlas2_igraph_layout(G, iterations=10, weight_attr=None) - + assert len(layout) == 4 - + def test_varying_weights(self): """Test with varying edge weights""" G = igraph.Graph() G.add_vertices(4) G.add_edges([(0, 1), (1, 2), (2, 3), (3, 0)]) - G.es['weight'] = [1.0, 10.0, 1.0, 1.0] - + G.es["weight"] = [1.0, 10.0, 1.0, 1.0] + fa2 = ForceAtlas2(edgeWeightInfluence=1.0, verbose=False) - layout = fa2.forceatlas2_igraph_layout(G, iterations=100, weight_attr='weight') - + layout = fa2.forceatlas2_igraph_layout(G, iterations=100, weight_attr="weight") + assert len(layout) == 4 # The edge with weight 10 should pull nodes 1 and 2 closer - diff --git a/tests/test_integration.py b/tests/test_integration.py index a64420e..bce1464 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2,6 +2,7 @@ Integration tests for fa2_modified package Tests the interaction between different components """ + import numpy as np import pytest import scipy.sparse @@ -11,18 +12,20 @@ class TestEndToEndWorkflow: """Test complete end-to-end workflows""" - + def test_numpy_to_layout_workflow(self): """Test complete workflow from numpy array to layout""" # Create adjacency matrix - G = np.array([ - [0, 1, 1, 0, 0], - [1, 0, 1, 1, 0], - [1, 1, 0, 1, 1], - [0, 1, 1, 0, 1], - [0, 0, 1, 1, 0] - ]) - + G = np.array( + [ + [0, 1, 1, 0, 0], + [1, 0, 1, 1, 0], + [1, 1, 0, 1, 1], + [0, 1, 1, 0, 1], + [0, 0, 1, 1, 0], + ] + ) + # Initialize ForceAtlas2 fa2 = ForceAtlas2( barnesHutOptimize=True, @@ -30,175 +33,164 @@ def test_numpy_to_layout_workflow(self): scalingRatio=2.0, strongGravityMode=False, gravity=1.0, - verbose=False + verbose=False, ) - + # Compute layout positions = fa2.forceatlas2(G, iterations=100) - + # Verify results assert len(positions) == 5 assert all(len(pos) == 2 for pos in positions) - + # Verify positions are reasonable (not all at origin) x_coords = [pos[0] for pos in positions] y_coords = [pos[1] for pos in positions] assert np.std(x_coords) > 0.01 assert np.std(y_coords) > 0.01 - + def test_sparse_to_layout_workflow(self): """Test complete workflow from sparse matrix to layout""" # Create sparse adjacency matrix - G = scipy.sparse.lil_matrix([ - [0, 1, 1, 0, 0], - [1, 0, 1, 1, 0], - [1, 1, 0, 1, 1], - [0, 1, 1, 0, 1], - [0, 0, 1, 1, 0] - ]) - + G = scipy.sparse.lil_matrix( + [ + [0, 1, 1, 0, 0], + [1, 0, 1, 1, 0], + [1, 1, 0, 1, 1], + [0, 1, 1, 0, 1], + [0, 0, 1, 1, 0], + ] + ) + fa2 = ForceAtlas2(verbose=False) positions = fa2.forceatlas2(G, iterations=100) - + assert len(positions) == 5 assert all(len(pos) == 2 for pos in positions) - + def test_weighted_workflow(self): """Test workflow with weighted edges""" # Create weighted graph where one edge is much stronger - G = np.array([ - [0.0, 1.0, 1.0, 0.0], - [1.0, 0.0, 10.0, 0.0], # Strong connection between 1 and 2 - [1.0, 10.0, 0.0, 1.0], - [0.0, 0.0, 1.0, 0.0] - ]) - + G = np.array( + [ + [0.0, 1.0, 1.0, 0.0], + [1.0, 0.0, 10.0, 0.0], # Strong connection between 1 and 2 + [1.0, 10.0, 0.0, 1.0], + [0.0, 0.0, 1.0, 0.0], + ] + ) + fa2 = ForceAtlas2(edgeWeightInfluence=1.0, verbose=False) positions = fa2.forceatlas2(G, iterations=200) - + # Nodes 1 and 2 should be closer together due to strong edge dist_1_2 = np.linalg.norm(np.array(positions[1]) - np.array(positions[2])) dist_0_1 = np.linalg.norm(np.array(positions[0]) - np.array(positions[1])) - + # The strong edge should pull nodes closer (this is a heuristic test) assert len(positions) == 4 - + def test_initial_positions_workflow(self): """Test workflow with provided initial positions""" - G = np.array([ - [0, 1, 0], - [1, 0, 1], - [0, 1, 0] - ]) - + G = np.array([[0, 1, 0], [1, 0, 1], [0, 1, 0]]) + # Start with nodes in a line - initial_pos = np.array([ - [0.0, 0.0], - [5.0, 0.0], - [10.0, 0.0] - ]) - + initial_pos = np.array([[0.0, 0.0], [5.0, 0.0], [10.0, 0.0]]) + fa2 = ForceAtlas2(verbose=False) positions = fa2.forceatlas2(G, pos=initial_pos, iterations=100) - + assert len(positions) == 3 # Positions should have changed from initial for i in range(3): - pos_changed = (positions[i][0] != initial_pos[i][0] or - positions[i][1] != initial_pos[i][1]) + pos_changed = ( + positions[i][0] != initial_pos[i][0] + or positions[i][1] != initial_pos[i][1] + ) assert pos_changed class TestParameterInteractions: """Test interactions between different parameters""" - + def test_gravity_repulsion_balance(self): """Test balance between gravity and repulsion""" - G = np.array([ - [0, 1, 0, 0], - [1, 0, 0, 0], - [0, 0, 0, 1], - [0, 0, 1, 0] - ]) - + G = np.array([[0, 1, 0, 0], [1, 0, 0, 0], [0, 0, 0, 1], [0, 0, 1, 0]]) + # High gravity should keep nodes close to origin fa2_high_gravity = ForceAtlas2(gravity=10.0, scalingRatio=1.0, verbose=False) pos_high_gravity = fa2_high_gravity.forceatlas2(G, iterations=100) - + # Low gravity should allow nodes to spread out more fa2_low_gravity = ForceAtlas2(gravity=0.1, scalingRatio=1.0, verbose=False) pos_low_gravity = fa2_low_gravity.forceatlas2(G, iterations=100) - + # Calculate spread from origin spread_high = np.mean([np.linalg.norm(pos) for pos in pos_high_gravity]) spread_low = np.mean([np.linalg.norm(pos) for pos in pos_low_gravity]) - + # Lower gravity should result in larger spread (generally) assert len(pos_high_gravity) == 4 assert len(pos_low_gravity) == 4 - + def test_barnes_hut_vs_direct(self): """Test that Barnes-Hut gives similar results to direct computation""" - G = np.array([ - [0, 1, 1, 0, 0], - [1, 0, 1, 1, 0], - [1, 1, 0, 1, 1], - [0, 1, 1, 0, 1], - [0, 0, 1, 1, 0] - ]) - + G = np.array( + [ + [0, 1, 1, 0, 0], + [1, 0, 1, 1, 0], + [1, 1, 0, 1, 1], + [0, 1, 1, 0, 1], + [0, 0, 1, 1, 0], + ] + ) + # Set seed for reproducibility np.random.seed(42) import random + random.seed(42) - + fa2_barnes = ForceAtlas2(barnesHutOptimize=True, verbose=False) pos_barnes = fa2_barnes.forceatlas2(G, iterations=50) - + np.random.seed(42) random.seed(42) - + fa2_direct = ForceAtlas2(barnesHutOptimize=False, verbose=False) pos_direct = fa2_direct.forceatlas2(G, iterations=50) - + # Results should be similar (not identical due to approximation) assert len(pos_barnes) == len(pos_direct) - + def test_strong_vs_normal_gravity(self): """Test difference between strong and normal gravity""" - G = np.array([ - [0, 1, 0], - [1, 0, 1], - [0, 1, 0] - ]) - + G = np.array([[0, 1, 0], [1, 0, 1], [0, 1, 0]]) + np.random.seed(42) fa2_strong = ForceAtlas2(strongGravityMode=True, gravity=1.0, verbose=False) pos_strong = fa2_strong.forceatlas2(G, iterations=100) - + np.random.seed(42) fa2_normal = ForceAtlas2(strongGravityMode=False, gravity=1.0, verbose=False) pos_normal = fa2_normal.forceatlas2(G, iterations=100) - + assert len(pos_strong) == 3 assert len(pos_normal) == 3 class TestScalability: """Test with different graph sizes""" - + def test_small_graph(self): """Test with very small graph (2 nodes)""" - G = np.array([ - [0, 1], - [1, 0] - ]) - + G = np.array([[0, 1], [1, 0]]) + fa2 = ForceAtlas2(verbose=False) positions = fa2.forceatlas2(G, iterations=10) - + assert len(positions) == 2 - + def test_medium_graph(self): """Test with medium-sized graph (50 nodes)""" n = 50 @@ -209,12 +201,12 @@ def test_medium_graph(self): if np.random.random() < 0.1: G[i, j] = 1 G[j, i] = 1 - + fa2 = ForceAtlas2(barnesHutOptimize=True, verbose=False) positions = fa2.forceatlas2(G, iterations=50) - + assert len(positions) == n - + @pytest.mark.slow def test_large_graph(self): """Test with larger graph (200 nodes)""" @@ -228,16 +220,16 @@ def test_large_graph(self): if i != j: G[i, j] = 1 G[j, i] = 1 - + fa2 = ForceAtlas2(barnesHutOptimize=True, verbose=False) positions = fa2.forceatlas2(G, iterations=50) - + assert len(positions) == n class TestSpecialGraphTopologies: """Test with special graph structures""" - + def test_star_topology(self): """Test star graph (hub and spokes)""" n = 10 @@ -246,12 +238,12 @@ def test_star_topology(self): for i in range(1, n): G[0, i] = 1 G[i, 0] = 1 - + fa2 = ForceAtlas2(outboundAttractionDistribution=True, verbose=False) positions = fa2.forceatlas2(G, iterations=100) - + assert len(positions) == n - + def test_complete_bipartite(self): """Test complete bipartite graph""" n1, n2 = 5, 5 @@ -262,12 +254,12 @@ def test_complete_bipartite(self): for j in range(n1, n): G[i, j] = 1 G[j, i] = 1 - + fa2 = ForceAtlas2(verbose=False) positions = fa2.forceatlas2(G, iterations=100) - + assert len(positions) == n - + def test_chain_topology(self): """Test chain/path graph""" n = 10 @@ -275,22 +267,22 @@ def test_chain_topology(self): for i in range(n - 1): G[i, i + 1] = 1 G[i + 1, i] = 1 - + fa2 = ForceAtlas2(verbose=False) positions = fa2.forceatlas2(G, iterations=100) - + assert len(positions) == n - + def test_grid_topology(self): """Test grid graph""" rows, cols = 4, 4 n = rows * cols G = np.zeros((n, n)) - + # Helper to convert 2D coords to 1D index def idx(r, c): return r * cols + c - + # Connect grid neighbors for r in range(rows): for c in range(cols): @@ -300,65 +292,56 @@ def idx(r, c): if c < cols - 1: G[idx(r, c), idx(r, c + 1)] = 1 G[idx(r, c + 1), idx(r, c)] = 1 - + fa2 = ForceAtlas2(verbose=False) positions = fa2.forceatlas2(G, iterations=100) - + assert len(positions) == n class TestConvergence: """Test convergence properties""" - + def test_convergence_over_iterations(self): """Test that layout stabilizes over iterations""" - G = np.array([ - [0, 1, 1, 0], - [1, 0, 1, 1], - [1, 1, 0, 1], - [0, 1, 1, 0] - ]) - + G = np.array([[0, 1, 1, 0], [1, 0, 1, 1], [1, 1, 0, 1], [0, 1, 1, 0]]) + fa2 = ForceAtlas2(verbose=False) - + # Run with different iteration counts np.random.seed(42) pos_10 = fa2.forceatlas2(G, iterations=10) - + np.random.seed(42) pos_100 = fa2.forceatlas2(G, iterations=100) - + np.random.seed(42) pos_1000 = fa2.forceatlas2(G, iterations=1000) - + # More iterations should produce valid layouts assert len(pos_10) == 4 assert len(pos_100) == 4 assert len(pos_1000) == 4 - + def test_reproducibility_same_seed(self): """Test that same seed produces identical results""" - G = np.array([ - [0, 1, 1], - [1, 0, 1], - [1, 1, 0] - ]) - + G = np.array([[0, 1, 1], [1, 0, 1], [1, 1, 0]]) + fa2 = ForceAtlas2(verbose=False) - + # First run np.random.seed(12345) import random + random.seed(12345) pos1 = fa2.forceatlas2(G, iterations=50) - + # Second run with same seed np.random.seed(12345) random.seed(12345) pos2 = fa2.forceatlas2(G, iterations=50) - + # Should be identical for i in range(len(pos1)): assert abs(pos1[i][0] - pos2[i][0]) < 1e-10 assert abs(pos1[i][1] - pos2[i][1]) < 1e-10 - diff --git a/tests/test_networkx_integration.py b/tests/test_networkx_integration.py index acb4379..835d895 100644 --- a/tests/test_networkx_integration.py +++ b/tests/test_networkx_integration.py @@ -1,6 +1,7 @@ """ Tests for NetworkX integration """ + import numbers import numpy as np @@ -8,6 +9,7 @@ try: import networkx as nx + NETWORKX_AVAILABLE = True except ImportError: NETWORKX_AVAILABLE = False @@ -18,210 +20,206 @@ @pytest.mark.skipif(not NETWORKX_AVAILABLE, reason="NetworkX not installed") class TestNetworkXIntegration: """Test ForceAtlas2 with NetworkX graphs""" - + def test_networkx_simple_graph(self): """Test with simple NetworkX graph""" G = nx.Graph() G.add_edges_from([(0, 1), (1, 2), (2, 3), (3, 0)]) - + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2_networkx_layout(G, iterations=10) - + assert len(pos) == 4 assert all(node in pos for node in G.nodes()) assert all(len(pos[node]) == 2 for node in G.nodes()) - + def test_networkx_with_initial_positions(self): """Test NetworkX layout with initial positions""" G = nx.Graph() G.add_edges_from([(0, 1), (1, 2)]) - - initial_pos = { - 0: (0.0, 0.0), - 1: (1.0, 0.0), - 2: (2.0, 0.0) - } - + + initial_pos = {0: (0.0, 0.0), 1: (1.0, 0.0), 2: (2.0, 0.0)} + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2_networkx_layout(G, pos=initial_pos, iterations=10) - + assert len(pos) == 3 assert all(node in pos for node in G.nodes()) - + def test_networkx_weighted_graph(self): """Test with weighted NetworkX graph""" G = nx.Graph() G.add_edge(0, 1, weight=1.0) G.add_edge(1, 2, weight=5.0) G.add_edge(2, 0, weight=1.0) - + fa2 = ForceAtlas2(verbose=False) - pos = fa2.forceatlas2_networkx_layout(G, iterations=10, weight_attr='weight') - + pos = fa2.forceatlas2_networkx_layout(G, iterations=10, weight_attr="weight") + assert len(pos) == 3 - + def test_networkx_star_graph(self): """Test with star graph (hub topology)""" G = nx.star_graph(5) - + fa2 = ForceAtlas2(outboundAttractionDistribution=True, verbose=False) pos = fa2.forceatlas2_networkx_layout(G, iterations=50) - + assert len(pos) == 6 # 1 hub + 5 leaves - + def test_networkx_complete_graph(self): """Test with complete graph""" G = nx.complete_graph(5) - + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2_networkx_layout(G, iterations=10) - + assert len(pos) == 5 - + def test_networkx_path_graph(self): """Test with path graph""" G = nx.path_graph(10) - + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2_networkx_layout(G, iterations=50) - + assert len(pos) == 10 - + def test_networkx_cycle_graph(self): """Test with cycle graph""" G = nx.cycle_graph(8) - + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2_networkx_layout(G, iterations=50) - + assert len(pos) == 8 - + def test_networkx_grid_graph(self): """Test with 2D grid graph""" G = nx.grid_2d_graph(3, 3) - + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2_networkx_layout(G, iterations=50) - + assert len(pos) == 9 - + def test_networkx_random_geometric_graph(self): """Test with random geometric graph""" G = nx.random_geometric_graph(20, 0.3) - + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2_networkx_layout(G, iterations=50) - + assert len(pos) == 20 - + def test_networkx_karate_club(self): """Test with Karate Club graph (classic test graph)""" G = nx.karate_club_graph() - + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2_networkx_layout(G, iterations=100) - + assert len(pos) == 34 - + def test_networkx_single_node(self): """Test with single node""" G = nx.Graph() G.add_node(0) - + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2_networkx_layout(G, iterations=10) - + assert len(pos) == 1 assert 0 in pos - + def test_networkx_disconnected_graph(self): """Test with disconnected components""" G = nx.Graph() G.add_edges_from([(0, 1), (1, 2)]) G.add_edges_from([(3, 4), (4, 5)]) - + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2_networkx_layout(G, iterations=50) - + assert len(pos) == 6 - + def test_networkx_string_node_labels(self): """Test with string node labels""" G = nx.Graph() - G.add_edges_from([('A', 'B'), ('B', 'C'), ('C', 'A')]) - + G.add_edges_from([("A", "B"), ("B", "C"), ("C", "A")]) + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2_networkx_layout(G, iterations=10) - + assert len(pos) == 3 - assert 'A' in pos - assert 'B' in pos - assert 'C' in pos - + assert "A" in pos + assert "B" in pos + assert "C" in pos + def test_networkx_mixed_node_labels(self): """Test with mixed node labels""" G = nx.Graph() - G.add_edges_from([(1, 'A'), ('A', 2.5), (2.5, 1)]) - + G.add_edges_from([(1, "A"), ("A", 2.5), (2.5, 1)]) + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2_networkx_layout(G, iterations=10) - + assert len(pos) == 3 - + def test_networkx_no_edges(self): """Test with nodes but no edges""" G = nx.Graph() G.add_nodes_from([0, 1, 2, 3]) - + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2_networkx_layout(G, iterations=10) - + assert len(pos) == 4 - + def test_networkx_position_format(self): """Test that positions are in correct format""" G = nx.Graph() G.add_edges_from([(0, 1), (1, 2)]) - + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2_networkx_layout(G, iterations=10) - + assert isinstance(pos, dict) for node, position in pos.items(): assert len(position) == 2 assert isinstance(position[0], numbers.Real) assert isinstance(position[1], numbers.Real) - + def test_networkx_parameter_combinations(self): """Test various parameter combinations with NetworkX""" G = nx.karate_club_graph() - + params = [ - {'barnesHutOptimize': True, 'strongGravityMode': False}, - {'barnesHutOptimize': False, 'strongGravityMode': True}, - {'outboundAttractionDistribution': True, 'edgeWeightInfluence': 0.5}, - {'scalingRatio': 5.0, 'gravity': 2.0}, + {"barnesHutOptimize": True, "strongGravityMode": False}, + {"barnesHutOptimize": False, "strongGravityMode": True}, + {"outboundAttractionDistribution": True, "edgeWeightInfluence": 0.5}, + {"scalingRatio": 5.0, "gravity": 2.0}, ] - + for param in params: fa2 = ForceAtlas2(**param, verbose=False) pos = fa2.forceatlas2_networkx_layout(G, iterations=10) assert len(pos) == 34 - + def test_networkx_invalid_input(self): """Test that non-NetworkX graph raises error""" fa2 = ForceAtlas2(verbose=False) - + with pytest.raises(AssertionError): fa2.forceatlas2_networkx_layout([[0, 1], [1, 0]], iterations=10) - + def test_networkx_invalid_pos_type(self): """Test that invalid pos type raises error""" G = nx.Graph() G.add_edges_from([(0, 1), (1, 2)]) - + fa2 = ForceAtlas2(verbose=False) - + with pytest.raises(AssertionError): fa2.forceatlas2_networkx_layout(G, pos=[(0, 0), (1, 1)], iterations=10) @@ -229,63 +227,63 @@ def test_networkx_invalid_pos_type(self): @pytest.mark.skipif(not NETWORKX_AVAILABLE, reason="NetworkX not installed") class TestNetworkXEdgeWeights: """Test edge weight handling in NetworkX graphs""" - + def test_default_weight_attribute(self): """Test with default weight attribute""" G = nx.Graph() G.add_edge(0, 1, weight=2.0) G.add_edge(1, 2, weight=1.0) - + fa2 = ForceAtlas2(edgeWeightInfluence=1.0, verbose=False) pos = fa2.forceatlas2_networkx_layout(G, iterations=50) - + assert len(pos) == 3 - + def test_custom_weight_attribute(self): """Test with custom weight attribute""" G = nx.Graph() G.add_edge(0, 1, strength=3.0) G.add_edge(1, 2, strength=1.0) - + fa2 = ForceAtlas2(edgeWeightInfluence=1.0, verbose=False) - pos = fa2.forceatlas2_networkx_layout(G, iterations=50, weight_attr='strength') - + pos = fa2.forceatlas2_networkx_layout(G, iterations=50, weight_attr="strength") + assert len(pos) == 3 - + def test_no_weight_attribute(self): """Test with no weight attribute (unweighted)""" G = nx.Graph() G.add_edges_from([(0, 1), (1, 2), (2, 0)]) - + fa2 = ForceAtlas2(verbose=False) pos = fa2.forceatlas2_networkx_layout(G, iterations=50, weight_attr=None) - + assert len(pos) == 3 @pytest.mark.skipif(not NETWORKX_AVAILABLE, reason="NetworkX not installed") class TestNetworkXReproducibility: """Test reproducibility with NetworkX graphs""" - + def test_reproducibility_with_seed(self): """Test that same seed produces same NetworkX layout""" G = nx.karate_club_graph() - + fa2 = ForceAtlas2(verbose=False) - + # First run np.random.seed(42) import random + random.seed(42) pos1 = fa2.forceatlas2_networkx_layout(G, iterations=50) - + # Second run with same seed np.random.seed(42) random.seed(42) pos2 = fa2.forceatlas2_networkx_layout(G, iterations=50) - + # Results should be identical for node in G.nodes(): assert abs(pos1[node][0] - pos2[node][0]) < 1e-10 assert abs(pos1[node][1] - pos2[node][1]) < 1e-10 - diff --git a/tests/test_utils.py b/tests/test_utils.py index 8c4a2d7..d2330c3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,7 @@ """ Tests for utility classes and functions """ + import time import pytest @@ -10,69 +11,68 @@ class TestTimer: """Test Timer utility class""" - + def test_timer_initialization(self): """Test Timer initialization""" timer = Timer() assert timer.name == "Timer" assert timer.start_time == 0.0 assert timer.total_time == 0.0 - + def test_timer_custom_name(self): """Test Timer with custom name""" timer = Timer(name="TestTimer") assert timer.name == "TestTimer" - + def test_timer_start_stop(self): """Test timer start and stop""" timer = Timer() timer.start() time.sleep(0.01) # Sleep for 10ms timer.stop() - + assert timer.total_time > 0.0 assert timer.total_time < 1.0 # Should be much less than 1 second - + def test_timer_multiple_runs(self): """Test timer accumulates time over multiple runs""" timer = Timer() - + timer.start() time.sleep(0.01) timer.stop() first_time = timer.total_time - + timer.start() time.sleep(0.01) timer.stop() second_time = timer.total_time - + assert second_time > first_time # Allow for OS scheduling/timer resolution differences across platforms assert second_time >= first_time * 1.05 - + def test_timer_display(self, capsys): """Test timer display output""" timer = Timer(name="DisplayTest") timer.start() time.sleep(0.01) timer.stop() - + timer.display() - + captured = capsys.readouterr() assert "DisplayTest" in captured.out assert "seconds" in captured.out - + def test_timer_zero_time(self): """Test timer with no elapsed time""" timer = Timer() assert timer.total_time == 0.0 - + # Start and immediately stop timer.start() timer.stop() - + # Time should still be very small (close to zero) assert timer.total_time >= 0.0 - From 6cdbd4c3818b332ab48c9f7206024aaa608dbdc5 Mon Sep 17 00:00:00 2001 From: Amin Alam Date: Sun, 26 Oct 2025 21:29:23 +0100 Subject: [PATCH 11/12] publiush workflow for all builds --- .github/workflows/python-publish.yml | 120 ++++++++++++++++++++------- MANIFEST.in | 3 + pyproject.toml | 3 +- setup.py | 2 +- 4 files changed, 94 insertions(+), 34 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index f80697e..33ade86 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,45 +1,101 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -name: Upload Python Package +name: Build and Publish Wheels on: release: types: [published] + workflow_dispatch: permissions: contents: read jobs: - deploy: + build-wheels: + name: Build wheels on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Set up QEMU for Linux ARM builds + if: runner.os == 'Linux' + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: Install cibuildwheel + run: | + python -m pip install --upgrade pip + pip install cibuildwheel==2.16.5 + - name: Build wheels + env: + CIBW_BUILD: "cp38-* cp39-* cp310-* cp311-* cp312-* cp313-*" + CIBW_SKIP: "pp* *-musllinux_*" + CIBW_BEFORE_BUILD: "pip install -U pip setuptools wheel Cython numpy" + CIBW_BEFORE_BUILD_LINUX: "pip install -U pip setuptools wheel Cython numpy && yum install -y gcc" + CIBW_TEST_COMMAND: "python -c \"import fa2_modified; print('Import successful')\"" + CIBW_TEST_REQUIRES: "numpy scipy tqdm" + CIBW_ARCHS_MACOS: "x86_64 arm64" + CIBW_ARCHS_WINDOWS: "AMD64" + CIBW_ARCHS_LINUX: "x86_64 aarch64" + CIBW_BUILD_VERBOSITY: 1 + run: | + python -m cibuildwheel --output-dir wheelhouse + + - name: Build sdist (source) + if: matrix.os == 'ubuntu-latest' + run: | + python -m pip install build Cython + python -m build --sdist --outdir wheelhouse + + - name: List built wheels + run: ls -lh wheelhouse/ + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.os }}-${{ strategy.job-index }} + path: wheelhouse/* + if-no-files-found: error + + publish: + name: Publish to PyPI + needs: build-wheels runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/fa2-modified + permissions: + id-token: write + contents: read steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: docker://quay.io/pypa/manylinux2014_x86_64 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine auditwheel - - name: Build package - run: | - python3 -m pip install --upgrade build - python3 -m build - - name: Repair wheels - run: | - auditwheel repair --wheel-dir dist dist/*.whl - rm -rf dist/*linux_x86_64*.whl - - name: Publish package - uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN_GITHUB }} + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: List all files to publish + run: ls -lh dist/ + + - name: Verify wheels + run: | + pip install twine + twine check dist/* + + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN_GITHUB }} + packages-dir: dist + verbose: true + print-hash: true diff --git a/MANIFEST.in b/MANIFEST.in index e55be1c..fb44dea 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,5 @@ include fa2_modified/fa2util.pxd +include fa2_modified/fa2util.c +include README.md +include LICENSE include examples/* \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index eb56957..fda8443 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,3 @@ [build-system] -requires = ["setuptools", "wheel", "Cython"] \ No newline at end of file +requires = ["setuptools>=45", "wheel", "Cython>=0.29.0", "numpy>=1.19.0"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/setup.py b/setup.py index 4edec1f..953253b 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ setup( name='fa2_modified', - version='0.3.10', + version='0.3.11', description='The fastest ForceAtlas2 algorithm for Python (and NetworkX)', long_description_content_type='text/markdown', long_description=long_description, From ef6a4ac02df290b0e8365533060c4a12a07094a4 Mon Sep 17 00:00:00 2001 From: Amin Alam Date: Sun, 26 Oct 2025 21:35:52 +0100 Subject: [PATCH 12/12] full test only on PR and master branch --- .github/workflows/ci.yml | 2 +- .github/workflows/code-quality.yml | 2 +- .github/workflows/tests.yml | 2 +- README.md | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f858a9a..2deb86e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: Continuous Integration on: push: - branches: [ main, master, dev ] + branches: [ main, master ] pull_request: branches: [ main, master, dev ] schedule: diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 6ee2af1..9065258 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -4,7 +4,7 @@ on: pull_request: branches: [ main, master, dev ] push: - branches: [ main, master, dev ] + branches: [ main, master ] jobs: lint: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3a2a2cd..2547862 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,7 +4,7 @@ on: pull_request: branches: [ main, master, dev ] push: - branches: [ main, master, dev ] + branches: [ main, master ] workflow_dispatch: jobs: diff --git a/README.md b/README.md index d7849a7..5465b6a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ ## ForceAtlas2 for Python3 (modified/maintained version) -#### This is a maintained version of fa2 [python library](https://github.com/bhargavchippada/forceatlas2). The source code is same as fa2, but this repo can be installed on Python 3.9+ whereas the original fa2 only runs on Python <3.8. +#### This is a maintained version of fa2 [python library](https://github.com/AminAlam/fa2_modified). The source code is same as fa2, but this repo can be installed on Python 3.9+ whereas the original fa2 only runs on Python <3.8. -------- [![PyPI version](https://img.shields.io/pypi/v/fa2-modified.svg)]([[https://pypi.python.org/pypi/fa2-modified](https://pypi.org/project/fa2-modified)](https://pypi.org/project/fa2-modified)) -[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) +[![Python 3.8+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/) [![continuous-integration](https://github.com/irahorecka/pyforceatlas2/workflows/continuous-integration/badge.svg)](https://github.com/irahorecka/fa2-modified/actions) [![License: GPL v3](https://img.shields.io/badge/license-GPLv3-purple.svg)](https://raw.githubusercontent.com/aminalam/fa2-modified/main/LICENSE) [![DOI](https://img.shields.io/badge/DOI-10.1371/journal.pone.0098679-blue)](https://doi.org/10.1371/journal.pone.0098679)