quantion 0.1.6__py3-none-win_amd64.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.
quantion/_bridge.py ADDED
@@ -0,0 +1,595 @@
1
+ from __future__ import annotations
2
+
3
+ import ctypes
4
+ import struct
5
+ import os
6
+ import platform
7
+ import sys
8
+ from ctypes import (
9
+ POINTER,
10
+ c_double,
11
+ c_float,
12
+ c_int32,
13
+ c_size_t,
14
+ c_uint8,
15
+ c_uint32,
16
+ c_void_p,
17
+ )
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ import numpy as np
22
+
23
+
24
+ class _Buf(ctypes.Structure):
25
+ _fields_ = [("ptr", POINTER(c_uint8)), ("len", c_size_t)]
26
+
27
+
28
+ class PeakOptions(ctypes.Structure):
29
+ _fields_ = [
30
+ ("min_integral", c_double),
31
+ ("min_intensity", c_double),
32
+ ("min_peak_width_points", c_int32),
33
+ ("shape", c_int32),
34
+ ("noise", c_double),
35
+ ("auto_noise", c_int32),
36
+ ("auto_baseline", c_int32),
37
+ ("lambda_", c_int32),
38
+ ("max_iterations", c_int32),
39
+ ("allow_overlap", c_int32),
40
+ ("_pad2", c_int32),
41
+ ("min_snr", c_double),
42
+ ("min_r2", c_double),
43
+ ("kernel_size", c_int32),
44
+ ]
45
+
46
+
47
+ assert ctypes.sizeof(PeakOptions) == 80, (
48
+ f"PeakOptions is {ctypes.sizeof(PeakOptions)} bytes — expected 80"
49
+ )
50
+
51
+ QUANTION_ABI_VERSION = 1
52
+
53
+ _CODE_MSG = {
54
+ 1: "invalid arguments",
55
+ 2: "panic inside Rust",
56
+ 4: "parse error",
57
+ 5: "encode error",
58
+ 6: "fast EIC path unavailable: this .ion file has no usable spectrum bounds (A3); re-encode it with the current Ionic to use the fast EIC path",
59
+ }
60
+
61
+
62
+ def _check(name: str, code: int) -> None:
63
+ if code != 0:
64
+ msg = _CODE_MSG.get(code, f"unknown error code {code}")
65
+ raise RuntimeError(f"quantion/{name}: {msg} (code={code})")
66
+
67
+
68
+ def _check_with_message(name: str, code: int, abi: "_ABI", message: "_Buf") -> None:
69
+ if code != 0:
70
+ text = buf_to_str(abi, message) if message.len else _CODE_MSG.get(code, f"unknown error code {code}")
71
+ raise RuntimeError(f"quantion/{name}: {text} (code={code})")
72
+
73
+
74
+ READ_RANGE = ctypes.CFUNCTYPE(
75
+ c_int32, c_void_p, ctypes.c_uint64, ctypes.c_uint64, POINTER(c_uint8)
76
+ )
77
+
78
+
79
+ class _ABI:
80
+ _REQUIRED: dict[str, tuple[list, object]] = {
81
+ "quantion_abi_version": ([], ctypes.c_uint32),
82
+ "quantion_sizeof_peak_options": ([], c_size_t),
83
+ "parse_mzml": (
84
+ [POINTER(c_uint8), c_size_t, POINTER(c_void_p)], c_int32),
85
+ "parse_bin": ([POINTER(c_uint8), c_size_t, c_size_t, POINTER(c_void_p)], c_int32),
86
+ "parse_ion_path": ([ctypes.c_char_p, c_size_t, POINTER(c_void_p)], c_int32),
87
+ "parse_ion_source": ([READ_RANGE, c_void_p, c_size_t, POINTER(c_void_p)], c_int32),
88
+ "plan_open": ([POINTER(c_uint8), c_size_t, POINTER(_Buf)], c_int32),
89
+ "plan_eic": ([c_void_p, c_double, c_double, c_double, c_double, c_double,
90
+ POINTER(_Buf)], c_int32),
91
+ "free_mzml": ([c_void_p], None),
92
+ "free_": ([POINTER(c_uint8), c_size_t], None),
93
+ "bin_to_mzml": ([c_void_p, POINTER(_Buf)], c_int32),
94
+ "mzml_to_bin": ([c_void_p, POINTER(_Buf), c_uint8, c_uint8], c_int32),
95
+ "convert_mzml_file_to_ion_file": (
96
+ [ctypes.c_char_p, ctypes.c_char_p, c_uint8, c_uint8], c_int32),
97
+ "get_peak": ([POINTER(c_double), POINTER(c_double), c_size_t,
98
+ c_double, c_double, POINTER(PeakOptions), POINTER(_Buf)], c_int32),
99
+ "calculate_eic": ([c_void_p, c_double, c_double, c_double,
100
+ c_double, c_double, POINTER(_Buf), POINTER(_Buf)], c_int32),
101
+ "get_scans": ([c_void_p, c_uint8, c_double, c_double, c_uint8, POINTER(_Buf)], c_int32),
102
+ "get_peaks_from_eic": ([c_void_p,
103
+ POINTER(c_double), POINTER(c_double), POINTER(c_double),
104
+ POINTER(c_uint32), POINTER(c_uint32),
105
+ POINTER(c_uint8), c_size_t,
106
+ c_size_t, c_double, c_double,
107
+ POINTER(PeakOptions), c_size_t, POINTER(_Buf)], c_int32),
108
+ "get_peaks_from_chrom": ([c_void_p,
109
+ POINTER(c_uint32), POINTER(c_double), POINTER(c_double),
110
+ c_size_t, POINTER(PeakOptions), c_size_t, POINTER(_Buf)], c_int32),
111
+ "find_peaks": ([POINTER(c_double), POINTER(c_double), c_size_t,
112
+ POINTER(PeakOptions), POINTER(_Buf)], c_int32),
113
+ "fit_peak": ([POINTER(c_double), POINTER(c_double), c_size_t,
114
+ c_double, c_double, c_int32, POINTER(_Buf)], c_int32),
115
+ "draw_peak": ([POINTER(c_double), c_size_t, c_int32,
116
+ c_double, c_double, c_double, c_double, POINTER(_Buf)], c_int32),
117
+ "calculate_baseline": ([POINTER(c_double), c_size_t, c_int32, c_int32,
118
+ POINTER(_Buf)], c_int32),
119
+ "find_feature": ([c_void_p,
120
+ POINTER(c_double), POINTER(c_double), POINTER(c_double),
121
+ POINTER(c_uint32), POINTER(c_uint32),
122
+ POINTER(c_uint8), c_size_t,
123
+ c_size_t, c_size_t,
124
+ c_double, c_double, c_double, c_double,
125
+ POINTER(PeakOptions), POINTER(_Buf)], c_int32),
126
+ "find_features": ([c_void_p,
127
+ c_double, c_double, c_double, c_double,
128
+ c_double, c_double, c_double,
129
+ POINTER(PeakOptions), c_int32, POINTER(_Buf)], c_int32),
130
+ "get_features": ([ctypes.c_char_p,
131
+ c_double, c_double, c_double, c_double,
132
+ c_double, c_double, c_double,
133
+ c_double, c_double, c_double,
134
+ c_int32, POINTER(PeakOptions), c_int32, POINTER(_Buf)], c_int32),
135
+ "find_noise_level": ([POINTER(c_float), c_size_t,
136
+ POINTER(c_size_t), POINTER(c_double)], c_int32),
137
+ "model_load_path": ([POINTER(c_uint8), c_size_t, POINTER(c_void_p),
138
+ POINTER(_Buf)], c_int32),
139
+ "model_load_bytes": ([POINTER(c_uint8), c_size_t, POINTER(c_void_p),
140
+ POINTER(_Buf)], c_int32),
141
+ "model_free": ([c_void_p], None),
142
+ "model_metadata": ([c_void_p, POINTER(_Buf)], c_int32),
143
+ "warp_model_load": ([c_void_p, POINTER(c_void_p), POINTER(_Buf)], c_int32),
144
+ "warp_model_free": ([c_void_p], None),
145
+ "calibrate_rts": ([c_void_p, POINTER(c_void_p), c_size_t, POINTER(c_size_t),
146
+ POINTER(c_uint32), POINTER(c_uint32), POINTER(c_uint8), c_size_t,
147
+ POINTER(c_double), POINTER(c_double), c_size_t, POINTER(_Buf)], c_int32),
148
+ "predict_rts_from_file": ([c_void_p, c_void_p, POINTER(c_double), POINTER(c_double),
149
+ c_size_t, POINTER(_Buf)], c_int32),
150
+ "plan_predict_rts": ([c_void_p, c_void_p, POINTER(c_double), POINTER(c_double),
151
+ c_size_t, POINTER(_Buf)], c_int32),
152
+ }
153
+
154
+ _OPTIONAL: dict[str, tuple[list, object]] = {
155
+ "plan_scans": ([c_void_p, c_uint8, c_double, c_double, c_uint8,
156
+ POINTER(_Buf)], c_int32),
157
+ }
158
+
159
+ def __init__(self, lib: ctypes.CDLL) -> None:
160
+ for name, (argtypes, restype) in self._REQUIRED.items():
161
+ try:
162
+ fn = getattr(lib, name)
163
+ fn.argtypes = argtypes
164
+ fn.restype = restype
165
+ setattr(self, name, fn)
166
+ except AttributeError:
167
+ raise RuntimeError(
168
+ f"quantion: required symbol '{name}' not found in shared library"
169
+ )
170
+ for name, (argtypes, restype) in self._OPTIONAL.items():
171
+ try:
172
+ fn = getattr(lib, name)
173
+ fn.argtypes = argtypes
174
+ fn.restype = restype
175
+ setattr(self, name, fn)
176
+ except AttributeError:
177
+ setattr(self, name, None)
178
+
179
+
180
+ def _read_and_free(abi: _ABI, buf: _Buf) -> bytes:
181
+ try:
182
+ return ctypes.string_at(buf.ptr, buf.len)
183
+ finally:
184
+ if buf.ptr:
185
+ abi.free_(buf.ptr, buf.len)
186
+ buf.ptr = None
187
+ buf.len = 0
188
+
189
+
190
+ def buf_to_str(abi: _ABI, buf: _Buf) -> str:
191
+ return _read_and_free(abi, buf).decode("utf-8")
192
+
193
+
194
+ def buf_to_bytes(abi: _ABI, buf: _Buf) -> bytes:
195
+ return _read_and_free(abi, buf)
196
+
197
+
198
+ def buf_to_f64(abi: _ABI, buf: _Buf) -> np.ndarray:
199
+ raw = _read_and_free(abi, buf)
200
+ return np.frombuffer(raw, dtype=np.float64).copy()
201
+
202
+
203
+ BRIDGE_MAGIC = 0x42544E51
204
+ BRIDGE_LAYOUT_VERSION = 1
205
+ BRIDGE_HEADER_BYTES = 32
206
+ SECTION_ENTRY_BYTES = 24
207
+
208
+ PAYLOAD_SCANS = 1
209
+ PAYLOAD_ION_IMAGE = 2
210
+
211
+ ELEMENT_F64 = 1
212
+ ELEMENT_U32 = 2
213
+ ELEMENT_U64 = 3
214
+ ELEMENT_U8 = 4
215
+
216
+ SECTION_POINT_STARTS = 1
217
+ SECTION_MZ = 2
218
+ SECTION_INTENSITY = 3
219
+ SECTION_RT = 4
220
+ SECTION_RT_SECONDS = 5
221
+ SECTION_BASE_PEAK_MZ = 6
222
+ SECTION_SELECTED_ION_MZ = 7
223
+ SECTION_BASE_PEAK_INT = 8
224
+ SECTION_TOTAL_ION_CURRENT = 9
225
+ SECTION_MS_LEVEL = 10
226
+ SECTION_POLARITY = 11
227
+ SECTION_POSITION_X = 12
228
+ SECTION_POSITION_Y = 13
229
+ SECTION_POSITION_Z = 14
230
+ SECTION_IMAGE_SHAPE = 15
231
+ SECTION_IMAGE_DATA = 16
232
+ SECTION_IMAGE_COUNTS = 17
233
+
234
+ _ELEMENT_SIZES = {ELEMENT_F64: 8, ELEMENT_U32: 4, ELEMENT_U64: 8, ELEMENT_U8: 1}
235
+
236
+ _SECTION_TYPES = {
237
+ SECTION_POINT_STARTS: ELEMENT_U64,
238
+ SECTION_MZ: ELEMENT_F64,
239
+ SECTION_INTENSITY: ELEMENT_F64,
240
+ SECTION_RT: ELEMENT_F64,
241
+ SECTION_RT_SECONDS: ELEMENT_F64,
242
+ SECTION_BASE_PEAK_MZ: ELEMENT_F64,
243
+ SECTION_SELECTED_ION_MZ: ELEMENT_F64,
244
+ SECTION_BASE_PEAK_INT: ELEMENT_F64,
245
+ SECTION_TOTAL_ION_CURRENT: ELEMENT_F64,
246
+ SECTION_MS_LEVEL: ELEMENT_F64,
247
+ SECTION_POLARITY: ELEMENT_F64,
248
+ SECTION_POSITION_X: ELEMENT_F64,
249
+ SECTION_POSITION_Y: ELEMENT_F64,
250
+ SECTION_POSITION_Z: ELEMENT_F64,
251
+ SECTION_IMAGE_SHAPE: ELEMENT_U32,
252
+ SECTION_IMAGE_DATA: ELEMENT_F64,
253
+ SECTION_IMAGE_COUNTS: ELEMENT_U32,
254
+ }
255
+
256
+ _METADATA_SECTIONS = (
257
+ ("rt_seconds", SECTION_RT_SECONDS),
258
+ ("base_peak_mz", SECTION_BASE_PEAK_MZ),
259
+ ("selected_ion_mz", SECTION_SELECTED_ION_MZ),
260
+ ("base_peak_int", SECTION_BASE_PEAK_INT),
261
+ ("total_ion_current", SECTION_TOTAL_ION_CURRENT),
262
+ ("ms_level", SECTION_MS_LEVEL),
263
+ ("polarity", SECTION_POLARITY),
264
+ ("position_x", SECTION_POSITION_X),
265
+ ("position_y", SECTION_POSITION_Y),
266
+ ("position_z", SECTION_POSITION_Z),
267
+ )
268
+
269
+
270
+ class Bridge:
271
+ def __init__(self, raw: bytes, payload_kind: int, record_count: int, sections: dict):
272
+ self.raw = raw
273
+ self.payload_kind = payload_kind
274
+ self.record_count = record_count
275
+ self.sections = sections
276
+
277
+
278
+ def _fail(reason: str):
279
+ raise ValueError(f"quantion bridge: {reason}")
280
+
281
+
282
+ def read_bridge(raw: bytes) -> Bridge:
283
+ total = len(raw)
284
+ if total < BRIDGE_HEADER_BYTES:
285
+ _fail("buffer is shorter than the header")
286
+
287
+ magic, layout, kind, count, table_offset, total_bytes, records = struct.unpack_from(
288
+ "<IHHIIQQ", raw, 0
289
+ )
290
+ if magic != BRIDGE_MAGIC:
291
+ _fail("magic does not match")
292
+ if layout != BRIDGE_LAYOUT_VERSION:
293
+ _fail(f"layout version {layout} is not supported")
294
+ if total_bytes != total:
295
+ _fail("total bytes does not match the buffer")
296
+ if table_offset != BRIDGE_HEADER_BYTES:
297
+ _fail("section table does not start after the header")
298
+ if count * SECTION_ENTRY_BYTES > total - BRIDGE_HEADER_BYTES:
299
+ _fail("section table does not fit")
300
+
301
+ sections = {}
302
+ reach = BRIDGE_HEADER_BYTES + count * SECTION_ENTRY_BYTES
303
+ for index in range(count):
304
+ start = BRIDGE_HEADER_BYTES + index * SECTION_ENTRY_BYTES
305
+ section_id, element_type, offset, length = struct.unpack_from("<IIQQ", raw, start)
306
+ if section_id in sections:
307
+ _fail(f"section {section_id} appears twice")
308
+ if offset > total or length > total - offset:
309
+ _fail(f"section {section_id} runs past the end")
310
+ if offset % 8 != 0:
311
+ _fail(f"section {section_id} is not aligned to eight bytes")
312
+ if offset < reach:
313
+ _fail(f"section {section_id} overlaps the section before it")
314
+ size = _ELEMENT_SIZES.get(element_type)
315
+ if size is None:
316
+ _fail(f"section {section_id} has an unknown element type")
317
+ if length % size != 0:
318
+ _fail(f"section {section_id} is not a whole number of elements")
319
+ expected = _SECTION_TYPES.get(section_id)
320
+ if expected is not None and expected != element_type:
321
+ _fail(f"section {section_id} has the wrong element type")
322
+ reach = offset + length
323
+ sections[section_id] = (element_type, offset, length)
324
+
325
+ return Bridge(raw, kind, records, sections)
326
+
327
+
328
+ def expect_kind(bridge: Bridge, wanted: int) -> None:
329
+ if bridge.payload_kind != wanted:
330
+ _fail(f"expected payload kind {wanted} but found {bridge.payload_kind}")
331
+
332
+
333
+ def _take_section(bridge: Bridge, section_id: int):
334
+ section = bridge.sections.get(section_id)
335
+ if section is None:
336
+ _fail(f"section {section_id} is missing")
337
+ return section
338
+
339
+
340
+ def read_section(bridge: Bridge, section_id: int, dtype) -> np.ndarray:
341
+ _, offset, length = _take_section(bridge, section_id)
342
+ return np.frombuffer(bridge.raw, dtype=dtype, count=length // np.dtype(dtype).itemsize,
343
+ offset=offset)
344
+
345
+
346
+ def read_scans_bridge(raw: bytes) -> list:
347
+ bridge = read_bridge(raw)
348
+ expect_kind(bridge, PAYLOAD_SCANS)
349
+
350
+ point_starts = read_section(bridge, SECTION_POINT_STARTS, np.uint64)
351
+ mz = read_section(bridge, SECTION_MZ, np.float64)
352
+ intensity = read_section(bridge, SECTION_INTENSITY, np.float64)
353
+ rt = read_section(bridge, SECTION_RT, np.float64)
354
+
355
+ count = bridge.record_count
356
+ if len(point_starts) != count + 1:
357
+ _fail("point starts do not match the scan count")
358
+ if len(intensity) != len(mz):
359
+ _fail("intensity length does not match m/z length")
360
+ if int(point_starts[-1]) != len(mz):
361
+ _fail("point starts do not span the m/z section")
362
+ if np.any(np.diff(point_starts.astype(np.int64)) < 0):
363
+ _fail("point starts do not increase")
364
+
365
+ columns = {name: read_section(bridge, section_id, np.float64)
366
+ for name, section_id in _METADATA_SECTIONS}
367
+
368
+ scans = []
369
+ for index in range(count):
370
+ start = int(point_starts[index])
371
+ stop = int(point_starts[index + 1])
372
+ scans.append({
373
+ "rt": float(rt[index]),
374
+ "mz": mz[start:stop],
375
+ "intensity": intensity[start:stop],
376
+ "metadata": {name: float(column[index]) for name, column in columns.items()},
377
+ })
378
+ return scans
379
+
380
+
381
+ def buf_to_scans(abi: _ABI, buf: _Buf) -> list:
382
+ return read_scans_bridge(_read_and_free(abi, buf))
383
+
384
+
385
+ PAYLOAD_PEAKS = 3
386
+ PAYLOAD_FEATURES = 4
387
+ PAYLOAD_CHROM_PEAKS = 5
388
+ PAYLOAD_FIT_RESULT = 6
389
+ PAYLOAD_EIC_PEAKS = 7
390
+ PAYLOAD_CONSENSUS_FEATURES = 8
391
+ PAYLOAD_FOUND_FEATURES = 9
392
+ PAYLOAD_WARP_TARGETS = 10
393
+ PAYLOAD_MODEL_METADATA = 11
394
+
395
+ _NUMBER, _COUNT, _TEXT = "number", "count", "text"
396
+
397
+ _RECORD_COLUMNS = {
398
+ PAYLOAD_PEAKS: [
399
+ ("from", 18, _NUMBER), ("to", 19, _NUMBER), ("rt", 20, _NUMBER),
400
+ ("integral", 21, _NUMBER), ("intensity", 22, _NUMBER),
401
+ ("n_points", 23, _COUNT), ("noise", 24, _NUMBER), ("r2", 25, _NUMBER),
402
+ ],
403
+ PAYLOAD_FEATURES: [
404
+ ("mz", 26, _NUMBER), ("rt", 27, _NUMBER), ("from", 28, _NUMBER),
405
+ ("to", 29, _NUMBER), ("intensity", 30, _NUMBER), ("integral", 31, _NUMBER),
406
+ ("n_points", 32, _COUNT), ("noise", 33, _NUMBER),
407
+ ],
408
+ PAYLOAD_CHROM_PEAKS: [
409
+ ("index", 34, _COUNT), ("id", (42, 43), _TEXT), ("ort", 35, _NUMBER),
410
+ ("rt", 36, _NUMBER), ("from", 37, _NUMBER), ("to", 38, _NUMBER),
411
+ ("intensity", 39, _NUMBER), ("integral", 40, _NUMBER),
412
+ ("total_area", 41, _NUMBER), ("timestamp", (44, 45), _TEXT),
413
+ ],
414
+ PAYLOAD_FIT_RESULT: [
415
+ ("shape", 46, _NUMBER), ("height", 47, _NUMBER), ("center", 48, _NUMBER),
416
+ ("fwhm", 49, _NUMBER), ("tail", 50, _NUMBER), ("r2", 51, _NUMBER),
417
+ ],
418
+ PAYLOAD_EIC_PEAKS: [
419
+ ("id", (52, 53), _TEXT), ("mz", 54, _NUMBER), ("ort", 55, _NUMBER),
420
+ ("rt", 56, _NUMBER), ("from", 57, _NUMBER), ("to", 58, _NUMBER),
421
+ ("intensity", 59, _NUMBER), ("integral", 60, _NUMBER), ("noise", 61, _NUMBER),
422
+ ],
423
+ PAYLOAD_CONSENSUS_FEATURES: [
424
+ ("mz", 62, _NUMBER), ("rt", 63, _NUMBER), ("from", 64, _NUMBER),
425
+ ("to", 65, _NUMBER), ("intensity", 66, _NUMBER), ("integral", 67, _NUMBER),
426
+ ("frequency", 68, _NUMBER),
427
+ ],
428
+ PAYLOAD_FOUND_FEATURES: [
429
+ ("id", (69, 70), _TEXT), ("mz", 71, _NUMBER), ("rt", 72, _NUMBER),
430
+ ("from", 73, _NUMBER), ("to", 74, _NUMBER), ("intensity", 75, _NUMBER),
431
+ ("integral", 76, _NUMBER), ("n_points", 77, _COUNT), ("noise", 78, _NUMBER),
432
+ ],
433
+ PAYLOAD_WARP_TARGETS: [
434
+ ("id", (79, 80), _TEXT), ("mz", 81, _NUMBER), ("rt", 82, _NUMBER),
435
+ ],
436
+ PAYLOAD_MODEL_METADATA: [
437
+ ("key", (83, 84), _TEXT), ("value", (85, 86), _TEXT),
438
+ ],
439
+ }
440
+
441
+
442
+ def read_records(raw: bytes) -> list:
443
+ bridge = read_bridge(raw)
444
+ columns = _RECORD_COLUMNS.get(bridge.payload_kind)
445
+ if columns is None:
446
+ _fail(f"payload kind {bridge.payload_kind} is not a record table")
447
+
448
+ taken = []
449
+ for name, where, kind in columns:
450
+ if kind == _NUMBER:
451
+ taken.append((name, read_section(bridge, where, np.float64), None))
452
+ elif kind == _COUNT:
453
+ taken.append((name, read_section(bridge, where, np.uint32), None))
454
+ else:
455
+ starts = read_section(bridge, where[0], np.uint64)
456
+ _, offset, length = _take_section(bridge, where[1])
457
+ taken.append((name, starts, raw[offset:offset + length]))
458
+
459
+ rows = []
460
+ for index in range(bridge.record_count):
461
+ row = {}
462
+ for name, values, text_bytes in taken:
463
+ if text_bytes is None:
464
+ row[name] = values[index].item()
465
+ else:
466
+ row[name] = text_bytes[int(values[index]):int(values[index + 1])].decode("utf-8")
467
+ rows.append(row)
468
+ return rows
469
+
470
+
471
+ def buf_to_records(abi: _ABI, buf: _Buf) -> list:
472
+ return read_records(_read_and_free(abi, buf))
473
+
474
+
475
+ def _platform_dir() -> str:
476
+ machine = platform.machine().lower()
477
+ arm = machine in ("aarch64", "arm64")
478
+ if sys.platform == "linux":
479
+ return "linux-arm64" if arm else "linux-x86_64"
480
+ if sys.platform == "darwin":
481
+ return "macos-arm64" if arm else "macos-x86_64"
482
+ if sys.platform == "win32":
483
+ return "windows-arm64" if arm else "windows-x86_64"
484
+ raise RuntimeError(f"quantion: unsupported platform {sys.platform}/{machine}")
485
+
486
+
487
+ def _default_lib_name() -> str:
488
+ names = {
489
+ "linux": "libquantion.so",
490
+ "darwin": "libquantion.dylib",
491
+ "win32": "libquantion.dll",
492
+ }
493
+ return names.get(sys.platform, "libquantion.so")
494
+
495
+
496
+ def _is_package_root(directory: Path) -> bool:
497
+ manifest = directory / "pyproject.toml"
498
+ if not manifest.is_file():
499
+ return False
500
+ return 'name = "quantion"' in manifest.read_text(encoding="utf-8")
501
+
502
+
503
+ def _artifacts_root() -> Optional[Path]:
504
+ from_env = os.environ.get("QUANTION_ARTIFACTS_ROOT")
505
+ if from_env:
506
+ chosen = Path(from_env)
507
+ return chosen if chosen.is_dir() else None
508
+ for parent in Path(__file__).resolve().parents:
509
+ candidate = parent / "artifacts"
510
+ if candidate.is_dir():
511
+ return candidate
512
+ if _is_package_root(parent):
513
+ return None
514
+ return None
515
+
516
+
517
+ def _version_key(path: Path) -> tuple:
518
+ return tuple(int(part) if part.isdigit() else 0 for part in path.name.split("."))
519
+
520
+
521
+ def _own_version() -> str:
522
+ from quantion import __version__
523
+ return __version__
524
+
525
+
526
+ def _packaged_lib_path() -> Optional[Path]:
527
+ candidate = Path(__file__).resolve().parent / "native" / _platform_dir() / _default_lib_name()
528
+ return candidate if candidate.exists() else None
529
+
530
+
531
+ def _bundled_lib_path() -> Optional[Path]:
532
+ root = _artifacts_root()
533
+ if root is not None:
534
+ wanted = _platform_dir()
535
+ name = _default_lib_name()
536
+
537
+ direct = root / wanted / name
538
+ if direct.exists():
539
+ return direct
540
+
541
+ own = _own_version()
542
+ if own:
543
+ preferred = root / own / wanted / name
544
+ if preferred.exists():
545
+ return preferred
546
+
547
+ versions = sorted(
548
+ (p for p in root.iterdir()
549
+ if p.is_dir() and (p / wanted / name).exists() and (p / "manifest.json").exists()),
550
+ key=_version_key,
551
+ reverse=True,
552
+ )
553
+ if versions:
554
+ return versions[0] / wanted / name
555
+
556
+ return _packaged_lib_path()
557
+
558
+
559
+ def load_library(path: Optional[str] = None) -> tuple[ctypes.CDLL, _ABI]:
560
+ if path is None:
561
+ path = os.environ.get("QUANTION_LIB")
562
+ if path is None:
563
+ candidate = _bundled_lib_path()
564
+ if candidate is not None:
565
+ path = str(candidate)
566
+ if path is None:
567
+ raise FileNotFoundError(
568
+ f"quantion: no library for {_platform_dir()}. "
569
+ "Build one with 'make native', "
570
+ "or set QUANTION_LIB to a library file, "
571
+ "or QUANTION_ARTIFACTS_ROOT to the artifacts folder."
572
+ )
573
+ try:
574
+ lib = ctypes.CDLL(str(path))
575
+ except OSError as exc:
576
+ raise OSError(f"Failed to dlopen '{path}': {exc}") from exc
577
+ abi = _ABI(lib)
578
+ actual = abi.quantion_abi_version()
579
+ if actual != QUANTION_ABI_VERSION:
580
+ raise RuntimeError(
581
+ f"quantion ABI version mismatch: library={actual}, "
582
+ f"wrapper={QUANTION_ABI_VERSION}. Rebuild or update the package."
583
+ )
584
+ # Defence-in-depth: verify the PeakOptions struct layout matches the binary.
585
+ # If the Rust struct ever changes without bumping the ABI version, this
586
+ # catches it at import time instead of silently corrupting peak detection.
587
+ expected = ctypes.sizeof(PeakOptions)
588
+ actual_size = abi.quantion_sizeof_peak_options()
589
+ if actual_size != expected:
590
+ raise RuntimeError(
591
+ f"quantion PeakOptions size mismatch: library={actual_size} bytes, "
592
+ f"wrapper={expected} bytes. The native binary and Python wrapper "
593
+ f"are out of sync — reinstall quantion."
594
+ )
595
+ return lib, abi
quantion/_pack.py ADDED
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
5
+
6
+ import numpy as np
7
+
8
+ from quantion._bridge import PeakOptions
9
+
10
+ SHAPE_CODES = {"gaussian": 0, "emg": 1}
11
+ DEFAULT_SHAPE_CODE = 1
12
+
13
+
14
+ def pack_peak_options(opts: Optional[Dict[str, Any]]) -> Optional[PeakOptions]:
15
+ if not opts:
16
+ return None
17
+
18
+ def _get_float(key: str) -> float:
19
+ value = opts.get(key)
20
+ if value is None:
21
+ return math.nan
22
+ parsed = float(value)
23
+ return parsed if math.isfinite(parsed) else math.nan
24
+
25
+ def _get_int(key: str) -> int:
26
+ value = opts.get(key)
27
+ if value is None:
28
+ return 0
29
+ if isinstance(value, bool):
30
+ return int(value)
31
+ try:
32
+ parsed = float(value)
33
+ return int(parsed) if math.isfinite(parsed) else 0
34
+ except (TypeError, ValueError):
35
+ return 0
36
+
37
+ def _get_bool(key: str) -> int:
38
+ value = opts.get(key)
39
+ if value is None:
40
+ return 0
41
+ if isinstance(value, bool):
42
+ return int(value)
43
+ return 1 if value else 0
44
+
45
+ def _get_shape(key: str) -> int:
46
+ value = opts.get(key)
47
+ if value is None:
48
+ return DEFAULT_SHAPE_CODE
49
+ if isinstance(value, str):
50
+ return SHAPE_CODES.get(value.strip().lower(), DEFAULT_SHAPE_CODE)
51
+ return _get_int(key)
52
+
53
+ options_struct = PeakOptions()
54
+ options_struct.min_integral = _get_float("min_integral")
55
+ options_struct.min_intensity = _get_float("min_intensity")
56
+ options_struct.min_peak_width_points = _get_int("min_peak_width_points")
57
+ options_struct.shape = _get_shape("shape")
58
+ options_struct.noise = _get_float("noise")
59
+ options_struct.auto_noise = _get_bool("auto_noise")
60
+ options_struct.auto_baseline = _get_bool("auto_baseline")
61
+ options_struct.lambda_ = _get_int("lambda_")
62
+ options_struct.max_iterations = _get_int("max_iterations")
63
+ options_struct.allow_overlap = _get_bool("allow_overlap")
64
+ options_struct._pad2 = 0
65
+ options_struct.min_snr = _get_float("min_snr")
66
+ options_struct.min_r2 = _get_float("min_r2")
67
+ options_struct.kernel_size = _get_int("kernel_size")
68
+ return options_struct
69
+
70
+
71
+ def encode_target_ids(
72
+ ids: Sequence[str],
73
+ ) -> Tuple[np.ndarray, np.ndarray, bytes]:
74
+ n = len(ids)
75
+ offsets = np.zeros(n, dtype=np.uint32)
76
+ lengths = np.zeros(n, dtype=np.uint32)
77
+ parts: List[bytes] = []
78
+ cursor = 0
79
+ for i, s in enumerate(ids):
80
+ b = (s or "").encode("utf-8")
81
+ offsets[i] = cursor
82
+ lengths[i] = len(b)
83
+ parts.append(b)
84
+ cursor += len(b)
85
+ return offsets, lengths, b"".join(parts)
86
+
87
+
88
+ def unpack_targets(
89
+ targets: Sequence[Dict[str, Any]],
90
+ default_range: float = 0.0,
91
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, List[str]]:
92
+ n = len(targets)
93
+ rts = np.empty(n, dtype=np.float64)
94
+ mzs = np.empty(n, dtype=np.float64)
95
+ ranges = np.empty(n, dtype=np.float64)
96
+ ids: List[str] = []
97
+
98
+ for i, t in enumerate(targets):
99
+ rts[i] = float(t.get("rt", t.get("retention_time", 0.0)))
100
+ mzs[i] = float(t.get("mz", t.get("mass", 0.0)))
101
+ ranges[i] = float(t.get("range", t.get("window", default_range)))
102
+ ids.append(str(t.get("id", t.get("name", ""))))
103
+
104
+ return rts, mzs, ranges, ids