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/__init__.py +156 -0
- quantion/_bridge.py +595 -0
- quantion/_pack.py +104 -0
- quantion/_shared.py +8 -0
- quantion/_types.py +55 -0
- quantion/api.py +1218 -0
- quantion/file.py +45 -0
- quantion/model.py +56 -0
- quantion/native/windows-x86_64/libquantion.dll +0 -0
- quantion/warp_model.py +74 -0
- quantion-0.1.6.dist-info/METADATA +298 -0
- quantion-0.1.6.dist-info/RECORD +14 -0
- quantion-0.1.6.dist-info/WHEEL +5 -0
- quantion-0.1.6.dist-info/top_level.txt +1 -0
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
|