Coverage for src/evutils/io/_native_core.py: 84%
228 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-18 05:24 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-18 05:24 +0000
1"""Low-level ctypes binding and common structs for the native library.
2"""
3from __future__ import annotations
5import ctypes
6import os
7import sys
8from ctypes import (
9 POINTER, Structure, byref, cast as c_cast, c_char_p, c_char,
10 c_int, c_size_t, c_uint8, c_uint16, c_uint64, c_void_p,
11)
12from pathlib import Path
13from typing import cast, Callable
15import numpy as np
16from ..types import EventArray, TriggerArray
18__all__ = [
19 "NativeError", "lib", "register_bindings",
20 "EventBufferSOA", "TriggerBufferSOA",
21 "ParserResult", "EventSoABuffers", "TriggerSoABuffers",
22 "EVENT_DTYPE", "TRIGGER_DTYPE",
23 "events_view", "triggers_view",
24 "parse_step", "decode_all_soa", "_handle_parse_warning",
25 "EVUTILS_PARSE_OK", "EVUTILS_PARSE_INPUT_EMPTY", "EVUTILS_PARSE_OUTPUT_FULL",
26 "EVUTILS_PARSE_ERROR", "EVUTILS_PARSE_INCOMPLETE", "EVUTILS_PARSE_WINDOW_DONE", "EVUTILS_PARSE_WARNING",
27 "_T_DTYPE", "_X_DTYPE", "_Y_DTYPE", "_P_DTYPE", "_ID_DTYPE"
28]
30class NativeError(RuntimeError):
31 pass
33EVENT_DTYPE = np.dtype({"names": ["t", "x", "y", "p"], "formats": ["<u4", "<u2", "<u2", "u1"], "offsets": [0, 4, 6, 8], "itemsize": 12})
34TRIGGER_DTYPE = np.dtype({"names": ["t", "id", "p"], "formats": ["<u4", "u1", "u1"], "offsets": [0, 4, 5], "itemsize": 8})
36_T_DTYPE = np.uint64
37_X_DTYPE = np.uint16
38_Y_DTYPE = np.uint16
39_P_DTYPE = np.uint8
40_ID_DTYPE = np.uint8
42class EventBufferSOA(Structure):
43 _fields_ = [
44 ("t", POINTER(c_uint64)), ("x", POINTER(c_uint16)),
45 ("y", POINTER(c_uint16)), ("p", POINTER(c_uint8)),
46 ("capacity", c_size_t), ("size", c_size_t),
47 ]
49class TriggerBufferSOA(Structure):
50 _fields_ = [
51 ("t", POINTER(c_uint64)), ("id", POINTER(c_uint8)), ("p", POINTER(c_uint8)),
52 ("capacity", c_size_t), ("size", c_size_t),
53 ]
55EVUTILS_PARSE_OK = 0
56EVUTILS_PARSE_INPUT_EMPTY = 1
57EVUTILS_PARSE_OUTPUT_FULL = 2
58EVUTILS_PARSE_ERROR = 3
59EVUTILS_PARSE_INCOMPLETE = 4
60EVUTILS_PARSE_WINDOW_DONE = 5
61EVUTILS_PARSE_WARNING = 6
63class ParserResult(Structure):
64 _fields_ = [("current", POINTER(c_uint16)), ("status", c_int)]
66def _candidate_filenames() -> list[str]:
67 base = "evutils_native"
68 if sys.platform.startswith("win"): return [f"{base}.dll", f"lib{base}.dll"]
69 if sys.platform == "darwin": return [f"lib{base}.dylib", f"{base}.dylib"]
70 return [f"lib{base}.so", f"{base}.so"]
72def _search_roots() -> list[Path]:
73 here = Path(__file__).resolve().parent
74 roots = [here]
75 for parent in (here, *here.parents[:5]):
76 if (parent / "pyproject.toml").exists():
77 roots.append(parent / "build")
78 break
79 roots.append(Path.cwd() / "build")
80 return roots
82def _find_library() -> str:
83 override = os.environ.get("EVUTILS_NATIVE_LIB")
84 if override: return override
85 names = set(_candidate_filenames())
86 for root in _search_roots():
87 if not root.exists(): continue
88 for name in names:
89 p = root / name
90 if p.is_file(): return str(p)
91 for p in root.glob("**/*evutils_native*"):
92 if p.is_file() and p.name in names: return str(p)
93 raise NativeError("Could not find the evutils native library.")
95_BINDINGS: list[Callable[[ctypes.CDLL], None]] = []
97def register_bindings(binder: Callable[[ctypes.CDLL], None]) -> None:
98 _BINDINGS.append(binder)
99 # If already loaded, bind immediately
100 if _LIB is not None:
101 binder(_LIB)
103def _bind(handle: ctypes.CDLL) -> ctypes.CDLL:
104 handle.evutils_version.argtypes = []
105 handle.evutils_version.restype = c_char_p
106 for binder in _BINDINGS:
107 binder(handle)
108 return handle
110_LIB: ctypes.CDLL | None = None
112def lib() -> ctypes.CDLL:
113 global _LIB
114 if _LIB is None:
115 try:
116 handle = ctypes.CDLL(_find_library())
117 except OSError as exc:
118 raise NativeError(f"Failed to load evutils native library: {exc}") from exc
119 _LIB = _bind(handle)
120 return _LIB
122class EventSoABuffers:
123 __slots__ = ("capacity", "t", "x", "y", "p", "c")
124 def __init__(self, capacity: int):
125 self.capacity = int(capacity)
126 self.t = np.empty(self.capacity, dtype=_T_DTYPE)
127 self.x = np.empty(self.capacity, dtype=_X_DTYPE)
128 self.y = np.empty(self.capacity, dtype=_Y_DTYPE)
129 self.p = np.empty(self.capacity, dtype=_P_DTYPE)
130 self.c = EventBufferSOA()
131 self.c.t = self.t.ctypes.data_as(POINTER(c_uint64))
132 self.c.x = self.x.ctypes.data_as(POINTER(c_uint16))
133 self.c.y = self.y.ctypes.data_as(POINTER(c_uint16))
134 self.c.p = self.p.ctypes.data_as(POINTER(c_uint8))
135 self.c.capacity = self.capacity
136 self.c.size = 0
137 @property
138 def size(self) -> int: return int(self.c.size)
139 def reset(self) -> None: self.c.size = 0
140 def grow(self, new_capacity: int) -> None:
141 new_capacity = int(new_capacity)
142 if new_capacity <= self.capacity: return
143 n = self.size
144 for name, dtype, field, ptr in (
145 ("t", _T_DTYPE, "t", c_uint64), ("x", _X_DTYPE, "x", c_uint16),
146 ("y", _Y_DTYPE, "y", c_uint16), ("p", _P_DTYPE, "p", c_uint8),
147 ):
148 grown = np.empty(new_capacity, dtype=dtype)
149 grown[:n] = getattr(self, name)[:n]
150 setattr(self, name, grown)
151 setattr(self.c, field, grown.ctypes.data_as(POINTER(ptr)))
152 self.capacity = new_capacity
153 self.c.capacity = new_capacity
154 def view(self) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
155 n = self.size
156 return self.t[:n], self.x[:n], self.y[:n], self.p[:n]
158class TriggerSoABuffers:
159 __slots__ = ("capacity", "t", "id", "p", "c")
160 def __init__(self, capacity: int):
161 self.capacity = int(capacity)
162 self.t = np.empty(self.capacity, dtype=_T_DTYPE)
163 self.id = np.empty(self.capacity, dtype=_ID_DTYPE)
164 self.p = np.empty(self.capacity, dtype=_P_DTYPE)
165 self.c = TriggerBufferSOA()
166 self.c.t = self.t.ctypes.data_as(POINTER(c_uint64))
167 self.c.id = self.id.ctypes.data_as(POINTER(c_uint8))
168 self.c.p = self.p.ctypes.data_as(POINTER(c_uint8))
169 self.c.capacity = self.capacity
170 self.c.size = 0
171 @property
172 def size(self) -> int: return int(self.c.size)
173 def reset(self) -> None: self.c.size = 0
174 def grow(self, new_capacity: int) -> None:
175 new_capacity = int(new_capacity)
176 if new_capacity <= self.capacity: return
177 n = self.size
178 for name, dtype, ptr in (
179 ("t", _T_DTYPE, c_uint64), ("id", _ID_DTYPE, c_uint8), ("p", _P_DTYPE, c_uint8),
180 ):
181 grown = np.empty(new_capacity, dtype=dtype)
182 grown[:n] = getattr(self, name)[:n]
183 setattr(self, name, grown)
184 setattr(self.c, name, grown.ctypes.data_as(POINTER(ptr)))
185 self.capacity = new_capacity
186 self.c.capacity = new_capacity
187 def view(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
188 n = self.size
189 return self.t[:n], self.id[:n], self.p[:n]
191def events_view(ev: EventSoABuffers) -> EventArray:
192 n = ev.size
193 return EventArray(ev.t[:n].view(np.int64), ev.x[:n], ev.y[:n], ev.p[:n])
195def triggers_view(tr: TriggerSoABuffers) -> TriggerArray:
196 n = tr.size
197 return TriggerArray(tr.t[:n].view(np.int64), tr.p[:n], tr.id[:n])
199def _handle_parse_warning(loc: "int | str", strict: bool, fmt: "str | None" = None) -> None:
200 """Raise (strict) or warn (default) on an ``EVUTILS_PARSE_WARNING`` -- a
201 malformed packet the parser skipped over.
203 Single source of truth for the strict-vs-warn decision, shared by every
204 parser binding (``parse_step``, ``decode_all_soa``, the EVT delta_t parser
205 and the CSV reader).
207 ``loc`` is the location descriptor appended to the message: an int word
208 offset (rendered as ``"near word N"``) for the binary parsers, or a ready
209 string (e.g. CSV's malformed-row count) for text formats. ``fmt`` prefixes
210 the format name and switches the strict exception to :class:`NativeError`
211 (the EVT decoder path); without it a bare ``RuntimeError`` is raised.
212 """
213 where = f"near word {loc}" if isinstance(loc, int) else str(loc)
214 if strict:
215 if fmt:
216 raise NativeError(f"{fmt} malformed packet {where} (strict mode)")
217 raise RuntimeError(f"malformed packet {where} (strict mode)")
218 import warnings
219 if fmt:
220 warnings.warn(f"{fmt} malformed packets ignored {where}")
221 else:
222 warnings.warn(f"Malformed packets ignored {where}")
225def parse_step(words: "np.ndarray", offset: int, make_input: "Callable", parser: "Callable", events: "EventArray", triggers: "TriggerArray", *, tail_pad: int = 0, word_dtype: "np.dtype | None" = None, strict: bool = False) -> tuple[int, int]:
226 n_words = len(words)
227 if offset >= n_words: return 0, n_words
228 before = events.size
229 inp = make_input(words[offset:])
230 while True:
231 res = parser.parse_chunk_soa(inp, events, triggers)
232 consumed = inp.consumed(res)
233 if res.status == EVUTILS_PARSE_WARNING:
234 _handle_parse_warning(offset + consumed, strict)
235 if consumed > 0:
236 offset += consumed
237 inp = make_input(words[offset:])
238 continue
239 elif res.status == EVUTILS_PARSE_ERROR:
240 raise RuntimeError(f"parse error near word {offset + consumed}")
241 break
242 if consumed == 0:
243 # Distinguish WHY nothing was consumed. A full output buffer means the
244 # input is intact and the caller must drain/grow and retry -- flushing
245 # the tail here would silently skip unread input.
246 if res.status == EVUTILS_PARSE_OUTPUT_FULL:
247 return events.size - before, offset
248 # Input truly drained mid-group: only the sub-padding tail remains.
249 if tail_pad and word_dtype is not None:
250 tail = words[offset:]
251 if len(tail):
252 scratch = np.zeros(len(tail) + tail_pad, dtype=word_dtype)
253 scratch[: len(tail)] = tail
254 parser.parse_chunk_soa(make_input(scratch), events, triggers)
255 return events.size - before, n_words
256 return events.size - before, offset + consumed
258def decode_all_soa(words: "np.ndarray", start_offset: int, make_input: "Callable", parser: "Callable", *, est_events_per_word: float = 1.0, tail_pad: int = 0, word_dtype: "np.dtype | None" = None, trigger_cap: int = 1 << 12, strict: bool = False) -> tuple[EventArray, int]:
259 n_words = len(words) if words is not None else 0
260 if n_words == 0 or start_offset >= n_words: return EventArray.empty(), n_words
261 remaining = n_words - start_offset
262 cap = int(remaining * est_events_per_word) + 1024
263 ev = EventSoABuffers(cap)
264 tr = TriggerSoABuffers(max(trigger_cap, 1))
265 offset = start_offset
266 while offset < n_words:
267 if ev.capacity - ev.size < 128:
268 # Extrapolate the true event count from the fraction of input
269 # consumed so far and grow straight to it (+15% slack). A too-low
270 # est_events_per_word therefore self-corrects in a *single* realloc
271 # instead of repeatedly growing by a constant factor -- dense EVT2.1
272 # vector streams emit up to 32 events per 64-bit word, which the old
273 # 1.5x-per-grow schedule reached only after ~7 full-buffer copies.
274 consumed_frac = (offset - start_offset) / remaining
275 if consumed_frac > 0.02:
276 projected = int(ev.size / consumed_frac * 1.15) + (1 << 16)
277 else:
278 projected = ev.capacity * 2 + (1 << 16)
279 ev.grow(max(projected, ev.capacity + (1 << 16)))
280 if tr.capacity - tr.size < 64: tr.grow(tr.capacity * 2 + 64)
281 inp = make_input(words[offset:])
282 while True:
283 res = parser.parse_chunk_soa(inp, ev, tr)
284 consumed = inp.consumed(res)
285 if res.status == EVUTILS_PARSE_WARNING:
286 _handle_parse_warning(offset + consumed, strict)
287 if consumed > 0:
288 offset += consumed
289 inp = make_input(words[offset:])
290 continue
291 elif res.status == EVUTILS_PARSE_ERROR:
292 raise RuntimeError(f"parse error near word {offset + consumed}")
293 break
294 if consumed == 0:
295 if tail_pad and word_dtype is not None:
296 tail = words[offset:]
297 if len(tail):
298 if ev.capacity - ev.size < len(tail) + 128: ev.grow(ev.size + len(tail) + (1 << 16))
299 scratch = np.zeros(len(tail) + tail_pad, dtype=word_dtype)
300 scratch[: len(tail)] = tail
301 parser.parse_chunk_soa(make_input(scratch), ev, tr)
302 offset = n_words
303 break
304 offset += consumed
305 n = ev.size
306 if n == 0: return EventArray.empty(), offset
307 out = EventArray(ev.t[:n].view(np.int64), ev.x[:n], ev.y[:n], ev.p[:n])
308 return out, offset