PyLV200 0.1.1__tar.gz

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.
Files changed (73) hide show
  1. pylv200-0.1.1/LICENSE +21 -0
  2. pylv200-0.1.1/MANIFEST.in +4 -0
  3. pylv200-0.1.1/PKG-INFO +648 -0
  4. pylv200-0.1.1/README.md +605 -0
  5. pylv200-0.1.1/pyproject.toml +73 -0
  6. pylv200-0.1.1/setup.cfg +4 -0
  7. pylv200-0.1.1/src/PyLV200.egg-info/PKG-INFO +648 -0
  8. pylv200-0.1.1/src/PyLV200.egg-info/SOURCES.txt +77 -0
  9. pylv200-0.1.1/src/PyLV200.egg-info/dependency_links.txt +1 -0
  10. pylv200-0.1.1/src/PyLV200.egg-info/entry_points.txt +5 -0
  11. pylv200-0.1.1/src/PyLV200.egg-info/requires.txt +18 -0
  12. pylv200-0.1.1/src/PyLV200.egg-info/top_level.txt +1 -0
  13. pylv200-0.1.1/src/pylv200/__init__.py +100 -0
  14. pylv200-0.1.1/src/pylv200/__main__.py +7 -0
  15. pylv200-0.1.1/src/pylv200/cache.py +365 -0
  16. pylv200-0.1.1/src/pylv200/channels.py +133 -0
  17. pylv200-0.1.1/src/pylv200/cli.py +2073 -0
  18. pylv200-0.1.1/src/pylv200/client.py +609 -0
  19. pylv200-0.1.1/src/pylv200/compat.py +83 -0
  20. pylv200-0.1.1/src/pylv200/config.py +368 -0
  21. pylv200-0.1.1/src/pylv200/discover.py +308 -0
  22. pylv200-0.1.1/src/pylv200/engine.py +1761 -0
  23. pylv200-0.1.1/src/pylv200/errors.py +107 -0
  24. pylv200-0.1.1/src/pylv200/gui/__init__.py +10 -0
  25. pylv200-0.1.1/src/pylv200/gui/__main__.py +8 -0
  26. pylv200-0.1.1/src/pylv200/gui/app.py +1937 -0
  27. pylv200-0.1.1/src/pylv200/gui/dialogs.py +402 -0
  28. pylv200-0.1.1/src/pylv200/gui/preview.py +256 -0
  29. pylv200-0.1.1/src/pylv200/gui/theme.py +423 -0
  30. pylv200-0.1.1/src/pylv200/gui/timeline.py +567 -0
  31. pylv200-0.1.1/src/pylv200/gui/widgets.py +751 -0
  32. pylv200-0.1.1/src/pylv200/manifest.py +365 -0
  33. pylv200-0.1.1/src/pylv200/models.py +452 -0
  34. pylv200-0.1.1/src/pylv200/oex.py +134 -0
  35. pylv200-0.1.1/src/pylv200/options.py +601 -0
  36. pylv200-0.1.1/src/pylv200/preview.py +608 -0
  37. pylv200-0.1.1/src/pylv200/proxy.py +200 -0
  38. pylv200-0.1.1/src/pylv200/setup.py +898 -0
  39. pylv200-0.1.1/src/pylv200/state.py +324 -0
  40. pylv200-0.1.1/src/pylv200/tiffstack.py +453 -0
  41. pylv200-0.1.1/src/pylv200/timeline.py +1630 -0
  42. pylv200-0.1.1/src/pylv200/vsi.py +366 -0
  43. pylv200-0.1.1/src/pylv200/watch.py +675 -0
  44. pylv200-0.1.1/tests/__init__.py +0 -0
  45. pylv200-0.1.1/tests/conftest.py +18 -0
  46. pylv200-0.1.1/tests/synthetic.py +269 -0
  47. pylv200-0.1.1/tests/test_api.py +302 -0
  48. pylv200-0.1.1/tests/test_cli.py +709 -0
  49. pylv200-0.1.1/tests/test_cli_parity.py +326 -0
  50. pylv200-0.1.1/tests/test_cli_selection.py +319 -0
  51. pylv200-0.1.1/tests/test_client.py +215 -0
  52. pylv200-0.1.1/tests/test_config.py +188 -0
  53. pylv200-0.1.1/tests/test_console.py +266 -0
  54. pylv200-0.1.1/tests/test_gui.py +757 -0
  55. pylv200-0.1.1/tests/test_handoff.py +292 -0
  56. pylv200-0.1.1/tests/test_manifest.py +306 -0
  57. pylv200-0.1.1/tests/test_mirror.py +232 -0
  58. pylv200-0.1.1/tests/test_models.py +250 -0
  59. pylv200-0.1.1/tests/test_naming.py +351 -0
  60. pylv200-0.1.1/tests/test_packaging.py +59 -0
  61. pylv200-0.1.1/tests/test_preview.py +204 -0
  62. pylv200-0.1.1/tests/test_progress.py +206 -0
  63. pylv200-0.1.1/tests/test_proxy.py +252 -0
  64. pylv200-0.1.1/tests/test_readme.py +177 -0
  65. pylv200-0.1.1/tests/test_roundtrip.py +370 -0
  66. pylv200-0.1.1/tests/test_setup.py +482 -0
  67. pylv200-0.1.1/tests/test_state.py +214 -0
  68. pylv200-0.1.1/tests/test_tiffstack.py +326 -0
  69. pylv200-0.1.1/tests/test_timeline.py +409 -0
  70. pylv200-0.1.1/tests/test_timeline_gui.py +174 -0
  71. pylv200-0.1.1/tests/test_vsi.py +162 -0
  72. pylv200-0.1.1/tests/test_watch.py +317 -0
  73. pylv200-0.1.1/tests/test_watch_controls.py +394 -0
pylv200-0.1.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jamie Malcolm
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ include LICENSE
2
+ include README.md
3
+ recursive-include tests *.py
4
+ global-exclude __pycache__ *.py[cod]
pylv200-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,648 @@
1
+ Metadata-Version: 2.4
2
+ Name: PyLV200
3
+ Version: 0.1.1
4
+ Summary: Pull images from a running Olympus/Evident cellSens experiment before the .vsi is written
5
+ Author: Jamie Malcolm
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Jay2owe/pylv200
8
+ Project-URL: Source, https://github.com/Jay2owe/pylv200
9
+ Project-URL: Issues, https://github.com/Jay2owe/pylv200/issues
10
+ Keywords: microscopy,cellsens,olympus,evident,lv200,bioluminescence,ets,vsi,time-lapse
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Environment :: Win32 (MS Windows)
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
24
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
25
+ Requires-Python: >=3.9
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: numpy>=1.21
29
+ Requires-Dist: tifffile>=2021.7.2
30
+ Provides-Extra: compressed
31
+ Requires-Dist: imagecodecs>=2021.7.30; extra == "compressed"
32
+ Provides-Extra: preview
33
+ Requires-Dist: matplotlib>=3.5; extra == "preview"
34
+ Provides-Extra: test
35
+ Requires-Dist: pytest>=7; extra == "test"
36
+ Requires-Dist: matplotlib>=3.5; extra == "test"
37
+ Provides-Extra: dev
38
+ Requires-Dist: build>=1.2; extra == "dev"
39
+ Requires-Dist: twine>=5.0; extra == "dev"
40
+ Requires-Dist: pytest>=7; extra == "dev"
41
+ Requires-Dist: matplotlib>=3.5; extra == "dev"
42
+ Dynamic: license-file
43
+
44
+ # PyLV200
45
+
46
+ Pull images out of a **running** Olympus/Evident cellSens experiment on the LV200.
47
+
48
+ cellSens does not finalise its image container until a run ends, but the pixel
49
+ data is already on disk in the `_<experiment>_` folder as `.ets` tile files, and
50
+ a small `.vsi` written when the run **starts** names the channels. `pylv200`
51
+ reads both and writes an ImageJ hyperstack covering everything acquired so far,
52
+ so you do not have to end a multi-day recording to look at it.
53
+
54
+ It is strictly read-only: it never writes, renames or locks anything inside the
55
+ experiment folder, so it is safe to run mid-acquisition.
56
+
57
+ When a run does end, cellSens writes the finished `.vsi`, and Bio-Formats
58
+ converts that to OME-TIFF. `pylv200` is for the run that has not finished — or
59
+ has not survived. `--ome-tiff` writes the same kind of file that conversion
60
+ produces, so a stack pulled mid-run and one converted afterwards are
61
+ interchangeable downstream.
62
+
63
+ ## Install
64
+
65
+ ```
66
+ pip install pylv200
67
+ pip install pylv200[compressed] # only if your tiles turn out compressed
68
+ ```
69
+
70
+ **On a microscope PC, install nothing.** A standalone Windows build carries
71
+ Python, numpy, tifffile and the image codecs inside a single 46 MB file. It is
72
+ built locally rather than committed, so it is not in a fresh clone. Copy the one
73
+ file across, run it, delete it when done — nothing is registered, no PATH is
74
+ changed, and no administrator rights are needed. Rebuild it with:
75
+
76
+ ```
77
+ pyinstaller --onefile --console --name pylv200 --collect-all imagecodecs src/pylv200/__main__.py
78
+ ```
79
+
80
+ ## Use
81
+
82
+ ```
83
+ pylv200 list left-lv200 # what is on that microscope
84
+ pylv200 plan left-lv200:ois351A --last-n 24 # what a pull would fetch
85
+ pylv200 sync left-lv200:ois351A -o D:\pulled # fetch what is new
86
+ ```
87
+
88
+ | command | does |
89
+ |---|---|
90
+ | `setup` | run **on the microscope**: find the data, open one read-only door |
91
+ | `probe <source>` | reachable? roots resolve? anything being written? |
92
+ | `login <source>` | hand a credential to Windows; this package never sees it |
93
+ | `logout <source>` | forget a source's stored credential |
94
+ | `rescan <source>` | find an output folder that moved, without a lab visit |
95
+ | `sources` | the microscopes this machine knows about |
96
+ | `status [<target>]` | what is reachable, what a watcher is doing, what has been written |
97
+ | `scans <target>` | which timepoints a run holds, and when each was acquired |
98
+ | `list [<source>]` | experiments on a source, `--live` to mark the running ones |
99
+ | `find <pattern>` | which experiment is that? |
100
+ | `plan <target>` | what a pull would fetch, and what is already held |
101
+ | `pull <target>` | fetch once, writing every selected timepoint |
102
+ | `sync <target>` | fetch only what is new, using the ledger |
103
+ | `preview <target>` | a few tiles per position, to see which one you want |
104
+ | `timeline <target>` | save byte-bounded time-course previews |
105
+ | `watch <target>` | poll on an interval and collect what is new |
106
+ | `manifest <folder>` | summarise a previous pull |
107
+ | `preset` | show or save a named recipe |
108
+ | `gui` | the desktop app: choose a position by looking at it |
109
+
110
+ A target is `source:experiment`. A bare experiment name works when only one
111
+ microscope is configured; with two it says so rather than guessing, because
112
+ guessing puts one experiment's channel names on another's pixels. A path is
113
+ taken as itself:
114
+
115
+ ```
116
+ pylv200 pull "D:\Experiments\_MyRun_" --dry-run
117
+ ```
118
+
119
+ `--json` goes **before** the subcommand and puts a machine-readable payload on
120
+ stdout, with everything else on stderr:
121
+
122
+ ```
123
+ pylv200 --json list left-lv200 | jq '.experiments[].name'
124
+ ```
125
+
126
+ Exit codes: `0` success, `1` nothing to do or a fault, `2` usage or unreachable.
127
+ `probe` and `sync` are meant for a scheduled task, so "there was nothing new" is
128
+ `1` and distinguishable from both success and failure.
129
+
130
+ `pull --dry-run` first, and check the reported geometry — channel count,
131
+ timepoint count, frame size — against the experiment you actually set up.
132
+
133
+ **A run in progress has no chunk table.** cellSens writes the table that locates
134
+ every tile only when it closes the file, so a live acquisition has zeroes where
135
+ the table pointer and count belong. `pylv200` detects this and rebuilds the index
136
+ by walking the fixed tile stride instead, reporting `recovered by walking the
137
+ tile stride` when it does. That path needs `--channels N` to be correct, because
138
+ without a table there is nothing recording which tile belongs to which channel —
139
+ tiles are laid down channel-fastest, N per timepoint. Get `--channels` wrong and
140
+ the frames will be dealt into the wrong stacks.
141
+
142
+ **Pulling from a run that is still going, use `--safe-copy`.** The chunk table is
143
+ written at the *end* of the file and rewritten as the file grows, so it can move
144
+ while the tool is reading. `--safe-copy` parses a stable snapshot instead. The
145
+ tool checks whether the newest part is still growing and reminds you if it is.
146
+
147
+ Windows will not refresh a file's size or timestamp in a directory listing while
148
+ cellSens holds it open, so a part that looks days old in Explorer may be the one
149
+ being written to right now. Never use the listed timestamp to decide which part
150
+ is live.
151
+
152
+ | flag (on `pull` / `sync`) | what it does |
153
+ |---|---|
154
+ | `--last-n N` | the N most recent timepoints |
155
+ | `--from-t T` / `--to-t T` | an explicit window, numbered as the file numbers it |
156
+ | `--channels N` | override the channel count read from the file header |
157
+ | `--pixel-size` | microns per pixel, for spatial calibration |
158
+ | `--interval` | seconds between timepoints; derived from the run's own timestamps otherwise |
159
+ | `--channel-names` | override the names read from the `.vsi`, and pin them for later pulls |
160
+ | `--composite` | overlay channels in ImageJ's default colours instead of grayscale |
161
+ | `--split-channels` | one TIFF per channel instead of one composite |
162
+ | `--ome-tiff` | a lossless LZW OME-TIFF, matching a Bio-Formats conversion |
163
+ | `--include-last` | include the final tile (dropped by default, it may be mid-write) |
164
+ | `--axes tx,ty,z,c,t` | override the axis assignment if the guess is wrong |
165
+ | `--ndims N` | force the chunk-table dimension count |
166
+ | `--dtype uint16` | force pixel type |
167
+ | `--safe-copy` | copy each `.ets` to temp before parsing |
168
+ | `--mirror DIR` | keep a local byte copy and fetch only what the run has added |
169
+ | `--preset NAME` | apply a saved recipe; the command line still wins |
170
+ | `--no-pin` | do not remember `--channel-names`, and forget any already pinned |
171
+
172
+ ### Setting up a microscope, once
173
+
174
+ Run `pylv200 setup` on the LV200 itself. It walks every fixed drive and
175
+ recognises cellSens data by the first four bytes of a file, never by a folder
176
+ name — the output folder is a user setting and the two microscopes disagree
177
+ about it — then reports what is stopping a share from working:
178
+
179
+ ```
180
+ pylv200 setup # look, change nothing
181
+ pylv200 setup --create-share # print the plan
182
+ pylv200 setup --create-share --yes
183
+ ```
184
+
185
+ It creates nothing without `--yes`, writes the undo commands to
186
+ `lv200-setup-undo.txt` *before* the first change, and prints a source block to
187
+ paste on the analysis machine. Then, once, from there:
188
+
189
+ ```
190
+ pylv200 login left-lv200
191
+ pylv200 probe left-lv200
192
+ ```
193
+
194
+ The account it creates is an ordinary user, and it refuses to build the shares
195
+ if that account is ever in Administrators: a broad read-only share is safe only
196
+ because the account cannot read what an administrator could. Its password is
197
+ typed into Windows' own prompt, so it never reaches `pylv200` — nothing here can
198
+ leak what it never receives.
199
+
200
+ If cellSens is later pointed somewhere else on the same machine,
201
+ `pylv200 rescan left-lv200` finds it again without a visit.
202
+
203
+ ### Pulling over the network
204
+
205
+ Tiles are fetched by seeking to each offset rather than reading the file end to
206
+ end, so restricting the time axis restricts what crosses the wire. On a nine-day
207
+ run at half-hour intervals, `--last-n 24` pulls the last day: 50 MB instead of
208
+ 875 MB. The reported frame interval is unaffected, because it is derived from
209
+ the whole file's timestamps before the selection is applied.
210
+
211
+ `plan` reads only headers and tile indexes — a few kilobytes — so it is the cheap
212
+ way to see what a transfer would cost before committing to it:
213
+
214
+ ```
215
+ pylv200 plan left-lv200:ois351A --last-n 24 -o D:\pulled
216
+ ois351A_20260814
217
+ 4 position(s), 417 timepoint(s), 4 channel(s)
218
+ channels C0 BioLuminescence C1 BF C2 GFP C3 RFP
219
+ selection t=393..416
220
+ already held 393 timepoint(s) in D:\pulled
221
+ to fetch 24 timepoint(s), 50.3 MB
222
+ ```
223
+
224
+ ### Look before you pull
225
+
226
+ Eleven positions of nearly a gigabyte each, named `ois351A` and `ois3518`, tell
227
+ you nothing about which one holds the field of view you care about.
228
+
229
+ ```
230
+ pylv200 preview left-lv200:ois351A -o D:\thumbs
231
+ ```
232
+
233
+ It reads a handful of tiles — kilobytes, not gigabytes — and writes one
234
+ thumbnail per position and channel plus a contact sheet: positions down,
235
+ channels across, each column naming its channel and whether that name was read
236
+ from the `.vsi` or pinned by hand.
237
+
238
+ The display range is computed once per channel across every position on the
239
+ sheet, not per image. A per-image stretch makes an empty position look exactly
240
+ as bright as a full one, which is the precise question the sheet exists to
241
+ answer.
242
+
243
+ Photon-starved channels are averaged over a few frames, because a single
244
+ bioluminescence frame is mostly shot noise and the cells appear only as it
245
+ averages. The contrast is a percentile stretch, not a full-range map: a
246
+ full-range map renders a luminescence frame black, which reads as "no data"
247
+ rather than "few photons". `--stretch LO HI` changes it.
248
+
249
+ Previews need matplotlib: `pip install pylv200[preview]`. Nothing else in
250
+ `pylv200` does, and importing it never pulls in a display toolkit.
251
+
252
+ ### Scrub through a time course
253
+
254
+ The desktop app's **Preview time course** action opens a slider without loading
255
+ the whole acquisition. From a prompt, the same provider can save its initial
256
+ anchor frames:
257
+
258
+ ```
259
+ pylv200 timeline left-lv200:ois351A --position ois351A --channel 0 -o D:\timeline
260
+ ```
261
+
262
+ The initial source-byte budget is 64 MiB. On the current Olympus data, that is
263
+ at most 128 evenly spaced single frames, or 32 displayed anchors when a
264
+ four-frame bioluminescence mean is used. Every other timepoint remains
265
+ addressable in the desktop slider; a miss reads that frame and at most the
266
+ bounded neighbour prefetch. `--budget-mib` changes the command-line budget.
267
+
268
+ Current `.ets` files have no per-timepoint image pyramid. Each new uncached
269
+ single frame therefore requires one complete 512 x 512, 16-bit source tile
270
+ (524,288 bytes), which is immediately reduced to a 256 x 256 block-mean proxy.
271
+ PyLV200 does not claim the smaller network read that a real pyramid would
272
+ provide.
273
+
274
+ Already requested proxies persist under the PyLV200 settings home. Source
275
+ selection is: completed local pull (`--local`), valid persistent proxy,
276
+ configured mirror (`--mirror`), then the remote `.ets`. The proxy is disposable
277
+ display data and is never used by resume or scientific-output checks.
278
+
279
+ ### Watching a run instead of remembering to pull it
280
+
281
+ ```
282
+ pylv200 watch left-lv200:ois351A -o D:\pulled
283
+ ```
284
+
285
+ It polls, collects what is new, and holds it until there is enough to be worth
286
+ writing. Over a nine-day acquisition that is the difference between remembering
287
+ to pull and simply having the data — and if cellSens clears its scratch when a
288
+ run ends, between capturing a crashed run and losing it.
289
+
290
+ | flag | what it does |
291
+ |---|---|
292
+ | `-i N` | seconds between polls. Defaults to half the run's own frame interval |
293
+ | `--batch-frames N` | hold until N new timepoints have accumulated (default 4) |
294
+ | `--batch-after 7d` | write anyway once the oldest waiting frame is that old |
295
+ | `--once` | poll once and stop |
296
+ | `--flush` | write whatever is held right now, then stop |
297
+ | `--status` | what the watcher for this output has done, and whether it is still running |
298
+
299
+ `--batch-frames` is not cosmetic. A composite hyperstack is rewritten whole when
300
+ a timepoint is added, so polling a large experiment every half hour without it
301
+ rewrites gigabytes 48 times a day. A held timepoint leaves **nothing** behind —
302
+ no file and no ledger entry — so holding can never lose one.
303
+
304
+ `--batch-after` is measured from when a timepoint was *acquired*, not from when
305
+ the watcher started, so restarting the watcher does not restart the wait.
306
+
307
+ A dropped share, a rebooted microscope and a sleeping laptop all look the same
308
+ and are all recovered from, with backoff. An expired credential is told apart
309
+ from an unreachable host, because the fix for it is `pylv200 login` and not the
310
+ network.
311
+
312
+ Every poll is recorded in `pylv200-watch.log` beside the output, including the
313
+ ones that did nothing — a watcher that silently died is worse than no watcher,
314
+ because you believe you have the data. `pylv200 watch <target> -o <dir> --status`
315
+ reads it back and exits non-zero if the log has gone quiet.
316
+
317
+ If you would rather not leave a terminal open, run it from a Scheduled Task:
318
+
319
+ ```powershell
320
+ $action = New-ScheduledTaskAction -Execute "pylv200" `
321
+ -Argument 'watch left-lv200:ois351A -o D:\pulled --once --batch-frames 4'
322
+ $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) `
323
+ -RepetitionInterval (New-TimeSpan -Minutes 15)
324
+ Register-ScheduledTask -TaskName "pylv200 ois351A" -Action $action `
325
+ -Trigger $trigger -RunLevel Limited
326
+ ```
327
+
328
+ `--once` exits 0 when it wrote, 1 when there was nothing new, and 2 when the
329
+ source was unreachable, so the task's own history is a second record of whether
330
+ it is working.
331
+
332
+ ### Adding to a stack instead of rewriting it
333
+
334
+ A watcher that rewrote its output on every poll would move the whole stack 48
335
+ times a day to gain one timepoint. It does not: new planes are written into the
336
+ existing file in place, and only the new tiles cross the wire.
337
+
338
+ The trade is that an appended frame cannot be un-appended, where a rewrite
339
+ simply replaces the file. `--no-append` opts out, and `--include-last
340
+ --no-append` is the conservative pair. Appending falls back to a rewrite on its
341
+ own whenever the file is not the earlier part of this same stack - a different
342
+ geometry, a different frame interval, different channel names - so the reason
343
+ to ask is caution, not correctness.
344
+
345
+ It applies to the default composite hyperstack. `--split-channels` and
346
+ `--ome-tiff` still rewrite.
347
+
348
+ ### The window
349
+
350
+ ```
351
+ pylv200 gui # or pylv200-gui, or python -m pylv200.gui
352
+ pylv200 gui left-lv200:ois351A # opening straight onto one run
353
+ ```
354
+
355
+ The app exists for one job the command line does badly: choosing which field of
356
+ view you want. Eleven positions called `ois351A` and `ois3518` are eleven names
357
+ that say nothing, so the app draws a grid of thumbnails instead and you click
358
+ the pictures. Reading them costs a handful of tiles per field, not a pull.
359
+
360
+ It has no pull of its own. Every button turns the panel into a `pylv200`
361
+ command, hands it to the same parser the terminal uses, and calls the same
362
+ function — so the app and the command line cannot write different files, and
363
+ the confirmation dialog shows you the exact command before it runs one. Copy
364
+ that command and it is a Scheduled Task.
365
+
366
+ The panel is PyIncucyte's, subject for subject: an output folder, the channels
367
+ this run's own `.vsi` names, a time window, a layout, the switches, the watch
368
+ cadence, a mirror, and the calibration. Everything on it is one field of the
369
+ recipe, so the whole panel saves as a preset and reloads.
370
+
371
+ Two consequences worth knowing. Anything the command line cannot express is not
372
+ on the panel: the time window is counted in timepoints, never in hours, because
373
+ `--last-n` and `--from-t` are what exist. And Stop takes effect between fields
374
+ of view — a field already being written is finished, so the ledger and the disk
375
+ never disagree about what you have.
376
+
377
+ Tk is imported only by this command. `pylv200 list` on a headless microscope
378
+ needs no display, and a test asserts it.
379
+
380
+ ### Keeping a local copy that grows with the run
381
+
382
+ `--mirror DIR` keeps a byte copy of each `.ets` in `DIR`. Tile data is appended
383
+ at a fixed stride, so a second run fetches only what the acquisition has added
384
+ since the first:
385
+
386
+ ```
387
+ pylv200 sync left-lv200:ois7041 --mirror "D:\mirror" -o "D:\pulled"
388
+ ```
389
+
390
+ ```
391
+ mirroring to D:\mirror (232.8 MB to fetch)
392
+ ois7041.ets +232.8 MB of 232.8 MB
393
+ ... and an hour later:
394
+ mirroring to D:\mirror (16.8 MB to fetch)
395
+ ois7041.ets +16.8 MB of 249.6 MB
396
+ ```
397
+
398
+ The tiles are then read from the copy, so a re-run costs nothing over the wire.
399
+ What is left behind matters more than the speed: during acquisition the tiles are
400
+ the only copy of the run that exists, because cellSens writes nothing else until
401
+ it ends.
402
+
403
+ It is **not a backup**. It is a copy of a live scratch file and inherits whatever
404
+ the source has, including corruption. When a copy looks wrong, the mirror can
405
+ re-read sampled stretches from the source and compare.
406
+
407
+ `sync` into the same folder twice does no work the second time:
408
+
409
+ ```
410
+ nothing new: all 111 selected timepoint(s) are already in D:\pulled
411
+ ```
412
+
413
+ A ledger beside the output records which timepoints it holds, and how many tiles
414
+ the source had for each when it was written. So a frame the acquisition has since
415
+ added to is fetched again, one it has not touched is not, and re-running with
416
+ different channel names or a different layout is never mistaken for work already
417
+ done. `pull` ignores the ledger and writes everything selected; deleting an
418
+ output file is enough to make it come back.
419
+
420
+ The sidecars and the timestamps are still read from the source, where they live:
421
+ a copy's modification time is when it was copied, and using it would make the
422
+ frame interval whatever the copy took.
423
+
424
+ ### What is read, what is derived, and what is not known
425
+
426
+ Channel identity is **read, not guessed**. The `.vsi` written beside the tiles
427
+ when the run starts carries the channel names in acquisition order, along with
428
+ the experiment name, the camera, the objective and each channel's filter-wheel
429
+ positions. On a real run: `BioLuminescence, BF, GFP, RFP`, Hamamatsu ImagEM X2
430
+ 9100-23B, LUCPLFLN 40x.
431
+
432
+ Three more things come out of the `.ets` itself:
433
+
434
+ | | where it comes from |
435
+ |---|---|
436
+ | channel count | a length-prefixed `[X, Y, C, Z, T]` array in the sub-header tail. On a live part the `T` entry is stale, written once at creation, but `C` is correct |
437
+ | timepoint count | the tiles actually present |
438
+ | frame interval | the run's start (from the `.vsi`) to its last write, divided by the timepoints written |
439
+
440
+ One thing is **not** known: microns per pixel. The `.vsi`'s XResolution is its
441
+ thumbnail's screen resolution, not the microscope's, and no value in the metadata
442
+ could be tied to a spatial calibration with any confidence. So `pylv200` reports
443
+ the objective and leaves the calibration empty rather than inventing one — a
444
+ wrong micron-per-pixel silently rescales every measurement downstream. Pass
445
+ `--pixel-size` if you know it.
446
+
447
+ The pixel statistics are still computed and printed, as a cross-check on the
448
+ names rather than a source of them: bioluminescence barely correlates frame to
449
+ frame, brightfield is the brightest and near-identical, and nothing in the pixels
450
+ distinguishes GFP from RFP. Every value in the manifest records where it came
451
+ from, so six months later a name read off the `.vsi` and one the pixels were
452
+ asked to guess at are still tellable apart.
453
+
454
+ ### Pulling only some of it
455
+
456
+ ```
457
+ pylv200 sync left-lv200:ois351A -o D:\pulled --positions ois351A ois3518
458
+ pylv200 sync left-lv200:ois351A -o D:\pulled --channels 0 2
459
+ ```
460
+
461
+ Both drop tiles before they cross the wire — a tile is fetched by seeking to its
462
+ offset, so what is not asked for is never transferred. Pulling one channel of a
463
+ four-channel run moves roughly a quarter of the bytes.
464
+
465
+ Channel names follow the selection **by index**, so `--channels 0 2` on
466
+ `BioLuminescence, BF, GFP, RFP` writes a two-channel stack labelled
467
+ `BioLuminescence, GFP` — not the first two names. The manifest records the
468
+ original indexes alongside them.
469
+
470
+ `--channels` used to mean "how many channels the header should report". That is
471
+ now `--channel-count`. Asking for a channel the file does not have says so and
472
+ names the other flag rather than writing a stack with the wrong shape.
473
+
474
+ ### From Python
475
+
476
+ ```python
477
+ import pylv200
478
+
479
+ with pylv200.connect() as lv200:
480
+ result = lv200.fetch(output="./run-01", last_n=2)
481
+ ```
482
+
483
+ `connect()` takes a source name from `pylv200 setup`, a folder, or nothing at
484
+ all when there is only one saved source. What comes back writes the same bytes
485
+ as `pylv200 sync` - the command line calls the same function. `lv200.pull(...)`
486
+ is the same method under the verb everything else in this package uses.
487
+
488
+ ```python
489
+ plan = lv200.plan("ois351A", last_n=24) # what a pull would fetch
490
+ lv200.preview("ois351A").save("./thumbs") # a few tiles per position
491
+ timeline = lv200.timeline("ois351A") # lazy; reads frames on request
492
+ frame = timeline.get_frame("ois351A", 0, 1337)
493
+ timeline.close()
494
+ lv200.timeline("ois351A", show=True) # open the interactive scrubber
495
+ watcher = lv200.watch("ois351A", output="./run-01", batch_after="7d")
496
+ print(watcher.pending_frames, watcher.hold_description)
497
+ watcher.stop(wait=True, flush=True) # write what it was holding
498
+ ```
499
+
500
+ A watcher left to itself pulls through the client, so a poll that wrote
501
+ something hands the whole result to `on_result` - not just the paths:
502
+
503
+ ```python
504
+ def outline(poll): # poll.wrote is the paths
505
+ for image in poll.result.files: # poll.result is a PullResult
506
+ print(image.path, image.axes, image.complete,
507
+ [(c.index, c.name, c.source) for c in image.channel_refs])
508
+
509
+ lv200.watch("ois351A", output="./run-01", on_result=outline)
510
+ ```
511
+
512
+ A recipe is one object, and it round-trips:
513
+
514
+ ```python
515
+ options = pylv200.ExportOptions(start_from="-48h", layout="ome")
516
+ options.save("nightly.json")
517
+ print(options.cli_command(target="left-lv200:ois351A"))
518
+ # pylv200 sync left-lv200:ois351A --start-from -48h --ome-tiff
519
+ ```
520
+
521
+ ### Channels are written grayscale
522
+
523
+ These are grayscale detector channels, so the stack is written in ImageJ's
524
+ `grayscale` display mode and no lookup tables are assigned. An ImageJ composite
525
+ would otherwise colour them red/green/blue/grey purely by index, implying a
526
+ relationship between channels that does not exist. `--composite` opts back in.
527
+
528
+ ### Output
529
+
530
+ One composite ImageJ hyperstack per position, `(T, Z, C, Y, X)`, opening in Fiji
531
+ as a multichannel hyperstack you can scrub. Past ImageJ's 4 GB hyperstack ceiling
532
+ it writes OME-TIFF instead, which Fiji opens through Bio-Formats; `--ome-tiff`
533
+ chooses that outright. `--split-channels` gives one file per channel.
534
+
535
+ Beside the stacks it writes `pylv200-manifest.json` and `pylv200-index.csv`
536
+ recording what was written, how each derived value was known, and which source
537
+ files it came from. `pylv200 manifest <folder>` reads them back.
538
+
539
+ Each file entry is written to be read on its own, without joining anything back
540
+ to the header:
541
+
542
+ ```json
543
+ {
544
+ "path": "D:\\pulled\\Per2_Torin1_ois7041.tif",
545
+ "axes": "TZCYX",
546
+ "channels": [{"index": 1, "name": "BioLuminescence", "source": "vsi",
547
+ "source_index": 0},
548
+ {"index": 2, "name": "GFP", "source": "vsi",
549
+ "source_index": 2}],
550
+ "frame_count": 393, "missing": 0,
551
+ "complete": false, "interval_s": 1380.0, "pixel_size_um": null
552
+ }
553
+ ```
554
+
555
+ `index` counts from one, the way ImageJ and Fiji count, and describes **this
556
+ file** — a pull that caught a partly acquired newest frame holds fewer channels
557
+ than the run has, and an index taken from the run would name a plane that is not
558
+ there. `source_index` is the run's own numbering, which is what a `--channels`
559
+ selection is written in. `source` says how the name was known: `vsi`, `oex`,
560
+ `override` or `inferred`.
561
+
562
+ `complete` is `false` while cellSens is still writing — the acquisition has no
563
+ chunk table until it closes — so a downstream step knows to come back for this
564
+ stack rather than treating it as final. Alongside it in the header,
565
+ `include_last` states the policy that produced the frame count: the newest tile
566
+ may be half written, so it is dropped unless `--include-last` asks for it, and
567
+ 393 frames of a 394-frame run is a decision rather than a fault.
568
+
569
+ As a library:
570
+
571
+ ```python
572
+ from pylv200 import LV200Client, ExportOptions
573
+
574
+ client = LV200Client(r"D:\Experiments")
575
+ for experiment in client.experiments(live=True):
576
+ print(experiment.name, [p.name for p in experiment.positions])
577
+
578
+ plan = client.plan("MyRun", ExportOptions(start_from="-24f"))
579
+ print(plan.timepoints, plan.bytes_to_read)
580
+ ```
581
+
582
+ ```python
583
+ from pylv200 import parse_ets, assign_axes, build_stacks
584
+
585
+ meta = parse_ets("D:/Experiments/_MyRun_/frame_t.ets")
586
+ axes = assign_axes(meta, channels=2)
587
+ built = build_stacks(meta, axes)
588
+ built["data"] # (T, Z, C, Y, X) composite
589
+ built["stacks"] # {channel: view into that array}
590
+ ```
591
+
592
+ ## How it finds the data
593
+
594
+ `.ets` field offsets shift between cellSens versions, so nothing is hardcoded.
595
+ The parser scans the volume header for a pointer that lands on the `ETS` magic,
596
+ then scores every candidate chunk-table position against every dimension count
597
+ from 2 to 8, keeping the reading where tile byte counts agree with each other,
598
+ divide evenly into whole bytes per pixel, and where the tile coordinates form a
599
+ dense regular grid. A wrong guess fails all three tests.
600
+
601
+ For a run still in progress the header over-declares how many tiles exist, so
602
+ records are read past the declared count and stop at the first run of invalid
603
+ ones. Incomplete timepoints are dropped rather than half-filled.
604
+
605
+ Datasets are found the same way — by the first four bytes of a file, never by a
606
+ folder name. A directory qualifies on the first valid header rather than on every
607
+ file in it, which is what makes `list` usable over a share.
608
+
609
+ ## Coming from PyIncucyte
610
+
611
+ `pylv200` is the sister package to `PyIncucyte`, and the two are deliberately
612
+ driven the same way. Where the idea is shared the name is shared; where the
613
+ instrument differs, so does the noun.
614
+
615
+ | PyIncucyte | pylv200 |
616
+ |---|---|
617
+ | `pyincucyte vessels` | `pylv200 list` (`vessels` also works) |
618
+ | `pyincucyte download -v 38 -o out` | `pylv200 sync <source>:<run> -o out` (`download` also works) |
619
+ | `pyincucyte scans` | `pylv200 scans` - same name (`timepoints` also works) |
620
+ | `--wells A1-D6` | `--positions ois351A ois3518` - fields of view, not plate wells |
621
+ | `--channels phase,green` | `--channels 0 2` - channel indexes, named from the `.vsi` |
622
+ | `--layout time_channel_stack` | `--layout composite` - the default; a run here is always a time series |
623
+ | `IncucyteClient.from_saved()` | `pylv200.connect()` |
624
+ | `client.fetch(vessel=38, ...)` | `client.fetch(positions=["ois351A"], ...)` |
625
+ | `pyincucyte scan-now` | *nothing* - `pylv200` never commands the microscope |
626
+ | `--unmix`, `--calibrate`, `--green-lut` | *nothing* - no device coefficients, and a display LUT would corrupt what downstream analysis measures |
627
+
628
+ Typing one of the absent commands says why it will not exist rather than
629
+ printing a usage block.
630
+
631
+ ## Status
632
+
633
+ Alpha. The parser is validated against synthetic `.ets` files covering complete
634
+ and truncated acquisitions, and against five real acquisitions up to 875 MB, but
635
+ has not been confirmed against every cellSens version in the wild. Run
636
+ `pull --dry-run` and sanity-check the geometry before trusting the pixels.
637
+ Reports of files it misreads are welcome.
638
+
639
+ ## Tests
640
+
641
+ ```
642
+ pip install -e .[test]
643
+ pytest
644
+ ```
645
+
646
+ ## Licence
647
+
648
+ MIT.