PyIncucyte 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyincucyte/__init__.py ADDED
@@ -0,0 +1,113 @@
1
+ """PyIncucyte - download Incucyte live-cell images from Python.
2
+
3
+ Two ways in. A desktop app::
4
+
5
+ pyincucyte gui # or: pyincucyte-gui, python -m pyincucyte.gui
6
+
7
+ ...and an importable API for an automated pipeline::
8
+
9
+ from pyincucyte import IncucyteClient
10
+
11
+ with IncucyteClient.from_saved() as incucyte:
12
+ result = incucyte.fetch(
13
+ vessel=38, output="./run-01", wells="A1-D6",
14
+ channels="phase,green", layout="time_channel_stack",
15
+ start_from="first")
16
+
17
+ for image in result.files:
18
+ segment(image.path, well=image.well, channels=image.channels)
19
+
20
+ ``result.files`` carries the well, channel names, timepoints and axis order for
21
+ every file, and the same information is written to ``pyincucyte-manifest.json``
22
+ next to the images so a later stage can pick up without re-parsing filenames.
23
+ """
24
+
25
+ import logging
26
+
27
+ __version__ = "0.3.0"
28
+
29
+ # A library should not configure logging for its host application; this only
30
+ # stops "No handlers could be found" warnings when nobody has set one up.
31
+ logging.getLogger("pyincucyte").addHandler(logging.NullHandler())
32
+
33
+ from .errors import ( # noqa: E402
34
+ ApiError,
35
+ AuthenticationError,
36
+ DeviceUnreachableError,
37
+ EncryptionUnavailableError,
38
+ ExportCancelled,
39
+ ExportError,
40
+ IncucyteError,
41
+ NotLoggedInError,
42
+ TokenExpiredError,
43
+ VesselNotFoundError,
44
+ )
45
+ from .models import ( # noqa: E402
46
+ DownloadResult,
47
+ ExportPlan,
48
+ LAYOUTS,
49
+ LAYOUT_AXES,
50
+ LAYOUT_DESCRIPTIONS,
51
+ LAYOUT_LABELS,
52
+ OutputFile,
53
+ ProgressEvent,
54
+ Vessel,
55
+ human_bytes,
56
+ resolve_layout,
57
+ )
58
+ from .options import ExportOptions # noqa: E402
59
+ from .config import ConfigStore, Credentials # noqa: E402
60
+ from .state import StateStore # noqa: E402
61
+ from .client import IncucyteClient # noqa: E402
62
+ from .watch import Watcher # noqa: E402
63
+ from .manifest import load_manifest, write_manifest # noqa: E402
64
+ from .engine import DEFAULT_HOST, APP_DIR # noqa: E402
65
+
66
+ # Import names retired in 0.3 - see pyincucyte.compat.
67
+ from . import compat # noqa: E402
68
+
69
+ compat.install()
70
+
71
+
72
+ def connect(host=None, username=None, password=None):
73
+ """Return a ready client - saved login by default, or fresh credentials."""
74
+ if username and password:
75
+ return IncucyteClient.connect(host or DEFAULT_HOST, username, password)
76
+ return IncucyteClient.from_saved(host)
77
+
78
+
79
+ __all__ = [
80
+ "__version__",
81
+ "connect",
82
+ "IncucyteClient",
83
+ "ExportOptions",
84
+ "ExportPlan",
85
+ "DownloadResult",
86
+ "OutputFile",
87
+ "ProgressEvent",
88
+ "Vessel",
89
+ "Watcher",
90
+ "StateStore",
91
+ "ConfigStore",
92
+ "Credentials",
93
+ "LAYOUTS",
94
+ "LAYOUT_AXES",
95
+ "LAYOUT_LABELS",
96
+ "LAYOUT_DESCRIPTIONS",
97
+ "resolve_layout",
98
+ "human_bytes",
99
+ "load_manifest",
100
+ "write_manifest",
101
+ "DEFAULT_HOST",
102
+ "APP_DIR",
103
+ "IncucyteError",
104
+ "ApiError",
105
+ "AuthenticationError",
106
+ "NotLoggedInError",
107
+ "TokenExpiredError",
108
+ "DeviceUnreachableError",
109
+ "EncryptionUnavailableError",
110
+ "VesselNotFoundError",
111
+ "ExportError",
112
+ "ExportCancelled",
113
+ ]
pyincucyte/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """``python -m pyincucyte`` runs the command line interface."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
pyincucyte/cache.py ADDED
@@ -0,0 +1,169 @@
1
+ """A local cache of source image payloads.
2
+
3
+ Time stacks have an awkward property: a stack must contain every frame, so the
4
+ arrival of one new scan invalidates the whole file. Rebuilt naively, a watch
5
+ loop re-downloads the entire experiment on every poll — an hour into a five-day
6
+ run that is already thousands of redundant images, and it gets worse every hour.
7
+
8
+ The cache fixes that by keeping each source payload on disk the first time it is
9
+ fetched. Rebuilding a stack then costs one disk read per frame instead of one
10
+ network round trip, and only genuinely new frames touch the instrument.
11
+
12
+ It is a cache, not a record: deleting it only costs time.
13
+ """
14
+
15
+ import logging
16
+ import os
17
+ import shutil
18
+ import threading
19
+ import time
20
+ from pathlib import Path
21
+
22
+ log = logging.getLogger("pyincucyte.cache")
23
+
24
+ #: Folder created inside the output directory.
25
+ CACHE_DIRNAME = ".pyincucyte-cache"
26
+
27
+ #: Cached payloads older than this are swept away (14 days).
28
+ DEFAULT_MAX_AGE_SECONDS = 14 * 24 * 3600
29
+
30
+
31
+ def payload_key(item):
32
+ """Return the stable identity of one source image payload."""
33
+ scan = str(item.get("scan_time", "")).replace(":", "").replace("-", "")
34
+ scan = scan.replace("+", "_").replace(".", "_")
35
+ return (f"V{item.get('vessel_id')}_r{item.get('row', 0)}"
36
+ f"_c{item.get('col', 0)}_s{item.get('site', 0)}"
37
+ f"_t{item.get('img_type', 1)}_{scan}.tif")
38
+
39
+
40
+ class PayloadCache:
41
+ """Stores raw payload bytes on disk, keyed by well/channel/scan time."""
42
+
43
+ def __init__(self, root, max_age_seconds=DEFAULT_MAX_AGE_SECONDS):
44
+ self.root = Path(root)
45
+ self.max_age_seconds = max_age_seconds
46
+ self.hits = 0
47
+ self.misses = 0
48
+ self._lock = threading.Lock()
49
+ self._ready = False
50
+
51
+ # -- lifecycle --------------------------------------------------------
52
+
53
+ def _ensure(self):
54
+ if self._ready:
55
+ return True
56
+ with self._lock:
57
+ if self._ready:
58
+ return True
59
+ try:
60
+ self.root.mkdir(parents=True, exist_ok=True)
61
+ self._ready = True
62
+ except OSError as exc:
63
+ log.debug("payload cache unavailable at %s: %s", self.root, exc)
64
+ return False
65
+ return True
66
+
67
+ # -- access -----------------------------------------------------------
68
+
69
+ def get(self, item):
70
+ """Return cached bytes for this item, or None."""
71
+ path = self.root / payload_key(item)
72
+ try:
73
+ data = path.read_bytes()
74
+ except OSError:
75
+ with self._lock:
76
+ self.misses += 1
77
+ return None
78
+ if not data:
79
+ with self._lock:
80
+ self.misses += 1
81
+ return None
82
+ with self._lock:
83
+ self.hits += 1
84
+ return data
85
+
86
+ def put(self, item, data):
87
+ """Store payload bytes. Failures are ignored — it is only a cache."""
88
+ if not data or not self._ensure():
89
+ return
90
+ path = self.root / payload_key(item)
91
+ tmp = path.with_suffix(".part")
92
+ try:
93
+ tmp.write_bytes(data)
94
+ os.replace(tmp, path)
95
+ except OSError:
96
+ try:
97
+ tmp.unlink()
98
+ except OSError:
99
+ pass
100
+
101
+ # -- housekeeping -----------------------------------------------------
102
+
103
+ def size_bytes(self):
104
+ try:
105
+ return sum(f.stat().st_size for f in self.root.glob("*.tif"))
106
+ except OSError:
107
+ return 0
108
+
109
+ def count(self):
110
+ try:
111
+ return sum(1 for _ in self.root.glob("*.tif"))
112
+ except OSError:
113
+ return 0
114
+
115
+ def sweep(self, max_age_seconds=None):
116
+ """Delete cached payloads older than the age limit. Returns the count."""
117
+ limit = self.max_age_seconds if max_age_seconds is None else max_age_seconds
118
+ if not limit or not self.root.is_dir():
119
+ return 0
120
+ cutoff = time.time() - limit
121
+ removed = 0
122
+ for path in self.root.glob("*.tif"):
123
+ try:
124
+ if path.stat().st_mtime < cutoff:
125
+ path.unlink()
126
+ removed += 1
127
+ except OSError:
128
+ continue
129
+ return removed
130
+
131
+ def clear(self):
132
+ """Remove the cache folder entirely."""
133
+ try:
134
+ shutil.rmtree(self.root)
135
+ except OSError:
136
+ pass
137
+ self._ready = False
138
+
139
+ @property
140
+ def hit_rate(self):
141
+ total = self.hits + self.misses
142
+ return (self.hits / total) if total else 0.0
143
+
144
+ def summary(self):
145
+ from .models import human_bytes
146
+ return (f"{self.count():,} cached payloads ({human_bytes(self.size_bytes())}), "
147
+ f"{self.hits:,} hits / {self.misses:,} misses")
148
+
149
+ def __repr__(self):
150
+ return f"<PayloadCache {self.root} hits={self.hits} misses={self.misses}>"
151
+
152
+
153
+ def cache_for_output(output_dir, mode="auto", layout="separate",
154
+ max_age_seconds=DEFAULT_MAX_AGE_SECONDS):
155
+ """Return the cache to use for a download, or None.
156
+
157
+ ``"auto"`` caches only for the layouts that rebuild whole files when a scan
158
+ arrives — the ones where the cache actually saves work.
159
+ """
160
+ if mode == "never":
161
+ return None
162
+ if mode == "auto" and "time" not in layout:
163
+ return None
164
+ return PayloadCache(Path(output_dir) / CACHE_DIRNAME,
165
+ max_age_seconds=max_age_seconds)
166
+
167
+
168
+ __all__ = ["PayloadCache", "cache_for_output", "payload_key", "CACHE_DIRNAME",
169
+ "DEFAULT_MAX_AGE_SECONDS"]
pyincucyte/channels.py ADDED
@@ -0,0 +1,89 @@
1
+ """Channel identity: the Incucyte's three acquisition channels.
2
+
3
+ The device numbers its channels 1/2/3 ("ImageType"). Channel 1 is the
4
+ transmitted-light Phase image; channels 2 and 3 are the two fluorescence
5
+ "Colors", which the instrument reports as Green and Red respectively but which
6
+ an experiment can rename (e.g. "GFP", "mCherry").
7
+ """
8
+
9
+ import re
10
+
11
+ from .engine import (
12
+ CHANNEL_HELP,
13
+ IMAGE_TYPE_LABELS,
14
+ IMAGE_TYPE_MAP,
15
+ IMAGE_TYPE_SHORT_LABELS,
16
+ channel_name_from_channels,
17
+ channel_tag,
18
+ image_type_label,
19
+ image_type_sort_key,
20
+ parse_channels,
21
+ )
22
+
23
+ #: Symbolic channel numbers, matching the device's ImageType field.
24
+ PHASE = 1
25
+ COLOR1 = GREEN = 2
26
+ COLOR2 = RED = 3
27
+
28
+ #: Every channel, in acquisition order.
29
+ ALL_CHANNELS = (PHASE, COLOR1, COLOR2)
30
+
31
+
32
+ def channel_token(label):
33
+ """Return a filename-safe token for a channel display name."""
34
+ token = re.sub(r"[^a-z0-9]+", "-", str(label).lower()).strip("-")
35
+ return token or "channel"
36
+
37
+
38
+ def format_channels(channels, labels=None):
39
+ """Return a human-readable channel list, e.g. ``"Phase + GFP"``."""
40
+ if channels is None:
41
+ return "all channels"
42
+ labels = labels or IMAGE_TYPE_LABELS
43
+ names = [labels.get(c, image_type_label(c))
44
+ for c in sorted(channels, key=image_type_sort_key)]
45
+ return " + ".join(names) if names else "no channels"
46
+
47
+
48
+ def channel_spec(channels):
49
+ """Return the CLI ``--channels`` spec string for a set of channel numbers."""
50
+ if channels is None:
51
+ return "all"
52
+ names = [IMAGE_TYPE_SHORT_LABELS.get(c, f"type{c}")
53
+ for c in sorted(channels, key=image_type_sort_key)]
54
+ return ",".join(names) or "all"
55
+
56
+
57
+ def labels_for_vessel(vessel_channels):
58
+ """Return ``{channel_number: display name}`` for one vessel's metadata."""
59
+ labels = dict(IMAGE_TYPE_LABELS)
60
+ if isinstance(vessel_channels, dict):
61
+ labels[COLOR1] = channel_name_from_channels(vessel_channels, COLOR1)
62
+ labels[COLOR2] = channel_name_from_channels(vessel_channels, COLOR2)
63
+ return labels
64
+
65
+
66
+ def active_channels(vessel_channels):
67
+ """Return the channel numbers actually switched on for a vessel."""
68
+ if not isinstance(vessel_channels, dict):
69
+ return set()
70
+ active = set()
71
+ if (vessel_channels.get("Phase") or {}).get("On"):
72
+ active.add(PHASE)
73
+ colors = vessel_channels.get("Colors") or {}
74
+ if isinstance(colors, dict):
75
+ if (colors.get("Color1") or {}).get("On"):
76
+ active.add(COLOR1)
77
+ if (colors.get("Color2") or {}).get("On"):
78
+ active.add(COLOR2)
79
+ return active
80
+
81
+
82
+ __all__ = [
83
+ "PHASE", "COLOR1", "COLOR2", "GREEN", "RED", "ALL_CHANNELS",
84
+ "CHANNEL_HELP", "IMAGE_TYPE_LABELS", "IMAGE_TYPE_MAP",
85
+ "IMAGE_TYPE_SHORT_LABELS", "parse_channels", "channel_name_from_channels",
86
+ "channel_tag", "image_type_label", "image_type_sort_key",
87
+ "channel_token", "format_channels", "channel_spec",
88
+ "labels_for_vessel", "active_channels",
89
+ ]