Coverage for src/evutils/io/_native_core.py: 81%
191 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-15 09:33 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-15 09:33 +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, Any, 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",
25 "EVUTILS_PARSE_OK", "EVUTILS_PARSE_INPUT_EMPTY", "EVUTILS_PARSE_OUTPUT_FULL",
26 "EVUTILS_PARSE_ERROR", "EVUTILS_PARSE_INCOMPLETE",
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
61class ParserResult(Structure):
62 _fields_ = [("current", POINTER(c_uint16)), ("status", c_int)]
64def _candidate_filenames() -> list[str]:
65 base = "evutils_native"
66 if sys.platform.startswith("win"): return [f"{base}.dll", f"lib{base}.dll"]
67 if sys.platform == "darwin": return [f"lib{base}.dylib", f"{base}.dylib"]
68 return [f"lib{base}.so", f"{base}.so"]
70def _search_roots() -> list[Path]:
71 here = Path(__file__).resolve().parent
72 roots = [here]
73 for parent in (here, *here.parents[:5]):
74 if (parent / "pyproject.toml").exists():
75 roots.append(parent / "build")
76 break
77 roots.append(Path.cwd() / "build")
78 return roots
80def _find_library() -> str:
81 override = os.environ.get("EVUTILS_NATIVE_LIB")
82 if override: return override
83 names = set(_candidate_filenames())
84 for root in _search_roots():
85 if not root.exists(): continue
86 for name in names:
87 p = root / name
88 if p.is_file(): return str(p)
89 for p in root.glob("**/*evutils_native*"):
90 if p.is_file() and p.name in names: return str(p)
91 raise NativeError("Could not find the evutils native library.")
93_BINDINGS: list[Callable[[ctypes.CDLL], None]] = []
95def register_bindings(binder: Callable[[ctypes.CDLL], None]) -> None:
96 _BINDINGS.append(binder)
97 # If already loaded, bind immediately
98 if _LIB is not None:
99 binder(_LIB)
101def _bind(handle: ctypes.CDLL) -> ctypes.CDLL:
102 handle.evutils_version.argtypes = []
103 handle.evutils_version.restype = c_char_p
104 for binder in _BINDINGS:
105 binder(handle)
106 return handle
108_LIB: ctypes.CDLL | None = None
110def lib() -> ctypes.CDLL:
111 global _LIB
112 if _LIB is None:
113 try:
114 handle = ctypes.CDLL(_find_library())
115 except OSError as exc:
116 raise NativeError(f"Failed to load evutils native library: {exc}") from exc
117 _LIB = _bind(handle)
118 return _LIB
120class EventSoABuffers:
121 __slots__ = ("capacity", "t", "x", "y", "p", "c")
122 def __init__(self, capacity: int):
123 self.capacity = int(capacity)
124 self.t = np.empty(self.capacity, dtype=_T_DTYPE)
125 self.x = np.empty(self.capacity, dtype=_X_DTYPE)
126 self.y = np.empty(self.capacity, dtype=_Y_DTYPE)
127 self.p = np.empty(self.capacity, dtype=_P_DTYPE)
128 self.c = EventBufferSOA()
129 self.c.t = self.t.ctypes.data_as(POINTER(c_uint64))
130 self.c.x = self.x.ctypes.data_as(POINTER(c_uint16))
131 self.c.y = self.y.ctypes.data_as(POINTER(c_uint16))
132 self.c.p = self.p.ctypes.data_as(POINTER(c_uint8))
133 self.c.capacity = self.capacity
134 self.c.size = 0
135 @property
136 def size(self) -> int: return int(self.c.size)
137 def reset(self) -> None: self.c.size = 0
138 def grow(self, new_capacity: int) -> None:
139 new_capacity = int(new_capacity)
140 if new_capacity <= self.capacity: return
141 n = self.size
142 for name, dtype, field, ptr in (
143 ("t", _T_DTYPE, "t", c_uint64), ("x", _X_DTYPE, "x", c_uint16),
144 ("y", _Y_DTYPE, "y", c_uint16), ("p", _P_DTYPE, "p", c_uint8),
145 ):
146 grown = np.empty(new_capacity, dtype=dtype)
147 grown[:n] = getattr(self, name)[:n]
148 setattr(self, name, grown)
149 setattr(self.c, field, grown.ctypes.data_as(POINTER(ptr)))
150 self.capacity = new_capacity
151 self.c.capacity = new_capacity
152 def view(self) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
153 n = self.size
154 return self.t[:n], self.x[:n], self.y[:n], self.p[:n]
156class TriggerSoABuffers:
157 __slots__ = ("capacity", "t", "id", "p", "c")
158 def __init__(self, capacity: int):
159 self.capacity = int(capacity)
160 self.t = np.empty(self.capacity, dtype=_T_DTYPE)
161 self.id = np.empty(self.capacity, dtype=_ID_DTYPE)
162 self.p = np.empty(self.capacity, dtype=_P_DTYPE)
163 self.c = TriggerBufferSOA()
164 self.c.t = self.t.ctypes.data_as(POINTER(c_uint64))
165 self.c.id = self.id.ctypes.data_as(POINTER(c_uint8))
166 self.c.p = self.p.ctypes.data_as(POINTER(c_uint8))
167 self.c.capacity = self.capacity
168 self.c.size = 0
169 @property
170 def size(self) -> int: return int(self.c.size)
171 def reset(self) -> None: self.c.size = 0
172 def grow(self, new_capacity: int) -> None:
173 new_capacity = int(new_capacity)
174 if new_capacity <= self.capacity: return
175 n = self.size
176 for name, dtype, ptr in (
177 ("t", _T_DTYPE, c_uint64), ("id", _ID_DTYPE, c_uint8), ("p", _P_DTYPE, c_uint8),
178 ):
179 grown = np.empty(new_capacity, dtype=dtype)
180 grown[:n] = getattr(self, name)[:n]
181 setattr(self, name, grown)
182 setattr(self.c, name, grown.ctypes.data_as(POINTER(ptr)))
183 self.capacity = new_capacity
184 self.c.capacity = new_capacity
185 def view(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
186 n = self.size
187 return self.t[:n], self.id[:n], self.p[:n]
189def events_view(ev: EventSoABuffers) -> EventArray:
190 n = ev.size
191 return EventArray(ev.t[:n].view(np.int64), ev.x[:n], ev.y[:n], ev.p[:n])
193def triggers_view(tr: TriggerSoABuffers) -> TriggerArray:
194 n = tr.size
195 return TriggerArray(tr.t[:n].view(np.int64), tr.p[:n], tr.id[:n])
197def parse_step(words: Any, offset: int, make_input: Any, parser: Any, events: Any, triggers: Any, *, tail_pad: int = 0, word_dtype: Any = None) -> tuple[int, int]:
198 n_words = len(words)
199 if offset >= n_words: return 0, n_words
200 before = events.size
201 inp = make_input(words[offset:])
202 res = parser.parse_chunk_soa(inp, events, triggers)
203 if res.status == EVUTILS_PARSE_ERROR: raise RuntimeError(f"parse error near word {offset}")
204 consumed = inp.consumed(res)
205 if consumed == 0:
206 if tail_pad and word_dtype is not None:
207 tail = words[offset:]
208 if len(tail):
209 scratch = np.zeros(len(tail) + tail_pad, dtype=word_dtype)
210 scratch[: len(tail)] = tail
211 parser.parse_chunk_soa(make_input(scratch), events, triggers)
212 return events.size - before, n_words
213 return events.size - before, offset + consumed
215def decode_all_soa(words: Any, start_offset: int, make_input: Any, parser: Any, *, est_events_per_word: float = 1.0, tail_pad: int = 0, word_dtype: Any = None, trigger_cap: int = 1 << 12) -> tuple[EventArray, int]:
216 n_words = len(words) if words is not None else 0
217 if n_words == 0 or start_offset >= n_words: return EventArray.empty(), n_words
218 remaining = n_words - start_offset
219 cap = int(remaining * est_events_per_word) + 1024
220 ev = EventSoABuffers(cap)
221 tr = TriggerSoABuffers(max(trigger_cap, 1))
222 offset = start_offset
223 while offset < n_words:
224 if ev.capacity - ev.size < 128: ev.grow(int(ev.capacity * 1.5) + (1 << 16))
225 if tr.capacity - tr.size < 64: tr.grow(tr.capacity * 2 + 64)
226 inp = make_input(words[offset:])
227 res = parser.parse_chunk_soa(inp, ev, tr)
228 if res.status == EVUTILS_PARSE_ERROR: raise RuntimeError(f"parse error near word {offset}")
229 consumed = inp.consumed(res)
230 if consumed == 0:
231 if tail_pad and word_dtype is not None:
232 tail = words[offset:]
233 if len(tail):
234 if ev.capacity - ev.size < len(tail) + 128: ev.grow(ev.size + len(tail) + (1 << 16))
235 scratch = np.zeros(len(tail) + tail_pad, dtype=word_dtype)
236 scratch[: len(tail)] = tail
237 parser.parse_chunk_soa(make_input(scratch), ev, tr)
238 offset = n_words
239 break
240 offset += consumed
241 n = ev.size
242 if n == 0: return EventArray.empty(), offset
243 out = EventArray(ev.t[:n].view(np.int64), ev.x[:n], ev.y[:n], ev.p[:n])
244 return out, offset