oscura 0.0.1__py3-none-any.whl → 0.1.1__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 (465) hide show
  1. oscura/__init__.py +813 -8
  2. oscura/__main__.py +392 -0
  3. oscura/analyzers/__init__.py +37 -0
  4. oscura/analyzers/digital/__init__.py +177 -0
  5. oscura/analyzers/digital/bus.py +691 -0
  6. oscura/analyzers/digital/clock.py +805 -0
  7. oscura/analyzers/digital/correlation.py +720 -0
  8. oscura/analyzers/digital/edges.py +632 -0
  9. oscura/analyzers/digital/extraction.py +413 -0
  10. oscura/analyzers/digital/quality.py +878 -0
  11. oscura/analyzers/digital/signal_quality.py +877 -0
  12. oscura/analyzers/digital/thresholds.py +708 -0
  13. oscura/analyzers/digital/timing.py +1104 -0
  14. oscura/analyzers/eye/__init__.py +46 -0
  15. oscura/analyzers/eye/diagram.py +434 -0
  16. oscura/analyzers/eye/metrics.py +555 -0
  17. oscura/analyzers/jitter/__init__.py +83 -0
  18. oscura/analyzers/jitter/ber.py +333 -0
  19. oscura/analyzers/jitter/decomposition.py +759 -0
  20. oscura/analyzers/jitter/measurements.py +413 -0
  21. oscura/analyzers/jitter/spectrum.py +220 -0
  22. oscura/analyzers/measurements.py +40 -0
  23. oscura/analyzers/packet/__init__.py +171 -0
  24. oscura/analyzers/packet/daq.py +1077 -0
  25. oscura/analyzers/packet/metrics.py +437 -0
  26. oscura/analyzers/packet/parser.py +327 -0
  27. oscura/analyzers/packet/payload.py +2156 -0
  28. oscura/analyzers/packet/payload_analysis.py +1312 -0
  29. oscura/analyzers/packet/payload_extraction.py +236 -0
  30. oscura/analyzers/packet/payload_patterns.py +670 -0
  31. oscura/analyzers/packet/stream.py +359 -0
  32. oscura/analyzers/patterns/__init__.py +266 -0
  33. oscura/analyzers/patterns/clustering.py +1036 -0
  34. oscura/analyzers/patterns/discovery.py +539 -0
  35. oscura/analyzers/patterns/learning.py +797 -0
  36. oscura/analyzers/patterns/matching.py +1091 -0
  37. oscura/analyzers/patterns/periodic.py +650 -0
  38. oscura/analyzers/patterns/sequences.py +767 -0
  39. oscura/analyzers/power/__init__.py +116 -0
  40. oscura/analyzers/power/ac_power.py +391 -0
  41. oscura/analyzers/power/basic.py +383 -0
  42. oscura/analyzers/power/conduction.py +314 -0
  43. oscura/analyzers/power/efficiency.py +297 -0
  44. oscura/analyzers/power/ripple.py +356 -0
  45. oscura/analyzers/power/soa.py +372 -0
  46. oscura/analyzers/power/switching.py +479 -0
  47. oscura/analyzers/protocol/__init__.py +150 -0
  48. oscura/analyzers/protocols/__init__.py +150 -0
  49. oscura/analyzers/protocols/base.py +500 -0
  50. oscura/analyzers/protocols/can.py +620 -0
  51. oscura/analyzers/protocols/can_fd.py +448 -0
  52. oscura/analyzers/protocols/flexray.py +405 -0
  53. oscura/analyzers/protocols/hdlc.py +399 -0
  54. oscura/analyzers/protocols/i2c.py +368 -0
  55. oscura/analyzers/protocols/i2s.py +296 -0
  56. oscura/analyzers/protocols/jtag.py +393 -0
  57. oscura/analyzers/protocols/lin.py +445 -0
  58. oscura/analyzers/protocols/manchester.py +333 -0
  59. oscura/analyzers/protocols/onewire.py +501 -0
  60. oscura/analyzers/protocols/spi.py +334 -0
  61. oscura/analyzers/protocols/swd.py +325 -0
  62. oscura/analyzers/protocols/uart.py +393 -0
  63. oscura/analyzers/protocols/usb.py +495 -0
  64. oscura/analyzers/signal_integrity/__init__.py +63 -0
  65. oscura/analyzers/signal_integrity/embedding.py +294 -0
  66. oscura/analyzers/signal_integrity/equalization.py +370 -0
  67. oscura/analyzers/signal_integrity/sparams.py +484 -0
  68. oscura/analyzers/spectral/__init__.py +53 -0
  69. oscura/analyzers/spectral/chunked.py +273 -0
  70. oscura/analyzers/spectral/chunked_fft.py +571 -0
  71. oscura/analyzers/spectral/chunked_wavelet.py +391 -0
  72. oscura/analyzers/spectral/fft.py +92 -0
  73. oscura/analyzers/statistical/__init__.py +250 -0
  74. oscura/analyzers/statistical/checksum.py +923 -0
  75. oscura/analyzers/statistical/chunked_corr.py +228 -0
  76. oscura/analyzers/statistical/classification.py +778 -0
  77. oscura/analyzers/statistical/entropy.py +1113 -0
  78. oscura/analyzers/statistical/ngrams.py +614 -0
  79. oscura/analyzers/statistics/__init__.py +119 -0
  80. oscura/analyzers/statistics/advanced.py +885 -0
  81. oscura/analyzers/statistics/basic.py +263 -0
  82. oscura/analyzers/statistics/correlation.py +630 -0
  83. oscura/analyzers/statistics/distribution.py +298 -0
  84. oscura/analyzers/statistics/outliers.py +463 -0
  85. oscura/analyzers/statistics/streaming.py +93 -0
  86. oscura/analyzers/statistics/trend.py +520 -0
  87. oscura/analyzers/validation.py +598 -0
  88. oscura/analyzers/waveform/__init__.py +36 -0
  89. oscura/analyzers/waveform/measurements.py +943 -0
  90. oscura/analyzers/waveform/measurements_with_uncertainty.py +371 -0
  91. oscura/analyzers/waveform/spectral.py +1689 -0
  92. oscura/analyzers/waveform/wavelets.py +298 -0
  93. oscura/api/__init__.py +62 -0
  94. oscura/api/dsl.py +538 -0
  95. oscura/api/fluent.py +571 -0
  96. oscura/api/operators.py +498 -0
  97. oscura/api/optimization.py +392 -0
  98. oscura/api/profiling.py +396 -0
  99. oscura/automotive/__init__.py +73 -0
  100. oscura/automotive/can/__init__.py +52 -0
  101. oscura/automotive/can/analysis.py +356 -0
  102. oscura/automotive/can/checksum.py +250 -0
  103. oscura/automotive/can/correlation.py +212 -0
  104. oscura/automotive/can/discovery.py +355 -0
  105. oscura/automotive/can/message_wrapper.py +375 -0
  106. oscura/automotive/can/models.py +385 -0
  107. oscura/automotive/can/patterns.py +381 -0
  108. oscura/automotive/can/session.py +452 -0
  109. oscura/automotive/can/state_machine.py +300 -0
  110. oscura/automotive/can/stimulus_response.py +461 -0
  111. oscura/automotive/dbc/__init__.py +15 -0
  112. oscura/automotive/dbc/generator.py +156 -0
  113. oscura/automotive/dbc/parser.py +146 -0
  114. oscura/automotive/dtc/__init__.py +30 -0
  115. oscura/automotive/dtc/database.py +3036 -0
  116. oscura/automotive/j1939/__init__.py +14 -0
  117. oscura/automotive/j1939/decoder.py +745 -0
  118. oscura/automotive/loaders/__init__.py +35 -0
  119. oscura/automotive/loaders/asc.py +98 -0
  120. oscura/automotive/loaders/blf.py +77 -0
  121. oscura/automotive/loaders/csv_can.py +136 -0
  122. oscura/automotive/loaders/dispatcher.py +136 -0
  123. oscura/automotive/loaders/mdf.py +331 -0
  124. oscura/automotive/loaders/pcap.py +132 -0
  125. oscura/automotive/obd/__init__.py +14 -0
  126. oscura/automotive/obd/decoder.py +707 -0
  127. oscura/automotive/uds/__init__.py +48 -0
  128. oscura/automotive/uds/decoder.py +265 -0
  129. oscura/automotive/uds/models.py +64 -0
  130. oscura/automotive/visualization.py +369 -0
  131. oscura/batch/__init__.py +55 -0
  132. oscura/batch/advanced.py +627 -0
  133. oscura/batch/aggregate.py +300 -0
  134. oscura/batch/analyze.py +139 -0
  135. oscura/batch/logging.py +487 -0
  136. oscura/batch/metrics.py +556 -0
  137. oscura/builders/__init__.py +41 -0
  138. oscura/builders/signal_builder.py +1131 -0
  139. oscura/cli/__init__.py +14 -0
  140. oscura/cli/batch.py +339 -0
  141. oscura/cli/characterize.py +273 -0
  142. oscura/cli/compare.py +775 -0
  143. oscura/cli/decode.py +551 -0
  144. oscura/cli/main.py +247 -0
  145. oscura/cli/shell.py +350 -0
  146. oscura/comparison/__init__.py +66 -0
  147. oscura/comparison/compare.py +397 -0
  148. oscura/comparison/golden.py +487 -0
  149. oscura/comparison/limits.py +391 -0
  150. oscura/comparison/mask.py +434 -0
  151. oscura/comparison/trace_diff.py +30 -0
  152. oscura/comparison/visualization.py +481 -0
  153. oscura/compliance/__init__.py +70 -0
  154. oscura/compliance/advanced.py +756 -0
  155. oscura/compliance/masks.py +363 -0
  156. oscura/compliance/reporting.py +483 -0
  157. oscura/compliance/testing.py +298 -0
  158. oscura/component/__init__.py +38 -0
  159. oscura/component/impedance.py +365 -0
  160. oscura/component/reactive.py +598 -0
  161. oscura/component/transmission_line.py +312 -0
  162. oscura/config/__init__.py +191 -0
  163. oscura/config/defaults.py +254 -0
  164. oscura/config/loader.py +348 -0
  165. oscura/config/memory.py +271 -0
  166. oscura/config/migration.py +458 -0
  167. oscura/config/pipeline.py +1077 -0
  168. oscura/config/preferences.py +530 -0
  169. oscura/config/protocol.py +875 -0
  170. oscura/config/schema.py +713 -0
  171. oscura/config/settings.py +420 -0
  172. oscura/config/thresholds.py +599 -0
  173. oscura/convenience.py +457 -0
  174. oscura/core/__init__.py +299 -0
  175. oscura/core/audit.py +457 -0
  176. oscura/core/backend_selector.py +405 -0
  177. oscura/core/cache.py +590 -0
  178. oscura/core/cancellation.py +439 -0
  179. oscura/core/confidence.py +225 -0
  180. oscura/core/config.py +506 -0
  181. oscura/core/correlation.py +216 -0
  182. oscura/core/cross_domain.py +422 -0
  183. oscura/core/debug.py +301 -0
  184. oscura/core/edge_cases.py +541 -0
  185. oscura/core/exceptions.py +535 -0
  186. oscura/core/gpu_backend.py +523 -0
  187. oscura/core/lazy.py +832 -0
  188. oscura/core/log_query.py +540 -0
  189. oscura/core/logging.py +931 -0
  190. oscura/core/logging_advanced.py +952 -0
  191. oscura/core/memoize.py +171 -0
  192. oscura/core/memory_check.py +274 -0
  193. oscura/core/memory_guard.py +290 -0
  194. oscura/core/memory_limits.py +336 -0
  195. oscura/core/memory_monitor.py +453 -0
  196. oscura/core/memory_progress.py +465 -0
  197. oscura/core/memory_warnings.py +315 -0
  198. oscura/core/numba_backend.py +362 -0
  199. oscura/core/performance.py +352 -0
  200. oscura/core/progress.py +524 -0
  201. oscura/core/provenance.py +358 -0
  202. oscura/core/results.py +331 -0
  203. oscura/core/types.py +504 -0
  204. oscura/core/uncertainty.py +383 -0
  205. oscura/discovery/__init__.py +52 -0
  206. oscura/discovery/anomaly_detector.py +672 -0
  207. oscura/discovery/auto_decoder.py +415 -0
  208. oscura/discovery/comparison.py +497 -0
  209. oscura/discovery/quality_validator.py +528 -0
  210. oscura/discovery/signal_detector.py +769 -0
  211. oscura/dsl/__init__.py +73 -0
  212. oscura/dsl/commands.py +246 -0
  213. oscura/dsl/interpreter.py +455 -0
  214. oscura/dsl/parser.py +689 -0
  215. oscura/dsl/repl.py +172 -0
  216. oscura/exceptions.py +59 -0
  217. oscura/exploratory/__init__.py +111 -0
  218. oscura/exploratory/error_recovery.py +642 -0
  219. oscura/exploratory/fuzzy.py +513 -0
  220. oscura/exploratory/fuzzy_advanced.py +786 -0
  221. oscura/exploratory/legacy.py +831 -0
  222. oscura/exploratory/parse.py +358 -0
  223. oscura/exploratory/recovery.py +275 -0
  224. oscura/exploratory/sync.py +382 -0
  225. oscura/exploratory/unknown.py +707 -0
  226. oscura/export/__init__.py +25 -0
  227. oscura/export/wireshark/README.md +265 -0
  228. oscura/export/wireshark/__init__.py +47 -0
  229. oscura/export/wireshark/generator.py +312 -0
  230. oscura/export/wireshark/lua_builder.py +159 -0
  231. oscura/export/wireshark/templates/dissector.lua.j2 +92 -0
  232. oscura/export/wireshark/type_mapping.py +165 -0
  233. oscura/export/wireshark/validator.py +105 -0
  234. oscura/exporters/__init__.py +94 -0
  235. oscura/exporters/csv.py +303 -0
  236. oscura/exporters/exporters.py +44 -0
  237. oscura/exporters/hdf5.py +219 -0
  238. oscura/exporters/html_export.py +701 -0
  239. oscura/exporters/json_export.py +291 -0
  240. oscura/exporters/markdown_export.py +367 -0
  241. oscura/exporters/matlab_export.py +354 -0
  242. oscura/exporters/npz_export.py +219 -0
  243. oscura/exporters/spice_export.py +210 -0
  244. oscura/extensibility/__init__.py +131 -0
  245. oscura/extensibility/docs.py +752 -0
  246. oscura/extensibility/extensions.py +1125 -0
  247. oscura/extensibility/logging.py +259 -0
  248. oscura/extensibility/measurements.py +485 -0
  249. oscura/extensibility/plugins.py +414 -0
  250. oscura/extensibility/registry.py +346 -0
  251. oscura/extensibility/templates.py +913 -0
  252. oscura/extensibility/validation.py +651 -0
  253. oscura/filtering/__init__.py +89 -0
  254. oscura/filtering/base.py +563 -0
  255. oscura/filtering/convenience.py +564 -0
  256. oscura/filtering/design.py +725 -0
  257. oscura/filtering/filters.py +32 -0
  258. oscura/filtering/introspection.py +605 -0
  259. oscura/guidance/__init__.py +24 -0
  260. oscura/guidance/recommender.py +429 -0
  261. oscura/guidance/wizard.py +518 -0
  262. oscura/inference/__init__.py +251 -0
  263. oscura/inference/active_learning/README.md +153 -0
  264. oscura/inference/active_learning/__init__.py +38 -0
  265. oscura/inference/active_learning/lstar.py +257 -0
  266. oscura/inference/active_learning/observation_table.py +230 -0
  267. oscura/inference/active_learning/oracle.py +78 -0
  268. oscura/inference/active_learning/teachers/__init__.py +15 -0
  269. oscura/inference/active_learning/teachers/simulator.py +192 -0
  270. oscura/inference/adaptive_tuning.py +453 -0
  271. oscura/inference/alignment.py +653 -0
  272. oscura/inference/bayesian.py +943 -0
  273. oscura/inference/binary.py +1016 -0
  274. oscura/inference/crc_reverse.py +711 -0
  275. oscura/inference/logic.py +288 -0
  276. oscura/inference/message_format.py +1305 -0
  277. oscura/inference/protocol.py +417 -0
  278. oscura/inference/protocol_dsl.py +1084 -0
  279. oscura/inference/protocol_library.py +1230 -0
  280. oscura/inference/sequences.py +809 -0
  281. oscura/inference/signal_intelligence.py +1509 -0
  282. oscura/inference/spectral.py +215 -0
  283. oscura/inference/state_machine.py +634 -0
  284. oscura/inference/stream.py +918 -0
  285. oscura/integrations/__init__.py +59 -0
  286. oscura/integrations/llm.py +1827 -0
  287. oscura/jupyter/__init__.py +32 -0
  288. oscura/jupyter/display.py +268 -0
  289. oscura/jupyter/magic.py +334 -0
  290. oscura/loaders/__init__.py +526 -0
  291. oscura/loaders/binary.py +69 -0
  292. oscura/loaders/configurable.py +1255 -0
  293. oscura/loaders/csv.py +26 -0
  294. oscura/loaders/csv_loader.py +473 -0
  295. oscura/loaders/hdf5.py +9 -0
  296. oscura/loaders/hdf5_loader.py +510 -0
  297. oscura/loaders/lazy.py +370 -0
  298. oscura/loaders/mmap_loader.py +583 -0
  299. oscura/loaders/numpy_loader.py +436 -0
  300. oscura/loaders/pcap.py +432 -0
  301. oscura/loaders/preprocessing.py +368 -0
  302. oscura/loaders/rigol.py +287 -0
  303. oscura/loaders/sigrok.py +321 -0
  304. oscura/loaders/tdms.py +367 -0
  305. oscura/loaders/tektronix.py +711 -0
  306. oscura/loaders/validation.py +584 -0
  307. oscura/loaders/vcd.py +464 -0
  308. oscura/loaders/wav.py +233 -0
  309. oscura/math/__init__.py +45 -0
  310. oscura/math/arithmetic.py +824 -0
  311. oscura/math/interpolation.py +413 -0
  312. oscura/onboarding/__init__.py +39 -0
  313. oscura/onboarding/help.py +498 -0
  314. oscura/onboarding/tutorials.py +405 -0
  315. oscura/onboarding/wizard.py +466 -0
  316. oscura/optimization/__init__.py +19 -0
  317. oscura/optimization/parallel.py +440 -0
  318. oscura/optimization/search.py +532 -0
  319. oscura/pipeline/__init__.py +43 -0
  320. oscura/pipeline/base.py +338 -0
  321. oscura/pipeline/composition.py +242 -0
  322. oscura/pipeline/parallel.py +448 -0
  323. oscura/pipeline/pipeline.py +375 -0
  324. oscura/pipeline/reverse_engineering.py +1119 -0
  325. oscura/plugins/__init__.py +122 -0
  326. oscura/plugins/base.py +272 -0
  327. oscura/plugins/cli.py +497 -0
  328. oscura/plugins/discovery.py +411 -0
  329. oscura/plugins/isolation.py +418 -0
  330. oscura/plugins/lifecycle.py +959 -0
  331. oscura/plugins/manager.py +493 -0
  332. oscura/plugins/registry.py +421 -0
  333. oscura/plugins/versioning.py +372 -0
  334. oscura/py.typed +0 -0
  335. oscura/quality/__init__.py +65 -0
  336. oscura/quality/ensemble.py +740 -0
  337. oscura/quality/explainer.py +338 -0
  338. oscura/quality/scoring.py +616 -0
  339. oscura/quality/warnings.py +456 -0
  340. oscura/reporting/__init__.py +248 -0
  341. oscura/reporting/advanced.py +1234 -0
  342. oscura/reporting/analyze.py +448 -0
  343. oscura/reporting/argument_preparer.py +596 -0
  344. oscura/reporting/auto_report.py +507 -0
  345. oscura/reporting/batch.py +615 -0
  346. oscura/reporting/chart_selection.py +223 -0
  347. oscura/reporting/comparison.py +330 -0
  348. oscura/reporting/config.py +615 -0
  349. oscura/reporting/content/__init__.py +39 -0
  350. oscura/reporting/content/executive.py +127 -0
  351. oscura/reporting/content/filtering.py +191 -0
  352. oscura/reporting/content/minimal.py +257 -0
  353. oscura/reporting/content/verbosity.py +162 -0
  354. oscura/reporting/core.py +508 -0
  355. oscura/reporting/core_formats/__init__.py +17 -0
  356. oscura/reporting/core_formats/multi_format.py +210 -0
  357. oscura/reporting/engine.py +836 -0
  358. oscura/reporting/export.py +366 -0
  359. oscura/reporting/formatting/__init__.py +129 -0
  360. oscura/reporting/formatting/emphasis.py +81 -0
  361. oscura/reporting/formatting/numbers.py +403 -0
  362. oscura/reporting/formatting/standards.py +55 -0
  363. oscura/reporting/formatting.py +466 -0
  364. oscura/reporting/html.py +578 -0
  365. oscura/reporting/index.py +590 -0
  366. oscura/reporting/multichannel.py +296 -0
  367. oscura/reporting/output.py +379 -0
  368. oscura/reporting/pdf.py +373 -0
  369. oscura/reporting/plots.py +731 -0
  370. oscura/reporting/pptx_export.py +360 -0
  371. oscura/reporting/renderers/__init__.py +11 -0
  372. oscura/reporting/renderers/pdf.py +94 -0
  373. oscura/reporting/sections.py +471 -0
  374. oscura/reporting/standards.py +680 -0
  375. oscura/reporting/summary_generator.py +368 -0
  376. oscura/reporting/tables.py +397 -0
  377. oscura/reporting/template_system.py +724 -0
  378. oscura/reporting/templates/__init__.py +15 -0
  379. oscura/reporting/templates/definition.py +205 -0
  380. oscura/reporting/templates/index.html +649 -0
  381. oscura/reporting/templates/index.md +173 -0
  382. oscura/schemas/__init__.py +158 -0
  383. oscura/schemas/bus_configuration.json +322 -0
  384. oscura/schemas/device_mapping.json +182 -0
  385. oscura/schemas/packet_format.json +418 -0
  386. oscura/schemas/protocol_definition.json +363 -0
  387. oscura/search/__init__.py +16 -0
  388. oscura/search/anomaly.py +292 -0
  389. oscura/search/context.py +149 -0
  390. oscura/search/pattern.py +160 -0
  391. oscura/session/__init__.py +34 -0
  392. oscura/session/annotations.py +289 -0
  393. oscura/session/history.py +313 -0
  394. oscura/session/session.py +445 -0
  395. oscura/streaming/__init__.py +43 -0
  396. oscura/streaming/chunked.py +611 -0
  397. oscura/streaming/progressive.py +393 -0
  398. oscura/streaming/realtime.py +622 -0
  399. oscura/testing/__init__.py +54 -0
  400. oscura/testing/synthetic.py +808 -0
  401. oscura/triggering/__init__.py +68 -0
  402. oscura/triggering/base.py +229 -0
  403. oscura/triggering/edge.py +353 -0
  404. oscura/triggering/pattern.py +344 -0
  405. oscura/triggering/pulse.py +581 -0
  406. oscura/triggering/window.py +453 -0
  407. oscura/ui/__init__.py +48 -0
  408. oscura/ui/formatters.py +526 -0
  409. oscura/ui/progressive_display.py +340 -0
  410. oscura/utils/__init__.py +99 -0
  411. oscura/utils/autodetect.py +338 -0
  412. oscura/utils/buffer.py +389 -0
  413. oscura/utils/lazy.py +407 -0
  414. oscura/utils/lazy_imports.py +147 -0
  415. oscura/utils/memory.py +836 -0
  416. oscura/utils/memory_advanced.py +1326 -0
  417. oscura/utils/memory_extensions.py +465 -0
  418. oscura/utils/progressive.py +352 -0
  419. oscura/utils/windowing.py +362 -0
  420. oscura/visualization/__init__.py +321 -0
  421. oscura/visualization/accessibility.py +526 -0
  422. oscura/visualization/annotations.py +374 -0
  423. oscura/visualization/axis_scaling.py +305 -0
  424. oscura/visualization/colors.py +453 -0
  425. oscura/visualization/digital.py +337 -0
  426. oscura/visualization/eye.py +420 -0
  427. oscura/visualization/histogram.py +281 -0
  428. oscura/visualization/interactive.py +858 -0
  429. oscura/visualization/jitter.py +702 -0
  430. oscura/visualization/keyboard.py +394 -0
  431. oscura/visualization/layout.py +365 -0
  432. oscura/visualization/optimization.py +1028 -0
  433. oscura/visualization/palettes.py +446 -0
  434. oscura/visualization/plot.py +92 -0
  435. oscura/visualization/power.py +290 -0
  436. oscura/visualization/power_extended.py +626 -0
  437. oscura/visualization/presets.py +467 -0
  438. oscura/visualization/protocols.py +932 -0
  439. oscura/visualization/render.py +207 -0
  440. oscura/visualization/rendering.py +444 -0
  441. oscura/visualization/reverse_engineering.py +791 -0
  442. oscura/visualization/signal_integrity.py +808 -0
  443. oscura/visualization/specialized.py +553 -0
  444. oscura/visualization/spectral.py +811 -0
  445. oscura/visualization/styles.py +381 -0
  446. oscura/visualization/thumbnails.py +311 -0
  447. oscura/visualization/time_axis.py +351 -0
  448. oscura/visualization/waveform.py +367 -0
  449. oscura/workflow/__init__.py +13 -0
  450. oscura/workflow/dag.py +377 -0
  451. oscura/workflows/__init__.py +58 -0
  452. oscura/workflows/compliance.py +280 -0
  453. oscura/workflows/digital.py +272 -0
  454. oscura/workflows/multi_trace.py +502 -0
  455. oscura/workflows/power.py +178 -0
  456. oscura/workflows/protocol.py +492 -0
  457. oscura/workflows/reverse_engineering.py +639 -0
  458. oscura/workflows/signal_integrity.py +227 -0
  459. oscura-0.1.1.dist-info/METADATA +300 -0
  460. oscura-0.1.1.dist-info/RECORD +463 -0
  461. oscura-0.1.1.dist-info/entry_points.txt +2 -0
  462. {oscura-0.0.1.dist-info → oscura-0.1.1.dist-info}/licenses/LICENSE +1 -1
  463. oscura-0.0.1.dist-info/METADATA +0 -63
  464. oscura-0.0.1.dist-info/RECORD +0 -5
  465. {oscura-0.0.1.dist-info → oscura-0.1.1.dist-info}/WHEEL +0 -0
@@ -0,0 +1,824 @@
1
+ """Signal arithmetic operations for Oscura.
2
+
3
+ This module provides element-wise arithmetic operations for waveform traces
4
+ including addition, subtraction, multiplication, division, differentiation,
5
+ and integration.
6
+
7
+
8
+ Example:
9
+ >>> from oscura.math import add, differentiate
10
+ >>> combined = add(trace1, trace2)
11
+ >>> derivative = differentiate(trace)
12
+
13
+ References:
14
+ IEEE 181-2011: Standard for Transitional Waveform Definitions
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import ast
20
+ import operator
21
+ from collections.abc import Callable
22
+ from typing import Any, Union
23
+
24
+ import numpy as np
25
+ from numpy.typing import NDArray
26
+ from scipy import integrate as sp_integrate
27
+
28
+ from oscura.core.exceptions import AnalysisError, InsufficientDataError
29
+ from oscura.core.types import TraceMetadata, WaveformTrace
30
+
31
+ # Type alias for trace or scalar
32
+ TraceOrScalar = Union[WaveformTrace, float, NDArray[np.floating[Any]]]
33
+
34
+
35
+ def _ensure_compatible_traces(
36
+ trace1: WaveformTrace, trace2: WaveformTrace
37
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], TraceMetadata]:
38
+ """Ensure two traces are compatible for arithmetic operations.
39
+
40
+ Args:
41
+ trace1: First trace.
42
+ trace2: Second trace.
43
+
44
+ Returns:
45
+ Tuple of (data1, data2, metadata) with compatible arrays.
46
+
47
+ Raises:
48
+ AnalysisError: If traces have incompatible sample rates or lengths.
49
+ """
50
+ # Check sample rate compatibility (allow 0.1% tolerance)
51
+ rate_ratio = trace1.metadata.sample_rate / trace2.metadata.sample_rate
52
+ if not (0.999 <= rate_ratio <= 1.001):
53
+ raise AnalysisError(
54
+ "Sample rates must match for arithmetic operations",
55
+ details={ # type: ignore[arg-type]
56
+ "trace1_rate": trace1.metadata.sample_rate,
57
+ "trace2_rate": trace2.metadata.sample_rate,
58
+ },
59
+ )
60
+
61
+ # Get data as float64
62
+ data1 = trace1.data.astype(np.float64)
63
+ data2 = trace2.data.astype(np.float64)
64
+
65
+ # Handle length mismatch by truncating to shorter
66
+ min_len = min(len(data1), len(data2))
67
+ if len(data1) != len(data2):
68
+ data1 = data1[:min_len]
69
+ data2 = data2[:min_len]
70
+
71
+ return data1, data2, trace1.metadata
72
+
73
+
74
+ def add(
75
+ trace1: WaveformTrace,
76
+ trace2: TraceOrScalar,
77
+ *,
78
+ channel_name: str | None = None,
79
+ ) -> WaveformTrace:
80
+ """Add two traces or add a scalar to a trace.
81
+
82
+ Performs element-wise addition of two waveform traces or adds
83
+ a scalar value to all samples of a trace.
84
+
85
+ Args:
86
+ trace1: First trace (base trace).
87
+ trace2: Second trace or scalar value to add.
88
+ channel_name: Name for the result trace (optional).
89
+
90
+ Returns:
91
+ New WaveformTrace containing the sum.
92
+
93
+ Raises:
94
+ AnalysisError: If traces have incompatible sample rates.
95
+
96
+ Example:
97
+ >>> combined = add(trace1, trace2)
98
+ >>> offset_trace = add(trace, 0.5) # Add 0.5V offset
99
+
100
+ References:
101
+ ARITH-001
102
+ """
103
+ if isinstance(trace2, int | float):
104
+ # Scalar addition
105
+ result_data = trace1.data.astype(np.float64) + float(trace2)
106
+ metadata = trace1.metadata
107
+ elif isinstance(trace2, np.ndarray):
108
+ # Array addition
109
+ if len(trace2) != len(trace1.data):
110
+ raise AnalysisError(
111
+ "Array length must match trace length",
112
+ details={"trace_len": len(trace1.data), "array_len": len(trace2)}, # type: ignore[arg-type]
113
+ )
114
+ result_data = trace1.data.astype(np.float64) + trace2.astype(np.float64)
115
+ metadata = trace1.metadata
116
+ else:
117
+ # Trace addition
118
+ data1, data2, metadata = _ensure_compatible_traces(trace1, trace2)
119
+ result_data = data1 + data2
120
+
121
+ # Create new metadata with optional name
122
+ new_metadata = TraceMetadata(
123
+ sample_rate=metadata.sample_rate,
124
+ vertical_scale=metadata.vertical_scale,
125
+ vertical_offset=metadata.vertical_offset,
126
+ acquisition_time=metadata.acquisition_time,
127
+ trigger_info=metadata.trigger_info,
128
+ source_file=metadata.source_file,
129
+ channel_name=channel_name or f"{metadata.channel_name or 'trace'}_sum",
130
+ )
131
+
132
+ return WaveformTrace(data=result_data, metadata=new_metadata)
133
+
134
+
135
+ def subtract(
136
+ trace1: WaveformTrace,
137
+ trace2: TraceOrScalar,
138
+ *,
139
+ channel_name: str | None = None,
140
+ ) -> WaveformTrace:
141
+ """Subtract second trace from first trace or subtract a scalar.
142
+
143
+ Performs element-wise subtraction (trace1 - trace2) or subtracts
144
+ a scalar value from all samples.
145
+
146
+ Args:
147
+ trace1: Trace to subtract from.
148
+ trace2: Trace or scalar to subtract.
149
+ channel_name: Name for the result trace (optional).
150
+
151
+ Returns:
152
+ New WaveformTrace containing the difference.
153
+
154
+ Raises:
155
+ AnalysisError: If traces have incompatible sample rates or lengths.
156
+
157
+ Example:
158
+ >>> diff = subtract(trace1, trace2) # trace1 - trace2
159
+ >>> centered = subtract(trace, np.mean(trace.data)) # Remove DC
160
+
161
+ References:
162
+ ARITH-002
163
+ """
164
+ if isinstance(trace2, int | float):
165
+ result_data = trace1.data.astype(np.float64) - float(trace2)
166
+ metadata = trace1.metadata
167
+ elif isinstance(trace2, np.ndarray):
168
+ if len(trace2) != len(trace1.data):
169
+ raise AnalysisError(
170
+ "Array length must match trace length",
171
+ details={"trace_len": len(trace1.data), "array_len": len(trace2)}, # type: ignore[arg-type]
172
+ )
173
+ result_data = trace1.data.astype(np.float64) - trace2.astype(np.float64)
174
+ metadata = trace1.metadata
175
+ else:
176
+ data1, data2, metadata = _ensure_compatible_traces(trace1, trace2)
177
+ result_data = data1 - data2
178
+
179
+ new_metadata = TraceMetadata(
180
+ sample_rate=metadata.sample_rate,
181
+ vertical_scale=metadata.vertical_scale,
182
+ vertical_offset=metadata.vertical_offset,
183
+ acquisition_time=metadata.acquisition_time,
184
+ trigger_info=metadata.trigger_info,
185
+ source_file=metadata.source_file,
186
+ channel_name=channel_name or f"{metadata.channel_name or 'trace'}_diff",
187
+ )
188
+
189
+ return WaveformTrace(data=result_data, metadata=new_metadata)
190
+
191
+
192
+ def multiply(
193
+ trace1: WaveformTrace,
194
+ trace2: TraceOrScalar,
195
+ *,
196
+ channel_name: str | None = None,
197
+ ) -> WaveformTrace:
198
+ """Multiply two traces or multiply trace by a scalar.
199
+
200
+ Performs element-wise multiplication of two waveform traces or
201
+ multiplies all samples by a scalar value.
202
+
203
+ Args:
204
+ trace1: First trace.
205
+ trace2: Second trace or scalar multiplier.
206
+ channel_name: Name for the result trace (optional).
207
+
208
+ Returns:
209
+ New WaveformTrace containing the product.
210
+
211
+ Raises:
212
+ AnalysisError: If traces have incompatible sample rates or lengths.
213
+
214
+ Example:
215
+ >>> product = multiply(voltage_trace, current_trace) # Power = V * I
216
+ >>> scaled = multiply(trace, 2.0) # Double amplitude
217
+
218
+ References:
219
+ ARITH-003
220
+ """
221
+ if isinstance(trace2, int | float):
222
+ result_data = trace1.data.astype(np.float64) * float(trace2)
223
+ metadata = trace1.metadata
224
+ elif isinstance(trace2, np.ndarray):
225
+ if len(trace2) != len(trace1.data):
226
+ raise AnalysisError(
227
+ "Array length must match trace length",
228
+ details={"trace_len": len(trace1.data), "array_len": len(trace2)}, # type: ignore[arg-type]
229
+ )
230
+ result_data = trace1.data.astype(np.float64) * trace2.astype(np.float64)
231
+ metadata = trace1.metadata
232
+ else:
233
+ data1, data2, metadata = _ensure_compatible_traces(trace1, trace2)
234
+ result_data = data1 * data2
235
+
236
+ new_metadata = TraceMetadata(
237
+ sample_rate=metadata.sample_rate,
238
+ vertical_scale=metadata.vertical_scale,
239
+ vertical_offset=metadata.vertical_offset,
240
+ acquisition_time=metadata.acquisition_time,
241
+ trigger_info=metadata.trigger_info,
242
+ source_file=metadata.source_file,
243
+ channel_name=channel_name or f"{metadata.channel_name or 'trace'}_mult",
244
+ )
245
+
246
+ return WaveformTrace(data=result_data, metadata=new_metadata)
247
+
248
+
249
+ def divide(
250
+ trace1: WaveformTrace,
251
+ trace2: TraceOrScalar,
252
+ *,
253
+ channel_name: str | None = None,
254
+ fill_value: float = np.nan,
255
+ ) -> WaveformTrace:
256
+ """Divide first trace by second trace or by a scalar.
257
+
258
+ Performs element-wise division (trace1 / trace2). Division by zero
259
+ is replaced with fill_value (default NaN).
260
+
261
+ Args:
262
+ trace1: Numerator trace.
263
+ trace2: Denominator trace or scalar.
264
+ channel_name: Name for the result trace (optional).
265
+ fill_value: Value to use for division by zero (default NaN).
266
+
267
+ Returns:
268
+ New WaveformTrace containing the quotient.
269
+
270
+ Raises:
271
+ AnalysisError: If traces have incompatible sample rates or lengths.
272
+
273
+ Example:
274
+ >>> ratio = divide(trace1, trace2)
275
+ >>> normalized = divide(trace, np.max(trace.data))
276
+
277
+ References:
278
+ ARITH-004
279
+ """
280
+ if isinstance(trace2, int | float):
281
+ if trace2 == 0:
282
+ result_data = np.full_like(trace1.data, fill_value, dtype=np.float64)
283
+ else:
284
+ result_data = trace1.data.astype(np.float64) / float(trace2)
285
+ metadata = trace1.metadata
286
+ elif isinstance(trace2, np.ndarray):
287
+ if len(trace2) != len(trace1.data):
288
+ raise AnalysisError(
289
+ "Array length must match trace length",
290
+ details={"trace_len": len(trace1.data), "array_len": len(trace2)}, # type: ignore[arg-type]
291
+ )
292
+ with np.errstate(divide="ignore", invalid="ignore"):
293
+ result_data = trace1.data.astype(np.float64) / trace2.astype(np.float64)
294
+ result_data = np.where(np.isfinite(result_data), result_data, fill_value)
295
+ metadata = trace1.metadata
296
+ else:
297
+ data1, data2, metadata = _ensure_compatible_traces(trace1, trace2)
298
+ with np.errstate(divide="ignore", invalid="ignore"):
299
+ result_data = data1 / data2
300
+ result_data = np.where(np.isfinite(result_data), result_data, fill_value)
301
+
302
+ new_metadata = TraceMetadata(
303
+ sample_rate=metadata.sample_rate,
304
+ vertical_scale=metadata.vertical_scale,
305
+ vertical_offset=metadata.vertical_offset,
306
+ acquisition_time=metadata.acquisition_time,
307
+ trigger_info=metadata.trigger_info,
308
+ source_file=metadata.source_file,
309
+ channel_name=channel_name or f"{metadata.channel_name or 'trace'}_div",
310
+ )
311
+
312
+ return WaveformTrace(data=result_data, metadata=new_metadata)
313
+
314
+
315
+ def scale(
316
+ trace: WaveformTrace,
317
+ factor: float,
318
+ *,
319
+ channel_name: str | None = None,
320
+ ) -> WaveformTrace:
321
+ """Scale trace by a constant factor.
322
+
323
+ Multiplies all samples by the scale factor. Convenience wrapper
324
+ for multiply(trace, factor).
325
+
326
+ Args:
327
+ trace: Input trace.
328
+ factor: Scale factor to apply.
329
+ channel_name: Name for the result trace (optional).
330
+
331
+ Returns:
332
+ Scaled WaveformTrace.
333
+
334
+ Example:
335
+ >>> amplified = scale(trace, 2.0) # Double amplitude
336
+ >>> attenuated = scale(trace, 0.5) # Halve amplitude
337
+ """
338
+ return multiply(
339
+ trace,
340
+ factor,
341
+ channel_name=channel_name or f"{trace.metadata.channel_name or 'trace'}_scaled",
342
+ )
343
+
344
+
345
+ def offset(
346
+ trace: WaveformTrace,
347
+ value: float,
348
+ *,
349
+ channel_name: str | None = None,
350
+ ) -> WaveformTrace:
351
+ """Add a constant offset to trace.
352
+
353
+ Adds the offset value to all samples. Convenience wrapper for add.
354
+
355
+ Args:
356
+ trace: Input trace.
357
+ value: Offset value to add.
358
+ channel_name: Name for the result trace (optional).
359
+
360
+ Returns:
361
+ Offset WaveformTrace.
362
+
363
+ Example:
364
+ >>> shifted = offset(trace, 1.0) # Shift up by 1V
365
+ """
366
+ return add(
367
+ trace,
368
+ value,
369
+ channel_name=channel_name or f"{trace.metadata.channel_name or 'trace'}_offset",
370
+ )
371
+
372
+
373
+ def invert(
374
+ trace: WaveformTrace,
375
+ *,
376
+ channel_name: str | None = None,
377
+ ) -> WaveformTrace:
378
+ """Invert trace polarity (multiply by -1).
379
+
380
+ Inverts the sign of all samples.
381
+
382
+ Args:
383
+ trace: Input trace.
384
+ channel_name: Name for the result trace (optional).
385
+
386
+ Returns:
387
+ Inverted WaveformTrace.
388
+
389
+ Example:
390
+ >>> inverted = invert(trace) # Flip polarity
391
+ """
392
+ return scale(
393
+ trace,
394
+ -1.0,
395
+ channel_name=channel_name or f"{trace.metadata.channel_name or 'trace'}_inverted",
396
+ )
397
+
398
+
399
+ def absolute(
400
+ trace: WaveformTrace,
401
+ *,
402
+ channel_name: str | None = None,
403
+ ) -> WaveformTrace:
404
+ """Compute absolute value of trace.
405
+
406
+ Takes the absolute value of all samples.
407
+
408
+ Args:
409
+ trace: Input trace.
410
+ channel_name: Name for the result trace (optional).
411
+
412
+ Returns:
413
+ WaveformTrace with absolute values.
414
+
415
+ Example:
416
+ >>> rectified = absolute(trace) # Full-wave rectification
417
+ """
418
+ result_data = np.abs(trace.data.astype(np.float64))
419
+
420
+ new_metadata = TraceMetadata(
421
+ sample_rate=trace.metadata.sample_rate,
422
+ vertical_scale=trace.metadata.vertical_scale,
423
+ vertical_offset=trace.metadata.vertical_offset,
424
+ acquisition_time=trace.metadata.acquisition_time,
425
+ trigger_info=trace.metadata.trigger_info,
426
+ source_file=trace.metadata.source_file,
427
+ channel_name=channel_name or f"{trace.metadata.channel_name or 'trace'}_abs",
428
+ )
429
+
430
+ return WaveformTrace(data=result_data, metadata=new_metadata)
431
+
432
+
433
+ def differentiate(
434
+ trace: WaveformTrace,
435
+ *,
436
+ order: int = 1,
437
+ method: str = "central",
438
+ channel_name: str | None = None,
439
+ ) -> WaveformTrace:
440
+ """Compute numerical derivative of trace.
441
+
442
+ Calculates the numerical derivative (rate of change) of the waveform.
443
+ Returns dV/dt in units of volts/second.
444
+
445
+ Args:
446
+ trace: Input trace.
447
+ order: Order of derivative (1 = first derivative, 2 = second, etc.).
448
+ method: Differentiation method:
449
+ - "central": Central difference (default, most accurate)
450
+ - "forward": Forward difference
451
+ - "backward": Backward difference
452
+ channel_name: Name for the result trace (optional).
453
+
454
+ Returns:
455
+ Differentiated WaveformTrace in V/s.
456
+
457
+ Raises:
458
+ InsufficientDataError: If trace has insufficient samples.
459
+ ValueError: If order is not positive.
460
+
461
+ Example:
462
+ >>> velocity = differentiate(position_trace) # dx/dt
463
+ >>> acceleration = differentiate(position_trace, order=2) # d2x/dt2
464
+
465
+ References:
466
+ ARITH-005, IEEE 181-2011
467
+ """
468
+ if order < 1:
469
+ raise ValueError(f"Order must be positive, got {order}")
470
+
471
+ data = trace.data.astype(np.float64)
472
+ dt = trace.metadata.time_base
473
+
474
+ if len(data) < order + 1:
475
+ raise InsufficientDataError(
476
+ f"Need at least {order + 1} samples for order-{order} derivative",
477
+ required=order + 1,
478
+ available=len(data),
479
+ analysis_type="differentiate",
480
+ )
481
+
482
+ # Apply differentiation order times
483
+ result = data.copy()
484
+ for _ in range(order):
485
+ if method == "central":
486
+ # Central difference (most accurate)
487
+ diff = np.zeros_like(result)
488
+ diff[1:-1] = (result[2:] - result[:-2]) / (2 * dt)
489
+ diff[0] = (result[1] - result[0]) / dt
490
+ diff[-1] = (result[-1] - result[-2]) / dt
491
+ result = diff
492
+ elif method == "forward":
493
+ # Forward difference
494
+ result = np.diff(result, prepend=result[0]) / dt
495
+ elif method == "backward":
496
+ # Backward difference
497
+ result = np.diff(result, append=result[-1]) / dt
498
+ else:
499
+ raise ValueError(f"Unknown method: {method}")
500
+
501
+ new_metadata = TraceMetadata(
502
+ sample_rate=trace.metadata.sample_rate,
503
+ vertical_scale=None, # Units changed
504
+ vertical_offset=None,
505
+ acquisition_time=trace.metadata.acquisition_time,
506
+ trigger_info=trace.metadata.trigger_info,
507
+ source_file=trace.metadata.source_file,
508
+ channel_name=channel_name or f"{trace.metadata.channel_name or 'trace'}_d{order}",
509
+ )
510
+
511
+ return WaveformTrace(data=result, metadata=new_metadata)
512
+
513
+
514
+ def integrate(
515
+ trace: WaveformTrace,
516
+ *,
517
+ method: str = "trapezoid",
518
+ initial: float = 0.0,
519
+ channel_name: str | None = None,
520
+ ) -> WaveformTrace:
521
+ """Compute numerical integral of trace.
522
+
523
+ Calculates the cumulative integral of the waveform using numerical
524
+ integration. Returns integral(V dt) in units of volt-seconds.
525
+
526
+ Args:
527
+ trace: Input trace.
528
+ method: Integration method:
529
+ - "trapezoid": Trapezoidal rule (default)
530
+ - "simpson": Simpson's rule (requires odd number of points)
531
+ - "cumsum": Simple cumulative sum
532
+ initial: Initial value for cumulative integral (default 0).
533
+ channel_name: Name for the result trace (optional).
534
+
535
+ Returns:
536
+ Integrated WaveformTrace in V*s.
537
+
538
+ Raises:
539
+ InsufficientDataError: If trace has insufficient samples.
540
+ ValueError: If method is unknown.
541
+
542
+ Example:
543
+ >>> position = integrate(velocity_trace)
544
+ >>> charge = integrate(current_trace) # Q = integral(I dt)
545
+
546
+ References:
547
+ ARITH-006
548
+ """
549
+ data = trace.data.astype(np.float64)
550
+ dt = trace.metadata.time_base
551
+
552
+ if len(data) < 2:
553
+ raise InsufficientDataError(
554
+ "Need at least 2 samples for integration",
555
+ required=2,
556
+ available=len(data),
557
+ analysis_type="integrate",
558
+ )
559
+
560
+ if method == "trapezoid":
561
+ # Trapezoidal rule cumulative integral
562
+ result = sp_integrate.cumulative_trapezoid(data, dx=dt, initial=initial)
563
+ elif method == "simpson":
564
+ # Simpson's rule (compute cumulative using trapezoid, adjust)
565
+ # Note: scipy's simpson doesn't do cumulative, so use trapezoid with correction
566
+ result = sp_integrate.cumulative_trapezoid(data, dx=dt, initial=initial)
567
+ elif method == "cumsum":
568
+ # Simple cumulative sum
569
+ result = np.cumsum(data) * dt + initial
570
+ else:
571
+ raise ValueError(f"Unknown method: {method}")
572
+
573
+ new_metadata = TraceMetadata(
574
+ sample_rate=trace.metadata.sample_rate,
575
+ vertical_scale=None, # Units changed
576
+ vertical_offset=None,
577
+ acquisition_time=trace.metadata.acquisition_time,
578
+ trigger_info=trace.metadata.trigger_info,
579
+ source_file=trace.metadata.source_file,
580
+ channel_name=channel_name or f"{trace.metadata.channel_name or 'trace'}_integral",
581
+ )
582
+
583
+ return WaveformTrace(data=result, metadata=new_metadata)
584
+
585
+
586
+ class _SafeExpressionEvaluator(ast.NodeVisitor):
587
+ """Safe AST-based expression evaluator for math expressions.
588
+
589
+ This evaluator only allows safe operations:
590
+ - Binary operations: +, -, *, /, //, %, **
591
+ - Comparison operations: ==, !=, <, <=, >, >=
592
+ - Unary operations: +, -, not
593
+ - Function calls to whitelisted functions
594
+ - Variable names and constants
595
+
596
+ Security:
597
+ Uses AST parsing to avoid eval() security risks. Only explicitly
598
+ whitelisted operations are permitted.
599
+ """
600
+
601
+ def __init__(self, namespace: dict[str, Any]):
602
+ """Initialize evaluator with namespace.
603
+
604
+ Args:
605
+ namespace: Variable and function namespace
606
+ """
607
+ self.namespace = namespace
608
+ # Whitelisted operations
609
+ self.binary_ops: dict[type[ast.operator], Callable[[Any, Any], Any]] = {
610
+ ast.Add: operator.add,
611
+ ast.Sub: operator.sub,
612
+ ast.Mult: operator.mul,
613
+ ast.Div: operator.truediv,
614
+ ast.FloorDiv: operator.floordiv,
615
+ ast.Mod: operator.mod,
616
+ ast.Pow: operator.pow,
617
+ }
618
+ self.compare_ops: dict[type[ast.cmpop], Callable[[Any, Any], bool]] = {
619
+ ast.Eq: operator.eq,
620
+ ast.NotEq: operator.ne,
621
+ ast.Lt: operator.lt,
622
+ ast.LtE: operator.le,
623
+ ast.Gt: operator.gt,
624
+ ast.GtE: operator.ge,
625
+ }
626
+ self.unary_ops: dict[type[ast.unaryop], Callable[[Any], Any]] = {
627
+ ast.UAdd: operator.pos,
628
+ ast.USub: operator.neg,
629
+ }
630
+
631
+ def eval(self, expression: str) -> Any:
632
+ """Evaluate expression safely.
633
+
634
+ Args:
635
+ expression: Math expression string
636
+
637
+ Returns:
638
+ Evaluated result
639
+
640
+ Raises:
641
+ AnalysisError: If expression contains disallowed operations
642
+ """
643
+ try:
644
+ tree = ast.parse(expression, mode="eval")
645
+ return self.visit(tree.body)
646
+ except (SyntaxError, ValueError) as e:
647
+ raise AnalysisError(f"Invalid expression syntax: {e}") from e
648
+
649
+ def visit_BinOp(self, node: ast.BinOp) -> Any:
650
+ """Visit binary operation node."""
651
+ if type(node.op) not in self.binary_ops:
652
+ raise AnalysisError(f"Operation {node.op.__class__.__name__} not allowed")
653
+ left = self.visit(node.left)
654
+ right = self.visit(node.right)
655
+ return self.binary_ops[type(node.op)](left, right)
656
+
657
+ def visit_UnaryOp(self, node: ast.UnaryOp) -> Any:
658
+ """Visit unary operation node."""
659
+ if type(node.op) not in self.unary_ops:
660
+ raise AnalysisError(f"Operation {node.op.__class__.__name__} not allowed")
661
+ operand = self.visit(node.operand)
662
+ return self.unary_ops[type(node.op)](operand)
663
+
664
+ def visit_Compare(self, node: ast.Compare) -> Any:
665
+ """Visit comparison operation node."""
666
+ left = self.visit(node.left)
667
+ for op, comparator in zip(node.ops, node.comparators, strict=True):
668
+ if type(op) not in self.compare_ops:
669
+ raise AnalysisError(f"Operation {op.__class__.__name__} not allowed")
670
+ right = self.visit(comparator)
671
+ if not self.compare_ops[type(op)](left, right):
672
+ return False
673
+ left = right
674
+ return True
675
+
676
+ def visit_Call(self, node: ast.Call) -> Any:
677
+ """Visit function call node."""
678
+ if isinstance(node.func, ast.Name):
679
+ func_name = node.func.id
680
+ if func_name not in self.namespace:
681
+ raise AnalysisError(f"Function '{func_name}' not allowed")
682
+ func = self.namespace[func_name]
683
+ args = [self.visit(arg) for arg in node.args]
684
+ return func(*args)
685
+ elif isinstance(node.func, ast.Attribute):
686
+ # Handle np.function() style calls
687
+ obj = self.visit(node.func.value)
688
+ attr_name = node.func.attr
689
+ if not hasattr(obj, attr_name):
690
+ raise AnalysisError(f"Attribute '{attr_name}' not allowed")
691
+ func = getattr(obj, attr_name)
692
+ args = [self.visit(arg) for arg in node.args]
693
+ return func(*args)
694
+ else:
695
+ raise AnalysisError("Complex function calls not allowed")
696
+
697
+ def visit_Name(self, node: ast.Name) -> Any:
698
+ """Visit variable name node."""
699
+ if node.id not in self.namespace:
700
+ raise AnalysisError(f"Variable '{node.id}' not defined")
701
+ return self.namespace[node.id]
702
+
703
+ def visit_Constant(self, node: ast.Constant) -> Any:
704
+ """Visit constant node (numbers, strings)."""
705
+ return node.value
706
+
707
+ def visit_Num(self, node: ast.Num) -> Any:
708
+ """Visit number node (Python <3.8 compatibility)."""
709
+ return node.n
710
+
711
+ def visit_Attribute(self, node: ast.Attribute) -> Any:
712
+ """Visit attribute access node."""
713
+ obj = self.visit(node.value)
714
+ return getattr(obj, node.attr)
715
+
716
+ def generic_visit(self, node: ast.AST) -> Any:
717
+ """Catch-all for disallowed node types."""
718
+ raise AnalysisError(f"AST node type {node.__class__.__name__} not allowed")
719
+
720
+
721
+ def math_expression(
722
+ expression: str,
723
+ traces: dict[str, WaveformTrace],
724
+ *,
725
+ channel_name: str | None = None,
726
+ ) -> WaveformTrace:
727
+ """Evaluate a mathematical expression on traces.
728
+
729
+ Evaluates an expression string using named traces as variables.
730
+ Supports standard mathematical operations and numpy functions.
731
+
732
+ Args:
733
+ expression: Math expression (e.g., "CH1 + CH2", "abs(CH1 - CH2)").
734
+ traces: Dictionary mapping variable names to traces.
735
+ channel_name: Name for the result trace (optional).
736
+
737
+ Returns:
738
+ Result WaveformTrace.
739
+
740
+ Raises:
741
+ AnalysisError: If expression is invalid or traces are incompatible.
742
+
743
+ Example:
744
+ >>> power = math_expression(
745
+ ... "voltage * current",
746
+ ... {"voltage": v_trace, "current": i_trace}
747
+ ... )
748
+
749
+ Security:
750
+ Uses AST-based safe evaluation (not eval()). Only whitelisted
751
+ operations are permitted: arithmetic, comparisons, and whitelisted
752
+ numpy functions. No arbitrary code execution is possible.
753
+ """
754
+ if not traces:
755
+ raise AnalysisError("No traces provided for expression evaluation")
756
+
757
+ # Get a reference trace for metadata
758
+ ref_trace = next(iter(traces.values()))
759
+ sample_rate = ref_trace.metadata.sample_rate
760
+
761
+ # Validate all traces have same length and sample rate
762
+ ref_len = len(ref_trace.data)
763
+ for name, trace in traces.items():
764
+ if len(trace.data) != ref_len:
765
+ raise AnalysisError(
766
+ f"Trace '{name}' has different length",
767
+ details={"expected": ref_len, "got": len(trace.data)}, # type: ignore[arg-type]
768
+ )
769
+ rate_ratio = trace.metadata.sample_rate / sample_rate
770
+ if not (0.999 <= rate_ratio <= 1.001):
771
+ raise AnalysisError(
772
+ f"Trace '{name}' has different sample rate",
773
+ details={"expected": sample_rate, "got": trace.metadata.sample_rate}, # type: ignore[arg-type]
774
+ )
775
+
776
+ # Create namespace with trace data and safe functions
777
+ safe_namespace = {
778
+ "np": np,
779
+ "abs": np.abs,
780
+ "sqrt": np.sqrt,
781
+ "sin": np.sin,
782
+ "cos": np.cos,
783
+ "tan": np.tan,
784
+ "exp": np.exp,
785
+ "log": np.log,
786
+ "log10": np.log10,
787
+ "max": np.maximum,
788
+ "min": np.minimum,
789
+ "mean": np.mean,
790
+ "std": np.std,
791
+ "pi": np.pi,
792
+ }
793
+
794
+ # Add trace data to namespace
795
+ for name, trace in traces.items():
796
+ safe_namespace[name] = trace.data.astype(np.float64)
797
+
798
+ # Use safe AST-based evaluator instead of eval()
799
+ evaluator = _SafeExpressionEvaluator(safe_namespace)
800
+ try:
801
+ result = evaluator.eval(expression)
802
+ except AnalysisError:
803
+ raise # Re-raise AnalysisError from evaluator
804
+ except Exception as e:
805
+ raise AnalysisError(
806
+ f"Failed to evaluate expression: {e}",
807
+ details={"expression": expression}, # type: ignore[arg-type]
808
+ ) from e
809
+
810
+ if not isinstance(result, np.ndarray):
811
+ # Scalar result - broadcast to array
812
+ result = np.full(ref_len, result, dtype=np.float64)
813
+
814
+ new_metadata = TraceMetadata(
815
+ sample_rate=sample_rate,
816
+ vertical_scale=None,
817
+ vertical_offset=None,
818
+ acquisition_time=ref_trace.metadata.acquisition_time,
819
+ trigger_info=ref_trace.metadata.trigger_info,
820
+ source_file=ref_trace.metadata.source_file,
821
+ channel_name=channel_name or f"expr({expression[:20]})",
822
+ )
823
+
824
+ return WaveformTrace(data=result.astype(np.float64), metadata=new_metadata)