scigantic-headers 0.1.0__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.
- scigantic_headers-0.1.0/LICENSE +4 -0
- scigantic_headers-0.1.0/PKG-INFO +203 -0
- scigantic_headers-0.1.0/README.md +187 -0
- scigantic_headers-0.1.0/pyproject.toml +32 -0
- scigantic_headers-0.1.0/setup.cfg +4 -0
- scigantic_headers-0.1.0/src/scigantic_headers/__init__.py +52 -0
- scigantic_headers-0.1.0/src/scigantic_headers/benchmark.py +107 -0
- scigantic_headers-0.1.0/src/scigantic_headers/cli.py +50 -0
- scigantic_headers-0.1.0/src/scigantic_headers/cryosparc.py +144 -0
- scigantic_headers-0.1.0/src/scigantic_headers/decoders.py +389 -0
- scigantic_headers-0.1.0/src/scigantic_headers/optics.py +48 -0
- scigantic_headers-0.1.0/src/scigantic_headers/parquet.py +184 -0
- scigantic_headers-0.1.0/src/scigantic_headers/sources.py +199 -0
- scigantic_headers-0.1.0/src/scigantic_headers/star.py +114 -0
- scigantic_headers-0.1.0/src/scigantic_headers.egg-info/PKG-INFO +203 -0
- scigantic_headers-0.1.0/src/scigantic_headers.egg-info/SOURCES.txt +27 -0
- scigantic_headers-0.1.0/src/scigantic_headers.egg-info/dependency_links.txt +1 -0
- scigantic_headers-0.1.0/src/scigantic_headers.egg-info/entry_points.txt +3 -0
- scigantic_headers-0.1.0/src/scigantic_headers.egg-info/requires.txt +3 -0
- scigantic_headers-0.1.0/src/scigantic_headers.egg-info/top_level.txt +1 -0
- scigantic_headers-0.1.0/tests/test_cryosparc.py +91 -0
- scigantic_headers-0.1.0/tests/test_decoders.py +145 -0
- scigantic_headers-0.1.0/tests/test_fixtures.py +34 -0
- scigantic_headers-0.1.0/tests/test_formats.py +135 -0
- scigantic_headers-0.1.0/tests/test_fuzz.py +99 -0
- scigantic_headers-0.1.0/tests/test_optics.py +68 -0
- scigantic_headers-0.1.0/tests/test_parquet.py +64 -0
- scigantic_headers-0.1.0/tests/test_sources.py +141 -0
- scigantic_headers-0.1.0/tests/test_star.py +121 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scigantic-headers
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Decode scientific file headers (MRC cryo-EM, extensible) into typed context for agents — pure, zero-dependency, with bounded parallel readers.
|
|
5
|
+
Author: Scigantic
|
|
6
|
+
License: Proprietary — Evaluation Use Only. See LICENSE.
|
|
7
|
+
Keywords: cryo-em,mrc,header,schema-card,instrument,context,edge
|
|
8
|
+
Classifier: License :: Other/Proprietary License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# scigantic-headers
|
|
18
|
+
|
|
19
|
+
Read the metadata of a scientific file and return its fields (dimensions, data
|
|
20
|
+
type, pixel size, columns, row count) as a dict, without reading the rest of the
|
|
21
|
+
file. Decodes MRC, NPY, NIfTI, CryoSPARC `.cs`, and Parquet. The decode
|
|
22
|
+
functions have no dependencies; separate reader functions fetch the bytes from a
|
|
23
|
+
file or a URL (the leading bytes for most formats, the trailing bytes for
|
|
24
|
+
Parquet, whose schema is in a footer).
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from scigantic_headers import decode_file
|
|
28
|
+
hdr = decode_file("session/frame.mrc")
|
|
29
|
+
hdr.summary # "MRC stack 4096x4096x16, float32"
|
|
30
|
+
hdr.fields["dtype"] # "float32"
|
|
31
|
+
hdr.fields["nz"] # 16 (frames)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
One implementation, shared. The cloud probe's TypeScript twin
|
|
35
|
+
(`backend/src/services/headerDecoders.ts`), the notebook, and the edge
|
|
36
|
+
context-producer describe a file the same way. Same byte offsets, same
|
|
37
|
+
`mode -> dtype` table, held to the same test fixtures so they cannot drift.
|
|
38
|
+
|
|
39
|
+
Edge use: the context-producer (`infrastructure/edge/context-producer`) runs
|
|
40
|
+
this next to an instrument, with no network, on amd64 or arm64. The zero
|
|
41
|
+
dependencies and header-only reads are what let it run there.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
Published to PyPI on a `scigantic-headers-vX.Y.Z` tag (see
|
|
46
|
+
`.github/workflows/scigantic-headers.yml`):
|
|
47
|
+
|
|
48
|
+
pip install scigantic-headers
|
|
49
|
+
|
|
50
|
+
It has zero runtime dependencies, so it also installs from source into an
|
|
51
|
+
air-gapped image with no package index at build time. The notebook and
|
|
52
|
+
edge-producer images bundle it this way:
|
|
53
|
+
|
|
54
|
+
COPY infrastructure/python/scigantic-headers /tmp/scigantic-headers
|
|
55
|
+
RUN pip install /tmp/scigantic-headers
|
|
56
|
+
|
|
57
|
+
Both paths install the same version pinned in `pyproject.toml`. Consumers that
|
|
58
|
+
install from an index should pin it (`scigantic-headers==X.Y.Z`).
|
|
59
|
+
|
|
60
|
+
## Why it exists
|
|
61
|
+
|
|
62
|
+
A schema card or context record wants to say what a file is without opening it
|
|
63
|
+
over a FUSE mount. A scientific file's first ~1 KiB is a structured header that
|
|
64
|
+
already says so. This reads that, and nothing more.
|
|
65
|
+
|
|
66
|
+
Decoders today:
|
|
67
|
+
|
|
68
|
+
- **MRC / MRCS**: cryo-EM micrographs, movie stacks, tilt series, EMDB maps.
|
|
69
|
+
- **NPY**: NumPy arrays (ML, genomics, materials, not cryo-EM). Cross-checked
|
|
70
|
+
against numpy.
|
|
71
|
+
- **NIfTI-1**: neuroimaging volumes, `.nii` and `.nii.gz` (not cryo-EM).
|
|
72
|
+
Cross-checked against nibabel. Big- and little-endian.
|
|
73
|
+
- **CryoSPARC `.cs`**: a structured-array dataset. Reports record count and the
|
|
74
|
+
field schema.
|
|
75
|
+
- **Parquet**: reads the footer, not a leading header, and returns the column
|
|
76
|
+
names, physical types, and row count. Cross-checked against pyarrow.
|
|
77
|
+
|
|
78
|
+
`.gz` is transparent. The reader decompresses just the leading block, so a
|
|
79
|
+
gzipped `.nii.gz` or `.mrc.gz` decodes without inflating the whole file, and
|
|
80
|
+
dispatch sees the inner format.
|
|
81
|
+
|
|
82
|
+
Adding a format is a pure `bytes -> DecodedHeader | None` function plus one
|
|
83
|
+
`register_decoder` call. Nothing about the dispatch, the readers, or the batch
|
|
84
|
+
path is cryo-EM-specific. NPY and NIfTI landed with zero plumbing changes.
|
|
85
|
+
|
|
86
|
+
## Pixel size for raw movies
|
|
87
|
+
|
|
88
|
+
A raw MRC movie header has no pixel size: CELLA is 0, so `decode_*` returns
|
|
89
|
+
`pixelSizeA` as `None`. The value is in the RELION STAR or CryoSPARC `.cs` file
|
|
90
|
+
the workflow writes in the session, and the library reads it. `read_session_optics`
|
|
91
|
+
finds that file next to (or above) the data file and returns the optics:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from scigantic_headers import decode_file, read_session_optics
|
|
95
|
+
|
|
96
|
+
hdr = decode_file("Movies/frame.mrc") # header fields; pixelSizeA is None
|
|
97
|
+
opt = read_session_optics("Movies/frame.mrc")
|
|
98
|
+
# {'pixelSizeA': 1.05, 'voltageKv': 300.0, 'source': 'relion-star:particles.star'}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`read_star_optics(path)` and `read_cryosparc_optics(path)` read a specific optics
|
|
102
|
+
file when you already know which one. The RELION 3.1 `data_optics` loop, the
|
|
103
|
+
older key-value form, optics columns in the main data loop, and the legacy
|
|
104
|
+
detector-pixel-size / magnification pair are all handled.
|
|
105
|
+
|
|
106
|
+
## Layout
|
|
107
|
+
|
|
108
|
+
src/scigantic_headers/
|
|
109
|
+
decoders.py pure core: registry + MRC / NPY / NIfTI / .cs decoders
|
|
110
|
+
parquet.py Parquet footer decode (small Thrift-compact reader)
|
|
111
|
+
sources.py read leading bytes from file or URL; bounded parallel batch
|
|
112
|
+
star.py RELION STAR optics reader
|
|
113
|
+
cryosparc.py CryoSPARC .cs optics reader
|
|
114
|
+
optics.py read_session_optics: find a data file's optics file
|
|
115
|
+
cli.py scigantic-headers <file|url|--dir>
|
|
116
|
+
benchmark.py scigantic-headers-bench, measures the speed levers
|
|
117
|
+
tests/ pytest (117 tests, incl. fuzz + golden fixtures)
|
|
118
|
+
|
|
119
|
+
## Robustness
|
|
120
|
+
|
|
121
|
+
A decoder is handed the leading bytes of arbitrary files, so it must be total:
|
|
122
|
+
for any input it returns None or a valid, JSON-safe DecodedHeader. It never
|
|
123
|
+
raises and never emits NaN or inf. This is enforced, not assumed.
|
|
124
|
+
|
|
125
|
+
- **Fuzz** (`test_fuzz.py`): thousands of random and magic-seeded byte buffers
|
|
126
|
+
through every decoder. Each result must serialize with `allow_nan=False`.
|
|
127
|
+
- **Golden fixtures** (`fixtures/mrc-cases.json`): hex input to exact decode.
|
|
128
|
+
This is the single source of truth the three MRC decoders (this library, the
|
|
129
|
+
TypeScript twin, scigantic_empiar) are all held to, so none can silently
|
|
130
|
+
diverge.
|
|
131
|
+
- Non-finite floats (a garbage NIfTI pixdim, an inf MRC cell) sanitize to null.
|
|
132
|
+
|
|
133
|
+
## Speed
|
|
134
|
+
|
|
135
|
+
The decode is microseconds: a 1 KiB read and a few `struct` unpacks. Optimizing
|
|
136
|
+
that is a rounding error. All the speed is in I/O, and two things carry it. Both
|
|
137
|
+
are measured, not asserted (run `scigantic-headers-bench`).
|
|
138
|
+
|
|
139
|
+
**Read the header, not the file.** The read is exactly `HEADER_BYTES` (1 KiB),
|
|
140
|
+
so its cost is independent of file size and a multi-GB movie decodes as fast as
|
|
141
|
+
a small one.
|
|
142
|
+
|
|
143
|
+
header read + decode (1 KiB): 15 us
|
|
144
|
+
full-file read + decode (200 MB): 22.7 ms (about 1,500x cheaper)
|
|
145
|
+
|
|
146
|
+
**Parallelize the I/O, bounded.** Header reads across files are independent and
|
|
147
|
+
I/O-bound, so a thread pool overlaps their latency (the read releases the GIL).
|
|
148
|
+
Measured over 8 real EMPIAR headers pulled by HTTP Range:
|
|
149
|
+
|
|
150
|
+
serial (1 worker): 25.7 s
|
|
151
|
+
parallel (8 workers): 0.7 s (about 35x)
|
|
152
|
+
|
|
153
|
+
That 35x is large because EBI's per-request latency is about 3 s, so overlapping
|
|
154
|
+
eight requests recovers a lot of dead wait. On low-latency local storage the
|
|
155
|
+
speedup is smaller. The win scales with per-read latency, which is why the pool
|
|
156
|
+
is bounded and tunable (default 8, the EMPIAR sweet spot; past a point more
|
|
157
|
+
connections hit server throttling or disk-queue thrash and get slower).
|
|
158
|
+
|
|
159
|
+
Two more, by construction:
|
|
160
|
+
|
|
161
|
+
- **Filter before I/O.** `has_decoder_for` is a cheap extension check. The batch
|
|
162
|
+
and walk paths use it to skip a file before any syscall.
|
|
163
|
+
- **Zero deps, fast import.** The dtype table is a plain dict, not numpy. There
|
|
164
|
+
is nothing to install and nothing to import, and it runs air-gapped.
|
|
165
|
+
|
|
166
|
+
The biggest system-level lever lives in the caller, not here: decode once when a
|
|
167
|
+
file lands, write the context record, and answer every later query from the tiny
|
|
168
|
+
record without re-decoding. The edge context-producer and the cloud probe both
|
|
169
|
+
do this.
|
|
170
|
+
|
|
171
|
+
## Use
|
|
172
|
+
|
|
173
|
+
from scigantic_headers import decode_file, decode_paths, decode_urls, iter_decodable_files
|
|
174
|
+
|
|
175
|
+
decode_file("s/frame.mrc") # one local file
|
|
176
|
+
decode_paths(iter_decodable_files("/mnt/sessions")) # a tree, parallel
|
|
177
|
+
decode_urls([...], workers=8) # remote, by Range
|
|
178
|
+
|
|
179
|
+
# CLI
|
|
180
|
+
scigantic-headers session/frame.mrc
|
|
181
|
+
scigantic-headers https://ftp.ebi.ac.uk/empiar/.../img.mrcs
|
|
182
|
+
scigantic-headers --dir /mnt/flashblade/sessions --workers 8
|
|
183
|
+
|
|
184
|
+
pytest
|
|
185
|
+
scigantic-headers-bench --empiar # reproduce the numbers above
|
|
186
|
+
|
|
187
|
+
## Limitations
|
|
188
|
+
|
|
189
|
+
- **Pointer-based formats are not supported.** HDF5 and TIFF store their layout
|
|
190
|
+
behind internal pointers, so reading the header or footer alone is not enough.
|
|
191
|
+
Parquet (a footer) is supported; formats that need to chase offsets through the
|
|
192
|
+
file are not. Plain gzip is handled (the reader inflates the leading block),
|
|
193
|
+
but BAM's BGZF framing and per-record structure are not.
|
|
194
|
+
- **Output is not a standard.** The fields are an ad-hoc dict, not an
|
|
195
|
+
interchange format such as Allotrope ASM. Map the dict to a standard if you
|
|
196
|
+
need one.
|
|
197
|
+
|
|
198
|
+
## Repository
|
|
199
|
+
|
|
200
|
+
This is the standalone home of the library. It is also vendored into the
|
|
201
|
+
Scigantic monorepo, where the paths above (`backend/...`, `infrastructure/...`)
|
|
202
|
+
point at the TypeScript twin, the notebook, and the edge context-producer that
|
|
203
|
+
use it.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# scigantic-headers
|
|
2
|
+
|
|
3
|
+
Read the metadata of a scientific file and return its fields (dimensions, data
|
|
4
|
+
type, pixel size, columns, row count) as a dict, without reading the rest of the
|
|
5
|
+
file. Decodes MRC, NPY, NIfTI, CryoSPARC `.cs`, and Parquet. The decode
|
|
6
|
+
functions have no dependencies; separate reader functions fetch the bytes from a
|
|
7
|
+
file or a URL (the leading bytes for most formats, the trailing bytes for
|
|
8
|
+
Parquet, whose schema is in a footer).
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from scigantic_headers import decode_file
|
|
12
|
+
hdr = decode_file("session/frame.mrc")
|
|
13
|
+
hdr.summary # "MRC stack 4096x4096x16, float32"
|
|
14
|
+
hdr.fields["dtype"] # "float32"
|
|
15
|
+
hdr.fields["nz"] # 16 (frames)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
One implementation, shared. The cloud probe's TypeScript twin
|
|
19
|
+
(`backend/src/services/headerDecoders.ts`), the notebook, and the edge
|
|
20
|
+
context-producer describe a file the same way. Same byte offsets, same
|
|
21
|
+
`mode -> dtype` table, held to the same test fixtures so they cannot drift.
|
|
22
|
+
|
|
23
|
+
Edge use: the context-producer (`infrastructure/edge/context-producer`) runs
|
|
24
|
+
this next to an instrument, with no network, on amd64 or arm64. The zero
|
|
25
|
+
dependencies and header-only reads are what let it run there.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
Published to PyPI on a `scigantic-headers-vX.Y.Z` tag (see
|
|
30
|
+
`.github/workflows/scigantic-headers.yml`):
|
|
31
|
+
|
|
32
|
+
pip install scigantic-headers
|
|
33
|
+
|
|
34
|
+
It has zero runtime dependencies, so it also installs from source into an
|
|
35
|
+
air-gapped image with no package index at build time. The notebook and
|
|
36
|
+
edge-producer images bundle it this way:
|
|
37
|
+
|
|
38
|
+
COPY infrastructure/python/scigantic-headers /tmp/scigantic-headers
|
|
39
|
+
RUN pip install /tmp/scigantic-headers
|
|
40
|
+
|
|
41
|
+
Both paths install the same version pinned in `pyproject.toml`. Consumers that
|
|
42
|
+
install from an index should pin it (`scigantic-headers==X.Y.Z`).
|
|
43
|
+
|
|
44
|
+
## Why it exists
|
|
45
|
+
|
|
46
|
+
A schema card or context record wants to say what a file is without opening it
|
|
47
|
+
over a FUSE mount. A scientific file's first ~1 KiB is a structured header that
|
|
48
|
+
already says so. This reads that, and nothing more.
|
|
49
|
+
|
|
50
|
+
Decoders today:
|
|
51
|
+
|
|
52
|
+
- **MRC / MRCS**: cryo-EM micrographs, movie stacks, tilt series, EMDB maps.
|
|
53
|
+
- **NPY**: NumPy arrays (ML, genomics, materials, not cryo-EM). Cross-checked
|
|
54
|
+
against numpy.
|
|
55
|
+
- **NIfTI-1**: neuroimaging volumes, `.nii` and `.nii.gz` (not cryo-EM).
|
|
56
|
+
Cross-checked against nibabel. Big- and little-endian.
|
|
57
|
+
- **CryoSPARC `.cs`**: a structured-array dataset. Reports record count and the
|
|
58
|
+
field schema.
|
|
59
|
+
- **Parquet**: reads the footer, not a leading header, and returns the column
|
|
60
|
+
names, physical types, and row count. Cross-checked against pyarrow.
|
|
61
|
+
|
|
62
|
+
`.gz` is transparent. The reader decompresses just the leading block, so a
|
|
63
|
+
gzipped `.nii.gz` or `.mrc.gz` decodes without inflating the whole file, and
|
|
64
|
+
dispatch sees the inner format.
|
|
65
|
+
|
|
66
|
+
Adding a format is a pure `bytes -> DecodedHeader | None` function plus one
|
|
67
|
+
`register_decoder` call. Nothing about the dispatch, the readers, or the batch
|
|
68
|
+
path is cryo-EM-specific. NPY and NIfTI landed with zero plumbing changes.
|
|
69
|
+
|
|
70
|
+
## Pixel size for raw movies
|
|
71
|
+
|
|
72
|
+
A raw MRC movie header has no pixel size: CELLA is 0, so `decode_*` returns
|
|
73
|
+
`pixelSizeA` as `None`. The value is in the RELION STAR or CryoSPARC `.cs` file
|
|
74
|
+
the workflow writes in the session, and the library reads it. `read_session_optics`
|
|
75
|
+
finds that file next to (or above) the data file and returns the optics:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from scigantic_headers import decode_file, read_session_optics
|
|
79
|
+
|
|
80
|
+
hdr = decode_file("Movies/frame.mrc") # header fields; pixelSizeA is None
|
|
81
|
+
opt = read_session_optics("Movies/frame.mrc")
|
|
82
|
+
# {'pixelSizeA': 1.05, 'voltageKv': 300.0, 'source': 'relion-star:particles.star'}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`read_star_optics(path)` and `read_cryosparc_optics(path)` read a specific optics
|
|
86
|
+
file when you already know which one. The RELION 3.1 `data_optics` loop, the
|
|
87
|
+
older key-value form, optics columns in the main data loop, and the legacy
|
|
88
|
+
detector-pixel-size / magnification pair are all handled.
|
|
89
|
+
|
|
90
|
+
## Layout
|
|
91
|
+
|
|
92
|
+
src/scigantic_headers/
|
|
93
|
+
decoders.py pure core: registry + MRC / NPY / NIfTI / .cs decoders
|
|
94
|
+
parquet.py Parquet footer decode (small Thrift-compact reader)
|
|
95
|
+
sources.py read leading bytes from file or URL; bounded parallel batch
|
|
96
|
+
star.py RELION STAR optics reader
|
|
97
|
+
cryosparc.py CryoSPARC .cs optics reader
|
|
98
|
+
optics.py read_session_optics: find a data file's optics file
|
|
99
|
+
cli.py scigantic-headers <file|url|--dir>
|
|
100
|
+
benchmark.py scigantic-headers-bench, measures the speed levers
|
|
101
|
+
tests/ pytest (117 tests, incl. fuzz + golden fixtures)
|
|
102
|
+
|
|
103
|
+
## Robustness
|
|
104
|
+
|
|
105
|
+
A decoder is handed the leading bytes of arbitrary files, so it must be total:
|
|
106
|
+
for any input it returns None or a valid, JSON-safe DecodedHeader. It never
|
|
107
|
+
raises and never emits NaN or inf. This is enforced, not assumed.
|
|
108
|
+
|
|
109
|
+
- **Fuzz** (`test_fuzz.py`): thousands of random and magic-seeded byte buffers
|
|
110
|
+
through every decoder. Each result must serialize with `allow_nan=False`.
|
|
111
|
+
- **Golden fixtures** (`fixtures/mrc-cases.json`): hex input to exact decode.
|
|
112
|
+
This is the single source of truth the three MRC decoders (this library, the
|
|
113
|
+
TypeScript twin, scigantic_empiar) are all held to, so none can silently
|
|
114
|
+
diverge.
|
|
115
|
+
- Non-finite floats (a garbage NIfTI pixdim, an inf MRC cell) sanitize to null.
|
|
116
|
+
|
|
117
|
+
## Speed
|
|
118
|
+
|
|
119
|
+
The decode is microseconds: a 1 KiB read and a few `struct` unpacks. Optimizing
|
|
120
|
+
that is a rounding error. All the speed is in I/O, and two things carry it. Both
|
|
121
|
+
are measured, not asserted (run `scigantic-headers-bench`).
|
|
122
|
+
|
|
123
|
+
**Read the header, not the file.** The read is exactly `HEADER_BYTES` (1 KiB),
|
|
124
|
+
so its cost is independent of file size and a multi-GB movie decodes as fast as
|
|
125
|
+
a small one.
|
|
126
|
+
|
|
127
|
+
header read + decode (1 KiB): 15 us
|
|
128
|
+
full-file read + decode (200 MB): 22.7 ms (about 1,500x cheaper)
|
|
129
|
+
|
|
130
|
+
**Parallelize the I/O, bounded.** Header reads across files are independent and
|
|
131
|
+
I/O-bound, so a thread pool overlaps their latency (the read releases the GIL).
|
|
132
|
+
Measured over 8 real EMPIAR headers pulled by HTTP Range:
|
|
133
|
+
|
|
134
|
+
serial (1 worker): 25.7 s
|
|
135
|
+
parallel (8 workers): 0.7 s (about 35x)
|
|
136
|
+
|
|
137
|
+
That 35x is large because EBI's per-request latency is about 3 s, so overlapping
|
|
138
|
+
eight requests recovers a lot of dead wait. On low-latency local storage the
|
|
139
|
+
speedup is smaller. The win scales with per-read latency, which is why the pool
|
|
140
|
+
is bounded and tunable (default 8, the EMPIAR sweet spot; past a point more
|
|
141
|
+
connections hit server throttling or disk-queue thrash and get slower).
|
|
142
|
+
|
|
143
|
+
Two more, by construction:
|
|
144
|
+
|
|
145
|
+
- **Filter before I/O.** `has_decoder_for` is a cheap extension check. The batch
|
|
146
|
+
and walk paths use it to skip a file before any syscall.
|
|
147
|
+
- **Zero deps, fast import.** The dtype table is a plain dict, not numpy. There
|
|
148
|
+
is nothing to install and nothing to import, and it runs air-gapped.
|
|
149
|
+
|
|
150
|
+
The biggest system-level lever lives in the caller, not here: decode once when a
|
|
151
|
+
file lands, write the context record, and answer every later query from the tiny
|
|
152
|
+
record without re-decoding. The edge context-producer and the cloud probe both
|
|
153
|
+
do this.
|
|
154
|
+
|
|
155
|
+
## Use
|
|
156
|
+
|
|
157
|
+
from scigantic_headers import decode_file, decode_paths, decode_urls, iter_decodable_files
|
|
158
|
+
|
|
159
|
+
decode_file("s/frame.mrc") # one local file
|
|
160
|
+
decode_paths(iter_decodable_files("/mnt/sessions")) # a tree, parallel
|
|
161
|
+
decode_urls([...], workers=8) # remote, by Range
|
|
162
|
+
|
|
163
|
+
# CLI
|
|
164
|
+
scigantic-headers session/frame.mrc
|
|
165
|
+
scigantic-headers https://ftp.ebi.ac.uk/empiar/.../img.mrcs
|
|
166
|
+
scigantic-headers --dir /mnt/flashblade/sessions --workers 8
|
|
167
|
+
|
|
168
|
+
pytest
|
|
169
|
+
scigantic-headers-bench --empiar # reproduce the numbers above
|
|
170
|
+
|
|
171
|
+
## Limitations
|
|
172
|
+
|
|
173
|
+
- **Pointer-based formats are not supported.** HDF5 and TIFF store their layout
|
|
174
|
+
behind internal pointers, so reading the header or footer alone is not enough.
|
|
175
|
+
Parquet (a footer) is supported; formats that need to chase offsets through the
|
|
176
|
+
file are not. Plain gzip is handled (the reader inflates the leading block),
|
|
177
|
+
but BAM's BGZF framing and per-record structure are not.
|
|
178
|
+
- **Output is not a standard.** The fields are an ad-hoc dict, not an
|
|
179
|
+
interchange format such as Allotrope ASM. Map the dict to a standard if you
|
|
180
|
+
need one.
|
|
181
|
+
|
|
182
|
+
## Repository
|
|
183
|
+
|
|
184
|
+
This is the standalone home of the library. It is also vendored into the
|
|
185
|
+
Scigantic monorepo, where the paths above (`backend/...`, `infrastructure/...`)
|
|
186
|
+
point at the TypeScript twin, the notebook, and the edge context-producer that
|
|
187
|
+
use it.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "scigantic-headers"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Decode scientific file headers (MRC cryo-EM, extensible) into typed context for agents — pure, zero-dependency, with bounded parallel readers."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Proprietary — Evaluation Use Only. See LICENSE." }
|
|
12
|
+
authors = [{ name = "Scigantic" }]
|
|
13
|
+
keywords = ["cryo-em", "mrc", "header", "schema-card", "instrument", "context", "edge"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"License :: Other/Proprietary License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
]
|
|
18
|
+
# Zero runtime dependencies is a feature: installs and runs on an air-gapped box.
|
|
19
|
+
dependencies = []
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = ["pytest>=7"]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
scigantic-headers = "scigantic_headers.cli:main"
|
|
26
|
+
scigantic-headers-bench = "scigantic_headers.benchmark:main"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""scigantic-headers, decode scientific file headers into typed context.
|
|
2
|
+
|
|
3
|
+
Bytes in, typed fields out. Pure, zero-dependency core (decoders) plus bounded
|
|
4
|
+
parallel I/O readers (sources). One implementation shared by the cloud probe's
|
|
5
|
+
twin, the notebook, and the edge box, so a file is described the same way
|
|
6
|
+
everywhere.
|
|
7
|
+
|
|
8
|
+
from scigantic_headers import decode_file, decode_paths
|
|
9
|
+
hdr = decode_file("session/frame.mrc")
|
|
10
|
+
print(hdr.summary) # "MRC stack 4096x4096x16, float32"
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .cryosparc import parse_cryosparc_optics, read_cryosparc_optics
|
|
14
|
+
from .optics import read_session_optics
|
|
15
|
+
from .parquet import decode_parquet
|
|
16
|
+
from .decoders import (
|
|
17
|
+
HEADER_BYTES,
|
|
18
|
+
DecodedHeader,
|
|
19
|
+
decode_bytes,
|
|
20
|
+
decode_cryosparc_header,
|
|
21
|
+
decode_mrc_header,
|
|
22
|
+
decode_nifti_header,
|
|
23
|
+
decode_npy_header,
|
|
24
|
+
extension_of,
|
|
25
|
+
has_decoder_for,
|
|
26
|
+
register_decoder,
|
|
27
|
+
)
|
|
28
|
+
from .sources import (
|
|
29
|
+
DEFAULT_WORKERS,
|
|
30
|
+
decode_file,
|
|
31
|
+
decode_paths,
|
|
32
|
+
decode_url,
|
|
33
|
+
decode_urls,
|
|
34
|
+
is_decodable,
|
|
35
|
+
iter_decodable_files,
|
|
36
|
+
read_leading_bytes,
|
|
37
|
+
read_leading_bytes_url,
|
|
38
|
+
)
|
|
39
|
+
from .star import parse_relion_optics, read_star_optics
|
|
40
|
+
|
|
41
|
+
__version__ = "0.1.0"
|
|
42
|
+
__all__ = [
|
|
43
|
+
"HEADER_BYTES", "DecodedHeader", "decode_bytes", "decode_mrc_header",
|
|
44
|
+
"decode_nifti_header", "decode_npy_header", "decode_cryosparc_header",
|
|
45
|
+
"parse_cryosparc_optics", "read_cryosparc_optics", "read_session_optics",
|
|
46
|
+
"decode_parquet",
|
|
47
|
+
"extension_of", "has_decoder_for", "register_decoder",
|
|
48
|
+
"DEFAULT_WORKERS", "decode_file", "decode_paths", "decode_url",
|
|
49
|
+
"decode_urls", "is_decodable", "iter_decodable_files", "read_leading_bytes",
|
|
50
|
+
"read_leading_bytes_url", "parse_relion_optics", "read_star_optics",
|
|
51
|
+
"__version__",
|
|
52
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Measure where the time actually goes, honestly, not by assertion.
|
|
2
|
+
|
|
3
|
+
Two things worth measuring, matching the two real speed levers:
|
|
4
|
+
|
|
5
|
+
1. Bounded read vs full read. Decoding reads HEADER_BYTES; it does not read
|
|
6
|
+
the file. This shows the header read is ~constant and tiny while a full
|
|
7
|
+
read scales with file size, the single biggest win, and the reason a
|
|
8
|
+
multi-GB movie costs the same to decode as a 1 KB one.
|
|
9
|
+
|
|
10
|
+
2. Serial vs bounded-parallel over many *remote* files. Header reads are
|
|
11
|
+
independent and I/O-bound, so a thread pool overlaps their latency. Run
|
|
12
|
+
against real EMPIAR URLs so the numbers are real network latency, not a
|
|
13
|
+
simulation, this reproduces the EMPIAR range-reader's finding at the
|
|
14
|
+
file-set level.
|
|
15
|
+
|
|
16
|
+
scigantic-headers-bench # local read-size bench (offline)
|
|
17
|
+
scigantic-headers-bench --empiar # + remote serial-vs-parallel (network)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import os
|
|
24
|
+
import struct
|
|
25
|
+
import tempfile
|
|
26
|
+
import time
|
|
27
|
+
|
|
28
|
+
from .decoders import decode_bytes
|
|
29
|
+
from .sources import DEFAULT_WORKERS, decode_file, decode_urls, read_leading_bytes
|
|
30
|
+
|
|
31
|
+
# A handful of real EMPIAR-10002 movie stacks (public, no auth). Header pulled
|
|
32
|
+
# via Range, nothing is downloaded.
|
|
33
|
+
_EMPIAR_BASE = "https://ftp.ebi.ac.uk/empiar/world_availability/10002/data"
|
|
34
|
+
_EMPIAR_FILES = [
|
|
35
|
+
"15_movie_gc.mrcs", "16_movie_gc.mrcs", "17_movie_gc.mrcs", "18_movie_gc.mrcs",
|
|
36
|
+
"20_movie_gc.mrcs", "21_movie_gc.mrcs", "22_movie_gc.mrcs", "23_movie_gc.mrcs",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _make_mrc_file(path: str, size_mb: int) -> None:
|
|
41
|
+
"""Write a valid-header MRC file of `size_mb` megabytes so we can time a full
|
|
42
|
+
read against a bounded read at a realistic size."""
|
|
43
|
+
buf = bytearray(1024)
|
|
44
|
+
struct.pack_into("<4i", buf, 0, 4096, 4096, 16, 2)
|
|
45
|
+
struct.pack_into("<i", buf, 28, 4096)
|
|
46
|
+
buf[208:212] = b"MAP "
|
|
47
|
+
with open(path, "wb") as fh:
|
|
48
|
+
fh.write(buf)
|
|
49
|
+
fh.write(b"\0" * (size_mb * 1024 * 1024))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _time(fn, repeat=5):
|
|
53
|
+
best = float("inf")
|
|
54
|
+
for _ in range(repeat):
|
|
55
|
+
t0 = time.perf_counter()
|
|
56
|
+
fn()
|
|
57
|
+
best = min(best, time.perf_counter() - t0)
|
|
58
|
+
return best
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def bench_read_size(size_mb: int = 200) -> None:
|
|
62
|
+
print(f"\n[1] bounded read vs full read ({size_mb} MB file)")
|
|
63
|
+
with tempfile.TemporaryDirectory() as d:
|
|
64
|
+
path = os.path.join(d, "big.mrc")
|
|
65
|
+
_make_mrc_file(path, size_mb)
|
|
66
|
+
|
|
67
|
+
def bounded():
|
|
68
|
+
decode_bytes(path, read_leading_bytes(path))
|
|
69
|
+
|
|
70
|
+
def full():
|
|
71
|
+
with open(path, "rb") as fh:
|
|
72
|
+
decode_bytes(path, fh.read())
|
|
73
|
+
|
|
74
|
+
tb = _time(bounded)
|
|
75
|
+
tf = _time(full)
|
|
76
|
+
print(f" header read + decode (1 KiB): {tb * 1e6:8.1f} us")
|
|
77
|
+
print(f" full file read + decode ({size_mb} MB): {tf * 1e3:8.1f} ms")
|
|
78
|
+
print(f" bounded read is ~{tf / tb:,.0f}x cheaper, and flat in file size")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def bench_parallel_remote(workers: int = DEFAULT_WORKERS) -> None:
|
|
82
|
+
urls = [f"{_EMPIAR_BASE}/{name}" for name in _EMPIAR_FILES]
|
|
83
|
+
print(f"\n[2] serial vs {workers}-way parallel over {len(urls)} real EMPIAR headers (network)")
|
|
84
|
+
t_serial = _time(lambda: decode_urls(urls, workers=1), repeat=1)
|
|
85
|
+
t_par = _time(lambda: decode_urls(urls, workers=workers), repeat=1)
|
|
86
|
+
got = len(decode_urls(urls, workers=workers))
|
|
87
|
+
print(f" serial (1 worker): {t_serial:6.2f} s")
|
|
88
|
+
print(f" parallel ({workers} workers): {t_par:6.2f} s")
|
|
89
|
+
if t_par > 0:
|
|
90
|
+
print(f" speedup: {t_serial / t_par:.1f}x ({got}/{len(urls)} decoded)")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main(argv=None) -> int:
|
|
94
|
+
ap = argparse.ArgumentParser(prog="scigantic-headers-bench", description=__doc__)
|
|
95
|
+
ap.add_argument("--size-mb", type=int, default=200, help="file size for the read-size bench")
|
|
96
|
+
ap.add_argument("--empiar", action="store_true", help="also run the remote parallel bench (needs network)")
|
|
97
|
+
ap.add_argument("--workers", type=int, default=DEFAULT_WORKERS)
|
|
98
|
+
args = ap.parse_args(argv)
|
|
99
|
+
|
|
100
|
+
bench_read_size(args.size_mb)
|
|
101
|
+
if args.empiar:
|
|
102
|
+
bench_parallel_remote(args.workers)
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""scigantic-headers, decode a file or URL header into typed fields.
|
|
2
|
+
|
|
3
|
+
scigantic-headers session/frame.mrc
|
|
4
|
+
scigantic-headers https://ftp.ebi.ac.uk/empiar/.../img.mrcs
|
|
5
|
+
scigantic-headers --dir /mnt/flashblade/sessions --workers 8
|
|
6
|
+
|
|
7
|
+
A URL is read with an HTTP Range request, so a header is pulled from a remote
|
|
8
|
+
archive without downloading the file.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from .decoders import has_decoder_for
|
|
18
|
+
from .sources import decode_file, decode_paths, decode_url, iter_decodable_files
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main(argv=None) -> int:
|
|
22
|
+
ap = argparse.ArgumentParser(prog="scigantic-headers", description=__doc__)
|
|
23
|
+
ap.add_argument("source", nargs="?", help="a file path or http(s) URL")
|
|
24
|
+
ap.add_argument("--dir", help="decode every recognized file under this directory")
|
|
25
|
+
ap.add_argument("--workers", type=int, default=8, help="parallel workers for --dir")
|
|
26
|
+
args = ap.parse_args(argv)
|
|
27
|
+
|
|
28
|
+
if args.dir:
|
|
29
|
+
results = decode_paths(iter_decodable_files(args.dir), workers=args.workers)
|
|
30
|
+
print(json.dumps(
|
|
31
|
+
{p: h.to_dict() for p, h in results.items()}, indent=2, default=str))
|
|
32
|
+
print(f"# {len(results)} decoded", file=sys.stderr)
|
|
33
|
+
return 0
|
|
34
|
+
|
|
35
|
+
if not args.source:
|
|
36
|
+
ap.error("give a file/URL, or --dir")
|
|
37
|
+
if not has_decoder_for(args.source):
|
|
38
|
+
print(f"no decoder for '{args.source}' (extension not recognized)", file=sys.stderr)
|
|
39
|
+
return 1
|
|
40
|
+
|
|
41
|
+
hdr = decode_url(args.source) if args.source.startswith(("http://", "https://")) else decode_file(args.source)
|
|
42
|
+
if hdr is None:
|
|
43
|
+
print(f"header did not decode for {args.source} (invalid or unreadable)", file=sys.stderr)
|
|
44
|
+
return 1
|
|
45
|
+
print(json.dumps({"source": args.source, **hdr.to_dict()}, indent=2, default=str))
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
raise SystemExit(main())
|