Coverage for src/evutils/io/_csv.py: 92%

157 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-15 09:33 +0000

1"""CSV file decoder and encoder.""" 

2 

3import io 

4import warnings 

5from datetime import datetime 

6from io import TextIOWrapper 

7from pathlib import Path 

8from typing import Any 

9 

10import numpy as np 

11from ..types import Event_dtype, EventArray, TriggerArray 

12from .common import EventDecoder, EventEncoder 

13 

14 

15class EventDecoder_Csv(EventDecoder): 

16 """A reader for CSV files with events. 

17 

18 Parameters 

19 ---------- 

20 file 

21 Path to the data file 

22 order 

23 Order of the columns in the CSV file, by default ['t', 'x', 'y', 'p'] 

24 chunk_size 

25 Size of the chunk to read, by default 10_000 

26 delimiter 

27 Delimiter for the CSV file, by default "," 

28 engine 

29 Engine to use to read the CSV file, by default 'c' 

30 

31 Raises 

32 ------ 

33 ValueError 

34 If the order is not a list of 4 strings or if the order does not contain 't', 'x', 'y' and 'p' 

35 

36 """ 

37 

38 def __init__(self, readable:io.BufferedReader, order:list[str]|None=None, chunk_size:int=1_000_000, delimiter:str=",", engine:str='c'): 

39 super().__init__(readable, chunk_size) 

40 

41 

42 if order is None: 

43 # we infer the header from the file, or use a default header 

44 pass 

45 else: 

46 # Validate the parameters 

47 if len(order) != 4: 

48 raise ValueError("Order must be a list of 4 strings") 

49 if "t" not in order or "x" not in order or "y" not in order or "p" not in order: 

50 raise ValueError("Order must contain 't', 'x', 'y' and 'p'") 

51 

52 self._order = order 

53 self._delimiter = delimiter 

54 self._engine = engine 

55 

56 

57 

58 def _check_header(self) -> None: 

59 have_header = False 

60 

61 # Check first line to see if it is a header 

62 # TODO: Need to deal with non-seekable files 

63 first_line: str = self._fd.readline().decode('utf-8').strip() 

64 

65 # Check if the first line is a header 

66 # If we find a header, it will take precendence over the order parameter 

67 

68 if "t" in first_line or "x" in first_line or "y" in first_line or "p" in first_line: 

69 # Header found 

70 have_header = True 

71 

72 if have_header: 

73 # We expect a header: 

74 if "t" in first_line and "x" in first_line and "y" in first_line and "p" in first_line: 

75 # Header found 

76 order = [c.strip() for c in first_line.split(self._delimiter)] 

77 

78 if self._order is not None and order != self._order: 

79 warnings.warn(f"Header order {order} in file takes precedence over requested order {self._order}") 

80 self._order = order 

81 

82 

83 else: 

84 raise ValueError(f"Header not found or invalid: {first_line}") 

85 else: 

86 # No header found, just seek to start of file 

87 self._fd.seek(0) 

88 

89 # If we still don't have a header, we use a default one 

90 if self._order is None: 

91 self._order = ['t', 'x', 'y', 'p'] 

92 

93 

94 

95 def init(self) -> None: 

96 """Initialize the CSV reader. 

97 

98 Returns 

99 ------- 

100 None 

101 

102 """ 

103 self._check_header() 

104 assert self._order is not None # set by _check_header 

105 

106 # Build col_mapping: maps CSV index to out_array index 

107 # out_arrays index: 0=t, 1=x, 2=y, 3=p 

108 self._field_map = {'t': 0, 'x': 1, 'y': 2, 'p': 3} 

109 self._col_mapping = [-1] * len(self._order) 

110 for i, col in enumerate(self._order): 

111 if col in self._field_map: 

112 self._col_mapping[i] = self._field_map[col] 

113 

114 self._buffer = bytearray() 

115 self._is_initialized = True 

116 

117 def read_chunk(self, delta_t_hint:int | None = None, n_events_hint:int | None = None) -> 'EventArray': 

118 """Read a chunk of events from the CSV file.""" 

119 import ctypes 

120 from . import _native_csv; from ._native_core import lib 

121 

122 assert self._is_initialized, "Reader is not initialized" 

123 chunk_size = self._chunk_size 

124 if n_events_hint is not None: 

125 chunk_size = n_events_hint 

126 

127 t_arr = np.empty(chunk_size, dtype=np.int64) 

128 x_arr = np.empty(chunk_size, dtype=np.uint16) 

129 y_arr = np.empty(chunk_size, dtype=np.uint16) 

130 p_arr = np.empty(chunk_size, dtype=np.uint8) 

131 

132 array_types = (ctypes.c_int * 4)(8, 2, 2, 1) 

133 col_mapping = (ctypes.c_int * len(self._col_mapping))(*self._col_mapping) 

134 delimiter = self._delimiter.encode('utf-8')[0] 

135 

136 events_parsed_total = 0 

137 

138 while events_parsed_total < chunk_size: 

139 if not self._eof and len(self._buffer) < 1024 * 1024: 

140 new_data = self._fd.read(4 * 1024 * 1024) 

141 if not new_data: 

142 self._eof = True 

143 self._buffer.extend(b'\n') # Guarantee last line ends with newline 

144 else: 

145 self._buffer.extend(new_data) 

146 

147 if len(self._buffer) == 0: 

148 break 

149 

150 bytes_consumed = ctypes.c_size_t(0) 

151 events_parsed = ctypes.c_size_t(0) 

152 

153 cur_out_ptrs = (ctypes.c_void_p * 4)( 

154 t_arr.ctypes.data + events_parsed_total * 8, 

155 x_arr.ctypes.data + events_parsed_total * 2, 

156 y_arr.ctypes.data + events_parsed_total * 2, 

157 p_arr.ctypes.data + events_parsed_total * 1 

158 ) 

159 

160 c_buf = (ctypes.c_char * len(self._buffer)).from_buffer(self._buffer) 

161 

162 lib().evutils_read_csv( 

163 c_buf, len(self._buffer), delimiter, cur_out_ptrs, array_types, 

164 col_mapping, len(self._col_mapping), chunk_size - events_parsed_total, 

165 ctypes.byref(bytes_consumed), ctypes.byref(events_parsed) 

166 ) 

167 

168 del c_buf # Release memory view so buffer can be resized 

169 

170 consumed = bytes_consumed.value 

171 parsed = events_parsed.value 

172 

173 if consumed == 0: 

174 if self._eof: 

175 break 

176 # No full line in the buffered window (a line longer than the 

177 # refill threshold): pull more data regardless of the usual 

178 # low-water mark, otherwise this loop would never progress. 

179 new_data = self._fd.read(4 * 1024 * 1024) 

180 if not new_data: 

181 self._eof = True 

182 self._buffer.extend(b'\n') # guarantee final-line newline 

183 else: 

184 self._buffer.extend(new_data) 

185 continue 

186 

187 del self._buffer[:consumed] 

188 events_parsed_total += parsed 

189 

190 if events_parsed_total < chunk_size: 

191 self._eof = True 

192 

193 return EventArray( 

194 t_arr[:events_parsed_total], 

195 x_arr[:events_parsed_total], 

196 y_arr[:events_parsed_total], 

197 p_arr[:events_parsed_total] 

198 ) 

199 

200 def reset(self) -> None: 

201 """Reset the CSV reader to the beginning of the file.""" 

202 assert self._fd is not None 

203 self._fd.seek(0) 

204 self._eof = False 

205 if self._is_initialized: 

206 self._is_initialized = False 

207 self.init() 

208 

209 

210 

211 

212class EventEncoder_Csv(EventEncoder): 

213 """A writer for CSV files with events. 

214 

215 Parameters 

216 ---------- 

217 file : str 

218 Path to the data file 

219 width : int, optional 

220 Width of the frame, by default 1280 (not relevant for this formats) 

221 height : int, optional 

222 Height of the frame, by default 720 (not relevant for this formats) 

223 sep : str, optional 

224 Separator for the CSV file, by default "," 

225 order : list, optional 

226 Order of the columns in the CSV file, by default ['t', 'x', 'y', 'p'] 

227 header : bool, optional 

228 If True, a header is written on the first line, by default True 

229 

230 Raises 

231 ------ 

232 ValueError 

233 If the order is not a list of 4 strings or if the order does not contain 't', 'x', 'y' and 'p' 

234 

235 """ 

236 

237 def __init__(self, writable: io.BufferedWriter, width:int=1280, height:int=720, dt:datetime|None=None, sep:str=",", order:list[str]|None=None, header:bool=True): 

238 super().__init__(writable, width, height, dt) 

239 if order is None: 

240 order = ['t', 'x', 'y', 'p'] 

241 

242 

243 if len(order) != 4: 

244 raise ValueError("Order must be a list of 4 strings") 

245 if "t" not in order or "x" not in order or "y" not in order or "p" not in order: 

246 raise ValueError("Order must contain 't', 'x', 'y' and 'p'") 

247 

248 self._order = order 

249 self._header = header 

250 self._sep = sep 

251 

252 def init(self) -> None: 

253 """Initialize the CSV writer. 

254 

255 Returns 

256 ------- 

257 None 

258 

259 """ 

260 if self._header: 

261 header = self._sep.join(self._order) + "\n" 

262 self._fd.write(header.encode('utf-8')) 

263 

264 self._is_initialized = True 

265 

266 

267 def write(self, events: 'np.ndarray | EventArray', triggers: 'np.ndarray | TriggerArray | None' = None) -> int: 

268 """Write events to the CSV file.""" 

269 import ctypes 

270 from . import _native_csv; from ._native_core import lib 

271 

272 if not self._is_initialized: 

273 self.init() 

274 

275 if isinstance(events, np.ndarray): 

276 events = EventArray.from_aos(events) 

277 

278 chunk_size = len(events) 

279 if chunk_size == 0: 

280 return 0 

281 

282 t_arr = events.t 

283 x_arr = events.x 

284 y_arr = events.y 

285 p_arr = events.p 

286 

287 in_ptrs_list = [] 

288 array_types_list = [] 

289 for col in self._order: 

290 if col == 't': 

291 in_ptrs_list.append(t_arr.ctypes.data) 

292 array_types_list.append(8) 

293 elif col == 'x': 

294 in_ptrs_list.append(x_arr.ctypes.data) 

295 array_types_list.append(2) 

296 elif col == 'y': 

297 in_ptrs_list.append(y_arr.ctypes.data) 

298 array_types_list.append(2) 

299 elif col == 'p': 

300 in_ptrs_list.append(p_arr.ctypes.data) 

301 array_types_list.append(1) 

302 else: 

303 in_ptrs_list.append(0) 

304 array_types_list.append(0) 

305 

306 in_ptrs = (ctypes.c_void_p * len(self._order))(*in_ptrs_list) 

307 array_types = (ctypes.c_int * len(self._order))(*array_types_list) 

308 delimiter = self._sep.encode('utf-8')[0] 

309 

310 out_buffer_len = chunk_size * len(self._order) * 22 

311 out_buffer = ctypes.create_string_buffer(out_buffer_len) 

312 

313 bytes_written = ctypes.c_size_t(0) 

314 events_written = ctypes.c_size_t(0) 

315 

316 lib().evutils_write_csv( 

317 in_ptrs, array_types, len(self._order), delimiter, chunk_size, 

318 out_buffer, out_buffer_len, ctypes.byref(bytes_written), ctypes.byref(events_written) 

319 ) 

320 

321 self._fd.write(out_buffer.raw[:bytes_written.value]) 

322 return events_written.value