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.
Files changed (29) hide show
  1. scigantic_headers-0.1.0/LICENSE +4 -0
  2. scigantic_headers-0.1.0/PKG-INFO +203 -0
  3. scigantic_headers-0.1.0/README.md +187 -0
  4. scigantic_headers-0.1.0/pyproject.toml +32 -0
  5. scigantic_headers-0.1.0/setup.cfg +4 -0
  6. scigantic_headers-0.1.0/src/scigantic_headers/__init__.py +52 -0
  7. scigantic_headers-0.1.0/src/scigantic_headers/benchmark.py +107 -0
  8. scigantic_headers-0.1.0/src/scigantic_headers/cli.py +50 -0
  9. scigantic_headers-0.1.0/src/scigantic_headers/cryosparc.py +144 -0
  10. scigantic_headers-0.1.0/src/scigantic_headers/decoders.py +389 -0
  11. scigantic_headers-0.1.0/src/scigantic_headers/optics.py +48 -0
  12. scigantic_headers-0.1.0/src/scigantic_headers/parquet.py +184 -0
  13. scigantic_headers-0.1.0/src/scigantic_headers/sources.py +199 -0
  14. scigantic_headers-0.1.0/src/scigantic_headers/star.py +114 -0
  15. scigantic_headers-0.1.0/src/scigantic_headers.egg-info/PKG-INFO +203 -0
  16. scigantic_headers-0.1.0/src/scigantic_headers.egg-info/SOURCES.txt +27 -0
  17. scigantic_headers-0.1.0/src/scigantic_headers.egg-info/dependency_links.txt +1 -0
  18. scigantic_headers-0.1.0/src/scigantic_headers.egg-info/entry_points.txt +3 -0
  19. scigantic_headers-0.1.0/src/scigantic_headers.egg-info/requires.txt +3 -0
  20. scigantic_headers-0.1.0/src/scigantic_headers.egg-info/top_level.txt +1 -0
  21. scigantic_headers-0.1.0/tests/test_cryosparc.py +91 -0
  22. scigantic_headers-0.1.0/tests/test_decoders.py +145 -0
  23. scigantic_headers-0.1.0/tests/test_fixtures.py +34 -0
  24. scigantic_headers-0.1.0/tests/test_formats.py +135 -0
  25. scigantic_headers-0.1.0/tests/test_fuzz.py +99 -0
  26. scigantic_headers-0.1.0/tests/test_optics.py +68 -0
  27. scigantic_headers-0.1.0/tests/test_parquet.py +64 -0
  28. scigantic_headers-0.1.0/tests/test_sources.py +141 -0
  29. scigantic_headers-0.1.0/tests/test_star.py +121 -0
@@ -0,0 +1,4 @@
1
+ Proprietary — Evaluation Use Only.
2
+ Copyright (c) Scigantic. All rights reserved.
3
+ Provided for evaluation; not licensed for redistribution or production use
4
+ without a separate written agreement.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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())