Skip to content

Commit d619ceb

Browse files
committed
caching: add global cache for confocal objects
1 parent a94eb76 commit d619ceb

File tree

8 files changed

+166
-118
lines changed

8 files changed

+166
-118
lines changed

lumicks/pylake/detail/caching.py

+59-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import sys
2+
13
import numpy as np
2-
from cachetools import LRUCache, cached
4+
from cachetools import LRUCache, keys, cached, cachedmethod
35

46
global_cache = False
57

@@ -94,3 +96,59 @@ def read_lazy_cache(self, key, src_field):
9496
self._cache[key] = np.asarray(src_field)
9597

9698
return self._cache[key]
99+
100+
101+
def _getsize(x):
102+
return x.nbytes if isinstance(x, np.ndarray) else sys.getsizeof(x)
103+
104+
105+
_method_cache = LRUCache(maxsize=1 << 30, getsizeof=_getsize) # 1 GB of cache
106+
107+
108+
def method_cache(name):
109+
"""A small convenience decorator to incorporate some really basic instance method memoization
110+
111+
Note: When used on properties, this one should be included _after_ the @property decorator.
112+
Data will be stored in the `_cache` variable of the instance.
113+
114+
Parameters
115+
----------
116+
name : str
117+
Name of the instance method to memo-ize. Suggestion: the instance method name.
118+
119+
Examples
120+
--------
121+
::
122+
123+
class Test:
124+
def __init__(self):
125+
self._cache = {}
126+
...
127+
128+
@property
129+
@method_cache("example_property")
130+
def example_property(self):
131+
return 10
132+
133+
@method_cache("example_method")
134+
def example_method(self, arguments):
135+
return 5
136+
137+
138+
test = Test()
139+
test.example_property
140+
test.example_method("hi")
141+
test._cache
142+
# test._cache will now show {('example_property',): 10, ('example_method', 'hi'): 5}
143+
"""
144+
145+
# cachetools>=5.0.0 passes self as first argument. We don't want to bump the reference count
146+
# by including a reference to the object we're about to store the cache into, so we explicitly
147+
# drop the first argument. Note that for the default key, they do the same in the package, but
148+
# we can't use the default key, since it doesn't hash in the method name.
149+
def key(self, *args, **kwargs):
150+
return keys.hashkey(self._location, name, *args, **kwargs)
151+
152+
return cachedmethod(
153+
lambda self: _method_cache if global_cache and self._location else self._cache, key=key
154+
)

lumicks/pylake/detail/confocal.py

+11-4
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010

1111
from .image import reconstruct_image, reconstruct_image_sum
1212
from .mixin import PhotonCounts, ExcitationLaserPower
13+
from .caching import method_cache
1314
from .plotting import parse_color_channel
14-
from .utilities import method_cache, could_sum_overflow
15+
from .utilities import could_sum_overflow
1516
from ..adjustments import no_adjustment
1617
from .imaging_mixins import TiffExport
1718

@@ -208,9 +209,11 @@ class BaseScan(PhotonCounts, ExcitationLaserPower):
208209
End point in the relevant info wave.
209210
metadata : ScanMetaData
210211
Metadata.
212+
location : str | None
213+
Path of the confocal object.
211214
"""
212215

213-
def __init__(self, name, file, start, stop, metadata):
216+
def __init__(self, name, file, start, stop, metadata, location):
214217
self.start = start
215218
self.stop = stop
216219
self.name = name
@@ -220,6 +223,7 @@ def __init__(self, name, file, start, stop, metadata):
220223
self._timestamp_factory = _default_timestamp_factory
221224
self._pixelsize_factory = _default_pixelsize_factory
222225
self._pixelcount_factory = _default_pixelcount_factory
226+
self._location = location
223227
self._cache = {}
224228

225229
def _has_default_factories(self):
@@ -243,12 +247,13 @@ def from_dataset(cls, h5py_dset, file):
243247
start = h5py_dset.attrs["Start time (ns)"]
244248
stop = h5py_dset.attrs["Stop time (ns)"]
245249
name = h5py_dset.name.split("/")[-1]
250+
location = file.h5.filename + h5py_dset.name
246251
try:
247252
metadata = ScanMetaData.from_json(h5py_dset[()])
248253
except KeyError:
249254
raise KeyError(f"{cls.__name__} '{name}' is missing metadata and cannot be loaded")
250255

251-
return cls(name, file, start, stop, metadata)
256+
return cls(name, file, start, stop, metadata, location)
252257

253258
@property
254259
def file(self):
@@ -269,6 +274,9 @@ def __copy__(self):
269274
start=self.start,
270275
stop=self.stop,
271276
metadata=self._metadata,
277+
# If it has no location, it will be cached only locally. This is safer than implicitly
278+
# caching it under the same location as the parent.
279+
location=None,
272280
)
273281

274282
# Preserve custom factories
@@ -512,5 +520,4 @@ def get_image(self, channel="rgb") -> np.ndarray:
512520
if channel not in ("red", "green", "blue"):
513521
return np.stack([self.get_image(color) for color in ("red", "green", "blue")], axis=-1)
514522
else:
515-
# Make sure we don't return a reference to our cache
516523
return self._image(channel)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import pytest
2+
3+
from lumicks.pylake.detail import caching
4+
5+
6+
@pytest.mark.parametrize(
7+
"location, use_global_cache",
8+
[
9+
(None, False),
10+
(None, True),
11+
("test", False),
12+
("test", True),
13+
],
14+
)
15+
def test_cache_method(location, use_global_cache):
16+
calls = 0
17+
18+
def call():
19+
nonlocal calls
20+
calls += 1
21+
22+
class Test:
23+
def __init__(self, location):
24+
self._cache = {}
25+
self._location = location
26+
27+
@property
28+
@caching.method_cache("example_property")
29+
def example_property(self):
30+
call()
31+
return 10
32+
33+
@caching.method_cache("example_method")
34+
def example_method(self, argument=5):
35+
call()
36+
return argument
37+
38+
old_cache = caching.global_cache
39+
caching.set_cache_enabled(use_global_cache)
40+
caching._method_cache.clear()
41+
test = Test(location=location)
42+
43+
cache_location = caching._method_cache if use_global_cache and location else test._cache
44+
45+
assert len(cache_location) == 0
46+
assert test.example_property == 10
47+
assert len(cache_location) == 1
48+
assert calls == 1
49+
assert test.example_property == 10
50+
assert calls == 1
51+
assert len(cache_location) == 1
52+
53+
assert test.example_method() == 5
54+
assert calls == 2
55+
assert len(cache_location) == 2
56+
57+
assert test.example_method() == 5
58+
assert calls == 2
59+
assert len(cache_location) == 2
60+
61+
assert test.example_method(6) == 6
62+
assert calls == 3
63+
assert len(cache_location) == 3
64+
65+
assert test.example_method(6) == 6
66+
assert calls == 3
67+
assert len(cache_location) == 3
68+
69+
assert test.example_method() == 5
70+
assert calls == 3
71+
assert len(cache_location) == 3
72+
73+
caching.set_cache_enabled(old_cache)

lumicks/pylake/detail/utilities.py

-54
Original file line numberDiff line numberDiff line change
@@ -2,60 +2,6 @@
22
import contextlib
33

44
import numpy as np
5-
import cachetools
6-
7-
8-
def method_cache(name):
9-
"""A small convenience decorator to incorporate some really basic instance method memoization
10-
11-
Note: When used on properties, this one should be included _after_ the @property decorator.
12-
Data will be stored in the `_cache` variable of the instance.
13-
14-
Parameters
15-
----------
16-
name : str
17-
Name of the instance method to memo-ize. Suggestion: the instance method name.
18-
19-
Examples
20-
--------
21-
::
22-
23-
class Test:
24-
def __init__(self):
25-
self._cache = {}
26-
...
27-
28-
@property
29-
@method_cache("example_property")
30-
def example_property(self):
31-
return 10
32-
33-
@method_cache("example_method")
34-
def example_method(self, arguments):
35-
return 5
36-
37-
38-
test = Test()
39-
test.example_property
40-
test.example_method("hi")
41-
test._cache
42-
# test._cache will now show {('example_property',): 10, ('example_method', 'hi'): 5}
43-
"""
44-
if int(cachetools.__version__.split(".")[0]) < 5:
45-
46-
def key(*args, **kwargs):
47-
return cachetools.keys.hashkey(name, *args, **kwargs)
48-
49-
else:
50-
# cachetools>=5.0.0 started passing self as first argument. We don't want to bump the
51-
# reference count by including a reference to the object we're about to store the cache
52-
# into, so we explicitly drop the first argument. Note that for the default key, they
53-
# do the same in the package, but we can't use the default key, since it doesn't hash
54-
# in the method name.
55-
def key(_, *args, **kwargs):
56-
return cachetools.keys.hashkey(name, *args, **kwargs)
57-
58-
return cachetools.cachedmethod(lambda self: self._cache, key=key)
595

606

617
def use_docstring_from(copy_func):

lumicks/pylake/kymo.py

+15-3
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
seek_timestamp_next_line,
1414
first_pixel_sample_indices,
1515
)
16+
from .detail.caching import method_cache
1617
from .detail.confocal import ScanAxis, ScanMetaData, ConfocalImage
1718
from .detail.plotting import get_axes, show_image
1819
from .detail.timeindex import to_timestamp
19-
from .detail.utilities import method_cache
2020
from .detail.bead_cropping import find_beads_template, find_beads_brightness
2121

2222

@@ -83,10 +83,22 @@ class Kymo(ConfocalImage):
8383
Coordinate position offset with respect to the original raw data.
8484
calibration : PositionCalibration
8585
Class defining calibration from microns to desired position units.
86+
location : str | None
87+
Path of the Kymo.
8688
"""
8789

88-
def __init__(self, name, file, start, stop, metadata, position_offset=0, calibration=None):
89-
super().__init__(name, file, start, stop, metadata)
90+
def __init__(
91+
self,
92+
name,
93+
file,
94+
start,
95+
stop,
96+
metadata,
97+
location=None,
98+
position_offset=0,
99+
calibration=None,
100+
):
101+
super().__init__(name, file, start, stop, metadata, location)
90102
self._line_time_factory = _default_line_time_factory
91103
self._line_timestamp_ranges_factory = _default_line_timestamp_ranges_factory
92104
self._position_offset = position_offset

lumicks/pylake/low_level/low_level.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ def create_confocal_object(
3232
metadata = ScanMetaData.from_json(json_metadata)
3333
file = ConfocalFileProxy(infowave, red_channel, green_channel, blue_channel)
3434
confocal_cls = {0: PointScan, 1: Kymo, 2: Scan}
35-
return confocal_cls[metadata.num_axes](name, file, infowave.start, infowave.stop, metadata)
35+
return confocal_cls[metadata.num_axes](
36+
name, file, infowave.start, infowave.stop, metadata, location=None
37+
)
3638

3739

3840
def make_continuous_slice(data, start, dt, y_label="y", name="") -> Slice:

lumicks/pylake/scan.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55

66
from .adjustments import colormaps, no_adjustment
77
from .detail.image import make_image_title, reconstruct_num_frames, first_pixel_sample_indices
8+
from .detail.caching import method_cache
89
from .detail.confocal import ConfocalImage
910
from .detail.plotting import get_axes, show_image
10-
from .detail.utilities import method_cache
1111
from .detail.imaging_mixins import FrameIndex, VideoExport
1212

1313

@@ -26,10 +26,12 @@ class Scan(ConfocalImage, VideoExport, FrameIndex):
2626
End point in the relevant info wave.
2727
metadata : ScanMetaData
2828
Metadata.
29+
location : str | None
30+
Path of the Scan.
2931
"""
3032

31-
def __init__(self, name, file, start, stop, metadata):
32-
super().__init__(name, file, start, stop, metadata)
33+
def __init__(self, name, file, start, stop, metadata, location=None):
34+
super().__init__(name, file, start, stop, metadata, location)
3335
if self._metadata.num_axes == 1:
3436
raise RuntimeError("1D scans are not supported")
3537
if self._metadata.num_axes > 2:

0 commit comments

Comments
 (0)