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
@@ -16,7 +16,7 @@ References:
16
16
  from __future__ import annotations
17
17
 
18
18
  from pathlib import Path
19
- from typing import TYPE_CHECKING, Any, Literal, cast
19
+ from typing import TYPE_CHECKING, Any, cast
20
20
 
21
21
  import numpy as np
22
22
 
@@ -37,6 +37,157 @@ if TYPE_CHECKING:
37
37
  from oscura.core.types import WaveformTrace
38
38
 
39
39
 
40
+ def _get_fft_data(
41
+ trace: WaveformTrace,
42
+ fft_result: tuple[Any, Any] | None,
43
+ window: str,
44
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
45
+ """Get FFT data, either from cache or by computing.
46
+
47
+ Args:
48
+ trace: Waveform trace.
49
+ fft_result: Pre-computed FFT result.
50
+ window: Window function name.
51
+
52
+ Returns:
53
+ Tuple of (frequencies, magnitudes_db).
54
+ """
55
+ if fft_result is not None:
56
+ return fft_result
57
+
58
+ from oscura.analyzers.waveform.spectral import fft
59
+
60
+ return fft(trace, window=window) # type: ignore[return-value]
61
+
62
+
63
+ def _scale_frequencies(
64
+ freq: NDArray[np.float64], freq_unit: str
65
+ ) -> tuple[NDArray[np.float64], float, str]:
66
+ """Scale frequencies to appropriate unit.
67
+
68
+ Args:
69
+ freq: Frequency array in Hz.
70
+ freq_unit: Requested unit or "auto".
71
+
72
+ Returns:
73
+ Tuple of (scaled_frequencies, divisor, unit_name).
74
+ """
75
+ if freq_unit == "auto":
76
+ max_freq = freq[-1]
77
+ freq_unit = _auto_select_freq_unit(max_freq)
78
+
79
+ freq_divisors = {"Hz": 1.0, "kHz": 1e3, "MHz": 1e6, "GHz": 1e9}
80
+ divisor = freq_divisors.get(freq_unit, 1.0)
81
+ return freq / divisor, divisor, freq_unit
82
+
83
+
84
+ def _set_auto_ylimits(ax: Axes, mag_db: NDArray[np.float64]) -> None:
85
+ """Set reasonable y-axis limits based on data.
86
+
87
+ Args:
88
+ ax: Matplotlib axes.
89
+ mag_db: Magnitude data in dB.
90
+ """
91
+ valid_db = mag_db[np.isfinite(mag_db)]
92
+ if len(valid_db) == 0:
93
+ return
94
+
95
+ y_max = np.max(valid_db)
96
+ y_min = max(np.min(valid_db), y_max - 120) # Limit dynamic range
97
+ ax.set_ylim(y_min, y_max + 5)
98
+
99
+
100
+ def _apply_axis_limits(
101
+ ax: Axes,
102
+ divisor: float,
103
+ freq_range: tuple[float, float] | None,
104
+ xlim: tuple[float, float] | None,
105
+ ylim: tuple[float, float] | None,
106
+ ) -> None:
107
+ """Apply custom axis limits if specified.
108
+
109
+ Args:
110
+ ax: Matplotlib axes.
111
+ divisor: Frequency divisor for unit conversion.
112
+ freq_range: Frequency range in Hz (will be converted to display units).
113
+ xlim: X-axis limits in display units.
114
+ ylim: Y-axis limits.
115
+ """
116
+ if freq_range is not None and len(freq_range) == 2:
117
+ # freq_range is in Hz, convert to display units
118
+ freq_min = freq_range[0] / divisor
119
+ freq_max = freq_range[1] / divisor
120
+
121
+ # For log scale, ensure minimum is positive (avoid 0 on log axis)
122
+ if ax.get_xscale() == "log" and freq_min <= 0:
123
+ freq_min = freq_max / 1000 # Use a small positive value
124
+
125
+ ax.set_xlim(freq_min, freq_max)
126
+ elif xlim is not None:
127
+ ax.set_xlim(xlim)
128
+
129
+ if ylim is not None:
130
+ ax.set_ylim(ylim)
131
+
132
+
133
+ def _prepare_spectrum_data(
134
+ trace: WaveformTrace,
135
+ fft_result: tuple[Any, Any] | None,
136
+ window: str,
137
+ db_ref: float | None,
138
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
139
+ """Prepare spectrum data with FFT and dB scaling.
140
+
141
+ Args:
142
+ trace: Waveform trace to analyze.
143
+ fft_result: Pre-computed FFT result or None.
144
+ window: Window function name.
145
+ db_ref: Reference for dB scaling or None.
146
+
147
+ Returns:
148
+ Tuple of (frequencies, magnitudes_db).
149
+ """
150
+ freq, mag_db = _get_fft_data(trace, fft_result, window)
151
+
152
+ # Adjust dB reference if specified
153
+ if db_ref is not None:
154
+ mag_db = mag_db - db_ref
155
+
156
+ return freq, mag_db
157
+
158
+
159
+ def _render_spectrum_plot(
160
+ ax: Axes,
161
+ freq_scaled: NDArray[np.float64],
162
+ mag_db: NDArray[np.float64],
163
+ freq_unit: str,
164
+ color: str,
165
+ title: str | None,
166
+ log_scale: bool,
167
+ show_grid: bool,
168
+ ) -> None:
169
+ """Render spectrum plot on axes.
170
+
171
+ Args:
172
+ ax: Matplotlib axes to plot on.
173
+ freq_scaled: Scaled frequency array.
174
+ mag_db: Magnitude array in dB.
175
+ freq_unit: Frequency unit string.
176
+ color: Line color.
177
+ title: Plot title.
178
+ log_scale: Use logarithmic frequency scale.
179
+ show_grid: Show grid lines.
180
+ """
181
+ ax.plot(freq_scaled, mag_db, color=color, linewidth=0.8)
182
+ ax.set_xlabel(f"Frequency ({freq_unit})")
183
+ ax.set_ylabel("Magnitude (dB)")
184
+ ax.set_xscale("log" if log_scale else "linear")
185
+ ax.set_title(title if title else "Magnitude Spectrum")
186
+
187
+ if show_grid:
188
+ ax.grid(True, alpha=0.3, which="both")
189
+
190
+
40
191
  def plot_spectrum(
41
192
  trace: WaveformTrace,
42
193
  *,
@@ -48,7 +199,6 @@ def plot_spectrum(
48
199
  color: str = "C0",
49
200
  title: str | None = None,
50
201
  window: str = "hann",
51
- xscale: Literal["linear", "log"] = "log",
52
202
  show: bool = True,
53
203
  save_path: str | None = None,
54
204
  figsize: tuple[float, float] = (10, 6),
@@ -56,7 +206,6 @@ def plot_spectrum(
56
206
  ylim: tuple[float, float] | None = None,
57
207
  fft_result: tuple[Any, Any] | None = None,
58
208
  log_scale: bool = True,
59
- db_scale: bool | None = None,
60
209
  ) -> Figure:
61
210
  """Plot magnitude spectrum.
62
211
 
@@ -70,7 +219,6 @@ def plot_spectrum(
70
219
  color: Line color.
71
220
  title: Plot title.
72
221
  window: Window function for FFT.
73
- xscale: X-axis scale ("linear" or "log"). Deprecated, use log_scale instead.
74
222
  show: If True, call plt.show() to display the plot.
75
223
  save_path: Path to save the figure. If None, figure is not saved.
76
224
  figsize: Figure size (width, height) in inches. Only used if ax is None.
@@ -78,7 +226,6 @@ def plot_spectrum(
78
226
  ylim: Y-axis limits (min, max) in dB.
79
227
  fft_result: Pre-computed FFT result (frequencies, magnitudes). If None, computes FFT.
80
228
  log_scale: Use logarithmic scale for frequency axis (default True).
81
- db_scale: Deprecated alias for log_scale. If provided, overrides log_scale.
82
229
 
83
230
  Returns:
84
231
  Matplotlib Figure object.
@@ -100,12 +247,7 @@ def plot_spectrum(
100
247
  if not HAS_MATPLOTLIB:
101
248
  raise ImportError("matplotlib is required for visualization")
102
249
 
103
- # Handle deprecated db_scale parameter
104
- if db_scale is not None:
105
- log_scale = db_scale
106
-
107
- from oscura.analyzers.waveform.spectral import fft
108
-
250
+ # Figure/axes creation
109
251
  if ax is None:
110
252
  fig, ax = plt.subplots(figsize=figsize)
111
253
  else:
@@ -114,77 +256,217 @@ def plot_spectrum(
114
256
  raise ValueError("Axes must have an associated figure")
115
257
  fig = cast("Figure", fig_temp)
116
258
 
117
- # Compute FFT if not provided
118
- if fft_result is not None:
119
- freq, mag_db = fft_result
120
- else:
121
- freq, mag_db = fft(trace, window=window) # type: ignore[misc]
259
+ # Data preparation
260
+ freq, mag_db = _prepare_spectrum_data(trace, fft_result, window, db_ref)
122
261
 
123
- # Auto-select frequency unit
124
- if freq_unit == "auto":
125
- max_freq = freq[-1]
126
- if max_freq >= 1e9:
127
- freq_unit = "GHz"
128
- elif max_freq >= 1e6:
129
- freq_unit = "MHz"
130
- elif max_freq >= 1e3:
131
- freq_unit = "kHz"
132
- else:
133
- freq_unit = "Hz"
262
+ # Unit/scale selection
263
+ freq_scaled, divisor, freq_unit = _scale_frequencies(freq, freq_unit)
134
264
 
135
- freq_divisors = {"Hz": 1.0, "kHz": 1e3, "MHz": 1e6, "GHz": 1e9}
136
- divisor = freq_divisors.get(freq_unit, 1.0)
137
- freq_scaled = freq / divisor
265
+ # Plotting/rendering
266
+ _render_spectrum_plot(ax, freq_scaled, mag_db, freq_unit, color, title, log_scale, show_grid)
138
267
 
139
- # Adjust dB reference if specified
140
- if db_ref is not None:
141
- mag_db = mag_db - db_ref
268
+ # Set limits
269
+ _set_auto_ylimits(ax, mag_db)
270
+ _apply_axis_limits(ax, divisor, freq_range, xlim, ylim)
142
271
 
143
- # Plot
144
- ax.plot(freq_scaled, mag_db, color=color, linewidth=0.8)
272
+ # Layout/formatting
273
+ fig.tight_layout()
145
274
 
146
- ax.set_xlabel(f"Frequency ({freq_unit})")
147
- ax.set_ylabel("Magnitude (dB)")
275
+ if save_path is not None:
276
+ fig.savefig(save_path, dpi=300, bbox_inches="tight")
148
277
 
149
- # Use log_scale parameter, fall back to xscale for backward compatibility
150
- # Note: xscale is Literal["linear", "log"] so can never be "log" at this point
151
- ax.set_xscale("log" if log_scale else "linear")
278
+ if show:
279
+ plt.show()
152
280
 
153
- if title:
154
- ax.set_title(title)
281
+ return fig
282
+
283
+
284
+ def _auto_select_time_unit(max_time: float) -> str:
285
+ """Select appropriate time unit based on maximum time value.
286
+
287
+ Args:
288
+ max_time: Maximum time value in seconds.
289
+
290
+ Returns:
291
+ Time unit string ("s", "ms", "us", or "ns").
292
+ """
293
+ if max_time < 1e-6:
294
+ return "ns"
295
+ elif max_time < 1e-3:
296
+ return "us"
297
+ elif max_time < 1:
298
+ return "ms"
155
299
  else:
156
- ax.set_title("Magnitude Spectrum")
300
+ return "s"
157
301
 
158
- if show_grid:
159
- ax.grid(True, alpha=0.3, which="both")
160
302
 
161
- # Set reasonable y-limits
162
- valid_db = mag_db[np.isfinite(mag_db)]
163
- if len(valid_db) > 0:
164
- y_max = np.max(valid_db)
165
- y_min = max(np.min(valid_db), y_max - 120) # Limit dynamic range
166
- ax.set_ylim(y_min, y_max + 5)
167
-
168
- # Apply custom limits if specified
169
- if freq_range is not None:
170
- ax.set_xlim(freq_range[0] / divisor, freq_range[1] / divisor)
171
- elif xlim is not None:
172
- ax.set_xlim(xlim)
303
+ def _auto_select_freq_unit(max_freq: float) -> str:
304
+ """Select appropriate frequency unit based on maximum frequency.
173
305
 
174
- if ylim is not None:
175
- ax.set_ylim(ylim)
306
+ Args:
307
+ max_freq: Maximum frequency in Hz.
176
308
 
177
- fig.tight_layout()
309
+ Returns:
310
+ Frequency unit string ("Hz", "kHz", "MHz", or "GHz").
311
+ """
312
+ if max_freq >= 1e9:
313
+ return "GHz"
314
+ elif max_freq >= 1e6:
315
+ return "MHz"
316
+ elif max_freq >= 1e3:
317
+ return "kHz"
318
+ else:
319
+ return "Hz"
178
320
 
179
- # Save if path provided
180
- if save_path is not None:
181
- fig.savefig(save_path, dpi=300, bbox_inches="tight")
182
321
 
183
- # Show if requested
184
- if show:
185
- plt.show()
322
+ def _get_unit_multipliers() -> tuple[dict[str, float], dict[str, float]]:
323
+ """Get time and frequency unit multipliers.
324
+
325
+ Returns:
326
+ Tuple of (time_multipliers, freq_divisors).
327
+ """
328
+ time_mult = {"s": 1.0, "ms": 1e3, "us": 1e6, "ns": 1e9}
329
+ freq_div = {"Hz": 1.0, "kHz": 1e3, "MHz": 1e6, "GHz": 1e9}
330
+ return time_mult, freq_div
186
331
 
187
- return fig
332
+
333
+ def _auto_color_limits(
334
+ data: NDArray[np.float64], vmin: float | None, vmax: float | None
335
+ ) -> tuple[float | None, float | None]:
336
+ """Automatically determine color limits for spectrogram.
337
+
338
+ Args:
339
+ data: Spectrogram data in dB.
340
+ vmin: Minimum dB value (if None, auto-computed).
341
+ vmax: Maximum dB value (if None, auto-computed).
342
+
343
+ Returns:
344
+ Tuple of (vmin, vmax).
345
+ """
346
+ if vmin is not None and vmax is not None:
347
+ return vmin, vmax
348
+
349
+ valid_db = data[np.isfinite(data)]
350
+ if len(valid_db) == 0:
351
+ return vmin, vmax
352
+
353
+ if vmax is None:
354
+ vmax = np.max(valid_db)
355
+ if vmin is None:
356
+ vmin = max(np.min(valid_db), vmax - 80)
357
+
358
+ return vmin, vmax
359
+
360
+
361
+ def _compute_spectrogram_data(
362
+ trace: WaveformTrace,
363
+ window: str,
364
+ nperseg: int | None,
365
+ nfft: int | None,
366
+ overlap: float | None,
367
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
368
+ """Compute spectrogram data using STFT.
369
+
370
+ Args:
371
+ trace: Waveform trace to analyze.
372
+ window: Window function name.
373
+ nperseg: Segment length for STFT.
374
+ nfft: FFT length (overrides nperseg if specified).
375
+ overlap: Overlap fraction (0.0 to 1.0).
376
+
377
+ Returns:
378
+ Tuple of (times, frequencies, Sxx_db).
379
+ """
380
+ from oscura.analyzers.waveform.spectral import spectrogram
381
+
382
+ if nfft is not None:
383
+ nperseg = nfft
384
+ noverlap = int(nperseg * overlap) if overlap is not None and nperseg is not None else None
385
+
386
+ return spectrogram(trace, window=window, nperseg=nperseg, noverlap=noverlap)
387
+
388
+
389
+ def _scale_spectrogram_axes(
390
+ times: NDArray[np.float64],
391
+ freq: NDArray[np.float64],
392
+ time_unit: str,
393
+ freq_unit: str,
394
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], str, str]:
395
+ """Scale time and frequency axes to appropriate units.
396
+
397
+ Args:
398
+ times: Time array in seconds.
399
+ freq: Frequency array in Hz.
400
+ time_unit: Time unit ("auto" or specific).
401
+ freq_unit: Frequency unit ("auto" or specific).
402
+
403
+ Returns:
404
+ Tuple of (times_scaled, freq_scaled, time_unit, freq_unit).
405
+ """
406
+ if time_unit == "auto":
407
+ max_time = times[-1] if len(times) > 0 else 0
408
+ time_unit = _auto_select_time_unit(max_time)
409
+
410
+ if freq_unit == "auto":
411
+ max_freq = freq[-1] if len(freq) > 0 else 0
412
+ freq_unit = _auto_select_freq_unit(max_freq)
413
+
414
+ time_multipliers, freq_divisors = _get_unit_multipliers()
415
+ times_scaled = times * time_multipliers.get(time_unit, 1.0)
416
+ freq_scaled = freq / freq_divisors.get(freq_unit, 1.0)
417
+
418
+ return times_scaled, freq_scaled, time_unit, freq_unit
419
+
420
+
421
+ def _render_spectrogram_plot(
422
+ ax: Axes,
423
+ times_scaled: NDArray[np.float64],
424
+ freq_scaled: NDArray[np.float64],
425
+ Sxx_db: NDArray[np.float64],
426
+ time_unit: str,
427
+ freq_unit: str,
428
+ cmap: str,
429
+ vmin: float | None,
430
+ vmax: float | None,
431
+ title: str | None,
432
+ ) -> None:
433
+ """Render spectrogram plot on axes.
434
+
435
+ Args:
436
+ ax: Matplotlib axes to plot on.
437
+ times_scaled: Scaled time array.
438
+ freq_scaled: Scaled frequency array.
439
+ Sxx_db: Spectrogram data in dB.
440
+ time_unit: Time unit string.
441
+ freq_unit: Frequency unit string.
442
+ cmap: Colormap name.
443
+ vmin: Minimum dB value for color scaling.
444
+ vmax: Maximum dB value for color scaling.
445
+ title: Plot title.
446
+ """
447
+ # Auto color limits
448
+ vmin, vmax = _auto_color_limits(Sxx_db, vmin, vmax)
449
+
450
+ # Plot
451
+ pcm = ax.pcolormesh(
452
+ times_scaled,
453
+ freq_scaled,
454
+ Sxx_db,
455
+ shading="auto",
456
+ cmap=cmap,
457
+ vmin=vmin,
458
+ vmax=vmax,
459
+ )
460
+
461
+ ax.set_xlabel(f"Time ({time_unit})")
462
+ ax.set_ylabel(f"Frequency ({freq_unit})")
463
+ ax.set_title(title if title else "Spectrogram")
464
+
465
+ # Colorbar
466
+ fig = ax.get_figure()
467
+ if fig is not None:
468
+ cbar = fig.colorbar(pcm, ax=ax)
469
+ cbar.set_label("Magnitude (dB)")
188
470
 
189
471
 
190
472
  def plot_spectrogram(
@@ -232,8 +514,7 @@ def plot_spectrogram(
232
514
  if not HAS_MATPLOTLIB:
233
515
  raise ImportError("matplotlib is required for visualization")
234
516
 
235
- from oscura.analyzers.waveform.spectral import spectrogram
236
-
517
+ # Figure/axes creation
237
518
  if ax is None:
238
519
  fig, ax = plt.subplots(figsize=(10, 4))
239
520
  else:
@@ -242,80 +523,20 @@ def plot_spectrogram(
242
523
  raise ValueError("Axes must have an associated figure")
243
524
  fig = cast("Figure", fig_temp)
244
525
 
245
- # Handle nfft as alias for nperseg
246
- if nfft is not None:
247
- nperseg = nfft
248
-
249
- # Compute spectrogram with optional overlap
250
- noverlap = None
251
- if overlap is not None and nperseg is not None:
252
- noverlap = int(nperseg * overlap)
253
- times, freq, Sxx_db = spectrogram(trace, window=window, nperseg=nperseg, noverlap=noverlap)
254
-
255
- # Auto-select units
256
- if time_unit == "auto":
257
- max_time = times[-1] if len(times) > 0 else 0
258
- if max_time < 1e-6:
259
- time_unit = "ns"
260
- elif max_time < 1e-3:
261
- time_unit = "us"
262
- elif max_time < 1:
263
- time_unit = "ms"
264
- else:
265
- time_unit = "s"
266
-
267
- if freq_unit == "auto":
268
- max_freq = freq[-1] if len(freq) > 0 else 0
269
- if max_freq >= 1e9:
270
- freq_unit = "GHz"
271
- elif max_freq >= 1e6:
272
- freq_unit = "MHz"
273
- elif max_freq >= 1e3:
274
- freq_unit = "kHz"
275
- else:
276
- freq_unit = "Hz"
277
-
278
- time_multipliers = {"s": 1.0, "ms": 1e3, "us": 1e6, "ns": 1e9}
279
- freq_divisors = {"Hz": 1.0, "kHz": 1e3, "MHz": 1e6, "GHz": 1e9}
280
-
281
- time_mult = time_multipliers.get(time_unit, 1.0)
282
- freq_div = freq_divisors.get(freq_unit, 1.0)
526
+ # Data preparation/computation
527
+ times, freq, Sxx_db = _compute_spectrogram_data(trace, window, nperseg, nfft, overlap)
283
528
 
284
- times_scaled = times * time_mult
285
- freq_scaled = freq / freq_div
286
-
287
- # Auto color limits
288
- if vmin is None or vmax is None:
289
- valid_db = Sxx_db[np.isfinite(Sxx_db)]
290
- if len(valid_db) > 0:
291
- if vmax is None:
292
- vmax = np.max(valid_db)
293
- if vmin is None:
294
- vmin = max(np.min(valid_db), vmax - 80)
295
-
296
- # Plot
297
- pcm = ax.pcolormesh(
298
- times_scaled,
299
- freq_scaled,
300
- Sxx_db,
301
- shading="auto",
302
- cmap=cmap,
303
- vmin=vmin,
304
- vmax=vmax,
529
+ # Unit/scale selection
530
+ times_scaled, freq_scaled, time_unit, freq_unit = _scale_spectrogram_axes(
531
+ times, freq, time_unit, freq_unit
305
532
  )
306
533
 
307
- ax.set_xlabel(f"Time ({time_unit})")
308
- ax.set_ylabel(f"Frequency ({freq_unit})")
309
-
310
- if title:
311
- ax.set_title(title)
312
- else:
313
- ax.set_title("Spectrogram")
314
-
315
- # Colorbar
316
- cbar = fig.colorbar(pcm, ax=ax)
317
- cbar.set_label("Magnitude (dB)")
534
+ # Plotting/rendering
535
+ _render_spectrogram_plot(
536
+ ax, times_scaled, freq_scaled, Sxx_db, time_unit, freq_unit, cmap, vmin, vmax, title
537
+ )
318
538
 
539
+ # Layout/formatting
319
540
  fig.tight_layout()
320
541
  return fig
321
542
 
@@ -329,7 +550,7 @@ def plot_psd(
329
550
  color: str = "C0",
330
551
  title: str | None = None,
331
552
  window: str = "hann",
332
- xscale: Literal["linear", "log"] = "log",
553
+ log_scale: bool = True,
333
554
  ) -> Figure:
334
555
  """Plot Power Spectral Density.
335
556
 
@@ -341,7 +562,7 @@ def plot_psd(
341
562
  color: Line color.
342
563
  title: Plot title.
343
564
  window: Window function.
344
- xscale: X-axis scale.
565
+ log_scale: Use logarithmic scale for frequency axis (default True).
345
566
 
346
567
  Returns:
347
568
  Matplotlib Figure object.
@@ -391,7 +612,7 @@ def plot_psd(
391
612
 
392
613
  ax.set_xlabel(f"Frequency ({freq_unit})")
393
614
  ax.set_ylabel("PSD (dB/Hz)")
394
- ax.set_xscale(xscale)
615
+ ax.set_xscale("log" if log_scale else "linear")
395
616
 
396
617
  if title:
397
618
  ax.set_title(title)
@@ -472,17 +693,10 @@ def plot_fft(
472
693
  if not HAS_MATPLOTLIB:
473
694
  raise ImportError("matplotlib is required for visualization")
474
695
 
475
- # Create figure if needed
476
- if ax is None:
477
- fig, ax = plt.subplots(figsize=figsize)
478
- else:
479
- fig_temp = ax.get_figure()
480
- if fig_temp is None:
481
- raise ValueError("Axes must have an associated figure")
482
- fig = cast("Figure", fig_temp)
696
+ # Setup figure and axes
697
+ fig, ax = _setup_plot_figure(ax, figsize)
483
698
 
484
- # Use plot_spectrum to do the actual plotting
485
- xscale_value: Literal["linear", "log"] = "log" if log_scale else "linear"
699
+ # Plot spectrum using main plotting function
486
700
  plot_spectrum(
487
701
  trace,
488
702
  ax=ax,
@@ -491,12 +705,47 @@ def plot_fft(
491
705
  color=color,
492
706
  title=title if title else "FFT Magnitude Spectrum",
493
707
  window=window,
494
- xscale=xscale_value,
708
+ log_scale=log_scale,
495
709
  )
496
710
 
497
- # Apply custom labels if different from defaults
711
+ # Apply custom labels and limits
712
+ _apply_custom_labels(ax, xlabel, ylabel)
713
+ _apply_axis_limits_simple(ax, xlim, ylim)
714
+
715
+ # Output handling
716
+ _handle_plot_output(fig, save_path, show)
717
+
718
+ return fig
719
+
720
+
721
+ def _setup_plot_figure(ax: Axes | None, figsize: tuple[float, float]) -> tuple[Figure, Axes]:
722
+ """Setup figure and axes for plotting.
723
+
724
+ Args:
725
+ ax: Existing axes or None.
726
+ figsize: Figure size if creating new.
727
+
728
+ Returns:
729
+ Tuple of (Figure, Axes).
730
+ """
731
+ if ax is None:
732
+ return plt.subplots(figsize=figsize)
733
+
734
+ fig_temp = ax.get_figure()
735
+ if fig_temp is None:
736
+ raise ValueError("Axes must have an associated figure")
737
+ return cast("Figure", fig_temp), ax
738
+
739
+
740
+ def _apply_custom_labels(ax: Axes, xlabel: str, ylabel: str) -> None:
741
+ """Apply custom labels to plot axes.
742
+
743
+ Args:
744
+ ax: Matplotlib axes.
745
+ xlabel: X-axis label.
746
+ ylabel: Y-axis label.
747
+ """
498
748
  if xlabel != "Frequency":
499
- # Get current label to preserve unit
500
749
  current_label = ax.get_xlabel()
501
750
  if "(" in current_label and ")" in current_label:
502
751
  unit = current_label[current_label.find("(") : current_label.find(")") + 1]
@@ -507,22 +756,123 @@ def plot_fft(
507
756
  if ylabel != "Magnitude (dB)":
508
757
  ax.set_ylabel(ylabel)
509
758
 
510
- # Apply custom limits if specified
759
+
760
+ def _apply_axis_limits_simple(
761
+ ax: Axes, xlim: tuple[float, float] | None, ylim: tuple[float, float] | None
762
+ ) -> None:
763
+ """Apply axis limits if specified.
764
+
765
+ Args:
766
+ ax: Matplotlib axes.
767
+ xlim: X-axis limits.
768
+ ylim: Y-axis limits.
769
+ """
511
770
  if xlim is not None:
512
771
  ax.set_xlim(xlim)
513
-
514
772
  if ylim is not None:
515
773
  ax.set_ylim(ylim)
516
774
 
517
- # Save if path provided
775
+
776
+ def _handle_plot_output(fig: Figure, save_path: str | None, show: bool) -> None:
777
+ """Handle plot output (save and/or show).
778
+
779
+ Args:
780
+ fig: Matplotlib figure.
781
+ save_path: Path to save figure.
782
+ show: Whether to display plot.
783
+ """
518
784
  if save_path is not None:
519
785
  fig.savefig(save_path, dpi=300, bbox_inches="tight")
520
-
521
- # Show if requested
522
786
  if show:
523
787
  plt.show()
524
788
 
525
- return fig
789
+
790
+ def _create_harmonic_labels(
791
+ n_harmonics: int,
792
+ fundamental_freq: float | None,
793
+ ) -> list[str]:
794
+ """Create x-axis labels for harmonics.
795
+
796
+ Args:
797
+ n_harmonics: Number of harmonics.
798
+ fundamental_freq: Fundamental frequency in Hz or None.
799
+
800
+ Returns:
801
+ List of label strings.
802
+ """
803
+ if fundamental_freq is not None:
804
+ labels = [
805
+ f"H{i + 1}\n({(i + 1) * fundamental_freq / 1e3:.1f} kHz)"
806
+ if fundamental_freq >= 1000
807
+ else f"H{i + 1}\n({(i + 1) * fundamental_freq:.0f} Hz)"
808
+ for i in range(n_harmonics)
809
+ ]
810
+ labels[0] = (
811
+ f"Fund\n({fundamental_freq / 1e3:.1f} kHz)"
812
+ if fundamental_freq >= 1000
813
+ else f"Fund\n({fundamental_freq:.0f} Hz)"
814
+ )
815
+ else:
816
+ labels = [f"H{i + 1}" for i in range(n_harmonics)]
817
+ labels[0] = "Fund"
818
+
819
+ return labels
820
+
821
+
822
+ def _assign_harmonic_colors(
823
+ harmonic_magnitudes: NDArray[np.floating[Any]],
824
+ ) -> list[str]:
825
+ """Assign colors to harmonics based on magnitude.
826
+
827
+ Args:
828
+ harmonic_magnitudes: Array of harmonic magnitudes in dB.
829
+
830
+ Returns:
831
+ List of color strings.
832
+ """
833
+ colors = []
834
+ for i, mag in enumerate(harmonic_magnitudes):
835
+ if i == 0:
836
+ colors.append("#3498DB") # Blue for fundamental
837
+ elif mag > -30:
838
+ colors.append("#E74C3C") # Red for significant harmonics
839
+ elif mag > -50:
840
+ colors.append("#F39C12") # Orange for moderate
841
+ else:
842
+ colors.append("#95A5A6") # Gray for low
843
+
844
+ return colors
845
+
846
+
847
+ def _add_thd_annotation(
848
+ ax: Axes,
849
+ thd_value: float | None,
850
+ show_thd: bool,
851
+ ) -> None:
852
+ """Add THD annotation to plot.
853
+
854
+ Args:
855
+ ax: Matplotlib axes to annotate.
856
+ thd_value: THD value in dB or %.
857
+ show_thd: Show annotation flag.
858
+ """
859
+ if show_thd and thd_value is not None:
860
+ if thd_value > 0:
861
+ thd_text = f"THD: {thd_value:.2f}%"
862
+ else:
863
+ thd_text = f"THD: {thd_value:.1f} dB"
864
+
865
+ ax.text(
866
+ 0.98,
867
+ 0.98,
868
+ thd_text,
869
+ transform=ax.transAxes,
870
+ fontsize=12,
871
+ fontweight="bold",
872
+ ha="right",
873
+ va="top",
874
+ bbox={"boxstyle": "round,pad=0.5", "facecolor": "wheat", "alpha": 0.9},
875
+ )
526
876
 
527
877
 
528
878
  def plot_thd_bars(
@@ -571,6 +921,7 @@ def plot_thd_bars(
571
921
  if not HAS_MATPLOTLIB:
572
922
  raise ImportError("matplotlib is required for visualization")
573
923
 
924
+ # Figure/axes creation
574
925
  if ax is None:
575
926
  fig, ax = plt.subplots(figsize=figsize)
576
927
  else:
@@ -579,76 +930,27 @@ def plot_thd_bars(
579
930
  raise ValueError("Axes must have an associated figure")
580
931
  fig = cast("Figure", fig_temp)
581
932
 
933
+ # Data preparation
582
934
  n_harmonics = len(harmonic_magnitudes)
583
-
584
- # Create x-positions for harmonics
585
935
  x_pos = np.arange(n_harmonics)
936
+ labels = _create_harmonic_labels(n_harmonics, fundamental_freq)
937
+ colors = _assign_harmonic_colors(harmonic_magnitudes)
586
938
 
587
- # Create labels
588
- if fundamental_freq is not None:
589
- labels = [
590
- f"H{i + 1}\n({(i + 1) * fundamental_freq / 1e3:.1f} kHz)"
591
- if fundamental_freq >= 1000
592
- else f"H{i + 1}\n({(i + 1) * fundamental_freq:.0f} Hz)"
593
- for i in range(n_harmonics)
594
- ]
595
- labels[0] = (
596
- f"Fund\n({fundamental_freq / 1e3:.1f} kHz)"
597
- if fundamental_freq >= 1000
598
- else f"Fund\n({fundamental_freq:.0f} Hz)"
599
- )
600
- else:
601
- labels = [f"H{i + 1}" for i in range(n_harmonics)]
602
- labels[0] = "Fund"
603
-
604
- # Color code: fundamental in blue, harmonics in orange/red based on magnitude
605
- colors = []
606
- for i, mag in enumerate(harmonic_magnitudes):
607
- if i == 0:
608
- colors.append("#3498DB") # Blue for fundamental
609
- elif mag > -30:
610
- colors.append("#E74C3C") # Red for significant harmonics
611
- elif mag > -50:
612
- colors.append("#F39C12") # Orange for moderate
613
- else:
614
- colors.append("#95A5A6") # Gray for low
615
-
616
- # Plot bars
939
+ # Plotting/rendering
617
940
  ax.bar(
618
941
  x_pos, harmonic_magnitudes - reference_db, color=colors, edgecolor="black", linewidth=0.5
619
942
  )
620
-
621
- # Reference line at fundamental level
622
943
  ax.axhline(0, color="gray", linestyle="--", linewidth=1, alpha=0.7)
623
944
 
624
- # THD annotation
625
- if show_thd and thd_value is not None:
626
- # Position in upper right
627
- if thd_value > 0:
628
- thd_text = f"THD: {thd_value:.2f}%"
629
- else:
630
- thd_text = f"THD: {thd_value:.1f} dB"
631
-
632
- ax.text(
633
- 0.98,
634
- 0.98,
635
- thd_text,
636
- transform=ax.transAxes,
637
- fontsize=12,
638
- fontweight="bold",
639
- ha="right",
640
- va="top",
641
- bbox={"boxstyle": "round,pad=0.5", "facecolor": "wheat", "alpha": 0.9},
642
- )
643
-
644
- # Labels
945
+ # Annotation/labeling
946
+ _add_thd_annotation(ax, thd_value, show_thd)
645
947
  ax.set_xticks(x_pos)
646
948
  ax.set_xticklabels(labels, fontsize=9)
647
949
  ax.set_xlabel("Harmonic", fontsize=11)
648
950
  ax.set_ylabel("Magnitude (dB rel. to fundamental)", fontsize=11)
649
951
  ax.grid(True, axis="y", alpha=0.3)
650
952
 
651
- # Y-axis limits
953
+ # Layout/formatting
652
954
  min_mag = min(harmonic_magnitudes) - reference_db
653
955
  ax.set_ylim(min(min_mag - 10, -80), 10)
654
956
 
@@ -706,42 +1008,122 @@ def plot_quality_summary(
706
1008
  if not HAS_MATPLOTLIB:
707
1009
  raise ImportError("matplotlib is required for visualization")
708
1010
 
1011
+ # Setup figure and axes
1012
+ fig, ax = _setup_quality_plot_axes(ax, figsize)
1013
+
1014
+ # Define metric metadata
1015
+ metric_info = _get_metric_info()
1016
+
1017
+ # Filter to available metrics
1018
+ available_metrics = [(k, v) for k, v in metrics.items() if k in metric_info]
1019
+
1020
+ if len(available_metrics) == 0:
1021
+ ax.text(0.5, 0.5, "No metrics available", ha="center", va="center", fontsize=14)
1022
+ ax.axis("off")
1023
+ return fig
1024
+
1025
+ # Plot metrics with pass/fail coloring
1026
+ _plot_quality_bars(ax, available_metrics, metric_info, show_specs)
1027
+
1028
+ # Add value labels and spec markers
1029
+ _add_quality_labels(ax, available_metrics, metric_info)
1030
+ _add_spec_markers(ax, available_metrics, show_specs)
1031
+
1032
+ # Configure axes
1033
+ _configure_quality_axes(ax, available_metrics, metric_info, title)
1034
+
1035
+ fig.tight_layout()
1036
+
1037
+ if save_path is not None:
1038
+ fig.savefig(save_path, dpi=300, bbox_inches="tight")
1039
+
1040
+ if show:
1041
+ plt.show()
1042
+
1043
+ return fig
1044
+
1045
+
1046
+ def _setup_quality_plot_axes(
1047
+ ax: Axes | None,
1048
+ figsize: tuple[float, float],
1049
+ ) -> tuple[Figure, Axes]:
1050
+ """Setup figure and axes for quality summary plot.
1051
+
1052
+ Args:
1053
+ ax: Existing axes or None.
1054
+ figsize: Figure size if creating new figure.
1055
+
1056
+ Returns:
1057
+ Tuple of (Figure, Axes).
1058
+
1059
+ Raises:
1060
+ ValueError: If provided axes has no associated figure.
1061
+ """
709
1062
  if ax is None:
710
- fig, ax = plt.subplots(figsize=figsize)
1063
+ fig, ax_obj = plt.subplots(figsize=figsize)
1064
+ return fig, ax_obj
711
1065
  else:
712
1066
  fig_temp = ax.get_figure()
713
1067
  if fig_temp is None:
714
1068
  raise ValueError("Axes must have an associated figure")
715
- fig = cast("Figure", fig_temp)
1069
+ return cast("Figure", fig_temp), ax
1070
+
1071
+
1072
+ def _get_metric_info() -> dict[str, dict[str, Any]]:
1073
+ """Get metric display information.
716
1074
 
717
- # Define metric display info
718
- metric_info = {
1075
+ Returns:
1076
+ Dictionary mapping metric keys to display info (name, unit, higher_better).
1077
+ """
1078
+ return {
719
1079
  "snr": {"name": "SNR", "unit": "dB", "higher_better": True},
720
1080
  "sinad": {"name": "SINAD", "unit": "dB", "higher_better": True},
721
- "thd": {
722
- "name": "THD",
723
- "unit": "dB",
724
- "higher_better": False,
725
- }, # Lower (more negative) is better
1081
+ "thd": {"name": "THD", "unit": "dB", "higher_better": False},
726
1082
  "enob": {"name": "ENOB", "unit": "bits", "higher_better": True},
727
1083
  "sfdr": {"name": "SFDR", "unit": "dBc", "higher_better": True},
728
1084
  }
729
1085
 
730
- # Filter to available metrics
731
- available_metrics = [(k, v) for k, v in metrics.items() if k in metric_info]
732
- n_metrics = len(available_metrics)
733
1086
 
734
- if n_metrics == 0:
735
- ax.text(0.5, 0.5, "No metrics available", ha="center", va="center", fontsize=14)
736
- ax.axis("off")
737
- return fig
1087
+ def _plot_quality_bars(
1088
+ ax: Axes,
1089
+ available_metrics: list[tuple[str, float]],
1090
+ metric_info: dict[str, dict[str, Any]],
1091
+ show_specs: dict[str, float] | None,
1092
+ ) -> None:
1093
+ """Plot horizontal bars for quality metrics.
738
1094
 
739
- # Create horizontal bar chart
1095
+ Args:
1096
+ ax: Matplotlib axes.
1097
+ available_metrics: List of (key, value) tuples for available metrics.
1098
+ metric_info: Metric display information.
1099
+ show_specs: Specification values for pass/fail coloring.
1100
+ """
1101
+ n_metrics = len(available_metrics)
740
1102
  y_pos = np.arange(n_metrics)
741
1103
  values = [v for _, v in available_metrics]
742
- names = [metric_info[k]["name"] for k, _ in available_metrics]
743
1104
 
744
1105
  # Determine colors based on pass/fail
1106
+ colors = _determine_bar_colors(available_metrics, metric_info, show_specs)
1107
+
1108
+ # Plot horizontal bars
1109
+ ax.barh(y_pos, values, color=colors, edgecolor="black", linewidth=0.5)
1110
+
1111
+
1112
+ def _determine_bar_colors(
1113
+ available_metrics: list[tuple[str, float]],
1114
+ metric_info: dict[str, dict[str, Any]],
1115
+ show_specs: dict[str, float] | None,
1116
+ ) -> list[str]:
1117
+ """Determine bar colors based on pass/fail status.
1118
+
1119
+ Args:
1120
+ available_metrics: List of (key, value) tuples.
1121
+ metric_info: Metric display information.
1122
+ show_specs: Specification values.
1123
+
1124
+ Returns:
1125
+ List of color strings (hex codes).
1126
+ """
745
1127
  colors = []
746
1128
  for key, value in available_metrics:
747
1129
  if show_specs and key in show_specs:
@@ -755,11 +1137,21 @@ def plot_quality_summary(
755
1137
  colors.append("#27AE60" if passed else "#E74C3C")
756
1138
  else:
757
1139
  colors.append("#3498DB")
1140
+ return colors
758
1141
 
759
- # Plot horizontal bars
760
- ax.barh(y_pos, values, color=colors, edgecolor="black", linewidth=0.5)
761
1142
 
762
- # Add value labels
1143
+ def _add_quality_labels(
1144
+ ax: Axes,
1145
+ available_metrics: list[tuple[str, float]],
1146
+ metric_info: dict[str, dict[str, Any]],
1147
+ ) -> None:
1148
+ """Add value labels to quality metric bars.
1149
+
1150
+ Args:
1151
+ ax: Matplotlib axes.
1152
+ available_metrics: List of (key, value) tuples.
1153
+ metric_info: Metric display information.
1154
+ """
763
1155
  for i, (key, value) in enumerate(available_metrics):
764
1156
  unit = metric_info[key]["unit"]
765
1157
  label_text = f"{value:.1f} {unit}"
@@ -773,13 +1165,46 @@ def plot_quality_summary(
773
1165
  fontweight="bold",
774
1166
  )
775
1167
 
776
- # Add spec markers
777
- if show_specs:
778
- for i, (key, _) in enumerate(available_metrics):
779
- if key in show_specs:
780
- spec = show_specs[key]
781
- ax.plot(spec, i, "k|", markersize=20, markeredgewidth=2)
782
- ax.text(spec, i + 0.3, f"Spec: {spec}", fontsize=8, ha="center")
1168
+
1169
+ def _add_spec_markers(
1170
+ ax: Axes,
1171
+ available_metrics: list[tuple[str, float]],
1172
+ show_specs: dict[str, float] | None,
1173
+ ) -> None:
1174
+ """Add specification markers to quality plot.
1175
+
1176
+ Args:
1177
+ ax: Matplotlib axes.
1178
+ available_metrics: List of (key, value) tuples.
1179
+ show_specs: Specification values.
1180
+ """
1181
+ if not show_specs:
1182
+ return
1183
+
1184
+ for i, (key, _) in enumerate(available_metrics):
1185
+ if key in show_specs:
1186
+ spec = show_specs[key]
1187
+ ax.plot(spec, i, "k|", markersize=20, markeredgewidth=2)
1188
+ ax.text(spec, i + 0.3, f"Spec: {spec}", fontsize=8, ha="center")
1189
+
1190
+
1191
+ def _configure_quality_axes(
1192
+ ax: Axes,
1193
+ available_metrics: list[tuple[str, float]],
1194
+ metric_info: dict[str, dict[str, Any]],
1195
+ title: str | None,
1196
+ ) -> None:
1197
+ """Configure axes for quality summary plot.
1198
+
1199
+ Args:
1200
+ ax: Matplotlib axes.
1201
+ available_metrics: List of (key, value) tuples.
1202
+ metric_info: Metric display information.
1203
+ title: Plot title.
1204
+ """
1205
+ n_metrics = len(available_metrics)
1206
+ y_pos = np.arange(n_metrics)
1207
+ names = [metric_info[k]["name"] for k, _ in available_metrics]
783
1208
 
784
1209
  ax.set_yticks(y_pos)
785
1210
  ax.set_yticklabels([str(name) for name in names], fontsize=11)
@@ -792,16 +1217,6 @@ def plot_quality_summary(
792
1217
  else:
793
1218
  ax.set_title("Signal Quality Summary (IEEE 1241-2010)", fontsize=12, fontweight="bold")
794
1219
 
795
- fig.tight_layout()
796
-
797
- if save_path is not None:
798
- fig.savefig(save_path, dpi=300, bbox_inches="tight")
799
-
800
- if show:
801
- plt.show()
802
-
803
- return fig
804
-
805
1220
 
806
1221
  __all__ = [
807
1222
  "plot_fft",