scigantic-headers 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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())
@@ -0,0 +1,144 @@
1
+ """Read acquisition optics from a CryoSPARC .cs metadata file.
2
+
3
+ A .cs file is an .npy with a structured dtype, one record per particle, with
4
+ fields like 'blob/psize_A' (pixel size), 'ctf/accel_kv' (voltage), 'ctf/cs_mm'
5
+ (spherical aberration). The header gives the schema; the values are in the data,
6
+ so this reads the first record and extracts the optics fields. Same job as the
7
+ RELION STAR reader (star.py), different container.
8
+
9
+ Records are a flat C-packed buffer, so a field's value is at a computed offset.
10
+ That holds as long as no field is an object dtype ('|O'), object fields make
11
+ .npy store a pickle instead of a flat buffer, so this bails (returns {}) in that
12
+ case rather than reading garbage. CryoSPARC stores paths as fixed-width strings,
13
+ not objects, so real .cs files parse.
14
+
15
+ Pure stdlib. Verified against numpy-written structured arrays (numpy is the
16
+ reference for the .npy format).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ import struct
23
+ from typing import Dict, Optional
24
+
25
+ from .decoders import npy_preamble
26
+
27
+ # Header + first record for typical CryoSPARC schemas (many fields, some
28
+ # fixed-string paths). Generous; the first record is right after the header.
29
+ CS_SCAN_BYTES = 64 * 1024
30
+
31
+ # CryoSPARC field name -> (output key, rounding). All are float32 in .cs.
32
+ _CS_OPTICS = {
33
+ "blob/psize_A": ("pixelSizeA", 4),
34
+ "ctf/accel_kv": ("voltageKv", 1),
35
+ "ctf/cs_mm": ("sphericalAberrationMm", 3),
36
+ }
37
+
38
+ _NUM_STRUCT = {
39
+ ("f", 2): "e", ("f", 4): "f", ("f", 8): "d",
40
+ ("i", 1): "b", ("i", 2): "h", ("i", 4): "i", ("i", 8): "q",
41
+ ("u", 1): "B", ("u", 2): "H", ("u", 4): "I", ("u", 8): "Q",
42
+ ("b", 1): "?",
43
+ }
44
+
45
+
46
+ def _base(ts: str) -> str:
47
+ return ts[1:] if ts[:1] in "<>|=" else ts
48
+
49
+
50
+ def _itemsize(ts) -> Optional[int]:
51
+ """Bytes one element of typestr `ts` occupies, or None if it is an object
52
+ dtype or otherwise not a flat fixed-size element."""
53
+ if not isinstance(ts, str):
54
+ return None
55
+ s = _base(ts)
56
+ if len(s) < 2 or not s[1:].isdigit():
57
+ return None
58
+ kind, size = s[0], int(s[1:])
59
+ if kind == "O":
60
+ return None # object -> pickled, not a flat buffer
61
+ if kind == "U":
62
+ return size * 4 # unicode: 4 bytes/char
63
+ if kind in ("S", "a", "V", "f", "i", "u", "c", "b"):
64
+ return size
65
+ return None
66
+
67
+
68
+ def _num_fmt(ts: str) -> Optional[str]:
69
+ s = _base(ts)
70
+ ch = _NUM_STRUCT.get((s[0], int(s[1:]))) if len(s) >= 2 and s[1:].isdigit() else None
71
+ if ch is None:
72
+ return None
73
+ return (">" if ts[:1] == ">" else "<") + ch
74
+
75
+
76
+ def _field_layout(descr):
77
+ """{name: (offset, typestr)} plus the record size, or None if any field is
78
+ object/unknown dtype (so the array is not a flat readable buffer)."""
79
+ offset = 0
80
+ layout = {}
81
+ for spec in descr:
82
+ try:
83
+ name, ts = spec[0], spec[1]
84
+ shape = spec[2] if len(spec) > 2 else ()
85
+ except (TypeError, IndexError):
86
+ return None
87
+ base = _itemsize(ts)
88
+ if base is None:
89
+ return None
90
+ count = 1
91
+ for d in (shape if isinstance(shape, (tuple, list)) else (shape,)):
92
+ count *= int(d)
93
+ layout[name] = (offset, ts)
94
+ offset += base * count
95
+ return layout, offset
96
+
97
+
98
+ def parse_cryosparc_optics(data: bytes) -> Dict[str, float]:
99
+ """Extract {pixelSizeA, voltageKv, sphericalAberrationMm} (those present)
100
+ from the first record of a .cs buffer. {} if not a flat structured array or
101
+ the fields aren't in the bytes we have."""
102
+ pre = npy_preamble(data)
103
+ if pre is None:
104
+ return {}
105
+ meta, data_off = pre
106
+ descr = meta.get("descr")
107
+ if not isinstance(descr, list):
108
+ return {}
109
+ lay = _field_layout(descr)
110
+ if lay is None:
111
+ return {}
112
+ layout, _rec_size = lay
113
+
114
+ out: Dict[str, float] = {}
115
+ for cs_name, (key, ndigits) in _CS_OPTICS.items():
116
+ if cs_name not in layout:
117
+ continue
118
+ foff, ts = layout[cs_name]
119
+ fmt = _num_fmt(ts)
120
+ size = _itemsize(ts)
121
+ if fmt is None or size is None:
122
+ continue
123
+ pos = data_off + foff
124
+ if pos + size > len(data):
125
+ continue # field lies past the bytes we read
126
+ try:
127
+ (val,) = struct.unpack_from(fmt, data, pos)
128
+ except struct.error:
129
+ continue
130
+ if isinstance(val, float) and not math.isfinite(val):
131
+ continue
132
+ out[key] = round(float(val), ndigits)
133
+ return out
134
+
135
+
136
+ def read_cryosparc_optics(path: str, max_bytes: int = CS_SCAN_BYTES) -> Dict[str, float]:
137
+ """Read a .cs file's leading bytes (header + first record) and parse its
138
+ optics. {} if unreadable or not a flat structured array."""
139
+ try:
140
+ with open(path, "rb") as fh:
141
+ data = fh.read(max_bytes)
142
+ except OSError:
143
+ return {}
144
+ return parse_cryosparc_optics(data)