oscura 0.5.1__py3-none-any.whl → 0.7.0__py3-none-any.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.
Files changed (497) hide show
  1. oscura/__init__.py +169 -167
  2. oscura/analyzers/__init__.py +3 -0
  3. oscura/analyzers/classification.py +659 -0
  4. oscura/analyzers/digital/edges.py +325 -65
  5. oscura/analyzers/digital/quality.py +293 -166
  6. oscura/analyzers/digital/timing.py +260 -115
  7. oscura/analyzers/digital/timing_numba.py +334 -0
  8. oscura/analyzers/entropy.py +605 -0
  9. oscura/analyzers/eye/diagram.py +176 -109
  10. oscura/analyzers/eye/metrics.py +5 -5
  11. oscura/analyzers/jitter/__init__.py +6 -4
  12. oscura/analyzers/jitter/ber.py +52 -52
  13. oscura/analyzers/jitter/classification.py +156 -0
  14. oscura/analyzers/jitter/decomposition.py +163 -113
  15. oscura/analyzers/jitter/spectrum.py +80 -64
  16. oscura/analyzers/ml/__init__.py +39 -0
  17. oscura/analyzers/ml/features.py +600 -0
  18. oscura/analyzers/ml/signal_classifier.py +604 -0
  19. oscura/analyzers/packet/daq.py +246 -158
  20. oscura/analyzers/packet/parser.py +12 -1
  21. oscura/analyzers/packet/payload.py +50 -2110
  22. oscura/analyzers/packet/payload_analysis.py +361 -181
  23. oscura/analyzers/packet/payload_patterns.py +133 -70
  24. oscura/analyzers/packet/stream.py +84 -23
  25. oscura/analyzers/patterns/__init__.py +26 -5
  26. oscura/analyzers/patterns/anomaly_detection.py +908 -0
  27. oscura/analyzers/patterns/clustering.py +169 -108
  28. oscura/analyzers/patterns/clustering_optimized.py +227 -0
  29. oscura/analyzers/patterns/discovery.py +1 -1
  30. oscura/analyzers/patterns/matching.py +581 -197
  31. oscura/analyzers/patterns/pattern_mining.py +778 -0
  32. oscura/analyzers/patterns/periodic.py +121 -38
  33. oscura/analyzers/patterns/sequences.py +175 -78
  34. oscura/analyzers/power/conduction.py +1 -1
  35. oscura/analyzers/power/soa.py +6 -6
  36. oscura/analyzers/power/switching.py +250 -110
  37. oscura/analyzers/protocol/__init__.py +17 -1
  38. oscura/analyzers/protocols/base.py +6 -6
  39. oscura/analyzers/protocols/ble/__init__.py +38 -0
  40. oscura/analyzers/protocols/ble/analyzer.py +809 -0
  41. oscura/analyzers/protocols/ble/uuids.py +288 -0
  42. oscura/analyzers/protocols/can.py +257 -127
  43. oscura/analyzers/protocols/can_fd.py +107 -80
  44. oscura/analyzers/protocols/flexray.py +139 -80
  45. oscura/analyzers/protocols/hdlc.py +93 -58
  46. oscura/analyzers/protocols/i2c.py +247 -106
  47. oscura/analyzers/protocols/i2s.py +138 -86
  48. oscura/analyzers/protocols/industrial/__init__.py +40 -0
  49. oscura/analyzers/protocols/industrial/bacnet/__init__.py +33 -0
  50. oscura/analyzers/protocols/industrial/bacnet/analyzer.py +708 -0
  51. oscura/analyzers/protocols/industrial/bacnet/encoding.py +412 -0
  52. oscura/analyzers/protocols/industrial/bacnet/services.py +622 -0
  53. oscura/analyzers/protocols/industrial/ethercat/__init__.py +30 -0
  54. oscura/analyzers/protocols/industrial/ethercat/analyzer.py +474 -0
  55. oscura/analyzers/protocols/industrial/ethercat/mailbox.py +339 -0
  56. oscura/analyzers/protocols/industrial/ethercat/topology.py +166 -0
  57. oscura/analyzers/protocols/industrial/modbus/__init__.py +31 -0
  58. oscura/analyzers/protocols/industrial/modbus/analyzer.py +525 -0
  59. oscura/analyzers/protocols/industrial/modbus/crc.py +79 -0
  60. oscura/analyzers/protocols/industrial/modbus/functions.py +436 -0
  61. oscura/analyzers/protocols/industrial/opcua/__init__.py +21 -0
  62. oscura/analyzers/protocols/industrial/opcua/analyzer.py +552 -0
  63. oscura/analyzers/protocols/industrial/opcua/datatypes.py +446 -0
  64. oscura/analyzers/protocols/industrial/opcua/services.py +264 -0
  65. oscura/analyzers/protocols/industrial/profinet/__init__.py +23 -0
  66. oscura/analyzers/protocols/industrial/profinet/analyzer.py +441 -0
  67. oscura/analyzers/protocols/industrial/profinet/dcp.py +263 -0
  68. oscura/analyzers/protocols/industrial/profinet/ptcp.py +200 -0
  69. oscura/analyzers/protocols/jtag.py +180 -98
  70. oscura/analyzers/protocols/lin.py +219 -114
  71. oscura/analyzers/protocols/manchester.py +4 -4
  72. oscura/analyzers/protocols/onewire.py +253 -149
  73. oscura/analyzers/protocols/parallel_bus/__init__.py +20 -0
  74. oscura/analyzers/protocols/parallel_bus/centronics.py +92 -0
  75. oscura/analyzers/protocols/parallel_bus/gpib.py +137 -0
  76. oscura/analyzers/protocols/spi.py +192 -95
  77. oscura/analyzers/protocols/swd.py +321 -167
  78. oscura/analyzers/protocols/uart.py +267 -125
  79. oscura/analyzers/protocols/usb.py +235 -131
  80. oscura/analyzers/side_channel/power.py +17 -12
  81. oscura/analyzers/signal/__init__.py +15 -0
  82. oscura/analyzers/signal/timing_analysis.py +1086 -0
  83. oscura/analyzers/signal_integrity/__init__.py +4 -1
  84. oscura/analyzers/signal_integrity/sparams.py +2 -19
  85. oscura/analyzers/spectral/chunked.py +129 -60
  86. oscura/analyzers/spectral/chunked_fft.py +300 -94
  87. oscura/analyzers/spectral/chunked_wavelet.py +100 -80
  88. oscura/analyzers/statistical/checksum.py +376 -217
  89. oscura/analyzers/statistical/classification.py +229 -107
  90. oscura/analyzers/statistical/entropy.py +78 -53
  91. oscura/analyzers/statistics/correlation.py +407 -211
  92. oscura/analyzers/statistics/outliers.py +2 -2
  93. oscura/analyzers/statistics/streaming.py +30 -5
  94. oscura/analyzers/validation.py +216 -101
  95. oscura/analyzers/waveform/measurements.py +9 -0
  96. oscura/analyzers/waveform/measurements_with_uncertainty.py +31 -15
  97. oscura/analyzers/waveform/spectral.py +500 -228
  98. oscura/api/__init__.py +31 -5
  99. oscura/api/dsl/__init__.py +582 -0
  100. oscura/{dsl → api/dsl}/commands.py +43 -76
  101. oscura/{dsl → api/dsl}/interpreter.py +26 -51
  102. oscura/{dsl → api/dsl}/parser.py +107 -77
  103. oscura/{dsl → api/dsl}/repl.py +2 -2
  104. oscura/api/dsl.py +1 -1
  105. oscura/{integrations → api/integrations}/__init__.py +1 -1
  106. oscura/{integrations → api/integrations}/llm.py +201 -102
  107. oscura/api/operators.py +3 -3
  108. oscura/api/optimization.py +144 -30
  109. oscura/api/rest_server.py +921 -0
  110. oscura/api/server/__init__.py +17 -0
  111. oscura/api/server/dashboard.py +850 -0
  112. oscura/api/server/static/README.md +34 -0
  113. oscura/api/server/templates/base.html +181 -0
  114. oscura/api/server/templates/export.html +120 -0
  115. oscura/api/server/templates/home.html +284 -0
  116. oscura/api/server/templates/protocols.html +58 -0
  117. oscura/api/server/templates/reports.html +43 -0
  118. oscura/api/server/templates/session_detail.html +89 -0
  119. oscura/api/server/templates/sessions.html +83 -0
  120. oscura/api/server/templates/waveforms.html +73 -0
  121. oscura/automotive/__init__.py +8 -1
  122. oscura/automotive/can/__init__.py +10 -0
  123. oscura/automotive/can/checksum.py +3 -1
  124. oscura/automotive/can/dbc_generator.py +590 -0
  125. oscura/automotive/can/message_wrapper.py +121 -74
  126. oscura/automotive/can/patterns.py +98 -21
  127. oscura/automotive/can/session.py +292 -56
  128. oscura/automotive/can/state_machine.py +6 -3
  129. oscura/automotive/can/stimulus_response.py +97 -75
  130. oscura/automotive/dbc/__init__.py +10 -2
  131. oscura/automotive/dbc/generator.py +84 -56
  132. oscura/automotive/dbc/parser.py +6 -6
  133. oscura/automotive/dtc/data.json +17 -102
  134. oscura/automotive/dtc/database.py +2 -2
  135. oscura/automotive/flexray/__init__.py +31 -0
  136. oscura/automotive/flexray/analyzer.py +504 -0
  137. oscura/automotive/flexray/crc.py +185 -0
  138. oscura/automotive/flexray/fibex.py +449 -0
  139. oscura/automotive/j1939/__init__.py +45 -8
  140. oscura/automotive/j1939/analyzer.py +605 -0
  141. oscura/automotive/j1939/spns.py +326 -0
  142. oscura/automotive/j1939/transport.py +306 -0
  143. oscura/automotive/lin/__init__.py +47 -0
  144. oscura/automotive/lin/analyzer.py +612 -0
  145. oscura/automotive/loaders/blf.py +13 -2
  146. oscura/automotive/loaders/csv_can.py +143 -72
  147. oscura/automotive/loaders/dispatcher.py +50 -2
  148. oscura/automotive/loaders/mdf.py +86 -45
  149. oscura/automotive/loaders/pcap.py +111 -61
  150. oscura/automotive/uds/__init__.py +4 -0
  151. oscura/automotive/uds/analyzer.py +725 -0
  152. oscura/automotive/uds/decoder.py +140 -58
  153. oscura/automotive/uds/models.py +7 -1
  154. oscura/automotive/visualization.py +1 -1
  155. oscura/cli/analyze.py +348 -0
  156. oscura/cli/batch.py +142 -122
  157. oscura/cli/benchmark.py +275 -0
  158. oscura/cli/characterize.py +137 -82
  159. oscura/cli/compare.py +224 -131
  160. oscura/cli/completion.py +250 -0
  161. oscura/cli/config_cmd.py +361 -0
  162. oscura/cli/decode.py +164 -87
  163. oscura/cli/export.py +286 -0
  164. oscura/cli/main.py +115 -31
  165. oscura/{onboarding → cli/onboarding}/__init__.py +3 -3
  166. oscura/{onboarding → cli/onboarding}/help.py +80 -58
  167. oscura/{onboarding → cli/onboarding}/tutorials.py +97 -72
  168. oscura/{onboarding → cli/onboarding}/wizard.py +55 -36
  169. oscura/cli/progress.py +147 -0
  170. oscura/cli/shell.py +157 -135
  171. oscura/cli/validate_cmd.py +204 -0
  172. oscura/cli/visualize.py +158 -0
  173. oscura/convenience.py +125 -79
  174. oscura/core/__init__.py +4 -2
  175. oscura/core/backend_selector.py +3 -3
  176. oscura/core/cache.py +126 -15
  177. oscura/core/cancellation.py +1 -1
  178. oscura/{config → core/config}/__init__.py +20 -11
  179. oscura/{config → core/config}/defaults.py +1 -1
  180. oscura/{config → core/config}/loader.py +7 -5
  181. oscura/{config → core/config}/memory.py +5 -5
  182. oscura/{config → core/config}/migration.py +1 -1
  183. oscura/{config → core/config}/pipeline.py +99 -23
  184. oscura/{config → core/config}/preferences.py +1 -1
  185. oscura/{config → core/config}/protocol.py +3 -3
  186. oscura/{config → core/config}/schema.py +426 -272
  187. oscura/{config → core/config}/settings.py +1 -1
  188. oscura/{config → core/config}/thresholds.py +195 -153
  189. oscura/core/correlation.py +5 -6
  190. oscura/core/cross_domain.py +0 -2
  191. oscura/core/debug.py +9 -5
  192. oscura/{extensibility → core/extensibility}/docs.py +158 -70
  193. oscura/{extensibility → core/extensibility}/extensions.py +160 -76
  194. oscura/{extensibility → core/extensibility}/logging.py +1 -1
  195. oscura/{extensibility → core/extensibility}/measurements.py +1 -1
  196. oscura/{extensibility → core/extensibility}/plugins.py +1 -1
  197. oscura/{extensibility → core/extensibility}/templates.py +73 -3
  198. oscura/{extensibility → core/extensibility}/validation.py +1 -1
  199. oscura/core/gpu_backend.py +11 -7
  200. oscura/core/log_query.py +101 -11
  201. oscura/core/logging.py +126 -54
  202. oscura/core/logging_advanced.py +5 -5
  203. oscura/core/memory_limits.py +108 -70
  204. oscura/core/memory_monitor.py +2 -2
  205. oscura/core/memory_progress.py +7 -7
  206. oscura/core/memory_warnings.py +1 -1
  207. oscura/core/numba_backend.py +13 -13
  208. oscura/{plugins → core/plugins}/__init__.py +9 -9
  209. oscura/{plugins → core/plugins}/base.py +7 -7
  210. oscura/{plugins → core/plugins}/cli.py +3 -3
  211. oscura/{plugins → core/plugins}/discovery.py +186 -106
  212. oscura/{plugins → core/plugins}/lifecycle.py +1 -1
  213. oscura/{plugins → core/plugins}/manager.py +7 -7
  214. oscura/{plugins → core/plugins}/registry.py +3 -3
  215. oscura/{plugins → core/plugins}/versioning.py +1 -1
  216. oscura/core/progress.py +16 -1
  217. oscura/core/provenance.py +8 -2
  218. oscura/{schemas → core/schemas}/__init__.py +2 -2
  219. oscura/{schemas → core/schemas}/device_mapping.json +2 -8
  220. oscura/{schemas → core/schemas}/packet_format.json +4 -24
  221. oscura/{schemas → core/schemas}/protocol_definition.json +2 -12
  222. oscura/core/types.py +4 -0
  223. oscura/core/uncertainty.py +3 -3
  224. oscura/correlation/__init__.py +52 -0
  225. oscura/correlation/multi_protocol.py +811 -0
  226. oscura/discovery/auto_decoder.py +117 -35
  227. oscura/discovery/comparison.py +191 -86
  228. oscura/discovery/quality_validator.py +155 -68
  229. oscura/discovery/signal_detector.py +196 -79
  230. oscura/export/__init__.py +18 -8
  231. oscura/export/kaitai_struct.py +513 -0
  232. oscura/export/scapy_layer.py +801 -0
  233. oscura/export/wireshark/generator.py +1 -1
  234. oscura/export/wireshark/templates/dissector.lua.j2 +2 -2
  235. oscura/export/wireshark_dissector.py +746 -0
  236. oscura/guidance/wizard.py +207 -111
  237. oscura/hardware/__init__.py +19 -0
  238. oscura/{acquisition → hardware/acquisition}/__init__.py +4 -4
  239. oscura/{acquisition → hardware/acquisition}/file.py +2 -2
  240. oscura/{acquisition → hardware/acquisition}/hardware.py +7 -7
  241. oscura/{acquisition → hardware/acquisition}/saleae.py +15 -12
  242. oscura/{acquisition → hardware/acquisition}/socketcan.py +1 -1
  243. oscura/{acquisition → hardware/acquisition}/streaming.py +2 -2
  244. oscura/{acquisition → hardware/acquisition}/synthetic.py +3 -3
  245. oscura/{acquisition → hardware/acquisition}/visa.py +33 -11
  246. oscura/hardware/firmware/__init__.py +29 -0
  247. oscura/hardware/firmware/pattern_recognition.py +874 -0
  248. oscura/hardware/hal_detector.py +736 -0
  249. oscura/hardware/security/__init__.py +37 -0
  250. oscura/hardware/security/side_channel_detector.py +1126 -0
  251. oscura/inference/__init__.py +4 -0
  252. oscura/inference/active_learning/observation_table.py +4 -1
  253. oscura/inference/alignment.py +216 -123
  254. oscura/inference/bayesian.py +113 -33
  255. oscura/inference/crc_reverse.py +101 -55
  256. oscura/inference/logic.py +6 -2
  257. oscura/inference/message_format.py +342 -183
  258. oscura/inference/protocol.py +95 -44
  259. oscura/inference/protocol_dsl.py +180 -82
  260. oscura/inference/signal_intelligence.py +1439 -706
  261. oscura/inference/spectral.py +99 -57
  262. oscura/inference/state_machine.py +810 -158
  263. oscura/inference/stream.py +270 -110
  264. oscura/iot/__init__.py +34 -0
  265. oscura/iot/coap/__init__.py +32 -0
  266. oscura/iot/coap/analyzer.py +668 -0
  267. oscura/iot/coap/options.py +212 -0
  268. oscura/iot/lorawan/__init__.py +21 -0
  269. oscura/iot/lorawan/crypto.py +206 -0
  270. oscura/iot/lorawan/decoder.py +801 -0
  271. oscura/iot/lorawan/mac_commands.py +341 -0
  272. oscura/iot/mqtt/__init__.py +27 -0
  273. oscura/iot/mqtt/analyzer.py +999 -0
  274. oscura/iot/mqtt/properties.py +315 -0
  275. oscura/iot/zigbee/__init__.py +31 -0
  276. oscura/iot/zigbee/analyzer.py +615 -0
  277. oscura/iot/zigbee/security.py +153 -0
  278. oscura/iot/zigbee/zcl.py +349 -0
  279. oscura/jupyter/display.py +125 -45
  280. oscura/{exploratory → jupyter/exploratory}/__init__.py +8 -8
  281. oscura/{exploratory → jupyter/exploratory}/error_recovery.py +298 -141
  282. oscura/jupyter/exploratory/fuzzy.py +746 -0
  283. oscura/{exploratory → jupyter/exploratory}/fuzzy_advanced.py +258 -100
  284. oscura/{exploratory → jupyter/exploratory}/legacy.py +464 -242
  285. oscura/{exploratory → jupyter/exploratory}/parse.py +167 -145
  286. oscura/{exploratory → jupyter/exploratory}/recovery.py +119 -87
  287. oscura/jupyter/exploratory/sync.py +612 -0
  288. oscura/{exploratory → jupyter/exploratory}/unknown.py +299 -176
  289. oscura/jupyter/magic.py +4 -4
  290. oscura/{ui → jupyter/ui}/__init__.py +2 -2
  291. oscura/{ui → jupyter/ui}/formatters.py +3 -3
  292. oscura/{ui → jupyter/ui}/progressive_display.py +153 -82
  293. oscura/loaders/__init__.py +183 -67
  294. oscura/loaders/binary.py +88 -1
  295. oscura/loaders/chipwhisperer.py +153 -137
  296. oscura/loaders/configurable.py +208 -86
  297. oscura/loaders/csv_loader.py +458 -215
  298. oscura/loaders/hdf5_loader.py +278 -119
  299. oscura/loaders/lazy.py +87 -54
  300. oscura/loaders/mmap_loader.py +1 -1
  301. oscura/loaders/numpy_loader.py +253 -116
  302. oscura/loaders/pcap.py +226 -151
  303. oscura/loaders/rigol.py +110 -49
  304. oscura/loaders/sigrok.py +201 -78
  305. oscura/loaders/tdms.py +81 -58
  306. oscura/loaders/tektronix.py +291 -174
  307. oscura/loaders/touchstone.py +182 -87
  308. oscura/loaders/tss.py +456 -0
  309. oscura/loaders/vcd.py +215 -117
  310. oscura/loaders/wav.py +155 -68
  311. oscura/reporting/__init__.py +9 -0
  312. oscura/reporting/analyze.py +352 -146
  313. oscura/reporting/argument_preparer.py +69 -14
  314. oscura/reporting/auto_report.py +97 -61
  315. oscura/reporting/batch.py +131 -58
  316. oscura/reporting/chart_selection.py +57 -45
  317. oscura/reporting/comparison.py +63 -17
  318. oscura/reporting/content/executive.py +76 -24
  319. oscura/reporting/core_formats/multi_format.py +11 -8
  320. oscura/reporting/engine.py +312 -158
  321. oscura/reporting/enhanced_reports.py +949 -0
  322. oscura/reporting/export.py +86 -43
  323. oscura/reporting/formatting/numbers.py +69 -42
  324. oscura/reporting/html.py +139 -58
  325. oscura/reporting/index.py +137 -65
  326. oscura/reporting/output.py +158 -67
  327. oscura/reporting/pdf.py +67 -102
  328. oscura/reporting/plots.py +191 -112
  329. oscura/reporting/sections.py +88 -47
  330. oscura/reporting/standards.py +104 -61
  331. oscura/reporting/summary_generator.py +75 -55
  332. oscura/reporting/tables.py +138 -54
  333. oscura/reporting/templates/enhanced/protocol_re.html +525 -0
  334. oscura/sessions/__init__.py +14 -23
  335. oscura/sessions/base.py +3 -3
  336. oscura/sessions/blackbox.py +106 -10
  337. oscura/sessions/generic.py +2 -2
  338. oscura/sessions/legacy.py +783 -0
  339. oscura/side_channel/__init__.py +63 -0
  340. oscura/side_channel/dpa.py +1025 -0
  341. oscura/utils/__init__.py +15 -1
  342. oscura/utils/bitwise.py +118 -0
  343. oscura/{builders → utils/builders}/__init__.py +1 -1
  344. oscura/{comparison → utils/comparison}/__init__.py +6 -6
  345. oscura/{comparison → utils/comparison}/compare.py +202 -101
  346. oscura/{comparison → utils/comparison}/golden.py +83 -63
  347. oscura/{comparison → utils/comparison}/limits.py +313 -89
  348. oscura/{comparison → utils/comparison}/mask.py +151 -45
  349. oscura/{comparison → utils/comparison}/trace_diff.py +1 -1
  350. oscura/{comparison → utils/comparison}/visualization.py +147 -89
  351. oscura/{component → utils/component}/__init__.py +3 -3
  352. oscura/{component → utils/component}/impedance.py +122 -58
  353. oscura/{component → utils/component}/reactive.py +165 -168
  354. oscura/{component → utils/component}/transmission_line.py +3 -3
  355. oscura/{filtering → utils/filtering}/__init__.py +6 -6
  356. oscura/{filtering → utils/filtering}/base.py +1 -1
  357. oscura/{filtering → utils/filtering}/convenience.py +2 -2
  358. oscura/{filtering → utils/filtering}/design.py +169 -93
  359. oscura/{filtering → utils/filtering}/filters.py +2 -2
  360. oscura/{filtering → utils/filtering}/introspection.py +2 -2
  361. oscura/utils/geometry.py +31 -0
  362. oscura/utils/imports.py +184 -0
  363. oscura/utils/lazy.py +1 -1
  364. oscura/{math → utils/math}/__init__.py +2 -2
  365. oscura/{math → utils/math}/arithmetic.py +114 -48
  366. oscura/{math → utils/math}/interpolation.py +139 -106
  367. oscura/utils/memory.py +129 -66
  368. oscura/utils/memory_advanced.py +92 -9
  369. oscura/utils/memory_extensions.py +10 -8
  370. oscura/{optimization → utils/optimization}/__init__.py +1 -1
  371. oscura/{optimization → utils/optimization}/search.py +2 -2
  372. oscura/utils/performance/__init__.py +58 -0
  373. oscura/utils/performance/caching.py +889 -0
  374. oscura/utils/performance/lsh_clustering.py +333 -0
  375. oscura/utils/performance/memory_optimizer.py +699 -0
  376. oscura/utils/performance/optimizations.py +675 -0
  377. oscura/utils/performance/parallel.py +654 -0
  378. oscura/utils/performance/profiling.py +661 -0
  379. oscura/{pipeline → utils/pipeline}/base.py +1 -1
  380. oscura/{pipeline → utils/pipeline}/composition.py +1 -1
  381. oscura/{pipeline → utils/pipeline}/parallel.py +3 -2
  382. oscura/{pipeline → utils/pipeline}/pipeline.py +1 -1
  383. oscura/{pipeline → utils/pipeline}/reverse_engineering.py +412 -221
  384. oscura/{search → utils/search}/__init__.py +3 -3
  385. oscura/{search → utils/search}/anomaly.py +188 -58
  386. oscura/utils/search/context.py +294 -0
  387. oscura/{search → utils/search}/pattern.py +138 -10
  388. oscura/utils/serial.py +51 -0
  389. oscura/utils/storage/__init__.py +61 -0
  390. oscura/utils/storage/database.py +1166 -0
  391. oscura/{streaming → utils/streaming}/chunked.py +302 -143
  392. oscura/{streaming → utils/streaming}/progressive.py +1 -1
  393. oscura/{streaming → utils/streaming}/realtime.py +3 -2
  394. oscura/{triggering → utils/triggering}/__init__.py +6 -6
  395. oscura/{triggering → utils/triggering}/base.py +6 -6
  396. oscura/{triggering → utils/triggering}/edge.py +2 -2
  397. oscura/{triggering → utils/triggering}/pattern.py +2 -2
  398. oscura/{triggering → utils/triggering}/pulse.py +115 -74
  399. oscura/{triggering → utils/triggering}/window.py +2 -2
  400. oscura/utils/validation.py +32 -0
  401. oscura/validation/__init__.py +121 -0
  402. oscura/{compliance → validation/compliance}/__init__.py +5 -5
  403. oscura/{compliance → validation/compliance}/advanced.py +5 -5
  404. oscura/{compliance → validation/compliance}/masks.py +1 -1
  405. oscura/{compliance → validation/compliance}/reporting.py +127 -53
  406. oscura/{compliance → validation/compliance}/testing.py +114 -52
  407. oscura/validation/compliance_tests.py +915 -0
  408. oscura/validation/fuzzer.py +990 -0
  409. oscura/validation/grammar_tests.py +596 -0
  410. oscura/validation/grammar_validator.py +904 -0
  411. oscura/validation/hil_testing.py +977 -0
  412. oscura/{quality → validation/quality}/__init__.py +4 -4
  413. oscura/{quality → validation/quality}/ensemble.py +251 -171
  414. oscura/{quality → validation/quality}/explainer.py +3 -3
  415. oscura/{quality → validation/quality}/scoring.py +1 -1
  416. oscura/{quality → validation/quality}/warnings.py +4 -4
  417. oscura/validation/regression_suite.py +808 -0
  418. oscura/validation/replay.py +788 -0
  419. oscura/{testing → validation/testing}/__init__.py +2 -2
  420. oscura/{testing → validation/testing}/synthetic.py +5 -5
  421. oscura/visualization/__init__.py +9 -0
  422. oscura/visualization/accessibility.py +1 -1
  423. oscura/visualization/annotations.py +64 -67
  424. oscura/visualization/colors.py +7 -7
  425. oscura/visualization/digital.py +180 -81
  426. oscura/visualization/eye.py +236 -85
  427. oscura/visualization/interactive.py +320 -143
  428. oscura/visualization/jitter.py +587 -247
  429. oscura/visualization/layout.py +169 -134
  430. oscura/visualization/optimization.py +103 -52
  431. oscura/visualization/palettes.py +1 -1
  432. oscura/visualization/power.py +427 -211
  433. oscura/visualization/power_extended.py +626 -297
  434. oscura/visualization/presets.py +2 -0
  435. oscura/visualization/protocols.py +495 -181
  436. oscura/visualization/render.py +79 -63
  437. oscura/visualization/reverse_engineering.py +171 -124
  438. oscura/visualization/signal_integrity.py +460 -279
  439. oscura/visualization/specialized.py +190 -100
  440. oscura/visualization/spectral.py +670 -255
  441. oscura/visualization/thumbnails.py +166 -137
  442. oscura/visualization/waveform.py +150 -63
  443. oscura/workflows/__init__.py +3 -0
  444. oscura/{batch → workflows/batch}/__init__.py +5 -5
  445. oscura/{batch → workflows/batch}/advanced.py +150 -75
  446. oscura/workflows/batch/aggregate.py +531 -0
  447. oscura/workflows/batch/analyze.py +236 -0
  448. oscura/{batch → workflows/batch}/logging.py +2 -2
  449. oscura/{batch → workflows/batch}/metrics.py +1 -1
  450. oscura/workflows/complete_re.py +1144 -0
  451. oscura/workflows/compliance.py +44 -54
  452. oscura/workflows/digital.py +197 -51
  453. oscura/workflows/legacy/__init__.py +12 -0
  454. oscura/{workflow → workflows/legacy}/dag.py +4 -1
  455. oscura/workflows/multi_trace.py +9 -9
  456. oscura/workflows/power.py +42 -62
  457. oscura/workflows/protocol.py +82 -49
  458. oscura/workflows/reverse_engineering.py +351 -150
  459. oscura/workflows/signal_integrity.py +157 -82
  460. oscura-0.7.0.dist-info/METADATA +661 -0
  461. oscura-0.7.0.dist-info/RECORD +591 -0
  462. oscura/batch/aggregate.py +0 -300
  463. oscura/batch/analyze.py +0 -139
  464. oscura/dsl/__init__.py +0 -73
  465. oscura/exceptions.py +0 -59
  466. oscura/exploratory/fuzzy.py +0 -513
  467. oscura/exploratory/sync.py +0 -384
  468. oscura/exporters/__init__.py +0 -94
  469. oscura/exporters/csv.py +0 -303
  470. oscura/exporters/exporters.py +0 -44
  471. oscura/exporters/hdf5.py +0 -217
  472. oscura/exporters/html_export.py +0 -701
  473. oscura/exporters/json_export.py +0 -291
  474. oscura/exporters/markdown_export.py +0 -367
  475. oscura/exporters/matlab_export.py +0 -354
  476. oscura/exporters/npz_export.py +0 -219
  477. oscura/exporters/spice_export.py +0 -210
  478. oscura/search/context.py +0 -149
  479. oscura/session/__init__.py +0 -34
  480. oscura/session/annotations.py +0 -289
  481. oscura/session/history.py +0 -313
  482. oscura/session/session.py +0 -520
  483. oscura/workflow/__init__.py +0 -13
  484. oscura-0.5.1.dist-info/METADATA +0 -583
  485. oscura-0.5.1.dist-info/RECORD +0 -481
  486. /oscura/core/{config.py → config/legacy.py} +0 -0
  487. /oscura/{extensibility → core/extensibility}/__init__.py +0 -0
  488. /oscura/{extensibility → core/extensibility}/registry.py +0 -0
  489. /oscura/{plugins → core/plugins}/isolation.py +0 -0
  490. /oscura/{schemas → core/schemas}/bus_configuration.json +0 -0
  491. /oscura/{builders → utils/builders}/signal_builder.py +0 -0
  492. /oscura/{optimization → utils/optimization}/parallel.py +0 -0
  493. /oscura/{pipeline → utils/pipeline}/__init__.py +0 -0
  494. /oscura/{streaming → utils/streaming}/__init__.py +0 -0
  495. {oscura-0.5.1.dist-info → oscura-0.7.0.dist-info}/WHEEL +0 -0
  496. {oscura-0.5.1.dist-info → oscura-0.7.0.dist-info}/entry_points.txt +0 -0
  497. {oscura-0.5.1.dist-info → oscura-0.7.0.dist-info}/licenses/LICENSE +0 -0
@@ -173,109 +173,46 @@ def _load_with_pandas(
173
173
  skip_rows: int,
174
174
  encoding: str,
175
175
  ) -> WaveformTrace:
176
- """Load CSV using pandas for better parsing."""
177
- try:
178
- # Auto-detect delimiter if not specified
179
- if delimiter is None:
180
- delimiter = _detect_delimiter(path, encoding)
176
+ """Load CSV using pandas for better parsing.
177
+
178
+ Args:
179
+ path: Path to CSV file.
180
+ time_column: Name or index of time column (None for auto-detect).
181
+ voltage_column: Name or index of voltage column (None for auto-detect).
182
+ sample_rate: Override sample rate (None to compute from time column).
183
+ delimiter: Column delimiter (None for auto-detect).
184
+ skip_rows: Number of rows to skip before header.
185
+ encoding: File encoding.
186
+
187
+ Returns:
188
+ WaveformTrace containing waveform data and metadata.
181
189
 
190
+ Raises:
191
+ FormatError: If CSV format is invalid or missing data.
192
+ LoaderError: If file cannot be loaded.
193
+ """
194
+ try:
182
195
  # Read CSV with pandas
183
- df = pd.read_csv(
184
- path,
185
- delimiter=delimiter,
186
- skiprows=skip_rows,
187
- encoding=encoding,
188
- engine="python", # More flexible parsing
196
+ delimiter = delimiter or _detect_delimiter(path, encoding)
197
+ df = _read_csv_with_pandas(path, delimiter, skip_rows, encoding)
198
+
199
+ # Find time and voltage columns
200
+ time_data, time_col_name = _find_pandas_time_column(df, time_column)
201
+ voltage_data, voltage_col_name = _find_pandas_voltage_column(
202
+ df, voltage_column, time_col_name, path
189
203
  )
190
204
 
191
- if df.empty:
192
- raise FormatError(
193
- "CSV file is empty",
194
- file_path=str(path),
195
- )
196
-
197
- # Find time column
198
- time_data = None
199
- time_col_name = None
200
-
201
- if time_column is not None:
202
- if isinstance(time_column, int):
203
- if time_column < len(df.columns):
204
- time_col_name = df.columns[time_column]
205
- time_data = df.iloc[:, time_column].values
206
- elif time_column in df.columns:
207
- time_col_name = time_column
208
- time_data = df[time_column].values
209
- else:
210
- # Auto-detect time column
211
- for col in df.columns:
212
- col_lower = col.lower().strip()
213
- if col_lower in [n.lower() for n in TIME_COLUMN_NAMES]:
214
- time_col_name = col
215
- time_data = df[col].values
216
- break
205
+ # Compute sample rate from time data if not provided
206
+ detected_sample_rate = _compute_sample_rate_from_array(sample_rate, time_data)
217
207
 
218
- # Find voltage column
219
- voltage_data = None
220
- voltage_col_name = None
221
-
222
- if voltage_column is not None:
223
- if isinstance(voltage_column, int):
224
- if voltage_column < len(df.columns):
225
- voltage_col_name = df.columns[voltage_column]
226
- voltage_data = df.iloc[:, voltage_column].values
227
- elif voltage_column in df.columns:
228
- voltage_col_name = voltage_column
229
- voltage_data = df[voltage_column].values
230
- else:
231
- # Auto-detect voltage column (first non-time numeric column)
232
- for col in df.columns:
233
- if col == time_col_name:
234
- continue
235
- col_lower = col.lower().strip()
236
- # Check if numeric
237
- if pd.api.types.is_numeric_dtype(df[col]):
238
- # Prefer columns with voltage-like names
239
- if col_lower in [n.lower() for n in VOLTAGE_COLUMN_NAMES]:
240
- voltage_col_name = col
241
- voltage_data = df[col].values
242
- break
243
- elif voltage_data is None:
244
- voltage_col_name = col
245
- voltage_data = df[col].values
246
-
247
- if voltage_data is None:
248
- raise FormatError(
249
- "No voltage data found in CSV",
250
- file_path=str(path),
251
- expected="Numeric column for voltage data",
252
- got=f"Columns: {', '.join(df.columns)}",
253
- )
254
-
255
- # Convert to float64
256
- data = np.asarray(voltage_data, dtype=np.float64)
257
-
258
- # Determine sample rate
259
- detected_sample_rate = sample_rate
260
- if detected_sample_rate is None and time_data is not None:
261
- time_data = np.asarray(time_data, dtype=np.float64)
262
- if len(time_data) > 1:
263
- # Calculate sample rate from time intervals
264
- dt = np.median(np.diff(time_data))
265
- if dt > 0:
266
- detected_sample_rate = 1.0 / dt
267
-
268
- if detected_sample_rate is None:
269
- detected_sample_rate = 1e6 # Default to 1 MSa/s
270
-
271
- # Build metadata
208
+ # Create metadata and trace
272
209
  metadata = TraceMetadata(
273
210
  sample_rate=detected_sample_rate,
274
211
  source_file=str(path),
275
212
  channel_name=voltage_col_name or "CH1",
276
213
  )
277
214
 
278
- return WaveformTrace(data=data, metadata=metadata)
215
+ return WaveformTrace(data=np.asarray(voltage_data, dtype=np.float64), metadata=metadata)
279
216
 
280
217
  except pd.errors.ParserError as e:
281
218
  raise FormatError(
@@ -293,6 +230,151 @@ def _load_with_pandas(
293
230
  ) from e
294
231
 
295
232
 
233
+ def _read_csv_with_pandas(path: Path, delimiter: str, skip_rows: int, encoding: str) -> Any:
234
+ """Read CSV file using pandas.
235
+
236
+ Args:
237
+ path: Path to CSV file.
238
+ delimiter: Column delimiter.
239
+ skip_rows: Number of rows to skip before header.
240
+ encoding: File encoding.
241
+
242
+ Returns:
243
+ Pandas DataFrame.
244
+
245
+ Raises:
246
+ FormatError: If CSV is empty.
247
+ """
248
+ df = pd.read_csv(
249
+ path,
250
+ delimiter=delimiter,
251
+ skiprows=skip_rows,
252
+ encoding=encoding,
253
+ engine="python", # More flexible parsing
254
+ )
255
+
256
+ if df.empty:
257
+ raise FormatError("CSV file is empty", file_path=str(path))
258
+
259
+ return df
260
+
261
+
262
+ def _find_pandas_time_column(
263
+ df: Any, time_column: str | int | None
264
+ ) -> tuple[Any | None, str | None]:
265
+ """Find time column in pandas DataFrame.
266
+
267
+ Args:
268
+ df: Pandas DataFrame.
269
+ time_column: User-specified time column name or index.
270
+
271
+ Returns:
272
+ Tuple of (time_data, time_column_name).
273
+ """
274
+ time_data = None
275
+ time_col_name = None
276
+
277
+ if time_column is not None:
278
+ if isinstance(time_column, int):
279
+ if time_column < len(df.columns):
280
+ time_col_name = df.columns[time_column]
281
+ time_data = df.iloc[:, time_column].values
282
+ elif time_column in df.columns:
283
+ time_col_name = time_column
284
+ time_data = df[time_column].values
285
+ else:
286
+ # Auto-detect time column
287
+ for col in df.columns:
288
+ col_lower = col.lower().strip()
289
+ if col_lower in [n.lower() for n in TIME_COLUMN_NAMES]:
290
+ time_col_name = col
291
+ time_data = df[col].values
292
+ break
293
+
294
+ return time_data, time_col_name
295
+
296
+
297
+ def _find_pandas_voltage_column(
298
+ df: Any, voltage_column: str | int | None, time_col_name: str | None, path: Path
299
+ ) -> tuple[Any, str | None]:
300
+ """Find voltage column in pandas DataFrame.
301
+
302
+ Args:
303
+ df: Pandas DataFrame.
304
+ voltage_column: User-specified voltage column name or index.
305
+ time_col_name: Name of time column (to exclude from voltage search).
306
+ path: Path to CSV file (for error reporting).
307
+
308
+ Returns:
309
+ Tuple of (voltage_data, voltage_column_name).
310
+
311
+ Raises:
312
+ FormatError: If no voltage data found.
313
+ """
314
+ voltage_data = None
315
+ voltage_col_name = None
316
+
317
+ if voltage_column is not None:
318
+ if isinstance(voltage_column, int):
319
+ if voltage_column < len(df.columns):
320
+ voltage_col_name = df.columns[voltage_column]
321
+ voltage_data = df.iloc[:, voltage_column].values
322
+ elif voltage_column in df.columns:
323
+ voltage_col_name = voltage_column
324
+ voltage_data = df[voltage_column].values
325
+ else:
326
+ # Auto-detect voltage column (first non-time numeric column)
327
+ for col in df.columns:
328
+ if col == time_col_name:
329
+ continue
330
+
331
+ col_lower = col.lower().strip()
332
+
333
+ # Check if numeric
334
+ if pd.api.types.is_numeric_dtype(df[col]):
335
+ # Prefer columns with voltage-like names
336
+ if col_lower in [n.lower() for n in VOLTAGE_COLUMN_NAMES]:
337
+ voltage_col_name = col
338
+ voltage_data = df[col].values
339
+ break
340
+ elif voltage_data is None:
341
+ voltage_col_name = col
342
+ voltage_data = df[col].values
343
+
344
+ if voltage_data is None:
345
+ raise FormatError(
346
+ "No voltage data found in CSV",
347
+ file_path=str(path),
348
+ expected="Numeric column for voltage data",
349
+ got=f"Columns: {', '.join(df.columns)}",
350
+ )
351
+
352
+ return voltage_data, voltage_col_name
353
+
354
+
355
+ def _compute_sample_rate_from_array(sample_rate: float | None, time_data: Any | None) -> float:
356
+ """Compute sample rate from numpy array or use override.
357
+
358
+ Args:
359
+ sample_rate: User-specified sample rate (None to compute).
360
+ time_data: Numpy array of time values.
361
+
362
+ Returns:
363
+ Sample rate in Hz. Defaults to 1 MHz if cannot be computed.
364
+ """
365
+ if sample_rate is not None:
366
+ return sample_rate
367
+
368
+ if time_data is not None:
369
+ time_arr = np.asarray(time_data, dtype=np.float64)
370
+ if len(time_arr) > 1:
371
+ dt = float(np.median(np.diff(time_arr)))
372
+ if dt > 0:
373
+ return 1.0 / dt
374
+
375
+ return 1e6 # Default to 1 MSa/s
376
+
377
+
296
378
  def _load_basic(
297
379
  path: Path,
298
380
  *,
@@ -303,137 +385,55 @@ def _load_basic(
303
385
  skip_rows: int,
304
386
  encoding: str,
305
387
  ) -> WaveformTrace:
306
- """Basic CSV loader without pandas."""
388
+ """Basic CSV loader without pandas.
389
+
390
+ Args:
391
+ path: Path to CSV file.
392
+ time_column: Name or index of time column (None for auto-detect).
393
+ voltage_column: Name or index of voltage column (None for auto-detect).
394
+ sample_rate: Override sample rate (None to compute from time column).
395
+ delimiter: Column delimiter (None for auto-detect).
396
+ skip_rows: Number of rows to skip before header.
397
+ encoding: File encoding.
398
+
399
+ Returns:
400
+ WaveformTrace containing waveform data and metadata.
401
+
402
+ Raises:
403
+ FormatError: If CSV format is invalid or missing data.
404
+ LoaderError: If file cannot be loaded.
405
+ """
307
406
  try:
308
- with open(path, encoding=encoding) as f:
309
- # Skip rows
310
- for _ in range(skip_rows):
311
- next(f)
312
-
313
- content = f.read()
314
-
315
- # Auto-detect delimiter
316
- if delimiter is None:
317
- delimiter = _detect_delimiter_from_content(content)
318
-
319
- # Parse CSV
320
- reader = csv.reader(StringIO(content), delimiter=delimiter)
321
- rows = list(reader)
322
-
323
- if not rows:
324
- raise FormatError("CSV file is empty", file_path=str(path))
325
-
326
- # Detect header
327
- header = None
328
- data_start = 0
329
- first_row = rows[0]
330
-
331
- # Check if first row is a header (contains non-numeric values)
332
- is_header = False
333
- for cell in first_row:
334
- try:
335
- float(cell)
336
- except ValueError:
337
- if cell.strip(): # Non-empty, non-numeric
338
- is_header = True
339
- break
407
+ # Read and parse CSV
408
+ content = _read_file_content(path, skip_rows, encoding)
409
+ delimiter = delimiter or _detect_delimiter_from_content(content)
410
+ rows = _parse_csv_rows(content, delimiter, path)
340
411
 
341
- if is_header:
342
- header = [cell.strip() for cell in first_row]
343
- data_start = 1
344
-
345
- # Determine column indices
346
- time_idx = None
347
- voltage_idx = None
348
-
349
- if header:
350
- # Find columns by name
351
- if time_column is not None:
352
- if isinstance(time_column, int):
353
- time_idx = time_column
354
- elif time_column in header:
355
- time_idx = header.index(time_column)
356
- else:
357
- # Auto-detect
358
- for i, col in enumerate(header):
359
- if col.lower() in [n.lower() for n in TIME_COLUMN_NAMES]:
360
- time_idx = i
361
- break
362
-
363
- if voltage_column is not None:
364
- if isinstance(voltage_column, int):
365
- voltage_idx = voltage_column
366
- elif voltage_column in header:
367
- voltage_idx = header.index(voltage_column)
368
- else:
369
- # Auto-detect (first column that's not time)
370
- for i, col in enumerate(header):
371
- if i == time_idx:
372
- continue
373
- if col.lower() in [n.lower() for n in VOLTAGE_COLUMN_NAMES]:
374
- voltage_idx = i
375
- break
376
- if voltage_idx is None:
377
- voltage_idx = 1 if time_idx == 0 else 0
378
- else:
379
- # No header - use indices
380
- if isinstance(time_column, int):
381
- time_idx = time_column
382
- else:
383
- time_idx = 0 # Assume first column is time
384
-
385
- if isinstance(voltage_column, int):
386
- voltage_idx = voltage_column
387
- else:
388
- voltage_idx = 1 # Assume second column is voltage
389
-
390
- # Extract data
391
- time_data = []
392
- voltage_data = []
393
-
394
- for row in rows[data_start:]:
395
- if not row:
396
- continue
397
- try:
398
- if voltage_idx is not None and voltage_idx < len(row):
399
- voltage_data.append(float(row[voltage_idx]))
400
- if time_idx is not None and time_idx < len(row):
401
- time_data.append(float(row[time_idx]))
402
- except (ValueError, IndexError):
403
- continue # Skip malformed rows
404
-
405
- if not voltage_data:
406
- raise FormatError(
407
- "No valid voltage data found in CSV",
408
- file_path=str(path),
409
- )
410
-
411
- data = np.array(voltage_data, dtype=np.float64)
412
-
413
- # Determine sample rate
414
- detected_sample_rate = sample_rate
415
- if detected_sample_rate is None and time_data:
416
- time_arr = np.array(time_data, dtype=np.float64)
417
- if len(time_arr) > 1:
418
- dt = np.median(np.diff(time_arr))
419
- if dt > 0:
420
- detected_sample_rate = 1.0 / dt
421
-
422
- if detected_sample_rate is None:
423
- detected_sample_rate = 1e6
424
-
425
- # Channel name
426
- channel_name = "CH1"
427
- if header and voltage_idx is not None and voltage_idx < len(header):
428
- channel_name = header[voltage_idx]
412
+ # Detect header and determine data start position
413
+ header, data_start = _detect_header(rows)
414
+
415
+ # Find column indices for time and voltage data
416
+ time_idx, voltage_idx = _determine_column_indices(header, time_column, voltage_column)
417
+
418
+ # Extract numeric data from rows
419
+ time_data, voltage_data = _extract_data_from_rows(
420
+ rows, data_start, time_idx, voltage_idx, path
421
+ )
422
+
423
+ # Calculate sample rate from time data if not provided
424
+ detected_sample_rate = _compute_sample_rate(sample_rate, time_data)
429
425
 
426
+ # Build channel name from header if available
427
+ channel_name = _get_channel_name(header, voltage_idx)
428
+
429
+ # Create metadata and trace
430
430
  metadata = TraceMetadata(
431
431
  sample_rate=detected_sample_rate,
432
432
  source_file=str(path),
433
433
  channel_name=channel_name,
434
434
  )
435
435
 
436
- return WaveformTrace(data=data, metadata=metadata)
436
+ return WaveformTrace(data=np.array(voltage_data, dtype=np.float64), metadata=metadata)
437
437
 
438
438
  except Exception as e:
439
439
  if isinstance(e, LoaderError | FormatError):
@@ -445,10 +445,253 @@ def _load_basic(
445
445
  ) from e
446
446
 
447
447
 
448
+ def _read_file_content(path: Path, skip_rows: int, encoding: str) -> str:
449
+ """Read file content after skipping specified rows.
450
+
451
+ Args:
452
+ path: Path to CSV file.
453
+ skip_rows: Number of rows to skip.
454
+ encoding: File encoding.
455
+
456
+ Returns:
457
+ File content as string.
458
+ """
459
+ with open(path, encoding=encoding, buffering=65536) as f:
460
+ for _ in range(skip_rows):
461
+ next(f)
462
+ return f.read()
463
+
464
+
465
+ def _parse_csv_rows(content: str, delimiter: str, path: Path) -> list[list[str]]:
466
+ """Parse CSV content into rows.
467
+
468
+ Args:
469
+ content: CSV file content.
470
+ delimiter: Column delimiter.
471
+ path: Path to CSV file (for error reporting).
472
+
473
+ Returns:
474
+ List of rows, where each row is a list of cell values.
475
+
476
+ Raises:
477
+ FormatError: If CSV is empty.
478
+ """
479
+ reader = csv.reader(StringIO(content), delimiter=delimiter)
480
+ rows = list(reader)
481
+
482
+ if not rows:
483
+ raise FormatError("CSV file is empty", file_path=str(path))
484
+
485
+ return rows
486
+
487
+
488
+ def _detect_header(rows: list[list[str]]) -> tuple[list[str] | None, int]:
489
+ """Detect if first row is a header row.
490
+
491
+ Args:
492
+ rows: Parsed CSV rows.
493
+
494
+ Returns:
495
+ Tuple of (header row, data start index).
496
+ If no header detected, returns (None, 0).
497
+ """
498
+ first_row = rows[0]
499
+
500
+ # Check if first row contains non-numeric values (indicates header)
501
+ for cell in first_row:
502
+ try:
503
+ float(cell)
504
+ except ValueError:
505
+ if cell.strip(): # Non-empty, non-numeric
506
+ return [cell.strip() for cell in first_row], 1
507
+
508
+ return None, 0
509
+
510
+
511
+ def _determine_column_indices(
512
+ header: list[str] | None,
513
+ time_column: str | int | None,
514
+ voltage_column: str | int | None,
515
+ ) -> tuple[int | None, int | None]:
516
+ """Determine column indices for time and voltage data.
517
+
518
+ Args:
519
+ header: Header row if detected, None otherwise.
520
+ time_column: User-specified time column name or index.
521
+ voltage_column: User-specified voltage column name or index.
522
+
523
+ Returns:
524
+ Tuple of (time_index, voltage_index).
525
+ """
526
+ if header:
527
+ time_idx = _find_time_column_index(header, time_column)
528
+ voltage_idx = _find_voltage_column_index(header, voltage_column, time_idx)
529
+ else:
530
+ time_idx = _get_index_or_default(time_column, 0)
531
+ voltage_idx = _get_index_or_default(voltage_column, 1)
532
+
533
+ return time_idx, voltage_idx
534
+
535
+
536
+ def _find_time_column_index(header: list[str], time_column: str | int | None) -> int | None:
537
+ """Find time column index from header.
538
+
539
+ Args:
540
+ header: Header row.
541
+ time_column: User-specified time column name or index.
542
+
543
+ Returns:
544
+ Time column index, or None if not found.
545
+ """
546
+ if time_column is not None:
547
+ if isinstance(time_column, int):
548
+ return time_column
549
+ if time_column in header:
550
+ return header.index(time_column)
551
+ else:
552
+ # Auto-detect
553
+ for i, col in enumerate(header):
554
+ if col.lower() in [n.lower() for n in TIME_COLUMN_NAMES]:
555
+ return i
556
+
557
+ return None
558
+
559
+
560
+ def _find_voltage_column_index(
561
+ header: list[str], voltage_column: str | int | None, time_idx: int | None
562
+ ) -> int | None:
563
+ """Find voltage column index from header.
564
+
565
+ Args:
566
+ header: Header row.
567
+ voltage_column: User-specified voltage column name or index.
568
+ time_idx: Time column index (to exclude from voltage search).
569
+
570
+ Returns:
571
+ Voltage column index.
572
+ """
573
+ if voltage_column is not None:
574
+ if isinstance(voltage_column, int):
575
+ return voltage_column
576
+ if voltage_column in header:
577
+ return header.index(voltage_column)
578
+ else:
579
+ # Auto-detect (first column that's not time)
580
+ for i, col in enumerate(header):
581
+ if i == time_idx:
582
+ continue
583
+ if col.lower() in [n.lower() for n in VOLTAGE_COLUMN_NAMES]:
584
+ return i
585
+
586
+ # Default: column 1 if time is 0, otherwise column 0
587
+ return 1 if time_idx == 0 else 0
588
+
589
+ return None
590
+
591
+
592
+ def _get_index_or_default(column: str | int | None, default: int) -> int:
593
+ """Get column index or return default.
594
+
595
+ Args:
596
+ column: User-specified column (int or string).
597
+ default: Default index if column is not an int.
598
+
599
+ Returns:
600
+ Column index.
601
+ """
602
+ if isinstance(column, int):
603
+ return column
604
+ return default
605
+
606
+
607
+ def _extract_data_from_rows(
608
+ rows: list[list[str]],
609
+ data_start: int,
610
+ time_idx: int | None,
611
+ voltage_idx: int | None,
612
+ path: Path,
613
+ ) -> tuple[list[float], list[float]]:
614
+ """Extract numeric data from CSV rows.
615
+
616
+ Args:
617
+ rows: Parsed CSV rows.
618
+ data_start: Index of first data row.
619
+ time_idx: Time column index.
620
+ voltage_idx: Voltage column index.
621
+ path: Path to CSV file (for error reporting).
622
+
623
+ Returns:
624
+ Tuple of (time_data, voltage_data) lists.
625
+
626
+ Raises:
627
+ FormatError: If no valid voltage data found.
628
+ """
629
+ time_data: list[float] = []
630
+ voltage_data: list[float] = []
631
+
632
+ for row in rows[data_start:]:
633
+ if not row:
634
+ continue
635
+
636
+ try:
637
+ if voltage_idx is not None and voltage_idx < len(row):
638
+ voltage_data.append(float(row[voltage_idx]))
639
+ if time_idx is not None and time_idx < len(row):
640
+ time_data.append(float(row[time_idx]))
641
+ except (ValueError, IndexError):
642
+ continue # Skip malformed rows
643
+
644
+ if not voltage_data:
645
+ raise FormatError(
646
+ "No valid voltage data found in CSV",
647
+ file_path=str(path),
648
+ )
649
+
650
+ return time_data, voltage_data
651
+
652
+
653
+ def _compute_sample_rate(sample_rate: float | None, time_data: list[float]) -> float:
654
+ """Compute sample rate from time data or use override.
655
+
656
+ Args:
657
+ sample_rate: User-specified sample rate (None to compute).
658
+ time_data: List of time values.
659
+
660
+ Returns:
661
+ Sample rate in Hz. Defaults to 1 MHz if cannot be computed.
662
+ """
663
+ if sample_rate is not None:
664
+ return sample_rate
665
+
666
+ if time_data:
667
+ time_arr = np.array(time_data, dtype=np.float64)
668
+ if len(time_arr) > 1:
669
+ dt = float(np.median(np.diff(time_arr)))
670
+ if dt > 0:
671
+ return 1.0 / dt
672
+
673
+ return 1e6 # Default to 1 MSa/s
674
+
675
+
676
+ def _get_channel_name(header: list[str] | None, voltage_idx: int | None) -> str:
677
+ """Get channel name from header or use default.
678
+
679
+ Args:
680
+ header: Header row if available.
681
+ voltage_idx: Voltage column index.
682
+
683
+ Returns:
684
+ Channel name string.
685
+ """
686
+ if header and voltage_idx is not None and voltage_idx < len(header):
687
+ return header[voltage_idx]
688
+ return "CH1"
689
+
690
+
448
691
  def _detect_delimiter(path: Path, encoding: str) -> str:
449
692
  """Detect the delimiter used in a CSV file."""
450
693
  try:
451
- with open(path, encoding=encoding) as f:
694
+ with open(path, encoding=encoding, buffering=65536) as f:
452
695
  sample = f.read(4096)
453
696
  return _detect_delimiter_from_content(sample)
454
697
  except Exception: