Skip to content

Commit 31c7b94

Browse files
committed
fixed cache
1 parent a136077 commit 31c7b94

3 files changed

Lines changed: 272 additions & 19 deletions

File tree

ggplotly/geoms/geom_edgebundle.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,12 @@ def __init__(
183183
184184
Examples
185185
--------
186+
>>> # From igraph (auto-detects 'weight' edge attribute)
187+
>>> g = data('us_flights')
188+
>>> ggplot() + geom_map(map_type='usa') + geom_edgebundle(graph=g)
189+
190+
>>> # From igraph with explicit weight attribute
191+
>>> ggplot() + geom_map(map_type='usa') + geom_edgebundle(graph=g, weight='passengers')
186192
>>> # From DataFrame
187193
>>> (ggplot(edges_df, aes(x='x', y='y', xend='xend', yend='yend'))
188194
... + geom_edgebundle(compatibility_threshold=0.6))
@@ -191,12 +197,6 @@ def __init__(
191197
>>> (ggplot(edges_df, aes(x='x', y='y', xend='xend', yend='yend', weight='traffic'))
192198
... + geom_edgebundle())
193199
194-
>>> # From igraph (auto-detects 'weight' edge attribute)
195-
>>> g = data('us_flights')
196-
>>> ggplot() + geom_edgebundle(graph=g)
197-
198-
>>> # From igraph with explicit weight attribute
199-
>>> ggplot() + geom_edgebundle(graph=g, weight='passengers')
200200
"""
201201
super().__init__(data=data, mapping=mapping, **kwargs)
202202

ggplotly/stats/stat_edgebundle.py

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@
1111
import pandas as pd
1212
from scipy import sparse
1313

14+
# Module-level cache for bundling results (survives deepcopy of stat objects)
15+
_bundling_cache: dict[int, pd.DataFrame] = {}
16+
17+
18+
def clear_bundling_cache():
19+
"""Clear the edge bundling cache."""
20+
_bundling_cache.clear()
21+
1422

1523
def _euclidean_distance(p1, p2):
1624
"""Calculate Euclidean distance between two points."""
@@ -501,20 +509,27 @@ def __init__(
501509
self.compatibility_threshold = compatibility_threshold
502510
self.verbose = verbose
503511

504-
# Cache for computed results
505-
self._cached_result = None
506-
self._cached_data_hash = None
507-
508-
def _compute_data_hash(self, data: pd.DataFrame, weights: Optional[np.ndarray] = None) -> int:
509-
"""Compute a hash of the input data for cache invalidation."""
512+
def _compute_cache_key(self, data: pd.DataFrame, weights: Optional[np.ndarray] = None) -> int:
513+
"""Compute a cache key including data and algorithm parameters."""
510514
weight_hash = weights.tobytes() if weights is not None else b''
511515
return hash((
516+
# Data
512517
data.shape,
513518
data['x'].values.tobytes(),
514519
data['y'].values.tobytes(),
515520
data['xend'].values.tobytes(),
516521
data['yend'].values.tobytes(),
517522
weight_hash,
523+
# Algorithm parameters (different params = different result)
524+
self.K,
525+
self.E,
526+
self.C,
527+
self.P,
528+
self.S,
529+
self.P_rate,
530+
self.I,
531+
self.I_rate,
532+
self.compatibility_threshold,
518533
))
519534

520535
def _normalize_weights(self, weights: np.ndarray) -> np.ndarray:
@@ -560,12 +575,12 @@ def compute(self, data: pd.DataFrame, weights: Optional[np.ndarray] = None) -> p
560575
if len(weights) != len(data):
561576
raise ValueError(f"weights length ({len(weights)}) must match data length ({len(data)})")
562577

563-
# Check cache
564-
data_hash = self._compute_data_hash(data, weights)
565-
if self._cached_result is not None and self._cached_data_hash == data_hash:
578+
# Check module-level cache (survives deepcopy of stat objects)
579+
cache_key = self._compute_cache_key(data, weights)
580+
if cache_key in _bundling_cache:
566581
if self.verbose:
567582
print("Using cached bundling result")
568-
return self._cached_result
583+
return _bundling_cache[cache_key]
569584

570585
# Normalize weights if provided
571586
normalized_weights = None
@@ -584,9 +599,8 @@ def compute(self, data: pd.DataFrame, weights: Optional[np.ndarray] = None) -> p
584599

585600
result = self._bundle_edges(edges_xy, normalized_weights)
586601

587-
# Cache the result
588-
self._cached_result = result
589-
self._cached_data_hash = data_hash
602+
# Cache the result at module level
603+
_bundling_cache[cache_key] = result
590604

591605
return result
592606

pytest/test_geom_edgebundle.py

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1361,3 +1361,242 @@ def get_midpoint_y(bundled):
13611361
assert mid_y_top - mid_y_bottom > 2.0, (
13621362
f"Weight effect should create >2.0 difference, got {mid_y_top - mid_y_bottom:.2f}"
13631363
)
1364+
1365+
1366+
class TestCaching:
1367+
"""Tests for caching functionality in stat_edgebundle and geom_edgebundle."""
1368+
1369+
def test_stat_edgebundle_cache_hit(self):
1370+
"""Test that identical calls return cached result."""
1371+
from ggplotly.stats.stat_edgebundle import _bundling_cache, clear_bundling_cache
1372+
1373+
clear_bundling_cache()
1374+
1375+
edges_df = pd.DataFrame({
1376+
'x': [0, 0],
1377+
'y': [0, 1],
1378+
'xend': [10, 10],
1379+
'yend': [0, 1]
1380+
})
1381+
1382+
stat = stat_edgebundle(C=2, I=5, verbose=False)
1383+
1384+
# First call - should compute and cache
1385+
result1 = stat.compute(edges_df)
1386+
cache_size_after_first = len(_bundling_cache)
1387+
assert cache_size_after_first == 1
1388+
1389+
# Second call with same data - should use cache
1390+
result2 = stat.compute(edges_df)
1391+
cache_size_after_second = len(_bundling_cache)
1392+
assert cache_size_after_second == 1 # No new cache entry
1393+
1394+
# Results should be identical
1395+
pd.testing.assert_frame_equal(result1, result2)
1396+
1397+
def test_stat_edgebundle_cache_miss_different_data(self):
1398+
"""Test that different data creates new cache entry."""
1399+
from ggplotly.stats.stat_edgebundle import _bundling_cache, clear_bundling_cache
1400+
1401+
clear_bundling_cache()
1402+
1403+
edges_df1 = pd.DataFrame({
1404+
'x': [0, 0],
1405+
'y': [0, 1],
1406+
'xend': [10, 10],
1407+
'yend': [0, 1]
1408+
})
1409+
1410+
edges_df2 = pd.DataFrame({
1411+
'x': [0, 0],
1412+
'y': [0, 2], # Different y value
1413+
'xend': [10, 10],
1414+
'yend': [0, 2]
1415+
})
1416+
1417+
stat = stat_edgebundle(C=2, I=5, verbose=False)
1418+
1419+
stat.compute(edges_df1)
1420+
assert len(_bundling_cache) == 1
1421+
1422+
stat.compute(edges_df2)
1423+
assert len(_bundling_cache) == 2 # New cache entry
1424+
1425+
def test_stat_edgebundle_cache_miss_different_params(self):
1426+
"""Test that different algorithm parameters create new cache entry."""
1427+
from ggplotly.stats.stat_edgebundle import _bundling_cache, clear_bundling_cache
1428+
1429+
clear_bundling_cache()
1430+
1431+
edges_df = pd.DataFrame({
1432+
'x': [0, 0],
1433+
'y': [0, 1],
1434+
'xend': [10, 10],
1435+
'yend': [0, 1]
1436+
})
1437+
1438+
stat1 = stat_edgebundle(C=2, I=5, verbose=False)
1439+
stat2 = stat_edgebundle(C=3, I=5, verbose=False) # Different C
1440+
1441+
stat1.compute(edges_df)
1442+
assert len(_bundling_cache) == 1
1443+
1444+
stat2.compute(edges_df)
1445+
assert len(_bundling_cache) == 2 # New cache entry due to different params
1446+
1447+
def test_stat_edgebundle_cache_miss_different_weights(self):
1448+
"""Test that different weights create new cache entry."""
1449+
from ggplotly.stats.stat_edgebundle import _bundling_cache, clear_bundling_cache
1450+
1451+
clear_bundling_cache()
1452+
1453+
edges_df = pd.DataFrame({
1454+
'x': [0, 0],
1455+
'y': [0, 1],
1456+
'xend': [10, 10],
1457+
'yend': [0, 1]
1458+
})
1459+
1460+
stat = stat_edgebundle(C=2, I=5, verbose=False)
1461+
1462+
weights1 = np.array([1.0, 1.0])
1463+
weights2 = np.array([10.0, 1.0]) # Different weights
1464+
1465+
stat.compute(edges_df, weights=weights1)
1466+
assert len(_bundling_cache) == 1
1467+
1468+
stat.compute(edges_df, weights=weights2)
1469+
assert len(_bundling_cache) == 2 # New cache entry
1470+
1471+
def test_stat_edgebundle_cache_hit_with_weights(self):
1472+
"""Test cache hit when same weights are used."""
1473+
from ggplotly.stats.stat_edgebundle import _bundling_cache, clear_bundling_cache
1474+
1475+
clear_bundling_cache()
1476+
1477+
edges_df = pd.DataFrame({
1478+
'x': [0, 0],
1479+
'y': [0, 1],
1480+
'xend': [10, 10],
1481+
'yend': [0, 1]
1482+
})
1483+
1484+
stat = stat_edgebundle(C=2, I=5, verbose=False)
1485+
weights = np.array([5.0, 10.0])
1486+
1487+
result1 = stat.compute(edges_df, weights=weights)
1488+
assert len(_bundling_cache) == 1
1489+
1490+
result2 = stat.compute(edges_df, weights=weights.copy()) # Same values
1491+
assert len(_bundling_cache) == 1 # Cache hit
1492+
1493+
pd.testing.assert_frame_equal(result1, result2)
1494+
1495+
def test_clear_bundling_cache(self):
1496+
"""Test that clear_bundling_cache clears the cache."""
1497+
from ggplotly.stats.stat_edgebundle import _bundling_cache, clear_bundling_cache
1498+
1499+
clear_bundling_cache()
1500+
1501+
edges_df = pd.DataFrame({
1502+
'x': [0, 0],
1503+
'y': [0, 1],
1504+
'xend': [10, 10],
1505+
'yend': [0, 1]
1506+
})
1507+
1508+
stat = stat_edgebundle(C=2, I=5, verbose=False)
1509+
stat.compute(edges_df)
1510+
assert len(_bundling_cache) >= 1
1511+
1512+
clear_bundling_cache()
1513+
assert len(_bundling_cache) == 0
1514+
1515+
def test_cache_survives_stat_deepcopy(self):
1516+
"""Test that cache persists across deepcopy of stat objects."""
1517+
import copy
1518+
from ggplotly.stats.stat_edgebundle import _bundling_cache, clear_bundling_cache
1519+
1520+
clear_bundling_cache()
1521+
1522+
edges_df = pd.DataFrame({
1523+
'x': [0, 0],
1524+
'y': [0, 1],
1525+
'xend': [10, 10],
1526+
'yend': [0, 1]
1527+
})
1528+
1529+
stat1 = stat_edgebundle(C=2, I=5, verbose=False)
1530+
result1 = stat1.compute(edges_df)
1531+
assert len(_bundling_cache) == 1
1532+
1533+
# Deep copy the stat (this happens during ggplot processing)
1534+
stat2 = copy.deepcopy(stat1)
1535+
1536+
# Compute with the copy - should hit cache
1537+
result2 = stat2.compute(edges_df)
1538+
assert len(_bundling_cache) == 1 # Still just one entry
1539+
1540+
pd.testing.assert_frame_equal(result1, result2)
1541+
1542+
def test_geom_edgebundle_uses_cache(self):
1543+
"""Test that geom_edgebundle uses the stat cache correctly."""
1544+
from ggplotly.stats.stat_edgebundle import _bundling_cache, clear_bundling_cache
1545+
1546+
clear_bundling_cache()
1547+
1548+
edges_df = pd.DataFrame({
1549+
'x': [0, 0],
1550+
'y': [0, 1],
1551+
'xend': [10, 10],
1552+
'yend': [0, 1]
1553+
})
1554+
1555+
# First plot - should populate cache
1556+
fig1 = (
1557+
ggplot(edges_df, aes(x='x', y='y', xend='xend', yend='yend'))
1558+
+ geom_edgebundle(C=2, I=5, verbose=False, show_highlight=False)
1559+
).draw()
1560+
cache_size_after_first = len(_bundling_cache)
1561+
assert cache_size_after_first >= 1
1562+
1563+
# Second plot with same data and params - should use cache
1564+
fig2 = (
1565+
ggplot(edges_df, aes(x='x', y='y', xend='xend', yend='yend'))
1566+
+ geom_edgebundle(C=2, I=5, verbose=False, show_highlight=False)
1567+
).draw()
1568+
cache_size_after_second = len(_bundling_cache)
1569+
assert cache_size_after_second == cache_size_after_first
1570+
1571+
def test_cache_key_includes_all_params(self):
1572+
"""Test that cache key includes all relevant algorithm parameters."""
1573+
from ggplotly.stats.stat_edgebundle import _bundling_cache, clear_bundling_cache
1574+
1575+
clear_bundling_cache()
1576+
1577+
edges_df = pd.DataFrame({
1578+
'x': [0, 0],
1579+
'y': [0, 1],
1580+
'xend': [10, 10],
1581+
'yend': [0, 1]
1582+
})
1583+
1584+
# Create stats with different parameter variations
1585+
param_variations = [
1586+
{'K': 1.0, 'E': 1.0, 'C': 2, 'P': 1, 'S': 0.04, 'P_rate': 2, 'I': 5, 'I_rate': 2/3, 'compatibility_threshold': 0.6},
1587+
{'K': 2.0, 'E': 1.0, 'C': 2, 'P': 1, 'S': 0.04, 'P_rate': 2, 'I': 5, 'I_rate': 2/3, 'compatibility_threshold': 0.6}, # Different K
1588+
{'K': 1.0, 'E': 2.0, 'C': 2, 'P': 1, 'S': 0.04, 'P_rate': 2, 'I': 5, 'I_rate': 2/3, 'compatibility_threshold': 0.6}, # Different E
1589+
{'K': 1.0, 'E': 1.0, 'C': 2, 'P': 2, 'S': 0.04, 'P_rate': 2, 'I': 5, 'I_rate': 2/3, 'compatibility_threshold': 0.6}, # Different P
1590+
{'K': 1.0, 'E': 1.0, 'C': 2, 'P': 1, 'S': 0.08, 'P_rate': 2, 'I': 5, 'I_rate': 2/3, 'compatibility_threshold': 0.6}, # Different S
1591+
{'K': 1.0, 'E': 1.0, 'C': 2, 'P': 1, 'S': 0.04, 'P_rate': 3, 'I': 5, 'I_rate': 2/3, 'compatibility_threshold': 0.6}, # Different P_rate
1592+
{'K': 1.0, 'E': 1.0, 'C': 2, 'P': 1, 'S': 0.04, 'P_rate': 2, 'I': 10, 'I_rate': 2/3, 'compatibility_threshold': 0.6}, # Different I
1593+
{'K': 1.0, 'E': 1.0, 'C': 2, 'P': 1, 'S': 0.04, 'P_rate': 2, 'I': 5, 'I_rate': 0.5, 'compatibility_threshold': 0.6}, # Different I_rate
1594+
{'K': 1.0, 'E': 1.0, 'C': 2, 'P': 1, 'S': 0.04, 'P_rate': 2, 'I': 5, 'I_rate': 2/3, 'compatibility_threshold': 0.8}, # Different threshold
1595+
]
1596+
1597+
for params in param_variations:
1598+
stat = stat_edgebundle(verbose=False, **params)
1599+
stat.compute(edges_df)
1600+
1601+
# Each parameter variation should create a unique cache entry
1602+
assert len(_bundling_cache) == len(param_variations)

0 commit comments

Comments
 (0)